WIP: half broken
This commit is contained in:
@@ -5,3 +5,4 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
ratatui = "0.29"
|
||||
tui-textarea = "0.7"
|
||||
|
||||
392
crates/ratatui/src/editor.rs
Normal file
392
crates/ratatui/src/editor.rs
Normal file
@@ -0,0 +1,392 @@
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Editor {
|
||||
text: TextArea<'static>,
|
||||
completion: CompletionState,
|
||||
}
|
||||
|
||||
impl Default for Editor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
text: TextArea::default(),
|
||||
completion: CompletionState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_content(&mut self, lines: Vec<String>) {
|
||||
self.text = TextArea::new(lines);
|
||||
self.completion.active = false;
|
||||
}
|
||||
|
||||
pub fn set_candidates(&mut self, candidates: Vec<CompletionCandidate>) {
|
||||
self.completion.candidates = candidates;
|
||||
}
|
||||
|
||||
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 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(Color::White).fg(Color::Black);
|
||||
|
||||
let lines: Vec<Line> = self
|
||||
.text
|
||||
.lines()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(row, line)| {
|
||||
let tokens = highlighter(row, line);
|
||||
let mut spans: Vec<Span> = Vec::new();
|
||||
|
||||
if row == cursor_row {
|
||||
let mut col = 0;
|
||||
for (style, text) in tokens {
|
||||
let text_len = text.chars().count();
|
||||
if cursor_col >= col && cursor_col < col + text_len {
|
||||
let before = text.chars().take(cursor_col - col).collect::<String>();
|
||||
let cursor_char =
|
||||
text.chars().nth(cursor_col - col).unwrap_or(' ');
|
||||
let after =
|
||||
text.chars().skip(cursor_col - col + 1).collect::<String>();
|
||||
|
||||
if !before.is_empty() {
|
||||
spans.push(Span::styled(before, style));
|
||||
}
|
||||
spans.push(Span::styled(cursor_char.to_string(), cursor_style));
|
||||
if !after.is_empty() {
|
||||
spans.push(Span::styled(after, style));
|
||||
}
|
||||
} else {
|
||||
spans.push(Span::styled(text, style));
|
||||
}
|
||||
col += text_len;
|
||||
}
|
||||
if cursor_col >= col {
|
||||
spans.push(Span::styled(" ", cursor_style));
|
||||
}
|
||||
} else {
|
||||
for (style, text) in tokens {
|
||||
spans.push(Span::styled(text, 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(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
let normal_style = Style::default().fg(Color::White);
|
||||
let bg_style = Style::default().bg(Color::Rgb(30, 30, 40));
|
||||
|
||||
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(Color::Rgb(30, 30, 40))))
|
||||
})
|
||||
.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(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
.bg(Color::Rgb(30, 30, 40));
|
||||
let desc_style = Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(Color::Rgb(30, 30, 40));
|
||||
let example_style = Style::default()
|
||||
.fg(Color::Rgb(120, 200, 160))
|
||||
.bg(Color::Rgb(30, 30, 40));
|
||||
|
||||
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, '!' | '@' | '?' | '.' | ':' | '_' | '#')
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
mod confirm;
|
||||
mod editor;
|
||||
mod file_browser;
|
||||
mod list_select;
|
||||
mod modal;
|
||||
mod sample_browser;
|
||||
mod scope;
|
||||
mod spectrum;
|
||||
mod text_input;
|
||||
mod vu_meter;
|
||||
|
||||
pub use confirm::ConfirmModal;
|
||||
pub use editor::{CompletionCandidate, Editor};
|
||||
pub use file_browser::FileBrowserModal;
|
||||
pub use list_select::ListSelect;
|
||||
pub use modal::ModalFrame;
|
||||
pub use sample_browser::{SampleBrowser, TreeLine, TreeLineKind};
|
||||
pub use scope::{Orientation, Scope};
|
||||
pub use spectrum::Spectrum;
|
||||
pub use text_input::TextInputModal;
|
||||
|
||||
170
crates/ratatui/src/sample_browser.rs
Normal file
170
crates/ratatui/src/sample_browser.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum TreeLineKind {
|
||||
Root { expanded: bool },
|
||||
Folder { expanded: bool },
|
||||
File,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TreeLine {
|
||||
pub depth: u8,
|
||||
pub kind: TreeLineKind,
|
||||
pub label: String,
|
||||
pub folder: String,
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
pub struct SampleBrowser<'a> {
|
||||
entries: &'a [TreeLine],
|
||||
cursor: usize,
|
||||
scroll_offset: usize,
|
||||
search_query: &'a str,
|
||||
search_active: bool,
|
||||
focused: bool,
|
||||
}
|
||||
|
||||
impl<'a> SampleBrowser<'a> {
|
||||
pub fn new(entries: &'a [TreeLine], cursor: usize) -> Self {
|
||||
Self {
|
||||
entries,
|
||||
cursor,
|
||||
scroll_offset: 0,
|
||||
search_query: "",
|
||||
search_active: false,
|
||||
focused: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_offset(mut self, offset: usize) -> Self {
|
||||
self.scroll_offset = offset;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn search(mut self, query: &'a str, active: bool) -> Self {
|
||||
self.search_query = query;
|
||||
self.search_active = active;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn focused(mut self, focused: bool) -> Self {
|
||||
self.focused = focused;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn render(self, frame: &mut Frame, area: Rect) {
|
||||
let border_style = if self.focused {
|
||||
Style::new().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::new().fg(Color::DarkGray)
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style)
|
||||
.title(" Samples ");
|
||||
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
if inner.height == 0 || inner.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let show_search = self.search_active || !self.search_query.is_empty();
|
||||
let (search_area, list_area) = if show_search {
|
||||
let [s, l] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(inner);
|
||||
(Some(s), l)
|
||||
} else {
|
||||
(None, inner)
|
||||
};
|
||||
|
||||
if let Some(sa) = search_area {
|
||||
self.render_search(frame, sa);
|
||||
}
|
||||
self.render_tree(frame, list_area);
|
||||
}
|
||||
|
||||
fn render_search(&self, frame: &mut Frame, area: Rect) {
|
||||
let style = if self.search_active {
|
||||
Style::new().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::new().fg(Color::DarkGray)
|
||||
};
|
||||
let cursor = if self.search_active { "_" } else { "" };
|
||||
let text = format!("/{}{}", self.search_query, cursor);
|
||||
let line = Line::from(Span::styled(text, style));
|
||||
frame.render_widget(Paragraph::new(vec![line]), area);
|
||||
}
|
||||
|
||||
fn render_tree(&self, frame: &mut Frame, area: Rect) {
|
||||
let height = area.height as usize;
|
||||
if self.entries.is_empty() {
|
||||
let msg = if self.search_query.is_empty() {
|
||||
"No samples loaded"
|
||||
} else {
|
||||
"No matches"
|
||||
};
|
||||
let line = Line::from(Span::styled(msg, Style::new().fg(Color::DarkGray)));
|
||||
frame.render_widget(Paragraph::new(vec![line]), area);
|
||||
return;
|
||||
}
|
||||
|
||||
let visible_end = (self.scroll_offset + height).min(self.entries.len());
|
||||
let mut lines: Vec<Line> = Vec::with_capacity(height);
|
||||
|
||||
for i in self.scroll_offset..visible_end {
|
||||
let entry = &self.entries[i];
|
||||
let is_cursor = i == self.cursor;
|
||||
let indent = " ".repeat(entry.depth as usize);
|
||||
|
||||
let (icon, icon_color) = match entry.kind {
|
||||
TreeLineKind::Root { expanded: true } | TreeLineKind::Folder { expanded: true } => {
|
||||
("\u{25BC} ", Color::Cyan)
|
||||
}
|
||||
TreeLineKind::Root { expanded: false }
|
||||
| TreeLineKind::Folder { expanded: false } => ("\u{25B6} ", Color::Cyan),
|
||||
TreeLineKind::File => ("\u{266A} ", Color::DarkGray),
|
||||
};
|
||||
|
||||
let label_style = if is_cursor && self.focused {
|
||||
Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||
} else if is_cursor {
|
||||
Style::new().fg(Color::White)
|
||||
} else {
|
||||
match entry.kind {
|
||||
TreeLineKind::Root { .. } => {
|
||||
Style::new().fg(Color::White).add_modifier(Modifier::BOLD)
|
||||
}
|
||||
TreeLineKind::Folder { .. } => Style::new().fg(Color::Cyan),
|
||||
TreeLineKind::File => Style::default(),
|
||||
}
|
||||
};
|
||||
|
||||
let icon_style = if is_cursor && self.focused {
|
||||
label_style
|
||||
} else {
|
||||
Style::new().fg(icon_color)
|
||||
};
|
||||
|
||||
let spans = vec![
|
||||
Span::raw(indent),
|
||||
Span::styled(icon, icon_style),
|
||||
Span::styled(&entry.label, label_style),
|
||||
];
|
||||
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
frame.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user