use crate::automation::hit_keys::HitKeyDoc;
use crate::panels::instructions::PartsDrag;
use brep_instructions::model::{CalloutKind, Instructions, Step};
use brep_instructions::player::Mode;
use brep_render::engine_state::{EngineState, PlayTo};
use std::collections::{BTreeMap, BTreeSet, HashMap};
const TOUCH_HEIGHT: f32 = 44.0;
#[derive(Default)]
pub struct InstructionsPlayerPanel {
hits: HashMap<String, egui::Rect>,
client: Option<std::rc::Rc<crate::plm::client::PlmClient>>,
pictures: HashMap<String, egui::TextureHandle>,
mode: Mode,
request: Option<PlayTo>,
checked: BTreeMap<String, BTreeSet<usize>>,
serials: BTreeMap<String, String>,
signed: BTreeSet<String>,
error: Option<String>,
notice: Option<String>,
drop: Option<(String, Vec<String>)>,
}
impl InstructionsPlayerPanel {
pub fn new() -> Self {
Self::default()
}
pub fn request(&mut self, to: PlayTo) {
self.request = Some(to);
}
pub fn mode(&self) -> Mode {
self.mode
}
pub fn set_mode(&mut self, mode: Mode) {
self.mode = mode;
}
pub fn hits_json(&self) -> String {
crate::automation::hit_rects::hits_json(&self.hits)
}
pub fn clear_hits(&mut self) {
self.hits.clear();
}
pub fn state_json(&self, engine: &EngineState) -> String {
serde_json::json!({
"mode": self.mode.as_str(),
"step": engine.instructions_shown_step(),
"insertion": engine.instructions_insertion_fraction(),
"pending": self.request.is_some(),
"checked": self.checked,
"serials": self.serials,
"signed": self.signed,
"error": self.error,
"notice": self.notice,
})
.to_string()
}
pub fn show(&mut self, ui: &mut egui::Ui, engine: &mut EngineState, client: Option<&std::rc::Rc<crate::plm::client::PlmClient>>) {
self.client = client.cloned();
self.hits.clear();
self.hits.insert("panel:clip".into(), ui.clip_rect());
if !engine.is_instructions() {
ui.label("Open instructions to play them: New from assembly on the toolbar makes some.");
return;
}
let mut go = self.request.take();
let block = match engine.instructions() {
Ok(Some(block)) => block,
Ok(None) => return,
Err(error) => {
ui.colored_label(ui.visuals().error_fg_color, format!("The instructions cannot be read: {error}"));
return;
}
};
let order: Vec<&Step> = block.steps().map(|(_, step)| step).collect();
let shown = engine.instructions_shown_step();
let at = shown.as_deref().and_then(|id| order.iter().position(|step| step.id == id));
ui.horizontal(|ui| {
for mode in [Mode::Shop, Mode::Customer] {
let label = if mode == Mode::Shop { "Shop" } else { "Customer" };
let toggle = ui.selectable_label(self.mode == mode, label);
self.hits.insert(format!("player:mode:{}", mode.as_str()), toggle.rect);
if toggle.clicked() {
self.mode = mode;
}
}
});
if let Some(error) = &self.error {
let label = ui.colored_label(ui.visuals().error_fg_color, error);
self.hits.insert("player:error".into(), label.rect);
}
if order.is_empty() {
ui.weak("No steps yet: press Wizard on the toolbar.");
return;
}
let height = if self.mode == Mode::Shop { TOUCH_HEIGHT } else { 0.0 };
ui.horizontal_wrapped(|ui| {
let back = ui.add_enabled(at.is_some_and(|i| i > 0), egui::Button::new("◀ Back").min_size(egui::vec2(0.0, height)));
self.hits.insert("player:back".into(), back.rect);
if back.clicked() {
go = Some(PlayTo::Back);
}
let next_label = if at.is_none() { "Start ▶" } else { "Next ▶" };
let next = ui.add_enabled(at.is_none_or(|i| i + 1 < order.len()), egui::Button::new(next_label).min_size(egui::vec2(0.0, height)));
self.hits.insert("player:next".into(), next.rect);
if next.clicked() {
go = Some(if at.is_none() { PlayTo::First } else { PlayTo::Next });
}
let replay = ui.add_enabled(at.is_some(), egui::Button::new("Replay").min_size(egui::vec2(0.0, height))).on_hover_text("Show this step's parts coming in again");
self.hits.insert("player:replay".into(), replay.rect);
if replay.clicked() {
go = Some(PlayTo::Replay);
}
if let Some(i) = at {
let count = ui.weak(format!("Step {} of {}", i + 1, order.len()));
self.hits.insert("player:count".into(), count.rect);
}
});
ui.separator();
match at {
Some(i) => self.step(ui, &block, order[i], i + 1),
None => {
ui.weak("Press Start to play the steps.");
}
}
if let Some((step, occurrences)) = self.drop.take() {
match engine.instructions_add_parts(&step, &occurrences) {
Ok(report) => {
let _ = engine.instructions_show_step(&step);
self.error = None;
self.notice = Some(match report.added {
0 => "No part joined the step".to_string(),
1 => "1 part joined the step".to_string(),
n => format!("{n} parts joined the step"),
});
}
Err(error) => self.error = Some(error),
}
}
if let Some(to) = go {
self.error = engine.instructions_play(to).err();
}
}
fn step(&mut self, ui: &mut egui::Ui, block: &Instructions, step: &Step, number: usize) {
if let Some(parent) = block.parent_id(&step.id).flatten().and_then(|parent| block.step(parent)) {
ui.weak(&parent.title);
}
let title = ui.heading(format!("{number}. {}", step.title));
self.hits.insert("player:title".into(), title.rect);
if title.dnd_hover_payload::<PartsDrag>().is_some() {
ui.painter().rect_stroke(title.rect, 2.0, ui.visuals().selection.stroke, egui::StrokeKind::Outside);
}
if egui::DragAndDrop::has_payload_of_type::<PartsDrag>(ui.ctx()) {
if let Some(parts) = title.dnd_release_payload::<PartsDrag>() {
self.drop = Some((step.id.clone(), parts.occurrences.clone()));
}
}
if let Some(notice) = &self.notice {
let label = ui.weak(notice);
self.hits.insert("player:notice".into(), label.rect);
}
match self.mode {
Mode::Shop => self.shop(ui, step),
Mode::Customer => self.customer(ui, step),
}
}
fn shop(&mut self, ui: &mut egui::Ui, step: &Step) {
ui.label(egui::RichText::new("Parts").strong());
let ticked = self.checked.entry(step.id.clone()).or_default();
for (index, add) in step.adds.iter().enumerate() {
let mut on = ticked.contains(&index);
let tick = ui.checkbox(&mut on, egui::RichText::new(part_text(add)).size(16.0));
self.hits.insert(format!("player:part:{index}"), tick.rect);
if tick.changed() {
if on {
ticked.insert(index);
} else {
ticked.remove(&index);
}
}
if add.serial_tracked {
for (unit, occurrence) in add.occurrences.iter().enumerate() {
ui.horizontal(|ui| {
ui.label(format!("Serial {}", unit + 1));
let serial = self.serials.entry(occurrence.clone()).or_default();
let field = ui.add(egui::TextEdit::singleline(serial).min_size(egui::vec2(160.0, 0.0)));
self.hits.insert(format!("player:serial:{index}:{unit}"), field.rect);
});
}
}
}
if !step.tools.is_empty() {
let tools: Vec<String> =
step.tools.iter().map(|tool| if tool.spec.is_empty() { tool.name.clone() } else { format!("{} ({})", tool.name, tool.spec) }).collect();
let label = ui.label(format!("Tools: {}", tools.join(", ")));
self.hits.insert("player:tools".into(), label.rect);
let ids: Vec<String> = step.tools.iter().filter_map(|tool| tool.tool_id.clone()).collect();
if let Some(client) = &self.client {
crate::plm::tool_pictures::request(client, &ids);
}
ui.horizontal_wrapped(|ui| {
for id in &ids {
let texture = match self.pictures.get(id) {
Some(texture) => texture.clone(),
None => {
let Some(image) = crate::plm::tool_pictures::png_of(id).and_then(|bytes| crate::plm::thumbnail_view::decode(&bytes)) else { continue };
let texture = ui.ctx().load_texture(format!("tool-picture-{id}"), image, egui::TextureOptions::LINEAR);
self.pictures.insert(id.clone(), texture.clone());
texture
}
};
let image = ui.add(egui::Image::new(&texture).fit_to_exact_size(egui::vec2(64.0, 64.0)));
self.hits.insert(format!("player:tool:{id}:picture"), image.rect);
}
});
}
if let Some(torque) = &step.torque {
let label = ui.label(egui::RichText::new(format!("Torque: {}", torque.text())).strong());
self.hits.insert("player:torque".into(), label.rect);
}
self.callouts(ui, step);
if !step.notes.is_empty() {
let notes = ui.label(&step.notes);
self.hits.insert("player:notes".into(), notes.rect);
}
if step.signoff.required {
ui.add_space(6.0);
if self.signed.contains(&step.id) {
let label = ui.label(egui::RichText::new("✔ Signed off").strong());
self.hits.insert("player:signed".into(), label.rect);
} else {
let ticked = self.checked.get(&step.id).map_or(0, BTreeSet::len);
let serials_in = step
.adds
.iter()
.filter(|add| add.serial_tracked)
.flat_map(|add| &add.occurrences)
.all(|occurrence| self.serials.get(occurrence).is_some_and(|serial| !serial.trim().is_empty()));
let ready = ticked == step.adds.len() && serials_in;
let sign = ui
.add_enabled(ready, egui::Button::new("Sign off step").min_size(egui::vec2(0.0, TOUCH_HEIGHT)))
.on_disabled_hover_text("Tick every part (and enter its serials) first");
self.hits.insert("player:signoff".into(), sign.rect);
if sign.clicked() {
self.signed.insert(step.id.clone());
}
}
}
}
fn customer(&mut self, ui: &mut egui::Ui, step: &Step) {
for (index, add) in step.adds.iter().enumerate() {
let label = ui.label(egui::RichText::new(part_text(add)).size(18.0));
self.hits.insert(format!("player:part:{index}"), label.rect);
}
self.callouts(ui, step);
}
fn callouts(&mut self, ui: &mut egui::Ui, step: &Step) {
for (index, callout) in step.callouts.iter().enumerate() {
let (kind, colour) = match callout.kind {
CalloutKind::Caution => ("⚠ Caution", ui.visuals().warn_fg_color),
CalloutKind::Note => ("ℹ Note", ui.visuals().text_color()),
CalloutKind::Check => ("✔ Check", ui.visuals().text_color()),
};
let label = ui.colored_label(colour, format!("{kind}: {}", callout.text));
self.hits.insert(format!("player:callout:{index}"), label.rect);
}
}
}
fn part_text(add: &brep_instructions::model::Add) -> String {
let mut text = String::new();
if !add.find_number.is_empty() {
text.push_str(&format!("[{}] ", add.find_number));
}
text.push_str(add.display_name());
if add.quantity() > 1 {
text.push_str(&format!(" × {}", add.quantity()));
}
text
}
pub static HIT_KEYS: &[HitKeyDoc] = &[
HitKeyDoc { panel: "instructionsplayer", prefix: "panel:clip", meaning: "the visible region of the pane (scroll_into_view scrolls a player:* widget inside it)", command: None },
HitKeyDoc { panel: "instructionsplayer", prefix: "player:mode:", meaning: "the mode toggle, by mode (shop, customer)", command: Some("instructions_play") },
HitKeyDoc { panel: "instructionsplayer", prefix: "player:error", meaning: "why the last move was refused", command: None },
HitKeyDoc { panel: "instructionsplayer", prefix: "player:back", meaning: "show the previous step", command: Some("instructions_play") },
HitKeyDoc { panel: "instructionsplayer", prefix: "player:next", meaning: "show the next step (Start: the first)", command: Some("instructions_play") },
HitKeyDoc { panel: "instructionsplayer", prefix: "player:replay", meaning: "show the step's parts coming in again", command: Some("instructions_play") },
HitKeyDoc { panel: "instructionsplayer", prefix: "player:count", meaning: "where the shown step is: Step N of M", command: None },
HitKeyDoc { panel: "instructionsplayer", prefix: "player:title", meaning: "the shown step's number and title; a BOM row or a not-yet-planned part dropped on it joins the shown step (instructions_add_parts), the outcome said under it (player:notice)", command: Some("instructions_add_parts") },
HitKeyDoc { panel: "instructionsplayer", prefix: "player:notice", meaning: "what the last drop on the title said", command: None },
HitKeyDoc { panel: "instructionsplayer", prefix: "player:part:", meaning: "a part the step adds, by index: a checklist tick in shop mode, a label in customer mode", command: None },
HitKeyDoc { panel: "instructionsplayer", prefix: "player:serial:", meaning: "the serial field of one unit of a serial-tracked part, by part and unit index (shop mode)", command: None },
HitKeyDoc { panel: "instructionsplayer", prefix: "player:tools", meaning: "the step's tools (shop mode)", command: None },
HitKeyDoc { panel: "instructionsplayer", prefix: "player:tool:", meaning: "a linked tool's picture from the PLM tools library, `<tool id>:picture` (shown once fetched; PNG pictures only in the pane)", command: None },
HitKeyDoc { panel: "instructionsplayer", prefix: "player:torque", meaning: "the step's torque (shop mode)", command: None },
HitKeyDoc { panel: "instructionsplayer", prefix: "player:callout:", meaning: "a callout of the step, by index", command: None },
HitKeyDoc { panel: "instructionsplayer", prefix: "player:notes", meaning: "the step's notes (shop mode)", command: None },
HitKeyDoc { panel: "instructionsplayer", prefix: "player:signoff", meaning: "sign the step off, once every part is ticked (shop mode)", command: None },
HitKeyDoc { panel: "instructionsplayer", prefix: "player:signed", meaning: "the step is signed off (shop mode)", command: None },
];