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
2021const CLAUDE_ORANGE : Color = Color :: Rgb ( 233 , 30 , 99 ) ;
2122const 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