Files
mitus/cht/ui/transcript_panel.py
2026-04-03 04:41:59 -03:00

246 lines
8.1 KiB
Python

"""
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,)),
}
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] = []
# 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 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
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)