486 lines
21 KiB
Python
486 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)
|
|
|
|
|
|
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()
|