diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c5cfa8..8ffff1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,8 +17,10 @@ jobs: - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 + with: + cache-bin: false - name: Format - run: cargo fmt --check + run: rustup run stable cargo fmt --check test: strategy: @@ -33,5 +35,7 @@ jobs: - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 + with: + cache-bin: false - name: Test - run: cargo test + run: rustup run stable cargo test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bca40c7..d9988eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,10 +75,11 @@ jobs: uses: Swatinem/rust-cache@v2 with: key: ${{ matrix.target }} + cache-bin: false - name: Install cross if: matrix.use_cross - run: cargo install cross --locked + run: rustup run stable cargo install cross --locked - name: Build binary shell: bash @@ -86,7 +87,7 @@ jobs: if [[ "${{ matrix.use_cross }}" == "true" ]]; then cross build --release --target "${{ matrix.target }}" else - cargo build --release --target "${{ matrix.target }}" + rustup run stable cargo build --release --target "${{ matrix.target }}" fi - name: Package release archive diff --git a/CHANGELOG.md b/CHANGELOG.md index 84bf0b9..4d8a1c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 0.1.4 + +- Add customizable `twatch` keymaps with `-K/--keymap KEY=ACTION`, including pane-specific navigation, snapshot actions, pause control, and app-input mode switching +- Add `twrap`-style child key overrides with `-k/--bind FROM=TO`, supporting key aliases, comma-separated key sequences, `text:...`, and `screenshot` +- Remove the unused `--interval` option and simplify batch capture timing so PTY-backed sources wait on real source updates instead of a polling interval +- Expand README and in-app help to document the current interaction model, custom keymap actions, and child key override behavior +- Add regression coverage for custom keymaps and child binding overrides, including passthrough interception and custom exit/app-input flows +- Refresh Rust CI and release workflows to avoid cached `cargo` shims on GitHub Actions and make macOS runner behavior more reliable +- Add consistent source headers across Rust modules touched during this release + +This release is centered on input control and operational polish. +It makes `twatch` more usable with editor-like workflows, shell-heavy sessions, and wrapped TUIs that benefit from custom local shortcuts or remapped child input. + +Notes: + +- `--interval` was removed because the current PTY workflow is event-driven and no longer relies on that option +- The new input customization layer is the main user-facing change in this release + ## 0.1.3 - Improve Windows compatibility for wrapped TUIs by fixing PTY startup, direct process spawning, terminal query handling, and repeated key/mouse input behavior diff --git a/Cargo.lock b/Cargo.lock index bd445cb..5ff0ef6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1660,7 +1660,7 @@ dependencies = [ [[package]] name = "twatch" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index b67459b..4745ebe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "twatch" -version = "0.1.3" +version = "0.1.4" edition = "2024" description = "twatch - record, rewind, and diff terminal UI screens." license = "MIT" diff --git a/README.md b/README.md index 019d6e0..9095cb2 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,11 @@ twatch - record, rewind, inspect, and diff terminal UI screens. ## Description -`twatch` runs a TUI application inside a PTY, records screen changes, and lets -you move back through history later, similar to [hwatch](https://github.com/blacknon/hwatch). -It is aimed at debugging terminal UIs, not only recording them. +twatch adds rewindable history to existing TUI applications. + +Full-screen terminal apps like htop, lazygit, k9s, and nmtui constantly redraw the same screen, so normal terminal scrollback often cannot show you what happened before. + +twatch runs the target app through a PTY, records its screen states, and lets you rewind, search, and diff previous frames. It can also extract selected ranges in batch mode and write them to standard output, making TUI content easier to inspect, debug, or pipe into other commands. ### demo @@ -53,7 +55,7 @@ cargo install twatch ```text $ twatch --help -TUI watch for terminal apps +watch for TUI apps Usage: twatch [OPTIONS] [COMMAND]... @@ -61,38 +63,66 @@ Arguments: [COMMAND]... Options: - -n, --interval [default: 2] -b, --batch - --batch-count Stop after emitting this many batch frames - --batch-size Use a fixed PTY size such as 80,24 - --batch-crop Crop batch output to a rectangle such as 10,5,40,12 - --batch-diff-only Print only added content for batch diff output - --batch-no-color Disable ANSI color sequences in batch output + + --batch-count + Stop after emitting this many batch frames + --batch-size + Use a fixed PTY size such as 80,24 + --batch-crop + Crop batch output to a rectangle such as 10,5,40,12 + --batch-diff-only + Print only added content for batch diff output + --batch-no-color + Disable ANSI color sequences in batch output -A, --aftercommand - --aftercommand-regex Only run aftercommand when output matches this regex + + -K, --keymap + Remap twatch keys with KEY=ACTION + -k, --bind + Override child TUI keys with FROM=TO + --aftercommand-regex + Only run aftercommand when output matches this regex --aftercommand-change-cells - Only run aftercommand when changed cell count reaches this threshold - --aftercommand-every Only run aftercommand on every Nth changed frame + Only run aftercommand when changed cell count reaches this threshold + --aftercommand-every + Only run aftercommand on every Nth changed frame --aftercommand-debounce-ms - Debounce aftercommand for this many milliseconds + Debounce aftercommand for this many milliseconds --aftercommand-timeout-ms - Kill aftercommand if it exceeds this timeout in milliseconds [default: 3000] + Kill aftercommand if it exceeds this timeout in milliseconds [default: 3000] -C, --compress + -l, --logfile - --replay Replay a saved JSONL trace in read-only mode - --screenshot-dir [default: /tmp] - --screenshot-format [default: text] [possible values: text, svg] - --snapshot-on Auto-save a snapshot when the screen contains this string - --snapshot-on-regex Auto-save a snapshot when the screen matches this regex + + --replay + Replay a saved JSONL trace in read-only mode + --screenshot-dir + [default: /tmp] + --screenshot-format + [default: text] [possible values: text, svg] + --snapshot-on + Auto-save a snapshot when the screen contains this string + --snapshot-on-regex + Auto-save a snapshot when the screen matches this regex --snapshot-on-change-cells - Auto-save a snapshot when changed cell count reaches this threshold - --snapshot-once Only trigger automatic snapshot once - -s, --shell [default: "sh -c"] - -d, --differences Diff mode: watch for TUI mode, list/word for batch mode [default: none] [possible values: none, watch, list, word] - -L, --limit [default: 500] - --checkpoint-interval [default: 12] - -h, --help Print help - -V, --version Print version + Auto-save a snapshot when changed cell count reaches this threshold + --snapshot-once + Only trigger automatic snapshot once + -s, --shell + [default: "sh -c"] + -d, --differences + Diff mode: watch for TUI mode, list/word for batch mode [default: none] [possible values: none, watch, list, word] + -L, --limit + [default: 500] + --checkpoint-interval + [default: 12] + --debug + Show debug diagnostics in the interactive UI + -h, --help + Print help + -V, --version + Print version ``` ### Keybind @@ -127,6 +157,69 @@ Options: | `Shift+S` | Toggle selected frame info | | `Ctrl+S` | Save selected snapshot | +#### Custom keybind + +Remap `twatch` actions with `-K/--keymap` using `KEY=ACTION`. +Custom keymaps are checked before the built-in passthrough rules, so you can +override keys such as `Down` and use them for local history navigation. + +```bash +twatch -K ctrl-p=history_pane_up -K ctrl-n=history_pane_down htop +twatch -K down=history_pane_down htop +``` + +Supported actions: + +| action | description | +| --- | --- | +| `up` / `down` | Move selected view using the current focus | +| `watch_pane_up` / `watch_pane_down` | Scroll only the watch pane | +| `history_pane_up` / `history_pane_down` | Move only the history selection | +| `page_up` / `page_down` | Page move using the current focus | +| `watch_pane_page_up` / `watch_pane_page_down` | Page scroll only the watch pane | +| `history_pane_page_up` / `history_pane_page_down` | Page move only the history pane | +| `move_top` / `move_end` | Jump using the current focus | +| `watch_pane_move_top` / `watch_pane_move_end` | Jump only the watch pane | +| `history_pane_move_top` / `history_pane_move_end` | Jump only the history selection | +| `toggle_focus` | Switch watch/history focus | +| `focus_watch_pane` / `focus_history_pane` | Focus a specific pane | +| `quit` | Open the exit dialog | +| `reset` | Close help/exit or clear the current filter | +| `delete` | Delete the selected history entry | +| `clear_except_selected` | Keep only the selected history entry | +| `cancel` | Match the built-in `Ctrl-c` behavior | +| `force_cancel` | Exit immediately | +| `help` | Toggle the help dialog | +| `toggle_view_history_pane` | Toggle the history pane | +| `toggle_history_summary` | Toggle selected frame details | +| `toggle_diff_mode` | Toggle watch diff | +| `set_diff_mode_none` | Disable diff | +| `set_diff_mode_watch` | Enable watch diff | +| `toggle_pause` | Pause or resume capture | +| `toggle_child_pause` | Pause or resume the wrapped process | +| `change_filter_mode` | Start plain-text history filtering | +| `change_regex_filter_mode` | Start regex history filtering | +| `enter_app_input_mode` | Enter child app input mode | +| `leave_app_input_mode` | Leave child app input mode | +| `toggle_inspector` | Toggle the cell inspector | +| `save_snapshot` | Save the selected snapshot | +| `cycle_snapshot_format` | Cycle snapshot output format | +| `scroll_left` / `scroll_right` | Scroll the watch pane horizontally | + +#### Child key override + +Override keys before they reach the wrapped TUI with `-k/--bind FROM=TO`. +This follows the `twrap` style: `TO` accepts key names like `up`, `down`, +`enter`, `f1`, `ctrl-c`, comma-separated key sequences, `text:...`, or +`screenshot`. + +```bash +twatch -k j=down -k k=up lazygit +twatch -k ctrl-j=text:gg -k ctrl-t=screenshot nvim +``` + +`Ctrl-g` remains reserved for leaving app input mode. + ### Notes - Mouse support is implemented, but behavior still depends on the child TUI and diff --git a/src/aftercommand/mod.rs b/src/aftercommand/mod.rs index 5f8a66d..598a2f3 100644 --- a/src/aftercommand/mod.rs +++ b/src/aftercommand/mod.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use std::sync::mpsc::{self, SyncSender, TrySendError}; use std::thread; use std::time::Duration; diff --git a/src/aftercommand/rules.rs b/src/aftercommand/rules.rs index 2a97cf4..efa8bdf 100644 --- a/src/aftercommand/rules.rs +++ b/src/aftercommand/rules.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use crate::aftercommand::{AfterCommandConfig, AfterCommandEvent}; pub(super) fn evaluate_rules( diff --git a/src/aftercommand/worker.rs b/src/aftercommand/worker.rs index fdb941d..bfac07e 100644 --- a/src/aftercommand/worker.rs +++ b/src/aftercommand/worker.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use std::process::{Command, Stdio}; use std::time::{Duration, SystemTime}; diff --git a/src/app/config.rs b/src/app/config.rs index 98655db..8f2a5e3 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use std::path::PathBuf; use anyhow::{Context, Result}; @@ -5,11 +9,12 @@ use regex::Regex; use super::{App, DiffMode}; use crate::aftercommand::{AfterCommandConfig, AfterCommandRuntime}; +use crate::child_bindings::{ChildBinding, compile_child_bindings}; use crate::cli::Cli; +use crate::keymap::{KeyBinding, compile_keymap}; use crate::screenshot::ScreenshotFormat; pub(super) struct AppConfig { - pub interval_secs: f64, pub child_pause_supported: bool, pub diff_mode: DiffMode, pub history_limit: usize, @@ -26,6 +31,8 @@ pub(super) struct AppConfig { pub aftercommand_runtime: Option, pub command_display: String, pub debug: bool, + pub keymap: Vec, + pub child_bindings: Vec, } impl AppConfig { @@ -46,9 +53,10 @@ impl AppConfig { }) .transpose()?; let command_display = App::command_display_from_cli(cli); + let keymap = compile_keymap(&cli.keymap).context("invalid --keymap")?; + let child_bindings = compile_child_bindings(&cli.bind).context("invalid --bind")?; Ok(Self { - interval_secs: cli.interval, child_pause_supported, diff_mode: cli.differences.into(), history_limit: cli.limit.max(1), @@ -76,6 +84,8 @@ impl AppConfig { }), command_display, debug: cli.debug, + keymap, + child_bindings, }) } } diff --git a/src/app/filter.rs b/src/app/filter.rs index 34209fa..92959dc 100644 --- a/src/app/filter.rs +++ b/src/app/filter.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use anyhow::Result; use regex::Regex; diff --git a/src/app/history_ops.rs b/src/app/history_ops.rs index 10bd072..65d8361 100644 --- a/src/app/history_ops.rs +++ b/src/app/history_ops.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use anyhow::Result; use super::{App, FocusPane}; diff --git a/src/app/input/history_nav.rs b/src/app/input/history_nav.rs index d310b41..f6daf48 100644 --- a/src/app/input/history_nav.rs +++ b/src/app/input/history_nav.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use crate::app::{App, FocusPane}; impl App { @@ -91,7 +95,7 @@ impl App { self.follow_latest = false; } - fn move_history_by(&mut self, offset: isize) { + pub(super) fn move_history_by(&mut self, offset: isize) { if self.filtered.is_empty() { return; } diff --git a/src/app/input/key.rs b/src/app/input/key.rs index fe7578f..3b2c034 100644 --- a/src/app/input/key.rs +++ b/src/app/input/key.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use anyhow::Result; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; @@ -40,16 +44,24 @@ impl App { return self.handle_search_key(key); } + if let Some(action) = self.find_key_action(key) { + return self.execute_key_action(action); + } + if self.ui.app_input_mode { if !self.follow_latest { return Ok(false); } if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('g') { self.ui.app_input_mode = false; - return Ok(false); + } else { + if let Some(action) = self.find_child_binding_action(key) { + self.apply_child_binding(key, action)?; + } else { + self.record_child_key_event(key); + self.source.send_key(key)?; + } } - self.record_child_key_event(key); - self.source.send_key(key)?; return Ok(false); } @@ -57,8 +69,12 @@ impl App { && self.ui.focus == FocusPane::Watch && self.should_passthrough_to_app(key) { - self.record_child_key_event(key); - self.source.send_key(key)?; + if let Some(action) = self.find_child_binding_action(key) { + self.apply_child_binding(key, action)?; + } else { + self.record_child_key_event(key); + self.source.send_key(key)?; + } return Ok(false); } diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index e5e0a26..a44a2f7 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -1,9 +1,16 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use std::time::Duration; use anyhow::Result; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; -use super::{App, DiffMode, FocusPane, InputMode}; +use super::{App, DiffMode, FilterMode, FocusPane, InputMode}; +use crate::child_bindings::{ChildBindingAction, ChildBindingKey}; +use crate::input_key::KeyPress; +use crate::keymap::KeyAction; mod history_nav; mod key; @@ -144,4 +151,181 @@ impl App { self.ui.inspect_x = self.ui.inspect_x.min(snapshot.width().saturating_sub(1)); self.ui.inspect_y = self.ui.inspect_y.min(snapshot.height().saturating_sub(1)); } + + fn find_key_action(&self, key: KeyEvent) -> Option { + let press = KeyPress::from(key); + self.keymap + .iter() + .rev() + .find(|binding| binding.trigger == press) + .map(|binding| binding.action) + } + + fn find_child_binding_action(&self, key: KeyEvent) -> Option { + let press = KeyPress::from(key); + self.child_bindings + .iter() + .rev() + .find(|binding| binding.trigger == press) + .map(|binding| binding.action.clone()) + } + + fn apply_child_binding(&mut self, key: KeyEvent, action: ChildBindingAction) -> Result<()> { + match action { + ChildBindingAction::SendKeys(keys) => { + self.record_child_key_event(key); + for item in keys { + match item { + ChildBindingKey::Press(press) => { + self.source.send_key(KeyEvent { + code: press.code, + modifiers: press.modifiers, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + })?; + } + ChildBindingKey::Bytes(bytes) => self.source.send_bytes(&bytes)?, + } + } + } + ChildBindingAction::SaveSnapshot => self.save_snapshot()?, + } + Ok(()) + } + + fn execute_key_action(&mut self, action: KeyAction) -> Result { + match action { + KeyAction::Up => self.move_up(), + KeyAction::WatchPaneUp => { + self.ui.focus = FocusPane::Watch; + self.scroll_selected_watch_view(-1); + } + KeyAction::HistoryPaneUp => { + self.ui.focus = FocusPane::History; + self.move_history_by(-1); + } + KeyAction::Down => self.move_down(), + KeyAction::WatchPaneDown => { + self.ui.focus = FocusPane::Watch; + self.scroll_selected_watch_view(1); + } + KeyAction::HistoryPaneDown => { + self.ui.focus = FocusPane::History; + self.move_history_by(1); + } + KeyAction::PageUp => self.page_up(), + KeyAction::WatchPanePageUp => { + self.ui.focus = FocusPane::Watch; + self.scroll_selected_watch_view(-10); + } + KeyAction::HistoryPanePageUp => { + self.ui.focus = FocusPane::History; + self.move_history_by(-10); + } + KeyAction::PageDown => self.page_down(), + KeyAction::WatchPanePageDown => { + self.ui.focus = FocusPane::Watch; + self.scroll_selected_watch_view(10); + } + KeyAction::HistoryPanePageDown => { + self.ui.focus = FocusPane::History; + self.move_history_by(10); + } + KeyAction::MoveTop => self.move_top(), + KeyAction::WatchPaneMoveTop => { + self.ui.focus = FocusPane::Watch; + self.ui.watch_scroll = 0; + } + KeyAction::HistoryPaneMoveTop => { + self.ui.focus = FocusPane::History; + self.follow_latest = true; + if let Some(first) = self.filtered.first().copied() { + self.selected_index = first; + } + self.invalidate_view_cache(); + } + KeyAction::MoveEnd => self.move_end(), + KeyAction::WatchPaneMoveEnd => { + self.ui.focus = FocusPane::Watch; + self.ui.watch_scroll = usize::MAX / 2; + } + KeyAction::HistoryPaneMoveEnd => { + self.ui.focus = FocusPane::History; + if let Some(last) = self.filtered.last().copied() { + self.selected_index = last; + self.follow_latest = false; + self.sync_follow_latest_with_selection(); + } + } + KeyAction::ToggleFocus => self.toggle_focus(), + KeyAction::FocusWatchPane => self.ui.focus = FocusPane::Watch, + KeyAction::FocusHistoryPane => self.ui.focus = FocusPane::History, + KeyAction::Quit => self.ui.show_exit_confirm = true, + KeyAction::Reset => { + if self.ui.show_help { + self.ui.show_help = false; + } else if self.ui.show_exit_confirm { + self.ui.show_exit_confirm = false; + } else if self.input_mode == InputMode::Search || !self.ui.filter_query.is_empty() { + self.clear_filter()?; + } + } + KeyAction::Delete => self.delete_selected_history()?, + KeyAction::ClearExceptSelected => self.clear_history_except_selected()?, + KeyAction::Cancel => { + if self.ui.filter_query.is_empty() { + self.ui.show_exit_confirm = true; + } else { + self.clear_filter()?; + } + } + KeyAction::ForceCancel => return Ok(true), + KeyAction::Help => self.ui.show_help = !self.ui.show_help, + KeyAction::ToggleViewHistoryPane => { + self.ui.show_history = !self.ui.show_history; + if self.ui.show_history { + self.ui.focus = FocusPane::History; + } else if self.ui.focus == FocusPane::History { + self.ui.focus = FocusPane::Watch; + self.ui.show_history_details = false; + } + } + KeyAction::ToggleHistorySummary => { + self.ui.show_history_details = !self.ui.show_history_details; + if !self.ui.show_history { + self.ui.show_history = true; + self.ui.focus = FocusPane::History; + } + } + KeyAction::ToggleDiffMode => self.cycle_diff_mode(), + KeyAction::SetDiffModeNone => self.diff_mode = DiffMode::None, + KeyAction::SetDiffModeWatch => self.diff_mode = DiffMode::Watch, + KeyAction::TogglePause => self.paused = !self.paused, + KeyAction::ToggleChildPause => self.toggle_child_pause()?, + KeyAction::ChangeFilterMode => self.start_search(FilterMode::Plain), + KeyAction::ChangeRegexFilterMode => self.start_search(FilterMode::Regex), + KeyAction::EnterAppInputMode => { + if self.ui.focus == FocusPane::Watch && self.follow_latest { + self.reset_watch_viewport(); + self.clear_live_scrollback_view(); + self.ui.app_input_mode = true; + } else if self.ui.focus == FocusPane::Watch { + self.ui.status_message = + Some("app input mode is available only on latest".to_string()); + } + } + KeyAction::LeaveAppInputMode => self.ui.app_input_mode = false, + KeyAction::ToggleInspector => { + self.ui.show_inspector = !self.ui.show_inspector; + self.clamp_inspector_to_snapshot(); + } + KeyAction::SaveSnapshot => self.save_snapshot()?, + KeyAction::CycleSnapshotFormat => self.cycle_screenshot_format(), + KeyAction::ScrollLeft => { + self.ui.horizontal_scroll = self.ui.horizontal_scroll.saturating_sub(4) + } + KeyAction::ScrollRight => self.ui.horizontal_scroll += 4, + } + Ok(false) + } } diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index be156c9..b0d4170 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use std::time::Instant; use anyhow::Result; diff --git a/src/app/input/trace.rs b/src/app/input/trace.rs index 212e6aa..94e32fa 100644 --- a/src/app/input/trace.rs +++ b/src/app/input/trace.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crossterm::event::{KeyEvent, MouseEvent}; diff --git a/src/app/mod.rs b/src/app/mod.rs index e61cee4..834af31 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,12 +1,18 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use std::cell::RefCell; use std::collections::VecDeque; use std::path::PathBuf; use std::time::{Duration, Instant}; use crate::aftercommand::AfterCommandRuntime; +use crate::child_bindings::ChildBinding; use crate::cli::{DiffModeArg, ScreenshotFormatArg}; use crate::history::HistoryMetadata; use crate::history::HistoryStore; +use crate::keymap::KeyBinding; use crate::runner::FrameSource; use crate::screen::ScreenSnapshot; use crate::screenshot::ScreenshotFormat; @@ -280,7 +286,6 @@ impl InputTraceEvent { } pub struct App { - pub interval_secs: f64, pub debug: bool, pub paused: bool, pub child_paused: bool, @@ -310,8 +315,9 @@ pub struct App { snapshot_trigger_fired: bool, aftercommand_runtime: Option, command_display: String, + keymap: Vec, + child_bindings: Vec, source: Box, - last_tick: Instant, last_mouse_input: Option, last_mouse_scroll_input: Option, pending_mouse_escape: Option, diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 2e1530c..f16b04d 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, RecvTimeoutError, Sender}; @@ -12,9 +16,7 @@ use crate::runner::SourceEvent; use crate::ui; impl App { - fn event_driven_poll_timeout(&self) -> Duration { - self.tick_timeout().min(Duration::from_millis(50)) - } + const EVENT_POLL_TIMEOUT: Duration = Duration::from_millis(50); pub fn run(mut self, terminal: DefaultTerminal) -> Result<()> { let (tx, rx) = mpsc::channel(); @@ -105,7 +107,7 @@ impl App { } if self.source.is_event_driven() { - match rx.recv_timeout(self.event_driven_poll_timeout()) { + match rx.recv_timeout(Self::EVENT_POLL_TIMEOUT) { Ok(AppEvent::Terminal(event)) => match self.process_terminal_event(event)? { LoopControl::Continue(redraw) => { if !self.paused && self.source.has_pending_update() { @@ -149,7 +151,7 @@ impl App { Err(RecvTimeoutError::Disconnected) => break, } } else { - match rx.recv_timeout(self.tick_timeout()) { + match rx.recv_timeout(Self::EVENT_POLL_TIMEOUT) { Ok(AppEvent::Terminal(event)) => match self.process_terminal_event(event)? { LoopControl::Continue(redraw) => { needs_redraw = redraw || needs_redraw; @@ -157,12 +159,7 @@ impl App { LoopControl::Break => break, }, Ok(AppEvent::SourceUpdated) | Ok(AppEvent::SourceClosed(_)) => {} - Err(RecvTimeoutError::Timeout) => { - if !self.paused && self.should_capture_now() { - self.capture_terminal_size(&terminal)?; - needs_redraw = true; - } - } + Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Disconnected) => break, } } diff --git a/src/app/snapshot.rs b/src/app/snapshot.rs index 67878ee..00e9049 100644 --- a/src/app/snapshot.rs +++ b/src/app/snapshot.rs @@ -1,5 +1,8 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use std::path::PathBuf; -use std::time::Instant; use anyhow::Result; @@ -26,7 +29,6 @@ impl App { } = self.source.capture(width, height)?; if !changed && self.current_snapshot.is_some() { - self.last_tick = Instant::now(); return Ok(()); } @@ -89,7 +91,6 @@ impl App { self.append_log_record()?; self.maybe_save_triggered_snapshot()?; self.maybe_run_aftercommand(&raw_output)?; - self.last_tick = Instant::now(); Ok(()) } diff --git a/src/app/state.rs b/src/app/state.rs index 56d8b1b..d2ca8e9 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1,4 +1,6 @@ -use std::time::{Duration, Instant}; +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. use anyhow::Result; @@ -12,7 +14,6 @@ impl App { pub fn new(cli: &Cli, source: Box) -> Result { let config = AppConfig::from_cli(cli, source.supports_child_pause())?; let mut app = Self { - interval_secs: config.interval_secs, debug: config.debug, paused: false, child_paused: false, @@ -42,8 +43,9 @@ impl App { snapshot_trigger_fired: false, aftercommand_runtime: config.aftercommand_runtime, command_display: config.command_display, + keymap: config.keymap, + child_bindings: config.child_bindings, source, - last_tick: Instant::now(), last_mouse_input: None, last_mouse_scroll_input: None, pending_mouse_escape: None, @@ -139,19 +141,6 @@ impl App { self.limit.saturating_add(self.trim_slack()) } - pub(super) fn tick_timeout(&self) -> Duration { - let interval = Duration::from_secs_f64(self.interval_secs.max(0.2)); - interval.saturating_sub(self.last_tick.elapsed()) - } - - pub(super) fn should_capture_now(&self) -> bool { - if self.source.is_event_driven() { - self.source.has_pending_update() - } else { - self.last_tick.elapsed() >= Duration::from_secs_f64(self.interval_secs.max(0.2)) - } - } - pub(super) fn is_source_closed_error(&self, err: &anyhow::Error) -> bool { err.chain().any(|cause| { cause.downcast_ref::().is_some_and(|io| { diff --git a/src/app/tests.rs b/src/app/tests.rs index 409c44d..ae9af7c 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use super::{App, FilterMode, FocusPane}; use crate::cli::{Cli, DiffModeArg, ScreenshotFormatArg}; use crate::logging::{LogRecord, append_record}; @@ -19,6 +23,7 @@ struct MockSource { mouse_passthrough_enabled: bool, view_snapshot_requests: Arc>>, key_events: Arc>>, + byte_events: Arc>>>, mouse_events: Arc>>, } @@ -32,6 +37,7 @@ impl MockSource { mouse_passthrough_enabled: true, view_snapshot_requests: Arc::new(Mutex::new(Vec::new())), key_events: Arc::new(Mutex::new(Vec::new())), + byte_events: Arc::new(Mutex::new(Vec::new())), mouse_events: Arc::new(Mutex::new(Vec::new())), } } @@ -45,6 +51,7 @@ impl MockSource { mouse_passthrough_enabled: false, view_snapshot_requests: Arc::new(Mutex::new(Vec::new())), key_events: Arc::new(Mutex::new(Vec::new())), + byte_events: Arc::new(Mutex::new(Vec::new())), mouse_events: Arc::new(Mutex::new(Vec::new())), } } @@ -58,6 +65,7 @@ impl MockSource { mouse_passthrough_enabled: true, view_snapshot_requests: Arc::new(Mutex::new(Vec::new())), key_events: Arc::new(Mutex::new(Vec::new())), + byte_events: Arc::new(Mutex::new(Vec::new())), mouse_events: Arc::new(Mutex::new(Vec::new())), } } @@ -104,6 +112,11 @@ impl FrameSource for MockSource { Ok(()) } + fn send_bytes(&mut self, bytes: &[u8]) -> Result<()> { + self.byte_events.lock().unwrap().push(bytes.to_vec()); + Ok(()) + } + fn send_mouse(&mut self, event: MouseEvent, _body_row_offset: u16) -> Result { if !self.mouse_passthrough_enabled { return Ok(false); @@ -1116,6 +1129,68 @@ fn key_passthrough_is_allowed_when_following_latest() { assert_eq!(key_events.lock().unwrap().len(), 1); } +#[test] +fn custom_keymap_can_override_watch_passthrough() { + let mut cli = test_cli(); + cli.keymap = vec!["down=history_pane_down".to_string()]; + let mut app = App::new( + &cli, + Box::new(MockSource::new(vec![ + frame("a", &["one"]), + frame("b", &["two"]), + frame("c", &["three"]), + ])), + ) + .unwrap(); + + app.capture(20, 5).unwrap(); + app.capture(20, 5).unwrap(); + app.capture(20, 5).unwrap(); + app.ui.focus = FocusPane::Watch; + + app.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) + .unwrap(); + + assert!(!app.follow_latest); + assert_eq!(app.selected_index, 1); +} + +#[test] +fn child_bindings_remap_passthrough_keys_to_raw_bytes() { + let mut cli = test_cli(); + cli.bind = vec!["j=down".to_string()]; + let source = MockSource::new(vec![frame("a", &["one"])]); + let byte_events = source.byte_events.clone(); + let key_events = source.key_events.clone(); + let mut app = App::new(&cli, Box::new(source)).unwrap(); + + app.capture(20, 5).unwrap(); + app.ui.app_input_mode = true; + app.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)) + .unwrap(); + + assert_eq!(key_events.lock().unwrap().len(), 1); + assert!(byte_events.lock().unwrap().is_empty()); + assert_eq!(key_events.lock().unwrap()[0].code, KeyCode::Down); +} + +#[test] +fn ctrl_g_remains_reserved_to_leave_app_input_mode() { + let mut cli = test_cli(); + cli.bind = vec!["ctrl-g=text:gg".to_string()]; + let source = MockSource::new(vec![frame("a", &["one"])]); + let byte_events = source.byte_events.clone(); + let mut app = App::new(&cli, Box::new(source)).unwrap(); + + app.capture(20, 5).unwrap(); + app.ui.app_input_mode = true; + + app.handle_key_event(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL)) + .unwrap(); + assert!(byte_events.lock().unwrap().is_empty()); + assert!(!app.ui.app_input_mode); +} + #[test] fn app_input_mode_is_available_only_on_latest() { let mut app = App::new( @@ -1471,7 +1546,6 @@ fn history_snapshots_preserve_cursor_state() { fn test_cli() -> Cli { Cli { - interval: 2.0, batch: false, batch_count: None, batch_size: None, @@ -1479,6 +1553,8 @@ fn test_cli() -> Cli { batch_diff_only: false, batch_no_color: false, aftercommand: None, + keymap: Vec::new(), + bind: Vec::new(), aftercommand_regex: None, aftercommand_change_cells: None, aftercommand_every: None, diff --git a/src/app/trigger.rs b/src/app/trigger.rs index 4ac9ba7..f0901d7 100644 --- a/src/app/trigger.rs +++ b/src/app/trigger.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use std::path::PathBuf; use anyhow::Result; diff --git a/src/app/view.rs b/src/app/view.rs index 4ac05ab..ad44907 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use super::{App, AppHistoryMetadata, ViewCache, ViewCacheKey}; use crate::screen::ScreenSnapshot; diff --git a/src/batch/mod.rs b/src/batch/mod.rs index db5af84..5d5d1f8 100644 --- a/src/batch/mod.rs +++ b/src/batch/mod.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use crate::cli::{Cli, CropSpec}; use crate::screen::ScreenSnapshot; diff --git a/src/batch/render.rs b/src/batch/render.rs index 8fb14b1..6f895ab 100644 --- a/src/batch/render.rs +++ b/src/batch/render.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use crate::cli::{Cli, DiffModeArg}; use crate::screen::ScreenSnapshot; use crate::screenshot::render_ansi_text; diff --git a/src/batch/tests.rs b/src/batch/tests.rs index 293dd4d..db20cbd 100644 --- a/src/batch/tests.rs +++ b/src/batch/tests.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use super::{prepare_snapshot, render_output}; use crate::cli::{Cli, CropSpec, DiffModeArg, ScreenshotFormatArg}; use crate::screen::ScreenSnapshot; @@ -61,7 +65,6 @@ fn renders_word_diff_with_same_prefix_shape() { fn test_cli() -> Cli { Cli { - interval: 2.0, batch: true, batch_count: None, batch_size: None, @@ -69,6 +72,8 @@ fn test_cli() -> Cli { batch_diff_only: false, batch_no_color: false, aftercommand: None, + keymap: Vec::new(), + bind: Vec::new(), aftercommand_regex: None, aftercommand_change_cells: None, aftercommand_every: None, diff --git a/src/child_bindings.rs b/src/child_bindings.rs new file mode 100644 index 0000000..92a5f8b --- /dev/null +++ b/src/child_bindings.rs @@ -0,0 +1,92 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + +use anyhow::{Context, Result}; + +use crate::input_key::{KeyPress, parse_key_bytes, parse_key_press}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ChildBindingAction { + SendKeys(Vec), + SaveSnapshot, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ChildBindingKey { + Press(KeyPress), + Bytes(Vec), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ChildBinding { + pub(crate) trigger: KeyPress, + pub(crate) action: ChildBindingAction, +} + +pub(crate) fn compile_child_bindings(specs: &[String]) -> Result> { + let mut bindings = Vec::new(); + for spec in specs { + let (left, right) = spec + .split_once('=') + .with_context(|| format!("binding must be FROM=TO: {spec}"))?; + let trigger = parse_key_press(left.trim())?; + let action = parse_child_binding_action(right.trim())?; + bindings.push(ChildBinding { trigger, action }); + } + Ok(bindings) +} + +fn parse_child_binding_action(value: &str) -> Result { + if value.eq_ignore_ascii_case("screenshot") { + return Ok(ChildBindingAction::SaveSnapshot); + } + + if let Some(text) = value.strip_prefix("text:") { + return Ok(ChildBindingAction::SendKeys(vec![ChildBindingKey::Bytes( + text.as_bytes().to_vec(), + )])); + } + + let key_list = value.strip_prefix("send:").unwrap_or(value); + let mut keys = Vec::new(); + for item in key_list.split(',') { + let item = item.trim(); + if let Ok(press) = parse_key_press(item) { + keys.push(ChildBindingKey::Press(press)); + } else { + keys.push(ChildBindingKey::Bytes(parse_key_bytes(item)?)); + } + } + Ok(ChildBindingAction::SendKeys(keys)) +} + +#[cfg(test)] +mod tests { + use super::{ChildBindingAction, ChildBindingKey, compile_child_bindings}; + use crate::input_key::KeyPress; + use crossterm::event::{KeyCode, KeyModifiers}; + + #[test] + fn parses_twrap_style_bindings() { + let bindings = compile_child_bindings(&[ + "j=down".to_string(), + "ctrl-t=screenshot".to_string(), + "g=text:gg".to_string(), + ]) + .unwrap(); + + assert!(bindings.iter().any(|binding| binding.action + == ChildBindingAction::SendKeys(vec![ChildBindingKey::Press(KeyPress { + code: KeyCode::Down, + modifiers: KeyModifiers::NONE, + })]))); + assert!( + bindings + .iter() + .any(|binding| binding.action == ChildBindingAction::SaveSnapshot) + ); + assert!(bindings.iter().any(|binding| binding.action + == ChildBindingAction::SendKeys(vec![ChildBindingKey::Bytes(b"gg".to_vec())]))); + } +} diff --git a/src/cli.rs b/src/cli.rs index bad350f..ea71e2e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -18,11 +18,8 @@ pub fn default_shell() -> String { } #[derive(Debug, Clone, Parser)] -#[command(author, version, about = "watch for TUI apps", long_about = None)] +#[command(author, version, about = "twatch - adds rewindable history to existing TUI applications.", long_about = None)] pub struct Cli { - #[arg(short = 'n', long, default_value_t = 2.0)] - pub interval: f64, - #[arg(short = 'b', long)] pub batch: bool, @@ -52,6 +49,22 @@ pub struct Cli { #[arg(short = 'A', long)] pub aftercommand: Option, + #[arg( + short = 'K', + long, + help = "Remap twatch keys with KEY=ACTION", + value_name = "KEY=ACTION" + )] + pub keymap: Vec, + + #[arg( + short = 'k', + long, + help = "Override child TUI keys with FROM=TO", + value_name = "FROM=TO" + )] + pub bind: Vec, + #[arg(long, help = "Only run aftercommand when output matches this regex")] pub aftercommand_regex: Option, diff --git a/src/diff.rs b/src/diff.rs index e3f361f..7aa5c9c 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + #[derive(Clone, Debug, Eq, PartialEq)] pub struct LineDiff { pub line_index: usize, diff --git a/src/history.rs b/src/history.rs index b462840..d27b5ef 100644 --- a/src/history.rs +++ b/src/history.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use std::io::{Read, Write}; use anyhow::{Context, Result}; diff --git a/src/input_key.rs b/src/input_key.rs new file mode 100644 index 0000000..77e2b46 --- /dev/null +++ b/src/input_key.rs @@ -0,0 +1,198 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + +use anyhow::{Context, Result}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub(crate) struct KeyPress { + pub(crate) code: KeyCode, + pub(crate) modifiers: KeyModifiers, +} + +impl From for KeyPress { + fn from(value: KeyEvent) -> Self { + Self { + code: value.code, + modifiers: value.modifiers, + } + } +} + +pub(crate) fn parse_key_press(value: &str) -> Result { + let (modifiers, key_name) = parse_key_name(value)?; + let code = parse_key_code(key_name, modifiers)?; + Ok(KeyPress { code, modifiers }) +} + +pub(crate) fn parse_key_bytes(value: &str) -> Result> { + let normalized = value.trim().to_ascii_lowercase(); + if normalized.is_empty() { + anyhow::bail!("key binding token must not be empty"); + } + + Ok(match normalized.as_str() { + "up" => b"\x1b[A".to_vec(), + "down" => b"\x1b[B".to_vec(), + "right" => b"\x1b[C".to_vec(), + "left" => b"\x1b[D".to_vec(), + "home" => b"\x1b[H".to_vec(), + "end" => b"\x1b[F".to_vec(), + "pageup" => b"\x1b[5~".to_vec(), + "pagedown" => b"\x1b[6~".to_vec(), + "insert" => b"\x1b[2~".to_vec(), + "delete" => b"\x1b[3~".to_vec(), + "enter" => vec![b'\r'], + "tab" => vec![b'\t'], + "esc" => vec![0x1b], + "space" => vec![b' '], + "backspace" => vec![0x7f], + "f1" => b"\x1bOP".to_vec(), + "f2" => b"\x1bOQ".to_vec(), + "f3" => b"\x1bOR".to_vec(), + "f4" => b"\x1bOS".to_vec(), + "f5" => b"\x1b[15~".to_vec(), + "f6" => b"\x1b[17~".to_vec(), + "f7" => b"\x1b[18~".to_vec(), + "f8" => b"\x1b[19~".to_vec(), + "f9" => b"\x1b[20~".to_vec(), + "f10" => b"\x1b[21~".to_vec(), + "f11" => b"\x1b[23~".to_vec(), + "f12" => b"\x1b[24~".to_vec(), + token if token.starts_with("ctrl-") => parse_ctrl_key(token)?, + token => parse_literal_key(token)?, + }) +} + +fn parse_key_name(value: &str) -> Result<(KeyModifiers, &str)> { + let trimmed = value.trim(); + if trimmed.is_empty() { + anyhow::bail!("key binding token must not be empty"); + } + + let mut modifiers = KeyModifiers::empty(); + let mut rest = trimmed; + loop { + let lower = rest.to_ascii_lowercase(); + if let Some(stripped) = lower.strip_prefix("ctrl-") { + modifiers.insert(KeyModifiers::CONTROL); + rest = &rest[(rest.len() - stripped.len())..]; + continue; + } + if let Some(stripped) = lower.strip_prefix("alt-") { + modifiers.insert(KeyModifiers::ALT); + rest = &rest[(rest.len() - stripped.len())..]; + continue; + } + if let Some(stripped) = lower.strip_prefix("shift-") { + modifiers.insert(KeyModifiers::SHIFT); + rest = &rest[(rest.len() - stripped.len())..]; + continue; + } + break; + } + + if rest.is_empty() { + anyhow::bail!("key binding token must not be empty"); + } + + Ok((modifiers, rest)) +} + +fn parse_key_code(value: &str, modifiers: KeyModifiers) -> Result { + let normalized = value.to_ascii_lowercase(); + match normalized.as_str() { + "up" => Ok(KeyCode::Up), + "down" => Ok(KeyCode::Down), + "left" => Ok(KeyCode::Left), + "right" => Ok(KeyCode::Right), + "home" => Ok(KeyCode::Home), + "end" => Ok(KeyCode::End), + "pageup" => Ok(KeyCode::PageUp), + "pagedown" => Ok(KeyCode::PageDown), + "tab" => Ok(KeyCode::Tab), + "backtab" => Ok(KeyCode::BackTab), + "backspace" => Ok(KeyCode::Backspace), + "enter" => Ok(KeyCode::Enter), + "esc" => Ok(KeyCode::Esc), + "insert" => Ok(KeyCode::Insert), + "delete" => Ok(KeyCode::Delete), + "space" => Ok(KeyCode::Char(' ')), + "plus" => Ok(KeyCode::Char('+')), + "minus" => Ok(KeyCode::Char('-')), + name if name.starts_with('f') => { + let num = name[1..] + .parse::() + .with_context(|| format!("unsupported function key: {value}"))?; + Ok(KeyCode::F(num)) + } + _ => parse_char_code(value, modifiers), + } +} + +fn parse_char_code(value: &str, modifiers: KeyModifiers) -> Result { + let mut chars = value.chars(); + let ch = chars + .next() + .with_context(|| format!("unsupported key binding token: {value}"))?; + if chars.next().is_some() { + anyhow::bail!("unsupported key binding token: {value}"); + } + + let code = if modifiers.contains(KeyModifiers::SHIFT) && ch.is_ascii_alphabetic() { + ch.to_ascii_uppercase() + } else { + ch + }; + + Ok(KeyCode::Char(code)) +} + +fn parse_ctrl_key(value: &str) -> Result> { + let suffix = &value[5..]; + if suffix.len() != 1 { + anyhow::bail!("ctrl binding must have one character: {value}"); + } + let ch = suffix.as_bytes()[0]; + if !ch.is_ascii_alphabetic() && ch != b'\\' && ch != b'[' && ch != b']' { + anyhow::bail!("unsupported ctrl binding: {value}"); + } + Ok(match ch { + b'\\' => vec![0x1c], + b'[' => vec![0x1b], + b']' => vec![0x1d], + _ => vec![ch.to_ascii_uppercase() - b'@'], + }) +} + +fn parse_literal_key(value: &str) -> Result> { + if value.chars().count() != 1 { + anyhow::bail!("unsupported key binding token: {value}"); + } + Ok(value.as_bytes().to_vec()) +} + +#[cfg(test)] +mod tests { + use super::{KeyPress, parse_key_bytes, parse_key_press}; + use crossterm::event::{KeyCode, KeyModifiers}; + + #[test] + fn parses_shift_letter_keypress() { + let key = parse_key_press("shift-s").unwrap(); + assert_eq!( + key, + KeyPress { + code: KeyCode::Char('S'), + modifiers: KeyModifiers::SHIFT, + } + ); + } + + #[test] + fn parses_ctrl_bytes_like_twrap() { + assert_eq!(parse_key_bytes("ctrl-g").unwrap(), vec![0x07]); + assert_eq!(parse_key_bytes("down").unwrap(), b"\x1b[B".to_vec()); + } +} diff --git a/src/keymap.rs b/src/keymap.rs new file mode 100644 index 0000000..42176f2 --- /dev/null +++ b/src/keymap.rs @@ -0,0 +1,138 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + +use anyhow::{Context, Result}; + +use crate::input_key::{KeyPress, parse_key_press}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum KeyAction { + Up, + WatchPaneUp, + HistoryPaneUp, + Down, + WatchPaneDown, + HistoryPaneDown, + PageUp, + WatchPanePageUp, + HistoryPanePageUp, + PageDown, + WatchPanePageDown, + HistoryPanePageDown, + MoveTop, + WatchPaneMoveTop, + HistoryPaneMoveTop, + MoveEnd, + WatchPaneMoveEnd, + HistoryPaneMoveEnd, + ToggleFocus, + FocusWatchPane, + FocusHistoryPane, + Quit, + Reset, + Delete, + ClearExceptSelected, + Cancel, + ForceCancel, + Help, + ToggleViewHistoryPane, + ToggleHistorySummary, + ToggleDiffMode, + SetDiffModeNone, + SetDiffModeWatch, + TogglePause, + ToggleChildPause, + ChangeFilterMode, + ChangeRegexFilterMode, + EnterAppInputMode, + LeaveAppInputMode, + ToggleInspector, + SaveSnapshot, + CycleSnapshotFormat, + ScrollLeft, + ScrollRight, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct KeyBinding { + pub(crate) trigger: KeyPress, + pub(crate) action: KeyAction, +} + +pub(crate) fn compile_keymap(specs: &[String]) -> Result> { + let mut bindings = Vec::new(); + for spec in specs { + let (left, right) = spec + .split_once('=') + .with_context(|| format!("keymap must be KEY=ACTION: {spec}"))?; + let trigger = parse_key_press(left.trim())?; + let action = parse_key_action(right.trim())?; + bindings.push(KeyBinding { trigger, action }); + } + Ok(bindings) +} + +fn parse_key_action(value: &str) -> Result { + Ok(match value.trim().to_ascii_lowercase().as_str() { + "up" => KeyAction::Up, + "watch_pane_up" => KeyAction::WatchPaneUp, + "history_pane_up" => KeyAction::HistoryPaneUp, + "down" => KeyAction::Down, + "watch_pane_down" => KeyAction::WatchPaneDown, + "history_pane_down" => KeyAction::HistoryPaneDown, + "page_up" => KeyAction::PageUp, + "watch_pane_page_up" => KeyAction::WatchPanePageUp, + "history_pane_page_up" => KeyAction::HistoryPanePageUp, + "page_down" => KeyAction::PageDown, + "watch_pane_page_down" => KeyAction::WatchPanePageDown, + "history_pane_page_down" => KeyAction::HistoryPanePageDown, + "move_top" => KeyAction::MoveTop, + "watch_pane_move_top" => KeyAction::WatchPaneMoveTop, + "history_pane_move_top" => KeyAction::HistoryPaneMoveTop, + "move_end" => KeyAction::MoveEnd, + "watch_pane_move_end" => KeyAction::WatchPaneMoveEnd, + "history_pane_move_end" => KeyAction::HistoryPaneMoveEnd, + "toggle_focus" => KeyAction::ToggleFocus, + "focus_watch_pane" => KeyAction::FocusWatchPane, + "focus_history_pane" => KeyAction::FocusHistoryPane, + "quit" => KeyAction::Quit, + "reset" => KeyAction::Reset, + "delete" => KeyAction::Delete, + "clear_except_selected" => KeyAction::ClearExceptSelected, + "cancel" => KeyAction::Cancel, + "force_cancel" => KeyAction::ForceCancel, + "help" => KeyAction::Help, + "toggle_view_history_pane" => KeyAction::ToggleViewHistoryPane, + "toggle_history_summary" => KeyAction::ToggleHistorySummary, + "toggle_diff_mode" => KeyAction::ToggleDiffMode, + "set_diff_mode_none" | "set_diff_mode_plane" => KeyAction::SetDiffModeNone, + "set_diff_mode_watch" => KeyAction::SetDiffModeWatch, + "toggle_pause" => KeyAction::TogglePause, + "toggle_child_pause" => KeyAction::ToggleChildPause, + "change_filter_mode" => KeyAction::ChangeFilterMode, + "change_regex_filter_mode" => KeyAction::ChangeRegexFilterMode, + "enter_app_input_mode" => KeyAction::EnterAppInputMode, + "leave_app_input_mode" => KeyAction::LeaveAppInputMode, + "toggle_inspector" => KeyAction::ToggleInspector, + "save_snapshot" => KeyAction::SaveSnapshot, + "cycle_snapshot_format" => KeyAction::CycleSnapshotFormat, + "scroll_left" => KeyAction::ScrollLeft, + "scroll_right" => KeyAction::ScrollRight, + _ => anyhow::bail!("unsupported key action: {value}"), + }) +} + +#[cfg(test)] +mod tests { + use super::{KeyAction, compile_keymap}; + + #[test] + fn parses_hwatch_style_keymap() { + let bindings = + compile_keymap(&["ctrl-p=history_pane_up".to_string(), "q=quit".to_string()]).unwrap(); + assert_eq!(bindings.len(), 2); + assert_eq!(bindings[0].action, KeyAction::HistoryPaneUp); + assert_eq!(bindings[1].action, KeyAction::Quit); + } +} diff --git a/src/lib.rs b/src/lib.rs index 1952c75..71f5417 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,16 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + pub mod aftercommand; pub mod app; pub mod batch; +mod child_bindings; pub mod cli; pub mod diff; pub mod history; +mod input_key; +mod keymap; pub mod logging; mod process_control; pub mod runner; diff --git a/src/logging.rs b/src/logging.rs index e085aa3..47292dd 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use std::fs::{File, OpenOptions}; use std::io::{BufRead, BufReader, Write}; use std::path::Path; diff --git a/src/main.rs b/src/main.rs index 98cf0d4..784876b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use std::borrow::Cow; use std::fs; use std::io::Write; @@ -16,9 +20,11 @@ use twatch::aftercommand::{AfterCommandConfig, AfterCommandEvent, AfterCommandRu use twatch::app::App; use twatch::batch; use twatch::cli::Cli; -use twatch::runner::{DemoRunner, FrameSource, PtyRunner, ReplayRunner}; +use twatch::runner::{DemoRunner, FrameSource, PtyRunner, ReplayRunner, SourceEvent}; use twatch::screen::ScreenSnapshot; +const DEMO_BATCH_INTERVAL: Duration = Duration::from_millis(500); + fn main() -> Result<()> { let cli = Cli::parse(); if cli.batch { @@ -49,8 +55,8 @@ fn run_batch(cli: Cli) -> Result<()> { let (width, height) = batch::terminal_size(&cli); let mut source = build_source(&cli, SourceBuildMode::Batch, width, height)?; let mut aftercommand_runtime = build_batch_aftercommand_runtime(&cli)?; - - let interval = Duration::from_secs_f64(cli.interval.max(0.2)); + let is_event_driven = source.is_event_driven(); + let update_rx = source.take_update_receiver(); let mut stdout = std::io::stdout(); let mut emitted = 0usize; let mut previous = None; @@ -61,7 +67,15 @@ fn run_batch(cli: Cli) -> Result<()> { break; } - std::thread::sleep(interval); + if is_event_driven { + match wait_for_batch_source_update(update_rx.as_ref(), &*source) { + BatchLoopStep::Capture => {} + BatchLoopStep::Break => break, + } + } else { + std::thread::sleep(DEMO_BATCH_INTERVAL); + } + let frame = source.capture(width, height)?; if let Some(runtime) = &mut aftercommand_runtime { let changed_cell_count = frame @@ -91,6 +105,40 @@ fn run_batch(cli: Cli) -> Result<()> { Ok(()) } +enum BatchLoopStep { + Capture, + Break, +} + +fn wait_for_batch_source_update( + update_rx: Option<&std::sync::mpsc::Receiver>, + source: &dyn FrameSource, +) -> BatchLoopStep { + let Some(update_rx) = update_rx else { + return BatchLoopStep::Capture; + }; + + loop { + match update_rx.recv() { + Ok(SourceEvent::Updated) => return BatchLoopStep::Capture, + Ok(SourceEvent::Closed) | Err(_) => { + return if source.has_pending_update() { + BatchLoopStep::Capture + } else { + BatchLoopStep::Break + }; + } + Ok(SourceEvent::ClosedWithError(_)) => { + return if source.has_pending_update() { + BatchLoopStep::Capture + } else { + BatchLoopStep::Break + }; + } + } + } +} + #[derive(Copy, Clone, Debug, Eq, PartialEq)] enum SourceBuildMode { Interactive, diff --git a/src/process_control.rs b/src/process_control.rs index 57b0fd1..efcda3d 100644 --- a/src/process_control.rs +++ b/src/process_control.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use std::io; #[cfg(unix)] diff --git a/src/runner/capture_hook.rs b/src/runner/capture_hook.rs index c4f8d0b..ec04fa2 100644 --- a/src/runner/capture_hook.rs +++ b/src/runner/capture_hook.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use std::process::{Command, Stdio}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 5a5a02e..e2081c9 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -43,6 +43,9 @@ pub trait FrameSource { } fn resize(&mut self, width: u16, height: u16) -> Result<()>; fn send_key(&mut self, key: KeyEvent) -> Result<()>; + fn send_bytes(&mut self, _bytes: &[u8]) -> Result<()> { + Ok(()) + } fn send_mouse(&mut self, event: MouseEvent, body_row_offset: u16) -> Result; fn toggle_child_pause(&mut self) -> Result> { Ok(None) diff --git a/src/runner/pty.rs b/src/runner/pty.rs index 30dbcfa..46b88e1 100644 --- a/src/runner/pty.rs +++ b/src/runner/pty.rs @@ -274,6 +274,15 @@ impl FrameSource for PtyRunner { Ok(()) } + fn send_bytes(&mut self, bytes: &[u8]) -> Result<()> { + let mut writer = self.writer.lock().expect("writer poisoned"); + writer + .write_all(bytes) + .context("failed to write child binding bytes to PTY")?; + writer.flush().ok(); + Ok(()) + } + fn send_mouse(&mut self, event: MouseEvent, body_row_offset: u16) -> Result { let encoded = { let state = self.state.read().expect("terminal state poisoned"); diff --git a/src/screen.rs b/src/screen.rs index b2303f7..6ee5c00 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use std::fmt; use ratatui::style::{Color, Modifier, Style as TuiStyle}; diff --git a/src/screenshot.rs b/src/screenshot.rs index 9177964..77b0bcc 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use std::fs; use std::path::Path; diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs index 398ccac..0c33b27 100644 --- a/src/ui/dialogs.rs +++ b/src/ui/dialogs.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Style}; @@ -20,6 +24,8 @@ pub(super) fn draw_help(frame: &mut Frame<'_>, area: Rect) { Line::from("Left/Right focus watch/history"), Line::from("Up/Down child app in watch pane / move history"), Line::from("Mouse passthrough only on latest"), + Line::from("--keymap remap twatch actions"), + Line::from("--bind override child TUI keys"), Line::from("Backspace toggle history pane"), Line::from("/ search history"), Line::from("* regex filter history"), diff --git a/src/ui/header.rs b/src/ui/header.rs index 30ed931..3849edd 100644 --- a/src/ui/header.rs +++ b/src/ui/header.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; @@ -23,12 +27,7 @@ pub(super) fn draw_header_line_one(frame: &mut Frame<'_>, app: &App, area: Rect) .constraints([Constraint::Min(10), Constraint::Length(24)]) .split(area); - let cadence = if app.is_event_driven() { - "Event".to_string() - } else { - format!("Every {:>4.3}", app.interval_secs) - }; - let command_text = format!("{cadence} {}", app.command_display()); + let command_text = format!("Event {}", app.command_display()); let command_line = Line::from(vec![ Span::styled(" ", Style::default().bg(HEADER_BG)), Span::styled( diff --git a/src/ui/history.rs b/src/ui/history.rs index e958cd8..3aa2571 100644 --- a/src/ui/history.rs +++ b/src/ui/history.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use ratatui::Frame; use ratatui::layout::{Alignment, Rect}; use ratatui::style::{Color, Style}; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ec48b93..4a7a3e4 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::style::Color; diff --git a/src/ui/watch.rs b/src/ui/watch.rs index e4dd201..74a58c9 100644 --- a/src/ui/watch.rs +++ b/src/ui/watch.rs @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Blacknon. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + use ratatui::buffer::Buffer; use ratatui::layout::Position; use ratatui::layout::{Constraint, Direction, Layout, Rect};