feat(kebab-tui): SearchState.input → InputBuffer + cursor placement

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 09:29:41 +00:00
parent 7b0beed280
commit 9a923474dd
3 changed files with 71 additions and 24 deletions

View File

@@ -114,7 +114,10 @@ impl Default for LibraryState {
/// re-exporting field accessors. The pane behavior + render live in /// re-exporting field accessors. The pane behavior + render live in
/// `crate::search`. /// `crate::search`.
pub struct SearchState { 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 mode: kebab_core::SearchMode,
pub hits: Vec<kebab_core::SearchHit>, pub hits: Vec<kebab_core::SearchHit>,
pub selected_hit: usize, pub selected_hit: usize,
@@ -166,7 +169,7 @@ pub enum SearchWorkerMessage {
impl Default for SearchState { impl Default for SearchState {
fn default() -> Self { fn default() -> Self {
Self { Self {
input: String::new(), input: crate::input::InputBuffer::new(),
mode: kebab_core::SearchMode::Hybrid, mode: kebab_core::SearchMode::Hybrid,
hits: Vec::new(), hits: Vec::new(),
selected_hit: 0, selected_hit: 0,

View File

@@ -72,15 +72,28 @@ fn render_input_bar(f: &mut Frame, area: Rect, s: &SearchState, theme: &crate::t
SearchMode::Hybrid => crate::theme::Role::ModeHybrid, SearchMode::Hybrid => crate::theme::Role::ModeHybrid,
}; };
let searching_hint = if s.searching { " searching…" } else { "" }; 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![ 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::raw(s.input.as_str()),
Span::styled(searching_hint, theme.style(crate::theme::Role::Hint)), Span::styled(searching_hint, theme.style(crate::theme::Role::Hint)),
]); ]);
let block = Block::default() let block = Block::default()
.title("query (Tab=mode Enter=search Esc=back)") .title("query (Tab=mode Enter=search Esc=back)")
.borders(Borders::ALL); .borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(Paragraph::new(line).block(block), 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 { fn mode_label(m: SearchMode) -> &'static str {
@@ -256,14 +269,14 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome {
(KeyCode::Tab, _) => { (KeyCode::Tab, _) => {
s.mode = cycle_mode(s.mode); s.mode = cycle_mode(s.mode);
// Force re-search at the new mode if there's a query. // 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()); s.input_dirty_at = Some(time::OffsetDateTime::now_utc());
} }
KeyOutcome::Continue KeyOutcome::Continue
} }
(KeyCode::Enter, _) => { (KeyCode::Enter, _) => {
// Skip debounce; refresh now if there's anything to query. // 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 KeyOutcome::Continue
} else { } else {
s.input_dirty_at = None; s.input_dirty_at = None;
@@ -283,7 +296,7 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome {
} }
(KeyCode::Backspace, _) => { (KeyCode::Backspace, _) => {
if !s.input.is_empty() { if !s.input.is_empty() {
s.input.pop(); s.input.pop_char();
s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); s.input_dirty_at = Some(time::OffsetDateTime::now_utc());
} }
KeyOutcome::Continue KeyOutcome::Continue
@@ -297,7 +310,7 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome {
move_selection(s, 1); move_selection(s, 1);
s.preview = None; s.preview = None;
} else { } else {
s.input.push('j'); s.input.push_char('j');
s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); s.input_dirty_at = Some(time::OffsetDateTime::now_utc());
} }
KeyOutcome::Continue KeyOutcome::Continue
@@ -307,7 +320,7 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome {
move_selection(s, -1); move_selection(s, -1);
s.preview = None; s.preview = None;
} else { } else {
s.input.push('k'); s.input.push_char('k');
s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); s.input_dirty_at = Some(time::OffsetDateTime::now_utc());
} }
KeyOutcome::Continue 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 // input. CTRL/ALT chords stay reserved for future
// bindings (and don't currently match any Search // bindings (and don't currently match any Search
// command, so they're a safe fall-through to Continue). // 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()); s.input_dirty_at = Some(time::OffsetDateTime::now_utc());
KeyOutcome::Continue KeyOutcome::Continue
} }
@@ -455,7 +468,7 @@ pub fn debounce_due(s: &SearchState) -> bool {
if elapsed < SEARCH_DEBOUNCE { if elapsed < SEARCH_DEBOUNCE {
return false; return false;
} }
let q = s.input.trim(); let q = s.input.as_str().trim();
if q.is_empty() { if q.is_empty() {
return false; return false;
} }
@@ -464,7 +477,7 @@ pub fn debounce_due(s: &SearchState) -> bool {
// existing result will land via `poll_worker`. // existing result will land via `poll_worker`.
if s.searching { if s.searching {
if let Some((prev_input, prev_mode)) = &s.last_query { 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; return false;
} }
} }
@@ -472,7 +485,7 @@ pub fn debounce_due(s: &SearchState) -> bool {
!matches!( !matches!(
&s.last_query, &s.last_query,
Some((prev_input, prev_mode)) 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.generation = s.generation.wrapping_add(1);
s.searching = true; s.searching = true;
s.input_dirty_at = None; s.input_dirty_at = None;
s.last_query = Some((s.input.clone(), s.mode)); s.last_query = Some((s.input.as_str().to_string(), s.mode));
(s.input.clone(), s.mode, s.generation) (s.input.as_str().to_string(), s.mode, s.generation)
}; };
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();

View File

@@ -83,7 +83,7 @@ fn typing_appends_to_input_and_marks_dirty() {
); );
} }
let s = app.search.as_ref().unwrap(); 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()); assert!(s.input_dirty_at.is_some());
} }
@@ -92,13 +92,14 @@ fn backspace_removes_last_char() {
let mut app = fresh_app(); let mut app = fresh_app();
{ {
let s = app.search.as_mut().unwrap(); let s = app.search.as_mut().unwrap();
s.input = "abc".into(); s.input.clear();
s.input.push_str("abc");
} }
handle_key_search( handle_key_search(
&mut app, &mut app,
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), 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] #[test]
@@ -122,7 +123,11 @@ fn tab_cycles_mode_lex_vec_hybrid() {
#[test] #[test]
fn enter_with_query_emits_refresh() { fn enter_with_query_emits_refresh() {
let mut app = fresh_app(); 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( let outcome = handle_key_search(
&mut app, &mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), 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 mut app = fresh_app();
{ {
let s = app.search.as_mut().unwrap(); 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.mode = SearchMode::Hybrid;
s.hits = vec![ s.hits = vec![
make_hit(1, "notes/rust.md", "trait dispatch\nis dynamic", line_citation("notes/rust.md", 12)), 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), KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
); );
let s = app.search.as_ref().unwrap(); 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"); 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), KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE),
); );
let s = app.search.as_ref().unwrap(); 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] #[test]
@@ -317,7 +323,7 @@ fn shift_j_stays_in_input_does_not_move_selection() {
); );
let s = app.search.as_ref().unwrap(); let s = app.search.as_ref().unwrap();
assert_eq!(s.selected_hit, 0, "selection must NOT move on SHIFT-J"); 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] #[test]
@@ -334,7 +340,7 @@ fn shift_g_does_not_trigger_editor_jump() {
KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT), KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT),
); );
assert_eq!(outcome, KeyOutcome::Continue); 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` /// 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)] #[allow(clippy::field_reassign_with_default)]
fn search_state_with(input: &str, mode: SearchMode, searching: bool, last_query: Option<(String, SearchMode)>) -> SearchState { fn search_state_with(input: &str, mode: SearchMode, searching: bool, last_query: Option<(String, SearchMode)>) -> SearchState {
let mut s = SearchState::default(); let mut s = SearchState::default();
s.input = input.into(); s.input.push_str(input);
s.mode = mode; s.mode = mode;
s.searching = searching; s.searching = searching;
s.last_query = last_query; s.last_query = last_query;
@@ -543,3 +549,28 @@ fn no_search_state_returns_to_library() {
); );
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::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);
}