Skip to main content

ravenrs_movie/
flight.rs

1//! The last minutes on screen, always at hand, and a bug report made from them.
2//!
3//! Nothing here leaves the computer by itself. What is recorded stays in memory
4//! until someone opens a report, sees the film, covers what should not show and
5//! chooses to send it, through whatever the app does with [`BugReport`].
6
7use std::cell::{Cell, RefCell};
8use std::rc::Rc;
9
10use ravenrs_app_i18n::{Language, Texts};
11use ravenrs_core::{AccessTree, Border, Color, CursorIcon, Point, Primitive, Rect, Role, Size};
12use ravenrs_layout::{Align, Axis, LayoutStyle};
13use ravenrs_reactive::{Binding, State};
14use ravenrs_style::{Semantic, TextStyle};
15use ravenrs_widgets::{
16    BuildCx, Button, ButtonStyle, Dialog, Drag, DragPhase, IconName, NodeId, Paint, Placement, QuadPaint,
17    SegmentedControl, Semantics, Text, TextField, Toggle, UiActivity, VStack, View, ViewExt,
18};
19use serde::{Deserialize, Serialize};
20
21use crate::editor::{EditorLabels, EditorState, MovieEditor};
22use crate::movie::{FilmBuilder, Movie, MovieError};
23use crate::player::{MovieLabels, MoviePlayer};
24use crate::project::Region;
25
26const REPORT_MAGIC: &[u8] = b"RAVENBUG1\n";
27/// The fewest seconds between two frames it keeps: a report does not need more,
28/// and the app it watches should not pay for more.
29const SHORTEST_GAP: f64 = 1.0 / 15.0;
30const PREVIEW_WIDTH: f32 = 640.0;
31/// A smaller box is a click that slipped, not a choice.
32const SMALLEST_MARK: f32 = 12.0;
33/// How many elements under the mark a report names, the first in reading order.
34const MARKED_ELEMENTS: usize = 12;
35/// How much the page dims while marking: enough to tell the mode, not to hide the page.
36const MARKING_DIM: f32 = 0.25;
37
38#[derive(Clone, Debug)]
39struct Stretch {
40    film: FilmBuilder,
41    started: f64,
42}
43
44#[derive(Debug)]
45struct Flight {
46    keep_seconds: f64,
47    size: Size,
48    newer: Option<Stretch>,
49    older: Option<Stretch>,
50    last_frame: Vec<Primitive>,
51    last_at: f64,
52    now: f64,
53    paused: bool,
54    /// What the screen had when last asked, for naming what was marked.
55    described: Option<AccessTree>,
56}
57
58#[cfg_attr(feature = "live", ravenrs_live::expose)]
59impl Flight {
60    /// The stretch being written, starting another when this one is full. Two
61    /// stretches of half the time each always hold at least that half, and old
62    /// memory goes all at once instead of frame by frame.
63    fn stretch(&mut self, now: f64, background: ravenrs_core::Color) -> &mut Stretch {
64        let full = self.newer.as_ref().is_none_or(|s| now - s.started >= self.keep_seconds / 2.0);
65        if full {
66            self.older = self.newer.take();
67            self.last_frame.clear();
68            self.newer = Some(Stretch { film: FilmBuilder::new(self.size, background), started: now });
69        }
70        self.newer
71            .get_or_insert_with(|| Stretch { film: FilmBuilder::new(self.size, background), started: now })
72    }
73
74    fn painted(&mut self, scene: &ravenrs_core::Scene, now: f64) {
75        self.now = now;
76        let primitives = scene.primitives();
77        if self.paused || (now - self.last_at < SHORTEST_GAP && !self.last_frame.is_empty()) {
78            return;
79        }
80        if self.last_frame == primitives
81            && self.newer.as_ref().is_some_and(|s| now - s.started < self.keep_seconds / 2.0)
82        {
83            return;
84        }
85        let stretch = self.stretch(now, scene.clear_color());
86        let at = ((now - stretch.started).max(0.0) * 1000.0) as u64;
87        stretch.film.frame(at, primitives);
88        self.last_frame = primitives.to_vec();
89        self.last_at = now;
90    }
91
92    fn pointer(&mut self, position: Point, clicked: bool, now: f64) {
93        self.now = now;
94        if self.paused {
95            return;
96        }
97        if let Some(stretch) = &mut self.newer {
98            let at = ((now - stretch.started).max(0.0) * 1000.0) as u64;
99            stretch.film.pointer(at, position);
100            if clicked {
101                stretch.film.click(at, position);
102            }
103        }
104    }
105
106    /// The stretches as one film, without disturbing the recording.
107    fn film(&self) -> Option<Movie> {
108        let newer = self.newer.as_ref()?;
109        let stretches: Vec<&Stretch> = self.older.iter().chain([newer]).collect();
110        let origin = stretches[0].started;
111        let mut whole: Option<FilmBuilder> = None;
112        let mut last_ms = 0;
113        for stretch in stretches {
114            let ms = ((self.now - stretch.started).max(0.0) * 1000.0) as u64;
115            let Ok(part) = stretch.film.clone().finish(ms) else {
116                continue;
117            };
118            let shift = ((stretch.started - origin).max(0.0) * 1000.0) as u64;
119            let logical = part.logical_size();
120            let background = part.background();
121            let film = whole.get_or_insert_with(|| FilmBuilder::new(logical, background));
122            for (at, list) in part.frames() {
123                let drawn: Vec<Primitive> = list.iter().filter_map(|i| part.primitive(*i).cloned()).collect();
124                film.frame(at + shift, &drawn);
125            }
126            for key in part.pointer_keys() {
127                film.pointer(key.at_ms + shift, Point::new(key.x, key.y));
128            }
129            for key in part.click_keys() {
130                film.click(key.at_ms + shift, Point::new(key.x, key.y));
131            }
132            last_ms = last_ms.max(ms + shift);
133        }
134        whole?.finish(last_ms).ok()
135    }
136}
137
138/// Keeps the last minutes on screen in memory, the way a flight recorder does, so
139/// that a bug can show what led to it. Put it anywhere in the app's views: it
140/// draws nothing. A film costs little because each thing drawn is stored once and
141/// a screen that does not change costs nothing, and it runs only when the app paints.
142///
143/// It sees all that the app shows, so it writes nothing anywhere: only
144/// [`Self::film`] gets anything out of it.
145///
146/// ```
147/// use ravenrs_movie::FlightRecorder;
148///
149/// // The last three minutes.
150/// let recorder = FlightRecorder::new(180.0);
151/// assert!(recorder.film().is_none(), "nothing on screen yet");
152/// ```
153#[derive(Clone, Debug)]
154#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
155pub struct FlightRecorder {
156    flight: Rc<RefCell<Flight>>,
157}
158
159#[cfg_attr(feature = "live", ravenrs_live::expose)]
160impl FlightRecorder {
161    /// A recorder that keeps at least half of `seconds`, and up to all of them.
162    pub fn new(seconds: f64) -> Self {
163        Self {
164            flight: Rc::new(RefCell::new(Flight {
165                keep_seconds: seconds.max(1.0),
166                size: Size::new(1.0, 1.0),
167                newer: None,
168                older: None,
169                last_frame: Vec::new(),
170                last_at: f64::NEG_INFINITY,
171                now: 0.0,
172                paused: false,
173                described: None,
174            })),
175        }
176    }
177
178    /// The film so far, to look at, edit or send. Recording goes on.
179    pub fn film(&self) -> Option<Movie> {
180        self.flight.borrow().film()
181    }
182
183    fn take_described(&self) -> Option<AccessTree> {
184        self.flight.borrow_mut().described.take()
185    }
186
187    /// Stops or resumes noting anything, for screens that should stay out of it.
188    pub fn set_paused(&self, paused: bool) {
189        self.flight.borrow_mut().paused = paused;
190    }
191}
192
193impl View for FlightRecorder {
194    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
195        let size = cx.viewport();
196        {
197            let mut flight = self.flight.borrow_mut();
198            if flight.size != size {
199                // A film has one size, so a resized window starts over.
200                flight.size = size;
201                flight.newer = None;
202                flight.older = None;
203                flight.last_frame.clear();
204            }
205        }
206        let flight = self.flight.clone();
207        cx.observe_activity(Rc::new(move |activity: UiActivity<'_>, now: f64| {
208            // Its own dialog paints inside a borrow only if it reads the film while
209            // painting, which it does not; if that changes, a frame is skipped, no more.
210            let Ok(mut flight) = flight.try_borrow_mut() else {
211                return;
212            };
213            match activity {
214                UiActivity::Painted(scene) => flight.painted(scene, now),
215                UiActivity::PointerMoved(position) => flight.pointer(position, false, now),
216                UiActivity::Clicked(position) => flight.pointer(position, true, now),
217                UiActivity::Described(tree) => flight.described = Some(tree.clone()),
218                _ => {}
219            }
220        }));
221        cx.empty()
222    }
223}
224
225/// What someone says about a bug, for the development team.
226#[derive(Clone, Debug, Default)]
227#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
228pub struct BugReport {
229    /// What kind of report it is, in the words of [`BugLabels::kinds`].
230    pub kind: String,
231    /// What happened, in their words.
232    pub description: String,
233    /// The box they drew around the problem, in the logical pixels of the window.
234    pub area: Option<Region>,
235    /// What was on screen under that box, by the names screen readers use.
236    pub elements: Vec<String>,
237    /// The app's last errors, from [`BugReporter::errors`].
238    pub errors: Vec<String>,
239    /// The film of what led to it, if they chose to include it.
240    pub film: Option<Movie>,
241    /// What the app adds, such as its version. Shown before it is sent.
242    pub details: Vec<(String, String)>,
243}
244
245/// What is not in the older files is simply empty there.
246#[derive(Serialize, Deserialize)]
247struct ReportHeader {
248    #[serde(default)]
249    kind: String,
250    description: String,
251    #[serde(default)]
252    area: Option<Region>,
253    #[serde(default)]
254    elements: Vec<String>,
255    #[serde(default)]
256    errors: Vec<String>,
257    details: Vec<(String, String)>,
258}
259
260#[cfg_attr(feature = "live", ravenrs_live::expose)]
261impl BugReport {
262    /// The report as the bytes of one file.
263    pub fn to_bytes(&self) -> Vec<u8> {
264        let header = ReportHeader {
265            kind: self.kind.clone(),
266            description: self.description.clone(),
267            area: self.area,
268            elements: self.elements.clone(),
269            errors: self.errors.clone(),
270            details: self.details.clone(),
271        };
272        let header = serde_json::to_vec(&header).unwrap_or_default();
273        let mut bytes = Vec::new();
274        bytes.extend_from_slice(REPORT_MAGIC);
275        bytes.extend_from_slice(&(header.len() as u64).to_le_bytes());
276        bytes.extend_from_slice(&header);
277        if let Some(film) = &self.film {
278            bytes.extend_from_slice(&film.to_bytes());
279        }
280        bytes
281    }
282
283    /// Reads a report back from the bytes of a file.
284    pub fn from_bytes(bytes: &[u8]) -> Result<Self, MovieError> {
285        let damaged = |why: &str| MovieError::Damaged(why.to_owned());
286        let rest =
287            bytes.strip_prefix(REPORT_MAGIC).ok_or_else(|| damaged("it does not start like a report"))?;
288        let (length, rest) = rest.split_first_chunk::<8>().ok_or_else(|| damaged("no header"))?;
289        let length = usize::try_from(u64::from_le_bytes(*length)).map_err(|_| damaged("header too long"))?;
290        let header = rest.get(..length).ok_or_else(|| damaged("the header was cut"))?;
291        let header: ReportHeader =
292            serde_json::from_slice(header).map_err(|e| MovieError::Damaged(e.to_string()))?;
293        let rest = &rest[length..];
294        let film = if rest.is_empty() { None } else { Some(Movie::from_bytes(rest)?) };
295        Ok(Self {
296            kind: header.kind,
297            description: header.description,
298            area: header.area,
299            elements: header.elements,
300            errors: header.errors,
301            film,
302            details: header.details,
303        })
304    }
305}
306
307/// The words a [`BugReporter`] shows. English by default.
308#[derive(Clone, Debug)]
309#[non_exhaustive]
310#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
311pub struct BugLabels {
312    /// The dialog's title.
313    pub title: String,
314    /// What the dialog is about.
315    pub intro: String,
316    /// The field for what happened.
317    pub description: String,
318    /// Includes the film.
319    pub include_film: String,
320    /// Warns that the film shows what was on screen.
321    pub film_warning: String,
322    /// Opens the editor to cover what should not show.
323    pub edit_film: String,
324    /// Goes back from the editor.
325    pub done_editing: String,
326    /// Sends it.
327    pub send: String,
328    /// Gives up.
329    pub cancel: String,
330    /// Names the choice of [`Self::kinds`].
331    pub kind: String,
332    /// The kinds of report to choose from. The first starts chosen.
333    pub kinds: Vec<String>,
334    /// The button that starts marking the problem on screen.
335    pub mark: String,
336    /// The same button, when a box was already drawn.
337    pub mark_again: String,
338    /// What to do while marking.
339    pub mark_hint: String,
340    /// Heads what goes along with the report.
341    pub goes_along: String,
342    /// Before the elements under the box.
343    pub elements: String,
344    /// Before the app's last errors.
345    pub errors: String,
346    /// The film player's words.
347    pub player: MovieLabels,
348    /// The film editor's words.
349    pub editor: EditorLabels,
350}
351
352impl Default for BugLabels {
353    fn default() -> Self {
354        Self::from_texts(&Texts::new(Language::English))
355    }
356}
357
358#[cfg_attr(feature = "live", ravenrs_live::expose)]
359impl BugLabels {
360    /// The texts of a catalog, in its language. What it does not have comes from English.
361    pub fn from_texts(texts: &Texts) -> Self {
362        Self {
363            title: texts.get("movie.bug.title"),
364            intro: texts.get("movie.bug.intro"),
365            description: texts.get("movie.bug.description"),
366            include_film: texts.get("movie.bug.include_film"),
367            film_warning: texts.get("movie.bug.film_warning"),
368            edit_film: texts.get("movie.bug.edit_film"),
369            done_editing: texts.get("movie.bug.done_editing"),
370            send: texts.get("movie.bug.send"),
371            cancel: texts.get("movie.bug.cancel"),
372            kind: texts.get("movie.bug.kind"),
373            kinds: texts.list("movie.bug.kinds"),
374            mark: texts.get("movie.bug.mark"),
375            mark_again: texts.get("movie.bug.mark_again"),
376            mark_hint: texts.get("movie.bug.mark_hint"),
377            goes_along: texts.get("movie.bug.goes_along"),
378            elements: texts.get("movie.bug.elements"),
379            errors: texts.get("movie.bug.errors"),
380            player: MovieLabels::from_texts(texts),
381            editor: EditorLabels::from_texts(texts),
382        }
383    }
384
385    /// The words in Brazilian Portuguese.
386    pub fn pt_br() -> Self {
387        Self::from_texts(&Texts::new(Language::BrazilianPortuguese))
388    }
389}
390
391/// A report being written.
392#[derive(Clone)]
393struct Draft {
394    editor: Option<EditorState>,
395    /// What lights the marked box in the film, to replace when it is marked again.
396    light: Option<u64>,
397    described: Option<AccessTree>,
398    elements: Vec<String>,
399}
400
401/// The dialog that turns what the recorder has into a [`BugReport`].
402///
403/// The film is taken when the dialog opens, so it does not show the report being
404/// written. The person watches it, can leave it out, and can cover or cut parts
405/// of it in the editor before sending. They can also draw a box around the problem,
406/// which names what is under it and lights it in the film, and everything else that
407/// goes along is listed before they send. Where the report goes is up to the app.
408#[derive(Clone)]
409#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
410pub struct BugReporter {
411    recorder: FlightRecorder,
412    open: State<bool>,
413    editing: State<bool>,
414    include: State<bool>,
415    description: State<String>,
416    kind: State<usize>,
417    marking: State<bool>,
418    marked: State<Option<Rect>>,
419    drawing: State<Option<Rect>>,
420    asked: Rc<Cell<bool>>,
421    draft: Rc<RefCell<Draft>>,
422    labels: Rc<BugLabels>,
423    details: Rc<dyn Fn() -> Vec<(String, String)>>,
424    errors: Rc<dyn Fn() -> Vec<String>>,
425    on_send: Rc<dyn Fn(BugReport)>,
426}
427
428impl std::fmt::Debug for BugReporter {
429    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
430        f.debug_struct("BugReporter").field("recorder", &self.recorder).finish_non_exhaustive()
431    }
432}
433
434#[cfg_attr(feature = "live", ravenrs_live::expose)]
435impl BugReporter {
436    /// A reporter over what `recorder` keeps; `on_send` gets the finished report.
437    pub fn new(recorder: FlightRecorder, on_send: impl Fn(BugReport) + 'static) -> Self {
438        Self {
439            recorder,
440            open: State::new(false),
441            editing: State::new(false),
442            include: State::new(true),
443            description: State::new(String::new()),
444            kind: State::new(0),
445            marking: State::new(false),
446            marked: State::new(None),
447            drawing: State::new(None),
448            asked: Rc::default(),
449            draft: Rc::new(RefCell::new(Draft {
450                editor: None,
451                light: None,
452                described: None,
453                elements: Vec::new(),
454            })),
455            labels: Rc::new(BugLabels::default()),
456            details: Rc::new(Vec::new),
457            errors: Rc::new(Vec::new),
458            on_send: Rc::new(on_send),
459        }
460    }
461
462    /// The words it shows.
463    pub fn labels(mut self, labels: BugLabels) -> Self {
464        self.labels = Rc::new(labels);
465        self
466    }
467
468    /// What the app adds to every report, such as its version.
469    pub fn details(mut self, details: impl Fn() -> Vec<(String, String)> + 'static) -> Self {
470        self.details = Rc::new(details);
471        self
472    }
473
474    /// The app's last errors, such as [`RecentErrors::lines`](crate::RecentErrors::lines).
475    /// The person sees them before anything goes.
476    pub fn errors(mut self, errors: impl Fn() -> Vec<String> + 'static) -> Self {
477        self.errors = Rc::new(errors);
478        self
479    }
480
481    /// Opens the dialog, with the film up to now.
482    pub fn open(&self) {
483        *self.draft.borrow_mut() = Draft {
484            editor: self.recorder.film().map(EditorState::new),
485            light: None,
486            described: None,
487            elements: Vec::new(),
488        };
489        self.recorder.set_paused(true);
490        self.description.set(String::new());
491        self.kind.set(0);
492        self.marking.set(false);
493        self.marked.set(None);
494        self.drawing.set(None);
495        self.include.set(true);
496        self.editing.set(false);
497        self.open.set(true);
498    }
499
500    fn close(&self) {
501        self.open.set(false);
502        self.editing.set(false);
503        self.marking.set(false);
504        self.draft.borrow_mut().editor = None;
505        self.recorder.set_paused(false);
506    }
507
508    /// Hides the dialog to mark the page behind it.
509    fn start_marking(&self) {
510        self.draft.borrow_mut().described = None;
511        self.asked.set(false);
512        self.drawing.set(None);
513        self.marking.set(true);
514    }
515
516    /// Keeps the box drawn, names what is under it and lights it in the film.
517    fn finish_marking(&self, area: Rect) {
518        let region = Region::new(area.x(), area.y(), area.width(), area.height());
519        let mut draft = self.draft.borrow_mut();
520        draft.elements = draft.described.as_ref().map(|tree| named_under(tree, area)).unwrap_or_default();
521        if let Some(editor) = draft.editor.clone() {
522            draft.light = Some(editor.light_at_end(region, draft.light));
523        }
524        self.marked.set(Some(area));
525        self.marking.set(false);
526    }
527
528    fn send(&self) {
529        let film = if self.include.get() {
530            self.draft.borrow().editor.as_ref().map(EditorState::movie)
531        } else {
532            None
533        };
534        let kind = self.labels.kinds.get(self.kind.get()).cloned().unwrap_or_default();
535        let area = self.marked.get().map(|a| Region::new(a.x(), a.y(), a.width(), a.height()));
536        (self.on_send)(BugReport {
537            kind,
538            description: self.description.get(),
539            area,
540            elements: self.draft.borrow().elements.clone(),
541            errors: (self.errors)(),
542            film,
543            details: (self.details)(),
544        });
545        self.close();
546    }
547}
548
549impl View for BugReporter {
550    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
551        if !self.open.get() {
552            return cx.empty();
553        }
554        if self.marking.get() {
555            if let Some(tree) = self.recorder.take_described() {
556                self.draft.borrow_mut().described = Some(tree);
557            }
558            // The layer below hides the page from this description, so it comes a frame
559            // earlier. An app without a recorder never gets one, and marks all the same.
560            if self.draft.borrow().described.is_none() && !self.asked.replace(true) {
561                cx.ask_for_access_tree();
562                cx.wake_at(cx.now());
563                return cx.empty();
564            }
565            let anchor = cx.empty();
566            let leave = self.marking.clone();
567            cx.overlay(
568                anchor,
569                Placement::Fill,
570                &MarkingLayer(self.clone()),
571                Some(Rc::new(move || leave.set(false))),
572            );
573            return anchor;
574        }
575        let labels = &self.labels;
576        let viewport = cx.viewport();
577        let (closing, read_open, cancel) = (self.clone(), self.open.clone(), self.clone());
578        let open = Binding::new(
579            move || read_open.get(),
580            move |open: bool| {
581                if !open {
582                    closing.close();
583                }
584            },
585        );
586        let editor = self.draft.borrow().editor.clone();
587        if self.editing.get()
588            && let Some(state) = editor
589        {
590            let area = Size::new(viewport.width - 120.0, viewport.height - 200.0);
591            let back = self.editing.clone();
592            let dialog = Dialog::new(open, labels.edit_film.clone())
593                .width(area.width + 48.0)
594                .content(
595                    MovieEditor::new(state)
596                        .labels(labels.editor.clone())
597                        .area(area)
598                        .frame(Some(area.width), Some(area.height)),
599                )
600                .action(Button::new(labels.done_editing.clone()).on_click(move || back.set(false)));
601            return cx.child(&dialog);
602        }
603        let include = self.include.get();
604        let has_film = editor.is_some();
605        let film = editor.filter(|_| include).map(|state| {
606            let edit = self.editing.clone();
607            VStack::new((
608                Text::new(labels.film_warning.clone())
609                    .style(TextStyle::Callout)
610                    .foreground(Semantic::InkMuted),
611                MoviePlayer::new(state.movie())
612                    .labels(labels.player.clone())
613                    .width(PREVIEW_WIDTH.min(viewport.width - 120.0)),
614                Button::new(labels.edit_film.clone())
615                    .icon(IconName::EyeOff)
616                    .style(ButtonStyle::Secondary)
617                    .on_click(move || edit.set(true)),
618            ))
619            .spacing(10.0)
620            .align(Align::Start)
621        });
622        let send = self.clone();
623        let written = !self.description.get().trim().is_empty();
624        let mark = self.clone();
625        let marked = self.marked.get().is_some();
626        let (elements, errors) = (self.draft.borrow().elements.clone(), (self.errors)());
627        let details = (self.details)();
628        let along = (!elements.is_empty() || !errors.is_empty() || !details.is_empty()).then(|| {
629            let mut lines: Vec<ravenrs_widgets::AnyView> =
630                vec![Box::new(Text::new(labels.goes_along.clone()).style(TextStyle::Callout))];
631            let mut line = |text: String| {
632                lines
633                    .push(Box::new(Text::new(text).style(TextStyle::Caption).foreground(Semantic::InkMuted)));
634            };
635            details.iter().for_each(|(name, value)| line(format!("{name}: {value}")));
636            if !elements.is_empty() {
637                line(format!("{}: {}", labels.elements, elements.join(", ")));
638            }
639            errors.iter().for_each(|error| line(format!("{}: {error}", labels.errors)));
640            VStack::new(lines).spacing(4.0).align(Align::Stretch)
641        });
642        let dialog = Dialog::new(open, labels.title.clone())
643            .description(labels.intro.clone())
644            .width(PREVIEW_WIDTH + 64.0)
645            .content(
646                ravenrs_widgets::ScrollView::new((VStack::new((
647                    Text::new(labels.kind.clone()).style(TextStyle::Callout),
648                    SegmentedControl::new(self.kind.binding(), labels.kinds.iter().map(String::as_str))
649                        .label(labels.kind.clone()),
650                    Text::new(labels.description.clone()).style(TextStyle::Callout),
651                    TextField::new(self.description.binding()),
652                    Button::new(if marked { labels.mark_again.clone() } else { labels.mark.clone() })
653                        .icon(IconName::Crosshair)
654                        .style(ButtonStyle::Secondary)
655                        .on_click(move || mark.start_marking()),
656                    has_film.then(|| Toggle::new(self.include.binding()).label(labels.include_film.clone())),
657                    along,
658                    film,
659                ))
660                .spacing(10.0)
661                .align(Align::Stretch),))
662                .frame(None, Some((viewport.height - 260.0).max(200.0))),
663            )
664            .action(
665                Button::new(labels.cancel.clone())
666                    .style(ButtonStyle::Secondary)
667                    .on_click(move || cancel.close()),
668            )
669            .action(Button::new(labels.send.clone()).disabled(!written).on_click(move || send.send()));
670        cx.child(&dialog)
671    }
672}
673
674/// The names on screen under `area`, in reading order and once each. What is more
675/// than half outside it is left out, or the panels around it would always count.
676fn named_under(tree: &AccessTree, area: Rect) -> Vec<String> {
677    let mut names: Vec<String> = Vec::new();
678    for node in &tree.nodes {
679        let Some(name) = node.name.as_deref().map(str::trim).filter(|name| !name.is_empty()) else {
680            continue;
681        };
682        let bounds = node.bounds;
683        let inside = bounds.intersect(&area);
684        let is_under = inside.width() * inside.height() * 2.0 >= bounds.width() * bounds.height()
685            && bounds.width() * bounds.height() > 0.0;
686        if !is_under || node.role == Role::Window {
687            continue;
688        }
689        if !names.iter().any(|known| known == name) {
690            names.push(name.to_owned());
691        }
692        if names.len() == MARKED_ELEMENTS {
693            break;
694        }
695    }
696    names
697}
698
699/// Covers the window while someone draws a box around the problem.
700struct MarkingLayer(BugReporter);
701
702impl View for MarkingLayer {
703    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
704        let theme = *cx.theme();
705        let viewport = cx.viewport();
706        let reporter = &self.0;
707        let leave = reporter.marking.clone();
708        let hint = cx.child(
709            &VStack::new((
710                Text::new(reporter.labels.mark_hint.clone()),
711                Button::new(reporter.labels.cancel.clone())
712                    .style(ButtonStyle::Secondary)
713                    .on_click(move || leave.set(false)),
714            ))
715            .spacing(theme.spacing.s2)
716            .align(Align::Center)
717            .padding(theme.spacing.s3)
718            .background(Semantic::Surface)
719            .corner_radius(theme.radii.md),
720        );
721        let mut children = vec![hint];
722        if let Some(area) = reporter.drawing.get() {
723            let color = theme.color(Semantic::FocusRing);
724            let box_node = cx.container(
725                LayoutStyle {
726                    absolute: Some((area.x(), area.y())),
727                    width: Some(area.width()),
728                    height: Some(area.height()),
729                    ..LayoutStyle::stack(Axis::Vertical)
730                },
731                &[],
732                Paint::Quad(QuadPaint {
733                    fill: Color::TRANSPARENT.into(),
734                    corner_radius: 0.0,
735                    border: Some(Border { width: 2.0, color }),
736                    shadow: None,
737                }),
738            );
739            cx.semantics(box_node, Semantics::hidden());
740            children.push(box_node);
741        }
742        let mut dim = theme.color(Semantic::Scrim);
743        dim.a = MARKING_DIM;
744        let node = cx.container(
745            LayoutStyle {
746                width: Some(viewport.width),
747                height: Some(viewport.height),
748                padding: theme.spacing.s4.into(),
749                align: Align::Center,
750                ..LayoutStyle::stack(Axis::Vertical)
751            },
752            &children,
753            Paint::Quad(QuadPaint::new(dim)),
754        );
755        cx.cursor(node, CursorIcon::Default);
756        let drawing = reporter.clone();
757        let from = Rc::new(Cell::new(Point::ZERO));
758        let from = cx.use_state(move || from).get();
759        cx.on_drag(
760            node,
761            Rc::new(move |drag: Drag| {
762                if drag.phase == DragPhase::Start {
763                    from.set(drag.position);
764                }
765                let start = from.get();
766                let (left, top) = (start.x.min(drag.position.x), start.y.min(drag.position.y));
767                let area = Rect::from_origin_size(
768                    Point::new(left, top),
769                    Size::new((start.x - drag.position.x).abs(), (start.y - drag.position.y).abs()),
770                );
771                if drag.phase != DragPhase::End {
772                    drawing.drawing.set(Some(area));
773                } else if area.width() >= SMALLEST_MARK && area.height() >= SMALLEST_MARK {
774                    drawing.finish_marking(area);
775                } else {
776                    drawing.drawing.set(None);
777                }
778            }),
779        );
780        node
781    }
782}