520 lines
15 KiB
Rust
520 lines
15 KiB
Rust
use crate::theme::editor_widget;
|
|
use ratatui::{
|
|
layout::Rect,
|
|
style::{Modifier, Style},
|
|
text::{Line, Span},
|
|
widgets::{Clear, Paragraph},
|
|
Frame,
|
|
};
|
|
use tui_textarea::TextArea;
|
|
|
|
pub type Highlighter<'a> = &'a dyn Fn(usize, &str) -> Vec<(Style, String)>;
|
|
|
|
pub struct CompletionCandidate {
|
|
pub name: String,
|
|
pub signature: String,
|
|
pub description: String,
|
|
pub example: String,
|
|
}
|
|
|
|
struct CompletionState {
|
|
candidates: Vec<CompletionCandidate>,
|
|
matches: Vec<usize>,
|
|
cursor: usize,
|
|
prefix: String,
|
|
prefix_start_col: usize,
|
|
active: bool,
|
|
enabled: bool,
|
|
}
|
|
|
|
impl CompletionState {
|
|
fn new() -> Self {
|
|
Self {
|
|
candidates: Vec::new(),
|
|
matches: Vec::new(),
|
|
cursor: 0,
|
|
prefix: String::new(),
|
|
prefix_start_col: 0,
|
|
active: false,
|
|
enabled: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SearchState {
|
|
query: String,
|
|
active: bool,
|
|
}
|
|
|
|
impl SearchState {
|
|
fn new() -> Self {
|
|
Self {
|
|
query: String::new(),
|
|
active: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct Editor {
|
|
text: TextArea<'static>,
|
|
completion: CompletionState,
|
|
search: SearchState,
|
|
}
|
|
|
|
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 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 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(),
|
|
search: SearchState::new(),
|
|
}
|
|
}
|
|
|
|
pub fn set_content(&mut self, lines: Vec<String>) {
|
|
self.text = TextArea::new(lines);
|
|
self.completion.active = false;
|
|
self.search.query.clear();
|
|
self.search.active = false;
|
|
}
|
|
|
|
pub fn set_candidates(&mut self, candidates: Vec<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 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 input(&mut self, input: impl Into<tui_textarea::Input>) {
|
|
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::Up, .. } => {
|
|
if self.completion.cursor > 0 {
|
|
self.completion.cursor -= 1;
|
|
}
|
|
return;
|
|
}
|
|
tui_textarea::Input { key: tui_textarea::Key::Down, .. } => {
|
|
if self.completion.cursor + 1 < self.completion.matches.len() {
|
|
self.completion.cursor += 1;
|
|
}
|
|
return;
|
|
}
|
|
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() {
|
|
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<usize> = 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 (cursor_row, cursor_col) = self.text.cursor();
|
|
let cursor_style = Style::default().bg(editor_widget::CURSOR_BG).fg(editor_widget::CURSOR_FG);
|
|
let selection_style = Style::default().bg(editor_widget::SELECTION_BG);
|
|
|
|
let selection = self.text.selection_range();
|
|
|
|
let lines: Vec<Line> = self
|
|
.text
|
|
.lines()
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(row, line)| {
|
|
let tokens = highlighter(row, line);
|
|
let mut spans: Vec<Span> = Vec::new();
|
|
let mut col = 0;
|
|
|
|
for (base_style, text) in tokens {
|
|
for ch in text.chars() {
|
|
let is_cursor = row == cursor_row && col == cursor_col;
|
|
let is_selected = is_in_selection(row, col, selection);
|
|
|
|
let style = if is_cursor {
|
|
cursor_style
|
|
} else if is_selected {
|
|
base_style.bg(selection_style.bg.unwrap())
|
|
} else {
|
|
base_style
|
|
};
|
|
|
|
spans.push(Span::styled(ch.to_string(), style));
|
|
col += 1;
|
|
}
|
|
}
|
|
|
|
if row == cursor_row && cursor_col >= col {
|
|
spans.push(Span::styled(" ", cursor_style));
|
|
}
|
|
|
|
Line::from(spans)
|
|
})
|
|
.collect();
|
|
|
|
frame.render_widget(Paragraph::new(lines), area);
|
|
|
|
if self.completion.active && !self.completion.matches.is_empty() {
|
|
self.render_completion(frame, area, cursor_row);
|
|
}
|
|
}
|
|
|
|
fn render_completion(&self, frame: &mut Frame, editor_area: Rect, cursor_row: usize) {
|
|
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(editor_widget::COMPLETION_SELECTED).add_modifier(Modifier::BOLD);
|
|
let normal_style = Style::default().fg(editor_widget::COMPLETION_FG);
|
|
let bg_style = Style::default().bg(editor_widget::COMPLETION_BG);
|
|
|
|
let list_lines: Vec<Line> = (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:<width$}", width = list_width as usize - 2);
|
|
Line::from(Span::styled(display, style.bg(editor_widget::COMPLETION_BG)))
|
|
})
|
|
.collect();
|
|
|
|
// Fill remaining height with empty bg lines
|
|
let mut all_list_lines = list_lines;
|
|
for _ in visible_count as u16..total_height {
|
|
all_list_lines.push(Line::from(Span::styled(
|
|
" ".repeat(list_width as usize),
|
|
bg_style,
|
|
)));
|
|
}
|
|
|
|
frame.render_widget(Paragraph::new(all_list_lines), list_area);
|
|
|
|
// Doc panel
|
|
let doc_area = Rect::new(popup_x + list_width, popup_y, doc_width, total_height);
|
|
frame.render_widget(Clear, doc_area);
|
|
|
|
let selected_idx = self.completion.matches[self.completion.cursor];
|
|
let candidate = &self.completion.candidates[selected_idx];
|
|
|
|
let name_style = Style::default()
|
|
.fg(editor_widget::COMPLETION_SELECTED)
|
|
.add_modifier(Modifier::BOLD)
|
|
.bg(editor_widget::COMPLETION_BG);
|
|
let desc_style = Style::default()
|
|
.fg(editor_widget::COMPLETION_FG)
|
|
.bg(editor_widget::COMPLETION_BG);
|
|
let example_style = Style::default()
|
|
.fg(editor_widget::COMPLETION_EXAMPLE)
|
|
.bg(editor_widget::COMPLETION_BG);
|
|
|
|
let w = doc_width as usize;
|
|
let mut doc_lines: Vec<Line> = Vec::new();
|
|
|
|
let header = format!(" {} {}", candidate.name, candidate.signature);
|
|
doc_lines.push(Line::from(Span::styled(
|
|
format!("{header:<w$}"),
|
|
name_style,
|
|
)));
|
|
|
|
let desc = format!(" {}", candidate.description);
|
|
doc_lines.push(Line::from(Span::styled(
|
|
format!("{desc:<w$}"),
|
|
desc_style,
|
|
)));
|
|
|
|
doc_lines.push(Line::from(Span::styled(" ".repeat(w), bg_style)));
|
|
|
|
if !candidate.example.is_empty() {
|
|
let ex = format!(" {}", candidate.example);
|
|
doc_lines.push(Line::from(Span::styled(
|
|
format!("{ex:<w$}"),
|
|
example_style,
|
|
)));
|
|
}
|
|
|
|
for _ in doc_lines.len() as u16..total_height {
|
|
doc_lines.push(Line::from(Span::styled(" ".repeat(w), bg_style)));
|
|
}
|
|
|
|
frame.render_widget(Paragraph::new(doc_lines), doc_area);
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|