Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/component/chat_view/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/component/data_grid/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,7 @@ impl<T: TableRow + 'static> Component for DataGrid<T> {
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));
}
Expand Down
2 changes: 1 addition & 1 deletion src/component/form/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
27 changes: 26 additions & 1 deletion src/component/input_field/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
55 changes: 55 additions & 0 deletions src/component/input_field/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
2 changes: 1 addition & 1 deletion src/component/log_viewer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
32 changes: 28 additions & 4 deletions src/component/text_area/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions src/component/text_area/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}