frame fix
This commit is contained in:
@@ -42,8 +42,8 @@ class ChtApp(Adw.Application):
|
||||
if not win:
|
||||
css = Gtk.CssProvider()
|
||||
css.load_from_string(
|
||||
".frame-selected { border: 3px solid @accent_color; border-radius: 6px; }\n"
|
||||
"row.frame-selected { background: alpha(@accent_color, 0.25); border: none; border-radius: 0; }"
|
||||
".frame-selected { outline: 3px solid @accent_color; outline-offset: -3px; border-radius: 6px; }\n"
|
||||
"row.frame-selected, row.frame-selected:hover { background: alpha(@accent_color, 0.25); outline: none; border-radius: 0; }"
|
||||
)
|
||||
Gtk.StyleContext.add_provider_for_display(
|
||||
Gdk.Display.get_default(),
|
||||
|
||||
197
cht/ui/frames_panel.py
Normal file
197
cht/ui/frames_panel.py
Normal 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
92
cht/ui/keyboard.py
Normal 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
201
cht/ui/transcript_panel.py
Normal 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
|
||||
648
cht/window.py
648
cht/window.py
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user