use crate::action::Action;
use crate::sanitize;
use crate::theme::Theme;
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState};
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryKind {
Dir,
File,
Repo,
Org,
Owner,
}
#[derive(Debug, Clone)]
pub struct Entry {
pub name: String,
pub kind: EntryKind,
}
impl Entry {
pub fn new(name: &str, kind: EntryKind) -> Self {
Entry {
name: sanitize::sanitize_inline(name),
kind,
}
}
}
#[derive(Debug, Clone)]
pub struct Pane {
pub title: String,
pub entries: Vec<Entry>,
pub filter: String,
pub show_badges: bool,
pub checkboxes: Option<std::collections::HashSet<String>>,
pub marks_only: bool,
selected: usize,
state: ListState,
pub focused: bool,
}
impl Pane {
pub fn new(title: impl Into<String>, entries: Vec<Entry>) -> Self {
let mut state = ListState::default();
state.select(Some(0));
Pane {
title: title.into(),
entries,
filter: String::new(),
show_badges: false,
checkboxes: None,
marks_only: false,
selected: 0,
state,
focused: false,
}
}
pub fn visible(&self) -> Vec<&Entry> {
let needle = self.filter.to_lowercase();
self.entries
.iter()
.filter(|e| needle.is_empty() || e.name.to_lowercase().contains(&needle))
.collect()
}
pub fn selected_entry(&self) -> Option<&Entry> {
self.visible().get(self.selected).copied()
}
fn clamp(&mut self) {
let len = self.visible().len();
if len == 0 {
self.selected = 0;
} else if self.selected >= len {
self.selected = len - 1;
}
self.state.select(Some(self.selected));
}
pub fn move_by(&mut self, delta: i32) {
let len = self.visible().len() as i32;
if len == 0 {
return;
}
self.selected = (self.selected as i32 + delta).clamp(0, len - 1) as usize;
self.state.select(Some(self.selected));
}
pub fn select(&mut self, idx: usize) {
self.selected = idx;
self.clamp();
}
pub fn set_filter(&mut self, filter: String) {
self.filter = filter;
self.clamp();
}
pub fn update(&mut self, action: &Action) {
match action {
Action::MoveDown => self.move_by(1),
Action::MoveUp => self.move_by(-1),
_ => {}
}
}
pub(crate) fn diagnostics(&self) -> PaneDiagnostics {
PaneDiagnostics {
selected: self.selected,
offset: self.state.offset(),
visible: self.visible().len(),
filter_len: self.filter.len(),
}
}
pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
let sem = &theme.semantic;
let focused = self.focused;
let border_style = if focused {
Style::default().fg(sem.border_focused)
} else {
Style::default().fg(sem.border_unfocused)
};
let file_fg = if focused { sem.file } else { sem.subtext0 };
let dir_style = if focused {
Style::default()
.fg(sem.directory)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(sem.directory)
.add_modifier(Modifier::DIM)
};
let highlight_style = if focused {
Style::default().bg(sem.selection_bg).fg(sem.selection_fg)
} else {
Style::default().bg(sem.selection_bg)
};
let title = if self.filter.is_empty() {
self.title.clone()
} else {
format!("{} /{}", self.title, self.filter)
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(theme.border_type())
.border_style(border_style)
.style(Style::default().bg(sem.base))
.title(Span::styled(
format!(" {} ", title),
Style::default().fg(sem.subtext0),
));
let box_w = if self.checkboxes.is_some() { 4 } else { 0 };
let width = area.width.saturating_sub(2 + box_w) as usize;
let items: Vec<ListItem> = self
.visible()
.iter()
.map(|e| {
let checkbox = self.checkboxes.as_ref().map(|set| {
if set.contains(&e.name) {
Span::styled("● ", Style::default().fg(sem.mode_browse))
} else if self.marks_only {
Span::raw(" ") } else {
Span::styled("○ ", Style::default().fg(sem.subtext0))
}
});
let line = match e.kind {
EntryKind::Repo | EntryKind::Org if self.show_badges => {
let (badge, color) = match e.kind {
EntryKind::Repo => ("[repo]", sem.badge_repo),
_ => ("[org]", sem.badge_org),
};
let badge_style = Style::default().fg(color).add_modifier(if focused {
Modifier::BOLD
} else {
Modifier::DIM
});
Line::from(vec![
Span::styled(badge, badge_style),
Span::styled(
format!(" {}", fit(&e.name, width.saturating_sub(8))),
Style::default().fg(file_fg),
),
])
}
EntryKind::Dir | EntryKind::Repo | EntryKind::Org | EntryKind::Owner => {
Line::from(Span::styled(fit(&format!("{}/", e.name), width), dir_style))
}
EntryKind::File => Line::from(Span::styled(
fit(&e.name, width),
Style::default().fg(file_fg),
)),
};
let line = match checkbox {
Some(mark) => {
let mut spans = vec![mark];
spans.extend(line.spans);
Line::from(spans)
}
None => line,
};
ListItem::new(line)
})
.collect();
let total = self.visible().len();
let list = List::new(items)
.block(block)
.highlight_style(highlight_style)
.highlight_symbol("▌");
frame.render_stateful_widget(list, area, &mut self.state);
super::scrollbar(
frame,
area,
area.height.saturating_sub(2) as usize,
total,
self.state.offset(),
theme,
);
}
}
#[derive(Debug, Clone, Copy, serde::Serialize)]
pub(crate) struct PaneDiagnostics {
pub(crate) selected: usize,
pub(crate) offset: usize,
pub(crate) visible: usize,
pub(crate) filter_len: usize,
}
pub(crate) fn fit(s: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
if s.width() <= width {
return s.to_string();
}
let mut out = String::new();
let mut w = 0;
for c in s.chars() {
let cw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
if w + cw + 1 > width {
break;
}
w += cw;
out.push(c);
}
out.push('…');
out
}