580 lines
25 KiB
Python
580 lines
25 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 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()
|