4daca8247c
Maintainability: - Choice options now carry a stable `code` separate from the display `label`. The command builder and recipe storage key off codes, so relabeling a menu entry no longer silently changes the encode or breaks a saved recipe. Format/Audio dispatch is now a clean code match instead of `starts_with` on display strings. Old recipes that stored labels still load via a label fallback. Correctness: - Fade start times now scale by the speed factor already applied in the filter chain, so Speed + Fade produces a fade in the right place regardless of op order (video and audio). - Two-pass pass 1 uses the platform null device (NUL on Windows). - The stderr drain thread is joined before Finished is sent, so the failure tail reported to the user is never truncated. Features / cleanup: - Accept multiple file arguments on the CLI to open batch mode directly. - try_recv() instead of recv_timeout(ZERO); drop unused import. Testing: - 23 unit tests covering build() arg generation, speed/fade ordering, recipe round-trip + old-label fallback, and the pure helpers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
535 lines
20 KiB
Rust
535 lines
20 KiB
Rust
//! 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::raw(" "),
|
|
Span::styled("\u{1f4c1} ", Style::default()),
|
|
Span::styled(e.name.clone(), Style::default().fg(LAVENDER)),
|
|
Span::raw("/"),
|
|
]))
|
|
} else {
|
|
let marked = app.marks.contains(&app.cwd.join(&e.name));
|
|
ListItem::new(Line::from(vec![
|
|
if marked {
|
|
Span::styled("\u{2713} ", Style::default().fg(GREEN))
|
|
} else {
|
|
Span::raw(" ")
|
|
},
|
|
Span::raw("\u{1f3ac} "),
|
|
Span::raw(e.name.clone()),
|
|
]))
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
let title = if app.marks.is_empty() {
|
|
"Files".to_string()
|
|
} else {
|
|
format!("Files \u{2014} {} marked for batch", app.marks.len())
|
|
};
|
|
let mut block = title_block(title);
|
|
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"),
|
|
("Space", "mark for batch"),
|
|
("u", "unmark all"),
|
|
("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 = match app.files.as_slice() {
|
|
[] => String::new(),
|
|
[(path, _)] => crate::app::file_name(path),
|
|
files => format!("{} files (batch)", files.len()),
|
|
};
|
|
|
|
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((path, m)) = app.files.first() {
|
|
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 app.is_batch() {
|
|
lines.insert(0, info_line("First file", &crate::app::file_name(path)));
|
|
lines.truncate(4);
|
|
lines.push(info_line(
|
|
"Batch",
|
|
&format!("+ {} more file(s)", app.files.len() - 1),
|
|
));
|
|
}
|
|
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, media)) = app.files.first() {
|
|
let built = ffmpeg::build(input, &app.ops, &app.stem_for(input), media);
|
|
let mut text = Text::default();
|
|
text.push_line(Line::styled(
|
|
format!("Output: {}", built.output.display()),
|
|
Style::default().fg(GREEN),
|
|
));
|
|
if app.is_batch() {
|
|
text.push_line(Line::styled(
|
|
format!(
|
|
"These edits run on all {} files (first one shown)",
|
|
app.files.len()
|
|
),
|
|
Style::default().fg(YELLOW),
|
|
));
|
|
}
|
|
text.push_line(Line::raw(""));
|
|
for line in ffmpeg::preview_string(&built).lines() {
|
|
let style = if line.starts_with('#') {
|
|
Style::default().fg(DIM)
|
|
} else {
|
|
Style::default().fg(TEXT)
|
|
};
|
|
text.push_line(Line::styled(line.to_string(), style));
|
|
}
|
|
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"),
|
|
("s", "save recipe"),
|
|
("l", "load recipe"),
|
|
("o", "output"),
|
|
("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].label)
|
|
}
|
|
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 (title, hint) = if app.is_batch() {
|
|
(
|
|
"Output suffix (added to each file's name)",
|
|
" (extension is chosen automatically)",
|
|
)
|
|
} else {
|
|
("Output file name", " (extension is chosen automatically)")
|
|
};
|
|
draw_text_input(f, title, text, hint);
|
|
}
|
|
|
|
Modal::SaveRecipe { text } => {
|
|
draw_text_input(
|
|
f,
|
|
"Save these edits as a recipe \u{2014} name?",
|
|
text,
|
|
" (apply later with 'l')",
|
|
);
|
|
}
|
|
|
|
Modal::LoadRecipe { selected } => {
|
|
let area = centered(f.area(), 56, app.recipes.len() as u16 + 4);
|
|
f.render_widget(Clear, area);
|
|
let items: Vec<ListItem> = app
|
|
.recipes
|
|
.iter()
|
|
.map(|r| {
|
|
ListItem::new(Line::from(vec![
|
|
Span::styled(
|
|
format!(" {:<30}", r.name),
|
|
Style::default().fg(YELLOW),
|
|
),
|
|
Span::styled(
|
|
format!("{} edit(s)", r.ops.len()),
|
|
Style::default().fg(SUBTLE),
|
|
),
|
|
]))
|
|
})
|
|
.collect();
|
|
let mut state = ListState::default();
|
|
state.select(Some(*selected));
|
|
let list = List::new(items)
|
|
.block(
|
|
title_block("Apply a recipe").title_bottom(Line::from(Span::styled(
|
|
" Enter apply \u{2502} d delete \u{2502} Esc cancel ",
|
|
Style::default().fg(DIM),
|
|
))),
|
|
)
|
|
.highlight_style(
|
|
Style::default().bg(ACCENT).fg(ON_ACCENT).add_modifier(Modifier::BOLD),
|
|
);
|
|
f.render_stateful_widget(list, area, &mut state);
|
|
}
|
|
|
|
Modal::Running => {
|
|
let area = centered(f.area(), 64, 9);
|
|
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 [head_area, gauge_area, info_area, err_area] = Layout::vertical([
|
|
Constraint::Length(1),
|
|
Constraint::Length(3),
|
|
Constraint::Length(1),
|
|
Constraint::Min(0),
|
|
])
|
|
.areas(inner);
|
|
|
|
let job = &rs.jobs[rs.job_idx];
|
|
let mut head = String::new();
|
|
if rs.jobs.len() > 1 {
|
|
head.push_str(&format!("File {}/{}: ", rs.job_idx + 1, rs.jobs.len()));
|
|
}
|
|
head.push_str(&job.input_name);
|
|
if job.built.passes.len() > 1 {
|
|
head.push_str(&format!(
|
|
" \u{2014} pass {}/{}",
|
|
rs.pass_idx + 1,
|
|
job.built.passes.len()
|
|
));
|
|
}
|
|
f.render_widget(Paragraph::new(head).alignment(Alignment::Center), head_area);
|
|
|
|
let expected = job.built.expected_duration;
|
|
let ratio = if expected > 0.0 {
|
|
(rs.secs / 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(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 draw_text_input(f: &mut Frame, title: &str, text: &str, hint: &str) {
|
|
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(hint.to_string(), 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(title.to_string())),
|
|
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,
|
|
}
|
|
}
|