use std::cmp::Ordering;
use std::collections::BTreeSet;
use std::ops::Range;
use eframe::egui;
use nord_format::accept::Family;
use nord_usb::wire::ProgramInfo;
use nord_usb::{Location, ObjectClass};
use crate::app::{accent, micro, ui as ui_text, warn};
use crate::browser::{cell_ink, families_present, qualifier, Act, Browser, Bulk, Item, Kind};
use crate::device::{fit, read_only, Device, DeviceState};
use crate::filter::{Filter, Narrow, Place, State};
use crate::icon::{icon, painted, Glyph};
use crate::panel::{chip, Track};
use crate::queue::{Diff, Queue};
use crate::shell::{Page, Shell};
use crate::strings::{folder, place, shown};
use crate::tags::Tags;
use crate::workspace::{LocalEntity, Workspace};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Where {
Both(Option<bool>),
Computer,
Foreign,
Keyboard,
Unread,
}
impl Where {
pub fn short(self) -> &'static str {
match self {
Where::Both(Some(true)) => "both =",
Where::Both(Some(false)) => "both ≠",
Where::Both(None) => "both",
Where::Computer | Where::Foreign => "computer",
Where::Keyboard => "keyboard",
Where::Unread => "—",
}
}
pub fn sentence(self) -> &'static str {
match self {
Where::Both(Some(true)) => "On this computer and in a slot holding these very bytes.",
Where::Both(Some(false)) => {
"On this computer and in a slot it was matched to, and the two bodies \
no longer agree."
}
Where::Both(None) => {
"On this computer and in a slot matched by name. Nothing here can say \
whether the two bodies agree."
}
Where::Computer => "On this computer only.",
Where::Foreign => "On this computer only — not for this keyboard.",
Where::Keyboard => "On the instrument only.",
Where::Unread => "It came off a slot this session has not read.",
}
}
fn places(self) -> &'static [Place] {
match self {
Where::Both(_) => &[Place::Computer, Place::Keyboard],
Where::Computer | Where::Foreign | Where::Unread => &[Place::Computer],
Where::Keyboard => &[Place::Keyboard],
}
}
fn rank(self) -> u8 {
match self {
Where::Both(Some(false)) => 0,
Where::Both(None) => 1,
Where::Both(Some(true)) => 2,
Where::Computer => 3,
Where::Foreign => 4,
Where::Keyboard => 5,
Where::Unread => 6,
}
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum Needs {
Nothing,
Named { class: ObjectClass, name: String },
Wanted { class: ObjectClass, id: u32 },
}
impl Needs {
pub fn text(&self) -> String {
match self {
Needs::Nothing => String::new(),
Needs::Named { name, .. } => name.clone(),
Needs::Wanted { class, id } => {
format!("{} {id:#010x}", Kind::from_class(*class).chip())
}
}
}
pub fn sentence(&self) -> String {
match self {
Needs::Nothing => "Nothing here says what it plays.".to_string(),
Needs::Named { class, name } => format!(
"It plays the {} the instrument calls “{name}”.",
Kind::from_class(*class).chip()
),
Needs::Wanted { class, id } => format!(
"This names a {} the instrument has not listed by id ({id:#010x}). Only the \
instrument can put a name to one, and only for a slot it has been asked \
about.",
Kind::from_class(*class).chip()
),
}
}
fn short(&self) -> Option<ObjectClass> {
match self {
Needs::Wanted { class, .. } => Some(*class),
_ => None,
}
}
}
pub struct Row {
pub item: Item,
pub kind: Kind,
pub family: Option<Family>,
pub name: String,
pub tags: usize,
pub unsaved: bool,
pub where_: Where,
pub at: Option<(ObjectClass, Location)>,
pub size: u64,
pub needs: Needs,
}
impl Row {
fn destination(&self) -> Option<(ObjectClass, Location)> {
let (class, at) = self.at?;
(matches!(self.item, Item::Local(_)) && !read_only(class)).then_some((class, at))
}
}
pub fn rows(
workspace: &Workspace,
device: &DeviceState,
queue: &Queue,
tags: &Tags,
filter: &Filter,
) -> Vec<Row> {
let mut rows = Vec::new();
let mut claimed: Vec<(ObjectClass, Location)> = Vec::new();
let kept = families_present(workspace);
let instrument = device.product().and_then(Family::from_product);
for entity in workspace.listed() {
if let Some(slot) = entity.spot() {
claimed.push(slot);
}
let worn = tags.worn(entity.id);
let row = local(entity, device, queue, worn.len(), &kept, instrument);
let state = state(row.item, row.where_, queue);
if admits(filter, &row, worn, state) {
rows.push(row);
}
}
let untagged = BTreeSet::new();
for class in device.classes() {
for bank in device.banks_of(class) {
let Some(slots) = device.bank(class, bank) else {
continue;
};
for (index, held) in slots.iter().enumerate() {
let Some(info) = held else {
continue;
};
let at = Location::from_user(bank, index as u32 + 1);
if claimed.contains(&(class, at)) {
continue;
}
let row = slot(class, at, info, device);
if admits(filter, &row, &untagged, None) {
rows.push(row);
}
}
}
}
rows
}
fn admits(filter: &Filter, row: &Row, tags: &BTreeSet<u64>, state: Option<State>) -> bool {
row.where_
.places()
.iter()
.any(|place| filter.admits(row.kind, *place, tags, state))
}
pub fn state(item: Item, where_: Where, queue: &Queue) -> Option<State> {
match item {
Item::Local(id) if queue.holds(id) => Some(State::Waiting),
Item::Local(_) => (where_ == Where::Both(Some(false))).then_some(State::Differs),
_ => None,
}
}
pub fn differing(workspace: &Workspace, device: &DeviceState, queue: &Queue) -> usize {
workspace
.listed()
.filter(|entity| {
state(
Item::Local(entity.id),
whereabouts(entity, device, queue),
queue,
) == Some(State::Differs)
})
.count()
}
pub fn row_of(
item: Item,
workspace: &Workspace,
device: &DeviceState,
queue: &Queue,
tags: &Tags,
) -> Option<Row> {
match item {
Item::Local(id) => {
let entity = workspace.get(id)?;
let instrument = device.product().and_then(Family::from_product);
Some(local(
entity,
device,
queue,
tags.worn(id).len(),
&families_present(workspace),
instrument,
))
}
Item::Slot { class, at } => {
Some(slot(class, at, device.slot(class, at).flatten()?, device))
}
Item::Folder(_) | Item::Tag(_) => None,
}
}
fn local(
entity: &LocalEntity,
device: &DeviceState,
queue: &Queue,
tags: usize,
kept: &[Family],
instrument: Option<Family>,
) -> Row {
Row {
item: Item::Local(entity.id),
kind: Kind::of(entity.entity.as_ref()),
family: qualifier(entity, kept, instrument),
name: entity.name.clone(),
tags,
unsaved: entity.is_unsaved(),
where_: whereabouts(entity, device, queue),
at: entity.spot(),
size: entity.bytes.len() as u64,
needs: wanted(entity, device),
}
}
fn slot(class: ObjectClass, at: Location, info: &ProgramInfo, device: &DeviceState) -> Row {
Row {
item: Item::Slot { class, at },
kind: Kind::from_class(class),
family: None,
name: info.name.trim().to_string(),
tags: 0,
unsaved: false,
where_: Where::Keyboard,
at: Some((class, at)),
size: u64::from(info.body_len),
needs: played(class, at, device),
}
}
fn whereabouts(entity: &LocalEntity, device: &DeviceState, queue: &Queue) -> Where {
let held = entity
.link
.and_then(|(class, at)| Some((class, device.slot(class, at).flatten()?)));
let Some((class, info)) = held else {
if !fit(device, entity).allowed() {
return Where::Foreign;
}
return match entity.origin.slot() {
Some((class, at)) if device.slot(class, at).is_none() => Where::Unread,
_ => Where::Computer,
};
};
Where::Both(agrees(entity, class, info, queue))
}
pub fn agrees(
entity: &LocalEntity,
class: ObjectClass,
info: &ProgramInfo,
queue: &Queue,
) -> Option<bool> {
if let (Some(here), Some(there)) = (entity.saved.crc32, info.crc32) {
return Some(here == there);
}
if wrote(entity, class, info.location) {
return Some(true);
}
match queue.entry(entity.id).map(|held| &held.diff) {
Some(Diff::Identical) => Some(true),
Some(Diff::Fields(_) | Diff::Bytes { .. }) => Some(false),
_ => None,
}
}
fn wrote(entity: &LocalEntity, class: ObjectClass, at: Location) -> bool {
entity.wrote.is_some_and(|wrote| {
(wrote.class, wrote.at) == (class, at) && Some(wrote.crc32) == entity.saved.crc32
})
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Mark {
Agrees,
Differs,
Unknown,
Unsaved,
}
pub fn mark_ink(mark: Mark, visuals: &egui::Visuals) -> egui::Color32 {
match mark {
Mark::Agrees => crate::app::good(visuals),
Mark::Differs => warn(visuals),
Mark::Unknown => crate::app::caption(visuals),
Mark::Unsaved => visuals.text_color(),
}
}
pub fn mark_words(mark: Mark) -> &'static str {
match mark {
Mark::Agrees => "on the keyboard, the same as saved here",
Mark::Differs => {
"on the keyboard, but different from what is saved here, or waiting to be sent"
}
Mark::Unknown => "on the keyboard; whether it matches is not known yet",
Mark::Unsaved => "edited since it was last saved",
}
}
pub fn keyboard_mark(entity: &LocalEntity, device: &DeviceState, queue: &Queue) -> Option<Mark> {
let (class, at) = entity.link?;
let info = device.slot(class, at).flatten()?;
if queue.holds(entity.id) {
return Some(Mark::Differs);
}
match agrees(entity, class, info, queue) {
Some(true) => Some(Mark::Agrees),
Some(false) => Some(Mark::Differs),
None => Some(Mark::Unknown),
}
}
pub(crate) fn wanted(entity: &LocalEntity, device: &DeviceState) -> Needs {
let Some(fields) = entity.entity.as_ref().and_then(crate::fields::fields_of) else {
return Needs::Nothing;
};
for (path, class) in [
("piano_panel.id", ObjectClass::Piano),
("sample_panel.id", ObjectClass::Sample),
] {
let Some(id) = fields
.iter()
.find(|field| field.path == path)
.and_then(|field| crate::document::library_id(&field.value))
.filter(|id| *id != 0)
else {
continue;
};
return match device.dependency_name(entity.origin.slot(), class, id) {
Some(name) => Needs::Named {
class,
name: name.to_string(),
},
None => Needs::Wanted { class, id },
};
}
Needs::Nothing
}
fn played(class: ObjectClass, at: Location, device: &DeviceState) -> Needs {
if class != ObjectClass::Program || device.detail.at != Some((class, at)) {
return Needs::Nothing;
}
let Some(deps) = device.detail.deps.as_ref() else {
return Needs::Nothing;
};
deps.iter()
.filter(|dep| dep.flag == 1)
.find(|dep| matches!(dep.class, ObjectClass::Piano | ObjectClass::Sample))
.map_or(Needs::Nothing, |dep| Needs::Named {
class: dep.class,
name: dep.name.trim().to_string(),
})
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Column {
Mark,
Glyph,
Kind,
Name,
Tags,
Where,
At,
Size,
Needs,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Order {
Up,
Down,
}
impl Order {
fn flipped(self) -> Order {
match self {
Order::Up => Order::Down,
Order::Down => Order::Up,
}
}
}
impl Column {
pub const ALL: [Column; 9] = [
Column::Mark,
Column::Glyph,
Column::Kind,
Column::Name,
Column::Tags,
Column::Where,
Column::At,
Column::Size,
Column::Needs,
];
fn head(self) -> &'static str {
match self {
Column::Mark | Column::Glyph => "",
Column::Kind => "kind",
Column::Name => "name",
Column::Tags => "tags",
Column::Where => "where",
Column::At => "at",
Column::Size => "size",
Column::Needs => "needs",
}
}
fn index(self) -> usize {
match self {
Column::Mark => 0,
Column::Glyph => 1,
Column::Kind => 2,
Column::Name => 3,
Column::Tags => 4,
Column::Where => 5,
Column::At => 6,
Column::Size => 7,
Column::Needs => 8,
}
}
fn track(self) -> Track {
match self {
Column::Mark => Track::Px(18.0),
Column::Glyph => Track::Px(20.0),
Column::Kind => Track::Capped {
share: 0.7,
max: 96.0,
},
Column::Name => Track::Share(1.9),
Column::Tags => Track::Px(38.0),
Column::Where => Track::Px(64.0),
Column::At => Track::Px(50.0),
Column::Size => Track::Px(54.0),
Column::Needs => Track::Share(1.3),
}
}
}
const GAP: f32 = 8.0;
pub fn tracks(width: f32) -> [Range<f32>; 9] {
let wanted = Column::ALL.map(Column::track);
let held = crate::panel::tracks(width, &wanted, GAP);
std::array::from_fn(|index| held[index].clone())
}
pub fn arrange(mut rows: Vec<Row>, query: &str, by: Column, order: Order) -> Vec<Row> {
let query = query.trim().to_lowercase();
if !query.is_empty() {
rows.retain(|row| row.name.to_lowercase().contains(&query));
}
rows.sort_by(|a, b| {
let ranked = match order {
Order::Up => compare(by, a, b),
Order::Down => compare(by, a, b).reverse(),
};
ranked
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
.then_with(|| a.item.cmp(&b.item))
});
rows
}
fn compare(by: Column, a: &Row, b: &Row) -> Ordering {
match by {
Column::Mark => Ordering::Equal,
Column::Glyph | Column::Kind => word(a).cmp(&word(b)),
Column::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
Column::Tags => a.tags.cmp(&b.tags),
Column::Where => a.where_.rank().cmp(&b.where_.rank()),
Column::At => address(a).cmp(&address(b)),
Column::Size => a.size.cmp(&b.size),
Column::Needs => a.needs.text().cmp(&b.needs.text()),
}
}
fn word(row: &Row) -> String {
crate::strings::kind_word(row.kind, row.family)
}
fn address(row: &Row) -> (bool, u32, u32, u32) {
match row.at {
Some((class, at)) => (false, class.to_raw(), at.bank, at.slot),
None => (true, 0, 0, 0),
}
}
pub fn consequence(rows: &[&Row], device: &DeviceState, queue: &Queue) -> String {
let mut going: Vec<(ObjectClass, Location)> =
rows.iter().filter_map(|row| row.destination()).collect();
going.sort_unstable_by_key(|(class, at)| (class.to_raw(), at.bank, at.slot));
let mut said = Vec::new();
if !going.is_empty() {
said.push(format!("→ {}", spans(&going)));
let occupied = going
.iter()
.filter(|(class, at)| device.slot(*class, *at).flatten().is_some())
.count();
said.push(match occupied {
0 => "every slot is empty".to_string(),
1 => "1 slot occupied".to_string(),
n => format!("{n} slots occupied"),
});
}
let mine = rows.iter().filter(|row| matches!(row.item, Item::Local(_)));
let (fits, of) = mine.fold((0, 0), |(fits, of), row| {
(fits + usize::from(row.where_ != Where::Foreign), of + 1)
});
if let Some(unfit) = device
.product()
.and_then(|product| crate::strings::fitting(fits, of, product))
{
said.push(unfit);
}
let waiting = rows
.iter()
.filter(|row| matches!(row.item, Item::Local(id) if queue.holds(id)))
.count();
if waiting > 0 {
said.push(format!("{waiting} already waiting"));
}
for class in [ObjectClass::Piano, ObjectClass::Sample] {
let short = rows
.iter()
.filter(|row| row.needs.short() == Some(class))
.count();
let named = Kind::from_class(class).chip();
match short {
0 => {}
1 => said.push(format!("1 needs a {named} the instrument has not named")),
n => said.push(format!("{n} need a {named} the instrument has not named")),
}
}
match said.is_empty() {
true => "Nothing picked goes to the instrument.".to_string(),
false => said.join(" · "),
}
}
fn spans(going: &[(ObjectClass, Location)]) -> String {
let mut folders: Vec<ObjectClass> = going.iter().map(|(class, _)| *class).collect();
folders.dedup();
let mut runs: Vec<String> = Vec::new();
for class in folders {
let mut ats = going
.iter()
.filter(|(held, _)| *held == class)
.map(|(_, at)| *at);
let Some(first) = ats.next() else {
continue;
};
let last = ats.next_back().unwrap_or(first);
runs.push(match first == last {
true => place(class, first),
false => format!("{} {}–{}", folder(class), shown(first), shown(last)),
});
}
runs.join(", ")
}
const ROW: f32 = 24.0;
const HEAD: f32 = 20.0;
const BAR: f32 = 28.0;
const PAD: f32 = 8.0;
const GLYPH: f32 = 13.0;
const SMALL: f32 = 10.0;
const MARK: f32 = 11.0;
const NAME: f32 = 12.0;
const MONO: f32 = 10.5;
pub struct Library {
by: Column,
order: Order,
}
impl Default for Library {
fn default() -> Library {
Library {
by: Column::Name,
order: Order::Up,
}
}
}
impl Library {
pub fn ui(
&mut self,
ui: &mut egui::Ui,
browser: &mut Browser,
workspace: &Workspace,
device: &Device,
queue: &Queue,
shell: &Shell,
) -> Vec<Act> {
ui.spacing_mut().item_spacing.y = 0.0;
let mut acts = Vec::new();
let held = rows(
workspace,
&device.state,
queue,
browser.tags(),
&shell.filter,
);
let held = arrange(held, &shell.omnibox, self.by, self.order);
let counts = [queue.len(), differing(workspace, &device.state, queue)];
bar(ui, counts, browser.tags(), &shell.filter, &mut acts);
let picked: Vec<&Row> = held
.iter()
.filter(|row| browser.picked().holds(row.item))
.collect();
if !picked.is_empty() {
egui::TopBottomPanel::bottom("library_footer")
.resizable(false)
.frame(egui::Frame::new())
.show_inside(ui, |ui| {
footer(
ui,
&picked,
browser,
workspace,
&device.state,
queue,
&mut acts,
)
});
}
self.table(ui, &held, browser, workspace, device, queue, &mut acts);
acts
}
#[allow(clippy::too_many_arguments)]
fn table(
&mut self,
ui: &mut egui::Ui,
rows: &[Row],
browser: &mut Browser,
workspace: &Workspace,
device: &Device,
queue: &Queue,
acts: &mut Vec<Act>,
) {
let room = ui.available_rect_before_wrap();
let mut inset = ui.new_child(
egui::UiBuilder::new()
.max_rect(room.with_min_x(room.left() + PAD))
.layout(*ui.layout()),
);
let ui = &mut inset;
let body = ui.available_height() - HEAD;
let scrolls = rows.len() as f32 * ROW > body;
let bar = match scrolls {
true => ui.spacing().scroll.bar_width,
false => 0.0,
};
let width = (ui.available_width() - bar).max(0.0);
let tracks = tracks(width);
self.head(ui, width, &tracks);
if rows.is_empty() {
return nothing(ui);
}
let list: Vec<Item> = rows.iter().map(|row| row.item).collect();
let shown = egui::ScrollArea::vertical()
.id_salt("library_table")
.auto_shrink([false; 2])
.show_rows(ui, ROW, rows.len(), |ui, shown| {
for row in shown.filter_map(|index| rows.get(index)) {
paint(
ui, row, width, &tracks, browser, &list, workspace, device, queue, acts,
);
}
});
let rest = shown
.inner_rect
.with_min_y(shown.inner_rect.top() + shown.content_size.y);
if rest.height() > 0.0
&& ui
.interact(rest, ui.id().with("library_empty"), egui::Sense::click())
.clicked()
{
browser.unpick();
}
}
fn head(&mut self, ui: &mut egui::Ui, width: f32, tracks: &[Range<f32>; 9]) {
let (rect, response) =
ui.allocate_exact_size(egui::vec2(width, HEAD), egui::Sense::click());
let visuals = ui.visuals().clone();
let painter = ui.painter().clone();
painter.rect_filled(rect, 0.0, visuals.faint_bg_color);
let quiet = crate::app::caption(&visuals);
let strong = visuals.widgets.active.fg_stroke.color;
for (column, track) in Column::ALL.iter().zip(tracks) {
if column.head().is_empty() {
continue;
}
let sorted = self.by == *column;
let ink = match sorted {
true => strong,
false => quiet,
};
let mut room = track.end - track.start;
if sorted {
let glyph = match self.order {
Order::Up => Glyph::ChevronRight,
Order::Down => Glyph::ChevronDown,
};
let box_ = egui::Rect::from_min_size(
egui::pos2(
rect.left() + track.end - SMALL,
rect.center().y - SMALL / 2.0,
),
egui::Vec2::splat(SMALL),
);
painted(ui, glyph, box_, ink);
room = (room - SMALL - 2.0).max(0.0);
}
let mut job = egui::text::LayoutJob::simple_singleline(
column.head().to_uppercase(),
egui::FontId::proportional(9.5),
ink,
);
job.wrap = egui::text::TextWrapping::truncate_at_width(room);
let galley = painter.layout_job(job);
painter.galley(
egui::pos2(
rect.left() + track.start,
rect.center().y - galley.size().y / 2.0,
),
galley,
egui::Color32::PLACEHOLDER,
);
}
let Some(column) = response
.clicked()
.then(|| under(&response, rect, tracks))
.flatten()
else {
return;
};
match self.by == column {
true => self.order = self.order.flipped(),
false => {
self.by = column;
self.order = Order::Up;
}
}
}
}
fn bar(ui: &mut egui::Ui, counts: [usize; 2], tags: &Tags, filter: &Filter, acts: &mut Vec<Act>) {
let (rect, _) =
ui.allocate_exact_size(egui::vec2(ui.available_width(), BAR), egui::Sense::hover());
let mut inner = ui.new_child(
egui::UiBuilder::new()
.max_rect(rect.shrink2(egui::vec2(PAD, 0.0)))
.layout(egui::Layout::left_to_right(egui::Align::Center)),
);
let ui = &mut inner;
ui.spacing_mut().item_spacing.x = 6.0;
let ink = ui.visuals().widgets.inactive.fg_stroke.color;
icon(ui, Glyph::LibraryBig, GLYPH, ink);
ui.label(
egui::RichText::new(format!("Library · {}", over(filter)))
.size(12.0)
.color(ink),
);
for tag in filter.tags.iter().filter_map(|id| tags.name_of(*id)) {
chip(ui, Glyph::Tag, SMALL, tag, ink, None);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let states = [
(State::Waiting, Glyph::Clock),
(State::Differs, Glyph::CircleAlert),
];
for ((state, glyph), count) in states.into_iter().zip(counts) {
if count == 0 && !filter.on(Narrow::State(state)) {
continue;
}
let text = format!("{count} {}", state.word());
let drawn = chip(ui, glyph, SMALL, &text, warn(ui.visuals()), None);
let picked = ui
.interact(
drawn.rect,
drawn.id.with(state.word()),
egui::Sense::click(),
)
.on_hover_text(state.sentence());
if picked.clicked() {
acts.push(Act::Narrow(Narrow::State(state)));
}
}
});
}
fn over(filter: &Filter) -> String {
let mut narrowed = Vec::new();
if let Some(kind) = filter.kind {
narrowed.push(kind.plural().to_string());
}
if let Some(place) = filter.place {
narrowed.push(
match place {
Place::Computer => "this computer",
Place::Keyboard => "the instrument",
}
.to_string(),
);
}
if let Some(state) = filter.state {
narrowed.push(state.word().to_string());
}
match narrowed.is_empty() {
true => "everything".to_string(),
false => narrowed.join(" · "),
}
}
fn under(response: &egui::Response, rect: egui::Rect, tracks: &[Range<f32>; 9]) -> Option<Column> {
let at = response.interact_pointer_pos().or(response.hover_pos())?;
let x = at.x - rect.left();
Column::ALL
.iter()
.zip(tracks)
.find(|(_, track)| (track.start..track.end + GAP).contains(&x))
.map(|(column, _)| *column)
}
#[allow(clippy::too_many_arguments)]
fn paint(
ui: &mut egui::Ui,
row: &Row,
width: f32,
tracks: &[Range<f32>; 9],
browser: &mut Browser,
list: &[Item],
workspace: &Workspace,
device: &Device,
queue: &Queue,
acts: &mut Vec<Act>,
) {
let selected = browser.picked().holds(row.item);
let (rect, response) =
ui.allocate_exact_size(egui::vec2(width, ROW), egui::Sense::click_and_drag());
let visuals = ui.visuals().clone();
let painter = ui.painter().clone();
let fill = match (selected, response.hovered()) {
(true, _) => Some(visuals.selection.bg_fill),
(false, true) => Some(visuals.faint_bg_color),
(false, false) => None,
};
if let Some(fill) = fill {
painter.rect_filled(rect, 3.0, fill);
}
let ink = match selected {
true => visuals.selection.stroke.color,
false => visuals.text_color(),
};
let quiet = cell_ink(selected, visuals.weak_text_color(), &visuals);
let cell = |column: Column| {
let track = &tracks[column.index()];
egui::Rect::from_min_max(
egui::pos2(rect.left() + track.start, rect.top()),
egui::pos2(rect.left() + track.end, rect.bottom()),
)
};
let write =
|box_: egui::Rect, text: &str, font: egui::FontId, tint: egui::Color32, italics: bool| {
let mut job = egui::text::LayoutJob::default();
job.append(
text,
0.0,
egui::TextFormat {
font_id: font,
color: tint,
italics,
..egui::TextFormat::default()
},
);
job.wrap = egui::text::TextWrapping::truncate_at_width(box_.width());
let galley = painter.layout_job(job);
painter.galley(
egui::pos2(box_.left(), box_.center().y - galley.size().y / 2.0),
galley,
egui::Color32::PLACEHOLDER,
);
};
let checkbox = mark(ui, cell(Column::Mark), selected, &response);
let glyph = cell(Column::Glyph);
if glyph.width() > 0.0 {
painted(
ui,
row.kind.glyph(),
egui::Rect::from_center_size(
egui::pos2(glyph.left() + GLYPH / 2.0, glyph.center().y),
egui::Vec2::splat(GLYPH),
),
ink,
);
}
write(
cell(Column::Kind),
&word(row),
egui::FontId::proportional(NAME - 1.0),
quiet,
false,
);
write(
cell(Column::Name),
&crate::browser::starred(&row.name, row.unsaved),
egui::FontId::proportional(NAME),
ink,
row.unsaved,
);
if row.tags > 0 {
let tags = cell(Column::Tags);
painted(
ui,
Glyph::Tag,
egui::Rect::from_center_size(
egui::pos2(tags.left() + SMALL / 2.0, tags.center().y),
egui::Vec2::splat(SMALL),
),
quiet,
);
let count = tags.with_min_x(tags.left() + SMALL + 3.0);
write(
count,
&row.tags.to_string(),
egui::FontId::monospace(MONO),
quiet,
false,
);
}
let mark = row
.item
.local()
.and_then(|id| workspace.get(id))
.and_then(|entity| keyboard_mark(entity, &device.state, queue));
write(
cell(Column::Where),
row.where_.short(),
egui::FontId::proportional(NAME - 1.0),
match mark {
Some(mark) => cell_ink(selected, mark_ink(mark, &visuals), &visuals),
None => quiet,
},
false,
);
if let Some((_, at)) = row.at {
write(
cell(Column::At),
&shown(at),
egui::FontId::monospace(MONO),
quiet,
false,
);
}
write(
cell(Column::Size),
&crate::room::measure(row.size),
egui::FontId::monospace(MONO),
quiet,
false,
);
write(
cell(Column::Needs),
&row.needs.text(),
egui::FontId::proportional(NAME - 1.0),
quiet,
false,
);
if response.dragged() {
if let Some(head) = browser.held(row.item, workspace, &device.state) {
let carried = browser.carrying(head, &row.name, workspace, &device.state);
egui::DragAndDrop::set_payload(ui.ctx(), carried);
}
}
let checked = checkbox
.map(|box_| {
box_.on_hover_text(tooltip(
row,
Column::Mark,
mark,
browser.tags(),
workspace,
device,
))
})
.is_some_and(|box_| box_.clicked());
let response = match under(&response, rect, tracks) {
Some(column) => response.on_hover_text(tooltip(
row,
column,
mark,
browser.tags(),
workspace,
device,
)),
None => response,
};
if checked {
browser.check(row.item);
} else if response.double_clicked() {
acts.push(Act::Open(row.item));
} else if response.clicked() {
browser.pick(ui, row.item, list);
}
response.context_menu(|ui| browser.menu(ui, row.item, workspace, device, queue, acts));
}
fn mark(
ui: &egui::Ui,
box_: egui::Rect,
picked: bool,
row: &egui::Response,
) -> Option<egui::Response> {
if box_.width() < MARK {
return None;
}
let visuals = ui.visuals().clone();
let at = egui::Rect::from_center_size(
egui::pos2(box_.left() + MARK / 2.0, box_.center().y),
egui::Vec2::splat(MARK),
);
match picked {
true => {
ui.painter().rect_filled(at, 2.0, accent(&visuals));
painted(
ui,
Glyph::Check,
at.shrink(1.5),
visuals.selection.stroke.color,
);
}
false => {
ui.painter().rect_stroke(
at,
2.0,
egui::Stroke::new(1.0_f32, visuals.widgets.noninteractive.bg_stroke.color),
egui::StrokeKind::Inside,
);
}
}
Some(ui.interact(at, row.id.with("mark"), egui::Sense::click()))
}
fn tooltip(
row: &Row,
column: Column,
mark: Option<Mark>,
tags: &Tags,
workspace: &Workspace,
device: &Device,
) -> String {
match column {
Column::Mark => {
"check it to act on several at once; a click on the row picks it alone".to_string()
}
Column::Glyph | Column::Kind => word(row),
Column::Name => row.name.clone(),
Column::Tags => match worn(row, tags) {
names if names.is_empty() => "no tags".to_string(),
names => names.join(", "),
},
Column::Where => match mark {
Some(mark) => format!("{}\n{}", row.where_.sentence(), mark_words(mark)),
None => row.where_.sentence().to_string(),
},
Column::At => match row.at {
Some((class, at)) => {
let where_ = place(class, at);
match (row.where_, also_holding(row, workspace, device)) {
(Where::Both(None), _) => format!("{where_}, matched by name"),
(_, 0) => where_,
(_, more) => format!("{where_}, and {more} more hold the same bytes"),
}
}
None => "it never came off a slot".to_string(),
},
Column::Size => format!("{} bytes", row.size),
Column::Needs => row.needs.sentence(),
}
}
fn also_holding(row: &Row, workspace: &Workspace, device: &Device) -> usize {
row.item
.local()
.and_then(|id| workspace.get(id))
.map_or(0, |entity| {
crate::device::also_holding(&device.state, entity)
})
}
fn worn(row: &Row, tags: &Tags) -> Vec<String> {
let Item::Local(id) = row.item else {
return Vec::new();
};
tags.worn(id)
.iter()
.filter_map(|tag| tags.name_of(*tag))
.map(str::to_string)
.collect()
}
#[allow(clippy::too_many_arguments)]
fn footer(
ui: &mut egui::Ui,
picked: &[&Row],
browser: &mut Browser,
workspace: &Workspace,
device: &DeviceState,
queue: &Queue,
acts: &mut Vec<Act>,
) {
let checked: Vec<Item> = picked.iter().map(|row| row.item).collect();
egui::Frame::new()
.stroke(egui::Stroke::new(1.0_f32, accent(ui.visuals())))
.inner_margin(egui::Margin::symmetric(8, 4))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
ui.label(
egui::RichText::new(format!("{} selected", picked.len()))
.text_style(ui_text())
.strong(),
);
ui.scope(|ui| {
crate::panel::flat(ui);
if ui
.add(egui::Button::new(
egui::RichText::new("clear").text_style(ui_text()),
))
.on_hover_text("let go of everything picked — or press Escape")
.clicked()
{
browser.unpick();
}
});
ui.label(
egui::RichText::new(consequence(picked, device, queue))
.text_style(ui_text())
.weak(),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().button_padding.y = 0.0;
if ui.small_button("Review send queue").clicked() {
acts.push(Act::ShowPage(Page::Queue));
}
for action in Bulk::ALL.iter().rev() {
browser.bulk_item(ui, *action, &checked, workspace, device, acts);
}
});
});
});
}
fn nothing(ui: &mut egui::Ui) {
ui.add_space(6.0);
ui.label(
egui::RichText::new(
"Nothing here — drop Nord files in, attach an instrument, or ask for less.",
)
.text_style(micro())
.weak()
.italics(),
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::browser::apply;
use crate::log::Log;
use crate::tabs::Tabs;
use crate::workspace::{Fresh, Origin};
fn at(bank: u32, slot: u32) -> Location {
Location { bank, slot }
}
fn context() -> egui::Context {
let ctx = egui::Context::default();
ctx.all_styles_mut(crate::app::metrics);
ctx
}
fn row(name: &str, kind: Kind, where_: Where, at: Option<Location>, size: u64) -> Row {
Row {
item: Item::Local(size),
kind,
family: None,
name: name.to_string(),
tags: 0,
unsaved: false,
where_,
at: at.map(|at| (ObjectClass::Program, at)),
size,
needs: Needs::Nothing,
}
}
#[test]
fn the_columns_share_the_width_without_overlapping_or_overflowing_it() {
for width in [430.0_f32, 900.0] {
let tracks = tracks(width);
for (column, track) in Column::ALL.iter().zip(&tracks) {
assert!(
track.start >= 0.0 && track.end >= track.start,
"{column:?} at {width}: {track:?}"
);
assert!(
track.end <= width + 0.01,
"{column:?} at {width}: {track:?}"
);
}
for pair in tracks.windows(2) {
assert!(
pair[1].start >= pair[0].end,
"columns overlap at {width}: {pair:?}"
);
}
for column in [Column::At, Column::Size, Column::Needs] {
let track = &tracks[column.index()];
assert!(
track.end - track.start > 0.0,
"{column:?} vanished at {width}"
);
}
}
}
#[test]
fn the_kind_reaches_96_px_when_wide_and_yields_to_the_name_when_narrow() {
let width_of = |tracks: &[Range<f32>; 9], column: Column| {
let track = &tracks[column.index()];
track.end - track.start
};
let wide = tracks(900.0);
assert!(
(width_of(&wide, Column::Kind) - 96.0).abs() < 0.01,
"at 900 the kind holds the longest word: {}",
width_of(&wide, Column::Kind)
);
let narrow = tracks(430.0);
assert!(
width_of(&narrow, Column::Name) >= 40.0,
"at 430 the name is unreadable: {}",
width_of(&narrow, Column::Name)
);
assert!(
width_of(&narrow, Column::Kind) > 0.0,
"at 430 the kind vanished"
);
}
#[test]
fn every_column_indexes_its_own_track() {
for (index, column) in Column::ALL.iter().enumerate() {
assert_eq!(column.index(), index, "{column:?}");
}
}
#[test]
fn a_width_below_the_fixed_columns_shrinks_every_track_instead_of_going_negative() {
for width in [0.0_f32, 40.0, 120.0] {
let tracks = tracks(width);
for (column, track) in Column::ALL.iter().zip(&tracks) {
assert!(track.start >= 0.0, "{column:?} at {width}: {track:?}");
assert!(track.end >= track.start, "{column:?} at {width}: {track:?}");
assert!(
track.end <= width + 0.01,
"{column:?} at {width}: {track:?}"
);
}
}
}
#[test]
fn the_search_narrows_and_the_column_orders_what_is_left() {
let held = || {
vec![
row("Africa Split", Kind::Program, Where::Computer, None, 300),
row(
"africa bass",
Kind::Program,
Where::Both(Some(false)),
Some(at(6, 0)),
100,
),
row(
"Squabble B",
Kind::Live,
Where::Keyboard,
Some(at(0, 0)),
200,
),
]
};
let names = |rows: &[Row]| -> Vec<String> { rows.iter().map(|r| r.name.clone()).collect() };
let found = arrange(held(), "AFRICA", Column::Name, Order::Up);
assert_eq!(names(&found), ["africa bass", "Africa Split"]);
assert!(arrange(held(), "nothing at all", Column::Name, Order::Up).is_empty());
let biggest = arrange(held(), "africa", Column::Size, Order::Down);
assert_eq!(names(&biggest), ["Africa Split", "africa bass"]);
let smallest = arrange(held(), "africa", Column::Size, Order::Up);
assert_eq!(names(&smallest), ["africa bass", "Africa Split"]);
}
#[test]
fn a_row_with_no_address_sorts_after_every_row_that_has_one() {
let rows = arrange(
vec![
row("no slot", Kind::Program, Where::Computer, None, 1),
row("later", Kind::Program, Where::Keyboard, Some(at(6, 3)), 2),
row("first", Kind::Program, Where::Keyboard, Some(at(0, 0)), 3),
],
"",
Column::At,
Order::Up,
);
let names: Vec<&str> = rows.iter().map(|row| row.name.as_str()).collect();
assert_eq!(names, ["first", "later", "no slot"]);
}
#[test]
fn the_kind_place_and_tag_filters_compose_over_the_row_model() {
use crate::filter::Narrow;
let ctx = context();
let mut workspace = Workspace::new(ctx.clone());
let mut device = Device::new(ctx);
let mut log = Log::default();
let mut tags = Tags::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
};
device.pretend_partitions(&crate::device::ELECTRO5);
device.pretend_scanned(ObjectClass::SetList, 1, &["Sunday"]);
let both = workspace.ingest(
"Africa-Split.ne5p".into(),
Origin::Device {
class: ObjectClass::Program,
at: at(6, 0),
},
bytes,
&mut log,
);
let crc = workspace
.get(both)
.and_then(|entity| entity.saved.crc32)
.expect("every CBIN container has one");
device.pretend_bodies(
ObjectClass::Program,
7,
&[Some(("Africa Split", crc)), Some(("Squabble B", crc ^ 1))],
);
device.relink(&mut workspace);
workspace.create(Fresh::Live, &mut log).unwrap();
let sunday = tags.make("Sunday").unwrap();
tags.set(both, sunday, true);
let names = |filter: &Filter| -> Vec<String> {
rows(&workspace, &device.state, &Queue::default(), &tags, filter)
.into_iter()
.map(|row| row.name)
.collect()
};
let mut filter = Filter::default();
assert_eq!(
names(&filter),
["Africa-Split.ne5p", "untitled.ne5l", "Squabble B", "Sunday"]
);
filter.narrow(Narrow::Kind(Kind::Program));
assert_eq!(names(&filter), ["Africa-Split.ne5p", "Squabble B"]);
filter.narrow(Narrow::Place(Place::Computer));
assert_eq!(names(&filter), ["Africa-Split.ne5p"]);
filter.narrow(Narrow::Place(Place::Computer));
filter.narrow(Narrow::Place(Place::Keyboard));
assert_eq!(names(&filter), ["Africa-Split.ne5p", "Squabble B"]);
filter.narrow(Narrow::Tag(sunday));
assert_eq!(names(&filter), ["Africa-Split.ne5p"]);
}
#[test]
fn a_linked_asset_is_in_both_places_and_says_when_the_two_stop_agreeing() {
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx.clone());
let mut device = Device::new(ctx);
let mut log = Log::default();
let tags = Tags::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 id = workspace.ingest(
"Africa-Split.ne5p".into(),
Origin::Device {
class: ObjectClass::Program,
at: at(6, 0),
},
bytes.clone(),
&mut log,
);
let crc = workspace
.get(id)
.and_then(|entity| entity.saved.crc32)
.expect("every CBIN container has one");
let filter = Filter::default();
let where_ = |workspace: &Workspace, device: &Device| {
rows(workspace, &device.state, &Queue::default(), &tags, &filter)
.into_iter()
.find(|row| matches!(row.item, Item::Local(_)))
.map(|row| row.where_)
};
device.relink(&mut workspace);
assert_eq!(where_(&workspace, &device), Some(Where::Unread));
device.pretend_bodies(ObjectClass::Program, 7, &[Some(("Africa Split", crc))]);
device.relink(&mut workspace);
assert_eq!(where_(&workspace, &device), Some(Where::Both(Some(true))));
let (_, edited) =
crate::fields::apply(&bytes, &[("center_panel.gain".into(), "96".into())])
.expect("the registry takes the set");
workspace.replace_bytes(id, edited, &mut log);
device.relink(&mut workspace);
assert_eq!(where_(&workspace, &device), Some(Where::Both(Some(true))));
workspace.mark_saved(id);
device.relink(&mut workspace);
assert_eq!(
workspace.get(id).unwrap().link,
Some((ObjectClass::Program, at(6, 0)))
);
assert_eq!(where_(&workspace, &device), Some(Where::Both(Some(false))));
device.pretend_bodies(ObjectClass::Program, 7, &[None]);
device.relink(&mut workspace);
assert_eq!(where_(&workspace, &device), Some(Where::Computer));
}
#[test]
fn a_foreign_asset_is_neither_marked_against_a_slot_nor_counted_as_changed() {
let ctx = context();
let mut workspace = Workspace::new(ctx.clone());
let mut device = Device::new(ctx);
let mut log = Log::default();
let queue = Queue::default();
let class = ObjectClass::Program;
let held_at = at(6, 0);
let bytes = {
let id = workspace.create(Fresh::Stage4Program, &mut log).unwrap();
let bytes = workspace.get(id).unwrap().bytes.clone();
workspace.remove(id, &mut log);
bytes
};
let id = workspace.ingest(
"Africa-Split.ns4p".into(),
Origin::Device { class, at: held_at },
bytes,
&mut log,
);
let crc = workspace
.get(id)
.and_then(|entity| entity.saved.crc32)
.expect("every CBIN container has one");
device.pretend_bodies(class, 7, &[Some(("Squabble B", crc ^ 1))]);
device.relink(&mut workspace);
let entity = workspace.get(id).expect("it is on the list");
assert!(!fit(&device.state, entity).allowed(), "a Stage 4 program");
assert_eq!(whereabouts(entity, &device.state, &queue), Where::Foreign);
assert_eq!(keyboard_mark(entity, &device.state, &queue), None);
assert!(crate::queue::changed(&workspace, &device.state, &queue).is_empty());
}
#[test]
fn a_slot_reporting_no_checksum_says_nothing_until_it_is_read_or_written() {
let ctx = context();
let mut workspace = Workspace::new(ctx.clone());
let mut device = Device::new(ctx);
let mut log = Log::default();
let mut queue = Queue::default();
let tags = Tags::default();
let class = ObjectClass::Settings;
let held_at = at(6, 0);
let id = workspace.create(Fresh::Settings, &mut log).unwrap();
let bytes = workspace.get(id).unwrap().bytes.clone();
let held = workspace.get(id).unwrap();
let crc = held.saved.crc32.expect("a container");
let body_len = held.container.as_ref().expect("a container").body_len();
device.pretend_attached();
device.pretend(crate::device::DeviceEvent::BankScanned {
class,
bank: 7,
slots: vec![Some(ProgramInfo {
location: held_at,
body_len: u32::try_from(body_len).unwrap(),
format: "ne5s".into(),
version: 1,
crc32: None,
name: "Live Settings".into(),
})],
});
device.poll(&mut log, &mut workspace, &mut Tabs::default(), &mut queue);
assert_eq!(
workspace.get(id).unwrap().link,
Some((class, held_at)),
"the one slot the folder has"
);
let said = |workspace: &Workspace, device: &Device, queue: &Queue| {
let entity = workspace.get(id).expect("it is on the list");
let info = device
.state
.slot(class, held_at)
.flatten()
.expect("the slot was scanned");
(
agrees(entity, class, info, queue),
rows(workspace, &device.state, queue, &tags, &Filter::default())
.into_iter()
.find(|row| matches!(row.item, Item::Local(_)))
.map(|row| row.where_),
keyboard_mark(entity, &device.state, queue),
)
};
assert_eq!(
said(&workspace, &device, &queue),
(None, Some(Where::Both(None)), Some(Mark::Unknown)),
"an address and a length are not a body"
);
crate::queue::enqueue(
&workspace,
&mut device,
&mut queue,
&mut log,
id,
class,
held_at,
);
queue.arrived(class, held_at, "Live Settings", &bytes, &workspace);
assert_eq!(said(&workspace, &device, &queue).0, Some(true));
queue.clear();
workspace.landed(id, class, held_at, bytes.clone());
device.relink(&mut workspace);
assert_eq!(
said(&workspace, &device, &queue),
(
Some(true),
Some(Where::Both(Some(true))),
Some(Mark::Agrees)
),
"this app put those bytes there"
);
device.pretend_bodies(class, 7, &[Some(("Live Settings", crc ^ 1))]);
device.relink(&mut workspace);
assert_eq!(
said(&workspace, &device, &queue),
(
Some(false),
Some(Where::Both(Some(false))),
Some(Mark::Differs)
),
"what the instrument reports outlives our own write"
);
}
#[test]
fn a_type_0_asset_links_to_the_slot_reporting_its_body_checksum() {
let ctx = context();
let mut workspace = Workspace::new(ctx.clone());
let mut device = Device::new(ctx);
let mut log = Log::default();
let (queue, tags) = (Queue::default(), Tags::default());
let held_at = at(6, 0);
let bytes = {
let id = workspace.create(Fresh::Program, &mut log).unwrap();
let bytes = crate::workspace::as_type_0(&workspace.get(id).unwrap().bytes);
workspace.remove(id, &mut log);
bytes
};
let id = workspace.ingest(
"Circling Bells.ne5p".into(),
Origin::File("Circling Bells.ne5p".into()),
bytes,
&mut log,
);
let crc = workspace
.get(id)
.and_then(|entity| entity.saved.crc32)
.expect("hashed from the body the header does not checksum");
device.pretend_bodies(
ObjectClass::Program,
7,
&[Some(("Circling Bells", crc)), Some(("Squabble B", crc ^ 1))],
);
device.relink(&mut workspace);
assert_eq!(
workspace.get(id).unwrap().link,
Some((ObjectClass::Program, held_at))
);
let where_ = rows(&workspace, &device.state, &queue, &tags, &Filter::default())
.into_iter()
.find(|row| matches!(row.item, Item::Local(_)))
.map(|row| row.where_);
assert_eq!(where_, Some(Where::Both(Some(true))));
assert_eq!(
keyboard_mark(workspace.get(id).unwrap(), &device.state, &queue),
Some(Mark::Agrees)
);
}
#[test]
fn an_unsaved_edit_still_links_to_the_slot_holding_the_saved_bytes() {
let ctx = context();
let mut workspace = Workspace::new(ctx.clone());
let mut device = Device::new(ctx);
let mut log = Log::default();
let (queue, tags) = (Queue::default(), Tags::default());
let held_at = at(6, 0);
let id = workspace.create(Fresh::Program, &mut log).unwrap();
let bytes = workspace.get(id).unwrap().bytes.clone();
let saved_as = workspace.get(id).unwrap().saved.crc32.unwrap();
let (_, edited) =
crate::fields::apply(&bytes, &[("center_panel.gain".into(), "96".into())])
.expect("the registry takes the set");
workspace.replace_bytes(id, edited, &mut log);
assert!(workspace.get(id).unwrap().is_unsaved());
assert_ne!(
workspace
.get(id)
.unwrap()
.container
.as_ref()
.map(|held| held.body_crc32),
Some(saved_as),
"the edit moved the bytes it holds now"
);
let where_ = |workspace: &Workspace, device: &Device| {
rows(workspace, &device.state, &queue, &tags, &Filter::default())
.into_iter()
.find(|row| matches!(row.item, Item::Local(_)))
.map(|row| row.where_)
};
let mark = |workspace: &Workspace, device: &Device| {
keyboard_mark(workspace.get(id).unwrap(), &device.state, &queue)
};
device.pretend_bodies(ObjectClass::Program, 7, &[Some(("Africa Split", saved_as))]);
device.relink(&mut workspace);
assert_eq!(
workspace.get(id).unwrap().link,
Some((ObjectClass::Program, held_at)),
"the slot holds what this was saved as"
);
assert_eq!(
crate::device::also_holding(&device.state, workspace.get(id).unwrap()),
0,
"the one slot holding it is the one it is linked to"
);
assert_eq!(mark(&workspace, &device), Some(Mark::Agrees));
assert_eq!(where_(&workspace, &device), Some(Where::Both(Some(true))));
workspace.mark_saved(id);
device.relink(&mut workspace);
assert_eq!(
workspace.get(id).unwrap().link,
Some((ObjectClass::Program, held_at)),
"no slot holds the new baseline, and where it stands is where it stands"
);
assert_eq!(mark(&workspace, &device), Some(Mark::Differs));
assert_eq!(where_(&workspace, &device), Some(Where::Both(Some(false))));
}
#[test]
fn a_row_waiting_to_be_sent_is_not_also_one_that_differs() {
use crate::filter::{Narrow, State};
let ctx = context();
let mut workspace = Workspace::new(ctx.clone());
let mut device = Device::new(ctx);
let mut log = Log::default();
let (tags, mut queue) = (Tags::default(), Queue::default());
let id = workspace.create(Fresh::Program, &mut log).unwrap();
let bytes = workspace.get(id).unwrap().bytes.clone();
let crc = workspace
.get(id)
.and_then(|entity| entity.saved.crc32)
.expect("every CBIN container has one");
device.pretend_bodies(ObjectClass::Program, 7, &[Some(("Africa Split", crc))]);
device.relink(&mut workspace);
let (_, edited) =
crate::fields::apply(&bytes, &[("center_panel.gain".into(), "96".into())])
.expect("the registry takes the set");
workspace.replace_bytes(id, edited, &mut log);
workspace.mark_saved(id);
device.relink(&mut workspace);
fn narrowed(
workspace: &Workspace,
device: &Device,
queue: &Queue,
tags: &Tags,
state: crate::filter::State,
) -> usize {
let mut filter = Filter::default();
filter.narrow(Narrow::State(state));
rows(workspace, &device.state, queue, tags, &filter)
.into_iter()
.filter(|row| matches!(row.item, Item::Local(_)))
.count()
}
assert_eq!(differing(&workspace, &device.state, &queue), 1);
assert_eq!(
narrowed(&workspace, &device, &queue, &tags, State::Differs),
1
);
assert_eq!(
narrowed(&workspace, &device, &queue, &tags, State::Waiting),
0
);
crate::queue::enqueue(
&workspace,
&mut device,
&mut queue,
&mut log,
id,
ObjectClass::Program,
at(6, 0),
);
assert!(queue.holds(id), "it is owed back to the slot it came off");
assert_eq!(
differing(&workspace, &device.state, &queue),
0,
"the write already agreed to is what settles the two"
);
assert_eq!(
narrowed(&workspace, &device, &queue, &tags, State::Waiting),
1
);
assert_eq!(
narrowed(&workspace, &device, &queue, &tags, State::Differs),
0
);
}
#[test]
fn a_class_linked_by_name_reads_both_without_a_sign() {
let ctx = context();
let mut workspace = Workspace::new(ctx.clone());
let mut device = Device::new(ctx);
let mut log = Log::default();
let (tags, filter, queue) = (Tags::default(), Filter::default(), Queue::default());
workspace.create(Fresh::Settings, &mut log).unwrap();
device.pretend_scanned(ObjectClass::Settings, 1, &["Settings"]);
device.relink(&mut workspace);
let row = rows(&workspace, &device.state, &queue, &tags, &filter)
.into_iter()
.find(|row| matches!(row.item, Item::Local(_)))
.expect("the settings row");
assert_eq!(row.where_, Where::Both(None));
assert_eq!(row.where_.short(), "both");
assert!(
tooltip(&row, Column::At, None, &tags, &workspace, &device)
.ends_with("matched by name"),
"{}",
tooltip(&row, Column::At, None, &tags, &workspace, &device)
);
}
#[test]
fn every_mark_is_painted_and_said_apart_from_the_others() {
let all = [Mark::Agrees, Mark::Differs, Mark::Unknown, Mark::Unsaved];
let said: BTreeSet<&str> = all.iter().map(|mark| mark_words(*mark)).collect();
assert_eq!(said.len(), all.len(), "{said:?}");
for visuals in [egui::Visuals::dark(), egui::Visuals::light()] {
let inks: BTreeSet<[u8; 4]> = all
.iter()
.map(|mark| mark_ink(*mark, &visuals).to_array())
.collect();
assert_eq!(inks.len(), all.len(), "{inks:?}");
}
}
#[test]
fn a_coloured_where_cell_says_what_its_colour_claims() {
let ctx = context();
let workspace = Workspace::new(ctx.clone());
let device = Device::new(ctx);
let tags = Tags::default();
let held = row(
"Africa Split",
Kind::Program,
Where::Both(Some(false)),
Some(at(6, 0)),
121,
);
let plain = tooltip(&held, Column::Where, None, &tags, &workspace, &device);
assert_eq!(plain, Where::Both(Some(false)).sentence());
let marked = tooltip(
&held,
Column::Where,
Some(Mark::Differs),
&tags,
&workspace,
&device,
);
assert!(marked.starts_with(&plain), "{marked}");
assert!(marked.ends_with(mark_words(Mark::Differs)), "{marked}");
}
#[test]
fn the_footer_says_where_a_selection_goes_and_what_it_would_replace() {
let ctx = egui::Context::default();
let mut device = Device::new(ctx.clone());
let mut workspace = Workspace::new(ctx);
let mut log = crate::log::Log::default();
let mut queue = Queue::default();
device.pretend_scanned(ObjectClass::Program, 7, &["Africa Split", "Squabble B"]);
let mut going: Vec<Row> = (0..4)
.map(|slot| {
row(
"owed",
Kind::Program,
Where::Computer,
Some(at(6, slot)),
121,
)
})
.collect();
going[0].needs = Needs::Wanted {
class: ObjectClass::Piano,
id: 0x0102_0304,
};
let picked: Vec<&Row> = going.iter().collect();
assert_eq!(
consequence(&picked, &device.state, &queue),
"→ Programs 7:1–7:4 · 2 slots occupied · 1 needs a piano the instrument has not named"
);
let id = workspace.create(Fresh::Program, &mut log).unwrap();
going[3].item = Item::Local(id);
crate::queue::enqueue(
&workspace,
&mut device,
&mut queue,
&mut log,
id,
ObjectClass::Program,
at(6, 3),
);
let picked: Vec<&Row> = going.iter().collect();
assert!(
consequence(&picked, &device.state, &queue).contains("1 already waiting"),
"{}",
consequence(&picked, &device.state, &queue)
);
let there = vec![row(
"held",
Kind::Program,
Where::Keyboard,
Some(at(6, 0)),
121,
)];
let mut only = there;
only[0].item = Item::Slot {
class: ObjectClass::Program,
at: at(6, 0),
};
assert_eq!(
consequence(&only.iter().collect::<Vec<_>>(), &device.state, &queue),
"Nothing picked goes to the instrument."
);
assert_eq!(
consequence(&[], &device.state, &queue),
"Nothing picked goes to the instrument."
);
}
#[test]
fn a_click_on_a_row_box_checks_it_beside_what_is_already_checked() {
const WIDTH: f32 = 900.0;
let ctx = context();
let mut workspace = Workspace::new(ctx.clone());
let device = Device::new(ctx.clone());
let mut log = Log::default();
let mut browser = Browser::default();
let mut library = Library::default();
let queue = Queue::default();
let shell = Shell::default();
for kind in [Fresh::Program, Fresh::Live, Fresh::Settings] {
workspace.create(kind, &mut log).unwrap();
}
let box_x = PAD + tracks(WIDTH - PAD)[Column::Mark.index()].start + MARK / 2.0;
let on_box = |index: f32| egui::pos2(box_x, BAR + HEAD + ROW * (index + 0.5));
let mut frames = Vec::new();
for index in [0.0_f32, 1.0] {
let press = move |pressed| egui::Event::PointerButton {
pos: on_box(index),
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::NONE,
};
frames.push(vec![egui::Event::PointerMoved(on_box(index))]);
frames.push(vec![press(true), press(false)]);
frames.push(Vec::new());
}
for events in frames {
let input = egui::RawInput {
events,
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(WIDTH, 540.0),
)),
..Default::default()
};
let _ = ctx.run(input, |ctx| {
egui::CentralPanel::default()
.frame(egui::Frame::new())
.show(ctx, |ui| {
library.ui(ui, &mut browser, &workspace, &device, &queue, &shell);
});
});
}
assert_eq!(
browser.picked().items().count(),
2,
"both boxes were ticked, and neither click dropped the other row"
);
}
fn painted(output: &egui::FullOutput) -> Vec<String> {
fn words(shape: &egui::Shape, into: &mut Vec<String>) {
match shape {
egui::Shape::Text(text) => into.push(text.galley.text().to_string()),
egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| words(shape, into)),
_ => {}
}
}
let mut said = Vec::new();
for clipped in &output.shapes {
words(&clipped.shape, &mut said);
}
said
}
#[test]
fn the_keyboard_mark_says_what_the_instrument_holds_where_this_stands() {
let ctx = context();
let mut workspace = Workspace::new(ctx.clone());
let mut device = Device::new(ctx.clone());
let mut log = Log::default();
let mut queue = Queue::default();
let (good, warn) = (Mark::Agrees, Mark::Differs);
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 off = |workspace: &mut Workspace, slot: u32, log: &mut Log| {
workspace.ingest(
format!("off-{slot}.ne5p"),
Origin::Device {
class: ObjectClass::Program,
at: at(6, slot),
},
bytes.clone(),
log,
)
};
let same = off(&mut workspace, 0, &mut log);
let other = off(&mut workspace, 1, &mut log);
let waiting = off(&mut workspace, 2, &mut log);
let (_, typed) = crate::fields::apply(&bytes, &[("center_panel.gain".into(), "96".into())])
.expect("the registry takes the set");
let nowhere = workspace.ingest("typed.ne5p".into(), Origin::Fresh, typed, &mut log);
let held = workspace.get(same).unwrap().saved.crc32.unwrap();
let mark = |workspace: &Workspace, device: &Device, queue: &Queue, id: u64| {
keyboard_mark(workspace.get(id).unwrap(), &device.state, queue)
};
assert_eq!(mark(&workspace, &device, &queue, same), None);
device.pretend_bodies(
ObjectClass::Program,
7,
&[
Some(("off-0", held)),
Some(("off-1", held ^ 1)),
Some(("off-2", held)),
],
);
device.relink(&mut workspace);
assert_eq!(mark(&workspace, &device, &queue, same), Some(good));
assert_eq!(mark(&workspace, &device, &queue, other), Some(warn));
assert_eq!(
mark(&workspace, &device, &queue, nowhere),
None,
"it came off nowhere"
);
assert_eq!(mark(&workspace, &device, &queue, waiting), Some(good));
crate::queue::enqueue(
&workspace,
&mut device,
&mut queue,
&mut log,
waiting,
ObjectClass::Program,
at(6, 2),
);
assert_eq!(mark(&workspace, &device, &queue, waiting), Some(warn));
}
#[test]
fn an_unsaved_name_wears_a_star_in_the_table() {
const WIDTH: f32 = 900.0;
let ctx = context();
let mut workspace = Workspace::new(ctx.clone());
let device = Device::new(ctx.clone());
let mut log = Log::default();
let mut browser = Browser::default();
let mut library = Library::default();
let (queue, shell) = (Queue::default(), Shell::default());
let id = workspace.create(Fresh::Program, &mut log).unwrap();
workspace.rename(id, "Africa Split".into());
let draw = |library: &mut Library, browser: &mut Browser, workspace: &Workspace| {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(WIDTH, 540.0),
)),
..Default::default()
};
painted(&ctx.run(input, |ctx| {
egui::CentralPanel::default()
.frame(egui::Frame::new())
.show(ctx, |ui| {
library.ui(ui, browser, workspace, &device, &queue, &shell);
});
}))
};
let said = draw(&mut library, &mut browser, &workspace);
assert!(said.contains(&"Africa Split".to_string()), "{said:?}");
let bytes = workspace.get(id).unwrap().bytes.clone();
let (_, edited) =
crate::fields::apply(&bytes, &[("center_panel.gain".into(), "96".into())]).unwrap();
workspace.replace_bytes(id, edited, &mut log);
let said = draw(&mut library, &mut browser, &workspace);
assert!(said.contains(&"Africa Split*".to_string()), "{said:?}");
}
#[test]
fn the_kind_column_writes_the_browsers_own_word_and_sorts_by_it() {
const WIDTH: f32 = 900.0;
let ctx = context();
let mut workspace = Workspace::new(ctx.clone());
let device = Device::new(ctx.clone());
let mut log = Log::default();
let mut browser = Browser::default();
let mut library = Library::default();
let (queue, shell) = (Queue::default(), Shell::default());
for kind in [Fresh::Settings, Fresh::Program] {
workspace.create(kind, &mut log).unwrap();
}
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(WIDTH, 540.0),
)),
..Default::default()
};
let said = painted(&ctx.run(input, |ctx| {
egui::CentralPanel::default()
.frame(egui::Frame::new())
.show(ctx, |ui| {
library.ui(ui, &mut browser, &workspace, &device, &queue, &shell);
});
}));
assert!(said.contains(&"KIND".to_string()), "{said:?}");
for word in ["program", "settings"] {
assert!(said.contains(&word.to_string()), "{word}: {said:?}");
}
let held = rows(
&workspace,
&device.state,
&queue,
browser.tags(),
&Filter::default(),
);
let ordered: Vec<String> = arrange(held, "", Column::Kind, Order::Up)
.iter()
.map(word)
.collect();
assert_eq!(ordered, ["program", "settings"]);
}
#[test]
fn a_row_dragged_from_the_table_onto_a_folder_files_the_asset() {
const WIDTH: f32 = 900.0;
let ctx = context();
let mut workspace = Workspace::new(ctx.clone());
let mut device = Device::new(ctx.clone());
let mut log = Log::default();
let mut tabs = Tabs::default();
let mut browser = Browser::default();
let mut library = Library::default();
let mut queue = Queue::default();
let shell = Shell::default();
let id = workspace.create(Fresh::Program, &mut log).unwrap();
apply(
&mut browser,
&mut Shell::default(),
vec![Act::NewFolder],
&mut workspace,
&mut device,
&mut tabs,
&mut queue,
&mut log,
);
let from = egui::pos2(crate::shell::BROWSER + PAD + 60.0, BAR + HEAD + ROW / 2.0);
let onto = egui::pos2(100.0, crate::panel::HEADER + 22.0 + 10.0);
let button = |pos, pressed| egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::NONE,
};
let escape = egui::Event::Key {
key: egui::Key::Escape,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::NONE,
};
let frames: [Vec<egui::Event>; 6] = [
Vec::new(),
vec![escape],
vec![egui::Event::PointerMoved(from)],
vec![button(from, true)],
vec![egui::Event::PointerMoved(onto)],
vec![button(onto, false)],
];
let mut asked = Vec::new();
for events in frames {
let input = egui::RawInput {
events,
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(WIDTH, 540.0),
)),
..Default::default()
};
let _ = ctx.run(input, |ctx| {
egui::SidePanel::left("places")
.exact_width(crate::shell::BROWSER)
.frame(egui::Frame::new())
.show(ctx, |ui| {
asked.extend(browser.ui(
ui,
&workspace,
&device,
&queue,
&Filter::default(),
));
});
egui::CentralPanel::default()
.frame(egui::Frame::new())
.show(ctx, |ui| {
library.ui(ui, &mut browser, &workspace, &device, &queue, &shell);
});
});
}
let filed: Vec<(u64, Option<u64>)> = asked
.iter()
.filter_map(|act| match act {
Act::File { id, folder } => Some((*id, *folder)),
_ => None,
})
.collect();
assert!(
matches!(filed.as_slice(), [(dragged, Some(_))] if *dragged == id),
"the drag filed the row it started on: {filed:?}"
);
}
#[test]
fn the_table_paints_at_every_width_the_centre_has() {
let ctx = context();
let mut workspace = Workspace::new(ctx.clone());
let mut device = Device::new(ctx.clone());
let mut log = Log::default();
let mut tabs = Tabs::default();
let mut browser = Browser::default();
let mut library = Library::default();
let mut queue = Queue::default();
let shell = Shell::default();
for kind in [Fresh::Program, Fresh::Live, Fresh::Settings] {
workspace.create(kind, &mut log).unwrap();
}
device.pretend_scanned(ObjectClass::Program, 7, &["Africa Split", "", "Squabble B"]);
device.pretend_scanned(ObjectClass::Piano, 1, &["Royal Grand 3D"]);
device.pretend_scanned(ObjectClass::SetList, 1, &["Sunday"]);
let on_a_row = egui::pos2(60.0, BAR + HEAD + ROW / 2.0);
let press = |pressed| egui::Event::PointerButton {
pos: on_a_row,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::NONE,
};
for width in [430.0_f32, 900.0] {
let frames: [Vec<egui::Event>; 4] = [
Vec::new(),
vec![egui::Event::PointerMoved(on_a_row)],
vec![press(true), press(false)],
Vec::new(),
];
for events in frames {
let input = egui::RawInput {
events,
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(width, 540.0),
)),
..Default::default()
};
let _ = ctx.run(input, |ctx| {
egui::CentralPanel::default()
.frame(egui::Frame::new())
.show(ctx, |ui| {
let acts =
library.ui(ui, &mut browser, &workspace, &device, &queue, &shell);
apply(
&mut browser,
&mut Shell::default(),
acts,
&mut workspace,
&mut device,
&mut tabs,
&mut queue,
&mut log,
);
});
});
}
assert!(
browser.picked().sole().is_some(),
"a click on a row picked it at {width}"
);
}
}
}