Dotfiles/desktopenvs/hyprdrive/station-bar/bar.py

358 lines
14 KiB
Python

"""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()