"""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 random 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("Gtk4LayerShell", "1.0") from gi.repository import Gdk, GLib, 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"] # Two ornamental satellite rings framing the (innermost) windows orbit \u2014 one # just inside it, one just outside \u2014 as fractions of the windows ring radius. SAT_RING_FACTORS = (0.60, 1.20) TRAY_SIZE = 40 TRAY_MARGIN = 30.0 SCROLL_SENSITIVITY = 0.9 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._sat_time = 0.0 self._last_tick: Optional[float] = None self._tick_id: Optional[int] = None # cursor tracking for the mouse-reactive ornamental satellites self._mouse_x = self._width / 2 self._mouse_y = 0.0 self._mouse_sx = self._mouse_x # smoothed (eased) toward the raw position self._mouse_sy = self._mouse_y self._satellites = self._make_satellites() 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, fade_widget=self._content, intro_duration=2.4) 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: self._mouse_x, self._mouse_y = x, y # drives the ornamental satellites 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_satellites(cr) 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 big holographic world rising from the horizon. Its top fills the clear central band (icon buttons float in front of it), while its bottom quarter sinks below the screen edge and is clipped away by the dock's overflow — so it reads as a huge planet, not a small bead. Translucent body (the desktop/scanlines show through) with a strong outer glow + rim.""" cx = self._width / 2 base = self._base_y() # Clear band below the lowest icon arch (windows); the readout lives here. band_top = base - self._ring_ry("windows") + self.PLANET_SIZE / 2 bottom_edge = float(self.DOCK_HEIGHT) # dock surface bottom = screen bottom vis_h = bottom_edge - band_top # visible vertical extent of the planet # top anchored at band_top; radius sized so ~1/4 of the disc falls past the # bottom edge (top 3/4 == the visible band): vis_h = 1.5 * rad. rad = max(48.0, vis_h / 1.5) cy = band_top + rad cr.save() # gentle breathing pulse so the planet reads as a live, radiant body pulse = 0.82 + 0.18 * math.sin(self._sat_time * 2.2) # wide outer bloom halo — the planet glows well beyond its own disc halo_r = rad * 1.9 halo = cairo.RadialGradient(cx, cy, rad * 0.5, cx, cy, halo_r) halo.add_color_stop_rgba(0.0, *_ACCENT, 0.40 * pulse) halo.add_color_stop_rgba(0.45, *_VIOLET, 0.18 * pulse) halo.add_color_stop_rgba(1.0, *_VIOLET, 0.0) cr.arc(cx, cy, halo_r, 0, 2 * math.pi) cr.set_source(halo) cr.fill() # translucent holographic body: a soft see-through core fading to a nearly # transparent rim, so the blur/scanlines/desktop read through it grad = cairo.RadialGradient(cx - rad * 0.3, cy - rad * 0.35, rad * 0.05, cx, cy, rad) grad.add_color_stop_rgba(0.0, 1.0, 0.82, 0.98, 0.48) grad.add_color_stop_rgba(0.4, *_VIOLET, 0.36) grad.add_color_stop_rgba(1.0, *_VIOLET, 0.10) cr.arc(cx, cy, rad, 0, 2 * math.pi) cr.set_source(grad) cr.fill() # faint specular sheen (top-left) for a glassy 3D read spec = cairo.RadialGradient(cx - rad * 0.34, cy - rad * 0.42, 0, cx - rad * 0.34, cy - rad * 0.42, rad * 0.5) spec.add_color_stop_rgba(0.0, 1, 1, 1, 0.38 * pulse) spec.add_color_stop_rgba(1.0, 1, 1, 1, 0.0) cr.arc(cx, cy, rad, 0, 2 * math.pi) cr.set_source(spec) cr.fill() # strong, wide rim glow rings, capped by a crisp bright edge for lw, a in ((24.0, 0.06 * pulse), (14.0, 0.12 * pulse), (7.0, 0.22 * pulse), (2.6, 0.85)): 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, centred in the VISIBLE band (not at cy, which is low) vis_cy = (band_top + bottom_edge) / 2 cr.select_font_face(self.GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) clock = time.strftime("%H:%M") cr.set_font_size(vis_h * 0.40) ext = cr.text_extents(clock) cr.move_to(cx - ext.width / 2 - ext.x_bearing, vis_cy - ext.height / 2 - ext.y_bearing - vis_h * 0.05) 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(vis_h * 0.20) ext2 = cr.text_extents(date) cr.move_to(cx - ext2.width / 2 - ext2.x_bearing, vis_cy + vis_h * 0.30) cr.set_source_rgba(*_ACCENT, 0.95) cr.show_text(date) cr.restore() def _make_satellites(self) -> list[dict]: """A handful of ornamental glyphs, split across the two satellite rings that frame the windows orbit (SAT_RING_FACTORS). Each ring gets a random 3–7 of them at fixed base angles, and each satellite's *motion source* is picked at random — it either leans TOWARD the cursor, or is driven by the cursor's X, or by the cursor's Y — so the pair of rings reads as a lively little swarm.""" sats: list[dict] = [] for fac in self.SAT_RING_FACTORS: for _ in range(random.randint(3, 7)): sats.append({ "fac": fac, # base position on the visible (upper) half of the ring ellipse "angle": random.uniform(0.16 * math.pi, 0.84 * math.pi), "glyph": random.choice(self.ORBIT_GLYPHS), "size": random.uniform(11.0, 15.0), "source": random.choice(("toward", "mouseX", "mouseY")), "amp": random.uniform(16.0, 32.0), "wob_amp": random.uniform(1.5, 3.5), "wob_speed": random.uniform(1.0, 2.0), "phase": random.uniform(0.0, 2.0 * math.pi), }) return sats def _draw_satellites(self, cr) -> None: """Draw the two satellite rings (faint guide arcs) and their mouse-reactive glyphs. Drawn on the background, so they sit behind the icons and sphere.""" cx = self._width / 2 base = self._base_y() win_rx = self._ring_rx("windows") mx, my = self._mouse_sx, self._mouse_sy half_w = max(1.0, self._width / 2) # faint guide arcs for the two rings cr.save() for fac in self.SAT_RING_FACTORS: self._front_arc_path(cr, win_rx * fac, win_rx * fac * self.PERSPECTIVE_SQUASH, close_on_horizon=False) cr.set_source_rgba(*_VIOLET, 0.14) cr.set_line_width(1.0) cr.stroke() cr.select_font_face(self.GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) for s in self._satellites: rx = win_rx * s["fac"] ry = rx * self.PERSPECTIVE_SQUASH bx = cx + rx * math.cos(s["angle"]) by = base - ry * math.sin(s["angle"]) wob = s["wob_amp"] * math.sin(self._sat_time * s["wob_speed"] + s["phase"]) ox = oy = 0.0 if s["source"] == "toward": ddx, ddy = mx - bx, my - by d = math.hypot(ddx, ddy) or 1.0 ox, oy = ddx / d * s["amp"], ddy / d * s["amp"] # lean toward the cursor elif s["source"] == "mouseX": ox = (mx - cx) / half_w * s["amp"] * 2.0 # driven by cursor X else: # mouseY oy = (my - base) / half_w * s["amp"] * 2.0 # driven by cursor Y x = bx + ox + wob y = by + oy + wob * 0.5 border_w = min(1.0, x / 44.0, (self._width - x) / 44.0) if border_w <= 0.0: continue glyph = s["glyph"] cr.set_font_size(s["size"]) 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.7 * border_w) cr.show_text(glyph) cr.restore() # -- animation loop ------------------------------------------------------- # No slide/fade reveal: the dock simply appears and the hologram's # "materialise from static" intro (start_intro, below) is the whole opening # effect — matching every other Cosmonaut Shell surface. The tick only drives # the hologram animation while the dock is shown. 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 # ease the smoothed cursor toward the raw position (frame-rate independent) e = 1.0 - math.exp(-dt * 6.0) self._mouse_sx += (self._mouse_x - self._mouse_sx) * e self._mouse_sy += (self._mouse_y - self._mouse_sy) * e self._hologram.tick(dt) self._bg.queue_draw() # animate the planet's glow + the satellites 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 # Reset the frame-time baseline so the next show's first tick has dt=0. # Otherwise a warm reopen sees dt = (time the dock was hidden), which would # blow past the whole intro in one frame and skip the materialise animation. self._last_tick = 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.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(): self._bg.queue_draw() # repaint the planet's date/time return True self._clock_timer_id = None return False def hide_dock(self) -> None: # Dissolve into static first, then hide via _finish_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: self.set_visible(False) self._stop_tick() if self._on_close: self._on_close() def toggle(self) -> None: if self.get_visible(): self.hide_dock() else: self.show_dock()