""" 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 from cht.config import TRANSCRIBE_MIN_CHUNK_S, TRANSCRIBE_LINES_PER_GROUP log = logging.getLogger(__name__) class TranscriptPanel(Gtk.Box): """Vertical transcript list with single/multi-selection.""" __gsignals__ = { "selection-changed": (GObject.SignalFlags.RUN_FIRST, None, ()), "min-chunk-changed": (GObject.SignalFlags.RUN_FIRST, None, (float,)), "lines-per-group-changed": (GObject.SignalFlags.RUN_FIRST, None, (int,)), "seek-requested": (GObject.SignalFlags.RUN_FIRST, None, (float,)), } 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._timestamps: dict[str, float] = {} self._order: list[str] = [] self._selected: list[str] = [] # Header with sliders 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._header_label = Gtk.Label( label=f"Transcript (chunk: {TRANSCRIBE_MIN_CHUNK_S}s, group: {TRANSCRIBE_LINES_PER_GROUP})" ) self._header_label.add_css_class("heading") header.append(self._header_label) chunk_scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 2, 15, 1) chunk_scale.set_value(TRANSCRIBE_MIN_CHUNK_S) chunk_scale.set_hexpand(True) chunk_scale.set_draw_value(False) chunk_scale.connect("value-changed", self._on_chunk_changed) header.append(chunk_scale) group_scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 1, 10, 1) group_scale.set_value(TRANSCRIBE_LINES_PER_GROUP) group_scale.set_hexpand(True) group_scale.set_draw_value(False) group_scale.connect("value-changed", self._on_group_changed) header.append(group_scale) self._chunk_val = TRANSCRIBE_MIN_CHUNK_S self._group_val = TRANSCRIBE_LINES_PER_GROUP self.append(header) 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 highlight_nearest(self, timestamp: float) -> None: """Scroll to and briefly highlight the transcript segment closest to *timestamp*.""" if not self._order: return best_id = None best_dist = float("inf") for sid, ts in self._timestamps.items(): d = abs(ts - timestamp) if d < best_dist: best_dist = d best_id = sid if not best_id or best_id not in self._rows: return row = self._rows[best_id] row.add_css_class("frame-highlight") self._scroll_to_row(row) GLib.timeout_add(400, lambda: row.remove_css_class("frame-highlight") or False) def clear(self): """Remove all items and reset state.""" self._selected.clear() self._rows.clear() self._texts.clear() self._timestamps.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) # Double-activation (Enter key on focused row) → seek ts = self._timestamps.get(seg_id) if ts is not None: self.emit("seek-requested", ts) 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._timestamps[seg.id] = seg.start 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 def _update_header(self): self._header_label.set_label( f"Transcript (chunk: {self._chunk_val}s, group: {self._group_val})" ) def _on_chunk_changed(self, scale): self._chunk_val = int(scale.get_value()) self._update_header() self.emit("min-chunk-changed", float(self._chunk_val)) def _on_group_changed(self, scale): self._group_val = int(scale.get_value()) self._update_header() self.emit("lines-per-group-changed", self._group_val)