381 lines
15 KiB
Python
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()
|