use nord_format::cbin::Cbin;
use nord_format::formats::ne5::{program, song, Song};
use nord_format::formats::nsmp::{Sample, MAX_NAME_LEN};
use nord_format::formats::nsmpproj::Project;
use crate::note;
use crate::ui::Ui;
pub struct Row {
pub path: String,
pub value: String,
pub accepts: String,
}
pub trait Fields {
fn rows(&self) -> Result<Vec<Row>, String>;
fn set(&mut self, path: &str, value: &str) -> Result<(), String>;
}
pub fn stage(
ui: &Ui,
fields: bool,
sets: &[String],
editor: &mut dyn Fields,
) -> Result<Option<usize>, String> {
if fields {
if !sets.is_empty() {
return Err("--fields lists and writes nothing; drop it to apply --set".into());
}
list(ui, editor)?;
return Ok(None);
}
if sets.is_empty() {
return Err("nothing to do: pass --set PATH=VALUE, or --fields to see what exists".into());
}
let before = editor.rows()?;
for assignment in sets {
let (path, value) = assignment
.split_once('=')
.ok_or_else(|| format!("expected PATH=VALUE, got {assignment:?}"))?;
editor.set(path.trim(), value.trim())?;
}
let after = editor.rows()?;
let mut changed = 0;
for (b, a) in before.iter().zip(&after) {
if b.value != a.value {
changed += 1;
ui.out(format!(
"{:<24} {} -> {}",
a.path,
b.value,
ui.bold(&a.value)
));
}
}
Ok(Some(changed))
}
fn list(ui: &Ui, editor: &dyn Fields) -> Result<(), String> {
ui.out(format!("{:<24} {:<40} {}", "path", "value", "accepts"));
for row in editor.rows()? {
ui.out(format!(
"{:<24} {:<40} {}",
row.path, row.value, row.accepts
));
}
Ok(())
}
const NOTE_ACCEPTS: &str = "a note name (C4, F#3) or 0-127";
fn unknown(path: &str) -> String {
format!("unknown field {path:?}; --fields lists what exists")
}
fn indexed(part: &str, label: &str) -> Option<usize> {
part.strip_prefix(label)
.and_then(|n| n.parse().ok())
.filter(|&n| n >= 1)
}
pub struct SampleEditor<'a>(pub &'a mut Cbin<Sample>);
impl Fields for SampleEditor<'_> {
fn rows(&self) -> Result<Vec<Row>, String> {
let sample = &self.0;
let mut out = vec![Row {
path: "name".into(),
value: sample.name().map_err(|e| e.to_string())?,
accepts: format!("up to {MAX_NAME_LEN} bytes"),
}];
let zones = sample.zones().map_err(|e| e.to_string())?;
let strokes = sample.strokes().map_err(|e| e.to_string())?;
for (i, (zone, stroke)) in zones.iter().zip(&strokes).enumerate() {
let n = i + 1;
out.push(Row {
path: format!("zone{n}.root_key"),
value: note::name(stroke.root_key),
accepts: NOTE_ACCEPTS.into(),
});
out.push(Row {
path: format!("zone{n}.top_note"),
value: note::name(zone.top_note),
accepts: NOTE_ACCEPTS.into(),
});
}
Ok(out)
}
fn set(&mut self, path: &str, value: &str) -> Result<(), String> {
let sample = &mut self.0;
if path == "name" {
return sample.set_name(value).map_err(|e| e.to_string());
}
let (zone, field) = path.split_once('.').ok_or_else(|| unknown(path))?;
let index = indexed(zone, "zone").ok_or_else(|| unknown(path))?;
let zones = sample.zones().map_err(|e| e.to_string())?.len();
if index > zones {
return Err(format!("no zone {index}: the instrument has {zones}"));
}
let value = note::parse(value)?;
match field {
"root_key" => sample.set_root_key(index - 1, value),
"top_note" => sample.set_zone_top_note(index - 1, value),
_ => return Err(unknown(path)),
}
.map_err(|e| e.to_string())
}
}
pub struct SongEditor<'a>(pub &'a mut Cbin<Song>);
impl Fields for SongEditor<'_> {
fn rows(&self) -> Result<Vec<Row>, String> {
Ok((0..song::PROGRAM_COUNT as u16)
.map(|slot| {
let (bank, at) = self.0.get(slot).inner();
Row {
path: format!("slot{}", slot + 1),
value: format!("{}:{}", bank + 1, at + 1),
accepts: format!(
"a program slot, BANK:SLOT (1:1 .. {}:{})",
program::BANK_COUNT,
program::SLOT_COUNT
),
}
})
.collect())
}
fn set(&mut self, path: &str, value: &str) -> Result<(), String> {
let slot = indexed(path, "slot")
.filter(|&n| n <= song::PROGRAM_COUNT)
.ok_or_else(|| unknown(path))?;
let at = crate::slot::parse(value)?;
let target: program::Location = (at.bank as u16, at.slot as u16)
.try_into()
.map_err(|e| format!("{path}: {e}"))?;
self.0.set(slot as u16 - 1, target);
Ok(())
}
}
pub struct ProjectEditor<'a>(pub &'a mut Project);
impl Fields for ProjectEditor<'_> {
fn rows(&self) -> Result<Vec<Row>, String> {
let project = &self.0;
let mut out = vec![Row {
path: "name".into(),
value: project.name().map_err(|e| e.to_string())?,
accepts: "the instrument's name".into(),
}];
for zone in project.zones().map_err(|e| e.to_string())? {
let id = zone.zone_id;
for (field, value) in [
("root_key", zone.root_key),
("bottom_note", zone.bottom_note),
("top_note", zone.top_note),
] {
out.push(Row {
path: format!("zone{id}.{field}"),
value: note::name(value),
accepts: NOTE_ACCEPTS.into(),
});
}
}
for file in project.audio_files().map_err(|e| e.to_string())? {
out.push(Row {
path: format!("file{}.path", file.id),
value: file.path,
accepts: "a path the editor resolves from the project's directory".into(),
});
}
Ok(out)
}
fn set(&mut self, path: &str, value: &str) -> Result<(), String> {
let project = &mut self.0;
if path == "name" {
return project.set_name(value).map_err(|e| e.to_string());
}
let (block, field) = path.split_once('.').ok_or_else(|| unknown(path))?;
if let Some(id) = indexed(block, "file") {
if field != "path" {
return Err(unknown(path));
}
return project
.set_audio_path(id as u32, value)
.map_err(|e| e.to_string());
}
let id = indexed(block, "zone").ok_or_else(|| unknown(path))? as u32;
let zones = project.zones().map_err(|e| e.to_string())?;
let zone = zones
.iter()
.find(|z| z.zone_id == id)
.ok_or_else(|| format!("no zone {id}; --fields lists the ids this project holds"))?;
let note = note::parse(value)?;
match field {
"root_key" => project.set_root_key(id, note),
"bottom_note" => project.set_key_range(id, note, zone.top_note),
"top_note" => project.set_key_range(id, zone.bottom_note, note),
_ => return Err(unknown(path)),
}
.map_err(|e| e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use nord_format::formats::ne5;
use nord_format::formats::nsmpproj::NewZone;
fn project() -> Project {
Project::new(
"Marimba",
&[
NewZone {
path: "low.wav".into(),
sample_rate: 44100,
frames: 44100,
root_key: 48,
},
NewZone {
path: "high.wav".into(),
sample_rate: 44100,
frames: 44100,
root_key: 72,
},
],
0,
)
.unwrap()
}
#[test]
fn project_paths_round_trip_from_the_listing() {
let mut project = project();
let rows = ProjectEditor(&mut project).rows().unwrap();
let paths: Vec<&str> = rows.iter().map(|r| r.path.as_str()).collect();
assert!(paths.contains(&"name"), "{paths:?}");
assert!(paths.iter().any(|p| p.starts_with("zone129.")), "{paths:?}");
assert!(paths.contains(&"file1.path"), "{paths:?}");
let mut editor = ProjectEditor(&mut project);
for (path, value) in [
("name", "Vibes"),
("zone129.root_key", "C2"),
("file1.path", "verylow.wav"),
] {
editor.set(path, value).unwrap();
}
assert_eq!(project.name().unwrap(), "Vibes");
let zones = project.zones().unwrap();
let edited = zones.iter().find(|z| z.zone_id == 129).unwrap();
assert_eq!(edited.root_key, 36);
assert_eq!(project.audio_files().unwrap()[0].path, "verylow.wav");
}
#[test]
fn a_key_range_moves_one_end_at_a_time() {
let mut project = project();
let before = project.zones().unwrap()[0].clone();
ProjectEditor(&mut project)
.set(&format!("zone{}.top_note", before.zone_id), "C7")
.unwrap();
let after = &project.zones().unwrap()[0];
assert_eq!(after.top_note, 96);
assert_eq!(after.bottom_note, before.bottom_note);
}
#[test]
fn song_slots_speak_the_instruments_numbering() {
let mut song = ne5::song::new(
(0, 0).try_into().unwrap(),
ne5::song::DEFAULT_VERSION,
[(0, 0).try_into().unwrap(); 4],
);
SongEditor(&mut song).set("slot2", "3:14").unwrap();
assert_eq!(song.get(1).inner(), (2, 13));
let rows = SongEditor(&mut song).rows().unwrap();
assert_eq!(rows[1].path, "slot2");
assert_eq!(rows[1].value, "3:14");
for bad in ["9:1", "1:51"] {
assert!(SongEditor(&mut song).set("slot1", bad).is_err(), "{bad}");
}
assert!(SongEditor(&mut song).set("slot5", "1:1").is_err());
}
}