1#[path = "bug_report_markup.rs"]
30mod markup;
31
32use crate::automation::hit_keys::HitKeyDoc;
33use crate::diagnostics::Diagnostics;
34use std::sync::mpsc::Receiver;
35
36use brep_render::engine_state::EngineState;
37use crate::icon_text::IconTextUi as _;
38use eframe::egui;
39
40const REPORT_URL: &str = "https://next.brep.io/api/report";
44
45const FEATURE: &str = "Bug report";
47
48const MAX_DESC: usize = 20_000;
55
56const CAPTURE_TIMEOUT_FRAMES: u32 = 60;
61
62const DIALOG_WIDTH_FRACTION: f32 = 0.90;
64const DIALOG_HEIGHT_FRACTION: f32 = 0.95;
65
66const DESCRIPTION_ROWS: usize = 12;
69
70const DESCRIPTION_MARGIN: egui::Margin = egui::Margin::symmetric(4, 2);
73
74const FOOTER_GAP: f32 = 10.0;
76const STATUS_GAP: f32 = 6.0;
77
78const MENU_REACHES_CLIPBOARD: bool = cfg!(not(target_arch = "wasm32"));
84
85#[derive(Default, PartialEq)]
87enum Phase {
88 #[default]
90 Idle,
91 Capturing { frames: u32 },
93 Editing,
95 Marking,
97 Sending,
99}
100
101pub struct BugReportPanel {
102 phase: Phase,
103 description: String,
105 email: String,
107 status: String,
109 screenshot_png: Option<Vec<u8>>,
111 thumb: Option<egui::TextureHandle>,
113 markup: Option<markup::Markup>,
115 model_json: String,
117 diagnostics: String,
122 response_rx: Option<Receiver<Result<Option<String>, String>>>,
125 submitted: Option<(String, String)>,
128 hits: std::collections::HashMap<String, egui::Rect>,
130 description_scroll: DescriptionScroll,
134}
135
136#[derive(Clone, Copy, Default, Debug, PartialEq)]
138struct DescriptionScroll {
139 content: f32,
141 viewport: f32,
143 offset: f32,
145}
146
147impl BugReportPanel {
148 pub fn new() -> Self {
149 Self {
150 phase: Phase::Idle,
151 description: String::new(),
152 email: String::new(),
153 status: String::new(),
154 screenshot_png: None,
155 thumb: None,
156 markup: None,
157 model_json: String::new(),
158 diagnostics: String::new(),
159 response_rx: None,
160 submitted: None,
161 hits: std::collections::HashMap::new(),
162 description_scroll: DescriptionScroll::default(),
163 }
164 }
165
166 pub fn request(&mut self, ctx: &egui::Context, state: &EngineState, diagnostics: &Diagnostics) {
174 if self.phase != Phase::Idle {
175 return;
176 }
177 self.description.clear();
178 self.email.clear();
179 self.status.clear();
180 self.screenshot_png = None;
181 self.thumb = None;
182 self.markup = None;
183 self.response_rx = None;
184 self.model_json = state.history_request_json();
187 self.diagnostics = diagnostics.report_text();
188 ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
189 self.phase = Phase::Capturing { frames: 0 };
190 ctx.request_repaint();
191 }
192
193 pub fn request_for_error(
196 &mut self,
197 ctx: &egui::Context,
198 state: &EngineState,
199 diagnostics: &Diagnostics,
200 dialog: &str,
201 error: &str,
202 ) {
203 if self.phase != Phase::Idle {
204 return;
205 }
206 self.request(ctx, state, diagnostics);
207 self.description = error_description(dialog, error);
208 }
209
210 pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState) {
213 self.hits.clear();
214
215 self.poll_capture(ctx);
216 self.drain_response(state);
217
218 if !matches!(self.phase, Phase::Editing | Phase::Marking | Phase::Sending) {
219 return;
220 }
221
222 let sending = self.phase == Phase::Sending;
223 let mut submit = false;
224 let mut cancel = false;
225 let marking = self.phase == Phase::Marking;
226 let mut finish = false;
227 let window = ctx.content_rect();
228 let frame = egui::Frame::popup(&ctx.global_style());
229 let content_size = egui::vec2(
230 window.width() * DIALOG_WIDTH_FRACTION,
231 window.height() * DIALOG_HEIGHT_FRACTION,
232 ) - frame.total_margin().sum();
233 let modal = egui::Modal::new(egui::Id::new("brep-bug-report"))
234 .frame(frame)
235 .show(ctx, |ui| {
236 ui.set_width(content_size.x);
237 let content_rect = egui::Rect::from_min_size(ui.min_rect().min, content_size);
238 if marking {
239 let finish_btn = ui.button("Finish");
240 self.hit("markup:finish", &finish_btn);
241 finish = finish_btn.clicked();
242 ui.separator();
243 let height =
244 (content_size.y - ui.min_rect().height() - ui.spacing().item_spacing.y)
245 .max(0.0);
246 let budget = egui::Rect::from_min_size(
247 ui.cursor().min,
248 egui::vec2(content_size.x, height),
249 );
250 ui.scope_builder(egui::UiBuilder::new().max_rect(budget), |ui| {
251 if let (Some(markup), Some(tex)) = (&mut self.markup, &self.thumb) {
252 if let Some(image) = markup.show(ui, tex, &mut self.hits) {
253 self.screenshot_png = encode_png(&image);
254 self.thumb
255 .as_mut()
256 .unwrap()
257 .set(image, egui::TextureOptions::LINEAR);
258 }
259 }
260 });
261 ui.expand_to_include_rect(content_rect);
262 return;
263 }
264 ui.icon_label(egui::RichText::new("\u{1F41E} Submit a bug report").heading());
268 ui.add_space(4.0);
269 ui.label(
270 "Describe what went wrong. Your current model and a screenshot of \
271 the app (UI + 3D view) are attached automatically.",
272 );
273 ui.add_space(2.0);
274 ui.weak("Your report and its screenshot may be shown publicly on the bug list.");
275 ui.add_space(8.0);
276
277 let chrome = ui.min_rect().height();
280 let body_height = (content_size.y - chrome - self.footer_height(ui)).max(0.0);
281 let budget = egui::Rect::from_min_size(
282 ui.cursor().min,
283 egui::vec2(ui.available_width(), body_height),
284 );
285 ui.scope_builder(egui::UiBuilder::new().max_rect(budget), |ui| {
286 egui::ScrollArea::vertical()
287 .id_salt("brep-bug-body")
288 .min_scrolled_height(body_height)
289 .max_height(body_height)
290 .auto_shrink([false, false])
291 .show(ui, |ui| self.body(ui));
292 });
293
294 ui.add_space(FOOTER_GAP);
295 ui.horizontal(|ui| {
296 let can_submit = !self.description.trim().is_empty() && !sending;
297 let label = if sending {
298 "Sending\u{2026}"
299 } else {
300 "Submit report"
301 };
302 let submit_btn = ui.add_enabled(can_submit, egui::Button::new(label));
303 self.hit("submit", &submit_btn);
304 if submit_btn.clicked() {
305 submit = true;
306 }
307 let cancel_btn = ui.add_enabled(!sending, egui::Button::new("Cancel"));
308 self.hit("cancel", &cancel_btn);
309 if cancel_btn.clicked() {
310 cancel = true;
311 }
312 });
313 ui.add_space(STATUS_GAP);
316 ui.weak(if self.status.is_empty() {
317 " "
318 } else {
319 self.status.as_str()
320 });
321 ui.expand_to_include_rect(content_rect);
322 });
323
324 if marking {
325 if finish || modal.should_close() {
326 self.phase = Phase::Editing;
327 }
328 } else if submit {
329 self.send(ctx);
330 } else if cancel || (modal.should_close() && !sending) {
331 self.reset();
332 }
333 }
334
335 fn footer_height(&self, ui: &egui::Ui) -> f32 {
340 let spacing = ui.spacing();
341 let buttons = spacing
342 .interact_size
343 .y
344 .max(ui.text_style_height(&egui::TextStyle::Button) + 2.0 * spacing.button_padding.y);
345 let status = STATUS_GAP
346 + ui.text_style_height(&egui::TextStyle::Body)
347 + 2.0 * spacing.item_spacing.y;
348 FOOTER_GAP + buttons + 2.0 * spacing.item_spacing.y + status
349 }
350
351 fn body(&mut self, ui: &mut egui::Ui) {
354 ui.label("What happened?");
355 self.description_field(ui);
356
357 ui.add_space(6.0);
358 ui.label("Email (optional)");
359 let email = ui.add(
360 egui::TextEdit::singleline(&mut self.email)
361 .desired_width(f32::INFINITY)
362 .hint_text("so we can follow up — optional"),
363 );
364 self.hit("field:email", &email);
365
366 ui.add_space(8.0);
367 if let Some(tex) = &self.thumb {
368 ui.label("Attached screenshot:");
369 ui.add_space(2.0);
370 if self.markup.is_some() {
371 let button = ui.add_enabled(
372 self.phase != Phase::Sending,
373 egui::Button::new("Mark up screenshot"),
374 );
375 self.hits
376 .insert("markup:toggle".into(), button.interact_rect);
377 if button.clicked() {
378 self.phase = Phase::Marking;
379 }
380 }
381 let size = tex.size_vec2();
382 let scale = (ui.available_width() / size.x).min(240.0 / size.y).min(1.0);
383 ui.add(egui::Image::new((tex.id(), size * scale)));
384 } else {
385 ui.weak("(screenshot unavailable — the model + description will still be sent)");
386 }
387
388 ui.add_space(8.0);
393 let diag = egui::CollapsingHeader::new("Diagnostics attached to this report")
394 .id_salt("brep-bug-diagnostics")
395 .show(ui, |ui| {
396 ui.add(
397 egui::Label::new(egui::RichText::new(&self.diagnostics).monospace().small())
398 .wrap(),
399 );
400 });
401 self.hits.insert(
402 "diagnostics".to_string(),
403 diag.header_response.interact_rect,
404 );
405 }
406
407 fn description_field(&mut self, ui: &mut egui::Ui) {
415 let cap = DESCRIPTION_ROWS as f32 * ui.text_style_height(&egui::TextStyle::Body)
416 + DESCRIPTION_MARGIN.sum().y;
417 let scroll = egui::ScrollArea::vertical()
418 .id_salt("brep-bug-description")
419 .max_height(cap)
420 .auto_shrink([false, true])
421 .show(ui, |ui| {
422 ui.add(
423 egui::TextEdit::multiline(&mut self.description)
424 .margin(DESCRIPTION_MARGIN)
425 .desired_rows(5)
426 .desired_width(f32::INFINITY)
427 .hint_text("Steps, what you expected, and what actually happened"),
428 )
429 });
430 self.description_scroll = DescriptionScroll {
431 content: scroll.content_size.y,
432 viewport: scroll.inner_rect.height(),
433 offset: scroll.state.offset.y,
434 };
435 let field = scroll.inner;
436 self.hit("field:description", &field);
437 field.context_menu(|ui| self.edit_menu(ui, &field));
438 }
439
440 fn edit_menu(&mut self, ui: &mut egui::Ui, field: &egui::Response) {
450 let entries = [
451 ("menu:cut", "Cut", egui::ViewportCommand::RequestCut),
452 ("menu:copy", "Copy", egui::ViewportCommand::RequestCopy),
453 ("menu:paste", "Paste", egui::ViewportCommand::RequestPaste),
454 ];
455 for (key, label, command) in entries {
456 let item = ui.add_enabled(MENU_REACHES_CLIPBOARD, egui::Button::new(label));
457 self.hit(key, &item);
458 if item.clicked() {
459 field.request_focus();
460 ui.ctx().send_viewport_cmd(command);
461 ui.close();
462 }
463 }
464 if !MENU_REACHES_CLIPBOARD {
465 ui.weak("In the browser, use Ctrl+X / C / V (Cmd on a Mac).");
466 }
467 }
468
469 fn poll_capture(&mut self, ctx: &egui::Context) {
471 let frames = match &mut self.phase {
472 Phase::Capturing { frames } => {
473 *frames += 1;
474 *frames
475 }
476 _ => return,
477 };
478 let shot = ctx.input(|i| {
481 i.raw.events.iter().rev().find_map(|e| match e {
482 egui::Event::Screenshot { image, .. } => Some(image.clone()),
483 _ => None,
484 })
485 });
486 if let Some(img) = shot {
487 self.screenshot_png = encode_png(&img);
488 self.markup = Some(markup::Markup::new((*img).clone()));
489 self.thumb = Some(ctx.load_texture(
490 "brep-bug-shot",
491 (*img).clone(),
492 egui::TextureOptions::LINEAR,
493 ));
494 self.phase = Phase::Editing;
495 } else if frames > CAPTURE_TIMEOUT_FRAMES {
496 self.status = "(screenshot unavailable)".into();
497 self.phase = Phase::Editing;
498 } else {
499 ctx.request_repaint();
500 }
501 }
502
503 fn drain_response(&mut self, state: &mut EngineState) {
506 let Some(rx) = &self.response_rx else { return };
507 let Ok(result) = rx.try_recv() else { return };
508 self.response_rx = None;
509 match result {
510 Ok(id) => {
511 state.push_notice("Bug report submitted — thank you!".to_string());
512 self.submitted = id.map(|id| (id, crate::report_followup::summary_of(&self.description)));
513 self.reset();
514 }
515 Err(e) => {
516 self.status = crate::offsite::explain(FEATURE, REPORT_URL, format!("Submit failed: {e}"));
517 self.phase = Phase::Editing;
518 }
519 }
520 }
521
522 fn send(&mut self, ctx: &egui::Context) {
524 if let Some(sentence) = crate::offsite::refusal(FEATURE, REPORT_URL) {
527 self.status = sentence;
528 return;
529 }
530 self.status = "Submitting\u{2026}".into();
531 self.phase = Phase::Sending;
532
533 let (content_type, body) = build_multipart(
534 &compose_description(&self.description, &self.diagnostics),
535 &self.email,
536 &self.model_json,
537 self.screenshot_png.as_deref(),
538 );
539 let mut req = ehttp::Request::post(REPORT_URL, body);
540 req.headers
542 .headers
543 .retain(|(k, _)| !k.eq_ignore_ascii_case("content-type"));
544 req.headers.headers.push(("Content-Type".to_string(), content_type));
545
546 let (tx, rx) = std::sync::mpsc::channel();
547 let ctx = ctx.clone();
548 ehttp::fetch(req, move |result| {
549 let out = match result {
550 Ok(resp) if resp.ok => Ok(crate::report_followup::id_of_reply(&resp.bytes)),
551 Ok(resp) => Err(format!("HTTP {} {}", resp.status, resp.status_text)),
552 Err(err) => Err(err),
553 };
554 let _ = tx.send(out);
555 ctx.request_repaint();
556 });
557 self.response_rx = Some(rx);
558 }
559
560 pub fn take_submitted(&mut self) -> Option<(String, String)> {
562 self.submitted.take()
563 }
564
565 fn reset(&mut self) {
567 self.phase = Phase::Idle;
568 self.description.clear();
569 self.email.clear();
570 self.status.clear();
571 self.diagnostics.clear();
572 self.screenshot_png = None;
573 self.thumb = None;
574 self.markup = None;
575 self.response_rx = None;
576 self.description_scroll = DescriptionScroll::default();
577 }
578
579 fn hit(&mut self, key: &str, resp: &egui::Response) {
583 self.hits.insert(key.to_string(), resp.interact_rect);
584 }
585
586 pub fn state_json(&self) -> String {
589 let phase = match self.phase {
590 Phase::Idle => "idle",
591 Phase::Capturing { .. } => "capturing",
592 Phase::Editing => "editing",
593 Phase::Marking => "marking",
594 Phase::Sending => "sending",
595 };
596 serde_json::json!({
597 "phase": phase,
598 "hasScreenshot": self.screenshot_png.is_some(),
599 "description": self.description,
600 "annotationCount": self.markup.as_ref().map_or(0, markup::Markup::count),
601 "diagnostics": self.diagnostics,
602 "status": self.status,
603 "descriptionScroll": {
604 "content": self.description_scroll.content,
605 "viewport": self.description_scroll.viewport,
606 "offset": self.description_scroll.offset,
607 },
608 })
609 .to_string()
610 }
611
612 pub fn hits_json(&self) -> String {
614 let map: serde_json::Map<String, serde_json::Value> = self
615 .hits
616 .iter()
617 .map(|(k, r)| {
618 (
619 k.clone(),
620 serde_json::json!([r.center().x, r.center().y, r.width(), r.height()]),
621 )
622 })
623 .collect();
624 serde_json::Value::Object(map).to_string()
625 }
626}
627
628fn error_description(dialog: &str, error: &str) -> String {
632 let dialog = dialog.split_whitespace().collect::<Vec<_>>().join(" ");
633 format!("{dialog}: {error}\n\n")
634}
635
636fn encode_png(color: &egui::ColorImage) -> Option<Vec<u8>> {
639 use image::ImageEncoder;
640 let [w, h] = color.size;
641 if w == 0 || h == 0 {
642 return None;
643 }
644 let mut rgba = Vec::with_capacity(w * h * 4);
645 for px in &color.pixels {
646 rgba.extend_from_slice(&px.to_srgba_unmultiplied());
648 }
649 let mut png = Vec::new();
650 image::codecs::png::PngEncoder::new(&mut png)
651 .write_image(&rgba, w as u32, h as u32, image::ExtendedColorType::Rgba8)
652 .ok()?;
653 Some(png)
654}
655
656fn compose_description(description: &str, diagnostics: &str) -> String {
677 if diagnostics.is_empty() {
678 return description.chars().take(MAX_DESC).collect();
679 }
680 const SEPARATOR: &str = "\n\n";
681 let block_len = diagnostics.chars().count() + SEPARATOR.chars().count();
683 let room = MAX_DESC.saturating_sub(block_len);
684 let user: String = description.chars().take(room).collect();
685 format!("{user}{SEPARATOR}{diagnostics}")
686}
687
688fn build_multipart(
693 description: &str,
694 email: &str,
695 model: &str,
696 screenshot: Option<&[u8]>,
697) -> (String, Vec<u8>) {
698 let boundary = format!(
699 "----BREPBugReport{:x}x{:x}Boundary",
700 model.len(),
701 screenshot.map(|s| s.len()).unwrap_or(0)
702 );
703 let mut body = Vec::new();
704 push_text_field(&mut body, &boundary, "description", description);
705 push_text_field(&mut body, &boundary, "email", email);
706 push_file_field(
707 &mut body,
708 &boundary,
709 "model",
710 "model.nbrep",
711 "application/json",
712 model.as_bytes(),
713 );
714 if let Some(png) = screenshot {
715 push_file_field(&mut body, &boundary, "screenshot", "screenshot.png", "image/png", png);
716 }
717 body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
718 (format!("multipart/form-data; boundary={boundary}"), body)
719}
720
721fn push_text_field(body: &mut Vec<u8>, boundary: &str, name: &str, value: &str) {
722 body.extend_from_slice(
723 format!("--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n")
724 .as_bytes(),
725 );
726 body.extend_from_slice(value.as_bytes());
727 body.extend_from_slice(b"\r\n");
728}
729
730fn push_file_field(
731 body: &mut Vec<u8>,
732 boundary: &str,
733 name: &str,
734 filename: &str,
735 content_type: &str,
736 bytes: &[u8],
737) {
738 body.extend_from_slice(
739 format!(
740 "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; \
741 filename=\"{filename}\"\r\nContent-Type: {content_type}\r\n\r\n"
742 )
743 .as_bytes(),
744 );
745 body.extend_from_slice(bytes);
746 body.extend_from_slice(b"\r\n");
747}
748
749pub static HIT_KEYS: &[HitKeyDoc] = &[
751 HitKeyDoc { panel: "bug", prefix: "markup:", meaning: "screenshot markup toggle, markup:tool (the pen/arrow/rectangle dropdown; its open entries markup:tool#pen, #arrow, #rectangle), image drawing area, undo and clear", command: None },
752 HitKeyDoc { panel: "bug", prefix: "field:description", meaning: "the description field (its visible part; right-click opens its Cut / Copy / Paste menu)", command: None },
753 HitKeyDoc { panel: "bug", prefix: "menu:cut", meaning: "cut the description's selection, from its right-click menu (native; disabled in the browser)", command: None },
754 HitKeyDoc { panel: "bug", prefix: "menu:copy", meaning: "copy the description's selection, from its right-click menu (native; disabled in the browser)", command: None },
755 HitKeyDoc { panel: "bug", prefix: "menu:paste", meaning: "paste the clipboard into the description, from its right-click menu (native; disabled in the browser)", command: None },
756 HitKeyDoc { panel: "bug", prefix: "field:email", meaning: "the email field", command: None },
757 HitKeyDoc { panel: "bug", prefix: "diagnostics", meaning: "expand the diagnostics block the report will carry (the `diagnostics` command returns the same rows, but expanding a header is not what it does)", command: None },
758 HitKeyDoc { panel: "bug", prefix: "submit", meaning: "submit the report", command: None },
759 HitKeyDoc { panel: "bug", prefix: "cancel", meaning: "close the report", command: None },
760];
761