From 9a923474ddce97358d37b83046ff494b89d98bce Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 3 May 2026 09:29:41 +0000 Subject: [PATCH] =?UTF-8?q?feat(kebab-tui):=20SearchState.input=20?= =?UTF-8?q?=E2=86=92=20InputBuffer=20+=20cursor=20placement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates SearchState.input from String to InputBuffer so wide-char (Hangul/CJK) keystrokes advance the terminal cursor by display columns instead of char count. Adds cursor placement in render_input_bar via f.set_cursor_position and a Hangul round-trip pin in tests/search.rs. Co-Authored-By: Claude Sonnet 4.6 --- crates/kebab-tui/src/app.rs | 7 +++-- crates/kebab-tui/src/search.rs | 37 +++++++++++++++-------- crates/kebab-tui/tests/search.rs | 51 +++++++++++++++++++++++++------- 3 files changed, 71 insertions(+), 24 deletions(-) diff --git a/crates/kebab-tui/src/app.rs b/crates/kebab-tui/src/app.rs index 9f02b5f..3c4e839 100644 --- a/crates/kebab-tui/src/app.rs +++ b/crates/kebab-tui/src/app.rs @@ -114,7 +114,10 @@ impl Default for LibraryState { /// re-exporting field accessors. The pane behavior + render live in /// `crate::search`. pub struct SearchState { - pub input: String, + /// p9-fb-10: `InputBuffer` tracks display-column cursor position + /// alongside content so wide chars (Hangul, CJK) place the + /// terminal cursor in the correct column. + pub input: crate::input::InputBuffer, pub mode: kebab_core::SearchMode, pub hits: Vec, pub selected_hit: usize, @@ -166,7 +169,7 @@ pub enum SearchWorkerMessage { impl Default for SearchState { fn default() -> Self { Self { - input: String::new(), + input: crate::input::InputBuffer::new(), mode: kebab_core::SearchMode::Hybrid, hits: Vec::new(), selected_hit: 0, diff --git a/crates/kebab-tui/src/search.rs b/crates/kebab-tui/src/search.rs index edd1b9e..d069a73 100644 --- a/crates/kebab-tui/src/search.rs +++ b/crates/kebab-tui/src/search.rs @@ -72,15 +72,28 @@ fn render_input_bar(f: &mut Frame, area: Rect, s: &SearchState, theme: &crate::t SearchMode::Hybrid => crate::theme::Role::ModeHybrid, }; let searching_hint = if s.searching { " searching…" } else { "" }; + // p9-fb-10: compute prompt display width before moving the String + // into the Span so we can place the cursor without a second alloc. + let prompt = format!("[{mode_label}] "); + let prompt_w = crate::input::display_width(&prompt); let line = Line::from(vec![ - Span::styled(format!("[{mode_label}] "), theme.style(mode_role)), + Span::styled(prompt, theme.style(mode_role)), Span::raw(s.input.as_str()), Span::styled(searching_hint, theme.style(crate::theme::Role::Hint)), ]); let block = Block::default() .title("query (Tab=mode Enter=search Esc=back)") .borders(Borders::ALL); + let inner = block.inner(area); f.render_widget(Paragraph::new(line).block(block), area); + // p9-fb-10: place the terminal cursor inside the block border at + // `prompt_w + cursor_col` columns from the inner left edge. + // The `Hide` call in terminal.rs keeps the caret invisible in + // normal use; placing it here is still correct for any consumer + // that flips the cursor on (e.g. a future INSERT-mode `Show`). + let cursor_x = inner.x + (prompt_w + s.input.cursor_col()) as u16; + let cursor_y = inner.y; + f.set_cursor_position((cursor_x, cursor_y)); } fn mode_label(m: SearchMode) -> &'static str { @@ -256,14 +269,14 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome { (KeyCode::Tab, _) => { s.mode = cycle_mode(s.mode); // Force re-search at the new mode if there's a query. - if !s.input.trim().is_empty() { + if !s.input.as_str().trim().is_empty() { s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); } KeyOutcome::Continue } (KeyCode::Enter, _) => { // Skip debounce; refresh now if there's anything to query. - if s.input.trim().is_empty() { + if s.input.as_str().trim().is_empty() { KeyOutcome::Continue } else { s.input_dirty_at = None; @@ -283,7 +296,7 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome { } (KeyCode::Backspace, _) => { if !s.input.is_empty() { - s.input.pop(); + s.input.pop_char(); s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); } KeyOutcome::Continue @@ -297,7 +310,7 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome { move_selection(s, 1); s.preview = None; } else { - s.input.push('j'); + s.input.push_char('j'); s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); } KeyOutcome::Continue @@ -307,7 +320,7 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome { move_selection(s, -1); s.preview = None; } else { - s.input.push('k'); + s.input.push_char('k'); s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); } KeyOutcome::Continue @@ -321,7 +334,7 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome { // input. CTRL/ALT chords stay reserved for future // bindings (and don't currently match any Search // command, so they're a safe fall-through to Continue). - s.input.push(c); + s.input.push_char(c); s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); KeyOutcome::Continue } @@ -455,7 +468,7 @@ pub fn debounce_due(s: &SearchState) -> bool { if elapsed < SEARCH_DEBOUNCE { return false; } - let q = s.input.trim(); + let q = s.input.as_str().trim(); if q.is_empty() { return false; } @@ -464,7 +477,7 @@ pub fn debounce_due(s: &SearchState) -> bool { // existing result will land via `poll_worker`. if s.searching { if let Some((prev_input, prev_mode)) = &s.last_query { - if prev_input == &s.input && *prev_mode == s.mode { + if prev_input.as_str() == s.input.as_str() && *prev_mode == s.mode { return false; } } @@ -472,7 +485,7 @@ pub fn debounce_due(s: &SearchState) -> bool { !matches!( &s.last_query, Some((prev_input, prev_mode)) - if prev_input == &s.input && *prev_mode == s.mode + if prev_input.as_str() == s.input.as_str() && *prev_mode == s.mode ) } @@ -499,8 +512,8 @@ pub(crate) fn fire_search(state: &mut App) -> anyhow::Result<()> { s.generation = s.generation.wrapping_add(1); s.searching = true; s.input_dirty_at = None; - s.last_query = Some((s.input.clone(), s.mode)); - (s.input.clone(), s.mode, s.generation) + s.last_query = Some((s.input.as_str().to_string(), s.mode)); + (s.input.as_str().to_string(), s.mode, s.generation) }; let (tx, rx) = std::sync::mpsc::channel(); diff --git a/crates/kebab-tui/tests/search.rs b/crates/kebab-tui/tests/search.rs index f1edc99..66ef83c 100644 --- a/crates/kebab-tui/tests/search.rs +++ b/crates/kebab-tui/tests/search.rs @@ -83,7 +83,7 @@ fn typing_appends_to_input_and_marks_dirty() { ); } let s = app.search.as_ref().unwrap(); - assert_eq!(s.input, "hello"); + assert_eq!(s.input.as_str(), "hello"); assert!(s.input_dirty_at.is_some()); } @@ -92,13 +92,14 @@ fn backspace_removes_last_char() { let mut app = fresh_app(); { let s = app.search.as_mut().unwrap(); - s.input = "abc".into(); + s.input.clear(); + s.input.push_str("abc"); } handle_key_search( &mut app, KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), ); - assert_eq!(app.search.as_ref().unwrap().input, "ab"); + assert_eq!(app.search.as_ref().unwrap().input.as_str(), "ab"); } #[test] @@ -122,7 +123,11 @@ fn tab_cycles_mode_lex_vec_hybrid() { #[test] fn enter_with_query_emits_refresh() { let mut app = fresh_app(); - app.search.as_mut().unwrap().input = "rust".into(); + { + let s = app.search.as_mut().unwrap(); + s.input.clear(); + s.input.push_str("rust"); + } let outcome = handle_key_search( &mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), @@ -213,7 +218,8 @@ fn render_search_with_hits_shows_input_and_path() { let mut app = fresh_app(); { let s = app.search.as_mut().unwrap(); - s.input = "rust traits".into(); + s.input.clear(); + s.input.push_str("rust traits"); s.mode = SearchMode::Hybrid; s.hits = vec![ make_hit(1, "notes/rust.md", "trait dispatch\nis dynamic", line_citation("notes/rust.md", 12)), @@ -278,7 +284,7 @@ fn j_in_insert_types_does_not_move_selection() { KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), ); let s = app.search.as_ref().unwrap(); - assert_eq!(s.input, "j", "j must type in Insert mode"); + assert_eq!(s.input.as_str(), "j", "j must type in Insert mode"); assert_eq!(s.selected_hit, 0, "selection must NOT move in Insert"); } @@ -294,7 +300,7 @@ fn arbitrary_char_in_normal_mode_is_noop() { KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE), ); let s = app.search.as_ref().unwrap(); - assert_eq!(s.input, "", "Normal-mode Char must NOT type"); + assert_eq!(s.input.as_str(), "", "Normal-mode Char must NOT type"); } #[test] @@ -317,7 +323,7 @@ fn shift_j_stays_in_input_does_not_move_selection() { ); let s = app.search.as_ref().unwrap(); assert_eq!(s.selected_hit, 0, "selection must NOT move on SHIFT-J"); - assert_eq!(s.input, "J", "SHIFT-J must reach the input buffer"); + assert_eq!(s.input.as_str(), "J", "SHIFT-J must reach the input buffer"); } #[test] @@ -334,7 +340,7 @@ fn shift_g_does_not_trigger_editor_jump() { KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT), ); assert_eq!(outcome, KeyOutcome::Continue); - assert_eq!(app.search.as_ref().unwrap().input, "G"); + assert_eq!(app.search.as_ref().unwrap().input.as_str(), "G"); } /// p9-fb-09 — `g` on a hit enqueues an `EditorRequest` on `App.pending_editor` @@ -467,7 +473,7 @@ fn poll_worker_noop_when_no_rx() { #[allow(clippy::field_reassign_with_default)] fn search_state_with(input: &str, mode: SearchMode, searching: bool, last_query: Option<(String, SearchMode)>) -> SearchState { let mut s = SearchState::default(); - s.input = input.into(); + s.input.push_str(input); s.mode = mode; s.searching = searching; s.last_query = last_query; @@ -543,3 +549,28 @@ fn no_search_state_returns_to_library() { ); assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Library)); } + +/// p9-fb-10: typing Hangul into Search input advances cursor by 2 +/// per char and round-trips through the buffer correctly. +#[test] +fn hangul_typing_in_search_input_advances_cursor_by_two_per_char() { + let mut app = fresh_app(); + // Switch to search and ensure Insert mode so chars type. + app.focus = Pane::Search; + app.mode = kebab_tui::Mode::auto_for(Pane::Search); + for ch in "한글".chars() { + handle_key_search( + &mut app, + KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } + assert_eq!(app.search.as_ref().unwrap().input.as_str(), "한글"); + assert_eq!(app.search.as_ref().unwrap().input.cursor_col(), 4); + // Backspace pops the trailing Hangul char and rewinds 2 cols. + handle_key_search( + &mut app, + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), + ); + assert_eq!(app.search.as_ref().unwrap().input.as_str(), "한"); + assert_eq!(app.search.as_ref().unwrap().input.cursor_col(), 2); +}