#[path = "bug_report_markup.rs"]
mod markup;
use crate::automation::hit_keys::HitKeyDoc;
use crate::diagnostics::Diagnostics;
use std::sync::mpsc::Receiver;
use brep_render::engine_state::EngineState;
use crate::icon_text::IconTextUi as _;
use eframe::egui;
const REPORT_URL: &str = "https://next.brep.io/api/report";
const FEATURE: &str = "Bug report";
const MAX_DESC: usize = 20_000;
const CAPTURE_TIMEOUT_FRAMES: u32 = 60;
const DIALOG_WIDTH_FRACTION: f32 = 0.90;
const DIALOG_HEIGHT_FRACTION: f32 = 0.95;
const DESCRIPTION_ROWS: usize = 12;
const DESCRIPTION_MARGIN: egui::Margin = egui::Margin::symmetric(4, 2);
const FOOTER_GAP: f32 = 10.0;
const STATUS_GAP: f32 = 6.0;
const MENU_REACHES_CLIPBOARD: bool = cfg!(not(target_arch = "wasm32"));
#[derive(Default, PartialEq)]
enum Phase {
#[default]
Idle,
Capturing { frames: u32 },
Editing,
Marking,
Sending,
}
pub struct BugReportPanel {
phase: Phase,
description: String,
email: String,
status: String,
screenshot_png: Option<Vec<u8>>,
thumb: Option<egui::TextureHandle>,
markup: Option<markup::Markup>,
model_json: String,
diagnostics: String,
response_rx: Option<Receiver<Result<(), String>>>,
hits: std::collections::HashMap<String, egui::Rect>,
description_scroll: DescriptionScroll,
}
#[derive(Clone, Copy, Default, Debug, PartialEq)]
struct DescriptionScroll {
content: f32,
viewport: f32,
offset: f32,
}
impl BugReportPanel {
pub fn new() -> Self {
Self {
phase: Phase::Idle,
description: String::new(),
email: String::new(),
status: String::new(),
screenshot_png: None,
thumb: None,
markup: None,
model_json: String::new(),
diagnostics: String::new(),
response_rx: None,
hits: std::collections::HashMap::new(),
description_scroll: DescriptionScroll::default(),
}
}
pub fn request(&mut self, ctx: &egui::Context, state: &EngineState, diagnostics: &Diagnostics) {
if self.phase != Phase::Idle {
return;
}
self.description.clear();
self.email.clear();
self.status.clear();
self.screenshot_png = None;
self.thumb = None;
self.markup = None;
self.response_rx = None;
self.model_json = state.history_request_json();
self.diagnostics = diagnostics.report_text();
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
self.phase = Phase::Capturing { frames: 0 };
ctx.request_repaint();
}
pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState) {
self.hits.clear();
self.poll_capture(ctx);
self.drain_response(state);
if !matches!(self.phase, Phase::Editing | Phase::Marking | Phase::Sending) {
return;
}
let sending = self.phase == Phase::Sending;
let mut submit = false;
let mut cancel = false;
let marking = self.phase == Phase::Marking;
let mut finish = false;
let window = ctx.content_rect();
let frame = egui::Frame::popup(&ctx.global_style());
let content_size = egui::vec2(
window.width() * DIALOG_WIDTH_FRACTION,
window.height() * DIALOG_HEIGHT_FRACTION,
) - frame.total_margin().sum();
let modal = egui::Modal::new(egui::Id::new("brep-bug-report"))
.frame(frame)
.show(ctx, |ui| {
ui.set_width(content_size.x);
let content_rect = egui::Rect::from_min_size(ui.min_rect().min, content_size);
if marking {
let finish_btn = ui.button("Finish");
self.hit("markup:finish", &finish_btn);
finish = finish_btn.clicked();
ui.separator();
let height =
(content_size.y - ui.min_rect().height() - ui.spacing().item_spacing.y)
.max(0.0);
let budget = egui::Rect::from_min_size(
ui.cursor().min,
egui::vec2(content_size.x, height),
);
ui.scope_builder(egui::UiBuilder::new().max_rect(budget), |ui| {
if let (Some(markup), Some(tex)) = (&mut self.markup, &self.thumb) {
if let Some(image) = markup.show(ui, tex, &mut self.hits) {
self.screenshot_png = encode_png(&image);
self.thumb
.as_mut()
.unwrap()
.set(image, egui::TextureOptions::LINEAR);
}
}
});
ui.expand_to_include_rect(content_rect);
return;
}
ui.icon_label(egui::RichText::new("\u{1F41E} Submit a bug report").heading());
ui.add_space(4.0);
ui.label(
"Describe what went wrong. Your current model and a screenshot of \
the app (UI + 3D view) are attached automatically.",
);
ui.add_space(2.0);
ui.weak("Your report and its screenshot may be shown publicly on the bug list.");
ui.add_space(8.0);
let chrome = ui.min_rect().height();
let body_height = (content_size.y - chrome - self.footer_height(ui)).max(0.0);
let budget = egui::Rect::from_min_size(
ui.cursor().min,
egui::vec2(ui.available_width(), body_height),
);
ui.scope_builder(egui::UiBuilder::new().max_rect(budget), |ui| {
egui::ScrollArea::vertical()
.id_salt("brep-bug-body")
.min_scrolled_height(body_height)
.max_height(body_height)
.auto_shrink([false, false])
.show(ui, |ui| self.body(ui));
});
ui.add_space(FOOTER_GAP);
ui.horizontal(|ui| {
let can_submit = !self.description.trim().is_empty() && !sending;
let label = if sending {
"Sending\u{2026}"
} else {
"Submit report"
};
let submit_btn = ui.add_enabled(can_submit, egui::Button::new(label));
self.hit("submit", &submit_btn);
if submit_btn.clicked() {
submit = true;
}
let cancel_btn = ui.add_enabled(!sending, egui::Button::new("Cancel"));
self.hit("cancel", &cancel_btn);
if cancel_btn.clicked() {
cancel = true;
}
});
ui.add_space(STATUS_GAP);
ui.weak(if self.status.is_empty() {
" "
} else {
self.status.as_str()
});
ui.expand_to_include_rect(content_rect);
});
if marking {
if finish || modal.should_close() {
self.phase = Phase::Editing;
}
} else if submit {
self.send(ctx);
} else if cancel || (modal.should_close() && !sending) {
self.reset();
}
}
fn footer_height(&self, ui: &egui::Ui) -> f32 {
let spacing = ui.spacing();
let buttons = spacing
.interact_size
.y
.max(ui.text_style_height(&egui::TextStyle::Button) + 2.0 * spacing.button_padding.y);
let status = STATUS_GAP
+ ui.text_style_height(&egui::TextStyle::Body)
+ 2.0 * spacing.item_spacing.y;
FOOTER_GAP + buttons + 2.0 * spacing.item_spacing.y + status
}
fn body(&mut self, ui: &mut egui::Ui) {
ui.label("What happened?");
self.description_field(ui);
ui.add_space(6.0);
ui.label("Email (optional)");
let email = ui.add(
egui::TextEdit::singleline(&mut self.email)
.desired_width(f32::INFINITY)
.hint_text("so we can follow up — optional"),
);
self.hit("field:email", &email);
ui.add_space(8.0);
if let Some(tex) = &self.thumb {
ui.label("Attached screenshot:");
ui.add_space(2.0);
if self.markup.is_some() {
let button = ui.add_enabled(
self.phase != Phase::Sending,
egui::Button::new("Mark up screenshot"),
);
self.hits
.insert("markup:toggle".into(), button.interact_rect);
if button.clicked() {
self.phase = Phase::Marking;
}
}
let size = tex.size_vec2();
let scale = (ui.available_width() / size.x).min(240.0 / size.y).min(1.0);
ui.add(egui::Image::new((tex.id(), size * scale)));
} else {
ui.weak("(screenshot unavailable — the model + description will still be sent)");
}
ui.add_space(8.0);
let diag = egui::CollapsingHeader::new("Diagnostics attached to this report")
.id_salt("brep-bug-diagnostics")
.show(ui, |ui| {
ui.add(
egui::Label::new(egui::RichText::new(&self.diagnostics).monospace().small())
.wrap(),
);
});
self.hits.insert(
"diagnostics".to_string(),
diag.header_response.interact_rect,
);
}
fn description_field(&mut self, ui: &mut egui::Ui) {
let cap = DESCRIPTION_ROWS as f32 * ui.text_style_height(&egui::TextStyle::Body)
+ DESCRIPTION_MARGIN.sum().y;
let scroll = egui::ScrollArea::vertical()
.id_salt("brep-bug-description")
.max_height(cap)
.auto_shrink([false, true])
.show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut self.description)
.margin(DESCRIPTION_MARGIN)
.desired_rows(5)
.desired_width(f32::INFINITY)
.hint_text("Steps, what you expected, and what actually happened"),
)
});
self.description_scroll = DescriptionScroll {
content: scroll.content_size.y,
viewport: scroll.inner_rect.height(),
offset: scroll.state.offset.y,
};
let field = scroll.inner;
self.hit("field:description", &field);
field.context_menu(|ui| self.edit_menu(ui, &field));
}
fn edit_menu(&mut self, ui: &mut egui::Ui, field: &egui::Response) {
let entries = [
("menu:cut", "Cut", egui::ViewportCommand::RequestCut),
("menu:copy", "Copy", egui::ViewportCommand::RequestCopy),
("menu:paste", "Paste", egui::ViewportCommand::RequestPaste),
];
for (key, label, command) in entries {
let item = ui.add_enabled(MENU_REACHES_CLIPBOARD, egui::Button::new(label));
self.hit(key, &item);
if item.clicked() {
field.request_focus();
ui.ctx().send_viewport_cmd(command);
ui.close();
}
}
if !MENU_REACHES_CLIPBOARD {
ui.weak("In the browser, use Ctrl+X / C / V (Cmd on a Mac).");
}
}
fn poll_capture(&mut self, ctx: &egui::Context) {
let frames = match &mut self.phase {
Phase::Capturing { frames } => {
*frames += 1;
*frames
}
_ => return,
};
let shot = ctx.input(|i| {
i.raw.events.iter().rev().find_map(|e| match e {
egui::Event::Screenshot { image, .. } => Some(image.clone()),
_ => None,
})
});
if let Some(img) = shot {
self.screenshot_png = encode_png(&img);
self.markup = Some(markup::Markup::new((*img).clone()));
self.thumb = Some(ctx.load_texture(
"brep-bug-shot",
(*img).clone(),
egui::TextureOptions::LINEAR,
));
self.phase = Phase::Editing;
} else if frames > CAPTURE_TIMEOUT_FRAMES {
self.status = "(screenshot unavailable)".into();
self.phase = Phase::Editing;
} else {
ctx.request_repaint();
}
}
fn drain_response(&mut self, state: &mut EngineState) {
let Some(rx) = &self.response_rx else { return };
let Ok(result) = rx.try_recv() else { return };
self.response_rx = None;
match result {
Ok(()) => {
state.push_notice("Bug report submitted — thank you!".to_string());
self.reset();
}
Err(e) => {
self.status = crate::offsite::explain(FEATURE, REPORT_URL, format!("Submit failed: {e}"));
self.phase = Phase::Editing;
}
}
}
fn send(&mut self, ctx: &egui::Context) {
if let Some(sentence) = crate::offsite::refusal(FEATURE, REPORT_URL) {
self.status = sentence;
return;
}
self.status = "Submitting\u{2026}".into();
self.phase = Phase::Sending;
let (content_type, body) = build_multipart(
&compose_description(&self.description, &self.diagnostics),
&self.email,
&self.model_json,
self.screenshot_png.as_deref(),
);
let mut req = ehttp::Request::post(REPORT_URL, body);
req.headers
.headers
.retain(|(k, _)| !k.eq_ignore_ascii_case("content-type"));
req.headers.headers.push(("Content-Type".to_string(), content_type));
let (tx, rx) = std::sync::mpsc::channel();
let ctx = ctx.clone();
ehttp::fetch(req, move |result| {
let out = match result {
Ok(resp) if resp.ok => Ok(()),
Ok(resp) => Err(format!("HTTP {} {}", resp.status, resp.status_text)),
Err(err) => Err(err),
};
let _ = tx.send(out);
ctx.request_repaint();
});
self.response_rx = Some(rx);
}
fn reset(&mut self) {
self.phase = Phase::Idle;
self.description.clear();
self.email.clear();
self.status.clear();
self.diagnostics.clear();
self.screenshot_png = None;
self.thumb = None;
self.markup = None;
self.response_rx = None;
self.description_scroll = DescriptionScroll::default();
}
fn hit(&mut self, key: &str, resp: &egui::Response) {
self.hits.insert(key.to_string(), resp.interact_rect);
}
pub fn state_json(&self) -> String {
let phase = match self.phase {
Phase::Idle => "idle",
Phase::Capturing { .. } => "capturing",
Phase::Editing => "editing",
Phase::Marking => "marking",
Phase::Sending => "sending",
};
serde_json::json!({
"phase": phase,
"hasScreenshot": self.screenshot_png.is_some(),
"annotationCount": self.markup.as_ref().map_or(0, markup::Markup::count),
"diagnostics": self.diagnostics,
"status": self.status,
"descriptionScroll": {
"content": self.description_scroll.content,
"viewport": self.description_scroll.viewport,
"offset": self.description_scroll.offset,
},
})
.to_string()
}
pub fn hits_json(&self) -> String {
let map: serde_json::Map<String, serde_json::Value> = self
.hits
.iter()
.map(|(k, r)| {
(
k.clone(),
serde_json::json!([r.center().x, r.center().y, r.width(), r.height()]),
)
})
.collect();
serde_json::Value::Object(map).to_string()
}
}
fn encode_png(color: &egui::ColorImage) -> Option<Vec<u8>> {
use image::ImageEncoder;
let [w, h] = color.size;
if w == 0 || h == 0 {
return None;
}
let mut rgba = Vec::with_capacity(w * h * 4);
for px in &color.pixels {
rgba.extend_from_slice(&px.to_srgba_unmultiplied());
}
let mut png = Vec::new();
image::codecs::png::PngEncoder::new(&mut png)
.write_image(&rgba, w as u32, h as u32, image::ExtendedColorType::Rgba8)
.ok()?;
Some(png)
}
fn compose_description(description: &str, diagnostics: &str) -> String {
if diagnostics.is_empty() {
return description.chars().take(MAX_DESC).collect();
}
const SEPARATOR: &str = "\n\n";
let block_len = diagnostics.chars().count() + SEPARATOR.chars().count();
let room = MAX_DESC.saturating_sub(block_len);
let user: String = description.chars().take(room).collect();
format!("{user}{SEPARATOR}{diagnostics}")
}
fn build_multipart(
description: &str,
email: &str,
model: &str,
screenshot: Option<&[u8]>,
) -> (String, Vec<u8>) {
let boundary = format!(
"----BREPBugReport{:x}x{:x}Boundary",
model.len(),
screenshot.map(|s| s.len()).unwrap_or(0)
);
let mut body = Vec::new();
push_text_field(&mut body, &boundary, "description", description);
push_text_field(&mut body, &boundary, "email", email);
push_file_field(
&mut body,
&boundary,
"model",
"model.nbrep",
"application/json",
model.as_bytes(),
);
if let Some(png) = screenshot {
push_file_field(&mut body, &boundary, "screenshot", "screenshot.png", "image/png", png);
}
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
(format!("multipart/form-data; boundary={boundary}"), body)
}
fn push_text_field(body: &mut Vec<u8>, boundary: &str, name: &str, value: &str) {
body.extend_from_slice(
format!("--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n")
.as_bytes(),
);
body.extend_from_slice(value.as_bytes());
body.extend_from_slice(b"\r\n");
}
fn push_file_field(
body: &mut Vec<u8>,
boundary: &str,
name: &str,
filename: &str,
content_type: &str,
bytes: &[u8],
) {
body.extend_from_slice(
format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; \
filename=\"{filename}\"\r\nContent-Type: {content_type}\r\n\r\n"
)
.as_bytes(),
);
body.extend_from_slice(bytes);
body.extend_from_slice(b"\r\n");
}
pub static HIT_KEYS: &[HitKeyDoc] = &[
HitKeyDoc { panel: "bug", prefix: "markup:", meaning: "screenshot markup toggle, pen/arrow/rectangle tools, image drawing area, undo and clear", command: None },
HitKeyDoc { panel: "bug", prefix: "field:description", meaning: "the description field (its visible part; right-click opens its Cut / Copy / Paste menu)", command: None },
HitKeyDoc { panel: "bug", prefix: "menu:cut", meaning: "cut the description's selection, from its right-click menu (native; disabled in the browser)", command: None },
HitKeyDoc { panel: "bug", prefix: "menu:copy", meaning: "copy the description's selection, from its right-click menu (native; disabled in the browser)", command: None },
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 },
HitKeyDoc { panel: "bug", prefix: "field:email", meaning: "the email field", command: None },
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 },
HitKeyDoc { panel: "bug", prefix: "submit", meaning: "submit the report", command: None },
HitKeyDoc { panel: "bug", prefix: "cancel", meaning: "close the report", command: None },
];