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 `.nbrep` recipe), and
8//!   * a user **description** (+ an optional email), and
9//!   * the session's **diagnostics** — which renderer is actually in use and
10//!     what it is running on, appended to the description (see
11//!     `compose_description` for why they travel inside that field),
12//! then POSTs them to the public reports endpoint (`next.brep.io/api/report`).
13//! ONE code path runs on both native and wasm.
14//!
15//! ## Screenshot-before-dialog
16//! egui/eframe captures a screenshot of the FRAME (the composited surface, so it
17//! includes the 3D viewport, which is an `egui_wgpu` paint callback into egui's
18//! frame — see [`crate::viewport`]). We must not let the dialog appear in that
19//! frame, so the flow is a small state machine:
20//!   1. Button click → snapshot the model, send `ViewportCommand::Screenshot`,
21//!      enter `Phase::Capturing`. The dialog is NOT drawn while capturing.
22//!   2. A later frame delivers `Event::Screenshot`; we encode it to PNG, build a
23//!      preview thumbnail, and enter `Phase::Editing` — only NOW is the dialog
24//!      drawn, so the captured frame(s) never contain it.
25//!   3. Submit builds a small multipart body by hand (no extra deps) and fires
26//!      it through `ehttp`; the reply marshals back over an mpsc channel +
27//!      `request_repaint`, exactly like [`crate::panels::step_parts`].
28
29#[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
40/// The public reports endpoint the button posts to (fronted
41/// by next.brep.io). Accepts the multipart fields
42/// `description`,`email`,`model`,`screenshot`.
43const REPORT_URL: &str = "https://next.brep.io/api/report";
44
45/// The feature name [`crate::offsite`]'s refusal sentence starts with.
46const FEATURE: &str = "Bug report";
47
48/// The server's cap on the `description` field, in CHARACTERS
49/// (`description.chars().take(MAX_DESC)` in the endpoint's own
50/// `routes/reports.rs`). It truncates the TAIL, and the diagnostics block is at
51/// the tail — so [`compose_description`] clamps the user's own text to leave
52/// room rather than letting a very long description silently cut the
53/// diagnostics off.
54const MAX_DESC: usize = 20_000;
55
56/// Frames to wait for the screenshot event before giving up and opening the
57/// dialog anyway (so a device that never delivers the capture can't hang the
58/// flow). ~1s at 60fps; the persistent offscreen 3D means the capture normally
59/// lands within a few frames.
60const CAPTURE_TIMEOUT_FRAMES: u32 = 60;
61
62/// The report and markup views occupy this fraction of the application window.
63const DIALOG_WIDTH_FRACTION: f32 = 0.90;
64const DIALOG_HEIGHT_FRACTION: f32 = 0.95;
65
66/// How many rows of text the description shows before it scrolls instead of
67/// growing.
68const DESCRIPTION_ROWS: usize = 12;
69
70/// The description field's own text margin (egui's default, named so the
71/// field's cap can count it).
72const DESCRIPTION_MARGIN: egui::Margin = egui::Margin::symmetric(4, 2);
73
74/// The gap above the button row, and between it and the status line.
75const FOOTER_GAP: f32 = 10.0;
76const STATUS_GAP: f32 = 6.0;
77
78/// Whether the description's Cut / Copy / Paste menu can reach the clipboard.
79/// Native eframe turns those requests into the same input as the keyboard
80/// shortcuts; the web runner does not handle them at all, and a browser only
81/// hands a page the clipboard inside its own copy/paste events — so there the
82/// menu says which keys to press instead.
83const MENU_REACHES_CLIPBOARD: bool = cfg!(not(target_arch = "wasm32"));
84
85/// Where the flow is between "button pressed" and "dialog closed".
86#[derive(Default, PartialEq)]
87enum Phase {
88    /// Nothing in progress.
89    #[default]
90    Idle,
91    /// Screenshot requested; dialog intentionally hidden so it isn't captured.
92    Capturing { frames: u32 },
93    /// Screenshot in hand; dialog open, collecting description + email.
94    Editing,
95    /// Dedicated screenshot editor, with the report form hidden.
96    Marking,
97    /// POST in flight.
98    Sending,
99}
100
101pub struct BugReportPanel {
102    phase: Phase,
103    /// The problem description (required to submit).
104    description: String,
105    /// Optional reporter email.
106    email: String,
107    /// A short status / error line under the buttons.
108    status: String,
109    /// PNG bytes of the pre-dialog screenshot (UI + 3D), including markup.
110    screenshot_png: Option<Vec<u8>>,
111    /// A preview texture of the screenshot shown in the dialog.
112    thumb: Option<egui::TextureHandle>,
113    /// Original capture and editable annotations.
114    markup: Option<markup::Markup>,
115    /// The model (`.nbrep`) snapshotted at button-press time.
116    model_json: String,
117    /// The session diagnostics block, taken from the app's ONE
118    /// [`Diagnostics`] at button-press time — the same text the Info window
119    /// shows, so a report can never describe a different machine from the one
120    /// the user was reading about.
121    diagnostics: String,
122    /// The in-flight POST reply channel (drained each frame).
123    /// `Ok` carries the new report's id when the server's reply names one.
124    response_rx: Option<Receiver<Result<Option<String>, String>>>,
125    /// A report just submitted: `(id, summary)`, for the app to remember
126    /// (`crate::report_followup`). Taken by [`Self::take_submitted`].
127    submitted: Option<(String, String)>,
128    /// Per-frame widget rects for the headed verifier (wasm only).
129    hits: std::collections::HashMap<String, egui::Rect>,
130    /// How the description's scroll area stood on the last drawn frame, for the
131    /// verifier: whether a long report overflows the field and where it is
132    /// scrolled to.
133    description_scroll: DescriptionScroll,
134}
135
136/// The description field's scroll extent, in points.
137#[derive(Clone, Copy, Default, Debug, PartialEq)]
138struct DescriptionScroll {
139    /// The height of the text.
140    content: f32,
141    /// The height of the field that shows it.
142    viewport: f32,
143    /// How far down the text is scrolled.
144    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    /// Toolbar entry point: snapshot the model + the session diagnostics,
167    /// request a screenshot of THIS frame (before the dialog exists), and begin
168    /// capturing. Ignored if a report flow is already in progress.
169    ///
170    /// `diagnostics` is the app's ONE instance — the report renders it here
171    /// rather than collecting anything of its own, which is what keeps the
172    /// submitted text and the Info window's rows the same rows.
173    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        // The model can't change while the modal is open, but snapshot it now so
185        // the report reflects exactly the state the user was looking at.
186        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    /// [`Self::request`] from a dialog's Report problem button: the same flow,
194    /// with the description already naming the dialog and its error.
195    pub fn request_for_error(
196        &mut self,
197        ctx: &egui::Context,
198        state: &EngineState,
199        diagnostics: &Diagnostics,
200        dialog: &str,
201        error: &str,
202    ) {
203        if self.phase != Phase::Idle {
204            return;
205        }
206        self.request(ctx, state, diagnostics);
207        self.description = error_description(dialog, error);
208    }
209
210    /// Draw + drive the flow. Called once per frame at ctx level (like the file
211    /// dialog). Idempotent while `Phase::Idle`.
212    pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState) {
213        self.hits.clear();
214
215        self.poll_capture(ctx);
216        self.drain_response(state);
217
218        if !matches!(self.phase, Phase::Editing | Phase::Marking | Phase::Sending) {
219            return;
220        }
221
222        let sending = self.phase == Phase::Sending;
223        let mut submit = false;
224        let mut cancel = false;
225        let marking = self.phase == Phase::Marking;
226        let mut finish = false;
227        let window = ctx.content_rect();
228        let frame = egui::Frame::popup(&ctx.global_style());
229        let content_size = egui::vec2(
230            window.width() * DIALOG_WIDTH_FRACTION,
231            window.height() * DIALOG_HEIGHT_FRACTION,
232        ) - frame.total_margin().sum();
233        let modal = egui::Modal::new(egui::Id::new("brep-bug-report"))
234            .frame(frame)
235            .show(ctx, |ui| {
236                ui.set_width(content_size.x);
237                let content_rect = egui::Rect::from_min_size(ui.min_rect().min, content_size);
238                if marking {
239                    let finish_btn = ui.button("Finish");
240                    self.hit("markup:finish", &finish_btn);
241                    finish = finish_btn.clicked();
242                    ui.separator();
243                    let height =
244                        (content_size.y - ui.min_rect().height() - ui.spacing().item_spacing.y)
245                            .max(0.0);
246                    let budget = egui::Rect::from_min_size(
247                        ui.cursor().min,
248                        egui::vec2(content_size.x, height),
249                    );
250                    ui.scope_builder(egui::UiBuilder::new().max_rect(budget), |ui| {
251                        if let (Some(markup), Some(tex)) = (&mut self.markup, &self.thumb) {
252                            if let Some(image) = markup.show(ui, tex, &mut self.hits) {
253                                self.screenshot_png = encode_png(&image);
254                                self.thumb
255                                    .as_mut()
256                                    .unwrap()
257                                    .set(image, egui::TextureOptions::LINEAR);
258                            }
259                        }
260                    });
261                    ui.expand_to_include_rect(content_rect);
262                    return;
263                }
264                // `icon_label`, not `heading`: U+1F41E is a catalogued COLOUR icon, so
265                // this draws the real artwork inline with the title instead of the
266                // font's monochrome outline.
267                ui.icon_label(egui::RichText::new("\u{1F41E}  Submit a bug report").heading());
268                ui.add_space(4.0);
269                ui.label(
270                    "Describe what went wrong. Your current model and a screenshot of \
271                 the app (UI + 3D view) are attached automatically.",
272                );
273                ui.add_space(2.0);
274                ui.weak("Your report and its screenshot may be shown publicly on the bug list.");
275                ui.add_space(8.0);
276
277                // Keep the body at a fixed height within the proportional modal,
278                // leaving the footer visible even when the report body scrolls.
279                let chrome = ui.min_rect().height();
280                let body_height = (content_size.y - chrome - self.footer_height(ui)).max(0.0);
281                let budget = egui::Rect::from_min_size(
282                    ui.cursor().min,
283                    egui::vec2(ui.available_width(), body_height),
284                );
285                ui.scope_builder(egui::UiBuilder::new().max_rect(budget), |ui| {
286                    egui::ScrollArea::vertical()
287                        .id_salt("brep-bug-body")
288                        .min_scrolled_height(body_height)
289                        .max_height(body_height)
290                        .auto_shrink([false, false])
291                        .show(ui, |ui| self.body(ui));
292                });
293
294                ui.add_space(FOOTER_GAP);
295                ui.horizontal(|ui| {
296                    let can_submit = !self.description.trim().is_empty() && !sending;
297                    let label = if sending {
298                        "Sending\u{2026}"
299                    } else {
300                        "Submit report"
301                    };
302                    let submit_btn = ui.add_enabled(can_submit, egui::Button::new(label));
303                    self.hit("submit", &submit_btn);
304                    if submit_btn.clicked() {
305                        submit = true;
306                    }
307                    let cancel_btn = ui.add_enabled(!sending, egui::Button::new("Cancel"));
308                    self.hit("cancel", &cancel_btn);
309                    if cancel_btn.clicked() {
310                        cancel = true;
311                    }
312                });
313                // The status line's row is always there, empty or not, so a status
314                // appearing ("Submitting…") does not change the dialog's size either.
315                ui.add_space(STATUS_GAP);
316                ui.weak(if self.status.is_empty() {
317                    " "
318                } else {
319                    self.status.as_str()
320                });
321                ui.expand_to_include_rect(content_rect);
322            });
323
324        if marking {
325            if finish || modal.should_close() {
326                self.phase = Phase::Editing;
327            }
328        } else if submit {
329            self.send(ctx);
330        } else if cancel || (modal.should_close() && !sending) {
331            self.reset();
332        }
333    }
334
335    /// The height [`Self::show`] draws under the body: the gap, the button row
336    /// and the status line's row, which is always drawn. Worked out from the
337    /// style the footer is drawn with, so the body's budget is right on the
338    /// first frame.
339    fn footer_height(&self, ui: &egui::Ui) -> f32 {
340        let spacing = ui.spacing();
341        let buttons = spacing
342            .interact_size
343            .y
344            .max(ui.text_style_height(&egui::TextStyle::Button) + 2.0 * spacing.button_padding.y);
345        let status = STATUS_GAP
346            + ui.text_style_height(&egui::TextStyle::Body)
347            + 2.0 * spacing.item_spacing.y;
348        FOOTER_GAP + buttons + 2.0 * spacing.item_spacing.y + status
349    }
350
351    /// The dialog's scrolling body: the description, the email, the attached
352    /// screenshot and the diagnostics.
353    fn body(&mut self, ui: &mut egui::Ui) {
354        ui.label("What happened?");
355        self.description_field(ui);
356
357        ui.add_space(6.0);
358        ui.label("Email (optional)");
359        let email = ui.add(
360            egui::TextEdit::singleline(&mut self.email)
361                .desired_width(f32::INFINITY)
362                .hint_text("so we can follow up — optional"),
363        );
364        self.hit("field:email", &email);
365
366        ui.add_space(8.0);
367        if let Some(tex) = &self.thumb {
368            ui.label("Attached screenshot:");
369            ui.add_space(2.0);
370            if self.markup.is_some() {
371                let button = ui.add_enabled(
372                    self.phase != Phase::Sending,
373                    egui::Button::new("Mark up screenshot"),
374                );
375                self.hits
376                    .insert("markup:toggle".into(), button.interact_rect);
377                if button.clicked() {
378                    self.phase = Phase::Marking;
379                }
380            }
381            let size = tex.size_vec2();
382            let scale = (ui.available_width() / size.x).min(240.0 / size.y).min(1.0);
383            ui.add(egui::Image::new((tex.id(), size * scale)));
384        } else {
385            ui.weak("(screenshot unavailable — the model + description will still be sent)");
386        }
387
388        // What the report will carry about this machine, shown before it is
389        // sent rather than attached invisibly. Collapsed: it is for the
390        // triager, and the user has already read it in the Info window if
391        // they wanted to.
392        ui.add_space(8.0);
393        let diag = egui::CollapsingHeader::new("Diagnostics attached to this report")
394            .id_salt("brep-bug-diagnostics")
395            .show(ui, |ui| {
396                ui.add(
397                    egui::Label::new(egui::RichText::new(&self.diagnostics).monospace().small())
398                        .wrap(),
399                );
400            });
401        self.hits.insert(
402            "diagnostics".to_string(),
403            diag.header_response.interact_rect,
404        );
405    }
406
407    /// The description. It grows with its text up to [`DESCRIPTION_ROWS`] rows
408    /// and then scrolls inside its own bar, so a long report never grows the
409    /// dialog; the field follows the caret as the user types.
410    ///
411    /// A bare scroll area is enough here because the body it sits in was given
412    /// an explicit budget: the room it measures is that budget, not the size
413    /// the modal happened to have last frame.
414    fn description_field(&mut self, ui: &mut egui::Ui) {
415        let cap = DESCRIPTION_ROWS as f32 * ui.text_style_height(&egui::TextStyle::Body)
416            + DESCRIPTION_MARGIN.sum().y;
417        let scroll = egui::ScrollArea::vertical()
418            .id_salt("brep-bug-description")
419            .max_height(cap)
420            .auto_shrink([false, true])
421            .show(ui, |ui| {
422                ui.add(
423                    egui::TextEdit::multiline(&mut self.description)
424                        .margin(DESCRIPTION_MARGIN)
425                        .desired_rows(5)
426                        .desired_width(f32::INFINITY)
427                        .hint_text("Steps, what you expected, and what actually happened"),
428                )
429            });
430        self.description_scroll = DescriptionScroll {
431            content: scroll.content_size.y,
432            viewport: scroll.inner_rect.height(),
433            offset: scroll.state.offset.y,
434        };
435        let field = scroll.inner;
436        self.hit("field:description", &field);
437        field.context_menu(|ui| self.edit_menu(ui, &field));
438    }
439
440    /// The description's right-click menu: Cut, Copy and Paste. An egui text
441    /// field has no menu of its own, and right-click → Paste is how a lot of
442    /// people paste — on a Mac especially, where the reporter looked for it.
443    ///
444    /// Each entry asks the platform for exactly what its keyboard shortcut
445    /// does, and first hands focus back to the field: clicking the menu took
446    /// focus away, and a text field ignores a paste it is not focused for. The
447    /// platform delivers the action as input on the NEXT frame, by which time
448    /// the field has focus again.
449    fn edit_menu(&mut self, ui: &mut egui::Ui, field: &egui::Response) {
450        let entries = [
451            ("menu:cut", "Cut", egui::ViewportCommand::RequestCut),
452            ("menu:copy", "Copy", egui::ViewportCommand::RequestCopy),
453            ("menu:paste", "Paste", egui::ViewportCommand::RequestPaste),
454        ];
455        for (key, label, command) in entries {
456            let item = ui.add_enabled(MENU_REACHES_CLIPBOARD, egui::Button::new(label));
457            self.hit(key, &item);
458            if item.clicked() {
459                field.request_focus();
460                ui.ctx().send_viewport_cmd(command);
461                ui.close();
462            }
463        }
464        if !MENU_REACHES_CLIPBOARD {
465            ui.weak("In the browser, use Ctrl+X / C / V (Cmd on a Mac).");
466        }
467    }
468
469    /// While capturing, look for the delivered screenshot; time out gracefully.
470    fn poll_capture(&mut self, ctx: &egui::Context) {
471        let frames = match &mut self.phase {
472            Phase::Capturing { frames } => {
473                *frames += 1;
474                *frames
475            }
476            _ => return,
477        };
478        // eframe injects `Event::Screenshot` into the frame's raw input once the
479        // async framebuffer readback completes. Take the newest one.
480        let shot = ctx.input(|i| {
481            i.raw.events.iter().rev().find_map(|e| match e {
482                egui::Event::Screenshot { image, .. } => Some(image.clone()),
483                _ => None,
484            })
485        });
486        if let Some(img) = shot {
487            self.screenshot_png = encode_png(&img);
488            self.markup = Some(markup::Markup::new((*img).clone()));
489            self.thumb = Some(ctx.load_texture(
490                "brep-bug-shot",
491                (*img).clone(),
492                egui::TextureOptions::LINEAR,
493            ));
494            self.phase = Phase::Editing;
495        } else if frames > CAPTURE_TIMEOUT_FRAMES {
496            self.status = "(screenshot unavailable)".into();
497            self.phase = Phase::Editing;
498        } else {
499            ctx.request_repaint();
500        }
501    }
502
503    /// Drain the POST reply: success closes the dialog with a toast; an error
504    /// stays open so the user can retry.
505    fn drain_response(&mut self, state: &mut EngineState) {
506        let Some(rx) = &self.response_rx else { return };
507        let Ok(result) = rx.try_recv() else { return };
508        self.response_rx = None;
509        match result {
510            Ok(id) => {
511                state.push_notice("Bug report submitted — thank you!".to_string());
512                self.submitted = id.map(|id| (id, crate::report_followup::summary_of(&self.description)));
513                self.reset();
514            }
515            Err(e) => {
516                self.status = crate::offsite::explain(FEATURE, REPORT_URL, format!("Submit failed: {e}"));
517                self.phase = Phase::Editing;
518            }
519        }
520    }
521
522    /// Build the multipart body and fire the POST (native + wasm via ehttp).
523    fn send(&mut self, ctx: &egui::Context) {
524        // On a PLM-hosted app that does not allow the report site, say so
525        // instead of letting the browser refuse the request (crate::offsite).
526        if let Some(sentence) = crate::offsite::refusal(FEATURE, REPORT_URL) {
527            self.status = sentence;
528            return;
529        }
530        self.status = "Submitting\u{2026}".into();
531        self.phase = Phase::Sending;
532
533        let (content_type, body) = build_multipart(
534            &compose_description(&self.description, &self.diagnostics),
535            &self.email,
536            &self.model_json,
537            self.screenshot_png.as_deref(),
538        );
539        let mut req = ehttp::Request::post(REPORT_URL, body);
540        // `Request::post` sets text/plain; replace it with our multipart type.
541        req.headers
542            .headers
543            .retain(|(k, _)| !k.eq_ignore_ascii_case("content-type"));
544        req.headers.headers.push(("Content-Type".to_string(), content_type));
545
546        let (tx, rx) = std::sync::mpsc::channel();
547        let ctx = ctx.clone();
548        ehttp::fetch(req, move |result| {
549            let out = match result {
550                Ok(resp) if resp.ok => Ok(crate::report_followup::id_of_reply(&resp.bytes)),
551                Ok(resp) => Err(format!("HTTP {} {}", resp.status, resp.status_text)),
552                Err(err) => Err(err),
553            };
554            let _ = tx.send(out);
555            ctx.request_repaint();
556        });
557        self.response_rx = Some(rx);
558    }
559
560    /// The report submitted since the last call: `(id, summary)`.
561    pub fn take_submitted(&mut self) -> Option<(String, String)> {
562        self.submitted.take()
563    }
564
565    /// Back to idle, dropping the screenshot + preview texture.
566    fn reset(&mut self) {
567        self.phase = Phase::Idle;
568        self.description.clear();
569        self.email.clear();
570        self.status.clear();
571        self.diagnostics.clear();
572        self.screenshot_png = None;
573        self.thumb = None;
574        self.markup = None;
575        self.response_rx = None;
576        self.description_scroll = DescriptionScroll::default();
577    }
578
579    /// Record a widget's screen rect for the verifier — its VISIBLE part: the
580    /// body and the description both scroll, and a click at the centre of a
581    /// rect that runs past its scroll area's clip lands on nothing.
582    fn hit(&mut self, key: &str, resp: &egui::Response) {
583        self.hits.insert(key.to_string(), resp.interact_rect);
584    }
585
586    /// Logical state for the headed verifier: which phase + whether a shot was
587    /// captured.
588    pub fn state_json(&self) -> String {
589        let phase = match self.phase {
590            Phase::Idle => "idle",
591            Phase::Capturing { .. } => "capturing",
592            Phase::Editing => "editing",
593            Phase::Marking => "marking",
594            Phase::Sending => "sending",
595        };
596        serde_json::json!({
597            "phase": phase,
598            "hasScreenshot": self.screenshot_png.is_some(),
599            "description": self.description,
600            "annotationCount": self.markup.as_ref().map_or(0, markup::Markup::count),
601            "diagnostics": self.diagnostics,
602            "status": self.status,
603            "descriptionScroll": {
604                "content": self.description_scroll.content,
605                "viewport": self.description_scroll.viewport,
606                "offset": self.description_scroll.offset,
607            },
608        })
609        .to_string()
610    }
611
612    /// The published widget hit-rects (egui points) for the headed verifier.
613    pub fn hits_json(&self) -> String {
614        let map: serde_json::Map<String, serde_json::Value> = self
615            .hits
616            .iter()
617            .map(|(k, r)| {
618                (
619                    k.clone(),
620                    serde_json::json!([r.center().x, r.center().y, r.width(), r.height()]),
621                )
622            })
623            .collect();
624        serde_json::Value::Object(map).to_string()
625    }
626}
627
628/// The description a dialog's Report problem button starts from. The dialog and
629/// the error lead, because the server's list and notification summarise a report
630/// by its first line; the blank line leaves the user room to add to it.
631fn error_description(dialog: &str, error: &str) -> String {
632    let dialog = dialog.split_whitespace().collect::<Vec<_>>().join(" ");
633    format!("{dialog}: {error}\n\n")
634}
635
636/// Encode an egui `ColorImage` (the screenshot) to PNG bytes via the pure-Rust
637/// `image` crate (wasm-safe). `None` on a zero-size image or encode error.
638fn encode_png(color: &egui::ColorImage) -> Option<Vec<u8>> {
639    use image::ImageEncoder;
640    let [w, h] = color.size;
641    if w == 0 || h == 0 {
642        return None;
643    }
644    let mut rgba = Vec::with_capacity(w * h * 4);
645    for px in &color.pixels {
646        // Straight (un-premultiplied) sRGBA, matching a normal PNG.
647        rgba.extend_from_slice(&px.to_srgba_unmultiplied());
648    }
649    let mut png = Vec::new();
650    image::codecs::png::PngEncoder::new(&mut png)
651        .write_image(&rgba, w as u32, h as u32, image::ExtendedColorType::Rgba8)
652        .ok()?;
653    Some(png)
654}
655
656/// The `description` the report actually submits: what the user typed, then the
657/// diagnostics block.
658///
659/// **Why inside `description` and not its own part.** The endpoint
660/// (`next.brep.io/api/report`) parses multipart into a fixed four-field record
661/// and its match ends `_ => {}` — an unknown part is accepted and silently
662/// DROPPED, never rejected. A `diagnostics` part would therefore submit cleanly
663/// and arrive nowhere, which is the worst of both: a client that looks like it
664/// reports the renderer and a server that never stores it. `description` is
665/// stored verbatim in `report.json` and is what a triager reads first, so the
666/// block goes there until the server grows a field of its own.
667///
668/// APPENDED, never prepended: the server's push notification and its public
669/// report list both summarise a report by its FIRST LINE, and a list where every
670/// row reads "--- diagnostics ---" tells a reader nothing.
671///
672/// The user's own text is clamped so the block survives the server's
673/// [`MAX_DESC`] tail truncation. A description long enough to hit that cap has
674/// already said what it has to say; the diagnostics are the part that cannot be
675/// re-derived later.
676fn compose_description(description: &str, diagnostics: &str) -> String {
677    if diagnostics.is_empty() {
678        return description.chars().take(MAX_DESC).collect();
679    }
680    const SEPARATOR: &str = "\n\n";
681    // `chars`, matching the server's own `chars().take(MAX_DESC)`.
682    let block_len = diagnostics.chars().count() + SEPARATOR.chars().count();
683    let room = MAX_DESC.saturating_sub(block_len);
684    let user: String = description.chars().take(room).collect();
685    format!("{user}{SEPARATOR}{diagnostics}")
686}
687
688/// Hand-build a `multipart/form-data` body (avoids ehttp's `multipart` feature,
689/// which pulls `rand`→`getrandom` and would need the wasm `js` feature). The
690/// boundary carries a distinctive ASCII prefix plus the payload lengths so it
691/// can't collide with the (text) model JSON or the PNG bytes.
692fn build_multipart(
693    description: &str,
694    email: &str,
695    model: &str,
696    screenshot: Option<&[u8]>,
697) -> (String, Vec<u8>) {
698    let boundary = format!(
699        "----BREPBugReport{:x}x{:x}Boundary",
700        model.len(),
701        screenshot.map(|s| s.len()).unwrap_or(0)
702    );
703    let mut body = Vec::new();
704    push_text_field(&mut body, &boundary, "description", description);
705    push_text_field(&mut body, &boundary, "email", email);
706    push_file_field(
707        &mut body,
708        &boundary,
709        "model",
710        "model.nbrep",
711        "application/json",
712        model.as_bytes(),
713    );
714    if let Some(png) = screenshot {
715        push_file_field(&mut body, &boundary, "screenshot", "screenshot.png", "image/png", png);
716    }
717    body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
718    (format!("multipart/form-data; boundary={boundary}"), body)
719}
720
721fn push_text_field(body: &mut Vec<u8>, boundary: &str, name: &str, value: &str) {
722    body.extend_from_slice(
723        format!("--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n")
724            .as_bytes(),
725    );
726    body.extend_from_slice(value.as_bytes());
727    body.extend_from_slice(b"\r\n");
728}
729
730fn push_file_field(
731    body: &mut Vec<u8>,
732    boundary: &str,
733    name: &str,
734    filename: &str,
735    content_type: &str,
736    bytes: &[u8],
737) {
738    body.extend_from_slice(
739        format!(
740            "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; \
741             filename=\"{filename}\"\r\nContent-Type: {content_type}\r\n\r\n"
742        )
743        .as_bytes(),
744    );
745    body.extend_from_slice(bytes);
746    body.extend_from_slice(b"\r\n");
747}
748
749/// The hit keys this panel publishes (see `automation::hit_keys`).
750pub static HIT_KEYS: &[HitKeyDoc] = &[
751    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 },
752    HitKeyDoc { panel: "bug", prefix: "field:description", meaning: "the description field (its visible part; right-click opens its Cut / Copy / Paste menu)", command: None },
753    HitKeyDoc { panel: "bug", prefix: "menu:cut", meaning: "cut the description's selection, from its right-click menu (native; disabled in the browser)", command: None },
754    HitKeyDoc { panel: "bug", prefix: "menu:copy", meaning: "copy the description's selection, from its right-click menu (native; disabled in the browser)", command: None },
755    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 },
756    HitKeyDoc { panel: "bug", prefix: "field:email", meaning: "the email field", command: None },
757    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 },
758    HitKeyDoc { panel: "bug", prefix: "submit", meaning: "submit the report", command: None },
759    HitKeyDoc { panel: "bug", prefix: "cancel", meaning: "close the report", command: None },
760];
761