Files
mitus/cht/ui/frames_panel.py
2026-04-03 07:18:42 -03:00

226 lines
7.6 KiB
Python

"""
FramesPanel: horizontal thumbnail strip with single-selection.
Owns its own widget tree, selection state, scroll, and row management.
Window reads `selected` property and calls methods — no internal state leaks.
"""
import logging
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("GdkPixbuf", "2.0")
from gi.repository import Gtk, Gdk, GLib, Pango, GdkPixbuf, GObject
from cht.config import SCENE_THRESHOLD
log = logging.getLogger(__name__)
class FramesPanel(Gtk.Box):
"""Horizontal thumbnail strip with single-selection."""
__gsignals__ = {
"selection-changed": (GObject.SignalFlags.RUN_FIRST, None, ()),
"capture-requested": (GObject.SignalFlags.RUN_FIRST, None, ()),
"threshold-changed": (GObject.SignalFlags.RUN_FIRST, None, (float,)),
"seek-requested": (GObject.SignalFlags.RUN_FIRST, None, (float,)),
}
def __init__(self, **kwargs):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0, **kwargs)
self._widgets: dict[str, Gtk.Box] = {}
self._timestamps: dict[str, float] = {}
self._order: list[str] = []
self._selected: str | None = None
# Header
header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
header.set_margin_top(4)
header.set_margin_bottom(4)
header.set_margin_start(8)
header.set_margin_end(8)
self._scene_label = Gtk.Label(label=f"Frames (scene: {SCENE_THRESHOLD:.2f})")
self._scene_label.add_css_class("heading")
header.append(self._scene_label)
scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0.01, 0.50, 0.01)
scale.set_value(SCENE_THRESHOLD)
scale.set_hexpand(True)
scale.set_draw_value(False)
scale.connect("value-changed", self._on_threshold_changed)
header.append(scale)
capture_btn = Gtk.Button(label="Capture")
capture_btn.add_css_class("flat")
capture_btn.connect("clicked", lambda b: self.emit("capture-requested"))
header.append(capture_btn)
self.append(header)
# Scroll strip
self._scroll = Gtk.ScrolledWindow()
self._scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.NEVER)
self._scroll.set_min_content_height(168)
self._scroll.set_size_request(-1, 168)
self._strip = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
self._strip.set_margin_start(4)
self._strip.set_margin_end(4)
self._strip.set_margin_top(4)
self._strip.set_margin_bottom(4)
self._scroll.set_child(self._strip)
self.append(self._scroll)
# -- Properties --
@property
def selected(self) -> str | None:
return self._selected
@property
def order(self) -> list[str]:
return list(self._order)
# -- Public API --
def add_item(self, frame_id: str, pixbuf, timestamp: float, auto_select: bool = True):
"""Add a single thumbnail. Optionally auto-select it."""
if frame_id in self._widgets:
return
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
box.set_size_request(256, -1)
texture = Gdk.Texture.new_for_pixbuf(pixbuf)
pic = Gtk.Picture.new_for_paintable(texture)
pic.set_content_fit(Gtk.ContentFit.CONTAIN)
pic.set_size_request(256, 144)
pic.set_vexpand(False)
box.append(pic)
m, s = divmod(int(timestamp), 60)
label = Gtk.Label(label=f"{frame_id} [{m:02d}:{s:02d}]")
label.add_css_class("caption")
label.set_ellipsize(Pango.EllipsizeMode.END)
box.append(label)
gesture = Gtk.GestureClick()
gesture.connect("released", self._on_frame_click, frame_id)
box.add_controller(gesture)
self._widgets[frame_id] = box
self._timestamps[frame_id] = timestamp
self._order.append(frame_id)
self._strip.append(box)
if auto_select:
self.select(frame_id)
log.info("Thumbnail: %s at %.1fs", frame_id, timestamp)
def _on_frame_click(self, gesture, n_press, x, y, frame_id):
self.select(frame_id)
if n_press == 2:
ts = self._timestamps.get(frame_id, 0)
self.emit("seek-requested", ts)
def load_items(self, items: list[dict]):
"""Bulk load. Each dict has 'id', 'pixbuf', 'timestamp'."""
self.clear()
for item in items:
self.add_item(item["id"], item["pixbuf"], item["timestamp"], auto_select=False)
if self._order:
self.select(self._order[-1])
def select(self, frame_id: str):
"""Select or deselect (toggle) a frame."""
# Deselect previous
if self._selected and self._selected in self._widgets:
self._widgets[self._selected].remove_css_class("frame-selected")
if self._selected == frame_id:
self._selected = None
self.emit("selection-changed")
return
self._selected = frame_id
if frame_id in self._widgets:
self._widgets[frame_id].add_css_class("frame-selected")
GLib.timeout_add(50, self._scroll_to, self._widgets[frame_id])
self.emit("selection-changed")
def select_adjacent(self, delta: int):
"""Move selection by delta. Stops at edges."""
if not self._order:
return
if self._selected is None:
idx = 0 if delta > 0 else len(self._order) - 1
else:
try:
cur = self._order.index(self._selected)
except ValueError:
cur = 0
idx = cur + delta
if idx < 0 or idx >= len(self._order):
return
self.select(self._order[idx])
def clear_selection(self):
"""Deselect without removing items."""
if self._selected is None:
return
if self._selected in self._widgets:
self._widgets[self._selected].remove_css_class("frame-selected")
self._selected = None
self.emit("selection-changed")
def highlight_nearest(self, timestamp: float) -> None:
"""Scroll to and briefly highlight the frame closest to *timestamp*."""
if not self._order:
return
best_id = None
best_dist = float("inf")
for fid, ts in self._timestamps.items():
d = abs(ts - timestamp)
if d < best_dist:
best_dist = d
best_id = fid
if not best_id or best_id not in self._widgets:
return
widget = self._widgets[best_id]
widget.add_css_class("frame-highlight")
self._scroll_to(widget)
GLib.timeout_add(400, lambda: widget.remove_css_class("frame-highlight") or False)
def clear(self):
"""Remove all items and reset state."""
self._selected = None
self._widgets.clear()
self._timestamps.clear()
self._order.clear()
while child := self._strip.get_first_child():
self._strip.remove(child)
# -- Internal --
def _scroll_to(self, widget):
adj = self._scroll.get_hadjustment()
alloc = widget.get_allocation()
x, w = alloc.x, alloc.width
if w <= 0:
return False
page = adj.get_page_size()
val = adj.get_value()
if x < val:
adj.set_value(x)
elif x + w > val + page:
adj.set_value(x + w - page)
return False
def _on_threshold_changed(self, scale):
val = scale.get_value()
self._scene_label.set_label(f"Frames (scene: {val:.2f})")
self.emit("threshold-changed", val)