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

580 lines
25 KiB
Python
Raw Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

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