feat(kebab-tui): InputBuffer struct (display-column cursor)

Add `InputBuffer` with `push_char`/`push_str`/`pop_char`/`clear` tracking
cursor position in display columns (CJK = 2, ASCII = 1) plus 6 unit tests
(p9-fb-10 Task 1). Re-export from crate root.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 09:18:08 +00:00
parent bbe83ca00b
commit dfec781f0a
2 changed files with 145 additions and 1 deletions

View File

@@ -74,6 +74,82 @@ pub fn truncate_to_display_width(s: &str, max_cols: usize) -> String {
out
}
/// Text input buffer that tracks **display column** position, not
/// char count. Every wide char (Hangul / Kanji / fullwidth) advances
/// `cursor_col` by 2; every ASCII char by 1. Backspace pops one
/// char (`String::pop()` is char-aware) and rewinds the cursor by
/// that char's width.
///
/// Cursor invariant: `cursor_col == display_width(&content)` —
/// the cursor sits at the right edge of the typed content. v1
/// is append-only; mid-string editing (insert at cursor / arrow
/// key navigation) is out of scope and would relax this invariant.
#[derive(Debug, Default, Clone)]
pub struct InputBuffer {
content: String,
cursor_col: usize,
}
impl InputBuffer {
pub fn new() -> Self {
Self::default()
}
/// Append a single char and advance cursor by its display width.
/// Zero-width chars (combining marks) leave the cursor in place
/// but still extend `content`.
pub fn push_char(&mut self, ch: char) {
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
self.content.push(ch);
self.cursor_col += w;
}
/// Append a `&str` char-by-char. Same width semantics as
/// `push_char` per element.
pub fn push_str(&mut self, s: &str) {
for ch in s.chars() {
self.push_char(ch);
}
}
/// Remove the trailing char (Backspace) and rewind the cursor
/// by that char's display width. No-op on empty input.
pub fn pop_char(&mut self) -> Option<char> {
let ch = self.content.pop()?;
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
self.cursor_col = self.cursor_col.saturating_sub(w);
Some(ch)
}
/// Reset to empty.
pub fn clear(&mut self) {
self.content.clear();
self.cursor_col = 0;
}
/// Borrow the typed text.
pub fn as_str(&self) -> &str {
&self.content
}
/// Cursor column (display-width units). Matches
/// `display_width(self.as_str())` by construction.
pub fn cursor_col(&self) -> usize {
self.cursor_col
}
/// True when no chars have been typed.
pub fn is_empty(&self) -> bool {
self.content.is_empty()
}
/// Length of `content` in chars (NOT display columns). Use
/// `cursor_col()` for column-aware layout.
pub fn char_len(&self) -> usize {
self.content.chars().count()
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -158,4 +234,72 @@ mod tests {
assert_eq!(s, "");
assert_eq!(display_width(&s), 2);
}
/// p9-fb-10: ASCII typing advances cursor by 1 per char.
#[test]
fn input_buffer_ascii_cursor_advances_by_one() {
let mut b = InputBuffer::new();
for ch in "hello".chars() {
b.push_char(ch);
}
assert_eq!(b.cursor_col(), 5);
assert_eq!(b.as_str(), "hello");
}
/// p9-fb-10: Hangul typing advances cursor by 2 per char.
#[test]
fn input_buffer_hangul_cursor_advances_by_two() {
let mut b = InputBuffer::new();
for ch in "한글".chars() {
b.push_char(ch);
}
assert_eq!(b.cursor_col(), 4);
assert_eq!(b.as_str(), "한글");
}
/// p9-fb-10: Backspace rewinds cursor by the popped char's
/// width — Hangul rewinds by 2, ASCII by 1.
#[test]
fn input_buffer_pop_char_rewinds_cursor_by_width() {
let mut b = InputBuffer::new();
b.push_str("러스트");
assert_eq!(b.cursor_col(), 6);
let popped = b.pop_char();
assert_eq!(popped, Some('트'));
assert_eq!(b.cursor_col(), 4);
assert_eq!(b.as_str(), "러스");
b.push_char('a');
assert_eq!(b.cursor_col(), 5);
assert_eq!(b.as_str(), "러스a");
}
/// p9-fb-10: cursor invariant — cursor_col always equals
/// display_width(content).
#[test]
fn input_buffer_cursor_matches_display_width() {
let mut b = InputBuffer::new();
for ch in "Hello, 세계 mixed".chars() {
b.push_char(ch);
}
assert_eq!(b.cursor_col(), display_width(b.as_str()));
}
/// p9-fb-10: clear resets both content and cursor.
#[test]
fn input_buffer_clear_resets_state() {
let mut b = InputBuffer::new();
b.push_str("한글");
b.clear();
assert_eq!(b.cursor_col(), 0);
assert!(b.is_empty());
}
/// p9-fb-10: pop_char on empty input returns None and leaves
/// cursor at 0 (no underflow).
#[test]
fn input_buffer_pop_on_empty_is_noop() {
let mut b = InputBuffer::new();
assert!(b.pop_char().is_none());
assert_eq!(b.cursor_col(), 0);
}
}

View File

@@ -27,7 +27,7 @@ mod search;
mod terminal;
mod theme;
pub use input::{display_width, truncate_to_display_width};
pub use input::{InputBuffer, display_width, truncate_to_display_width};
pub use theme::{Palette, Role, Theme};
pub use app::{
App, AskState, IngestState, InspectState, InspectTarget, KeyOutcome, LibraryState, Mode,