Files
mitus/cht/ui/scrub_bar.py
2026-04-03 06:40:08 -03:00

246 lines
8.4 KiB
Python

"""ScrubBar: tall segmented block bar for frame-accurate scrubbing.
Replaces the thin timeline slider with a horizontal row of blocks,
one per recording segment, proportional in width to duration.
Click a block to activate it (trigger proxy generation).
Drag within a block to scrub frame-by-frame at mouse speed.
"""
import logging
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk, Gdk, GLib, GObject, Pango
import cairo
log = logging.getLogger(__name__)
BAR_HEIGHT = 50
BLOCK_GAP = 2
BLOCK_COLOR = (0.25, 0.25, 0.30)
BLOCK_ACTIVE_COLOR = (0.35, 0.45, 0.60)
BLOCK_HOVER_COLOR = (0.30, 0.35, 0.40)
BLOCK_GENERATING_COLOR = (0.30, 0.40, 0.35)
CURSOR_COLOR = (0.9, 0.2, 0.2)
MARKER_COLOR = (0.9, 0.8, 0.2)
TEXT_COLOR = (0.8, 0.8, 0.8)
class ScrubBar(Gtk.DrawingArea):
"""Segmented block bar for scrubbing through recording segments."""
__gsignals__ = {
"segment-activated": (GObject.SignalFlags.RUN_FIRST, None, (int,)),
"scrub-position": (GObject.SignalFlags.RUN_FIRST, None, (float,)),
}
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.set_content_height(BAR_HEIGHT)
self.set_hexpand(True)
self._manifest = [] # list of {path, index, duration, global_offset}
self._total_duration = 0.0
self._cursor = 0.0 # global cursor position
self._active_index = -1 # currently active segment index
self._hover_index = -1 # segment under mouse
self._proxy_states = {} # segment_index → "generating" | "ready"
self._scene_markers = [] # global timestamps
self._scrubbing = False
self.set_draw_func(self._draw)
# Mouse events
click = Gtk.GestureClick()
click.connect("pressed", self._on_pressed)
click.connect("released", self._on_released)
self.add_controller(click)
motion = Gtk.EventControllerMotion()
motion.connect("motion", self._on_motion)
motion.connect("leave", self._on_leave)
self.add_controller(motion)
drag = Gtk.GestureDrag()
drag.connect("drag-begin", self._on_drag_begin)
drag.connect("drag-update", self._on_drag_update)
drag.connect("drag-end", self._on_drag_end)
self.add_controller(drag)
# -- Public API --
def set_manifest(self, manifest: list[dict]) -> None:
"""Update the segment manifest. Triggers redraw."""
self._manifest = manifest
self._total_duration = sum(s["duration"] for s in manifest)
self.queue_draw()
def set_cursor(self, global_time: float) -> None:
"""Update the cursor position (from Timeline)."""
self._cursor = global_time
self.queue_draw()
def set_scene_markers(self, markers: list[float]) -> None:
"""Set scene change marker positions."""
self._scene_markers = markers
self.queue_draw()
def set_active_segment(self, index: int) -> None:
"""Set which segment is active (loaded for scrubbing)."""
self._active_index = index
self.queue_draw()
def set_proxy_state(self, segment_index: int, state: str) -> None:
"""Update proxy state for a segment ('generating', 'ready')."""
self._proxy_states[segment_index] = state
self.queue_draw()
# -- Drawing --
def _draw(self, area, cr, width, height):
if not self._manifest or self._total_duration <= 0:
cr.set_source_rgb(0.15, 0.15, 0.15)
cr.rectangle(0, 0, width, height)
cr.fill()
return
# Draw segment blocks
for seg in self._manifest:
x, w = self._segment_rect(seg, width)
idx = seg["index"]
# Block color based on state
if idx == self._active_index:
cr.set_source_rgb(*BLOCK_ACTIVE_COLOR)
elif idx == self._hover_index:
cr.set_source_rgb(*BLOCK_HOVER_COLOR)
elif self._proxy_states.get(idx) == "generating":
cr.set_source_rgb(*BLOCK_GENERATING_COLOR)
else:
cr.set_source_rgb(*BLOCK_COLOR)
cr.rectangle(x, 0, w, height)
cr.fill()
# Segment label
dur = seg["duration"]
m, s = divmod(int(dur), 60)
label = f"S{idx}" if w < 40 else f"S{idx} {m}:{s:02d}"
cr.set_source_rgb(*TEXT_COLOR)
cr.set_font_size(11)
cr.move_to(x + 4, height - 6)
cr.show_text(label)
# Proxy state indicator
state = self._proxy_states.get(idx)
if state == "ready":
cr.set_source_rgb(0.3, 0.7, 0.3)
cr.arc(x + w - 8, 8, 3, 0, 6.28)
cr.fill()
elif state == "generating":
cr.set_source_rgb(0.7, 0.7, 0.3)
cr.arc(x + w - 8, 8, 3, 0, 6.28)
cr.fill()
# Scene markers
cr.set_source_rgb(*MARKER_COLOR)
for ts in self._scene_markers:
mx = self._global_to_x(ts, width)
if 0 <= mx <= width:
cr.rectangle(mx, 0, 1, 6)
cr.fill()
# Cursor
cx = self._global_to_x(self._cursor, width)
if 0 <= cx <= width:
cr.set_source_rgb(*CURSOR_COLOR)
cr.rectangle(cx - 1, 0, 2, height)
cr.fill()
# -- Geometry helpers --
def _segment_rect(self, seg, total_width):
"""Return (x, width) for a segment block."""
if self._total_duration <= 0:
return 0, 0
x = (seg["global_offset"] / self._total_duration) * total_width + BLOCK_GAP / 2
w = (seg["duration"] / self._total_duration) * total_width - BLOCK_GAP
return max(0, x), max(1, w)
def _global_to_x(self, global_time, total_width):
"""Map global time to pixel x position."""
if self._total_duration <= 0:
return 0
return (global_time / self._total_duration) * total_width
def _x_to_global(self, x, total_width):
"""Map pixel x to global time."""
if total_width <= 0 or self._total_duration <= 0:
return 0.0
return (x / total_width) * self._total_duration
def _segment_at_x(self, x, total_width):
"""Return the segment index at pixel x, or -1."""
for seg in self._manifest:
sx, sw = self._segment_rect(seg, total_width)
if sx <= x <= sx + sw:
return seg["index"]
return -1
# -- Mouse handlers --
def _on_pressed(self, gesture, n_press, x, y):
width = self.get_width()
idx = self._segment_at_x(x, width)
if idx >= 0:
if idx != self._active_index:
# New segment — activate it (proxy will be requested)
self._active_index = idx
self.emit("segment-activated", idx)
else:
# Already active — seek to click position
gt = self._x_to_global(x, width)
self.emit("scrub-position", gt)
self.queue_draw()
def _on_released(self, gesture, n_press, x, y):
self._scrubbing = False
def _on_motion(self, controller, x, y):
width = self.get_width()
old_hover = self._hover_index
self._hover_index = self._segment_at_x(x, width)
if self._hover_index != old_hover:
self.queue_draw()
# If scrubbing (dragging within active block), emit position
if self._scrubbing and self._active_index >= 0:
gt = self._x_to_global(x, width)
self.emit("scrub-position", gt)
def _on_leave(self, controller):
if self._hover_index != -1:
self._hover_index = -1
self.queue_draw()
def _on_drag_begin(self, gesture, start_x, start_y):
width = self.get_width()
idx = self._segment_at_x(start_x, width)
if idx >= 0 and idx == self._active_index:
self._scrubbing = True
def _on_drag_update(self, gesture, offset_x, offset_y):
if self._scrubbing:
ok, start_x, start_y = gesture.get_start_point()
if ok:
x = start_x + offset_x
width = self.get_width()
gt = self._x_to_global(x, width)
gt = max(0, min(gt, self._total_duration))
self.emit("scrub-position", gt)
def _on_drag_end(self, gesture, offset_x, offset_y):
self._scrubbing = False