use std::collections::{HashMap, HashSet};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Padding, Paragraph, StatefulWidget, Widget, Wrap},
};
use crate::app::{fuzzy_match_indices, App, Focus, MatchScores};
use nucleo_matcher::{Config, Matcher};
use ratatui_themes::ThemePalette;
pub struct UiColors {
pub command: Color,
pub flag: Color,
pub arg: Color,
pub value: Color,
pub required: Color,
pub help: Color,
pub active_border: Color,
pub inactive_border: Color,
pub selected_bg: Color,
pub hover_bg: Color,
pub editing_bg: Color,
pub preview_cmd: Color,
pub choice: Color,
pub default_val: Color,
pub count: Color,
pub bg: Color,
pub bar_bg: Color,
}
impl UiColors {
pub fn from_palette(p: &ThemePalette) -> Self {
let bar_bg = match p.bg {
Color::Rgb(r, g, b) => Color::Rgb(
r.saturating_add(10),
g.saturating_add(10),
b.saturating_add(15),
),
_ => Color::Rgb(30, 30, 40),
};
let selected_bg = match p.selection {
Color::Rgb(r, g, b) => Color::Rgb(r, g, b),
_ => Color::Rgb(40, 40, 60),
};
let hover_bg = match (p.bg, p.selection) {
(Color::Rgb(br, bg_g, bb), Color::Rgb(sr, sg, sb)) => {
Color::Rgb(
((br as u16 + sr as u16) / 2) as u8,
((bg_g as u16 + sg as u16) / 2) as u8,
((bb as u16 + sb as u16) / 2) as u8,
)
}
_ => Color::Rgb(30, 30, 45),
};
let editing_bg = match p.selection {
Color::Rgb(r, g, b) => Color::Rgb(
r.saturating_add(15),
g.saturating_sub(5),
b.saturating_sub(10),
),
_ => Color::Rgb(50, 30, 30),
};
Self {
command: p.info,
flag: p.warning,
arg: p.success,
value: p.accent,
required: p.error,
help: p.muted,
active_border: p.accent,
inactive_border: p.muted,
selected_bg,
hover_bg,
editing_bg,
preview_cmd: p.fg,
choice: p.info,
default_val: p.muted,
count: p.secondary,
bg: p.bg,
bar_bg,
}
}
}
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 from_app(app: &App, panel: Focus, colors: &UiColors) -> Self {
let is_focused = app.focus() == panel;
let is_filtering = app.filtering && app.focus() == panel;
let has_filter = app.filter_active() && app.focus() == panel;
let border_color = if is_focused || is_filtering {
colors.active_border
} else {
colors.inactive_border
};
let filter_text = if app.focus() == panel {
app.filter().to_string()
} else {
String::new()
};
PanelState {
is_focused,
is_filtering,
has_filter,
border_color,
filter_text,
match_scores: HashMap::new(),
}
}
pub fn with_scores(mut self, scores: HashMap<String, MatchScores>) -> Self {
self.match_scores = scores;
self
}
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) -> Block<'static> {
Block::default()
.borders(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()));
}
}
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 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,
}
}
}
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);
para.render(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 struct CommandPreview<'a> {
pub command: &'a str,
pub bin: &'a str,
pub subcommands: &'a [String],
pub is_focused: bool,
pub colors: &'a UiColors,
}
impl<'a> CommandPreview<'a> {
pub fn new(
command: &'a str,
bin: &'a str,
subcommands: &'a [String],
is_focused: bool,
colors: &'a UiColors,
) -> Self {
Self {
command,
bin,
subcommands,
is_focused,
colors,
}
}
fn colorize(&self, bold: Modifier) -> Vec<Span<'static>> {
let subcommand_names: HashSet<&str> =
self.subcommands.iter().map(|s| s.as_str()).collect();
let tokens: Vec<&str> = self.command.split_whitespace().collect();
let mut spans = Vec::new();
let mut i = 0;
let mut expect_flag_value = false;
while i < tokens.len() {
if i > 0 {
spans.push(Span::raw(" "));
}
let token = tokens[i];
if i == 0 && token == self.bin {
spans.push(Span::styled(
token.to_string(),
Style::default()
.fg(self.colors.preview_cmd)
.add_modifier(bold | Modifier::BOLD),
));
} else if expect_flag_value {
spans.push(Span::styled(
token.to_string(),
Style::default().fg(self.colors.value).add_modifier(bold),
));
expect_flag_value = false;
} else if token.starts_with('-') {
spans.push(Span::styled(
token.to_string(),
Style::default().fg(self.colors.flag).add_modifier(bold),
));
if let Some(&next) = tokens.get(i + 1) {
if !next.starts_with('-') && !subcommand_names.contains(next) {
expect_flag_value = true;
}
}
} else if subcommand_names.contains(token) {
spans.push(Span::styled(
token.to_string(),
Style::default().fg(self.colors.command).add_modifier(bold),
));
} else {
spans.push(Span::styled(
token.to_string(),
Style::default().fg(self.colors.arg).add_modifier(bold),
));
}
i += 1;
}
spans
}
}
impl Widget for CommandPreview<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let border_color = if self.is_focused {
self.colors.active_border
} else {
self.colors.inactive_border
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(" Command ")
.title_style(Style::default().fg(border_color).bold())
.padding(Padding::horizontal(1));
let prefix = if self.is_focused { "▶ " } else { "$ " };
let bold = if self.is_focused {
Modifier::BOLD
} else {
Modifier::empty()
};
let mut spans = vec![Span::styled(prefix, Style::default().fg(self.colors.command))];
spans.extend(self.colorize(bold));
let paragraph = Paragraph::new(Line::from(spans))
.block(block)
.wrap(Wrap { trim: false });
paragraph.render(area, buf);
}
}
pub struct Keybind<'a> {
pub key: &'a str,
pub desc: &'a str,
}
pub struct HelpBar<'a> {
pub keybinds: &'a [Keybind<'a>],
pub theme_display: &'a str,
pub colors: &'a UiColors,
}
impl<'a> HelpBar<'a> {
pub fn new(keybinds: &'a [Keybind<'a>], theme_display: &'a str, colors: &'a UiColors) -> Self {
Self {
keybinds,
theme_display,
colors,
}
}
pub fn theme_indicator_rect(&self, area: Rect) -> Rect {
let theme_indicator = format!("T: [{}] ", self.theme_display);
let theme_indicator_len = theme_indicator.len() as u16;
let indicator_x = area.x + area.width.saturating_sub(theme_indicator_len);
Rect::new(indicator_x, area.y, theme_indicator_len, 1)
}
fn styled_keybind_spans(&self) -> (Vec<Span<'a>>, u16) {
let mut spans: Vec<Span<'a>> = vec![Span::raw(" ")];
let mut total_len: u16 = 1;
for (i, kb) in self.keybinds.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" "));
total_len += 2;
}
spans.push(Span::styled(kb.key, Style::default().fg(self.colors.active_border)));
spans.push(Span::raw(" "));
spans.push(Span::styled(kb.desc, Style::default().fg(self.colors.help)));
total_len += (kb.key.chars().count() + 1 + kb.desc.chars().count()) as u16;
}
(spans, total_len)
}
}
impl Widget for HelpBar<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let theme_indicator = format!("T: [{}] ", self.theme_display);
let theme_indicator_len = theme_indicator.len() as u16;
let (mut spans, keybinds_len) = self.styled_keybind_spans();
let padding_len = area.width.saturating_sub(keybinds_len + theme_indicator_len);
let padding = " ".repeat(padding_len as usize);
spans.push(Span::styled(padding, Style::default()));
spans.push(Span::styled(
theme_indicator,
Style::default().fg(self.colors.active_border).italic(),
));
let paragraph = Paragraph::new(Line::from(spans))
.style(Style::default().bg(self.colors.bar_bg));
paragraph.render(area, buf);
}
}
pub struct SelectList<'a> {
pub title: String,
pub items: &'a [String],
pub descriptions: &'a [Option<String>],
pub selected: Option<usize>,
pub hovered: Option<usize>,
pub show_cursor: bool,
pub borders: Borders,
pub item_color: Color,
pub selected_color: Color,
pub colors: &'a UiColors,
}
impl<'a> SelectList<'a> {
pub fn new(
title: String,
items: &'a [String],
selected: Option<usize>,
item_color: Color,
selected_color: Color,
colors: &'a UiColors,
) -> Self {
Self {
title,
items,
descriptions: &[],
selected,
hovered: None,
show_cursor: false,
borders: Borders::ALL,
item_color,
selected_color,
colors,
}
}
pub fn with_descriptions(mut self, descriptions: &'a [Option<String>]) -> Self {
self.descriptions = descriptions;
self
}
pub fn with_cursor(mut self) -> Self {
self.show_cursor = true;
self
}
pub fn with_borders(mut self, borders: Borders) -> Self {
self.borders = borders;
self
}
pub fn with_hovered(mut self, hovered: Option<usize>) -> Self {
self.hovered = hovered;
self
}
}
impl Widget for SelectList<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let mut state = SelectListScrollState::default();
StatefulWidget::render(self, area, buf, &mut state);
}
}
#[derive(Debug, Clone, Default)]
pub struct SelectListScrollState {
pub scroll_offset: usize,
pub visible_items: usize,
}
impl StatefulWidget for SelectList<'_> {
type State = SelectListScrollState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
use ratatui::widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState};
ratatui::widgets::Clear.render(area, buf);
let mut block = Block::default()
.borders(self.borders)
.border_style(Style::default().fg(self.colors.active_border));
if !self.title.is_empty() {
block = block
.title(self.title)
.title_style(
Style::default()
.fg(self.colors.active_border)
.add_modifier(Modifier::BOLD),
);
}
let items: Vec<ratatui::widgets::ListItem> = if self.items.is_empty() {
vec![ratatui::widgets::ListItem::new(Line::from(Span::styled(
"(no matches)",
Style::default().fg(self.colors.help).italic(),
)))]
} else {
self.items
.iter()
.enumerate()
.map(|(i, label)| {
let is_selected = self.selected == Some(i);
let style = if is_selected {
Style::default()
.fg(self.selected_color)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(self.item_color)
};
let mut spans = Vec::new();
if self.show_cursor {
let prefix = if is_selected { "▶ " } else { " " };
spans.push(Span::styled(
prefix,
if is_selected {
Style::default()
.fg(self.colors.active_border)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
},
));
}
spans.push(Span::styled(label.clone(), style));
if let Some(Some(desc)) = self.descriptions.get(i) {
spans.push(Span::styled(
format!(" {}", desc),
Style::default().fg(self.colors.help),
));
}
let mut item = ratatui::widgets::ListItem::new(Line::from(spans));
if is_selected {
item = item.style(Style::default().bg(self.colors.selected_bg));
} else if self.hovered == Some(i) {
item = item.style(Style::default().bg(self.colors.hover_bg));
}
item
})
.collect()
};
let border_height = if self.borders.contains(Borders::TOP) { 1 } else { 0 }
+ if self.borders.contains(Borders::BOTTOM) { 1 } else { 0 };
let visible_items = area.height.saturating_sub(border_height) as usize;
state.visible_items = visible_items;
let mut list_state = ratatui::widgets::ListState::default().with_selected(
if self.items.is_empty() {
None
} else {
self.selected
},
);
let scroll_offset = if let Some(sel) = self.selected {
if visible_items > 0 && sel >= visible_items {
sel.saturating_sub(visible_items - 1)
} else {
0
}
} else {
0
};
list_state = list_state.with_offset(scroll_offset);
state.scroll_offset = scroll_offset;
let list = ratatui::widgets::List::new(items).block(block);
ratatui::widgets::StatefulWidget::render(list, area, buf, &mut list_state);
let total_items = self.items.len();
if total_items > visible_items && visible_items > 0 {
let inner = area.inner(ratatui::layout::Margin {
horizontal: 0,
vertical: if self.borders.contains(Borders::TOP) { 1 } else { 0 },
});
let scrollbar_area = if self.borders.contains(Borders::BOTTOM) {
Rect::new(inner.x, inner.y, inner.width, inner.height.saturating_sub(
if self.borders.contains(Borders::BOTTOM) { 1 } else { 0 }
))
} else {
inner
};
let mut scrollbar_state = ScrollbarState::new(total_items.saturating_sub(visible_items))
.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(self.colors.inactive_border))
.thumb_style(Style::default().fg(self.colors.active_border));
StatefulWidget::render(scrollbar, scrollbar_area, buf, &mut scrollbar_state);
}
}
}