use std::sync::mpsc::Receiver;
use brep_render::engine_state::EngineState;
use eframe::egui;
const REPORT_URL: &str = "https://v2.brep.io/api/report";
const CAPTURE_TIMEOUT_FRAMES: u32 = 60;
#[derive(Default, PartialEq)]
enum Phase {
#[default]
Idle,
Capturing { frames: u32 },
Editing,
Sending,
}
pub struct BugReportPanel {
phase: Phase,
description: String,
email: String,
status: String,
screenshot_png: Option<Vec<u8>>,
thumb: Option<egui::TextureHandle>,
model_json: String,
response_rx: Option<Receiver<Result<(), String>>>,
#[cfg(target_arch = "wasm32")]
hits: std::collections::HashMap<String, egui::Rect>,
}
impl BugReportPanel {
pub fn new() -> Self {
Self {
phase: Phase::Idle,
description: String::new(),
email: String::new(),
status: String::new(),
screenshot_png: None,
thumb: None,
model_json: String::new(),
response_rx: None,
#[cfg(target_arch = "wasm32")]
hits: std::collections::HashMap::new(),
}
}
pub fn request(&mut self, ctx: &egui::Context, state: &EngineState) {
if self.phase != Phase::Idle {
return;
}
self.description.clear();
self.email.clear();
self.status.clear();
self.screenshot_png = None;
self.thumb = None;
self.response_rx = None;
self.model_json = state.history_request_json();
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) {
#[cfg(target_arch = "wasm32")]
self.hits.clear();
self.poll_capture(ctx);
self.drain_response(state);
if !matches!(self.phase, Phase::Editing | Phase::Sending) {
return;
}
let sending = self.phase == Phase::Sending;
let mut submit = false;
let mut cancel = false;
let modal = egui::Modal::new(egui::Id::new("brep-bug-report")).show(ctx, |ui| {
ui.set_width(560.0);
ui.heading("\u{1F41E} Submit a bug report");
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);
ui.label("What happened?");
let desc = ui.add(
egui::TextEdit::multiline(&mut self.description)
.desired_rows(5)
.desired_width(f32::INFINITY)
.hint_text("Steps, what you expected, and what actually happened"),
);
self.hit("field:description", &desc);
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);
let size = tex.size_vec2();
let scale = (520.0 / size.x).min(1.0);
ui.add(
egui::Image::new((tex.id(), size * scale))
.corner_radius(4.0)
.bg_fill(egui::Color32::from_gray(20)),
);
} else {
ui.weak("(screenshot unavailable — the model + description will still be sent)");
}
ui.add_space(10.0);
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;
}
});
if !self.status.is_empty() {
ui.add_space(6.0);
ui.weak(&self.status);
}
});
if submit {
self.send(ctx);
} else if cancel || (modal.should_close() && !sending) {
self.reset();
}
}
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.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 = format!("Submit failed: {e}");
self.phase = Phase::Editing;
}
}
}
fn send(&mut self, ctx: &egui::Context) {
self.status = "Submitting\u{2026}".into();
self.phase = Phase::Sending;
let (content_type, body) = build_multipart(
&self.description,
&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.screenshot_png = None;
self.thumb = None;
self.response_rx = None;
}
#[cfg(target_arch = "wasm32")]
fn hit(&mut self, key: &str, resp: &egui::Response) {
self.hits.insert(key.to_string(), resp.rect);
}
#[cfg(not(target_arch = "wasm32"))]
#[inline]
fn hit(&mut self, _key: &str, _resp: &egui::Response) {}
#[cfg(target_arch = "wasm32")]
pub fn state_json(&self) -> String {
let phase = match self.phase {
Phase::Idle => "idle",
Phase::Capturing { .. } => "capturing",
Phase::Editing => "editing",
Phase::Sending => "sending",
};
serde_json::json!({
"phase": phase,
"hasScreenshot": self.screenshot_png.is_some(),
"status": self.status,
})
.to_string()
}
#[cfg(target_arch = "wasm32")]
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 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.BREP.json",
"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");
}