Skip to main content

blockworx_web/
shell.rs

1//! The shell as one value, and the frame it runs.
2//!
3//! ```text
4//! a handler ──► batch.push(…) ──► one booking at a time (see `pacing`)
5//!                                      │
6//! the booked frame ───────────────────►│ view = kernel(session, Tick + batch, glyphs)
7//!                                      │ the ground and the display list, onto the canvas
8//!                                      │ the hand-offs, delivered
9//!                                      │ the chrome model, into its signal if it moved
10//!                                      │ whatever the answer asks for, booked
11//! ```
12//!
13//! A handler does one thing: [`Shell::say`] (or [`Shell::sampled`], for what
14//! the pointer and the keyboard did). The batch lives in a cell of its own, so
15//! a handler firing while a frame is in flight adds to the next one rather
16//! than re-entering the session.
17
18use core::time::Duration;
19use std::cell::{Cell, RefCell};
20use std::rc::Rc;
21
22use blockworx_canvas2d::input::{Focus, Reader, Sample};
23use blockworx_canvas2d::{
24    DevicePixelRatio, Glyphs, Images, Repaint, clipboard_write, css_color, css_cursor, download,
25    fit, paint_ground, replay,
26};
27use blockworx_doc::id::{BlockId, PinId};
28use blockworx_editor::shape::RoleTarget;
29use blockworx_editor::title_block::TitleBlock;
30use blockworx_export::bytes_for;
31use blockworx_geom::{Pos2, Rect, Vec2, pos2, vec2};
32use blockworx_kernel::Sheet;
33use blockworx_kernel::{Event, FrameRate, Handoff, Session, View, kernel};
34use blockworx_paint::theme::Theme;
35use blockworx_paint::{DrawList, FontChoice, Ground, Palette, Scheme, Tick, Vantage};
36use blockworx_store::doc::Doc;
37use blockworx_store::handle::Store;
38use blockworx_store::history;
39use blockworx_store::storage::{Any, DocumentRef, Name};
40use blockworx_tools::commands::{Act, Effect};
41use blockworx_tools::tool::Action;
42use dioxus::prelude::*;
43use wasm_bindgen::{JsCast as _, JsValue, prelude::Closure};
44use web_sys::{CanvasRenderingContext2d, Document, HtmlCanvasElement, HtmlElement, Window};
45
46use crate::chords::Token;
47use crate::chrome::Chrome;
48use crate::library::{Library, Listed, Opened};
49use crate::meter::{Frame, FrameMeter, Metered};
50use crate::mode::Prefers;
51use crate::pacing::{Pacing, Schedule};
52use crate::prefs::Preferences;
53use crate::sidebar::Section;
54
55/// The editor and everything it draws through: one borrow, taken by the frame
56/// and by nothing else.
57struct Editor {
58    session: Session,
59    /// The text engine the kernel is called with *and* the ink its glyphs are
60    /// filled from — one value, so the diagram cannot draw a face the recorder
61    /// did not measure.
62    glyphs: Glyphs,
63    images: Images,
64    reader: Reader,
65    /// The canvas, once it is mounted. A frame before that runs the kernel and
66    /// paints nothing.
67    surface: Option<Surface>,
68    /// What the canvas last showed.
69    diagram: Option<Diagram>,
70    /// What each piece of the chrome covers of the diagram, which the safe
71    /// region is the viewport minus. Measured by the bands themselves and
72    /// re-measured when they move, never cached.
73    bands: Vec<(Band, Rect)>,
74    /// What the diagram is currently drawn with, so a preference that did not
75    /// move books no frame.
76    dressed: Dressed,
77    /// What the browser would not do with this call's hand-offs, drained by
78    /// the frame that ran it — the editor has no signal of its own to say so.
79    refused: Vec<String>,
80    /// What a command named this call turned out to be the shell's to
81    /// perform. Drained by the frame that ran it, for the same reason: a
82    /// picker is opened by a signal the editor does not hold.
83    raised: Vec<Effect>,
84    /// The frames of the last second, for the frame-rate readout.
85    meter: FrameMeter,
86    /// What the last call's frame came to on the meter.
87    metered: Option<Metered>,
88}
89
90/// Which piece of glass is reporting its own bounds. One entry each, so a
91/// band that moves replaces its own reading rather than joining a pile.
92#[derive(Clone, Copy, PartialEq, Eq, Debug)]
93pub enum Band {
94    Toolbar,
95    Status,
96    Notices,
97    /// The navigator, which is glass over the diagram while it is open and
98    /// takes the edge it hugs off the safe region until it closes.
99    Sheet,
100}
101
102/// How the diagram is dressed: the palette it is painted in, the face it is
103/// lettered in, and the scheme an export prints in.
104#[derive(Clone, PartialEq)]
105struct Dressed {
106    palette: Palette,
107    font: FontChoice,
108    scheme: Scheme,
109}
110
111impl Dressed {
112    /// The roles the chrome's own readings of the diagram resolve through —
113    /// a tree row's dot, an accent swatch. The same palette the kernel is
114    /// told, so a swatch and the shape it stands for are one colour.
115    fn theme(&self) -> Theme {
116        let mut theme = Theme::default();
117        theme.set_palette(self.palette.clone());
118        theme
119    }
120}
121
122/// Which picker an effect opened, and on what. One value rather than two
123/// options, because opening one closes the other.
124#[derive(Clone, PartialEq, Debug)]
125pub enum Picker {
126    Accent(RoleTarget),
127    PinType(Vec<PinId>),
128}
129
130/// Whether a keystroke was taken by a piece of glass before the canvas saw
131/// it.
132#[derive(Clone, Copy, PartialEq, Eq, Debug)]
133pub enum Escaped {
134    Claimed,
135    Free,
136}
137
138/// What an open file pick is for, which is the shell's own record of the flow
139/// it is in — the artwork lands as a block's icon, as an image of its own, or
140/// as whatever an import turns out to be.
141#[derive(Clone, Copy, PartialEq, Eq, Debug)]
142pub enum Wanted {
143    Icon(BlockId),
144    Image,
145    Import,
146}
147
148/// Where a `paste` event came from, which the browser does not say.
149///
150/// X11 binds the middle button to pasting the primary selection, and the
151/// browser raises a real `paste` for it — indistinguishable, in the event,
152/// from the one ⌘V raises. The middle button *pans* here
153/// (`Drives::Camera`), so every pan would paste the clipboard into the
154/// diagram as its own commit. Refusing the platform's default on
155/// `pointerdown` does not reach it: Blink raises the paste when the button
156/// comes back up.
157#[derive(Clone, Copy, PartialEq, Eq, Debug)]
158pub enum Pasting {
159    /// Nothing has provoked one, so a paste is the user's own.
160    Asked,
161    /// The middle button went down on the diagram; the next paste, if one
162    /// comes, is the platform's and not an ask.
163    Provoked,
164}
165
166/// What the shell itself could not do — an export the browser refused, a
167/// clipboard that would not take the copy. The kernel knows nothing about
168/// these, so they reach the chrome here rather than through its notices.
169///
170/// The count is what makes a repeat a second report: the same words twice
171/// are two failures, and a toast showing the first must rise again for the
172/// second.
173#[derive(Clone, PartialEq, Eq, Debug, Default)]
174pub struct Reported {
175    pub said: u64,
176    pub what: Option<String>,
177}
178
179/// How much the storage still owes the log — the journal's depth. Zero for a
180/// session with no container to owe it to, and for one whose container is
181/// written to as it goes.
182#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
183pub struct Owed(pub usize);
184
185/// What the session's document goes away for.
186///
187/// Every one of these awaits the origin, and the origin performs one thing at
188/// a time: a rev lands before the row that names it, so no two of these are
189/// ever open at once.
190enum Door {
191    /// The writes the container owes the origin, made.
192    Drained,
193    Born,
194    Named(DocumentRef),
195    Renamed(String),
196    Deleted,
197    Exported,
198    /// A listed diagram, which may or may not be the one in this tab.
199    DeletedNamed(Name),
200    ExportedNamed(Name),
201    Imported {
202        called: String,
203        archive: Vec<u8>,
204    },
205}
206
207/// What a door leaves the session standing on.
208enum Stood {
209    /// The document it was lent, written or renamed — the view and the
210    /// selection are still about it.
211    Carried(Doc),
212    /// Another document, which everything the editor pointed into the last
213    /// one has to be stood back up around.
214    Opened(Doc),
215}
216
217/// What a door that opens a document leaves standing.
218fn opened(got: Result<Store<Any>, String>, lent: Doc) -> (Stood, Option<String>) {
219    match got {
220        Ok(store) => (Stood::Opened(Doc::attached(store)), None),
221        Err(why) => (Stood::Carried(lent), Some(why)),
222    }
223}
224
225/// What the doors that need a container say to a session with none.
226const NO_CONTAINER: &str = "This session has no diagram in the origin's storage";
227
228/// Where the diagram goes.
229struct Surface {
230    canvas: HtmlCanvasElement,
231    ctx: CanvasRenderingContext2d,
232}
233
234/// One call's diagram, kept past the call: a resize clears the backing store,
235/// and the page would show nothing until the call it books had answered.
236struct Diagram {
237    draw_list: DrawList,
238    ground: Ground,
239    vantage: Vantage,
240}
241
242impl Diagram {
243    fn of(view: &mut View) -> Self {
244        Self {
245            draw_list: std::mem::take(&mut view.draw_list),
246            ground: Ground {
247                background: view.ground.background,
248                grid: view.ground.grid,
249            },
250            vantage: view.vantage,
251        }
252    }
253}
254
255/// What handlers have said since the last frame.
256#[derive(Default)]
257struct Batch {
258    events: Vec<Event>,
259    /// What the browser told the canvas, read through the backend's own
260    /// [`Reader`] when the frame assembles the batch — a pan latches across a
261    /// gesture, so the samples are read in one go rather than one at a time.
262    samples: Vec<Sample>,
263}
264
265/// The shell, held once behind [`Rc`]s so every handler carries a clone.
266#[derive(Clone)]
267pub struct Shell {
268    editor: Rc<RefCell<Editor>>,
269    /// The documents the origin holds, and every door onto them.
270    library: Rc<Library>,
271    /// Whether a door or the journal is standing between the frame and the
272    /// document. Every one of them takes the document out of the session for
273    /// as long as it awaits, so the frame that shares this handle is held
274    /// off rather than shown a document that is not there.
275    away: Rc<Cell<bool>>,
276    /// Whether a `paste` arriving now is one the user asked for.
277    pasting: Rc<Cell<Pasting>>,
278    /// The door someone asked for while another was out, which opens when
279    /// that one is back. The latest ask is the one kept.
280    waiting: Rc<RefCell<Option<Door>>>,
281    batch: Rc<RefCell<Batch>>,
282    pacing: Rc<Cell<Pacing>>,
283    chrome: Signal<Chrome>,
284    /// The frame-rate readout, kept up only while the session shows it.
285    frames: Signal<Option<Metered>>,
286    /// What the shell itself could not do, which the toast shows.
287    reported: Signal<Reported>,
288    /// What the storage owes, which the status line's liveness slot shows.
289    owed: Signal<Owed>,
290    /// What the origin holds, which the Diagrams section lists.
291    held: Signal<Vec<Listed>>,
292    /// How many times the menu has asked for the rename box. A count rather
293    /// than a flag: the box lives on the breadcrumb, which has to tell a
294    /// fresh ask from the one it has already opened on.
295    renaming: Signal<u64>,
296    /// Which picker an effect opened, if any.
297    picker: Signal<Option<Picker>>,
298    /// What an open file pick is for, if one is open.
299    wanted: Signal<Option<Wanted>>,
300    /// The part of the diagram the chrome leaves clear, which the selection
301    /// overlay places itself inside.
302    safe: Signal<Rect>,
303    /// Whether the search palette is up. Shell state, not the session's —
304    /// which is why the command that asks for it is an effect.
305    palette: Signal<bool>,
306    /// Which sidebar section is open, if any. Likewise the shell's: the
307    /// session has no opinion about what is being browsed.
308    section: Signal<Option<Section>>,
309    /// How this platform writes the command modifier — the whole of §4's
310    /// per-platform difference.
311    token: Token,
312}
313
314/// Two shells are the same shell when they are clones of one another, which
315/// is what a component needs to know of one.
316impl PartialEq for Shell {
317    fn eq(&self, other: &Self) -> bool {
318        Rc::ptr_eq(&self.editor, &other.editor)
319    }
320}
321
322impl Shell {
323    /// A shell over the document the origin was opened on, primed: the first
324    /// call runs here — dressed as the preferences ask — so the chrome has a
325    /// bar and a tool cluster before a frame has ever been booked.
326    #[must_use]
327    pub fn opening(
328        prefs: &Preferences,
329        prefers: Prefers,
330        opened: Opened,
331    ) -> (Self, Signal<Chrome>) {
332        let Opened {
333            library,
334            doc,
335            notice,
336        } = opened;
337        let dressed = Dressed {
338            palette: prefs.palette(prefers),
339            font: prefs.font,
340            scheme: prefs.scheme,
341        };
342        let mut editor = Editor {
343            session: Session::opening(doc, prefs.identity()),
344            glyphs: Glyphs::new(dressed.font),
345            images: Images::default(),
346            reader: Reader::default(),
347            surface: None,
348            diagram: None,
349            bands: Vec::new(),
350            dressed: dressed.clone(),
351            refused: Vec::new(),
352            raised: Vec::new(),
353            meter: FrameMeter::default(),
354            metered: None,
355        };
356        if let Some(notice) = notice {
357            editor.session.failures.report(notice);
358        }
359        if let Some(named) = editor.session.doc.container_name() {
360            library.remembers(&named);
361        }
362        let primed = editor.run(vec![Event::Action(Action::SetPalette(dressed.palette))]);
363        let chrome = Signal::new(Chrome::of(&primed));
364        let shell = Self {
365            editor: Rc::new(RefCell::new(editor)),
366            library: Rc::new(library),
367            away: Rc::new(Cell::new(false)),
368            pasting: Rc::new(Cell::new(Pasting::Asked)),
369            waiting: Rc::new(RefCell::new(None)),
370            batch: Rc::new(RefCell::new(Batch::default())),
371            pacing: Rc::new(Cell::new(Pacing::default())),
372            chrome,
373            frames: Signal::new(None),
374            reported: Signal::new(Reported::default()),
375            owed: Signal::new(Owed::default()),
376            held: Signal::new(Vec::new()),
377            renaming: Signal::new(0),
378            picker: Signal::new(None),
379            wanted: Signal::new(None),
380            safe: Signal::new(Rect::ZERO),
381            palette: Signal::new(false),
382            section: Signal::new(None),
383            token: window().map_or_else(Token::default, |window| Token::of(&window)),
384        };
385        {
386            let listing = shell.clone();
387            spawn(async move { listing.lists_the_origin().await });
388        }
389        (shell, chrome)
390    }
391
392    /// A shell showing a made-up chrome, so a snapshot can stand a region
393    /// somewhere a scratch document never stands — under the lens, with a
394    /// notice, with something to take back.
395    #[cfg(test)]
396    #[must_use]
397    pub fn showing(chrome: Chrome) -> Self {
398        let (shell, mut signal) =
399            Self::opening(&Preferences::default(), Prefers::Light, Opened::detached());
400        signal.set(chrome);
401        // A snapshot has no canvas to measure, so the room the glass places
402        // itself inside is stated: a fixture bar with nowhere to stand would
403        // draw nothing and pin an empty region.
404        let mut safe = shell.safe;
405        safe.set(Rect::from_min_max(pos2(0.0, 54.0), pos2(1200.0, 800.0)));
406        shell
407    }
408
409    /// What the components bind.
410    #[must_use]
411    pub fn chrome(&self) -> Signal<Chrome> {
412        self.chrome
413    }
414
415    /// What the frame-rate readout shows, once a frame has been metered.
416    #[must_use]
417    pub fn frames(&self) -> Signal<Option<Metered>> {
418        self.frames
419    }
420
421    /// What the shell itself could not do, which the toast shows.
422    #[must_use]
423    pub fn reported(&self) -> Signal<Reported> {
424        self.reported
425    }
426
427    /// What the storage owes, which the status line's liveness slot shows.
428    #[must_use]
429    pub fn owed(&self) -> Signal<Owed> {
430        self.owed
431    }
432
433    /// The storage says how far behind it is.
434    pub fn owes(&self, depth: Owed) {
435        let mut owed = self.owed;
436        if *owed.peek() != depth {
437            owed.set(depth);
438        }
439    }
440
441    /// Which picker is open, which the overlay anchors.
442    #[must_use]
443    pub fn picker(&self) -> Signal<Option<Picker>> {
444        self.picker
445    }
446
447    /// What an open file pick is for, which the hidden input reads.
448    #[must_use]
449    pub fn wanted(&self) -> Signal<Option<Wanted>> {
450        self.wanted
451    }
452
453    /// The part of the diagram the chrome leaves clear.
454    #[must_use]
455    pub fn safe(&self) -> Signal<Rect> {
456        self.safe
457    }
458
459    /// Whether the search palette is up.
460    #[must_use]
461    pub fn palette(&self) -> Signal<bool> {
462        self.palette
463    }
464
465    /// Which sidebar section is open, if any.
466    #[must_use]
467    pub fn section(&self) -> Signal<Option<Section>> {
468        self.section
469    }
470
471    /// Only *working* dismisses the sidebar's panel: a press on the diagram
472    /// or a tool pick is unambiguous intent to edit (`docs/cad-ui-spec.md`
473    /// §8). Nothing inside the panel ever closes it.
474    pub fn works(&self) {
475        let mut section = self.section;
476        if section.peek().is_some() {
477            section.set(None);
478        }
479    }
480
481    /// Escape, claimed before anything else reads it: the palette closes
482    /// first, then the sidebar's panel, and only a keystroke neither wanted
483    /// reaches the canvas.
484    #[must_use]
485    pub fn escaped(&self) -> Escaped {
486        let mut palette = self.palette;
487        let mut section = self.section;
488        if *palette.peek() {
489            palette.set(false);
490            return Escaped::Claimed;
491        }
492        if section.peek().is_some() {
493            section.set(None);
494            return Escaped::Claimed;
495        }
496        Escaped::Free
497    }
498
499    /// The roles the chrome resolves its own readings of the diagram
500    /// through, in the palette the diagram is painted in.
501    #[must_use]
502    pub fn theme(&self) -> Theme {
503        self.editor.borrow().dressed.theme()
504    }
505
506    /// How this platform writes the command modifier.
507    #[must_use]
508    pub fn token(&self) -> Token {
509        self.token
510    }
511
512    /// Who this session's commits are attributed to as it stands.
513    #[must_use]
514    pub fn attributed(&self) -> String {
515        self.editor.borrow().session.identity.name.clone()
516    }
517
518    /// Dress the diagram as the preferences ask, and attribute the session to
519    /// who they name. A preference that did not move changes nothing and
520    /// books no frame.
521    pub fn wears(&self, prefs: &Preferences, prefers: Prefers) {
522        let asked = Dressed {
523            palette: prefs.palette(prefers),
524            font: prefs.font,
525            scheme: prefs.scheme,
526        };
527        let was = {
528            let mut editor = self.editor.borrow_mut();
529            editor.session.identity = prefs.identity();
530            let was = std::mem::replace(&mut editor.dressed, asked.clone());
531            if was.font != asked.font {
532                editor.glyphs = Glyphs::new(asked.font);
533            }
534            was
535        };
536        if was.palette != asked.palette {
537            self.say(Event::Action(Action::SetPalette(asked.palette)));
538        } else if was.font != asked.font {
539            // A new face re-measures every label, so the diagram is owed a
540            // frame even though nothing the kernel knows about moved.
541            self.books(Pacing::said);
542        }
543    }
544
545    /// Something the shell was asked to do did not work. It is the shell's
546    /// own failure, not the document's, so it is toasted rather than filed
547    /// among the session's notices.
548    pub fn failed(&self, what: impl Into<String>) {
549        let what = what.into();
550        tracing::error!("{what}");
551        let mut reported = self.reported;
552        let said = reported.peek().said + 1;
553        reported.set(Reported {
554            said,
555            what: Some(what),
556        });
557    }
558
559    /// What a control raised: an edit goes into the batch by name, and an
560    /// effect is performed by whoever owns the flow it belongs to.
561    pub fn raises(&self, act: Act) {
562        match act {
563            Act::Edit(action) => self.say(Event::Action(action)),
564            Act::Effect(effect) => self.performs(&effect),
565        }
566    }
567
568    /// The doors the chrome opens that the kernel cannot. A door that is not
569    /// there says so rather than failing silently.
570    fn performs(&self, effect: &Effect) {
571        let mut picker = self.picker;
572        let mut wanted = self.wanted;
573        match effect {
574            Effect::NewDocument => self.opens_a_door(Door::Born),
575            Effect::OpenRecent(named) => self.opens_a_door(Door::Named(named.clone())),
576            Effect::RenameDocument(to) => self.opens_a_door(Door::Renamed(to.clone())),
577            Effect::Accent(target) => picker.set(Some(Picker::Accent(*target))),
578            Effect::PinType(pins) => picker.set(Some(Picker::PinType(pins.clone()))),
579            Effect::AddIcon(block) => wanted.set(Some(Wanted::Icon(*block))),
580            Effect::AddImage => wanted.set(Some(Wanted::Image)),
581            Effect::Import => wanted.set(Some(Wanted::Import)),
582            Effect::Search => {
583                let mut palette = self.palette;
584                let open = !*palette.peek();
585                palette.set(open);
586            }
587            // A browser has no disk to pick a container from: a document
588            // arrives through File ▸ Import .bwx.zip instead.
589            Effect::PickFile(_) => {
590                self.failed("A browser cannot open a diagram from disk; use Import .bwx.zip");
591            }
592        }
593    }
594
595    /// What the origin holds, for the section that lists it. A signal rather
596    /// than a call, because the answer is a promise and a component cannot
597    /// await one.
598    #[must_use]
599    pub fn held(&self) -> Signal<Vec<Listed>> {
600        self.held
601    }
602
603    /// Rename: the box the breadcrumb opens on a double click, asked for from
604    /// the Diagrams section instead. One gesture, two ways in.
605    pub fn asks_to_rename(&self) {
606        let mut renaming = self.renaming;
607        let asked = *renaming.peek() + 1;
608        renaming.set(asked);
609    }
610
611    /// The count the box watches, so a fresh ask is told from the one it is
612    /// already open on.
613    #[must_use]
614    pub fn renaming(&self) -> Signal<u64> {
615        self.renaming
616    }
617
618    /// Delete: the document, gone from the origin, and the session left
619    /// standing on the next one.
620    pub fn deletes(&self) {
621        self.opens_a_door(Door::Deleted);
622    }
623
624    /// Export .bwx.zip: what the origin holds, as the one file a container
625    /// travels in.
626    pub fn exports_archive(&self) {
627        self.opens_a_door(Door::Exported);
628    }
629
630    /// A listed row's Export: the diagram it names, as the one file a
631    /// container travels in.
632    pub fn exports_named(&self, name: &str) {
633        match Name::new(name) {
634            Some(named) => self.opens_a_door(Door::ExportedNamed(named)),
635            None => self.failed(format!("{name} is not a diagram")),
636        }
637    }
638
639    /// A listed row's Delete: the diagram it names, gone from the origin.
640    /// Any but this tab's own leaves the session where it stands, and one
641    /// another tab has open is refused.
642    pub fn deletes_named(&self, name: &str) {
643        match Name::new(name) {
644            Some(named) => self.opens_a_door(Door::DeletedNamed(named)),
645            None => self.failed(format!("{name} is not a diagram")),
646        }
647    }
648
649    /// Import .bwx.zip: a container laid down in the origin under a
650    /// name nothing stands under, and opened.
651    pub fn imports_archive(&self, called: String, archive: Vec<u8>) {
652        self.opens_a_door(Door::Imported { called, archive });
653    }
654
655    /// The library's own doors, each of which awaits the origin — so the
656    /// document leaves the session for as long as one is open and the frame
657    /// stands off until it is back.
658    ///
659    /// One at a time and never two: the origin is written in the order the
660    /// store made its writes, and a second door open across the first would
661    /// interleave a rev with the row that names it.
662    fn opens_a_door(&self, door: Door) {
663        if self.away.get() {
664            // The journal is asked for again by the next frame; a door someone
665            // chose is not, so it waits rather than being lost.
666            if !matches!(door, Door::Drained) {
667                self.waiting.replace(Some(door));
668            }
669            return;
670        }
671        self.away.set(true);
672        // A drain writes the diagram in hand, which the list does not read
673        // from the origin, so only the other doors re-read what it holds.
674        let lists = !matches!(door, Door::Drained);
675        let lent = {
676            let mut editor = self.editor.borrow_mut();
677            std::mem::take(&mut editor.session.doc)
678        };
679        let shell = self.clone();
680        spawn(async move {
681            let (stood, failure) = shell.walks_through(door, lent).await;
682            shell.takes_back(stood);
683            if let Some(failure) = failure {
684                shell.reports(failure);
685            }
686            if lists {
687                shell.lists_the_origin().await;
688            }
689        });
690    }
691
692    /// What a door leaves the session standing on, and what it could not do.
693    async fn walks_through(&self, door: Door, mut doc: Doc) -> (Stood, Option<String>) {
694        match door {
695            // A drain that fails has already demoted the container and
696            // dropped what it had not written, so there is nothing to retry
697            // — only the reason to say, which the read-only notice then
698            // stands beside.
699            Door::Drained => {
700                let started = window().as_ref().and_then(clock_of);
701                let drained = doc.drain().await;
702                if let Ok(drained) = &drained {
703                    tracing::info!(took = ?elapsed(started), ?drained, "wrote the journal to the origin");
704                }
705                let failure = drained.err().map(|why| {
706                    format!("The diagram could not be written, and is now read-only: {why}")
707                });
708                (Stood::Carried(doc), failure)
709            }
710            Door::Born => opened(self.library.born().await, doc),
711            Door::Named(named) => opened(self.library.opens(&named).await, doc),
712            Door::Renamed(to) => {
713                let failure = self.library.renames(&mut doc, &to).await.err();
714                (Stood::Carried(doc), failure)
715            }
716            Door::Deleted => self.deletes_this(doc).await,
717            Door::Exported => self.exports_this(doc).await,
718            // A row names this tab's diagram as readily as any other, and that
719            // one goes the way this tab's own door takes it.
720            Door::DeletedNamed(named) if doc.container_name().as_ref() == Some(&named) => {
721                self.deletes_this(doc).await
722            }
723            Door::ExportedNamed(named) if doc.container_name().as_ref() == Some(&named) => {
724                self.exports_this(doc).await
725            }
726            Door::DeletedNamed(named) => {
727                let failure = self.library.removes_closed(&named).await.err();
728                (Stood::Carried(doc), failure)
729            }
730            Door::ExportedNamed(named) => {
731                let failure = self.delivers_archive(&named).await.err();
732                (Stood::Carried(doc), failure)
733            }
734            Door::Imported { called, archive } => {
735                opened(self.library.unpacks(&called, &archive).await, doc)
736            }
737        }
738    }
739
740    /// This tab's diagram, gone from the origin, and the session left
741    /// standing on a newborn.
742    async fn deletes_this(&self, doc: Doc) -> (Stood, Option<String>) {
743        let Some(named) = doc.container_name() else {
744            return (Stood::Carried(doc), Some(NO_CONTAINER.to_owned()));
745        };
746        // The lock goes before the directory does: a container the origin
747        // still has a view of is one the removal would race. So a removal
748        // that does not happen has to open what it let go of, rather than
749        // leaving the session standing on nothing.
750        drop(doc);
751        if let Err(why) = self.library.removes(&named).await {
752            let back = self.library.opens(&DocumentRef::new(named.as_str())).await;
753            return match back {
754                Ok(store) => (Stood::Opened(Doc::attached(store)), Some(why)),
755                Err(also) => (Stood::Opened(Doc::default()), Some(format!("{why} {also}"))),
756            };
757        }
758        let (opened, failure) = self.library.stands_on().await;
759        (Stood::Opened(opened), failure)
760    }
761
762    /// This tab's diagram, packed and handed to the browser to save.
763    async fn exports_this(&self, mut doc: Doc) -> (Stood, Option<String>) {
764        let Some(named) = doc.container_name() else {
765            return (Stood::Carried(doc), Some(NO_CONTAINER.to_owned()));
766        };
767        // What the origin holds is what travels, so what the container still
768        // owes it goes down first.
769        let failure = match doc.drain().await {
770            Ok(_) => self.delivers_archive(&named).await.err(),
771            Err(why) => Some(format!("Failed to export {named}: {why}")),
772        };
773        (Stood::Carried(doc), failure)
774    }
775
776    /// A picker or a file pick ended. What it ended *in* is an ordinary
777    /// action; a cancel is nothing at all, because opening the flow changed
778    /// nothing to put back.
779    pub fn picked(&self, act: Option<Act>) {
780        let mut picker = self.picker;
781        picker.set(None);
782        if let Some(act) = act {
783            self.raises(act);
784        }
785    }
786
787    /// The middle button went down on the diagram: a `paste` that follows is
788    /// the platform's, not an ask ([`Pasting`]).
789    pub fn provoked(&self) {
790        self.pasting.set(Pasting::Provoked);
791    }
792
793    /// A key was pressed, so the user is at the keyboard and the next paste
794    /// is theirs.
795    pub fn asked(&self) {
796        self.pasting.set(Pasting::Asked);
797    }
798
799    /// Whether to take a `paste` the browser just raised. Asking clears the
800    /// provocation, so one middle click refuses one paste.
801    pub fn takes_a_paste(&self) -> bool {
802        self.pasting.replace(Pasting::Asked) == Pasting::Asked
803    }
804
805    /// The bytes a file pick came back with, delivered to whatever the pick
806    /// was opened for — so one that took a while still lands where the press
807    /// that asked for it meant it to.
808    pub fn delivers(&self, what: Wanted, name: &str, bytes: Vec<u8>) {
809        let mut wanted = self.wanted;
810        wanted.set(None);
811        match what {
812            Wanted::Import => {
813                self.editor
814                    .borrow_mut()
815                    .session
816                    .handle_imported(name, bytes);
817                self.books(Pacing::said);
818            }
819            other => {
820                let Some(asset) = blockworx_editor::import::interpret(name, bytes) else {
821                    self.failed(format!("{name} is not a PNG or an SVG"));
822                    return;
823                };
824                self.say(Event::Action(match other {
825                    Wanted::Icon(block) => Action::SetIcon { block, asset },
826                    _ => Action::PlaceImage { asset },
827                }));
828            }
829        }
830    }
831
832    /// A document dropped on the diagram, embedded as a block of this one.
833    ///
834    /// No door: an embed is a copy of the arriving drawing, so nothing is
835    /// laid down in the origin and the session keeps its document throughout.
836    /// Filing a document is the Diagrams section's drop ([`Self::imports_archive`]).
837    pub fn embeds(&self, name: &str, bytes: &[u8]) {
838        self.editor
839            .borrow_mut()
840            .session
841            .handle_embedded(name, bytes);
842        self.books(Pacing::said);
843    }
844
845    /// The container `named`, packed and handed to the browser to save.
846    async fn delivers_archive(&self, named: &blockworx_store::storage::Name) -> Result<(), String> {
847        let archive = self.library.packs(named).await?;
848        let file = format!("{named}.zip");
849        download(&file, "application/zip", &archive)
850            .map_err(|refused| format!("Could not save {file}: {refused:?}"))
851    }
852
853    /// Give the document back and let the frame run again.
854    fn takes_back(&self, stood: Stood) {
855        {
856            let mut editor = self.editor.borrow_mut();
857            match stood {
858                Stood::Carried(doc) => editor.session.doc = doc,
859                Stood::Opened(doc) => {
860                    editor.session.opens(doc);
861                }
862            }
863            if let Some(named) = editor.session.doc.container_name() {
864                self.library.remembers(&named);
865            }
866            self.owes(Owed(editor.session.doc.pending()));
867        }
868        self.away.set(false);
869        self.books(Pacing::said);
870        let Some(door) = self.waiting.borrow_mut().take() else {
871            return;
872        };
873        // What the document still owes the origin goes down before a waiting
874        // door can take the document somewhere else.
875        if self.editor.borrow().session.doc.pending() > 0 {
876            self.waiting.replace(Some(door));
877            self.opens_a_door(Door::Drained);
878        } else {
879            self.opens_a_door(door);
880        }
881    }
882
883    async fn lists_the_origin(&self) {
884        let held = self.library.listing().await;
885        let mut signal = self.held;
886        if *signal.peek() != held {
887            signal.set(held);
888        }
889    }
890
891    /// The journal, after a frame: what the container owes the origin, made.
892    fn drains(&self) {
893        let owed = self.editor.borrow().session.doc.pending();
894        self.owes(Owed(owed));
895        if owed > 0 {
896            self.opens_a_door(Door::Drained);
897        }
898    }
899
900    /// A standing fact about the document, which the notices strip carries
901    /// until it is acknowledged — as the desktop's library reports one.
902    pub fn reports(&self, what: impl Into<String>) {
903        let what = what.into();
904        tracing::error!("{what}");
905        self.editor.borrow_mut().session.failures.report(what);
906        self.books(Pacing::said);
907    }
908
909    /// A file pick was cancelled. Opening the picker changed nothing, so
910    /// there is nothing to put back — only the record of the flow to drop.
911    pub fn unpicked(&self) {
912        let mut wanted = self.wanted;
913        wanted.set(None);
914    }
915
916    /// The face the diagram is drawn in — which the in-place editor types in
917    /// too, so the draft and what it covers are the same letters.
918    #[must_use]
919    pub fn typeface(&self) -> FontChoice {
920        use blockworx_paint::TextLayout as _;
921        self.editor.borrow().glyphs.typeface()
922    }
923
924    /// Say a thing happened, and book the frame that reads it.
925    pub fn say(&self, event: Event) {
926        self.batch.borrow_mut().events.push(event);
927        self.books(Pacing::said);
928    }
929
930    /// The same, for what the pointer or the keyboard did.
931    pub fn sampled(&self, sample: Sample) {
932        self.batch.borrow_mut().samples.push(sample);
933        self.books(Pacing::said);
934    }
935
936    /// The canvas is in the document: take its context, size its backing
937    /// store, and tell the session how big the diagram is.
938    pub fn mounted(&self, canvas: HtmlCanvasElement) {
939        let Some(ctx) = context_of(&canvas) else {
940            tracing::error!("the canvas has no 2d context");
941            return;
942        };
943        let _ = canvas.focus();
944        self.editor.borrow_mut().surface = Some(Surface { canvas, ctx });
945        // The page's own placeholder stands until there is a diagram to put
946        // in its place, which is now.
947        if let Some(loading) = window()
948            .and_then(|window| window.document())
949            .and_then(|document| document.get_element_by_id("bw-loading"))
950        {
951            loading.remove();
952        }
953        self.resized();
954    }
955
956    /// The canvas changed size: the backing store follows the display, and
957    /// the session is told the viewport it now paints in.
958    pub fn resized(&self) {
959        let Some(window) = window() else {
960            return;
961        };
962        let viewport = {
963            let editor = self.editor.borrow();
964            let Some(surface) = &editor.surface else {
965                return;
966            };
967            let viewport = fit(
968                &surface.canvas,
969                &surface.ctx,
970                css_size(&surface.canvas),
971                DevicePixelRatio::of(&window),
972            );
973            editor.repaints(viewport);
974            viewport
975        };
976        self.say(Event::Viewport(viewport));
977        self.says_the_safe_region(viewport);
978    }
979
980    /// `band` lies over the diagram at `at`, in the canvas's own coordinates.
981    /// Measured by the band itself and re-measured when it moves, never
982    /// cached: what a framing has to stay clear of is where the chrome *is*,
983    /// not where it was laid out.
984    pub fn covers(&self, band: Band, at: Rect) {
985        {
986            let mut editor = self.editor.borrow_mut();
987            match editor.bands.iter_mut().find(|(which, _)| *which == band) {
988                Some(stood) if stood.1 == at => return,
989                Some(stood) => stood.1 = at,
990                None => editor.bands.push((band, at)),
991            }
992        }
993        let viewport = self.editor.borrow().session.viewport();
994        self.says_the_safe_region(viewport);
995    }
996
997    /// Where the canvas's top-left sits in client coordinates, which a
998    /// pointer event is read against.
999    #[must_use]
1000    pub fn origin(&self) -> Option<Pos2> {
1001        let editor = self.editor.borrow();
1002        let bounds = editor.surface.as_ref()?.canvas.get_bounding_client_rect();
1003        Some(pos2(bounds.left() as f32, bounds.top() as f32))
1004    }
1005
1006    /// Take the pointer for the gesture and the keyboard for the canvas, so a
1007    /// drag that leaves the element still arrives and the keys are read.
1008    pub fn grabs(&self, pointer: i32) {
1009        let editor = self.editor.borrow();
1010        let Some(surface) = &editor.surface else {
1011            return;
1012        };
1013        let _ = surface.canvas.set_pointer_capture(pointer);
1014        let _ = surface.canvas.focus();
1015    }
1016
1017    /// The keyboard goes back to the canvas: a field that closes leaves the
1018    /// focus on the page's body otherwise, where no digit arms a tool.
1019    pub fn takes_keyboard(&self) {
1020        let editor = self.editor.borrow();
1021        if let Some(surface) = &editor.surface {
1022            let _ = surface.canvas.focus();
1023        }
1024    }
1025
1026    fn says_the_safe_region(&self, viewport: Rect) {
1027        let over: Vec<Rect> = self
1028            .editor
1029            .borrow()
1030            .bands
1031            .iter()
1032            .map(|(_, at)| *at)
1033            .collect();
1034        let clear = clear_of(viewport, &over);
1035        let mut safe = self.safe;
1036        if *safe.peek() != clear {
1037            safe.set(clear);
1038        }
1039        self.say(Event::Safe(clear));
1040    }
1041
1042    fn books(&self, ask: impl FnOnce(&mut Pacing) -> Schedule) {
1043        let mut pacing = self.pacing.get();
1044        let schedule = ask(&mut pacing);
1045        self.pacing.set(pacing);
1046        self.book(schedule);
1047    }
1048
1049    fn book(&self, schedule: Schedule) {
1050        let Some(window) = window() else {
1051            return;
1052        };
1053        match schedule {
1054            Schedule::Idle => {}
1055            Schedule::Frame => {
1056                let shell = self.clone();
1057                let _ = request_animation_frame(&window, move || shell.frame());
1058            }
1059            Schedule::Timer { ticket, after } => {
1060                let shell = self.clone();
1061                let _ = set_timeout(&window, after, move || {
1062                    shell.books(|pacing| pacing.woke(ticket));
1063                });
1064            }
1065        }
1066    }
1067
1068    /// One frame: what was said, run and shown.
1069    ///
1070    /// A frame while a door is open would find a document that is not there,
1071    /// so it stands off; what was said keeps until the door closes and books
1072    /// the frame that reads it. The wait is one origin write per owed row, so
1073    /// what it costs at worst is the frame after a commit lands.
1074    fn frame(&self) {
1075        self.books(Pacing::entered);
1076        if self.away.get() {
1077            return;
1078        }
1079        let batch = self.assemble();
1080        let (view, refused, raised, metered) = {
1081            let mut editor = self.editor.borrow_mut();
1082            let view = editor.run(batch);
1083            (
1084                view,
1085                std::mem::take(&mut editor.refused),
1086                std::mem::take(&mut editor.raised),
1087                editor.metered,
1088            )
1089        };
1090        if view.frame_rate == FrameRate::Shown {
1091            let mut frames = self.frames;
1092            frames.set(metered);
1093        }
1094        for refusal in refused {
1095            self.failed(refusal);
1096        }
1097        // Performed after the borrow is given up: a door that opens a picker
1098        // writes a signal this shell holds, not one the editor does.
1099        for effect in raised {
1100            self.performs(&effect);
1101        }
1102        let shown = Chrome::of(&view);
1103        if *self.chrome.peek() != shown {
1104            let mut chrome = self.chrome;
1105            chrome.set(shown);
1106        }
1107        self.books(|pacing| pacing.ran(view.repaint));
1108        self.drains();
1109    }
1110
1111    /// The batch as the call takes it: what time it is, then what was said,
1112    /// then what the browser told the canvas.
1113    fn assemble(&self) -> Vec<Event> {
1114        let Batch { events, samples } = std::mem::take(&mut *self.batch.borrow_mut());
1115        let focus = focus();
1116        let mut batch = Vec::with_capacity(events.len() + samples.len() + 2);
1117        if let Some(tick) = window().and_then(|window| tick(&window)) {
1118            batch.push(Event::Tick(tick));
1119        }
1120        batch.extend(events);
1121        let read = self.editor.borrow_mut().reader.read(&samples, focus);
1122        batch.extend(read.moves.into_iter().map(Event::Move));
1123        batch.extend(read.input.raw.into_iter().map(Event::Pointer));
1124        batch.push(Event::Keys(read.input.keys));
1125        batch
1126    }
1127}
1128
1129impl Editor {
1130    /// What an export is called, which sheet it is stamped with and which
1131    /// palette it prints in are the shell's facts, not the document's, so the
1132    /// session is told them before it draws its chrome or is told to do
1133    /// anything. The breadcrumb's root segment is the same name.
1134    fn states_the_sheet(&mut self) {
1135        // Nothing opens a document from anywhere yet, so a session with no
1136        // container is Untitled and a named one is called what its container
1137        // is; the library's recents join this when there is one.
1138        let name = self.session.document_name(None);
1139        let rev = self.session.viewed_repo().rev();
1140        self.session.sheet = Sheet {
1141            block: TitleBlock {
1142                name: name.clone(),
1143                author: self.session.identity.name.clone(),
1144                rev,
1145                date: self.session.written_at(rev).map(history::date),
1146                from: None,
1147            },
1148            name,
1149            scheme: self.dressed.scheme,
1150        };
1151    }
1152
1153    /// The call, its hand-offs delivered and its diagram painted.
1154    fn run(&mut self, batch: Vec<Event>) -> View {
1155        self.states_the_sheet();
1156        let clock = || window().as_ref().and_then(clock_of);
1157        let started = clock();
1158        let mut view = kernel(&mut self.session, batch, &self.glyphs);
1159        let answered = clock();
1160        for handoff in std::mem::take(&mut view.handoffs) {
1161            self.deliver(handoff);
1162        }
1163        self.raised.append(&mut view.effects);
1164        let painting = clock();
1165        let owed = self.shows(Diagram::of(&mut view), view.viewport);
1166        if let (Some(started), Some(answered), Some(painting), Some(painted)) =
1167            (started, answered, painting, clock())
1168        {
1169            self.metered = Some(self.meter.record(Frame {
1170                at: started,
1171                kernel: answered.saturating_sub(started),
1172                paint: painted.saturating_sub(painting),
1173            }));
1174        }
1175        self.wears(view.cursor);
1176        // A mark whose artwork is still decoding owes a frame the kernel
1177        // knows nothing about: the browser, not the editor, is what it waits
1178        // on.
1179        if owed == Repaint::Owed {
1180            view.repaint = Some(view.repaint.unwrap_or(Duration::ZERO));
1181        }
1182        view
1183    }
1184
1185    fn deliver(&mut self, handoff: Handoff) {
1186        match handoff {
1187            Handoff::Asset { hash, asset } => self.images.register(hash, &asset),
1188            Handoff::Clipboard(text) => {
1189                if let Err(refused) = clipboard_write(&text) {
1190                    self.refused
1191                        .push(format!("The clipboard refused the copy: {refused:?}"));
1192                }
1193            }
1194            Handoff::Export { content, name } => {
1195                let named = format!("{name}.{}", content.extension());
1196                if let Err(refused) = download(&named, content.mime(), &bytes_for(&content)) {
1197                    self.refused
1198                        .push(format!("Could not save {named}: {refused:?}"));
1199                }
1200            }
1201        }
1202    }
1203
1204    /// Paint `diagram` over `viewport`, and keep it.
1205    fn shows(&mut self, diagram: Diagram, viewport: Rect) -> Repaint {
1206        let owed = self.paint(&diagram, viewport);
1207        let reground = self
1208            .diagram
1209            .as_ref()
1210            .is_none_or(|was| was.ground.background != diagram.ground.background);
1211        if reground {
1212            grounds_the_page(diagram.ground.background);
1213        }
1214        self.diagram = Some(diagram);
1215        owed
1216    }
1217
1218    /// The kept diagram, back over a backing store a resize has just cleared —
1219    /// where it stood, until the call the resize books draws it for the new
1220    /// size.
1221    fn repaints(&self, viewport: Rect) {
1222        if let Some(diagram) = &self.diagram {
1223            self.paint(diagram, viewport);
1224        }
1225    }
1226
1227    fn paint(&self, diagram: &Diagram, viewport: Rect) -> Repaint {
1228        let Some(surface) = &self.surface else {
1229            return Repaint::Settled;
1230        };
1231        paint_ground(&surface.ctx, viewport, diagram.vantage, diagram.ground);
1232        replay(&diagram.draw_list, &surface.ctx, &self.images, &self.glyphs)
1233    }
1234
1235    fn wears(&self, cursor: Option<blockworx_paint::Cursor>) {
1236        let Some(surface) = &self.surface else {
1237            return;
1238        };
1239        let shape = cursor.map_or("default", css_cursor);
1240        let _ = surface.canvas.style().set_property("cursor", shape);
1241    }
1242}
1243
1244/// The part of `viewport` the chrome leaves clear: each band taken off the
1245/// edge it hugs, at the shallowest inset that clears it. A band that touches
1246/// no edge — glass floating over the middle of the diagram — takes nothing
1247/// off: a framing may pass under it, since glass is not a wall.
1248#[must_use]
1249pub fn clear_of(viewport: Rect, bands: &[Rect]) -> Rect {
1250    bands.iter().fold(viewport, |clear, band| {
1251        let band = band.intersect(clear);
1252        if band.width() <= 0.0 || band.height() <= 0.0 {
1253            return clear;
1254        }
1255        let hugged = [
1256            (
1257                band.min.x <= clear.min.x,
1258                band.max.x - clear.min.x,
1259                Edge::Left,
1260            ),
1261            (
1262                band.max.x >= clear.max.x,
1263                clear.max.x - band.min.x,
1264                Edge::Right,
1265            ),
1266            (
1267                band.min.y <= clear.min.y,
1268                band.max.y - clear.min.y,
1269                Edge::Top,
1270            ),
1271            (
1272                band.max.y >= clear.max.y,
1273                clear.max.y - band.min.y,
1274                Edge::Bottom,
1275            ),
1276        ];
1277        let Some(&(_, depth, edge)) = hugged
1278            .iter()
1279            .filter(|(hugs, _, _)| *hugs)
1280            .min_by(|(_, a, _), (_, b, _)| a.total_cmp(b))
1281        else {
1282            return clear;
1283        };
1284        match edge {
1285            Edge::Left => Rect::from_min_max(pos2(clear.min.x + depth, clear.min.y), clear.max),
1286            Edge::Right => Rect::from_min_max(clear.min, pos2(clear.max.x - depth, clear.max.y)),
1287            Edge::Top => Rect::from_min_max(pos2(clear.min.x, clear.min.y + depth), clear.max),
1288            Edge::Bottom => Rect::from_min_max(clear.min, pos2(clear.max.x, clear.max.y - depth)),
1289        }
1290    })
1291}
1292
1293/// Which edge of the viewport a band hugs.
1294#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1295enum Edge {
1296    Left,
1297    Right,
1298    Top,
1299    Bottom,
1300}
1301
1302/// Whose keyboard it is, as the canvas's own reading of Escape, Delete and
1303/// Shift is scoped by.
1304fn focus() -> Focus {
1305    window()
1306        .and_then(|window| window.document())
1307        .map_or(Focus::Elsewhere, |document| focus_of(&document))
1308}
1309
1310/// Whose keyboard it is: a field's while one is being typed into, and the
1311/// canvas's otherwise — a button a press left the focus on types nothing.
1312fn focus_of(document: &Document) -> Focus {
1313    match document.active_element() {
1314        Some(active) if types_into(&active) => Focus::Elsewhere,
1315        _ => Focus::Canvas,
1316    }
1317}
1318
1319/// Whether `element` takes typing, and so keeps the keys it is given.
1320#[must_use]
1321pub fn types_into(element: &web_sys::Element) -> bool {
1322    matches!(element.tag_name().as_str(), "INPUT" | "TEXTAREA" | "SELECT")
1323        || element
1324            .dyn_ref::<HtmlElement>()
1325            .is_some_and(HtmlElement::is_content_editable)
1326}
1327
1328/// Stand the page on the diagram's ground, which is what shows through while a
1329/// resize has cleared the canvas.
1330fn grounds_the_page(ground: blockworx_paint::Color) {
1331    let Some(root) = window()
1332        .and_then(|window| window.document())
1333        .and_then(|document| document.document_element())
1334        .and_then(|root| root.dyn_into::<HtmlElement>().ok())
1335    else {
1336        return;
1337    };
1338    let _ = root.style().set_property("--bw-ground", &css_color(ground));
1339}
1340
1341/// The frame clock, monotonic since the page was opened. The interval the
1342/// session animates over is its own to predict.
1343fn tick(window: &Window) -> Option<Tick> {
1344    clock_of(window).map(Tick::at)
1345}
1346
1347/// How long the page has been open.
1348fn clock_of(window: &Window) -> Option<Duration> {
1349    let now = window.performance()?.now();
1350    Some(Duration::from_secs_f64(now.max(0.0) / 1_000.0))
1351}
1352
1353/// How long since `started`, for a log line that times a write.
1354fn elapsed(started: Option<Duration>) -> Option<Duration> {
1355    let now = window().as_ref().and_then(clock_of)?;
1356    Some(now.saturating_sub(started?))
1357}
1358
1359fn css_size(canvas: &HtmlCanvasElement) -> Vec2 {
1360    let bounds = canvas.get_bounding_client_rect();
1361    vec2(bounds.width() as f32, bounds.height() as f32)
1362}
1363
1364fn context_of(canvas: &HtmlCanvasElement) -> Option<CanvasRenderingContext2d> {
1365    canvas
1366        .get_context("2d")
1367        .ok()
1368        .flatten()
1369        .and_then(|ctx| ctx.dyn_into::<CanvasRenderingContext2d>().ok())
1370}
1371
1372fn request_animation_frame(window: &Window, run: impl FnOnce() + 'static) -> Result<(), JsValue> {
1373    let once = Closure::once_into_js(run);
1374    window.request_animation_frame(once.unchecked_ref())?;
1375    Ok(())
1376}
1377
1378/// Run `work` on the page's own task queue — where every door onto the
1379/// origin is awaited, since the frame that shares the document cannot wait
1380/// with it.
1381///
1382/// Off the browser there is no queue and nothing in `work` could answer, so
1383/// the future is dropped unstarted: a snapshot renders the chrome a door has
1384/// not been walked through.
1385pub fn spawn(work: impl core::future::Future<Output = ()> + 'static) {
1386    #[cfg(target_arch = "wasm32")]
1387    wasm_bindgen_futures::spawn_local(work);
1388    #[cfg(not(target_arch = "wasm32"))]
1389    drop(work);
1390}
1391
1392/// The page this shell is running in, or nothing where there is no page.
1393///
1394/// Off the browser every one of these calls is a JavaScript import that is
1395/// not there, so asking at all would panic rather than answer — which is what
1396/// would happen in the snapshot tests, where the chrome is rendered with no
1397/// browser under it at all.
1398#[must_use]
1399pub fn window() -> Option<Window> {
1400    #[cfg(target_arch = "wasm32")]
1401    {
1402        web_sys::window()
1403    }
1404    #[cfg(not(target_arch = "wasm32"))]
1405    {
1406        None
1407    }
1408}
1409
1410/// Do `then` once `delay` has passed — the chrome's own clock, for the two
1411/// things that are a matter of time rather than of state: a confirmation's
1412/// dwell and the window a double click is told from a single one.
1413///
1414/// Off a browser there is no clock, so nothing happens: a snapshot renders
1415/// what the page looks like now, not what it will look like in two seconds.
1416pub fn after(delay: Duration, then: impl FnOnce() + 'static) {
1417    if let Some(window) = window() {
1418        let _ = set_timeout(&window, delay, then);
1419    }
1420}
1421
1422/// Where an element of the chrome sits over the diagram, in the canvas's own
1423/// coordinates — the two share a positioned parent, so the offsets are
1424/// already those.
1425#[must_use]
1426pub fn measured(element: &web_sys::Element) -> Option<Rect> {
1427    let band = element.dyn_ref::<web_sys::HtmlElement>()?;
1428    Some(Rect::from_min_size(
1429        pos2(band.offset_left() as f32, band.offset_top() as f32),
1430        vec2(band.offset_width() as f32, band.offset_height() as f32),
1431    ))
1432}
1433
1434fn set_timeout(
1435    window: &Window,
1436    after: Duration,
1437    run: impl FnOnce() + 'static,
1438) -> Result<(), JsValue> {
1439    let once = Closure::once_into_js(run);
1440    window.set_timeout_with_callback_and_timeout_and_arguments_0(
1441        once.unchecked_ref(),
1442        after.as_millis().min(i32::MAX as u128) as i32,
1443    )?;
1444    Ok(())
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449    use super::*;
1450
1451    const VIEWPORT: Rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0));
1452
1453    fn band(min: Pos2, size: Vec2) -> Rect {
1454        Rect::from_min_size(min, size)
1455    }
1456
1457    #[test]
1458    fn nothing_over_the_diagram_leaves_the_whole_viewport_clear() {
1459        assert_eq!(clear_of(VIEWPORT, &[]), VIEWPORT);
1460    }
1461
1462    #[test]
1463    fn a_band_along_an_edge_is_taken_off_that_edge() {
1464        let status = band(pos2(0.0, 560.0), vec2(240.0, 40.0));
1465        assert!(
1466            VIEWPORT.intersects(status),
1467            "a band off the diagram would prove nothing",
1468        );
1469        assert_eq!(
1470            clear_of(VIEWPORT, &[status]),
1471            Rect::from_min_max(VIEWPORT.min, pos2(800.0, 560.0)),
1472        );
1473        let strip = band(pos2(0.0, 0.0), vec2(800.0, 54.0));
1474        assert_eq!(
1475            clear_of(VIEWPORT, &[strip]),
1476            Rect::from_min_max(pos2(0.0, 54.0), VIEWPORT.max),
1477        );
1478    }
1479
1480    /// Two bands each take their own edge off, and the order they are
1481    /// measured in does not change the answer.
1482    #[test]
1483    fn bands_on_two_edges_are_both_taken_off() {
1484        let strip = band(pos2(0.0, 0.0), vec2(800.0, 54.0));
1485        let status = band(pos2(0.0, 560.0), vec2(240.0, 40.0));
1486        let clear = Rect::from_min_max(pos2(0.0, 54.0), pos2(800.0, 560.0));
1487        assert_eq!(clear_of(VIEWPORT, &[strip, status]), clear);
1488        assert_eq!(clear_of(VIEWPORT, &[status, strip]), clear);
1489    }
1490
1491    /// Glass floating in the middle of the diagram is not a wall: a framing
1492    /// may pass under it.
1493    #[test]
1494    fn a_band_in_the_middle_takes_nothing_off() {
1495        let popup = band(pos2(300.0, 250.0), vec2(200.0, 100.0));
1496        assert_eq!(clear_of(VIEWPORT, &[popup]), VIEWPORT);
1497    }
1498}