From f48c70273a8345d60779318d700072cb9ed73e92 Mon Sep 17 00:00:00 2001 From: The_miro Date: Sat, 18 Jul 2026 10:34:40 +0200 Subject: [PATCH] fix(hyprdrive): holographic Cosmonaut Shell fixes + per-monitor station bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../hyprdrive/astro-menu/lib/border.py | 9 +- .../hyprdrive/astro-menu/lib/hologram.py | 83 +++- .../hyprdrive/astro-menu/modules/location.py | 3 + .../hyprdrive/astro-menu/style/style.css | 24 +- desktopenvs/hyprdrive/astro-menu/window.py | 1 + desktopenvs/hyprdrive/horizon-dock/dock.py | 379 +++++++++++++----- .../hyprdrive/horizon-dock/lib/hologram.py | 83 +++- .../hyprdrive/horizon-dock/style/style.css | 10 + desktopenvs/hyprdrive/hypr/usr/binds.lua | 3 + .../hyprdrive/hypr/usr/windowrules.lua | 10 + .../hyprdrive/orbit-menu/orbit_menu.py | 20 +- .../hyprdrive/orbit-menu/style/style.css | 9 + desktopenvs/hyprdrive/scripts/station-bar.sh | 22 +- desktopenvs/hyprdrive/station-bar/bar.py | 178 ++++++-- desktopenvs/hyprdrive/station-bar/hypr_ipc.py | 17 +- .../hyprdrive/station-bar/lib/hologram.py | 83 +++- desktopenvs/hyprdrive/station-bar/main.py | 94 +++-- .../hyprdrive/station-bar/style/style.css | 111 ++--- 18 files changed, 934 insertions(+), 205 deletions(-) diff --git a/desktopenvs/hyprdrive/astro-menu/lib/border.py b/desktopenvs/hyprdrive/astro-menu/lib/border.py index e653e5f..52bc7d4 100644 --- a/desktopenvs/hyprdrive/astro-menu/lib/border.py +++ b/desktopenvs/hyprdrive/astro-menu/lib/border.py @@ -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/ diff --git a/desktopenvs/hyprdrive/astro-menu/lib/hologram.py b/desktopenvs/hyprdrive/astro-menu/lib/hologram.py index 73304f5..72a8627 100644 --- a/desktopenvs/hyprdrive/astro-menu/lib/hologram.py +++ b/desktopenvs/hyprdrive/astro-menu/lib/hologram.py @@ -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() diff --git a/desktopenvs/hyprdrive/astro-menu/modules/location.py b/desktopenvs/hyprdrive/astro-menu/modules/location.py index c0387bc..9b4236f 100644 --- a/desktopenvs/hyprdrive/astro-menu/modules/location.py +++ b/desktopenvs/hyprdrive/astro-menu/modules/location.py @@ -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 diff --git a/desktopenvs/hyprdrive/astro-menu/style/style.css b/desktopenvs/hyprdrive/astro-menu/style/style.css index b25703e..80c0e82 100644 --- a/desktopenvs/hyprdrive/astro-menu/style/style.css +++ b/desktopenvs/hyprdrive/astro-menu/style/style.css @@ -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; diff --git a/desktopenvs/hyprdrive/astro-menu/window.py b/desktopenvs/hyprdrive/astro-menu/window.py index c55c104..51c76ad 100644 --- a/desktopenvs/hyprdrive/astro-menu/window.py +++ b/desktopenvs/hyprdrive/astro-menu/window.py @@ -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() diff --git a/desktopenvs/hyprdrive/horizon-dock/dock.py b/desktopenvs/hyprdrive/horizon-dock/dock.py index 88db4c3..8a4d34c 100644 --- a/desktopenvs/hyprdrive/horizon-dock/dock.py +++ b/desktopenvs/hyprdrive/horizon-dock/dock.py @@ -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 diff --git a/desktopenvs/hyprdrive/horizon-dock/lib/hologram.py b/desktopenvs/hyprdrive/horizon-dock/lib/hologram.py index 8c03728..b84ee05 100644 --- a/desktopenvs/hyprdrive/horizon-dock/lib/hologram.py +++ b/desktopenvs/hyprdrive/horizon-dock/lib/hologram.py @@ -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() diff --git a/desktopenvs/hyprdrive/horizon-dock/style/style.css b/desktopenvs/hyprdrive/horizon-dock/style/style.css index f1dd10a..326078b 100644 --- a/desktopenvs/hyprdrive/horizon-dock/style/style.css +++ b/desktopenvs/hyprdrive/horizon-dock/style/style.css @@ -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 diff --git a/desktopenvs/hyprdrive/hypr/usr/binds.lua b/desktopenvs/hyprdrive/hypr/usr/binds.lua index d4b2d46..abc1839 100644 --- a/desktopenvs/hyprdrive/hypr/usr/binds.lua +++ b/desktopenvs/hyprdrive/hypr/usr/binds.lua @@ -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 ---- diff --git a/desktopenvs/hyprdrive/hypr/usr/windowrules.lua b/desktopenvs/hyprdrive/hypr/usr/windowrules.lua index e0696d4..eda3174 100644 --- a/desktopenvs/hyprdrive/hypr/usr/windowrules.lua +++ b/desktopenvs/hyprdrive/hypr/usr/windowrules.lua @@ -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 }) diff --git a/desktopenvs/hyprdrive/orbit-menu/orbit_menu.py b/desktopenvs/hyprdrive/orbit-menu/orbit_menu.py index 57719cd..53638f9 100644 --- a/desktopenvs/hyprdrive/orbit-menu/orbit_menu.py +++ b/desktopenvs/hyprdrive/orbit-menu/orbit_menu.py @@ -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 diff --git a/desktopenvs/hyprdrive/orbit-menu/style/style.css b/desktopenvs/hyprdrive/orbit-menu/style/style.css index 7e702dd..bd66b3f 100644 --- a/desktopenvs/hyprdrive/orbit-menu/style/style.css +++ b/desktopenvs/hyprdrive/orbit-menu/style/style.css @@ -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 diff --git a/desktopenvs/hyprdrive/scripts/station-bar.sh b/desktopenvs/hyprdrive/scripts/station-bar.sh index 2f985c3..d90862a 100755 --- a/desktopenvs/hyprdrive/scripts/station-bar.sh +++ b/desktopenvs/hyprdrive/scripts/station-bar.sh @@ -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" diff --git a/desktopenvs/hyprdrive/station-bar/bar.py b/desktopenvs/hyprdrive/station-bar/bar.py index cd506ab..30f176e 100644 --- a/desktopenvs/hyprdrive/station-bar/bar.py +++ b/desktopenvs/hyprdrive/station-bar/bar.py @@ -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) diff --git a/desktopenvs/hyprdrive/station-bar/hypr_ipc.py b/desktopenvs/hyprdrive/station-bar/hypr_ipc.py index d518ecb..da47823 100644 --- a/desktopenvs/hyprdrive/station-bar/hypr_ipc.py +++ b/desktopenvs/hyprdrive/station-bar/hypr_ipc.py @@ -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(",") diff --git a/desktopenvs/hyprdrive/station-bar/lib/hologram.py b/desktopenvs/hyprdrive/station-bar/lib/hologram.py index d5d2bac..c044d16 100644 --- a/desktopenvs/hyprdrive/station-bar/lib/hologram.py +++ b/desktopenvs/hyprdrive/station-bar/lib/hologram.py @@ -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() diff --git a/desktopenvs/hyprdrive/station-bar/main.py b/desktopenvs/hyprdrive/station-bar/main.py index 406fa1d..355fc09 100644 --- a/desktopenvs/hyprdrive/station-bar/main.py +++ b/desktopenvs/hyprdrive/station-bar/main.py @@ -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: diff --git a/desktopenvs/hyprdrive/station-bar/style/style.css b/desktopenvs/hyprdrive/station-bar/style/style.css index 89f63c7..ff431b5 100644 --- a/desktopenvs/hyprdrive/station-bar/style/style.css +++ b/desktopenvs/hyprdrive/station-bar/style/style.css @@ -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 */