Dotfiles/desktopenvs/hyprdrive/horizon-dock/dock.py

381 lines
15 KiB
Python

"""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()