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://v2.brep.io/api/report";
const MAX_DESC: usize = 20_000;
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,
diagnostics: String,
response_rx: Option<Receiver<Result<(), String>>>,
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(),
diagnostics: String::new(),
response_rx: None,
hits: std::collections::HashMap::new(),
}
}
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.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::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.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);
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(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.rect);
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(
&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.response_rx = None;
}
fn hit(&mut self, key: &str, resp: &egui::Response) {
self.hits.insert(key.to_string(), resp.rect);
}
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(),
"diagnostics": self.diagnostics,
"status": self.status,
})
.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.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");
}
pub static HIT_KEYS: &[HitKeyDoc] = &[
HitKeyDoc { panel: "bug", prefix: "field:description", meaning: "the description field", 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 },
];