"""horizon-dock — a hover-scrollable orbital dock, matching orbit-menu's visual language (same CyberQueer glow/hover "planet" node styling). Anchored full-width to the bottom of the screen. Toggled show/hide (not an always-resident hover-reveal dock); every time it's shown it fades+slides up into place from below the screen edge, and hiding reverses that — driven by the same per-frame tick-callback+easing pattern orbit-menu uses for its own animations. Three rows of circular "planet" icon buttons — Open Windows, Favorites, All Apps — each laid out evenly-spaced along a shared giant-circle arc (the "horizon" curve: a shallow dip toward the screen edges, radius derived from the monitor width so the sag reads consistently at any resolution). Hovering a row's Y-band selects it as the active scroll target — mouse-wheel scrolling only ever moves the currently-hovered row (Gtk.EventControllerScroll), independent of the other two. The tray satellite sits at the fixed right edge of the Favorites row: its position is never touched by that row's scroll repositioning, and it's added to the canvas last so it paints on top — meaning overflowing favorites scrolling under it are simply covered, reading as "going behind" the satellite. """ from __future__ import annotations import json import math import subprocess import time from typing import Callable, Optional import cairo import gi gi.require_version("Gtk", "4.0") gi.require_version("Gdk", "4.0") gi.require_version("Gsk", "4.0") gi.require_version("Graphene", "1.0") gi.require_version("Gtk4LayerShell", "1.0") from gi.repository import Gdk, GLib, Graphene, Gsk, Gtk # noqa: E402 from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402 import apps as apps_source import windows as windows_source from lib.hologram import HologramOverlay from tray import TrayHost # CyberQueer accent/violet — hardcoded here as orbit-menu's own orbit_menu.py # does for its Cairo drawing, since Cairo paints directly and doesn't see GTK # CSS @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) class HorizonDock(Gtk.Window): # The dock is one big "orbit node" (a glowing violet planet) whose centre sits # below the screen, so only its top cap rises above the bottom edge. The three # item rows are concentric rings on that cap and their icons ride it like # satellites orbiting the node — the same visual language as orbit-menu. # DOCK_HEIGHT is derived per-monitor from the two shape constraints below. # A horizon-perspective dock: the orbits are foreshortened ellipse arcs that # all converge to a HORIZON_Y line near the top, so they read as concentric # rings lying on a plane receding to the horizon (à la a low-angle view of a # ringed planet). Icons ride the front (near) edge of each ring. Short. DOCK_HEIGHT = 210 # placeholder; recomputed per width (see _dock_height) PLANET_SIZE = 52 ITEM_SPACING = 66.0 # center-to-center distance between planets in a row BASE_MARGIN = 16.0 # the horizon line sits this far above the bottom edge PERSPECTIVE_SQUASH = 0.19 # vertical foreshortening: low = flat, far-away horizon ROW_ORDER = ["windows", "favorites", "apps"] # far -> near, top(horizon) -> bottom(front) # each ring's horizontal radius as a fraction of the screen width; nearer rings # (apps) are wider, farther rings (windows) narrower — that's the perspective. ROW_HALFWIDTH = {"windows": 0.17, "favorites": 0.245, "apps": 0.32} ROW_TITLE = {"windows": "Open Windows", "favorites": "Favorites", "apps": "All Apps"} HOVER_BAND = 46.0 # how near the cursor must be to a ring to select it GLYPH_FONT = "Agave Nerd Font Mono" # decorative glyphs sprinkled along the orbits between icons (nerd-font) ORBIT_GLYPHS = ["\uf444", "\uf10c", "\U000f0471", "\U000f1383", "\uf005"] TRAY_SIZE = 40 TRAY_MARGIN = 30.0 SCROLL_SENSITIVITY = 0.9 REVEAL_DURATION = 0.28 def __init__(self, on_close: Optional[Callable[[], None]] = None, tray_enabled: bool = True, hologram_enabled: bool = True): super().__init__() self.add_css_class("horizon-dock-window") self._on_close = on_close self._tray_enabled = False # tray removed — the dock is a clean orbit node now self._apps = apps_source.AppSource() self._tray = None self._width = self._monitor_width() self.DOCK_HEIGHT = self._dock_height() # per-monitor, shadows the class default self._scroll_offset = {row: 0.0 for row in self.ROW_ORDER} self._items: dict[str, list] = {row: [] for row in self.ROW_ORDER} # source objects self._widgets: dict[str, list[Gtk.Widget]] = {row: [] for row in self.ROW_ORDER} self._hovered_row: Optional[str] = None self._reveal_progress = 0.0 self._reveal_target = 0.0 self._sat_time = 0.0 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.DOCK_HEIGHT) self._bg.set_draw_func(self._draw_background) self._bg.add_css_class("horizon-canvas") self._content = Gtk.Fixed() self._content.set_size_request(int(self._width), self.DOCK_HEIGHT) self._content.put(self._bg, 0, 0) self._viewport = Gtk.Fixed() self._viewport.set_size_request(int(self._width), self.DOCK_HEIGHT) self._viewport.put(self._content, 0, 0) # A Gtk.Fixed grows to the bounding box of ALL its children, and the dock # places every app button at an absolute position (many far off-screen, # each with an arc "dip" that grows unbounded with distance from centre). # So the viewport's natural size balloons to thousands of px in both axes, # and the layer-shell surface adopts that size — covering the whole screen # with an (input-grabbing) surface. Clip it: a base Overlay sized ONLY to a # DOCK_HEIGHT sizer, with the viewport added as a non-measured overlay # child and overflow hidden, pins the window to exactly (width x # DOCK_HEIGHT) no matter how large the Fixed inside gets. self._sizer = Gtk.DrawingArea() self._sizer.set_size_request(int(self._width), self.DOCK_HEIGHT) clip = Gtk.Overlay() clip.set_overflow(Gtk.Overflow.HIDDEN) clip.set_child(self._sizer) clip.add_overlay(self._viewport) clip.set_measure_overlay(self._viewport, False) self._hologram = HologramOverlay(enabled=hologram_enabled, clip_func=self._holo_clip) overlay = Gtk.Overlay() overlay.set_child(clip) overlay.add_overlay(self._hologram.widget) self.set_child(overlay) motion = Gtk.EventControllerMotion() motion.connect("motion", self._on_motion) motion.connect("leave", self._on_motion_leave) self.add_controller(motion) scroll = Gtk.EventControllerScroll() scroll.set_flags(Gtk.EventControllerScrollFlags.VERTICAL) scroll.connect("scroll", self._on_scroll) self.add_controller(scroll) self._tray_satellite: Optional[Gtk.Widget] = None self._tray_popover: Optional[Gtk.Popover] = None self.set_visible(False) self._rebuild_all() # -- layer shell ------------------------------------------------------ def _init_layer_shell(self) -> None: LayerShell.init_for_window(self) LayerShell.set_layer(self, LayerShell.Layer.TOP) LayerShell.set_namespace(self, "horizon-dock") LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.NONE) LayerShell.set_exclusive_zone(self, 0) for edge in (LayerShell.Edge.LEFT, LayerShell.Edge.RIGHT, LayerShell.Edge.BOTTOM): LayerShell.set_anchor(self, edge, True) LayerShell.set_margin(self, edge, 0) def _focused_gdk_monitor(self): """The Gdk monitor for Hyprland's currently-focused output, so the dock opens on (and is sized to) whichever monitor you're on — not always monitor 0. Falls back to monitor 0 if the lookup fails.""" display = Gdk.Display.get_default() if display is None: return None monitors = display.get_monitors() n = monitors.get_n_items() if monitors is not None else 0 name = None try: out = subprocess.run(["hyprctl", "-j", "monitors"], capture_output=True, text=True, timeout=1).stdout for m in json.loads(out): if m.get("focused"): name = m.get("name") break except Exception: name = None if name is not None: for i in range(n): mon = monitors.get_item(i) if mon is not None and mon.get_connector() == name: return mon return monitors.get_item(0) if n else None def _monitor_width(self) -> int: mon = self._focused_gdk_monitor() # Gdk logical width (already divided by the monitor's scale), which is the # coordinate space layer-shell / GTK lay out in — not hyprctl's raw px. return mon.get_geometry().width if mon is not None else 1920 # -- layout math (horizon perspective) ---------------------------------- def _ring_rx(self, row: str) -> float: return self.ROW_HALFWIDTH[row] * self._width def _ring_ry(self, row: str) -> float: return self._ring_rx(row) * self.PERSPECTIVE_SQUASH def _dock_height(self) -> int: ry_max = max(self._ring_ry(r) for r in self.ROW_ORDER) return int(self.BASE_MARGIN + ry_max + self.PLANET_SIZE / 2 + 14) def _base_y(self) -> float: """The horizon line: near the BOTTOM of the dock. Orbits arch UP from it.""" return self.DOCK_HEIGHT - self.BASE_MARGIN def _row_y_at(self, row: str, x: float) -> float: """y on the near edge of a ring's foreshortened ellipse — highest at the centre, curving back down to the horizon line toward the sides.""" cx = self._width / 2 rx, ry = self._ring_rx(row), self._ring_ry(row) dx = x - cx if abs(dx) >= rx: return self._base_y() return self._base_y() - ry * math.sqrt(max(1.0 - (dx / rx) ** 2, 0.0)) def _center_offset(self, row: str) -> float: """The scroll offset that centres a row's items on the arc: the middle item lands at the centre, so a short row is a centred cluster and a long row fills the arc symmetrically (overflowing/​fading equally on both sides).""" return max(0.0, (len(self._items[row]) - 1) / 2.0) def _item_position(self, row: str, index: int) -> tuple[float, float]: slot = index - self._scroll_offset[row] x = self._width / 2 + slot * self.ITEM_SPACING return x, self._row_y_at(row, x) def _clamp_scroll(self, row: str) -> None: count = len(self._items[row]) max_offset = max(0.0, count - 1) self._scroll_offset[row] = min(max(self._scroll_offset[row], 0.0), max_offset) # -- planet buttons ------------------------------------------------------- def _make_planet(self, icon_name: str, tooltip: str, size: int = PLANET_SIZE) -> Gtk.Button: btn = Gtk.Button() btn.set_has_frame(False) btn.add_css_class("horizon-planet") btn.set_size_request(size, size) btn.set_tooltip_text(tooltip) image = Gtk.Image.new_from_icon_name(icon_name or "application-x-executable") image.set_pixel_size(int(size * 0.55)) btn.set_child(image) return btn def _rebuild_row(self, row: str) -> None: for w in self._widgets[row]: if w.get_parent() is not None: self._content.remove(w) self._widgets[row] = [] if row == "windows": self._items[row] = windows_source.open_windows() elif row == "favorites": self._items[row] = self._apps.favorite_apps() else: self._items[row] = self._apps.all_apps() # start each orbit centred on the arc (re-centred whenever it's rebuilt) self._scroll_offset[row] = self._center_offset(row) self._clamp_scroll(row) for i, item in enumerate(self._items[row]): btn = self._build_item_button(row, item) x, y = self._item_position(row, i) self._place_item(row, btn, x, y, put=True) self._widgets[row].append(btn) self._bg.queue_draw() def _build_item_button(self, row: str, item) -> Gtk.Button: if row == "windows": icon = self._apps.icon_for_window(item) title = item.get("title") or item.get("class") or "?" btn = self._make_planet(icon, title) addr = item.get("address") btn.connect("clicked", lambda *_a, a=addr: windows_source.focus_window(a)) else: btn = self._make_planet(item.get_icon_name(), item.get_name() or "") btn.connect("clicked", lambda *_a, a=item: self._apps.launch(a)) if row == "apps": right_click = Gtk.GestureClick(button=3) right_click.connect("pressed", lambda *_a, a=item: self._toggle_favorite(a)) btn.add_controller(right_click) return btn def _toggle_favorite(self, item) -> None: self._apps.toggle_favorite(item) self._rebuild_row("favorites") def _edge_fade(self, row: str, x: float) -> float: """1.0 in the middle of an orbit, smoothly fading to 0 as an icon nears the ring's horizon extremity — so overflowing icons dissolve into the horizon at the sides instead of piling up / being hard-clipped off the edge.""" rx = self._ring_rx(row) dx = abs(x - self._width / 2) start = rx * 0.68 if dx <= start: return 1.0 if dx >= rx: return 0.0 t = (dx - start) / (rx - start) return 1.0 - t * t * (3 - 2 * t) # smoothstep down def _place_item(self, row: str, w: Gtk.Widget, x: float, y: float, put: bool) -> None: fx, fy = x - self.PLANET_SIZE / 2, y - self.PLANET_SIZE / 2 if put: self._content.put(w, fx, fy) else: self._content.move(w, fx, fy) fade = self._edge_fade(row, x) w.set_opacity(fade) w.set_can_target(fade > 0.05) # faded-out icons don't grab clicks w.set_sensitive(fade > 0.05) def _reflow_row(self, row: str) -> None: for i, w in enumerate(self._widgets[row]): x, y = self._item_position(row, i) self._place_item(row, w, x, y, put=False) def _rebuild_all(self) -> None: for row in self.ROW_ORDER: self._rebuild_row(row) # -- hover / scroll routing ------------------------------------------------- def _row_at(self, x: float, y: float) -> Optional[str]: """The ring nearest the cursor (by vertical distance to its arc at x), within HOVER_BAND — so scrolling targets whichever orbit you're over.""" best, best_d = None, self.HOVER_BAND for row in self.ROW_ORDER: d = abs(y - self._row_y_at(row, x)) if d < best_d: best, best_d = row, d return best def _on_motion(self, _ctrl, x: float, y: float) -> None: row = self._row_at(x, y) if row != self._hovered_row: self._hovered_row = row self._bg.queue_draw() def _on_motion_leave(self, _ctrl) -> None: if self._hovered_row is not None: self._hovered_row = None self._bg.queue_draw() def _on_scroll(self, _ctrl, _dx: float, dy: float) -> bool: row = self._hovered_row if row is None: return False self._scroll_offset[row] += dy * self.SCROLL_SENSITIVITY self._clamp_scroll(row) self._reflow_row(row) return True # -- background: the giant orbit node + its rings ----------------------- def _draw_background(self, _area, cr, width: float, height: float) -> None: self._draw_node(cr) for row in self.ROW_ORDER: self._draw_ring(cr, row, hovered=(row == self._hovered_row)) self._draw_orbit_glyphs(cr, row) self._draw_center_sphere(cr) def _front_arc_path(self, cr, rx: float, ry: float, close_on_horizon: bool) -> None: """Trace the near edge of a ring's foreshortened ellipse (arching UP) from the left horizon point across to the right one; optionally close it back along the horizon line to make a fillable semi-ellipse dome.""" cx = self._width / 2 base = self._base_y() steps = 72 cr.move_to(cx - rx, base) for s in range(1, steps + 1): x = cx - rx + 2 * rx * s / steps dx = x - cx y = base - ry * math.sqrt(max(1.0 - (dx / rx) ** 2, 0.0)) cr.line_to(x, y) if close_on_horizon: cr.close_path() def _draw_node(self, cr) -> None: """The 'planet' the orbits sit on: the widest ring's foreshortened dome filled with a vertical violet gradient (brighter at the arching near rim), with a soft glowing edge — a lit surface curving up from the horizon.""" rx = self._ring_rx("apps") * 1.05 ry = self._ring_ry("apps") * 1.05 base = self._base_y() cr.save() self._front_arc_path(cr, rx, ry, close_on_horizon=True) grad = cairo.LinearGradient(0, base - ry, 0, base) grad.add_color_stop_rgba(0.0, *_VIOLET, 0.30) grad.add_color_stop_rgba(1.0, *_VIOLET, 0.06) cr.set_source(grad) cr.fill() for lw, a in ((12.0, 0.05), (6.0, 0.10), (2.2, 0.5)): self._front_arc_path(cr, rx, ry, close_on_horizon=False) cr.set_source_rgba(*_ACCENT, a) cr.set_line_width(lw) cr.stroke() cr.restore() def _draw_ring(self, cr, row: str, hovered: bool) -> None: """A faint guide arc along a row's foreshortened orbit; the hovered ring brightens to accent so you can see which orbit the scroll will move.""" cr.save() if hovered: cr.set_source_rgba(*_ACCENT, 0.4) cr.set_line_width(2.0) else: cr.set_source_rgba(*_VIOLET, 0.22) cr.set_line_width(1.2) self._front_arc_path(cr, self._ring_rx(row), self._ring_ry(row), close_on_horizon=False) cr.stroke() cr.restore() def _holo_clip(self, cr, width: float, height: float) -> None: """Path-setter handed to the hologram overlay: trace the node dome (widest ring, expanded to cover the icon tops) so the scanlines are clipped to the UI shape instead of painting a full-height rectangle above it.""" pad = self.PLANET_SIZE / 2 + 12 self._front_arc_path(cr, self._ring_rx("apps") + pad, self._ring_ry("apps") + pad, close_on_horizon=True) def _draw_center_sphere(self, cr) -> None: """The 'planet': a glowing sphere sitting on the surface below the orbits, displaying the date and time. Sits in the clear central column so the icon rows don't cover the readout.""" cx = self._width / 2 base = self._base_y() # Sit in the clear band BELOW the lowest icon arch (windows) so the icon # rows never cover the readout. band_top = base - self._ring_ry("windows") + self.PLANET_SIZE / 2 cy = (band_top + base) / 2 rad = max(26.0, min((base - band_top) / 2 - 2, 40.0)) cr.save() grad = cairo.RadialGradient(cx - rad * 0.3, cy - rad * 0.35, rad * 0.1, cx, cy, rad) grad.add_color_stop_rgba(0.0, *_VIOLET, 0.9) grad.add_color_stop_rgba(1.0, *_VIOLET, 0.4) cr.arc(cx, cy, rad, 0, 2 * math.pi) cr.set_source(grad) cr.fill() for lw, a in ((11.0, 0.06), (5.5, 0.12), (2.4, 0.7)): cr.arc(cx, cy, rad, 0, 2 * math.pi) cr.set_source_rgba(*_ACCENT, a) cr.set_line_width(lw) cr.stroke() # date + time readout cr.select_font_face(self.GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) clock = time.strftime("%H:%M") cr.set_font_size(rad * 0.62) ext = cr.text_extents(clock) cr.move_to(cx - ext.width / 2 - ext.x_bearing, cy - ext.height / 2 - ext.y_bearing - rad * 0.16) cr.set_source_rgba(0.98, 0.92, 0.99, 0.98) cr.show_text(clock) date = time.strftime("%a %d %b") cr.select_font_face(self.GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) cr.set_font_size(rad * 0.34) ext2 = cr.text_extents(date) cr.move_to(cx - ext2.width / 2 - ext2.x_bearing, cy + rad * 0.5) cr.set_source_rgba(*_ACCENT, 0.95) cr.show_text(date) cr.restore() def _draw_orbit_glyphs(self, cr, row: str) -> None: """Sprinkle small faint nerd-font glyphs along a ring, on the integer slots between the (half-slot) icons — the orbit-menu satellites' glyph language, so the empty stretches of orbit still read as 'in orbit'.""" cx = self._width / 2 rx, ry = self._ring_rx(row), self._ring_ry(row) cr.save() cr.select_font_face(self.GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) cr.set_font_size(12) n = int(rx / self.ITEM_SPACING) + 1 gi = 0 for k in range(-n, n + 1): x = cx + k * self.ITEM_SPACING # integer slots = the gaps between icons dx = x - cx if abs(dx) >= rx or x < 8 or x > self._width - 8: continue y = self._base_y() - ry * math.sqrt(max(1.0 - (dx / rx) ** 2, 0.0)) glyph = self.ORBIT_GLYPHS[gi % len(self.ORBIT_GLYPHS)] gi += 1 ext = cr.text_extents(glyph) cr.move_to(x - ext.width / 2 - ext.x_bearing, y - ext.height / 2 - ext.y_bearing) cr.set_source_rgba(*_VIOLET, 0.5) cr.show_text(glyph) cr.restore() # -- reveal animation ----------------------------------------------------- def _reveal_transform(self, progress: float) -> Gsk.Transform: offset = (1.0 - progress) * self.DOCK_HEIGHT t = Gsk.Transform.new() t = t.translate(Graphene.Point().init(0, offset)) return t 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._sat_time += dt self._hologram.tick(dt) ease = 1 - math.exp(-dt * (1.0 / self.REVEAL_DURATION) * 3.2) self._reveal_progress += (self._reveal_target - self._reveal_progress) * ease if abs(self._reveal_target - self._reveal_progress) < 0.002: self._reveal_progress = self._reveal_target self._viewport.set_child_transform(self._content, self._reveal_transform(self._reveal_progress)) self._content.set_opacity(max(0.0, min(1.0, self._reveal_progress))) if self._reveal_progress == self._reveal_target and self._reveal_target == 0.0: self.set_visible(False) self._tick_id = None # GTK drops the callback itself on a False return if self._on_close: self._on_close() return False return True 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 # -- external control ---------------------------------------------------- def _apply_width(self, width: int) -> None: self._width = width self.DOCK_HEIGHT = self._dock_height() for w in (self._bg, self._content, self._viewport, self._sizer): w.set_size_request(int(width), self.DOCK_HEIGHT) def show_dock(self) -> None: mon = self._focused_gdk_monitor() if mon is not None: LayerShell.set_monitor(self, mon) self._apply_width(mon.get_geometry().width) else: self._apply_width(self._monitor_width()) self._rebuild_all() self._reveal_target = 1.0 self.set_visible(True) self.present() self._ensure_tick() self._hologram.start_intro() if getattr(self, "_clock_timer_id", None) is None: self._clock_timer_id = GLib.timeout_add_seconds(15, self._refresh_clock) def _refresh_clock(self) -> bool: if self.get_visible() and self._reveal_target == 1.0: self._bg.queue_draw() # repaint the planet's date/time return True self._clock_timer_id = None return False def hide_dock(self) -> None: self._reveal_target = 0.0 self._ensure_tick() def toggle(self) -> None: if self.get_visible() and self._reveal_target == 1.0: self.hide_dock() else: self.show_dock()