Compare commits

...

2 Commits

Author SHA1 Message Date
otto 017d9ad006 Decompose build() into a Plan accumulator with named phases
The 250-line build() god-function is split into:
- resolve_timeline(): collapse Trim ops into a Timeline.
- Plan: the mutable arg/filter accumulator, with one method per phase —
  translate() (per-op dispatch), translate_format()/start_audio_only()
  (format handling), drop_inapplicable_video_filters(),
  resolve_stream_copy(), resolve_target_size() (two-pass planning),
  filter_args() (vf/af vs gif palette graph), assemble_passes().
- build() now just orchestrates these in order.

Pure refactor: argument output is byte-for-byte identical, guarded by
the existing 23-test suite (all green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:51:05 +01:00
otto 4daca8247c Decouple option codes, fix encode bugs, add test suite
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>
2026-06-17 15:47:16 +01:00
7 changed files with 1018 additions and 466 deletions
Generated
+1 -1
View File
@@ -204,7 +204,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "lazyff" name = "lazyff"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ratatui", "ratatui",
+17 -8
View File
@@ -2,7 +2,6 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::Result; use anyhow::Result;
use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::crossterm::event::{KeyCode, KeyEvent};
@@ -92,7 +91,7 @@ pub struct App {
} }
impl App { impl App {
pub fn new(initial: Option<PathBuf>) -> Result<App> { pub fn new(initial: Vec<PathBuf>) -> Result<App> {
let mut app = App { let mut app = App {
should_quit: false, should_quit: false,
screen: Screen::Browser, screen: Screen::Browser,
@@ -109,13 +108,23 @@ impl App {
recipes: Vec::new(), recipes: Vec::new(),
run: None, run: None,
}; };
if let Some(path) = initial { if !initial.is_empty() {
let info = ffmpeg::probe(&path)?; let mut files = Vec::new();
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { for path in initial {
let info = ffmpeg::probe(&path)?;
files.push((path, info));
}
if let Some(parent) =
files[0].0.parent().filter(|p| !p.as_os_str().is_empty())
{
app.cwd = parent.to_path_buf(); app.cwd = parent.to_path_buf();
} }
app.output_stem = format!("{}_lazyff", file_stem(&path)); app.output_stem = if files.len() == 1 {
app.files = vec![(path, info)]; format!("{}_lazyff", file_stem(&files[0].0))
} else {
"_lazyff".into()
};
app.files = files;
app.screen = Screen::Editor; app.screen = Screen::Editor;
} }
app.refresh_entries(); app.refresh_entries();
@@ -178,7 +187,7 @@ impl App {
pub fn poll_run_messages(&mut self) { pub fn poll_run_messages(&mut self) {
let Some(rs) = &mut self.run else { return }; let Some(rs) = &mut self.run else { return };
let mut finished = false; let mut finished = false;
while let Ok(msg) = rs.runner.rx.recv_timeout(Duration::ZERO) { while let Ok(msg) = rs.runner.rx.try_recv() {
match msg { match msg {
RunMsg::Progress { secs, speed } => { RunMsg::Progress { secs, speed } => {
rs.secs = secs; rs.secs = secs;
+681 -297
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -14,8 +14,8 @@ use ratatui::DefaultTerminal;
use app::App; use app::App;
fn main() -> Result<()> { fn main() -> Result<()> {
let mut initial: Option<std::path::PathBuf> = None; let mut initial: Vec<std::path::PathBuf> = Vec::new();
if let Some(arg) = std::env::args().nth(1) { for arg in std::env::args().skip(1) {
match arg.as_str() { match arg.as_str() {
"--version" | "-V" => { "--version" | "-V" => {
println!("lazyff {}", env!("CARGO_PKG_VERSION")); println!("lazyff {}", env!("CARGO_PKG_VERSION"));
@@ -23,9 +23,10 @@ fn main() -> Result<()> {
} }
"--help" | "-h" => { "--help" | "-h" => {
println!("lazyff {} — a friendly TUI for FFmpeg", env!("CARGO_PKG_VERSION")); println!("lazyff {} — a friendly TUI for FFmpeg", env!("CARGO_PKG_VERSION"));
println!("Usage: lazyff [FILE]"); println!("Usage: lazyff [FILE...]");
println!(" no argument open a file browser in the current directory"); println!(" no argument open a file browser in the current directory");
println!(" FILE open this video/audio file straight in the editor"); println!(" FILE open this video/audio file straight in the editor");
println!(" FILE FILE... open several files together in batch mode");
return Ok(()); return Ok(());
} }
_ => { _ => {
@@ -33,7 +34,7 @@ fn main() -> Result<()> {
if !path.is_file() { if !path.is_file() {
bail!("{arg}: no such file"); bail!("{arg}: no such file");
} }
initial = Some(path); initial.push(path);
} }
} }
} }
+282 -155
View File
@@ -3,8 +3,13 @@
//! Every operation is a uniform list of fields (either a multiple-choice //! Every operation is a uniform list of fields (either a multiple-choice
//! value or a free-text value) so the form UI and the command builder can //! value or a free-text value) so the form UI and the command builder can
//! treat them generically. //! treat them generically.
//!
//! Choice options carry a stable `code` (used by the command builder and by
//! recipe storage) separate from their human `label` (shown in the UI), so
//! relabeling a menu entry never changes what ffmpeg does or breaks a saved
//! recipe.
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum OpKind { pub enum OpKind {
Trim, Trim,
Resize, Resize,
@@ -77,145 +82,196 @@ impl OpKind {
/// Default fields. Some forms offer different choices depending on /// Default fields. Some forms offer different choices depending on
/// whether the input has a video stream. /// whether the input has a video stream.
///
/// Option lists live in `const` bindings because rvalue static promotion
/// doesn't reach `const fn` calls inside an inline `&[...]`.
pub fn build(self, has_video: bool) -> Operation { pub fn build(self, has_video: bool) -> Operation {
let fields = match self { let fields = match self {
Trim => vec![ Trim => {
Field::text("Start (h:mm:ss)", "0:00"), const MODE: &[Opt] = &[
Field::text("End (h:mm:ss, empty = end)", ""), Opt::new("reencode", "Re-encode (precise)"),
Field::choice( Opt::new("copy", "Stream copy (instant, trim only)"),
"Mode", ];
&["Re-encode (precise)", "Stream copy (instant, trim only)"], vec![
0, Field::text("Start (h:mm:ss)", "0:00"),
), Field::text("End (h:mm:ss, empty = end)", ""),
], Field::choice("Mode", MODE, 0),
Resize => vec![ ]
Field::choice( }
"Size", Resize => {
&[ const SIZES: &[Opt] = &[
"1080p", "720p", "480p", "360p", "1440p", "4K (2160p)", "Half size", Opt::same("1080p"),
"Custom", Opt::same("720p"),
], Opt::same("480p"),
0, Opt::same("360p"),
), Opt::same("1440p"),
Field::text("Custom width (if Custom)", ""), Opt::new("2160p", "4K (2160p)"),
Field::text("Custom height (if Custom)", ""), Opt::new("half", "Half size"),
], Opt::new("custom", "Custom"),
Crop => vec![ ];
Field::choice( vec![
"Shape (centered)", Field::choice("Size", SIZES, 0),
&[ Field::text("Custom width (if Custom)", ""),
"Square (1:1)", Field::text("Custom height (if Custom)", ""),
"Widescreen (16:9)", ]
"Vertical (9:16)", }
"Classic (4:3)", Crop => {
"Custom", const SHAPES: &[Opt] = &[
], Opt::new("square", "Square (1:1)"),
0, Opt::new("16:9", "Widescreen (16:9)"),
), Opt::new("9:16", "Vertical (9:16)"),
Field::text("Custom width (if Custom)", ""), Opt::new("4:3", "Classic (4:3)"),
Field::text("Custom height (if Custom)", ""), Opt::new("custom", "Custom"),
Field::text("Custom X (empty = center)", ""), ];
Field::text("Custom Y (empty = center)", ""), vec![
], Field::choice("Shape (centered)", SHAPES, 0),
Rotate => vec![Field::choice( Field::text("Custom width (if Custom)", ""),
"Rotation", Field::text("Custom height (if Custom)", ""),
&[ Field::text("Custom X (empty = center)", ""),
"90\u{b0} clockwise", Field::text("Custom Y (empty = center)", ""),
"90\u{b0} counter-clockwise", ]
"180\u{b0}", }
"Mirror horizontally", Rotate => {
"Mirror vertically", const ROT: &[Opt] = &[
], Opt::new("cw90", "90\u{b0} clockwise"),
0, Opt::new("ccw90", "90\u{b0} counter-clockwise"),
)], Opt::new("180", "180\u{b0}"),
Speed => vec![Field::choice( Opt::new("hflip", "Mirror horizontally"),
"Speed", Opt::new("vflip", "Mirror vertically"),
&["2x", "1.5x", "1.25x", "0.75x", "0.5x", "0.25x", "3x", "4x"], ];
0, vec![Field::choice("Rotation", ROT, 0)]
)], }
Adjust => vec![ Speed => {
Field::choice( const SPEEDS: &[Opt] = &[
"Brightness", Opt::new("2.0", "2x"),
&["-0.3", "-0.2", "-0.1", "0", "+0.1", "+0.2", "+0.3"], Opt::new("1.5", "1.5x"),
3, Opt::new("1.25", "1.25x"),
), Opt::new("0.75", "0.75x"),
Field::choice("Contrast", &["0.8", "0.9", "1.0", "1.1", "1.2", "1.4"], 2), Opt::new("0.5", "0.5x"),
Field::choice( Opt::new("0.25", "0.25x"),
"Saturation", Opt::new("3.0", "3x"),
&["0.0", "0.5", "0.8", "1.0", "1.2", "1.5", "2.0"], Opt::new("4.0", "4x"),
3, ];
), vec![Field::choice("Speed", SPEEDS, 0)]
], }
Effect => vec![Field::choice( Adjust => {
"Effect", const BRIGHT: &[Opt] = &[
&[ Opt::same("-0.3"),
"Grayscale", Opt::same("-0.2"),
"Sepia", Opt::same("-0.1"),
"Blur", Opt::same("0"),
"Sharpen", Opt::new("0.1", "+0.1"),
"Vignette", Opt::new("0.2", "+0.2"),
"Denoise", Opt::new("0.3", "+0.3"),
"Fade in", ];
"Fade out", const CONTRAST: &[Opt] = &[
"Fade in + out", Opt::same("0.8"),
], Opt::same("0.9"),
0, Opt::same("1.0"),
)], Opt::same("1.1"),
Fps => vec![Field::choice("Frame rate", &["30", "24", "60", "15", "12"], 0)], Opt::same("1.2"),
Compress => vec![ Opt::same("1.4"),
Field::choice( ];
"Codec", const SAT: &[Opt] = &[
&["H.264 (most compatible)", "H.265 (smaller files)"], Opt::same("0.0"),
0, Opt::same("0.5"),
), Opt::same("0.8"),
Field::choice( Opt::same("1.0"),
"Quality", Opt::same("1.2"),
&["Balanced", "High (bigger file)", "Small", "Tiny (worst quality)"], Opt::same("1.5"),
0, Opt::same("2.0"),
), ];
Field::choice( vec![
"Encoding speed", Field::choice("Brightness", BRIGHT, 3),
&["Medium", "Fast (bigger file)", "Slow (smaller file)"], Field::choice("Contrast", CONTRAST, 2),
0, Field::choice("Saturation", SAT, 3),
), ]
], }
Effect => {
const EFFECTS: &[Opt] = &[
Opt::new("grayscale", "Grayscale"),
Opt::new("sepia", "Sepia"),
Opt::new("blur", "Blur"),
Opt::new("sharpen", "Sharpen"),
Opt::new("vignette", "Vignette"),
Opt::new("denoise", "Denoise"),
Opt::new("fadein", "Fade in"),
Opt::new("fadeout", "Fade out"),
Opt::new("fadeinout", "Fade in + out"),
];
vec![Field::choice("Effect", EFFECTS, 0)]
}
Fps => {
const RATES: &[Opt] = &[
Opt::same("30"),
Opt::same("24"),
Opt::same("60"),
Opt::same("15"),
Opt::same("12"),
];
vec![Field::choice("Frame rate", RATES, 0)]
}
Compress => {
const CODEC: &[Opt] = &[
Opt::new("h264", "H.264 (most compatible)"),
Opt::new("h265", "H.265 (smaller files)"),
];
const QUALITY: &[Opt] = &[
Opt::new("balanced", "Balanced"),
Opt::new("high", "High (bigger file)"),
Opt::new("small", "Small"),
Opt::new("tiny", "Tiny (worst quality)"),
];
const SPEED: &[Opt] = &[
Opt::new("medium", "Medium"),
Opt::new("fast", "Fast (bigger file)"),
Opt::new("slow", "Slow (smaller file)"),
];
vec![
Field::choice("Codec", CODEC, 0),
Field::choice("Quality", QUALITY, 0),
Field::choice("Encoding speed", SPEED, 0),
]
}
TargetSize => vec![Field::text("Target size (MB)", "25")], TargetSize => vec![Field::text("Target size (MB)", "25")],
Format => { Format => {
let options: &'static [&'static str] = if has_video { const VIDEO: &[Opt] = &[
&[ Opt::new("mp4", "MP4 video"),
"MP4 video", Opt::new("mkv", "MKV video"),
"MKV video", Opt::new("webm", "WebM video"),
"WebM video", Opt::new("mov", "MOV video"),
"MOV video", Opt::new("gif", "GIF animation"),
"GIF animation", Opt::new("mp3", "MP3 (audio only)"),
"MP3 (audio only)", Opt::new("m4a", "M4A (audio only)"),
"M4A (audio only)", Opt::new("wav", "WAV (audio only)"),
"WAV (audio only)", ];
] const AUDIO: &[Opt] = &[
} else { Opt::new("mp3", "MP3"),
&["MP3", "M4A (AAC)", "FLAC (lossless)", "WAV (lossless)", "OGG (Opus)"] Opt::new("m4a", "M4A (AAC)"),
}; Opt::new("flac", "FLAC (lossless)"),
Opt::new("wav", "WAV (lossless)"),
Opt::new("ogg", "OGG (Opus)"),
];
let options = if has_video { VIDEO } else { AUDIO };
vec![Field::choice("Convert to", options, 0)] vec![Field::choice("Convert to", options, 0)]
} }
Audio => { Audio => {
let options: &'static [&'static str] = if has_video { const VIDEO: &[Opt] = &[
&[ Opt::new("remove", "Remove audio"),
"Remove audio", Opt::new("vol+50", "Volume +50%"),
"Volume +50%", Opt::new("vol2x", "Volume 2x"),
"Volume 2x", Opt::new("vol-50", "Volume -50%"),
"Volume -50%", Opt::new("normalize", "Normalize loudness"),
"Normalize loudness", Opt::new("fade", "Fade in + out"),
"Fade in + out", ];
] const AUDIO_ONLY: &[Opt] = &[
} else { Opt::new("vol+50", "Volume +50%"),
&[ Opt::new("vol2x", "Volume 2x"),
"Volume +50%", Opt::new("vol-50", "Volume -50%"),
"Volume 2x", Opt::new("normalize", "Normalize loudness"),
"Volume -50%", Opt::new("fade", "Fade in + out"),
"Normalize loudness", ];
"Fade in + out", let options = if has_video { VIDEO } else { AUDIO_ONLY };
]
};
vec![Field::choice("Audio", options, 0)] vec![Field::choice("Audio", options, 0)]
} }
}; };
@@ -223,12 +279,27 @@ impl OpKind {
} }
} }
/// One option in a multiple-choice field: a stable `code` the rest of the
/// program keys off, plus a human `label` for display.
#[derive(Clone, Copy)]
pub struct Opt {
pub code: &'static str,
pub label: &'static str,
}
impl Opt {
const fn new(code: &'static str, label: &'static str) -> Self {
Opt { code, label }
}
/// An option whose code and label are identical.
const fn same(s: &'static str) -> Self {
Opt { code: s, label: s }
}
}
#[derive(Clone)] #[derive(Clone)]
pub enum FieldValue { pub enum FieldValue {
Choice { Choice { options: &'static [Opt], selected: usize },
options: &'static [&'static str],
selected: usize,
},
Text(String), Text(String),
} }
@@ -239,7 +310,7 @@ pub struct Field {
} }
impl Field { impl Field {
fn choice(label: &'static str, options: &'static [&'static str], selected: usize) -> Self { fn choice(label: &'static str, options: &'static [Opt], selected: usize) -> Self {
Field { label, value: FieldValue::Choice { options, selected } } Field { label, value: FieldValue::Choice { options, selected } }
} }
fn text(label: &'static str, initial: &str) -> Self { fn text(label: &'static str, initial: &str) -> Self {
@@ -262,10 +333,18 @@ impl Operation {
} }
} }
/// Selected option string of a choice field. /// Stable code of the selected option (what the command builder keys off).
pub fn choice_str(&self, i: usize) -> &'static str { pub fn code(&self, i: usize) -> &'static str {
match &self.fields[i].value { match &self.fields[i].value {
FieldValue::Choice { options, selected } => options[*selected], FieldValue::Choice { options, selected } => options[*selected].code,
FieldValue::Text(_) => "",
}
}
/// Human label of the selected option (for display).
pub fn label(&self, i: usize) -> &'static str {
match &self.fields[i].value {
FieldValue::Choice { options, selected } => options[*selected].label,
FieldValue::Text(_) => "", FieldValue::Text(_) => "",
} }
} }
@@ -288,43 +367,44 @@ impl Operation {
format!("{} \u{2192} {}{}", start, end, mode) format!("{} \u{2192} {}{}", start, end, mode)
} }
Resize => { Resize => {
if self.choice_str(0) == "Custom" { if self.code(0) == "custom" {
format!("{}\u{d7}{}", self.text(1), self.text(2)) format!("{}\u{d7}{}", self.text(1), self.text(2))
} else { } else {
self.choice_str(0).to_string() self.label(0).to_string()
} }
} }
Crop => { Crop => {
if self.choice_str(0) == "Custom" { if self.code(0) == "custom" {
format!("{}\u{d7}{}", self.text(1), self.text(2)) format!("{}\u{d7}{}", self.text(1), self.text(2))
} else { } else {
self.choice_str(0).to_string() self.label(0).to_string()
} }
} }
Adjust => format!( Adjust => format!(
"bright {} / contrast {} / sat {}", "bright {} / contrast {} / sat {}",
self.choice_str(0), self.label(0),
self.choice_str(1), self.label(1),
self.choice_str(2) self.label(2)
), ),
Compress => format!( Compress => format!(
"{}, {}", "{}, {}",
self.choice_str(0).split(' ').next().unwrap_or(""), self.label(0).split(' ').next().unwrap_or(""),
self.choice_str(1) self.label(1)
), ),
TargetSize => format!("\u{2264} {} MB", self.text(0)), TargetSize => format!("\u{2264} {} MB", self.text(0)),
_ => self.choice_str(0).to_string(), _ => self.label(0).to_string(),
} }
} }
/// Serialize for recipe storage. Choices are stored as their option /// Serialize for recipe storage. Choices are stored as their stable code
/// string (not index) so saved recipes survive menu reordering. /// (not index or label) so saved recipes survive both menu reordering and
/// relabeling.
pub fn to_json(&self) -> serde_json::Value { pub fn to_json(&self) -> serde_json::Value {
let values: Vec<serde_json::Value> = self let values: Vec<serde_json::Value> = self
.fields .fields
.iter() .iter()
.map(|f| match &f.value { .map(|f| match &f.value {
FieldValue::Choice { options, selected } => options[*selected].into(), FieldValue::Choice { options, selected } => options[*selected].code.into(),
FieldValue::Text(s) => s.as_str().into(), FieldValue::Text(s) => s.as_str().into(),
}) })
.collect(); .collect();
@@ -339,7 +419,11 @@ impl Operation {
let s = value.as_str().unwrap_or(""); let s = value.as_str().unwrap_or("");
match &mut field.value { match &mut field.value {
FieldValue::Choice { options, selected } => { FieldValue::Choice { options, selected } => {
if let Some(pos) = options.iter().position(|o| *o == s) { // Match by code; fall back to the label so recipes
// saved by older versions (which stored labels) load.
if let Some(pos) = options.iter().position(|o| o.code == s) {
*selected = pos;
} else if let Some(pos) = options.iter().position(|o| o.label == s) {
*selected = pos; *selected = pos;
} }
} }
@@ -350,3 +434,46 @@ impl Operation {
Some(op) Some(op)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn set_choice(op: &mut Operation, field: usize, code: &str) {
if let FieldValue::Choice { options, selected } = &mut op.fields[field].value {
*selected = options
.iter()
.position(|o| o.code == code)
.unwrap_or_else(|| panic!("no option with code {code:?}"));
} else {
panic!("field {field} is not a choice");
}
}
#[test]
fn recipe_roundtrip_preserves_code() {
let mut op = OpKind::Format.build(true);
set_choice(&mut op, 0, "webm");
let json = op.to_json();
let restored = Operation::from_json(&json, true).expect("restored");
assert_eq!(restored.code(0), "webm");
}
#[test]
fn from_json_falls_back_to_old_label() {
// Older lazyff stored the display label, not the code.
let v = serde_json::json!({ "kind": "Convert format", "values": ["WebM video"] });
let op = Operation::from_json(&v, true).expect("restored");
assert_eq!(op.code(0), "webm");
}
#[test]
fn text_fields_round_trip() {
let mut op = OpKind::Trim.build(true);
if let FieldValue::Text(s) = &mut op.fields[0].value {
*s = "0:30".into();
}
let restored = Operation::from_json(&op.to_json(), true).expect("restored");
assert_eq!(restored.text(0), "0:30");
}
}
+31
View File
@@ -90,3 +90,34 @@ pub fn materialize(recipe: &Recipe, has_video: bool) -> (Vec<Operation>, Vec<Str
} }
(ops, notes) (ops, notes)
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn materialize_skips_video_ops_for_audio_input() {
let recipe = Recipe {
name: "demo".into(),
ops: vec![
serde_json::json!({ "kind": "Resize", "values": ["720p"] }),
serde_json::json!({ "kind": "Audio", "values": ["normalize"] }),
],
};
let (ops, notes) = materialize(&recipe, false);
assert_eq!(ops.len(), 1, "only the audio-safe op should survive");
assert_eq!(ops[0].kind, crate::ops::OpKind::Audio);
assert!(notes.iter().any(|n| n.contains("Resize")));
}
#[test]
fn materialize_reports_unknown_op() {
let recipe = Recipe {
name: "demo".into(),
ops: vec![serde_json::json!({ "kind": "Telepathy", "values": [] })],
};
let (ops, notes) = materialize(&recipe, true);
assert!(ops.is_empty());
assert!(notes.iter().any(|n| n.contains("Telepathy")));
}
}
+1 -1
View File
@@ -320,7 +320,7 @@ fn draw_modal(f: &mut Frame, app: &mut App, modal: &Modal) {
}; };
let value = match &fld.value { let value = match &fld.value {
FieldValue::Choice { options, selected: s } => { FieldValue::Choice { options, selected: s } => {
format!("\u{25c2} {} \u{25b8}", options[*s]) format!("\u{25c2} {} \u{25b8}", options[*s].label)
} }
FieldValue::Text(s) => { FieldValue::Text(s) => {
if selected { if selected {