1
//! The `SysDialogType::ReportProblem` dialog: user message + optional
2
//! screenshot of the CURRENT window + optional system information, delivered
3
//! to the app's support mailbox (`AppConfig.report_problem`) — or saved to
4
//! disk when no mailbox / no mail transport is available. Nothing leaves the
5
//! machine before the user presses **Send**.
6
//!
7
//! The screenshot is captured IN-PROCESS at invoke time
8
//! (`CallbackInfo::take_screenshot` re-renders the current display list on
9
//! the CPU), so it shows the user's real situation — no state
10
//! serialization round-trip needed for the common case.
11

            
12
// fn-item -> fn-pointer casts: required for the Into<Callback> generics;
13
// the annotated-temporary alternative buries the callback wiring.
14
#![allow(trivial_casts)]
15
use azul_core::{
16
    callbacks::Update,
17
    refany::RefAny,
18
    task::{ThreadId, ThreadReceiver},
19
};
20
use azul_css::AzString;
21

            
22
use super::{cpu_dialog_window, style};
23
use crate::callbacks::CallbackInfo;
24
use azul_core::callbacks::{LayoutCallbackInfo, LayoutCallbackType};
25
use crate::thread::{
26
    Thread, ThreadCallbackType, ThreadReceiveMsg, ThreadSender, ThreadWriteBackMsg,
27
    WriteBackCallbackType,
28
};
29
use crate::widgets::button::{Button, ButtonOnClickCallbackType};
30
use crate::widgets::check_box::{CheckBox, CheckBoxOnToggleCallbackType, CheckBoxState};
31
use crate::widgets::text_area::{TextArea, TextAreaOnTextInputCallbackType, TextAreaState};
32
use crate::widgets::text_input::{OnTextInputReturn, TextInputValid};
33
use azul_core::dom::Dom;
34

            
35
/// Where the report currently is.
36
#[derive(Debug, Clone, PartialEq, Eq)]
37
pub enum ReportStatus {
38
    /// The user is typing.
39
    Editing,
40
    /// The send worker is running.
41
    Sending,
42
    /// Delivered (or saved) — the message names where it went.
43
    Done(String),
44
    /// Transport failed; the report stays editable for a retry.
45
    Failed(String),
46
}
47

            
48
/// Shared dialog state.
49
// Each bool is one independent opt-in checkbox in the dialog.
50
#[allow(clippy::struct_excessive_bools)]
51
#[derive(Debug, Clone)]
52
pub struct ReportProblemState {
53
    /// Destination mailbox (from `AppConfig.report_problem`); None = save to
54
    /// disk instead of mailing.
55
    pub email: Option<String>,
56
    /// The user's description.
57
    pub message: String,
58
    /// Attach the best-effort hardware/OS block.
59
    pub include_sysinfo: bool,
60
    /// Attach the screenshot below.
61
    pub attach_screenshot: bool,
62
    /// PNG of the window the dialog was invoked from, captured at invoke
63
    /// time (None when the capture failed).
64
    pub screenshot_png: Option<Vec<u8>>,
65
    /// Attach the ACTION JOURNAL (handler names + nodes, no user content).
66
    pub include_actions: bool,
67
    /// Attach the app's serialized state. DEFAULT OFF: this is the one
68
    /// section that can carry the user's actual document.
69
    pub include_app_data: bool,
70
    /// Blackout rectangles the user drew on the preview, in PREVIEW
71
    /// coordinates. Applied to the PNG that is actually sent.
72
    pub redactions: Vec<crate::dialogs::report::RedactRect>,
73
    /// First corner of the blackout currently being dragged.
74
    pub drag_start: Option<(f32, f32)>,
75
    /// `image_px / preview_px`, so a rectangle drawn on the preview lands on
76
    /// the right pixels of the full-size capture.
77
    pub preview_scale: f32,
78
    /// Preview size in logical pixels (width, height).
79
    pub preview_size: (f32, f32),
80
    pub status: ReportStatus,
81
}
82

            
83
/// Opens the dialog. `screenshot_png` is the already-captured window
84
/// screenshot (the capture happens in `invoke_system_dialog`, BEFORE this
85
/// window exists, so the dialog itself is never in the picture).
86
pub fn open(info: &mut CallbackInfo, screenshot_png: Option<Vec<u8>>) {
87
    let env = crate::appenv::app_env();
88
    // Fit the capture into the dialog: the preview is a scaled copy, and the
89
    // scale is REMEMBERED so a blackout drawn here maps to the real pixels.
90
    const PREVIEW_MAX_W: f32 = 460.0;
91
    let (preview_size, preview_scale) = screenshot_png
92
        .as_deref()
93
        .and_then(|png| crate::cpurender::AzulPixmap::decode_png(png).ok())
94
        .map_or(((0.0, 0.0), 1.0), |p| {
95
            #[allow(clippy::cast_precision_loss)]
96
            let (w, h) = (p.width() as f32, p.height() as f32);
97
            if w <= 0.0 || h <= 0.0 {
98
                return ((0.0, 0.0), 1.0);
99
            }
100
            let scale = (w / PREVIEW_MAX_W).max(1.0);
101
            ((w / scale, h / scale), scale)
102
        });
103
    let state = RefAny::new(ReportProblemState {
104
        email: env.report_problem,
105
        message: String::new(),
106
        include_sysinfo: true,
107
        attach_screenshot: screenshot_png.is_some(),
108
        screenshot_png,
109
        // The journal carries handler names and node ids, never user
110
        // content, so it defaults ON; app data is the user's document and
111
        // defaults OFF.
112
        include_actions: crate::journal::is_enabled(),
113
        include_app_data: false,
114
        redactions: Vec::new(),
115
        drag_start: None,
116
        preview_scale,
117
        preview_size,
118
        status: ReportStatus::Editing,
119
    });
120
    info.create_window(cpu_dialog_window(
121
        "Report a Problem",
122
        (520.0, 480.0),
123
        dialog_layout as LayoutCallbackType,
124
        state,
125
    ));
126
}
127

            
128
// --- widget callbacks -----------------------------------------------------
129

            
130
extern "C" fn on_message_input(
131
    mut state: RefAny,
132
    _info: CallbackInfo,
133
    text_state: TextAreaState,
134
) -> OnTextInputReturn {
135
    if let Some(mut s) = state.downcast_mut::<ReportProblemState>() {
136
        s.message = text_state.get_text();
137
    }
138
    OnTextInputReturn {
139
        update: Update::DoNothing,
140
        valid: TextInputValid::Yes,
141
    }
142
}
143

            
144
extern "C" fn on_toggle_sysinfo(
145
    mut state: RefAny,
146
    _info: CallbackInfo,
147
    cb: CheckBoxState,
148
) -> Update {
149
    if let Some(mut s) = state.downcast_mut::<ReportProblemState>() {
150
        s.include_sysinfo = cb.checked;
151
    }
152
    Update::DoNothing
153
}
154

            
155
extern "C" fn on_toggle_screenshot(
156
    mut state: RefAny,
157
    _info: CallbackInfo,
158
    cb: CheckBoxState,
159
) -> Update {
160
    if let Some(mut s) = state.downcast_mut::<ReportProblemState>() {
161
        s.attach_screenshot = cb.checked;
162
    }
163
    Update::DoNothing
164
}
165

            
166
extern "C" fn on_toggle_actions(
167
    mut state: RefAny,
168
    _info: CallbackInfo,
169
    cb: CheckBoxState,
170
) -> Update {
171
    if let Some(mut s) = state.downcast_mut::<ReportProblemState>() {
172
        s.include_actions = cb.checked;
173
    }
174
    Update::DoNothing
175
}
176

            
177
extern "C" fn on_toggle_app_data(
178
    mut state: RefAny,
179
    _info: CallbackInfo,
180
    cb: CheckBoxState,
181
) -> Update {
182
    if let Some(mut s) = state.downcast_mut::<ReportProblemState>() {
183
        s.include_app_data = cb.checked;
184
    }
185
    Update::DoNothing
186
}
187

            
188
/// First corner of a blackout drag.
189
extern "C" fn on_preview_mouse_down(mut state: RefAny, mut info: CallbackInfo) -> Update {
190
    let Some(pos) = info.get_cursor_relative_to_node().into_option() else {
191
        return Update::DoNothing;
192
    };
193
    if let Some(mut s) = state.downcast_mut::<ReportProblemState>() {
194
        s.drag_start = Some((pos.x, pos.y));
195
    }
196
    Update::DoNothing
197
}
198

            
199
/// Second corner: the rectangle is added and drawn over the preview. It is
200
/// applied to the PNG at SEND time (see `on_send`) — the overlay here is a
201
/// preview of a redaction that really happens, not the redaction itself.
202
extern "C" fn on_preview_mouse_up(mut state: RefAny, mut info: CallbackInfo) -> Update {
203
    let Some(pos) = info.get_cursor_relative_to_node().into_option() else {
204
        return Update::DoNothing;
205
    };
206
    let Some(mut s) = state.downcast_mut::<ReportProblemState>() else {
207
        return Update::DoNothing;
208
    };
209
    let Some((sx, sy)) = s.drag_start.take() else {
210
        return Update::DoNothing;
211
    };
212
    let rect = crate::dialogs::report::RedactRect::from_corners(sx, sy, pos.x, pos.y);
213
    if rect.is_empty() {
214
        return Update::DoNothing;
215
    }
216
    s.redactions.push(rect);
217
    Update::RefreshDomAllWindows
218
}
219

            
220
extern "C" fn on_clear_redactions(mut state: RefAny, _info: CallbackInfo) -> Update {
221
    if let Some(mut s) = state.downcast_mut::<ReportProblemState>() {
222
        s.redactions.clear();
223
        s.drag_start = None;
224
    }
225
    Update::RefreshDomAllWindows
226
}
227

            
228
extern "C" fn on_cancel(mut _state: RefAny, mut info: CallbackInfo) -> Update {
229
    info.close_window();
230
    Update::DoNothing
231
}
232

            
233
extern "C" fn on_send(mut state: RefAny, mut info: CallbackInfo) -> Update {
234
    let task = {
235
        let Some(mut s) = state.downcast_mut::<ReportProblemState>() else {
236
            return Update::DoNothing;
237
        };
238
        if s.status == ReportStatus::Sending {
239
            return Update::DoNothing;
240
        }
241
        s.status = ReportStatus::Sending;
242
        // The redactions are applied HERE, to the bytes that will be
243
        // attached. If the blackout cannot be applied, the screenshot is
244
        // DROPPED rather than sent unredacted.
245
        let screenshot = if s.attach_screenshot {
246
            match (&s.screenshot_png, s.redactions.is_empty()) {
247
                (Some(png), true) => Some(png.clone()),
248
                (Some(png), false) => crate::dialogs::report::redact_png(
249
                    png,
250
                    &s.redactions,
251
                    s.preview_scale,
252
                )
253
                .ok(),
254
                (None, _) => None,
255
            }
256
        } else {
257
            None
258
        };
259
        SendTask {
260
            email: s.email.clone(),
261
            report: build_report_text(
262
                &s.message,
263
                s.include_sysinfo,
264
                s.include_actions,
265
            ),
266
            screenshot,
267
            recent_actions: if s.include_actions {
268
                Some(crate::journal::recent_json(crate::journal::DEFAULT_CAPACITY))
269
            } else {
270
                None
271
            },
272
            app_data: if s.include_app_data {
273
                app_data_json(&info)
274
            } else {
275
                None
276
            },
277
        }
278
    };
279
    info.add_thread(
280
        ThreadId::unique(),
281
        Thread::create(
282
            RefAny::new(task),
283
            state.clone(),
284
            send_worker as ThreadCallbackType,
285
        ),
286
    );
287
    Update::RefreshDomAllWindows
288
}
289

            
290
/// The report body: user message + app identity + (optional) system block.
291
/// Plain text by design — it is read by a HUMAN at the support mailbox.
292
5
fn build_report_text(message: &str, include_sysinfo: bool, include_actions: bool) -> String {
293
    use std::fmt::Write as _;
294
5
    let env = crate::appenv::app_env();
295
5
    let mut out = String::new();
296
5
    let _ = writeln!(out, "Problem report — {} {}", env.app_name, env.current_version);
297
5
    let _ = writeln!(out, "----------------------------------------");
298
5
    if message.trim().is_empty() {
299
1
        let _ = writeln!(out, "(no user message)");
300
4
    } else {
301
4
        let _ = writeln!(out, "{}", message.trim());
302
4
    }
303
5
    if include_sysinfo {
304
2
        let _ = writeln!(out, "\nSystem information:");
305
        #[cfg(feature = "telemetry")]
306
        for (k, v) in crate::telemetry::sysinfo::get().as_attributes() {
307
            let _ = writeln!(out, "  {k} = {v}");
308
        }
309
        #[cfg(not(feature = "telemetry"))]
310
2
        {
311
2
            let _ = writeln!(out, "  os = {}", std::env::consts::OS);
312
2
            let _ = writeln!(out, "  arch = {}", std::env::consts::ARCH);
313
2
        }
314
3
    }
315
5
    if include_actions {
316
1
        let _ = writeln!(out, "\nRecent actions are attached as recent-actions.json.");
317
4
    }
318
5
    out
319
5
}
320

            
321
/// The app's serialized state, when the app registered a JSON serializer
322
/// (`RefAny::set_serialize_fn`) — the honest reading of "include app data".
323
/// Without a serializer there is nothing to include and the report says so
324
/// rather than attaching an empty file.
325
#[cfg(feature = "json")]
326
fn app_data_json(info: &CallbackInfo) -> Option<String> {
327
    let data = info.get_ctx().into_option()?;
328
    let json = crate::json::serialize_refany_to_json(&data)?;
329
    Some(json.to_json_string().as_str().to_owned())
330
}
331

            
332
/// Without the `json` feature there is no serializer to ask.
333
#[cfg(not(feature = "json"))]
334
const fn app_data_json(_info: &CallbackInfo) -> Option<String> {
335
    None
336
}
337

            
338
struct SendTask {
339
    email: Option<String>,
340
    report: String,
341
    screenshot: Option<Vec<u8>>,
342
    recent_actions: Option<String>,
343
    app_data: Option<String>,
344
}
345

            
346
struct NewStatus(ReportStatus);
347

            
348
/// Background transport ladder: mail (crash-mail feature + mailbox set) →
349
/// save to disk. Either way the user gets told exactly where it went.
350
extern "C" fn send_worker(mut init: RefAny, mut sender: ThreadSender, _recv: ThreadReceiver) {
351
    let Some(task) = init.downcast_ref::<SendTask>() else {
352
        return;
353
    };
354
    let email = task.email.clone();
355
    let report = task.report.clone();
356
    let screenshot = task.screenshot.clone();
357
    let recent_actions = task.recent_actions.clone();
358
    let app_data = task.app_data.clone();
359
    drop(task);
360

            
361
    let mut attachments: Vec<(String, Vec<u8>)> =
362
        vec![("report.txt".to_owned(), report.clone().into_bytes())];
363
    if let Some(png) = screenshot {
364
        attachments.push(("screenshot.png".to_owned(), png));
365
    }
366
    if let Some(actions) = recent_actions {
367
        attachments.push(("recent-actions.json".to_owned(), actions.into_bytes()));
368
    }
369
    if let Some(data) = app_data {
370
        attachments.push(("app-data.json".to_owned(), data.into_bytes()));
371
    }
372

            
373
    let status = deliver(email.as_deref(), &report, &attachments);
374

            
375
    let _ = sender.send(ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg::new(
376
        apply_status as WriteBackCallbackType,
377
        RefAny::new(NewStatus(status)),
378
    )));
379
}
380

            
381
#[cfg(feature = "crash-mail")]
382
fn deliver(email: Option<&str>, _report: &str, attachments: &[(String, Vec<u8>)]) -> ReportStatus {
383
    match email {
384
        Some(to) => {
385
            let domain = to.split('@').nth(1).unwrap_or("localhost").to_owned();
386
            let config = crate::telemetry::crash_mail::CrashMailConfig::new(
387
                to.to_owned(),
388
                format!("problem-reporter@{domain}"),
389
                domain,
390
            );
391
            match crate::telemetry::crash_mail::send_attachments(
392
                &config,
393
                "Problem report attached (report.txt).",
394
                attachments,
395
            ) {
396
                Ok(()) => ReportStatus::Done(format!("Report sent to {to}.")),
397
                Err(e) => ReportStatus::Failed(format!("Sending failed: {e}")),
398
            }
399
        }
400
        None => save_to_disk(attachments),
401
    }
402
}
403

            
404
#[cfg(not(feature = "crash-mail"))]
405
fn deliver(_email: Option<&str>, _report: &str, attachments: &[(String, Vec<u8>)]) -> ReportStatus {
406
    // Built without the mail transport: saving to disk is the honest path.
407
    save_to_disk(attachments)
408
}
409

            
410
/// Disk fallback: `{data_dir}/{app}/problem-reports/report-<unix>[.-]*`.
411
fn save_to_disk(attachments: &[(String, Vec<u8>)]) -> ReportStatus {
412
    let env = crate::appenv::app_env();
413
    let dir = report_dir(&env.app_name);
414
    if let Err(e) = std::fs::create_dir_all(&dir) {
415
        return ReportStatus::Failed(format!("cannot create {}: {e}", dir.display()));
416
    }
417
    let stamp = std::time::SystemTime::now()
418
        .duration_since(std::time::UNIX_EPOCH)
419
        .map_or(0, |d| d.as_secs());
420
    for (name, bytes) in attachments {
421
        let path = dir.join(format!("report-{stamp}-{name}"));
422
        if let Err(e) = std::fs::write(&path, bytes) {
423
            return ReportStatus::Failed(format!("cannot write {}: {e}", path.display()));
424
        }
425
    }
426
    ReportStatus::Done(format!("Report saved to {}.", dir.display()))
427
}
428

            
429
/// Where disk-fallback reports go (public so apps can point users at it).
430
#[must_use]
431
pub fn report_dir(app_name: &str) -> std::path::PathBuf {
432
    #[cfg(feature = "updater")]
433
    {
434
        crate::updater::default_state_dir(app_name).join("problem-reports")
435
    }
436
    #[cfg(not(feature = "updater"))]
437
    {
438
        std::env::temp_dir().join(app_name).join("problem-reports")
439
    }
440
}
441

            
442
extern "C" fn apply_status(mut state: RefAny, mut msg: RefAny, _info: CallbackInfo) -> Update {
443
    let Some(new_status) = msg.downcast_ref::<NewStatus>().map(|s| s.0.clone()) else {
444
        return Update::DoNothing;
445
    };
446
    if let Some(mut s) = state.downcast_mut::<ReportProblemState>() {
447
        s.status = new_status;
448
    }
449
    Update::RefreshDomAllWindows
450
}
451

            
452
// --- the dialog DOM -------------------------------------------------------
453

            
454
extern "C" fn dialog_layout(_data: RefAny, info: LayoutCallbackInfo) -> Dom {
455
    let Some(mut ctx) = info.get_ctx().into_option() else {
456
        return Dom::create_body();
457
    };
458
    let snapshot = match ctx.downcast_ref::<ReportProblemState>() {
459
        Some(s) => s.clone(),
460
        None => return Dom::create_body(),
461
    };
462
    drop(ctx);
463
    let state = info.get_ctx().into_option().unwrap_or_else(|| RefAny::new(()));
464

            
465
    use azul_css::props::{
466
        basic::pixel::PixelValue,
467
        layout::{LayoutPaddingBottom, LayoutPaddingLeft, LayoutPaddingRight, LayoutPaddingTop},
468
    };
469
    let pad = style(vec![
470
        azul_css::props::property::CssProperty::padding_left(LayoutPaddingLeft {
471
            inner: PixelValue::px(16.0),
472
        }),
473
        azul_css::props::property::CssProperty::padding_right(LayoutPaddingRight {
474
            inner: PixelValue::px(16.0),
475
        }),
476
        azul_css::props::property::CssProperty::padding_top(LayoutPaddingTop {
477
            inner: PixelValue::px(16.0),
478
        }),
479
        azul_css::props::property::CssProperty::padding_bottom(LayoutPaddingBottom {
480
            inner: PixelValue::px(16.0),
481
        }),
482
    ]);
483

            
484
    let mut children: Vec<Dom> = vec![Dom::create_h2_with_text("Report a problem")];
485
    match &snapshot.email {
486
        Some(to) => children.push(Dom::create_p_with_text(format!(
487
            "Describe what went wrong. The report goes to {to} — nothing is sent until you press Send."
488
        ))),
489
        None => children.push(Dom::create_p_with_text(
490
            "Describe what went wrong. The report is saved to disk (no support address is configured).",
491
        )),
492
    }
493

            
494
    match &snapshot.status {
495
        ReportStatus::Editing | ReportStatus::Failed(_) => {
496
            if let ReportStatus::Failed(e) = &snapshot.status {
497
                children.push(Dom::create_p_with_text(format!("Previous attempt failed: {e}")));
498
            }
499
            children.push(
500
                TextArea::create()
501
                    .with_text(AzString::from(snapshot.message.as_str()))
502
                    .with_placeholder(AzString::from("What were you doing when the problem happened?"))
503
                    .with_on_text_input(state.clone(), on_message_input as TextAreaOnTextInputCallbackType)
504
                    .dom(),
505
            );
506
            children.push(check_row(
507
                snapshot.include_sysinfo,
508
                "Include system information (CPU, GPU, OS, RAM)",
509
                on_toggle_sysinfo,
510
                state.clone(),
511
            ));
512
            children.push(check_row(
513
                snapshot.include_actions,
514
                "Include recent actions (which handlers ran - no typed text)",
515
                on_toggle_actions,
516
                state.clone(),
517
            ));
518
            children.push(check_row(
519
                snapshot.include_app_data,
520
                "Include application data (your document - off by default)",
521
                on_toggle_app_data,
522
                state.clone(),
523
            ));
524
            if snapshot.screenshot_png.is_some() {
525
                children.push(check_row(
526
                    snapshot.attach_screenshot,
527
                    "Attach a screenshot of the window",
528
                    on_toggle_screenshot,
529
                    state.clone(),
530
                ));
531
            }
532
            if snapshot.attach_screenshot {
533
                children.extend(preview_section(&snapshot, &state));
534
            }
535
            children.push(button_row(vec![
536
                ("Send", on_send, state.clone()),
537
                ("Cancel", on_cancel, state),
538
            ]));
539
        }
540
        ReportStatus::Sending => {
541
            children.push(Dom::create_p_with_text("Sending the report…"));
542
        }
543
        ReportStatus::Done(msg) => {
544
            children.push(Dom::create_p_with_text(msg.as_str()));
545
            children.push(button_row(vec![("Close", on_cancel, state)]));
546
        }
547
    }
548

            
549
    Dom::create_body().with_child(
550
        Dom::create_div()
551
            .with_css_props(pad)
552
            .with_children(children.into()),
553
    )
554
}
555

            
556
/// The screenshot preview plus its blackout overlay.
557
///
558
/// Drag on the image to cover anything private; the rectangles are drawn
559
/// here and applied to the attached PNG at send time. Nothing about the
560
/// preview is decorative: what you black out is what leaves the machine.
561
fn preview_section(snapshot: &ReportProblemState, state: &RefAny) -> Vec<Dom> {
562
    use azul_core::{
563
        callbacks::{CoreCallback, CoreCallbackType},
564
        dom::{EventFilter, HoverEventFilter},
565
    };
566
    use azul_css::{
567
        dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec},
568
        props::{
569
            basic::color::ColorU,
570
            layout::{LayoutHeight, LayoutLeft, LayoutPosition, LayoutTop, LayoutWidth},
571
            property::CssProperty,
572
            style::StyleBackgroundContent,
573
        },
574
    };
575

            
576
    let Some(png) = snapshot.screenshot_png.as_deref() else {
577
        return Vec::new();
578
    };
579
    let (pw, ph) = snapshot.preview_size;
580
    if pw <= 0.0 || ph <= 0.0 {
581
        return Vec::new();
582
    }
583
    let Ok(pixmap) = crate::cpurender::AzulPixmap::decode_png(png) else {
584
        return vec![Dom::create_p_with_text(
585
            "The screenshot could not be decoded for preview; it will not be attached.",
586
        )];
587
    };
588
    let raw = azul_core::resources::RawImage {
589
        pixels: azul_core::resources::RawImageData::U8(pixmap.data().to_vec().into()),
590
        width: pixmap.width() as usize,
591
        height: pixmap.height() as usize,
592
        premultiplied_alpha: false,
593
        data_format: azul_core::resources::RawImageFormat::RGBA8,
594
        tag: Vec::new().into(),
595
    };
596
    let Some(image_ref) = azul_core::resources::ImageRef::new_rawimage(raw) else {
597
        return vec![Dom::create_p_with_text(
598
            "The screenshot could not be prepared for preview.",
599
        )];
600
    };
601

            
602
    let sized = |w: f32, h: f32| {
603
        style(vec![
604
            CssProperty::width(LayoutWidth::px(w)),
605
            CssProperty::height(LayoutHeight::px(h)),
606
        ])
607
    };
608

            
609
    // The image, listening for the two drag corners.
610
    let image = Dom::create_image(image_ref)
611
        .with_css_props(sized(pw, ph))
612
        .with_callback(
613
            EventFilter::Hover(HoverEventFilter::MouseDown),
614
            state.clone(),
615
            CoreCallback {
616
                cb: on_preview_mouse_down as *const () as CoreCallbackType,
617
                ctx: azul_core::refany::OptionRefAny::None,
618
            },
619
        )
620
        .with_callback(
621
            EventFilter::Hover(HoverEventFilter::MouseUp),
622
            state.clone(),
623
            CoreCallback {
624
                cb: on_preview_mouse_up as *const () as CoreCallbackType,
625
                ctx: azul_core::refany::OptionRefAny::None,
626
            },
627
        );
628

            
629
    // Absolutely-positioned black rectangles over it.
630
    let mut stack: Vec<Dom> = vec![image];
631
    for rect in &snapshot.redactions {
632
        let r = rect.normalized();
633
        stack.push(
634
            Dom::create_div().with_css_props(style(vec![
635
                CssProperty::position(LayoutPosition::Absolute),
636
                CssProperty::left(LayoutLeft::px(r.x)),
637
                CssProperty::top(LayoutTop::px(r.y)),
638
                CssProperty::width(LayoutWidth::px(r.width)),
639
                CssProperty::height(LayoutHeight::px(r.height)),
640
                CssProperty::background_content(
641
                    vec![StyleBackgroundContent::Color(ColorU {
642
                        r: 0,
643
                        g: 0,
644
                        b: 0,
645
                        a: 255,
646
                    })]
647
                    .into(),
648
                ),
649
            ])),
650
        );
651
    }
652

            
653
    vec![
654
        Dom::create_p_with_text(
655
            "Drag on the preview to black out anything private - the blackout is \
656
             applied to the image that is sent.",
657
        ),
658
        Dom::create_div()
659
            .with_css_props({
660
                let mut props = sized(pw, ph).as_ref().to_vec();
661
                props.push(CssPropertyWithConditions::simple(CssProperty::position(
662
                    LayoutPosition::Relative,
663
                )));
664
                CssPropertyWithConditionsVec::from_vec(props)
665
            })
666
            .with_children(stack.into()),
667
        button_row(vec![(
668
            "Clear blackouts",
669
            on_clear_redactions as ButtonOnClickCallbackType,
670
            state.clone(),
671
        )]),
672
    ]
673
}
674

            
675
fn check_row(
676
    checked: bool,
677
    label: &str,
678
    cb: CheckBoxOnToggleCallbackType,
679
    state: RefAny,
680
) -> Dom {
681
    Dom::create_div().with_children(
682
        vec![
683
            CheckBox::create(checked).with_on_toggle(state, cb).dom(),
684
            Dom::create_p_with_text(label),
685
        ]
686
        .into(),
687
    )
688
}
689

            
690
fn button_row(buttons: Vec<(&str, ButtonOnClickCallbackType, RefAny)>) -> Dom {
691
    let children: Vec<Dom> = buttons
692
        .into_iter()
693
        .map(|(label, cb, state)| {
694
            Button::create(AzString::from(label))
695
                .with_on_click(state, cb)
696
                .dom()
697
        })
698
        .collect();
699
    Dom::create_div().with_children(children.into())
700
}
701

            
702
#[cfg(test)]
703
mod tests {
704
    use super::*;
705

            
706
    #[test]
707
1
    fn report_text_carries_message_and_sysinfo_toggle_is_respected() {
708
1
        crate::appenv::set_app_env(crate::appenv::AppEnv {
709
1
            app_name: "reporttest".to_owned(),
710
1
            current_version: "9.9.9".to_owned(),
711
1
            ..Default::default()
712
1
        });
713
1
        let with = build_report_text("it broke while saving", true, false);
714
1
        assert!(with.contains("reporttest 9.9.9"), "{with}");
715
1
        assert!(with.contains("it broke while saving"), "{with}");
716
1
        assert!(with.contains("System information"), "{with}");
717

            
718
        // The toggle is a PRIVACY control: off must mean ABSENT, not empty.
719
1
        let without = build_report_text("msg", false, false);
720
1
        assert!(!without.contains("System information"), "{without}");
721
1
    }
722

            
723
    /// LAW: every opt-in section is ABSENT when declined — the report must
724
    /// never mention data the user chose not to send.
725
    #[test]
726
1
    fn the_recent_actions_section_follows_its_toggle() {
727
1
        let on = build_report_text("msg", false, true);
728
1
        assert!(on.contains("recent-actions.json"), "{on}");
729
1
        let off = build_report_text("msg", false, false);
730
1
        assert!(!off.contains("recent-actions"), "{off}");
731
1
    }
732

            
733
    #[test]
734
1
    fn empty_message_is_labeled_not_blank() {
735
1
        let text = build_report_text("   ", true, false);
736
1
        assert!(text.contains("(no user message)"), "{text}");
737
1
    }
738
}