use eframe::egui;
use nord_format::accept::Family;
use nord_usb::{Location, ObjectClass};
use super::controls::{self, Sets};
use super::{encode, piano, project, sample, setlist, SendBack, Shape};
use crate::app::{accent, caption, good, warn};
use crate::browser::Kind;
use crate::device::{read_only, DeviceState};
use crate::icon::{icon, painted, Glyph};
use crate::library::{keyboard_mark, mark_words, Mark};
use crate::panel::caps;
use crate::queue::Queue;
use crate::room;
use crate::strings::{carries_tag, display_name, folder, kind_word, place, shown};
use crate::tags::Tags;
use crate::workspace::{LocalEntity, Origin};
const HEIGHT: f32 = 38.0;
const PAD: i8 = 12;
const GAP: f32 = 10.0;
const CONTROL: f32 = 20.0;
const RADIUS: f32 = 2.0;
const KIND: f32 = 15.0;
const SMALL: f32 = 11.0;
const LOUD: f32 = 12.0;
const TAG: f32 = 10.0;
const DOT: f32 = 8.0;
const INSET: f32 = 5.0;
const WORD: f32 = 10.5;
const MONO: f32 = 10.5;
const READ: f32 = 11.5;
const NAME: f32 = 190.0;
const PIANO_NAME: f32 = 150.0;
const VARIANT: f32 = 84.0;
const FIXED: f32 = 12.5;
const INDENT: f32 = 37.0;
const CHIP: f32 = 18.0;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Face {
#[default]
Edit,
Metadata,
Advanced,
}
impl Face {
pub fn label(self) -> &'static str {
match self {
Face::Edit => "Edit",
Face::Metadata => "Metadata",
Face::Advanced => "Advanced",
}
}
fn glyph(self) -> Glyph {
match self {
Face::Edit => Glyph::Pencil,
Face::Metadata => Glyph::Info,
Face::Advanced => Glyph::Wrench,
}
}
fn hint(self) -> &'static str {
match self {
Face::Edit => "the fields that change the sound",
Face::Metadata => "what the file says about itself — read only",
Face::Advanced => "capabilities, offsets, raw values — engineering",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Stage {
Full,
Quiet,
Faces,
Narrow,
}
const BREAKPOINTS: [f32; 3] = [1000.0, 860.0, 720.0];
pub fn stage(width: f32) -> Stage {
match width {
width if width >= BREAKPOINTS[0] => Stage::Full,
width if width >= BREAKPOINTS[1] => Stage::Quiet,
width if width >= BREAKPOINTS[2] => Stage::Faces,
_ => Stage::Narrow,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Ink {
Good,
Warn,
Quiet,
}
impl Ink {
pub(super) fn color(self, visuals: &egui::Visuals) -> egui::Color32 {
match self {
Ink::Good => good(visuals),
Ink::Warn => warn(visuals),
Ink::Quiet => caption(visuals),
}
}
}
pub struct SizeLine {
pub text: String,
pub warn: bool,
pub hint: String,
}
#[derive(Clone)]
pub struct StateLine {
pub words: String,
pub ink: Ink,
pub hint: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tone {
Ready,
Blocked,
Idle,
}
pub struct Loud {
pub label: String,
pub short: String,
pub glyph: Glyph,
pub tone: Tone,
pub hint: String,
pub send: Option<(ObjectClass, Location)>,
}
#[derive(Default)]
pub struct Extras {
pub size: Option<SizeLine>,
pub edited: Option<StateLine>,
pub state: Option<StateLine>,
pub loud: Option<Loud>,
}
pub struct Cell {
pub label: &'static str,
pub body: Body,
pub note: Option<String>,
pub hint: &'static str,
}
pub enum Body {
Chips(Vec<String>),
Read(String),
}
pub(super) struct Facts<'a> {
pub faces: &'a [Face],
pub showing: Face,
pub device: &'a DeviceState,
pub queue: &'a Queue,
pub tags: &'a Tags,
pub view: bool,
pub renaming: (Option<String>, Option<String>),
pub shape: Shape,
pub extras: Extras,
}
#[derive(Default)]
pub(super) struct Clicked {
pub revert: bool,
pub export: bool,
pub send: Option<SendBack>,
pub face: Option<Face>,
pub rename: Option<String>,
}
pub(super) fn ui(
ui: &mut egui::Ui,
entity: &LocalEntity,
facts: &Facts<'_>,
boxes: (&mut String, &mut String),
sets: &mut Sets,
) -> Clicked {
let stage = stage(ui.available_width());
let cells = identity(entity, facts.tags);
let visuals = ui.visuals().clone();
let mut act = Clicked::default();
let drawn = egui::Frame::new()
.fill(visuals.window_fill)
.inner_margin(egui::Margin {
left: PAD,
right: PAD,
top: 4,
bottom: match cells.is_empty() {
true => 4,
false => 8,
},
})
.show(ui, |ui| {
ui.spacing_mut().item_spacing.x = GAP;
ui.spacing_mut().interact_size.y = CONTROL;
strip(
ui,
&mut act,
|rhs, act| right(rhs, entity, facts, stage, act),
|lhs, act| left(lhs, entity, facts, boxes, sets, act),
);
if !cells.is_empty() {
row(ui, &cells, stage);
}
});
let hairline = egui::Stroke::new(1.0_f32, visuals.widgets.noninteractive.bg_stroke.color);
let rect = drawn.response.rect;
ui.painter()
.hline(ui.max_rect().x_range(), rect.bottom() - 0.5, hairline);
act
}
fn strip<T>(
ui: &mut egui::Ui,
state: &mut T,
right: impl FnOnce(&mut egui::Ui, &mut T),
left: impl FnOnce(&mut egui::Ui, &mut T),
) {
let row = egui::Rect::from_min_size(
ui.cursor().min,
egui::vec2(ui.available_width(), HEIGHT - 8.0),
);
let mut rhs = ui.new_child(
egui::UiBuilder::new()
.max_rect(row)
.layout(egui::Layout::right_to_left(egui::Align::Center)),
);
rhs.spacing_mut().item_spacing.x = GAP;
right(&mut rhs, state);
let taken = rhs.min_rect();
let room = egui::Rect::from_min_max(row.min, egui::pos2(taken.left() - GAP, row.max.y));
let mut lhs = ui.new_child(
egui::UiBuilder::new()
.max_rect(room)
.layout(egui::Layout::left_to_right(egui::Align::Center).with_main_wrap(true)),
);
lhs.spacing_mut().item_spacing = egui::vec2(GAP, 4.0);
left(&mut lhs, state);
ui.advance_cursor_after_rect(lhs.min_rect().union(taken).union(row));
}
fn left(
ui: &mut egui::Ui,
entity: &LocalEntity,
facts: &Facts<'_>,
boxes: (&mut String, &mut String),
sets: &mut Sets,
act: &mut Clicked,
) {
let visuals = ui.visuals().clone();
let quiet = caption(&visuals);
let glyph = Kind::of(entity.entity.as_ref()).glyph();
icon(ui, glyph, KIND, accent(&visuals));
let (held, stored) = named(entity, facts.shape, facts.view, facts.renaming.clone());
act.rename = name(ui, entity, &held, &stored, boxes, sets);
let (badge, hint) = badge(entity);
let drawn = pill(
ui,
Pill {
glyph: None,
label: Some(&badge),
ink: visuals
.widgets
.inactive
.fg_stroke
.color
.gamma_multiply(0.85),
mono: true,
stroke: Some(visuals.widgets.noninteractive.bg_stroke.color),
dashed: false,
pad: 6.0,
height: CONTROL,
live: false,
},
);
if !hint.is_empty() {
drawn.on_hover_text(hint);
}
mono(ui, &lives(entity), quiet).on_hover_text(entity.origin.label());
let own = sized(entity);
if let Some(size) = facts.extras.size.as_ref().or(own.as_ref()) {
rule(ui);
let ink = match size.warn {
true => warn(&visuals),
false => quiet,
};
let drawn = mono(ui, &size.text, ink);
if !size.hint.is_empty() {
drawn.on_hover_text(&size.hint);
}
}
if let Some(state) = state(entity, facts) {
let ink = state.ink.color(&visuals);
claim(ui, &state.words, ink).on_hover_text(&state.hint);
}
}
fn claim(ui: &mut egui::Ui, words: &str, ink: egui::Color32) -> egui::Response {
let galley =
ui.painter()
.layout_no_wrap(words.to_owned(), egui::FontId::proportional(WORD), ink);
let size = egui::vec2(DOT + INSET + galley.size().x, CONTROL);
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::hover());
let painter = ui.painter();
painter.circle_filled(
egui::pos2(rect.left() + DOT / 2.0, rect.center().y),
DOT / 2.0 - 1.0,
ink,
);
painter.galley(
egui::pos2(
rect.left() + DOT + INSET,
rect.center().y - galley.size().y / 2.0,
),
galley,
ink,
);
response
}
fn right(
ui: &mut egui::Ui,
entity: &LocalEntity,
facts: &Facts<'_>,
stage: Stage,
act: &mut Clicked,
) {
let visuals = ui.visuals().clone();
let quiet = caption(&visuals);
let own = action(entity, facts.device);
let loud = facts.extras.loud.as_ref().unwrap_or(&own);
let label = match stage {
Stage::Narrow => &loud.short,
_ => &loud.label,
};
let (stroke, mark, ink) = match loud.tone {
Tone::Ready => (accent(&visuals), accent(&visuals), visuals.text_color()),
Tone::Blocked => (warn(&visuals), warn(&visuals), visuals.text_color()),
Tone::Idle => (visuals.widgets.noninteractive.bg_stroke.color, quiet, quiet),
};
let clicked = pill(
ui,
Pill {
glyph: Some((loud.glyph, LOUD, mark)),
label: Some(label),
ink,
mono: false,
stroke: Some(stroke),
dashed: loud.tone == Tone::Idle,
pad: 8.0,
height: CONTROL,
live: loud.tone == Tone::Ready,
},
)
.on_hover_text(&loud.hint)
.clicked();
if let (true, Some((class, at))) = (clicked, loud.send) {
act.send = Some(SendBack {
id: entity.id,
class,
at,
});
}
let words = stage == Stage::Full;
let quiet_pill = |ui: &mut egui::Ui, glyph, label: &str, live| {
pill(
ui,
Pill {
glyph: Some((glyph, SMALL, quiet)),
label: words.then_some(label),
ink: quiet,
mono: false,
stroke: Some(visuals.widgets.noninteractive.bg_stroke.color),
dashed: false,
pad: match words {
true => 7.0,
false => 5.0,
},
height: CONTROL,
live,
},
)
};
act.export = quiet_pill(ui, Glyph::ArrowDownToLine, "Export…", true)
.on_hover_text(hint(
"Export…",
"a copy on this computer, under a name you pick",
words,
))
.clicked();
let unsaved = entity.is_unsaved();
let revert = quiet_pill(ui, Glyph::RotateCcw, "Revert", unsaved);
act.revert = match unsaved {
true => revert
.on_hover_text(hint(
"Revert",
"back to the bytes it was last saved as",
words,
))
.clicked(),
false => {
revert.on_hover_text(hint(
"Revert",
"nothing has changed since it was saved",
words,
));
false
}
};
rule(ui);
act.face = segments(ui, facts, stage);
}
fn hint(label: &str, why: &str, words: bool) -> String {
match words {
true => why.to_string(),
false => format!("{why} ({label})"),
}
}
fn segments(ui: &mut egui::Ui, facts: &Facts<'_>, stage: Stage) -> Option<Face> {
let visuals = ui.visuals().clone();
let painter = ui.painter().clone();
let words = matches!(stage, Stage::Full | Stage::Quiet);
let pad = match words {
true => 8.0,
false => 6.0,
};
let ink = |face: Face| match face == facts.showing {
true => visuals.text_color(),
false => caption(&visuals),
};
let laid: Vec<(Face, Option<std::sync::Arc<egui::Galley>>, f32)> = facts
.faces
.iter()
.map(|face| {
let word = words.then(|| {
painter.layout_no_wrap(
face.label().to_string(),
egui::FontId::proportional(WORD),
ink(*face),
)
});
let width = pad * 2.0 + SMALL + word.as_ref().map_or(0.0, |laid| INSET + laid.size().x);
(*face, word, width)
})
.collect();
let total: f32 = laid.iter().map(|(_, _, width)| width).sum();
let (rect, _) = ui.allocate_exact_size(egui::vec2(total, CONTROL), egui::Sense::hover());
let border = egui::Stroke::new(1.0_f32, visuals.widgets.noninteractive.bg_stroke.color);
let mut picked = None;
let mut x = rect.left();
let last = laid.len().saturating_sub(1);
for (nth, (face, word, width)) in laid.into_iter().enumerate() {
let cell = egui::Rect::from_min_size(egui::pos2(x, rect.top()), egui::vec2(width, CONTROL));
let response = ui.interact(
cell,
ui.id().with(("face", face.label())),
egui::Sense::click(),
);
let fill = match (face == facts.showing, response.hovered()) {
(true, _) => Some(visuals.widgets.active.weak_bg_fill),
(false, true) => Some(visuals.widgets.hovered.weak_bg_fill),
(false, false) => None,
};
if let Some(fill) = fill {
painter.rect_filled(cell, 0.0, fill);
}
if nth < last {
painter.vline(cell.right() - 0.5, cell.y_range(), border);
}
painted(
ui,
face.glyph(),
egui::Rect::from_center_size(
egui::pos2(cell.left() + pad + SMALL / 2.0, cell.center().y),
egui::Vec2::splat(SMALL),
),
ink(face),
);
if let Some(word) = word {
let at = egui::pos2(
cell.left() + pad + SMALL + INSET,
cell.center().y - word.size().y / 2.0,
);
painter.galley(at, word, ink(face));
}
if response.clicked() {
picked = Some(face);
}
response.on_hover_text(hint(face.label(), face.hint(), words));
x = cell.right();
}
painter.rect_stroke(rect, RADIUS, border, egui::StrokeKind::Inside);
picked
}
fn row(ui: &mut egui::Ui, cells: &[Cell], stage: Stage) {
let indent = INDENT - f32::from(PAD);
let draw = |ui: &mut egui::Ui| {
ui.spacing_mut().item_spacing = egui::vec2(18.0, 4.0);
ui.add_space(indent);
for cell in cells {
let response = ui
.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 7.0;
ui.label(caps(cell.label).color(caption(ui.visuals())));
ui.spacing_mut().item_spacing.x = 4.0;
match &cell.body {
Body::Chips(chips) => {
for text in chips {
chip(ui, text);
}
}
Body::Read(value) => read(ui, value),
}
if let Some(note) = &cell.note {
ui.label(
egui::RichText::new(note)
.size(10.0)
.color(caption(ui.visuals())),
);
}
})
.response;
if !cell.hint.is_empty() {
response.on_hover_text(cell.hint);
}
}
};
match stage {
Stage::Narrow => ui.horizontal_wrapped(draw),
_ => ui.horizontal(draw),
};
}
fn read(ui: &mut egui::Ui, value: &str) {
ui.spacing_mut().item_spacing.x = INSET;
icon(ui, Glyph::Eye, SMALL, caption(ui.visuals()));
ui.label(
egui::RichText::new(value)
.font(egui::FontId::monospace(READ))
.color(ui.visuals().weak_text_color()),
);
}
fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
let ink = accent(ui.visuals());
pill(
ui,
Pill {
glyph: Some((Glyph::Tag, TAG, ink)),
label: Some(text),
ink,
mono: false,
stroke: Some(ink),
dashed: false,
pad: 6.0,
height: CHIP,
live: false,
},
)
}
struct Pill<'a> {
glyph: Option<(Glyph, f32, egui::Color32)>,
label: Option<&'a str>,
ink: egui::Color32,
mono: bool,
stroke: Option<egui::Color32>,
dashed: bool,
pad: f32,
height: f32,
live: bool,
}
fn pill(ui: &mut egui::Ui, held: Pill<'_>) -> egui::Response {
let visuals = ui.visuals().clone();
let painter = ui.painter().clone();
let font = match held.mono {
true => egui::FontId::monospace(MONO),
false => egui::FontId::proportional(WORD),
};
let word = held
.label
.map(|label| painter.layout_no_wrap(label.to_string(), font, held.ink));
let marked = held.glyph.map_or(0.0, |(_, size, _)| size);
let gap = match (held.glyph.is_some(), word.is_some()) {
(true, true) => INSET,
_ => 0.0,
};
let width = held.pad * 2.0 + marked + gap + word.as_ref().map_or(0.0, |laid| laid.size().x);
let sense = match held.live {
true => egui::Sense::click(),
false => egui::Sense::hover(),
};
let (rect, response) = ui.allocate_exact_size(egui::vec2(width, held.height), sense);
if held.live && response.hovered() {
painter.rect_filled(rect, RADIUS, visuals.widgets.hovered.weak_bg_fill);
}
if let Some(color) = held.stroke {
let stroke = egui::Stroke::new(1.0_f32, color);
match held.dashed {
true => crate::panel::dashed_rect(&painter, rect.shrink(0.5), stroke),
false => {
painter.rect_stroke(rect, RADIUS, stroke, egui::StrokeKind::Inside);
}
}
}
let mut x = rect.left() + held.pad;
if let Some((glyph, size, mark)) = held.glyph {
painted(
ui,
glyph,
egui::Rect::from_center_size(
egui::pos2(x + size / 2.0, rect.center().y),
egui::Vec2::splat(size),
),
mark,
);
x += size + gap;
}
if let Some(word) = word {
let at = egui::pos2(x, rect.center().y - word.size().y / 2.0);
painter.galley(at, word, held.ink);
}
response
}
fn mono(ui: &mut egui::Ui, text: &str, ink: egui::Color32) -> egui::Response {
ui.add(
egui::Label::new(
egui::RichText::new(text)
.font(egui::FontId::monospace(MONO))
.color(ink),
)
.wrap_mode(egui::TextWrapMode::Extend),
)
}
fn rule(ui: &mut egui::Ui) {
let (rect, _) = ui.allocate_exact_size(egui::vec2(1.0, 12.0), egui::Sense::hover());
let color = ui.visuals().widgets.noninteractive.bg_stroke.color;
ui.painter().rect_filled(rect, 0.0, color);
}
enum Named {
Stored {
limit: Option<usize>,
variant: Option<String>,
width: f32,
},
Asset,
Device,
}
fn stored_name(
entity: &LocalEntity,
shape: Shape,
renaming: (Option<String>, Option<String>),
) -> Option<(Named, String)> {
let decoded = entity.entity.as_ref()?;
match shape {
Shape::Sample => {
let held = sample::snapshot(decoded)?.ok()?;
Some((
Named::Stored {
limit: Some(held.max_name_len),
variant: None,
width: NAME,
},
held.name,
))
}
Shape::Project => {
let held = project::snapshot(decoded)?.ok()?;
Some((
Named::Stored {
limit: None,
variant: None,
width: NAME,
},
held.name,
))
}
Shape::Piano => {
let held = piano::snapshot(decoded)?.ok()?;
let (name, variant) = renaming;
Some((
Named::Stored {
limit: None,
variant: Some(variant.unwrap_or(held.variant)),
width: PIANO_NAME,
},
name.unwrap_or(held.name),
))
}
Shape::Fields | Shape::SetList | Shape::Verbatim | Shape::Wav | Shape::Undecoded => None,
}
}
fn named(
entity: &LocalEntity,
shape: Shape,
view: bool,
renaming: (Option<String>, Option<String>),
) -> (Named, String) {
if let Some(held) = stored_name(entity, shape, renaming) {
return held;
}
let settings = Kind::of(entity.entity.as_ref()) == Kind::Settings;
match view && settings {
true => (Named::Device, display_name(&entity.name).to_string()),
false => (Named::Asset, display_name(&entity.name).to_string()),
}
}
pub(super) fn boxes(
entity: &LocalEntity,
shape: Shape,
view: bool,
renaming: (Option<String>, Option<String>),
) -> (String, String) {
let (held, stored) = named(entity, shape, view, renaming);
let variant = match held {
Named::Stored { variant, .. } => variant.unwrap_or_default(),
Named::Asset | Named::Device => String::new(),
};
(stored, variant)
}
fn name(
ui: &mut egui::Ui,
entity: &LocalEntity,
held: &Named,
stored: &str,
boxes: (&mut String, &mut String),
sets: &mut Sets,
) -> Option<String> {
let (typed, variant_box) = boxes;
match held {
Named::Device => {
ui.label(
egui::RichText::new(stored)
.size(FIXED)
.strong()
.color(ui.visuals().text_color()),
)
.on_hover_text("a keyboard has one settings block; it is not renamed");
None
}
Named::Stored {
limit,
variant,
width,
} => {
if settled(ui, typed, *width, *limit, false) && typed != stored {
sets.push(("name".to_string(), typed.clone()));
}
if let Some(stored) = variant {
ui.label(
egui::RichText::new("#")
.font(egui::FontId::monospace(12.0))
.color(caption(ui.visuals())),
);
if settled(ui, variant_box, VARIANT, None, true) && variant_box != stored {
sets.push(("variant".to_string(), variant_box.clone()));
}
}
None
}
Named::Asset => {
let done = settled(ui, typed, NAME, None, false);
let wanted = tagged(&entity.name, typed);
(done && !typed.trim().is_empty() && wanted != entity.name).then_some(wanted)
}
}
}
fn settled(
ui: &mut egui::Ui,
text: &mut String,
width: f32,
limit: Option<usize>,
mono: bool,
) -> bool {
let mut edit = egui::TextEdit::singleline(text)
.desired_width(width)
.margin(egui::Margin::symmetric(4, 1));
if mono {
edit = edit.font(egui::FontId::monospace(MONO));
}
let response = ui.add(edit);
if let Some(limit) = limit {
controls::fits(text, limit);
}
response.lost_focus()
}
fn tagged(stored: &str, typed: &str) -> String {
match carries_tag(stored) {
true => match stored.rsplit_once('.') {
Some((_, tag)) => format!("{typed}.{tag}"),
None => typed.to_string(),
},
false => typed.to_string(),
}
}
pub(super) fn badge(entity: &LocalEntity) -> (String, String) {
let tag = entity.tag();
let kind = Kind::of(entity.entity.as_ref());
let word = kind_word(kind, Family::of_tag(&tag));
let version = entity.container.as_ref().map(|held| held.header.version);
let sentence = match version {
Some(version) => format!("{word}, content version {version}"),
None => word,
};
match kind {
Kind::Sample => {
let generation = entity
.entity
.as_ref()
.and_then(sample::snapshot)
.and_then(Result::ok)
.map_or_else(String::new, |held| format!(" {}", held.generation));
(
format!("nsmp{generation}"),
match version {
Some(version) => format!("content version {version}"),
None => sentence,
},
)
}
Kind::Piano => match stream_version(entity) {
Some(stream) => (
format!("npno {stream:#05x}"),
format!("stream version {stream:#05x}"),
),
None => (tag, sentence),
},
Kind::Project => (
"project".to_string(),
"a Nord Sample Editor project, which builds an nsmp".to_string(),
),
Kind::Program => (format!("program v{}", version.unwrap_or(0)), sentence),
Kind::SetList => ("set list".to_string(), sentence),
Kind::Settings => ("settings".to_string(), sentence),
Kind::Other => match encode::is_wav(&entity.bytes) {
true => (
"wav".to_string(),
"audio this app can make an instrument out of".to_string(),
),
false => (tag, "these bytes did not decode".to_string()),
},
Kind::Live
| Kind::Synth
| Kind::OrganPreset
| Kind::PianoPreset
| Kind::Performance
| Kind::LeadBank
| Kind::SampleLibrary
| Kind::PipeLibrary
| Kind::Bundle => (tag, sentence),
}
}
fn stream_version(entity: &LocalEntity) -> Option<u16> {
match entity.entity.as_ref()? {
nord_format::Entity::Piano(piano) => piano.stream_version().ok(),
_ => None,
}
}
pub(super) fn lives(entity: &LocalEntity) -> String {
if let Some((class, at)) = entity.spot() {
return place(class, at);
}
match &entity.origin {
Origin::File(path) => folder_of(path, home()),
Origin::Rescued { at } => shown(*at),
Origin::Device { class, at } => place(*class, *at),
Origin::Fresh => "This computer".to_string(),
}
}
fn home() -> Option<String> {
std::env::var("HOME").ok().filter(|home| !home.is_empty())
}
fn folder_of(path: &str, home: Option<String>) -> String {
let parent = match path.rsplit_once('/') {
Some(("", _)) => "/".to_string(),
Some((parent, _)) => parent.to_string(),
None => return "This computer".to_string(),
};
match home.filter(|home| parent == *home || parent.starts_with(&format!("{home}/"))) {
Some(home) => format!("~{}", &parent[home.len()..]),
None => parent,
}
}
fn sized(entity: &LocalEntity) -> Option<SizeLine> {
let kind = Kind::of(entity.entity.as_ref());
if kind == Kind::Settings {
return None;
}
if kind == Kind::SetList {
let entries = setlist::entries(entity.entity.as_ref()?)?;
return Some(SizeLine {
text: match entries {
1 => "1 entry".to_string(),
entries => format!("{entries} entries"),
},
warn: false,
hint: "the programs this set list orders".to_string(),
});
}
let bytes = entity.bytes.len() as u64;
Some(SizeLine {
text: room::measure(bytes),
warn: false,
hint: format!("{bytes} bytes"),
})
}
fn state(entity: &LocalEntity, facts: &Facts<'_>) -> Option<StateLine> {
let waiting = facts.queue.holds(entity.id);
if entity.is_unsaved() {
return Some(match &facts.extras.edited {
Some(line) => StateLine {
words: line.words.clone(),
ink: Ink::Warn,
hint: line.hint.clone(),
},
None => phrase(Mark::Unsaved, waiting),
});
}
if let Some(claim) = &facts.extras.state {
return Some(claim.clone());
}
let mark = keyboard_mark(entity, facts.device, facts.queue)?;
Some(phrase(mark, waiting))
}
fn phrase(mark: Mark, waiting: bool) -> StateLine {
let hint = mark_words(mark).to_string();
match mark {
Mark::Unsaved => StateLine {
words: "edited".to_string(),
ink: Ink::Warn,
hint,
},
Mark::Agrees => StateLine {
words: "matches keyboard".to_string(),
ink: Ink::Good,
hint,
},
Mark::Differs => StateLine {
words: match waiting {
true => "waiting to send".to_string(),
false => "differs from keyboard".to_string(),
},
ink: Ink::Warn,
hint,
},
Mark::Unknown => StateLine {
words: "on the keyboard".to_string(),
ink: Ink::Quiet,
hint,
},
}
}
pub(super) fn action(entity: &LocalEntity, device: &DeviceState) -> Loud {
let send = |hint: String| Loud {
label: "Queue send".to_string(),
short: "Send".to_string(),
glyph: Glyph::Upload,
tone: Tone::Ready,
hint,
send: entity.spot(),
};
let idle = |hint: String| Loud {
send: None,
tone: Tone::Idle,
..send(hint)
};
if Kind::of(entity.entity.as_ref()) == Kind::Project {
return Loud {
label: "Build → .nsmp".to_string(),
short: "Build".to_string(),
glyph: Glyph::CircleAlert,
tone: Tone::Blocked,
hint: "the codec is not understood yet".to_string(),
send: None,
};
}
if !device.connected() {
return idle("no instrument attached — nothing to send to".to_string());
}
let Some((class, at)) = entity.spot() else {
return idle("this stands on no slot — there is nothing to replace".to_string());
};
if read_only(class) {
return idle(format!(
"nothing here knows what {} holds, so nothing is written there",
folder(class)
));
}
if let Some((over, free)) = over(entity, class, device) {
return Loud {
label: format!("Won't fit · {} over", room::measure(over)),
short: format!("{} over", room::measure(over)),
glyph: Glyph::CircleAlert,
tone: Tone::Blocked,
hint: format!(
"{} is free in {}, and this is {}",
room::measure(free),
folder(class),
room::measure(entity.bytes.len() as u64)
),
send: None,
};
}
send(format!("replaces {}", place(class, at)))
}
fn over(entity: &LocalEntity, class: ObjectClass, device: &DeviceState) -> Option<(u64, u64)> {
let free = room::free_bytes(class, device)?;
let bytes = entity.bytes.len() as u64;
bytes
.checked_sub(free)
.filter(|over| *over > 0)
.map(|over| (over, free))
}
fn identity(entity: &LocalEntity, tags: &Tags) -> Vec<Cell> {
let mut cells = Vec::new();
if Kind::of(entity.entity.as_ref()) == Kind::Program {
let worn: Vec<String> = tags
.worn(entity.id)
.iter()
.filter_map(|tag| tags.name_of(*tag))
.map(str::to_string)
.collect();
if !worn.is_empty() {
cells.push(Cell {
label: "Tags",
body: Body::Chips(worn),
note: None,
hint: "what this computer's list labels it with",
});
}
}
if let Some(stated) = entity.entity.as_ref().and_then(sample::stated) {
cells.push(stated);
}
cells
}
#[cfg(test)]
mod tests {
use super::*;
use crate::log::Log;
use crate::workspace::{Fresh, Workspace};
fn workspace() -> (Workspace, Log) {
let ctx = egui::Context::default();
(Workspace::new(ctx), Log::default())
}
fn opened(name: &str, bytes: Vec<u8>) -> (Workspace, u64) {
let (mut workspace, mut log) = workspace();
let id = workspace.ingest(
name.to_string(),
Origin::File(name.to_string()),
bytes,
&mut log,
);
(workspace, id)
}
#[test]
fn a_name_box_settles_on_its_own_enter_rather_than_the_windows() {
let ctx = egui::Context::default();
ctx.set_fonts(crate::app::fonts());
let mut text = "Marimba".to_string();
let key = |key| egui::Event::Key {
key,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::NONE,
};
let at = |pos| egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::NONE,
};
let frame = |events: Vec<egui::Event>, text: &mut String| {
let mut done = false;
let input = egui::RawInput {
events,
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(400.0, 100.0),
)),
..Default::default()
};
let _ = ctx.run(input, |ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
done = settled(ui, text, NAME, None, false);
});
});
done
};
assert!(!frame(Vec::new(), &mut text), "nothing has happened");
assert!(
!frame(vec![key(egui::Key::Enter)], &mut text),
"the box never had the focus"
);
let _ = frame(vec![at(egui::pos2(40.0, 20.0))], &mut text);
assert!(
frame(vec![key(egui::Key::Enter)], &mut text),
"an Enter typed in the box is the box being done with"
);
}
#[test]
fn each_breakpoint_belongs_to_the_stage_above_it() {
assert_eq!(stage(1600.0), Stage::Full);
assert_eq!(stage(1000.0), Stage::Full);
assert_eq!(stage(999.0), Stage::Quiet);
assert_eq!(stage(860.0), Stage::Quiet);
assert_eq!(stage(859.0), Stage::Faces);
assert_eq!(stage(720.0), Stage::Faces);
assert_eq!(stage(719.0), Stage::Narrow);
assert_eq!(stage(0.0), Stage::Narrow);
}
#[test]
fn a_path_reads_as_the_folder_it_is_in() {
let home = || Some("/Users/x".to_string());
assert_eq!(
folder_of("/Users/x/Nord/projects/a.nsmpproj", home()),
"~/Nord/projects"
);
assert_eq!(folder_of("/Users/x/a.nsmp", home()), "~");
assert_eq!(folder_of("/opt/nord/a.nsmp", home()), "/opt/nord");
assert_eq!(folder_of("/a.nsmp", home()), "/");
assert_eq!(folder_of("/Users/xavier/a.nsmp", home()), "/Users/xavier");
assert_eq!(folder_of("a.nsmp", home()), "This computer");
assert_eq!(folder_of("/opt/a.nsmp", None), "/opt");
}
#[test]
fn the_place_is_the_slot_where_there_is_one() {
let (mut workspace, mut log) = workspace();
let fresh = workspace.create(Fresh::Program, &mut log).unwrap();
assert_eq!(lives(workspace.get(fresh).unwrap()), "This computer");
let bytes = workspace.get(fresh).unwrap().bytes.clone();
let copied = workspace.ingest(
"Africa.ne5p".to_string(),
Origin::Device {
class: ObjectClass::Program,
at: Location { bank: 6, slot: 3 },
},
bytes,
&mut log,
);
assert_eq!(lives(workspace.get(copied).unwrap()), "Programs 7:4");
}
#[test]
fn every_kind_says_what_format_it_is() {
let (mut workspace, mut log) = workspace();
let program = workspace.create(Fresh::Program, &mut log).unwrap();
assert_eq!(
badge(workspace.get(program).unwrap()),
(
"program v4".to_string(),
"Electro 5 program, content version 4".to_string()
)
);
let settings = workspace.create(Fresh::Settings, &mut log).unwrap();
assert_eq!(
badge(workspace.get(settings).unwrap()),
(
"settings".to_string(),
"Electro 5 settings, content version 0".to_string()
)
);
let (held, id) = opened("blank.ne5t", Fresh::SetList.bytes().unwrap());
assert_eq!(
badge(held.get(id).unwrap()),
(
"set list".to_string(),
"Electro 5 set list, content version 1".to_string()
)
);
let (held, id) = opened("junk.bin", b"not a nord file".to_vec());
assert_eq!(
badge(held.get(id).unwrap()),
("?".to_string(), "these bytes did not decode".to_string())
);
let (held, id) = opened("Marimba.nsmp", sample_bytes());
assert_eq!(
badge(held.get(id).unwrap()),
("nsmp v2".to_string(), "content version 200".to_string())
);
let (held, id) = opened("clarinet.nsmpproj", project_bytes());
let (text, hint) = badge(held.get(id).unwrap());
assert_eq!(text, "project");
assert!(hint.contains("Sample Editor project"), "{hint}");
let (held, id) = opened("Marimba hit.wav", wav_bytes());
assert_eq!(badge(held.get(id).unwrap()).0, "wav");
}
#[test]
fn the_size_is_whatever_this_kind_measures_in() {
let (mut workspace, mut log) = workspace();
let settings = workspace.create(Fresh::Settings, &mut log).unwrap();
assert!(sized(workspace.get(settings).unwrap()).is_none());
let (held, id) = opened("blank.ne5t", Fresh::SetList.bytes().unwrap());
assert_eq!(sized(held.get(id).unwrap()).unwrap().text, "4 entries");
let program = workspace.create(Fresh::Program, &mut log).unwrap();
let held = workspace.get(program).unwrap();
assert_eq!(
sized(held).unwrap().text,
room::measure(held.bytes.len() as u64)
);
}
#[test]
fn a_renamed_asset_keeps_the_tag_its_stored_name_carries() {
assert_eq!(
tagged("Africa-Split.ne5p", "Kenya Split"),
"Kenya Split.ne5p"
);
assert_eq!(tagged("Africa Split", "Kenya Split"), "Kenya Split");
assert_eq!(tagged("notes.txt", "list"), "list.txt");
}
#[test]
fn every_mark_has_one_phrase_and_none_of_them_is_red() {
let marks = [Mark::Unsaved, Mark::Agrees, Mark::Differs, Mark::Unknown];
for mark in marks {
for waiting in [false, true] {
let held = phrase(mark, waiting);
assert!(!held.words.is_empty(), "{mark:?} says something");
assert_eq!(held.hint, mark_words(mark), "{mark:?} explains itself");
for visuals in [egui::Visuals::dark(), egui::Visuals::light()] {
assert_ne!(
held.ink.color(&visuals),
crate::app::bad(&visuals),
"{mark:?} in the header"
);
}
}
}
assert_eq!(phrase(Mark::Unsaved, false).words, "edited");
assert_eq!(phrase(Mark::Agrees, false).words, "matches keyboard");
assert_eq!(phrase(Mark::Differs, false).words, "differs from keyboard");
assert_eq!(phrase(Mark::Differs, true).words, "waiting to send");
assert_eq!(phrase(Mark::Unknown, false).words, "on the keyboard");
}
fn facts<'a>(device: &'a DeviceState, queue: &'a Queue, tags: &'a Tags) -> Facts<'a> {
Facts {
faces: &[Face::Edit],
showing: Face::Edit,
device,
queue,
tags,
view: false,
renaming: (None, None),
shape: Shape::Fields,
extras: Extras::default(),
}
}
#[test]
fn an_editors_own_state_phrase_keeps_the_strips_ink() {
let (queue, tags) = (Queue::default(), Tags::default());
let device = crate::device::Device::new(egui::Context::default());
let (mut workspace, mut log) = workspace();
let id = workspace.create(Fresh::Program, &mut log).unwrap();
let mut edited = workspace.get(id).unwrap().bytes.clone();
*edited.last_mut().expect("a byte to move") ^= 0xff;
workspace.replace_bytes(id, edited, &mut log);
let mut facts = facts(&device.state, &queue, &tags);
let held = state(workspace.get(id).unwrap(), &facts).expect("an unsaved document");
assert_eq!(held.words, "edited");
facts.extras.edited = Some(StateLine {
words: "6 pending".to_string(),
ink: Ink::Good,
hint: "raw ≠ bits on 6 fields".to_string(),
});
let held = state(workspace.get(id).unwrap(), &facts).expect("an unsaved document");
assert_eq!(held.words, "6 pending");
assert_eq!(held.hint, "raw ≠ bits on 6 fields");
assert_eq!(
held.ink,
Ink::Warn,
"the strip decides the ink, not the editor"
);
}
#[test]
fn the_loud_action_says_which_of_its_three_states_it_is_in() {
use crate::device::Device;
use nord_usb::wire::Status;
let (mut workspace, mut log) = workspace();
let at = Location { bank: 6, slot: 3 };
let bytes = Fresh::SetList.bytes().unwrap();
let unattached = Device::new(egui::Context::default());
let id = workspace.ingest(
"Blue Room.ne5t".into(),
Origin::Device {
class: ObjectClass::SetList,
at,
},
bytes.clone(),
&mut log,
);
let held = action(workspace.get(id).unwrap(), &unattached.state);
assert_eq!(held.tone, Tone::Idle, "{}", held.hint);
assert_eq!(held.send, None, "a dashed action asks for nothing");
assert!(
held.hint.contains("no instrument attached"),
"{}",
held.hint
);
let mut attached = Device::new(egui::Context::default());
attached.pretend_scanned(ObjectClass::SetList, 7, &["Blue Room"]);
let held = action(workspace.get(id).unwrap(), &attached.state);
assert_eq!(held.tone, Tone::Ready);
assert_eq!(held.send, Some((ObjectClass::SetList, at)));
assert_eq!(held.label, "Queue send");
assert_eq!(held.short, "Send");
assert_eq!(held.hint, "replaces Set lists 7:4");
let in_pianos = Location { bank: 0, slot: 3 };
let library = workspace.ingest(
"Grand.npno".into(),
Origin::Device {
class: ObjectClass::Piano,
at: in_pianos,
},
bytes.clone(),
&mut log,
);
let held = action(workspace.get(library).unwrap(), &attached.state);
assert_eq!(held.tone, Tone::Ready, "{}", held.hint);
assert_eq!(held.send, Some((ObjectClass::Piano, in_pianos)));
assert_eq!(held.hint, "replaces Pianos 1:4");
let mut full = Device::new(egui::Context::default());
full.pretend_scanned(ObjectClass::Piano, 1, &["Royal Grand 3D"]);
full.pretend_partitions(&crate::device::ELECTRO5);
full.state.inventory.push(Status {
class: ObjectClass::Piano,
count: 1,
free: 0,
used: 1072,
dirty: 0,
spare: 0,
});
let held = action(workspace.get(library).unwrap(), &full.state);
assert_eq!(held.tone, Tone::Blocked);
assert_eq!(
held.label,
format!("Won't fit · {} over", room::measure(bytes.len() as u64))
);
assert_eq!(held.send, None, "a blocked action asks for nothing");
assert!(held.hint.contains("free in Pianos"), "{}", held.hint);
}
#[test]
fn a_project_offers_a_build_that_refuses_rather_than_a_send() {
let device = crate::device::Device::new(egui::Context::default());
let (held, id) = opened("clarinet.nsmpproj", project_bytes());
let loud = action(held.get(id).unwrap(), &device.state);
assert_eq!(loud.tone, Tone::Blocked);
assert_eq!(loud.label, "Build → .nsmp");
assert_eq!(loud.short, "Build");
assert_eq!(loud.send, None);
}
fn wav_bytes() -> Vec<u8> {
use nord_format::formats::nsmp::codec;
let samples: Vec<i16> = (0..codec::SOURCE_RATE as usize)
.map(|i| ((i as f64 / 40.0).sin() * 12_000.0) as i16)
.collect();
nord_format::wav::mono_pcm16(&samples, codec::SOURCE_RATE).unwrap()
}
fn sample_bytes() -> Vec<u8> {
let source = nord_format::wav::read_pcm16(&wav_bytes()).unwrap();
let options = nord_format::formats::nsmp::encode::Options::new("Marimba");
nord_format::formats::nsmp::encode::instrument(&source.samples, &options)
.unwrap()
.to_bytes()
.unwrap()
}
fn project_bytes() -> Vec<u8> {
use nord_format::formats::nsmpproj::{NewZone, Project};
let project = Project::new(
"clarinet",
&[NewZone {
path: "low.wav".into(),
sample_rate: 44100,
frames: 44100,
root_key: 48,
}],
0,
)
.unwrap();
nord_format::to_bytes(&nord_format::Entity::SampleProject(project)).unwrap()
}
}