shortcuts
This commit is contained in:
239
cht/window.py
239
cht/window.py
@@ -13,7 +13,7 @@ from gi.repository import Gtk, Gdk, Adw, GLib, Pango, GdkPixbuf
|
||||
from cht.config import APP_NAME, SCENE_THRESHOLD
|
||||
from cht.ui.timeline import Timeline, TimelineControls
|
||||
from cht.ui.monitor import MonitorWidget
|
||||
from cht.stream.manager import StreamManager
|
||||
from cht.stream.manager import StreamManager, list_sessions
|
||||
from cht.stream.tracker import RecordingTracker
|
||||
from cht.agent.runner import AgentRunner, ACTIONS, check_claude_cli
|
||||
|
||||
@@ -32,6 +32,7 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
self._known_frames = set()
|
||||
self._selected_frame = None # currently selected frame ID
|
||||
self._frame_widgets = {} # frame_id → outer Box widget
|
||||
self._frame_order = [] # ordered list of frame IDs
|
||||
|
||||
# Timeline is the central state machine
|
||||
self._timeline = Timeline()
|
||||
@@ -58,11 +59,23 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
self._connect_btn.connect("clicked", self._on_connect_clicked)
|
||||
header.pack_start(self._connect_btn)
|
||||
|
||||
self._load_btn = Gtk.Button(label="Load Session")
|
||||
self._load_btn.connect("clicked", self._on_load_session_clicked)
|
||||
header.pack_start(self._load_btn)
|
||||
|
||||
toolbar.add_top_bar(header)
|
||||
toolbar.set_content(self._main_paned)
|
||||
self.set_content(toolbar)
|
||||
|
||||
self.connect("close-request", self._on_close)
|
||||
|
||||
# Global keyboard shortcuts for frame navigation (capture phase
|
||||
# so we intercept before GTK activates focused buttons)
|
||||
key_ctrl = Gtk.EventControllerKey()
|
||||
key_ctrl.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
|
||||
key_ctrl.connect("key-pressed", self._on_key_pressed)
|
||||
self.add_controller(key_ctrl)
|
||||
|
||||
log.info("Window initialized")
|
||||
|
||||
GLib.idle_add(self._start_stream)
|
||||
@@ -74,6 +87,87 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
else:
|
||||
self._start_stream()
|
||||
|
||||
def _on_load_session_clicked(self, button):
|
||||
sessions = list_sessions()
|
||||
if not sessions:
|
||||
self._append_agent_output("No previous sessions found.\n")
|
||||
return
|
||||
|
||||
dialog = Adw.Window(transient_for=self, modal=True)
|
||||
dialog.set_title("Load Session")
|
||||
dialog.set_default_size(500, 400)
|
||||
|
||||
toolbar = Adw.ToolbarView()
|
||||
header = Adw.HeaderBar()
|
||||
toolbar.add_top_bar(header)
|
||||
|
||||
scroll = Gtk.ScrolledWindow()
|
||||
scroll.set_vexpand(True)
|
||||
listbox = Gtk.ListBox()
|
||||
listbox.set_selection_mode(Gtk.SelectionMode.NONE)
|
||||
listbox.add_css_class("boxed-list")
|
||||
|
||||
for sid, sdir in sessions:
|
||||
idx = sdir / "frames" / "index.json"
|
||||
nframes = 0
|
||||
try:
|
||||
nframes = len(json.loads(idx.read_text()))
|
||||
except Exception:
|
||||
pass
|
||||
nrec = len(list((sdir / "stream").glob("recording_*.mp4")))
|
||||
|
||||
row = Adw.ActionRow()
|
||||
row.set_title(sid)
|
||||
row.set_subtitle(f"{nframes} frames, {nrec} segments")
|
||||
row.set_activatable(True)
|
||||
def _on_row_activated(r, s=sid, d=dialog):
|
||||
d.close()
|
||||
self._load_session(s)
|
||||
row.connect("activated", _on_row_activated)
|
||||
listbox.append(row)
|
||||
|
||||
scroll.set_child(listbox)
|
||||
toolbar.set_content(scroll)
|
||||
dialog.set_content(toolbar)
|
||||
dialog.present()
|
||||
|
||||
def _load_session(self, session_id):
|
||||
"""Load an existing session for review (no streaming)."""
|
||||
# Stop any active stream or previous loaded session
|
||||
if self._streaming or self._stream_mgr:
|
||||
self._stop_stream()
|
||||
|
||||
try:
|
||||
self._stream_mgr = StreamManager.from_existing(session_id)
|
||||
except FileNotFoundError as e:
|
||||
self._append_agent_output(f"Error: {e}\n")
|
||||
return
|
||||
|
||||
self.set_title(f"{APP_NAME} — {session_id}")
|
||||
self._append_agent_output(f"Loaded session: {session_id}\n")
|
||||
|
||||
# Load recording into review player if segments exist
|
||||
segments = self._stream_mgr.recording_segments
|
||||
if segments:
|
||||
self._monitor.set_recording(segments[0])
|
||||
# Probe total duration and set timeline
|
||||
duration = self._stream_mgr.total_duration()
|
||||
if duration > 0:
|
||||
self._timeline.set_duration(duration)
|
||||
self._timeline.seek(0) # enter scrub mode at start
|
||||
self._append_agent_output(
|
||||
f" Recording: {len(segments)} segment(s), "
|
||||
f"{int(duration)}s duration\n"
|
||||
)
|
||||
else:
|
||||
self._append_agent_output(" No recordings found (frames only).\n")
|
||||
|
||||
# Load existing frames into the strip
|
||||
self._load_existing_frames()
|
||||
|
||||
# Set up agent auth/model if not already done
|
||||
self._populate_model_dropdown()
|
||||
|
||||
def _start_stream(self):
|
||||
log.info("Starting stream...")
|
||||
self._connect_btn.set_label("Disconnect")
|
||||
@@ -167,16 +261,23 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
self._tracker.stop()
|
||||
self._tracker = None
|
||||
if self._stream_mgr:
|
||||
self._stream_mgr.stop_all()
|
||||
if not self._stream_mgr.readonly:
|
||||
self._stream_mgr.stop_all()
|
||||
self._stream_mgr = None
|
||||
self._known_frames = set()
|
||||
self._selected_frame = None
|
||||
self._frame_widgets = {}
|
||||
self._frame_order = []
|
||||
|
||||
# Clear frame strip
|
||||
while child := self._frames_strip.get_first_child():
|
||||
self._frames_strip.remove(child)
|
||||
|
||||
self._connect_btn.set_label("Connect")
|
||||
self._connect_btn.remove_css_class("destructive-action")
|
||||
self._connect_btn.add_css_class("suggested-action")
|
||||
self._streaming = False
|
||||
self.set_title(APP_NAME)
|
||||
|
||||
def _on_close(self, *args):
|
||||
self._stop_stream()
|
||||
@@ -323,11 +424,24 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
|
||||
def _build_agent_output(self):
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
|
||||
header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
|
||||
header.set_margin_start(8)
|
||||
header.set_margin_end(8)
|
||||
header.set_margin_top(8)
|
||||
header.set_margin_bottom(8)
|
||||
label = Gtk.Label(label="Agent Output")
|
||||
label.add_css_class("heading")
|
||||
label.set_margin_top(8)
|
||||
label.set_margin_bottom(8)
|
||||
box.append(label)
|
||||
label.set_hexpand(True)
|
||||
label.set_halign(Gtk.Align.START)
|
||||
header.append(label)
|
||||
|
||||
clear_btn = Gtk.Button(label="Clear")
|
||||
clear_btn.add_css_class("flat")
|
||||
clear_btn.connect("clicked", lambda b: self._agent_output_view.get_buffer().set_text(""))
|
||||
header.append(clear_btn)
|
||||
|
||||
box.append(header)
|
||||
|
||||
self._agent_output_view = Gtk.TextView()
|
||||
self._agent_output_view.set_editable(False)
|
||||
@@ -395,6 +509,22 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
frame.set_child(outer)
|
||||
return frame
|
||||
|
||||
def _scroll_to_frame(self, widget):
|
||||
"""Scroll the frames strip so that widget is visible."""
|
||||
adj = self._frames_scroll.get_hadjustment()
|
||||
alloc = widget.get_allocation()
|
||||
x = alloc.x
|
||||
w = alloc.width
|
||||
if w <= 0:
|
||||
return False # not allocated yet
|
||||
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 _send_action(self, verb: str):
|
||||
"""Send a predefined action with the selected frame."""
|
||||
if not self._selected_frame:
|
||||
@@ -403,7 +533,7 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
self._send_message(f"{verb} @{self._selected_frame}")
|
||||
|
||||
def _select_frame(self, frame_id: str):
|
||||
"""Toggle selection of a frame thumbnail."""
|
||||
"""Select a frame thumbnail (or deselect if already selected)."""
|
||||
# Deselect previous
|
||||
if self._selected_frame and self._selected_frame in self._frame_widgets:
|
||||
self._frame_widgets[self._selected_frame].remove_css_class("frame-selected")
|
||||
@@ -414,7 +544,48 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
|
||||
self._selected_frame = frame_id
|
||||
if frame_id in self._frame_widgets:
|
||||
self._frame_widgets[frame_id].add_css_class("frame-selected")
|
||||
widget = self._frame_widgets[frame_id]
|
||||
widget.add_css_class("frame-selected")
|
||||
# Scroll after layout settles (idle may fire before allocation)
|
||||
GLib.timeout_add(50, self._scroll_to_frame, widget)
|
||||
|
||||
def _on_key_pressed(self, controller, keyval, keycode, state):
|
||||
"""Handle Left/Right arrow for frame selection, Enter for answer."""
|
||||
# Don't intercept when text entry is focused
|
||||
focus = self.get_focus()
|
||||
if isinstance(focus, (Gtk.Entry, Gtk.TextView)):
|
||||
return False
|
||||
|
||||
if keyval == Gdk.KEY_Left:
|
||||
self._select_adjacent_frame(-1)
|
||||
return True
|
||||
elif keyval == Gdk.KEY_Right:
|
||||
self._select_adjacent_frame(1)
|
||||
return True
|
||||
elif keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
|
||||
if self._selected_frame:
|
||||
self._send_action("answer")
|
||||
return True
|
||||
elif keyval == Gdk.KEY_Delete:
|
||||
self._agent_output_view.get_buffer().set_text("")
|
||||
return True
|
||||
return False
|
||||
|
||||
def _select_adjacent_frame(self, delta):
|
||||
"""Select the next (+1) or previous (-1) frame. Stops at ends."""
|
||||
if not self._frame_order:
|
||||
return
|
||||
if self._selected_frame is None:
|
||||
idx = 0 if delta > 0 else len(self._frame_order) - 1
|
||||
else:
|
||||
try:
|
||||
cur = self._frame_order.index(self._selected_frame)
|
||||
except ValueError:
|
||||
cur = 0
|
||||
idx = cur + delta
|
||||
if idx < 0 or idx >= len(self._frame_order):
|
||||
return # at the edge, do nothing
|
||||
self._select_frame(self._frame_order[idx])
|
||||
|
||||
def _send_message(self, text: str | None = None):
|
||||
if text is None:
|
||||
@@ -650,6 +821,52 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
|
||||
# -- Frame thumbnails --
|
||||
|
||||
def _load_existing_frames(self):
|
||||
"""Load all frames from an existing session's index into the strip."""
|
||||
if not self._stream_mgr:
|
||||
return
|
||||
index_path = self._stream_mgr.frames_dir / "index.json"
|
||||
if not index_path.exists():
|
||||
self._append_agent_output(" No frames found.\n")
|
||||
return
|
||||
|
||||
try:
|
||||
index = json.loads(index_path.read_text())
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return
|
||||
|
||||
# Clear existing strip
|
||||
while child := self._frames_strip.get_first_child():
|
||||
self._frames_strip.remove(child)
|
||||
self._known_frames.clear()
|
||||
self._frame_widgets.clear()
|
||||
self._frame_order.clear()
|
||||
self._selected_frame = None
|
||||
|
||||
loaded = 0
|
||||
for entry in index:
|
||||
fid = entry["id"]
|
||||
fpath = Path(entry["path"])
|
||||
# Resolve path: try absolute first, then relative to frames_dir
|
||||
if not fpath.exists():
|
||||
fpath = self._stream_mgr.frames_dir / fpath.name
|
||||
if not fpath.exists():
|
||||
continue
|
||||
self._known_frames.add(fid)
|
||||
timestamp = entry.get("timestamp", 0)
|
||||
try:
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(fpath), 256, 144, True)
|
||||
self._add_frame_thumbnail(fid, pixbuf, timestamp, auto_select=False)
|
||||
loaded += 1
|
||||
except Exception as e:
|
||||
log.warning("Thumbnail load failed for %s: %s", fid, e)
|
||||
|
||||
# Select the last frame after bulk load
|
||||
if self._frame_order:
|
||||
self._select_frame(self._frame_order[-1])
|
||||
|
||||
self._append_agent_output(f" Loaded {loaded} frame thumbnails.\n")
|
||||
|
||||
def _poll_frames(self):
|
||||
if not self._stream_mgr:
|
||||
return False
|
||||
@@ -681,7 +898,7 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
|
||||
return True
|
||||
|
||||
def _add_frame_thumbnail(self, frame_id, pixbuf, timestamp):
|
||||
def _add_frame_thumbnail(self, frame_id, pixbuf, timestamp, auto_select=True):
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||||
box.set_size_request(256, -1)
|
||||
|
||||
@@ -703,10 +920,10 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
box.add_controller(gesture)
|
||||
|
||||
self._frame_widgets[frame_id] = box
|
||||
self._frame_order.append(frame_id)
|
||||
self._frames_strip.append(box)
|
||||
|
||||
# Auto-scroll to show the latest frame
|
||||
adj = self._frames_scroll.get_hadjustment()
|
||||
GLib.idle_add(lambda: adj.set_value(adj.get_upper()) or False)
|
||||
if auto_select:
|
||||
self._select_frame(frame_id)
|
||||
|
||||
log.info("Thumbnail: %s at %.1fs", frame_id, timestamp)
|
||||
|
||||
Reference in New Issue
Block a user