From 312fb45fd466cd99ed6db9de57e9ad6d41af1694 Mon Sep 17 00:00:00 2001 From: Mark Jaquith Date: Sun, 6 Sep 2026 18:46:39 -0400 Subject: [PATCH 1/3] feat: add cursor-based editing to text inputs refs #1803 --- Cargo.lock | 1 + Cargo.toml | 1 + src/client/shell.rs | 26 +- src/client/shell/actions.rs | 2 +- src/client/shell/composition.rs | 6 +- src/client/shell/context_menu.rs | 12 +- src/client/shell/copy_mode.rs | 39 +- src/client/shell/input.rs | 26 +- src/client/shell/mouse.rs | 1 - src/client/shell/overlay_input.rs | 175 ++---- src/client/shell/overlays.rs | 76 +-- src/client/shell/render.rs | 41 +- src/client/shell/state.rs | 14 +- .../tests/agents_worktrees_notifications.rs | 2 +- src/client/shell/tests/copy.rs | 11 +- src/client/shell/tests/input.rs | 16 +- src/client/shell/tests/mod.rs | 1 + .../shell/tests/popup_focus_projection.rs | 15 +- src/client/shell/tests/text_editing.rs | 436 +++++++++++++++ src/client/shell/text_editor.rs | 528 ++++++++++++++++++ src/client/shell/worktree_overlays.rs | 41 +- src/client/shell/worktrees.rs | 108 ++-- 22 files changed, 1238 insertions(+), 340 deletions(-) create mode 100644 src/client/shell/tests/text_editing.rs create mode 100644 src/client/shell/text_editor.rs diff --git a/Cargo.lock b/Cargo.lock index 6594bcb6dc..4d8c788513 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -697,6 +697,7 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", + "unicode-segmentation", "unicode-width", "widestring", "windows-sys", diff --git a/Cargo.toml b/Cargo.toml index 02edde19b8..570c8ecc75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ toml = "0.8" tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } unicode-width = "0.2" +unicode-segmentation = "1.13.1" schemars = { version = "1.2.1", features = ["derive"] } [patch.crates-io] diff --git a/src/client/shell.rs b/src/client/shell.rs index 14f4f6600b..be0cb17ff8 100644 --- a/src/client/shell.rs +++ b/src/client/shell.rs @@ -29,7 +29,9 @@ mod scroll; mod settings; mod state; mod surface_patch; +mod text_editor; mod worktrees; +use text_editor::TextEditor; pub(in crate::client::shell) use render::sidebar; pub(crate) use state::*; @@ -59,30 +61,6 @@ use crate::protocol::{ #[cfg(test)] use crate::raw_input::RawInputEvent; -fn delete_overlay_word(rename: &mut ClientRenameOverlay) { - if rename.replace_on_type { - rename.input.clear(); - rename.replace_on_type = false; - return; - } - while rename.input.chars().last().is_some_and(char::is_whitespace) { - rename.input.pop(); - } - let Some(word) = rename - .input - .chars() - .last() - .map(|character| character.is_alphanumeric() || character == '_') - else { - return; - }; - while rename.input.chars().last().is_some_and(|character| { - !character.is_whitespace() && (character.is_alphanumeric() || character == '_') == word - }) { - rename.input.pop(); - } -} - fn target_event_message(target: ClientInputTarget, event: ClientPaneInputEvent) -> ClientMessage { match target { ClientInputTarget::Pane(pane_id) => ClientMessage::ClientShellPaneInput { diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs index 834169fe58..a7804915a1 100644 --- a/src/client/shell/actions.rs +++ b/src/client/shell/actions.rs @@ -35,7 +35,7 @@ impl ClientShellState { } if action == crate::input::KeybindAction::Help { self.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay { - query: String::new(), + query: TextEditor::default(), search_focused: false, scroll: 0, })); diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs index a80ebb3f52..753817a8b2 100644 --- a/src/client/shell/composition.rs +++ b/src/client/shell/composition.rs @@ -334,12 +334,14 @@ impl ClientShellState { .saturating_sub(copy_mode.offset_from_bottom) .min(u32::MAX as usize) as u32; let viewport_row = copy_mode.cursor.row.saturating_sub(viewport_top); + let x = hit.inner_rect.x.saturating_add(copy_mode.cursor.col); + let y = hit.inner_rect.y.saturating_add(viewport_row as u16); if viewport_row < u32::from(hit.inner_rect.height) && copy_mode.cursor.col < hit.inner_rect.width + && x < frame.width + && y < frame.height { let mut composed = frame.to_ratatui_buffer()?; - let x = hit.inner_rect.x + copy_mode.cursor.col; - let y = hit.inner_rect.y + viewport_row as u16; composed[(x, y)].set_style( Style::default() .fg(match self.config.palette.panel_bg { diff --git a/src/client/shell/context_menu.rs b/src/client/shell/context_menu.rs index af271bb657..0d080ec6c3 100644 --- a/src/client/shell/context_menu.rs +++ b/src/client/shell/context_menu.rs @@ -239,8 +239,7 @@ impl ClientShellState { if let Some(label) = label { self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { title: "rename workspace", - input: label, - replace_on_type: false, + input: TextEditor::new(&label, false), target: ClientRenameTarget::Workspace { workspace_id }, })); } @@ -322,8 +321,7 @@ impl ClientShellState { .to_string(); self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { title: "new tab", - input: default_name.clone(), - replace_on_type: true, + input: TextEditor::new(&default_name, true), target: ClientRenameTarget::NewTab { workspace_id, default_name, @@ -350,8 +348,7 @@ impl ClientShellState { if let Some(tab) = tab { self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { title: "rename tab", - input: tab.label.clone(), - replace_on_type: false, + input: TextEditor::new(&tab.label, false), target: ClientRenameTarget::Tab { tab_id, auto_name: !tab.custom_label, @@ -392,8 +389,7 @@ impl ClientShellState { }); self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { title: "rename pane", - input: label.clone().unwrap_or_default(), - replace_on_type: label.is_none(), + input: TextEditor::new(label.as_deref().unwrap_or_default(), label.is_none()), target: ClientRenameTarget::Pane { pane_id }, })); } diff --git a/src/client/shell/copy_mode.rs b/src/client/shell/copy_mode.rs index f08c4afb98..9750e19eb2 100644 --- a/src/client/shell/copy_mode.rs +++ b/src/client/shell/copy_mode.rs @@ -279,38 +279,18 @@ impl ClientShellState { } } KeyCode::Enter => { - submit = Some((prompt.query.clone(), prompt.direction)); + submit = Some((prompt.query.to_string(), prompt.direction)); if let Some(copy_mode) = self.copy_mode.as_mut() { copy_mode.search_prompt = None; } } - KeyCode::Backspace => { - if let Some(prompt) = self - .copy_mode - .as_mut() - .and_then(|copy_mode| copy_mode.search_prompt.as_mut()) - { - prompt.query.pop(); - } - } - KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + _ => { if let Some(prompt) = self .copy_mode .as_mut() .and_then(|copy_mode| copy_mode.search_prompt.as_mut()) { - prompt.query.clear(); - } - } - _ => { - if let Some(ch) = crate::copy_mode::copy_mode_command_char(key.clone()) { - if let Some(prompt) = self - .copy_mode - .as_mut() - .and_then(|copy_mode| copy_mode.search_prompt.as_mut()) - { - prompt.query.push(ch); - } + prompt.query.handle_key(key); } } } @@ -322,6 +302,13 @@ impl ClientShellState { } pub(super) fn insert_copy_search_text(&mut self, text: &str) -> bool { + if self.mode != ClientShellMode::Copy + || self.overlay.is_some() + || self.popup_terminal_id.is_some() + || self.popup_pending + { + return false; + } let Some(prompt) = self .copy_mode .as_mut() @@ -329,9 +316,7 @@ impl ClientShellState { else { return false; }; - prompt - .query - .extend(text.chars().filter(|character| !character.is_control())); + prompt.query.insert(text); true } @@ -341,7 +326,7 @@ impl ClientShellState { }; copy_mode.search_prompt = Some(ClientCopySearchPrompt { direction, - query: String::new(), + query: TextEditor::default(), }); } diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index 9b5fff21b0..249b77a764 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -14,9 +14,10 @@ pub(super) fn is_modal_paste_shortcut_for_platform( key: &crate::input::TerminalKey, macos: bool, ) -> bool { - matches!(key.code, KeyCode::Char('v' | 'V')) - && (key.modifiers.contains(KeyModifiers::CONTROL) - || macos && key.modifiers.contains(KeyModifiers::SUPER)) + key.generated_text.as_deref().is_none_or(str::is_empty) + && matches!(key.code, KeyCode::Char('v' | 'V')) + && (key.modifiers.difference(KeyModifiers::SHIFT) == KeyModifiers::CONTROL + || macos && key.modifiers.difference(KeyModifiers::SHIFT) == KeyModifiers::SUPER) } fn is_modal_paste_shortcut(key: &crate::input::TerminalKey) -> bool { @@ -428,12 +429,14 @@ impl ClientShellState { if self.popup_pending || self.popup_input_target().is_some() { return false; } - if self - .copy_mode - .as_ref() - .is_some_and(|copy_mode| copy_mode.search_prompt.is_some()) + if self.mode == ClientShellMode::Copy + && self.overlay.is_none() + && self + .copy_mode + .as_ref() + .is_some_and(|copy_mode| copy_mode.search_prompt.is_some()) { - return self.overlay.is_none(); + return true; } matches!( self.overlay.as_ref(), @@ -593,7 +596,12 @@ impl ClientShellState { None } ClientShellMode::Copy => { - if crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) { + if self + .copy_mode + .as_ref() + .is_none_or(|copy_mode| copy_mode.search_prompt.is_none()) + && crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) + { self.mode = ClientShellMode::Prefix; outcome.repaint = true; } else { diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs index cd4a6c1640..446a063cce 100644 --- a/src/client/shell/mouse.rs +++ b/src/client/shell/mouse.rs @@ -1618,7 +1618,6 @@ impl ClientShellState { } else if super::contains(self.hits.overlay_clear, point) { if let Some(ClientShellOverlay::Rename(rename)) = self.overlay.as_mut() { rename.input.clear(); - rename.replace_on_type = false; outcome.repaint = true; } } else { diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs index db089f4ede..397d988c04 100644 --- a/src/client/shell/overlay_input.rs +++ b/src/client/shell/overlay_input.rs @@ -200,7 +200,7 @@ impl ClientShellState { }) .collect(); let mut navigator = ClientNavigatorOverlay { - query: String::new(), + query: TextEditor::default(), search_focused: false, selected: None, scroll: 0, @@ -337,8 +337,7 @@ impl ClientShellState { .unwrap_or_else(|| "workspace".to_owned()); self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { title: "new workspace", - input: suggested_name.clone(), - replace_on_type: true, + input: TextEditor::new(&suggested_name, true), target: ClientRenameTarget::NewWorkspace { source_workspace_id, cwd, @@ -363,8 +362,7 @@ impl ClientShellState { }; self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { title: "rename workspace", - input: workspace.label.clone(), - replace_on_type: false, + input: TextEditor::new(&workspace.label, false), target: ClientRenameTarget::Workspace { workspace_id }, })); } @@ -385,8 +383,7 @@ impl ClientShellState { .to_string(); self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { title: "new tab", - input: default_name.clone(), - replace_on_type: true, + input: TextEditor::new(&default_name, true), target: ClientRenameTarget::NewTab { workspace_id, default_name, @@ -406,8 +403,7 @@ impl ClientShellState { }; self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { title: "rename tab", - input: tab.label.clone(), - replace_on_type: false, + input: TextEditor::new(&tab.label, false), target: ClientRenameTarget::Tab { tab_id: tab.tab_id.clone(), auto_name: !tab.custom_label, @@ -428,8 +424,10 @@ impl ClientShellState { }; self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { title: "rename pane", - input: pane.label.clone().unwrap_or_default(), - replace_on_type: pane.label.is_none(), + input: TextEditor::new( + pane.label.as_deref().unwrap_or_default(), + pane.label.is_none(), + ), target: ClientRenameTarget::Pane { pane_id: pane.pane_id.clone(), }, @@ -442,23 +440,20 @@ impl ClientShellState { } match self.overlay.as_mut() { Some(ClientShellOverlay::Rename(rename)) => { - if rename.replace_on_type { - rename.input.clear(); - rename.replace_on_type = false; - } - rename.input.push_str(text); + rename.input.insert(text); true } Some(ClientShellOverlay::Help(help)) if help.search_focused => { - help.query - .extend(text.chars().filter(|character| !character.is_control())); - help.scroll = 0; + if help.query.insert(text) { + help.scroll = 0; + } true } Some(ClientShellOverlay::Navigator(navigator)) if navigator.search_focused => { - navigator.query.push_str(text); - navigator.filter = None; - navigator.selected = None; + if navigator.query.insert(text) { + navigator.filter = None; + navigator.selected = None; + } true } _ => false, @@ -648,42 +643,30 @@ impl ClientShellState { return; } if search_focused { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + if let Some(edit) = navigator.query.handle_key(key) { + if edit.content_changed { + navigator.filter = None; + navigator.selected = None; + } + outcome.repaint = true; + return; + } + } if code == KeyCode::Up - || code == KeyCode::Char('p') && modifiers.contains(KeyModifiers::CONTROL) + || code == KeyCode::Char('p') && modifiers == KeyModifiers::CONTROL { self.move_navigator_selection(-1); outcome.repaint = true; return; } if code == KeyCode::Down - || code == KeyCode::Char('n') && modifiers.contains(KeyModifiers::CONTROL) + || code == KeyCode::Char('n') && modifiers == KeyModifiers::CONTROL { self.move_navigator_selection(1); outcome.repaint = true; return; } - if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { - if code == KeyCode::Char('u') && modifiers.contains(KeyModifiers::CONTROL) { - navigator.query.clear(); - navigator.filter = None; - navigator.selected = None; - } else if code == KeyCode::Backspace { - navigator.query.pop(); - navigator.filter = None; - navigator.selected = None; - } else if let KeyCode::Char(character) = code { - if modifiers.difference(KeyModifiers::SHIFT).is_empty() { - navigator.filter = None; - if let Some(text) = key.generated_text.as_deref() { - navigator.query.push_str(text); - } else { - navigator.query.push(character); - } - navigator.selected = None; - } - } - outcome.repaint = true; - } return; } if code == KeyCode::Backspace && modifiers.is_empty() { @@ -791,6 +774,15 @@ impl ClientShellState { })) ); if search_focused { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + if let Some(edit) = help.query.handle_key(key) { + if edit.content_changed { + help.scroll = 0; + } + outcome.repaint = true; + return; + } + } match code { KeyCode::Esc => { if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { @@ -800,20 +792,17 @@ impl ClientShellState { } } KeyCode::Enter => self.overlay = None, - KeyCode::Home => { - if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { - help.scroll = 0; - } - } - KeyCode::End => { - if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { - help.scroll = self.hits.help_max_scroll; - } - } - KeyCode::Up | KeyCode::Down | KeyCode::PageUp | KeyCode::PageDown => { + KeyCode::Up + | KeyCode::Down + | KeyCode::PageUp + | KeyCode::PageDown + | KeyCode::Char('n' | 'p') + if !matches!(code, KeyCode::Char(_)) + || modifiers == KeyModifiers::CONTROL => + { let delta = match code { - KeyCode::Up => -1, - KeyCode::Down => 1, + KeyCode::Up | KeyCode::Char('p') => -1, + KeyCode::Down | KeyCode::Char('n') => 1, KeyCode::PageUp => -8, KeyCode::PageDown => 8, _ => unreachable!(), @@ -825,26 +814,7 @@ impl ClientShellState { .min(self.hits.help_max_scroll); } } - KeyCode::Backspace => { - if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { - help.query.pop(); - help.scroll = 0; - } - } - KeyCode::Char('u') if modifiers == KeyModifiers::CONTROL => { - if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { - help.query.clear(); - help.scroll = 0; - } - } - _ => { - if let Some(character) = text_character { - if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { - help.query.push(character); - help.scroll = 0; - } - } - } + _ => {} } outcome.repaint = true; return; @@ -934,53 +904,26 @@ impl ClientShellState { outcome.repaint = true; return; } - if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { - rename.input.clear(); - rename.replace_on_type = false; - outcome.repaint = true; + if key + .generated_text + .as_deref() + .is_some_and(|text| !text.is_empty()) + { + outcome.repaint |= rename.input.handle_key(key).is_some(); return; } - if (key.code == KeyCode::Char('u') && key.modifiers.contains(KeyModifiers::CONTROL)) - || (key.code == KeyCode::Backspace && key.modifiers.contains(KeyModifiers::SUPER)) - { + if key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::CONTROL { rename.input.clear(); - rename.replace_on_type = false; outcome.repaint = true; return; } - if key.code == KeyCode::Backspace - && (key.modifiers.contains(KeyModifiers::CONTROL) - || key.modifiers.contains(KeyModifiers::ALT)) - || matches!(key.code, KeyCode::Char('h' | 'w')) - && key.modifiers.contains(KeyModifiers::CONTROL) - { - delete_overlay_word(rename); + if key.code == KeyCode::Backspace && key.modifiers.contains(KeyModifiers::SUPER) { + rename.input.clear(); outcome.repaint = true; return; } - if key.code == KeyCode::Backspace { - if rename.replace_on_type { - rename.input.clear(); - rename.replace_on_type = false; - } else { - rename.input.pop(); - } + if rename.input.handle_key(key).is_some() { outcome.repaint = true; - return; - } - if let KeyCode::Char(character) = key.code { - if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() { - if rename.replace_on_type { - rename.input.clear(); - rename.replace_on_type = false; - } - if let Some(text) = key.generated_text.as_deref() { - rename.input.push_str(text); - } else { - rename.input.push(character); - } - outcome.repaint = true; - } } } diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs index faf252a843..8b25b77c79 100644 --- a/src/client/shell/overlays.rs +++ b/src/client/shell/overlays.rs @@ -627,12 +627,10 @@ fn render_rename_overlay( ); let input = Rect::new(i.x, i.y + 2, i.width, 1); b.set_style(input, Style::default().fg(p.text).bg(p.surface0)); - put_text( + let cursor = text_editor::render( b, - input.x, - input.y, - input.width.saturating_sub(1), - &format!(" {}", v.input), + Rect::new(input.x + 1, input.y, input.width.saturating_sub(1), 1), + &v.input, Style::default().fg(p.text).bg(p.surface0), ); let rs = row(i, &[8, 10, 12], 2, 3); @@ -663,12 +661,7 @@ fn render_rename_overlay( navigator_rows: Vec::new(), worktree_search: Rect::default(), worktree_rows: Vec::new(), - cursor: Some(crate::protocol::CursorState { - x: (input.x + 1 + display_width(&v.input)).min(input.right() - 1), - y: input.y, - visible: true, - shape: 0, - }), + cursor, ..OverlayRender::default() }) } @@ -688,11 +681,12 @@ fn render_navigator_overlay( a.y + my, a.width.saturating_sub(mx * 2).max(4), a.height.saturating_sub(my * 2).max(4), - ); + ) + .intersection(a); let i = panel(b, q, p.accent, p.panel_bg)?; let rows = super::aggregate_navigation::navigator_rows(endpoints, active_endpoint_id, n); let search = if n.search_focused { - format!(" / {}", n.query) + " / ".to_owned() } else if let Some(f) = n.filter { format!( " / {}", @@ -718,16 +712,32 @@ fn render_navigator_overlay( .fg(if n.search_focused { p.text } else { p.overlay0 }) .bg(p.panel_bg), ); + let count = format!( + "{} panes", + rows.iter() + .filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. })) + .count() + ); + let cursor = if n.search_focused { + text_editor::render( + b, + Rect::new( + i.x + 3, + i.y, + i.width.saturating_sub(4 + display_width(&count)), + 1, + ), + &n.query, + Style::default().fg(p.text).bg(p.panel_bg), + ) + } else { + None + }; put_right_text( b, i, i.y, - &format!( - "{} panes", - rows.iter() - .filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. })) - .count() - ), + &count, Style::default().fg(p.overlay0).bg(p.panel_bg), ); put_text( @@ -904,12 +914,7 @@ fn render_navigator_overlay( navigator_rows: row_hits, worktree_search: Rect::default(), worktree_rows: Vec::new(), - cursor: n.search_focused.then(|| crate::protocol::CursorState { - x: i.x + 3 + display_width(&n.query), - y: i.y, - visible: true, - shape: 0, - }), + cursor, ..OverlayRender::default() }) } @@ -1023,7 +1028,7 @@ fn render_help_overlay( sy, i.width, &if h.search_focused { - format!(" / {}", h.query) + " / ".to_owned() } else { " / press / to filter by command or shortcut".to_owned() }, @@ -1031,6 +1036,16 @@ fn render_help_overlay( .fg(if h.search_focused { p.text } else { p.overlay0 }) .bg(p.panel_bg), ); + let cursor = if h.search_focused { + text_editor::render( + b, + Rect::new(i.x + 3, sy, i.width.saturating_sub(3), 1), + &h.query, + Style::default().fg(p.text).bg(p.panel_bg), + ) + } else { + None + }; let body = Rect::new(i.x, i.y + 3, i.width, i.height.saturating_sub(5)); let lines = help_lines(k, &h.query, p); @@ -1090,7 +1105,7 @@ fn render_help_overlay( i.bottom() - 1, i.width, if h.search_focused { - " filter type/backspace · clear ctrl+u · scroll ↑↓/pgup/pgdn · back esc" + " edit ←→/home/end · kill ^u/^k · yank ^y · scroll ↑↓ · back esc" } else { " search / · scroll j/k/↑↓/pgup/pgdn · close esc/enter" }, @@ -1102,12 +1117,7 @@ fn render_help_overlay( help_scrollbar: scrollbar.unwrap_or_default(), help_scroll_metrics: Some(metrics), help_max_scroll: max_scroll, - cursor: h.search_focused.then(|| crate::protocol::CursorState { - x: (i.x + 3 + display_width(&h.query)).min(i.right() - 1), - y: sy, - visible: true, - shape: 0, - }), + cursor, ..OverlayRender::default() }) } diff --git a/src/client/shell/render.rs b/src/client/shell/render.rs index ad9ac8b164..580b31d2c0 100644 --- a/src/client/shell/render.rs +++ b/src/client/shell/render.rs @@ -128,17 +128,36 @@ pub(super) fn render_mode_bar( crate::api::schema::PaneCopySearchDirection::Forward => "/", crate::api::schema::PaneCopySearchDirection::Backward => "?", }; - segments.extend([ - (" COPY ".to_owned(), mode_style), - (" ".to_owned(), base), - (marker.to_owned(), key), - ( - prompt.query.clone(), - Style::default().fg(palette.text).bg(palette.panel_bg), - ), - ("█".to_owned(), key), - (" enter search esc cancel".to_owned(), base), - ]); + buffer.set_stringn(bar.x, bar.y, " COPY ", usize::from(bar.width), mode_style); + let prefix = 8.min(bar.width); + if bar.width >= 8 { + buffer.set_string(bar.x + 7, bar.y, marker, key); + } + let footer = " enter search esc cancel"; + let footer_width = if bar.width >= 50 { + footer.len() as u16 + } else { + 0 + }; + let field = Rect::new( + bar.x + prefix, + bar.y, + bar.width.saturating_sub(prefix + footer_width), + 1, + ); + if let Some(cursor) = text_editor::render( + buffer, + field, + &prompt.query, + Style::default().fg(palette.text).bg(palette.panel_bg), + ) { + buffer[(cursor.x, cursor.y)] + .set_style(Style::default().fg(palette.panel_bg).bg(palette.text)); + } + if footer_width > 0 { + buffer.set_string(bar.right() - footer_width, bar.y, footer, base); + } + return Some(bar); } else { let select = if copy_mode.selection.is_some() { "selecting" diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index 81845d35db..f86f25cc14 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -371,8 +371,7 @@ pub(super) enum ClientRenameTarget { #[derive(Debug)] pub(super) struct ClientRenameOverlay { pub(super) title: &'static str, - pub(super) input: String, - pub(super) replace_on_type: bool, + pub(super) input: TextEditor, pub(super) target: ClientRenameTarget, } @@ -416,7 +415,7 @@ pub(super) struct ClientNavigatorRow { #[derive(Debug)] pub(super) struct ClientNavigatorOverlay { - pub(super) query: String, + pub(super) query: TextEditor, pub(super) search_focused: bool, pub(super) selected: Option, pub(super) scroll: usize, @@ -426,7 +425,7 @@ pub(super) struct ClientNavigatorOverlay { #[derive(Debug)] pub(super) struct ClientHelpOverlay { - pub(super) query: String, + pub(super) query: TextEditor, pub(super) search_focused: bool, pub(super) scroll: usize, } @@ -481,9 +480,8 @@ pub(super) struct ClientSettingsOverlay { pub(super) struct ClientWorktreeCreateOverlay { pub(super) source_workspace_id: String, pub(super) repo_name: String, - pub(super) branch: String, + pub(super) branch: TextEditor, pub(super) checkout_path: String, - pub(super) replace_on_type: bool, pub(super) error: Option, pub(super) creating: bool, } @@ -531,7 +529,7 @@ pub(super) struct ClientWorktreeOpenOverlay { pub(super) source_workspace_id: String, pub(super) entries: Vec, pub(super) selected: usize, - pub(super) query: String, + pub(super) query: TextEditor, pub(super) search_focused: bool, pub(super) error: Option, pub(super) opening: bool, @@ -847,7 +845,7 @@ pub(super) enum ClientCopySelection { #[derive(Clone, Debug, PartialEq, Eq)] pub(super) struct ClientCopySearchPrompt { pub(super) direction: crate::api::schema::PaneCopySearchDirection, - pub(super) query: String, + pub(super) query: TextEditor, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/src/client/shell/tests/agents_worktrees_notifications.rs b/src/client/shell/tests/agents_worktrees_notifications.rs index 10f9711848..cdefd2128e 100644 --- a/src/client/shell/tests/agents_worktrees_notifications.rs +++ b/src/client/shell/tests/agents_worktrees_notifications.rs @@ -851,7 +851,7 @@ fn named_workspace_overlay_targets_projected_source_workspace() { .. }, .. - })) if value == "repo" && source_workspace_id.as_deref() == Some("ws_1") + })) if value.as_str() == "repo" && source_workspace_id.as_deref() == Some("ws_1") )); let create = state.handle_input_bytes(b"\r"); let [ClientShellAction::Endpoint { request, .. }] = &create.actions[..] else { diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs index 95413f8ac1..40cd9a2c20 100644 --- a/src/client/shell/tests/copy.rs +++ b/src/client/shell/tests/copy.rs @@ -1,10 +1,10 @@ use super::*; #[test] -fn pasted_help_and_copy_queries_strip_control_characters() { +fn pasted_help_and_copy_queries_normalize_single_line_text() { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); state.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay { - query: String::new(), + query: TextEditor::default(), search_focused: true, scroll: 0, })); @@ -13,10 +13,11 @@ fn pasted_help_and_copy_queries_strip_control_characters() { assert!(matches!( state.overlay, Some(ClientShellOverlay::Help(ClientHelpOverlay { ref query, .. })) - if query == "workspace" + if query.as_str() == "work space" )); state.overlay = None; + state.mode = ClientShellMode::Copy; state.copy_mode = Some(ClientCopyModeState { pane_id: "pane_1".into(), content_revision: 0, @@ -28,7 +29,7 @@ fn pasted_help_and_copy_queries_strip_control_characters() { selection: None, search_prompt: Some(ClientCopySearchPrompt { direction: crate::api::schema::PaneCopySearchDirection::Forward, - query: String::new(), + query: TextEditor::default(), }), search_query: String::new(), search_direction: None, @@ -47,7 +48,7 @@ fn pasted_help_and_copy_queries_strip_control_characters() { .as_ref() .and_then(|copy_mode| copy_mode.search_prompt.as_ref()) .map(|prompt| prompt.query.as_str()), - Some("needle") + Some("needle ") ); } diff --git a/src/client/shell/tests/input.rs b/src/client/shell/tests/input.rs index 3f831fa45f..4f7f79b747 100644 --- a/src/client/shell/tests/input.rs +++ b/src/client/shell/tests/input.rs @@ -107,6 +107,15 @@ fn full_host_palette_response_is_sent_as_one_theme_update() { #[test] fn modal_paste_shortcut_modifiers_are_platform_specific() { let key = |code, modifiers| crate::input::TerminalKey::new(code, modifiers); + for macos in [false, true] { + assert!(!input::is_modal_paste_shortcut_for_platform( + &key( + KeyCode::Char('v'), + KeyModifiers::CONTROL | KeyModifiers::ALT + ), + macos + )); + } assert!(input::is_modal_paste_shortcut_for_platform( &key(KeyCode::Char('v'), KeyModifiers::CONTROL), @@ -142,8 +151,7 @@ fn modal_paste_inserts_clipboard_text_through_overlay_text_path() { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); state.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { title: "rename pane", - input: "replace me".into(), - replace_on_type: true, + input: TextEditor::new("replace me", true), target: ClientRenameTarget::Pane { pane_id: "pane_1".into(), }, @@ -159,8 +167,8 @@ fn modal_paste_inserts_clipboard_text_through_overlay_text_path() { assert!(outcome.repaint); assert!(matches!( state.overlay, - Some(ClientShellOverlay::Rename(ClientRenameOverlay { ref input, replace_on_type: false, .. })) - if input == "feature/pasted" + Some(ClientShellOverlay::Rename(ClientRenameOverlay { ref input, .. })) + if input.as_str() == "feature/pasted" )); } diff --git a/src/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs index 497ce2c673..d1a502f633 100644 --- a/src/client/shell/tests/mod.rs +++ b/src/client/shell/tests/mod.rs @@ -5,6 +5,7 @@ use crate::protocol::{ PaneSurfaceSplit, PaneSurfaceSplitDirection, SurfaceRect, }; use crossterm::event::MouseEvent; +mod text_editing; pub(super) fn snapshot() -> ClientShellSnapshot { ClientShellSnapshot { diff --git a/src/client/shell/tests/popup_focus_projection.rs b/src/client/shell/tests/popup_focus_projection.rs index 044ef145f3..aa44bf6dd4 100644 --- a/src/client/shell/tests/popup_focus_projection.rs +++ b/src/client/shell/tests/popup_focus_projection.rs @@ -37,8 +37,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() { state.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { title: "rename pane", - input: String::new(), - replace_on_type: false, + input: TextEditor::default(), target: ClientRenameTarget::Pane { pane_id: "pane_1".into(), }, @@ -49,9 +48,8 @@ fn modal_paste_target_requires_a_focused_editable_client_field() { ClientWorktreeCreateOverlay { source_workspace_id: "ws_1".into(), repo_name: "repo".into(), - branch: String::new(), + branch: TextEditor::default(), checkout_path: String::new(), - replace_on_type: false, error: None, creating: true, }, @@ -67,7 +65,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() { source_workspace_id: "ws_1".into(), entries: Vec::new(), selected: 0, - query: String::new(), + query: TextEditor::default(), search_focused: false, error: None, opening: false, @@ -80,7 +78,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() { assert!(state.modal_paste_target_active()); state.overlay = Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay { - query: String::new(), + query: TextEditor::default(), search_focused: false, selected: None, scroll: 0, @@ -94,7 +92,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() { assert!(state.modal_paste_target_active()); state.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay { - query: String::new(), + query: TextEditor::default(), search_focused: false, scroll: 0, })); @@ -116,7 +114,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() { selection: None, search_prompt: Some(ClientCopySearchPrompt { direction: crate::api::schema::PaneCopySearchDirection::Forward, - query: String::new(), + query: TextEditor::default(), }), search_query: String::new(), search_direction: None, @@ -127,6 +125,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() { search_generation: 0, copy_after_search: false, }); + state.mode = ClientShellMode::Copy; assert!(state.modal_paste_target_active()); state.popup_pending = true; assert!(!state.modal_paste_target_active()); diff --git a/src/client/shell/tests/text_editing.rs b/src/client/shell/tests/text_editing.rs new file mode 100644 index 0000000000..fdfb35537e --- /dev/null +++ b/src/client/shell/tests/text_editing.rs @@ -0,0 +1,436 @@ +use super::*; +use crate::input::{KeybindAction, KeybindMatch, TerminalKey, TextCommit}; +use crossterm::event::KeyEventKind; + +fn shell(field: usize) -> ClientShellState { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut frame = surface(); + frame.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 0, + viewport_rows: 2, + }); + state.set_pane_surface(frame); + state.compose(106, 30).expect("initial shell"); + match field { + 0 => state.open_new_workspace_overlay(), + 1 => state.open_rename_workspace_overlay(), + 2 => state.open_new_tab_overlay(), + 3 => state.open_rename_tab_overlay(), + 4 => state.open_rename_pane_overlay(), + 5 => { + state.handle_worktree_endpoint_result( + PendingEndpointKind::PrepareWorktreeCreate { + workspace_id: "ws_1".into(), + }, + Ok(worktree_list_result(None)), + ); + } + 6 => { + state.open_navigator_overlay(); + state.handle_input_bytes(b"/"); + } + 7 => { + state.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay { + query: TextEditor::default(), + search_focused: true, + scroll: 0, + })) + } + 8 => { + state.handle_worktree_endpoint_result( + PendingEndpointKind::PrepareWorktreeOpen { + workspace_id: "ws_1".into(), + }, + Ok(worktree_list_result(None)), + ); + state.handle_input_bytes(b"/"); + } + 9 => { + state.record_binding( + KeybindMatch::Action(KeybindAction::CopyMode), + &mut ClientShellInput::default(), + ); + state.handle_input_bytes(b"/"); + } + _ => unreachable!(), + } + state +} + +fn editor(state: &mut ClientShellState) -> &mut TextEditor { + match state.overlay.as_mut() { + Some(ClientShellOverlay::Rename(v)) => &mut v.input, + Some(ClientShellOverlay::Navigator(v)) => &mut v.query, + Some(ClientShellOverlay::Help(v)) => &mut v.query, + Some(ClientShellOverlay::WorktreeCreate(v)) => &mut v.branch, + Some(ClientShellOverlay::WorktreeOpen(v)) => &mut v.query, + _ => { + &mut state + .copy_mode + .as_mut() + .expect("copy mode") + .search_prompt + .as_mut() + .expect("prompt") + .query + } + } +} + +fn press(state: &mut ClientShellState, code: KeyCode, modifiers: KeyModifiers) -> ClientShellInput { + state.handle_raw_events(vec![RawInputEvent::Key(TerminalKey::new(code, modifiers))]) +} + +#[test] +fn all_ten_fields_share_keys_text_paste_clipboard_and_local_yank() { + for field in 0..10 { + for delivery in 0..5 { + let mut state = shell(field); + *editor(&mut state) = TextEditor::from("ab"); + press(&mut state, KeyCode::Left, KeyModifiers::NONE); + let result = match delivery { + 0 => press(&mut state, KeyCode::Char('X'), KeyModifiers::NONE), + 1 => state.handle_raw_events(vec![RawInputEvent::Key( + TerminalKey::new(KeyCode::Char('x'), KeyModifiers::NONE) + .with_generated_text(Some("X".into())), + )]), + 2 => state.handle_raw_events(vec![RawInputEvent::Text(TextCommit::new("X"))]), + 3 => state.handle_raw_events(vec![RawInputEvent::Paste("X".into())]), + _ => { + let mut result = ClientShellInput::default(); + assert!(state.handle_modal_paste_shortcut_with( + &TerminalKey::new(KeyCode::Char('v'), KeyModifiers::CONTROL), + &mut result, + || Some("X".into()) + )); + result + } + }; + assert!(result.repaint, "field {field}, delivery {delivery}"); + assert!(result.requests.is_empty() && result.actions.is_empty()); + assert_eq!(editor(&mut state).as_str(), "aXb"); + press(&mut state, KeyCode::Char('k'), KeyModifiers::CONTROL); + press(&mut state, KeyCode::Char('y'), KeyModifiers::CONTROL); + press(&mut state, KeyCode::Char('y'), KeyModifiers::CONTROL); + assert_eq!(editor(&mut state).as_str(), "aXbb"); + let mut reopened = shell(field); + editor(&mut reopened).clear(); + press(&mut reopened, KeyCode::Char('y'), KeyModifiers::CONTROL); + assert!(editor(&mut reopened).is_empty()); + } + } +} + +#[test] +fn all_fields_normalize_unicode_delivery_and_preserve_key_lifecycle() { + for field in 0..10 { + let mut state = shell(field); + *editor(&mut state) = TextEditor::from("e\u{301}中👩‍💻"); + let left = TerminalKey::new(KeyCode::Left, KeyModifiers::NONE); + state.handle_raw_events(vec![ + RawInputEvent::Key(left.clone()), + RawInputEvent::Key(left.clone().with_kind(KeyEventKind::Repeat)), + RawInputEvent::Key(left.with_kind(KeyEventKind::Release)), + ]); + state.handle_raw_events(vec![RawInputEvent::Text(TextCommit::new("X\r\n\t\x00"))]); + assert_eq!( + editor(&mut state).as_str(), + "e\u{301}X 中👩‍💻", + "field {field}" + ); + state.handle_raw_events(vec![RawInputEvent::Key( + TerminalKey::new( + KeyCode::Char('b'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + ) + .with_generated_text(Some("β".into())), + )]); + assert_eq!(editor(&mut state).as_str(), "e\u{301}X β中👩‍💻"); + for ch in ['n', 'p', 'c', 'v'] { + let key = + TerminalKey::new(KeyCode::Char(ch), KeyModifiers::CONTROL | KeyModifiers::ALT) + .with_generated_text(Some("β".into())); + let previous_len = editor(&mut state).len(); + state.handle_raw_events(vec![RawInputEvent::Key(key)]); + assert_eq!( + editor(&mut state).len(), + previous_len + "β".len(), + "field {field}, AltGr {ch}" + ); + } + } +} + +#[test] +fn every_editor_binding_reaches_each_active_field() { + use KeyCode::*; + let plain = KeyModifiers::NONE; + let ctrl = KeyModifiers::CONTROL; + let alt = KeyModifiers::ALT; + for field in 0..10 { + for (code, modifiers) in [ + (Left, plain), + (Right, plain), + (Home, plain), + (End, plain), + (Backspace, plain), + (Delete, plain), + (Char('a'), ctrl), + (Char('e'), ctrl), + (Char('b'), ctrl), + (Char('f'), ctrl), + (Char('h'), ctrl), + (Char('d'), ctrl), + (Char('b'), alt), + (Char('f'), alt), + (Char('u'), ctrl), + (Char('k'), ctrl), + (Char('w'), ctrl), + (Backspace, alt), + (Backspace, ctrl), + (Char('d'), alt), + (Char('y'), ctrl), + ] { + let mut state = shell(field); + let mut expected = TextEditor::from("one e\u{301}中 👩‍💻/two"); + expected.handle_key(&TerminalKey::new(Char('w'), ctrl)); + expected.handle_key(&TerminalKey::new(Left, plain)); + *editor(&mut state) = expected.clone(); + expected + .handle_key(&TerminalKey::new(code, modifiers)) + .expect("binding"); + let result = press(&mut state, code, modifiers); + assert_eq!( + editor(&mut state), + &expected, + "field {field}, {code:?} {modifiers:?}" + ); + assert!(result.requests.is_empty() && result.actions.is_empty()); + } + } +} + +#[test] +fn rename_clear_exceptions_remain_local_and_copy_prompt_yields_to_popup() { + for field in 0..5 { + for (code, modifiers) in [ + (KeyCode::Char('c'), KeyModifiers::CONTROL), + (KeyCode::Backspace, KeyModifiers::SUPER), + ] { + let mut state = shell(field); + *editor(&mut state) = TextEditor::from("name"); + press(&mut state, code, modifiers); + assert!(state.overlay.is_some()); + assert!(editor(&mut state).is_empty()); + } + } + let mut state = shell(9); + *editor(&mut state) = TextEditor::from("query"); + state.popup_terminal_id = Some("popup-test".into()); + let result = state.handle_raw_events(vec![ + RawInputEvent::Text(TextCommit::new("text")), + RawInputEvent::Paste("paste".into()), + ]); + assert!( + matches!(&result.requests[..], [ClientMessage::ClientShellPopupInput { terminal_id, events }] if terminal_id == "popup-test" && events.len() == 2) + ); + assert_eq!(editor(&mut state).as_str(), "query"); + assert!(!state.modal_paste_target_active()); +} + +#[test] +fn cursor_movement_preserves_filter_selection_scroll_and_branch_error() { + for field in [5, 6, 7, 8] { + let mut state = shell(field); + *editor(&mut state) = TextEditor::from("ab"); + match state.overlay.as_mut().expect("overlay") { + ClientShellOverlay::WorktreeCreate(v) => { + v.checkout_path = "sentinel".into(); + v.error = Some("error".into()); + } + ClientShellOverlay::Navigator(v) => { + v.scroll = 3; + v.selected = Some(ClientNavigatorTarget::Pane { + endpoint_id: ClientEndpointId::Local, + pane_id: "pane_1".into(), + }); + } + ClientShellOverlay::Help(v) => v.scroll = 3, + ClientShellOverlay::WorktreeOpen(v) => v.selected = 3, + _ => unreachable!(), + } + press(&mut state, KeyCode::Home, KeyModifiers::NONE); + press(&mut state, KeyCode::Char('u'), KeyModifiers::CONTROL); // Empty kill must not refresh results. + match state.overlay.as_ref().expect("overlay") { + ClientShellOverlay::WorktreeCreate(v) => { + assert_eq!(v.checkout_path, "sentinel"); + assert_eq!(v.error.as_deref(), Some("error")); + } + ClientShellOverlay::Navigator(v) => { + assert_eq!(v.scroll, 3); + assert!(v.selected.is_some()); + } + ClientShellOverlay::Help(v) => assert_eq!(v.scroll, 3), + ClientShellOverlay::WorktreeOpen(v) => assert_eq!(v.selected, 3), + _ => unreachable!(), + } + press(&mut state, KeyCode::Char('k'), KeyModifiers::CONTROL); + match state.overlay.as_ref().expect("overlay") { + ClientShellOverlay::WorktreeCreate(v) => { + assert_ne!(v.checkout_path, "sentinel"); + assert!(v.error.is_none()); + } + ClientShellOverlay::Navigator(v) => assert!(v.selected.is_none()), + ClientShellOverlay::Help(v) => assert_eq!(v.scroll, 0), + ClientShellOverlay::WorktreeOpen(v) => assert_eq!(v.selected, 0), + _ => unreachable!(), + } + } +} + +#[test] +fn busy_worktree_inputs_ignore_edits_paste_and_cancel() { + for field in [5, 8] { + let mut state = shell(field); + *editor(&mut state) = TextEditor::from("ab"); + match state.overlay.as_mut().expect("overlay") { + ClientShellOverlay::WorktreeCreate(v) => v.creating = true, + ClientShellOverlay::WorktreeOpen(v) => v.opening = true, + _ => unreachable!(), + } + let before = editor(&mut state).clone(); + for code in [ + KeyCode::Left, + KeyCode::Backspace, + KeyCode::Esc, + KeyCode::Enter, + ] { + press(&mut state, code, KeyModifiers::NONE); + } + state.handle_raw_events(vec![RawInputEvent::Paste("ignored".into())]); + assert_eq!(editor(&mut state), &before); + assert!(!state.modal_paste_target_active()); + } +} + +#[test] +fn focused_filters_keep_ctrl_n_p_navigation_and_literal_commands() { + for field in [6, 7, 8] { + let mut state = shell(field); + if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { + navigator.selected = None; + } + if let Some(ClientShellOverlay::WorktreeOpen(open)) = state.overlay.as_mut() { + let mut second = open.entries[0].clone(); + second.path = "/second".into(); + open.entries.push(second); + } + state.compose(106, 30).expect("filter frame"); + let before = match state.overlay.as_ref().expect("overlay") { + ClientShellOverlay::Navigator(v) => format!("{:?}", v.selected), + ClientShellOverlay::Help(v) => v.scroll.to_string(), + ClientShellOverlay::WorktreeOpen(v) => v.selected.to_string(), + _ => unreachable!(), + }; + press(&mut state, KeyCode::Char('n'), KeyModifiers::CONTROL); + let after = match state.overlay.as_ref().expect("overlay") { + ClientShellOverlay::Navigator(v) => format!("{:?}", v.selected), + ClientShellOverlay::Help(v) => v.scroll.to_string(), + ClientShellOverlay::WorktreeOpen(v) => v.selected.to_string(), + _ => unreachable!(), + }; + assert_ne!(before, after, "field {field}"); + press(&mut state, KeyCode::Char('p'), KeyModifiers::CONTROL); + assert!(editor(&mut state).is_empty()); + for ch in ['j', 'k', '?'] { + press(&mut state, KeyCode::Char(ch), KeyModifiers::NONE); + } + assert_eq!(editor(&mut state).as_str(), "jk?"); + } +} + +#[test] +fn all_naming_targets_preserve_submission_and_empty_semantics() { + use crate::api::schema::Method; + for field in 0..5 { + for empty in [false, true] { + let mut state = shell(field); + *editor(&mut state) = TextEditor::from(if empty { " " } else { " ab " }); + if !empty { + press(&mut state, KeyCode::Home, KeyModifiers::NONE); + press(&mut state, KeyCode::Char('X'), KeyModifiers::NONE); + } + let result = press(&mut state, KeyCode::Enter, KeyModifiers::NONE); + assert!(state.overlay.is_none()); + if empty && matches!(field, 1 | 3) { + assert!(result.actions.is_empty()); + continue; + } + let [ClientShellAction::Endpoint { request, .. }] = &result.actions[..] else { + panic!("naming target {field}"); + }; + let expected = if empty { "" } else { "X ab" }; + match &request.method { + Method::WorkspaceCreate(v) => { + assert_eq!(v.label.as_deref(), (!empty).then_some(expected)) + } + Method::WorkspaceRename(v) => assert_eq!(v.label, expected), + Method::TabCreate(v) => { + assert_eq!(v.label.as_deref(), (!empty).then_some(expected)) + } + Method::TabRename(v) => assert_eq!(v.label, expected), + Method::PaneRename(v) => assert_eq!(v.label.as_deref(), Some(expected)), + _ => panic!("wrong method"), + } + } + } +} + +#[test] +fn copy_search_owns_prefix_but_parked_prompt_does_not_steal_input() { + let mut state = shell(9); + *editor(&mut state) = TextEditor::from("ab"); + press(&mut state, KeyCode::Char('b'), KeyModifiers::CONTROL); + assert_eq!(state.mode, ClientShellMode::Copy); + state.handle_raw_events(vec![RawInputEvent::Text(TextCommit::new("X"))]); + assert_eq!(editor(&mut state).as_str(), "aXb"); + state.open_rename_pane_overlay(); + assert!(state.modal_paste_target_active()); + state.handle_raw_events(vec![RawInputEvent::Paste("name".into())]); + assert_eq!(editor(&mut state).as_str(), "name"); + state.overlay = None; + assert_eq!(editor(&mut state).as_str(), "aXb"); + state.mode = ClientShellMode::Terminal; + assert!(!state.modal_paste_target_active()); + let input = state.handle_raw_events(vec![RawInputEvent::Text(TextCommit::new("terminal"))]); + assert!( + matches!(&input.requests[..], [ClientMessage::ClientShellPaneInput { events, .. }] if matches!(&events[..], [ClientPaneInputEvent::TextCommit(text)] if text == "terminal")) + ); + assert_eq!(editor(&mut state).as_str(), "aXb"); + state.mode = ClientShellMode::Copy; + press(&mut state, KeyCode::Esc, KeyModifiers::NONE); + press(&mut state, KeyCode::Char('b'), KeyModifiers::CONTROL); + assert_eq!(state.mode, ClientShellMode::Prefix); +} + +#[test] +fn every_field_renders_long_unicode_across_resize_without_mutation() { + for field in 0..10 { + let mut state = shell(field); + *editor(&mut state) = TextEditor::new(&"e\u{301}中👩‍💻".repeat(40), false); + for position in [KeyCode::Home, KeyCode::End, KeyCode::Left] { + press(&mut state, position, KeyModifiers::NONE); + for (width, height) in [(120, 40), (60, 20), (12, 6), (1, 1), (120, 40)] { + let before = editor(&mut state).clone(); + if let Some(frame) = state.compose(width, height) { + if let Some(cursor) = frame.cursor.filter(|cursor| cursor.visible) { + assert!(cursor.x < width && cursor.y < height, "field {field}"); + } + } + assert_eq!(editor(&mut state), &before); + } + } + } +} diff --git a/src/client/shell/text_editor.rs b/src/client/shell/text_editor.rs new file mode 100644 index 0000000000..f28a6e715b --- /dev/null +++ b/src/client/shell/text_editor.rs @@ -0,0 +1,528 @@ +use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(super) struct TextEditor { + text: String, + cursor: usize, + replace_on_type: bool, + killed: String, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) struct EditOutcome { + pub content_changed: bool, + pub cursor_changed: bool, +} + +impl std::ops::Deref for TextEditor { + type Target = str; + fn deref(&self) -> &str { + self.as_str() + } +} + +impl std::fmt::Display for TextEditor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.text.fmt(f) + } +} + +impl From<&str> for TextEditor { + fn from(text: &str) -> Self { + Self::new(text, false) + } +} + +impl TextEditor { + pub fn new(text: &str, replace_on_type: bool) -> Self { + let mut editor = Self::default(); + editor.insert(text); + editor.replace_on_type = replace_on_type; + editor + } + + pub fn as_str(&self) -> &str { + &self.text + } + + pub fn clear(&mut self) { + self.text.clear(); + self.cursor = 0; + self.replace_on_type = false; + } + + pub fn trim_and_accept(&mut self) { + let leading = self.text.len() - self.text.trim_start().len(); + self.text = self.text.trim().to_owned(); + self.cursor = self.cursor.saturating_sub(leading).min(self.text.len()); + self.replace_on_type = false; + self.repair_cursor(); + } + + fn repair_cursor(&mut self) { + // Insertion/deletion can join clusters across the edit. Snap forward, never + // leave an insertion offset inside the newly formed grapheme. + self.cursor = self + .text + .grapheme_indices(true) + .map(|(index, _)| index) + .find(|index| *index >= self.cursor) + .unwrap_or(self.text.len()); + } + + pub fn insert(&mut self, text: &str) -> bool { + let mut normalized = String::new(); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '\r' => { + if chars.peek() == Some(&'\n') { + chars.next(); + } + normalized.push(' '); + } + '\n' | '\t' => normalized.push(' '), + ch if !ch.is_control() => normalized.push(ch), + _ => {} + } + } + if normalized.is_empty() { + return false; + } + let content_changed = !self.replace_on_type || self.text != normalized; + if self.replace_on_type { + self.clear(); + } + self.text.insert_str(self.cursor, &normalized); + self.cursor += normalized.len(); + self.repair_cursor(); + content_changed + } + + fn previous(&self) -> usize { + self.text[..self.cursor] + .grapheme_indices(true) + .next_back() + .map(|(index, _)| index) + .unwrap_or(0) + } + + fn next(&self) -> usize { + self.cursor + + self.text[self.cursor..] + .graphemes(true) + .next() + .map(str::len) + .unwrap_or(0) + } + + fn word_boundary(&self, backward: bool) -> usize { + let class = |grapheme: &str| { + let ch = grapheme.chars().next().unwrap_or(' '); + if ch.is_whitespace() { + 0 + } else if ch.is_alphanumeric() || ch == '_' { + 1 + } else { + 2 + } + }; + let mut boundary = self.cursor; + let mut run = 0; + if backward { + for (index, grapheme) in self.text[..self.cursor].grapheme_indices(true).rev() { + let current = class(grapheme); + if run != 0 && current != run { + break; + } + run = current; + boundary = index; + } + } else { + for (index, grapheme) in self.text[self.cursor..].grapheme_indices(true) { + let current = class(grapheme); + if run != 0 && current != run { + break; + } + run = current; + boundary = self.cursor + index + grapheme.len(); + } + } + boundary + } + + fn remove(&mut self, start: usize, end: usize, kill: bool) { + self.replace_on_type = false; + if start == end { + return; + } + if kill { + self.killed = self.text[start..end].to_owned(); + } + self.text.replace_range(start..end, ""); + self.cursor = start; + self.repair_cursor(); + } + + pub fn handle_key(&mut self, key: &crate::input::TerminalKey) -> Option { + if key.kind == KeyEventKind::Release { + return None; + } + let previous_len = self.text.len(); + let mut content_changed = false; + let cursor = self.cursor; + let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); + // Explicit text from the host is authoritative, including AltGr/composition. + if let Some(text) = key + .generated_text + .as_deref() + .filter(|text| !text.is_empty()) + { + content_changed = self.insert(text); + } else { + let ctrl = modifiers == KeyModifiers::CONTROL; + let alt = modifiers == KeyModifiers::ALT; + let plain = modifiers.is_empty(); + let movement = match code { + KeyCode::Left if plain => Some(self.previous()), + KeyCode::Char('b') if ctrl => Some(self.previous()), + KeyCode::Right if plain => Some(self.next()), + KeyCode::Char('f') if ctrl => Some(self.next()), + KeyCode::Home if plain => Some(0), + KeyCode::Char('a') if ctrl => Some(0), + KeyCode::End if plain => Some(self.text.len()), + KeyCode::Char('e') if ctrl => Some(self.text.len()), + KeyCode::Char('b') if alt => Some(self.word_boundary(true)), + KeyCode::Char('f') if alt => Some(self.word_boundary(false)), + _ => None, + }; + if let Some(position) = movement { + self.cursor = position; + self.replace_on_type = false; + } else { + match code { + KeyCode::Backspace | KeyCode::Char('h') + if code == KeyCode::Backspace && plain + || code == KeyCode::Char('h') && ctrl => + { + if self.replace_on_type { + self.clear(); + } else { + self.remove(self.previous(), self.cursor, false); + } + } + KeyCode::Delete if plain => self.remove(self.cursor, self.next(), false), + KeyCode::Char('d') if ctrl => self.remove(self.cursor, self.next(), false), + KeyCode::Char('u') if ctrl => self.remove(0, self.cursor, true), + KeyCode::Char('k') if ctrl => self.remove(self.cursor, self.text.len(), true), + KeyCode::Char('w') if ctrl => { + self.remove(self.word_boundary(true), self.cursor, true) + } + KeyCode::Backspace if ctrl || alt => { + self.remove(self.word_boundary(true), self.cursor, true) + } + KeyCode::Char('d') if alt => { + self.remove(self.cursor, self.word_boundary(false), true) + } + KeyCode::Char('y') if ctrl => { + content_changed = self.insert(&self.killed.clone()); + } + KeyCode::Char(_) if modifiers.difference(KeyModifiers::SHIFT).is_empty() => { + if let Some(ch) = crate::input::keybind_help_text_char(key) { + content_changed = self.insert(&ch.to_string()); + } + } + _ => return None, + } + } + } + Some(EditOutcome { + content_changed: content_changed || self.text.len() != previous_len, + cursor_changed: self.cursor != cursor, + }) + } + + pub fn viewport(&self, width: u16) -> (&str, u16) { + if width == 0 { + return ("", 0); + } + let mut start = self.cursor; + let mut cells = 0; + for (index, grapheme) in self.text[..self.cursor].grapheme_indices(true).rev() { + let next = cells + grapheme.width(); + if next >= usize::from(width) { + break; + } + start = index; + cells = next; + } + let mut end = self.cursor; + let mut used = cells; + for (index, grapheme) in self.text[self.cursor..].grapheme_indices(true) { + used += grapheme.width(); + if used > usize::from(width) { + break; + } + end = self.cursor + index + grapheme.len(); + } + (&self.text[start..end], cells as u16) + } +} + +pub(super) fn render( + buffer: &mut ratatui::buffer::Buffer, + area: ratatui::layout::Rect, + editor: &TextEditor, + style: ratatui::style::Style, +) -> Option { + let area = area.intersection(buffer.area); + if area.is_empty() { + return None; + } + let (text, cursor) = editor.viewport(area.width); + for x in area.x..area.right() { + buffer[(x, area.y)].set_symbol(" ").set_style(style); + } + buffer.set_stringn(area.x, area.y, text, usize::from(area.width), style); + Some(crate::protocol::CursorState { + x: area.x + cursor, + y: area.y, + visible: true, + shape: 0, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::TerminalKey; + + fn key(editor: &mut TextEditor, code: KeyCode, modifiers: KeyModifiers) -> EditOutcome { + let result = editor + .handle_key(&TerminalKey::new(code, modifiers)) + .expect("editor binding"); + assert!( + editor.cursor == editor.len() + || editor + .grapheme_indices(true) + .any(|(i, _)| i == editor.cursor) + ); + result + } + + #[test] + fn every_binding_edits_at_the_cursor() { + use KeyCode::*; + let plain = KeyModifiers::NONE; + let ctrl = KeyModifiers::CONTROL; + let alt = KeyModifiers::ALT; + for (code, modifiers, text, cursor, killed) in [ + (Left, plain, "one two", 3, ""), + (Char('b'), ctrl, "one two", 3, ""), + (Right, plain, "one two", 5, ""), + (Char('f'), ctrl, "one two", 5, ""), + (Home, plain, "one two", 0, ""), + (Char('a'), ctrl, "one two", 0, ""), + (End, plain, "one two", 7, ""), + (Char('e'), ctrl, "one two", 7, ""), + (Backspace, plain, "onetwo", 3, ""), + (Char('h'), ctrl, "onetwo", 3, ""), + (Delete, plain, "one wo", 4, ""), + (Char('d'), ctrl, "one wo", 4, ""), + (Char('b'), alt, "one two", 0, ""), + (Char('f'), alt, "one two", 7, ""), + (Char('u'), ctrl, "two", 0, "one "), + (Char('k'), ctrl, "one ", 4, "two"), + (Char('w'), ctrl, "two", 0, "one "), + (Backspace, alt, "two", 0, "one "), + (Backspace, ctrl, "two", 0, "one "), + (Char('d'), alt, "one ", 4, "two"), + (Char('y'), ctrl, "one two", 4, ""), + (Char('X'), plain, "one Xtwo", 5, ""), + ] { + let mut editor = TextEditor::from("one two"); + editor.cursor = 4; + let result = key(&mut editor, code, modifiers); + assert_eq!( + (editor.as_str(), editor.cursor, editor.killed.as_str()), + (text, cursor, killed), + "{code:?} {modifiers:?}" + ); + assert_eq!(result.content_changed, text != "one two"); + assert_eq!(result.cursor_changed, cursor != 4); + let mut empty = TextEditor::default(); + key(&mut empty, code, modifiers); + } + } + + #[test] + fn suggestions_movement_kills_and_yank() { + for (code, expected) in [ + (KeyCode::Left, "defaulxt"), + (KeyCode::Right, "defaultx"), + (KeyCode::Home, "xdefault"), + (KeyCode::End, "defaultx"), + ] { + let mut editor = TextEditor::new("default", true); + key(&mut editor, code, KeyModifiers::NONE); + editor.insert("x"); + assert_eq!(editor.as_str(), expected); + } + let mut editor = TextEditor::new("default", true); + editor.insert("new"); + assert_eq!(editor.as_str(), "new"); + for (replacement, changed) in [("default", false), ("another", true)] { + let mut editor = TextEditor::new("default", true); + let event = TerminalKey::new(KeyCode::Char('x'), KeyModifiers::NONE) + .with_generated_text(Some(replacement.into())); + assert_eq!( + editor + .handle_key(&event) + .expect("replacement") + .content_changed, + changed + ); + assert_eq!(editor.as_str(), replacement); + assert!(!editor.replace_on_type); + } + let mut editor = TextEditor::new("default", true); + key(&mut editor, KeyCode::Backspace, KeyModifiers::NONE); + assert!(editor.is_empty()); + let mut editor = TextEditor::new("one two", true); + key(&mut editor, KeyCode::Char('w'), KeyModifiers::CONTROL); + assert_eq!(editor.as_str(), "one "); + assert!(!editor.replace_on_type); + key(&mut editor, KeyCode::Char('k'), KeyModifiers::CONTROL); + key(&mut editor, KeyCode::Backspace, KeyModifiers::NONE); + key(&mut editor, KeyCode::Char('y'), KeyModifiers::CONTROL); + key(&mut editor, KeyCode::Char('y'), KeyModifiers::CONTROL); + assert_eq!(editor.as_str(), "onetwotwo"); + key(&mut editor, KeyCode::Char('u'), KeyModifiers::CONTROL); + assert_eq!(editor.killed, "onetwotwo"); + let mut other = TextEditor::default(); + key(&mut other, KeyCode::Char('y'), KeyModifiers::CONTROL); + assert!(other.is_empty()); + } + + #[test] + fn unicode_graphemes_and_boundary_changing_edits() { + let mut editor = TextEditor::from("e\u{301}中👩‍💻"); + for expected in ["e\u{301}中", "e\u{301}", ""] { + key(&mut editor, KeyCode::Backspace, KeyModifiers::NONE); + assert_eq!(editor.as_str(), expected); + } + let mut editor = TextEditor::from("👩💻"); + key(&mut editor, KeyCode::Left, KeyModifiers::NONE); + editor.insert("\u{200d}"); + assert_eq!(editor.cursor, editor.len()); + key(&mut editor, KeyCode::Backspace, KeyModifiers::NONE); + assert!(editor.is_empty()); + let mut editor = TextEditor::from("\u{301}x"); + key(&mut editor, KeyCode::Home, KeyModifiers::NONE); + editor.insert("e"); + assert_eq!(editor.cursor, "e\u{301}".len()); + key(&mut editor, KeyCode::Delete, KeyModifiers::NONE); + assert_eq!(editor.as_str(), "e\u{301}"); + } + + #[test] + fn accepting_trimmed_branch_preserves_cursor_and_local_kill_buffer() { + let mut editor = TextEditor::new(" feature/name ", true); + editor.trim_and_accept(); + assert_eq!(editor.as_str(), "feature/name"); + assert!(!editor.replace_on_type); + assert_eq!(editor.cursor, editor.len()); + key(&mut editor, KeyCode::Char('w'), KeyModifiers::CONTROL); + editor.trim_and_accept(); + key(&mut editor, KeyCode::Char('y'), KeyModifiers::CONTROL); + assert_eq!(editor.as_str(), "feature/name"); + } + + #[test] + fn words_distinguish_paths_punctuation_and_whitespace() { + let mut editor = TextEditor::from("src/foo_bar.rs e\u{301}中 👩‍💻"); + for expected in [ + "src/foo_bar.rs e\u{301}中 ", + "src/foo_bar.rs ", + "src/foo_bar.", + "src/foo_bar", + "src/", + "src", + "", + ] { + key(&mut editor, KeyCode::Char('w'), KeyModifiers::CONTROL); + assert_eq!(editor.as_str(), expected); + } + let mut editor = TextEditor::from(" /tmp/foo_bar.rs"); + key(&mut editor, KeyCode::Home, KeyModifiers::NONE); + for expected in [2, 5, 6, 13, 14, 16] { + key(&mut editor, KeyCode::Char('f'), KeyModifiers::ALT); + assert_eq!(editor.cursor, expected); + } + } + + #[test] + fn insertion_normalizes_controls_and_respects_host_text() { + let mut editor = TextEditor::from("ab"); + key(&mut editor, KeyCode::Left, KeyModifiers::NONE); + editor.insert("中\r\n\r\n\t\x00\x1b\u{7f}e\u{301}"); + assert_eq!(editor.as_str(), "a中 e\u{301}b"); + let event = TerminalKey::new( + KeyCode::Char('b'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + ) + .with_generated_text(Some("β".into())); + editor.handle_key(&event); + assert_eq!(editor.as_str(), "a中 e\u{301}βb"); + let before = editor.clone(); + assert!(editor + .handle_key(&event.with_kind(KeyEventKind::Release)) + .is_none()); + assert_eq!(editor, before); + let repeat = + TerminalKey::new(KeyCode::Left, KeyModifiers::NONE).with_kind(KeyEventKind::Repeat); + assert!(editor.handle_key(&repeat).expect("repeat").cursor_changed); + } + + #[test] + fn viewport_and_render_are_pure_and_grapheme_safe() { + use ratatui::{buffer::Buffer, layout::Rect, style::Style}; + for text in [ + "abcdefghijklmnopqrstuvwxyz", + "e\u{301}中👩‍💻xyz", + "\u{301}abc", + ] { + let mut editor = TextEditor::from(text); + for cursor in text + .grapheme_indices(true) + .map(|(i, _)| i) + .chain([text.len()]) + { + editor.cursor = cursor; + for width in [0, 1, 2, 3, 8, 80] { + let before = editor.clone(); + let (visible, col) = editor.viewport(width); + assert!(visible.width() <= usize::from(width)); + assert!(width == 0 || col < width); + let mut buffer = Buffer::empty(Rect::new(0, 0, 80, 1)); + let result = render( + &mut buffer, + Rect::new(0, 0, width, 1), + &editor, + Style::default(), + ); + assert_eq!(result.map(|c| c.x), (width > 0).then_some(col)); + assert_eq!(editor, before); + } + } + } + let mut editor = TextEditor::from("abcdef"); + assert_eq!(editor.viewport(4), ("def", 3)); + editor.cursor = 2; + assert_eq!(editor.viewport(4), ("abcd", 2)); + editor.cursor = 0; + assert_eq!(editor.viewport(4), ("abcd", 0)); + } +} diff --git a/src/client/shell/worktree_overlays.rs b/src/client/shell/worktree_overlays.rs index a2b5c65f03..12b0adb25b 100644 --- a/src/client/shell/worktree_overlays.rs +++ b/src/client/shell/worktree_overlays.rs @@ -28,12 +28,10 @@ pub(super) fn render_worktree_create_overlay( ); let input = Rect::new(inner.x, inner.y + 3, inner.width, 1); b.set_style(input, Style::default().fg(p.text).bg(p.surface0)); - put_text( + let cursor = text_editor::render( b, - input.x, - input.y, - input.width, - &format!(" {}", create.branch), + Rect::new(input.x + 1, input.y, input.width.saturating_sub(1), 1), + &create.branch, Style::default().fg(p.text).bg(p.surface0), ); put_text( @@ -102,12 +100,7 @@ pub(super) fn render_worktree_create_overlay( navigator_rows: Vec::new(), worktree_search: Rect::default(), worktree_rows: Vec::new(), - cursor: (!create.creating).then(|| crate::protocol::CursorState { - x: (input.x + 1 + display_width(&create.branch)).min(input.right() - 1), - y: input.y, - visible: true, - shape: 0, - }), + cursor: cursor.filter(|_| !create.creating), ..OverlayRender::default() }) } @@ -138,7 +131,9 @@ pub(super) fn render_worktree_open_overlay( search.x, search.y, search.width, - &if open.search_focused || !open.query.is_empty() { + &if open.search_focused { + " / ".to_owned() + } else if !open.query.is_empty() { format!(" / {}", open.query) } else { " / filter worktrees".to_owned() @@ -156,6 +151,21 @@ pub(super) fn render_worktree_open_overlay( } else { format!("{}/{} checkouts", filtered.len(), open.entries.len()) }; + let cursor = if open.search_focused { + text_editor::render( + b, + Rect::new( + search.x + 3, + search.y, + search.width.saturating_sub(4 + display_width(&count)), + 1, + ), + &open.query, + Style::default().fg(p.text).bg(p.panel_bg), + ) + } else { + None + }; put_right_text( b, search, @@ -288,12 +298,7 @@ pub(super) fn render_worktree_open_overlay( navigator_rows: Vec::new(), worktree_search: search, worktree_rows: row_hits, - cursor: (open.search_focused && !open.opening).then(|| crate::protocol::CursorState { - x: (search.x + 3 + display_width(&open.query)).min(search.right() - 1), - y: search.y, - visible: true, - shape: 0, - }), + cursor: cursor.filter(|_| !open.opening), ..OverlayRender::default() }) } diff --git a/src/client/shell/worktrees.rs b/src/client/shell/worktrees.rs index 8218c0ccf6..f6936ccbb9 100644 --- a/src/client/shell/worktrees.rs +++ b/src/client/shell/worktrees.rs @@ -24,20 +24,18 @@ impl ClientShellState { pub(super) fn insert_worktree_overlay_text(&mut self, text: &str) -> bool { match self.overlay.as_mut() { Some(ClientShellOverlay::WorktreeCreate(create)) if !create.creating => { - if create.replace_on_type { - create.branch.clear(); - create.replace_on_type = false; + if create.branch.insert(text) { + self.sync_worktree_create_path(); } - create.branch.push_str(text); - self.sync_worktree_create_path(); true } Some(ClientShellOverlay::WorktreeOpen(open)) if open.search_focused && !open.opening => { - open.query.push_str(text); - if let Some(first) = open.filtered_indices().first().copied() { - open.selected = first; + if open.query.insert(text) { + if let Some(first) = open.filtered_indices().first().copied() { + open.selected = first; + } } true } @@ -59,39 +57,24 @@ impl ClientShellState { ClientWorktreeCreateOverlay { creating: true, .. } )) ); + if !creating { + if let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut() + { + if let Some(edit) = create.branch.handle_key(key) { + if edit.content_changed { + self.sync_worktree_create_path(); + } + outcome.repaint = true; + return true; + } + } + } match code { KeyCode::Esc if !creating => { self.overlay = None; outcome.repaint = true; } KeyCode::Enter => self.submit_worktree_create(outcome), - KeyCode::Backspace if !creating => { - if let Some(ClientShellOverlay::WorktreeCreate(create)) = - self.overlay.as_mut() - { - if create.replace_on_type { - create.branch.clear(); - create.replace_on_type = false; - } else { - create.branch.pop(); - } - } - self.sync_worktree_create_path(); - outcome.repaint = true; - } - KeyCode::Char(character) - if !creating - && modifiers - .difference(crossterm::event::KeyModifiers::SHIFT) - .is_empty() => - { - let text = key - .generated_text - .clone() - .unwrap_or_else(|| character.to_string()); - self.insert_worktree_overlay_text(&text); - outcome.repaint = true; - } _ => {} } true @@ -112,6 +95,19 @@ impl ClientShellState { } )) ); + if !opening && search_focused { + if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() { + if let Some(edit) = open.query.handle_key(key) { + if edit.content_changed { + if let Some(first) = open.filtered_indices().first().copied() { + open.selected = first; + } + } + outcome.repaint = true; + return true; + } + } + } match code { KeyCode::Esc if !opening => { self.overlay = None; @@ -126,37 +122,23 @@ impl ClientShellState { self.move_worktree_open_selection(1); outcome.repaint = true; } - KeyCode::Char('/') if !opening && !search_focused => { - if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() - { - open.search_focused = true; - } + KeyCode::Char('n' | 'p') + if !opening && modifiers == crossterm::event::KeyModifiers::CONTROL => + { + self.move_worktree_open_selection(if code == KeyCode::Char('n') { + 1 + } else { + -1 + }); outcome.repaint = true; } - KeyCode::Backspace if !opening && search_focused => { + KeyCode::Char('/') if !opening && !search_focused => { if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() { - open.query.pop(); - if let Some(first) = open.filtered_indices().first().copied() { - open.selected = first; - } + open.search_focused = true; } outcome.repaint = true; } - KeyCode::Char(character) - if !opening - && search_focused - && modifiers - .difference(crossterm::event::KeyModifiers::SHIFT) - .is_empty() => - { - let text = key - .generated_text - .clone() - .unwrap_or_else(|| character.to_string()); - self.insert_worktree_overlay_text(&text); - outcome.repaint = true; - } _ => {} } true @@ -276,8 +258,7 @@ impl ClientShellState { outcome.repaint = true; return; } - create.branch = branch.clone(); - create.replace_on_type = false; + create.branch.trim_and_accept(); create.checkout_path = checkout_path_preview(&worktree_directory, &create.repo_name, &branch); create.creating = true; @@ -412,9 +393,8 @@ impl ClientShellState { ClientWorktreeCreateOverlay { source_workspace_id: workspace_id, repo_name: source.repo_name, - branch, + branch: TextEditor::new(&branch, true), checkout_path, - replace_on_type: true, error: None, creating: false, }, @@ -448,7 +428,7 @@ impl ClientShellState { source_workspace_id: workspace_id, entries, selected: 0, - query: String::new(), + query: TextEditor::default(), search_focused: false, error: None, opening: false, From 7b2d11b8e52d459b01f7dd1dc2c4fed64edd6d00 Mon Sep 17 00:00:00 2001 From: Mark Jaquith Date: Sun, 6 Sep 2026 20:32:04 -0400 Subject: [PATCH 2/3] fix: preserve dialog actions with associated text refs #1803 --- src/client/shell/tests/text_editing.rs | 45 ++++++++++++++++++++++++++ src/client/shell/text_editor.rs | 15 +++++++++ 2 files changed, 60 insertions(+) diff --git a/src/client/shell/tests/text_editing.rs b/src/client/shell/tests/text_editing.rs index fdfb35537e..b81e92ae3d 100644 --- a/src/client/shell/tests/text_editing.rs +++ b/src/client/shell/tests/text_editing.rs @@ -290,6 +290,51 @@ fn cursor_movement_preserves_filter_selection_scroll_and_branch_error() { } } +#[test] +fn enter_and_escape_preserve_overlay_actions_with_generated_text() { + use crate::api::schema::Method; + for field in [5, 7, 8] { + for code in [KeyCode::Enter, KeyCode::Esc] { + let mut state = shell(field); + *editor(&mut state) = TextEditor::from("feature"); + let before = editor(&mut state).clone(); + let result = state.handle_raw_events(vec![RawInputEvent::Key( + TerminalKey::new(code, KeyModifiers::NONE) + .with_generated_text(Some("printable".into())), + )]); + assert!(result.repaint); + assert!(result.requests.is_empty()); + if code == KeyCode::Enter && field != 7 { + assert_eq!(editor(&mut state), &before); + let [ClientShellAction::Endpoint { request, .. }] = &result.actions[..] else { + panic!("field {field} should submit"); + }; + match &request.method { + Method::WorktreeCreate(params) if field == 5 => { + assert_eq!(params.branch.as_deref(), Some("feature")); + } + Method::WorktreeOpen(params) if field == 8 => { + assert_eq!(params.path.as_deref(), Some("/repo-feature")); + } + _ => panic!("wrong method for field {field}"), + } + } else { + assert!(result.actions.is_empty()); + if field == 7 && code == KeyCode::Esc { + let Some(ClientShellOverlay::Help(help)) = &state.overlay else { + panic!("Escape should leave help open"); + }; + assert!(!help.search_focused); + assert!(help.query.is_empty()); + assert_eq!(help.scroll, 0); + } else { + assert!(state.overlay.is_none(), "field {field}, {code:?}"); + } + } + } + } +} + #[test] fn busy_worktree_inputs_ignore_edits_paste_and_cancel() { for field in [5, 8] { diff --git a/src/client/shell/text_editor.rs b/src/client/shell/text_editor.rs index f28a6e715b..157e0f9057 100644 --- a/src/client/shell/text_editor.rs +++ b/src/client/shell/text_editor.rs @@ -170,6 +170,9 @@ impl TextEditor { if key.kind == KeyEventKind::Release { return None; } + if matches!(key.code, KeyCode::Enter | KeyCode::Esc) { + return None; + } let previous_len = self.text.len(); let mut content_changed = false; let cursor = self.cursor; @@ -486,6 +489,18 @@ mod tests { assert!(editor.handle_key(&repeat).expect("repeat").cursor_changed); } + #[test] + fn enter_and_escape_ignore_generated_text() { + for code in [KeyCode::Enter, KeyCode::Esc] { + let mut editor = TextEditor::new("default", true); + let before = editor.clone(); + let event = TerminalKey::new(code, KeyModifiers::NONE) + .with_generated_text(Some("printable".into())); + assert_eq!(editor.handle_key(&event), None, "{code:?}"); + assert_eq!(editor, before, "{code:?}"); + } + } + #[test] fn viewport_and_render_are_pure_and_grapheme_safe() { use ratatui::{buffer::Buffer, layout::Rect, style::Style}; From eed1dd8452bdb820befd0dffb86b0bc3f353a589 Mon Sep 17 00:00:00 2001 From: Jonathan Liebig Date: Sun, 13 Sep 2026 23:55:37 +0200 Subject: [PATCH 3/3] refactor: simplify text editor outcomes and tests refs #1803 --- src/client/shell/overlay_input.rs | 8 +- src/client/shell/tests/text_editing.rs | 152 ++++++------------------- src/client/shell/text_editor.rs | 29 +---- src/client/shell/worktrees.rs | 8 +- 4 files changed, 46 insertions(+), 151 deletions(-) diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs index feb728b7d7..7cf151cbc7 100644 --- a/src/client/shell/overlay_input.rs +++ b/src/client/shell/overlay_input.rs @@ -651,8 +651,8 @@ impl ClientShellState { } if search_focused { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { - if let Some(edit) = navigator.query.handle_key(key) { - if edit.content_changed { + if let Some(content_changed) = navigator.query.handle_key(key) { + if content_changed { navigator.filter = None; navigator.selected = None; } @@ -782,8 +782,8 @@ impl ClientShellState { ); if search_focused { if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { - if let Some(edit) = help.query.handle_key(key) { - if edit.content_changed { + if let Some(content_changed) = help.query.handle_key(key) { + if content_changed { help.scroll = 0; } outcome.repaint = true; diff --git a/src/client/shell/tests/text_editing.rs b/src/client/shell/tests/text_editing.rs index cf91cb2993..b63ce9f63c 100644 --- a/src/client/shell/tests/text_editing.rs +++ b/src/client/shell/tests/text_editing.rs @@ -1,6 +1,5 @@ use super::*; use crate::input::{KeybindAction, KeybindMatch, TerminalKey, TextCommit}; -use crossterm::event::KeyEventKind; fn shell(field: usize) -> ClientShellState { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); @@ -86,131 +85,44 @@ fn press(state: &mut ClientShellState, code: KeyCode, modifiers: KeyModifiers) - } #[test] -fn all_ten_fields_share_keys_text_paste_clipboard_and_local_yank() { - for field in 0..10 { - for delivery in 0..5 { - let mut state = shell(field); - *editor(&mut state) = TextEditor::from("ab"); - press(&mut state, KeyCode::Left, KeyModifiers::NONE); - let result = match delivery { - 0 => press(&mut state, KeyCode::Char('X'), KeyModifiers::NONE), - 1 => state.handle_raw_events(vec![RawInputEvent::Key( - TerminalKey::new(KeyCode::Char('x'), KeyModifiers::NONE) - .with_generated_text(Some("X".into())), - )]), - 2 => state.handle_raw_events(vec![RawInputEvent::Text(TextCommit::new("X"))]), - 3 => state.handle_raw_events(vec![RawInputEvent::Paste("X".into())]), - _ => { - let mut result = ClientShellInput::default(); - assert!(state.handle_modal_paste_shortcut_with( - &TerminalKey::new(KeyCode::Char('v'), KeyModifiers::CONTROL), - &mut result, - || Some("X".into()) - )); - result - } - }; - assert!(result.repaint, "field {field}, delivery {delivery}"); - assert!(result.requests.is_empty() && result.actions.is_empty()); - assert_eq!(editor(&mut state).as_str(), "aXb"); - press(&mut state, KeyCode::Char('k'), KeyModifiers::CONTROL); - press(&mut state, KeyCode::Char('y'), KeyModifiers::CONTROL); - press(&mut state, KeyCode::Char('y'), KeyModifiers::CONTROL); - assert_eq!(editor(&mut state).as_str(), "aXbb"); - let mut reopened = shell(field); - editor(&mut reopened).clear(); - press(&mut reopened, KeyCode::Char('y'), KeyModifiers::CONTROL); - assert!(editor(&mut reopened).is_empty()); - } - } -} - -#[test] -fn all_fields_normalize_unicode_delivery_and_preserve_key_lifecycle() { +fn all_ten_fields_route_shared_text_editing() { for field in 0..10 { let mut state = shell(field); - *editor(&mut state) = TextEditor::from("e\u{301}中👩‍💻"); - let left = TerminalKey::new(KeyCode::Left, KeyModifiers::NONE); - state.handle_raw_events(vec![ - RawInputEvent::Key(left.clone()), - RawInputEvent::Key(left.clone().with_kind(KeyEventKind::Repeat)), - RawInputEvent::Key(left.with_kind(KeyEventKind::Release)), - ]); - state.handle_raw_events(vec![RawInputEvent::Text(TextCommit::new("X\r\n\t\x00"))]); - assert_eq!( - editor(&mut state).as_str(), - "e\u{301}X 中👩‍💻", - "field {field}" - ); - state.handle_raw_events(vec![RawInputEvent::Key( - TerminalKey::new( - KeyCode::Char('b'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - ) - .with_generated_text(Some("β".into())), - )]); - assert_eq!(editor(&mut state).as_str(), "e\u{301}X β中👩‍💻"); - for ch in ['n', 'p', 'c', 'v'] { - let key = - TerminalKey::new(KeyCode::Char(ch), KeyModifiers::CONTROL | KeyModifiers::ALT) - .with_generated_text(Some("β".into())); - let previous_len = editor(&mut state).len(); - state.handle_raw_events(vec![RawInputEvent::Key(key)]); - assert_eq!( - editor(&mut state).len(), - previous_len + "β".len(), - "field {field}, AltGr {ch}" - ); - } + *editor(&mut state) = TextEditor::from("ab"); + press(&mut state, KeyCode::Left, KeyModifiers::NONE); + let result = press(&mut state, KeyCode::Char('X'), KeyModifiers::NONE); + assert!(result.repaint, "field {field}"); + assert!(result.requests.is_empty() && result.actions.is_empty()); + assert_eq!(editor(&mut state).as_str(), "aXb"); } } #[test] -fn every_editor_binding_reaches_each_active_field() { - use KeyCode::*; - let plain = KeyModifiers::NONE; - let ctrl = KeyModifiers::CONTROL; - let alt = KeyModifiers::ALT; - for field in 0..10 { - for (code, modifiers) in [ - (Left, plain), - (Right, plain), - (Home, plain), - (End, plain), - (Backspace, plain), - (Delete, plain), - (Char('a'), ctrl), - (Char('e'), ctrl), - (Char('b'), ctrl), - (Char('f'), ctrl), - (Char('h'), ctrl), - (Char('d'), ctrl), - (Char('b'), alt), - (Char('f'), alt), - (Char('u'), ctrl), - (Char('k'), ctrl), - (Char('w'), ctrl), - (Backspace, alt), - (Backspace, ctrl), - (Char('d'), alt), - (Char('y'), ctrl), - ] { - let mut state = shell(field); - let mut expected = TextEditor::from("one e\u{301}中 👩‍💻/two"); - expected.handle_key(&TerminalKey::new(Char('w'), ctrl)); - expected.handle_key(&TerminalKey::new(Left, plain)); - *editor(&mut state) = expected.clone(); - expected - .handle_key(&TerminalKey::new(code, modifiers)) - .expect("binding"); - let result = press(&mut state, code, modifiers); - assert_eq!( - editor(&mut state), - &expected, - "field {field}, {code:?} {modifiers:?}" - ); - assert!(result.requests.is_empty() && result.actions.is_empty()); - } +fn text_delivery_paths_insert_at_the_cursor() { + for delivery in 0..4 { + let mut state = shell(0); + *editor(&mut state) = TextEditor::from("ab"); + press(&mut state, KeyCode::Left, KeyModifiers::NONE); + let result = match delivery { + 0 => state.handle_raw_events(vec![RawInputEvent::Key( + TerminalKey::new(KeyCode::Char('x'), KeyModifiers::NONE) + .with_generated_text(Some("X".into())), + )]), + 1 => state.handle_raw_events(vec![RawInputEvent::Text(TextCommit::new("X"))]), + 2 => state.handle_raw_events(vec![RawInputEvent::Paste("X".into())]), + _ => { + let mut result = ClientShellInput::default(); + assert!(state.handle_modal_paste_shortcut_with( + &TerminalKey::new(KeyCode::Char('v'), KeyModifiers::CONTROL), + &mut result, + || Some("X".into()) + )); + result + } + }; + assert!(result.repaint, "delivery {delivery}"); + assert!(result.requests.is_empty() && result.actions.is_empty()); + assert_eq!(editor(&mut state).as_str(), "aXb"); } } diff --git a/src/client/shell/text_editor.rs b/src/client/shell/text_editor.rs index 157e0f9057..c903b9196f 100644 --- a/src/client/shell/text_editor.rs +++ b/src/client/shell/text_editor.rs @@ -10,12 +10,6 @@ pub(super) struct TextEditor { killed: String, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub(super) struct EditOutcome { - pub content_changed: bool, - pub cursor_changed: bool, -} - impl std::ops::Deref for TextEditor { type Target = str; fn deref(&self) -> &str { @@ -166,7 +160,7 @@ impl TextEditor { self.repair_cursor(); } - pub fn handle_key(&mut self, key: &crate::input::TerminalKey) -> Option { + pub fn handle_key(&mut self, key: &crate::input::TerminalKey) -> Option { if key.kind == KeyEventKind::Release { return None; } @@ -175,7 +169,6 @@ impl TextEditor { } let previous_len = self.text.len(); let mut content_changed = false; - let cursor = self.cursor; let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); // Explicit text from the host is authoritative, including AltGr/composition. if let Some(text) = key @@ -241,10 +234,7 @@ impl TextEditor { } } } - Some(EditOutcome { - content_changed: content_changed || self.text.len() != previous_len, - cursor_changed: self.cursor != cursor, - }) + Some(content_changed || self.text.len() != previous_len) } pub fn viewport(&self, width: u16) -> (&str, u16) { @@ -302,7 +292,7 @@ mod tests { use super::*; use crate::input::TerminalKey; - fn key(editor: &mut TextEditor, code: KeyCode, modifiers: KeyModifiers) -> EditOutcome { + fn key(editor: &mut TextEditor, code: KeyCode, modifiers: KeyModifiers) -> bool { let result = editor .handle_key(&TerminalKey::new(code, modifiers)) .expect("editor binding"); @@ -353,8 +343,7 @@ mod tests { (text, cursor, killed), "{code:?} {modifiers:?}" ); - assert_eq!(result.content_changed, text != "one two"); - assert_eq!(result.cursor_changed, cursor != 4); + assert_eq!(result, text != "one two"); let mut empty = TextEditor::default(); key(&mut empty, code, modifiers); } @@ -380,13 +369,7 @@ mod tests { let mut editor = TextEditor::new("default", true); let event = TerminalKey::new(KeyCode::Char('x'), KeyModifiers::NONE) .with_generated_text(Some(replacement.into())); - assert_eq!( - editor - .handle_key(&event) - .expect("replacement") - .content_changed, - changed - ); + assert_eq!(editor.handle_key(&event).expect("replacement"), changed); assert_eq!(editor.as_str(), replacement); assert!(!editor.replace_on_type); } @@ -486,7 +469,7 @@ mod tests { assert_eq!(editor, before); let repeat = TerminalKey::new(KeyCode::Left, KeyModifiers::NONE).with_kind(KeyEventKind::Repeat); - assert!(editor.handle_key(&repeat).expect("repeat").cursor_changed); + assert!(editor.handle_key(&repeat).is_some()); } #[test] diff --git a/src/client/shell/worktrees.rs b/src/client/shell/worktrees.rs index 7b84a77358..56c554c8f7 100644 --- a/src/client/shell/worktrees.rs +++ b/src/client/shell/worktrees.rs @@ -60,8 +60,8 @@ impl ClientShellState { if !creating { if let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut() { - if let Some(edit) = create.branch.handle_key(key) { - if edit.content_changed { + if let Some(content_changed) = create.branch.handle_key(key) { + if content_changed { self.sync_worktree_create_path(); } outcome.repaint = true; @@ -97,8 +97,8 @@ impl ClientShellState { ); if !opening && search_focused { if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() { - if let Some(edit) = open.query.handle_key(key) { - if edit.content_changed { + if let Some(content_changed) = open.query.handle_key(key) { + if content_changed { if let Some(first) = open.filtered_indices().first().copied() { open.selected = first; }