pub mod arg_panel;
pub mod choice_select;
pub mod command_panel;
pub mod execution;
pub mod filterable;
pub mod flag_panel;
pub mod help_bar;
pub mod list_panel_base;
pub mod preview;
pub mod select_list;
pub mod theme_picker;
use std::collections::HashMap;
use crossterm::event::{KeyEvent, MouseEvent};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget},
};
use nucleo_matcher::{Config, Matcher};
use crate::app::{fuzzy_match_indices, MatchScores};
use crate::theme::UiColors;
pub trait OverlayContent {
fn render(&self, area: Rect, buf: &mut Buffer, colors: &UiColors);
}
pub struct OverlayRequest {
pub anchor: Rect,
pub size: (u16, u16),
pub content: Box<dyn OverlayContent>,
}
pub fn clamp_overlay(anchor: Rect, size: (u16, u16), viewport: Rect) -> Rect {
let (pref_w, pref_h) = size;
let w = pref_w.min(viewport.width);
let y = anchor.y + anchor.height;
let space_below = viewport.bottom().saturating_sub(y);
let h = pref_h.min(space_below).max(2);
let x = anchor.x.min(viewport.right().saturating_sub(w)).max(viewport.x);
let y = y.min(viewport.bottom().saturating_sub(h)).max(viewport.y);
Rect::new(x, y, w, h)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EventResult<A> {
Consumed,
NotHandled,
Action(A),
}
impl<A> EventResult<A> {
#[allow(dead_code)]
pub fn map<B>(self, f: impl FnOnce(A) -> B) -> EventResult<B> {
match self {
EventResult::Consumed => EventResult::Consumed,
EventResult::NotHandled => EventResult::NotHandled,
EventResult::Action(a) => EventResult::Action(f(a)),
}
}
#[allow(dead_code)]
pub fn and_then<B>(self, f: impl FnOnce(A) -> EventResult<B>) -> EventResult<B> {
match self {
EventResult::Consumed => EventResult::Consumed,
EventResult::NotHandled => EventResult::NotHandled,
EventResult::Action(a) => f(a),
}
}
}
pub trait Component {
type Action;
fn handle_focus_gained(&mut self) -> EventResult<Self::Action> {
EventResult::NotHandled
}
fn handle_focus_lost(&mut self) -> EventResult<Self::Action> {
EventResult::NotHandled
}
fn handle_key(&mut self, key: KeyEvent) -> EventResult<Self::Action>;
fn handle_mouse(&mut self, event: MouseEvent, area: Rect) -> EventResult<Self::Action>;
fn collect_overlays(&mut self) -> Vec<OverlayRequest> {
Vec::new()
}
}
pub trait RenderableComponent: Component {
fn render(&mut self, area: Rect, buf: &mut Buffer, colors: &UiColors);
}
pub struct PanelState {
pub is_focused: bool,
pub is_filtering: bool,
pub has_filter: bool,
pub border_color: Color,
pub filter_text: String,
pub match_scores: HashMap<String, MatchScores>,
}
impl PanelState {
pub fn filter_visible(&self) -> bool {
self.is_filtering || self.has_filter
}
}
pub fn panel_title(name: &str, ps: &PanelState) -> String {
if ps.filter_visible() {
format!(" {} 🔍 {} ", name, ps.filter_text)
} else {
format!(" {} ", name)
}
}
pub fn panel_block(title: String, ps: &PanelState) -> ratatui::widgets::Block<'static> {
ratatui::widgets::Block::default()
.borders(ratatui::widgets::Borders::ALL)
.border_style(Style::default().fg(ps.border_color))
.title(title)
.title_style(Style::default().fg(ps.border_color).bold())
}
pub fn push_selection_cursor<'a>(spans: &mut Vec<Span<'a>>, is_selected: bool, colors: &UiColors) {
if is_selected {
spans.push(Span::styled(
"▶ ",
Style::default()
.fg(colors.active_border)
.add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled(" ", Style::default()));
}
}
pub struct ItemContext {
pub is_selected: bool,
pub is_match: bool,
pub name_matches: bool,
pub help_matches: bool,
}
impl ItemContext {
pub fn new(key: &str, is_selected: bool, ps: &PanelState) -> Self {
let (is_match, name_matches, help_matches) = item_match_state(key, ps);
Self {
is_selected,
is_match,
name_matches,
help_matches,
}
}
}
fn highlight_styles(
base_color: Color,
bg_color: Color,
is_selected: bool,
) -> (Style, Style) {
if is_selected {
(
Style::default()
.fg(base_color)
.add_modifier(Modifier::BOLD),
Style::default()
.fg(bg_color)
.bg(base_color)
.add_modifier(Modifier::BOLD),
)
} else {
(
Style::default().fg(base_color),
Style::default()
.fg(base_color)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
)
}
}
pub fn push_highlighted_name(
spans: &mut Vec<Span<'static>>,
text: &str,
base_color: Color,
ctx: &ItemContext,
ps: &PanelState,
colors: &UiColors,
) {
let has_scores = !ps.match_scores.is_empty();
if ctx.is_selected {
if has_scores && ctx.name_matches {
let (normal, highlight) = highlight_styles(base_color, colors.bg, true);
let highlighted = build_highlighted_text(text, &ps.filter_text, normal, highlight);
spans.extend(highlighted);
} else {
spans.push(Span::styled(
text.to_string(),
Style::default()
.fg(base_color)
.add_modifier(Modifier::BOLD),
));
}
} else if !ctx.is_match && has_scores {
spans.push(Span::styled(
text.to_string(),
Style::default().fg(colors.help).add_modifier(Modifier::DIM),
));
} else if has_scores && ctx.name_matches {
let (normal, highlight) = highlight_styles(base_color, colors.bg, false);
let highlighted = build_highlighted_text(text, &ps.filter_text, normal, highlight);
spans.extend(highlighted);
} else {
spans.push(Span::styled(
text.to_string(),
Style::default().fg(base_color),
));
}
}
pub fn build_help_line(
help: &str,
ctx: &ItemContext,
ps: &PanelState,
colors: &UiColors,
) -> Line<'static> {
let has_scores = !ps.match_scores.is_empty();
let spans = if !ctx.is_match && has_scores {
vec![Span::styled(
help.to_string(),
Style::default().fg(colors.help).add_modifier(Modifier::DIM),
)]
} else if has_scores && ctx.help_matches {
let (normal, highlight) = highlight_styles(colors.help, colors.bg, ctx.is_selected);
build_highlighted_text(help, &ps.filter_text, normal, highlight)
} else {
vec![Span::styled(
help.to_string(),
Style::default().fg(colors.help),
)]
};
Line::from(spans)
}
pub fn render_help_overlays(
buf: &mut Buffer,
help_entries: &[(usize, Line<'static>)],
scroll_offset: usize,
inner: Rect,
) {
let visible_rows = inner.height as usize;
if inner.width < 2 {
return;
}
for &(item_idx, ref help_line) in help_entries {
if item_idx < scroll_offset {
continue;
}
let row_in_view = item_idx - scroll_offset;
if row_in_view >= visible_rows {
continue;
}
let y = inner.y + row_in_view as u16;
let help_width = help_line.width() as u16;
if help_width == 0 || help_width >= inner.width {
continue;
}
let total_width = help_width + 1;
if total_width >= inner.width {
continue;
}
let help_x = inner.x + inner.width - total_width;
let mut content_end = inner.x;
for x in (inner.x..inner.x + inner.width).rev() {
let cell = &buf[(x, y)];
if cell.symbol() != " " {
content_end = x + 1;
break;
}
}
if help_x < content_end {
continue;
}
let help_rect = Rect::new(help_x, y, total_width, 1);
let spaced_line = Line::from(
std::iter::once(Span::raw(" "))
.chain(help_line.spans.iter().cloned())
.collect::<Vec<_>>(),
);
let para = Paragraph::new(spaced_line);
ratatui::widgets::Widget::render(para, help_rect, buf);
}
}
pub fn push_edit_cursor(
spans: &mut Vec<Span<'static>>,
before_cursor: &str,
after_cursor: &str,
colors: &UiColors,
) {
spans.push(Span::styled(
before_cursor.to_string(),
Style::default()
.fg(colors.value)
.add_modifier(Modifier::UNDERLINED),
));
spans.push(Span::styled(
"▎",
Style::default()
.fg(colors.value)
.add_modifier(Modifier::SLOW_BLINK),
));
spans.push(Span::styled(
after_cursor.to_string(),
Style::default()
.fg(colors.value)
.add_modifier(Modifier::UNDERLINED),
));
}
pub fn selection_bg(is_editing: bool, colors: &UiColors) -> Style {
let bg = if is_editing {
colors.editing_bg
} else {
colors.selected_bg
};
Style::default().bg(bg)
}
pub fn item_match_state(key: &str, ps: &PanelState) -> (bool, bool, bool) {
let scores = ps.match_scores.get(key);
let is_match = scores.map(|s| s.overall()).unwrap_or(1) > 0 || ps.match_scores.is_empty();
let name_matches = scores.map(|s| s.name_score).unwrap_or(0) > 0;
let help_matches = scores.map(|s| s.help_score).unwrap_or(0) > 0;
(is_match, name_matches, help_matches)
}
pub fn build_highlighted_text(
text: &str,
pattern: &str,
normal_style: Style,
highlight_style: Style,
) -> Vec<Span<'static>> {
let mut matcher = Matcher::new(Config::DEFAULT);
let (_score, indices) = fuzzy_match_indices(text, pattern, &mut matcher);
if indices.is_empty() {
return vec![Span::styled(text.to_string(), normal_style)];
}
let mut spans = Vec::new();
let chars: Vec<char> = text.chars().collect();
let mut last_idx = 0;
for &match_idx in &indices {
let idx = match_idx as usize;
if idx >= chars.len() {
continue;
}
if last_idx < idx {
let before: String = chars[last_idx..idx].iter().collect();
if !before.is_empty() {
spans.push(Span::styled(before, normal_style));
}
}
spans.push(Span::styled(chars[idx].to_string(), highlight_style));
last_idx = idx + 1;
}
if last_idx < chars.len() {
let after: String = chars[last_idx..].iter().collect();
if !after.is_empty() {
spans.push(Span::styled(after, normal_style));
}
}
spans
}
pub fn render_panel_scrollbar(
buf: &mut Buffer,
area: Rect,
total_items: usize,
scroll_offset: usize,
colors: &UiColors,
) {
let inner_height = area.height.saturating_sub(2) as usize;
if total_items <= inner_height || inner_height == 0 {
return;
}
let inner = area.inner(ratatui::layout::Margin::new(0, 1));
let mut scrollbar_state =
ScrollbarState::new(total_items.saturating_sub(inner_height)).position(scroll_offset);
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.track_symbol(Some("│"))
.thumb_symbol("┃")
.track_style(Style::default().fg(colors.inactive_border))
.thumb_style(Style::default().fg(colors.active_border));
StatefulWidget::render(scrollbar, inner, buf, &mut scrollbar_state);
}
pub(crate) fn find_adjacent_match(
keys: &[String],
scores: &HashMap<String, MatchScores>,
current: usize,
forward: bool,
) -> Option<usize> {
let total = keys.len();
if total == 0 {
return None;
}
for offset in 1..total {
let idx = if forward {
(current + offset) % total
} else {
(current + total - offset) % total
};
if let Some(key) = keys.get(idx) {
if scores.get(key).map(|s| s.overall()).unwrap_or(0) > 0 {
return Some(idx);
}
}
}
None
}
pub(crate) fn find_first_match(
keys: &[String],
scores: &HashMap<String, MatchScores>,
current: usize,
) -> Option<usize> {
if let Some(key) = keys.get(current) {
if scores.get(key).map(|s| s.overall()).unwrap_or(0) > 0 {
return Some(current);
}
}
for (idx, key) in keys.iter().enumerate() {
if scores.get(key).map(|s| s.overall()).unwrap_or(0) > 0 {
return Some(idx);
}
}
None
}