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 show(&mut self, ctx: &egui::Context, state: &mut EngineState) {
196 self.hits.clear();
197
198 self.poll_capture(ctx);
199 self.drain_response(state);
200
201 if !matches!(self.phase, Phase::Editing | Phase::Marking | Phase::Sending) {
202 return;
203 }
204
205 let sending = self.phase == Phase::Sending;
206 let mut submit = false;
207 let mut cancel = false;
208 let marking = self.phase == Phase::Marking;
209 let mut finish = false;
210 let window = ctx.content_rect();
211 let frame = egui::Frame::popup(&ctx.global_style());
212 let content_size = egui::vec2(
213 window.width() * DIALOG_WIDTH_FRACTION,
214 window.height() * DIALOG_HEIGHT_FRACTION,
215 ) - frame.total_margin().sum();
216 let modal = egui::Modal::new(egui::Id::new("brep-bug-report"))
217 .frame(frame)
218 .show(ctx, |ui| {
219 ui.set_width(content_size.x);
220 let content_rect = egui::Rect::from_min_size(ui.min_rect().min, content_size);
221 if marking {
222 let finish_btn = ui.button("Finish");
223 self.hit("markup:finish", &finish_btn);
224 finish = finish_btn.clicked();
225 ui.separator();
226 let height =
227 (content_size.y - ui.min_rect().height() - ui.spacing().item_spacing.y)
228 .max(0.0);
229 let budget = egui::Rect::from_min_size(
230 ui.cursor().min,
231 egui::vec2(content_size.x, height),
232 );
233 ui.scope_builder(egui::UiBuilder::new().max_rect(budget), |ui| {
234 if let (Some(markup), Some(tex)) = (&mut self.markup, &self.thumb) {
235 if let Some(image) = markup.show(ui, tex, &mut self.hits) {
236 self.screenshot_png = encode_png(&image);
237 self.thumb
238 .as_mut()
239 .unwrap()
240 .set(image, egui::TextureOptions::LINEAR);
241 }
242 }
243 });
244 ui.expand_to_include_rect(content_rect);
245 return;
246 }
247 ui.icon_label(egui::RichText::new("\u{1F41E} Submit a bug report").heading());
251 ui.add_space(4.0);
252 ui.label(
253 "Describe what went wrong. Your current model and a screenshot of \
254 the app (UI + 3D view) are attached automatically.",
255 );
256 ui.add_space(2.0);
257 ui.weak("Your report and its screenshot may be shown publicly on the bug list.");
258 ui.add_space(8.0);
259
260 let chrome = ui.min_rect().height();
263 let body_height = (content_size.y - chrome - self.footer_height(ui)).max(0.0);
264 let budget = egui::Rect::from_min_size(
265 ui.cursor().min,
266 egui::vec2(ui.available_width(), body_height),
267 );
268 ui.scope_builder(egui::UiBuilder::new().max_rect(budget), |ui| {
269 egui::ScrollArea::vertical()
270 .id_salt("brep-bug-body")
271 .min_scrolled_height(body_height)
272 .max_height(body_height)
273 .auto_shrink([false, false])
274 .show(ui, |ui| self.body(ui));
275 });
276
277 ui.add_space(FOOTER_GAP);
278 ui.horizontal(|ui| {
279 let can_submit = !self.description.trim().is_empty() && !sending;
280 let label = if sending {
281 "Sending\u{2026}"
282 } else {
283 "Submit report"
284 };
285 let submit_btn = ui.add_enabled(can_submit, egui::Button::new(label));
286 self.hit("submit", &submit_btn);
287 if submit_btn.clicked() {
288 submit = true;
289 }
290 let cancel_btn = ui.add_enabled(!sending, egui::Button::new("Cancel"));
291 self.hit("cancel", &cancel_btn);
292 if cancel_btn.clicked() {
293 cancel = true;
294 }
295 });
296 ui.add_space(STATUS_GAP);
299 ui.weak(if self.status.is_empty() {
300 " "
301 } else {
302 self.status.as_str()
303 });
304 ui.expand_to_include_rect(content_rect);
305 });
306
307 if marking {
308 if finish || modal.should_close() {
309 self.phase = Phase::Editing;
310 }
311 } else if submit {
312 self.send(ctx);
313 } else if cancel || (modal.should_close() && !sending) {
314 self.reset();
315 }
316 }
317
318 fn footer_height(&self, ui: &egui::Ui) -> f32 {
323 let spacing = ui.spacing();
324 let buttons = spacing
325 .interact_size
326 .y
327 .max(ui.text_style_height(&egui::TextStyle::Button) + 2.0 * spacing.button_padding.y);
328 let status = STATUS_GAP
329 + ui.text_style_height(&egui::TextStyle::Body)
330 + 2.0 * spacing.item_spacing.y;
331 FOOTER_GAP + buttons + 2.0 * spacing.item_spacing.y + status
332 }
333
334 fn body(&mut self, ui: &mut egui::Ui) {
337 ui.label("What happened?");
338 self.description_field(ui);
339
340 ui.add_space(6.0);
341 ui.label("Email (optional)");
342 let email = ui.add(
343 egui::TextEdit::singleline(&mut self.email)
344 .desired_width(f32::INFINITY)
345 .hint_text("so we can follow up — optional"),
346 );
347 self.hit("field:email", &email);
348
349 ui.add_space(8.0);
350 if let Some(tex) = &self.thumb {
351 ui.label("Attached screenshot:");
352 ui.add_space(2.0);
353 if self.markup.is_some() {
354 let button = ui.add_enabled(
355 self.phase != Phase::Sending,
356 egui::Button::new("Mark up screenshot"),
357 );
358 self.hits
359 .insert("markup:toggle".into(), button.interact_rect);
360 if button.clicked() {
361 self.phase = Phase::Marking;
362 }
363 }
364 let size = tex.size_vec2();
365 let scale = (ui.available_width() / size.x).min(240.0 / size.y).min(1.0);
366 ui.add(egui::Image::new((tex.id(), size * scale)));
367 } else {
368 ui.weak("(screenshot unavailable — the model + description will still be sent)");
369 }
370
371 ui.add_space(8.0);
376 let diag = egui::CollapsingHeader::new("Diagnostics attached to this report")
377 .id_salt("brep-bug-diagnostics")
378 .show(ui, |ui| {
379 ui.add(
380 egui::Label::new(egui::RichText::new(&self.diagnostics).monospace().small())
381 .wrap(),
382 );
383 });
384 self.hits.insert(
385 "diagnostics".to_string(),
386 diag.header_response.interact_rect,
387 );
388 }
389
390 fn description_field(&mut self, ui: &mut egui::Ui) {
398 let cap = DESCRIPTION_ROWS as f32 * ui.text_style_height(&egui::TextStyle::Body)
399 + DESCRIPTION_MARGIN.sum().y;
400 let scroll = egui::ScrollArea::vertical()
401 .id_salt("brep-bug-description")
402 .max_height(cap)
403 .auto_shrink([false, true])
404 .show(ui, |ui| {
405 ui.add(
406 egui::TextEdit::multiline(&mut self.description)
407 .margin(DESCRIPTION_MARGIN)
408 .desired_rows(5)
409 .desired_width(f32::INFINITY)
410 .hint_text("Steps, what you expected, and what actually happened"),
411 )
412 });
413 self.description_scroll = DescriptionScroll {
414 content: scroll.content_size.y,
415 viewport: scroll.inner_rect.height(),
416 offset: scroll.state.offset.y,
417 };
418 let field = scroll.inner;
419 self.hit("field:description", &field);
420 field.context_menu(|ui| self.edit_menu(ui, &field));
421 }
422
423 fn edit_menu(&mut self, ui: &mut egui::Ui, field: &egui::Response) {
433 let entries = [
434 ("menu:cut", "Cut", egui::ViewportCommand::RequestCut),
435 ("menu:copy", "Copy", egui::ViewportCommand::RequestCopy),
436 ("menu:paste", "Paste", egui::ViewportCommand::RequestPaste),
437 ];
438 for (key, label, command) in entries {
439 let item = ui.add_enabled(MENU_REACHES_CLIPBOARD, egui::Button::new(label));
440 self.hit(key, &item);
441 if item.clicked() {
442 field.request_focus();
443 ui.ctx().send_viewport_cmd(command);
444 ui.close();
445 }
446 }
447 if !MENU_REACHES_CLIPBOARD {
448 ui.weak("In the browser, use Ctrl+X / C / V (Cmd on a Mac).");
449 }
450 }
451
452 fn poll_capture(&mut self, ctx: &egui::Context) {
454 let frames = match &mut self.phase {
455 Phase::Capturing { frames } => {
456 *frames += 1;
457 *frames
458 }
459 _ => return,
460 };
461 let shot = ctx.input(|i| {
464 i.raw.events.iter().rev().find_map(|e| match e {
465 egui::Event::Screenshot { image, .. } => Some(image.clone()),
466 _ => None,
467 })
468 });
469 if let Some(img) = shot {
470 self.screenshot_png = encode_png(&img);
471 self.markup = Some(markup::Markup::new((*img).clone()));
472 self.thumb = Some(ctx.load_texture(
473 "brep-bug-shot",
474 (*img).clone(),
475 egui::TextureOptions::LINEAR,
476 ));
477 self.phase = Phase::Editing;
478 } else if frames > CAPTURE_TIMEOUT_FRAMES {
479 self.status = "(screenshot unavailable)".into();
480 self.phase = Phase::Editing;
481 } else {
482 ctx.request_repaint();
483 }
484 }
485
486 fn drain_response(&mut self, state: &mut EngineState) {
489 let Some(rx) = &self.response_rx else { return };
490 let Ok(result) = rx.try_recv() else { return };
491 self.response_rx = None;
492 match result {
493 Ok(id) => {
494 state.push_notice("Bug report submitted — thank you!".to_string());
495 self.submitted = id.map(|id| (id, crate::report_followup::summary_of(&self.description)));
496 self.reset();
497 }
498 Err(e) => {
499 self.status = crate::offsite::explain(FEATURE, REPORT_URL, format!("Submit failed: {e}"));
500 self.phase = Phase::Editing;
501 }
502 }
503 }
504
505 fn send(&mut self, ctx: &egui::Context) {
507 if let Some(sentence) = crate::offsite::refusal(FEATURE, REPORT_URL) {
510 self.status = sentence;
511 return;
512 }
513 self.status = "Submitting\u{2026}".into();
514 self.phase = Phase::Sending;
515
516 let (content_type, body) = build_multipart(
517 &compose_description(&self.description, &self.diagnostics),
518 &self.email,
519 &self.model_json,
520 self.screenshot_png.as_deref(),
521 );
522 let mut req = ehttp::Request::post(REPORT_URL, body);
523 req.headers
525 .headers
526 .retain(|(k, _)| !k.eq_ignore_ascii_case("content-type"));
527 req.headers.headers.push(("Content-Type".to_string(), content_type));
528
529 let (tx, rx) = std::sync::mpsc::channel();
530 let ctx = ctx.clone();
531 ehttp::fetch(req, move |result| {
532 let out = match result {
533 Ok(resp) if resp.ok => Ok(crate::report_followup::id_of_reply(&resp.bytes)),
534 Ok(resp) => Err(format!("HTTP {} {}", resp.status, resp.status_text)),
535 Err(err) => Err(err),
536 };
537 let _ = tx.send(out);
538 ctx.request_repaint();
539 });
540 self.response_rx = Some(rx);
541 }
542
543 pub fn take_submitted(&mut self) -> Option<(String, String)> {
545 self.submitted.take()
546 }
547
548 fn reset(&mut self) {
550 self.phase = Phase::Idle;
551 self.description.clear();
552 self.email.clear();
553 self.status.clear();
554 self.diagnostics.clear();
555 self.screenshot_png = None;
556 self.thumb = None;
557 self.markup = None;
558 self.response_rx = None;
559 self.description_scroll = DescriptionScroll::default();
560 }
561
562 fn hit(&mut self, key: &str, resp: &egui::Response) {
566 self.hits.insert(key.to_string(), resp.interact_rect);
567 }
568
569 pub fn state_json(&self) -> String {
572 let phase = match self.phase {
573 Phase::Idle => "idle",
574 Phase::Capturing { .. } => "capturing",
575 Phase::Editing => "editing",
576 Phase::Marking => "marking",
577 Phase::Sending => "sending",
578 };
579 serde_json::json!({
580 "phase": phase,
581 "hasScreenshot": self.screenshot_png.is_some(),
582 "annotationCount": self.markup.as_ref().map_or(0, markup::Markup::count),
583 "diagnostics": self.diagnostics,
584 "status": self.status,
585 "descriptionScroll": {
586 "content": self.description_scroll.content,
587 "viewport": self.description_scroll.viewport,
588 "offset": self.description_scroll.offset,
589 },
590 })
591 .to_string()
592 }
593
594 pub fn hits_json(&self) -> String {
596 let map: serde_json::Map<String, serde_json::Value> = self
597 .hits
598 .iter()
599 .map(|(k, r)| {
600 (
601 k.clone(),
602 serde_json::json!([r.center().x, r.center().y, r.width(), r.height()]),
603 )
604 })
605 .collect();
606 serde_json::Value::Object(map).to_string()
607 }
608}
609
610fn encode_png(color: &egui::ColorImage) -> Option<Vec<u8>> {
613 use image::ImageEncoder;
614 let [w, h] = color.size;
615 if w == 0 || h == 0 {
616 return None;
617 }
618 let mut rgba = Vec::with_capacity(w * h * 4);
619 for px in &color.pixels {
620 rgba.extend_from_slice(&px.to_srgba_unmultiplied());
622 }
623 let mut png = Vec::new();
624 image::codecs::png::PngEncoder::new(&mut png)
625 .write_image(&rgba, w as u32, h as u32, image::ExtendedColorType::Rgba8)
626 .ok()?;
627 Some(png)
628}
629
630fn compose_description(description: &str, diagnostics: &str) -> String {
651 if diagnostics.is_empty() {
652 return description.chars().take(MAX_DESC).collect();
653 }
654 const SEPARATOR: &str = "\n\n";
655 let block_len = diagnostics.chars().count() + SEPARATOR.chars().count();
657 let room = MAX_DESC.saturating_sub(block_len);
658 let user: String = description.chars().take(room).collect();
659 format!("{user}{SEPARATOR}{diagnostics}")
660}
661
662fn build_multipart(
667 description: &str,
668 email: &str,
669 model: &str,
670 screenshot: Option<&[u8]>,
671) -> (String, Vec<u8>) {
672 let boundary = format!(
673 "----BREPBugReport{:x}x{:x}Boundary",
674 model.len(),
675 screenshot.map(|s| s.len()).unwrap_or(0)
676 );
677 let mut body = Vec::new();
678 push_text_field(&mut body, &boundary, "description", description);
679 push_text_field(&mut body, &boundary, "email", email);
680 push_file_field(
681 &mut body,
682 &boundary,
683 "model",
684 "model.nbrep",
685 "application/json",
686 model.as_bytes(),
687 );
688 if let Some(png) = screenshot {
689 push_file_field(&mut body, &boundary, "screenshot", "screenshot.png", "image/png", png);
690 }
691 body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
692 (format!("multipart/form-data; boundary={boundary}"), body)
693}
694
695fn push_text_field(body: &mut Vec<u8>, boundary: &str, name: &str, value: &str) {
696 body.extend_from_slice(
697 format!("--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n")
698 .as_bytes(),
699 );
700 body.extend_from_slice(value.as_bytes());
701 body.extend_from_slice(b"\r\n");
702}
703
704fn push_file_field(
705 body: &mut Vec<u8>,
706 boundary: &str,
707 name: &str,
708 filename: &str,
709 content_type: &str,
710 bytes: &[u8],
711) {
712 body.extend_from_slice(
713 format!(
714 "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; \
715 filename=\"{filename}\"\r\nContent-Type: {content_type}\r\n\r\n"
716 )
717 .as_bytes(),
718 );
719 body.extend_from_slice(bytes);
720 body.extend_from_slice(b"\r\n");
721}
722
723pub static HIT_KEYS: &[HitKeyDoc] = &[
725 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 },
726 HitKeyDoc { panel: "bug", prefix: "field:description", meaning: "the description field (its visible part; right-click opens its Cut / Copy / Paste menu)", command: None },
727 HitKeyDoc { panel: "bug", prefix: "menu:cut", meaning: "cut the description's selection, from its right-click menu (native; disabled in the browser)", command: None },
728 HitKeyDoc { panel: "bug", prefix: "menu:copy", meaning: "copy the description's selection, from its right-click menu (native; disabled in the browser)", command: None },
729 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 },
730 HitKeyDoc { panel: "bug", prefix: "field:email", meaning: "the email field", command: None },
731 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 },
732 HitKeyDoc { panel: "bug", prefix: "submit", meaning: "submit the report", command: None },
733 HitKeyDoc { panel: "bug", prefix: "cancel", meaning: "close the report", command: None },
734];
735