"""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 math from typing import Callable, Optional 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, 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): DOCK_HEIGHT = 214 PLANET_SIZE = 52 ITEM_SPACING = 66.0 # center-to-center distance between planets in a row ARC_SAG = 34.0 # how far a row dips toward the screen edges vs. its center ROW_ORDER = ["windows", "favorites", "apps"] # back -> front, top -> bottom ROW_Y = {"windows": 46.0, "favorites": 112.0, "apps": 176.0} ROW_BAND_TOP = {"windows": 0.0, "favorites": 79.0, "apps": 144.0} ROW_TITLE = {"windows": "Open Windows", "favorites": "Favorites", "apps": "All Apps"} 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 = tray_enabled self._apps = apps_source.AppSource() self._tray = TrayHost() if tray_enabled else None self._width = self._monitor_width() 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) self._hologram = HologramOverlay(enabled=hologram_enabled) overlay = Gtk.Overlay() overlay.set_child(self._viewport) 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 _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 # -- layout math -------------------------------------------------------- def _arc_radius(self) -> float: half_w = self._width / 2 sag = self.ARC_SAG return (sag * sag + half_w * half_w) / (2 * sag) def _row_y_at(self, row: str, x: float) -> float: row_y = self.ROW_Y[row] dx = x - self._width / 2 r = self._arc_radius() dip = r - math.sqrt(max(r * r - dx * dx, 0.0)) return row_y + dip 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() 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._content.put(btn, x - self.PLANET_SIZE / 2, y - self.PLANET_SIZE / 2) self._widgets[row].append(btn) if row == "favorites": self._place_tray_satellite() 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 _reflow_row(self, row: str) -> None: for i, w in enumerate(self._widgets[row]): x, y = self._item_position(row, i) self._content.move(w, x - self.PLANET_SIZE / 2, y - self.PLANET_SIZE / 2) def _rebuild_all(self) -> None: for row in self.ROW_ORDER: self._rebuild_row(row) # -- tray satellite ------------------------------------------------------- def _place_tray_satellite(self) -> None: if not self._tray_enabled: return if self._tray_satellite is not None and self._tray_satellite.get_parent() is not None: self._content.remove(self._tray_satellite) x = self._width - self.TRAY_MARGIN y = self.ROW_Y["favorites"] btn = Gtk.MenuButton() btn.add_css_class("horizon-planet") btn.add_css_class("horizon-tray-satellite") btn.set_size_request(self.TRAY_SIZE, self.TRAY_SIZE) btn.set_tooltip_text("Tray") icon = Gtk.Image.new_from_icon_name("view-list-symbolic") icon.set_pixel_size(int(self.TRAY_SIZE * 0.5)) btn.set_child(icon) popover = Gtk.Popover() popover.add_css_class("horizon-tray-popover") popover.set_child(self._build_tray_box()) btn.set_popover(popover) self._tray_popover = popover self._content.put(btn, x - self.TRAY_SIZE / 2, y - self.TRAY_SIZE / 2) self._tray_satellite = btn def _build_tray_box(self) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) box.add_css_class("horizon-tray-box") if self._tray is None: box.append(Gtk.Label(label="Tray disabled")) return box self._tray.build_into(box) return box # -- hover / scroll routing ------------------------------------------------- def _row_at_y(self, y: float) -> Optional[str]: for row in reversed(self.ROW_ORDER): if y >= self.ROW_BAND_TOP[row]: return row return None def _on_motion(self, _ctrl, _x: float, y: float) -> None: row = self._row_at_y(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 horizon arcs + hover band -------------------------- def _draw_background(self, _area, cr, width: float, height: float) -> None: for row in self.ROW_ORDER: self._draw_row_arc(cr, row, width) if self._hovered_row is not None: self._draw_hover_band(cr, self._hovered_row, width) def _draw_row_arc(self, cr, row: str, width: float) -> None: cr.save() cr.set_source_rgba(*_VIOLET, 0.16) cr.set_line_width(1.2) cr.move_to(0, self._row_y_at(row, 0)) steps = 48 for s in range(1, steps + 1): x = width * s / steps cr.line_to(x, self._row_y_at(row, x)) cr.stroke() cr.restore() def _draw_hover_band(self, cr, row: str, width: float) -> None: top = self.ROW_BAND_TOP[row] bottom_candidates = [self.ROW_BAND_TOP[r] for r in self.ROW_ORDER if self.ROW_BAND_TOP[r] > top] bottom = min(bottom_candidates) if bottom_candidates else self.DOCK_HEIGHT cr.save() cr.set_source_rgba(*_ACCENT, 0.05) cr.rectangle(0, top, width, bottom - top) cr.fill() 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 show_dock(self) -> None: self._width = self._monitor_width() self._rebuild_all() self._reveal_target = 1.0 self.set_visible(True) self.present() self._ensure_tick() 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()