Skip to content

Commit d85747b

Browse files
Merge pull request #169 from goodneamtakenbydogs/fix/cjk-prompt-cursor-width
fix(tui): account for wide chars in prompt cursor Fixes #168
2 parents 224c79a + b006879 commit d85747b

1 file changed

Lines changed: 109 additions & 65 deletions

File tree

src-rust/crates/tui/src/prompt_input.rs

Lines changed: 109 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@
99
//! - Paste handling (large pastes → placeholder)
1010
//! - Character count + token estimate
1111
12-
use ratatui::{
13-
buffer::Buffer,
14-
layout::Rect,
15-
style::{Color, Modifier, Style},
16-
text::{Line, Span},
17-
widgets::{Paragraph, Widget},
18-
};
12+
use ratatui::{
13+
buffer::Buffer,
14+
layout::Rect,
15+
style::{Color, Modifier, Style},
16+
text::{Line, Span},
17+
widgets::{Paragraph, Widget},
18+
};
19+
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
1920

2021
const CLAUDE_ORANGE: Color = Color::Rgb(233, 30, 99);
2122
const PROMPT_POINTER: &str = "\u{276f}";
@@ -2697,11 +2698,11 @@ impl PromptInputState {
26972698
while b > 0 && !line.is_char_boundary(b) {
26982699
b -= 1;
26992700
}
2700-
let intra_chars = line[..b].chars().count();
2701-
let chunk_idx = if intra_chars == 0 { 0 } else { intra_chars / width };
2702-
let chunk_col = intra_chars % width;
2703-
return (row + chunk_idx, chunk_col);
2704-
}
2701+
let display_col = UnicodeWidthStr::width(&line[..b]);
2702+
let chunk_idx = if display_col == 0 { 0 } else { display_col / width };
2703+
let chunk_col = display_col % width;
2704+
return (row + chunk_idx, chunk_col);
2705+
}
27052706
let chunks = wrap_line(line, width).len().max(1);
27062707
row += chunks;
27072708
byte = line_end + 1; // newline
@@ -2839,26 +2840,35 @@ pub fn input_height(state: &PromptInputState, text_width: u16) -> u16 {
28392840
base + if state.pending_images.is_empty() { 0 } else { 1 }
28402841
}
28412842

2842-
/// Wrap a logical line into visual chunks of `width` chars (char-based, not
2843-
/// byte-based, to preserve UTF-8 characters). Empty input yields a single
2844-
/// empty chunk so the caller can still place a cursor.
2845-
pub fn wrap_line(line: &str, width: usize) -> Vec<String> {
2846-
if width == 0 {
2847-
return vec![line.to_string()];
2848-
}
2849-
let chars: Vec<char> = line.chars().collect();
2850-
if chars.is_empty() {
2851-
return vec![String::new()];
2852-
}
2853-
let mut out = Vec::with_capacity(chars.len() / width + 1);
2854-
let mut i = 0;
2855-
while i < chars.len() {
2856-
let end = (i + width).min(chars.len());
2857-
out.push(chars[i..end].iter().collect());
2858-
i = end;
2859-
}
2860-
out
2861-
}
2843+
/// Wrap a logical line into visual chunks of `width` terminal cells. Empty
2844+
/// input yields a single empty chunk so the caller can still place a cursor.
2845+
pub fn wrap_line(line: &str, width: usize) -> Vec<String> {
2846+
if width == 0 {
2847+
return vec![line.to_string()];
2848+
}
2849+
if line.is_empty() {
2850+
return vec![String::new()];
2851+
}
2852+
2853+
let mut out = Vec::new();
2854+
let mut current = String::new();
2855+
let mut current_width = 0usize;
2856+
2857+
for ch in line.chars() {
2858+
let ch_width = ch.width().unwrap_or(0);
2859+
if current_width > 0 && current_width + ch_width > width {
2860+
out.push(std::mem::take(&mut current));
2861+
current_width = 0;
2862+
}
2863+
current.push(ch);
2864+
current_width += ch_width;
2865+
}
2866+
if !current.is_empty() {
2867+
out.push(current);
2868+
}
2869+
2870+
out
2871+
}
28622872

28632873
/// Render the prompt input widget in the same low-chrome style as Claurst:
28642874
/// multi-line input rows (one per logical line in the text) plus an accent
@@ -2908,7 +2918,7 @@ pub fn render_prompt_input(
29082918
_ => accent_override, // use mode-aware accent color
29092919
};
29102920
let prompt_prefix = format!("{PROMPT_POINTER} ");
2911-
let prefix_width = prompt_prefix.chars().count() as u16;
2921+
let prefix_width = UnicodeWidthStr::width(prompt_prefix.as_str()) as u16;
29122922
// Reserve a 2-cell right margin so wrapped text doesn't kiss the right edge
29132923
// of the box (issue #149: padding too tight).
29142924
let right_pad: u16 = 2;
@@ -2973,21 +2983,22 @@ pub fn render_prompt_input(
29732983
};
29742984

29752985
// Wrap each logical line into visual rows that fit `available_width`,
2976-
// and remember the (logical_idx, intra_line_char_offset) for each row
2977-
// so we can later compute where the cursor lives.
2986+
// and remember the (logical_idx, intra_line_display_col) for each row
2987+
// so we can later compute where the cursor lives.
29782988
let mut visual_rows: Vec<(usize, usize, String)> = Vec::new();
29792989
for (li, line_text) in logical_lines.iter().enumerate() {
29802990
let chunks = wrap_line(line_text, available_width.max(1));
29812991
let mut col_offset = 0usize;
29822992
for chunk in chunks {
2983-
let chunk_len = chunk.chars().count();
2984-
visual_rows.push((li, col_offset, chunk));
2985-
col_offset += chunk_len;
2986-
}
2987-
}
2988-
2989-
// Compute cursor's visual (row, col) within `visual_rows`.
2990-
// We map state.cursor (a byte offset into state.text) to (logical_line, char_offset).
2993+
let chunk_len = UnicodeWidthStr::width(chunk.as_str());
2994+
visual_rows.push((li, col_offset, chunk));
2995+
col_offset += chunk_len;
2996+
}
2997+
}
2998+
2999+
// Compute cursor's visual (row, col) within `visual_rows`.
3000+
// We map state.cursor (a byte offset into state.text) to
3001+
// (logical_line, display column).
29913002
let cursor_pos: Option<(usize, usize)> = if focused && !state.text.is_empty() {
29923003
let mut byte_idx = 0usize;
29933004
let mut found: Option<(usize, usize)> = None;
@@ -2997,18 +3008,21 @@ pub fn render_prompt_input(
29973008
let line_end_byte = byte_idx + line_bytes;
29983009
if state.cursor <= line_end_byte {
29993010
let intra_byte = state.cursor - byte_idx;
3000-
let char_offset = line_text[..intra_byte.min(line_bytes)].chars().count();
3001-
found = Some((li, char_offset));
3002-
break 'outer;
3003-
}
3011+
let display_col = UnicodeWidthStr::width(&line_text[..intra_byte.min(line_bytes)]);
3012+
found = Some((li, display_col));
3013+
break 'outer;
3014+
}
30043015
byte_idx = line_end_byte + 1; // newline
30053016
}
30063017
// Fallback: cursor at end of text.
30073018
found.or_else(|| {
30083019
let li = logical_lines.len().saturating_sub(1);
3009-
let col = logical_lines.get(li).map(|s| s.chars().count()).unwrap_or(0);
3010-
Some((li, col))
3011-
})
3020+
let col = logical_lines
3021+
.get(li)
3022+
.map(|s| UnicodeWidthStr::width(s.as_str()))
3023+
.unwrap_or(0);
3024+
Some((li, col))
3025+
})
30123026
} else if focused && state.text.is_empty() {
30133027
Some((0, 0))
30143028
} else {
@@ -3022,7 +3036,7 @@ pub fn render_prompt_input(
30223036
if *row_li != li {
30233037
continue;
30243038
}
3025-
let chunk_len = chunk.chars().count();
3039+
let chunk_len = UnicodeWidthStr::width(chunk.as_str());
30263040
let row_col_end = row_col_start + chunk_len;
30273041
if col >= *row_col_start && col <= row_col_end {
30283042
last_match = Some((vi, col - row_col_start));
@@ -3358,21 +3372,51 @@ mod tests {
33583372
}
33593373

33603374
#[test]
3361-
fn move_left_right() {
3362-
let mut s = PromptInputState::new();
3363-
s.text = "abc".to_string();
3364-
s.cursor = 1;
3365-
s.move_right();
3375+
fn move_left_right() {
3376+
let mut s = PromptInputState::new();
3377+
s.text = "abc".to_string();
3378+
s.cursor = 1;
3379+
s.move_right();
33663380
assert_eq!(s.cursor, 2);
3367-
s.move_left();
3368-
assert_eq!(s.cursor, 1);
3369-
}
3370-
3371-
#[test]
3372-
fn readonly_blocks_insert() {
3373-
let mut s = PromptInputState::new();
3374-
s.mode = InputMode::Readonly;
3375-
s.insert_char('x');
3381+
s.move_left();
3382+
assert_eq!(s.cursor, 1);
3383+
}
3384+
3385+
#[test]
3386+
fn cursor_visual_pos_counts_wide_characters() {
3387+
let mut s = PromptInputState::new();
3388+
s.text = "你a".to_string();
3389+
s.cursor = "你".len();
3390+
3391+
assert_eq!(s.cursor_visual_pos(10), (0, 2));
3392+
}
3393+
3394+
#[test]
3395+
fn render_cursor_after_wide_character() {
3396+
let mut s = PromptInputState::new();
3397+
s.text = "你a".to_string();
3398+
s.cursor = "你".len();
3399+
3400+
let area = Rect { x: 0, y: 0, width: 12, height: 4 };
3401+
let mut buf = Buffer::empty(area);
3402+
render_prompt_input(
3403+
&s,
3404+
area,
3405+
&mut buf,
3406+
true,
3407+
InputMode::Default,
3408+
Color::Blue,
3409+
false,
3410+
);
3411+
3412+
assert_eq!(buf[(4, 1)].symbol(), "\u{2588}");
3413+
}
3414+
3415+
#[test]
3416+
fn readonly_blocks_insert() {
3417+
let mut s = PromptInputState::new();
3418+
s.mode = InputMode::Readonly;
3419+
s.insert_char('x');
33763420
assert!(s.text.is_empty());
33773421
}
33783422

0 commit comments

Comments
 (0)