From f8a8b5fa09583963bb40845a73c1e49d12bd54f2 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Mon, 30 Mar 2026 17:45:50 +0300 Subject: [PATCH] refactor app loop and improve key capture metadata --- scripts/capture_key_matrix.py | 39 ++++- src/app/mod.rs | 273 +++++++++++++++++++++------------- 2 files changed, 206 insertions(+), 106 deletions(-) diff --git a/scripts/capture_key_matrix.py b/scripts/capture_key_matrix.py index 676056da..38e25c55 100644 --- a/scripts/capture_key_matrix.py +++ b/scripts/capture_key_matrix.py @@ -5,6 +5,7 @@ Self-contained: copy this file to any macOS/Linux machine and run it with Python No project imports, no external dependencies. What it does: +- auto-detects OS + tmux status - asks for terminal name + optional notes - prompts a fixed key matrix one key at a time - captures the raw byte sequence for each key press in raw mode @@ -21,6 +22,8 @@ Exit / control: from __future__ import annotations +import os +import platform import re import select import sys @@ -114,6 +117,19 @@ class RawMode: termios.tcsetattr(self.fd, termios.TCSADRAIN, self.old) +def detect_os() -> str: + system = platform.system().lower() + if system == "darwin": + return "macos" + if system == "linux": + return "linux" + return system or "unknown" + + +def detect_tmux() -> str: + return "yes" if os.environ.get("TMUX") else "no" + + def prompt(text: str, default: str | None = None) -> str: suffix = f" [{default}]" if default else "" value = input(f"{text}{suffix}: ").strip() @@ -122,9 +138,11 @@ def prompt(text: str, default: str | None = None) -> str: return value -def choose_output_path(terminal_name: str) -> Path: +def choose_output_path(terminal_name: str, os_name: str, tmux_state: str) -> Path: stamp = time.strftime("%Y%m%d-%H%M%S") - default_name = f"key-capture-{sanitize(terminal_name)}-{stamp}.tsv" + default_name = ( + f"key-capture-{sanitize(terminal_name)}-{sanitize(os_name)}-{sanitize(tmux_state)}-{stamp}.tsv" + ) raw = prompt("Output file path", default_name) return Path(raw).expanduser().resolve() @@ -153,12 +171,14 @@ def confirm_capture(data: bytes) -> str: def write_tsv(path: Path, rows: list[dict[str, str]]) -> None: path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8", newline="") as f: - f.write("terminal\tkey\tnotes\tbytes_hex\tescaped\n") + f.write("terminal\tos\ttmux\tkey\tnotes\tbytes_hex\tescaped\n") for row in rows: f.write( "\t".join( [ row["terminal"], + row["os"], + row["tmux"], row["key"], row["notes"], row["bytes_hex"], @@ -174,16 +194,21 @@ def main() -> int: print("This will collect one raw sequence per requested key and write a TSV file.") print() + os_name = detect_os() + tmux_state = detect_tmux() + terminal_name = prompt("Terminal name (e.g. gnome-terminal, ghostty, kitty, iterm2)") if not terminal_name: print("Terminal name is required.", file=sys.stderr) return 1 - notes = prompt("Optional notes (e.g. macos, tmux, ssh)", "") - output_path = choose_output_path(terminal_name) + notes = prompt("Optional notes", "") + output_path = choose_output_path(terminal_name, os_name, tmux_state) print() print(f"Terminal: {terminal_name}") + print(f"OS: {os_name}") + print(f"Tmux: {tmux_state}") print(f"Notes: {notes or '-'}") print(f"Output: {output_path}") print() @@ -207,6 +232,8 @@ def main() -> int: rows.append( { "terminal": terminal_name, + "os": os_name, + "tmux": tmux_state, "key": key_id, "notes": notes, "bytes_hex": "", @@ -222,6 +249,8 @@ def main() -> int: rows.append( { "terminal": terminal_name, + "os": os_name, + "tmux": tmux_state, "key": key_id, "notes": notes, "bytes_hex": to_hex(data), diff --git a/src/app/mod.rs b/src/app/mod.rs index 37f109cf..617db561 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -11,6 +11,7 @@ pub mod state; use std::io; use std::time::{Duration, Instant}; +const RENDER_INTERVAL: Duration = Duration::from_millis(16); const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500); const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); @@ -43,6 +44,14 @@ pub struct App { last_sidebar_divider_click: Option, } +enum LoopEvent { + RenderTick, + Internal(AppEvent), + RawInput(crate::raw_input::RawInputEvent), + InputClosed, + Idle, +} + /// Resolve the palette from config: base theme + optional custom overrides. fn resolve_palette(config: &crate::config::Config) -> state::Palette { // Start with the named theme (default: catppuccin) @@ -204,59 +213,89 @@ impl App { self.input_rx = Some(crate::raw_input::spawn_input_reader()); } + let mut render_tick = tokio::time::interval(RENDER_INTERVAL); + render_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut needs_render = true; + while !self.state.should_quit { - if self - .config_diagnostic_deadline - .is_some_and(|deadline| Instant::now() >= deadline) - { - self.config_diagnostic_deadline = None; - self.state.config_diagnostic = None; - } - - if self - .toast_deadline - .is_some_and(|deadline| Instant::now() >= deadline) - { - self.toast_deadline = None; - self.state.toast = None; - } - - self.state.spinner_tick = self.state.spinner_tick.wrapping_add(1); - - if self.last_git_remote_status_refresh.elapsed() >= GIT_REMOTE_STATUS_REFRESH_INTERVAL { - for ws in &mut self.state.workspaces { - ws.refresh_git_ahead_behind(); - } - self.last_git_remote_status_refresh = Instant::now(); - } - - terminal.draw(|frame| { - crate::ui::compute_view(&mut self.state, frame.area()); - crate::ui::render(&self.state, frame); - })?; - // Drain internal events first so API reads observe fresh pane state. - self.drain_internal_events(); + let had_internal_events = self.drain_internal_events(); + let mut had_api_requests = false; while let Ok(msg) = self.api_rx.try_recv() { + had_api_requests = true; let response = self.handle_api_request(msg.request); let _ = msg.respond_to.send(response); } + if had_internal_events || had_api_requests { + needs_render = true; + } + self.sync_focus_events(); self.handle_resize_poll(); - self.drain_raw_input().await; - - tokio::time::sleep(Duration::from_millis(16)).await; + if self.handle_frame_timers() { + needs_render = true; + } if self.state.request_complete_onboarding { self.state.request_complete_onboarding = false; self.complete_onboarding(); + needs_render = true; } if self.state.request_new_workspace { self.state.request_new_workspace = false; self.create_workspace(); + needs_render = true; + } + + if needs_render { + terminal.draw(|frame| { + crate::ui::compute_view(&mut self.state, frame.area()); + crate::ui::render(&self.state, frame); + })?; + needs_render = false; + continue; + } + + let event = { + let input_rx = self.input_rx.as_mut(); + tokio::select! { + _ = render_tick.tick() => LoopEvent::RenderTick, + maybe_ev = self.event_rx.recv() => match maybe_ev { + Some(ev) => LoopEvent::Internal(ev), + None => LoopEvent::Idle, + }, + maybe_input = async { + match input_rx { + Some(rx) => rx.recv().await, + None => None, + } + } => match maybe_input { + Some(input) => LoopEvent::RawInput(input), + None => LoopEvent::InputClosed, + }, + } + }; + + match event { + LoopEvent::RenderTick => { + self.state.spinner_tick = self.state.spinner_tick.wrapping_add(1); + needs_render = true; + } + LoopEvent::Internal(ev) => { + self.handle_internal_event(ev); + needs_render = true; + } + LoopEvent::RawInput(input) => { + self.handle_raw_input_event(input).await; + needs_render = true; + } + LoopEvent::InputClosed => { + self.input_rx = None; + } + LoopEvent::Idle => {} } } @@ -273,23 +312,16 @@ impl App { Ok(()) } - async fn drain_raw_input(&mut self) { - while let Some(rx) = self.input_rx.as_mut() { - match rx.try_recv() { - Ok(crate::raw_input::RawInputEvent::Key(key)) => { - if key.kind == crossterm::event::KeyEventKind::Press { - self.handle_key(key).await; - } - } - Ok(crate::raw_input::RawInputEvent::Paste(text)) => self.handle_paste(text).await, - Ok(crate::raw_input::RawInputEvent::Mouse(mouse)) => self.handle_mouse(mouse), - Ok(crate::raw_input::RawInputEvent::Unsupported) => {} - Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, - Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { - self.input_rx = None; - break; + async fn handle_raw_input_event(&mut self, event: crate::raw_input::RawInputEvent) { + match event { + crate::raw_input::RawInputEvent::Key(key) => { + if key.kind == crossterm::event::KeyEventKind::Press { + self.handle_key(key).await; } } + crate::raw_input::RawInputEvent::Paste(text) => self.handle_paste(text).await, + crate::raw_input::RawInputEvent::Mouse(mouse) => self.handle_mouse(mouse), + crate::raw_input::RawInputEvent::Unsupported => {} } } @@ -300,67 +332,106 @@ impl App { } } - fn drain_internal_events(&mut self) { + fn handle_frame_timers(&mut self) -> bool { + let mut changed = false; + + if self + .config_diagnostic_deadline + .is_some_and(|deadline| Instant::now() >= deadline) + { + self.config_diagnostic_deadline = None; + self.state.config_diagnostic = None; + changed = true; + } + + if self + .toast_deadline + .is_some_and(|deadline| Instant::now() >= deadline) + { + self.toast_deadline = None; + self.state.toast = None; + changed = true; + } + + if self.last_git_remote_status_refresh.elapsed() >= GIT_REMOTE_STATUS_REFRESH_INTERVAL { + for ws in &mut self.state.workspaces { + ws.refresh_git_ahead_behind(); + } + self.last_git_remote_status_refresh = Instant::now(); + changed = true; + } + + changed + } + + fn drain_internal_events(&mut self) -> bool { + let mut had_event = false; while let Ok(ev) = self.event_rx.try_recv() { - match &ev { - AppEvent::PaneDied { pane_id } => { - if let Some((ws_idx, _)) = self.find_pane(*pane_id) { - if let Some(public_pane_id) = self.public_pane_id(ws_idx, *pane_id) { + had_event = true; + self.handle_internal_event(ev); + } + had_event + } + + fn handle_internal_event(&mut self, ev: AppEvent) { + match &ev { + AppEvent::PaneDied { pane_id } => { + if let Some((ws_idx, _)) = self.find_pane(*pane_id) { + if let Some(public_pane_id) = self.public_pane_id(ws_idx, *pane_id) { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneExited, + data: crate::api::schema::EventData::PaneExited { + pane_id: public_pane_id, + workspace_id: self.public_workspace_id(ws_idx), + }, + }); + } + } + } + AppEvent::StateChanged { + pane_id, + agent, + state, + } => { + if let Some((ws_idx, pane)) = self.find_pane(*pane_id) { + if let Some(pane_id) = self.public_pane_id(ws_idx, *pane_id) { + let workspace_id = self.public_workspace_id(ws_idx); + if pane.detected_agent != *agent { self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneExited, - data: crate::api::schema::EventData::PaneExited { - pane_id: public_pane_id, - workspace_id: self.public_workspace_id(ws_idx), + event: crate::api::schema::EventKind::PaneAgentDetected, + data: crate::api::schema::EventData::PaneAgentDetected { + pane_id: pane_id.clone(), + workspace_id: workspace_id.clone(), + agent: agent.map(agent_name), + }, + }); + } + if pane.state != *state { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneAgentStateChanged, + data: crate::api::schema::EventData::PaneAgentStateChanged { + pane_id, + workspace_id, + state: pane_agent_state(*state), }, }); } } } - AppEvent::StateChanged { - pane_id, - agent, - state, - } => { - if let Some((ws_idx, pane)) = self.find_pane(*pane_id) { - if let Some(pane_id) = self.public_pane_id(ws_idx, *pane_id) { - let workspace_id = self.public_workspace_id(ws_idx); - if pane.detected_agent != *agent { - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneAgentDetected, - data: crate::api::schema::EventData::PaneAgentDetected { - pane_id: pane_id.clone(), - workspace_id: workspace_id.clone(), - agent: agent.map(agent_name), - }, - }); - } - if pane.state != *state { - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneAgentStateChanged, - data: crate::api::schema::EventData::PaneAgentStateChanged { - pane_id, - workspace_id, - state: pane_agent_state(*state), - }, - }); - } - } - } - } - AppEvent::UpdateReady { .. } => {} } + AppEvent::UpdateReady { .. } => {} + } - let previous_toast = self.state.toast.clone(); - self.state.handle_app_event(ev); - if self.state.toast != previous_toast { - self.toast_deadline = self.state.toast.as_ref().map(|toast| { - let duration = match toast.kind { - ToastKind::NeedsAttention => Duration::from_secs(8), - ToastKind::Finished => Duration::from_secs(5), - }; - Instant::now() + duration - }); - } + let previous_toast = self.state.toast.clone(); + self.state.handle_app_event(ev); + if self.state.toast != previous_toast { + self.toast_deadline = self.state.toast.as_ref().map(|toast| { + let duration = match toast.kind { + ToastKind::NeedsAttention => Duration::from_secs(8), + ToastKind::Finished => Duration::from_secs(5), + }; + Instant::now() + duration + }); } }