Skip to main content

brep_app/panels/
bug_report.rs

1//! In-app **Submit Bug** report.
2//!
3//! A toolbar button opens this flow. It captures, WITHOUT the report dialog in
4//! the shot:
5//!   * a **screenshot** of the whole application (egui UI + the 3D model) as it
6//!     looked the instant the button was pressed,
7//!   * the current **model** (the `.BREP.json` recipe), and
8//!   * a user **description** (+ an optional email),
9//! then POSTs them to the public reports endpoint (`v2.brep.io/api/report`).
10//! ONE code path runs on both native and wasm.
11//!
12//! ## Screenshot-before-dialog
13//! egui/eframe captures a screenshot of the FRAME (the composited surface, so it
14//! includes the 3D viewport, which is an `egui_wgpu` paint callback into egui's
15//! frame — see [`crate::viewport`]). We must not let the dialog appear in that
16//! frame, so the flow is a small state machine:
17//!   1. Button click → snapshot the model, send `ViewportCommand::Screenshot`,
18//!      enter [`Phase::Capturing`]. The dialog is NOT drawn while capturing.
19//!   2. A later frame delivers `Event::Screenshot`; we encode it to PNG, build a
20//!      preview thumbnail, and enter [`Phase::Editing`] — only NOW is the dialog
21//!      drawn, so the captured frame(s) never contain it.
22//!   3. Submit builds a small multipart body by hand (no extra deps) and fires
23//!      it through `ehttp`; the reply marshals back over an mpsc channel +
24//!      `request_repaint`, exactly like [`crate::panels::step_parts`].
25
26use std::sync::mpsc::Receiver;
27
28use brep_render::engine_state::EngineState;
29use crate::icon_text::IconTextUi as _;
30use eframe::egui;
31
32/// The public reports endpoint the button posts to (the cadDev public server,
33/// fronted by v2.brep.io). Accepts the multipart fields
34/// `description`,`email`,`model`,`screenshot`.
35const REPORT_URL: &str = "https://v2.brep.io/api/report";
36
37/// Frames to wait for the screenshot event before giving up and opening the
38/// dialog anyway (so a device that never delivers the capture can't hang the
39/// flow). ~1s at 60fps; the persistent offscreen 3D means the capture normally
40/// lands within a few frames.
41const CAPTURE_TIMEOUT_FRAMES: u32 = 60;
42
43/// Where the flow is between "button pressed" and "dialog closed".
44#[derive(Default, PartialEq)]
45enum Phase {
46    /// Nothing in progress.
47    #[default]
48    Idle,
49    /// Screenshot requested; dialog intentionally hidden so it isn't captured.
50    Capturing { frames: u32 },
51    /// Screenshot in hand; dialog open, collecting description + email.
52    Editing,
53    /// POST in flight.
54    Sending,
55}
56
57pub struct BugReportPanel {
58    phase: Phase,
59    /// The problem description (required to submit).
60    description: String,
61    /// Optional reporter email.
62    email: String,
63    /// A short status / error line under the buttons.
64    status: String,
65    /// PNG bytes of the pre-dialog screenshot (UI + 3D), if captured.
66    screenshot_png: Option<Vec<u8>>,
67    /// A preview texture of the screenshot shown in the dialog.
68    thumb: Option<egui::TextureHandle>,
69    /// The model (`.BREP.json`) snapshotted at button-press time.
70    model_json: String,
71    /// The in-flight POST reply channel (drained each frame).
72    response_rx: Option<Receiver<Result<(), String>>>,
73    /// Per-frame widget rects for the headed verifier (wasm only).
74    #[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    /// Toolbar entry point: snapshot the model, request a screenshot of THIS
95    /// frame (before the dialog exists), and begin capturing. Ignored if a
96    /// report flow is already in progress.
97    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        // The model can't change while the modal is open, but snapshot it now so
108        // the report reflects exactly the state the user was looking at.
109        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    /// Draw + drive the flow. Called once per frame at ctx level (like the file
116    /// dialog). Idempotent while [`Phase::Idle`].
117    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            // `icon_label`, not `heading`: U+1F41E is a catalogued COLOUR icon, so
134            // this draws the real artwork inline with the title instead of the
135            // font's monochrome outline.
136            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                // Fit the preview to the dialog width, keeping aspect.
171                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    /// While capturing, look for the delivered screenshot; time out gracefully.
211    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        // eframe injects `Event::Screenshot` into the frame's raw input once the
220        // async framebuffer readback completes. Take the newest one.
221        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    /// Drain the POST reply: success closes the dialog with a toast; an error
244    /// stays open so the user can retry.
245    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    /// Build the multipart body and fire the POST (native + wasm via ehttp).
262    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        // `Request::post` sets text/plain; replace it with our multipart type.
274        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    /// Back to idle, dropping the screenshot + preview texture.
294    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    /// Record a widget's screen rect for the headed verifier (wasm only; a no-op
305    /// on native).
306    #[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    /// Logical state for the headed verifier: which phase + whether a shot was
315    /// captured.
316    #[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    /// The published widget hit-rects (egui points) for the headed verifier.
333    #[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
349/// Encode an egui `ColorImage` (the screenshot) to PNG bytes via the pure-Rust
350/// `image` crate (wasm-safe). `None` on a zero-size image or encode error.
351fn 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        // Straight (un-premultiplied) sRGBA, matching a normal PNG.
360        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
369/// Hand-build a `multipart/form-data` body (avoids ehttp's `multipart` feature,
370/// which pulls `rand`→`getrandom` and would need the wasm `js` feature). The
371/// boundary carries a distinctive ASCII prefix plus the payload lengths so it
372/// can't collide with the (text) model JSON or the PNG bytes.
373fn 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}