"""station-bar (codename: Voidstation Status Bar) — the EWW top bar's replacement, styled as an extra orbit of Horizon Dock: a thin, full-width layer-shell panel anchored to the top edge with a shallow "horizon" curve drawn behind its content (same arc math as horizon-dock's row arcs, just one row, much shallower — a narrow curved space rather than a deep dip). Three zones, left/center/right (Gtk.CenterBox over a Cairo-drawn curve): left battery (hidden if none) · Orbit / Astro launcher buttons · workspace "stations" (the focused one drawn as the "spaceship") center the focused window's title right tray (drawn inside a small space-station pictogram) · volume · clock Unlike orbit-menu/horizon-dock this is a real reserved-space bar (an exclusive layer-shell zone), not a floating popover — but it's still toggleable exactly like them (D-Bus show/hide/toggle via main.py), releasing its exclusive zone on hide so windows reflow to use the freed space. """ from __future__ import annotations import math import subprocess import time from typing import Optional import gi gi.require_version("Gtk", "4.0") gi.require_version("Gdk", "4.0") gi.require_version("Pango", "1.0") gi.require_version("Gtk4LayerShell", "1.0") from gi.repository import Gdk, GLib, Gtk, Pango # noqa: E402 from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402 import battery import hypr_ipc import volume as volume_source from lib.hologram import HologramOverlay from tray import TrayHost # CyberQueer accent/violet — hardcoded for Cairo the same way orbit-menu and # horizon-dock do, since Cairo paints directly and doesn't see GTK's # @define-color names. Kept in sync by eye with style/_colors.css. _ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) _VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) # Icons are Nerd Font (Symbols Nerd Font / Agave Nerd Font Mono) codepoints, # each verified against the actually-installed font's cmap+glyph names before # use (see /tmp/icon-check during development) rather than guessed blind: # md-orbit 0xf0018 — same glyph eww.yuck's orbit-launcher button used # md-satellite_variant 0xf0471 — astro-menu launcher (satellite/info theme) # md-space_station 0xf1383 — unselected workspace ("station") — this is the # exact codepoint the user's own spec used # fa-rocket 0xf135 — focused workspace ("your spaceship") # md-dock_window 0xf10ac — focused window title prefix # md-speaker 0xf04c3 — volume, same glyph eww.yuck's volume metric used ICON_ORBIT = chr(0xf0018) ICON_ASTRO = chr(0xf0471) ICON_STATION = chr(0xf1383) ICON_SPACESHIP = chr(0xf135) ICON_WINDOW = chr(0xf10ac) ICON_VOLUME = chr(0xf04c3) class StationBar(Gtk.Window): 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, 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 self._last_tick: Optional[float] = None self._tick_id: Optional[int] = None self._init_layer_shell() # Width-adaptive: no hardcoded pixel width. The layer surface is stretched # to the monitor by the LEFT+RIGHT anchors, and _bg/content fill it via # hexpand, so the bar adapts to any resolution (and to hotplug) with no # captured width. Only the height is fixed. self._bg = Gtk.DrawingArea() self._bg.set_size_request(-1, self.BAR_HEIGHT) self._bg.set_hexpand(True) self._bg.set_draw_func(self._draw_background) self._bg.add_css_class("station-canvas") self._left = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) self._left.set_halign(Gtk.Align.START) self._left.set_valign(Gtk.Align.CENTER) self._left.add_css_class("station-zone") self._center_label = Gtk.Label(label=ICON_WINDOW) self._center_label.add_css_class("station-title") self._center_label.set_ellipsize(Pango.EllipsizeMode.END) self._center_label.set_max_width_chars(60) self._right = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) self._right.set_halign(Gtk.Align.END) self._right.set_valign(Gtk.Align.CENTER) self._right.add_css_class("station-zone") content = Gtk.CenterBox(orientation=Gtk.Orientation.HORIZONTAL) content.add_css_class("station-content") content.set_hexpand(True) content.set_valign(Gtk.Align.CENTER) content.set_start_widget(self._left) 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() self._build_right() self._tray = TrayHost(on_change=self._refresh_tray) self._refresh_tray() self._ipc = hypr_ipc.HyprIPC( on_workspaces_changed=self._refresh_workspaces, on_active_workspace=self._on_active_workspace, on_active_window=self._on_active_window, ) self._active_ws = self._derive_active_ws() self._refresh_workspaces() self._on_active_window(hypr_ipc.initial_active_window_title()) GLib.timeout_add_seconds(self.BATTERY_POLL_S, self._refresh_battery) GLib.timeout_add_seconds(self.CLOCK_POLL_S, self._refresh_clock) GLib.timeout_add_seconds(self.VOLUME_POLL_S, self._refresh_volume) self._refresh_battery() self._refresh_clock() self._refresh_volume() 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: now = frame_clock.get_frame_time() / 1_000_000 dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick) self._last_tick = now self._hologram.tick(dt) return True # keep ticking for the bar's whole lifetime while shown def _ensure_tick(self) -> None: if self._tick_id is None: self._tick_id = self.add_tick_callback(self._on_tick) def _stop_tick(self) -> None: if self._tick_id is not None: self.remove_tick_callback(self._tick_id) self._tick_id = None self._last_tick = None # -- layer shell ---------------------------------------------------------- def _init_layer_shell(self) -> None: LayerShell.init_for_window(self) LayerShell.set_layer(self, LayerShell.Layer.TOP) LayerShell.set_namespace(self, "station-bar") LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.NONE) if self._monitor is not None: LayerShell.set_monitor(self, self._monitor) for edge in (LayerShell.Edge.LEFT, LayerShell.Edge.RIGHT, LayerShell.Edge.TOP): LayerShell.set_anchor(self, edge, True) LayerShell.set_margin(self, edge, 0) LayerShell.set_exclusive_zone(self, self.BAR_HEIGHT) def _monitor_connector(self) -> Optional[str]: """The Gdk connector name (e.g. "DP-1"), which equals the Hyprland monitor `name` — so it keys this bar's workspaces/active out of the global lists. None (unknown monitor) falls back to showing everything.""" mon = self._monitor if mon is None: return None try: return mon.get_connector() except Exception: return None def _monitor_width(self) -> int: mon = self._monitor if mon is None: display = Gdk.Display.get_default() monitors = display.get_monitors() if display is not None else None mon = monitors.get_item(0) if monitors is not None and monitors.get_n_items() else None # Gdk logical width (already scaled), the coordinate space layer-shell # lays out in. return mon.get_geometry().width if mon is not None else 1920 # -- background curve ------------------------------------------------------- def _arc_radius(self, width: float) -> float: half_w = width / 2 sag = self.ARC_SAG return (sag * sag + half_w * half_w) / (2 * sag) 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. 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: base_y = height * 0.62 cr.save() cr.set_source_rgba(*_VIOLET, 0.22) cr.set_line_width(1.2) cr.move_to(0, base_y + self._curve_dip_at(0, width)) steps = 64 for s in range(1, steps + 1): x = width * s / steps cr.line_to(x, base_y + self._curve_dip_at(x, width)) cr.stroke() cr.restore() # -- left zone: battery, launchers, workspace stations ----------------------- def _build_left(self) -> None: self._battery_widget = Gtk.Label() self._battery_widget.add_css_class("station-badge") self._battery_widget.add_css_class("station-battery") self._battery_widget.set_visible(False) self._left.append(self._battery_widget) orbit_btn = self._make_launcher(ICON_ORBIT, "Orbit Menu", "station-orbit", ["bash", "-c", "$HOME/.config/scripts/orbit-menu.sh 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=6) self._ws_row.add_css_class("station-ws-row") self._left.append(self._ws_row) def _make_launcher(self, icon: str, tooltip: str, css_class: str, argv: list[str]) -> Gtk.Button: btn = Gtk.Button(label=icon) 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)) return btn 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: nxt = child.get_next_sibling() self._ws_row.remove(child) child = nxt for ws in sorted(self._workspaces, key=lambda w: w.get("id", 0)): wid = ws.get("id") if not isinstance(wid, int) or wid < 0: continue # special:* workspaces aren't shown as numbered stations if self._mon_name is not None and ws.get("monitor") != self._mon_name: continue # this bar only shows its own monitor's workspaces selected = wid == self._active_ws has_windows = ws.get("windows", 0) > 0 # A populated workspace is a "station" (its glyph); an empty one shows # just the bare spaceship + number instead — never hidden. The focused # workspace is framed by the reticle, with the rocket prepended when # there's a station to dock at (skipped when empty, so no double ship). base = ICON_STATION if has_windows else ICON_SPACESHIP if selected: prefix = ICON_SPACESHIP if has_windows else "" label = f"-< {prefix}{base}{wid} >-" else: label = f"{base}{wid}" btn = Gtk.Button(label=label) btn.add_css_class("station-node") btn.set_has_frame(False) if selected: btn.add_css_class("station-node-active") btn.connect("clicked", lambda *_a, w=wid: hypr_ipc.focus_workspace(w)) self._ws_row.append(btn) def _on_active_workspace(self, _ws_id: int) -> None: # the `workspace` event only names the focused monitor's new workspace; # re-derive from `monitors` so this bar reflects its own monitor's active. self._active_ws = self._derive_active_ws() 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() self._center_label.set_label(f"{ICON_WINDOW} {text}" if text else ICON_WINDOW) # -- right zone: tray, volume, clock ----------------------------------------- def _build_right(self) -> None: pod = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) pod.add_css_class("station-tray-pod") badge = Gtk.DrawingArea() badge.set_size_request(20, 14) badge.set_draw_func(self._draw_station_pictogram) pod.append(badge) self._tray_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=2) pod.append(self._tray_box) self._right.append(pod) 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) scroll.connect("scroll", self._on_volume_scroll) self._volume_btn.add_controller(scroll) self._right.append(self._volume_btn) 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: """A minimal space-station glyph: a hub circle with two docking-arm ticks, drawn in Cairo rather than guessing at an unverified icon-font codepoint for "satellite".""" cy = h / 2 cx = w / 2 cr.save() cr.set_source_rgba(*_ACCENT, 0.9) cr.set_line_width(1.4) cr.arc(cx, cy, h * 0.28, 0, 2 * math.pi) cr.stroke() cr.move_to(0, cy) cr.line_to(cx - h * 0.28, cy) cr.move_to(cx + h * 0.28, cy) cr.line_to(w, cy) cr.stroke() cr.restore() def _refresh_tray(self) -> None: self._tray.build_into(self._tray_box) def _refresh_battery(self) -> bool: state = battery.read() if state is None: self._battery_widget.set_visible(False) else: self._battery_widget.set_visible(True) self._battery_widget.set_label(f"{state.icon} {state.percent}%") return True def _refresh_volume(self) -> bool: if volume_source.is_muted(): self._volume_btn.set_label(f"{ICON_VOLUME} muted") else: self._volume_btn.set_label(f"{ICON_VOLUME} {volume_source.get_percent()}%") return True def _on_volume_scroll(self, _ctrl, _dx: float, dy: float) -> bool: volume_source.adjust(-1 if dy > 0 else 1) GLib.timeout_add(80, self._refresh_volume_once) return True def _refresh_volume_once(self) -> bool: self._refresh_volume() return False def _refresh_clock(self) -> bool: self._clock_label.set_label(time.strftime("%H:%M")) return True # -- external control (D-Bus show/hide/toggle, see main.py) ------------------- def show_bar(self) -> None: LayerShell.set_exclusive_zone(self, self.BAR_HEIGHT) self.set_visible(True) self.present() self._ensure_tick() self._hologram.start_intro() def hide_bar(self) -> None: LayerShell.set_exclusive_zone(self, 0) self.set_visible(False) self._stop_tick() def toggle(self) -> None: if self.get_visible(): self.hide_bar() else: self.show_bar()