use std::collections::BTreeSet;
use std::sync::Arc;
use eframe::egui;
use nord_usb::{Location, ObjectClass};
use crate::device::{read_only, Device, DeviceState};
use crate::filter::Filter;
use crate::folders::{self, Folders};
use crate::queue::Queue;
use crate::tags::{self, Tags};
use crate::workspace::Workspace;
mod act;
#[cfg(test)]
pub(crate) mod bench;
mod drag;
mod instrument;
mod row;
mod selection;
mod tree;
pub use act::{apply, bulk, foreign_format, Act, Bulk};
pub use drag::{
families_present, kinds_present, landing, qualifier, Carried, Held, Item, Kind, Landing, Onto,
};
pub use instrument::about;
pub use row::{cell_ink, starred, Cells};
pub use selection::Selection;
pub use tree::new_menu;
use act::{will_write, write_warnings};
use drag::ghost;
use selection::{gesture, Gesture};
use tree::{Branch, Sections};
struct Rename {
what: Item,
text: String,
fresh: bool,
}
struct Click<'a> {
item: Item,
list: &'a [Item],
}
struct Ask {
title: String,
note: Option<String>,
verb: &'static str,
acts: Vec<Act>,
}
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()),
}
}
fn act_of(verdict: Landing) -> Option<Act> {
Some(match verdict {
Landing::Copy { class, at } => Act::Copy { class, at },
Landing::Send { id, class, at } => Act::Send { id, class, at },
Landing::Rearrange { class, from, to } => Act::Rearrange { class, from, to },
Landing::File { id, folder } => Act::File {
id,
folder: Some(folder),
},
Landing::Unfile { id } => Act::File { id, folder: None },
Landing::No(_) => return None,
})
}
pub struct Browser {
selection: Selection,
rename: Option<Rename>,
ask: Option<Ask>,
folders: Folders,
tags: Tags,
sections: Sections,
open: BTreeSet<Branch>,
jump: Option<(ObjectClass, Location)>,
}
impl Default for Browser {
fn default() -> Browser {
Browser {
selection: Selection::default(),
rename: None,
ask: None,
folders: Folders::default(),
tags: Tags::default(),
sections: Sections::default(),
open: BTreeSet::from([Branch::Computer, Branch::Instrument]),
jump: None,
}
}
}
impl Browser {
pub fn restore(&mut self, storage: &dyn eframe::Storage) {
self.folders = storage
.get_string(folders::KEY)
.map(|text| Folders::read(&text))
.unwrap_or_default();
self.tags = storage
.get_string(tags::KEY)
.map(|text| Tags::read(&text))
.unwrap_or_default();
}
pub fn settle(&mut self, workspace: &Workspace) {
self.folders.forget_missing(workspace);
self.tags.forget_missing(workspace);
}
pub fn keep(&self, storage: &mut dyn eframe::Storage) {
storage.set_string(folders::KEY, self.folders.written());
storage.set_string(tags::KEY, self.tags.written());
}
pub fn ui(
&mut self,
ui: &mut egui::Ui,
workspace: &Workspace,
device: &Device,
queue: &Queue,
filter: &Filter,
) -> Vec<Act> {
let mut acts = Vec::new();
self.dialog(ui.ctx(), &mut acts);
self.tree(ui, workspace, device, queue, filter, &mut acts);
ghost(ui.ctx());
acts
}
pub fn picked(&self) -> &Selection {
&self.selection
}
pub fn tags(&self) -> &Tags {
&self.tags
}
pub fn pick(&mut self, ui: &egui::Ui, item: Item, list: &[Item]) {
self.clicked(ui, Click { item, list });
}
pub fn let_go(&mut self, ctx: &egui::Context) {
if self.rename.is_none() && ctx.input(|input| input.key_pressed(egui::Key::Escape)) {
self.selection.clear();
}
}
pub fn unpick(&mut self) {
self.selection.clear();
}
pub fn check(&mut self, item: Item) {
self.rename = None;
self.selection.toggle(item);
}
fn select(&mut self, item: Item) {
self.rename = None;
self.selection.only(item);
}
fn sole_is(&self, item: Item) -> bool {
self.selection.sole() == Some(item)
}
fn forget_rename(&mut self, what: Item) {
if self.rename.as_ref().is_some_and(|r| r.what == what) {
self.rename = None;
}
self.selection.forget(what);
}
fn start_rename(&mut self, what: Item, from: &str) {
self.selection.only(what);
self.rename = Some(Rename {
what,
text: from.to_string(),
fresh: true,
});
}
fn clicked(&mut self, ui: &egui::Ui, click: Click) {
self.rename = None;
match gesture(&ui.input(|input| input.modifiers)) {
Gesture::Plain => self.selection.plain(click.item),
Gesture::Toggle => self.selection.toggle(click.item),
Gesture::Extend => self.selection.extend(click.item, click.list),
}
}
pub(crate) fn carrying(
&self,
head: Held,
name: &str,
workspace: &Workspace,
device: &DeviceState,
) -> Carried {
let rest: Vec<Held> = match self.selection.holds(head.what) {
false => Vec::new(),
true => self
.selection
.items()
.filter(|item| *item != head.what)
.filter_map(|item| self.held(item, workspace, device))
.collect(),
};
Carried {
head,
name: match rest.len() {
0 => name.to_string(),
more => format!("{name} +{more}"),
},
rest,
}
}
pub(crate) fn held(
&self,
item: Item,
workspace: &Workspace,
device: &DeviceState,
) -> Option<Held> {
match item {
Item::Local(id) => {
let entity = workspace.get(id)?;
Some(Held {
what: item,
kind: Kind::of(entity.entity.as_ref()),
filed: self.folders.holding(id),
fits: crate::device::fit(device, entity).allowed(),
})
}
Item::Folder(_) | Item::Tag(_) => None,
Item::Slot { class, at } => {
(!read_only(class) && device.slot(class, at).flatten().is_some()).then_some(Held {
what: item,
kind: Kind::from_class(class),
filed: None,
fits: true,
})
}
}
}
fn rename_row(&mut self, ui: &mut egui::Ui, indent: f32, original: &str) -> Option<String> {
let rename = self.rename.as_mut()?;
let output = ui
.horizontal(|ui| {
ui.add_space(indent);
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()
}
pub(crate) 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.head, 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>) {
let verdict = landing(&carried.head, onto);
if let Landing::No(why) = verdict {
return acts.push(Act::Refused(format!(
"“{}” cannot go there — {why}.",
carried.name
)));
}
if !verdict.repeats() {
return acts.extend(act_of(verdict));
}
for held in carried.all() {
let each = landing(&held, onto);
if each.same(verdict) {
acts.extend(act_of(each));
}
}
}
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.extend(ask.acts);
}
}
Some(false) => self.ask = None,
None => {}
}
}
fn ask_send(
&mut self,
workspace: &Workspace,
device: &Device,
queue: &Queue,
title: String,
act: Act,
) {
let mut lines = Vec::new();
let mut warnings: Vec<String> = Vec::new();
for held in will_write(queue) {
let Some(entity) = workspace.get(held.id) else {
continue;
};
let (class, at) = (held.class, held.at);
for warning in write_warnings(&device.state, class, entity) {
if !warnings.contains(&warning) {
warnings.push(warning);
}
}
lines.push(format!(
"“{}” → {}",
entity.name,
held.replaces.said(class, at)
));
}
if lines.is_empty() {
return;
}
let mut note = warnings;
note.push(crate::queue::Behind::of(workspace, &device.state, queue).said());
note.push(String::new());
note.extend(lines);
self.ask = Some(Ask {
title,
note: Some(note.join("\n")),
verb: "Send",
acts: vec![act],
});
}
fn ask_write(&mut self, name: &str, at: String, note: String, act: Act) {
self.ask = Some(Ask {
title: format!("Save “{name}” to {at}?"),
note: Some(note),
verb: "Save",
acts: vec![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",
acts: vec![act],
});
}
pub(crate) fn bulk_item(
&mut self,
ui: &mut egui::Ui,
action: Bulk,
checked: &[Item],
workspace: &Workspace,
state: &DeviceState,
acts: &mut Vec<Act>,
) {
if action == Bulk::Tag {
let locals: Vec<u64> = checked.iter().copied().filter_map(Item::local).collect();
ui.add_enabled_ui(!locals.is_empty(), |ui| {
ui.menu_button(action.label(), |ui| self.tag_items(ui, &locals, acts))
.response
.on_disabled_hover_text(action.nothing());
});
return;
}
let wanted = bulk(action, checked, state);
let fits = (action == Bulk::Queue).then(|| act::fits(checked, workspace, state));
let label = match &fits {
Some(fits) => fits.label(),
None => action.label().to_string(),
};
let live = !wanted.is_empty() && fits.as_ref().is_none_or(|fits| fits.takes > 0);
let dead = fits
.and_then(|fits| fits.why)
.unwrap_or_else(|| action.nothing().to_string());
let mut button = ui
.add_enabled(live, egui::Button::new(label))
.on_disabled_hover_text(dead);
if action == Bulk::Queue {
button = button
.on_hover_text("to the slot it is linked to, or the first free one in its folder");
}
if !button.clicked() {
return;
}
match action {
Bulk::Delete => self.ask_discard(checked, wanted),
_ => acts.extend(wanted),
}
ui.close();
}
fn ask_discard(&mut self, checked: &[Item], acts: Vec<Act>) {
let slots = checked
.iter()
.filter(|item| matches!(item, Item::Slot { .. }))
.count();
let locals = checked.iter().filter(|item| item.local().is_some()).count();
let mut note = Vec::new();
if slots > 0 {
note.push(format!(
"{slots} on the instrument are removed from it. There is no undo."
));
}
if locals > 0 {
note.push(format!(
"{locals} leave the list on this computer; the files themselves stay where \
they are."
));
}
self.ask = Some(Ask {
title: format!("Delete {} checked items?", slots + locals),
note: Some(note.join("\n\n")),
verb: "Delete",
acts,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::browser::bench::{bench, context};
use crate::shell::Shell;
use crate::tabs::Tabs;
use crate::workspace::Fresh;
fn paint(with_device: bool) {
use crate::workspace::{Fresh, Origin};
let ctx = context();
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();
let mut queue = Queue::default();
for kind in [Fresh::Program, Fresh::Live, Fresh::Settings] {
workspace.create(kind, &mut log).unwrap();
}
let full = browser.folders.make().unwrap();
browser.folders.make().unwrap();
let filed = workspace.create(Fresh::Program, &mut log).unwrap();
browser.folders.file(filed, Some(full));
let sunday = browser.tags.make("Sunday").unwrap();
browser.tags.make("Loud").unwrap();
browser.tags.set(filed, sunday, true);
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)]);
device.pretend_partitions(&crate::device::ELECTRO5);
for class in device.state.classes() {
browser.open.insert(Branch::Class(class.to_raw()));
for bank in 0..=8 {
browser.open.insert(tree::bank_branch(class, bank));
}
}
}
for _ in 0..2 {
let _ = ctx.run(egui::RawInput::default(), |ctx| {
egui::SidePanel::left("places")
.exact_width(crate::shell::BROWSER)
.show(ctx, |ui| {
let acts = browser.ui(ui, &workspace, &device, &queue, &Filter::default());
apply(
&mut browser,
&mut Shell::default(),
acts,
&mut workspace,
&mut device,
&mut tabs,
&mut queue,
&mut log,
);
});
});
}
}
#[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_tree_paints_with_nothing_attached() {
paint(false);
}
#[test]
fn the_tree_paints_with_an_instrument_to_show() {
paint(true);
}
#[test]
fn a_drag_from_a_picked_row_carries_the_whole_selection() {
let (mut browser, mut workspace, device, _tabs, _queue, mut log) = bench();
let ids: Vec<u64> = (0..3)
.map(|_| workspace.create(Fresh::Program, &mut log).unwrap())
.collect();
let apart = workspace.create(Fresh::Live, &mut log).unwrap();
for id in &ids {
browser.selection.toggle(Item::Local(*id));
}
let head = browser
.held(Item::Local(ids[0]), &workspace, &device.state)
.expect("a local is dragged");
let carried = browser.carrying(head, "Africa Split", &workspace, &device.state);
assert_eq!(carried.rest.len(), 2);
assert_eq!(carried.all().count(), 3);
assert!(carried.name.contains("+2"), "{}", carried.name);
let outside = browser
.held(Item::Local(apart), &workspace, &device.state)
.expect("a local is dragged");
let alone = browser.carrying(outside, "Squabble B", &workspace, &device.state);
assert!(alone.rest.is_empty(), "a row nobody picked carries itself");
assert_eq!(alone.name, "Squabble B", "and says only its own name");
}
#[test]
fn an_empty_slot_is_not_something_a_drag_carries() {
let (mut browser, workspace, mut device, _tabs, _queue, _log) = bench();
device.pretend_scanned(ObjectClass::Program, 7, &["Africa Split", ""]);
let slot = |slot| Item::Slot {
class: ObjectClass::Program,
at: Location { bank: 6, slot },
};
let held = browser
.held(slot(0), &workspace, &device.state)
.expect("7:1 holds something");
assert!(
browser.held(slot(1), &workspace, &device.state).is_none(),
"7:2 was read and found empty"
);
browser.selection.toggle(slot(0));
browser.selection.toggle(slot(1));
let carried = browser.carrying(held, "Africa Split", &workspace, &device.state);
assert!(carried.rest.is_empty(), "the empty slot stays where it is");
assert_eq!(carried.name, "Africa Split", "and the ghost counts nothing");
}
#[test]
fn a_drop_of_many_repeats_only_where_one_destination_does_not() {
let (mut browser, mut workspace, device, _tabs, _queue, mut log) = bench();
let ids: Vec<u64> = (0..3)
.map(|_| workspace.create(Fresh::Program, &mut log).unwrap())
.collect();
for id in &ids {
browser.selection.toggle(Item::Local(*id));
}
let folder = browser.folders.make().unwrap();
let head = browser
.held(Item::Local(ids[0]), &workspace, &device.state)
.unwrap();
let carried = Arc::new(browser.carrying(head, "Africa Split", &workspace, &device.state));
let mut filed = Vec::new();
browser.land(&carried, Onto::Group(folder), &mut filed);
assert_eq!(filed.len(), 3, "every picked asset goes into the folder");
assert!(filed.iter().all(|act| matches!(
act,
Act::File {
folder: Some(_),
..
}
)));
let mut sent = Vec::new();
browser.land(
&carried,
Onto::Slot {
class: ObjectClass::Program,
at: Location { bank: 6, slot: 0 },
},
&mut sent,
);
assert_eq!(sent.len(), 1, "one slot takes one asset");
assert!(matches!(sent[0], Act::Send { id, .. } if id == ids[0]));
}
#[test]
fn a_drop_onto_a_row_inside_a_folder_never_unfiles_it() {
let (mut browser, mut workspace, device, _tabs, _queue, mut log) = bench();
let folder = browser.folders.make().unwrap();
let id = workspace.create(Fresh::Program, &mut log).unwrap();
browser.folders.file(id, Some(folder));
let head = browser
.held(Item::Local(id), &workspace, &device.state)
.expect("a local is dragged");
let carried = Arc::new(browser.carrying(head, "Africa Split", &workspace, &device.state));
let mut onto_sibling = Vec::new();
browser.land(&carried, tree::onto_list(Some(folder)), &mut onto_sibling);
assert!(
!onto_sibling
.iter()
.any(|act| matches!(act, Act::File { folder: None, .. })),
"a member row stands for its folder, so a drop on it is no way out of one"
);
let mut onto_loose = Vec::new();
browser.land(&carried, tree::onto_list(None), &mut onto_loose);
assert!(
matches!(onto_loose.as_slice(), [Act::File { folder: None, .. }]),
"and the loose rows beside the folder are the way out"
);
}
#[test]
fn f2_renames_only_while_its_row_is_the_only_one_picked() {
let (mut browser, _workspace, _device, _tabs, _queue, _log) = bench();
let row = Item::Local(1);
browser.selection.only(row);
assert!(browser.sole_is(row));
browser.selection.toggle(Item::Local(2));
assert!(!browser.sole_is(row), "two rows picked");
browser.selection.plain(Item::Local(2));
assert!(
browser.sole_is(row),
"a plain click on one of the two leaves the other sole"
);
}
#[test]
fn escape_lets_go_of_the_selection_unless_a_name_is_being_typed() {
let ctx = context();
let mut browser = Browser::default();
let escape = egui::RawInput {
events: vec![egui::Event::Key {
key: egui::Key::Escape,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::NONE,
}],
..Default::default()
};
browser.start_rename(Item::Local(1), "Africa Split");
let _ = ctx.run(escape.clone(), |ctx| browser.let_go(ctx));
assert_eq!(
browser.picked().items().count(),
1,
"the editor's Escape is not the selection's"
);
browser.rename = None;
let _ = ctx.run(escape, |ctx| browser.let_go(ctx));
assert_eq!(browser.picked().items().count(), 0);
}
#[test]
fn typing_a_name_and_pressing_enter_renames_the_row() {
use crate::workspace::Fresh;
let ctx = context();
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 queue = Queue::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, &queue, &Filter::default()) {
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_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()));
assert_eq!(
renamed("Africa Split", " LA Grand "),
Some("LA Grand".into())
);
}
#[test]
fn a_grouping_forgets_the_assets_the_list_came_back_without() {
let (mut browser, mut workspace, _device, _tabs, _queue, mut log) = bench();
let here = workspace.create(Fresh::Program, &mut log).unwrap();
let folder = browser.folders.make().unwrap();
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_tag_put_on_a_multi_selection_comes_back_next_session() {
let (mut browser, mut workspace, mut device, mut tabs, mut queue, mut log) = bench();
let ids: Vec<u64> = (0..3)
.map(|_| workspace.create(Fresh::Program, &mut log).unwrap())
.collect();
browser.selection.toggle(Item::Local(ids[0]));
browser.selection.toggle(Item::Local(ids[1]));
apply(
&mut browser,
&mut Shell::default(),
vec![Act::SaveAsGig],
&mut workspace,
&mut device,
&mut tabs,
&mut queue,
&mut log,
);
let Some(Item::Tag(tag)) = browser.rename.as_ref().map(|r| r.what) else {
panic!("a new gig opens its editor");
};
assert!(browser.tags.on_all(&ids[..2], tag));
assert!(!browser.tags.worn(ids[2]).contains(&tag));
let mut store = Fake::default();
browser.keep(&mut store);
let mut after = Browser::default();
after.restore(&store);
after.settle(&workspace);
assert_eq!(after.tags.name_of(tag), Some("New gig"));
assert!(after.tags.on_all(&ids[..2], tag));
workspace.remove(ids[0], &mut log);
after.settle(&workspace);
assert_eq!(after.tags.count(tag), 1, "the one still on the list");
}
#[test]
fn tagging_a_view_keeps_it_on_this_computer_first_and_says_so() {
use crate::workspace::Origin;
let (mut browser, mut workspace, mut device, mut tabs, mut queue, 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 id = workspace.view(
"Africa-Split.ne5p".into(),
Origin::Device {
class: ObjectClass::Program,
at: Location { bank: 6, slot: 0 },
},
bytes,
&mut log,
);
let tag = browser.tags.make("Sunday").unwrap();
assert!(workspace.is_view(id));
apply(
&mut browser,
&mut Shell::default(),
vec![Act::Tag { ids: vec![id], tag }],
&mut workspace,
&mut device,
&mut tabs,
&mut queue,
&mut log,
);
assert!(!workspace.is_view(id), "it is on this computer now");
assert!(browser.tags.worn(id).contains(&tag));
let said = log.transcript();
assert!(said.contains("kept on this computer first"), "{said}");
}
#[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 queue, mut log) = bench();
let class = ObjectClass::Program;
device.pretend_scanned(class, 7, &["Africa Split", "Squabble B"]);
device.pretend_attached_as("unnamed device");
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);
let at = Location { bank: 6, slot };
let id = workspace.ingest(
format!("stage-{slot}.ns4p"),
Origin::Device { class, at },
bytes,
&mut log,
);
crate::queue::enqueue(&workspace, &mut device, &mut queue, &mut log, id, class, at);
ids.push(id);
}
browser.ask_send(&workspace, &device, &queue, "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}");
}
}