1use std::sync::mpsc::Receiver;
27
28use brep_render::engine_state::EngineState;
29use crate::icon_text::IconTextUi as _;
30use eframe::egui;
31
32const REPORT_URL: &str = "https://v2.brep.io/api/report";
36
37const CAPTURE_TIMEOUT_FRAMES: u32 = 60;
42
43#[derive(Default, PartialEq)]
45enum Phase {
46 #[default]
48 Idle,
49 Capturing { frames: u32 },
51 Editing,
53 Sending,
55}
56
57pub struct BugReportPanel {
58 phase: Phase,
59 description: String,
61 email: String,
63 status: String,
65 screenshot_png: Option<Vec<u8>>,
67 thumb: Option<egui::TextureHandle>,
69 model_json: String,
71 response_rx: Option<Receiver<Result<(), String>>>,
73 #[cfg(target_arch = "wasm32")]
75 hits: std::collections::HashMap<String, egui::Rect>,
76}
77
78impl BugReportPanel {
79 pub fn new() -> Self {
80 Self {
81 phase: Phase::Idle,
82 description: String::new(),
83 email: String::new(),
84 status: String::new(),
85 screenshot_png: None,
86 thumb: None,
87 model_json: String::new(),
88 response_rx: None,
89 #[cfg(target_arch = "wasm32")]
90 hits: std::collections::HashMap::new(),
91 }
92 }
93
94 pub fn request(&mut self, ctx: &egui::Context, state: &EngineState) {
98 if self.phase != Phase::Idle {
99 return;
100 }
101 self.description.clear();
102 self.email.clear();
103 self.status.clear();
104 self.screenshot_png = None;
105 self.thumb = None;
106 self.response_rx = None;
107 self.model_json = state.history_request_json();
110 ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
111 self.phase = Phase::Capturing { frames: 0 };
112 ctx.request_repaint();
113 }
114
115 pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState) {
118 #[cfg(target_arch = "wasm32")]
119 self.hits.clear();
120
121 self.poll_capture(ctx);
122 self.drain_response(state);
123
124 if !matches!(self.phase, Phase::Editing | Phase::Sending) {
125 return;
126 }
127
128 let sending = self.phase == Phase::Sending;
129 let mut submit = false;
130 let mut cancel = false;
131 let modal = egui::Modal::new(egui::Id::new("brep-bug-report")).show(ctx, |ui| {
132 ui.set_width(560.0);
133 ui.icon_label(
137 egui::RichText::new("\u{1F41E} Submit a bug report").heading(),
138 );
139 ui.add_space(4.0);
140 ui.label(
141 "Describe what went wrong. Your current model and a screenshot of \
142 the app (UI + 3D view) are attached automatically.",
143 );
144 ui.add_space(2.0);
145 ui.weak("Your report and its screenshot may be shown publicly on the bug list.");
146 ui.add_space(8.0);
147
148 ui.label("What happened?");
149 let desc = ui.add(
150 egui::TextEdit::multiline(&mut self.description)
151 .desired_rows(5)
152 .desired_width(f32::INFINITY)
153 .hint_text("Steps, what you expected, and what actually happened"),
154 );
155 self.hit("field:description", &desc);
156
157 ui.add_space(6.0);
158 ui.label("Email (optional)");
159 let email = ui.add(
160 egui::TextEdit::singleline(&mut self.email)
161 .desired_width(f32::INFINITY)
162 .hint_text("so we can follow up — optional"),
163 );
164 self.hit("field:email", &email);
165
166 ui.add_space(8.0);
167 if let Some(tex) = &self.thumb {
168 ui.label("Attached screenshot:");
169 ui.add_space(2.0);
170 let size = tex.size_vec2();
172 let scale = (520.0 / size.x).min(1.0);
173 ui.add(
174 egui::Image::new((tex.id(), size * scale))
175 .corner_radius(4.0)
176 .bg_fill(egui::Color32::from_gray(20)),
177 );
178 } else {
179 ui.weak("(screenshot unavailable — the model + description will still be sent)");
180 }
181
182 ui.add_space(10.0);
183 ui.horizontal(|ui| {
184 let can_submit = !self.description.trim().is_empty() && !sending;
185 let label = if sending { "Sending\u{2026}" } else { "Submit report" };
186 let submit_btn = ui.add_enabled(can_submit, egui::Button::new(label));
187 self.hit("submit", &submit_btn);
188 if submit_btn.clicked() {
189 submit = true;
190 }
191 let cancel_btn = ui.add_enabled(!sending, egui::Button::new("Cancel"));
192 self.hit("cancel", &cancel_btn);
193 if cancel_btn.clicked() {
194 cancel = true;
195 }
196 });
197 if !self.status.is_empty() {
198 ui.add_space(6.0);
199 ui.weak(&self.status);
200 }
201 });
202
203 if submit {
204 self.send(ctx);
205 } else if cancel || (modal.should_close() && !sending) {
206 self.reset();
207 }
208 }
209
210 fn poll_capture(&mut self, ctx: &egui::Context) {
212 let frames = match &mut self.phase {
213 Phase::Capturing { frames } => {
214 *frames += 1;
215 *frames
216 }
217 _ => return,
218 };
219 let shot = ctx.input(|i| {
222 i.raw.events.iter().rev().find_map(|e| match e {
223 egui::Event::Screenshot { image, .. } => Some(image.clone()),
224 _ => None,
225 })
226 });
227 if let Some(img) = shot {
228 self.screenshot_png = encode_png(&img);
229 self.thumb = Some(ctx.load_texture(
230 "brep-bug-shot",
231 (*img).clone(),
232 egui::TextureOptions::LINEAR,
233 ));
234 self.phase = Phase::Editing;
235 } else if frames > CAPTURE_TIMEOUT_FRAMES {
236 self.status = "(screenshot unavailable)".into();
237 self.phase = Phase::Editing;
238 } else {
239 ctx.request_repaint();
240 }
241 }
242
243 fn drain_response(&mut self, state: &mut EngineState) {
246 let Some(rx) = &self.response_rx else { return };
247 let Ok(result) = rx.try_recv() else { return };
248 self.response_rx = None;
249 match result {
250 Ok(()) => {
251 state.push_notice("Bug report submitted — thank you!".to_string());
252 self.reset();
253 }
254 Err(e) => {
255 self.status = format!("Submit failed: {e}");
256 self.phase = Phase::Editing;
257 }
258 }
259 }
260
261 fn send(&mut self, ctx: &egui::Context) {
263 self.status = "Submitting\u{2026}".into();
264 self.phase = Phase::Sending;
265
266 let (content_type, body) = build_multipart(
267 &self.description,
268 &self.email,
269 &self.model_json,
270 self.screenshot_png.as_deref(),
271 );
272 let mut req = ehttp::Request::post(REPORT_URL, body);
273 req.headers
275 .headers
276 .retain(|(k, _)| !k.eq_ignore_ascii_case("content-type"));
277 req.headers.headers.push(("Content-Type".to_string(), content_type));
278
279 let (tx, rx) = std::sync::mpsc::channel();
280 let ctx = ctx.clone();
281 ehttp::fetch(req, move |result| {
282 let out = match result {
283 Ok(resp) if resp.ok => Ok(()),
284 Ok(resp) => Err(format!("HTTP {} {}", resp.status, resp.status_text)),
285 Err(err) => Err(err),
286 };
287 let _ = tx.send(out);
288 ctx.request_repaint();
289 });
290 self.response_rx = Some(rx);
291 }
292
293 fn reset(&mut self) {
295 self.phase = Phase::Idle;
296 self.description.clear();
297 self.email.clear();
298 self.status.clear();
299 self.screenshot_png = None;
300 self.thumb = None;
301 self.response_rx = None;
302 }
303
304 #[cfg(target_arch = "wasm32")]
307 fn hit(&mut self, key: &str, resp: &egui::Response) {
308 self.hits.insert(key.to_string(), resp.rect);
309 }
310 #[cfg(not(target_arch = "wasm32"))]
311 #[inline]
312 fn hit(&mut self, _key: &str, _resp: &egui::Response) {}
313
314 #[cfg(target_arch = "wasm32")]
317 pub fn state_json(&self) -> String {
318 let phase = match self.phase {
319 Phase::Idle => "idle",
320 Phase::Capturing { .. } => "capturing",
321 Phase::Editing => "editing",
322 Phase::Sending => "sending",
323 };
324 serde_json::json!({
325 "phase": phase,
326 "hasScreenshot": self.screenshot_png.is_some(),
327 "status": self.status,
328 })
329 .to_string()
330 }
331
332 #[cfg(target_arch = "wasm32")]
334 pub fn hits_json(&self) -> String {
335 let map: serde_json::Map<String, serde_json::Value> = self
336 .hits
337 .iter()
338 .map(|(k, r)| {
339 (
340 k.clone(),
341 serde_json::json!([r.center().x, r.center().y, r.width(), r.height()]),
342 )
343 })
344 .collect();
345 serde_json::Value::Object(map).to_string()
346 }
347}
348
349fn encode_png(color: &egui::ColorImage) -> Option<Vec<u8>> {
352 use image::ImageEncoder;
353 let [w, h] = color.size;
354 if w == 0 || h == 0 {
355 return None;
356 }
357 let mut rgba = Vec::with_capacity(w * h * 4);
358 for px in &color.pixels {
359 rgba.extend_from_slice(&px.to_srgba_unmultiplied());
361 }
362 let mut png = Vec::new();
363 image::codecs::png::PngEncoder::new(&mut png)
364 .write_image(&rgba, w as u32, h as u32, image::ExtendedColorType::Rgba8)
365 .ok()?;
366 Some(png)
367}
368
369fn build_multipart(
374 description: &str,
375 email: &str,
376 model: &str,
377 screenshot: Option<&[u8]>,
378) -> (String, Vec<u8>) {
379 let boundary = format!(
380 "----BREPBugReport{:x}x{:x}Boundary",
381 model.len(),
382 screenshot.map(|s| s.len()).unwrap_or(0)
383 );
384 let mut body = Vec::new();
385 push_text_field(&mut body, &boundary, "description", description);
386 push_text_field(&mut body, &boundary, "email", email);
387 push_file_field(
388 &mut body,
389 &boundary,
390 "model",
391 "model.BREP.json",
392 "application/json",
393 model.as_bytes(),
394 );
395 if let Some(png) = screenshot {
396 push_file_field(&mut body, &boundary, "screenshot", "screenshot.png", "image/png", png);
397 }
398 body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
399 (format!("multipart/form-data; boundary={boundary}"), body)
400}
401
402fn push_text_field(body: &mut Vec<u8>, boundary: &str, name: &str, value: &str) {
403 body.extend_from_slice(
404 format!("--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n")
405 .as_bytes(),
406 );
407 body.extend_from_slice(value.as_bytes());
408 body.extend_from_slice(b"\r\n");
409}
410
411fn push_file_field(
412 body: &mut Vec<u8>,
413 boundary: &str,
414 name: &str,
415 filename: &str,
416 content_type: &str,
417 bytes: &[u8],
418) {
419 body.extend_from_slice(
420 format!(
421 "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; \
422 filename=\"{filename}\"\r\nContent-Type: {content_type}\r\n\r\n"
423 )
424 .as_bytes(),
425 );
426 body.extend_from_slice(bytes);
427 body.extend_from_slice(b"\r\n");
428}