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