use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Widget},
};
use super::theme;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TextAreaAction {
None,
OpenLink(String),
NextField,
PrevField,
}
#[derive(Debug, Clone)]
pub struct DescriptionTextArea {
lines: Vec<String>,
cursor_row: usize,
cursor_col: usize,
links: Vec<LinkSpan>,
scroll_offset: usize,
}
#[derive(Debug, Clone)]
struct LinkSpan {
url: String,
line: usize,
start: usize,
end: usize,
}
impl DescriptionTextArea {
pub fn new() -> Self {
Self {
lines: vec![String::new()],
cursor_row: 0,
cursor_col: 0,
links: Vec::new(),
scroll_offset: 0,
}
}
pub fn with_text(text: &str) -> Self {
let mut ta = Self::new();
ta.set_text(text);
ta
}
pub fn set_text(&mut self, text: &str) {
self.lines = if text.is_empty() {
vec![String::new()]
} else {
text.lines().map(|s| s.to_string()).collect()
};
if self.lines.is_empty() {
self.lines.push(String::new());
}
self.cursor_row = 0;
self.cursor_col = 0;
self.scroll_offset = 0;
self.update_links();
}
pub fn text(&self) -> String {
self.lines.join("\n")
}
pub fn is_empty(&self) -> bool {
self.lines.len() == 1 && self.lines[0].is_empty()
}
fn update_links(&mut self) {
self.links.clear();
let lines_copy: Vec<String> = self.lines.clone();
for (line_idx, line) in lines_copy.iter().enumerate() {
self.detect_links_in_line(line, line_idx);
}
}
fn detect_links_in_line(&mut self, line: &str, line_idx: usize) {
let mut start = 0;
while start < line.len() {
let search_str = &line[start..];
let https_pos = search_str.find("https://");
let http_pos = search_str.find("http://");
let url_pos = match (https_pos, http_pos) {
(Some(https), Some(http)) => Some(https.min(http)),
(Some(pos), None) | (None, Some(pos)) => Some(pos),
(None, None) => None,
};
let Some(rel_pos) = url_pos else {
break;
};
let abs_start = start + rel_pos;
let url_start = abs_start;
let rest = &line[url_start..];
let url_end = rest
.find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '>' || c == '"' || c == '\'')
.map(|pos| url_start + pos)
.unwrap_or(line.len());
if url_end > url_start {
let url = line[url_start..url_end].to_string();
self.links.push(LinkSpan {
url,
line: line_idx,
start: url_start,
end: url_end,
});
}
start = url_end;
}
}
pub fn link_at_cursor(&self) -> Option<&str> {
for link in &self.links {
if link.line == self.cursor_row && self.cursor_col >= link.start && self.cursor_col < link.end {
return Some(&link.url);
}
}
None
}
pub fn links(&self) -> Vec<&str> {
self.links.iter().map(|l| l.url.as_str()).collect()
}
fn insert_char(&mut self, c: char) {
if self.cursor_row < self.lines.len() {
let line = &mut self.lines[self.cursor_row];
if self.cursor_col <= line.len() {
line.insert(self.cursor_col, c);
self.cursor_col += 1;
}
}
self.update_links();
}
fn insert_newline(&mut self) {
if self.cursor_row < self.lines.len() {
let current_line = &self.lines[self.cursor_row];
let rest = current_line[self.cursor_col..].to_string();
self.lines[self.cursor_row] = current_line[..self.cursor_col].to_string();
self.cursor_row += 1;
self.lines.insert(self.cursor_row, rest);
self.cursor_col = 0;
}
self.update_links();
}
fn delete_backward(&mut self) {
if self.cursor_col > 0 {
let line = &mut self.lines[self.cursor_row];
line.remove(self.cursor_col - 1);
self.cursor_col -= 1;
} else if self.cursor_row > 0 {
let current_line = self.lines.remove(self.cursor_row);
self.cursor_row -= 1;
self.cursor_col = self.lines[self.cursor_row].len();
self.lines[self.cursor_row].push_str(¤t_line);
}
self.update_links();
}
fn delete_forward(&mut self) {
if self.cursor_row < self.lines.len() {
let line = &self.lines[self.cursor_row];
if self.cursor_col < line.len() {
self.lines[self.cursor_row].remove(self.cursor_col);
} else if self.cursor_row < self.lines.len() - 1 {
let next_line = self.lines.remove(self.cursor_row + 1);
self.lines[self.cursor_row].push_str(&next_line);
}
}
self.update_links();
}
fn move_left(&mut self) {
if self.cursor_col > 0 {
self.cursor_col -= 1;
} else if self.cursor_row > 0 {
self.cursor_row -= 1;
self.cursor_col = self.lines[self.cursor_row].len();
}
}
fn move_right(&mut self) {
if self.cursor_row < self.lines.len() {
if self.cursor_col < self.lines[self.cursor_row].len() {
self.cursor_col += 1;
} else if self.cursor_row < self.lines.len() - 1 {
self.cursor_row += 1;
self.cursor_col = 0;
}
}
}
fn move_up(&mut self) {
if self.cursor_row > 0 {
self.cursor_row -= 1;
self.cursor_col = self.cursor_col.min(self.lines[self.cursor_row].len());
}
}
fn move_down(&mut self) {
if self.cursor_row < self.lines.len() - 1 {
self.cursor_row += 1;
self.cursor_col = self.cursor_col.min(self.lines[self.cursor_row].len());
}
}
fn move_home(&mut self) {
self.cursor_col = 0;
}
fn move_end(&mut self) {
if self.cursor_row < self.lines.len() {
self.cursor_col = self.lines[self.cursor_row].len();
}
}
fn move_word_left(&mut self) {
if self.cursor_row >= self.lines.len() {
return;
}
let line = &self.lines[self.cursor_row];
let chars: Vec<char> = line.chars().collect();
if self.cursor_col == 0 {
if self.cursor_row > 0 {
self.cursor_row -= 1;
self.cursor_col = self.lines[self.cursor_row].len();
}
return;
}
let mut pos = self.cursor_col;
while pos > 0 && chars[pos - 1].is_whitespace() {
pos -= 1;
}
while pos > 0 && !chars[pos - 1].is_whitespace() {
pos -= 1;
}
self.cursor_col = pos;
}
fn move_word_right(&mut self) {
if self.cursor_row >= self.lines.len() {
return;
}
let line = &self.lines[self.cursor_row];
let chars: Vec<char> = line.chars().collect();
let len = chars.len();
if self.cursor_col >= len {
if self.cursor_row < self.lines.len() - 1 {
self.cursor_row += 1;
self.cursor_col = 0;
}
return;
}
let mut pos = self.cursor_col;
while pos < len && !chars[pos].is_whitespace() {
pos += 1;
}
while pos < len && chars[pos].is_whitespace() {
pos += 1;
}
self.cursor_col = pos;
}
fn delete_word_backward(&mut self) {
if self.cursor_row >= self.lines.len() {
return;
}
if self.cursor_col == 0 {
if self.cursor_row > 0 {
let current_line = self.lines.remove(self.cursor_row);
self.cursor_row -= 1;
self.cursor_col = self.lines[self.cursor_row].len();
self.lines[self.cursor_row].push_str(¤t_line);
}
self.update_links();
return;
}
let line = &self.lines[self.cursor_row];
let chars: Vec<char> = line.chars().collect();
let mut pos = self.cursor_col;
while pos > 0 && chars[pos - 1].is_whitespace() {
pos -= 1;
}
while pos > 0 && !chars[pos - 1].is_whitespace() {
pos -= 1;
}
let new_line: String = chars[..pos].iter().chain(chars[self.cursor_col..].iter()).collect();
self.lines[self.cursor_row] = new_line;
self.cursor_col = pos;
self.update_links();
}
pub fn handle_key(&mut self, key: KeyEvent) -> TextAreaAction {
match key.code {
KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(url) = self.link_at_cursor() {
return TextAreaAction::OpenLink(url.to_string());
}
TextAreaAction::None
}
KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::ALT) => {
self.move_word_left();
TextAreaAction::None
}
KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::ALT) => {
self.move_word_right();
TextAreaAction::None
}
KeyCode::Tab if !key.modifiers.contains(KeyModifiers::SHIFT) => {
TextAreaAction::NextField
}
KeyCode::BackTab => TextAreaAction::PrevField,
KeyCode::Enter => {
self.insert_newline();
TextAreaAction::None
}
KeyCode::Char(c) => {
self.insert_char(c);
TextAreaAction::None
}
KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => {
self.delete_word_backward();
TextAreaAction::None
}
KeyCode::Backspace => {
self.delete_backward();
TextAreaAction::None
}
KeyCode::Delete => {
self.delete_forward();
TextAreaAction::None
}
KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => {
self.move_word_left();
TextAreaAction::None
}
KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => {
self.move_word_right();
TextAreaAction::None
}
KeyCode::Left => {
self.move_left();
TextAreaAction::None
}
KeyCode::Right => {
self.move_right();
TextAreaAction::None
}
KeyCode::Up => {
self.move_up();
TextAreaAction::None
}
KeyCode::Down => {
self.move_down();
TextAreaAction::None
}
KeyCode::Home => {
self.move_home();
TextAreaAction::None
}
KeyCode::End => {
self.move_end();
TextAreaAction::None
}
_ => TextAreaAction::None,
}
}
pub fn render(&self, area: Rect, buf: &mut Buffer, focused: bool, label: Option<&str>) {
let border_style = if focused {
Style::default().fg(theme::PRIMARY_LIGHT)
} else {
Style::default().fg(theme::BORDER)
};
let label_style = if focused {
Style::default()
.fg(theme::PRIMARY_LIGHT)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme::TEXT_MUTED)
};
let title = label
.map(|l| ratatui::text::Span::styled(format!(" {} ", l), label_style))
.unwrap_or_default();
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(border_style);
let inner = block.inner(area);
block.render(area, buf);
self.render_content(inner, buf, focused);
}
fn render_content(&self, area: Rect, buf: &mut Buffer, focused: bool) {
if self.is_empty() {
let placeholder = "Ctrl+O to open link under cursor";
for (i, ch) in placeholder.chars().enumerate() {
let x = area.x + i as u16;
if x >= area.x + area.width {
break;
}
buf[(x, area.y)]
.set_char(ch)
.set_style(Style::default().fg(theme::TEXT_MUTED));
}
if focused {
buf[(area.x, area.y)]
.set_char(' ')
.set_style(Style::default().bg(theme::ACCENT).fg(Color::Black));
}
return;
}
let visible_height = area.height as usize;
let scroll = if self.cursor_row < self.scroll_offset {
self.cursor_row
} else if self.cursor_row >= self.scroll_offset + visible_height {
self.cursor_row - visible_height + 1
} else {
self.scroll_offset
};
for (display_row, line_idx) in (scroll..self.lines.len()).enumerate() {
if display_row >= visible_height {
break;
}
let y = area.y + display_row as u16;
let line = &self.lines[line_idx];
let line_links: Vec<_> = self.links.iter().filter(|l| l.line == line_idx).collect();
let mut x = area.x;
let max_x = area.x + area.width;
for (col, ch) in line.chars().enumerate() {
if x >= max_x {
break;
}
let mut style = Style::default().fg(theme::TEXT_PRIMARY);
for link in &line_links {
if col >= link.start && col < link.end {
style = Style::default()
.fg(theme::INFO)
.add_modifier(Modifier::UNDERLINED);
break;
}
}
if focused && line_idx == self.cursor_row && col == self.cursor_col {
style = Style::default().bg(theme::ACCENT).fg(Color::Black);
}
buf[(x, y)].set_char(ch).set_style(style);
x += 1;
}
if focused && line_idx == self.cursor_row && self.cursor_col >= line.len() && x < max_x {
buf[(x, y)]
.set_char(' ')
.set_style(Style::default().bg(theme::ACCENT).fg(Color::Black));
}
}
}
}
impl Default for DescriptionTextArea {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_textarea() {
let ta = DescriptionTextArea::new();
assert!(ta.is_empty());
assert!(ta.links().is_empty());
}
#[test]
fn test_with_text() {
let ta = DescriptionTextArea::with_text("Hello world");
assert_eq!(ta.text(), "Hello world");
assert!(!ta.is_empty());
}
#[test]
fn test_detect_links() {
let ta = DescriptionTextArea::with_text("Check https://example.com for info");
let links = ta.links();
assert_eq!(links.len(), 1);
assert_eq!(links[0], "https://example.com");
}
#[test]
fn test_detect_multiple_links() {
let ta = DescriptionTextArea::with_text(
"Visit https://one.com and http://two.com for more",
);
let links = ta.links();
assert_eq!(links.len(), 2);
assert!(links.contains(&"https://one.com"));
assert!(links.contains(&"http://two.com"));
}
#[test]
fn test_multiline_links() {
let ta = DescriptionTextArea::with_text(
"First line https://first.com\nSecond line https://second.com",
);
let links = ta.links();
assert_eq!(links.len(), 2);
}
#[test]
fn test_no_links() {
let ta = DescriptionTextArea::with_text("Just plain text without any links");
assert!(ta.links().is_empty());
}
#[test]
fn test_set_text() {
let mut ta = DescriptionTextArea::new();
ta.set_text("New content with https://link.com");
assert_eq!(ta.text(), "New content with https://link.com");
assert_eq!(ta.links().len(), 1);
}
#[test]
fn test_tab_moves_to_next_field() {
let mut ta = DescriptionTextArea::new();
let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
assert_eq!(ta.handle_key(key), TextAreaAction::NextField);
}
#[test]
fn test_backtab_moves_to_prev_field() {
let mut ta = DescriptionTextArea::new();
let key = KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT);
assert_eq!(ta.handle_key(key), TextAreaAction::PrevField);
}
#[test]
fn test_insert_char() {
let mut ta = DescriptionTextArea::new();
let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
ta.handle_key(key);
assert_eq!(ta.text(), "a");
}
#[test]
fn test_insert_newline() {
let mut ta = DescriptionTextArea::with_text("Hello");
ta.cursor_col = 5; let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
ta.handle_key(key);
assert_eq!(ta.text(), "Hello\n");
}
#[test]
fn test_backspace() {
let mut ta = DescriptionTextArea::with_text("Hello");
ta.cursor_col = 5;
let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
ta.handle_key(key);
assert_eq!(ta.text(), "Hell");
}
#[test]
fn test_move_word_left() {
let mut ta = DescriptionTextArea::with_text("Hello world test");
ta.cursor_col = 16;
let key = KeyEvent::new(KeyCode::Left, KeyModifiers::ALT);
ta.handle_key(key);
assert_eq!(ta.cursor_col, 12);
ta.handle_key(key);
assert_eq!(ta.cursor_col, 6);
ta.handle_key(key);
assert_eq!(ta.cursor_col, 0); }
#[test]
fn test_move_word_right() {
let mut ta = DescriptionTextArea::with_text("Hello world test");
ta.cursor_col = 0;
let key = KeyEvent::new(KeyCode::Right, KeyModifiers::ALT);
ta.handle_key(key);
assert_eq!(ta.cursor_col, 6);
ta.handle_key(key);
assert_eq!(ta.cursor_col, 12);
ta.handle_key(key);
assert_eq!(ta.cursor_col, 16); }
#[test]
fn test_delete_word_backward() {
let mut ta = DescriptionTextArea::with_text("Hello world test");
ta.cursor_col = 16;
let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT);
ta.handle_key(key);
assert_eq!(ta.text(), "Hello world ");
assert_eq!(ta.cursor_col, 12);
ta.handle_key(key);
assert_eq!(ta.text(), "Hello ");
assert_eq!(ta.cursor_col, 6);
ta.handle_key(key);
assert_eq!(ta.text(), "");
assert_eq!(ta.cursor_col, 0);
}
}