Skip to content

Commit d7ce4bb

Browse files
ryanoneillclaude
andcommitted
Fix emoji and CJK cursor display width in component rendering
Add cursor_display_position() to InputFieldState and TextAreaState that returns terminal column width using unicode-width, accounting for wide characters (emoji, CJK) that occupy 2 terminal columns. Update all 6 rendering sites to use display width instead of character count. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 087bee6 commit d7ce4bb

File tree

8 files changed

+165
-9
lines changed

8 files changed

+165
-9
lines changed

src/component/chat_view/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -920,7 +920,7 @@ fn render_input(
920920

921921
// Show cursor when input is focused
922922
if state.focused && state.focus == Focus::Input && !state.disabled {
923-
let (cursor_row, cursor_col) = state.input.cursor_position();
923+
let (cursor_row, cursor_col) = state.input.cursor_display_position();
924924
let cursor_x = area.x + 1 + cursor_col as u16;
925925
let cursor_y = area.y + 1 + cursor_row as u16;
926926
if cursor_x < area.right() - 1 && cursor_y < area.bottom() - 1 {

src/component/data_grid/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,7 @@ impl<T: TableRow + 'static> Component for DataGrid<T> {
615615
if let Some(col_area) = col_areas.get(state.selected_column) {
616616
// +2 for header row and margin
617617
let cursor_y = content_area.y + 2 + (row_idx as u16);
618-
let cursor_x = col_area.x + state.editor.cursor_position() as u16;
618+
let cursor_x = col_area.x + state.editor.cursor_display_position() as u16;
619619
if cursor_y < area.bottom() && cursor_x < col_area.right() {
620620
frame.set_cursor_position(Position::new(cursor_x, cursor_y));
621621
}

src/component/form/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -776,7 +776,7 @@ fn render_text_field(
776776

777777
// Show cursor when focused
778778
if is_focused && !state.is_disabled() {
779-
let cursor_x = area.x + 1 + state.cursor_position() as u16;
779+
let cursor_x = area.x + 1 + state.cursor_display_position() as u16;
780780
let cursor_y = area.y + 1;
781781
frame.set_cursor_position(Position::new(cursor_x, cursor_y));
782782
}

src/component/input_field/mod.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
2222
use ratatui::prelude::*;
2323
use ratatui::widgets::{Block, Borders, Paragraph};
24+
use unicode_width::UnicodeWidthStr;
2425

2526
use super::{Component, Focusable};
2627
use crate::input::{Event, KeyCode, KeyModifiers};
@@ -201,6 +202,30 @@ impl InputFieldState {
201202
self.value[..self.cursor].chars().count()
202203
}
203204

205+
/// Returns the cursor display position (terminal column width).
206+
///
207+
/// Unlike [`cursor_position()`](Self::cursor_position) which returns the
208+
/// character count, this returns the display width accounting for
209+
/// wide characters (emoji, CJK) that occupy 2 terminal columns.
210+
///
211+
/// # Example
212+
///
213+
/// ```rust
214+
/// use envision::component::{InputField, InputFieldState, InputFieldMessage, Component};
215+
///
216+
/// let mut state = InputField::init();
217+
/// InputField::update(&mut state, InputFieldMessage::Insert('A'));
218+
/// InputField::update(&mut state, InputFieldMessage::Insert('\u{1F600}')); // emoji
219+
///
220+
/// // Character count is 2 (two characters)
221+
/// assert_eq!(state.cursor_position(), 2);
222+
/// // Display width is 3 (A=1 + 😀=2)
223+
/// assert_eq!(state.cursor_display_position(), 3);
224+
/// ```
225+
pub fn cursor_display_position(&self) -> usize {
226+
self.value[..self.cursor].width()
227+
}
228+
204229
/// Returns the cursor byte offset.
205230
pub fn cursor_byte_offset(&self) -> usize {
206231
self.cursor
@@ -868,7 +893,7 @@ impl Component for InputField {
868893

869894
// Show cursor when focused
870895
if state.focused && area.width > 2 && area.height > 2 {
871-
let cursor_x = area.x + 1 + state.cursor_position() as u16;
896+
let cursor_x = area.x + 1 + state.cursor_display_position() as u16;
872897
let cursor_y = area.y + 1;
873898

874899
if cursor_x < area.x + area.width - 1 {

src/component/input_field/tests.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,3 +630,58 @@ fn test_instance_update_disabled() {
630630
assert_eq!(output, None);
631631
assert_eq!(state.value(), "hello");
632632
}
633+
634+
// ========== cursor_display_position Tests ==========
635+
636+
#[test]
637+
fn test_cursor_display_position_ascii() {
638+
let state = InputFieldState::with_value("hello");
639+
// For ASCII, display position equals character position.
640+
assert_eq!(state.cursor_display_position(), 5);
641+
assert_eq!(state.cursor_position(), 5);
642+
}
643+
644+
#[test]
645+
fn test_cursor_display_position_emoji() {
646+
let mut state = InputFieldState::new();
647+
InputField::update(&mut state, InputFieldMessage::Insert('A'));
648+
InputField::update(&mut state, InputFieldMessage::Insert('\u{1F600}'));
649+
InputField::update(&mut state, InputFieldMessage::Insert('B'));
650+
// "A😀B" — char pos is 3, but display is 1 + 2 + 1 = 4
651+
assert_eq!(state.cursor_position(), 3);
652+
assert_eq!(state.cursor_display_position(), 4);
653+
654+
// Move cursor left (before 'B'), display pos should be 1 + 2 = 3
655+
InputField::update(&mut state, InputFieldMessage::Left);
656+
assert_eq!(state.cursor_position(), 2);
657+
assert_eq!(state.cursor_display_position(), 3);
658+
}
659+
660+
#[test]
661+
fn test_cursor_display_position_cjk() {
662+
let mut state = InputFieldState::new();
663+
InputField::update(&mut state, InputFieldMessage::Insert('日'));
664+
InputField::update(&mut state, InputFieldMessage::Insert('本'));
665+
// "日本" — char pos is 2, display is 2 + 2 = 4
666+
assert_eq!(state.cursor_position(), 2);
667+
assert_eq!(state.cursor_display_position(), 4);
668+
}
669+
670+
#[test]
671+
fn test_cursor_display_position_mixed() {
672+
let mut state = InputFieldState::new();
673+
// "A日😀B"
674+
InputField::update(&mut state, InputFieldMessage::Insert('A'));
675+
InputField::update(&mut state, InputFieldMessage::Insert('日'));
676+
InputField::update(&mut state, InputFieldMessage::Insert('\u{1F600}'));
677+
InputField::update(&mut state, InputFieldMessage::Insert('B'));
678+
// char pos = 4, display = 1 + 2 + 2 + 1 = 6
679+
assert_eq!(state.cursor_position(), 4);
680+
assert_eq!(state.cursor_display_position(), 6);
681+
}
682+
683+
#[test]
684+
fn test_cursor_display_position_empty() {
685+
let state = InputFieldState::new();
686+
assert_eq!(state.cursor_display_position(), 0);
687+
}

src/component/log_viewer/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -829,7 +829,7 @@ fn render_search_bar(
829829

830830
// Show cursor when search is focused
831831
if state.focused && state.focus == Focus::Search && !state.disabled {
832-
let cursor_x = area.x + 2 + state.search.cursor_position() as u16;
832+
let cursor_x = area.x + 2 + state.search.cursor_display_position() as u16;
833833
if cursor_x < area.right() {
834834
frame.set_cursor_position(Position::new(cursor_x, area.y));
835835
}

src/component/text_area/mod.rs

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
2424
use ratatui::prelude::*;
2525
use ratatui::widgets::{Block, Borders, Paragraph};
26+
use unicode_width::UnicodeWidthStr;
2627

2728
use super::{Component, Focusable};
2829
use crate::input::{Event, KeyCode, KeyModifiers};
@@ -281,6 +282,31 @@ impl TextAreaState {
281282
(self.cursor_row, char_col)
282283
}
283284

285+
/// Returns the cursor display position as (row, terminal_column_width).
286+
///
287+
/// Unlike [`cursor_position()`](Self::cursor_position) which returns the
288+
/// character count for the column, this returns the display width
289+
/// accounting for wide characters (emoji, CJK) that occupy 2 terminal columns.
290+
///
291+
/// # Example
292+
///
293+
/// ```rust
294+
/// use envision::component::{TextArea, TextAreaState, TextAreaMessage, Component};
295+
///
296+
/// let mut state = TextArea::init();
297+
/// TextArea::update(&mut state, TextAreaMessage::Insert('A'));
298+
/// TextArea::update(&mut state, TextAreaMessage::Insert('\u{1F600}')); // emoji
299+
///
300+
/// // Character count is 2 (two characters)
301+
/// assert_eq!(state.cursor_position(), (0, 2));
302+
/// // Display width is 3 (A=1 + 😀=2)
303+
/// assert_eq!(state.cursor_display_position(), (0, 3));
304+
/// ```
305+
pub fn cursor_display_position(&self) -> (usize, usize) {
306+
let display_col = self.lines[self.cursor_row][..self.cursor_col].width();
307+
(self.cursor_row, display_col)
308+
}
309+
284310
/// Returns the cursor row.
285311
pub fn cursor_row(&self) -> usize {
286312
self.cursor_row
@@ -865,11 +891,9 @@ impl Component for TextArea {
865891
// Show cursor when focused
866892
if state.focused && area.width > 2 && area.height > 2 {
867893
let cursor_row_in_view = state.cursor_row.saturating_sub(scroll);
868-
let char_col = state.lines[state.cursor_row][..state.cursor_col]
869-
.chars()
870-
.count();
894+
let (_, display_col) = state.cursor_display_position();
871895

872-
let cursor_x = area.x + 1 + char_col as u16;
896+
let cursor_x = area.x + 1 + display_col as u16;
873897
let cursor_y = area.y + 1 + cursor_row_in_view as u16;
874898

875899
// Only show cursor if it's within the visible area

src/component/text_area/tests.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -917,3 +917,55 @@ fn test_instance_update() {
917917
assert!(matches!(output, Some(TextAreaOutput::Changed(_))));
918918
assert_eq!(state.value(), "a");
919919
}
920+
921+
// ========== cursor_display_position Tests ==========
922+
923+
#[test]
924+
fn test_cursor_display_position_emoji() {
925+
let mut state = TextAreaState::new();
926+
TextArea::update(&mut state, TextAreaMessage::Insert('A'));
927+
TextArea::update(&mut state, TextAreaMessage::Insert('\u{1F600}'));
928+
TextArea::update(&mut state, TextAreaMessage::Insert('B'));
929+
// "A😀B" — char col is 3, display col is 1 + 2 + 1 = 4
930+
assert_eq!(state.cursor_position(), (0, 3));
931+
assert_eq!(state.cursor_display_position(), (0, 4));
932+
}
933+
934+
#[test]
935+
fn test_cursor_display_position_cjk() {
936+
let mut state = TextAreaState::new();
937+
TextArea::update(&mut state, TextAreaMessage::Insert('日'));
938+
TextArea::update(&mut state, TextAreaMessage::Insert('本'));
939+
// "日本" — char col is 2, display col is 2 + 2 = 4
940+
assert_eq!(state.cursor_position(), (0, 2));
941+
assert_eq!(state.cursor_display_position(), (0, 4));
942+
}
943+
944+
#[test]
945+
fn test_cursor_display_position_multiline_emoji() {
946+
let mut state = TextAreaState::new();
947+
for c in "Hello".chars() {
948+
TextArea::update(&mut state, TextAreaMessage::Insert(c));
949+
}
950+
TextArea::update(&mut state, TextAreaMessage::NewLine);
951+
TextArea::update(&mut state, TextAreaMessage::Insert('😀'));
952+
TextArea::update(&mut state, TextAreaMessage::Insert('B'));
953+
// Line 1: "Hello", Line 2: "😀B"
954+
// cursor at (1, 3) char count, (1, 3) display width (2 + 1 = 3)
955+
assert_eq!(state.cursor_position(), (1, 2));
956+
assert_eq!(state.cursor_display_position(), (1, 3));
957+
}
958+
959+
#[test]
960+
fn test_cursor_display_position_ascii() {
961+
let state = TextAreaState::with_value("hello");
962+
// For ASCII, display position equals character position.
963+
assert_eq!(state.cursor_display_position(), (0, 5));
964+
assert_eq!(state.cursor_position(), (0, 5));
965+
}
966+
967+
#[test]
968+
fn test_cursor_display_position_empty() {
969+
let state = TextAreaState::new();
970+
assert_eq!(state.cursor_display_position(), (0, 0));
971+
}

0 commit comments

Comments
 (0)