use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
use ratatui::{
prelude::*,
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
};
use crate::command::diff::state::Annotation;
use crate::command::diff::theme;
#[derive(Clone)]
pub struct KeyBind {
pub key: &'static str,
pub description: &'static str,
}
#[derive(Clone)]
pub struct KeyBindSection {
pub title: &'static str,
pub bindings: Vec<KeyBind>,
}
#[derive(Clone)]
pub struct FilePickerItem {
pub name: String,
pub file_index: usize,
pub status: FileStatus,
pub viewed: bool,
}
#[derive(Clone, Copy)]
pub enum FileStatus {
Added,
Modified,
Deleted,
}
#[derive(Clone)]
pub enum ModalContent {
#[allow(dead_code)]
Info { title: String, message: String },
#[allow(dead_code)]
Select {
title: String,
items: Vec<String>,
selected: usize,
},
KeyBindings {
title: String,
sections: Vec<KeyBindSection>,
scroll: u16,
content_height: u16,
},
FilePicker {
title: String,
items: Vec<FilePickerItem>,
filtered_indices: Vec<usize>,
query: String,
selected: usize,
},
Annotations {
title: String,
items: Vec<String>,
annotations: Vec<Annotation>,
selected: usize,
export_input: Option<String>,
error_message: Option<String>,
},
}
pub struct Modal {
pub content: ModalContent,
}
#[derive(Clone)]
pub enum ModalResult {
Dismissed,
#[allow(dead_code)]
Selected(usize, String),
FileSelected(usize),
AnnotationJump { annotation_id: u64 },
AnnotationEdit { annotation_id: u64 },
AnnotationDelete { annotation_id: u64 },
AnnotationCopyAll,
AnnotationExport(String),
}
impl Modal {
#[allow(dead_code)]
pub fn info(title: impl Into<String>, message: impl Into<String>) -> Self {
Self {
content: ModalContent::Info {
title: title.into(),
message: message.into(),
},
}
}
#[allow(dead_code)]
pub fn select(title: impl Into<String>, items: Vec<String>) -> Self {
Self {
content: ModalContent::Select {
title: title.into(),
items,
selected: 0,
},
}
}
pub fn keybindings(title: impl Into<String>, sections: Vec<KeyBindSection>) -> Self {
let content_height: u16 = sections
.iter()
.map(|s| s.bindings.len() as u16 + 2) .sum();
Self {
content: ModalContent::KeyBindings {
title: title.into(),
sections,
scroll: 0,
content_height,
},
}
}
pub fn file_picker(title: impl Into<String>, items: Vec<FilePickerItem>) -> Self {
let filtered_indices: Vec<usize> = (0..items.len()).collect();
Self {
content: ModalContent::FilePicker {
title: title.into(),
items,
filtered_indices,
query: String::new(),
selected: 0,
},
}
}
pub fn annotations(
title: impl Into<String>,
items: Vec<String>,
annotations: Vec<Annotation>,
) -> Self {
Self {
content: ModalContent::Annotations {
title: title.into(),
items,
annotations,
selected: 0,
export_input: None,
error_message: None,
},
}
}
pub fn render(&self, frame: &mut Frame) {
let area = frame.area();
let (modal_width, modal_height) = match &self.content {
ModalContent::Info { message, .. } => {
let width = 80.min(area.width.saturating_sub(4));
let lines = message.lines().count() as u16;
let height = (lines + 4).min(area.height * 80 / 100).max(5);
(width, height)
}
ModalContent::Select { items, .. } => {
let width = 80.min(area.width.saturating_sub(4));
let items_count = items.len() as u16;
let height = (items_count + 4).min(area.height * 80 / 100).max(5);
(width, height)
}
ModalContent::KeyBindings { sections, .. } => {
let width = 60.min(area.width.saturating_sub(4));
let total_lines: usize = sections
.iter()
.map(|s| s.bindings.len() + 2) .sum();
let height = (total_lines as u16 + 4).min(area.height * 80 / 100).max(5);
(width, height)
}
ModalContent::FilePicker {
filtered_indices, ..
} => {
let width = 80.min(area.width.saturating_sub(4));
let items_count = filtered_indices.len().min(15) as u16;
let height = (items_count + 5).min(area.height * 80 / 100).max(8);
(width, height)
}
ModalContent::Annotations {
items, export_input, ..
} => {
let width = 100.min(area.width.saturating_sub(4));
let items_count = items.len().min(12) as u16;
let extra = if export_input.is_some() { 4 } else { 2 };
let height = (items_count + extra + 2).min(area.height * 80 / 100).max(8);
(width, height)
}
};
let modal_x = (area.width.saturating_sub(modal_width)) / 2;
let modal_y = (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
frame.render_widget(Clear, modal_area);
match &self.content {
ModalContent::Info { title, message } => {
self.render_info(frame, modal_area, title, message);
}
ModalContent::Select {
title,
items,
selected,
} => {
self.render_select(frame, modal_area, title, items, *selected);
}
ModalContent::KeyBindings { title, sections, scroll, content_height } => {
self.render_keybindings(frame, modal_area, title, sections, *scroll, *content_height);
}
ModalContent::FilePicker {
title,
items,
filtered_indices,
query,
selected,
} => {
self.render_file_picker(
frame,
modal_area,
title,
items,
filtered_indices,
query,
*selected,
);
}
ModalContent::Annotations {
title,
items,
selected,
export_input,
error_message,
..
} => {
self.render_annotations(frame, modal_area, title, items, *selected, export_input.as_deref(), error_message.as_deref());
}
}
}
fn render_info(&self, frame: &mut Frame, area: Rect, title: &str, message: &str) {
let t = theme::get();
let block = Block::default()
.title(format!(" {} ", title))
.title_style(Style::default().fg(t.ui.border_focused).bold())
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(t.ui.border_unfocused));
let inner = block.inner(area);
frame.render_widget(block, area);
let lines: Vec<Line> = message
.lines()
.map(|line| Line::from(Span::styled(line, Style::default().fg(t.ui.text_primary))))
.collect();
let para = Paragraph::new(lines);
frame.render_widget(para, inner);
}
fn render_select(
&self,
frame: &mut Frame,
area: Rect,
title: &str,
items: &[String],
selected: usize,
) {
let t = theme::get();
let block = Block::default()
.title(format!(" {} ", title))
.title_style(Style::default().fg(t.ui.border_focused).bold())
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(t.ui.border_unfocused));
let inner = block.inner(area);
frame.render_widget(block, area);
let list_items: Vec<ListItem> = items
.iter()
.enumerate()
.map(|(i, item)| {
let style = if i == selected {
Style::default().fg(t.ui.selection_fg).bg(t.ui.selection_bg)
} else {
Style::default().fg(t.ui.text_primary)
};
ListItem::new(format!(" {} ", item)).style(style)
})
.collect();
let list = List::new(list_items);
frame.render_widget(list, inner);
}
fn render_keybindings(
&self,
frame: &mut Frame,
area: Rect,
title: &str,
sections: &[KeyBindSection],
scroll: u16,
content_height: u16,
) {
let t = theme::get();
let block = Block::default()
.title(format!(" {} ", title))
.title_style(Style::default().fg(t.ui.border_focused).bold())
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(t.ui.border_unfocused));
let inner = block.inner(area);
frame.render_widget(block, area);
let key_width = sections
.iter()
.flat_map(|s| s.bindings.iter())
.map(|b| b.key.len())
.max()
.unwrap_or(0);
let mut lines: Vec<Line> = Vec::new();
for (i, section) in sections.iter().enumerate() {
if i > 0 {
lines.push(Line::from(""));
}
let section_label = format!("[{}]", section.title);
lines.push(Line::from(Span::styled(
format!("{:>width$}", section_label, width = key_width),
Style::default().fg(t.ui.highlight).bold(),
)));
for bind in §ion.bindings {
lines.push(Line::from(vec![
Span::styled(
format!("{:>width$}", bind.key, width = key_width),
Style::default().fg(t.ui.status_added),
),
Span::styled(" ", Style::default()),
Span::styled(bind.description, Style::default().fg(t.ui.text_primary)),
]));
}
}
let content_area = Rect::new(inner.x, inner.y, inner.width.saturating_sub(1), inner.height);
let para = Paragraph::new(lines).scroll((scroll, 0));
frame.render_widget(para, content_area);
let visible_height = inner.height;
if content_height > visible_height {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.track_symbol(Some("│"))
.thumb_symbol("â–ˆ");
let mut scrollbar_state = ScrollbarState::new(content_height.saturating_sub(visible_height) as usize)
.position(scroll as usize);
frame.render_stateful_widget(scrollbar, inner, &mut scrollbar_state);
}
}
#[allow(clippy::too_many_arguments)]
fn render_file_picker(
&self,
frame: &mut Frame,
area: Rect,
title: &str,
items: &[FilePickerItem],
filtered_indices: &[usize],
query: &str,
selected: usize,
) {
let t = theme::get();
let block = Block::default()
.title(format!(" {} ", title))
.title_style(Style::default().fg(t.ui.border_focused).bold())
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(t.ui.border_unfocused));
let inner = block.inner(area);
frame.render_widget(block, area);
use ratatui::layout::{Constraint, Direction, Layout};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.split(inner);
let input_line = Line::from(vec![
Span::styled("> ", Style::default().fg(t.ui.status_added)),
Span::styled(query, Style::default().fg(t.ui.text_primary)),
Span::styled("_", Style::default().fg(t.ui.text_muted)),
]);
frame.render_widget(Paragraph::new(input_line), chunks[0]);
let visible_count = chunks[2].height as usize;
let scroll_offset = if selected >= visible_count {
selected - visible_count + 1
} else {
0
};
let list_items: Vec<ListItem> = filtered_indices
.iter()
.enumerate()
.skip(scroll_offset)
.take(visible_count)
.map(|(i, &idx)| {
let item = &items[idx];
let is_selected = i == selected;
let (status_char, status_color) = match item.status {
FileStatus::Added => ("A", t.ui.status_added),
FileStatus::Modified => ("M", t.ui.status_modified),
FileStatus::Deleted => ("D", t.ui.status_deleted),
};
let viewed_char = if item.viewed { "✓" } else { " " };
let spans = if is_selected {
let selected_style =
Style::default().fg(t.ui.selection_fg).bg(t.ui.selection_bg);
vec![
Span::styled(format!(" {} ", viewed_char), selected_style),
Span::styled(format!("{} ", status_char), selected_style),
Span::styled(item.name.clone(), selected_style),
]
} else {
vec![
Span::styled(
format!(" {} ", viewed_char),
Style::default().fg(t.ui.viewed),
),
Span::styled(
format!("{} ", status_char),
Style::default().fg(status_color),
),
Span::styled(item.name.clone(), Style::default().fg(t.ui.text_primary)),
]
};
ListItem::new(Line::from(spans))
})
.collect();
let list = List::new(list_items);
frame.render_widget(list, chunks[2]);
}
fn render_annotations(
&self,
frame: &mut Frame,
area: Rect,
title: &str,
items: &[String],
selected: usize,
export_input: Option<&str>,
error_message: Option<&str>,
) {
let t = theme::get();
let title_text = format!(" {} ({}) ", title, items.len());
let block = Block::default()
.title(title_text)
.title_style(Style::default().fg(t.ui.text_secondary))
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(t.ui.border_focused));
let inner = block.inner(area);
frame.render_widget(block, area);
use ratatui::layout::{Constraint, Direction, Layout};
let (list_area, footer_area) = if export_input.is_some() {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1),
Constraint::Length(3),
Constraint::Length(1),
])
.split(inner);
(chunks[0], chunks[2])
} else {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(inner);
(chunks[0], chunks[1])
};
let visible_count = list_area.height as usize;
let scroll_offset = if selected >= visible_count {
selected - visible_count + 1
} else {
0
};
let list_items: Vec<ListItem> = items
.iter()
.enumerate()
.skip(scroll_offset)
.take(visible_count)
.map(|(i, item)| {
let is_selected = i == selected;
let parts: Vec<&str> = item.splitn(3, " | ").collect();
let location = parts.first().unwrap_or(&"");
let preview = parts.get(1).unwrap_or(&"");
let time = parts.get(2).unwrap_or(&"");
let available_width = list_area.width as usize;
let time_width = time.len() + 2;
let content_width = available_width.saturating_sub(time_width + 4);
let location_max = (content_width * 45 / 100).max(20).min(content_width.saturating_sub(20));
let preview_max = content_width.saturating_sub(location_max);
let truncated_location = if location.chars().count() > location_max && location_max > 3 {
let truncate_at = location_max - 1;
let truncated: String = location.chars().take(truncate_at).collect();
format!("{}…", truncated)
} else {
location.to_string()
};
let truncated_preview = if preview.chars().count() > preview_max && preview_max > 3 {
let truncate_at = preview_max - 1;
let truncated: String = preview.chars().take(truncate_at).collect();
format!("{}…", truncated)
} else {
preview.to_string()
};
let location_len = truncated_location.chars().count() + 2; let preview_len = truncated_preview.chars().count() + 1; let used_width = location_len + preview_len + time_width;
let padding = available_width.saturating_sub(used_width);
let spans = if is_selected {
vec![
Span::styled(
format!(" {} ", truncated_location),
Style::default().fg(t.ui.selection_fg).bg(t.ui.selection_bg),
),
Span::styled(
format!(" {}", truncated_preview),
Style::default()
.fg(t.ui.selection_fg)
.bg(t.ui.selection_bg)
.italic(),
),
Span::styled(
format!("{:>width$}", "", width = padding),
Style::default().bg(t.ui.selection_bg),
),
Span::styled(
format!(" {} ", time),
Style::default().fg(t.ui.selection_fg).bg(t.ui.selection_bg),
),
]
} else {
vec![
Span::styled(
format!(" {} ", truncated_location),
Style::default().fg(t.ui.text_secondary),
),
Span::styled(
format!(" {}", truncated_preview),
Style::default().fg(t.ui.text_muted).italic(),
),
Span::styled(
format!("{:>width$}", "", width = padding),
Style::default(),
),
Span::styled(
format!(" {} ", time),
Style::default().fg(t.ui.text_muted),
),
]
};
ListItem::new(Line::from(spans))
})
.collect();
let list = List::new(list_items);
frame.render_widget(list, list_area);
if let Some(input) = export_input {
let input_area = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1),
Constraint::Length(3),
Constraint::Length(1),
])
.split(inner)[1];
let input_block = Block::default()
.title(" Export path ")
.title_style(Style::default().fg(t.ui.text_muted))
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(t.ui.border_unfocused));
let input_inner = input_block.inner(input_area);
frame.render_widget(input_block, input_area);
let input_line = Line::from(vec![
Span::styled(input, Style::default().fg(t.ui.text_primary)),
Span::styled("_", Style::default().fg(t.ui.text_muted)),
]);
frame.render_widget(Paragraph::new(input_line), input_inner);
}
if let Some(error) = error_message {
let error_line = Line::from(vec![
Span::styled("Error: ", Style::default().fg(t.ui.status_deleted).bold()),
Span::styled(error, Style::default().fg(t.ui.status_deleted)),
]);
let error_para = Paragraph::new(error_line).alignment(ratatui::prelude::Alignment::Center);
let error_area = Rect::new(list_area.x, list_area.y + list_area.height.saturating_sub(1), list_area.width, 1);
frame.render_widget(error_para, error_area);
}
let footer_text = if export_input.is_some() {
Line::from(vec![
Span::styled("enter", Style::default().fg(t.ui.text_muted)),
Span::styled(" confirm ", Style::default().fg(t.ui.text_muted)),
Span::styled("│ ", Style::default().fg(t.ui.border_unfocused)),
Span::styled("esc", Style::default().fg(t.ui.text_muted)),
Span::styled(" cancel", Style::default().fg(t.ui.text_muted)),
])
} else {
Line::from(vec![
Span::styled("enter", Style::default().fg(t.ui.text_muted)),
Span::styled(" jump ", Style::default().fg(t.ui.text_muted)),
Span::styled("│ ", Style::default().fg(t.ui.border_unfocused)),
Span::styled("e", Style::default().fg(t.ui.text_muted)),
Span::styled(" edit ", Style::default().fg(t.ui.text_muted)),
Span::styled("│ ", Style::default().fg(t.ui.border_unfocused)),
Span::styled("d", Style::default().fg(t.ui.text_muted)),
Span::styled(" del ", Style::default().fg(t.ui.text_muted)),
Span::styled("│ ", Style::default().fg(t.ui.border_unfocused)),
Span::styled("y", Style::default().fg(t.ui.text_muted)),
Span::styled(" copy ", Style::default().fg(t.ui.text_muted)),
Span::styled("│ ", Style::default().fg(t.ui.border_unfocused)),
Span::styled("o", Style::default().fg(t.ui.text_muted)),
Span::styled(" export", Style::default().fg(t.ui.text_muted)),
])
};
let footer = Paragraph::new(footer_text).alignment(ratatui::prelude::Alignment::Center);
frame.render_widget(footer, footer_area);
}
pub fn handle_mouse(&mut self, mouse: MouseEvent, terminal_height: u16) -> bool {
if let ModalContent::KeyBindings { scroll, content_height, .. } = &mut self.content {
let visible_height = calculate_keybindings_visible_height(terminal_height, *content_height);
let max_scroll = content_height.saturating_sub(visible_height);
match mouse.kind {
MouseEventKind::ScrollDown => {
*scroll = (*scroll + 3).min(max_scroll);
true
}
MouseEventKind::ScrollUp => {
*scroll = scroll.saturating_sub(3);
true
}
_ => false,
}
} else {
false
}
}
pub fn handle_input(&mut self, key: KeyEvent, terminal_height: u16) -> Option<ModalResult> {
if !matches!(
self.content,
ModalContent::FilePicker { .. } | ModalContent::Annotations { .. }
) {
if key.code == KeyCode::Esc
|| key.code == KeyCode::Char('q')
|| (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL))
{
return Some(ModalResult::Dismissed);
}
}
match &mut self.content {
ModalContent::Info { .. } => {
if key.code == KeyCode::Enter {
return Some(ModalResult::Dismissed);
}
None
}
ModalContent::Select {
items, selected, ..
} => match key.code {
KeyCode::Down | KeyCode::Char('j') => {
if *selected < items.len().saturating_sub(1) {
*selected += 1;
}
None
}
KeyCode::Up | KeyCode::Char('k') => {
*selected = selected.saturating_sub(1);
None
}
KeyCode::Enter => {
let idx = *selected;
let value = items.get(idx).cloned().unwrap_or_default();
Some(ModalResult::Selected(idx, value))
}
_ => None,
},
ModalContent::KeyBindings { scroll, content_height, .. } => {
let visible_height = calculate_keybindings_visible_height(terminal_height, *content_height);
let max_scroll = content_height.saturating_sub(visible_height);
match key.code {
KeyCode::Enter => Some(ModalResult::Dismissed),
KeyCode::Down | KeyCode::Char('j') => {
*scroll = (*scroll + 1).min(max_scroll);
None
}
KeyCode::Up | KeyCode::Char('k') => {
*scroll = scroll.saturating_sub(1);
None
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
*scroll = (*scroll + visible_height / 2).min(max_scroll);
None
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
*scroll = scroll.saturating_sub(visible_height / 2);
None
}
KeyCode::Char('g') => {
*scroll = 0;
None
}
KeyCode::Char('G') => {
*scroll = max_scroll;
None
}
_ => None,
}
}
ModalContent::FilePicker {
items,
filtered_indices,
query,
selected,
..
} => match key.code {
KeyCode::Esc => Some(ModalResult::Dismissed),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(ModalResult::Dismissed)
}
KeyCode::Down | KeyCode::Char('j')
if key.modifiers.contains(KeyModifiers::CONTROL)
|| key.code == KeyCode::Down =>
{
if *selected < filtered_indices.len().saturating_sub(1) {
*selected += 1;
}
None
}
KeyCode::Up | KeyCode::Char('k')
if key.modifiers.contains(KeyModifiers::CONTROL) || key.code == KeyCode::Up =>
{
*selected = selected.saturating_sub(1);
None
}
KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if *selected < filtered_indices.len().saturating_sub(1) {
*selected += 1;
}
None
}
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
*selected = selected.saturating_sub(1);
None
}
KeyCode::Enter => {
if let Some(&file_idx) = filtered_indices.get(*selected) {
Some(ModalResult::FileSelected(items[file_idx].file_index))
} else {
Some(ModalResult::Dismissed)
}
}
KeyCode::Backspace => {
query.pop();
Self::update_filtered_indices(items, query, filtered_indices, selected);
None
}
KeyCode::Char(c) => {
query.push(c);
Self::update_filtered_indices(items, query, filtered_indices, selected);
None
}
_ => None,
},
ModalContent::Annotations {
items,
annotations,
selected,
export_input,
error_message,
..
} => {
if let Some(ref mut input) = export_input {
match key.code {
KeyCode::Esc => {
*export_input = None;
*error_message = None;
None
}
KeyCode::Enter => {
let filename = input.trim();
if filename.is_empty() {
*error_message = Some("Path cannot be empty".to_string());
return None;
}
if filename.contains("..") {
*error_message = Some("Path cannot contain '..'".to_string());
return None;
}
*error_message = None;
Some(ModalResult::AnnotationExport(filename.to_string()))
}
KeyCode::Backspace => {
input.pop();
*error_message = None; None
}
KeyCode::Char(c) => {
input.push(c);
*error_message = None; None
}
_ => None,
}
} else {
match key.code {
KeyCode::Esc
| KeyCode::Char('q')
| KeyCode::Char('c')
if key.code == KeyCode::Esc
|| key.code == KeyCode::Char('q')
|| key.modifiers.contains(KeyModifiers::CONTROL) =>
{
Some(ModalResult::Dismissed)
}
KeyCode::Down | KeyCode::Char('j') => {
if *selected < items.len().saturating_sub(1) {
*selected += 1;
}
None
}
KeyCode::Up | KeyCode::Char('k') => {
*selected = selected.saturating_sub(1);
None
}
KeyCode::Enter => annotations.get(*selected).map(|ann| {
ModalResult::AnnotationJump {
annotation_id: ann.id,
}
}),
KeyCode::Char('e') => annotations.get(*selected).map(|ann| {
ModalResult::AnnotationEdit {
annotation_id: ann.id,
}
}),
KeyCode::Char('d') => annotations.get(*selected).map(|ann| {
ModalResult::AnnotationDelete {
annotation_id: ann.id,
}
}),
KeyCode::Char('y') => Some(ModalResult::AnnotationCopyAll),
KeyCode::Char('o') => {
*export_input = Some(String::from("annotations.txt"));
None
}
_ => None,
}
}
}
}
}
fn update_filtered_indices(
items: &[FilePickerItem],
query: &str,
filtered_indices: &mut Vec<usize>,
selected: &mut usize,
) {
let query_lower = query.to_lowercase();
*filtered_indices = items
.iter()
.enumerate()
.filter(|(_, item)| fuzzy_match(&item.name.to_lowercase(), &query_lower))
.map(|(i, _)| i)
.collect();
if *selected >= filtered_indices.len() {
*selected = filtered_indices.len().saturating_sub(1);
}
}
}
fn fuzzy_match(text: &str, pattern: &str) -> bool {
if pattern.is_empty() {
return true;
}
let mut pattern_chars = pattern.chars().peekable();
for c in text.chars() {
if pattern_chars.peek() == Some(&c) {
pattern_chars.next();
}
if pattern_chars.peek().is_none() {
return true;
}
}
pattern_chars.peek().is_none()
}
fn calculate_keybindings_visible_height(terminal_height: u16, content_height: u16) -> u16 {
let modal_height = (content_height + 4).min(terminal_height * 80 / 100).max(5);
modal_height.saturating_sub(2)
}