use std::collections::HashMap;
use std::sync::Arc;
use eframe::egui;
use nord_format::Entity;
use nord_usb::{Location, ObjectClass};
use crate::app::dot;
use crate::device::{
occupancy, put_refusal, read_only, Connection, Device, DeviceCmd, Outgoing, BROWSED,
};
use crate::log::Log;
use crate::strings::{folder, place, shown};
use crate::tabs::Tabs;
use crate::workspace::{Fresh, LocalEntity, Workspace};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Kind {
Program,
SetList,
Sample,
Piano,
Live,
Settings,
Other,
}
impl Kind {
pub fn of(entity: Option<&Entity>) -> Kind {
match entity {
Some(Entity::Program(_)) => Kind::Program,
Some(Entity::Song(_)) => Kind::SetList,
Some(Entity::Sample(_)) => Kind::Sample,
Some(Entity::Piano(_) | Entity::PianoLibrary(_)) => Kind::Piano,
Some(Entity::Live(_)) => Kind::Live,
Some(Entity::Settings(_)) => Kind::Settings,
_ => Kind::Other,
}
}
pub fn from_class(class: ObjectClass) -> Kind {
match class {
ObjectClass::Program => Kind::Program,
ObjectClass::SetList => Kind::SetList,
ObjectClass::Sample => Kind::Sample,
ObjectClass::Piano => Kind::Piano,
ObjectClass::Live => Kind::Live,
ObjectClass::Settings => Kind::Settings,
ObjectClass::Unknown(_) => Kind::Other,
}
}
pub fn home(self) -> Option<ObjectClass> {
match self {
Kind::Program => Some(ObjectClass::Program),
Kind::SetList => Some(ObjectClass::SetList),
Kind::Sample => Some(ObjectClass::Sample),
Kind::Piano => Some(ObjectClass::Piano),
Kind::Live => Some(ObjectClass::Live),
Kind::Settings => Some(ObjectClass::Settings),
Kind::Other => None,
}
}
pub fn chip(self) -> &'static str {
match self {
Kind::Program => "program",
Kind::SetList => "set list",
Kind::Sample => "sample",
Kind::Piano => "piano",
Kind::Live => "live",
Kind::Settings => "settings",
Kind::Other => "file",
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Item {
Local(u64),
Folder(u64),
Slot {
class: ObjectClass,
at: Location,
},
}
#[derive(Clone)]
pub struct Carried {
pub from: Item,
pub kind: Kind,
pub name: String,
pub filed: Option<u64>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Onto {
Computer,
Group(u64),
Slot {
class: ObjectClass,
at: Location,
},
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Landing {
Copy,
Send,
Rearrange,
File,
Unfile,
No(&'static str),
}
impl Landing {
pub fn allowed(self) -> bool {
!matches!(self, Landing::No(_))
}
}
pub fn landing(carried: &Carried, onto: Onto) -> Landing {
match (carried.from, onto) {
(Item::Folder(_), _) => Landing::No("a folder is not dragged"),
(Item::Local(_), Onto::Computer) => match carried.filed {
Some(_) => Landing::Unfile,
None => Landing::No("it is already on this computer"),
},
(Item::Local(_), Onto::Group(id)) => match carried.filed == Some(id) {
true => Landing::No("it is already in that folder"),
false => Landing::File,
},
(Item::Slot { .. }, Onto::Group(_)) => {
Landing::No("copy it to this computer first, then drag it into the folder")
}
(Item::Local(_), Onto::Slot { class, .. }) => {
if read_only(class) {
Landing::No("pianos are installed on the instrument, not moved into it")
} else if put_refusal(class).is_some() {
Landing::No("this folder cannot be written to over USB")
} else if carried.kind.home() != Some(class) {
Landing::No("that folder holds a different kind of thing")
} else {
Landing::Send
}
}
(Item::Slot { .. }, Onto::Computer) => Landing::Copy,
(
Item::Slot {
class: from,
at: was,
},
Onto::Slot { class, at },
) => {
if from != class {
Landing::No("things only move within their own folder")
} else if read_only(class) {
Landing::No("pianos stay where the instrument put them")
} else if was == at {
Landing::No("it is already there")
} else {
Landing::Rearrange
}
}
}
}
pub enum Act {
Connect,
Disconnect,
OpenFiles,
New(Fresh),
Resync,
ReadAgain(ObjectClass),
Open(Item),
Keep(u64),
NewFolder,
RemoveFolder(u64),
File {
id: u64,
folder: Option<u64>,
},
SendFolder(u64),
Copy {
class: ObjectClass,
at: Location,
},
LoadOnInstrument {
class: ObjectClass,
at: Location,
},
Send {
id: u64,
class: ObjectClass,
at: Location,
},
SendAll,
Replace {
id: u64,
class: ObjectClass,
at: Location,
},
Rearrange {
class: ObjectClass,
from: Location,
to: Location,
},
RenameLocal {
id: u64,
name: String,
},
RenameFolder {
id: u64,
name: String,
},
RenameSlot {
class: ObjectClass,
at: Location,
name: String,
},
DuplicateLocal(u64),
DuplicateSlot {
class: ObjectClass,
from: Location,
to: Location,
},
DeleteSlot {
class: ObjectClass,
at: Location,
},
Remove(u64),
Save(u64),
Refused(String),
}
struct Rename {
what: Item,
text: String,
fresh: bool,
}
struct Ask {
title: String,
note: Option<String>,
verb: &'static str,
act: Act,
}
pub fn arms_rename(selected: bool, on_name: bool) -> bool {
selected && on_name
}
pub fn renamed(original: &str, typed: &str) -> Option<String> {
let typed = typed.trim();
match typed.is_empty() || typed == original.trim() {
true => None,
false => Some(typed.to_string()),
}
}
pub struct Folder {
pub id: u64,
pub name: String,
}
#[derive(Default)]
pub struct Folders {
list: Vec<Folder>,
of: HashMap<u64, u64>,
}
impl Folders {
pub fn all(&self) -> &[Folder] {
&self.list
}
fn name_of(&self, id: u64) -> Option<&str> {
self.list
.iter()
.find(|folder| folder.id == id)
.map(|folder| folder.name.as_str())
}
fn make(&mut self) -> u64 {
let id = self.list.iter().map(|folder| folder.id).max().unwrap_or(0) + 1;
let taken = |name: &str| self.list.iter().any(|folder| folder.name == name);
let mut name = "New folder".to_string();
for nth in 2.. {
if !taken(&name) {
break;
}
name = format!("New folder {nth}");
}
self.list.push(Folder { id, name });
id
}
fn rename(&mut self, id: u64, name: String) {
if let Some(folder) = self.list.iter_mut().find(|folder| folder.id == id) {
folder.name = name;
}
}
fn remove(&mut self, id: u64) {
self.list.retain(|folder| folder.id != id);
self.of.retain(|_, held| *held != id);
}
fn file(&mut self, entity: u64, folder: Option<u64>) {
match folder.filter(|id| self.name_of(*id).is_some()) {
Some(id) => self.of.insert(entity, id),
None => self.of.remove(&entity),
};
}
fn forget(&mut self, entity: u64) {
self.of.remove(&entity);
}
fn forget_missing(&mut self, workspace: &Workspace) {
self.of.retain(|entity, _| workspace.get(*entity).is_some());
}
pub fn holding(&self, entity: u64) -> Option<u64> {
self.of.get(&entity).copied()
}
fn members<'a>(&self, id: u64, workspace: &'a Workspace) -> Vec<&'a LocalEntity> {
workspace
.listed()
.filter(|entity| self.holding(entity.id) == Some(id))
.collect()
}
}
fn written(folders: &Folders) -> String {
let mut out = format!("{FOLDERS_VERSION}\n");
for folder in &folders.list {
out.push_str(&format!(
"f\t{}\t{}\n",
folder.id,
crate::store::escape(&folder.name)
));
}
for (entity, folder) in &folders.of {
out.push_str(&format!("m\t{entity}\t{folder}\n"));
}
out
}
fn read(text: &str) -> Folders {
let mut lines = text.lines();
if lines.next() != Some(FOLDERS_VERSION) {
return Folders::default();
}
let mut folders = Folders::default();
for line in lines {
let mut parts = line.split('\t');
match (parts.next(), parts.next(), parts.next()) {
(Some("f"), Some(id), Some(name)) => {
if let Ok(id) = id.parse() {
folders.list.push(Folder {
id,
name: crate::store::unescape(name),
});
}
}
(Some("m"), Some(entity), Some(folder)) => {
if let (Ok(entity), Ok(folder)) = (entity.parse(), folder.parse()) {
folders.of.insert(entity, folder);
}
}
_ => {}
}
}
let known: Vec<u64> = folders.list.iter().map(|folder| folder.id).collect();
folders.of.retain(|_, folder| known.contains(folder));
folders
}
pub struct Browser {
selection: Option<Item>,
rename: Option<Rename>,
ask: Option<Ask>,
split: f32,
folders: Folders,
jump: Option<(ObjectClass, Location)>,
}
impl Default for Browser {
fn default() -> Browser {
Browser {
selection: None,
rename: None,
ask: None,
split: EVEN,
folders: Folders::default(),
jump: None,
}
}
}
const EVEN: f32 = 0.5;
const LEAST: f32 = 0.15;
const HANDLE: f32 = 7.0;
const FOLDERS_VERSION: &str = "drawbar folders 1";
impl Browser {
pub const SPLIT: &'static str = "drawbar.dock_split";
pub const FOLDERS: &'static str = "drawbar.folders";
pub fn restore(&mut self, storage: &dyn eframe::Storage) {
self.split = storage
.get_string(Browser::SPLIT)
.and_then(|text| text.parse::<f32>().ok())
.filter(|share| (LEAST..=1.0 - LEAST).contains(share))
.unwrap_or(EVEN);
self.folders = storage
.get_string(Browser::FOLDERS)
.map(|text| read(&text))
.unwrap_or_default();
}
pub fn settle(&mut self, workspace: &Workspace) {
self.folders.forget_missing(workspace);
}
pub fn keep(&self, storage: &mut dyn eframe::Storage) {
storage.set_string(Browser::SPLIT, self.split.to_string());
storage.set_string(Browser::FOLDERS, written(&self.folders));
}
pub fn ui(&mut self, ui: &mut egui::Ui, workspace: &Workspace, device: &Device) -> Vec<Act> {
let mut acts = Vec::new();
self.dialog(ui.ctx(), &mut acts);
match device.state.connected() {
true => self.dock(ui, workspace, device, &mut acts),
false => self.computer(ui, workspace, device, &mut acts),
}
ghost(ui.ctx());
acts
}
fn dock(
&mut self,
ui: &mut egui::Ui,
workspace: &Workspace,
device: &Device,
acts: &mut Vec<Act>,
) {
let whole = ui.available_rect_before_wrap();
let usable = (whole.width() - HANDLE).max(1.0);
let left = usable * self.split;
let divider = egui::Rect::from_min_size(
egui::pos2(whole.left() + left, whole.top()),
egui::vec2(HANDLE, whole.height()),
);
let dragging = ui
.interact(
divider,
ui.id().with("dock_divider"),
egui::Sense::click_and_drag(),
)
.on_hover_and_drag_cursor(egui::CursorIcon::ResizeHorizontal);
if dragging.dragged() {
self.split = ((left + dragging.drag_delta().x) / usable).clamp(LEAST, 1.0 - LEAST);
}
if dragging.double_clicked() {
self.split = EVEN;
}
let ends = |from: f32, to: f32| {
egui::Rect::from_min_max(
egui::pos2(from, whole.top()),
egui::pos2(to, whole.bottom()),
)
};
ui.scope_builder(
egui::UiBuilder::new().max_rect(ends(whole.left(), divider.left())),
|ui| self.computer(ui, workspace, device, acts),
);
ui.scope_builder(
egui::UiBuilder::new().max_rect(ends(divider.right(), whole.right())),
|ui| self.instrument(ui, workspace, device, acts),
);
let visuals = ui.visuals();
let stroke = match dragging.hovered() || dragging.dragged() {
true => egui::Stroke::new(2.0_f32, visuals.selection.stroke.color),
false => visuals.widgets.noninteractive.bg_stroke,
};
ui.painter()
.vline(divider.center().x, whole.y_range(), stroke);
ui.advance_cursor_after_rect(whole);
}
fn heading(
&mut self,
ui: &mut egui::Ui,
title: &str,
buttons: impl FnOnce(&mut egui::Ui),
) -> egui::Response {
let head = ui
.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new(title).strong());
buttons(ui);
})
.response;
ui.separator();
head
}
fn select(&mut self, item: Item) {
let same = self.rename.as_ref().is_some_and(|r| r.what == item);
if !same {
self.rename = None;
}
self.selection = Some(item);
}
fn forget_rename(&mut self, what: Item) {
if self.rename.as_ref().is_some_and(|r| r.what == what) {
self.rename = None;
}
if self.selection == Some(what) {
self.selection = None;
}
}
fn start_rename(&mut self, what: Item, from: &str) {
self.selection = Some(what);
self.rename = Some(Rename {
what,
text: from.to_string(),
fresh: true,
});
}
fn computer(
&mut self,
ui: &mut egui::Ui,
workspace: &Workspace,
device: &Device,
acts: &mut Vec<Act>,
) {
let mut open_files = false;
let mut fresh = None;
let mut new_folder = false;
let mut connect = false;
let attached = device.state.connected();
let connecting = matches!(device.state.connection, Connection::Connecting);
let head = self.heading(ui, "This computer", |ui| {
open_files = ui.small_button("Open…").clicked();
ui.menu_button("New", |ui| {
for family in &Fresh::FAMILIES {
ui.menu_button(family.label, |ui| {
for kind in family.kinds {
let mut entry = ui.button(kind.label());
if let Some(note) = kind.note() {
entry = entry.on_hover_text(note);
}
if entry.clicked() {
fresh = Some(*kind);
ui.close();
}
}
});
}
});
new_folder = ui
.small_button("New folder")
.on_hover_text(
"a way of grouping the list on this computer; the instrument never sees one",
)
.clicked();
if attached {
return;
}
match connecting {
true => {
ui.spinner();
}
false => {
connect = ui
.small_button("Connect instrument")
.on_hover_text(
"Close Nord Sound Manager first — it holds the instrument on its \
own, and nothing else can reach it alongside.\n\nIn a browser: \
Chrome or Edge only.",
)
.clicked();
}
}
});
if open_files {
acts.push(Act::OpenFiles);
}
if let Some(kind) = fresh {
acts.push(Act::New(kind));
}
if new_folder {
acts.push(Act::NewFolder);
}
if connect {
acts.push(Act::Connect);
}
self.drop_zone(ui, &head, Onto::Computer, acts);
egui::ScrollArea::vertical()
.id_salt("computer_scroll")
.auto_shrink([false; 2])
.show(ui, |ui| {
if workspace.listed().next().is_none() && self.folders.all().is_empty() {
ui.label(
egui::RichText::new("Drop Nord files here, or use Open…")
.weak()
.italics(),
);
}
for id in self.folder_ids() {
self.folder_rows(ui, id, workspace, device, acts);
}
for entity in workspace.listed() {
if self.folders.holding(entity.id).is_none() {
self.local_row(ui, entity, acts);
}
}
if let Some(carried) = egui::DragAndDrop::payload::<Carried>(ui.ctx()) {
let landing = row(
ui,
false,
&Cells {
name: match carried.filed.is_some() {
true => "Drop here to take it out of its folder",
false => "Drop here to copy it to this computer",
},
faint: true,
..Cells::default()
},
);
self.drop_zone(ui, &landing.response, Onto::Computer, acts);
}
});
}
fn folder_ids(&self) -> Vec<u64> {
self.folders.all().iter().map(|folder| folder.id).collect()
}
fn folder_rows(
&mut self,
ui: &mut egui::Ui,
id: u64,
workspace: &Workspace,
device: &Device,
acts: &mut Vec<Act>,
) {
let item = Item::Folder(id);
let Some(name) = self.folders.name_of(id).map(str::to_string) else {
return;
};
let members: Vec<u64> = self
.folders
.members(id, workspace)
.iter()
.map(|entity| entity.id)
.collect();
if self.rename.as_ref().is_some_and(|r| r.what == item) {
if let Some(name) = self.rename_row(ui, &name) {
acts.push(Act::RenameFolder { id, name });
}
ui.indent(("folder_body", id), |ui| {
for entity in members.iter().filter_map(|id| workspace.get(*id)) {
self.local_row(ui, entity, acts);
}
});
return;
}
let title = format!("{name} · {}", members.len());
let drawn = egui::CollapsingHeader::new(egui::RichText::new(title).strong())
.id_salt(("folder", id))
.default_open(true)
.show(ui, |ui| {
if members.is_empty() {
ui.label(egui::RichText::new("empty — drag sounds in").small().weak());
}
for entity in members.iter().filter_map(|id| workspace.get(*id)) {
self.local_row(ui, entity, acts);
}
});
let head = drawn.header_response;
self.drop_zone(ui, &head, Onto::Group(id), acts);
if head.clicked() {
self.select(item);
}
let sendable = members
.iter()
.filter_map(|id| workspace.get(*id))
.filter(|entity| owed(entity).is_some())
.count();
head.context_menu(|ui| {
self.select(item);
if ui
.add_enabled(
sendable > 0,
egui::Button::new(format!("Send folder to keyboard ({sendable})")),
)
.on_hover_text("everything in here that came off a slot, changed or not")
.on_disabled_hover_text(
"nothing in here came off a slot, so there is nowhere to send it back to",
)
.clicked()
{
self.ask_send(
workspace,
device,
&members,
format!("Send everything in “{name}” to the instrument?"),
Act::SendFolder(id),
);
ui.close();
}
ui.add_enabled(false, egui::Button::new("Export as a bundle…"))
.on_disabled_hover_text("bundles are not written yet");
ui.separator();
if ui.button("Rename").clicked() {
self.start_rename(item, &name);
ui.close();
}
if ui
.button("Remove folder")
.on_hover_text("what is in it goes back to the list; nothing is deleted")
.clicked()
{
acts.push(Act::RemoveFolder(id));
ui.close();
}
});
}
fn local_row(
&mut self,
ui: &mut egui::Ui,
entity: &crate::workspace::LocalEntity,
acts: &mut Vec<Act>,
) {
let item = Item::Local(entity.id);
let kind = Kind::of(entity.entity.as_ref());
let selected = self.selection == Some(item);
if self.rename.as_ref().is_some_and(|r| r.what == item) {
if let Some(name) = self.rename_row(ui, &entity.name) {
acts.push(Act::RenameLocal {
id: entity.id,
name,
});
}
return;
}
let owed = entity.pending.then(|| destination(entity)).flatten();
let filed = self.folders.holding(entity.id);
let drawn = row(
ui,
selected,
&Cells {
name: &entity.name,
note: owed.as_deref().or(Some(kind.chip())),
dirty: entity.dirty,
waiting: owed.is_some(),
..Cells::default()
},
);
let response = drawn.response;
if response.dragged() {
egui::DragAndDrop::set_payload(
ui.ctx(),
Carried {
from: item,
kind,
name: entity.name.clone(),
filed,
},
);
}
self.drop_zone(ui, &response, Onto::Computer, acts);
if response.double_clicked() {
acts.push(Act::Open(item));
} else if response.clicked() {
self.clicked(item, &response, drawn.name, &entity.name);
}
if selected && ui.input(|i| i.key_pressed(egui::Key::F2)) {
self.start_rename(item, &entity.name);
}
response.context_menu(|ui| {
self.select(item);
if ui.button("Open").clicked() {
acts.push(Act::Open(item));
ui.close();
}
if ui.button("Export…").clicked() {
acts.push(Act::Save(entity.id));
ui.close();
}
if ui.button("Rename").clicked() {
self.start_rename(item, &entity.name);
ui.close();
}
if ui.button("Duplicate").clicked() {
acts.push(Act::DuplicateLocal(entity.id));
ui.close();
}
self.filing_menu(ui, entity.id, filed, acts);
ui.separator();
if ui.button("Remove from list").clicked() {
acts.push(Act::Remove(entity.id));
ui.close();
}
});
}
fn filing_menu(&self, ui: &mut egui::Ui, id: u64, filed: Option<u64>, acts: &mut Vec<Act>) {
if self.folders.all().is_empty() {
return;
}
ui.menu_button("Move to folder", |ui| {
for folder in self.folders.all() {
if ui
.selectable_label(filed == Some(folder.id), &folder.name)
.clicked()
{
acts.push(Act::File {
id,
folder: Some(folder.id),
});
ui.close();
}
}
ui.separator();
if ui
.add_enabled(filed.is_some(), egui::Button::new("Out of any folder"))
.clicked()
{
acts.push(Act::File { id, folder: None });
ui.close();
}
});
}
fn clicked(&mut self, item: Item, response: &egui::Response, name: egui::Rect, from: &str) {
let on_name = response
.interact_pointer_pos()
.is_some_and(|at| name.contains(at));
match arms_rename(self.selection == Some(item), on_name) {
true => self.start_rename(item, from),
false => self.select(item),
}
}
fn instrument(
&mut self,
ui: &mut egui::Ui,
workspace: &Workspace,
device: &Device,
acts: &mut Vec<Act>,
) {
let Some(product) = device.state.product().map(str::to_string) else {
return;
};
let mut disconnect = false;
let mut send_all = false;
let mut sync = false;
let viewed: Vec<(ObjectClass, Location)> = workspace
.entities()
.iter()
.filter(|entity| !entity.kept)
.filter_map(|entity| entity.origin.slot())
.collect();
let owed = workspace.pending().len();
let firmware = device.state.firmware();
let reading = BROWSED
.iter()
.filter_map(|class| device.state.scan.progress(*class))
.any(|progress| progress.running);
self.heading(ui, &product, |ui| {
dot(ui, crate::app::good(ui.visuals())).on_hover_text("attached");
if let Some(firmware) = &firmware {
ui.label(egui::RichText::new(firmware).small().weak())
.on_hover_text("the firmware version the instrument reports");
}
sync = ui
.add_enabled(!reading, egui::Button::new("Sync").small())
.on_hover_text("read the whole instrument again")
.on_disabled_hover_text("already reading")
.clicked();
disconnect = ui.small_button("Disconnect").clicked();
if owed > 0 {
send_all = ui
.button(egui::RichText::new(format!("Send all ({owed})")).strong())
.on_hover_text("write every waiting sound back to the instrument")
.clicked();
}
});
if disconnect {
acts.push(Act::Disconnect);
}
if sync {
acts.push(Act::Resync);
}
if send_all {
let waiting: Vec<u64> = workspace.pending().iter().map(|e| e.id).collect();
let title = match waiting.len() {
1 => "Send 1 sound to the instrument?".to_string(),
n => format!("Send {n} sounds to the instrument?"),
};
self.ask_send(workspace, device, &waiting, title, Act::SendAll);
}
egui::ScrollArea::vertical()
.id_salt("instrument_scroll")
.auto_shrink([false; 2])
.show(ui, |ui| {
self.about(ui, device);
for class in BROWSED {
self.class(ui, device, class, &viewed, acts);
}
});
}
fn about(&self, ui: &mut egui::Ui, device: &Device) {
let Some(card) = device.state.card() else {
return;
};
egui::CollapsingHeader::new(egui::RichText::new("About this instrument").small())
.id_salt("instrument_about")
.default_open(false)
.show(ui, |ui| {
let mut fact = |what: &str, value: Option<String>| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(what).small().weak());
match value {
Some(value) => {
ui.label(egui::RichText::new(value).small().monospace());
}
None => {
ui.label(
egui::RichText::new("not asked for on this build")
.small()
.weak()
.italics(),
);
}
}
});
};
fact("product", Some(card.product.clone()));
fact("maker", card.manufacturer.clone());
fact(
"usb",
Some(format!("{:04x}:{:04x}", card.vendor_id, card.product_id)),
);
fact("serial", card.serial.clone());
fact(
"interface",
card.interface.map(|held| format!("{held} (vendor)")),
);
fact("firmware", device.state.firmware());
fact("build", card.build.map(|held| held.to_string()));
fact("kind", card.kind.map(|held| format!("{held:#06x}")));
fact(
"max transfer",
card.max_transfer.map(|held| format!("{held} bytes")),
);
ui.label(
egui::RichText::new(
"The build and kind words are what the device answers at their \
requests; what they mean is not pinned down.",
)
.small()
.weak()
.italics(),
);
});
}
fn class(
&mut self,
ui: &mut egui::Ui,
device: &Device,
class: ObjectClass,
viewed: &[(ObjectClass, Location)],
acts: &mut Vec<Act>,
) {
let progress = device.state.scan.progress(class);
let title = match progress {
Some(p) if p.running => match p.total {
Some(total) => format!("{} · reading {} of {total}", folder(class), p.done + 1),
None => format!("{} · reading…", folder(class)),
},
_ => match occupancy(class, &device.state.inventory) {
Some(held) => format!("{} · {held}", folder(class)),
None => folder(class).to_string(),
},
};
let focus = device.state.focused(class);
let heading = egui::CollapsingHeader::new(title);
let heading = match self.jump.is_some_and(|(held, _)| held == class) {
true => heading.open(Some(true)),
false => heading.default_open(matches!(class, ObjectClass::Program)),
};
let drawn = heading.id_salt(class.to_raw()).show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
if read_only(class) {
ui.label(egui::RichText::new("read only").small().weak());
}
if let Some(at) = focus {
if ui
.small_button("Go to loaded")
.on_hover_text(format!("the panel is on {}", shown(at)))
.clicked()
{
self.jump = Some((class, at));
}
}
});
let banks = device.state.banks_of(class);
if banks.is_empty() {
ui.label(egui::RichText::new("nothing read yet").small().weak());
}
let cut = banks.len() > 1;
for bank in banks {
self.bank(ui, device, class, bank, cut, viewed, acts);
}
});
if self
.jump
.is_some_and(|(held, at)| held == class && device.state.slot(class, at).is_none())
{
self.jump = None;
}
drawn.header_response.context_menu(|ui| {
if ui
.button("Read this folder again")
.on_hover_text("Sync reads the whole instrument; this reads one folder")
.clicked()
{
acts.push(Act::ReadAgain(class));
ui.close();
}
});
}
#[allow(clippy::too_many_arguments)]
fn bank(
&mut self,
ui: &mut egui::Ui,
device: &Device,
class: ObjectClass,
bank: u32,
cut: bool,
viewed: &[(ObjectClass, Location)],
acts: &mut Vec<Act>,
) {
let Some(slots) = device.state.bank(class, bank) else {
return;
};
let count = slots.len();
let held = slots.iter().filter(|slot| slot.is_some()).count();
let name = device
.state
.bank_name(class, bank)
.filter(|name| worth_captioning(bank, name))
.map(str::to_string);
let mut rows = |browser: &mut Browser, ui: &mut egui::Ui| {
for index in 0..count {
let at = Location::from_user(bank, index as u32 + 1);
browser.slot_row(ui, device, class, at, viewed, acts);
}
};
if !cut {
if let Some(name) = &name {
ui.label(egui::RichText::new(name).small().weak());
}
return rows(self, ui);
}
let title = match &name {
Some(name) => format!("{bank} · {name} · {held}/{count}"),
None => format!("Bank {bank} · {held}/{count}"),
};
let focused = device
.state
.focused(class)
.is_some_and(|at| at.bank + 1 == bank);
let jumping = self
.jump
.is_some_and(|(held, at)| held == class && at.bank + 1 == bank);
let heading = egui::CollapsingHeader::new(title);
let heading = match jumping {
true => heading.open(Some(true)),
false => heading.default_open(focused),
};
heading
.id_salt(("bank", class.to_raw(), bank))
.show(ui, |ui| rows(self, ui));
}
fn slot_row(
&mut self,
ui: &mut egui::Ui,
device: &Device,
class: ObjectClass,
at: Location,
viewed: &[(ObjectClass, Location)],
acts: &mut Vec<Act>,
) {
let held = device
.state
.slot(class, at)
.flatten()
.map(|info| info.name.trim().to_string());
let item = Item::Slot { class, at };
let selected = self.selection == Some(item);
if self.rename.as_ref().is_some_and(|r| r.what == item) {
let was = held.clone().unwrap_or_default();
if let Some(name) = self.rename_row(ui, &was) {
acts.push(Act::RenameSlot { class, at, name });
}
return;
}
let loaded = device.state.focused(class) == Some(at);
let viewing = viewed.contains(&(class, at));
let drawn = row(
ui,
selected,
&Cells {
at: Some(shown(at)),
name: held.as_deref().unwrap_or("empty"),
note: viewing.then_some("open"),
faint: held.is_none(),
loaded,
..Cells::default()
},
);
let mut response = drawn.response;
if loaded {
response = response.on_hover_text("on the instrument's panel now");
}
if viewing {
response = response
.on_hover_text("open in a tab as a view of this slot — it is not on this computer");
}
if self.jump == Some((class, at)) {
self.jump = None;
self.selection = Some(item);
response.scroll_to_me(Some(egui::Align::Center));
}
let fetchable = !read_only(class);
if let Some(name) = &held {
if fetchable && response.dragged() {
egui::DragAndDrop::set_payload(
ui.ctx(),
Carried {
from: item,
kind: Kind::from_class(class),
name: name.clone(),
filed: None,
},
);
}
}
self.drop_zone(ui, &response, Onto::Slot { class, at }, acts);
if response.double_clicked() {
if held.is_some() && fetchable {
acts.push(Act::Open(item));
}
} else if response.clicked() {
match (&held, fetchable) {
(Some(name), true) => self.clicked(item, &response, drawn.name, name),
_ => self.select(item),
}
}
if let Some(name) = &held {
if selected && fetchable && ui.input(|i| i.key_pressed(egui::Key::F2)) {
self.start_rename(item, name);
}
}
let Some(name) = held else {
return;
};
response.context_menu(|ui| {
self.select(item);
if !fetchable {
ui.label(
egui::RichText::new("Installed on the instrument; nothing to change here.")
.weak(),
);
return;
}
if ui
.button("Open")
.on_hover_text("a view of this slot; nothing joins the list on this computer")
.clicked()
{
acts.push(Act::Open(item));
ui.close();
}
if ui.button("Copy to this computer").clicked() {
acts.push(Act::Copy { class, at });
ui.close();
}
if ui.button("Load on instrument").clicked() {
acts.push(Act::LoadOnInstrument { class, at });
ui.close();
}
ui.separator();
if ui.button("Rename").clicked() {
self.start_rename(item, &name);
ui.close();
}
let free = device.state.first_free(class);
if ui
.add_enabled(free.is_some(), egui::Button::new("Duplicate"))
.on_disabled_hover_text("every slot read so far is taken")
.clicked()
{
if let Some(to) = free {
acts.push(Act::DuplicateSlot {
class,
from: at,
to,
});
}
ui.close();
}
ui.separator();
if ui.button("Delete…").clicked() {
self.ask = Some(Ask {
title: format!("Delete “{name}” from {}?", place(class, at)),
note: Some("It is removed from the instrument. There is no undo.".into()),
verb: "Delete",
act: Act::DeleteSlot { class, at },
});
ui.close();
}
});
}
fn rename_row(&mut self, ui: &mut egui::Ui, original: &str) -> Option<String> {
let rename = self.rename.as_mut()?;
let output = ui
.horizontal(|ui| {
egui::TextEdit::singleline(&mut rename.text)
.desired_width(ui.available_width())
.show(ui)
})
.inner;
if rename.fresh {
rename.fresh = false;
output.response.request_focus();
let all = egui::text::CCursorRange::two(
egui::text::CCursor::new(0),
egui::text::CCursor::new(rename.text.chars().count()),
);
if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), output.response.id) {
state.cursor.set_char_range(Some(all));
state.store(ui.ctx(), output.response.id);
}
return None;
}
let lost = output.response.lost_focus();
let entered = lost && ui.input(|i| i.key_pressed(egui::Key::Enter));
if !lost {
return None;
}
let typed = std::mem::take(&mut rename.text);
self.rename = None;
entered.then(|| renamed(original, &typed)).flatten()
}
fn drop_zone(
&mut self,
ui: &egui::Ui,
response: &egui::Response,
onto: Onto,
acts: &mut Vec<Act>,
) {
if let Some(carried) = response.dnd_hover_payload::<Carried>() {
if landing(&carried, onto).allowed() {
ui.painter().rect_stroke(
response.rect,
3.0,
egui::Stroke::new(1.0_f32, ui.visuals().selection.stroke.color),
egui::StrokeKind::Inside,
);
}
}
let Some(carried) = response.dnd_release_payload::<Carried>() else {
return;
};
self.land(&carried, onto, acts);
}
fn land(&mut self, carried: &Arc<Carried>, onto: Onto, acts: &mut Vec<Act>) {
match (landing(carried, onto), carried.from, onto) {
(Landing::Copy, Item::Slot { class, at }, _) => acts.push(Act::Copy { class, at }),
(Landing::Rearrange, Item::Slot { at: from, .. }, Onto::Slot { class, at }) => acts
.push(Act::Rearrange {
class,
from,
to: at,
}),
(Landing::Send, Item::Local(id), Onto::Slot { class, at }) => {
acts.push(Act::Send { id, class, at })
}
(Landing::File, Item::Local(id), Onto::Group(folder)) => acts.push(Act::File {
id,
folder: Some(folder),
}),
(Landing::Unfile, Item::Local(id), Onto::Computer) => {
acts.push(Act::File { id, folder: None })
}
(Landing::No(why), ..) => acts.push(Act::Refused(format!(
"“{}” cannot go there — {why}.",
carried.name
))),
_ => {}
}
}
fn dialog(&mut self, ctx: &egui::Context, acts: &mut Vec<Act>) {
let Some(ask) = &self.ask else {
return;
};
let mut decision = None;
egui::Modal::new(egui::Id::new("browser_ask")).show(ctx, |ui| {
ui.set_width(400.0);
ui.heading(&ask.title);
if let Some(note) = &ask.note {
ui.add_space(4.0);
ui.label(note);
}
ui.add_space(8.0);
ui.separator();
ui.horizontal(|ui| {
if ui.button("Cancel").clicked() {
decision = Some(false);
}
if ui
.add(egui::Button::new(egui::RichText::new(ask.verb).strong()))
.clicked()
{
decision = Some(true);
}
});
});
match decision {
Some(true) => {
if let Some(ask) = self.ask.take() {
acts.push(ask.act);
}
}
Some(false) => self.ask = None,
None => {}
}
}
fn ask_send(
&mut self,
workspace: &Workspace,
device: &Device,
ids: &[u64],
title: String,
act: Act,
) {
let mut lines = Vec::new();
let mut warnings: Vec<String> = Vec::new();
for entity in ids.iter().filter_map(|id| workspace.get(*id)) {
let Some((class, at)) = owed(entity) else {
continue;
};
let where_ = place(class, at);
if let Some(warning) = foreign_format(&entity.tag(), &device.state.formats_in(class)) {
if !warnings.contains(&warning) {
warnings.push(warning);
}
}
lines.push(match device.state.slot(class, at).flatten() {
Some(info) => format!(
"“{}” replaces “{}” in {where_}",
entity.name,
info.name.trim()
),
None => format!("“{}” goes into {where_}, which is empty", entity.name),
});
}
if lines.is_empty() {
return;
}
let mut note = warnings;
if !note.is_empty() {
note.push(String::new());
}
note.extend(lines);
self.ask = Some(Ask {
title,
note: Some(note.join("\n")),
verb: "Send",
act,
});
}
fn ask_replace(
&mut self,
occupant: &str,
incoming: &str,
at: String,
warning: Option<String>,
act: Act,
) {
let note =
format!("“{occupant}” is read back first and put where it was if anything goes wrong.");
self.ask = Some(Ask {
title: format!("Replace “{occupant}” in {at} with “{incoming}”?"),
note: Some(match warning {
Some(warning) => format!("{warning}\n\n{note}"),
None => note,
}),
verb: "Replace",
act,
});
}
}
#[derive(Default)]
pub struct Cells<'a> {
pub at: Option<String>,
pub name: &'a str,
pub note: Option<&'a str>,
pub waiting: bool,
pub faint: bool,
pub dirty: bool,
pub loaded: bool,
}
pub struct Drawn {
pub response: egui::Response,
pub name: egui::Rect,
}
const AT_W: f32 = 42.0;
fn row(ui: &mut egui::Ui, selected: bool, cells: &Cells) -> Drawn {
let height = ui.text_style_height(&egui::TextStyle::Body) + 4.0;
let (rect, response) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), height),
egui::Sense::click_and_drag(),
);
let visuals = ui.visuals();
let fill = match (selected, response.hovered()) {
(true, _) => Some(visuals.selection.bg_fill),
(false, true) => Some(visuals.faint_bg_color),
(false, false) => None,
};
let ink = match selected {
true => visuals.selection.stroke.color,
false => visuals.text_color(),
};
let weak = match selected {
true => ink.gamma_multiply(visuals.weak_text_alpha),
false => visuals.weak_text_color(),
};
let strong = match cells.faint {
true => weak,
false => ink,
};
let painter = ui.painter().clone();
if let Some(fill) = fill {
painter.rect_filled(rect, 3.0, fill);
}
let mut x = rect.left() + 4.0;
let gutter = egui::pos2(x + 3.5, rect.center().y);
if cells.dirty {
painter.circle_filled(gutter, 3.5, crate::app::warn(ui.visuals()));
} else if cells.loaded {
painter.circle_stroke(
gutter,
3.0,
egui::Stroke::new(1.5_f32, crate::app::good(ui.visuals())),
);
}
x += 10.0;
if let Some(at) = &cells.at {
let galley = painter.layout_no_wrap(at.clone(), egui::FontId::monospace(11.0), weak);
painter.galley(
egui::pos2(x, rect.center().y - galley.size().y / 2.0),
galley,
egui::Color32::PLACEHOLDER,
);
x += AT_W;
}
let font = egui::FontId::proportional(13.0);
let galley = painter.layout_no_wrap(cells.name.to_string(), font.clone(), strong);
let at = egui::pos2(x, rect.center().y - galley.size().y / 2.0);
let name = egui::Rect::from_min_size(at, galley.size());
x += galley.size().x + 8.0;
painter.galley(at, galley, egui::Color32::PLACEHOLDER);
if let Some(note) = cells.note {
let galley =
painter.layout_no_wrap(note.to_string(), egui::FontId::proportional(10.0), weak);
painter.galley(
egui::pos2(x, rect.center().y - galley.size().y / 2.0),
galley,
egui::Color32::PLACEHOLDER,
);
}
Drawn { response, name }
}
fn worth_captioning(bank: u32, name: &str) -> bool {
let name = name.trim();
!name.is_empty()
&& name != bank.to_string()
&& !name.eq_ignore_ascii_case(&format!("bank {bank}"))
}
fn destination(entity: &crate::workspace::LocalEntity) -> Option<String> {
let (class, at) = entity.origin.slot()?;
Some(format!("will be sent to {}", place(class, at)))
}
fn ghost(ctx: &egui::Context) {
let Some(carried) = egui::DragAndDrop::payload::<Carried>(ctx) else {
return;
};
let Some(at) = ctx.pointer_interact_pos() else {
return;
};
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Tooltip,
egui::Id::new("drag_ghost"),
));
let where_ = at + egui::vec2(12.0, 6.0);
let text = painter.layout_no_wrap(
carried.name.clone(),
egui::FontId::proportional(12.0),
ctx.style().visuals.strong_text_color(),
);
painter.rect_filled(
egui::Rect::from_min_size(where_, text.size()).expand(4.0),
3.0,
ctx.style().visuals.window_fill,
);
painter.galley(where_, text, egui::Color32::PLACEHOLDER);
}
pub fn apply(
browser: &mut Browser,
acts: Vec<Act>,
workspace: &mut Workspace,
device: &mut Device,
tabs: &mut Tabs,
log: &mut Log,
) {
for act in acts {
match act {
Act::Connect => device.connect(log),
Act::Disconnect => device.disconnect(log),
Act::OpenFiles => workspace.open_dialog(),
Act::New(kind) => {
if let Some(id) = workspace.create(kind, log) {
tabs.open(id, workspace);
}
}
Act::Resync => {
device.resync();
log.say("Reading the instrument again…");
}
Act::ReadAgain(class) => device.read_class(class),
Act::Keep(id) => workspace.keep(id, log),
Act::NewFolder => {
let id = browser.folders.make();
let name = browser.folders.name_of(id).unwrap_or_default().to_string();
browser.start_rename(Item::Folder(id), &name);
}
Act::RemoveFolder(id) => {
browser.forget_rename(Item::Folder(id));
browser.folders.remove(id);
}
Act::File { id, folder } => browser.folders.file(id, folder),
Act::SendFolder(id) => {
let members: Vec<u64> = browser
.folders
.members(id, workspace)
.iter()
.map(|entity| entity.id)
.collect();
send_batch(&members, workspace, device, log);
}
Act::Open(Item::Folder(_)) => {}
Act::Open(Item::Local(id)) => tabs.open(id, workspace),
Act::Open(Item::Slot { class, at }) => match workspace.view_of(class, at) {
Some(id) => tabs.open(id, workspace),
None => device.send(
DeviceCmd::Get {
class,
at,
body: false,
open: true,
},
log,
),
},
Act::Copy { class, at } => device.send(
DeviceCmd::Get {
class,
at,
body: false,
open: false,
},
log,
),
Act::LoadOnInstrument { class, at } => {
device.send(DeviceCmd::Select { class, at }, log)
}
Act::Send { id, class, at } => {
send(browser, workspace, device, log, id, class, at, true)
}
Act::Replace { id, class, at } => {
send(browser, workspace, device, log, id, class, at, false)
}
Act::SendAll => {
let waiting: Vec<u64> = workspace.pending().iter().map(|e| e.id).collect();
send_batch(&waiting, workspace, device, log);
}
Act::Rearrange { class, from, to } => {
device.send(DeviceCmd::Move { class, from, to }, log)
}
Act::RenameLocal { id, name } => {
workspace.rename(id, name.clone());
log.say(format!("Renamed it “{name}”."));
}
Act::RenameFolder { id, name } => browser.folders.rename(id, name),
Act::RenameSlot { class, at, name } => {
device.send(DeviceCmd::Rename { class, at, name }, log)
}
Act::DuplicateLocal(id) => {
workspace.duplicate(id, log);
}
Act::DuplicateSlot { class, from, to } => {
device.send(DeviceCmd::Duplicate { class, from, to }, log)
}
Act::DeleteSlot { class, at } => device.send(DeviceCmd::Delete { class, at }, log),
Act::Remove(id) => {
tabs.close(id);
browser.folders.forget(id);
workspace.remove(id, log);
}
Act::Save(id) => workspace.export(id),
Act::Refused(why) => log.say(why),
}
}
}
fn send_batch(ids: &[u64], workspace: &Workspace, device: &mut Device, log: &mut Log) {
for entity in ids.iter().filter_map(|id| workspace.get(*id)) {
if owed(entity).is_none() {
continue;
}
if let Err(e) = nord_usb::envelope::unwrap(&entity.bytes) {
log.error(format!("{}: {e}", entity.name));
log.trouble(format!(
"“{}” is not a file the instrument takes, so nothing was sent.",
entity.name
));
return;
}
}
for (class, items) in grouped(ids, workspace) {
device.send(DeviceCmd::SendAll { class, items }, log);
}
}
fn grouped(ids: &[u64], workspace: &Workspace) -> Vec<(ObjectClass, Vec<Outgoing>)> {
let mut by_class: Vec<(ObjectClass, Vec<Outgoing>)> = Vec::new();
for entity in ids.iter().filter_map(|id| workspace.get(*id)) {
let Some((class, at)) = owed(entity) else {
continue;
};
let item = Outgoing {
id: entity.id,
at,
name: entity.name.clone(),
bytes: entity.bytes.clone(),
};
match by_class.iter_mut().find(|(held, _)| *held == class) {
Some((_, items)) => items.push(item),
None => by_class.push((class, vec![item])),
}
}
by_class
}
pub fn foreign_format(outgoing: &str, resident: &[String]) -> Option<String> {
let outgoing = outgoing.trim();
let readable = !outgoing.is_empty() && outgoing.chars().all(|c| c.is_ascii_alphanumeric());
if !readable || resident.is_empty() {
return None;
}
if resident
.iter()
.any(|held| held.trim().eq_ignore_ascii_case(outgoing))
{
return None;
}
let held: Vec<&str> = resident.iter().map(|held| held.trim()).collect();
Some(format!(
"⚠️ This file is {outgoing}; everything read in that folder is {}. Sending it \
deletes what is there first.",
held.join(" or "),
))
}
fn owed(entity: &LocalEntity) -> Option<(ObjectClass, Location)> {
let (class, at) = entity.origin.slot()?;
crate::device::sendable(class).then_some((class, at))
}
#[allow(clippy::too_many_arguments)]
fn send(
browser: &mut Browser,
workspace: &Workspace,
device: &mut Device,
log: &mut Log,
id: u64,
class: ObjectClass,
at: Location,
ask: bool,
) {
let Some(entity) = workspace.get(id) else {
return;
};
if let Err(e) = nord_usb::envelope::unwrap(&entity.bytes) {
log.error(format!("{}: {e}", entity.name));
log.trouble(format!(
"“{}” is not a file the instrument takes.",
entity.name
));
return;
}
let occupant = device
.state
.slot(class, at)
.flatten()
.map(|info| info.name.trim().to_string());
match (ask, occupant) {
(true, Some(occupant)) => browser.ask_replace(
&occupant,
&entity.name,
place(class, at),
foreign_format(&entity.tag(), &device.state.formats_in(class)),
Act::Replace { id, class, at },
),
_ => device.send(
DeviceCmd::Put {
id,
class,
at,
name: entity.name.clone(),
bytes: entity.bytes.clone(),
},
log,
),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn local(kind: Kind) -> Carried {
Carried {
from: Item::Local(1),
kind,
name: "Africa Split".into(),
filed: None,
}
}
fn slot(class: ObjectClass, bank: u32, slot: u32) -> Carried {
Carried {
from: Item::Slot {
class,
at: Location { bank, slot },
},
kind: Kind::from_class(class),
name: "Squabble B".into(),
filed: None,
}
}
fn onto(class: ObjectClass, bank: u32, at: u32) -> Onto {
Onto::Slot {
class,
at: Location { bank, slot: at },
}
}
fn bench() -> (Browser, Workspace, Device, Tabs, crate::log::Log) {
let ctx = egui::Context::default();
(
Browser::default(),
Workspace::new(ctx.clone()),
Device::new(ctx),
Tabs::default(),
crate::log::Log::default(),
)
}
#[test]
fn a_drag_between_the_two_places_copies_one_way_and_sends_the_other() {
assert_eq!(
landing(&slot(ObjectClass::Program, 6, 3), Onto::Computer),
Landing::Copy
);
assert_eq!(
landing(&local(Kind::Program), onto(ObjectClass::Program, 6, 3)),
Landing::Send
);
}
#[test]
fn an_empty_slot_is_a_target() {
assert_eq!(
landing(&local(Kind::SetList), onto(ObjectClass::SetList, 0, 12)),
Landing::Send
);
}
#[test]
fn a_thing_cannot_be_dropped_into_a_folder_for_another_kind() {
for kind in [Kind::SetList, Kind::Sample, Kind::Other] {
assert!(!landing(&local(kind), onto(ObjectClass::Program, 0, 0)).allowed());
}
}
#[test]
fn the_folders_that_cannot_be_written_refuse_a_drop() {
for class in [ObjectClass::Piano, ObjectClass::Live, ObjectClass::Settings] {
let kind = Kind::from_class(class);
assert!(
!landing(&local(kind), onto(class, 0, 0)).allowed(),
"{}",
folder(class)
);
}
}
#[test]
fn slots_rearrange_only_within_their_own_folder() {
assert_eq!(
landing(
&slot(ObjectClass::Program, 6, 3),
onto(ObjectClass::Program, 7, 12)
),
Landing::Rearrange
);
assert!(!landing(
&slot(ObjectClass::Program, 6, 3),
onto(ObjectClass::SetList, 0, 0)
)
.allowed());
}
#[test]
fn dropping_a_slot_on_itself_does_nothing() {
assert!(!landing(
&slot(ObjectClass::Program, 6, 3),
onto(ObjectClass::Program, 6, 3)
)
.allowed());
assert!(!landing(&local(Kind::Program), Onto::Computer).allowed());
}
#[test]
fn every_refusal_explains_itself() {
let cases = [
landing(&local(Kind::Program), Onto::Computer),
landing(&local(Kind::Live), onto(ObjectClass::Live, 0, 0)),
landing(&local(Kind::Other), onto(ObjectClass::Program, 0, 0)),
landing(
&slot(ObjectClass::Program, 0, 0),
onto(ObjectClass::Sample, 0, 0),
),
];
for case in cases {
match case {
Landing::No(why) => assert!(!why.is_empty()),
other => panic!("{other:?} should have been refused"),
}
}
}
fn paint(with_device: bool) {
use crate::workspace::{Fresh, Origin};
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx.clone());
let mut device = Device::new(ctx.clone());
let mut log = crate::log::Log::default();
let mut tabs = Tabs::default();
let mut browser = Browser::default();
for kind in [Fresh::Program, Fresh::Live, Fresh::Settings] {
workspace.create(kind, &mut log).unwrap();
}
let full = browser.folders.make();
browser.folders.make();
let filed = workspace.create(Fresh::Program, &mut log).unwrap();
browser.folders.file(filed, Some(full));
let bytes = workspace.get(filed).unwrap().bytes.clone();
workspace.view(
"Africa-Split.ne5p".into(),
Origin::Device {
class: ObjectClass::Program,
at: Location { bank: 6, slot: 0 },
},
bytes,
&mut log,
);
if with_device {
device.pretend_scanned(ObjectClass::Program, 7, &["Africa Split", "", "Squabble B"]);
device.pretend_scanned(ObjectClass::Program, 8, &["Bass Manual"]);
device.pretend_scanned(ObjectClass::SetList, 1, &["Sunday"]);
device.pretend_focused(ObjectClass::Program, Location { bank: 6, slot: 2 });
device.pretend_scanned(ObjectClass::Piano, 1, &["Royal Grand 3D"]);
device.pretend_geometry(ObjectClass::Piano, &[("Grand", 1), ("Upright", 1)]);
}
for _ in 0..2 {
let _ = ctx.run(egui::RawInput::default(), |ctx| {
egui::SidePanel::left("places").show(ctx, |ui| {
let acts = browser.ui(ui, &workspace, &device);
apply(
&mut browser,
acts,
&mut workspace,
&mut device,
&mut tabs,
&mut log,
);
});
});
}
}
#[test]
fn a_batch_is_grouped_into_one_command_per_folder() {
use crate::workspace::{Fresh, Origin};
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx.clone());
let mut log = crate::log::Log::default();
let bytes = {
let id = workspace.create(Fresh::Program, &mut log).unwrap();
let bytes = workspace.get(id).unwrap().bytes.clone();
workspace.remove(id, &mut log);
bytes
};
let at = |slot| Location { bank: 6, slot };
for (class, slot) in [
(ObjectClass::Program, 0),
(ObjectClass::Program, 1),
(ObjectClass::SetList, 0),
(ObjectClass::Live, 0),
] {
let id = workspace.ingest(
format!("{}.ne5p", place(class, at(slot))),
Origin::Device {
class,
at: at(slot),
},
bytes.clone(),
&mut log,
);
workspace.mark_pending(id, true);
}
let waiting: Vec<u64> = workspace.pending().iter().map(|e| e.id).collect();
let queued = grouped(&waiting, &workspace);
assert_eq!(queued.len(), 2, "one command per folder");
let programs = queued
.iter()
.find(|(class, _)| *class == ObjectClass::Program)
.expect("programs are queued");
assert_eq!(programs.1.len(), 2);
assert!(queued.iter().all(|(class, _)| *class != ObjectClass::Live));
}
#[test]
fn sending_one_document_names_the_asset_it_sends() {
use crate::workspace::{Fresh, Origin};
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx.clone());
let mut device = Device::new(ctx);
let mut log = crate::log::Log::default();
let mut tabs = Tabs::default();
let mut browser = Browser::default();
device.pretend_scanned(ObjectClass::Program, 7, &["Africa Split"]);
let at = Location { bank: 6, slot: 1 };
let bytes = {
let id = workspace.create(Fresh::Program, &mut log).unwrap();
let bytes = workspace.get(id).unwrap().bytes.clone();
workspace.remove(id, &mut log);
bytes
};
let id = workspace.ingest(
"Africa-Split.ne5p".into(),
Origin::Device {
class: ObjectClass::Program,
at,
},
bytes,
&mut log,
);
workspace.mark_pending(id, true);
apply(
&mut browser,
vec![Act::Send {
id,
class: ObjectClass::Program,
at,
}],
&mut workspace,
&mut device,
&mut tabs,
&mut log,
);
let queued = device.queued().front().expect("a put was queued");
match queued {
DeviceCmd::Put { id: sending, .. } => assert_eq!(*sending, id),
other => panic!("{}", other.label()),
}
}
#[test]
fn the_divider_moves_and_stops_short_of_squeezing_a_column_out() {
use crate::workspace::Fresh;
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx.clone());
let mut device = Device::new(ctx.clone());
let mut log = crate::log::Log::default();
let mut browser = Browser::default();
workspace.create(Fresh::Program, &mut log).unwrap();
device.pretend_scanned(ObjectClass::Program, 7, &["Africa Split"]);
let travel = -400.0;
let button = |pos, pressed| egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::default(),
};
let mut divider = egui::pos2(0.0, 0.0);
let mut frame = 0;
while frame < 5 {
let grip = divider;
let moved = grip + egui::vec2(travel, 0.0);
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(1280.0, 720.0),
)),
events: match frame {
0 => Vec::new(),
1 => vec![egui::Event::PointerMoved(grip)],
2 => vec![button(grip, true)],
3 => vec![egui::Event::PointerMoved(moved)],
_ => vec![button(moved, false)],
},
..Default::default()
};
let _ = ctx.run(input, |ctx| {
egui::SidePanel::left("places")
.exact_width(600.0)
.show(ctx, |ui| {
let whole = ui.available_rect_before_wrap();
divider = egui::pos2(
whole.left() + (whole.width() - HANDLE) * browser.split + HANDLE / 2.0,
whole.center().y,
);
let _ = browser.ui(ui, &workspace, &device);
});
});
frame += 1;
}
assert!(browser.split < EVEN, "it moved: {}", browser.split);
assert_eq!(browser.split, LEAST, "and stopped at the stop");
}
#[test]
fn a_divider_comes_back_where_it_was_left_or_not_at_all() {
let restored = |held: Option<&str>| {
let mut store = Fake::default();
if let Some(held) = held {
eframe::Storage::set_string(&mut store, Browser::SPLIT, held.to_string());
}
let mut browser = Browser::default();
browser.restore(&store);
browser.split
};
assert_eq!(restored(Some("0.3")), 0.3);
assert_eq!(restored(None), EVEN);
for nonsense in ["0.0", "1.0", "-3", "wide", "", "NaN"] {
assert_eq!(restored(Some(nonsense)), EVEN, "{nonsense:?}");
}
let mut store = Fake::default();
let browser = Browser {
split: 0.42,
..Browser::default()
};
browser.keep(&mut store);
let mut after = Browser::default();
after.restore(&store);
assert_eq!(after.split, 0.42);
}
#[derive(Default)]
struct Fake(std::collections::HashMap<String, String>);
impl eframe::Storage for Fake {
fn get_string(&self, key: &str) -> Option<String> {
self.0.get(key).cloned()
}
fn set_string(&mut self, key: &str, value: String) {
self.0.insert(key.to_string(), value);
}
fn flush(&mut self) {}
}
#[test]
fn the_two_columns_paint_with_nothing_attached() {
paint(false);
}
#[test]
fn the_two_columns_paint_with_a_tree_to_show() {
paint(true);
}
#[test]
fn a_click_away_from_the_name_selects_rather_than_arming_a_rename() {
assert!(!arms_rename(true, false), "past the name on a selected row");
assert!(
!arms_rename(false, true),
"on the name of an unselected row"
);
assert!(!arms_rename(false, false));
assert!(arms_rename(true, true), "the one gesture that renames");
}
#[test]
fn typing_a_name_and_pressing_enter_renames_the_row() {
use crate::workspace::Fresh;
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx.clone());
let device = Device::new(ctx.clone());
let mut log = crate::log::Log::default();
let mut browser = Browser::default();
let id = workspace.create(Fresh::Program, &mut log).unwrap();
let key = |key| egui::Event::Key {
key,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::default(),
};
let frames: [Vec<egui::Event>; 3] = [
Vec::new(),
vec![egui::Event::Text("LA Grand".into())],
vec![key(egui::Key::Enter)],
];
browser.start_rename(Item::Local(id), "Africa Split");
let mut named = None;
for events in frames {
let input = egui::RawInput {
events,
..Default::default()
};
let _ = ctx.run(input, |ctx| {
egui::SidePanel::left("places").show(ctx, |ui| {
for act in browser.ui(ui, &workspace, &device) {
if let Act::RenameLocal { name, .. } = act {
named = Some(name);
}
}
});
});
}
assert_eq!(named.as_deref(), Some("LA Grand"));
assert!(browser.rename.is_none(), "and the editor is done with");
}
#[test]
fn a_rename_needs_enter_and_a_real_change() {
assert_eq!(renamed("Africa Split", "LA Grand"), Some("LA Grand".into()));
assert_eq!(renamed("Africa Split", "Africa Split"), None);
}
#[test]
fn a_rename_that_changes_nothing_is_not_a_rename() {
assert_eq!(renamed("Africa Split", "Africa Split"), None);
assert_eq!(renamed("Africa Split", " Africa Split "), None);
assert_eq!(renamed("Africa Split", " "), None);
assert_eq!(renamed("Africa Split", ""), None);
}
#[test]
fn a_rename_takes_the_typed_name_trimmed() {
assert_eq!(
renamed("Africa Split", " LA Grand "),
Some("LA Grand".into())
);
}
#[test]
fn a_bank_caption_only_shows_what_the_number_does_not_say() {
assert!(worth_captioning(1, "Grand"));
assert!(worth_captioning(2, "Upright"));
for furniture in ["Bank 1", "bank 1", "BANK 1", "1", " ", ""] {
assert!(!worth_captioning(1, furniture), "{furniture:?}");
}
assert!(worth_captioning(1, "Bank 2"));
}
#[test]
fn a_folder_takes_what_is_already_on_this_computer_and_nothing_else() {
let filed = |folder| Carried {
filed: folder,
..local(Kind::Program)
};
assert_eq!(landing(&filed(None), Onto::Group(1)), Landing::File);
assert_eq!(landing(&filed(Some(2)), Onto::Group(1)), Landing::File);
assert_eq!(landing(&filed(Some(1)), Onto::Computer), Landing::Unfile);
for refused in [
landing(&filed(Some(1)), Onto::Group(1)),
landing(&filed(None), Onto::Computer),
landing(&slot(ObjectClass::Program, 6, 3), Onto::Group(1)),
] {
match refused {
Landing::No(why) => assert!(!why.is_empty()),
other => panic!("{other:?} should have been refused"),
}
}
}
#[test]
fn a_folder_is_not_something_that_is_dragged() {
let carried = Carried {
from: Item::Folder(1),
kind: Kind::Program,
name: "Sunday".into(),
filed: None,
};
for onto in [
Onto::Computer,
Onto::Group(2),
onto(ObjectClass::Program, 6, 3),
] {
assert!(!landing(&carried, onto).allowed());
}
}
#[test]
fn a_new_folder_gets_a_name_no_other_folder_is_using() {
let mut folders = Folders::default();
let names: Vec<String> = (0..3)
.map(|_| {
let id = folders.make();
folders.name_of(id).expect("it was made").to_string()
})
.collect();
assert_eq!(names, ["New folder", "New folder 2", "New folder 3"]);
let ids: Vec<u64> = folders.all().iter().map(|folder| folder.id).collect();
assert_eq!(ids, vec![1, 2, 3]);
}
#[test]
fn removing_a_folder_leaves_what_was_in_it_on_this_computer() {
let mut folders = Folders::default();
let (kept, gone) = (folders.make(), folders.make());
folders.file(7, Some(kept));
folders.file(8, Some(gone));
folders.remove(gone);
assert_eq!(folders.holding(7), Some(kept));
assert_eq!(folders.holding(8), None, "loose, not lost");
folders.file(9, Some(gone));
assert_eq!(folders.holding(9), None);
}
#[test]
fn the_folders_and_what_is_in_them_survive_a_session() {
let mut folders = Folders::default();
let (sunday, empty) = (folders.make(), folders.make());
folders.rename(sunday, "Sunday\tmorning".into());
folders.file(7, Some(sunday));
folders.file(8, Some(sunday));
let after = read(&written(&folders));
assert_eq!(after.all().len(), 2, "an empty folder is still a folder");
assert_eq!(after.name_of(sunday), Some("Sunday\tmorning"));
assert_eq!(after.name_of(empty), Some("New folder 2"));
assert_eq!(after.holding(7), Some(sunday));
assert_eq!(after.holding(8), Some(sunday));
assert!(read("").all().is_empty());
assert!(read("drawbar folders 99\nf\t1\tSunday\n").all().is_empty());
let orphaned = read(&format!("{FOLDERS_VERSION}\nm\t7\t3\n"));
assert_eq!(orphaned.holding(7), None);
}
#[test]
fn a_grouping_forgets_the_assets_the_list_came_back_without() {
let (mut browser, mut workspace, _device, _tabs, mut log) = bench();
let here = workspace.create(Fresh::Program, &mut log).unwrap();
let folder = browser.folders.make();
browser.folders.file(here, Some(folder));
browser.folders.file(here + 99, Some(folder));
browser.settle(&workspace);
assert_eq!(browser.folders.holding(here), Some(folder));
assert_eq!(browser.folders.holding(here + 99), None);
assert_eq!(browser.folders.all().len(), 1, "the folder itself stays");
}
#[test]
fn a_folder_sends_only_what_can_go_back_to_a_slot() {
use crate::workspace::{Fresh, Origin};
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx);
let mut log = crate::log::Log::default();
let bytes = {
let id = workspace.create(Fresh::Program, &mut log).unwrap();
let bytes = workspace.get(id).unwrap().bytes.clone();
workspace.remove(id, &mut log);
bytes
};
let at = |slot| Location { bank: 6, slot };
let mut ids = Vec::new();
for (class, slot) in [
(ObjectClass::Program, 0),
(ObjectClass::SetList, 0),
(ObjectClass::Live, 0),
] {
ids.push(workspace.ingest(
format!("{}.ne5p", place(class, at(slot))),
Origin::Device {
class,
at: at(slot),
},
bytes.clone(),
&mut log,
));
}
ids.push(workspace.create(Fresh::Program, &mut log).unwrap());
let queued = grouped(&ids, &workspace);
let classes: Vec<ObjectClass> = queued.iter().map(|(class, _)| *class).collect();
assert_eq!(classes, vec![ObjectClass::Program, ObjectClass::SetList]);
assert!(queued.iter().all(|(_, items)| items.len() == 1));
assert!(grouped(&ids[2..], &workspace).is_empty());
}
#[test]
fn opening_a_slot_does_not_put_it_on_this_computer() {
use crate::device::DeviceEvent;
use crate::workspace::{Fresh, Origin};
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx.clone());
let mut device = Device::new(ctx);
let mut log = crate::log::Log::default();
let mut tabs = Tabs::default();
let mut browser = Browser::default();
let bytes = {
let id = workspace.create(Fresh::Program, &mut log).unwrap();
let bytes = workspace.get(id).unwrap().bytes.clone();
workspace.remove(id, &mut log);
bytes
};
let at = Location { bank: 6, slot: 3 };
let origin = Origin::Device {
class: ObjectClass::Program,
at,
};
device.pretend(DeviceEvent::Got {
name: "Africa-Split.ne5p".into(),
origin,
bytes,
open: true,
});
device.poll(&mut log, &mut workspace, &mut tabs);
let id = tabs.active().expect("a view opens in a tab");
assert!(workspace.is_view(id));
assert_eq!(workspace.listed().count(), 0, "nothing joined the list");
assert_eq!(
workspace.get(id).unwrap().origin.slot(),
Some((ObjectClass::Program, at))
);
apply(
&mut browser,
vec![Act::Keep(id)],
&mut workspace,
&mut device,
&mut tabs,
&mut log,
);
assert!(!workspace.is_view(id));
assert_eq!(workspace.listed().count(), 1);
}
#[test]
fn a_new_folder_opens_its_editor_on_the_name_it_was_given() {
let (mut browser, mut workspace, mut device, mut tabs, mut log) = bench();
let mut new_folder = |browser: &mut Browser| {
apply(
browser,
vec![Act::NewFolder],
&mut workspace,
&mut device,
&mut tabs,
&mut log,
);
let rename = browser.rename.as_ref().expect("the editor is armed");
let Item::Folder(id) = rename.what else {
panic!("it is armed on the folder");
};
(id, rename.text.clone())
};
let (first, typed) = new_folder(&mut browser);
assert_eq!(typed, "New folder");
let (second, typed) = new_folder(&mut browser);
assert_eq!(typed, "New folder 2", "the name it actually has");
assert_eq!(browser.folders.name_of(second), Some(typed.as_str()));
assert_ne!(first, second);
}
#[test]
fn removing_a_folder_mid_rename_takes_the_editor_with_it() {
let (mut browser, mut workspace, mut device, mut tabs, mut log) = bench();
let mut act = |browser: &mut Browser, act| {
apply(
browser,
vec![act],
&mut workspace,
&mut device,
&mut tabs,
&mut log,
)
};
act(&mut browser, Act::NewFolder);
let Some(Item::Folder(id)) = browser.rename.as_ref().map(|r| r.what) else {
panic!("a new folder arms its editor");
};
act(&mut browser, Act::RemoveFolder(id));
assert!(browser.rename.is_none(), "the editor went with it");
assert!(browser.selection.is_none());
act(&mut browser, Act::NewFolder);
let Some(Item::Folder(again)) = browser.rename.as_ref().map(|r| r.what) else {
panic!("the new one arms its own");
};
assert_eq!(again, id, "the id came back round");
assert_eq!(
browser.rename.as_ref().map(|r| r.text.as_str()),
Some("New folder")
);
}
#[test]
fn opening_a_slot_that_is_already_open_activates_its_tab() {
use crate::device::DeviceEvent;
use crate::workspace::Origin;
let (mut browser, mut workspace, mut device, mut tabs, mut log) = bench();
let bytes = {
let id = workspace.create(Fresh::Program, &mut log).unwrap();
let bytes = workspace.get(id).unwrap().bytes.clone();
workspace.remove(id, &mut log);
bytes
};
let class = ObjectClass::Program;
let at = Location { bank: 6, slot: 3 };
device.pretend_scanned(class, 7, &["", "", "", "Africa Split"]);
device.pretend(DeviceEvent::Got {
name: "Africa-Split.ne5p".into(),
origin: Origin::Device { class, at },
bytes,
open: true,
});
device.poll(&mut log, &mut workspace, &mut tabs);
let first = tabs.active().expect("a view opened");
tabs.close(first);
apply(
&mut browser,
vec![Act::Open(Item::Slot { class, at })],
&mut workspace,
&mut device,
&mut tabs,
&mut log,
);
assert!(device.queued().is_empty(), "nothing was read again");
assert_eq!(tabs.active(), Some(first), "its own tab came forward");
assert_eq!(workspace.entities().len(), 1, "and there is one copy");
let elsewhere = Location { bank: 6, slot: 4 };
apply(
&mut browser,
vec![Act::Open(Item::Slot {
class,
at: elsewhere,
})],
&mut workspace,
&mut device,
&mut tabs,
&mut log,
);
assert_eq!(device.queued().len(), 1);
}
#[test]
fn a_file_of_another_model_is_warned_about_and_not_refused() {
let held =
|tags: &[&str]| -> Vec<String> { tags.iter().map(|tag| tag.to_string()).collect() };
let warning = foreign_format("ns4p", &held(&["ne5p"])).expect("a Stage 4 file here");
assert!(
warning.contains("ns4p") && warning.contains("ne5p"),
"{warning}"
);
assert!(warning.contains("deletes"), "{warning}");
assert_eq!(foreign_format("ne5p", &held(&["ne5p"])), None);
assert_eq!(foreign_format(" ne5p ", &held(&["NE5P "])), None);
assert_eq!(foreign_format("ne5p", &held(&["ne5p", "ne5l"])), None);
assert_eq!(foreign_format("ns4p", &[]), None);
assert_eq!(
foreign_format("?", &held(&["ne5p"])),
None,
"no tag to judge"
);
assert_eq!(foreign_format("", &held(&["ne5p"])), None);
}
#[test]
fn the_modal_says_when_a_batch_is_of_another_model() {
use crate::workspace::Origin;
let (mut browser, mut workspace, mut device, _tabs, mut log) = bench();
let class = ObjectClass::Program;
device.pretend_scanned(class, 7, &["Africa Split", "Squabble B"]);
let mut ids = Vec::new();
for slot in 0..2 {
let stage = workspace.create(Fresh::Stage4Program, &mut log).unwrap();
let bytes = workspace.get(stage).unwrap().bytes.clone();
workspace.remove(stage, &mut log);
ids.push(workspace.ingest(
format!("stage-{slot}.ns4p"),
Origin::Device {
class,
at: Location { bank: 6, slot },
},
bytes,
&mut log,
));
}
browser.ask_send(&workspace, &device, &ids, "Send?".into(), Act::SendAll);
let note = browser.ask.as_ref().and_then(|ask| ask.note.clone());
let note = note.expect("the modal has a note");
assert_eq!(note.matches("This file is ns4p").count(), 1, "{note}");
let warned = note.find("ns4p").expect("the warning is there");
let listed = note.find("replaces").expect("and so are the destinations");
assert!(warned < listed, "the warning comes first:\n{note}");
}
#[test]
fn a_jump_lands_on_its_slot_and_is_spent_either_way() {
let ctx = egui::Context::default();
let workspace = Workspace::new(ctx.clone());
let mut device = Device::new(ctx.clone());
let mut browser = Browser::default();
let names: Vec<&str> = (0..50).map(|_| "Africa Split").collect();
device.pretend_scanned(ObjectClass::Program, 7, &names);
device.pretend_scanned(ObjectClass::Program, 8, &["Bass Manual"]);
let at = Location { bank: 7, slot: 0 };
device.pretend_focused(ObjectClass::Program, at);
let frame = |browser: &mut Browser| {
let _ = ctx.run(egui::RawInput::default(), |ctx| {
egui::SidePanel::left("places").show(ctx, |ui| {
let _ = browser.ui(ui, &workspace, &device);
});
});
};
browser.jump = Some((ObjectClass::Program, at));
frame(&mut browser);
assert!(browser.jump.is_none(), "the jump landed");
assert!(
browser.selection
== Some(Item::Slot {
class: ObjectClass::Program,
at
})
);
browser.jump = Some((ObjectClass::Program, Location { bank: 11, slot: 0 }));
frame(&mut browser);
assert!(browser.jump.is_none(), "and a jump to nowhere is spent");
}
#[test]
fn a_sync_reads_every_folder_again() {
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx.clone());
let mut device = Device::new(ctx);
let mut log = crate::log::Log::default();
let mut tabs = Tabs::default();
let mut browser = Browser::default();
device.pretend_scanned(ObjectClass::Program, 7, &["Africa Split"]);
apply(
&mut browser,
vec![Act::Resync],
&mut workspace,
&mut device,
&mut tabs,
&mut log,
);
for class in BROWSED {
let progress = device.state.scan.progress(class);
assert!(
progress.is_some_and(|progress| progress.running),
"{}",
folder(class)
);
}
}
#[test]
fn every_kind_knows_the_folder_it_belongs_in() {
for class in BROWSED {
assert_eq!(Kind::from_class(class).home(), Some(class), "{class:?}");
}
assert_eq!(Kind::Other.home(), None);
}
}