use crate::{
common::conversation::Inline,
ui::{
md_renderer::{InlineFragment, RenderConfig, fragments_to_lines, render_inline},
style::TextStyle,
},
};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
text::Span,
widgets::{Clear, StatefulWidget, Widget},
};
use std::cmp::{max, min};
const MINIMAL_COLUMN_WIDTH: usize = 20;
const MINIMAL_SPACE: usize = 1;
const NUM_ROWS: usize = 12;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Suggestion {
pub raw: String,
pub name: Vec<Inline>,
pub hint: Vec<Inline>,
}
#[derive(Debug, Default)]
pub struct SuggestionState {
filter: String,
candidates: Vec<Suggestion>,
selected: Option<usize>,
}
impl SuggestionState {
pub fn new(mut candidates: Vec<Suggestion>) -> Self {
candidates.sort();
SuggestionState {
filter: String::new(),
candidates,
selected: None,
}
}
pub fn filter(&mut self, text: &str) {
self.filter = text.to_string();
self.selected = None;
}
pub fn matches(&self) -> Vec<Suggestion> {
self.candidates
.iter()
.filter(|candidate| {
let name = inlines_as_string(&candidate.name);
name.contains(&self.filter)
})
.cloned()
.collect()
}
pub fn next_match(&mut self) -> Option<String> {
let matches = self.matches();
if matches.is_empty() {
return None;
}
if self.selected.is_none() {
self.selected = Some(matches.len() - 1);
}
if let Some(mut selected) = self.selected {
if selected >= matches.len() - 1 {
selected = 0;
} else {
selected += 1;
}
self.selected = Some(selected);
Some(matches[selected].raw.clone())
} else {
None
}
}
pub fn previous_match(&mut self) -> Option<String> {
let matches = self.matches();
if matches.is_empty() {
return None;
}
if self.selected.is_none() {
self.selected = Some(0);
}
if let Some(mut selected) = self.selected {
if selected == 0 {
selected = matches.len() - 1;
} else {
selected -= 1;
}
self.selected = Some(selected);
Some(matches[selected].raw.clone())
} else {
None
}
}
}
#[derive(Debug)]
pub struct SuggestionPopup {
style: TextStyle,
hl_style: TextStyle,
}
impl StatefulWidget for SuggestionPopup {
type State = SuggestionState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut <Self as StatefulWidget>::State) {
let mut config = RenderConfig::new().with_style(self.style);
let mut hl_config = RenderConfig::new().with_style(self.hl_style);
let suggestions = state.matches();
let max_name_width = suggestions
.iter()
.map(|suggestion| inlines_width(&suggestion.name, &config))
.fold(0, max)
+ MINIMAL_SPACE;
let max_hint_width = suggestions
.iter()
.map(|suggestion| inlines_width(&suggestion.hint, &config))
.fold(0, max)
+ 2;
let required_column_width = max(MINIMAL_COLUMN_WIDTH, max_name_width + max_hint_width);
let num_columns = max(1, area.width as usize / required_column_width);
let column_width = area.width as usize / num_columns;
config = RenderConfig {
width: column_width as u16,
..config
};
hl_config = RenderConfig {
width: column_width as u16,
..hl_config
};
let num_rows = min(NUM_ROWS, suggestions.len().div_ceil(num_columns));
let mut popup_area = area;
popup_area.y += area.y + area.height - num_rows as u16;
popup_area.height = num_rows as u16;
let column_constraints = vec![Constraint::Length(column_width as u16); num_columns];
let columns = Layout::horizontal(column_constraints).split(popup_area);
Clear.render(popup_area, buf);
suggestions
.iter()
.enumerate()
.take(num_columns * num_rows)
.for_each(|(pos, suggestion)| {
let current_config = if let Some(selected) = state.selected
&& pos == selected
{
&hl_config
} else {
&config
};
let mut fragments: Vec<InlineFragment> = suggestion
.name
.iter()
.flat_map(|inline| render_inline(inline, current_config))
.filter(|fragment| match *fragment {
InlineFragment::Text(_) => true,
InlineFragment::LineBreak => false, })
.collect();
if !suggestion.hint.is_empty() {
fragments.push(InlineFragment::Text(Span::styled(
" ".repeat(
max_name_width - inlines_width(&suggestion.name, current_config),
),
current_config.style.clone(),
)));
fragments.push(InlineFragment::Text(Span::styled(
"(",
current_config.style.clone(),
)));
fragments.extend(
suggestion
.hint
.iter()
.flat_map(|inline| render_inline(inline, current_config))
.filter(|fragment| match *fragment {
InlineFragment::Text(_) => true,
InlineFragment::LineBreak => false, }),
);
fragments.push(InlineFragment::Text(Span::styled(
")",
current_config.style.clone(),
)));
}
let lines = fragments_to_lines(&fragments);
let line = lines
.first()
.expect("Rendering a suggestion should result in one line");
let col_pos = pos / num_rows;
let row_pos = pos % num_rows;
let mut suggestion_area = columns[col_pos];
suggestion_area.y += row_pos as u16;
suggestion_area.height = 1;
line.render(suggestion_area, buf);
})
}
}
impl SuggestionPopup {
pub fn new(style: TextStyle, hl_style: TextStyle) -> Self {
SuggestionPopup { style, hl_style }
}
}
fn inlines_width(inlines: &[Inline], config: &RenderConfig) -> usize {
inlines
.iter()
.flat_map(|inline| render_inline(inline, config))
.filter_map(|fragment| match fragment {
InlineFragment::Text(txt) => Some(txt.width()),
InlineFragment::LineBreak => None,
})
.sum()
}
fn inlines_as_string(inlines: &[Inline]) -> String {
inlines
.iter()
.map(|inline| match inline {
Inline::Code(text) => text.to_string(),
Inline::Emphasis(inlines) => inlines_as_string(inlines),
Inline::StrongEmphasis(inlines) => inlines_as_string(inlines),
Inline::Link { url, title, label } => "".to_string(), Inline::Image(description) => "".to_string(), Inline::LineBreak => "\n".to_string(),
Inline::Text(text) => text.to_string(),
Inline::Unsupported(_) => "".to_string(),
})
.collect()
}