use ratatui::layout::Rect;
use ratatui_textarea::{CursorMove, TextArea};
use crate::components::autocomplete::AcceptAction;
pub fn byte_to_row_char_col(lines: &[String], byte_offset: usize) -> Option<(usize, usize)> {
let mut byte_running = 0;
for (row, line) in lines.iter().enumerate() {
let line_end = byte_running + line.len();
if byte_offset >= byte_running && byte_offset <= line_end {
let col_bytes = byte_offset - byte_running;
if !line.is_char_boundary(col_bytes) {
return None;
}
let char_col = line[..col_bytes].chars().count();
return Some((row, char_col));
}
byte_running = line_end + 1; }
None
}
pub fn row_char_col_to_byte(lines: &[String], row: usize, char_col: usize) -> usize {
let mut byte_offset = 0;
for (r, line) in lines.iter().enumerate().take(row) {
byte_offset += line.len() + 1;
let _ = r;
}
let Some(line) = lines.get(row) else {
return byte_offset;
};
byte_offset
+ line
.char_indices()
.nth(char_col)
.map(|(b, _)| b)
.unwrap_or(line.len())
}
pub fn apply_accept_to_textarea(ta: &mut TextArea<'_>, action: &AcceptAction) {
let before: Vec<String> = ta.lines().iter().map(|l| l.to_string()).collect();
let Some((start_row, start_col)) = byte_to_row_char_col(&before, action.range.start) else {
return;
};
if byte_to_row_char_col(&before, action.range.end).is_none() {
return;
}
ta.cancel_selection();
ta.move_cursor(CursorMove::Jump(start_row as u16, start_col as u16));
if action.range.end > action.range.start {
let preserved_yank = ta.yank_text();
let joined: String = before.join("\n");
let char_count = joined[action.range.clone()].chars().count();
ta.delete_str(char_count);
ta.set_yank_text(preserved_yank);
}
ta.insert_str(&action.new_text);
let after: Vec<String> = ta.lines().iter().map(|l| l.to_string()).collect();
if let Some((row, col)) = byte_to_row_char_col(&after, action.new_cursor_byte) {
ta.move_cursor(CursorMove::Jump(row as u16, col as u16));
}
}
pub fn cursor_screen_pos(
rendered_col: usize,
cursor_vrow: usize,
visual_scroll_offset: usize,
rect: Rect,
) -> Option<(u16, u16)> {
if cursor_vrow < visual_scroll_offset {
return None;
}
let vrow_in_view = cursor_vrow - visual_scroll_offset;
if vrow_in_view as u16 >= rect.height {
return None;
}
Some((rect.x + rendered_col as u16, rect.y + vrow_in_view as u16))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn byte_to_row_col_single_line() {
let lines = vec!["hello".to_string()];
assert_eq!(byte_to_row_char_col(&lines, 0), Some((0, 0)));
assert_eq!(byte_to_row_char_col(&lines, 3), Some((0, 3)));
assert_eq!(byte_to_row_char_col(&lines, 5), Some((0, 5)));
assert_eq!(byte_to_row_char_col(&lines, 6), None);
}
#[test]
fn byte_to_row_col_across_newlines() {
let lines = vec!["hi".to_string(), "world".to_string()];
assert_eq!(byte_to_row_char_col(&lines, 2), Some((0, 2))); assert_eq!(byte_to_row_char_col(&lines, 3), Some((1, 0))); assert_eq!(byte_to_row_char_col(&lines, 7), Some((1, 4)));
assert_eq!(byte_to_row_char_col(&lines, 8), Some((1, 5))); }
#[test]
fn byte_to_row_col_multi_byte_chars() {
let lines = vec!["héllo".to_string()];
assert_eq!(byte_to_row_char_col(&lines, 0), Some((0, 0)));
assert_eq!(byte_to_row_char_col(&lines, 1), Some((0, 1))); assert_eq!(byte_to_row_char_col(&lines, 2), None); assert_eq!(byte_to_row_char_col(&lines, 3), Some((0, 2))); assert_eq!(byte_to_row_char_col(&lines, 4), Some((0, 3))); }
#[test]
fn row_col_to_byte_round_trips() {
let lines = vec!["hi".to_string(), "héllo".to_string()];
for (row, col, expected_byte) in [(0, 0, 0), (0, 2, 2), (1, 0, 3), (1, 2, 6)] {
assert_eq!(row_char_col_to_byte(&lines, row, col), expected_byte);
assert_eq!(
byte_to_row_char_col(&lines, expected_byte),
Some((row, col))
);
}
}
#[test]
fn row_col_to_byte_clamps_to_line_end() {
let lines = vec!["hi".to_string()];
assert_eq!(row_char_col_to_byte(&lines, 0, 999), 2);
}
#[test]
fn cursor_screen_pos_scrolled_off_top() {
let rect = Rect::new(2, 5, 80, 24);
assert!(cursor_screen_pos(0, 0, 5, rect).is_none());
}
#[test]
fn cursor_screen_pos_scrolled_off_bottom() {
let rect = Rect::new(0, 0, 80, 10);
assert!(cursor_screen_pos(0, 100, 0, rect).is_none());
}
#[test]
fn cursor_screen_pos_in_view() {
let rect = Rect::new(2, 5, 80, 24);
assert_eq!(cursor_screen_pos(7, 12, 10, rect), Some((9, 7)));
}
#[test]
fn apply_accept_replaces_and_positions_cursor() {
let mut ta = TextArea::from(vec!["see [[me".to_string()]);
ta.move_cursor(CursorMove::End);
let action = AcceptAction {
range: 6..8,
new_text: "meeting]]".to_string(),
new_cursor_byte: 15,
};
apply_accept_to_textarea(&mut ta, &action);
let result: String = ta.lines().join("\n");
assert_eq!(result, "see [[meeting]]");
let ratatui_textarea::DataCursor(row, col) = ta.cursor();
assert_eq!((row, col), (0, 15));
}
#[test]
fn apply_accept_preserves_textarea_yank_buffer() {
let mut ta = TextArea::from(vec!["see [[me".to_string()]);
ta.set_yank_text("previously yanked text");
ta.move_cursor(CursorMove::End);
let action = AcceptAction {
range: 6..8,
new_text: "meeting]]".to_string(),
new_cursor_byte: 15,
};
apply_accept_to_textarea(&mut ta, &action);
assert_eq!(ta.yank_text(), "previously yanked text");
}
#[test]
fn apply_accept_replaces_across_multiple_lines_unaffected() {
let mut ta = TextArea::from(vec!["alpha".to_string(), "see [[me".to_string()]);
let action = AcceptAction {
range: 12..14, new_text: "meeting]]".to_string(),
new_cursor_byte: 21,
};
apply_accept_to_textarea(&mut ta, &action);
let result: String = ta.lines().join("\n");
assert_eq!(result, "alpha\nsee [[meeting]]");
}
}