//! 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) -> 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 = 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 = 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 = 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 = 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, } }