Compare commits
No commits in common. "838f954cad638f7a3f6cf065df3c49d472edab0e" and "90375a5733712d96fe7b289d78de5e5ab97365e2" have entirely different histories.
838f954cad
...
90375a5733
|
|
@ -1,101 +0,0 @@
|
|||
#!/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)
|
||||
|
|
@ -24,7 +24,7 @@ VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255)
|
|||
# the desktop behind the panel both read through, so the whole thing looks
|
||||
# like projected light/glass instead of an opaque card.
|
||||
FILL_COLOR = VIOLET
|
||||
FILL_ALPHA = 0.28
|
||||
FILL_ALPHA = 0.34
|
||||
|
||||
|
||||
def _rounded_rect(cr, x, y, w, h, r) -> None:
|
||||
|
|
@ -39,9 +39,8 @@ def _rounded_rect(cr, x, y, w, h, r) -> None:
|
|||
|
||||
# Soft outer glow around the border ring: Cairo has no native blur, so this is
|
||||
# the usual poor-man's bloom — the same crisp stroke redrawn a few times at
|
||||
# increasing width and decreasing alpha, underneath the final crisp line. Wider
|
||||
# and stronger than before so the thin border reads as glowing, not just drawn.
|
||||
_GLOW_LAYERS = [(22, 0.05), (15, 0.08), (9, 0.13), (4, 0.22)]
|
||||
# increasing width and decreasing alpha, underneath the final crisp line.
|
||||
_GLOW_LAYERS = [(10, 0.05), (6, 0.09), (3, 0.15)]
|
||||
|
||||
|
||||
def _make_draw(border: int, radius: int, color, fill_bg: bool, glow: bool):
|
||||
|
|
@ -64,7 +63,7 @@ def _make_draw(border: int, radius: int, color, fill_bg: bool, glow: bool):
|
|||
return draw
|
||||
|
||||
|
||||
def bordered(child: Gtk.Widget, border: int = 2, radius: int = 16,
|
||||
def bordered(child: Gtk.Widget, border: int = 3, radius: int = 16,
|
||||
color=ACCENT, fill_bg: bool = False, glow: bool = True) -> Gtk.Overlay:
|
||||
"""Wrap child in an overlay whose background is a drawn rounded border —
|
||||
with a soft holographic glow around it by default, matching orbit-menu/
|
||||
|
|
|
|||
|
|
@ -35,32 +35,16 @@ class HologramOverlay:
|
|||
SWEEP_PERIOD = 3.4 # seconds for one top-to-bottom pass
|
||||
SWEEP_HALF_HEIGHT = 90.0
|
||||
NOISE_COUNT = 95 # specs alive at once (each with its own lifetime)
|
||||
NOISE_COLORS = [_MAGENTA, _MAGENTA, _ACCENT] # magenta-biased specks
|
||||
NOISE_COLORS = [_MAGENTA, _ACCENT]
|
||||
NOISE_LIFETIME = (0.5, 1.4)
|
||||
NOISE_FADE_IN = 0.2
|
||||
NOISE_FADE_OUT = 0.35
|
||||
NOISE_ALPHA_RANGE = (0.10, 0.34)
|
||||
EDGE_FADE_X = 64.0 # smooth horizontal fade-out of the scanline field
|
||||
EDGE_FADE_Y = 40.0 # smooth vertical fade-out
|
||||
INTRO_DURATION = 1.5 # long, noisy 'materialise out of static' fade-in
|
||||
INTRO_STATIC = 1200 # static specks at the very start of the intro
|
||||
OUTRO_DURATION = 0.45 # quick reverse dissolve back into static on close
|
||||
|
||||
def __init__(self, enabled: bool = True, clip_func=None, fade_widget=None,
|
||||
intro_duration: float | None = None) -> None:
|
||||
def __init__(self, enabled: bool = True) -> None:
|
||||
self.enabled = enabled
|
||||
self._clip_func = clip_func # optional path-setter to clip the holo to the UI shape
|
||||
# widget whose opacity is ramped 0->1 during the intro so the UI genuinely
|
||||
# fades in (a gradual reveal), rather than a solid haze block popping on
|
||||
self._fade_widget = fade_widget
|
||||
if intro_duration is not None:
|
||||
self.INTRO_DURATION = intro_duration # per-instance override of the class default
|
||||
self._sat_time = 0.0
|
||||
self._particles: list[dict] = []
|
||||
self._intro_t: float | None = None # >=0 while the materialise intro plays
|
||||
self._outro_t: float | None = None # >=0 while the closing dissolve plays
|
||||
self._outro_done = None # callback fired when the dissolve finishes
|
||||
self._mask_cache: tuple | None = None # (w, h, pattern) edge-fade mask
|
||||
|
||||
self.widget = Gtk.DrawingArea()
|
||||
self.widget.set_can_target(False) # never steals clicks from content underneath
|
||||
|
|
@ -75,138 +59,12 @@ class HologramOverlay:
|
|||
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
|
||||
if self._fade_widget is not None:
|
||||
self._fade_widget.set_opacity(1.0)
|
||||
elif self._fade_widget is not None:
|
||||
p = self._intro_t / self.INTRO_DURATION
|
||||
self._fade_widget.set_opacity(p * p * (3 - 2 * p)) # smooth ramp 0->1
|
||||
if self._outro_t is not None:
|
||||
self._outro_t += dt
|
||||
po = min(1.0, self._outro_t / self.OUTRO_DURATION)
|
||||
if self._fade_widget is not None:
|
||||
self._fade_widget.set_opacity(1.0 - po * po * (3 - 2 * po)) # ramp 1->0
|
||||
if self._outro_t >= self.OUTRO_DURATION:
|
||||
done = self._outro_done
|
||||
self._outro_t = None
|
||||
self._outro_done = None
|
||||
if done is not None:
|
||||
done()
|
||||
self.widget.queue_draw()
|
||||
|
||||
def start_intro(self) -> None:
|
||||
"""Kick off the 'hologram materialising out of static' opening effect."""
|
||||
if self.enabled:
|
||||
self._outro_t = None # cancel any in-flight closing dissolve
|
||||
self._outro_done = None
|
||||
self._intro_t = 0.0
|
||||
if self._fade_widget is not None:
|
||||
self._fade_widget.set_opacity(0.0) # start hidden; tick() ramps it up
|
||||
|
||||
def start_outro(self, on_done) -> None:
|
||||
"""Play a quick reverse of the intro (content dissolving back into static),
|
||||
then call on_done to actually hide. If disabled, hide immediately."""
|
||||
if not self.enabled:
|
||||
on_done()
|
||||
return
|
||||
self._intro_t = None # cancel any in-flight opening intro
|
||||
self._outro_t = 0.0
|
||||
self._outro_done = on_done
|
||||
|
||||
# -- drawing --------------------------------------------------------------
|
||||
def _draw_frame(self, _area, cr, width: float, height: float) -> None:
|
||||
if not self.enabled or width <= 0 or height <= 0:
|
||||
return
|
||||
if self._clip_func is not None:
|
||||
cr.save()
|
||||
self._clip_func(cr, width, height) # clip the scanlines to the UI shape
|
||||
cr.clip()
|
||||
# Render the holo field into a group, then composite it back through a
|
||||
# soft edge-fade mask so the scanlines dissolve at the borders (reads far
|
||||
# more like a projected hologram than a hard-edged rectangle).
|
||||
cr.push_group()
|
||||
self._draw_content(cr, width, height)
|
||||
cr.pop_group_to_source()
|
||||
cr.mask(self._edge_fade_mask(width, height))
|
||||
if self._intro_t is not None:
|
||||
self._draw_intro(cr, width, height)
|
||||
elif self._outro_t is not None:
|
||||
self._draw_outro(cr, width, height)
|
||||
if self._clip_func is not None:
|
||||
cr.restore()
|
||||
|
||||
def _edge_fade_mask(self, width: float, height: float):
|
||||
key = (int(width), int(height))
|
||||
if self._mask_cache is not None and self._mask_cache[0] == key:
|
||||
return self._mask_cache[1]
|
||||
w, h = max(1, key[0]), max(1, key[1])
|
||||
surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h)
|
||||
m = cairo.Context(surf)
|
||||
m.set_source_rgba(0, 0, 0, 1)
|
||||
m.paint()
|
||||
m.set_operator(cairo.OPERATOR_DEST_OUT) # subtract edge gradients from the solid
|
||||
fx = min(self.EDGE_FADE_X, w / 2)
|
||||
fy = min(self.EDGE_FADE_Y, h / 2)
|
||||
def band(x0, y0, x1, y1, rx, ry, rw, rh):
|
||||
gr = cairo.LinearGradient(x0, y0, x1, y1)
|
||||
gr.add_color_stop_rgba(0.0, 0, 0, 0, 1)
|
||||
gr.add_color_stop_rgba(1.0, 0, 0, 0, 0)
|
||||
m.set_source(gr)
|
||||
m.rectangle(rx, ry, rw, rh)
|
||||
m.fill()
|
||||
band(0, 0, fx, 0, 0, 0, fx, h) # left
|
||||
band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right
|
||||
band(0, 0, 0, fy, 0, 0, w, fy) # top
|
||||
band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom
|
||||
pattern = cairo.SurfacePattern(surf)
|
||||
self._mask_cache = (key, pattern)
|
||||
return pattern
|
||||
|
||||
def _draw_intro(self, cr, width: float, height: float) -> None:
|
||||
p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION))
|
||||
strength = 1.0 - p
|
||||
# No solid veil block: the content itself fades in (see tick's fade_widget
|
||||
# ramp). Here we only lay churning static over it — dense at first, thinning
|
||||
# to nothing — so the UI resolves out of noise as it fades up.
|
||||
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
|
||||
count = int(self.INTRO_STATIC * (strength ** 0.5)) # dense, thinning to none
|
||||
for _ in range(count):
|
||||
x = random.uniform(0, width)
|
||||
y = random.uniform(0, height)
|
||||
col = random.choice(colors)
|
||||
cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength))
|
||||
sz = random.uniform(1.0, 2.8)
|
||||
cr.rectangle(x, y, sz, sz)
|
||||
cr.fill()
|
||||
band = p * height # a bright scan wiping down as it resolves
|
||||
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1)))
|
||||
cr.rectangle(0, band - 2.0, width, 4.0)
|
||||
cr.fill()
|
||||
|
||||
def _draw_outro(self, cr, width: float, height: float) -> None:
|
||||
# Reverse of the intro: the content is already fading back out (tick's
|
||||
# fade_widget ramp 1->0); here the static thickens from nothing as it goes,
|
||||
# so the panel dissolves into noise just before it vanishes.
|
||||
po = min(1.0, max(0.0, (self._outro_t or 0.0) / self.OUTRO_DURATION))
|
||||
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
|
||||
count = int(self.INTRO_STATIC * (po ** 0.5))
|
||||
for _ in range(count):
|
||||
x = random.uniform(0, width)
|
||||
y = random.uniform(0, height)
|
||||
col = random.choice(colors)
|
||||
cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * po))
|
||||
sz = random.uniform(1.0, 2.8)
|
||||
cr.rectangle(x, y, sz, sz)
|
||||
cr.fill()
|
||||
band = (1.0 - po) * height # scan wiping back up as it dissolves
|
||||
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1)))
|
||||
cr.rectangle(0, band - 2.0, width, 4.0)
|
||||
cr.fill()
|
||||
|
||||
def _draw_content(self, cr, width: float, height: float) -> None:
|
||||
r, g, b = _VIOLET
|
||||
|
||||
cr.save()
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ class _BluetoothView(Gtk.Box):
|
|||
self._list.remove(child)
|
||||
child = self._list.get_first_child()
|
||||
if not self.bt:
|
||||
self._list.append(Gtk.Label(label="No System Radio hardware"))
|
||||
self._list.append(Gtk.Label(label="No Bluetooth adapter"))
|
||||
return
|
||||
|
||||
devices = list(self.bt.get_devices())
|
||||
|
|
@ -283,7 +283,7 @@ def build(ctx: ModuleContext) -> ModuleInstance:
|
|||
|
||||
SPEC = ModuleSpec(
|
||||
id="bluetooth",
|
||||
title="System Radio",
|
||||
title="Bluetooth",
|
||||
icon="", # nf-fa-bluetooth
|
||||
build=build,
|
||||
default_enabled=True,
|
||||
|
|
|
|||
|
|
@ -50,9 +50,6 @@ class _MapView(Gtk.Box):
|
|||
tag: str, show_info: bool):
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.add_css_class("map-view")
|
||||
# clip the map texture to the rounded .map-view corners (the Picture is a
|
||||
# texture, so without this its square corners poke out of the card)
|
||||
self.set_overflow(Gtk.Overflow.HIDDEN)
|
||||
self.ctx = ctx
|
||||
self.zoom = zoom
|
||||
self.size = size
|
||||
|
|
|
|||
|
|
@ -518,7 +518,7 @@ def build(ctx: ModuleContext) -> ModuleInstance:
|
|||
|
||||
SPEC = ModuleSpec(
|
||||
id="network",
|
||||
title="Laser Antenna Uplink",
|
||||
title="Network",
|
||||
icon="", # nf-md-lan
|
||||
build=build,
|
||||
default_enabled=True,
|
||||
|
|
|
|||
|
|
@ -1,195 +0,0 @@
|
|||
"""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,8 +10,7 @@ from __future__ import annotations
|
|||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Pango", "1.0")
|
||||
from gi.repository import Gtk, Pango # noqa: E402
|
||||
from gi.repository import Gtk # noqa: E402
|
||||
|
||||
from lib.ansi import AnsiRenderer
|
||||
from lib.proc import run_text
|
||||
|
|
@ -29,11 +28,6 @@ class _WeatherView(Gtk.Box):
|
|||
self.opts = opts
|
||||
self._loc = ""
|
||||
|
||||
headline = Gtk.Label(label="Planetary Environment Report", xalign=0.0)
|
||||
headline.add_css_class("weather-headline")
|
||||
headline.set_ellipsize(Pango.EllipsizeMode.END)
|
||||
self.append(headline)
|
||||
|
||||
self.renderer = AnsiRenderer()
|
||||
self.append(self.renderer.view)
|
||||
self._status = Gtk.Label(label="Loading weather…")
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ specs fill the 2x2 grid; extra specs are kept for future paginated layouts.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from modules import bluetooth, network, sysmon, weather
|
||||
from modules import bluetooth, location, network, weather
|
||||
from module_base import ModuleSpec
|
||||
|
||||
ALL_SPECS: list[ModuleSpec] = [
|
||||
sysmon.SPEC,
|
||||
location.SPEC,
|
||||
weather.SPEC,
|
||||
bluetooth.SPEC,
|
||||
network.SPEC,
|
||||
|
|
|
|||
|
|
@ -7,29 +7,11 @@
|
|||
* .astro-hologram, painted by window.py) so the whole panel reads as one
|
||||
* continuous piece with orbit-menu and horizon-dock. */
|
||||
|
||||
/* Text colour override: bright magenta reads far better than the default dusty
|
||||
* @text (#D6ABAB) now that the panel is transparent + blurred glass. @accent
|
||||
* (red) stays the accent/alt colour. This @define-color is added after
|
||||
* _colors.css by theme.py (same USER+1 priority, later wins) and only affects
|
||||
* this process's widgets, so other Cosmonaut Shell apps keep their @text. */
|
||||
@define-color text #EB00A6;
|
||||
|
||||
* {
|
||||
font-family: "Agave Nerd Font Mono", monospace;
|
||||
font-size: 13pt;
|
||||
}
|
||||
|
||||
/* Neutralise the CyberQueer GTK theme's `* { background-color:#1a1a1a }`, which
|
||||
* otherwise paints EVERY node (every Gtk.Box, label, centerbox, …) an opaque
|
||||
* dark slab behind the floating modules. Enumerating node types by name is
|
||||
* fragile (it missed plain `box`), so blank them all here — this provider sits
|
||||
* at USER+1, above the theme. Interactive elements re-assert their own fills
|
||||
* via the higher-specificity .class rules below; module cards are Cairo-drawn
|
||||
* (lib/border.py, fill_bg=True), not CSS, so they are unaffected. */
|
||||
* {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* ---- window / letterbox --------------------------------------------- */
|
||||
/* The CyberQueer GTK theme paints `* { background-color: #1a1a1a }` on EVERY node,
|
||||
* which fills the gaps between modules with a solid slab. Each module carries its
|
||||
|
|
@ -114,20 +96,6 @@ drawingarea {
|
|||
|
||||
.quad-body { color: @text; }
|
||||
|
||||
/* weather "Planetary Environment Report" headline */
|
||||
.weather-headline {
|
||||
color: @text;
|
||||
font-weight: 700;
|
||||
font-size: 12pt;
|
||||
letter-spacing: 1px;
|
||||
margin: 0 0 6px 2px;
|
||||
}
|
||||
|
||||
/* Ship Systems (system monitor): the row labels carry their own inline Pango
|
||||
* markup (monospace, colour-coded meters), so this only spaces them out. */
|
||||
.sysmon-view { margin: 4px 6px; }
|
||||
.sysmon-row { margin: 1px 0; }
|
||||
|
||||
/* pill action buttons */
|
||||
.quad-action,
|
||||
.enable-btn {
|
||||
|
|
@ -268,13 +236,9 @@ button:checked.quad-action { background: @accent; color: @bg; border-color: @acc
|
|||
.map-marker { color: @accent; }
|
||||
|
||||
/* ---- weather -------------------------------------------------------- */
|
||||
/* Transparent, not a translucent slab: its own alpha(@violet) fill stacked on
|
||||
* top of the module's Cairo card fill, reading as a brighter square with hard
|
||||
* corners. Let the card fill show through instead. */
|
||||
.ansi-view,
|
||||
.ansi-view text {
|
||||
background: transparent;
|
||||
background-color: transparent;
|
||||
background: alpha(@violet, 0.4);
|
||||
color: @text;
|
||||
font-family: "Agave Nerd Font Mono", monospace;
|
||||
font-size: 11pt;
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ class MenuWindow(Gtk.ApplicationWindow):
|
|||
overlay = Gtk.Overlay()
|
||||
overlay.set_child(self.root)
|
||||
|
||||
self._hologram = HologramOverlay(enabled=config.hologram_enabled(), fade_widget=self.root)
|
||||
self._hologram = HologramOverlay(enabled=config.hologram_enabled())
|
||||
overlay.add_overlay(self._hologram.widget)
|
||||
|
||||
close = Gtk.Button(label="✕")
|
||||
|
|
@ -201,21 +201,12 @@ class MenuWindow(Gtk.ApplicationWindow):
|
|||
self.appdrawer.set_expanded(True)
|
||||
if self._hologram.enabled and self._tick_id is None:
|
||||
self._tick_id = self.add_tick_callback(self._on_tick)
|
||||
self._hologram.start_intro()
|
||||
|
||||
def hide_menu(self) -> None:
|
||||
self.grid.on_hide()
|
||||
self.taskbar.collapse_panel()
|
||||
self.grid.hide_takeover()
|
||||
self.appdrawer.set_expanded(False)
|
||||
# Play the closing dissolve first (content fades back into static), then
|
||||
# actually hide via _finish_hide. If the hologram is off, hide at once.
|
||||
if self._hologram.enabled and self._tick_id is not None:
|
||||
self._hologram.start_outro(self._finish_hide)
|
||||
else:
|
||||
self._finish_hide()
|
||||
|
||||
def _finish_hide(self) -> None:
|
||||
self.set_visible(False)
|
||||
if self._tick_id is not None:
|
||||
self.remove_tick_callback(self._tick_id)
|
||||
|
|
|
|||
|
|
@ -1,278 +0,0 @@
|
|||
"""Holographic scanline/sweep/noise overlay — the same treatment and tuning as
|
||||
the rest of the Cosmonaut Shell suite (astro-menu / station-bar / orbit-menu /
|
||||
horizon-dock lib/hologram.py), reused here so notification cards read as one more
|
||||
orbit of the same look: scanline grid + a slow vertical sweep + drifting noise
|
||||
specks, and a 'materialise out of static' intro when a card first appears.
|
||||
|
||||
Each notification card owns its own overlay (see notification.py); the stack
|
||||
window (window.py) runs a single frame-clock tick and feeds every visible card's
|
||||
overlay via .tick(dt).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
|
||||
import cairo
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
from gi.repository import GLib, Gtk # noqa: E402
|
||||
|
||||
# Same CyberQueer violet/magenta/red combo as the rest of the suite's hologram.
|
||||
_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255)
|
||||
_MAGENTA = (0.92, 0.0, 0.65)
|
||||
_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255)
|
||||
|
||||
|
||||
class HologramOverlay:
|
||||
SCANLINE_GAP = 4.0
|
||||
SCANLINE_ALPHA = 0.18 # a touch stronger than the bar's — cards are the focus
|
||||
SWEEP_PERIOD = 3.2 # seconds for one top-to-bottom pass
|
||||
SWEEP_HALF_HEIGHT = 34.0 # card-sized panel, taller than the thin bar strip
|
||||
NOISE_COUNT = 26
|
||||
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 = 40.0 # smooth horizontal fade-out of the scanline field
|
||||
EDGE_FADE_Y = 34.0 # smooth vertical fade-out
|
||||
INTRO_DURATION = 0.9 # brisk 'materialise out of static' when a card pops in
|
||||
INTRO_STATIC = 900 # static specks at the very start of the intro
|
||||
OUTRO_DURATION = 0.4 # quick reverse dissolve back into static on dismiss
|
||||
|
||||
def __init__(self, enabled: bool = True, clip_func=None, fade_widget=None,
|
||||
intro_duration: float | None = None) -> None:
|
||||
self.enabled = enabled
|
||||
self._clip_func = clip_func # optional path-setter to clip the holo to the card shape
|
||||
# widget whose opacity is ramped 0->1 during the intro so the card content
|
||||
# genuinely fades in, rather than a solid haze block popping on
|
||||
self._fade_widget = fade_widget
|
||||
if intro_duration is not None:
|
||||
self.INTRO_DURATION = intro_duration
|
||||
self._sat_time = 0.0
|
||||
self._particles: list[dict] = []
|
||||
self._intro_t: float | None = None
|
||||
self._outro_t: float | None = None
|
||||
self._outro_done = None
|
||||
# Wall-clock safety net: the frame-clock tick only advances while the
|
||||
# compositor sends frame callbacks; if those stall the intro could freeze
|
||||
# with content stuck at opacity 0. This timeout force-resolves it anyway.
|
||||
self._intro_deadline_id: int | None = None
|
||||
self._mask_cache: tuple | None = None
|
||||
|
||||
self.widget = Gtk.DrawingArea()
|
||||
self.widget.set_can_target(False) # never steals clicks from the card underneath
|
||||
self.widget.add_css_class("beacon-hologram")
|
||||
self.widget.set_hexpand(True)
|
||||
self.widget.set_vexpand(True)
|
||||
self.widget.set_halign(Gtk.Align.FILL)
|
||||
self.widget.set_valign(Gtk.Align.FILL)
|
||||
self.widget.set_draw_func(self._draw_frame)
|
||||
|
||||
def tick(self, dt: float) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
self._sat_time += dt
|
||||
if self._intro_t is not None:
|
||||
self._intro_t += dt
|
||||
if self._intro_t >= self.INTRO_DURATION:
|
||||
self._finish_intro()
|
||||
elif self._fade_widget is not None:
|
||||
p = self._intro_t / self.INTRO_DURATION
|
||||
self._fade_widget.set_opacity(p * p * (3 - 2 * p))
|
||||
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))
|
||||
if self._outro_t >= self.OUTRO_DURATION:
|
||||
done = self._outro_done
|
||||
self._outro_t = None
|
||||
self._outro_done = None
|
||||
if done is not None:
|
||||
done()
|
||||
self.widget.queue_draw()
|
||||
|
||||
def _finish_intro(self) -> None:
|
||||
self._intro_t = None
|
||||
if self._intro_deadline_id is not None:
|
||||
GLib.source_remove(self._intro_deadline_id)
|
||||
self._intro_deadline_id = None
|
||||
if self._fade_widget is not None:
|
||||
self._fade_widget.set_opacity(1.0)
|
||||
self.widget.queue_draw()
|
||||
|
||||
def start_intro(self) -> None:
|
||||
"""Kick off the 'card materialising out of static' opening effect."""
|
||||
if self.enabled:
|
||||
self._outro_t = None
|
||||
self._outro_done = None
|
||||
self._intro_t = 0.0
|
||||
if self._fade_widget is not None:
|
||||
self._fade_widget.set_opacity(0.0)
|
||||
if self._intro_deadline_id is not None:
|
||||
GLib.source_remove(self._intro_deadline_id)
|
||||
self._intro_deadline_id = GLib.timeout_add(
|
||||
int(self.INTRO_DURATION * 1000) + 150, self._on_intro_deadline)
|
||||
|
||||
def _on_intro_deadline(self) -> bool:
|
||||
self._intro_deadline_id = None
|
||||
if self._intro_t is not None:
|
||||
self._finish_intro()
|
||||
return False # one-shot
|
||||
|
||||
def start_outro(self, on_done) -> None:
|
||||
"""Reverse of the intro (card dissolving back into static), then on_done."""
|
||||
if not self.enabled:
|
||||
on_done()
|
||||
return
|
||||
self._intro_t = None
|
||||
if self._intro_deadline_id is not None:
|
||||
GLib.source_remove(self._intro_deadline_id)
|
||||
self._intro_deadline_id = None
|
||||
self._outro_t = 0.0
|
||||
self._outro_done = on_done
|
||||
|
||||
# -- drawing --------------------------------------------------------------
|
||||
def _draw_frame(self, _area, cr, width: float, height: float) -> None:
|
||||
if not self.enabled or width <= 0 or height <= 0:
|
||||
return
|
||||
if self._clip_func is not None:
|
||||
cr.save()
|
||||
self._clip_func(cr, width, height) # clip the scanlines to the card's rounded shape
|
||||
cr.clip()
|
||||
cr.push_group()
|
||||
self._draw_content(cr, width, height)
|
||||
cr.pop_group_to_source()
|
||||
cr.mask(self._edge_fade_mask(width, height))
|
||||
if self._intro_t is not None:
|
||||
self._draw_intro(cr, width, height)
|
||||
elif self._outro_t is not None:
|
||||
self._draw_outro(cr, width, height)
|
||||
if self._clip_func is not None:
|
||||
cr.restore()
|
||||
|
||||
def _edge_fade_mask(self, width: float, height: float):
|
||||
key = (int(width), int(height))
|
||||
if self._mask_cache is not None and self._mask_cache[0] == key:
|
||||
return self._mask_cache[1]
|
||||
w, h = max(1, key[0]), max(1, key[1])
|
||||
surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h)
|
||||
m = cairo.Context(surf)
|
||||
m.set_source_rgba(0, 0, 0, 1)
|
||||
m.paint()
|
||||
m.set_operator(cairo.OPERATOR_DEST_OUT)
|
||||
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
|
||||
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
|
||||
count = int(self.INTRO_STATIC * (strength ** 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 * strength))
|
||||
sz = random.uniform(1.0, 2.8)
|
||||
cr.rectangle(x, y, sz, sz)
|
||||
cr.fill()
|
||||
band = p * height
|
||||
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1)))
|
||||
cr.rectangle(0, band - 2.0, width, 4.0)
|
||||
cr.fill()
|
||||
|
||||
def _draw_outro(self, cr, width: float, height: float) -> None:
|
||||
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
|
||||
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1)))
|
||||
cr.rectangle(0, band - 2.0, width, 4.0)
|
||||
cr.fill()
|
||||
|
||||
def _draw_content(self, cr, width: float, height: float) -> None:
|
||||
r, g, b = _VIOLET
|
||||
|
||||
cr.save()
|
||||
cr.set_source_rgba(r, g, b, self.SCANLINE_ALPHA)
|
||||
cr.set_line_width(1.0)
|
||||
y = 0.0
|
||||
while y < height:
|
||||
cr.move_to(0, y)
|
||||
cr.line_to(width, y)
|
||||
y += self.SCANLINE_GAP
|
||||
cr.stroke()
|
||||
cr.restore()
|
||||
|
||||
phase = (self._sat_time % self.SWEEP_PERIOD) / self.SWEEP_PERIOD
|
||||
sweep_y = phase * height
|
||||
hh = self.SWEEP_HALF_HEIGHT
|
||||
grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh)
|
||||
grad.add_color_stop_rgba(0.0, r, g, b, 0.0)
|
||||
grad.add_color_stop_rgba(0.5, r, g, b, 0.09)
|
||||
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()
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""beacon — the Cosmonaut Shell notification daemon for hyprdrive.
|
||||
|
||||
Replaces dunst: owns org.freedesktop.Notifications and renders each notification
|
||||
as a holographic card (scanline/sweep/noise overlay, emitted-magenta text, violet
|
||||
holo-glass, radio-squiggle divider) in a top-centre layer-shell stack, so
|
||||
notifications read as one more orbit of the astro-menu / orbit-menu / station-bar
|
||||
look instead of a plain popup.
|
||||
|
||||
main.py run the resident daemon (owns the notification bus name, stays hidden
|
||||
until a notification arrives)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# beacon-start.sh LD_PRELOADs libgtk4-layer-shell (load-ordering requirement ahead
|
||||
# of libwayland-client). Drop it once resident so it isn't inherited by anything
|
||||
# this process launches (same rationale as the rest of the suite's main.py).
|
||||
os.environ.pop("LD_PRELOAD", None)
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import gi # noqa: E402
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
from gi.repository import Gio, GLib, Gtk # noqa: E402
|
||||
|
||||
import theme # noqa: E402
|
||||
from paths import APP_ID, FDN_NAME # noqa: E402
|
||||
from server import NotificationServer # noqa: E402
|
||||
from window import BeaconWindow # noqa: E402
|
||||
|
||||
|
||||
class BeaconApp(Gtk.Application):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(application_id=APP_ID,
|
||||
flags=Gio.ApplicationFlags.DEFAULT_FLAGS)
|
||||
self.window: BeaconWindow | None = None
|
||||
self._server: NotificationServer | None = None
|
||||
self._name_id = 0
|
||||
|
||||
def do_startup(self) -> None:
|
||||
Gtk.Application.do_startup(self)
|
||||
theme.load_css()
|
||||
self.window = BeaconWindow(self, on_closed=self._on_closed)
|
||||
# Own the freedesktop notification name; the server is created once the bus
|
||||
# connection is in hand.
|
||||
self._name_id = Gio.bus_own_name(
|
||||
Gio.BusType.SESSION, FDN_NAME, Gio.BusNameOwnerFlags.NONE,
|
||||
self._on_bus_acquired, None, self._on_name_lost)
|
||||
self.hold() # stay alive with no visible window
|
||||
|
||||
def _on_bus_acquired(self, connection: Gio.DBusConnection, _name: str) -> None:
|
||||
assert self.window is not None
|
||||
self._server = NotificationServer(connection, self.window)
|
||||
|
||||
def _on_closed(self, nid: int, reason: int) -> None:
|
||||
if self._server is not None:
|
||||
self._server.emit_closed(nid, reason)
|
||||
|
||||
def _on_name_lost(self, _connection, _name: str) -> None:
|
||||
# Another notification daemon (e.g. a still-running dunst) already owns it.
|
||||
sys.stderr.write(
|
||||
"beacon: could not acquire org.freedesktop.Notifications "
|
||||
"(another notification daemon is running); exiting.\n")
|
||||
self.quit()
|
||||
|
||||
def do_activate(self) -> None:
|
||||
pass # resident daemon: nothing to do on activate
|
||||
|
||||
|
||||
def main() -> int:
|
||||
GLib.set_prgname("beacon")
|
||||
return BeaconApp().run(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
"""A single holographic notification card.
|
||||
|
||||
Layout: [app icon] [ summary (emitted-magenta, letter-spaced) / radio-squiggle
|
||||
divider / body ] with an optional row of action-pill buttons, all under a
|
||||
scanline/sweep/noise HologramOverlay (lib/hologram.py) clipped to the card's
|
||||
rounded rectangle. The card materialises out of static (holo intro) when it
|
||||
appears and dissolves back into static when dismissed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Callable, Optional
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
gi.require_version("GdkPixbuf", "2.0")
|
||||
from gi.repository import Gdk, GdkPixbuf, GLib, Gtk # noqa: E402
|
||||
|
||||
from lib.hologram import HologramOverlay
|
||||
|
||||
_SQUIGGLE = "∿" * 16 # ∿ sine-wave "tuned signal" divider
|
||||
_CARD_RADIUS = 16.0
|
||||
|
||||
|
||||
def _rounded_path(cr, w: float, h: float, r: float = _CARD_RADIUS) -> None:
|
||||
r = min(r, w / 2, h / 2)
|
||||
cr.new_sub_path()
|
||||
cr.arc(w - r, r, r, -math.pi / 2, 0)
|
||||
cr.arc(w - r, h - r, r, 0, math.pi / 2)
|
||||
cr.arc(r, h - r, r, math.pi / 2, math.pi)
|
||||
cr.arc(r, r, r, math.pi, 3 * math.pi / 2)
|
||||
cr.close_path()
|
||||
|
||||
|
||||
class NotificationCard:
|
||||
def __init__(self, nid: int, summary: str, body: str, urgency: int,
|
||||
icon: str, image_data, actions: list[str],
|
||||
on_action: Callable[[int, str], None],
|
||||
on_dismiss: Callable[[int], None]) -> None:
|
||||
self.nid = nid
|
||||
self._on_action = on_action
|
||||
self._on_dismiss = on_dismiss
|
||||
self._dismissing = False
|
||||
critical = urgency >= 2
|
||||
|
||||
# -- content -----------------------------------------------------------
|
||||
content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
content.add_css_class("beacon-content")
|
||||
|
||||
img = self._build_icon(icon, image_data)
|
||||
if img is not None:
|
||||
content.append(img)
|
||||
|
||||
textcol = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||||
textcol.set_hexpand(True)
|
||||
textcol.set_valign(Gtk.Align.CENTER)
|
||||
|
||||
if summary:
|
||||
lbl = Gtk.Label(label=summary, xalign=0.0)
|
||||
lbl.add_css_class("beacon-summary")
|
||||
lbl.set_wrap(True)
|
||||
lbl.set_wrap_mode(2) # WORD_CHAR
|
||||
lbl.set_max_width_chars(30)
|
||||
textcol.append(lbl)
|
||||
|
||||
squig = Gtk.Label(label=_SQUIGGLE, xalign=0.0)
|
||||
squig.add_css_class("beacon-squiggle")
|
||||
textcol.append(squig)
|
||||
|
||||
if body:
|
||||
blbl = Gtk.Label(xalign=0.0)
|
||||
blbl.add_css_class("beacon-body")
|
||||
blbl.set_wrap(True)
|
||||
blbl.set_wrap_mode(2)
|
||||
blbl.set_max_width_chars(34)
|
||||
# bodies may carry a small Pango-markup subset (<b>/<i>/<u>/<a>…);
|
||||
# fall back to plain text if the sender's markup won't parse.
|
||||
try:
|
||||
blbl.set_markup(body)
|
||||
except GLib.GError:
|
||||
blbl.set_text(body)
|
||||
textcol.append(blbl)
|
||||
|
||||
content.append(textcol)
|
||||
|
||||
if actions:
|
||||
row = self._build_actions(actions)
|
||||
if row is not None:
|
||||
textcol.append(row)
|
||||
|
||||
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
card.add_css_class("beacon-card")
|
||||
if critical:
|
||||
card.add_css_class("critical")
|
||||
card.append(content)
|
||||
card.set_size_request(340, -1)
|
||||
|
||||
# -- hologram overlay --------------------------------------------------
|
||||
self._holo = HologramOverlay(enabled=True, clip_func=_rounded_path,
|
||||
fade_widget=content)
|
||||
|
||||
overlay = Gtk.Overlay()
|
||||
overlay.set_child(card)
|
||||
overlay.add_overlay(self._holo.widget)
|
||||
overlay.set_measure_overlay(self._holo.widget, False)
|
||||
|
||||
# left-click anywhere on the card = invoke default action if present, else
|
||||
# dismiss; right-click always dismisses.
|
||||
self._default_action = "default" if "default" in actions else None
|
||||
left = Gtk.GestureClick(button=1)
|
||||
left.connect("released", self._on_left_click)
|
||||
overlay.add_controller(left)
|
||||
right = Gtk.GestureClick(button=3)
|
||||
right.connect("released", lambda *_a: self.dismiss())
|
||||
overlay.add_controller(right)
|
||||
|
||||
self.widget = overlay
|
||||
self._holo.start_intro()
|
||||
|
||||
# -- public ---------------------------------------------------------------
|
||||
def tick(self, dt: float) -> None:
|
||||
self._holo.tick(dt)
|
||||
|
||||
def dismiss(self) -> None:
|
||||
"""Play the dissolve, then hand the id back to the window for removal."""
|
||||
if self._dismissing:
|
||||
return
|
||||
self._dismissing = True
|
||||
self._holo.start_outro(lambda: self._on_dismiss(self.nid))
|
||||
|
||||
# -- internals ------------------------------------------------------------
|
||||
def _on_left_click(self, *_a) -> None:
|
||||
if self._default_action is not None:
|
||||
self._on_action(self.nid, self._default_action)
|
||||
else:
|
||||
self.dismiss()
|
||||
|
||||
def _build_actions(self, actions: list[str]) -> Optional[Gtk.Box]:
|
||||
# actions is a flat [key, label, key, label, …] list; "default" is the
|
||||
# implicit click action and isn't shown as a button.
|
||||
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||
row.add_css_class("beacon-actions")
|
||||
row.set_margin_top(6)
|
||||
shown = 0
|
||||
for i in range(0, len(actions) - 1, 2):
|
||||
key, label = actions[i], actions[i + 1]
|
||||
if key == "default":
|
||||
continue
|
||||
btn = Gtk.Button(label=label or key)
|
||||
btn.add_css_class("beacon-action")
|
||||
btn.connect("clicked", lambda _b, k=key: self._on_action(self.nid, k))
|
||||
row.append(btn)
|
||||
shown += 1
|
||||
return row if shown else None
|
||||
|
||||
def _build_icon(self, icon: str, image_data) -> Optional[Gtk.Image]:
|
||||
img: Optional[Gtk.Image] = None
|
||||
if image_data is not None:
|
||||
pb = self._pixbuf_from_hint(image_data)
|
||||
if pb is not None:
|
||||
img = Gtk.Image.new_from_paintable(Gdk.Texture.new_for_pixbuf(pb))
|
||||
if img is None and icon:
|
||||
if icon.startswith("file://"):
|
||||
icon = icon[len("file://"):]
|
||||
if icon.startswith("/"):
|
||||
img = Gtk.Image.new_from_file(icon)
|
||||
else:
|
||||
img = Gtk.Image.new_from_icon_name(icon)
|
||||
if img is None:
|
||||
return None
|
||||
img.add_css_class("beacon-icon")
|
||||
img.set_pixel_size(44)
|
||||
img.set_valign(Gtk.Align.START)
|
||||
return img
|
||||
|
||||
@staticmethod
|
||||
def _pixbuf_from_hint(data) -> Optional[GdkPixbuf.Pixbuf]:
|
||||
# Spec "image-data": (width, height, rowstride, has_alpha, bits, channels, bytes)
|
||||
try:
|
||||
w, h, rowstride, has_alpha, bits, channels, raw = data
|
||||
return GdkPixbuf.Pixbuf.new_from_bytes(
|
||||
GLib.Bytes.new(bytes(raw)), GdkPixbuf.Colorspace.RGB,
|
||||
has_alpha, bits, w, h, rowstride)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
"""Shared filesystem locations. Works whether run from the repo or ~/.config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
STYLE_DIR = BASE_DIR / "style"
|
||||
|
||||
# GApplication single-instance id (our own process). NOT the freedesktop
|
||||
# notification name — that well-known name (org.freedesktop.Notifications) is
|
||||
# owned separately in main.py, the same one dunst used to hold.
|
||||
APP_ID = "eu.abdelbaki.beacon"
|
||||
|
||||
FDN_NAME = "org.freedesktop.Notifications"
|
||||
FDN_PATH = "/org/freedesktop/Notifications"
|
||||
FDN_IFACE = "org.freedesktop.Notifications"
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
"""org.freedesktop.Notifications D-Bus service — the well-known name dunst used
|
||||
to own. Parses each Notify call and hands it to the stack window (window.py) to
|
||||
render as a holographic card; owns per-notification expiry timers and emits the
|
||||
spec's NotificationClosed / ActionInvoked signals.
|
||||
|
||||
Deliberately small: the visible/interactive spec subset (body markup, actions,
|
||||
icons, urgency, replaces_id, timeouts, persistence) — no sound/markup-hint
|
||||
gymnastics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
from gi.repository import Gio, GLib # noqa: E402
|
||||
|
||||
from paths import FDN_IFACE, FDN_PATH
|
||||
|
||||
# reasons per the spec: 1 expired, 2 dismissed by user, 3 closed by call, 4 undefined
|
||||
_INTROSPECTION_XML = """
|
||||
<node>
|
||||
<interface name="org.freedesktop.Notifications">
|
||||
<method name="GetCapabilities">
|
||||
<arg type="as" name="capabilities" direction="out"/>
|
||||
</method>
|
||||
<method name="Notify">
|
||||
<arg type="s" name="app_name" direction="in"/>
|
||||
<arg type="u" name="replaces_id" direction="in"/>
|
||||
<arg type="s" name="app_icon" direction="in"/>
|
||||
<arg type="s" name="summary" direction="in"/>
|
||||
<arg type="s" name="body" direction="in"/>
|
||||
<arg type="as" name="actions" direction="in"/>
|
||||
<arg type="a{sv}" name="hints" direction="in"/>
|
||||
<arg type="i" name="expire_timeout" direction="in"/>
|
||||
<arg type="u" name="id" direction="out"/>
|
||||
</method>
|
||||
<method name="CloseNotification">
|
||||
<arg type="u" name="id" direction="in"/>
|
||||
</method>
|
||||
<method name="GetServerInformation">
|
||||
<arg type="s" name="name" direction="out"/>
|
||||
<arg type="s" name="vendor" direction="out"/>
|
||||
<arg type="s" name="version" direction="out"/>
|
||||
<arg type="s" name="spec_version" direction="out"/>
|
||||
</method>
|
||||
<signal name="NotificationClosed">
|
||||
<arg type="u" name="id"/>
|
||||
<arg type="u" name="reason"/>
|
||||
</signal>
|
||||
<signal name="ActionInvoked">
|
||||
<arg type="u" name="id"/>
|
||||
<arg type="s" name="action_key"/>
|
||||
</signal>
|
||||
</interface>
|
||||
</node>
|
||||
"""
|
||||
|
||||
# server default timeouts (ms) by urgency when the client passes -1
|
||||
_DEFAULT_TIMEOUT = {0: 5000, 1: 8000, 2: 0} # low / normal / critical(never)
|
||||
|
||||
|
||||
class NotificationServer:
|
||||
def __init__(self, connection: Gio.DBusConnection, window) -> None:
|
||||
self._conn = connection
|
||||
self._window = window
|
||||
self._next_id = 1
|
||||
self._timers: dict[int, int] = {}
|
||||
|
||||
node = Gio.DBusNodeInfo.new_for_xml(_INTROSPECTION_XML)
|
||||
self._iface = node.interfaces[0]
|
||||
connection.register_object(
|
||||
FDN_PATH, self._iface, self._on_method_call, None, None)
|
||||
|
||||
# -- D-Bus dispatch -------------------------------------------------------
|
||||
def _on_method_call(self, _conn, _sender, _path, _iface, method, params, invocation):
|
||||
if method == "Notify":
|
||||
invocation.return_value(GLib.Variant("(u)", (self._notify(params),)))
|
||||
elif method == "CloseNotification":
|
||||
(nid,) = params.unpack()
|
||||
self._window.close_notification(int(nid), reason=3)
|
||||
self._cancel_timer(int(nid))
|
||||
invocation.return_value(None)
|
||||
elif method == "GetCapabilities":
|
||||
invocation.return_value(GLib.Variant("(as)", (
|
||||
["body", "body-markup", "icon-static", "actions", "persistence"],)))
|
||||
elif method == "GetServerInformation":
|
||||
invocation.return_value(GLib.Variant("(ssss)", (
|
||||
"beacon", "abdelbaki.eu", "1.0", "1.2")))
|
||||
else:
|
||||
invocation.return_error_literal(
|
||||
Gio.dbus_error_quark(), Gio.DBusError.UNKNOWN_METHOD, method)
|
||||
|
||||
def _notify(self, params) -> int:
|
||||
(app_name, replaces_id, app_icon, summary, body,
|
||||
actions, hints, expire_timeout) = params.unpack()
|
||||
|
||||
nid = int(replaces_id) if replaces_id else self._next_id
|
||||
if not replaces_id:
|
||||
self._next_id += 1
|
||||
|
||||
urgency = int(hints.get("urgency", 1))
|
||||
image_data = (hints.get("image-data") or hints.get("image_data")
|
||||
or hints.get("icon_data"))
|
||||
image_path = hints.get("image-path") or hints.get("image_path")
|
||||
icon = image_path or app_icon or ""
|
||||
|
||||
self._window.show_notification(
|
||||
nid, summary, body, urgency, icon, image_data, list(actions),
|
||||
self._emit_action)
|
||||
|
||||
self._arm_timer(nid, int(expire_timeout), urgency)
|
||||
return nid
|
||||
|
||||
# -- expiry timers --------------------------------------------------------
|
||||
def _arm_timer(self, nid: int, expire_timeout: int, urgency: int) -> None:
|
||||
self._cancel_timer(nid)
|
||||
if expire_timeout < 0:
|
||||
ms = _DEFAULT_TIMEOUT.get(urgency, 8000)
|
||||
else:
|
||||
ms = expire_timeout
|
||||
if ms <= 0:
|
||||
return # 0 = never expire (or critical default)
|
||||
self._timers[nid] = GLib.timeout_add(ms, self._on_expire, nid)
|
||||
|
||||
def _on_expire(self, nid: int) -> bool:
|
||||
self._timers.pop(nid, None)
|
||||
self._window.close_notification(nid, reason=1) # 1 = expired
|
||||
return False
|
||||
|
||||
def _cancel_timer(self, nid: int) -> None:
|
||||
tid = self._timers.pop(nid, None)
|
||||
if tid is not None:
|
||||
GLib.source_remove(tid)
|
||||
|
||||
# -- signals (also the window's on_closed / on_action callbacks) -----------
|
||||
def emit_closed(self, nid: int, reason: int) -> None:
|
||||
self._cancel_timer(nid)
|
||||
self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "NotificationClosed",
|
||||
GLib.Variant("(uu)", (nid, reason)))
|
||||
|
||||
def _emit_action(self, nid: int, key: str) -> None:
|
||||
self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "ActionInvoked",
|
||||
GLib.Variant("(us)", (nid, key)))
|
||||
# dismiss the card once its action fired, matching typical daemon behaviour
|
||||
self._window.close_notification(nid, reason=2)
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the
|
||||
* hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the
|
||||
* sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */
|
||||
@define-color text #D6ABAB;
|
||||
@define-color bg #1A1A1A;
|
||||
@define-color accent #E40046;
|
||||
@define-color violet #5018DD;
|
||||
@define-color danger #F50505;
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
/* beacon — holographic notification cards. Matches the astro-menu idiom
|
||||
* (astro-menu/style/style.css): emitted-magenta text, violet holo-glass fills,
|
||||
* glow-violet / accent frames, Agave Nerd Font Mono, rounded cards. The compositor
|
||||
* blurs behind the translucent card fills (see the `beacon` layer-rule in
|
||||
* hypr/usr/windowrules.lua); the scanline/sweep/noise depth is Cairo-drawn on top
|
||||
* by lib/hologram.py. */
|
||||
|
||||
/* Emitted magenta reads as projected light on the blurred glass, exactly as the
|
||||
* astro panel overrides its @text. */
|
||||
@define-color text #EB00A6;
|
||||
|
||||
* {
|
||||
font-family: "Agave Nerd Font Mono", monospace;
|
||||
font-size: 12pt;
|
||||
}
|
||||
|
||||
/* The CyberQueer GTK theme paints `* { background-color:#1a1a1a }` on every node,
|
||||
* which would fill the surface and the gaps between cards with an opaque slab.
|
||||
* Blank the structural nodes; the card asserts its own glass fill below. */
|
||||
window,
|
||||
window.background,
|
||||
.beacon-window,
|
||||
.beacon-stack,
|
||||
.beacon-content,
|
||||
box,
|
||||
overlay,
|
||||
label,
|
||||
image,
|
||||
drawingarea {
|
||||
background: transparent;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* the holo-glass card */
|
||||
.beacon-card {
|
||||
background-color: alpha(@violet, 0.40);
|
||||
border: 2px solid #8A5CFF; /* glow_violet — emitted-light frame */
|
||||
border-radius: 16px;
|
||||
padding: 12px 14px;
|
||||
box-shadow: 0 0 16px 1px alpha(#8A5CFF, 0.35);
|
||||
}
|
||||
.beacon-card.critical {
|
||||
border-color: @accent;
|
||||
background-color: alpha(@accent, 0.14);
|
||||
box-shadow: 0 0 18px 1px alpha(@accent, 0.40);
|
||||
}
|
||||
|
||||
/* summary = emitted magenta, letter-spaced like the astro HUD headlines */
|
||||
.beacon-summary {
|
||||
color: @text;
|
||||
font-weight: bold;
|
||||
font-size: 12pt;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.beacon-card.critical .beacon-summary { color: @accent; }
|
||||
|
||||
/* radio-squiggle divider (the tuned-signal line) */
|
||||
.beacon-squiggle {
|
||||
color: #8A5CFF;
|
||||
font-size: 9pt;
|
||||
margin: 1px 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.beacon-card.critical .beacon-squiggle { color: @accent; }
|
||||
|
||||
.beacon-body {
|
||||
color: @text;
|
||||
font-size: 11pt;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.beacon-icon { margin-right: 2px; }
|
||||
|
||||
/* action pills — astro-menu's .quad-action idiom */
|
||||
.beacon-action {
|
||||
color: @text;
|
||||
background-color: alpha(@violet, 0.4);
|
||||
border: 2px solid #8A5CFF;
|
||||
border-radius: 20px;
|
||||
padding: 2px 12px;
|
||||
min-height: 22px;
|
||||
transition: border-color 180ms ease, color 180ms ease, box-shadow 220ms ease, background 180ms ease;
|
||||
}
|
||||
.beacon-action:hover {
|
||||
border-color: @accent;
|
||||
color: @accent;
|
||||
box-shadow: 0 0 12px 1px alpha(@accent, 0.55);
|
||||
}
|
||||
|
||||
.beacon-hologram { background: transparent; }
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
"""Load the two stylesheets as ordered CSS providers — same scheme as the rest
|
||||
of the Cosmonaut Shell suite (orbit-menu, horizon-dock, astro-menu, station-bar).
|
||||
_colors.css defines the CyberQueer @define-color names; style.css consumes them.
|
||||
|
||||
Priority is USER+1 for the same reason as the others: the CyberQueer GTK theme
|
||||
at ~/.config/gtk-4.0/gtk.css loads at PRIORITY_USER (800), above APPLICATION
|
||||
(600), and would beat our transparent structural containers otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
from gi.repository import Gdk, Gtk # noqa: E402
|
||||
|
||||
from paths import STYLE_DIR
|
||||
|
||||
|
||||
def load_css() -> None:
|
||||
display = Gdk.Display.get_default()
|
||||
for name in ("_colors.css", "style.css"):
|
||||
path = STYLE_DIR / name
|
||||
if not path.exists():
|
||||
continue
|
||||
provider = Gtk.CssProvider()
|
||||
provider.load_from_path(str(path))
|
||||
Gtk.StyleContext.add_provider_for_display(
|
||||
display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1
|
||||
)
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
"""The notification stack: a top-centre layer-shell surface that holds the live
|
||||
notification cards and drives one frame-clock tick for all their holograms.
|
||||
|
||||
Sized to its content (anchored TOP only, so it floats centred like dunst did),
|
||||
on the OVERLAY layer above normal windows. Newest card on top. The surface is
|
||||
hidden whenever no cards are showing, so it never eats clicks on an empty screen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Gtk4LayerShell", "1.0")
|
||||
from gi.repository import Gtk # noqa: E402
|
||||
from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402
|
||||
|
||||
from notification import NotificationCard
|
||||
|
||||
MARGIN_TOP = 46
|
||||
MAX_VISIBLE = 6
|
||||
|
||||
|
||||
class BeaconWindow(Gtk.ApplicationWindow):
|
||||
def __init__(self, app: Gtk.Application,
|
||||
on_closed: Callable[[int, int], None]) -> None:
|
||||
super().__init__(application=app)
|
||||
self._on_closed = on_closed # (id, reason) -> emit NotificationClosed
|
||||
self._cards: dict[int, NotificationCard] = {}
|
||||
self._order: list[int] = [] # newest first
|
||||
self._tick_id: Optional[int] = None
|
||||
self._last_tick: Optional[float] = None
|
||||
|
||||
self.set_decorated(False)
|
||||
self.add_css_class("beacon-window")
|
||||
|
||||
self._stack = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||
self._stack.add_css_class("beacon-stack")
|
||||
self._stack.set_halign(Gtk.Align.CENTER)
|
||||
self._stack.set_valign(Gtk.Align.START)
|
||||
self.set_child(self._stack)
|
||||
|
||||
self._init_layer_shell()
|
||||
self.set_visible(False)
|
||||
|
||||
def _init_layer_shell(self) -> None:
|
||||
LayerShell.init_for_window(self)
|
||||
LayerShell.set_layer(self, LayerShell.Layer.OVERLAY)
|
||||
LayerShell.set_namespace(self, "beacon")
|
||||
LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.NONE)
|
||||
LayerShell.set_anchor(self, LayerShell.Edge.TOP, True)
|
||||
LayerShell.set_margin(self, LayerShell.Edge.TOP, MARGIN_TOP)
|
||||
LayerShell.set_exclusive_zone(self, 0)
|
||||
|
||||
# -- public: driven by the D-Bus server -----------------------------------
|
||||
def show_notification(self, nid: int, summary: str, body: str, urgency: int,
|
||||
icon: str, image_data, actions: list[str],
|
||||
on_action: Callable[[int, str], None]) -> None:
|
||||
if nid in self._cards: # replaces_id: swap in place
|
||||
self._drop_card(nid)
|
||||
|
||||
card = NotificationCard(nid, summary, body, urgency, icon, image_data,
|
||||
actions, on_action, self._card_dismissed)
|
||||
self._cards[nid] = card
|
||||
self._order.insert(0, nid)
|
||||
self._stack.prepend(card.widget)
|
||||
|
||||
# cap the stack: quietly expire the oldest beyond the limit
|
||||
while len(self._order) > MAX_VISIBLE:
|
||||
old = self._order[-1]
|
||||
self._card_dismissed(old, reason=4) # 4 = undefined/expired-by-limit
|
||||
|
||||
self.set_visible(True)
|
||||
self._ensure_tick()
|
||||
|
||||
def close_notification(self, nid: int, reason: int = 3) -> bool:
|
||||
"""Server-/user-requested close. reason 3 = closed by CloseNotification."""
|
||||
if nid not in self._cards:
|
||||
return False
|
||||
card = self._cards[nid]
|
||||
# play the dissolve; removal + the NotificationClosed signal follow
|
||||
card.dismiss()
|
||||
self._pending_reason[nid] = reason
|
||||
return True
|
||||
|
||||
# -- card lifecycle -------------------------------------------------------
|
||||
_pending_reason: dict[int, int] = {}
|
||||
|
||||
def _card_dismissed(self, nid: int, reason: int = 2) -> None:
|
||||
"""Called after a card's dissolve finishes (reason 2 = dismissed by user),
|
||||
or directly for cap-expiry. Removes it and signals the closure."""
|
||||
if nid not in self._cards:
|
||||
return
|
||||
reason = self._pending_reason.pop(nid, reason)
|
||||
self._drop_card(nid)
|
||||
self._on_closed(nid, reason)
|
||||
if not self._cards:
|
||||
self.set_visible(False)
|
||||
self._stop_tick()
|
||||
|
||||
def _drop_card(self, nid: int) -> None:
|
||||
card = self._cards.pop(nid, None)
|
||||
if card is not None:
|
||||
self._stack.remove(card.widget)
|
||||
if nid in self._order:
|
||||
self._order.remove(nid)
|
||||
|
||||
# -- animation tick -------------------------------------------------------
|
||||
def _on_tick(self, _widget, frame_clock) -> bool:
|
||||
now = frame_clock.get_frame_time() / 1_000_000
|
||||
dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick)
|
||||
self._last_tick = now
|
||||
for card in list(self._cards.values()):
|
||||
card.tick(dt)
|
||||
return True
|
||||
|
||||
def _ensure_tick(self) -> None:
|
||||
if self._tick_id is None:
|
||||
self._last_tick = None
|
||||
self._tick_id = self.add_tick_callback(self._on_tick)
|
||||
|
||||
def _stop_tick(self) -> None:
|
||||
if self._tick_id is not None:
|
||||
self.remove_tick_callback(self._tick_id)
|
||||
self._tick_id = None
|
||||
self._last_tick = None
|
||||
|
|
@ -107,12 +107,7 @@
|
|||
frame_width = 3
|
||||
|
||||
# Defines color of the frame around the notification window.
|
||||
# Bright "glow" violet (astro-menu's glow_violet) rather than the raw @violet —
|
||||
# reads as emitted light over the blurred glass, since dunst can't Cairo-glow.
|
||||
frame_color = "#8A5CFF"
|
||||
|
||||
# Progress-bar fill: violet -> accent gradient, same sweep as the menus.
|
||||
highlight = "#8A5CFF, #EB00A6"
|
||||
frame_color = "#5018DD"
|
||||
|
||||
# Size of gap to display between notifications - requires a compositor.
|
||||
# If value is greater than 0, separator_height will be ignored and a border
|
||||
|
|
@ -145,7 +140,7 @@
|
|||
|
||||
### Text ###
|
||||
|
||||
font = Agave Nerd Font Mono 11
|
||||
font = Agave Nerd Font 12
|
||||
|
||||
# The spacing between lines. If the height is smaller than the
|
||||
# font height, it will get raised to the font height.
|
||||
|
|
@ -184,12 +179,8 @@
|
|||
# %p progress value if set ([ 0%] to [100%]) or nothing
|
||||
# %n progress value if set without any extra characters
|
||||
# %% Literal %
|
||||
# Markup is allowed.
|
||||
# Cosmonaut Shell / astro-menu hologram idiom: the title as emitted magenta
|
||||
# light (letter-spaced, like the astro HUD headlines), then a glow-violet
|
||||
# "radio squiggle" divider (a sine-wave line, like a tuned signal), then the
|
||||
# body. urgency_critical overrides this to shift title+squiggle to the accent.
|
||||
format = "<b><span letter_spacing='1536'>%s</span></b>\n<span foreground='#8A5CFF' size='small' rise='2000'>∿∿∿∿∿∿∿∿∿∿∿∿∿∿</span>\n%b"
|
||||
# Markup is allowed
|
||||
format = "<b>%s</b>\n%b"
|
||||
|
||||
# Alignment of message text.
|
||||
# Possible values are "left", "center" and "right".
|
||||
|
|
@ -343,39 +334,29 @@
|
|||
per_monitor_dpi = false
|
||||
|
||||
|
||||
# astro-menu hologram idiom (see astro-menu/style/style.css):
|
||||
# emitted text = magenta #EB00A6 (glow_magenta), glass = violet-tinted + very
|
||||
# transparent so the compositor blur reads as projected glass, glow frame =
|
||||
# #8A5CFF (glow_violet), accent = #E40046. The astro panel gets its depth from
|
||||
# Cairo scanlines/glow dunst can't draw, so we lean on colour + blur instead.
|
||||
|
||||
[urgency_low]
|
||||
# IMPORTANT: colors have to be defined in quotation marks.
|
||||
# Otherwise the "#" and following would be interpreted as a comment.
|
||||
# Violet-tinted glass, ~63% opaque (…A0) so the blur reads as strong holo glass.
|
||||
background = "#1C1442A0"
|
||||
foreground = "#EB00A6"
|
||||
frame_color = "#8A5CFF"
|
||||
background = "#1a1a1a"
|
||||
foreground = "#5018dd"
|
||||
frame_color = "#5018dd"
|
||||
timeout = 10
|
||||
# Icon for notifications with low urgency
|
||||
default_icon = dialog-information
|
||||
|
||||
[urgency_normal]
|
||||
background = "#1C1442A0"
|
||||
foreground = "#EB00A6"
|
||||
frame_color = "#8A5CFF"
|
||||
background = "#1a1a1a"
|
||||
foreground = "#E40046"
|
||||
frame_color = "#5018dd"
|
||||
timeout = 10
|
||||
override_pause_level = 30
|
||||
# Icon for notifications with normal urgency
|
||||
default_icon = dialog-information
|
||||
|
||||
[urgency_critical]
|
||||
# Accent-tinted violet glass + accent glow frame; emitted magenta text stays.
|
||||
background = "#2E1030A0"
|
||||
foreground = "#EB00A6"
|
||||
frame_color = "#E40046"
|
||||
# Shift the title + squiggle to the accent so they match the critical frame.
|
||||
format = "<b><span letter_spacing='1536' foreground='#E40046'>%s</span></b>\n<span foreground='#E40046' size='small' rise='2000'>∿∿∿∿∿∿∿∿∿∿∿∿∿∿</span>\n%b"
|
||||
background = "#E40046"
|
||||
foreground = "#5018dd"
|
||||
frame_color = "#5018dd"
|
||||
timeout = 0
|
||||
override_pause_level = 60
|
||||
# Icon for notifications with critical urgency
|
||||
|
|
|
|||
|
|
@ -21,21 +21,17 @@ behind" the satellite.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Callable, Optional
|
||||
|
||||
import cairo
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
gi.require_version("Gsk", "4.0")
|
||||
gi.require_version("Graphene", "1.0")
|
||||
gi.require_version("Gtk4LayerShell", "1.0")
|
||||
from gi.repository import Gdk, GLib, Gtk # noqa: E402
|
||||
from gi.repository import Gdk, Graphene, Gsk, Gtk # noqa: E402
|
||||
from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402
|
||||
|
||||
import apps as apps_source
|
||||
|
|
@ -51,67 +47,44 @@ _VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255)
|
|||
|
||||
|
||||
class HorizonDock(Gtk.Window):
|
||||
# The dock is one big "orbit node" (a glowing violet planet) whose centre sits
|
||||
# below the screen, so only its top cap rises above the bottom edge. The three
|
||||
# item rows are concentric rings on that cap and their icons ride it like
|
||||
# satellites orbiting the node — the same visual language as orbit-menu.
|
||||
# DOCK_HEIGHT is derived per-monitor from the two shape constraints below.
|
||||
# A horizon-perspective dock: the orbits are foreshortened ellipse arcs that
|
||||
# all converge to a HORIZON_Y line near the top, so they read as concentric
|
||||
# rings lying on a plane receding to the horizon (à la a low-angle view of a
|
||||
# ringed planet). Icons ride the front (near) edge of each ring. Short.
|
||||
DOCK_HEIGHT = 210 # placeholder; recomputed per width (see _dock_height)
|
||||
DOCK_HEIGHT = 214
|
||||
PLANET_SIZE = 52
|
||||
ITEM_SPACING = 66.0 # center-to-center distance between planets in a row
|
||||
BASE_MARGIN = 16.0 # the horizon line sits this far above the bottom edge
|
||||
PERSPECTIVE_SQUASH = 0.19 # vertical foreshortening: low = flat, far-away horizon
|
||||
ARC_SAG = 34.0 # how far a row dips toward the screen edges vs. its center
|
||||
|
||||
ROW_ORDER = ["windows", "favorites", "apps"] # far -> near, top(horizon) -> bottom(front)
|
||||
# each ring's horizontal radius as a fraction of the screen width; nearer rings
|
||||
# (apps) are wider, farther rings (windows) narrower — that's the perspective.
|
||||
ROW_HALFWIDTH = {"windows": 0.17, "favorites": 0.245, "apps": 0.32}
|
||||
ROW_ORDER = ["windows", "favorites", "apps"] # back -> front, top -> bottom
|
||||
ROW_Y = {"windows": 46.0, "favorites": 112.0, "apps": 176.0}
|
||||
ROW_BAND_TOP = {"windows": 0.0, "favorites": 79.0, "apps": 144.0}
|
||||
ROW_TITLE = {"windows": "Open Windows", "favorites": "Favorites", "apps": "All Apps"}
|
||||
HOVER_BAND = 46.0 # how near the cursor must be to a ring to select it
|
||||
|
||||
GLYPH_FONT = "Agave Nerd Font Mono"
|
||||
# decorative glyphs sprinkled along the orbits between icons (nerd-font)
|
||||
ORBIT_GLYPHS = ["\uf444", "\uf10c", "\U000f0471", "\U000f1383", "\uf005"]
|
||||
# Two ornamental satellite rings framing the (innermost) windows orbit \u2014 one
|
||||
# just inside it, one just outside \u2014 as fractions of the windows ring radius.
|
||||
SAT_RING_FACTORS = (0.60, 1.20)
|
||||
|
||||
TRAY_SIZE = 40
|
||||
TRAY_MARGIN = 30.0
|
||||
SCROLL_SENSITIVITY = 0.9
|
||||
|
||||
REVEAL_DURATION = 0.28
|
||||
|
||||
def __init__(self, on_close: Optional[Callable[[], None]] = None, tray_enabled: bool = True,
|
||||
hologram_enabled: bool = True):
|
||||
super().__init__()
|
||||
self.add_css_class("horizon-dock-window")
|
||||
self._on_close = on_close
|
||||
self._tray_enabled = False # tray removed — the dock is a clean orbit node now
|
||||
self._tray_enabled = tray_enabled
|
||||
|
||||
self._apps = apps_source.AppSource()
|
||||
self._tray = None
|
||||
self._tray = TrayHost() if tray_enabled else None
|
||||
|
||||
self._width = self._monitor_width()
|
||||
self.DOCK_HEIGHT = self._dock_height() # per-monitor, shadows the class default
|
||||
self._scroll_offset = {row: 0.0 for row in self.ROW_ORDER}
|
||||
self._items: dict[str, list] = {row: [] for row in self.ROW_ORDER} # source objects
|
||||
self._widgets: dict[str, list[Gtk.Widget]] = {row: [] for row in self.ROW_ORDER}
|
||||
self._hovered_row: Optional[str] = None
|
||||
|
||||
self._reveal_progress = 0.0
|
||||
self._reveal_target = 0.0
|
||||
self._sat_time = 0.0
|
||||
self._last_tick: Optional[float] = None
|
||||
self._tick_id: Optional[int] = None
|
||||
|
||||
# cursor tracking for the mouse-reactive ornamental satellites
|
||||
self._mouse_x = self._width / 2
|
||||
self._mouse_y = 0.0
|
||||
self._mouse_sx = self._mouse_x # smoothed (eased) toward the raw position
|
||||
self._mouse_sy = self._mouse_y
|
||||
self._satellites = self._make_satellites()
|
||||
|
||||
self._init_layer_shell()
|
||||
|
||||
self._bg = Gtk.DrawingArea()
|
||||
|
|
@ -127,27 +100,9 @@ class HorizonDock(Gtk.Window):
|
|||
self._viewport.set_size_request(int(self._width), self.DOCK_HEIGHT)
|
||||
self._viewport.put(self._content, 0, 0)
|
||||
|
||||
# A Gtk.Fixed grows to the bounding box of ALL its children, and the dock
|
||||
# places every app button at an absolute position (many far off-screen,
|
||||
# each with an arc "dip" that grows unbounded with distance from centre).
|
||||
# So the viewport's natural size balloons to thousands of px in both axes,
|
||||
# and the layer-shell surface adopts that size — covering the whole screen
|
||||
# with an (input-grabbing) surface. Clip it: a base Overlay sized ONLY to a
|
||||
# DOCK_HEIGHT sizer, with the viewport added as a non-measured overlay
|
||||
# child and overflow hidden, pins the window to exactly (width x
|
||||
# DOCK_HEIGHT) no matter how large the Fixed inside gets.
|
||||
self._sizer = Gtk.DrawingArea()
|
||||
self._sizer.set_size_request(int(self._width), self.DOCK_HEIGHT)
|
||||
clip = Gtk.Overlay()
|
||||
clip.set_overflow(Gtk.Overflow.HIDDEN)
|
||||
clip.set_child(self._sizer)
|
||||
clip.add_overlay(self._viewport)
|
||||
clip.set_measure_overlay(self._viewport, False)
|
||||
|
||||
self._hologram = HologramOverlay(enabled=hologram_enabled, clip_func=self._holo_clip,
|
||||
fade_widget=self._content, intro_duration=2.4)
|
||||
self._hologram = HologramOverlay(enabled=hologram_enabled)
|
||||
overlay = Gtk.Overlay()
|
||||
overlay.set_child(clip)
|
||||
overlay.set_child(self._viewport)
|
||||
overlay.add_overlay(self._hologram.widget)
|
||||
self.set_child(overlay)
|
||||
|
||||
|
|
@ -178,68 +133,24 @@ class HorizonDock(Gtk.Window):
|
|||
LayerShell.set_anchor(self, edge, True)
|
||||
LayerShell.set_margin(self, edge, 0)
|
||||
|
||||
def _focused_gdk_monitor(self):
|
||||
"""The Gdk monitor for Hyprland's currently-focused output, so the dock
|
||||
opens on (and is sized to) whichever monitor you're on — not always
|
||||
monitor 0. Falls back to monitor 0 if the lookup fails."""
|
||||
display = Gdk.Display.get_default()
|
||||
if display is None:
|
||||
return None
|
||||
monitors = display.get_monitors()
|
||||
n = monitors.get_n_items() if monitors is not None else 0
|
||||
name = None
|
||||
try:
|
||||
out = subprocess.run(["hyprctl", "-j", "monitors"],
|
||||
capture_output=True, text=True, timeout=1).stdout
|
||||
for m in json.loads(out):
|
||||
if m.get("focused"):
|
||||
name = m.get("name")
|
||||
break
|
||||
except Exception:
|
||||
name = None
|
||||
if name is not None:
|
||||
for i in range(n):
|
||||
mon = monitors.get_item(i)
|
||||
if mon is not None and mon.get_connector() == name:
|
||||
return mon
|
||||
return monitors.get_item(0) if n else None
|
||||
|
||||
def _monitor_width(self) -> int:
|
||||
mon = self._focused_gdk_monitor()
|
||||
# Gdk logical width (already divided by the monitor's scale), which is the
|
||||
# coordinate space layer-shell / GTK lay out in — not hyprctl's raw px.
|
||||
display = Gdk.Display.get_default()
|
||||
monitors = display.get_monitors() if display is not None else None
|
||||
mon = monitors.get_item(0) if monitors is not None and monitors.get_n_items() else None
|
||||
return mon.get_geometry().width if mon is not None else 1920
|
||||
|
||||
# -- layout math (horizon perspective) ----------------------------------
|
||||
def _ring_rx(self, row: str) -> float:
|
||||
return self.ROW_HALFWIDTH[row] * self._width
|
||||
|
||||
def _ring_ry(self, row: str) -> float:
|
||||
return self._ring_rx(row) * self.PERSPECTIVE_SQUASH
|
||||
|
||||
def _dock_height(self) -> int:
|
||||
ry_max = max(self._ring_ry(r) for r in self.ROW_ORDER)
|
||||
return int(self.BASE_MARGIN + ry_max + self.PLANET_SIZE / 2 + 14)
|
||||
|
||||
def _base_y(self) -> float:
|
||||
"""The horizon line: near the BOTTOM of the dock. Orbits arch UP from it."""
|
||||
return self.DOCK_HEIGHT - self.BASE_MARGIN
|
||||
# -- layout math --------------------------------------------------------
|
||||
def _arc_radius(self) -> float:
|
||||
half_w = self._width / 2
|
||||
sag = self.ARC_SAG
|
||||
return (sag * sag + half_w * half_w) / (2 * sag)
|
||||
|
||||
def _row_y_at(self, row: str, x: float) -> float:
|
||||
"""y on the near edge of a ring's foreshortened ellipse — highest at the
|
||||
centre, curving back down to the horizon line toward the sides."""
|
||||
cx = self._width / 2
|
||||
rx, ry = self._ring_rx(row), self._ring_ry(row)
|
||||
dx = x - cx
|
||||
if abs(dx) >= rx:
|
||||
return self._base_y()
|
||||
return self._base_y() - ry * math.sqrt(max(1.0 - (dx / rx) ** 2, 0.0))
|
||||
|
||||
def _center_offset(self, row: str) -> float:
|
||||
"""The scroll offset that centres a row's items on the arc: the middle item
|
||||
lands at the centre, so a short row is a centred cluster and a long row
|
||||
fills the arc symmetrically (overflowing/fading equally on both sides)."""
|
||||
return max(0.0, (len(self._items[row]) - 1) / 2.0)
|
||||
row_y = self.ROW_Y[row]
|
||||
dx = x - self._width / 2
|
||||
r = self._arc_radius()
|
||||
dip = r - math.sqrt(max(r * r - dx * dx, 0.0))
|
||||
return row_y + dip
|
||||
|
||||
def _item_position(self, row: str, index: int) -> tuple[float, float]:
|
||||
slot = index - self._scroll_offset[row]
|
||||
|
|
@ -276,16 +187,17 @@ class HorizonDock(Gtk.Window):
|
|||
else:
|
||||
self._items[row] = self._apps.all_apps()
|
||||
|
||||
# start each orbit centred on the arc (re-centred whenever it's rebuilt)
|
||||
self._scroll_offset[row] = self._center_offset(row)
|
||||
self._clamp_scroll(row)
|
||||
|
||||
for i, item in enumerate(self._items[row]):
|
||||
btn = self._build_item_button(row, item)
|
||||
x, y = self._item_position(row, i)
|
||||
self._place_item(row, btn, x, y, put=True)
|
||||
self._content.put(btn, x - self.PLANET_SIZE / 2, y - self.PLANET_SIZE / 2)
|
||||
self._widgets[row].append(btn)
|
||||
|
||||
if row == "favorites":
|
||||
self._place_tray_satellite()
|
||||
|
||||
self._bg.queue_draw()
|
||||
|
||||
def _build_item_button(self, row: str, item) -> Gtk.Button:
|
||||
|
|
@ -308,54 +220,60 @@ class HorizonDock(Gtk.Window):
|
|||
self._apps.toggle_favorite(item)
|
||||
self._rebuild_row("favorites")
|
||||
|
||||
def _edge_fade(self, row: str, x: float) -> float:
|
||||
"""1.0 in the middle of an orbit, smoothly fading to 0 as an icon nears the
|
||||
ring's horizon extremity — so overflowing icons dissolve into the horizon
|
||||
at the sides instead of piling up / being hard-clipped off the edge."""
|
||||
rx = self._ring_rx(row)
|
||||
dx = abs(x - self._width / 2)
|
||||
start = rx * 0.68
|
||||
if dx <= start:
|
||||
return 1.0
|
||||
if dx >= rx:
|
||||
return 0.0
|
||||
t = (dx - start) / (rx - start)
|
||||
return 1.0 - t * t * (3 - 2 * t) # smoothstep down
|
||||
|
||||
def _place_item(self, row: str, w: Gtk.Widget, x: float, y: float, put: bool) -> None:
|
||||
fx, fy = x - self.PLANET_SIZE / 2, y - self.PLANET_SIZE / 2
|
||||
if put:
|
||||
self._content.put(w, fx, fy)
|
||||
else:
|
||||
self._content.move(w, fx, fy)
|
||||
fade = self._edge_fade(row, x)
|
||||
w.set_opacity(fade)
|
||||
w.set_can_target(fade > 0.05) # faded-out icons don't grab clicks
|
||||
w.set_sensitive(fade > 0.05)
|
||||
|
||||
def _reflow_row(self, row: str) -> None:
|
||||
for i, w in enumerate(self._widgets[row]):
|
||||
x, y = self._item_position(row, i)
|
||||
self._place_item(row, w, x, y, put=False)
|
||||
self._content.move(w, x - self.PLANET_SIZE / 2, y - self.PLANET_SIZE / 2)
|
||||
|
||||
def _rebuild_all(self) -> None:
|
||||
for row in self.ROW_ORDER:
|
||||
self._rebuild_row(row)
|
||||
|
||||
# -- hover / scroll routing -------------------------------------------------
|
||||
def _row_at(self, x: float, y: float) -> Optional[str]:
|
||||
"""The ring nearest the cursor (by vertical distance to its arc at x),
|
||||
within HOVER_BAND — so scrolling targets whichever orbit you're over."""
|
||||
best, best_d = None, self.HOVER_BAND
|
||||
for row in self.ROW_ORDER:
|
||||
d = abs(y - self._row_y_at(row, x))
|
||||
if d < best_d:
|
||||
best, best_d = row, d
|
||||
return best
|
||||
# -- tray satellite -------------------------------------------------------
|
||||
def _place_tray_satellite(self) -> None:
|
||||
if not self._tray_enabled:
|
||||
return
|
||||
if self._tray_satellite is not None and self._tray_satellite.get_parent() is not None:
|
||||
self._content.remove(self._tray_satellite)
|
||||
|
||||
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)
|
||||
x = self._width - self.TRAY_MARGIN
|
||||
y = self.ROW_Y["favorites"]
|
||||
btn = Gtk.MenuButton()
|
||||
btn.add_css_class("horizon-planet")
|
||||
btn.add_css_class("horizon-tray-satellite")
|
||||
btn.set_size_request(self.TRAY_SIZE, self.TRAY_SIZE)
|
||||
btn.set_tooltip_text("Tray")
|
||||
icon = Gtk.Image.new_from_icon_name("view-list-symbolic")
|
||||
icon.set_pixel_size(int(self.TRAY_SIZE * 0.5))
|
||||
btn.set_child(icon)
|
||||
|
||||
popover = Gtk.Popover()
|
||||
popover.add_css_class("horizon-tray-popover")
|
||||
popover.set_child(self._build_tray_box())
|
||||
btn.set_popover(popover)
|
||||
self._tray_popover = popover
|
||||
|
||||
self._content.put(btn, x - self.TRAY_SIZE / 2, y - self.TRAY_SIZE / 2)
|
||||
self._tray_satellite = btn
|
||||
|
||||
def _build_tray_box(self) -> Gtk.Widget:
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
box.add_css_class("horizon-tray-box")
|
||||
if self._tray is None:
|
||||
box.append(Gtk.Label(label="Tray disabled"))
|
||||
return box
|
||||
self._tray.build_into(box)
|
||||
return box
|
||||
|
||||
# -- hover / scroll routing -------------------------------------------------
|
||||
def _row_at_y(self, y: float) -> Optional[str]:
|
||||
for row in reversed(self.ROW_ORDER):
|
||||
if y >= self.ROW_BAND_TOP[row]:
|
||||
return row
|
||||
return None
|
||||
|
||||
def _on_motion(self, _ctrl, _x: float, y: float) -> None:
|
||||
row = self._row_at_y(y)
|
||||
if row != self._hovered_row:
|
||||
self._hovered_row = row
|
||||
self._bg.queue_draw()
|
||||
|
|
@ -374,228 +292,63 @@ class HorizonDock(Gtk.Window):
|
|||
self._reflow_row(row)
|
||||
return True
|
||||
|
||||
# -- background: the giant orbit node + its rings -----------------------
|
||||
# -- background: the horizon arcs + hover band --------------------------
|
||||
def _draw_background(self, _area, cr, width: float, height: float) -> None:
|
||||
self._draw_node(cr)
|
||||
for row in self.ROW_ORDER:
|
||||
self._draw_ring(cr, row, hovered=(row == self._hovered_row))
|
||||
self._draw_satellites(cr)
|
||||
self._draw_center_sphere(cr)
|
||||
self._draw_row_arc(cr, row, width)
|
||||
if self._hovered_row is not None:
|
||||
self._draw_hover_band(cr, self._hovered_row, width)
|
||||
|
||||
def _front_arc_path(self, cr, rx: float, ry: float, close_on_horizon: bool) -> None:
|
||||
"""Trace the near edge of a ring's foreshortened ellipse (arching UP) from
|
||||
the left horizon point across to the right one; optionally close it back
|
||||
along the horizon line to make a fillable semi-ellipse dome."""
|
||||
cx = self._width / 2
|
||||
base = self._base_y()
|
||||
steps = 72
|
||||
cr.move_to(cx - rx, base)
|
||||
def _draw_row_arc(self, cr, row: str, width: float) -> None:
|
||||
cr.save()
|
||||
cr.set_source_rgba(*_VIOLET, 0.16)
|
||||
cr.set_line_width(1.2)
|
||||
cr.move_to(0, self._row_y_at(row, 0))
|
||||
steps = 48
|
||||
for s in range(1, steps + 1):
|
||||
x = cx - rx + 2 * rx * s / steps
|
||||
dx = x - cx
|
||||
y = base - ry * math.sqrt(max(1.0 - (dx / rx) ** 2, 0.0))
|
||||
cr.line_to(x, y)
|
||||
if close_on_horizon:
|
||||
cr.close_path()
|
||||
|
||||
def _draw_node(self, cr) -> None:
|
||||
"""The 'planet' the orbits sit on: the widest ring's foreshortened dome
|
||||
filled with a vertical violet gradient (brighter at the arching near rim),
|
||||
with a soft glowing edge — a lit surface curving up from the horizon."""
|
||||
rx = self._ring_rx("apps") * 1.05
|
||||
ry = self._ring_ry("apps") * 1.05
|
||||
base = self._base_y()
|
||||
cr.save()
|
||||
self._front_arc_path(cr, rx, ry, close_on_horizon=True)
|
||||
grad = cairo.LinearGradient(0, base - ry, 0, base)
|
||||
grad.add_color_stop_rgba(0.0, *_VIOLET, 0.30)
|
||||
grad.add_color_stop_rgba(1.0, *_VIOLET, 0.06)
|
||||
cr.set_source(grad)
|
||||
cr.fill()
|
||||
for lw, a in ((12.0, 0.05), (6.0, 0.10), (2.2, 0.5)):
|
||||
self._front_arc_path(cr, rx, ry, close_on_horizon=False)
|
||||
cr.set_source_rgba(*_ACCENT, a)
|
||||
cr.set_line_width(lw)
|
||||
cr.stroke()
|
||||
cr.restore()
|
||||
|
||||
def _draw_ring(self, cr, row: str, hovered: bool) -> None:
|
||||
"""A faint guide arc along a row's foreshortened orbit; the hovered ring
|
||||
brightens to accent so you can see which orbit the scroll will move."""
|
||||
cr.save()
|
||||
if hovered:
|
||||
cr.set_source_rgba(*_ACCENT, 0.4)
|
||||
cr.set_line_width(2.0)
|
||||
else:
|
||||
cr.set_source_rgba(*_VIOLET, 0.22)
|
||||
cr.set_line_width(1.2)
|
||||
self._front_arc_path(cr, self._ring_rx(row), self._ring_ry(row), close_on_horizon=False)
|
||||
x = width * s / steps
|
||||
cr.line_to(x, self._row_y_at(row, x))
|
||||
cr.stroke()
|
||||
cr.restore()
|
||||
|
||||
def _holo_clip(self, cr, width: float, height: float) -> None:
|
||||
"""Path-setter handed to the hologram overlay: trace the node dome (widest
|
||||
ring, expanded to cover the icon tops) so the scanlines are clipped to the
|
||||
UI shape instead of painting a full-height rectangle above it."""
|
||||
pad = self.PLANET_SIZE / 2 + 12
|
||||
self._front_arc_path(cr, self._ring_rx("apps") + pad,
|
||||
self._ring_ry("apps") + pad, close_on_horizon=True)
|
||||
|
||||
def _draw_center_sphere(self, cr) -> None:
|
||||
"""The 'planet': a big holographic world rising from the horizon. Its top
|
||||
fills the clear central band (icon buttons float in front of it), while its
|
||||
bottom quarter sinks below the screen edge and is clipped away by the dock's
|
||||
overflow — so it reads as a huge planet, not a small bead. Translucent body
|
||||
(the desktop/scanlines show through) with a strong outer glow + rim."""
|
||||
cx = self._width / 2
|
||||
base = self._base_y()
|
||||
# Clear band below the lowest icon arch (windows); the readout lives here.
|
||||
band_top = base - self._ring_ry("windows") + self.PLANET_SIZE / 2
|
||||
bottom_edge = float(self.DOCK_HEIGHT) # dock surface bottom = screen bottom
|
||||
vis_h = bottom_edge - band_top # visible vertical extent of the planet
|
||||
# top anchored at band_top; radius sized so ~1/4 of the disc falls past the
|
||||
# bottom edge (top 3/4 == the visible band): vis_h = 1.5 * rad.
|
||||
rad = max(48.0, vis_h / 1.5)
|
||||
cy = band_top + rad
|
||||
def _draw_hover_band(self, cr, row: str, width: float) -> None:
|
||||
top = self.ROW_BAND_TOP[row]
|
||||
bottom_candidates = [self.ROW_BAND_TOP[r] for r in self.ROW_ORDER if self.ROW_BAND_TOP[r] > top]
|
||||
bottom = min(bottom_candidates) if bottom_candidates else self.DOCK_HEIGHT
|
||||
cr.save()
|
||||
# gentle breathing pulse so the planet reads as a live, radiant body
|
||||
pulse = 0.82 + 0.18 * math.sin(self._sat_time * 2.2)
|
||||
# wide outer bloom halo — the planet glows well beyond its own disc
|
||||
halo_r = rad * 1.9
|
||||
halo = cairo.RadialGradient(cx, cy, rad * 0.5, cx, cy, halo_r)
|
||||
halo.add_color_stop_rgba(0.0, *_ACCENT, 0.40 * pulse)
|
||||
halo.add_color_stop_rgba(0.45, *_VIOLET, 0.18 * pulse)
|
||||
halo.add_color_stop_rgba(1.0, *_VIOLET, 0.0)
|
||||
cr.arc(cx, cy, halo_r, 0, 2 * math.pi)
|
||||
cr.set_source(halo)
|
||||
cr.set_source_rgba(*_ACCENT, 0.05)
|
||||
cr.rectangle(0, top, width, bottom - top)
|
||||
cr.fill()
|
||||
# translucent holographic body: a soft see-through core fading to a nearly
|
||||
# transparent rim, so the blur/scanlines/desktop read through it
|
||||
grad = cairo.RadialGradient(cx - rad * 0.3, cy - rad * 0.35, rad * 0.05, cx, cy, rad)
|
||||
grad.add_color_stop_rgba(0.0, 1.0, 0.82, 0.98, 0.48)
|
||||
grad.add_color_stop_rgba(0.4, *_VIOLET, 0.36)
|
||||
grad.add_color_stop_rgba(1.0, *_VIOLET, 0.10)
|
||||
cr.arc(cx, cy, rad, 0, 2 * math.pi)
|
||||
cr.set_source(grad)
|
||||
cr.fill()
|
||||
# faint specular sheen (top-left) for a glassy 3D read
|
||||
spec = cairo.RadialGradient(cx - rad * 0.34, cy - rad * 0.42, 0,
|
||||
cx - rad * 0.34, cy - rad * 0.42, rad * 0.5)
|
||||
spec.add_color_stop_rgba(0.0, 1, 1, 1, 0.38 * pulse)
|
||||
spec.add_color_stop_rgba(1.0, 1, 1, 1, 0.0)
|
||||
cr.arc(cx, cy, rad, 0, 2 * math.pi)
|
||||
cr.set_source(spec)
|
||||
cr.fill()
|
||||
# strong, wide rim glow rings, capped by a crisp bright edge
|
||||
for lw, a in ((24.0, 0.06 * pulse), (14.0, 0.12 * pulse),
|
||||
(7.0, 0.22 * pulse), (2.6, 0.85)):
|
||||
cr.arc(cx, cy, rad, 0, 2 * math.pi)
|
||||
cr.set_source_rgba(*_ACCENT, a)
|
||||
cr.set_line_width(lw)
|
||||
cr.stroke()
|
||||
# date + time readout, centred in the VISIBLE band (not at cy, which is low)
|
||||
vis_cy = (band_top + bottom_edge) / 2
|
||||
cr.select_font_face(self.GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
|
||||
clock = time.strftime("%H:%M")
|
||||
cr.set_font_size(vis_h * 0.40)
|
||||
ext = cr.text_extents(clock)
|
||||
cr.move_to(cx - ext.width / 2 - ext.x_bearing,
|
||||
vis_cy - ext.height / 2 - ext.y_bearing - vis_h * 0.05)
|
||||
cr.set_source_rgba(0.98, 0.92, 0.99, 0.98)
|
||||
cr.show_text(clock)
|
||||
date = time.strftime("%a %d %b")
|
||||
cr.select_font_face(self.GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
|
||||
cr.set_font_size(vis_h * 0.20)
|
||||
ext2 = cr.text_extents(date)
|
||||
cr.move_to(cx - ext2.width / 2 - ext2.x_bearing, vis_cy + vis_h * 0.30)
|
||||
cr.set_source_rgba(*_ACCENT, 0.95)
|
||||
cr.show_text(date)
|
||||
cr.restore()
|
||||
|
||||
def _make_satellites(self) -> list[dict]:
|
||||
"""A handful of ornamental glyphs, split across the two satellite rings that
|
||||
frame the windows orbit (SAT_RING_FACTORS). Each ring gets a random 3–7 of
|
||||
them at fixed base angles, and each satellite's *motion source* is picked at
|
||||
random — it either leans TOWARD the cursor, or is driven by the cursor's X,
|
||||
or by the cursor's Y — so the pair of rings reads as a lively little swarm."""
|
||||
sats: list[dict] = []
|
||||
for fac in self.SAT_RING_FACTORS:
|
||||
for _ in range(random.randint(3, 7)):
|
||||
sats.append({
|
||||
"fac": fac,
|
||||
# base position on the visible (upper) half of the ring ellipse
|
||||
"angle": random.uniform(0.16 * math.pi, 0.84 * math.pi),
|
||||
"glyph": random.choice(self.ORBIT_GLYPHS),
|
||||
"size": random.uniform(11.0, 15.0),
|
||||
"source": random.choice(("toward", "mouseX", "mouseY")),
|
||||
"amp": random.uniform(16.0, 32.0),
|
||||
"wob_amp": random.uniform(1.5, 3.5),
|
||||
"wob_speed": random.uniform(1.0, 2.0),
|
||||
"phase": random.uniform(0.0, 2.0 * math.pi),
|
||||
})
|
||||
return sats
|
||||
# -- reveal animation -----------------------------------------------------
|
||||
def _reveal_transform(self, progress: float) -> Gsk.Transform:
|
||||
offset = (1.0 - progress) * self.DOCK_HEIGHT
|
||||
t = Gsk.Transform.new()
|
||||
t = t.translate(Graphene.Point().init(0, offset))
|
||||
return t
|
||||
|
||||
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
|
||||
base = self._base_y()
|
||||
win_rx = self._ring_rx("windows")
|
||||
mx, my = self._mouse_sx, self._mouse_sy
|
||||
half_w = max(1.0, self._width / 2)
|
||||
# faint guide arcs for the two rings
|
||||
cr.save()
|
||||
for fac in self.SAT_RING_FACTORS:
|
||||
self._front_arc_path(cr, win_rx * fac, win_rx * fac * self.PERSPECTIVE_SQUASH,
|
||||
close_on_horizon=False)
|
||||
cr.set_source_rgba(*_VIOLET, 0.14)
|
||||
cr.set_line_width(1.0)
|
||||
cr.stroke()
|
||||
cr.select_font_face(self.GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
|
||||
for s in self._satellites:
|
||||
rx = win_rx * s["fac"]
|
||||
ry = rx * self.PERSPECTIVE_SQUASH
|
||||
bx = cx + rx * math.cos(s["angle"])
|
||||
by = base - ry * math.sin(s["angle"])
|
||||
wob = s["wob_amp"] * math.sin(self._sat_time * s["wob_speed"] + s["phase"])
|
||||
ox = oy = 0.0
|
||||
if s["source"] == "toward":
|
||||
ddx, ddy = mx - bx, my - by
|
||||
d = math.hypot(ddx, ddy) or 1.0
|
||||
ox, oy = ddx / d * s["amp"], ddy / d * s["amp"] # lean toward the cursor
|
||||
elif s["source"] == "mouseX":
|
||||
ox = (mx - cx) / half_w * s["amp"] * 2.0 # driven by cursor X
|
||||
else: # mouseY
|
||||
oy = (my - base) / half_w * s["amp"] * 2.0 # driven by cursor Y
|
||||
x = bx + ox + wob
|
||||
y = by + oy + wob * 0.5
|
||||
border_w = min(1.0, x / 44.0, (self._width - x) / 44.0)
|
||||
if border_w <= 0.0:
|
||||
continue
|
||||
glyph = s["glyph"]
|
||||
cr.set_font_size(s["size"])
|
||||
ext = cr.text_extents(glyph)
|
||||
cr.move_to(x - ext.width / 2 - ext.x_bearing, y - ext.height / 2 - ext.y_bearing)
|
||||
cr.set_source_rgba(*_VIOLET, 0.7 * border_w)
|
||||
cr.show_text(glyph)
|
||||
cr.restore()
|
||||
|
||||
# -- animation loop -------------------------------------------------------
|
||||
# No slide/fade reveal: the dock simply appears and the hologram's
|
||||
# "materialise from static" intro (start_intro, below) is the whole opening
|
||||
# effect — matching every other Cosmonaut Shell surface. The tick only drives
|
||||
# the hologram animation while the dock is shown.
|
||||
def _on_tick(self, _widget, frame_clock) -> bool:
|
||||
now = frame_clock.get_frame_time() / 1_000_000
|
||||
dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick)
|
||||
self._last_tick = now
|
||||
self._sat_time += dt
|
||||
# ease the smoothed cursor toward the raw position (frame-rate independent)
|
||||
e = 1.0 - math.exp(-dt * 6.0)
|
||||
self._mouse_sx += (self._mouse_x - self._mouse_sx) * e
|
||||
self._mouse_sy += (self._mouse_y - self._mouse_sy) * e
|
||||
self._hologram.tick(dt)
|
||||
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
|
||||
|
||||
def _ensure_tick(self) -> None:
|
||||
|
|
@ -606,55 +359,22 @@ class HorizonDock(Gtk.Window):
|
|||
if self._tick_id is not None:
|
||||
self.remove_tick_callback(self._tick_id)
|
||||
self._tick_id = None
|
||||
# Reset the frame-time baseline so the next show's first tick has dt=0.
|
||||
# Otherwise a warm reopen sees dt = (time the dock was hidden), which would
|
||||
# blow past the whole intro in one frame and skip the materialise animation.
|
||||
self._last_tick = None
|
||||
|
||||
# -- external control ----------------------------------------------------
|
||||
def _apply_width(self, width: int) -> None:
|
||||
self._width = width
|
||||
self.DOCK_HEIGHT = self._dock_height()
|
||||
for w in (self._bg, self._content, self._viewport, self._sizer):
|
||||
w.set_size_request(int(width), self.DOCK_HEIGHT)
|
||||
|
||||
def show_dock(self) -> None:
|
||||
mon = self._focused_gdk_monitor()
|
||||
if mon is not None:
|
||||
LayerShell.set_monitor(self, mon)
|
||||
self._apply_width(mon.get_geometry().width)
|
||||
else:
|
||||
self._apply_width(self._monitor_width())
|
||||
self._width = self._monitor_width()
|
||||
self._rebuild_all()
|
||||
self._reveal_target = 1.0
|
||||
self.set_visible(True)
|
||||
self.present()
|
||||
self._ensure_tick()
|
||||
self._hologram.start_intro()
|
||||
if getattr(self, "_clock_timer_id", None) is None:
|
||||
self._clock_timer_id = GLib.timeout_add_seconds(15, self._refresh_clock)
|
||||
|
||||
def _refresh_clock(self) -> bool:
|
||||
if self.get_visible():
|
||||
self._bg.queue_draw() # repaint the planet's date/time
|
||||
return True
|
||||
self._clock_timer_id = None
|
||||
return False
|
||||
|
||||
def hide_dock(self) -> None:
|
||||
# Dissolve into static first, then hide via _finish_hide.
|
||||
if self._hologram.enabled and self._tick_id is not None:
|
||||
self._hologram.start_outro(self._finish_hide)
|
||||
else:
|
||||
self._finish_hide()
|
||||
|
||||
def _finish_hide(self) -> None:
|
||||
self.set_visible(False)
|
||||
self._stop_tick()
|
||||
if self._on_close:
|
||||
self._on_close()
|
||||
self._reveal_target = 0.0
|
||||
self._ensure_tick()
|
||||
|
||||
def toggle(self) -> None:
|
||||
if self.get_visible():
|
||||
if self.get_visible() and self._reveal_target == 1.0:
|
||||
self.hide_dock()
|
||||
else:
|
||||
self.show_dock()
|
||||
|
|
|
|||
|
|
@ -34,32 +34,16 @@ class HologramOverlay:
|
|||
SWEEP_PERIOD = 3.4 # seconds for one top-to-bottom pass
|
||||
SWEEP_HALF_HEIGHT = 90.0
|
||||
NOISE_COUNT = 95 # specs alive at once (each with its own lifetime)
|
||||
NOISE_COLORS = [_MAGENTA, _MAGENTA, _ACCENT] # magenta-biased specks
|
||||
NOISE_COLORS = [_MAGENTA, _ACCENT]
|
||||
NOISE_LIFETIME = (0.5, 1.4)
|
||||
NOISE_FADE_IN = 0.2
|
||||
NOISE_FADE_OUT = 0.35
|
||||
NOISE_ALPHA_RANGE = (0.10, 0.34)
|
||||
EDGE_FADE_X = 64.0 # smooth horizontal fade-out of the scanline field
|
||||
EDGE_FADE_Y = 40.0 # smooth vertical fade-out
|
||||
INTRO_DURATION = 1.5 # long, noisy 'materialise out of static' fade-in
|
||||
INTRO_STATIC = 1200 # static specks at the very start of the intro
|
||||
OUTRO_DURATION = 0.45 # quick reverse dissolve back into static on close
|
||||
|
||||
def __init__(self, enabled: bool = True, clip_func=None, fade_widget=None,
|
||||
intro_duration: float | None = None) -> None:
|
||||
def __init__(self, enabled: bool = True) -> None:
|
||||
self.enabled = enabled
|
||||
self._clip_func = clip_func # optional path-setter to clip the holo to the UI shape
|
||||
# widget whose opacity is ramped 0->1 during the intro so the UI genuinely
|
||||
# fades in (a gradual reveal), rather than a solid haze block popping on
|
||||
self._fade_widget = fade_widget
|
||||
if intro_duration is not None:
|
||||
self.INTRO_DURATION = intro_duration # per-instance override of the class default
|
||||
self._sat_time = 0.0
|
||||
self._particles: list[dict] = []
|
||||
self._intro_t: float | None = None # >=0 while the materialise intro plays
|
||||
self._outro_t: float | None = None # >=0 while the closing dissolve plays
|
||||
self._outro_done = None # callback fired when the dissolve finishes
|
||||
self._mask_cache: tuple | None = None # (w, h, pattern) edge-fade mask
|
||||
|
||||
self.widget = Gtk.DrawingArea()
|
||||
self.widget.set_can_target(False) # never steals clicks from content underneath
|
||||
|
|
@ -74,138 +58,12 @@ class HologramOverlay:
|
|||
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
|
||||
if self._fade_widget is not None:
|
||||
self._fade_widget.set_opacity(1.0)
|
||||
elif self._fade_widget is not None:
|
||||
p = self._intro_t / self.INTRO_DURATION
|
||||
self._fade_widget.set_opacity(p * p * (3 - 2 * p)) # smooth ramp 0->1
|
||||
if self._outro_t is not None:
|
||||
self._outro_t += dt
|
||||
po = min(1.0, self._outro_t / self.OUTRO_DURATION)
|
||||
if self._fade_widget is not None:
|
||||
self._fade_widget.set_opacity(1.0 - po * po * (3 - 2 * po)) # ramp 1->0
|
||||
if self._outro_t >= self.OUTRO_DURATION:
|
||||
done = self._outro_done
|
||||
self._outro_t = None
|
||||
self._outro_done = None
|
||||
if done is not None:
|
||||
done()
|
||||
self.widget.queue_draw()
|
||||
|
||||
def start_intro(self) -> None:
|
||||
"""Kick off the 'hologram materialising out of static' opening effect."""
|
||||
if self.enabled:
|
||||
self._outro_t = None # cancel any in-flight closing dissolve
|
||||
self._outro_done = None
|
||||
self._intro_t = 0.0
|
||||
if self._fade_widget is not None:
|
||||
self._fade_widget.set_opacity(0.0) # start hidden; tick() ramps it up
|
||||
|
||||
def start_outro(self, on_done) -> None:
|
||||
"""Play a quick reverse of the intro (content dissolving back into static),
|
||||
then call on_done to actually hide. If disabled, hide immediately."""
|
||||
if not self.enabled:
|
||||
on_done()
|
||||
return
|
||||
self._intro_t = None # cancel any in-flight opening intro
|
||||
self._outro_t = 0.0
|
||||
self._outro_done = on_done
|
||||
|
||||
# -- drawing --------------------------------------------------------------
|
||||
def _draw_frame(self, _area, cr, width: float, height: float) -> None:
|
||||
if not self.enabled or width <= 0 or height <= 0:
|
||||
return
|
||||
if self._clip_func is not None:
|
||||
cr.save()
|
||||
self._clip_func(cr, width, height) # clip the scanlines to the UI shape
|
||||
cr.clip()
|
||||
# Render the holo field into a group, then composite it back through a
|
||||
# soft edge-fade mask so the scanlines dissolve at the borders (reads far
|
||||
# more like a projected hologram than a hard-edged rectangle).
|
||||
cr.push_group()
|
||||
self._draw_content(cr, width, height)
|
||||
cr.pop_group_to_source()
|
||||
cr.mask(self._edge_fade_mask(width, height))
|
||||
if self._intro_t is not None:
|
||||
self._draw_intro(cr, width, height)
|
||||
elif self._outro_t is not None:
|
||||
self._draw_outro(cr, width, height)
|
||||
if self._clip_func is not None:
|
||||
cr.restore()
|
||||
|
||||
def _edge_fade_mask(self, width: float, height: float):
|
||||
key = (int(width), int(height))
|
||||
if self._mask_cache is not None and self._mask_cache[0] == key:
|
||||
return self._mask_cache[1]
|
||||
w, h = max(1, key[0]), max(1, key[1])
|
||||
surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h)
|
||||
m = cairo.Context(surf)
|
||||
m.set_source_rgba(0, 0, 0, 1)
|
||||
m.paint()
|
||||
m.set_operator(cairo.OPERATOR_DEST_OUT) # subtract edge gradients from the solid
|
||||
fx = min(self.EDGE_FADE_X, w / 2)
|
||||
fy = min(self.EDGE_FADE_Y, h / 2)
|
||||
def band(x0, y0, x1, y1, rx, ry, rw, rh):
|
||||
gr = cairo.LinearGradient(x0, y0, x1, y1)
|
||||
gr.add_color_stop_rgba(0.0, 0, 0, 0, 1)
|
||||
gr.add_color_stop_rgba(1.0, 0, 0, 0, 0)
|
||||
m.set_source(gr)
|
||||
m.rectangle(rx, ry, rw, rh)
|
||||
m.fill()
|
||||
band(0, 0, fx, 0, 0, 0, fx, h) # left
|
||||
band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right
|
||||
band(0, 0, 0, fy, 0, 0, w, fy) # top
|
||||
band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom
|
||||
pattern = cairo.SurfacePattern(surf)
|
||||
self._mask_cache = (key, pattern)
|
||||
return pattern
|
||||
|
||||
def _draw_intro(self, cr, width: float, height: float) -> None:
|
||||
p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION))
|
||||
strength = 1.0 - p
|
||||
# No solid veil block: the content itself fades in (see tick's fade_widget
|
||||
# ramp). Here we only lay churning static over it — dense at first, thinning
|
||||
# to nothing — so the UI resolves out of noise as it fades up.
|
||||
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
|
||||
count = int(self.INTRO_STATIC * (strength ** 0.5)) # dense, thinning to none
|
||||
for _ in range(count):
|
||||
x = random.uniform(0, width)
|
||||
y = random.uniform(0, height)
|
||||
col = random.choice(colors)
|
||||
cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength))
|
||||
sz = random.uniform(1.0, 2.8)
|
||||
cr.rectangle(x, y, sz, sz)
|
||||
cr.fill()
|
||||
band = p * height # a bright scan wiping down as it resolves
|
||||
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1)))
|
||||
cr.rectangle(0, band - 2.0, width, 4.0)
|
||||
cr.fill()
|
||||
|
||||
def _draw_outro(self, cr, width: float, height: float) -> None:
|
||||
# Reverse of the intro: the content is already fading back out (tick's
|
||||
# fade_widget ramp 1->0); here the static thickens from nothing as it goes,
|
||||
# so the panel dissolves into noise just before it vanishes.
|
||||
po = min(1.0, max(0.0, (self._outro_t or 0.0) / self.OUTRO_DURATION))
|
||||
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
|
||||
count = int(self.INTRO_STATIC * (po ** 0.5))
|
||||
for _ in range(count):
|
||||
x = random.uniform(0, width)
|
||||
y = random.uniform(0, height)
|
||||
col = random.choice(colors)
|
||||
cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * po))
|
||||
sz = random.uniform(1.0, 2.8)
|
||||
cr.rectangle(x, y, sz, sz)
|
||||
cr.fill()
|
||||
band = (1.0 - po) * height # scan wiping back up as it dissolves
|
||||
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1)))
|
||||
cr.rectangle(0, band - 2.0, width, 4.0)
|
||||
cr.fill()
|
||||
|
||||
def _draw_content(self, cr, width: float, height: float) -> None:
|
||||
r, g, b = _VIOLET
|
||||
|
||||
cr.save()
|
||||
|
|
|
|||
|
|
@ -9,16 +9,6 @@ window.horizon-dock-window {
|
|||
background: transparent;
|
||||
}
|
||||
|
||||
/* Neutralise the CyberQueer GTK theme's `* { background-color:#1a1a1a }`, which
|
||||
* otherwise paints EVERY node (the Overlay, the Fixed viewport/content, every
|
||||
* Box) an opaque dark slab. Blanking by node name is fragile — it missed
|
||||
* `overlay`/`fixed` here — so blank them all (this provider sits at USER+1,
|
||||
* above the theme). Planets / tray satellite / popover re-assert their fills via
|
||||
* the higher-specificity .class rules below. */
|
||||
* {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* The cyberqueer theme's `* { background-color: #1a1a1a }` paints DrawingArea
|
||||
* nodes specifically (see orbit-menu/style/style.css's .orbit-hologram fix for
|
||||
* the same discovery) — the background arc canvas is a DrawingArea sitting
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ hl.on("hyprland.start", function()
|
|||
hl.exec_cmd("systemctl --user start hyprpolkitagent")
|
||||
hl.exec_cmd("hyprsunset")
|
||||
hl.exec_cmd("nm-applet")
|
||||
hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprdrive/scripts/beacon-start.sh")
|
||||
hl.exec_cmd("dunst")
|
||||
hl.exec_cmd("[workspace special:magic silent] kitty")
|
||||
hl.exec_cmd("hyprctl setcursor Nordzy-cursors-lefthand 50")
|
||||
hl.exec_cmd("hyprpaper")
|
||||
|
|
|
|||
|
|
@ -259,16 +259,17 @@ hl.bind(mainMod .. " + SHIFT + ALT + j", hl.dsp.group.move_window("d"))
|
|||
-- astro-menu is astal-menu re-themed as a hologram info display — same
|
||||
-- functionality (Location/Weather/Bluetooth/Network quads, taskbar, app
|
||||
-- drawer), same binds it always had in hyprlua.
|
||||
-- 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 .. " + D", hl.dsp.exec_cmd("~/.config/scripts/astro-menu.sh toggle top"), { release = true })
|
||||
hl.bind(mainMod .. " + SHIFT + A", hl.dsp.exec_cmd("~/.config/scripts/astro-menu.sh appdrawer"))
|
||||
|
||||
--------------------
|
||||
---- HORIZON-DOCK --
|
||||
--------------------
|
||||
|
||||
-- horizon-dock gets the primary Super+D; astro-menu moved to Super+Shift+D.
|
||||
hl.bind(mainMod .. " + D", hl.dsp.exec_cmd("~/.config/scripts/horizon-dock.sh toggle"), { release = true })
|
||||
-- Super+D went to astro-menu (its "og" bind); horizon-dock gets its own key.
|
||||
-- (H is fully claimed by vim-style hjkl focus/move/resize across every
|
||||
-- modifier combo, so this uses D too, distinguished by Shift.)
|
||||
hl.bind(mainMod .. " + SHIFT + D", hl.dsp.exec_cmd("~/.config/scripts/horizon-dock.sh toggle"), { release = true })
|
||||
|
||||
--------------------
|
||||
---- STATION-BAR ---
|
||||
|
|
@ -277,10 +278,7 @@ hl.bind(mainMod .. " + D", hl.dsp.exec_cmd("~/.config/scripts/horizon-dock.sh to
|
|||
-- station-bar autostarts (see hypr/usr/autostart.lua) and is visible by
|
||||
-- default; this just lets you hide/show it (e.g. to reclaim the reserved
|
||||
-- top-edge space for a fullscreen app that doesn't already do so itself).
|
||||
-- Super+B toggles every monitor's bar; Super+Z toggles only the bar on the
|
||||
-- monitor you're currently focused on.
|
||||
hl.bind(mainMod .. " + B", hl.dsp.exec_cmd("~/.config/scripts/station-bar.sh toggle"), { release = true })
|
||||
hl.bind(mainMod .. " + Z", hl.dsp.exec_cmd("~/.config/scripts/station-bar.sh toggle here"), { release = true })
|
||||
|
||||
--------------------
|
||||
---- SCREENSHOT ----
|
||||
|
|
|
|||
|
|
@ -111,30 +111,6 @@ hl.window_rule({
|
|||
opacity = "0.5 0.05",
|
||||
})
|
||||
|
||||
-- Cosmonaut Shell layer-shell surfaces (astro-menu / orbit-menu / horizon-dock /
|
||||
-- station-bar) are translucent "holographic" panels. Blur what shows through their
|
||||
-- module fills for a frosted-glass look + text readability. ignore_alpha keeps the
|
||||
-- fully-transparent gaps between modules sharp (no blur there), so the panels still
|
||||
-- read as floating pieces rather than one big frosted slab. (Global blur is on but
|
||||
-- layer-shell surfaces only get it via an explicit per-namespace layerrule.)
|
||||
--
|
||||
-- no_anim opts them OUT of the global `layers = slide` open animation: they must
|
||||
-- NOT slide in from an edge — each instead "materialises out of static" via its
|
||||
-- own in-app hologram intro (start_intro), so the compositor should just map them
|
||||
-- in place and let that intro be the whole opening effect.
|
||||
for _, ns in ipairs({ "astro-menu", "orbit-menu", "horizon-dock", "station-bar" }) do
|
||||
hl.layer_rule({ name = "cosmoshell-blur-" .. ns, match = { namespace = ns },
|
||||
blur = true, ignore_alpha = 0.1, no_anim = true })
|
||||
end
|
||||
|
||||
-- beacon (our notification daemon, namespace "beacon") renders holographic
|
||||
-- notification cards. Blur what shows through their translucent violet glass so
|
||||
-- they read as the same frosted holo panels as the menus. no_anim: each card
|
||||
-- materialises out of static via its own in-app hologram intro, so the compositor
|
||||
-- should map the surface in place rather than slide it.
|
||||
hl.layer_rule({ name = "cosmoshell-blur-beacon", match = { namespace = "beacon" },
|
||||
blur = true, ignore_alpha = 0.1, no_anim = true })
|
||||
|
||||
-- smart gaps
|
||||
hl.workspace_rule({ workspace = "w[tv1]s[false]", gaps_out = 0, gaps_in = 0 })
|
||||
hl.workspace_rule({ workspace = "f[1]s[false]", gaps_out = 0, gaps_in = 0 })
|
||||
|
|
|
|||
|
|
@ -20,14 +20,8 @@ from lib.proc import fire
|
|||
|
||||
def launch(cmd: str) -> None:
|
||||
"""Run cmd via Hyprland's exec dispatcher — cmd may carry a `[tag ...]` /
|
||||
`[workspace ...]` window-rule-on-launch prefix, same as binds.lua.
|
||||
|
||||
NB: `hyprctl dispatch` evaluates its argument as Lua here (hyprlua), so this
|
||||
must go through `hl.dsp.exec_cmd(...)` — NOT the native `dispatch exec <cmd>`
|
||||
form, which Lua parses as a syntax error and silently drops (see binds.lua's
|
||||
`hl.dsp.exec_cmd("[tag +mixer] ...")`)."""
|
||||
esc = cmd.replace("\\", "\\\\").replace('"', '\\"')
|
||||
fire(["hyprctl", "dispatch", f'hl.dsp.exec_cmd("{esc}")'])
|
||||
`[workspace ...]` window-rule-on-launch prefix, same as binds.lua."""
|
||||
fire(["hyprctl", "dispatch", "exec", cmd])
|
||||
|
||||
|
||||
def hyprshutdown(post_cmd: str | None = None) -> None:
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ from gi.repository import Gio, GLib, Gtk # noqa: E402
|
|||
import config # noqa: E402
|
||||
import menu_tree # noqa: E402
|
||||
import theme # noqa: E402
|
||||
from orbit_menu import MenuItem, OrbitMenu # noqa: E402
|
||||
from orbit_menu import OrbitMenu # noqa: E402
|
||||
from paths import APP_ID # noqa: E402
|
||||
|
||||
|
||||
|
|
@ -106,56 +106,8 @@ class OrbitMenuApp(Gtk.Application):
|
|||
pass # resident instance: nothing to do on plain activate
|
||||
|
||||
|
||||
class DmenuApp(Gtk.Application):
|
||||
"""Standalone 'classic dmenu' mode: read newline-delimited items from stdin,
|
||||
show them as ring nodes, print the chosen one to stdout and exit 0 (exit 1 on
|
||||
cancel, printing nothing). NON_UNIQUE so it never routes to the resident daemon
|
||||
(which couldn't write to this caller's stdout) — it's its own throwaway process."""
|
||||
|
||||
def __init__(self, items: list[str]) -> None:
|
||||
super().__init__(application_id=APP_ID + ".dmenu",
|
||||
flags=Gio.ApplicationFlags.NON_UNIQUE)
|
||||
self._items = items
|
||||
self.result: str | None = None
|
||||
self.window: OrbitMenu | None = None
|
||||
|
||||
def do_activate(self) -> None:
|
||||
if self.window is not None: # do_activate can fire more than once
|
||||
return
|
||||
theme.load_css()
|
||||
children = [MenuItem(label=line, action=(lambda ln=line: self._pick(ln)))
|
||||
for line in self._items]
|
||||
root = MenuItem(label="", children=children)
|
||||
self.window = OrbitMenu(root, on_close=self._on_closed,
|
||||
satellites=config.satellites_enabled(),
|
||||
hologram=config.hologram_enabled())
|
||||
self.window.open_at_root()
|
||||
self.hold() # a hidden layer-shell window wouldn't keep the app alive alone
|
||||
|
||||
def _pick(self, line: str) -> None:
|
||||
self.result = line # the leaf's action; _close() then runs the outro + on_close
|
||||
|
||||
def _on_closed(self) -> None:
|
||||
if self.result is not None:
|
||||
sys.stdout.write(self.result + "\n")
|
||||
sys.stdout.flush()
|
||||
self.quit()
|
||||
|
||||
|
||||
def _run_dmenu() -> int:
|
||||
items = [ln.rstrip("\n") for ln in sys.stdin.readlines()]
|
||||
items = [ln for ln in items if ln != ""]
|
||||
if not items:
|
||||
return 1
|
||||
app = DmenuApp(items)
|
||||
app.run([sys.argv[0]]) # don't forward --dmenu into GApplication arg handling
|
||||
return 0 if app.result is not None else 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
GLib.set_prgname("orbit-menu")
|
||||
if "--dmenu" in sys.argv[1:]:
|
||||
return _run_dmenu()
|
||||
return OrbitMenuApp().run(sys.argv)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -136,16 +136,8 @@ class OrbitMenu(Gtk.Window):
|
|||
self._current_ring: Gtk.DrawingArea | None = None
|
||||
self._current_page: Gtk.Fixed | None = None
|
||||
self._transition: dict | None = None # active zoom/fade nav transition, if any
|
||||
self._intro: dict | None = None # active opening 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._intro: dict | None = None # active opening "materialize" animation, if any
|
||||
self._holo_particles: list[dict] = [] # persistent hologram noise specs, see _update_hologram_particles
|
||||
# Where the hologram glow mask is centred + how big, so it follows the
|
||||
# zoom into/out of a submenu instead of staying pinned at canvas centre.
|
||||
c0 = self.CANVAS_SIZE / 2
|
||||
self._holo_cx = c0
|
||||
self._holo_cy = c0
|
||||
self._holo_scale = 1.0
|
||||
|
||||
# satellite/mouse-parallax state (unused entirely if satellites=False)
|
||||
cx = cy = self.CANVAS_SIZE / 2
|
||||
|
|
@ -516,12 +508,6 @@ class OrbitMenu(Gtk.Window):
|
|||
# faded out completely by here — capped so it fully resolves to 0 before the
|
||||
# canvas edge (CANVAS_SIZE/2), not just before the corners
|
||||
_HOLO_MASK_OUTER = min(RADIUS + NODE_SIZE * 1.4, CANVAS_SIZE / 2 - 10)
|
||||
# "materialize out of static" opening intro — same treatment as the other
|
||||
# Cosmonaut Shell surfaces' shared hologram lib (haze + burst of static specks
|
||||
# that thins out, plus a bright scan band wiping through as it resolves).
|
||||
_HOLO_INTRO_DURATION = 2.4 # long, noisy fade-in
|
||||
_HOLO_INTRO_STATIC = 1300 # specks at the very start, thinning to 0
|
||||
_HOLO_OUTRO_DURATION = 0.45 # quick reverse dissolve back into static on close
|
||||
|
||||
def _draw_hologram_frame(self, _area, cr, width: float, height: float) -> None:
|
||||
if self._hologram_enabled:
|
||||
|
|
@ -532,90 +518,17 @@ class OrbitMenu(Gtk.Window):
|
|||
through a radial-gradient alpha mask centered on the canvas — full strength
|
||||
around the ring, soft-fading to nothing by _HOLO_MASK_OUTER, so it reads as
|
||||
one big glow over the menu instead of tinting the whole square canvas."""
|
||||
cx, cy = self._holo_cx, self._holo_cy
|
||||
cx, cy = width / 2, height / 2
|
||||
cr.push_group()
|
||||
self._draw_hologram_content(cr, width, height)
|
||||
pattern = cr.pop_group()
|
||||
|
||||
inner = self._HOLO_MASK_INNER * self._holo_scale
|
||||
outer = self._HOLO_MASK_OUTER * self._holo_scale
|
||||
mask = cairo.RadialGradient(cx, cy, inner, cx, cy, outer)
|
||||
mask = cairo.RadialGradient(cx, cy, self._HOLO_MASK_INNER, cx, cy, self._HOLO_MASK_OUTER)
|
||||
mask.add_color_stop_rgba(0.0, 1, 1, 1, 1)
|
||||
mask.add_color_stop_rgba(1.0, 1, 1, 1, 0)
|
||||
cr.set_source(pattern)
|
||||
cr.mask(mask)
|
||||
|
||||
if self._holo_intro_t is not None:
|
||||
self._draw_holo_intro(cr, cx, cy, outer)
|
||||
elif self._holo_outro_t is not None:
|
||||
self._draw_holo_outro(cr, cx, cy, outer)
|
||||
|
||||
def _draw_holo_intro(self, cr, cx: float, cy: float, outer: float) -> None:
|
||||
"""The opening burst: a violet haze thick with static specks that thins as
|
||||
the menu resolves, plus a bright scan band sweeping through — confined to
|
||||
the same radial footprint as the ambient hologram so the menu looks like it
|
||||
condenses out of the noise (not a full-screen static wash over the desktop)."""
|
||||
p = min(1.0, max(0.0, (self._holo_intro_t or 0.0) / self._HOLO_INTRO_DURATION))
|
||||
strength = 1.0 - p
|
||||
# a slightly wider footprint than the ambient glow so the static reads as a
|
||||
# cloud the menu emerges from, faded out by its edge with a radial mask
|
||||
reach = outer * 1.15
|
||||
cr.push_group()
|
||||
# No solid veil block: the ring itself fades in (viewport opacity ramp in
|
||||
# _on_tick). Here we only lay churning static over it — dense at first,
|
||||
# thinning to nothing — so the menu resolves out of noise as it fades up.
|
||||
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
|
||||
count = int(self._HOLO_INTRO_STATIC * (strength ** 0.5)) # dense, thinning to none
|
||||
for _ in range(count):
|
||||
x = cx + random.uniform(-reach, reach)
|
||||
y = cy + random.uniform(-reach, reach)
|
||||
cr.set_source_rgba(*random.choice(colors),
|
||||
random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength))
|
||||
sz = random.uniform(1.0, 2.8)
|
||||
cr.rectangle(x, y, sz, sz)
|
||||
cr.fill()
|
||||
# bright scan band wiping down through the footprint as it resolves
|
||||
band_y = cy - reach + p * (2 * reach)
|
||||
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1)))
|
||||
cr.rectangle(cx - reach, band_y - 1.5, 2 * reach, 3.0)
|
||||
cr.fill()
|
||||
pat = cr.pop_group()
|
||||
imask = cairo.RadialGradient(cx, cy, 0, cx, cy, reach)
|
||||
imask.add_color_stop_rgba(0.0, 1, 1, 1, 1)
|
||||
imask.add_color_stop_rgba(0.82, 1, 1, 1, 1)
|
||||
imask.add_color_stop_rgba(1.0, 1, 1, 1, 0)
|
||||
cr.set_source(pat)
|
||||
cr.mask(imask)
|
||||
|
||||
def _draw_holo_outro(self, cr, cx: float, cy: float, outer: float) -> None:
|
||||
"""Reverse of the intro: the ring is already fading back out (viewport
|
||||
opacity ramp in _on_tick); here the static thickens from nothing as it goes,
|
||||
so the menu dissolves into noise just before it vanishes."""
|
||||
po = min(1.0, max(0.0, (self._holo_outro_t or 0.0) / self._HOLO_OUTRO_DURATION))
|
||||
reach = outer * 1.15
|
||||
cr.push_group()
|
||||
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
|
||||
count = int(self._HOLO_INTRO_STATIC * (po ** 0.5)) # static grows as it dissolves
|
||||
for _ in range(count):
|
||||
x = cx + random.uniform(-reach, reach)
|
||||
y = cy + random.uniform(-reach, reach)
|
||||
cr.set_source_rgba(*random.choice(colors),
|
||||
random.uniform(0.25, 0.85) * (0.35 + 0.65 * po))
|
||||
sz = random.uniform(1.0, 2.8)
|
||||
cr.rectangle(x, y, sz, sz)
|
||||
cr.fill()
|
||||
band_y = cy + reach - po * (2 * reach) # scan wiping back up as it dissolves
|
||||
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1)))
|
||||
cr.rectangle(cx - reach, band_y - 1.5, 2 * reach, 3.0)
|
||||
cr.fill()
|
||||
pat = cr.pop_group()
|
||||
imask = cairo.RadialGradient(cx, cy, 0, cx, cy, reach)
|
||||
imask.add_color_stop_rgba(0.0, 1, 1, 1, 1)
|
||||
imask.add_color_stop_rgba(0.82, 1, 1, 1, 1)
|
||||
imask.add_color_stop_rgba(1.0, 1, 1, 1, 0)
|
||||
cr.set_source(pat)
|
||||
cr.mask(imask)
|
||||
|
||||
def _draw_hologram_content(self, cr, width: float, height: float) -> None:
|
||||
r, g, b = self._HOLO_TINT
|
||||
|
||||
|
|
@ -720,23 +633,6 @@ class OrbitMenu(Gtk.Window):
|
|||
if self._intro is not None:
|
||||
self._update_intro()
|
||||
|
||||
if self._holo_intro_t is not None:
|
||||
self._holo_intro_t += dt
|
||||
if self._holo_intro_t >= self._HOLO_INTRO_DURATION:
|
||||
self._holo_intro_t = None
|
||||
self._viewport.set_opacity(1.0)
|
||||
else:
|
||||
q = self._holo_intro_t / self._HOLO_INTRO_DURATION
|
||||
self._viewport.set_opacity(q * q * (3 - 2 * q)) # smooth fade in of the ring
|
||||
|
||||
if self._holo_outro_t is not None:
|
||||
self._holo_outro_t += dt
|
||||
po = min(1.0, self._holo_outro_t / self._HOLO_OUTRO_DURATION)
|
||||
self._viewport.set_opacity(1.0 - po * po * (3 - 2 * po)) # fade out the ring
|
||||
if self._holo_outro_t >= self._HOLO_OUTRO_DURATION:
|
||||
self._finish_close()
|
||||
return True # tick already removed by _finish_close
|
||||
|
||||
if self._hologram_enabled:
|
||||
self._hologram_area.queue_draw()
|
||||
return True # keep ticking every frame while the window is mapped
|
||||
|
|
@ -839,11 +735,6 @@ class OrbitMenu(Gtk.Window):
|
|||
slide_scale = slide_s0 + (slide_s1 - slide_s0) * eased
|
||||
self._viewport.set_child_transform(slide_page, self._slide_zoom_transform(tx, ty, slide_scale))
|
||||
|
||||
# Make the hologram glow mask ride the slide page (the ring that becomes /
|
||||
# leaves the full view), so the scanline vignette dives into the focal
|
||||
# point with the submenu instead of staying pinned at canvas centre.
|
||||
self._holo_cx, self._holo_cy, self._holo_scale = tx, ty, slide_scale
|
||||
|
||||
if zoom_page is not None:
|
||||
zoom_scale = zoom_s0 + (zoom_s1 - zoom_s0) * eased
|
||||
self._viewport.set_child_transform(zoom_page, self._focal_zoom_transform(fx, fy, zoom_scale))
|
||||
|
|
@ -869,9 +760,6 @@ class OrbitMenu(Gtk.Window):
|
|||
if tr["old"].get_parent() is not None:
|
||||
self._viewport.remove(tr["old"])
|
||||
self._transition = None
|
||||
# settled back on the canvas-centred full view — reset the glow mask
|
||||
c = self.CANVAS_SIZE / 2
|
||||
self._holo_cx, self._holo_cy, self._holo_scale = c, c, 1.0
|
||||
|
||||
# -- opening intro: nodes materialize from holo-noise ----------------------
|
||||
# Only plays when the menu transitions closed -> open (see open_at_root), never
|
||||
|
|
@ -977,23 +865,10 @@ class OrbitMenu(Gtk.Window):
|
|||
self._close()
|
||||
|
||||
def _close(self) -> None:
|
||||
# Play the closing dissolve (ring fades back into static), then hide via
|
||||
# _finish_close. If already dissolving, or the hologram is off / not
|
||||
# ticking, just hide immediately.
|
||||
if (self._hologram_enabled and self._tick_id is not None
|
||||
and self._holo_outro_t is None):
|
||||
self._holo_intro_t = None # cancel any in-flight opening intro
|
||||
self._holo_outro_t = 0.0
|
||||
else:
|
||||
self._finish_close()
|
||||
|
||||
def _finish_close(self) -> None:
|
||||
self.set_visible(False)
|
||||
if self._tick_id is not None:
|
||||
self.remove_tick_callback(self._tick_id)
|
||||
self._tick_id = None
|
||||
self._holo_outro_t = None
|
||||
self._viewport.set_opacity(1.0) # reset for the next open
|
||||
if self._on_close:
|
||||
self._on_close()
|
||||
|
||||
|
|
@ -1024,8 +899,6 @@ class OrbitMenu(Gtk.Window):
|
|||
self._current_page = None
|
||||
self._stack_origins.clear()
|
||||
self._intro = None
|
||||
self._holo_intro_t = None
|
||||
self._holo_outro_t = None
|
||||
self._root = root
|
||||
|
||||
def open_at_root(self) -> None:
|
||||
|
|
@ -1036,15 +909,7 @@ class OrbitMenu(Gtk.Window):
|
|||
on in-menu navigation."""
|
||||
self._show_path((), animate=False)
|
||||
self._start_intro(self._current_page)
|
||||
if self._hologram_enabled:
|
||||
self._holo_outro_t = None # cancel any in-flight closing dissolve
|
||||
self._holo_intro_t = 0.0 # play the "materialize out of static" burst
|
||||
self._viewport.set_opacity(0.0) # start hidden; _on_tick ramps it up
|
||||
self.set_visible(True)
|
||||
self.present()
|
||||
# Reset the frame-time baseline so the first tick's dt is 0. Otherwise a
|
||||
# WARM reopen would see dt = (time the menu was closed), advancing the intro
|
||||
# past its whole duration in one frame — the intro would silently not play.
|
||||
self._last_tick = None
|
||||
if self._tick_id is None:
|
||||
self._tick_id = self.add_tick_callback(self._on_tick)
|
||||
|
|
|
|||
|
|
@ -8,15 +8,6 @@ window.orbit-menu-window {
|
|||
background: transparent;
|
||||
}
|
||||
|
||||
/* Neutralise the CyberQueer theme's `* { background-color:#1a1a1a }` on EVERY
|
||||
* node — the per-level Gtk.Fixed pages and the _viewport Fixed have no css class,
|
||||
* so blanking by name (below) misses them and they paint an opaque dark slab over
|
||||
* the whole menu. This provider is at USER+1; the node gradients survive because
|
||||
* their .orbit-* rules out-specify `*`. */
|
||||
* {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* The cyberqueer theme's `* { background-color: #1a1a1a }` paints DrawingArea
|
||||
* nodes too (unlike plain Box/Frame containers, which this GTK build's renderer
|
||||
* leaves alone — see lib/border.py in astal-menu for the same observation). The
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Resident launcher for beacon, the Cosmonaut Shell notification daemon (replaces
|
||||
# dunst). Same LD_PRELOAD requirement and rationale as the rest of the suite's
|
||||
# start scripts: gtk4-layer-shell must load before libwayland-client, which isn't
|
||||
# guaranteed under PyGObject.
|
||||
|
||||
APP="${HOME}/.config/beacon/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" "$@"
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
#!/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 "$@"
|
||||
|
|
@ -3,13 +3,9 @@
|
|||
# resident daemon over D-Bus; if the daemon isn't running yet, starts it
|
||||
# first. Mirrors horizon-dock.sh/orbit-menu.sh's pattern.
|
||||
#
|
||||
# station-bar.sh -> --toggle every monitor's bar (default)
|
||||
# station-bar.sh show|hide -> --show / --hide every monitor's bar
|
||||
# station-bar.sh toggle here -> toggle ONLY the bar on the focused monitor
|
||||
# station-bar.sh show DP-1 -> act on a specific monitor by connector name
|
||||
#
|
||||
# The optional 2nd arg is a monitor target: empty = all, "here" = the currently
|
||||
# focused monitor (resolved via hyprctl), or a literal connector like "HDMI-A-1".
|
||||
# station-bar.sh -> --toggle (default)
|
||||
# station-bar.sh show -> --show
|
||||
# station-bar.sh hide -> --hide
|
||||
#
|
||||
# (No `set -e`: a non-zero `busctl` in the wait loop is expected and must not
|
||||
# abort the script before it forwards the verb.)
|
||||
|
|
@ -24,25 +20,19 @@ case "${1:-toggle}" in
|
|||
*) VERB="--toggle"; ACTION="toggle" ;;
|
||||
esac
|
||||
|
||||
TARGET="${2:-}"
|
||||
if [[ "$TARGET" == "here" ]]; then
|
||||
TARGET="$(hyprctl -j monitors 2>/dev/null \
|
||||
| python3 -c 'import sys,json; print(next((m["name"] for m in json.load(sys.stdin) if m.get("focused")), ""))' 2>/dev/null)"
|
||||
fi
|
||||
|
||||
registered() { busctl --user list 2>/dev/null | grep -q "$BUS"; }
|
||||
|
||||
if registered; then
|
||||
exec gdbus call --session --dest "$BUS" --object-path "$OBJ" \
|
||||
--method org.gtk.Actions.Activate "$ACTION" "[<'${TARGET}'>]" "{}" >/dev/null
|
||||
--method org.gtk.Actions.Activate "$ACTION" "[]" "{}" >/dev/null
|
||||
fi
|
||||
|
||||
"${HOME}/.config/scripts/station-bar-start.sh" >/dev/null 2>&1 &
|
||||
for _ in $(seq 1 25); do
|
||||
if registered; then
|
||||
exec "$0" "$1" "$TARGET"
|
||||
exec "$0" "$@"
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
exec python3 "$APP" "$VERB" "$TARGET"
|
||||
exec python3 "$APP" "$VERB"
|
||||
|
|
|
|||
|
|
@ -60,23 +60,20 @@ ICON_STATION = chr(0xf1383)
|
|||
ICON_SPACESHIP = chr(0xf135)
|
||||
ICON_WINDOW = chr(0xf10ac)
|
||||
ICON_VOLUME = chr(0xf04c3)
|
||||
ICON_FUEL = chr(0xf07c5) # md-fuel — the "battery" reads as a fuel tank
|
||||
|
||||
|
||||
class StationBar(Gtk.Window):
|
||||
BAR_HEIGHT = 44 # a touch larger — the elements are bare glowing glyphs now
|
||||
BAR_HEIGHT = 30
|
||||
ARC_SAG = 6.0 # shallow — "narrow curved space", not horizon-dock's deep dip
|
||||
|
||||
BATTERY_POLL_S = 20
|
||||
CLOCK_POLL_S = 1
|
||||
VOLUME_POLL_S = 3 # cheap fallback so hardware volume keys still reflect promptly
|
||||
|
||||
def __init__(self, hologram_enabled: bool = True, monitor=None) -> None:
|
||||
def __init__(self, hologram_enabled: bool = True) -> None:
|
||||
super().__init__()
|
||||
self.add_css_class("station-bar-window")
|
||||
|
||||
self._monitor = monitor
|
||||
self._mon_name = self._monitor_connector()
|
||||
self._width = self._monitor_width()
|
||||
self._workspaces: list[dict] = []
|
||||
self._active_ws: Optional[int] = None
|
||||
|
|
@ -85,13 +82,8 @@ class StationBar(Gtk.Window):
|
|||
|
||||
self._init_layer_shell()
|
||||
|
||||
# Width-adaptive: no hardcoded pixel width. The layer surface is stretched
|
||||
# to the monitor by the LEFT+RIGHT anchors, and _bg/content fill it via
|
||||
# hexpand, so the bar adapts to any resolution (and to hotplug) with no
|
||||
# captured width. Only the height is fixed.
|
||||
self._bg = Gtk.DrawingArea()
|
||||
self._bg.set_size_request(-1, self.BAR_HEIGHT)
|
||||
self._bg.set_hexpand(True)
|
||||
self._bg.set_size_request(int(self._width), self.BAR_HEIGHT)
|
||||
self._bg.set_draw_func(self._draw_background)
|
||||
self._bg.add_css_class("station-canvas")
|
||||
|
||||
|
|
@ -112,27 +104,17 @@ class StationBar(Gtk.Window):
|
|||
|
||||
content = Gtk.CenterBox(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
content.add_css_class("station-content")
|
||||
content.set_hexpand(True)
|
||||
content.set_valign(Gtk.Align.CENTER)
|
||||
content.set_size_request(int(self._width), self.BAR_HEIGHT)
|
||||
content.set_start_widget(self._left)
|
||||
content.set_center_widget(self._build_center())
|
||||
content.set_center_widget(self._center_label)
|
||||
content.set_end_widget(self._right)
|
||||
|
||||
self._hologram = HologramOverlay(enabled=hologram_enabled, fade_widget=content)
|
||||
self._hologram = HologramOverlay(enabled=hologram_enabled)
|
||||
|
||||
# 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
|
||||
# the layer surface to ~70px while the exclusive zone still only reserves
|
||||
# BAR_HEIGHT — the overflow then overlaps windows below. Pin the surface to
|
||||
# _bg's height (BAR_HEIGHT), don't let the overlay children grow it, and
|
||||
# clip: content is vertically centred within the thin strip.
|
||||
overlay = Gtk.Overlay()
|
||||
overlay.set_overflow(Gtk.Overflow.HIDDEN)
|
||||
overlay.set_child(self._bg)
|
||||
overlay.add_overlay(content)
|
||||
overlay.set_measure_overlay(content, False)
|
||||
overlay.add_overlay(self._hologram.widget)
|
||||
overlay.set_measure_overlay(self._hologram.widget, False)
|
||||
self.set_child(overlay)
|
||||
|
||||
self._build_left()
|
||||
|
|
@ -146,7 +128,7 @@ class StationBar(Gtk.Window):
|
|||
on_active_workspace=self._on_active_workspace,
|
||||
on_active_window=self._on_active_window,
|
||||
)
|
||||
self._active_ws = self._derive_active_ws()
|
||||
self._active_ws = hypr_ipc.initial_active_workspace_id()
|
||||
self._refresh_workspaces()
|
||||
self._on_active_window(hypr_ipc.initial_active_window_title())
|
||||
|
||||
|
|
@ -159,7 +141,6 @@ class StationBar(Gtk.Window):
|
|||
|
||||
self.set_visible(True)
|
||||
self._ensure_tick()
|
||||
self._hologram.start_intro() # materialise on first appearance too
|
||||
|
||||
# -- hologram animation loop -----------------------------------------------
|
||||
def _on_tick(self, _widget, frame_clock) -> bool:
|
||||
|
|
@ -185,47 +166,28 @@ class StationBar(Gtk.Window):
|
|||
LayerShell.set_layer(self, LayerShell.Layer.TOP)
|
||||
LayerShell.set_namespace(self, "station-bar")
|
||||
LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.NONE)
|
||||
if self._monitor is not None:
|
||||
LayerShell.set_monitor(self, self._monitor)
|
||||
for edge in (LayerShell.Edge.LEFT, LayerShell.Edge.RIGHT, LayerShell.Edge.TOP):
|
||||
LayerShell.set_anchor(self, edge, True)
|
||||
LayerShell.set_margin(self, edge, 0)
|
||||
LayerShell.set_exclusive_zone(self, self.BAR_HEIGHT)
|
||||
|
||||
def _monitor_connector(self) -> Optional[str]:
|
||||
"""The Gdk connector name (e.g. "DP-1"), which equals the Hyprland
|
||||
monitor `name` — so it keys this bar's workspaces/active out of the
|
||||
global lists. None (unknown monitor) falls back to showing everything."""
|
||||
mon = self._monitor
|
||||
if mon is None:
|
||||
return None
|
||||
try:
|
||||
return mon.get_connector()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _monitor_width(self) -> int:
|
||||
mon = self._monitor
|
||||
if mon is None:
|
||||
display = Gdk.Display.get_default()
|
||||
monitors = display.get_monitors() if display is not None else None
|
||||
mon = monitors.get_item(0) if monitors is not None and monitors.get_n_items() else None
|
||||
# Gdk logical width (already scaled), the coordinate space layer-shell
|
||||
# lays out in.
|
||||
display = Gdk.Display.get_default()
|
||||
monitors = display.get_monitors() if display is not None else None
|
||||
mon = monitors.get_item(0) if monitors is not None and monitors.get_n_items() else None
|
||||
return mon.get_geometry().width if mon is not None else 1920
|
||||
|
||||
# -- background curve -------------------------------------------------------
|
||||
def _arc_radius(self, width: float) -> float:
|
||||
half_w = width / 2
|
||||
def _arc_radius(self) -> float:
|
||||
half_w = self._width / 2
|
||||
sag = self.ARC_SAG
|
||||
return (sag * sag + half_w * half_w) / (2 * sag)
|
||||
|
||||
def _curve_dip_at(self, x: float, width: float) -> float:
|
||||
def _curve_dip_at(self, x: float) -> float:
|
||||
"""0 at the bar's center, rising to ARC_SAG at its edges — same shape as
|
||||
horizon-dock's row arcs, so the bar reads as one shallow shared curve.
|
||||
Uses the live allocated width so it adapts to any monitor."""
|
||||
dx = x - width / 2
|
||||
r = self._arc_radius(width)
|
||||
horizon-dock's row arcs, so the bar reads as one shallow shared curve."""
|
||||
dx = x - self._width / 2
|
||||
r = self._arc_radius()
|
||||
return r - math.sqrt(max(r * r - dx * dx, 0.0))
|
||||
|
||||
def _draw_background(self, _area, cr, width: float, height: float) -> None:
|
||||
|
|
@ -233,11 +195,11 @@ class StationBar(Gtk.Window):
|
|||
cr.save()
|
||||
cr.set_source_rgba(*_VIOLET, 0.22)
|
||||
cr.set_line_width(1.2)
|
||||
cr.move_to(0, base_y + self._curve_dip_at(0, width))
|
||||
cr.move_to(0, base_y + self._curve_dip_at(0))
|
||||
steps = 64
|
||||
for s in range(1, steps + 1):
|
||||
x = width * s / steps
|
||||
cr.line_to(x, base_y + self._curve_dip_at(x, width))
|
||||
cr.line_to(x, base_y + self._curve_dip_at(x))
|
||||
cr.stroke()
|
||||
cr.restore()
|
||||
|
||||
|
|
@ -245,26 +207,23 @@ class StationBar(Gtk.Window):
|
|||
def _build_left(self) -> None:
|
||||
self._battery_widget = Gtk.Label()
|
||||
self._battery_widget.add_css_class("station-badge")
|
||||
self._battery_widget.add_css_class("station-battery")
|
||||
self._battery_widget.set_visible(False)
|
||||
self._left.append(self._battery_widget)
|
||||
|
||||
orbit_btn = self._make_launcher(ICON_ORBIT, "Orbit Menu", "station-orbit",
|
||||
orbit_btn = self._make_launcher(ICON_ORBIT, "Orbit Menu",
|
||||
["bash", "-c", "$HOME/.config/scripts/orbit-menu.sh menu"])
|
||||
astro_btn = self._make_launcher(ICON_ASTRO, "Astro Menu", "station-astro",
|
||||
astro_btn = self._make_launcher(ICON_ASTRO, "Astro Menu",
|
||||
["bash", "-c", "$HOME/.config/scripts/astro-menu.sh toggle top"])
|
||||
self._left.append(orbit_btn)
|
||||
self._left.append(astro_btn)
|
||||
|
||||
self._ws_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||
self._ws_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
|
||||
self._ws_row.add_css_class("station-ws-row")
|
||||
self._left.append(self._ws_row)
|
||||
|
||||
def _make_launcher(self, icon: str, tooltip: str, css_class: str, argv: list[str]) -> Gtk.Button:
|
||||
def _make_launcher(self, icon: str, tooltip: str, argv: list[str]) -> Gtk.Button:
|
||||
btn = Gtk.Button(label=icon)
|
||||
btn.add_css_class("station-launcher")
|
||||
btn.add_css_class(css_class)
|
||||
btn.set_has_frame(False)
|
||||
btn.add_css_class("station-planet")
|
||||
btn.set_tooltip_text(tooltip)
|
||||
btn.connect("clicked", lambda *_a: subprocess.Popen(
|
||||
argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL))
|
||||
|
|
@ -272,20 +231,8 @@ class StationBar(Gtk.Window):
|
|||
|
||||
def _refresh_workspaces(self) -> None:
|
||||
self._workspaces = hypr_ipc.initial_workspaces()
|
||||
self._active_ws = self._derive_active_ws()
|
||||
self._rebuild_workspace_row()
|
||||
|
||||
def _derive_active_ws(self) -> Optional[int]:
|
||||
"""This monitor's own active workspace (from `hyprctl monitors`), not the
|
||||
globally-focused one — so each bar highlights the workspace its monitor is
|
||||
actually showing, even while another monitor holds keyboard focus."""
|
||||
if self._mon_name is None:
|
||||
return hypr_ipc.initial_active_workspace_id()
|
||||
for mon in hypr_ipc.initial_monitors():
|
||||
if mon.get("name") == self._mon_name:
|
||||
return (mon.get("activeWorkspace") or {}).get("id")
|
||||
return None
|
||||
|
||||
def _rebuild_workspace_row(self) -> None:
|
||||
child = self._ws_row.get_first_child()
|
||||
while child is not None:
|
||||
|
|
@ -297,90 +244,18 @@ class StationBar(Gtk.Window):
|
|||
wid = ws.get("id")
|
||||
if not isinstance(wid, int) or wid < 0:
|
||||
continue # special:* workspaces aren't shown as numbered stations
|
||||
if self._mon_name is not None and ws.get("monitor") != self._mon_name:
|
||||
continue # this bar only shows its own monitor's workspaces
|
||||
selected = wid == self._active_ws
|
||||
has_windows = ws.get("windows", 0) > 0
|
||||
# A populated workspace is a "station" (its glyph); an empty one shows
|
||||
# just the bare spaceship + number instead — never hidden. The focused
|
||||
# workspace is framed by the reticle, with the rocket prepended when
|
||||
# there's a station to dock at (skipped when empty, so no double ship).
|
||||
base = ICON_STATION if has_windows else ICON_SPACESHIP
|
||||
if selected:
|
||||
prefix = ICON_SPACESHIP if has_windows else ""
|
||||
label = f"-< {prefix}{base}{wid} >-"
|
||||
else:
|
||||
label = f"{base}{wid}"
|
||||
btn = Gtk.Button(label=label)
|
||||
btn = Gtk.Button(label=f"{ICON_SPACESHIP if selected else ICON_STATION}{wid}")
|
||||
btn.add_css_class("station-node")
|
||||
btn.set_has_frame(False)
|
||||
if selected:
|
||||
btn.add_css_class("station-node-active")
|
||||
btn.connect("clicked", lambda *_a, w=wid: hypr_ipc.focus_workspace(w))
|
||||
self._ws_row.append(btn)
|
||||
|
||||
def _on_active_workspace(self, _ws_id: int) -> None:
|
||||
# the `workspace` event only names the focused monitor's new workspace;
|
||||
# re-derive from `monitors` so this bar reflects its own monitor's active.
|
||||
self._active_ws = self._derive_active_ws()
|
||||
def _on_active_workspace(self, ws_id: int) -> None:
|
||||
self._active_ws = ws_id
|
||||
self._rebuild_workspace_row()
|
||||
|
||||
# -- center: focused window title in a clickable trapezoid ------------------
|
||||
def _build_center(self) -> Gtk.Widget:
|
||||
"""The window title inside a faint elongated trapezoid (wider at the top,
|
||||
which is left un-bordered) that opens the Astro Menu when clicked — a
|
||||
little 'viewport screen' framing the title, like a HUD readout panel."""
|
||||
trap = Gtk.DrawingArea()
|
||||
trap.set_draw_func(self._draw_center_trap)
|
||||
trap.set_can_target(False)
|
||||
|
||||
self._center_label.set_margin_start(30)
|
||||
self._center_label.set_margin_end(30)
|
||||
self._center_label.set_margin_top(3)
|
||||
self._center_label.set_margin_bottom(4)
|
||||
|
||||
overlay = Gtk.Overlay()
|
||||
overlay.add_css_class("station-center")
|
||||
# hug the title: size to the label and stay centred (no hexpand, or the
|
||||
# CenterBox would stretch the trapezoid to fill the whole middle zone).
|
||||
overlay.set_halign(Gtk.Align.CENTER)
|
||||
overlay.set_child(trap)
|
||||
overlay.add_overlay(self._center_label)
|
||||
overlay.set_measure_overlay(self._center_label, True)
|
||||
|
||||
click = Gtk.GestureClick()
|
||||
click.connect("released", lambda *_a: subprocess.Popen(
|
||||
["bash", "-c", "$HOME/.config/scripts/astro-menu.sh toggle top"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL))
|
||||
overlay.add_controller(click)
|
||||
cursor = Gdk.Cursor.new_from_name("pointer", None)
|
||||
if cursor is not None:
|
||||
overlay.set_cursor(cursor)
|
||||
overlay.set_tooltip_text("Astro Menu")
|
||||
return overlay
|
||||
|
||||
def _draw_center_trap(self, _area, cr, width: float, height: float) -> None:
|
||||
if width <= 0 or height <= 0:
|
||||
return
|
||||
inset = min(height * 0.85, width * 0.10) # bottom is the shorter side
|
||||
# faint translucent fill (top edge included, just not stroked)
|
||||
cr.move_to(0, 0)
|
||||
cr.line_to(width, 0)
|
||||
cr.line_to(width - inset, height)
|
||||
cr.line_to(inset, height)
|
||||
cr.close_path()
|
||||
cr.set_source_rgba(*_VIOLET, 0.14)
|
||||
cr.fill()
|
||||
# borders on left / bottom / right only — the (longer) top edge stays open
|
||||
for lw, a in ((5.0, 0.05), (1.6, 0.34)):
|
||||
cr.move_to(0, 0)
|
||||
cr.line_to(inset, height)
|
||||
cr.line_to(width - inset, height)
|
||||
cr.line_to(width, 0)
|
||||
cr.set_source_rgba(*_ACCENT, a)
|
||||
cr.set_line_width(lw)
|
||||
cr.stroke()
|
||||
|
||||
# -- center: focused window title -------------------------------------------
|
||||
def _on_active_window(self, title: str) -> None:
|
||||
text = title.strip()
|
||||
|
|
@ -402,8 +277,6 @@ class StationBar(Gtk.Window):
|
|||
|
||||
self._volume_btn = Gtk.Button(label=f"{ICON_VOLUME} --%")
|
||||
self._volume_btn.add_css_class("station-badge")
|
||||
self._volume_btn.add_css_class("station-volume")
|
||||
self._volume_btn.set_has_frame(False)
|
||||
self._volume_btn.connect("clicked", lambda *_a: (volume_source.toggle_mute(), self._refresh_volume()))
|
||||
scroll = Gtk.EventControllerScroll()
|
||||
scroll.set_flags(Gtk.EventControllerScrollFlags.VERTICAL)
|
||||
|
|
@ -413,7 +286,6 @@ class StationBar(Gtk.Window):
|
|||
|
||||
self._clock_label = Gtk.Label()
|
||||
self._clock_label.add_css_class("station-badge")
|
||||
self._clock_label.add_css_class("station-clock")
|
||||
self._right.append(self._clock_label)
|
||||
|
||||
def _draw_station_pictogram(self, _, cr, w: float, h: float) -> None:
|
||||
|
|
@ -442,11 +314,8 @@ class StationBar(Gtk.Window):
|
|||
if state is None:
|
||||
self._battery_widget.set_visible(False)
|
||||
else:
|
||||
# the ship runs on "fuel", not a battery — a fixed fuel-tank glyph in
|
||||
# place of the charge-level battery icon (only shown when a battery
|
||||
# actually exists, e.g. on a laptop).
|
||||
self._battery_widget.set_visible(True)
|
||||
self._battery_widget.set_label(f"{ICON_FUEL} {state.percent}%")
|
||||
self._battery_widget.set_label(f"{state.icon} {state.percent}%")
|
||||
return True
|
||||
|
||||
def _refresh_volume(self) -> bool:
|
||||
|
|
@ -475,16 +344,8 @@ class StationBar(Gtk.Window):
|
|||
self.set_visible(True)
|
||||
self.present()
|
||||
self._ensure_tick()
|
||||
self._hologram.start_intro()
|
||||
|
||||
def hide_bar(self) -> None:
|
||||
# Dissolve into static first, then release the exclusive zone + hide.
|
||||
if self._hologram.enabled and self._tick_id is not None:
|
||||
self._hologram.start_outro(self._finish_hide)
|
||||
else:
|
||||
self._finish_hide()
|
||||
|
||||
def _finish_hide(self) -> None:
|
||||
LayerShell.set_exclusive_zone(self, 0)
|
||||
self.set_visible(False)
|
||||
self._stop_tick()
|
||||
|
|
|
|||
|
|
@ -55,15 +55,6 @@ def initial_active_workspace_id() -> Optional[int]:
|
|||
return data.get("id") if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def initial_monitors() -> list[dict]:
|
||||
"""Each monitor dict carries `name` (the connector, e.g. "DP-1", matching a
|
||||
Gdk.Monitor's connector) and `activeWorkspace: {id, name}` — the workspace
|
||||
that monitor is currently showing, independent of which monitor has focus.
|
||||
Used to give each per-monitor bar its own workspace list and active-highlight."""
|
||||
data = _hyprctl_json("monitors")
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
def initial_active_window_title() -> str:
|
||||
data = _hyprctl_json("activewindow")
|
||||
return (data.get("title") or "") if isinstance(data, dict) else ""
|
||||
|
|
@ -131,13 +122,7 @@ class HyprIPC:
|
|||
self._on_active_workspace(int(payload))
|
||||
except ValueError:
|
||||
pass # special:* workspaces aren't shown as numbered stations
|
||||
elif event in ("createworkspace", "destroyworkspace", "moveworkspace",
|
||||
"openwindow", "closewindow", "movewindow", "movewindowv2",
|
||||
"focusedmon"):
|
||||
# any of these can change a workspace's window count OR which monitor
|
||||
# a workspace lives on (moveworkspace) OR a monitor's active workspace
|
||||
# (focusedmon) — all of which per-monitor bars (bar.py) filter on, so
|
||||
# re-fetch the list + re-derive each bar's active from `monitors`.
|
||||
elif event in ("createworkspace", "destroyworkspace", "moveworkspace"):
|
||||
self._on_workspaces_changed()
|
||||
elif event == "activewindow":
|
||||
_cls, _, title = payload.partition(",")
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import cairo
|
|||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
from gi.repository import GLib, Gtk # noqa: E402
|
||||
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)
|
||||
|
|
@ -33,39 +33,16 @@ class HologramOverlay:
|
|||
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_COLORS = [_MAGENTA, _ACCENT]
|
||||
NOISE_LIFETIME = (0.5, 1.4)
|
||||
NOISE_FADE_IN = 0.2
|
||||
NOISE_FADE_OUT = 0.35
|
||||
NOISE_ALPHA_RANGE = (0.10, 0.34)
|
||||
EDGE_FADE_X = 64.0 # smooth horizontal fade-out of the scanline field
|
||||
EDGE_FADE_Y = 40.0 # smooth vertical fade-out
|
||||
INTRO_DURATION = 1.5 # long, noisy 'materialise out of static' fade-in
|
||||
INTRO_STATIC = 1200 # static specks at the very start of the intro
|
||||
OUTRO_DURATION = 0.45 # quick reverse dissolve back into static on close
|
||||
|
||||
def __init__(self, enabled: bool = True, clip_func=None, fade_widget=None,
|
||||
intro_duration: float | None = None) -> None:
|
||||
def __init__(self, enabled: bool = True) -> None:
|
||||
self.enabled = enabled
|
||||
self._clip_func = clip_func # optional path-setter to clip the holo to the UI shape
|
||||
# widget whose opacity is ramped 0->1 during the intro so the UI genuinely
|
||||
# fades in (a gradual reveal), rather than a solid haze block popping on
|
||||
self._fade_widget = fade_widget
|
||||
if intro_duration is not None:
|
||||
self.INTRO_DURATION = intro_duration # per-instance override of the class default
|
||||
self._sat_time = 0.0
|
||||
self._particles: list[dict] = []
|
||||
self._intro_t: float | None = None # >=0 while the materialise intro plays
|
||||
self._outro_t: float | None = None # >=0 while the closing dissolve plays
|
||||
self._outro_done = None # callback fired when the dissolve finishes
|
||||
# Wall-clock safety net for the intro: the frame-clock tick that normally
|
||||
# advances the intro only fires while the compositor sends frame callbacks.
|
||||
# When the bar is (re)started into an otherwise-idle compositor those can
|
||||
# stall, freezing the intro at t=0 — i.e. the bar sits in permanent static
|
||||
# with content stuck at opacity 0. This timeout force-resolves the intro on
|
||||
# real time regardless, so content always appears.
|
||||
self._intro_deadline_id: int | None = None
|
||||
self._mask_cache: tuple | None = None # (w, h, pattern) edge-fade mask
|
||||
|
||||
self.widget = Gtk.DrawingArea()
|
||||
self.widget.set_can_target(False) # never steals clicks from content underneath
|
||||
|
|
@ -80,162 +57,12 @@ class HologramOverlay:
|
|||
if not self.enabled:
|
||||
return
|
||||
self._sat_time += dt
|
||||
if self._intro_t is not None:
|
||||
self._intro_t += dt
|
||||
if self._intro_t >= self.INTRO_DURATION:
|
||||
self._finish_intro()
|
||||
elif self._fade_widget is not None:
|
||||
p = self._intro_t / self.INTRO_DURATION
|
||||
self._fade_widget.set_opacity(p * p * (3 - 2 * p)) # smooth ramp 0->1
|
||||
if self._outro_t is not None:
|
||||
self._outro_t += dt
|
||||
po = min(1.0, self._outro_t / self.OUTRO_DURATION)
|
||||
if self._fade_widget is not None:
|
||||
self._fade_widget.set_opacity(1.0 - po * po * (3 - 2 * po)) # ramp 1->0
|
||||
if self._outro_t >= self.OUTRO_DURATION:
|
||||
done = self._outro_done
|
||||
self._outro_t = None
|
||||
self._outro_done = None
|
||||
if done is not None:
|
||||
done()
|
||||
self.widget.queue_draw()
|
||||
|
||||
def _finish_intro(self) -> None:
|
||||
"""End the intro: clear its state, cancel the safety timeout, and make the
|
||||
content fully opaque. Safe to call from either the tick or the timeout."""
|
||||
self._intro_t = None
|
||||
if self._intro_deadline_id is not None:
|
||||
GLib.source_remove(self._intro_deadline_id)
|
||||
self._intro_deadline_id = None
|
||||
if self._fade_widget is not None:
|
||||
self._fade_widget.set_opacity(1.0)
|
||||
self.widget.queue_draw() # in case the frame clock has stalled
|
||||
|
||||
def start_intro(self) -> None:
|
||||
"""Kick off the 'hologram materialising out of static' opening effect."""
|
||||
if self.enabled:
|
||||
self._outro_t = None # cancel any in-flight closing dissolve
|
||||
self._outro_done = None
|
||||
self._intro_t = 0.0
|
||||
if self._fade_widget is not None:
|
||||
self._fade_widget.set_opacity(0.0) # start hidden; tick() ramps it up
|
||||
# Wall-clock backstop: if frame callbacks stall, force the intro done a
|
||||
# little past its nominal length so content can never get stuck hidden.
|
||||
if self._intro_deadline_id is not None:
|
||||
GLib.source_remove(self._intro_deadline_id)
|
||||
self._intro_deadline_id = GLib.timeout_add(
|
||||
int(self.INTRO_DURATION * 1000) + 150, self._on_intro_deadline)
|
||||
|
||||
def _on_intro_deadline(self) -> bool:
|
||||
self._intro_deadline_id = None
|
||||
if self._intro_t is not None:
|
||||
self._finish_intro()
|
||||
return False # one-shot
|
||||
|
||||
def start_outro(self, on_done) -> None:
|
||||
"""Play a quick reverse of the intro (content dissolving back into static),
|
||||
then call on_done to actually hide. If disabled, hide immediately."""
|
||||
if not self.enabled:
|
||||
on_done()
|
||||
return
|
||||
self._intro_t = None # cancel any in-flight opening intro
|
||||
if self._intro_deadline_id is not None:
|
||||
GLib.source_remove(self._intro_deadline_id)
|
||||
self._intro_deadline_id = None
|
||||
self._outro_t = 0.0
|
||||
self._outro_done = on_done
|
||||
|
||||
# -- drawing --------------------------------------------------------------
|
||||
def _draw_frame(self, _area, cr, width: float, height: float) -> None:
|
||||
if not self.enabled or width <= 0 or height <= 0:
|
||||
return
|
||||
if self._clip_func is not None:
|
||||
cr.save()
|
||||
self._clip_func(cr, width, height) # clip the scanlines to the UI shape
|
||||
cr.clip()
|
||||
# Render the holo field into a group, then composite it back through a
|
||||
# soft edge-fade mask so the scanlines dissolve at the borders (reads far
|
||||
# more like a projected hologram than a hard-edged rectangle).
|
||||
cr.push_group()
|
||||
self._draw_content(cr, width, height)
|
||||
cr.pop_group_to_source()
|
||||
cr.mask(self._edge_fade_mask(width, height))
|
||||
if self._intro_t is not None:
|
||||
self._draw_intro(cr, width, height)
|
||||
elif self._outro_t is not None:
|
||||
self._draw_outro(cr, width, height)
|
||||
if self._clip_func is not None:
|
||||
cr.restore()
|
||||
|
||||
def _edge_fade_mask(self, width: float, height: float):
|
||||
key = (int(width), int(height))
|
||||
if self._mask_cache is not None and self._mask_cache[0] == key:
|
||||
return self._mask_cache[1]
|
||||
w, h = max(1, key[0]), max(1, key[1])
|
||||
surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h)
|
||||
m = cairo.Context(surf)
|
||||
m.set_source_rgba(0, 0, 0, 1)
|
||||
m.paint()
|
||||
m.set_operator(cairo.OPERATOR_DEST_OUT) # subtract edge gradients from the solid
|
||||
fx = min(self.EDGE_FADE_X, w / 2)
|
||||
fy = min(self.EDGE_FADE_Y, h / 2)
|
||||
def band(x0, y0, x1, y1, rx, ry, rw, rh):
|
||||
gr = cairo.LinearGradient(x0, y0, x1, y1)
|
||||
gr.add_color_stop_rgba(0.0, 0, 0, 0, 1)
|
||||
gr.add_color_stop_rgba(1.0, 0, 0, 0, 0)
|
||||
m.set_source(gr)
|
||||
m.rectangle(rx, ry, rw, rh)
|
||||
m.fill()
|
||||
band(0, 0, fx, 0, 0, 0, fx, h) # left
|
||||
band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right
|
||||
band(0, 0, 0, fy, 0, 0, w, fy) # top
|
||||
band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom
|
||||
pattern = cairo.SurfacePattern(surf)
|
||||
self._mask_cache = (key, pattern)
|
||||
return pattern
|
||||
|
||||
def _draw_intro(self, cr, width: float, height: float) -> None:
|
||||
p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION))
|
||||
strength = 1.0 - p
|
||||
# No solid veil block: the content itself fades in (see tick's fade_widget
|
||||
# ramp). Here we only lay churning static over it — dense at first, thinning
|
||||
# to nothing — so the UI resolves out of noise as it fades up.
|
||||
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
|
||||
count = int(self.INTRO_STATIC * (strength ** 0.5)) # dense, thinning to none
|
||||
for _ in range(count):
|
||||
x = random.uniform(0, width)
|
||||
y = random.uniform(0, height)
|
||||
col = random.choice(colors)
|
||||
cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength))
|
||||
sz = random.uniform(1.0, 2.8)
|
||||
cr.rectangle(x, y, sz, sz)
|
||||
cr.fill()
|
||||
band = p * height # a bright scan wiping down as it resolves
|
||||
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1)))
|
||||
cr.rectangle(0, band - 2.0, width, 4.0)
|
||||
cr.fill()
|
||||
|
||||
def _draw_outro(self, cr, width: float, height: float) -> None:
|
||||
# Reverse of the intro: the content is already fading back out (tick's
|
||||
# fade_widget ramp 1->0); here the static thickens from nothing as it goes,
|
||||
# so the panel dissolves into noise just before it vanishes.
|
||||
po = min(1.0, max(0.0, (self._outro_t or 0.0) / self.OUTRO_DURATION))
|
||||
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
|
||||
count = int(self.INTRO_STATIC * (po ** 0.5))
|
||||
for _ in range(count):
|
||||
x = random.uniform(0, width)
|
||||
y = random.uniform(0, height)
|
||||
col = random.choice(colors)
|
||||
cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * po))
|
||||
sz = random.uniform(1.0, 2.8)
|
||||
cr.rectangle(x, y, sz, sz)
|
||||
cr.fill()
|
||||
band = (1.0 - po) * height # scan wiping back up as it dissolves
|
||||
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1)))
|
||||
cr.rectangle(0, band - 2.0, width, 4.0)
|
||||
cr.fill()
|
||||
|
||||
def _draw_content(self, cr, width: float, height: float) -> None:
|
||||
r, g, b = _VIOLET
|
||||
|
||||
cr.save()
|
||||
|
|
|
|||
|
|
@ -28,108 +28,55 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|||
import gi # noqa: E402
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
from gi.repository import Gdk, Gio, GLib, Gtk # noqa: E402
|
||||
from gi.repository import Gio, GLib, Gtk # noqa: E402
|
||||
|
||||
import config # noqa: E402
|
||||
import theme # noqa: E402
|
||||
from bar import StationBar # noqa: E402
|
||||
from paths import APP_ID # noqa: E402
|
||||
from sni_watcher import SniWatcher # noqa: E402
|
||||
|
||||
|
||||
class StationBarApp(Gtk.Application):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(application_id=APP_ID,
|
||||
flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE)
|
||||
# One bar per monitor, keyed by connector name so hotplugged monitors can
|
||||
# be matched on the "items-changed" signal.
|
||||
self.bars: dict[str, StationBar] = {}
|
||||
self._hologram = True
|
||||
self._visible = True
|
||||
self._sni_watcher: SniWatcher | None = None
|
||||
self.window: StationBar | None = None
|
||||
|
||||
def do_startup(self) -> None:
|
||||
Gtk.Application.do_startup(self)
|
||||
theme.load_css()
|
||||
# Provide the StatusNotifierWatcher ourselves (no DE does it here), so tray
|
||||
# apps have something to register with and the bars' tray fills up.
|
||||
self._sni_watcher = SniWatcher()
|
||||
self._hologram = config.hologram_enabled()
|
||||
display = Gdk.Display.get_default()
|
||||
if display is not None:
|
||||
monitors = display.get_monitors()
|
||||
monitors.connect("items-changed", lambda *_a: self._sync_bars())
|
||||
self._sync_bars()
|
||||
self.window = StationBar(hologram_enabled=config.hologram_enabled())
|
||||
self._register_actions()
|
||||
self.hold() # stay alive even while the bars are briefly hidden
|
||||
|
||||
def _sync_bars(self) -> None:
|
||||
"""Build a bar for every current monitor (and drop bars for monitors that
|
||||
went away) — a single layer-shell window only ever lands on one output, so
|
||||
a per-monitor bar is the only way to get one on every screen."""
|
||||
display = Gdk.Display.get_default()
|
||||
if display is None:
|
||||
return
|
||||
monitors = display.get_monitors()
|
||||
seen: set[str] = set()
|
||||
for i in range(monitors.get_n_items()):
|
||||
mon = monitors.get_item(i)
|
||||
conn = (mon.get_connector() if mon is not None else None) or f"mon{i}"
|
||||
seen.add(conn)
|
||||
if conn not in self.bars:
|
||||
bar = StationBar(hologram_enabled=self._hologram, monitor=mon)
|
||||
if not self._visible:
|
||||
bar.hide_bar()
|
||||
self.bars[conn] = bar
|
||||
for conn in list(self.bars):
|
||||
if conn not in seen:
|
||||
self.bars.pop(conn).destroy()
|
||||
|
||||
def _apply(self, verb: str, target: str = "") -> None:
|
||||
"""target="" acts on every monitor's bar (global); a connector name acts
|
||||
only on that monitor's bar (Super+Z toggles the bar you're looking at)."""
|
||||
if target:
|
||||
bars = [self.bars[target]] if target in self.bars else []
|
||||
else:
|
||||
bars = list(self.bars.values())
|
||||
if verb == "show":
|
||||
self._visible = True
|
||||
elif verb == "hide":
|
||||
self._visible = False
|
||||
elif verb == "toggle":
|
||||
self._visible = not self._visible
|
||||
for bar in bars:
|
||||
if verb == "show":
|
||||
bar.show_bar()
|
||||
elif verb == "hide":
|
||||
bar.hide_bar()
|
||||
elif verb == "toggle":
|
||||
# per-monitor toggle keys off that bar's own state; the global
|
||||
# toggle drives every bar from the shared _visible flag.
|
||||
bar.toggle() if target else (bar.show_bar() if self._visible else bar.hide_bar())
|
||||
self.hold() # stay alive even while the bar is briefly hidden
|
||||
|
||||
def _register_actions(self) -> None:
|
||||
stype = GLib.VariantType.new("s")
|
||||
for name in ("show", "hide", "toggle"):
|
||||
action = Gio.SimpleAction.new(name, stype)
|
||||
action.connect(
|
||||
"activate",
|
||||
lambda _a, p, n=name: self._apply(n, p.get_string() if p is not None else ""),
|
||||
)
|
||||
def add(name: str, callback) -> None:
|
||||
action = Gio.SimpleAction.new(name, None)
|
||||
action.connect("activate", callback)
|
||||
self.add_action(action)
|
||||
|
||||
def guarded(fn):
|
||||
def wrapper(*_a) -> None:
|
||||
assert self.window is not None
|
||||
fn(self.window)
|
||||
return wrapper
|
||||
|
||||
add("show", guarded(lambda w: w.show_bar()))
|
||||
add("hide", guarded(lambda w: w.hide_bar()))
|
||||
add("toggle", guarded(lambda w: w.toggle()))
|
||||
|
||||
def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int:
|
||||
args = list(cmdline.get_arguments()[1:])
|
||||
verb = args[0] if args else "--daemon"
|
||||
target = args[1] if len(args) > 1 else ""
|
||||
if self.window is None:
|
||||
return 0
|
||||
if verb == "--show":
|
||||
self._apply("show", target)
|
||||
self.window.show_bar()
|
||||
elif verb == "--hide":
|
||||
self._apply("hide", target)
|
||||
self.window.hide_bar()
|
||||
elif verb == "--toggle":
|
||||
self._apply("toggle", target)
|
||||
# --daemon and anything else: no-op (bars already shown by do_startup)
|
||||
self.window.toggle()
|
||||
# --daemon and anything else: no-op (bar already shown by do_startup)
|
||||
return 0
|
||||
|
||||
def do_activate(self) -> None:
|
||||
|
|
|
|||
|
|
@ -1,147 +0,0 @@
|
|||
"""A minimal StatusNotifierWatcher (org.kde.StatusNotifierWatcher).
|
||||
|
||||
Under a full desktop this bus name is provided by the panel/DE; hyprdrive has no
|
||||
such component (we replaced waybar/eww), so tray apps have nowhere to register
|
||||
and every SNI host — including our own bar's tray.py — sees an empty tray. This
|
||||
owns the name, tracks registered items/hosts, and emits the registration signals
|
||||
+ PropertiesChanged so hosts stay live as apps come and go.
|
||||
|
||||
Kept deliberately small: it does not proxy the items themselves (the host talks
|
||||
to each StatusNotifierItem directly), it just is the registry the spec requires.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gio", "2.0")
|
||||
from gi.repository import Gio, GLib # noqa: E402
|
||||
|
||||
_NAME = "org.kde.StatusNotifierWatcher"
|
||||
_PATH = "/StatusNotifierWatcher"
|
||||
_IFACE = "org.kde.StatusNotifierWatcher"
|
||||
|
||||
_XML = f"""
|
||||
<node>
|
||||
<interface name="{_IFACE}">
|
||||
<method name="RegisterStatusNotifierItem">
|
||||
<arg name="service" type="s" direction="in"/>
|
||||
</method>
|
||||
<method name="RegisterStatusNotifierHost">
|
||||
<arg name="service" type="s" direction="in"/>
|
||||
</method>
|
||||
<property name="RegisteredStatusNotifierItems" type="as" access="read"/>
|
||||
<property name="IsStatusNotifierHostRegistered" type="b" access="read"/>
|
||||
<property name="ProtocolVersion" type="i" access="read"/>
|
||||
<signal name="StatusNotifierItemRegistered"><arg name="service" type="s"/></signal>
|
||||
<signal name="StatusNotifierItemUnregistered"><arg name="service" type="s"/></signal>
|
||||
<signal name="StatusNotifierHostRegistered"/>
|
||||
<signal name="StatusNotifierHostUnregistered"/>
|
||||
</interface>
|
||||
</node>
|
||||
"""
|
||||
|
||||
|
||||
class SniWatcher:
|
||||
def __init__(self) -> None:
|
||||
self._conn: Gio.DBusConnection | None = None
|
||||
self._items: dict[str, str] = {} # "service/path" -> owner unique name
|
||||
self._hosts: set[str] = set()
|
||||
self._reg_id = 0
|
||||
# NAME_OWNER flags let a real DE watcher take over cleanly if one appears.
|
||||
self._owner_id = Gio.bus_own_name(
|
||||
Gio.BusType.SESSION, _NAME,
|
||||
Gio.BusNameOwnerFlags.ALLOW_REPLACEMENT,
|
||||
self._on_bus_acquired, None, self._on_name_lost,
|
||||
)
|
||||
|
||||
# -- bus lifecycle -----------------------------------------------------
|
||||
def _on_bus_acquired(self, conn: Gio.DBusConnection, _name: str) -> None:
|
||||
self._conn = conn
|
||||
info = Gio.DBusNodeInfo.new_for_xml(_XML).interfaces[0]
|
||||
try:
|
||||
self._reg_id = conn.register_object(
|
||||
_PATH, info, self._on_method, self._on_get_property, None)
|
||||
except GLib.Error:
|
||||
return
|
||||
# drop items whose owner leaves the bus
|
||||
conn.signal_subscribe(
|
||||
"org.freedesktop.DBus", "org.freedesktop.DBus", "NameOwnerChanged",
|
||||
"/org/freedesktop/DBus", None, Gio.DBusSignalFlags.NONE,
|
||||
self._on_name_owner_changed)
|
||||
|
||||
def _on_name_lost(self, _conn, _name) -> None:
|
||||
# another watcher grabbed the name — step aside quietly
|
||||
self._conn = None
|
||||
|
||||
# -- method / property handlers ---------------------------------------
|
||||
def _on_method(self, conn, sender, _path, _iface, method, params, invocation) -> None:
|
||||
if method == "RegisterStatusNotifierItem":
|
||||
self._register_item(sender, params.unpack()[0])
|
||||
invocation.return_value(None)
|
||||
elif method == "RegisterStatusNotifierHost":
|
||||
self._hosts.add(params.unpack()[0])
|
||||
conn.emit_signal(None, _PATH, _IFACE, "StatusNotifierHostRegistered", None)
|
||||
self._emit_props({"IsStatusNotifierHostRegistered": GLib.Variant("b", True)})
|
||||
invocation.return_value(None)
|
||||
else:
|
||||
invocation.return_value(None)
|
||||
|
||||
def _on_get_property(self, _conn, _sender, _path, _iface, prop):
|
||||
if prop == "RegisteredStatusNotifierItems":
|
||||
return GLib.Variant("as", list(self._items.keys()))
|
||||
if prop == "IsStatusNotifierHostRegistered":
|
||||
return GLib.Variant("b", bool(self._hosts))
|
||||
if prop == "ProtocolVersion":
|
||||
return GLib.Variant("i", 0)
|
||||
return None
|
||||
|
||||
# -- registry bookkeeping ---------------------------------------------
|
||||
@staticmethod
|
||||
def _entry_for(sender: str, service: str) -> str:
|
||||
# apps register either an object path (use the caller as the service),
|
||||
# a full "busname/path", or just a bus name (default item path).
|
||||
if service.startswith("/"):
|
||||
return sender + service
|
||||
if "/" in service:
|
||||
return service
|
||||
return service + "/StatusNotifierItem"
|
||||
|
||||
def _register_item(self, sender: str, service: str) -> None:
|
||||
if self._conn is None:
|
||||
return
|
||||
entry = self._entry_for(sender, service)
|
||||
if entry in self._items:
|
||||
return
|
||||
self._items[entry] = sender
|
||||
self._conn.emit_signal(None, _PATH, _IFACE, "StatusNotifierItemRegistered",
|
||||
GLib.Variant("(s)", (entry,)))
|
||||
self._emit_items_changed()
|
||||
|
||||
def _on_name_owner_changed(self, _conn, _sender, _path, _iface, _signal, params) -> None:
|
||||
name, _old, new_owner = params.unpack()
|
||||
if new_owner:
|
||||
return # a name was acquired, not lost
|
||||
gone = [e for e, owner in self._items.items()
|
||||
if owner == name or e.split("/", 1)[0] == name]
|
||||
for e in gone:
|
||||
del self._items[e]
|
||||
if self._conn is not None:
|
||||
self._conn.emit_signal(None, _PATH, _IFACE, "StatusNotifierItemUnregistered",
|
||||
GLib.Variant("(s)", (e,)))
|
||||
if name in self._hosts:
|
||||
self._hosts.discard(name)
|
||||
if gone:
|
||||
self._emit_items_changed()
|
||||
|
||||
# -- signalling --------------------------------------------------------
|
||||
def _emit_items_changed(self) -> None:
|
||||
self._emit_props({"RegisteredStatusNotifierItems":
|
||||
GLib.Variant("as", list(self._items.keys()))})
|
||||
|
||||
def _emit_props(self, changed: dict) -> None:
|
||||
if self._conn is None:
|
||||
return
|
||||
self._conn.emit_signal(
|
||||
None, _PATH, "org.freedesktop.DBus.Properties", "PropertiesChanged",
|
||||
GLib.Variant("(sa{sv}as)", (_IFACE, changed, [])))
|
||||
|
|
@ -8,18 +8,9 @@
|
|||
* of a solid strip.
|
||||
*/
|
||||
|
||||
/* Glow palette — bright, saturated variants that read as emitted light over the
|
||||
* dark/blurred strip (the raw @violet is too dark for glowing text). Added in
|
||||
* this process only, so the rest of the suite keeps its palette. */
|
||||
@define-color glow_violet #8A5CFF;
|
||||
@define-color glow_magenta #EB00A6;
|
||||
@define-color glow_cyan #22D3EE;
|
||||
@define-color glow_green #2BE08A;
|
||||
@define-color glow_amber #F5A623;
|
||||
|
||||
* {
|
||||
font-family: "Agave Nerd Font Mono", monospace;
|
||||
font-size: 13pt;
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
window,
|
||||
|
|
@ -37,73 +28,65 @@ drawingarea {
|
|||
background: transparent;
|
||||
}
|
||||
|
||||
/* Neutralise the CyberQueer GTK theme's `* { background-color:#1a1a1a }` on
|
||||
* every node — enumerating node names above misses some (labels, the CenterBox
|
||||
* boxes). This provider is at USER+1, above the theme; interactive elements
|
||||
* re-assert their own fills via the higher-specificity .class rules below. */
|
||||
* {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Everything on the bar is now a bare glowing glyph — no pills, borders, fills
|
||||
* or box-shadows. Each element type is a distinct colour and glows via
|
||||
* text-shadow, like the satellites orbiting an orbit-menu node. */
|
||||
.station-launcher,
|
||||
.station-node,
|
||||
button.station-badge,
|
||||
.station-badge {
|
||||
background: none;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.station-title {
|
||||
color: @text;
|
||||
opacity: 0.95;
|
||||
font-size: 12pt;
|
||||
text-shadow: 0 0 7px alpha(@glow_violet, 0.7);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* readouts — battery / volume / clock, each its own colour */
|
||||
.station-badge { color: @text; transition: text-shadow 160ms ease; }
|
||||
.station-battery { color: @glow_green; text-shadow: 0 0 7px alpha(@glow_green, 0.85); }
|
||||
.station-volume { color: @glow_cyan; text-shadow: 0 0 7px alpha(@glow_cyan, 0.85); }
|
||||
.station-clock { color: @glow_amber; text-shadow: 0 0 7px alpha(@glow_amber, 0.85); }
|
||||
.station-volume:hover { text-shadow: 0 0 13px @glow_cyan; }
|
||||
|
||||
/* Orbit / Astro launchers — larger glowing icon glyphs */
|
||||
.station-launcher {
|
||||
font-size: 17pt;
|
||||
padding: 0 7px;
|
||||
transition: text-shadow 160ms ease, color 160ms ease;
|
||||
/* generic small readout pill — battery / volume / clock */
|
||||
.station-badge {
|
||||
color: @text;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 1px 6px;
|
||||
min-height: 20px;
|
||||
}
|
||||
button.station-badge {
|
||||
transition: color 160ms ease;
|
||||
}
|
||||
button.station-badge:hover {
|
||||
color: @accent;
|
||||
}
|
||||
|
||||
/* Orbit / Astro launcher buttons — small planet nodes, same glow language as
|
||||
* orbit-menu's/horizon-dock's .horizon-planet / node styling. */
|
||||
.station-planet {
|
||||
color: @accent;
|
||||
background: alpha(@violet, 0.4);
|
||||
border: 2px solid @violet;
|
||||
border-radius: 50%;
|
||||
min-width: 22px;
|
||||
min-height: 22px;
|
||||
padding: 0;
|
||||
transition: border-color 160ms ease, box-shadow 200ms ease;
|
||||
box-shadow: 0 0 0 0 alpha(@accent, 0);
|
||||
}
|
||||
.station-planet:hover {
|
||||
border-color: @accent;
|
||||
box-shadow: 0 0 10px 1px alpha(@accent, 0.5);
|
||||
}
|
||||
.station-orbit { color: @glow_violet; text-shadow: 0 0 9px alpha(@glow_violet, 0.9); }
|
||||
.station-astro { color: @accent; text-shadow: 0 0 9px alpha(@accent, 0.9); }
|
||||
.station-orbit:hover { text-shadow: 0 0 16px @glow_violet, 0 0 5px @glow_violet; }
|
||||
.station-astro:hover { text-shadow: 0 0 16px @accent, 0 0 5px @accent; }
|
||||
|
||||
/* workspace "stations" row */
|
||||
.station-ws-row { padding: 0 4px; }
|
||||
.station-ws-row { padding: 0 2px; }
|
||||
.station-node {
|
||||
color: alpha(@glow_violet, 0.9);
|
||||
font-size: 13pt;
|
||||
text-shadow: 0 0 6px alpha(@glow_violet, 0.6);
|
||||
transition: color 160ms ease, text-shadow 160ms ease;
|
||||
color: @text;
|
||||
background: transparent;
|
||||
border: 1px solid alpha(@violet, 0.6);
|
||||
border-radius: 10px;
|
||||
padding: 0 6px;
|
||||
min-height: 20px;
|
||||
transition: border-color 160ms ease, color 160ms ease, box-shadow 200ms ease;
|
||||
box-shadow: 0 0 0 0 alpha(@accent, 0);
|
||||
}
|
||||
.station-node:hover {
|
||||
color: @glow_violet;
|
||||
text-shadow: 0 0 11px @glow_violet;
|
||||
border-color: @accent;
|
||||
box-shadow: 0 0 8px 1px alpha(@accent, 0.4);
|
||||
}
|
||||
/* the focused workspace — brightest, magenta, reticle-framed in bar.py */
|
||||
.station-node-active {
|
||||
color: @glow_magenta;
|
||||
font-weight: 700;
|
||||
font-size: 14pt;
|
||||
text-shadow: 0 0 13px @glow_magenta, 0 0 4px @glow_magenta;
|
||||
color: @bg;
|
||||
background: @accent;
|
||||
border-color: @accent;
|
||||
box-shadow: 0 0 10px 1px alpha(@accent, 0.55);
|
||||
}
|
||||
|
||||
/* tray pod — the drawn space-station pictogram + its icon row */
|
||||
|
|
@ -120,36 +103,3 @@ button.station-badge,
|
|||
opacity: 0.85;
|
||||
}
|
||||
.station-tray-item:hover { opacity: 1; }
|
||||
|
||||
/* Right-click context menu, rendered by us from the item's com.canonical.dbusmenu
|
||||
* (tray.py). Themed to match the suite: translucent violet-charcoal glass, violet
|
||||
* frame, accent on the hovered/active row. */
|
||||
.station-tray-menu > contents,
|
||||
.station-tray-menu > arrow {
|
||||
background-color: alpha(@bg, 0.92);
|
||||
border: 1px solid alpha(@violet, 0.85);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 18px alpha(@violet, 0.35);
|
||||
}
|
||||
.station-tray-menu modelbutton {
|
||||
border-radius: 8px;
|
||||
padding: 4px 10px;
|
||||
color: @text;
|
||||
min-height: 22px;
|
||||
}
|
||||
.station-tray-menu modelbutton:hover {
|
||||
background-color: alpha(@violet, 0.35);
|
||||
color: #F0E6EA;
|
||||
}
|
||||
.station-tray-menu modelbutton:active,
|
||||
.station-tray-menu modelbutton:checked {
|
||||
background-color: alpha(@accent, 0.40);
|
||||
}
|
||||
.station-tray-menu separator {
|
||||
background-color: alpha(@violet, 0.35);
|
||||
min-height: 1px;
|
||||
margin: 3px 6px;
|
||||
}
|
||||
.station-tray-menu modelbutton:disabled {
|
||||
color: alpha(@text, 0.45);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,31 +27,22 @@ class TrayHost:
|
|||
self._on_change = on_change
|
||||
self._watcher: Optional[Gio.DBusProxy] = None
|
||||
self._registered_host = False
|
||||
# Watch the name rather than connecting once: our own SniWatcher may not
|
||||
# own it yet at construction, and this also recovers if the watcher (ours
|
||||
# or a real DE's) restarts.
|
||||
self._watch_id = Gio.bus_watch_name(
|
||||
Gio.BusType.SESSION, _WATCHER_BUS, Gio.BusNameWatcherFlags.NONE,
|
||||
self._on_watcher_appeared, self._on_watcher_vanished)
|
||||
self._connect()
|
||||
|
||||
def _on_watcher_appeared(self, _conn, _name, _owner) -> None:
|
||||
def _connect(self) -> None:
|
||||
try:
|
||||
self._watcher = Gio.DBusProxy.new_for_bus_sync(
|
||||
proxy = Gio.DBusProxy.new_for_bus_sync(
|
||||
Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None,
|
||||
_WATCHER_BUS, _WATCHER_PATH, _WATCHER_IFACE, None,
|
||||
)
|
||||
self._watcher = proxy if proxy.get_name_owner() is not None else None
|
||||
except GLib.Error:
|
||||
self._watcher = None
|
||||
return
|
||||
if self._watcher is None:
|
||||
return
|
||||
self._watcher.connect("g-properties-changed", lambda *_a: self._on_change())
|
||||
self._registered_host = False
|
||||
self._ensure_host_registered()
|
||||
self._on_change()
|
||||
|
||||
def _on_watcher_vanished(self, _conn, _name) -> None:
|
||||
self._watcher = None
|
||||
self._registered_host = False
|
||||
self._on_change()
|
||||
|
||||
def _ensure_host_registered(self) -> None:
|
||||
if self._registered_host or self._watcher is None:
|
||||
|
|
@ -117,8 +108,7 @@ class TrayHost:
|
|||
btn.connect("clicked", lambda *_a, p=proxy: self._activate(p))
|
||||
|
||||
right_click = Gtk.GestureClick(button=3)
|
||||
right_click.connect("pressed",
|
||||
lambda *_a, p=proxy, b=btn: self._context_menu(p, b))
|
||||
right_click.connect("pressed", lambda *_a, p=proxy: self._context_menu(p))
|
||||
btn.add_controller(right_click)
|
||||
return btn
|
||||
|
||||
|
|
@ -132,112 +122,7 @@ class TrayHost:
|
|||
proxy.call("Activate", GLib.Variant("(ii)", (0, 0)),
|
||||
Gio.DBusCallFlags.NONE, -1, None, None, None)
|
||||
|
||||
# -- context menu (com.canonical.dbusmenu) --------------------------------
|
||||
#
|
||||
# SNI items don't hand us a positioned menu — most expose a dbusmenu object
|
||||
# (the `Menu` property) that the HOST is expected to render itself. The old
|
||||
# code just called the item's ContextMenu(0,0); apps that implement it (e.g.
|
||||
# Discord) popped their menu at screen (0,0) — top-left — and apps that only
|
||||
# do dbusmenu (nm-applet, blueman) got nothing at all. So instead we fetch
|
||||
# the dbusmenu layout and render it in a GTK popover anchored to the icon.
|
||||
|
||||
def _context_menu(self, proxy: Gio.DBusProxy, button: Gtk.Button) -> None:
|
||||
menu_path = self._prop_str(proxy, "Menu")
|
||||
if not menu_path:
|
||||
# No dbusmenu — last-ditch: ask the item to show its own context menu.
|
||||
proxy.call("ContextMenu", GLib.Variant("(ii)", (0, 0)),
|
||||
Gio.DBusCallFlags.NONE, -1, None, None, None)
|
||||
return
|
||||
try:
|
||||
dm = Gio.DBusProxy.new_for_bus_sync(
|
||||
Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None,
|
||||
proxy.get_name(), menu_path, "com.canonical.dbusmenu", None,
|
||||
)
|
||||
except GLib.Error:
|
||||
return
|
||||
|
||||
# Best-effort: let the app refresh its menu before we read it.
|
||||
try:
|
||||
dm.call_sync("AboutToShow", GLib.Variant("(i)", (0,)),
|
||||
Gio.DBusCallFlags.NONE, 800, None)
|
||||
except GLib.Error:
|
||||
pass
|
||||
try:
|
||||
res = dm.call_sync("GetLayout", GLib.Variant("(iias)", (0, -1, [])),
|
||||
Gio.DBusCallFlags.NONE, 1500, None)
|
||||
except GLib.Error:
|
||||
return
|
||||
_revision, layout = res.unpack()
|
||||
|
||||
actions = Gio.SimpleActionGroup()
|
||||
model = self._build_model(dm, layout, actions)
|
||||
|
||||
popover = Gtk.PopoverMenu.new_from_model(model)
|
||||
popover.add_css_class("station-tray-menu")
|
||||
popover.set_parent(button)
|
||||
popover.set_position(Gtk.PositionType.BOTTOM)
|
||||
popover.insert_action_group("traymenu", actions)
|
||||
# Keep a ref while shown, and tear the surface down cleanly on close.
|
||||
self._open_popover = popover
|
||||
popover.connect("closed", self._on_popover_closed)
|
||||
popover.popup()
|
||||
|
||||
def _on_popover_closed(self, popover: Gtk.Popover) -> None:
|
||||
popover.unparent()
|
||||
if getattr(self, "_open_popover", None) is popover:
|
||||
self._open_popover = None
|
||||
|
||||
def _build_model(self, dm: Gio.DBusProxy, node, actions: Gio.SimpleActionGroup) -> Gio.Menu:
|
||||
"""Turn a dbusmenu (id, props, children) node into a Gio.Menu. Separators
|
||||
split the run into sections (PopoverMenu draws a divider between them)."""
|
||||
menu = Gio.Menu()
|
||||
section = Gio.Menu()
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal section
|
||||
if section.get_n_items() > 0:
|
||||
menu.append_section(None, section)
|
||||
section = Gio.Menu()
|
||||
|
||||
for child in node[2]:
|
||||
cid, props, _kids = child
|
||||
if not props.get("visible", True):
|
||||
continue
|
||||
if props.get("type") == "separator":
|
||||
flush()
|
||||
continue
|
||||
|
||||
label = props.get("label", "") or ""
|
||||
if props.get("children-display") == "submenu":
|
||||
item = Gio.MenuItem.new(label, None)
|
||||
item.set_submenu(self._build_model(dm, child, actions))
|
||||
section.append_item(item)
|
||||
continue
|
||||
|
||||
aname = f"i{cid}"
|
||||
if props.get("toggle-type") in ("checkmark", "radio"):
|
||||
state = GLib.Variant("b", bool(props.get("toggle-state", 0)))
|
||||
act = Gio.SimpleAction.new_stateful(aname, None, state)
|
||||
else:
|
||||
act = Gio.SimpleAction.new(aname, None)
|
||||
act.set_enabled(bool(props.get("enabled", True)))
|
||||
act.connect("activate", lambda _a, _p, i=cid: self._menu_event(dm, i))
|
||||
actions.add_action(act)
|
||||
|
||||
item = Gio.MenuItem.new(label, f"traymenu.{aname}")
|
||||
icon_name = props.get("icon-name")
|
||||
if icon_name:
|
||||
try:
|
||||
item.set_icon(Gio.ThemedIcon.new(icon_name))
|
||||
except GLib.Error:
|
||||
pass
|
||||
section.append_item(item)
|
||||
|
||||
flush()
|
||||
return menu
|
||||
|
||||
@staticmethod
|
||||
def _menu_event(dm: Gio.DBusProxy, item_id: int) -> None:
|
||||
dm.call("Event",
|
||||
GLib.Variant("(isvu)", (item_id, "clicked", GLib.Variant("i", 0), 0)),
|
||||
Gio.DBusCallFlags.NONE, -1, None, None, None)
|
||||
def _context_menu(proxy: Gio.DBusProxy) -> None:
|
||||
proxy.call("ContextMenu", GLib.Variant("(ii)", (0, 0)),
|
||||
Gio.DBusCallFlags.NONE, -1, None, None, None)
|
||||
|
|
|
|||
Loading…
Reference in New Issue