272 lines
9.1 KiB
Python
272 lines
9.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,)),
|
|
"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)
|