use eframe::egui;
use nord_format::fields::Field;
use nord_usb::{Location, ObjectClass};
use super::controls::{self, Sets};
use super::field;
use crate::app;
use crate::device::{Device, DeviceCmd};
use crate::fields::{byte_diff, DiffRow};
use crate::icon::{icon, Glyph};
use crate::strings;
use crate::workspace::LocalEntity;
pub struct SlotDetails {
pub class: ObjectClass,
pub at: Location,
}
const COLUMNS: [(&str, f32); 6] = [
("Path", 250.0),
("Bits", 74.0),
("Control", 110.0),
("Raw", 150.0),
("Writes · editable", 140.0),
("", 20.0),
];
const ROW: f32 = 22.0;
const PAD: f32 = 12.0;
const MONO: f32 = 10.5;
const HEAD: f32 = 9.0;
#[derive(Default)]
struct Cell {
path: String,
text: String,
fresh: bool,
error: Option<String>,
}
#[derive(Default)]
pub struct Advanced {
filter: String,
cell: Cell,
dump_for: Option<(u64, u64)>,
dump: String,
diff_for: Option<(u64, u64, u64)>,
diff: Vec<DiffRow>,
}
impl Advanced {
pub fn about(ui: &mut egui::Ui, rows: &[(&'static str, String, String)]) {
let quiet = app::caption(ui.visuals());
controls::heading(
ui,
"About this file",
"what the file says about itself",
None,
);
for (label, value, note) in rows {
ui.horizontal(|ui| {
ui.add_space(PAD);
ui.spacing_mut().item_spacing.x = 10.0;
ui.add_sized(
[110.0, ROW],
egui::Label::new(
egui::RichText::new(*label)
.font(egui::FontId::proportional(11.0))
.color(ui.visuals().weak_text_color()),
)
.halign(egui::Align::LEFT),
);
ui.label(
egui::RichText::new(value)
.font(egui::FontId::monospace(11.0))
.color(ui.visuals().text_color()),
);
if !note.is_empty() {
ui.add(
egui::Label::new(
egui::RichText::new(note)
.font(egui::FontId::proportional(10.0))
.color(quiet),
)
.truncate(),
);
}
});
}
}
pub fn table(&mut self, ui: &mut egui::Ui, table: &Table<'_>, sets: &mut Sets) {
let quiet = app::caption(ui.visuals());
let rows: Vec<&Field> = table
.fields
.iter()
.filter(|field| self.matches(field))
.collect();
let unseen = rows
.iter()
.filter(|field| !table.shows(&field.path))
.count();
controls::heading(
ui,
"Every field",
"registry order · raw is what was read; type in Writes to change it — the value \
is taken as spelled, refused if the field cannot hold it",
Some((
&format!(
"{} of {} rows · {unseen} hidden from Edit",
rows.len(),
table.fields.len()
),
quiet,
)),
);
ui.horizontal(|ui| {
ui.add_space(PAD);
ui.label(egui::RichText::new("Filter").small().color(quiet));
ui.add(
egui::TextEdit::singleline(&mut self.filter)
.desired_width(200.0)
.hint_text("path or name"),
);
});
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_space(PAD);
ui.spacing_mut().item_spacing.x = 10.0;
for (head, width) in COLUMNS {
ui.add_sized(
[width, 14.0],
egui::Label::new(
egui::RichText::new(head.to_uppercase())
.font(egui::FontId::proportional(HEAD))
.color(quiet),
)
.halign(egui::Align::LEFT),
);
}
});
ui.separator();
for field in rows {
self.row(ui, field, table, sets);
}
}
fn row(&mut self, ui: &mut egui::Ui, field: &Field, table: &Table<'_>, sets: &mut Sets) {
let visuals = ui.visuals().clone();
let changed = table.changed.contains(&field.path);
let hidden = !table.shows(&field.path);
let labelled = strings::known(&field.path);
let (rect, response) =
ui.allocate_exact_size(egui::vec2(ui.available_width(), ROW), egui::Sense::hover());
if changed {
ui.painter()
.rect_filled(rect, 0.0, visuals.selection.bg_fill);
}
let mut row = ui.new_child(
egui::UiBuilder::new()
.max_rect(rect)
.layout(egui::Layout::left_to_right(egui::Align::Center)),
);
row.spacing_mut().item_spacing.x = 10.0;
row.add_space(PAD);
let ink = match hidden {
true => app::caption(&visuals),
false => visuals.weak_text_color(),
};
cell(&mut row, &field.path, COLUMNS[0].1, ink);
cell(
&mut row,
field.spec.placement,
COLUMNS[1].1,
app::caption(&visuals),
);
row.add_sized(
[COLUMNS[2].1, ROW],
egui::Label::new(
egui::RichText::new(field::kind_word(field))
.font(egui::FontId::proportional(MONO))
.color(app::caption(&visuals)),
)
.truncate()
.halign(egui::Align::LEFT),
);
cell(&mut row, table.raw(&field.path), COLUMNS[3].1, ink);
self.writes(&mut row, field, sets);
if let Some((glyph, tint)) = flag(changed, hidden, labelled, &visuals) {
icon(&mut row, glyph, 11.0, tint);
}
if !response.hovered() {
return;
}
let accepts = match (field.spec.legal)() {
legal if legal.is_empty() => "its stored bits, as spelled".to_string(),
legal if legal.len() > 12 => format!("{} .. {}", legal[0], legal[legal.len() - 1]),
legal => legal.join(", "),
};
response.on_hover_text(format!(
"{} · accepts {accepts}",
match (hidden, labelled) {
(true, _) => "not relevant: the instrument is not using this for the state the \
file holds — stored, valid, writable"
.to_string(),
(false, true) => strings::label(&field.path),
(false, false) => "no label in this app's table yet".to_string(),
}
));
}
fn writes(&mut self, ui: &mut egui::Ui, field: &Field, sets: &mut Sets) {
let width = COLUMNS[4].1;
if self.cell.path != field.path {
let drawn = ui.add_sized(
[width, ROW - 4.0],
egui::Button::new(
egui::RichText::new(&field.value)
.font(egui::FontId::monospace(MONO))
.color(ui.visuals().text_color()),
)
.fill(egui::Color32::TRANSPARENT)
.stroke(egui::Stroke::new(
1.0_f32,
ui.visuals().widgets.noninteractive.bg_stroke.color,
)),
);
if drawn.on_hover_text("click to type a value").clicked() {
self.cell = Cell {
path: field.path.clone(),
text: field.value.clone(),
fresh: true,
error: None,
};
}
return;
}
let response = ui.add_sized(
[width, ROW - 4.0],
egui::TextEdit::singleline(&mut self.cell.text).font(egui::FontId::monospace(MONO)),
);
if self.cell.fresh {
self.cell.fresh = false;
response.request_focus();
let all = egui::text::CCursorRange::two(
egui::text::CCursor::new(0),
egui::text::CCursor::new(self.cell.text.chars().count()),
);
if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), response.id) {
state.cursor.set_char_range(Some(all));
state.store(ui.ctx(), response.id);
}
}
if let Some(why) = &self.cell.error {
ui.label(
egui::RichText::new(why)
.small()
.color(crate::app::bad(ui.visuals())),
);
}
if !response.lost_focus() {
return;
}
let (escaped, entered) = ui.input(|i| {
(
i.key_pressed(egui::Key::Escape),
i.key_pressed(egui::Key::Enter),
)
});
if escaped {
self.cell = Cell::default();
return;
}
if self.cell.error.is_some() && !entered {
return;
}
let typed = self.cell.text.trim().to_string();
if typed == field.value {
self.cell = Cell::default();
return;
}
sets.push((field.path.clone(), typed));
}
fn matches(&self, field: &Field) -> bool {
let wanted = self.filter.trim().to_ascii_lowercase();
if wanted.is_empty() {
return true;
}
field.path.to_ascii_lowercase().contains(&wanted)
|| strings::label(&field.path)
.to_ascii_lowercase()
.contains(&wanted)
}
pub(super) fn leave(&mut self) {
self.cell = Cell::default();
}
#[cfg(test)]
pub(super) fn editing(&self) -> Option<&str> {
(!self.cell.path.is_empty()).then_some(self.cell.path.as_str())
}
#[cfg(test)]
pub(super) fn pretend_editing(&mut self, path: &str, typed: &str) {
self.cell = Cell {
path: path.to_string(),
text: typed.to_string(),
fresh: true,
error: None,
};
}
pub fn settled(&mut self, outcome: Result<(), String>) {
match outcome {
Ok(()) => self.cell = Cell::default(),
Err(why) => {
self.cell.error = Some(why);
self.cell.fresh = true;
}
}
}
pub fn meta(
&mut self,
ui: &mut egui::Ui,
entity: &LocalEntity,
device: &Device,
) -> Option<SlotDetails> {
let mut asked = None;
controls::section(ui, "Container", |ui| {
verify(ui, entity);
container(ui, entity);
});
let rows = self.changes(entity);
let title = match rows.len() {
0 => "Changes".to_string(),
n => format!("Changes ({n} bytes)"),
};
controls::section(ui, &title, |ui| diff(ui, entity, rows));
if entity.origin.slot().is_some() {
controls::section(ui, "On the instrument", |ui| {
asked = slot(ui, entity, device);
});
}
if entity.entity.is_some() {
controls::section(ui, "Raw", |ui| self.dump(ui, entity));
}
asked
}
fn changes(&mut self, entity: &LocalEntity) -> &[DiffRow] {
let against = (entity.id, entity.stamp, entity.saved.stamp);
if self.diff_for != Some(against) {
self.diff = byte_diff(&entity.saved.bytes, &entity.bytes);
self.diff_for = Some(against);
}
&self.diff
}
fn dump(&mut self, ui: &mut egui::Ui, entity: &LocalEntity) {
if entity.entity.is_none() {
return;
}
egui::CollapsingHeader::new("Show the decode")
.id_salt("raw_debug")
.show(ui, |ui| {
let dump = self.decoded(entity);
egui::ScrollArea::both()
.max_height(360.0)
.auto_shrink([false, true])
.show(ui, |ui| {
ui.label(egui::RichText::new(dump).monospace().small());
});
});
}
fn decoded(&mut self, entity: &LocalEntity) -> &str {
let laid = (entity.id, entity.stamp);
if self.dump_for != Some(laid) {
self.dump = match &entity.entity {
Some(decoded) => format!("{decoded:#?}"),
None => String::new(),
};
self.dump_for = Some(laid);
}
&self.dump
}
}
pub struct Table<'a> {
pub fields: &'a [Field],
pub saved: &'a [Field],
pub changed: &'a [String],
pub doc: Option<&'a field::Doc<'a>>,
}
impl Table<'_> {
fn raw(&self, path: &str) -> &str {
self.saved
.iter()
.chain(self.fields)
.find(|field| field.path == path)
.map_or("", |field| field.value.as_str())
}
fn shows(&self, path: &str) -> bool {
self.doc.is_none_or(|doc| doc.shows(path))
}
}
fn cell(ui: &mut egui::Ui, text: &str, width: f32, ink: egui::Color32) {
ui.add_sized(
[width, ROW],
egui::Label::new(
egui::RichText::new(text)
.font(egui::FontId::monospace(MONO))
.color(ink),
)
.truncate()
.halign(egui::Align::LEFT),
);
}
fn flag(
changed: bool,
hidden: bool,
labelled: bool,
visuals: &egui::Visuals,
) -> Option<(Glyph, egui::Color32)> {
match (changed, hidden, labelled) {
(true, _, _) => Some((Glyph::Pencil, app::warn(visuals))),
(false, true, _) => Some((Glyph::EyeOff, app::caption(visuals))),
(false, false, false) => Some((Glyph::Tag, app::caption(visuals))),
(false, false, true) => None,
}
}
fn verify(ui: &mut egui::Ui, entity: &LocalEntity) {
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new("verify").weak());
ui.label(
egui::RichText::new(entity.verify.badge())
.strong()
.color(entity.verify.color(ui.visuals())),
);
ui.label(egui::RichText::new(entity.verify.detail()).weak());
});
if let Some(e) = &entity.parse_error {
ui.label(egui::RichText::new(e).color(crate::app::bad(ui.visuals())));
}
}
fn row(ui: &mut egui::Ui, label: &str, value: impl Into<String>) {
ui.label(egui::RichText::new(label).weak());
ui.label(egui::RichText::new(value.into()).monospace());
ui.end_row();
}
fn container(ui: &mut egui::Ui, entity: &LocalEntity) {
let Some(container) = &entity.container else {
ui.label(
egui::RichText::new("these bytes carry no CBIN header, so there is nothing to read")
.weak()
.small(),
);
return;
};
egui::Grid::new("cbin_grid").num_columns(2).show(ui, |ui| {
row(
ui,
"generation",
format!("{:?}", container.header.generation),
);
row(ui, "format", container.tag());
row(ui, "version", container.header.version.to_string());
row(ui, "slot", stored_slot(container.header.slot()));
row(ui, "body", format!("{} bytes", container.body_len()));
row(ui, "file", format!("{} bytes", entity.bytes.len()));
row(
ui,
container.checksum_label.trim_end_matches(':'),
match container.checksum_ok {
true => container.checksum.clone(),
false => format!("{} (does not match the bytes)", container.checksum),
},
);
});
}
const NO_SLOT: u16 = 0xffff;
fn stored_slot(slot: (u16, u16)) -> String {
match slot {
(NO_SLOT, NO_SLOT) => "none (a library file, not a slot save)".into(),
(bank, slot) => format!("{}:{}", counted(bank), counted(slot)),
}
}
fn counted(half: u16) -> String {
match half {
NO_SLOT => "none".to_string(),
half => (u32::from(half) + 1).to_string(),
}
}
fn diff(ui: &mut egui::Ui, entity: &LocalEntity, rows: &[DiffRow]) {
if rows.is_empty() {
ui.label(
egui::RichText::new(match entity.saved.bytes.len() == entity.bytes.len() {
true => "nothing moved",
false => "the length changed, so there is nothing to line up",
})
.weak()
.small(),
);
return;
}
egui::ScrollArea::vertical()
.id_salt("bytediff")
.max_height(220.0)
.auto_shrink([false, true])
.show(ui, |ui| {
for row in rows {
ui.label(
egui::RichText::new(format!(
"byte {:#06x} {:#04x} -> {:#04x}{}",
row.at, row.before, row.after, row.note,
))
.monospace()
.small()
.weak(),
);
}
});
}
fn slot(ui: &mut egui::Ui, entity: &LocalEntity, device: &Device) -> Option<SlotDetails> {
let (class, at) = entity.origin.slot()?;
let mut asked = None;
ui.label(
egui::RichText::new(strings::place(class, at))
.monospace()
.small(),
);
let busy = device.state.in_flight.is_some();
if ui
.add_enabled(
device.state.connected() && !busy,
egui::Button::new("Read slot details"),
)
.on_disabled_hover_text("needs the instrument attached and idle")
.clicked()
{
asked = Some(SlotDetails { class, at });
}
if device.state.detail.at != Some((class, at)) {
return asked;
}
match &device.state.detail.info {
Some(Some(info)) => {
egui::Grid::new("slot_detail")
.num_columns(2)
.show(ui, |ui| {
row(ui, "name", format!("{:?}", info.name));
row(ui, "format", info.format.clone());
row(ui, "version", info.version.to_string());
row(ui, "body", format!("{} bytes", info.body_len));
row(
ui,
"crc32",
match info.crc32 {
Some(crc) => format!("{crc:#010x}"),
None => "none (not checksummed for this class)".into(),
},
);
});
}
Some(None) => {
ui.label(egui::RichText::new("the slot is empty").weak());
}
None => {}
}
if let Some(deps) = &device.state.detail.deps {
ui.separator();
if deps.is_empty() {
ui.label(egui::RichText::new("no dependencies").weak());
}
egui::Grid::new("deps").num_columns(3).show(ui, |ui| {
for dep in deps {
ui.label(egui::RichText::new(dep.class.label()).small().weak());
ui.label(egui::RichText::new(format!("{:08x}", dep.id)).monospace());
ui.label(dep.name.trim());
ui.end_row();
}
});
}
asked
}
pub fn commands(details: SlotDetails) -> [DeviceCmd; 2] {
let SlotDetails { class, at } = details;
[
DeviceCmd::SlotInfo { class, at },
DeviceCmd::Deps { class, at },
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::workspace::{Fresh, Workspace};
#[test]
fn the_raw_decode_follows_an_edit_to_the_bytes() {
let ctx = eframe::egui::Context::default();
let mut workspace = Workspace::new(ctx);
let mut log = crate::log::Log::default();
let id = workspace.create(Fresh::Program, &mut log).expect("a fresh");
let mut advanced = Advanced::default();
let before = advanced
.decoded(workspace.get(id).expect("it is open"))
.to_string();
assert!(
before.contains("organ_type"),
"it is the decode: {before:.200}"
);
let bytes = workspace.get(id).expect("it is open").bytes.clone();
let (_, edited) = crate::fields::apply(
&bytes,
&[("center_panel.organ_type".to_string(), "Vox".to_string())],
)
.expect("the set is legal");
workspace.replace_bytes(id, edited, &mut log);
let after = advanced.decoded(workspace.get(id).expect("it is open"));
assert_ne!(
before, after,
"the dump is of the bytes in front of the reader"
);
}
#[test]
fn the_changes_rows_follow_the_bytes_and_the_baseline() {
let ctx = eframe::egui::Context::default();
let mut workspace = Workspace::new(ctx);
let mut log = crate::log::Log::default();
let id = workspace.create(Fresh::Program, &mut log).expect("a fresh");
let mut advanced = Advanced::default();
assert!(
advanced
.changes(workspace.get(id).expect("it is open"))
.is_empty(),
"nothing has moved yet"
);
let bytes = workspace.get(id).expect("it is open").bytes.clone();
let (_, edited) = crate::fields::apply(
&bytes,
&[("center_panel.gain".to_string(), "96".to_string())],
)
.expect("the set is legal");
workspace.replace_bytes(id, edited, &mut log);
assert!(
!advanced
.changes(workspace.get(id).expect("it is open"))
.is_empty(),
"the edit is in the section"
);
workspace.mark_saved(id);
assert!(
advanced
.changes(workspace.get(id).expect("it is open"))
.is_empty(),
"the baseline moved onto the bytes"
);
}
#[test]
fn a_stored_slot_counts_from_one_and_names_a_half_that_holds_no_position() {
assert_eq!(stored_slot((0, 0)), "1:1");
assert_eq!(stored_slot((6, 3)), "7:4");
assert_eq!(
stored_slot((NO_SLOT, NO_SLOT)),
"none (a library file, not a slot save)"
);
assert_eq!(stored_slot((NO_SLOT, 5)), "none:6");
assert_eq!(stored_slot((5, NO_SLOT)), "6:none");
assert_eq!(stored_slot((0xfffe, 0xfffe)), "65535:65535");
}
}