Initial release: friendly TUI for FFmpeg

File browser, stackable edits (trim, resize, crop, rotate, speed,
color, effects, fps, compress, convert, audio) with a live preview
of the generated ffmpeg command, progress bar with cancel, audio-aware
edit menus, and Catppuccin Macchiato theme.
This commit is contained in:
2026-06-11 11:23:01 +01:00
commit 471aa6fb0c
9 changed files with 2606 additions and 0 deletions
+430
View File
@@ -0,0 +1,430 @@
//! All rendering. The app is two screens (file browser, editor) plus
//! centered modal popups.
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{
Block, BorderType, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph, Wrap,
};
use ratatui::Frame;
use crate::app::{App, Modal, Screen};
use crate::ffmpeg;
use crate::ops::FieldValue;
// Catppuccin Macchiato (https://catppuccin.com), blue accent.
const ACCENT: Color = Color::Rgb(0x8a, 0xad, 0xf4); // blue
const ON_ACCENT: Color = Color::Rgb(0x24, 0x27, 0x3a); // base, for text on accent
const TEXT: Color = Color::Rgb(0xca, 0xd3, 0xf5); // text
const SUBTLE: Color = Color::Rgb(0xa5, 0xad, 0xcb); // subtext0
const DIM: Color = Color::Rgb(0x6e, 0x73, 0x8d); // overlay0
const YELLOW: Color = Color::Rgb(0xee, 0xd4, 0x9f);
const GREEN: Color = Color::Rgb(0xa6, 0xda, 0x95);
const RED: Color = Color::Rgb(0xed, 0x87, 0x96);
const LAVENDER: Color = Color::Rgb(0xb7, 0xbd, 0xf8);
pub fn draw(f: &mut Frame, app: &mut App) {
match app.screen {
Screen::Browser => draw_browser(f, app),
Screen::Editor => draw_editor(f, app),
}
// Modals render on top of whichever screen is active.
let modal_ptr = app.modal.take();
if let Some(modal) = &modal_ptr {
draw_modal(f, app, modal);
}
app.modal = modal_ptr;
}
fn title_block(title: impl Into<String>) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(
format!(" {} ", title.into()),
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
))
}
fn key_hint(line: &[(&str, &str)]) -> Line<'static> {
let mut spans = Vec::new();
for (key, what) in line {
spans.push(Span::styled(
format!(" {} ", key),
Style::default().fg(ON_ACCENT).bg(ACCENT),
));
spans.push(Span::styled(format!(" {} ", what), Style::default().fg(SUBTLE)));
}
Line::from(spans)
}
// ---------------------------------------------------------------------------
// Browser
fn draw_browser(f: &mut Frame, app: &mut App) {
let [header, main, footer] =
Layout::vertical([Constraint::Length(3), Constraint::Min(0), Constraint::Length(1)])
.areas(f.area());
let banner = Paragraph::new(Line::from(vec![
Span::styled("lazyff ", Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)),
Span::styled("— pick a video or audio file to edit", Style::default().fg(SUBTLE)),
]))
.block(title_block(app.cwd.display().to_string()));
f.render_widget(banner, header);
let items: Vec<ListItem> = app
.entries
.iter()
.map(|e| {
if e.is_dir {
ListItem::new(Line::from(vec![
Span::styled("\u{1f4c1} ", Style::default()),
Span::styled(e.name.clone(), Style::default().fg(LAVENDER)),
Span::raw("/"),
]))
} else {
ListItem::new(Line::from(vec![
Span::raw("\u{1f3ac} "),
Span::raw(e.name.clone()),
]))
}
})
.collect();
let mut block = title_block("Files");
if let Some(err) = &app.browser_error {
block = block.title_bottom(Line::from(Span::styled(
format!(" {} ", err.lines().next().unwrap_or("error")),
Style::default().fg(RED),
)));
}
let list = List::new(items)
.block(block)
.highlight_style(Style::default().bg(ACCENT).fg(ON_ACCENT).add_modifier(Modifier::BOLD))
.highlight_symbol(" ");
f.render_stateful_widget(list, main, &mut app.browser_state);
let hints = key_hint(&[
("\u{2191}\u{2193}", "move"),
("Enter", "open"),
("Backspace", "up a folder"),
("q", "quit"),
]);
f.render_widget(Paragraph::new(hints), footer);
}
// ---------------------------------------------------------------------------
// Editor
fn draw_editor(f: &mut Frame, app: &mut App) {
let [main, footer] =
Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(f.area());
let [left, right] =
Layout::horizontal([Constraint::Percentage(52), Constraint::Percentage(48)]).areas(main);
// Left: list of queued operations.
let input_name = app
.input
.as_ref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let items: Vec<ListItem> = if app.ops.is_empty() {
vec![ListItem::new(Text::from(vec![
Line::raw(""),
Line::styled(" No edits yet.", Style::default().fg(DIM)),
Line::styled(" Press 'a' to add one (trim, resize, compress...).", Style::default().fg(DIM)),
]))]
} else {
app.ops
.iter()
.enumerate()
.map(|(i, op)| {
ListItem::new(Line::from(vec![
Span::styled(format!(" {}. ", i + 1), Style::default().fg(DIM)),
Span::styled(
format!("{:<15} ", op.kind.name()),
Style::default().fg(YELLOW),
),
Span::raw(op.summary()),
]))
})
.collect()
};
let list = List::new(items)
.block(title_block(format!("Edits \u{2014} {}", input_name)))
.highlight_style(Style::default().bg(ACCENT).fg(ON_ACCENT).add_modifier(Modifier::BOLD));
f.render_stateful_widget(list, left, &mut app.op_state);
// Right: media info on top, command preview below.
let [info_area, cmd_area] =
Layout::vertical([Constraint::Length(9), Constraint::Min(0)]).areas(right);
if let Some(m) = &app.media {
let mut lines = vec![
info_line("Container", &m.format),
info_line("Duration", &ffmpeg::fmt_clock(m.duration)),
info_line("Size", &ffmpeg::fmt_size(m.size_bytes)),
];
if let (Some(c), Some(w), Some(h)) = (&m.video_codec, m.width, m.height) {
let fps = m.fps.map(|f| format!(" @ {:.2} fps", f)).unwrap_or_default();
lines.push(info_line("Video", &format!("{} {}\u{d7}{}{}", c, w, h, fps)));
} else {
lines.push(info_line("Video", "none (audio file)"));
}
lines.push(info_line(
"Audio",
m.audio_codec.as_deref().unwrap_or("none"),
));
f.render_widget(
Paragraph::new(lines).block(title_block("Media info")),
info_area,
);
}
// Command preview, rebuilt every frame (cheap) so it always matches.
if let (Some(input), Some(media)) = (&app.input, &app.media) {
let built = ffmpeg::build(input, &app.ops, &app.output_stem, media);
let mut text = Text::default();
text.push_line(Line::styled(
format!("Output: {}", built.output.display()),
Style::default().fg(GREEN),
));
text.push_line(Line::raw(""));
text.push_line(Line::styled(
ffmpeg::preview_string(&built.args),
Style::default().fg(TEXT),
));
if !built.notes.is_empty() {
text.push_line(Line::raw(""));
for n in &built.notes {
text.push_line(Line::styled(
format!("\u{2139} {}", n),
Style::default().fg(YELLOW),
));
}
}
f.render_widget(
Paragraph::new(text)
.wrap(Wrap { trim: false })
.block(title_block("Command (what lazyff will run)")),
cmd_area,
);
}
let hints = key_hint(&[
("a", "add edit"),
("Enter", "change"),
("d", "delete"),
("J/K", "reorder"),
("o", "output name"),
("r", "run!"),
("Esc", "files"),
("q", "quit"),
]);
f.render_widget(Paragraph::new(hints), footer);
}
fn info_line(label: &str, value: &str) -> Line<'static> {
Line::from(vec![
Span::styled(format!(" {:<10}", label), Style::default().fg(DIM)),
Span::raw(value.to_string()),
])
}
// ---------------------------------------------------------------------------
// Modals
fn draw_modal(f: &mut Frame, app: &mut App, modal: &Modal) {
match modal {
Modal::AddOp { selected } => {
let kinds = app.available_kinds();
let title = if app.has_video() {
"Add an edit (Enter to choose, Esc to cancel)"
} else {
"Add an edit \u{2014} audio file (Enter to choose, Esc to cancel)"
};
let area = centered(f.area(), 64, kinds.len() as u16 + 2);
f.render_widget(Clear, area);
let items: Vec<ListItem> = kinds
.iter()
.map(|k| {
ListItem::new(Line::from(vec![
Span::styled(
format!(" {:<15} ", k.name()),
Style::default().fg(YELLOW),
),
Span::styled(k.blurb(), Style::default().fg(SUBTLE)),
]))
})
.collect();
let mut state = ListState::default();
state.select(Some(*selected));
let list = List::new(items)
.block(title_block(title))
.highlight_style(
Style::default().bg(ACCENT).fg(ON_ACCENT).add_modifier(Modifier::BOLD),
);
f.render_stateful_widget(list, area, &mut state);
}
Modal::EditOp { field, op, .. } => {
let area = centered(f.area(), 66, op.fields.len() as u16 + 4);
f.render_widget(Clear, area);
let mut lines = vec![Line::raw("")];
for (i, fld) in op.fields.iter().enumerate() {
let selected = i == *field;
let marker = if selected { " \u{25b8} " } else { " " };
let label_style = if selected {
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(SUBTLE)
};
let value = match &fld.value {
FieldValue::Choice { options, selected: s } => {
format!("\u{25c2} {} \u{25b8}", options[*s])
}
FieldValue::Text(s) => {
if selected {
format!("{}\u{2581}", s)
} else if s.is_empty() {
"(empty)".to_string()
} else {
s.clone()
}
}
};
lines.push(Line::from(vec![
Span::raw(marker.to_string()),
Span::styled(format!("{:<28}", fld.label), label_style),
Span::styled(
value,
if selected {
Style::default().fg(TEXT).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(SUBTLE)
},
),
]));
}
lines.push(Line::raw(""));
lines.push(Line::styled(
" \u{2191}\u{2193} field \u{2502} \u{2190}\u{2192} change \u{2502} type to edit \u{2502} Enter save \u{2502} Esc cancel",
Style::default().fg(DIM),
));
f.render_widget(
Paragraph::new(lines).block(title_block(op.kind.name())),
area,
);
}
Modal::Output { text } => {
let area = centered(f.area(), 60, 5);
f.render_widget(Clear, area);
let lines = vec![
Line::raw(""),
Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{}\u{2581}", text),
Style::default().fg(TEXT).add_modifier(Modifier::BOLD),
),
Span::styled(
" (extension is chosen automatically)",
Style::default().fg(DIM),
),
]),
Line::styled(" Enter save \u{2502} Esc cancel", Style::default().fg(DIM)),
];
f.render_widget(
Paragraph::new(lines).block(title_block("Output file name")),
area,
);
}
Modal::Running => {
let area = centered(f.area(), 64, 8);
f.render_widget(Clear, area);
let block = title_block("Encoding\u{2026} (Esc to cancel)");
let inner = block.inner(area);
f.render_widget(block, area);
f.render_widget(Clear, inner);
if let Some(rs) = &app.run {
let [gauge_area, info_area, err_area] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(1),
Constraint::Min(0),
])
.areas(inner);
let ratio = if rs.expected > 0.0 {
(rs.secs / rs.expected).clamp(0.0, 1.0)
} else {
0.0
};
let gauge = Gauge::default()
.gauge_style(Style::default().fg(ACCENT))
.ratio(ratio)
.label(format!("{:.0}%", ratio * 100.0));
f.render_widget(gauge, gauge_area.inner(ratatui::layout::Margin::new(1, 1)));
let speed = if rs.speed.is_empty() { "?".into() } else { rs.speed.clone() };
let info = Paragraph::new(format!(
"{} / {} speed {}",
ffmpeg::fmt_clock(rs.secs),
ffmpeg::fmt_clock(rs.expected),
speed
))
.alignment(Alignment::Center);
f.render_widget(info, info_area);
if !rs.errors.is_empty() {
let last = rs.errors.last().cloned().unwrap_or_default();
f.render_widget(
Paragraph::new(Line::styled(last, Style::default().fg(RED)))
.wrap(Wrap { trim: true }),
err_area,
);
}
}
}
Modal::Done { success, title, message } => {
let wanted = message.lines().count() as u16 + 4;
let area = centered(f.area(), 70, wanted.min(f.area().height.saturating_sub(4)));
f.render_widget(Clear, area);
let color = if *success { GREEN } else { RED };
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(color))
.title(Span::styled(
format!(" {} ", title),
Style::default().fg(color).add_modifier(Modifier::BOLD),
))
.title_bottom(Line::from(Span::styled(
" Enter to close ",
Style::default().fg(DIM),
)));
f.render_widget(
Paragraph::new(message.clone()).wrap(Wrap { trim: false }).block(block),
area,
);
}
}
}
fn centered(area: Rect, width: u16, height: u16) -> Rect {
let w = width.min(area.width.saturating_sub(2));
let h = height.min(area.height.saturating_sub(2));
Rect {
x: area.x + (area.width.saturating_sub(w)) / 2,
y: area.y + (area.height.saturating_sub(h)) / 2,
width: w,
height: h,
}
}