fix(hyprdrive): holographic Cosmonaut Shell fixes + per-monitor station bar

Daily-driving pass over the Cosmonaut Shell suite: transparency, holographic
effects, and layout/geometry fixes across every component built for the DE.

Global holographic layer (astro-menu/horizon-dock/station-bar lib/hologram.py):
- edge-fade mask (A8 DEST_OUT) so scanlines fade out at panel borders
- magenta particle field; "materialize from static" noise-mask intro animation
- optional clip_func so the mask follows non-rectangular / animated content
- universal `* { background-color: transparent }` at USER+1 to defeat the
  CyberQueer theme painting opaque bg on plain containers; blur layer_rules

astro-menu: magenta text (alt red) for readability; thinner, glowier borders;
rounded map-texture corners; fixed transparency stacking + square corners.

orbit-menu: scanline mask now follows the zoom transition into a submenu.

horizon-dock: reworked into an orbit-menu node ~1/3 on-screen (<=2/3 width) with
horizon perspective (arched orbit rows), a non-functional center clock-sphere,
orbit glyph decorations, graceful edge fade-out, and centered/filled orbits;
tray removed; giant-surface clip fixed.

station-bar: redesigned as an extra Horizon Dock orbit — bare glowing color-coded
glyphs (no pills), width-adaptive, one bar per monitor, Super+Z toggles the
focused monitor's bar. Clickable HUD trapezoid around the title opens Astro Menu.
Workspaces are now per-monitor: each bar shows only its own monitor's workspaces
and highlights that monitor's active workspace. Populated workspaces show the
station glyph, empty ones the bare spaceship + number; the focused one is framed
by a `-< >-` reticle with the rocket prepended when there's a station to dock at.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-07-18 10:34:40 +02:00
parent 90375a5733
commit f48c70273a
18 changed files with 934 additions and 205 deletions

View File

@ -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.34
FILL_ALPHA = 0.28
def _rounded_rect(cr, x, y, w, h, r) -> None:
@ -39,8 +39,9 @@ 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.
_GLOW_LAYERS = [(10, 0.05), (6, 0.09), (3, 0.15)]
# 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)]
def _make_draw(border: int, radius: int, color, fill_bg: bool, glow: bool):
@ -63,7 +64,7 @@ def _make_draw(border: int, radius: int, color, fill_bg: bool, glow: bool):
return draw
def bordered(child: Gtk.Widget, border: int = 3, radius: int = 16,
def bordered(child: Gtk.Widget, border: int = 2, 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/

View File

@ -35,16 +35,23 @@ 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, _ACCENT]
NOISE_COLORS = [_MAGENTA, _MAGENTA, _ACCENT] # magenta-biased specks
NOISE_LIFETIME = (0.5, 1.4)
NOISE_FADE_IN = 0.2
NOISE_FADE_OUT = 0.35
NOISE_ALPHA_RANGE = (0.10, 0.34)
EDGE_FADE_X = 64.0 # smooth horizontal fade-out of the scanline field
EDGE_FADE_Y = 40.0 # smooth vertical fade-out
INTRO_DURATION = 0.6 # 'materialise out of static' opening animation
INTRO_STATIC = 520 # static specks at the very start of the intro
def __init__(self, enabled: bool = True) -> None:
def __init__(self, enabled: bool = True, clip_func=None) -> None:
self.enabled = enabled
self._clip_func = clip_func # optional path-setter to clip the holo to the UI shape
self._sat_time = 0.0
self._particles: list[dict] = []
self._intro_t: float | None = None # >=0 while the materialise intro plays
self._mask_cache: tuple | None = None # (w, h, pattern) edge-fade mask
self.widget = Gtk.DrawingArea()
self.widget.set_can_target(False) # never steals clicks from content underneath
@ -59,12 +66,84 @@ 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
self.widget.queue_draw()
def start_intro(self) -> None:
"""Kick off the 'hologram materialising out of static' opening effect."""
if self.enabled:
self._intro_t = 0.0
# -- drawing --------------------------------------------------------------
def _draw_frame(self, _area, cr, width: float, height: float) -> None:
if not self.enabled or width <= 0 or height <= 0:
return
if self._clip_func is not None:
cr.save()
self._clip_func(cr, width, height) # clip the scanlines to the UI shape
cr.clip()
# Render the holo field into a group, then composite it back through a
# soft edge-fade mask so the scanlines dissolve at the borders (reads far
# more like a projected hologram than a hard-edged rectangle).
cr.push_group()
self._draw_content(cr, width, height)
cr.pop_group_to_source()
cr.mask(self._edge_fade_mask(width, height))
if self._intro_t is not None:
self._draw_intro(cr, width, height)
if self._clip_func is not None:
cr.restore()
def _edge_fade_mask(self, width: float, height: float):
key = (int(width), int(height))
if self._mask_cache is not None and self._mask_cache[0] == key:
return self._mask_cache[1]
w, h = max(1, key[0]), max(1, key[1])
surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h)
m = cairo.Context(surf)
m.set_source_rgba(0, 0, 0, 1)
m.paint()
m.set_operator(cairo.OPERATOR_DEST_OUT) # subtract edge gradients from the solid
fx = min(self.EDGE_FADE_X, w / 2)
fy = min(self.EDGE_FADE_Y, h / 2)
def band(x0, y0, x1, y1, rx, ry, rw, rh):
gr = cairo.LinearGradient(x0, y0, x1, y1)
gr.add_color_stop_rgba(0.0, 0, 0, 0, 1)
gr.add_color_stop_rgba(1.0, 0, 0, 0, 0)
m.set_source(gr)
m.rectangle(rx, ry, rw, rh)
m.fill()
band(0, 0, fx, 0, 0, 0, fx, h) # left
band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right
band(0, 0, 0, fy, 0, 0, w, fy) # top
band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom
pattern = cairo.SurfacePattern(surf)
self._mask_cache = (key, pattern)
return pattern
def _draw_intro(self, cr, width: float, height: float) -> None:
p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION))
strength = 1.0 - p
cr.set_source_rgba(*_VIOLET, 0.28 * strength) # haze the content emerges from
cr.paint()
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
for _ in range(int(self.INTRO_STATIC * strength)):
x = random.uniform(0, width)
y = random.uniform(0, height)
col = random.choice(colors)
cr.set_source_rgba(*col, random.uniform(0.25, 0.8) * (0.4 + 0.6 * strength))
sz = random.uniform(1.0, 2.4)
cr.rectangle(x, y, sz, sz)
cr.fill()
band = p * height # a bright scan wiping down as it resolves
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1)))
cr.rectangle(0, band - 1.5, width, 3.0)
cr.fill()
def _draw_content(self, cr, width: float, height: float) -> None:
r, g, b = _VIOLET
cr.save()

View File

@ -50,6 +50,9 @@ 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

View File

@ -7,11 +7,29 @@
* .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
@ -236,9 +254,13 @@ 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: alpha(@violet, 0.4);
background: transparent;
background-color: transparent;
color: @text;
font-family: "Agave Nerd Font Mono", monospace;
font-size: 11pt;

View File

@ -201,6 +201,7 @@ 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()

View File

@ -21,9 +21,14 @@ behind" the satellite.
from __future__ import annotations
import json
import math
import subprocess
import time
from typing import Callable, Optional
import cairo
import gi
gi.require_version("Gtk", "4.0")
@ -31,7 +36,7 @@ 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, Graphene, Gsk, Gtk # noqa: E402
from gi.repository import Gdk, GLib, Graphene, Gsk, Gtk # noqa: E402
from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402
import apps as apps_source
@ -47,15 +52,31 @@ _VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255)
class HorizonDock(Gtk.Window):
DOCK_HEIGHT = 214
# 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)
PLANET_SIZE = 52
ITEM_SPACING = 66.0 # center-to-center distance between planets in a row
ARC_SAG = 34.0 # how far a row dips toward the screen edges vs. its center
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
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_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_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"]
TRAY_SIZE = 40
TRAY_MARGIN = 30.0
@ -68,12 +89,13 @@ class HorizonDock(Gtk.Window):
super().__init__()
self.add_css_class("horizon-dock-window")
self._on_close = on_close
self._tray_enabled = tray_enabled
self._tray_enabled = False # tray removed — the dock is a clean orbit node now
self._apps = apps_source.AppSource()
self._tray = TrayHost() if tray_enabled else None
self._tray = 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}
@ -100,9 +122,26 @@ class HorizonDock(Gtk.Window):
self._viewport.set_size_request(int(self._width), self.DOCK_HEIGHT)
self._viewport.put(self._content, 0, 0)
self._hologram = HologramOverlay(enabled=hologram_enabled)
# 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)
overlay = Gtk.Overlay()
overlay.set_child(self._viewport)
overlay.set_child(clip)
overlay.add_overlay(self._hologram.widget)
self.set_child(overlay)
@ -133,24 +172,68 @@ class HorizonDock(Gtk.Window):
LayerShell.set_anchor(self, edge, True)
LayerShell.set_margin(self, edge, 0)
def _monitor_width(self) -> int:
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()
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
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.
return mon.get_geometry().width if mon is not None else 1920
# -- 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)
# -- 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
def _row_y_at(self, row: str, x: float) -> float:
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
"""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)
def _item_position(self, row: str, index: int) -> tuple[float, float]:
slot = index - self._scroll_offset[row]
@ -187,17 +270,16 @@ 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._content.put(btn, x - self.PLANET_SIZE / 2, y - self.PLANET_SIZE / 2)
self._place_item(row, btn, x, y, put=True)
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:
@ -220,60 +302,53 @@ 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._content.move(w, x - self.PLANET_SIZE / 2, y - self.PLANET_SIZE / 2)
self._place_item(row, w, x, y, put=False)
def _rebuild_all(self) -> None:
for row in self.ROW_ORDER:
self._rebuild_row(row)
# -- 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)
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 _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
def _on_motion(self, _ctrl, _x: float, y: float) -> None:
row = self._row_at_y(y)
def _on_motion(self, _ctrl, x: float, y: float) -> None:
row = self._row_at(x, y)
if row != self._hovered_row:
self._hovered_row = row
self._bg.queue_draw()
@ -292,33 +367,136 @@ class HorizonDock(Gtk.Window):
self._reflow_row(row)
return True
# -- background: the horizon arcs + hover band --------------------------
# -- background: the giant orbit node + its rings -----------------------
def _draw_background(self, _area, cr, width: float, height: float) -> None:
self._draw_node(cr)
for row in self.ROW_ORDER:
self._draw_row_arc(cr, row, width)
if self._hovered_row is not None:
self._draw_hover_band(cr, self._hovered_row, width)
self._draw_ring(cr, row, hovered=(row == self._hovered_row))
self._draw_orbit_glyphs(cr, row)
self._draw_center_sphere(cr)
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
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)
for s in range(1, steps + 1):
x = width * s / steps
cr.line_to(x, self._row_y_at(row, x))
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)
cr.stroke()
cr.restore()
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
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 glowing sphere sitting on the surface below the orbits,
displaying the date and time. Sits in the clear central column so the icon
rows don't cover the readout."""
cx = self._width / 2
base = self._base_y()
# Sit in the clear band BELOW the lowest icon arch (windows) so the icon
# rows never cover the readout.
band_top = base - self._ring_ry("windows") + self.PLANET_SIZE / 2
cy = (band_top + base) / 2
rad = max(26.0, min((base - band_top) / 2 - 2, 40.0))
cr.save()
cr.set_source_rgba(*_ACCENT, 0.05)
cr.rectangle(0, top, width, bottom - top)
grad = cairo.RadialGradient(cx - rad * 0.3, cy - rad * 0.35, rad * 0.1, cx, cy, rad)
grad.add_color_stop_rgba(0.0, *_VIOLET, 0.9)
grad.add_color_stop_rgba(1.0, *_VIOLET, 0.4)
cr.arc(cx, cy, rad, 0, 2 * math.pi)
cr.set_source(grad)
cr.fill()
for lw, a in ((11.0, 0.06), (5.5, 0.12), (2.4, 0.7)):
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
cr.select_font_face(self.GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
clock = time.strftime("%H:%M")
cr.set_font_size(rad * 0.62)
ext = cr.text_extents(clock)
cr.move_to(cx - ext.width / 2 - ext.x_bearing, cy - ext.height / 2 - ext.y_bearing - rad * 0.16)
cr.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(rad * 0.34)
ext2 = cr.text_extents(date)
cr.move_to(cx - ext2.width / 2 - ext2.x_bearing, cy + rad * 0.5)
cr.set_source_rgba(*_ACCENT, 0.95)
cr.show_text(date)
cr.restore()
def _draw_orbit_glyphs(self, cr, row: str) -> None:
"""Sprinkle small faint nerd-font glyphs along a ring, on the integer slots
between the (half-slot) icons the orbit-menu satellites' glyph language,
so the empty stretches of orbit still read as 'in orbit'."""
cx = self._width / 2
rx, ry = self._ring_rx(row), self._ring_ry(row)
cr.save()
cr.select_font_face(self.GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
cr.set_font_size(12)
n = int(rx / self.ITEM_SPACING) + 1
gi = 0
for k in range(-n, n + 1):
x = cx + k * self.ITEM_SPACING # integer slots = the gaps between icons
dx = x - cx
if abs(dx) >= rx or x < 8 or x > self._width - 8:
continue
y = self._base_y() - ry * math.sqrt(max(1.0 - (dx / rx) ** 2, 0.0))
glyph = self.ORBIT_GLYPHS[gi % len(self.ORBIT_GLYPHS)]
gi += 1
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.5)
cr.show_text(glyph)
cr.restore()
# -- reveal animation -----------------------------------------------------
@ -361,13 +539,34 @@ class HorizonDock(Gtk.Window):
self._tick_id = 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:
self._width = self._monitor_width()
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._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() and self._reveal_target == 1.0:
self._bg.queue_draw() # repaint the planet's date/time
return True
self._clock_timer_id = None
return False
def hide_dock(self) -> None:
self._reveal_target = 0.0

View File

@ -34,16 +34,23 @@ 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, _ACCENT]
NOISE_COLORS = [_MAGENTA, _MAGENTA, _ACCENT] # magenta-biased specks
NOISE_LIFETIME = (0.5, 1.4)
NOISE_FADE_IN = 0.2
NOISE_FADE_OUT = 0.35
NOISE_ALPHA_RANGE = (0.10, 0.34)
EDGE_FADE_X = 64.0 # smooth horizontal fade-out of the scanline field
EDGE_FADE_Y = 40.0 # smooth vertical fade-out
INTRO_DURATION = 0.6 # 'materialise out of static' opening animation
INTRO_STATIC = 520 # static specks at the very start of the intro
def __init__(self, enabled: bool = True) -> None:
def __init__(self, enabled: bool = True, clip_func=None) -> None:
self.enabled = enabled
self._clip_func = clip_func # optional path-setter to clip the holo to the UI shape
self._sat_time = 0.0
self._particles: list[dict] = []
self._intro_t: float | None = None # >=0 while the materialise intro plays
self._mask_cache: tuple | None = None # (w, h, pattern) edge-fade mask
self.widget = Gtk.DrawingArea()
self.widget.set_can_target(False) # never steals clicks from content underneath
@ -58,12 +65,84 @@ 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
self.widget.queue_draw()
def start_intro(self) -> None:
"""Kick off the 'hologram materialising out of static' opening effect."""
if self.enabled:
self._intro_t = 0.0
# -- drawing --------------------------------------------------------------
def _draw_frame(self, _area, cr, width: float, height: float) -> None:
if not self.enabled or width <= 0 or height <= 0:
return
if self._clip_func is not None:
cr.save()
self._clip_func(cr, width, height) # clip the scanlines to the UI shape
cr.clip()
# Render the holo field into a group, then composite it back through a
# soft edge-fade mask so the scanlines dissolve at the borders (reads far
# more like a projected hologram than a hard-edged rectangle).
cr.push_group()
self._draw_content(cr, width, height)
cr.pop_group_to_source()
cr.mask(self._edge_fade_mask(width, height))
if self._intro_t is not None:
self._draw_intro(cr, width, height)
if self._clip_func is not None:
cr.restore()
def _edge_fade_mask(self, width: float, height: float):
key = (int(width), int(height))
if self._mask_cache is not None and self._mask_cache[0] == key:
return self._mask_cache[1]
w, h = max(1, key[0]), max(1, key[1])
surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h)
m = cairo.Context(surf)
m.set_source_rgba(0, 0, 0, 1)
m.paint()
m.set_operator(cairo.OPERATOR_DEST_OUT) # subtract edge gradients from the solid
fx = min(self.EDGE_FADE_X, w / 2)
fy = min(self.EDGE_FADE_Y, h / 2)
def band(x0, y0, x1, y1, rx, ry, rw, rh):
gr = cairo.LinearGradient(x0, y0, x1, y1)
gr.add_color_stop_rgba(0.0, 0, 0, 0, 1)
gr.add_color_stop_rgba(1.0, 0, 0, 0, 0)
m.set_source(gr)
m.rectangle(rx, ry, rw, rh)
m.fill()
band(0, 0, fx, 0, 0, 0, fx, h) # left
band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right
band(0, 0, 0, fy, 0, 0, w, fy) # top
band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom
pattern = cairo.SurfacePattern(surf)
self._mask_cache = (key, pattern)
return pattern
def _draw_intro(self, cr, width: float, height: float) -> None:
p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION))
strength = 1.0 - p
cr.set_source_rgba(*_VIOLET, 0.28 * strength) # haze the content emerges from
cr.paint()
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
for _ in range(int(self.INTRO_STATIC * strength)):
x = random.uniform(0, width)
y = random.uniform(0, height)
col = random.choice(colors)
cr.set_source_rgba(*col, random.uniform(0.25, 0.8) * (0.4 + 0.6 * strength))
sz = random.uniform(1.0, 2.4)
cr.rectangle(x, y, sz, sz)
cr.fill()
band = p * height # a bright scan wiping down as it resolves
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1)))
cr.rectangle(0, band - 1.5, width, 3.0)
cr.fill()
def _draw_content(self, cr, width: float, height: float) -> None:
r, g, b = _VIOLET
cr.save()

View File

@ -9,6 +9,16 @@ 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

View File

@ -278,7 +278,10 @@ hl.bind(mainMod .. " + SHIFT + D", hl.dsp.exec_cmd("~/.config/scripts/horizon-do
-- 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 ----

View File

@ -111,6 +111,16 @@ 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.)
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 })
end
-- 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 })

View File

@ -138,6 +138,12 @@ class OrbitMenu(Gtk.Window):
self._transition: dict | None = None # active zoom/fade nav transition, if any
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
@ -518,12 +524,14 @@ 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 = width / 2, height / 2
cx, cy = self._holo_cx, self._holo_cy
cr.push_group()
self._draw_hologram_content(cr, width, height)
pattern = cr.pop_group()
mask = cairo.RadialGradient(cx, cy, self._HOLO_MASK_INNER, cx, cy, self._HOLO_MASK_OUTER)
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.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)
@ -735,6 +743,11 @@ 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))
@ -760,6 +773,9 @@ 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

View File

@ -8,6 +8,15 @@ 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

View File

@ -3,9 +3,13 @@
# 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 (default)
# station-bar.sh show -> --show
# station-bar.sh hide -> --hide
# 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".
#
# (No `set -e`: a non-zero `busctl` in the wait loop is expected and must not
# abort the script before it forwards the verb.)
@ -20,19 +24,25 @@ 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" "[]" "{}" >/dev/null
--method org.gtk.Actions.Activate "$ACTION" "[<'${TARGET}'>]" "{}" >/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" "$@"
exec "$0" "$1" "$TARGET"
fi
sleep 0.2
done
exec python3 "$APP" "$VERB"
exec python3 "$APP" "$VERB" "$TARGET"

View File

@ -63,17 +63,19 @@ ICON_VOLUME = chr(0xf04c3)
class StationBar(Gtk.Window):
BAR_HEIGHT = 30
BAR_HEIGHT = 44 # a touch larger — the elements are bare glowing glyphs now
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) -> None:
def __init__(self, hologram_enabled: bool = True, monitor=None) -> 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
@ -82,8 +84,13 @@ 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(int(self._width), self.BAR_HEIGHT)
self._bg.set_size_request(-1, self.BAR_HEIGHT)
self._bg.set_hexpand(True)
self._bg.set_draw_func(self._draw_background)
self._bg.add_css_class("station-canvas")
@ -104,17 +111,27 @@ class StationBar(Gtk.Window):
content = Gtk.CenterBox(orientation=Gtk.Orientation.HORIZONTAL)
content.add_css_class("station-content")
content.set_size_request(int(self._width), self.BAR_HEIGHT)
content.set_hexpand(True)
content.set_valign(Gtk.Align.CENTER)
content.set_start_widget(self._left)
content.set_center_widget(self._center_label)
content.set_center_widget(self._build_center())
content.set_end_widget(self._right)
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()
@ -128,7 +145,7 @@ class StationBar(Gtk.Window):
on_active_workspace=self._on_active_workspace,
on_active_window=self._on_active_window,
)
self._active_ws = hypr_ipc.initial_active_workspace_id()
self._active_ws = self._derive_active_ws()
self._refresh_workspaces()
self._on_active_window(hypr_ipc.initial_active_window_title())
@ -141,6 +158,7 @@ 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:
@ -166,28 +184,47 @@ 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:
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
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.
return mon.get_geometry().width if mon is not None else 1920
# -- background curve -------------------------------------------------------
def _arc_radius(self) -> float:
half_w = self._width / 2
def _arc_radius(self, width: float) -> float:
half_w = width / 2
sag = self.ARC_SAG
return (sag * sag + half_w * half_w) / (2 * sag)
def _curve_dip_at(self, x: float) -> float:
def _curve_dip_at(self, x: float, width: 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."""
dx = x - self._width / 2
r = self._arc_radius()
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)
return r - math.sqrt(max(r * r - dx * dx, 0.0))
def _draw_background(self, _area, cr, width: float, height: float) -> None:
@ -195,11 +232,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))
cr.move_to(0, base_y + self._curve_dip_at(0, width))
steps = 64
for s in range(1, steps + 1):
x = width * s / steps
cr.line_to(x, base_y + self._curve_dip_at(x))
cr.line_to(x, base_y + self._curve_dip_at(x, width))
cr.stroke()
cr.restore()
@ -207,23 +244,26 @@ 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",
orbit_btn = self._make_launcher(ICON_ORBIT, "Orbit Menu", "station-orbit",
["bash", "-c", "$HOME/.config/scripts/orbit-menu.sh menu"])
astro_btn = self._make_launcher(ICON_ASTRO, "Astro Menu",
astro_btn = self._make_launcher(ICON_ASTRO, "Astro Menu", "station-astro",
["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=4)
self._ws_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
self._ws_row.add_css_class("station-ws-row")
self._left.append(self._ws_row)
def _make_launcher(self, icon: str, tooltip: str, argv: list[str]) -> Gtk.Button:
def _make_launcher(self, icon: str, tooltip: str, css_class: str, argv: list[str]) -> Gtk.Button:
btn = Gtk.Button(label=icon)
btn.add_css_class("station-planet")
btn.add_css_class("station-launcher")
btn.add_css_class(css_class)
btn.set_has_frame(False)
btn.set_tooltip_text(tooltip)
btn.connect("clicked", lambda *_a: subprocess.Popen(
argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL))
@ -231,8 +271,20 @@ 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:
@ -244,18 +296,90 @@ 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
btn = Gtk.Button(label=f"{ICON_SPACESHIP if selected else ICON_STATION}{wid}")
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.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:
self._active_ws = ws_id
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()
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()
@ -277,6 +401,8 @@ 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)
@ -286,6 +412,7 @@ 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:
@ -344,6 +471,7 @@ class StationBar(Gtk.Window):
self.set_visible(True)
self.present()
self._ensure_tick()
self._hologram.start_intro()
def hide_bar(self) -> None:
LayerShell.set_exclusive_zone(self, 0)

View File

@ -55,6 +55,15 @@ 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 ""
@ -122,7 +131,13 @@ 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"):
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`.
self._on_workspaces_changed()
elif event == "activewindow":
_cls, _, title = payload.partition(",")

View File

@ -33,16 +33,23 @@ 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, _ACCENT]
NOISE_COLORS = [_MAGENTA, _MAGENTA, _ACCENT] # magenta-biased specks
NOISE_LIFETIME = (0.5, 1.4)
NOISE_FADE_IN = 0.2
NOISE_FADE_OUT = 0.35
NOISE_ALPHA_RANGE = (0.10, 0.34)
EDGE_FADE_X = 64.0 # smooth horizontal fade-out of the scanline field
EDGE_FADE_Y = 40.0 # smooth vertical fade-out
INTRO_DURATION = 0.6 # 'materialise out of static' opening animation
INTRO_STATIC = 520 # static specks at the very start of the intro
def __init__(self, enabled: bool = True) -> None:
def __init__(self, enabled: bool = True, clip_func=None) -> None:
self.enabled = enabled
self._clip_func = clip_func # optional path-setter to clip the holo to the UI shape
self._sat_time = 0.0
self._particles: list[dict] = []
self._intro_t: float | None = None # >=0 while the materialise intro plays
self._mask_cache: tuple | None = None # (w, h, pattern) edge-fade mask
self.widget = Gtk.DrawingArea()
self.widget.set_can_target(False) # never steals clicks from content underneath
@ -57,12 +64,84 @@ 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
self.widget.queue_draw()
def start_intro(self) -> None:
"""Kick off the 'hologram materialising out of static' opening effect."""
if self.enabled:
self._intro_t = 0.0
# -- drawing --------------------------------------------------------------
def _draw_frame(self, _area, cr, width: float, height: float) -> None:
if not self.enabled or width <= 0 or height <= 0:
return
if self._clip_func is not None:
cr.save()
self._clip_func(cr, width, height) # clip the scanlines to the UI shape
cr.clip()
# Render the holo field into a group, then composite it back through a
# soft edge-fade mask so the scanlines dissolve at the borders (reads far
# more like a projected hologram than a hard-edged rectangle).
cr.push_group()
self._draw_content(cr, width, height)
cr.pop_group_to_source()
cr.mask(self._edge_fade_mask(width, height))
if self._intro_t is not None:
self._draw_intro(cr, width, height)
if self._clip_func is not None:
cr.restore()
def _edge_fade_mask(self, width: float, height: float):
key = (int(width), int(height))
if self._mask_cache is not None and self._mask_cache[0] == key:
return self._mask_cache[1]
w, h = max(1, key[0]), max(1, key[1])
surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h)
m = cairo.Context(surf)
m.set_source_rgba(0, 0, 0, 1)
m.paint()
m.set_operator(cairo.OPERATOR_DEST_OUT) # subtract edge gradients from the solid
fx = min(self.EDGE_FADE_X, w / 2)
fy = min(self.EDGE_FADE_Y, h / 2)
def band(x0, y0, x1, y1, rx, ry, rw, rh):
gr = cairo.LinearGradient(x0, y0, x1, y1)
gr.add_color_stop_rgba(0.0, 0, 0, 0, 1)
gr.add_color_stop_rgba(1.0, 0, 0, 0, 0)
m.set_source(gr)
m.rectangle(rx, ry, rw, rh)
m.fill()
band(0, 0, fx, 0, 0, 0, fx, h) # left
band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right
band(0, 0, 0, fy, 0, 0, w, fy) # top
band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom
pattern = cairo.SurfacePattern(surf)
self._mask_cache = (key, pattern)
return pattern
def _draw_intro(self, cr, width: float, height: float) -> None:
p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION))
strength = 1.0 - p
cr.set_source_rgba(*_VIOLET, 0.28 * strength) # haze the content emerges from
cr.paint()
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
for _ in range(int(self.INTRO_STATIC * strength)):
x = random.uniform(0, width)
y = random.uniform(0, height)
col = random.choice(colors)
cr.set_source_rgba(*col, random.uniform(0.25, 0.8) * (0.4 + 0.6 * strength))
sz = random.uniform(1.0, 2.4)
cr.rectangle(x, y, sz, sz)
cr.fill()
band = p * height # a bright scan wiping down as it resolves
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1)))
cr.rectangle(0, band - 1.5, width, 3.0)
cr.fill()
def _draw_content(self, cr, width: float, height: float) -> None:
r, g, b = _VIOLET
cr.save()

View File

@ -28,7 +28,8 @@ 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
gi.require_version("Gdk", "4.0")
from gi.repository import Gdk, Gio, GLib, Gtk # noqa: E402
import config # noqa: E402
import theme # noqa: E402
@ -40,43 +41,90 @@ class StationBarApp(Gtk.Application):
def __init__(self) -> None:
super().__init__(application_id=APP_ID,
flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE)
self.window: StationBar | None = None
# 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
def do_startup(self) -> None:
Gtk.Application.do_startup(self)
theme.load_css()
self.window = StationBar(hologram_enabled=config.hologram_enabled())
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._register_actions()
self.hold() # stay alive even while the bar is briefly hidden
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())
def _register_actions(self) -> None:
def add(name: str, callback) -> None:
action = Gio.SimpleAction.new(name, None)
action.connect("activate", callback)
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 ""),
)
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"
if self.window is None:
return 0
target = args[1] if len(args) > 1 else ""
if verb == "--show":
self.window.show_bar()
self._apply("show", target)
elif verb == "--hide":
self.window.hide_bar()
self._apply("hide", target)
elif verb == "--toggle":
self.window.toggle()
# --daemon and anything else: no-op (bar already shown by do_startup)
self._apply("toggle", target)
# --daemon and anything else: no-op (bars already shown by do_startup)
return 0
def do_activate(self) -> None:

View File

@ -8,9 +8,18 @@
* 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: 11pt;
font-size: 13pt;
}
window,
@ -28,65 +37,73 @@ 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.9;
opacity: 0.95;
font-size: 12pt;
text-shadow: 0 0 7px alpha(@glow_violet, 0.7);
}
/* 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;
}
/* 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 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);
/* Orbit / Astro launchers — larger glowing icon glyphs */
.station-launcher {
font-size: 17pt;
padding: 0 7px;
transition: text-shadow 160ms ease, color 160ms ease;
}
.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 2px; }
.station-ws-row { padding: 0 4px; }
.station-node {
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);
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;
}
.station-node:hover {
border-color: @accent;
box-shadow: 0 0 8px 1px alpha(@accent, 0.4);
color: @glow_violet;
text-shadow: 0 0 11px @glow_violet;
}
/* the focused workspace — brightest, magenta, reticle-framed in bar.py */
.station-node-active {
color: @bg;
background: @accent;
border-color: @accent;
box-shadow: 0 0 10px 1px alpha(@accent, 0.55);
color: @glow_magenta;
font-weight: 700;
font-size: 14pt;
text-shadow: 0 0 13px @glow_magenta, 0 0 4px @glow_magenta;
}
/* tray pod — the drawn space-station pictogram + its icon row */