frame fix

This commit is contained in:
2026-04-03 04:00:02 -03:00
parent d14390a649
commit 6159b2a027
5 changed files with 668 additions and 474 deletions

197
cht/ui/frames_panel.py Normal file
View File

@@ -0,0 +1,197 @@
"""
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,)),
}
def __init__(self, **kwargs):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0, **kwargs)
self._widgets: dict[str, Gtk.Box] = {}
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", lambda g, n, x, y, fid=frame_id: self.select(fid))
box.add_controller(gesture)
self._widgets[frame_id] = box
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 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 clear(self):
"""Remove all items and reset state."""
self._selected = None
self._widgets.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)

92
cht/ui/keyboard.py Normal file
View File

@@ -0,0 +1,92 @@
"""
KeyboardManager: centralized keyboard shortcut handling.
Captures all key events at the window level before any child widget.
Routes to registered handlers based on keyval.
"""
import logging
from typing import Callable
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk, Gdk
log = logging.getLogger(__name__)
SHIFT = Gdk.ModifierType.SHIFT_MASK
CTRL = Gdk.ModifierType.CONTROL_MASK
KEY_LEFT = Gdk.KEY_Left
KEY_RIGHT = Gdk.KEY_Right
KEY_UP = Gdk.KEY_Up
KEY_DOWN = Gdk.KEY_Down
KEY_RETURN = Gdk.KEY_Return
KEY_KP_ENTER = Gdk.KEY_KP_Enter
KEY_ESCAPE = Gdk.KEY_Escape
KEY_DELETE = Gdk.KEY_Delete
class KeyboardManager:
"""Captures key events at window level before child widgets.
Usage:
kb = KeyboardManager()
kb.bind(KEY_LEFT, on_left)
kb.bind(KEY_UP, on_up)
kb.set_passthrough(lambda: isinstance(window.get_focus(), Gtk.Entry))
kb.attach(window)
"""
def __init__(self):
self._bindings: dict[int, Callable] = {}
self._passthrough: Callable[[], bool] | None = None
self._window = None
def bind(self, keyval: int, handler: Callable):
"""Register a handler for a key. Handler receives shift=bool."""
self._bindings[keyval] = handler
def set_passthrough(self, check: Callable[[], bool]):
"""When check() returns True, keys pass through to focused widget."""
self._passthrough = check
def attach(self, window):
"""Attach to a GTK4 window."""
self._window = window
# EventControllerKey on capture phase
key_ctrl = Gtk.EventControllerKey()
key_ctrl.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
key_ctrl.connect("key-pressed", self._on_key_pressed)
window.add_controller(key_ctrl)
# Reclaim focus from non-interactive widgets on click
click_ctrl = Gtk.GestureClick()
click_ctrl.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
click_ctrl.connect("released", self._on_click)
window.add_controller(click_ctrl)
def _on_click(self, gesture, n_press, x, y):
"""After any click, if focus landed on a non-text widget, clear it."""
if not self._window:
return
focus = self._window.get_focus()
if focus and not isinstance(focus, (Gtk.Entry, Gtk.TextView)):
self._window.set_focus(None)
def _on_key_pressed(self, controller, keyval, keycode, state):
if self._passthrough and self._passthrough():
return False
handler = self._bindings.get(keyval)
if handler is None:
return False
shift = bool(state & SHIFT)
try:
result = handler(shift=shift)
return result if result is not None else True
except TypeError:
result = handler()
return result if result is not None else True

201
cht/ui/transcript_panel.py Normal file
View File

@@ -0,0 +1,201 @@
"""
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