diff --git a/src/component/chat_view/mod.rs b/src/component/chat_view/mod.rs index b0afb4e..4e2c64c 100644 --- a/src/component/chat_view/mod.rs +++ b/src/component/chat_view/mod.rs @@ -902,7 +902,7 @@ fn render_input(state: &ChatViewState, frame: &mut Frame, area: Rect, theme: &Th // Show cursor when input is focused if state.focused && state.focus == Focus::Input && !state.disabled { - let (cursor_row, cursor_col) = state.input.cursor_position(); + let (cursor_row, cursor_col) = state.input.cursor_display_position(); let cursor_x = area.x + 1 + cursor_col as u16; let cursor_y = area.y + 1 + cursor_row as u16; if cursor_x < area.right() - 1 && cursor_y < area.bottom() - 1 { diff --git a/src/component/data_grid/mod.rs b/src/component/data_grid/mod.rs index f3297d6..f010577 100644 --- a/src/component/data_grid/mod.rs +++ b/src/component/data_grid/mod.rs @@ -610,7 +610,7 @@ impl Component for DataGrid { if let Some(col_area) = col_areas.get(state.selected_column) { // +2 for header row and margin let cursor_y = content_area.y + 2 + (row_idx as u16); - let cursor_x = col_area.x + state.editor.cursor_position() as u16; + let cursor_x = col_area.x + state.editor.cursor_display_position() as u16; if cursor_y < area.bottom() && cursor_x < col_area.right() { frame.set_cursor_position(Position::new(cursor_x, cursor_y)); } diff --git a/src/component/form/mod.rs b/src/component/form/mod.rs index 67363a3..0ee5629 100644 --- a/src/component/form/mod.rs +++ b/src/component/form/mod.rs @@ -772,7 +772,7 @@ fn render_text_field( // Show cursor when focused if is_focused && !state.is_disabled() { - let cursor_x = area.x + 1 + state.cursor_position() as u16; + let cursor_x = area.x + 1 + state.cursor_display_position() as u16; let cursor_y = area.y + 1; frame.set_cursor_position(Position::new(cursor_x, cursor_y)); } diff --git a/src/component/input_field/mod.rs b/src/component/input_field/mod.rs index 9363567..5b64e62 100644 --- a/src/component/input_field/mod.rs +++ b/src/component/input_field/mod.rs @@ -21,6 +21,7 @@ use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, Paragraph}; +use unicode_width::UnicodeWidthStr; use super::{Component, Focusable}; use crate::input::{Event, KeyCode, KeyModifiers}; @@ -204,6 +205,30 @@ impl InputFieldState { self.value[..self.cursor].chars().count() } + /// Returns the cursor display position (terminal column width). + /// + /// Unlike [`cursor_position()`](Self::cursor_position) which returns the + /// character count, this returns the display width accounting for + /// wide characters (emoji, CJK) that occupy 2 terminal columns. + /// + /// # Example + /// + /// ```rust + /// use envision::component::{InputField, InputFieldState, InputFieldMessage, Component}; + /// + /// let mut state = InputField::init(); + /// InputField::update(&mut state, InputFieldMessage::Insert('A')); + /// InputField::update(&mut state, InputFieldMessage::Insert('\u{1F600}')); // emoji + /// + /// // Character count is 2 (two characters) + /// assert_eq!(state.cursor_position(), 2); + /// // Display width is 3 (A=1 + 😀=2) + /// assert_eq!(state.cursor_display_position(), 3); + /// ``` + pub fn cursor_display_position(&self) -> usize { + self.value[..self.cursor].width() + } + /// Returns the cursor byte offset. pub fn cursor_byte_offset(&self) -> usize { self.cursor @@ -871,7 +896,7 @@ impl Component for InputField { // Show cursor when focused if state.focused && area.width > 2 && area.height > 2 { - let cursor_x = area.x + 1 + state.cursor_position() as u16; + let cursor_x = area.x + 1 + state.cursor_display_position() as u16; let cursor_y = area.y + 1; if cursor_x < area.x + area.width - 1 { diff --git a/src/component/input_field/tests.rs b/src/component/input_field/tests.rs index 0e5265e..41da6de 100644 --- a/src/component/input_field/tests.rs +++ b/src/component/input_field/tests.rs @@ -630,3 +630,58 @@ fn test_instance_update_disabled() { assert_eq!(output, None); assert_eq!(state.value(), "hello"); } + +// ========== cursor_display_position Tests ========== + +#[test] +fn test_cursor_display_position_ascii() { + let state = InputFieldState::with_value("hello"); + // For ASCII, display position equals character position. + assert_eq!(state.cursor_display_position(), 5); + assert_eq!(state.cursor_position(), 5); +} + +#[test] +fn test_cursor_display_position_emoji() { + let mut state = InputFieldState::new(); + InputField::update(&mut state, InputFieldMessage::Insert('A')); + InputField::update(&mut state, InputFieldMessage::Insert('\u{1F600}')); + InputField::update(&mut state, InputFieldMessage::Insert('B')); + // "A😀B" — char pos is 3, but display is 1 + 2 + 1 = 4 + assert_eq!(state.cursor_position(), 3); + assert_eq!(state.cursor_display_position(), 4); + + // Move cursor left (before 'B'), display pos should be 1 + 2 = 3 + InputField::update(&mut state, InputFieldMessage::Left); + assert_eq!(state.cursor_position(), 2); + assert_eq!(state.cursor_display_position(), 3); +} + +#[test] +fn test_cursor_display_position_cjk() { + let mut state = InputFieldState::new(); + InputField::update(&mut state, InputFieldMessage::Insert('日')); + InputField::update(&mut state, InputFieldMessage::Insert('本')); + // "日本" — char pos is 2, display is 2 + 2 = 4 + assert_eq!(state.cursor_position(), 2); + assert_eq!(state.cursor_display_position(), 4); +} + +#[test] +fn test_cursor_display_position_mixed() { + let mut state = InputFieldState::new(); + // "A日😀B" + InputField::update(&mut state, InputFieldMessage::Insert('A')); + InputField::update(&mut state, InputFieldMessage::Insert('日')); + InputField::update(&mut state, InputFieldMessage::Insert('\u{1F600}')); + InputField::update(&mut state, InputFieldMessage::Insert('B')); + // char pos = 4, display = 1 + 2 + 2 + 1 = 6 + assert_eq!(state.cursor_position(), 4); + assert_eq!(state.cursor_display_position(), 6); +} + +#[test] +fn test_cursor_display_position_empty() { + let state = InputFieldState::new(); + assert_eq!(state.cursor_display_position(), 0); +} diff --git a/src/component/log_viewer/mod.rs b/src/component/log_viewer/mod.rs index 08da6cc..807fd8c 100644 --- a/src/component/log_viewer/mod.rs +++ b/src/component/log_viewer/mod.rs @@ -824,7 +824,7 @@ fn render_search_bar(state: &LogViewerState, frame: &mut Frame, area: Rect, them // Show cursor when search is focused if state.focused && state.focus == Focus::Search && !state.disabled { - let cursor_x = area.x + 2 + state.search.cursor_position() as u16; + let cursor_x = area.x + 2 + state.search.cursor_display_position() as u16; if cursor_x < area.right() { frame.set_cursor_position(Position::new(cursor_x, area.y)); } diff --git a/src/component/text_area/mod.rs b/src/component/text_area/mod.rs index 3bada50..ecaa658 100644 --- a/src/component/text_area/mod.rs +++ b/src/component/text_area/mod.rs @@ -23,6 +23,7 @@ use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, Paragraph}; +use unicode_width::UnicodeWidthStr; use super::{Component, Focusable}; use crate::input::{Event, KeyCode, KeyModifiers}; @@ -284,6 +285,31 @@ impl TextAreaState { (self.cursor_row, char_col) } + /// Returns the cursor display position as (row, terminal_column_width). + /// + /// Unlike [`cursor_position()`](Self::cursor_position) which returns the + /// character count for the column, this returns the display width + /// accounting for wide characters (emoji, CJK) that occupy 2 terminal columns. + /// + /// # Example + /// + /// ```rust + /// use envision::component::{TextArea, TextAreaState, TextAreaMessage, Component}; + /// + /// let mut state = TextArea::init(); + /// TextArea::update(&mut state, TextAreaMessage::Insert('A')); + /// TextArea::update(&mut state, TextAreaMessage::Insert('\u{1F600}')); // emoji + /// + /// // Character count is 2 (two characters) + /// assert_eq!(state.cursor_position(), (0, 2)); + /// // Display width is 3 (A=1 + 😀=2) + /// assert_eq!(state.cursor_display_position(), (0, 3)); + /// ``` + pub fn cursor_display_position(&self) -> (usize, usize) { + let display_col = self.lines[self.cursor_row][..self.cursor_col].width(); + (self.cursor_row, display_col) + } + /// Returns the cursor row. pub fn cursor_row(&self) -> usize { self.cursor_row @@ -953,11 +979,9 @@ impl Component for TextArea { // Show cursor when focused if state.focused && area.width > 2 && area.height > 2 { let cursor_row_in_view = state.cursor_row.saturating_sub(scroll); - let char_col = state.lines[state.cursor_row][..state.cursor_col] - .chars() - .count(); + let (_, display_col) = state.cursor_display_position(); - let cursor_x = area.x + 1 + char_col as u16; + let cursor_x = area.x + 1 + display_col as u16; let cursor_y = area.y + 1 + cursor_row_in_view as u16; // Only show cursor if it's within the visible area diff --git a/src/component/text_area/tests.rs b/src/component/text_area/tests.rs index 5f4e457..0c39991 100644 --- a/src/component/text_area/tests.rs +++ b/src/component/text_area/tests.rs @@ -917,3 +917,55 @@ fn test_instance_update() { assert!(matches!(output, Some(TextAreaOutput::Changed(_)))); assert_eq!(state.value(), "a"); } + +// ========== cursor_display_position Tests ========== + +#[test] +fn test_cursor_display_position_emoji() { + let mut state = TextAreaState::new(); + TextArea::update(&mut state, TextAreaMessage::Insert('A')); + TextArea::update(&mut state, TextAreaMessage::Insert('\u{1F600}')); + TextArea::update(&mut state, TextAreaMessage::Insert('B')); + // "A😀B" — char col is 3, display col is 1 + 2 + 1 = 4 + assert_eq!(state.cursor_position(), (0, 3)); + assert_eq!(state.cursor_display_position(), (0, 4)); +} + +#[test] +fn test_cursor_display_position_cjk() { + let mut state = TextAreaState::new(); + TextArea::update(&mut state, TextAreaMessage::Insert('日')); + TextArea::update(&mut state, TextAreaMessage::Insert('本')); + // "日本" — char col is 2, display col is 2 + 2 = 4 + assert_eq!(state.cursor_position(), (0, 2)); + assert_eq!(state.cursor_display_position(), (0, 4)); +} + +#[test] +fn test_cursor_display_position_multiline_emoji() { + let mut state = TextAreaState::new(); + for c in "Hello".chars() { + TextArea::update(&mut state, TextAreaMessage::Insert(c)); + } + TextArea::update(&mut state, TextAreaMessage::NewLine); + TextArea::update(&mut state, TextAreaMessage::Insert('😀')); + TextArea::update(&mut state, TextAreaMessage::Insert('B')); + // Line 1: "Hello", Line 2: "😀B" + // cursor at (1, 3) char count, (1, 3) display width (2 + 1 = 3) + assert_eq!(state.cursor_position(), (1, 2)); + assert_eq!(state.cursor_display_position(), (1, 3)); +} + +#[test] +fn test_cursor_display_position_ascii() { + let state = TextAreaState::with_value("hello"); + // For ASCII, display position equals character position. + assert_eq!(state.cursor_display_position(), (0, 5)); + assert_eq!(state.cursor_position(), (0, 5)); +} + +#[test] +fn test_cursor_display_position_empty() { + let state = TextAreaState::new(); + assert_eq!(state.cursor_display_position(), (0, 0)); +}