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

497 lines
21 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)
ICON_FUEL = chr(0xf07c5) # md-fuel — the "battery" reads as a fuel tank
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, fade_widget=content)
# 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:
# the ship runs on "fuel", not a battery — a fixed fuel-tank glyph in
# place of the charge-level battery icon (only shown when a battery
# actually exists, e.g. on a laptop).
self._battery_widget.set_visible(True)
self._battery_widget.set_label(f"{ICON_FUEL} {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:
# Dissolve into static first, then release the exclusive zone + hide.
if self._hologram.enabled and self._tick_id is not None:
self._hologram.start_outro(self._finish_hide)
else:
self._finish_hide()
def _finish_hide(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()