""" TranscriptPanel: vertical transcript list with single/multi-selection. Uses Gtk.ListBox. New rows are always added; auto-scroll is suppressed while a selection is active. """ import logging import gi gi.require_version("Gtk", "4.0") from gi.repository import Gtk, GLib, GObject log = logging.getLogger(__name__) class TranscriptPanel(Gtk.Box): """Vertical transcript list with single/multi-selection.""" __gsignals__ = { "selection-changed": (GObject.SignalFlags.RUN_FIRST, None, ()), } def __init__(self, **kwargs): super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0, **kwargs) self._rows: dict[str, Gtk.ListBoxRow] = {} self._texts: dict[str, str] = {} self._order: list[str] = [] self._selected: list[str] = [] label = Gtk.Label(label="Transcript") label.add_css_class("heading") label.set_margin_top(4) label.set_margin_bottom(4) self.append(label) self._list = Gtk.ListBox() self._list.set_selection_mode(Gtk.SelectionMode.NONE) self._list.connect("row-activated", self._on_row_activated) self._scroll = Gtk.ScrolledWindow() self._scroll.set_vexpand(True) self._scroll.set_min_content_height(150) self._scroll.set_child(self._list) self.append(self._scroll) # -- Properties -- @property def selected(self) -> list[str]: return list(self._selected) @property def selected_texts(self) -> list[str]: return [self._texts[sid] for sid in self._selected if sid in self._texts] @property def order(self) -> list[str]: return list(self._order) @property def has_selection(self) -> bool: return len(self._selected) > 0 # -- Public API -- def add_items(self, segments): """Append new segments. Always adds; only auto-scrolls if no selection.""" for seg in segments: if seg.id in self._rows: continue self._make_row(seg) if not self._selected: adj = self._scroll.get_vadjustment() GLib.idle_add(lambda: adj.set_value(adj.get_upper()) or False) def select(self, seg_id: str, extend: bool = False): """Select a segment. extend=True adds to selection.""" if not extend: self._clear_visual() self._selected.clear() if seg_id in self._selected: if not extend: self.emit("selection-changed") return self._selected.remove(seg_id) if seg_id in self._rows: self._rows[seg_id].remove_css_class("frame-selected") self.emit("selection-changed") return self._selected.append(seg_id) if seg_id in self._rows: self._rows[seg_id].add_css_class("frame-selected") GLib.timeout_add(50, self._scroll_to_row, self._rows[seg_id]) self.emit("selection-changed") def select_adjacent(self, delta: int, extend: bool = False): """Move selection by delta. Shift-extend grows/shrinks range.""" if not self._order: return if not self._selected: idx = 0 if delta > 0 else len(self._order) - 1 self.select(self._order[idx], extend=False) return if extend and len(self._selected) > 1: last = self._selected[-1] prev = self._selected[-2] try: last_idx = self._order.index(last) prev_idx = self._order.index(prev) except ValueError: last_idx = prev_idx = 0 if (delta < 0 and last_idx > prev_idx) or (delta > 0 and last_idx < prev_idx): self._selected.remove(last) if last in self._rows: self._rows[last].remove_css_class("frame-selected") if self._selected and self._selected[-1] in self._rows: GLib.timeout_add(50, self._scroll_to_row, self._rows[self._selected[-1]]) self.emit("selection-changed") return last = self._selected[-1] try: cur = self._order.index(last) except ValueError: cur = 0 idx = cur + delta if idx < 0 or idx >= len(self._order): return self.select(self._order[idx], extend=extend) def clear_selection(self): """Deselect all, then flush any pending items.""" if not self._selected: return self._clear_visual() self._selected.clear() self.emit("selection-changed") def clear(self): """Remove all items and reset state.""" self._selected.clear() self._rows.clear() self._texts.clear() self._order.clear() while child := self._list.get_first_child(): self._list.remove(child) # -- Internal -- def _on_row_activated(self, listbox, row): seg_id = getattr(row, "_seg_id", None) if seg_id: self.select(seg_id) def _make_row(self, seg): m1, s1 = divmod(int(seg.start), 60) m2, s2 = divmod(int(seg.end), 60) text = f"{seg.id} [{m1:02d}:{s1:02d}-{m2:02d}:{s2:02d}] {seg.text}" row_label = Gtk.Label(label=text) row_label.set_xalign(0) row_label.set_wrap(True) row_label.set_margin_start(8) row_label.set_margin_end(8) row_label.set_margin_top(2) row_label.set_margin_bottom(2) row = Gtk.ListBoxRow() row.set_child(row_label) row.set_activatable(True) row._seg_id = seg.id self._list.append(row) self._rows[seg.id] = row self._texts[seg.id] = seg.text self._order.append(seg.id) def _clear_visual(self): for sid in self._selected: if sid in self._rows: self._rows[sid].remove_css_class("frame-selected") def _scroll_to_row(self, row): adj = self._scroll.get_vadjustment() alloc = row.get_allocation() y, h = alloc.y, alloc.height if h <= 0: return False page = adj.get_page_size() val = adj.get_value() if y < val: adj.set_value(y) elif y + h > val + page: adj.set_value(y + h - page) return False