"""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 = 30 ARC_SAG = 6.0 # shallow — "narrow curved space", not horizon-dock's deep dip BATTERY_POLL_S = 20 CLOCK_POLL_S = 1 VOLUME_POLL_S = 3 # cheap fallback so hardware volume keys still reflect promptly def __init__(self, hologram_enabled: bool = True) -> None: super().__init__() self.add_css_class("station-bar-window") 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() self._bg = Gtk.DrawingArea() self._bg.set_size_request(int(self._width), self.BAR_HEIGHT) self._bg.set_draw_func(self._draw_background) self._bg.add_css_class("station-canvas") 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_size_request(int(self._width), self.BAR_HEIGHT) content.set_start_widget(self._left) content.set_center_widget(self._center_label) content.set_end_widget(self._right) self._hologram = HologramOverlay(enabled=hologram_enabled) overlay = Gtk.Overlay() overlay.set_child(self._bg) overlay.add_overlay(content) overlay.add_overlay(self._hologram.widget) 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 = hypr_ipc.initial_active_workspace_id() 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() # -- 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) 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_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 return mon.get_geometry().width if mon is not None else 1920 # -- background curve ------------------------------------------------------- def _arc_radius(self) -> float: half_w = self._width / 2 sag = self.ARC_SAG return (sag * sag + half_w * half_w) / (2 * sag) def _curve_dip_at(self, x: float) -> 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() 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)) 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.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.set_visible(False) self._left.append(self._battery_widget) orbit_btn = self._make_launcher(ICON_ORBIT, "Orbit Menu", ["bash", "-c", "$HOME/.config/scripts/orbit-menu.sh menu"]) astro_btn = self._make_launcher(ICON_ASTRO, "Astro Menu", ["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.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: btn = Gtk.Button(label=icon) btn.add_css_class("station-planet") btn.set_tooltip_text(tooltip) btn.connect("clicked", lambda *_a: subprocess.Popen( argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)) return btn def _refresh_workspaces(self) -> None: self._workspaces = hypr_ipc.initial_workspaces() self._rebuild_workspace_row() 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 selected = wid == self._active_ws btn = Gtk.Button(label=f"{ICON_SPACESHIP if selected else ICON_STATION}{wid}") btn.add_css_class("station-node") 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 self._rebuild_workspace_row() # -- 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.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._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() 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()