diff --git a/TODO.md b/TODO.md index 40d2a92..0686f27 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,3 @@ -- [ ] `ctrl+i` seems to be highjacked in the diff view, so jumping forward isn't possible (only backwards is). instead, `ctrl+i` seems to cycle the panes which the `tab` key already does, so it's really not necessary to highjack `ctrl+i` too. - - this should be fixed. - [ ] diff view: local review UX first, independently of GitHub integration, for reviewing local changes and delivering feedback to coding agents. - add comments at the caret or on selected line ranges on either diff side; compose/edit with normal editor interaction, delete drafts, navigate comments, and open a review overview. - start with gutter markers and a focused comment panel rather than inserting comment text into source rows; iterate on the interaction before adding remote operations. diff --git a/docs/architecture.md b/docs/architecture.md index 3e2e7f6..667fffe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,7 +64,7 @@ Frontends borrow `snippet_selections()` for the active argument's end-exclusive The terminal guard owns raw mode, alternate-screen state, bracketed paste, mouse capture, focus reporting, DEC 2031 color-scheme notifications, and its panic hook across startup, rendering, session teardown, and relaunch. An inherited-screen restart installs cleanup and its panic hook before argument, file, or configuration parsing, resumes raw mode and input reporting without entering the alternate screen again, and clears and repaints inherited contents in its first synchronized frame. A private first-argument marker identifies this handoff without changing the original CLI arguments. Final quit, startup or I/O errors, failed relaunch, and unwinding panics restore normal input modes, the normal screen, cursor visibility, and the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while the guard is active. -`tui::TerminalSession::run(inherited_screen, body)` scopes this ownership; `tui::run(&mut session, inherited_screen)` renders inside that active guard. `TerminalSession::prepare_restart()` resets process-local input state without leaving the alternate screen, and the guard stays armed if relaunch fails. +`tui::TerminalSession::run(inherited_screen, body)` scopes this ownership; `tui::run(&mut session, inherited_screen)` renders inside that active guard. On Unix, a fresh alternate screen pushes disambiguated keyboard flags so Ctrl+i stays distinct from Tab; final cleanup pops them. `TerminalSession::prepare_restart()` resets process-local input state without leaving the alternate screen or popping its keyboard flags. The replacement process inherits that stack entry rather than pushing another, and the guard stays armed if relaunch fails. Redraws stage Ratatui's buffer diff, cursor placement, and DEC synchronized-update commands (mode 2026) in a reusable byte buffer, then write and flush the complete frame to the terminal. Frame computation never suspends terminal presentation; synchronization covers transmitted painting rather than layout/highlighting work. The hardware cursor is hidden before painting even when synchronization is unsupported. Terminal cleanup releases synchronization before leaving the alternate screen or invoking caller panic diagnostics. Backend-writer access is enabled solely for this staging seam. diff --git a/docs/configuration.md b/docs/configuration.md index 49b17eb..98ef408 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -87,7 +87,7 @@ config.language_tab_spaces.merge!( ) ``` -Active snippets retain Tab/Shift+Tab argument navigation; after the final stop, Tab uses the configured insertion setting. Explicit Tab bindings still take precedence, and Ctrl+i remains a Tab alias. Bracketed paste, clipboard paste, completion payloads, and plugin text retain literal tab characters. Insert undo/redo and dot repeat preserve the resolved spaces or tab. Widths other than `nil` or integers in `1..255`, non-hash language overrides, and empty, non-string, or control-containing language keys reject the configuration. +Active snippets retain Tab/Shift+Tab argument navigation; after the final stop, Tab uses the configured insertion setting. Ctrl+i defaults to the same Insert action but has its own binding. Explicit bindings for either key take precedence independently. Bracketed paste, clipboard paste, completion payloads, and plugin text retain literal tab characters. Insert undo/redo and dot repeat preserve the resolved spaces or tab. Widths other than `nil` or integers in `1..255`, non-hash language overrides, and empty, non-string, or control-containing language keys reject the configuration. `config.git_blame` defaults to `true`; set it to `false` to disable current-line inline Git blame and its background worker. Only literal Ruby booleans are accepted; other values reject the configuration. Toggle it for the running session with `:git_blame.toggle`, its palette action, or a custom binding such as `config.bind "leader b", config.core.git_blame.toggle`. There is no default shortcut. @@ -189,9 +189,9 @@ Key notation follows [Herdr's readable style](https://herdr.dev/docs/configurati - Modifiers: `ctrl+a` through `ctrl+z`, `alt+a` through `alt+z` (`option+` is an alias for `alt+`), `ctrl+alt+a` through `ctrl+alt+z` (either modifier order), `shift+a` through `shift+z`, and `shift+tab`. `shift+j` and `J` address the same key. Modified Backspace accepts `alt+backspace` / `option+backspace` and `cmd+backspace` / `super+backspace`. - Named punctuation: `minus`, `comma`, `ampersand`, `plus`, `backtick`. Each addresses the same binding as its literal character. -`ctrl+i` aliases `tab`, and `ctrl+m` aliases `enter`, including inside sequences. Ctrl+h and Ctrl+j are distinct control keys, not Backspace/Enter aliases. ASCII letters support a single modifier or combined Ctrl+Alt/Option; Shift+Tab and the modified Backspace keys above are also supported. Cmd/Super character keys, other combined modifiers, other modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key. +`ctrl+i` and `tab` are distinct bindings, including inside sequences; `ctrl+m` aliases `enter`. Ctrl+h and Ctrl+j are distinct control keys, not Backspace/Enter aliases. The Unix TUI requests disambiguated keyboard events so supporting terminals can distinguish Ctrl+i from Tab. Legacy terminals still send both as Tab. ASCII letters support a single modifier or combined Ctrl+Alt/Option; Shift+Tab and the modified Backspace keys above are also supported. Cmd/Super character keys, other combined modifiers, other modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key. -Normal mode binds `ctrl+o` to `config.core.jump_back` and `ctrl+i`/`tab` to `config.core.jump_forward`. Insert-mode Tab inserts literal text outside snippets; inside one it moves to the next argument. Override or disable these keys with the ordinary per-mode binding interface; jump actions also appear in the command palette. +Normal mode binds `ctrl+o` to `config.core.jump_back` and `ctrl+i` to `config.core.jump_forward`, including in comparisons. Normal Tab also jumps forward outside comparisons; in comparisons it cycles panes. Insert-mode Tab and Ctrl+i insert literal text outside snippets; inside one they move to the next argument. Override or disable these keys independently with the ordinary per-mode binding interface; jump actions also appear in the command palette. `config.core.backspace_word` and `config.core.backspace_line` are typed actions eligible in Insert, Command, and Search. Ctrl+u defaults to `config.core.backspace_line` in all three modes on every platform; it can be rebound or disabled with the ordinary per-mode binding interface. macOS additionally defaults Option+Backspace and Command+Backspace to word and line deletion respectively. Any platform can explicitly bind these actions to supported keys; for example, `config.bind "ctrl+w", config.core.backspace_word, mode: config.modes.insert`. Word deletion follows Unicode word boundaries after skipping trailing whitespace; line deletion removes the caret-to-logical-line-start prefix, not the suffix or newline. Active snippets confine both actions to their argument. macOS prompt and filter queries also recognize these modified Backspace keys. diff --git a/docs/development.md b/docs/development.md index 577009b..9dc5035 100644 --- a/docs/development.md +++ b/docs/development.md @@ -38,7 +38,7 @@ Rust tests live alongside their modules; native LSP integration checks are in [` - Editor checks cover Unicode editing, multi-target state, undo/redo, saving failures, and modal paste acceptance and rejection atomicity. - Markdown navigation checks cover inline/reference destinations, escaped/percent-encoded paths, heading markup and duplicates, same-document fragments, dirty target reuse, failed navigation atomicity, primary-only following, and `gf` prefix cancellation/configuration overrides. -- Jump-history checks cover cross-buffer/same-document navigation, search and picker recording, counts and repeated visits, bounded history, branch truncation, closed buffers, edited-coordinate clamping, dirty text/undo preservation, Ctrl+i/Tab aliases, and custom bindings. Real LSP fixtures cover definition round-trips and delayed replies after leaving and returning to the same source location. +- Jump-history checks cover cross-buffer/same-document navigation, search and picker recording, counts and repeated visits, bounded history, branch truncation, closed buffers, edited-coordinate clamping, dirty text/undo preservation, independent Ctrl+i/Tab bindings, and custom bindings. Diff checks exercise Ctrl+i on either side in split/unified views, both control-letter and modified-Tab terminal events, and plain Tab pane cycling. Real LSP fixtures cover definition round-trips and delayed replies after leaving and returning to the same source location. - Tab checks cover shared edits/history with independent pane layouts, focused views, explorers and viewports; cross-tab close guards, immutable snapshots, hidden comparison refresh/result routing, and separate repository workers. Rendered checks cover sanitized/dirty titles, active-tab visibility in narrow terminals, overlay row reservation, and exclusion of hidden diff geometry from palette polling and viewport reporting. - Session and Ruby checks exercise real Ruby execution, command/capability ownership, effect validation, configuration fallback, plugin retirement, crash/timeout recovery, and inherited-pipe cleanup. - For chrome changes, exercise anchor/template validation, padding/edge bounds, all builtin/custom segments in either bar, named startup values, and atomic live label batches. Rendering regressions should cover swapped bars across ordinary/split/diff/selector views, full-row center anchoring, terminal-cell glyph widths, empty-content row removal, tabs-only singleton hiding in either position, active-tab preservation, padding inside styled edges, and separate prompt geometry. Labels are session-wide presentation state: test cross-tab/read-only updates and unchanged-value redraw behavior without collecting real battery data or invoking render callbacks. diff --git a/docs/editing.md b/docs/editing.md index 58fcc3e..97497fc 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -29,7 +29,7 @@ Indicators refresh asynchronously. Typing and undo/redo retain the last complete | Normal | `gf` follows a Markdown file link or heading fragment at the primary caret | | Normal, Visual | `gt` goes to the first line; `gb` goes to the last line, at first nonblank; Visual extends every selection | | Normal, Visual | `g` jumps immediately to that one-based source line at first nonblank (`42g`); clamps at EOF and extends Visual selections | -| Normal | Ctrl+o goes to an older jump location; Ctrl+i/Tab goes forward; counts select multiple locations | +| Normal | Ctrl+o goes to an older jump location; Ctrl+i goes forward (Tab too outside comparisons); counts select multiple locations | | Normal, Visual | Decimal prefixes repeat cursor/word/search motions: `5j`, `3k`, `2w`, `4e`, `2n`; Esc cancels a pending count | | Normal, Visual | Ctrl+u/Ctrl+d scroll up/down without moving carets or selections; half a viewport by default, configurable with `config.scroll_step_lines`; counts multiply the distance | | Normal | `J` (Shift+j) joins each targeted line with the next line | @@ -527,7 +527,7 @@ Space begins a leader sequence in Normal/Visual by default. Explicit sequences s ## Jump history -Normal Ctrl+o moves back through previous locations; Ctrl+i moves forward. Traditional terminals encode Ctrl+i as Tab, so Normal Tab performs the same action outside comparisons while Insert Tab inserts text outside snippets. In a comparison, Ctrl+i/Tab retains its diff-side focus behavior; use the **Jump forward** palette action or another configured key such as `config.bind "ctrl+y", config.core.jump_forward`. Counts work in either direction, such as `3` followed by Ctrl+o, and stop at the available history. +Normal Ctrl+o moves back through previous locations; Ctrl+i moves forward, including in comparisons. Normal Tab also jumps forward outside comparisons, but cycles panes within comparisons; Shift+Tab cycles backward. The Unix TUI requests disambiguated keyboard events so supporting terminals preserve this distinction. Legacy terminals encode Ctrl+i as Tab and cannot distinguish them; there, use the **Jump forward** palette action or another configured key such as `config.bind "ctrl+y", config.core.jump_forward`. Counts work in either direction, such as `3` followed by Ctrl+o, and stop at the available history. The session keeps up to 100 primary-caret locations with their tab, pane, and comparison context, not just buffer coordinates. Successful Markdown links, accepted LSP definitions, file-picker opens, `:edit`, `:buffer`, `:bnext`/`:bprev`, and search `/`/`n`/`N` jumps record locations. Back/forward restores the original view, including its comparison file and side; an external LSP definition round-trip does not replace the comparison with an ordinary buffer. Identical buffer/cursor positions in different views remain distinct. Ordinary cursor/word motion, edits, failed navigation, manual tab/pane selection, and jumps that do not change location do not add entries. Traversal itself does not record another jump. diff --git a/src/editor.rs b/src/editor.rs index 9b9f031..892de7d 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1224,9 +1224,8 @@ fn parse_key(key: &str) -> Result { } fn control_key(ch: char) -> Input { - // Terminals encode these controls as named keys. + // Preserve distinguishable control keys; Ctrl+M retains its Enter alias. match ch.to_ascii_lowercase() { - 'i' => Input::Char('\t'), 'm' => Input::Enter, ch => Input::Ctrl(ch), } @@ -1299,6 +1298,7 @@ fn default_bindings() -> HashMap> { (Input::Ctrl('o'), JumpBack), (Input::Ctrl('b'), DiffModeToggle), (Input::Char('\t'), JumpForward), + (Input::Ctrl('i'), JumpForward), (Input::Alt('h'), PaneFocusLeft), (Input::Alt('j'), PaneFocusDown), (Input::Alt('k'), PaneFocusUp), @@ -1403,6 +1403,7 @@ fn default_bindings() -> HashMap> { .insert(Input::Ctrl('n'), Binding::Action(LspCompletion)); for (key, action) in [ (Input::Char('\t'), SnippetNext), + (Input::Ctrl('i'), SnippetNext), (Input::BackTab, SnippetPrevious), ] { bindings @@ -5139,7 +5140,11 @@ impl Editor { | Action::Plugins | Action::Buffers ) - ) + ) || (input != Input::Char('\t') + && matches!( + binding, + Binding::Action(Action::JumpBack | Action::JumpForward) + )) }) { return self.handle_input(input); @@ -18172,7 +18177,7 @@ mod tests { } #[test] - fn jump_keys_honor_control_aliases_custom_bindings_and_normal_mode_eligibility() { + fn jump_keys_honor_independent_tab_bindings_and_normal_mode_eligibility() { let directory = Directory::new(); let path = directory.path("text.txt"); fs::write(&path, "zero\none\ntwo").unwrap(); @@ -18193,12 +18198,9 @@ mod tests { editor .bind("normal", "ctrl+i", Some("core.right".into())) .unwrap(); - editor.handle(Input::Char('\t')); - assert_eq!(editor.cursor().column, 1); - editor - .bind("normal", "tab", Some("core.jump_forward".into())) - .unwrap(); editor.handle(Input::Ctrl('I')); + assert_eq!(editor.cursor().column, 1); + editor.handle(Input::Char('\t')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); for mode in ["insert", "visual", "command", "search"] { for action in ["core.jump_back", "core.jump_forward"] { @@ -20425,11 +20427,7 @@ mod tests { #[test] fn ctrl_j_is_distinct_from_enter_and_preserves_editor_defaults() { assert_eq!(parse_key("CTRL+J").unwrap(), Input::Ctrl('j')); - for (key, input) in [ - ("ctrl+m", Input::Enter), - ("ctrl+h", Input::Ctrl('h')), - ("ctrl+i", Input::Char('\t')), - ] { + for (key, input) in [("ctrl+m", Input::Enter), ("ctrl+h", Input::Ctrl('h'))] { assert_eq!(parse_key(key).unwrap(), input); } let mut editor = editor(); @@ -24537,12 +24535,6 @@ mod tests { editor .bind("normal", "ctrl+j", Some("core.insert".into())) .unwrap(); - assert_eq!( - palette_entry(&editor, "core.insert").bindings, - vec![ - "Ctrl+h", "Ctrl+j", "Ctrl+r", "Enter", "I", "Space", "Tab", "界" - ] - ); assert!( palette_entry(&editor, "core.insert_first_nonblank") .bindings @@ -25817,15 +25809,6 @@ mod tests { editor.handle(Input::Char('+')); assert_eq!(editor.lines(), &["界", ""]); - editor.bind("insert", "CTRL+I", None).unwrap(); - editor.handle(Input::Char('\t')); - assert_eq!(editor.lines(), &["界", ""]); - editor - .bind("insert", "TAB", Some("core.newline".into())) - .unwrap(); - editor.handle(Input::Ctrl('i')); - assert_eq!(editor.lines(), &["界", "", ""]); - editor .bind("insert", "shift+j", Some("core.normal".into())) .unwrap(); diff --git a/src/main.rs b/src/main.rs index c211d3e..3fe8aa3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,7 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line gf follows Markdown file links/reference links and heading fragments (no LSP) Normal/Visual: gt first line, gb last line, at first nonblank; Esc cancels g Normal/Visual: g jumps to that one-based source line at first nonblank, clamped at EOF - Ctrl+o older jump location; Ctrl+i/Tab newer (counts supported) + Ctrl+o older jump location; Ctrl+i newer (Tab too outside comparisons; counts supported) _ splits above/below, | left/right; Option/Alt+h/j/k/l focuses neighboring panes Ctrl+Option/Alt+h/l previous/next tab; :tab.new opens current buffer in another tab Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count diff --git a/src/tui.rs b/src/tui.rs index 0fdabcd..e7d6a1b 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -9,6 +9,10 @@ use std::{ time::{Duration, Instant}, }; +#[cfg(unix)] +use crossterm::event::{ + KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, +}; use crossterm::{ cursor::Show, event::{ @@ -85,6 +89,8 @@ impl crossterm::Command for ColorSchemeNotifications { fn restore_terminal() { let _ = disable_raw_mode(); + #[cfg(unix)] + let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags); let _ = execute!( io::stdout(), EndSynchronizedUpdate, @@ -128,6 +134,14 @@ impl TerminalSession { .active = true; if !inherited_screen { execute!(io::stdout(), EnterAlternateScreen)?; + // Restart inherits this screen and its keyboard stack entry. + #[cfg(unix)] + execute!( + io::stdout(), + PushKeyboardEnhancementFlags( + KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + ) + )?; } execute!( io::stdout(), @@ -1494,6 +1508,7 @@ fn translate(key: KeyEvent) -> Option { KeyCode::Down => Input::Down, KeyCode::Home => Input::Home, KeyCode::End => Input::End, + KeyCode::Tab if key.modifiers.contains(KeyModifiers::CONTROL) => Input::Ctrl('i'), KeyCode::Tab => Input::Char('\t'), KeyCode::BackTab => Input::BackTab, _ => return None, @@ -16028,6 +16043,68 @@ end assert_eq!(viewports.syntax.len(), 1); } + #[test] + fn diff_control_i_restores_jump_location_without_cycling_panes() { + for unified in [false, true] { + for focus in [Focus::Before, Focus::After] { + for code in [KeyCode::Char('i'), KeyCode::Tab] { + let mut editor = readonly_diff_editor(syntax_diff_file( + "jump.txt", + "old\nmiddle\ntarget", + "new\nmiddle\ntarget", + )); + if unified { + editor.execute("diff.view.toggle"); + } + for _ in 0..3 { + if editor.diff().unwrap().focus == focus { + break; + } + editor.handle(Input::Char('\t')); + } + assert_eq!(editor.diff().unwrap().focus, focus); + let buffer = editor.buffer_id(); + for ch in "/target".chars() { + editor.handle(Input::Char(ch)); + } + editor.handle(Input::Enter); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.cursor(), Cursor::default()); + editor.handle(translate(KeyEvent::new(code, KeyModifiers::CONTROL)).unwrap()); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + assert_eq!(editor.diff().unwrap().focus, focus); + assert_eq!(editor.buffer_id(), buffer); + assert!(!editor.dirty()); + editor.handle( + translate(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)).unwrap(), + ); + assert_ne!(editor.diff().unwrap().focus, focus); + for _ in 0..3 { + if editor.diff().unwrap().focus == Focus::Files { + break; + } + editor.handle(Input::Char('\t')); + } + assert_eq!(editor.diff().unwrap().focus, Focus::Files); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.cursor(), Cursor::default()); + assert_eq!(editor.diff().unwrap().focus, focus); + for _ in 0..3 { + if editor.diff().unwrap().focus == Focus::Files { + break; + } + editor.handle(Input::Char('\t')); + } + assert_eq!(editor.diff().unwrap().focus, Focus::Files); + editor.handle(translate(KeyEvent::new(code, KeyModifiers::CONTROL)).unwrap()); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + assert_eq!(editor.diff().unwrap().focus, Focus::Files); + } + } + } + } + #[test] fn translate_distinguishes_control_j_from_enter_and_normalizes_control_letters() { for (code, modifiers, expected) in [ @@ -20815,6 +20892,13 @@ end entered, "{mode}: {before:?}" ); + for sequence in ["\x1b[>1u", "\x1b[<1u"] { + assert_eq!( + before.matches(sequence).count(), + entered, + "{mode}: {before:?}" + ); + } for protocol in [1000, 1002, 1003, 1015, 1006] { assert_eq!( before.matches(&format!("\x1b[?{protocol}h")).count(),