use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::app::App;
use crate::files::{FileLoad, FileView, SIZE_CAP};
use crate::ui::theme::Theme;
use crate::ui::RenderTarget;
pub(super) fn draw_files_dock(f: &mut RenderTarget, area: Rect, app: &mut App, t: &Theme) {
app.files_area = area;
app.file_tree_rects.clear();
let cx = area.x + 2;
let cw = area.width.saturating_sub(3);
let line_at = |f: &mut RenderTarget, y: u16, line: Line| {
if y < area.bottom() {
f.buffer_mut().set_line(cx, y, &line, cw);
}
};
let name = app
.file_tree
.root()
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "—".into());
let title = format!("{} · {name}", app.catalog.files);
line_at(
f,
area.y,
Line::from(Span::styled(title, Style::new().fg(t.overlay1).bold())),
);
let list_top = area.y + 1;
let cap = area.height.saturating_sub(1) as usize;
let n = app.file_tree.visible_rows().len();
let max_scroll = n.saturating_sub(cap);
if app.file_tree.scroll > max_scroll {
app.file_tree.scroll = max_scroll;
}
let scroll = app.file_tree.scroll;
let hover = app.hover;
let rows = app.file_tree.visible_rows();
for (i, row) in rows.iter().enumerate().skip(scroll).take(cap) {
let y = list_top + (i - scroll) as u16;
let rect = Rect::new(area.x, y, area.width, 1);
let hovered = hover.is_some_and(|(hc, hr)| {
hc >= rect.x && hc < rect.right() && hr >= rect.y && hr < rect.bottom()
});
let indent = " ".repeat(row.depth as usize);
let glyph = if row.is_dir {
if row.expanded {
"▾ "
} else {
"▸ "
}
} else {
" "
};
let mut label = format!("{indent}{glyph}{}", row.name);
if row.loading {
label.push_str(" …");
}
let git = app.file_git_status.get(&row.path).copied();
let base_fg = if row.is_dir { t.subtext1 } else { t.subtext0 };
let git_fg = git.and_then(|s| git_color(s, t));
let mut style = Style::new().fg(git_fg.unwrap_or(base_fg));
if row.is_dir {
style = style.bold();
}
if hovered {
style = style.fg(t.accent);
}
let mut spans = vec![Span::styled(label, style)];
if let Some(badge) = git.map(|s| s.badge()).filter(|b| !b.is_empty()) {
spans.push(Span::styled(
format!(" {badge}"),
Style::new().fg(git_fg.unwrap_or(t.overlay1)),
));
}
line_at(f, y, Line::from(spans));
app.file_tree_rects.push((i, rect));
}
}
pub(super) fn draw_file_view(
f: &mut RenderTarget,
area: Rect,
v: &FileView,
sel: Option<&crate::app::Selection>,
t: &Theme,
) {
if area.height == 0 || area.width == 0 {
return;
}
let body = Rect::new(area.x, area.y, area.width, area.height.saturating_sub(1));
let footer_y = area.bottom().saturating_sub(1);
match &v.load {
FileLoad::Loading => center(f, body, "loading…", t.overlay0),
FileLoad::Binary(n) => center(f, body, &format!("binary file · {}", human(*n)), t.overlay1),
FileLoad::TooLarge(n) => center(
f,
body,
&format!(
"too large to preview · {} (cap {})",
human(*n),
human(SIZE_CAP)
),
t.overlay1,
),
FileLoad::Error(e) => center(f, body, &format!("cannot open: {e}"), t.coral),
FileLoad::Text(lines) => draw_text(f, body, v, lines, t),
}
if let Some(sel) = sel {
let buf = f.buffer_mut();
for y in body.y..body.bottom() {
for x in body.x..body.right() {
if sel.contains(x, y) {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_bg(t.sel_bg);
}
}
}
}
}
let name = v
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let foot = if let Some(s) = &v.search {
if s.editing {
format!(" /{}", s.query)
} else if s.matches.is_empty() {
format!(" /{} · no matches", s.query)
} else {
format!(" /{} · {}/{}", s.query, s.current + 1, s.matches.len())
}
} else {
match &v.load {
FileLoad::Text(lines) => format!(" {name} · {} lines · UTF-8", lines.len()),
FileLoad::Binary(_) => format!(" {name} · binary"),
FileLoad::TooLarge(_) => format!(" {name} · too large"),
FileLoad::Loading => format!(" {name} · loading…"),
FileLoad::Error(_) => format!(" {name} · error"),
}
};
let wrap_hint = if v.wrap { " wrap " } else { "" };
let foot = clip(&foot, area.width.saturating_sub(wrap_hint.len() as u16));
f.render_widget(
Paragraph::new(Line::from(Span::styled(foot, Style::new().fg(t.overlay0)))),
Rect::new(area.x, footer_y, area.width, 1),
);
if v.wrap {
f.render_widget(
Paragraph::new(Line::from(Span::styled(
wrap_hint,
Style::new().fg(t.base).bg(t.overlay0),
))),
Rect::new(area.right().saturating_sub(6), footer_y, 6, 1),
);
}
}
fn draw_text(f: &mut RenderTarget, body: Rect, v: &FileView, lines: &[String], t: &Theme) {
let rows = body.height as usize;
let gutter = crate::files::gutter_width(lines.len());
let text_x = body.x + gutter + 1;
let text_w = body.width.saturating_sub(gutter + 1);
if text_w == 0 {
return;
}
let gutter_cell = |f: &mut RenderTarget, y: u16, num: Option<usize>| {
let s = match num {
Some(n) => format!("{:>w$} ", n, w = gutter as usize),
None => " ".repeat(gutter as usize + 1),
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(s, Style::new().fg(t.overlay0)))),
Rect::new(body.x, y, gutter + 1, 1),
);
};
if v.wrap {
let mut y = body.y;
let bottom = body.y + body.height;
let mut i = v.scroll;
while y < bottom && i < lines.len() {
let line = &lines[i];
for (si, range) in crate::files::wrap_ranges(line, text_w as usize)
.into_iter()
.enumerate()
{
if y >= bottom {
break;
}
gutter_cell(f, y, (si == 0).then_some(i + 1));
f.render_widget(
Paragraph::new(Span::styled(
crate::files::seg_text(line, range),
Style::new().fg(t.text),
)),
Rect::new(text_x, y, text_w, 1),
);
y += 1;
}
i += 1;
}
return;
}
for (i, line) in lines.iter().enumerate().skip(v.scroll).take(rows) {
let y = body.y + (i - v.scroll) as u16;
gutter_cell(f, y, Some(i + 1));
let line_ui = search_line(v, i, line, t);
f.render_widget(
Paragraph::new(line_ui).scroll((0, v.hscroll)),
Rect::new(text_x, y, text_w, 1),
);
}
}
fn center(f: &mut RenderTarget, area: Rect, msg: &str, fg: ratatui::style::Color) {
if area.height == 0 {
return;
}
let y = area.y + area.height / 2;
let msg = clip(msg, area.width);
f.render_widget(
Paragraph::new(Line::from(Span::styled(msg, Style::new().fg(fg))))
.alignment(ratatui::layout::Alignment::Center),
Rect::new(area.x, y, area.width, 1),
);
}
fn clip(s: &str, w: u16) -> String {
s.chars().take(w as usize).collect()
}
fn human(n: u64) -> String {
if n >= 1 << 20 {
format!("{:.1} MB", n as f64 / (1 << 20) as f64)
} else if n >= 1 << 10 {
format!("{:.1} KB", n as f64 / (1 << 10) as f64)
} else {
format!("{n} B")
}
}
fn search_line<'a>(v: &FileView, line_idx: usize, line: &'a str, t: &Theme) -> Line<'a> {
let Some(s) = &v.search else {
return Line::from(Span::styled(line, Style::new().fg(t.text)));
};
let hits: Vec<(usize, usize)> = s
.matches
.iter()
.enumerate()
.filter(|(_, (l, _))| *l == line_idx)
.map(|(i, (_, c))| (i, *c))
.collect();
if hits.is_empty() || s.query.is_empty() {
return Line::from(Span::styled(line, Style::new().fg(t.text)));
}
let qlen = s.query.chars().count();
let mut spans: Vec<Span> = Vec::new();
let mut cursor = 0usize; let chars: Vec<char> = line.chars().collect();
for (mi, col) in hits {
if col > cursor {
let seg: String = chars[cursor..col.min(chars.len())].iter().collect();
spans.push(Span::styled(seg, Style::new().fg(t.text)));
}
let end = (col + qlen).min(chars.len());
let seg: String = chars[col..end].iter().collect();
let hl = if mi == s.current {
Style::new().fg(t.base).bg(t.accent).bold()
} else {
Style::new().fg(t.base).bg(t.amber)
};
spans.push(Span::styled(seg, hl));
cursor = end;
}
if cursor < chars.len() {
let seg: String = chars[cursor..].iter().collect();
spans.push(Span::styled(seg, Style::new().fg(t.text)));
}
Line::from(spans)
}
fn git_color(s: crate::git::local::FileStatus, t: &Theme) -> Option<ratatui::style::Color> {
use crate::git::local::FileStatus::*;
Some(match s {
Modified | DirDirty => t.amber,
Added | Untracked => t.green,
Deleted => t.coral,
Renamed => t.mint,
Conflict => t.coral,
})
}
pub(super) fn file_prompt_title(p: &crate::app::FilePrompt) -> &'static str {
use crate::app::FilePromptKind::*;
match p.kind {
NewFile => "New file",
NewFolder => "New folder",
Rename => "Rename",
}
}
pub(super) fn draw_delete_confirm(
f: &mut RenderTarget,
area: Rect,
path: &std::path::Path,
hover: Option<(u16, u16)>,
t: &Theme,
) -> (Option<Rect>, Option<Rect>) {
use ratatui::layout::Alignment;
use ratatui::widgets::{Block, Borders, Clear};
let buf = f.buffer_mut();
for y in area.y..area.bottom() {
for x in area.x..area.right() {
if let Some(c) = buf.cell_mut((x, y)) {
c.set_bg(t.crust);
}
}
}
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let is_dir = path.is_dir();
let w = area.width.saturating_sub(6).clamp(30, 60).min(area.width);
let h = 6u16;
let mx = area.x + (area.width.saturating_sub(w)) / 2;
let my = area.y + (area.height.saturating_sub(h)) / 2;
let modal = Rect::new(mx, my, w, h);
f.render_widget(Clear, modal);
let block = Block::new()
.borders(Borders::ALL)
.border_style(Style::new().fg(t.coral).bg(t.surface0))
.style(Style::new().bg(t.surface0));
let inner = block.inner(modal);
f.render_widget(block, modal);
let what = if is_dir {
"folder (and its contents)"
} else {
"file"
};
f.render_widget(
Paragraph::new(Span::styled(
format!("Delete {what}?"),
Style::new().fg(t.text).bold(),
))
.alignment(Alignment::Center),
Rect::new(inner.x, inner.y, inner.width, 1),
);
f.render_widget(
Paragraph::new(Span::styled(name, Style::new().fg(t.coral).bold()))
.alignment(Alignment::Center),
Rect::new(inner.x, inner.y + 2, inner.width, 1),
);
let footer_y = inner.bottom().saturating_sub(1);
let del = " y delete ";
let cancel = " esc cancel ";
let dw = del.chars().count() as u16;
let del_rect = Rect::new(inner.x, footer_y, dw.min(inner.width), 1);
let cancel_x = (inner.x + dw + 1).min(inner.right());
let cancel_rect = Rect::new(
cancel_x,
footer_y,
(cancel.chars().count() as u16).min(inner.right().saturating_sub(cancel_x)),
1,
);
let over = |r: Rect| hover.is_some_and(|(c, hr)| c >= r.x && c < r.right() && hr == r.y);
let hl = |on: bool, fg| {
if on {
Style::new().fg(t.crust).bg(fg).bold()
} else {
Style::new().fg(fg).bold()
}
};
f.render_widget(
Paragraph::new(Span::styled(del, hl(over(del_rect), t.coral))),
del_rect,
);
f.render_widget(
Paragraph::new(Span::styled(cancel, hl(over(cancel_rect), t.overlay1))),
cancel_rect,
);
(Some(del_rect), Some(cancel_rect))
}