661 lines
30 KiB
Python
661 lines
30 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 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()
|