use eframe::egui;
use nord_format::fields::Field;
use nord_usb::{Location, ObjectClass};
use super::controls::{self, Sets};
use crate::device::{Device, DeviceCmd};
use crate::fields::{byte_diff, DiffRow};
use crate::strings;
use crate::workspace::LocalEntity;
pub struct SlotDetails {
pub class: ObjectClass,
pub at: Location,
}
const NAME_W: f32 = 230.0;
const BITS_W: f32 = 70.0;
const STORED_W: f32 = 220.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>,
dump: String,
}
impl Advanced {
pub fn table(&mut self, ui: &mut egui::Ui, fields: &[Field], sets: &mut Sets) {
ui.horizontal(|ui| {
ui.label("Filter");
ui.add(
egui::TextEdit::singleline(&mut self.filter)
.desired_width(200.0)
.hint_text("path or name"),
);
let shown = fields.iter().filter(|f| self.matches(f)).count();
ui.label(
egui::RichText::new(format!("{shown} of {} fields", fields.len()))
.small()
.weak(),
);
});
ui.separator();
egui::Grid::new("registry_table")
.num_columns(4)
.striped(true)
.show(ui, |ui| {
ui.label(egui::RichText::new("field").small().weak());
ui.label(egui::RichText::new("bits").small().weak());
ui.label(egui::RichText::new("stored").small().weak());
ui.label(egui::RichText::new("value").small().weak());
ui.end_row();
let rows: Vec<&Field> = fields.iter().filter(|f| self.matches(f)).collect();
for field in rows {
self.cell_row(ui, field, sets);
ui.end_row();
}
});
}
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)
}
fn cell_row(&mut self, ui: &mut egui::Ui, field: &Field, sets: &mut Sets) {
ui.vertical(|ui| {
ui.set_min_width(NAME_W);
ui.add(egui::Label::new(strings::label(&field.path)).truncate());
ui.add(
egui::Label::new(egui::RichText::new(&field.path).monospace().small().weak())
.truncate(),
);
});
ui.add_sized(
[BITS_W, ui.spacing().interact_size.y],
egui::Label::new(
egui::RichText::new(field.spec.placement)
.monospace()
.small()
.weak(),
)
.truncate()
.halign(egui::Align::LEFT),
);
ui.add_sized(
[STORED_W, ui.spacing().interact_size.y],
egui::Label::new(egui::RichText::new(&field.display).monospace().small())
.truncate()
.halign(egui::Align::LEFT),
);
let editing = self.cell.path == field.path;
if !editing {
if ui
.add_sized(
[180.0, ui.spacing().interact_size.y],
egui::Label::new(egui::RichText::new(&field.value).monospace())
.truncate()
.halign(egui::Align::LEFT)
.sense(egui::Sense::click()),
)
.on_hover_text("click to edit")
.clicked()
{
self.cell = Cell {
path: field.path.clone(),
text: field.value.clone(),
fresh: true,
error: None,
};
}
return;
}
let response = ui.add(
egui::TextEdit::singleline(&mut self.cell.text)
.desired_width(160.0)
.font(egui::TextStyle::Monospace),
);
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 {
let bad = crate::app::bad(ui.visuals());
ui.label(egui::RichText::new(why).small().color(bad));
}
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
self.cell = Cell::default();
return;
}
let entered = ui.input(|i| i.key_pressed(egui::Key::Enter));
let settled = entered || (response.lost_focus() && self.cell.error.is_none());
if !settled {
return;
}
let typed = self.cell.text.trim().to_string();
if typed == field.value {
self.cell = Cell::default();
return;
}
sets.push((field.path.clone(), typed));
}
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,
opened: &[u8],
device: &Device,
) -> Option<SlotDetails> {
let mut asked = None;
controls::section(ui, "Container", |ui| {
verify(ui, entity);
container(ui, entity);
});
let rows = byte_diff(opened, &entity.bytes);
let title = match rows.len() {
0 => "Changes".to_string(),
n => format!("Changes ({n} bytes)"),
};
controls::section(ui, &title, |ui| diff(ui, entity, opened, 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 dump(&mut self, ui: &mut egui::Ui, entity: &LocalEntity) {
let Some(decoded) = &entity.entity else {
return;
};
egui::CollapsingHeader::new("Show the decode")
.id_salt("raw_debug")
.show(ui, |ui| {
if self.dump_for != Some(entity.id) {
self.dump = format!("{decoded:#?}");
self.dump_for = Some(entity.id);
}
egui::ScrollArea::both()
.max_height(360.0)
.auto_shrink([false, true])
.show(ui, |ui| {
ui.label(egui::RichText::new(&self.dump).monospace().small());
});
});
}
}
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));
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),
},
);
});
}
fn stored_slot(header: &nord_format::cbin::Header) -> String {
match header.slot() {
(0xffff, 0xffff) => "none (a library file, not a slot save)".into(),
(bank, slot) => format!("{}:{}", bank + 1, slot + 1),
}
}
fn diff(ui: &mut egui::Ui, entity: &LocalEntity, opened: &[u8], rows: Vec<DiffRow>) {
if rows.is_empty() {
ui.label(
egui::RichText::new(match opened.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(at) {
return asked;
}
match (&device.state.detail.info, device.state.detail.asked) {
(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(),
},
);
});
}
(None, true) => {
ui.label(egui::RichText::new("the slot is empty").weak());
}
(None, false) => {}
}
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 },
]
}