use humansize::{format_size, BINARY};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph, Wrap},
Frame,
};
use crate::app::{App, Focus};
pub fn draw(f: &mut Frame, app: &App) {
let root = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(5),
Constraint::Length(1),
Constraint::Length(1),
])
.split(f.area());
draw_header(f, root[0], app);
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(65), Constraint::Percentage(35)])
.split(root[1]);
draw_files(f, body[0], app);
draw_disks(f, body[1], app);
draw_status(f, root[2], app);
draw_help(f, root[3]);
if app.confirming_delete {
draw_confirm(f, app);
}
}
fn draw_header(f: &mut Frame, area: Rect, app: &App) {
let text = Line::from(vec![
Span::styled("diskr ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw("· "),
Span::styled(
app.cwd.display().to_string(),
Style::default().fg(Color::Cyan),
),
Span::raw(format!(
" [sort: {}, hidden: {}]",
app.sort.label(),
if app.show_hidden { "on" } else { "off" }
)),
]);
f.render_widget(Paragraph::new(text), area);
}
fn draw_files(f: &mut Frame, area: Rect, app: &App) {
let border_color = if app.focus == Focus::Files {
Color::Yellow
} else {
Color::DarkGray
};
let items: Vec<ListItem> = app
.entries
.iter()
.map(|e| {
let size_str = match (e.is_dir, e.size, e.scanning) {
(true, _, true) => String::from("scanning…"),
(true, Some(n), _) => format_size(n, BINARY),
(true, None, _) => String::from("—"),
(false, Some(n), _) => format_size(n, BINARY),
(false, None, _) => String::from("?"),
};
let icon = if e.is_dir { "▸ " } else { " " };
let name_style = if e.is_dir {
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let line = Line::from(vec![
Span::styled(icon, Style::default().fg(Color::DarkGray)),
Span::styled(format!("{:<40}", truncate(&e.name, 40)), name_style),
Span::raw(" "),
Span::styled(
format!("{:>12}", size_str),
Style::default().fg(Color::Green),
),
]);
ListItem::new(line)
})
.collect();
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title("files")
.border_style(Style::default().fg(border_color)),
)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶ ");
let mut state = ListState::default();
state.select(Some(app.selected));
f.render_stateful_widget(list, area, &mut state);
}
fn draw_disks(f: &mut Frame, area: Rect, app: &App) {
let border_color = if app.focus == Focus::Disks {
Color::Yellow
} else {
Color::DarkGray
};
let block = Block::default()
.borders(Borders::ALL)
.title("disks")
.border_style(Style::default().fg(border_color));
let inner = block.inner(area);
f.render_widget(block, area);
if app.disks.is_empty() {
return;
}
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints(
app.disks
.iter()
.map(|_| Constraint::Length(4))
.collect::<Vec<_>>(),
)
.split(inner);
for (i, d) in app.disks.iter().enumerate() {
if i >= rows.len() {
break;
}
let used = d.total.saturating_sub(d.available);
let pct = if d.total > 0 {
(used as f64 / d.total as f64 * 100.0) as u16
} else {
0
};
let disk_name = if d.name.is_empty() {
d.mount.display().to_string()
} else {
format!("{} {}", d.name, d.mount.display())
};
let label = format!(
"{} {} / {}",
disk_name,
format_size(used, BINARY),
format_size(d.total, BINARY)
);
let color = if pct > 90 {
Color::Red
} else if pct > 75 {
Color::Yellow
} else {
Color::Green
};
let gauge = Gauge::default()
.block(Block::default().title(label))
.gauge_style(Style::default().fg(color))
.percent(pct.min(100));
f.render_widget(gauge, rows[i]);
}
}
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
let text = Line::from(Span::styled(&app.status, Style::default().fg(Color::White)));
f.render_widget(Paragraph::new(text).wrap(Wrap { trim: true }), area);
}
fn draw_help(f: &mut Frame, area: Rect) {
let key = |k: &'static str| {
Span::styled(
k,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
};
let label = |s: &'static str| Span::styled(s, Style::default().fg(Color::Gray));
let sep = || Span::styled(" · ", Style::default().fg(Color::DarkGray));
let text = Line::from(vec![
key("↑↓/jk"),
label(" move"),
sep(),
key("⏎"),
label(" open"),
sep(),
key("⌫"),
label(" up"),
sep(),
key("r"),
label(" reload"),
sep(),
key("o"),
label(" sort"),
sep(),
key("."),
label(" hidden"),
sep(),
key("d"),
label(" trash"),
sep(),
key("Tab"),
label(" pane"),
sep(),
key("q"),
label(" quit"),
]);
f.render_widget(Paragraph::new(text), area);
}
fn draw_confirm(f: &mut Frame, app: &App) {
let area = centered_rect(60, 20, f.area());
f.render_widget(Clear, area);
let name = app
.entries
.get(app.selected)
.map(|e| e.name.as_str())
.unwrap_or("?");
let body = vec![
Line::from(""),
Line::from(Span::styled(
format!("Move to Trash: {}", name),
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from("press y to confirm · n to cancel"),
];
let block = Block::default()
.borders(Borders::ALL)
.title("confirm")
.border_style(Style::default().fg(Color::Red));
f.render_widget(
Paragraph::new(body)
.block(block)
.alignment(ratatui::layout::Alignment::Center),
area,
);
}
fn centered_rect(px: u16, py: u16, area: Rect) -> Rect {
let popup = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - py) / 2),
Constraint::Percentage(py),
Constraint::Percentage((100 - py) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - px) / 2),
Constraint::Percentage(px),
Constraint::Percentage((100 - px) / 2),
])
.split(popup[1])[1]
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
}