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