1use crate::automation::hit_keys::HitKeyDoc;
30use crate::diagnostics::Diagnostics;
31use std::sync::mpsc::Receiver;
32
33use brep_render::engine_state::EngineState;
34use crate::icon_text::IconTextUi as _;
35use eframe::egui;
36
37const REPORT_URL: &str = "https://v2.brep.io/api/report";
41
42const MAX_DESC: usize = 20_000;
49
50const CAPTURE_TIMEOUT_FRAMES: u32 = 60;
55
56#[derive(Default, PartialEq)]
58enum Phase {
59 #[default]
61 Idle,
62 Capturing { frames: u32 },
64 Editing,
66 Sending,
68}
69
70pub struct BugReportPanel {
71 phase: Phase,
72 description: String,
74 email: String,
76 status: String,
78 screenshot_png: Option<Vec<u8>>,
80 thumb: Option<egui::TextureHandle>,
82 model_json: String,
84 diagnostics: String,
89 response_rx: Option<Receiver<Result<(), String>>>,
91 hits: std::collections::HashMap<String, egui::Rect>,
93}
94
95impl BugReportPanel {
96 pub fn new() -> Self {
97 Self {
98 phase: Phase::Idle,
99 description: String::new(),
100 email: String::new(),
101 status: String::new(),
102 screenshot_png: None,
103 thumb: None,
104 model_json: String::new(),
105 diagnostics: String::new(),
106 response_rx: None,
107 hits: std::collections::HashMap::new(),
108 }
109 }
110
111 pub fn request(&mut self, ctx: &egui::Context, state: &EngineState, diagnostics: &Diagnostics) {
119 if self.phase != Phase::Idle {
120 return;
121 }
122 self.description.clear();
123 self.email.clear();
124 self.status.clear();
125 self.screenshot_png = None;
126 self.thumb = None;
127 self.response_rx = None;
128 self.model_json = state.history_request_json();
131 self.diagnostics = diagnostics.report_text();
132 ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
133 self.phase = Phase::Capturing { frames: 0 };
134 ctx.request_repaint();
135 }
136
137 pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState) {
140 self.hits.clear();
141
142 self.poll_capture(ctx);
143 self.drain_response(state);
144
145 if !matches!(self.phase, Phase::Editing | Phase::Sending) {
146 return;
147 }
148
149 let sending = self.phase == Phase::Sending;
150 let mut submit = false;
151 let mut cancel = false;
152 let modal = egui::Modal::new(egui::Id::new("brep-bug-report")).show(ctx, |ui| {
153 ui.set_width(560.0);
154 ui.icon_label(
158 egui::RichText::new("\u{1F41E} Submit a bug report").heading(),
159 );
160 ui.add_space(4.0);
161 ui.label(
162 "Describe what went wrong. Your current model and a screenshot of \
163 the app (UI + 3D view) are attached automatically.",
164 );
165 ui.add_space(2.0);
166 ui.weak("Your report and its screenshot may be shown publicly on the bug list.");
167 ui.add_space(8.0);
168
169 ui.label("What happened?");
170 let desc = ui.add(
171 egui::TextEdit::multiline(&mut self.description)
172 .desired_rows(5)
173 .desired_width(f32::INFINITY)
174 .hint_text("Steps, what you expected, and what actually happened"),
175 );
176 self.hit("field:description", &desc);
177
178 ui.add_space(6.0);
179 ui.label("Email (optional)");
180 let email = ui.add(
181 egui::TextEdit::singleline(&mut self.email)
182 .desired_width(f32::INFINITY)
183 .hint_text("so we can follow up — optional"),
184 );
185 self.hit("field:email", &email);
186
187 ui.add_space(8.0);
188 if let Some(tex) = &self.thumb {
189 ui.label("Attached screenshot:");
190 ui.add_space(2.0);
191 let size = tex.size_vec2();
193 let scale = (520.0 / size.x).min(1.0);
194 ui.add(
195 egui::Image::new((tex.id(), size * scale))
196 .corner_radius(4.0)
197 .bg_fill(egui::Color32::from_gray(20)),
198 );
199 } else {
200 ui.weak("(screenshot unavailable — the model + description will still be sent)");
201 }
202
203 ui.add_space(8.0);
208 let diag = egui::CollapsingHeader::new("Diagnostics attached to this report")
209 .id_salt("brep-bug-diagnostics")
210 .show(ui, |ui| {
211 ui.add(
212 egui::Label::new(
213 egui::RichText::new(&self.diagnostics).monospace().small(),
214 )
215 .wrap(),
216 );
217 });
218 self.hits.insert("diagnostics".to_string(), diag.header_response.rect);
219
220 ui.add_space(10.0);
221 ui.horizontal(|ui| {
222 let can_submit = !self.description.trim().is_empty() && !sending;
223 let label = if sending { "Sending\u{2026}" } else { "Submit report" };
224 let submit_btn = ui.add_enabled(can_submit, egui::Button::new(label));
225 self.hit("submit", &submit_btn);
226 if submit_btn.clicked() {
227 submit = true;
228 }
229 let cancel_btn = ui.add_enabled(!sending, egui::Button::new("Cancel"));
230 self.hit("cancel", &cancel_btn);
231 if cancel_btn.clicked() {
232 cancel = true;
233 }
234 });
235 if !self.status.is_empty() {
236 ui.add_space(6.0);
237 ui.weak(&self.status);
238 }
239 });
240
241 if submit {
242 self.send(ctx);
243 } else if cancel || (modal.should_close() && !sending) {
244 self.reset();
245 }
246 }
247
248 fn poll_capture(&mut self, ctx: &egui::Context) {
250 let frames = match &mut self.phase {
251 Phase::Capturing { frames } => {
252 *frames += 1;
253 *frames
254 }
255 _ => return,
256 };
257 let shot = ctx.input(|i| {
260 i.raw.events.iter().rev().find_map(|e| match e {
261 egui::Event::Screenshot { image, .. } => Some(image.clone()),
262 _ => None,
263 })
264 });
265 if let Some(img) = shot {
266 self.screenshot_png = encode_png(&img);
267 self.thumb = Some(ctx.load_texture(
268 "brep-bug-shot",
269 (*img).clone(),
270 egui::TextureOptions::LINEAR,
271 ));
272 self.phase = Phase::Editing;
273 } else if frames > CAPTURE_TIMEOUT_FRAMES {
274 self.status = "(screenshot unavailable)".into();
275 self.phase = Phase::Editing;
276 } else {
277 ctx.request_repaint();
278 }
279 }
280
281 fn drain_response(&mut self, state: &mut EngineState) {
284 let Some(rx) = &self.response_rx else { return };
285 let Ok(result) = rx.try_recv() else { return };
286 self.response_rx = None;
287 match result {
288 Ok(()) => {
289 state.push_notice("Bug report submitted — thank you!".to_string());
290 self.reset();
291 }
292 Err(e) => {
293 self.status = format!("Submit failed: {e}");
294 self.phase = Phase::Editing;
295 }
296 }
297 }
298
299 fn send(&mut self, ctx: &egui::Context) {
301 self.status = "Submitting\u{2026}".into();
302 self.phase = Phase::Sending;
303
304 let (content_type, body) = build_multipart(
305 &compose_description(&self.description, &self.diagnostics),
306 &self.email,
307 &self.model_json,
308 self.screenshot_png.as_deref(),
309 );
310 let mut req = ehttp::Request::post(REPORT_URL, body);
311 req.headers
313 .headers
314 .retain(|(k, _)| !k.eq_ignore_ascii_case("content-type"));
315 req.headers.headers.push(("Content-Type".to_string(), content_type));
316
317 let (tx, rx) = std::sync::mpsc::channel();
318 let ctx = ctx.clone();
319 ehttp::fetch(req, move |result| {
320 let out = match result {
321 Ok(resp) if resp.ok => Ok(()),
322 Ok(resp) => Err(format!("HTTP {} {}", resp.status, resp.status_text)),
323 Err(err) => Err(err),
324 };
325 let _ = tx.send(out);
326 ctx.request_repaint();
327 });
328 self.response_rx = Some(rx);
329 }
330
331 fn reset(&mut self) {
333 self.phase = Phase::Idle;
334 self.description.clear();
335 self.email.clear();
336 self.status.clear();
337 self.diagnostics.clear();
338 self.screenshot_png = None;
339 self.thumb = None;
340 self.response_rx = None;
341 }
342
343 fn hit(&mut self, key: &str, resp: &egui::Response) {
346 self.hits.insert(key.to_string(), resp.rect);
347 }
348
349 pub fn state_json(&self) -> String {
352 let phase = match self.phase {
353 Phase::Idle => "idle",
354 Phase::Capturing { .. } => "capturing",
355 Phase::Editing => "editing",
356 Phase::Sending => "sending",
357 };
358 serde_json::json!({
359 "phase": phase,
360 "hasScreenshot": self.screenshot_png.is_some(),
361 "diagnostics": self.diagnostics,
362 "status": self.status,
363 })
364 .to_string()
365 }
366
367 pub fn hits_json(&self) -> String {
369 let map: serde_json::Map<String, serde_json::Value> = self
370 .hits
371 .iter()
372 .map(|(k, r)| {
373 (
374 k.clone(),
375 serde_json::json!([r.center().x, r.center().y, r.width(), r.height()]),
376 )
377 })
378 .collect();
379 serde_json::Value::Object(map).to_string()
380 }
381}
382
383fn encode_png(color: &egui::ColorImage) -> Option<Vec<u8>> {
386 use image::ImageEncoder;
387 let [w, h] = color.size;
388 if w == 0 || h == 0 {
389 return None;
390 }
391 let mut rgba = Vec::with_capacity(w * h * 4);
392 for px in &color.pixels {
393 rgba.extend_from_slice(&px.to_srgba_unmultiplied());
395 }
396 let mut png = Vec::new();
397 image::codecs::png::PngEncoder::new(&mut png)
398 .write_image(&rgba, w as u32, h as u32, image::ExtendedColorType::Rgba8)
399 .ok()?;
400 Some(png)
401}
402
403fn compose_description(description: &str, diagnostics: &str) -> String {
425 if diagnostics.is_empty() {
426 return description.chars().take(MAX_DESC).collect();
427 }
428 const SEPARATOR: &str = "\n\n";
429 let block_len = diagnostics.chars().count() + SEPARATOR.chars().count();
431 let room = MAX_DESC.saturating_sub(block_len);
432 let user: String = description.chars().take(room).collect();
433 format!("{user}{SEPARATOR}{diagnostics}")
434}
435
436fn build_multipart(
441 description: &str,
442 email: &str,
443 model: &str,
444 screenshot: Option<&[u8]>,
445) -> (String, Vec<u8>) {
446 let boundary = format!(
447 "----BREPBugReport{:x}x{:x}Boundary",
448 model.len(),
449 screenshot.map(|s| s.len()).unwrap_or(0)
450 );
451 let mut body = Vec::new();
452 push_text_field(&mut body, &boundary, "description", description);
453 push_text_field(&mut body, &boundary, "email", email);
454 push_file_field(
455 &mut body,
456 &boundary,
457 "model",
458 "model.BREP.json",
459 "application/json",
460 model.as_bytes(),
461 );
462 if let Some(png) = screenshot {
463 push_file_field(&mut body, &boundary, "screenshot", "screenshot.png", "image/png", png);
464 }
465 body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
466 (format!("multipart/form-data; boundary={boundary}"), body)
467}
468
469fn push_text_field(body: &mut Vec<u8>, boundary: &str, name: &str, value: &str) {
470 body.extend_from_slice(
471 format!("--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n")
472 .as_bytes(),
473 );
474 body.extend_from_slice(value.as_bytes());
475 body.extend_from_slice(b"\r\n");
476}
477
478fn push_file_field(
479 body: &mut Vec<u8>,
480 boundary: &str,
481 name: &str,
482 filename: &str,
483 content_type: &str,
484 bytes: &[u8],
485) {
486 body.extend_from_slice(
487 format!(
488 "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; \
489 filename=\"{filename}\"\r\nContent-Type: {content_type}\r\n\r\n"
490 )
491 .as_bytes(),
492 );
493 body.extend_from_slice(bytes);
494 body.extend_from_slice(b"\r\n");
495}
496
497pub static HIT_KEYS: &[HitKeyDoc] = &[
499 HitKeyDoc { panel: "bug", prefix: "field:description", meaning: "the description field", command: None },
500 HitKeyDoc { panel: "bug", prefix: "field:email", meaning: "the email field", command: None },
501 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 },
502 HitKeyDoc { panel: "bug", prefix: "submit", meaning: "submit the report", command: None },
503 HitKeyDoc { panel: "bug", prefix: "cancel", meaning: "close the report", command: None },
504];
505
506