//! Script editor widget with completion, search, and sample finder popups. use std::cell::Cell; use std::sync::Arc; use crate::theme; use ratatui::{ layout::Rect, style::{Modifier, Style}, text::{Line, Span}, widgets::{Clear, Paragraph}, Frame, }; use tui_textarea::TextArea; /// Callback that syntax-highlights a single line, returning styled spans (bool = annotation). pub type Highlighter<'a> = &'a dyn Fn(usize, &str) -> Vec<(Style, String, bool)>; /// Metadata for a single autocomplete entry. #[derive(Clone)] pub struct CompletionCandidate { pub name: String, pub signature: String, pub description: String, pub example: String, } struct CompletionState { candidates: Arc<[CompletionCandidate]>, matches: Vec, cursor: usize, prefix: String, prefix_start_col: usize, active: bool, enabled: bool, } impl CompletionState { fn new() -> Self { Self { candidates: Arc::from([]), matches: Vec::new(), cursor: 0, prefix: String::new(), prefix_start_col: 0, active: false, enabled: true, } } } struct SampleFinderState { query: String, folders: Vec, matches: Vec, cursor: usize, active: bool, } impl SampleFinderState { fn new() -> Self { Self { query: String::new(), folders: Vec::new(), matches: Vec::new(), cursor: 0, active: false, } } } struct SearchState { query: String, active: bool, } impl SearchState { fn new() -> Self { Self { query: String::new(), active: false, } } } /// Multi-line text editor backed by tui_textarea. pub struct Editor { text: TextArea<'static>, completion: CompletionState, sample_finder: SampleFinderState, search: SearchState, scroll_offset: Cell, } impl Editor { pub fn start_selection(&mut self) { self.text.start_selection(); } pub fn cancel_selection(&mut self) { self.text.cancel_selection(); } pub fn is_selecting(&self) -> bool { self.text.is_selecting() } pub fn move_cursor_to(&mut self, row: u16, col: u16) { self.text.move_cursor(tui_textarea::CursorMove::Jump(row, col)); } pub fn scroll_offset(&self) -> u16 { self.scroll_offset.get() } pub fn copy(&mut self) { self.text.copy(); } pub fn cut(&mut self) -> bool { self.text.cut() } pub fn paste(&mut self) -> bool { self.text.paste() } pub fn yank_text(&self) -> String { self.text.yank_text() } pub fn set_yank_text(&mut self, text: impl Into) { self.text.set_yank_text(text); } pub fn select_all(&mut self) { self.text.select_all(); } pub fn selection_range(&self) -> Option<((usize, usize), (usize, usize))> { self.text.selection_range() } } impl Default for Editor { fn default() -> Self { Self::new() } } impl Editor { pub fn new() -> Self { Self { text: TextArea::default(), completion: CompletionState::new(), sample_finder: SampleFinderState::new(), search: SearchState::new(), scroll_offset: Cell::new(0), } } pub fn set_content(&mut self, lines: Vec) { let yank = self.text.yank_text(); self.text = TextArea::new(lines); if !yank.is_empty() { self.text.set_yank_text(yank); } self.completion.active = false; self.sample_finder.active = false; self.search.query.clear(); self.search.active = false; self.scroll_offset.set(0); } pub fn set_candidates(&mut self, candidates: Arc<[CompletionCandidate]>) { self.completion.candidates = candidates; } pub fn insert_str(&mut self, s: &str) { self.text.insert_str(s); } pub fn content(&self) -> String { self.text.lines().join("\n") } pub fn lines(&self) -> &[String] { self.text.lines() } pub fn cursor(&self) -> (usize, usize) { self.text.cursor() } pub fn completion_active(&self) -> bool { self.completion.active } pub fn dismiss_completion(&mut self) { self.completion.active = false; } pub fn completion_next(&mut self) { if self.completion.cursor + 1 < self.completion.matches.len() { self.completion.cursor += 1; } } pub fn completion_prev(&mut self) { if self.completion.cursor > 0 { self.completion.cursor -= 1; } } pub fn set_completion_enabled(&mut self, enabled: bool) { self.completion.enabled = enabled; if !enabled { self.completion.active = false; } } pub fn activate_search(&mut self) { self.search.active = true; self.completion.active = false; } pub fn search_active(&self) -> bool { self.search.active } pub fn search_query(&self) -> &str { &self.search.query } pub fn search_input(&mut self, c: char) { self.search.query.push(c); self.apply_search_pattern(); } pub fn search_backspace(&mut self) { self.search.query.pop(); self.apply_search_pattern(); } pub fn search_confirm(&mut self) { self.search.active = false; } pub fn search_clear(&mut self) { self.search.query.clear(); self.search.active = false; let _ = self.text.set_search_pattern(""); } pub fn search_next(&mut self) -> bool { if self.search.query.is_empty() { return false; } self.text.search_forward(false) } pub fn search_prev(&mut self) -> bool { if self.search.query.is_empty() { return false; } self.text.search_back(false) } fn apply_search_pattern(&mut self) { if self.search.query.is_empty() { let _ = self.text.set_search_pattern(""); } else { let pattern = format!("(?i){}", regex::escape(&self.search.query)); let _ = self.text.set_search_pattern(&pattern); } } pub fn set_sample_folders(&mut self, folders: Vec) { self.sample_finder.folders = folders; } pub fn activate_sample_finder(&mut self) { self.completion.active = false; self.sample_finder.query.clear(); self.sample_finder.cursor = 0; self.sample_finder.matches = (0..self.sample_finder.folders.len()).collect(); self.sample_finder.active = true; } pub fn dismiss_sample_finder(&mut self) { self.sample_finder.active = false; } pub fn sample_finder_active(&self) -> bool { self.sample_finder.active } pub fn sample_finder_input(&mut self, c: char) { self.sample_finder.query.push(c); self.update_sample_finder_matches(); } pub fn sample_finder_backspace(&mut self) { self.sample_finder.query.pop(); self.update_sample_finder_matches(); } pub fn sample_finder_next(&mut self) { if self.sample_finder.cursor + 1 < self.sample_finder.matches.len() { self.sample_finder.cursor += 1; } } pub fn sample_finder_prev(&mut self) { if self.sample_finder.cursor > 0 { self.sample_finder.cursor -= 1; } } pub fn accept_sample_finder(&mut self) { if self.sample_finder.matches.is_empty() { self.sample_finder.active = false; return; } let idx = self.sample_finder.matches[self.sample_finder.cursor]; let name = self.sample_finder.folders[idx].clone(); self.text.insert_str(&name); self.sample_finder.active = false; } fn update_sample_finder_matches(&mut self) { if self.sample_finder.query.is_empty() { self.sample_finder.matches = (0..self.sample_finder.folders.len()).collect(); } else { let mut scored: Vec<(usize, usize)> = self .sample_finder .folders .iter() .enumerate() .filter_map(|(i, name)| fuzzy_match(&self.sample_finder.query, name).map(|s| (s, i))) .collect(); scored.sort_by_key(|(score, _)| *score); self.sample_finder.matches = scored.into_iter().map(|(_, i)| i).collect(); } self.sample_finder.cursor = self .sample_finder .cursor .min(self.sample_finder.matches.len().saturating_sub(1)); } pub fn input(&mut self, input: impl Into) { let input: tui_textarea::Input = input.into(); let has_modifier = input.ctrl || input.alt; if self.completion.active && !has_modifier { match &input { tui_textarea::Input { key: tui_textarea::Key::Tab, .. } => { self.accept_completion(); return; } tui_textarea::Input { key: tui_textarea::Key::Esc, .. } => { self.completion.active = false; return; } tui_textarea::Input { key: tui_textarea::Key::Char(c), .. } => { if !is_word_char(*c) { self.completion.active = false; } self.text.input(input); self.update_completion(); return; } _ => { self.completion.active = false; } } } self.text.input(input); if !has_modifier { self.update_completion(); } } fn update_completion(&mut self) { if !self.completion.enabled || self.completion.candidates.is_empty() || self.sample_finder.active { return; } let (row, col) = self.text.cursor(); let line = &self.text.lines()[row]; // col is a character index; convert to byte offset for slicing let byte_col = line.char_indices() .nth(col) .map(|(i, _)| i) .unwrap_or(line.len()); let prefix_start = line[..byte_col] .char_indices() .rev() .take_while(|(_, c)| is_word_char(*c)) .last() .map(|(i, _)| i) .unwrap_or(byte_col); let prefix = &line[prefix_start..byte_col]; if prefix.len() < 2 { self.completion.active = false; return; } let prefix_lower = prefix.to_lowercase(); let matches: Vec = self .completion .candidates .iter() .enumerate() .filter(|(_, c)| c.name.to_lowercase().starts_with(&prefix_lower)) .map(|(i, _)| i) .collect(); if matches.is_empty() { self.completion.active = false; return; } if matches.len() == 1 && self.completion.candidates[matches[0]].name.to_lowercase() == prefix_lower { self.completion.active = false; return; } self.completion.prefix = prefix.to_string(); self.completion.prefix_start_col = prefix_start; self.completion.matches = matches; self.completion.cursor = self.completion.cursor.min( self.completion.matches.len().saturating_sub(1), ); self.completion.active = true; } fn accept_completion(&mut self) { if self.completion.matches.is_empty() { self.completion.active = false; return; } let idx = self.completion.matches[self.completion.cursor]; let name = self.completion.candidates[idx].name.clone(); let prefix_len = self.completion.prefix.len(); for _ in 0..prefix_len { self.text.delete_char(); } self.text.insert_str(&name); self.completion.active = false; } pub fn render(&self, frame: &mut Frame, area: Rect, highlighter: Highlighter) { let t = theme::get(); let (cursor_row, cursor_col) = self.text.cursor(); let cursor_style = Style::default().bg(t.editor_widget.cursor_bg).fg(t.editor_widget.cursor_fg); let selection_style = Style::default().bg(t.editor_widget.selection_bg); let selection = self.text.selection_range(); let lines: Vec = self .text .lines() .iter() .enumerate() .map(|(row, line)| { let tokens = highlighter(row, line); let mut spans: Vec = Vec::new(); let mut col = 0; for (base_style, text, is_annotation) in tokens { for ch in text.chars() { let style = if is_annotation { base_style } else { let is_cursor = row == cursor_row && col == cursor_col; let is_selected = is_in_selection(row, col, selection); if is_cursor { cursor_style } else if is_selected { base_style.bg(selection_style.bg.expect("selection style has bg")) } else { base_style } }; spans.push(Span::styled(ch.to_string(), style)); if !is_annotation { col += 1; } } } if row == cursor_row && cursor_col >= col { spans.push(Span::styled(" ", cursor_style)); } Line::from(spans) }) .collect(); let viewport_height = area.height as usize; let offset = self.scroll_offset.get() as usize; let offset = if cursor_row < offset { cursor_row } else if cursor_row >= offset + viewport_height { cursor_row - viewport_height + 1 } else { offset }; self.scroll_offset.set(offset as u16); frame.render_widget(Paragraph::new(lines).scroll((offset as u16, 0)), area); if self.sample_finder.active && !self.sample_finder.matches.is_empty() { self.render_sample_finder(frame, area, cursor_row - offset); } else if self.completion.active && !self.completion.matches.is_empty() { self.render_completion(frame, area, cursor_row - offset); } } fn render_completion(&self, frame: &mut Frame, editor_area: Rect, cursor_row: usize) { let t = theme::get(); let max_visible: usize = 6; let list_width: u16 = 18; let doc_width: u16 = 40; let total_width = list_width + doc_width; let visible_count = self.completion.matches.len().min(max_visible); let list_height = visible_count as u16; let doc_height = 4u16; let total_height = list_height.max(doc_height); let popup_x = (editor_area.x + self.completion.prefix_start_col as u16) .min(editor_area.x + editor_area.width.saturating_sub(total_width)); let below_y = editor_area.y + cursor_row as u16 + 1; let popup_y = if below_y + total_height > editor_area.y + editor_area.height { (editor_area.y + cursor_row as u16).saturating_sub(total_height) } else { below_y }; let scroll_offset = if self.completion.cursor >= max_visible { self.completion.cursor - max_visible + 1 } else { 0 }; // List panel let list_area = Rect::new(popup_x, popup_y, list_width, total_height); frame.render_widget(Clear, list_area); let highlight_style = Style::default().fg(t.editor_widget.completion_selected).add_modifier(Modifier::BOLD); let normal_style = Style::default().fg(t.editor_widget.completion_fg); let bg_style = Style::default().bg(t.editor_widget.completion_bg); let list_lines: Vec = (scroll_offset..scroll_offset + visible_count) .map(|i| { let idx = self.completion.matches[i]; let name = &self.completion.candidates[idx].name; let style = if i == self.completion.cursor { highlight_style } else { normal_style }; let prefix = if i == self.completion.cursor { "> " } else { " " }; let display = format!("{prefix}{name: = Vec::new(); let header = format!(" {} {}", candidate.name, candidate.signature); doc_lines.push(Line::from(Span::styled( format!("{header: editor_area.y + editor_area.height { (editor_area.y + cursor_row as u16).saturating_sub(total_height) } else { below_y }; let area = Rect::new(popup_x, popup_y, width, total_height); frame.render_widget(Clear, area); let bg_style = Style::default().bg(t.editor_widget.completion_bg); let highlight_style = Style::default() .fg(t.editor_widget.completion_selected) .add_modifier(Modifier::BOLD); let normal_style = Style::default().fg(t.editor_widget.completion_fg); let w = width as usize; let mut lines: Vec = Vec::new(); let query_display = format!("/{}", self.sample_finder.query); lines.push(Line::from(Span::styled( format!("{query_display:= max_visible { self.sample_finder.cursor - max_visible + 1 } else { 0 }; for i in scroll_offset..scroll_offset + visible_count { let idx = self.sample_finder.matches[i]; let name = &self.sample_finder.folders[idx]; let style = if i == self.sample_finder.cursor { highlight_style } else { normal_style }; let prefix = if i == self.sample_finder.cursor { "> " } else { " " }; let display = format!("{prefix}{name: Option { let target_lower: Vec = target.to_lowercase().chars().collect(); let query_lower: Vec = query.to_lowercase().chars().collect(); let mut ti = 0; let mut score = 0; let mut prev_pos = 0; for (qi, &qc) in query_lower.iter().enumerate() { loop { if ti >= target_lower.len() { return None; } if target_lower[ti] == qc { if qi > 0 { score += ti - prev_pos; } prev_pos = ti; ti += 1; break; } ti += 1; } } Some(score) } fn is_word_char(c: char) -> bool { c.is_alphanumeric() || matches!(c, '!' | '@' | '?' | '.' | ':' | '_' | '#') } fn is_in_selection(row: usize, col: usize, selection: Option<((usize, usize), (usize, usize))>) -> bool { let Some(((start_row, start_col), (end_row, end_col))) = selection else { return false; }; if row < start_row || row > end_row { return false; } if row == start_row && row == end_row { col >= start_col && col < end_col } else if row == start_row { col >= start_col } else if row == end_row { col < end_col } else { true } }