// src/textarea/state.rs use std::ops::{Deref, DerefMut}; use anyhow::Result; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crate::editor::FormEditor; use crate::textarea::provider::TextAreaProvider; use crate::data_provider::DataProvider; #[cfg(feature = "gui")] use ratatui::{layout::Rect, widgets::Block}; #[cfg(feature = "gui")] use unicode_width::UnicodeWidthChar; #[cfg(feature = "gui")] pub(crate) fn wrapped_rows(s: &str, width: u16) -> u16 { if width == 0 { return 1; } let mut rows: u16 = 1; let mut cols: u16 = 0; for ch in s.chars() { let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16; if cols.saturating_add(w) > width { rows = rows.saturating_add(1); cols = 0; } cols = cols.saturating_add(w); } rows } #[cfg(feature = "gui")] pub(crate) fn wrapped_rows_to_cursor(s: &str, width: u16, cursor_chars: usize) -> (u16, u16) { if width == 0 { return (0, 0); } let mut row: u16 = 0; let mut cols: u16 = 0; for (i, ch) in s.chars().enumerate() { if i >= cursor_chars { break; } let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16; if cols.saturating_add(w) > width { row = row.saturating_add(1); cols = 0; } cols = cols.saturating_add(w); } (row, cols) } #[cfg(feature = "gui")] pub(crate) const RIGHT_PAD: u16 = 3; #[cfg(feature = "gui")] pub(crate) fn compute_h_scroll_with_padding( cursor_cols: u16, width: u16, ) -> (u16, u16) { let mut h = 0u16; for _ in 0..2 { let left_cols = if h > 0 { 1 } else { 0 }; let max_x_visible = width.saturating_sub(1 + RIGHT_PAD + left_cols); let needed = cursor_cols.saturating_sub(max_x_visible); if needed <= h { return (h, left_cols); } h = needed; } let left_cols = if h > 0 { 1 } else { 0 }; (h, left_cols) } #[cfg(feature = "gui")] fn normalize_indent(width: u16, indent: u16) -> u16 { indent.min(width.saturating_sub(1)) } #[cfg(feature = "gui")] pub(crate) fn count_wrapped_rows_indented( s: &str, width: u16, indent: u16, ) -> u16 { if width == 0 { return 1; } let indent = normalize_indent(width, indent); let cont_cap = width.saturating_sub(indent); let mut rows: u16 = 1; let mut used: u16 = 0; let mut first = true; for ch in s.chars() { let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16; let cap = if first { width } else { cont_cap }; if used > 0 && used.saturating_add(w) >= cap { rows = rows.saturating_add(1); first = false; used = indent; } used = used.saturating_add(w); } rows } #[cfg(feature = "gui")] fn wrapped_rows_to_cursor_indented( s: &str, width: u16, indent: u16, cursor_chars: usize, ) -> (u16, u16) { if width == 0 { return (0, 0); } let indent = normalize_indent(width, indent); let cont_cap = width.saturating_sub(indent); let mut row: u16 = 0; let mut used: u16 = 0; let mut first = true; for (i, ch) in s.chars().enumerate() { if i >= cursor_chars { break; } let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16; let cap = if first { width } else { cont_cap }; if used > 0 && used.saturating_add(w) >= cap { row = row.saturating_add(1); first = false; used = indent; } used = used.saturating_add(w); } (row, used.min(width.saturating_sub(1))) } pub type TextAreaEditor = FormEditor; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TextOverflowMode { Indicator { ch: char }, Wrap, } pub struct TextAreaState { pub(crate) editor: TextAreaEditor, pub(crate) scroll_y: u16, pub(crate) placeholder: Option, pub(crate) overflow_mode: TextOverflowMode, pub(crate) h_scroll: u16, #[cfg(feature = "gui")] pub(crate) wrap_indent_cols: u16, #[cfg(feature = "gui")] pub(crate) edited_this_frame: bool, } impl Default for TextAreaState { fn default() -> Self { Self { editor: FormEditor::new(TextAreaProvider::default()), scroll_y: 0, placeholder: None, overflow_mode: TextOverflowMode::Indicator { ch: '$' }, h_scroll: 0, #[cfg(feature = "gui")] wrap_indent_cols: 0, #[cfg(feature = "gui")] edited_this_frame: false, } } } impl Deref for TextAreaState { type Target = TextAreaEditor; fn deref(&self) -> &Self::Target { &self.editor } } impl DerefMut for TextAreaState { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.editor } } impl TextAreaState { pub fn from_text>(text: S) -> Self { let provider = TextAreaProvider::from_text(text); Self { editor: FormEditor::new(provider), scroll_y: 0, placeholder: None, overflow_mode: TextOverflowMode::Indicator { ch: '$' }, h_scroll: 0, #[cfg(feature = "gui")] wrap_indent_cols: 0, #[cfg(feature = "gui")] edited_this_frame: false, } } pub fn text(&self) -> String { self.editor.data_provider().to_text() } pub fn set_text>(&mut self, text: S) { self.editor.data_provider_mut().set_text(text); self.editor.ui_state.current_field = 0; self.editor.ui_state.cursor_pos = 0; self.editor.ui_state.ideal_cursor_column = 0; } pub fn set_placeholder>(&mut self, s: S) { self.placeholder = Some(s.into()); } pub fn use_overflow_indicator(&mut self, ch: char) { self.overflow_mode = TextOverflowMode::Indicator { ch }; } pub fn use_wrap(&mut self) { self.overflow_mode = TextOverflowMode::Wrap; } pub fn set_wrap_indent_cols(&mut self, cols: u16) { #[cfg(feature = "gui")] { self.wrap_indent_cols = cols; } } pub fn insert_newline(&mut self) { #[cfg(feature = "gui")] { self.edited_this_frame = true; } let line_idx = self.current_field(); let col = self.cursor_position(); let new_idx = self .editor .data_provider_mut() .split_line_at(line_idx, col); let _ = self.transition_to_field(new_idx); self.move_line_start(); self.enter_edit_mode(); } pub fn backspace(&mut self) { let col = self.cursor_position(); if col > 0 { #[cfg(feature = "gui")] { self.edited_this_frame = true; } let _ = self.delete_backward(); return; } let line_idx = self.current_field(); if line_idx == 0 { return; } if let Some((prev_idx, new_col)) = self.editor.data_provider_mut().join_with_prev(line_idx) { #[cfg(feature = "gui")] { self.edited_this_frame = true; } let _ = self.transition_to_field(prev_idx); self.set_cursor_position(new_col); self.enter_edit_mode(); } } pub fn delete_forward_or_join(&mut self) { let line_idx = self.current_field(); let line_len = self.current_text().chars().count(); let col = self.cursor_position(); if col < line_len { #[cfg(feature = "gui")] { self.edited_this_frame = true; } let _ = self.delete_forward(); return; } if let Some(new_col) = self.editor.data_provider_mut().join_with_next(line_idx) { #[cfg(feature = "gui")] { self.edited_this_frame = true; } self.set_cursor_position(new_col); self.enter_edit_mode(); } } pub fn input(&mut self, key: KeyEvent) { if key.kind != KeyEventKind::Press { return; } match (key.code, key.modifiers) { (KeyCode::Enter, _) => self.insert_newline(), (KeyCode::Backspace, _) => self.backspace(), (KeyCode::Delete, _) => self.delete_forward_or_join(), (KeyCode::Left, _) => { let _ = self.move_left(); } (KeyCode::Right, _) => { let _ = self.move_right(); } (KeyCode::Up, _) => { let _ = self.move_up(); } (KeyCode::Down, _) => { let _ = self.move_down(); } (KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => { self.move_line_start(); } (KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => { self.move_line_end(); } (KeyCode::Char('b'), KeyModifiers::ALT) => self.move_word_prev(), (KeyCode::Char('f'), KeyModifiers::ALT) => self.move_word_next(), (KeyCode::Char('e'), KeyModifiers::ALT) => self.move_word_end(), (KeyCode::Char(c), m) if m.is_empty() => { self.enter_edit_mode(); #[cfg(feature = "gui")] { self.edited_this_frame = true; } let _ = self.insert_char(c); } (KeyCode::Tab, _) => { self.enter_edit_mode(); #[cfg(feature = "gui")] { self.edited_this_frame = true; } for _ in 0..4 { let _ = self.insert_char(' '); } } _ => {} } } #[cfg(feature = "gui")] fn visual_rows_before_line_and_intra_indented( &self, width: u16, line_idx: usize, ) -> u16 { let provider = self.editor.data_provider(); let mut acc: u16 = 0; let indent = self.wrap_indent_cols; for i in 0..line_idx { let s = provider.field_value(i); acc = acc.saturating_add(count_wrapped_rows_indented(s, width, indent)); } acc } #[cfg(feature = "gui")] pub fn cursor(&self, area: Rect, block: Option<&Block<'_>>) -> (u16, u16) { let inner = if let Some(b) = block { b.inner(area) } else { area }; let line_idx = self.current_field() as usize; match self.overflow_mode { TextOverflowMode::Wrap => { let width = inner.width; let y_top = inner.y; let indent = self.wrap_indent_cols; if width == 0 { let prefix = self.visual_rows_before_line_and_intra_indented(1, line_idx); let y = y_top.saturating_add(prefix.saturating_sub(self.scroll_y)); return (inner.x, y); } let prefix_rows = self.visual_rows_before_line_and_intra_indented(width, line_idx); let current_line = self.current_text(); let col_chars = self.display_cursor_position(); let (subrow, x_cols) = wrapped_rows_to_cursor_indented( ¤t_line, width, indent, col_chars, ); let caret_vis_row = prefix_rows.saturating_add(subrow); let y = y_top.saturating_add(caret_vis_row.saturating_sub(self.scroll_y)); let x = inner.x.saturating_add(x_cols); (x, y) } TextOverflowMode::Indicator { .. } => { let y = inner.y + (line_idx as u16).saturating_sub(self.scroll_y); let current_line = self.current_text(); let col = self.display_cursor_position(); let mut x_cols: u16 = 0; let mut total_cols: u16 = 0; for (i, ch) in current_line.chars().enumerate() { let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16; if i < col { x_cols = x_cols.saturating_add(w); } total_cols = total_cols.saturating_add(w); } let left_cols = if self.h_scroll > 0 { 1 } else { 0 }; let mut x_off_visible = x_cols .saturating_sub(self.h_scroll) .saturating_add(left_cols); let limit = inner.width.saturating_sub(1 + RIGHT_PAD); if x_off_visible > limit { x_off_visible = limit; } let x = inner.x.saturating_add(x_off_visible); (x, y) } } } #[cfg(feature = "gui")] pub(crate) fn ensure_visible(&mut self, area: Rect, block: Option<&Block<'_>>) { let inner = if let Some(b) = block { b.inner(area) } else { area }; if inner.height == 0 { return; } match self.overflow_mode { TextOverflowMode::Indicator { .. } => { let line_idx_u16 = self.current_field() as u16; if line_idx_u16 < self.scroll_y { self.scroll_y = line_idx_u16; } else if line_idx_u16 >= self.scroll_y + inner.height { self.scroll_y = line_idx_u16.saturating_sub(inner.height - 1); } let width = inner.width; if width == 0 { return; } let current_line = self.current_text(); let mut total_cols: u16 = 0; for ch in current_line.chars() { total_cols = total_cols .saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16); } if total_cols <= width { self.h_scroll = 0; return; } let col = self.display_cursor_position(); let mut cursor_cols: u16 = 0; for (i, ch) in current_line.chars().enumerate() { if i >= col { break; } cursor_cols = cursor_cols .saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16); } let (target_h, _left_cols) = compute_h_scroll_with_padding(cursor_cols, width); if target_h > self.h_scroll { self.h_scroll = target_h; } else if cursor_cols < self.h_scroll { self.h_scroll = cursor_cols; } } TextOverflowMode::Wrap => { let width = inner.width; if width == 0 { self.h_scroll = 0; return; } let indent = self.wrap_indent_cols; let line_idx = self.current_field() as usize; let prefix_rows = self.visual_rows_before_line_and_intra_indented(width, line_idx); let current_line = self.current_text(); let col = self.display_cursor_position(); let (subrow, _x_cols) = wrapped_rows_to_cursor_indented(¤t_line, width, indent, col); let caret_vis_row = prefix_rows.saturating_add(subrow); let top = self.scroll_y; let height = inner.height; if caret_vis_row < top { self.scroll_y = caret_vis_row; } else { let bottom = top.saturating_add(height.saturating_sub(1)); if caret_vis_row > bottom { let shift = caret_vis_row.saturating_sub(bottom); self.scroll_y = top.saturating_add(shift); } } self.h_scroll = 0; } } } #[cfg(feature = "gui")] pub(crate) fn take_edited_flag(&mut self) -> bool { let v = self.edited_this_frame; self.edited_this_frame = false; v } }