blockworx_kernel/session.rs
1//! Everything the editor is, apart from the surface it is shown on.
2//!
3//! [`Session`] holds the document and the state derived beside it — where the
4//! editor is standing, what is selected, what one undo would take back, what
5//! the last step lit up. Every method here is a write path a front end can
6//! reach without naming a toolkit: the gesture bracket, the history walk, the
7//! command registry, and the document-scoped half of [`Action`](blockworx_tools::tool::Action) dispatch.
8
9use core::time::Duration;
10use std::{
11 cell::RefCell,
12 collections::{BTreeMap, BTreeSet},
13 rc::Rc,
14};
15
16use blockworx_doc::{
17 block_model::Asset,
18 document::{DocIndex, Document},
19 hash::AssetHash,
20 id::BlockId,
21 repo::Repo,
22 rev::Rev,
23};
24use blockworx_editor::{
25 edit::{describe::Label, naming::InterfaceLock},
26 gesture::Gesture,
27 import,
28 path::{BlockPath, Scope},
29 presentation::Presentation,
30 shape::ShapeId,
31 widget::{drawing::Drawing, spatial::CachedIndex},
32};
33use blockworx_geom::{
34 Pos2, Rect,
35 grid::{GRID_SIZE, px_rect},
36 vec2,
37};
38use blockworx_paint::{
39 Base, Easing, EditId, EditText, Interaction, Palette, Saturation, TextEvent, TextOutcome, Tick,
40 Vantage,
41 theme::{FontSizes, Role, Theme},
42};
43use blockworx_store::{
44 Refusal,
45 doc::{Doc, Viewing, Writability},
46 record::Identity,
47};
48use blockworx_tools::{
49 SelectTool,
50 commands::{CommandContext, CommandSet, Effect},
51 history::{self, Direction},
52 multi_pin_select::MultiPinSelect,
53 multi_select::MultiSelect,
54 spotlight::{self, Spotlight, Spotlighter},
55 tool::{Deletable, Tool, ToolTrait},
56};
57
58use crate::{
59 camera::{Camera, CameraWork, Glide, Refit},
60 export::Sheet,
61 handoff::Handoff,
62};
63
64/// How big an imported image lands, measured in grid cells along its longer
65/// side.
66const IMPORTED_IMAGE_CELLS: f32 = 8.0;
67
68/// Where the editor is standing, owned: the scope's ids and the names they
69/// are spelled as. Resolving the names borrows the document index that a
70/// row's other fields are read out of, so a call site takes this first and
71/// lends it to the attribution afterwards.
72struct Standing {
73 path: blockworx_store::record::ScopePath,
74 names: Vec<String>,
75}
76
77impl Standing {
78 fn borrowed(&self) -> blockworx_store::record::Standing<'_> {
79 blockworx_store::record::Standing::new(&self.path, &self.names)
80 }
81}
82
83/// A past rev on the canvas: the rev, and the log prefix folded to it.
84///
85/// The fold lives in here rather than beside a flag, so it is minted exactly
86/// when the selection moves and can never be the wrong rev's. A fresh fold
87/// mints a fresh `DocStamp`, so every derived cache beside the document — the
88/// index, the presentation, the spatial tree — rebuilds against it without
89/// being told which document it is looking at.
90pub struct TimeMachine {
91 at: Rev,
92 folded: Box<Repo>,
93}
94
95impl TimeMachine {
96 pub(crate) fn repo(&self) -> &Repo {
97 &self.folded
98 }
99}
100
101/// What the two history buttons stand over this frame: which entry each would
102/// take, and what taking it costs the log. `None` is an empty stack, which
103/// draws the button dead rather than hiding it.
104#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
105pub struct Consequences {
106 pub undo: Option<history::Consequence>,
107 pub redo: Option<history::Consequence>,
108}
109
110impl Consequences {
111 pub fn history(&self) -> blockworx_tools::commands::History {
112 blockworx_tools::commands::History {
113 undo: self.undo.as_ref().map(|of| of.kind),
114 redo: self.redo.as_ref().map(|of| of.kind),
115 }
116 }
117}
118
119/// Whether a navigator pick replaces the selection or joins it.
120#[derive(Clone, Copy, PartialEq, Eq, Debug)]
121pub enum NavPick {
122 Replace,
123 Extend,
124}
125
126impl From<bool> for NavPick {
127 fn from(extend: bool) -> Self {
128 if extend { Self::Extend } else { Self::Replace }
129 }
130}
131
132/// What the frame draws over the diagram for the author's own benefit, toggled
133/// from the palette and `Off` by default — this is dev tooling with no place
134/// in a diagram somebody is reading.
135#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
136pub enum Diagnostic {
137 #[default]
138 Off,
139 /// The regional router as it sees the selection: what the selection raises
140 /// into the foreground, the bound a gesture on it heals in, and the lattice
141 /// the router builds inside that bound.
142 RegionalRouter,
143}
144
145impl Diagnostic {
146 #[must_use]
147 pub fn toggled(self) -> Self {
148 match self {
149 Self::Off => Self::RegionalRouter,
150 Self::RegionalRouter => Self::Off,
151 }
152 }
153}
154
155/// Whether the front end shows its frame-rate readout. The kernel keeps the
156/// switch, since the palette's commands are its own; the numbers are the host's,
157/// which is the side of the call with a clock.
158#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
159pub enum FrameRate {
160 #[default]
161 Hidden,
162 Shown,
163}
164
165impl FrameRate {
166 #[must_use]
167 pub fn toggled(self) -> Self {
168 match self {
169 Self::Hidden => Self::Shown,
170 Self::Shown => Self::Hidden,
171 }
172 }
173}
174
175/// What the record at `rev` is called — how the undo and redo buttons name
176/// the step they stand over.
177pub fn step_label(doc: &Doc, rev: Option<Rev>) -> Option<&str> {
178 doc.label_at(rev?)
179}
180
181/// What a lens does to the palette: the past is drained of colour, so a rev on
182/// the canvas cannot be mistaken for the writable present.
183pub fn saturation(viewing: Viewing) -> Saturation {
184 match viewing {
185 Viewing::Head => Saturation::Full,
186 Viewing::Past(_) => Saturation::Drained,
187 }
188}
189
190/// The repo the canvas reads: the document's own, or the prefix fold the time
191/// machine is holding. A free function over the two fields rather than a
192/// method, so the passes that also need `&mut` on the index, the presentation,
193/// or the gesture can still split the borrow.
194pub fn viewed<'a>(doc: &'a Doc, time_machine: Option<&'a TimeMachine>) -> &'a Repo {
195 match time_machine {
196 None => doc.repo(),
197 Some(past) => &past.folded,
198 }
199}
200
201/// The tool a set of freshly-landed shapes is left selected in.
202fn select_shapes(shapes: Vec<ShapeId>) -> Tool {
203 if shapes.is_empty() {
204 Tool::Select(SelectTool)
205 } else {
206 Tool::MultiSelect(MultiSelect::Selected { shapes })
207 }
208}
209
210/// The editor, minus its surface.
211pub struct Session {
212 /// The document, always: an in-process session or an attached `.bwx`
213 /// container. Every read goes through it and every write through
214 /// [`Self::submit`], so which of the two homes is open is not a question
215 /// the editor asks.
216 pub doc: Doc,
217 /// Who this session's commits are attributed to. The shell restores it
218 /// from the preferences; what it is used for is entirely here.
219 pub identity: Identity,
220 /// Where the editor is looking. The scope being edited is the path's last
221 /// segment (`Scope::Root` at the document root).
222 pub path: BlockPath,
223 /// The index over the repo's document, kept across frames and rebuilt
224 /// only when a commit lands (`DocIndex::view`).
225 pub doc_index: DocIndex,
226 /// The gesture in progress: its ops and the prediction they imply.
227 pub gesture: Gesture,
228 /// One step back, for the user: document edits and the view steps between
229 /// them, interleaved.
230 pub undo_stack: history::UndoStack,
231 /// The rev [`Self::undo_stack`] has already accounted for. Everything the
232 /// repo has assigned above it is the frame's to record — read off the repo
233 /// rather than tallied beside it, so the two cannot drift.
234 pub recorded_through: Rev,
235 /// Suspended for the rest of a frame that ran an undo or a redo, whose
236 /// restore must not be recorded as a fresh step.
237 pub recording: history::Recording,
238 /// The container name [`Self::undo_stack`] has accounted for — read off
239 /// the document at the start of a frame, as [`Self::recorded_through`] is
240 /// read off the repo at the end of one.
241 naming: history::Naming,
242 /// How the editor arrived where it stands — what one undo would take
243 /// back, in the words its tooltip names.
244 pub moved: history::Moved,
245 /// The ring over what the last document step changed, while one is up.
246 pub spotlight: Spotlighter,
247 /// The roles and fonts are the engine's own; the palette is the one the
248 /// front end last told it
249 /// ([`Action::SetPalette`](blockworx_tools::tool::Action::SetPalette)).
250 pub(crate) theme: Theme,
251 /// The document's derived state — solver geometry, measurement caches,
252 /// propagated accents.
253 pub presentation: Presentation,
254 /// Spatial index over the current level's hittables, shared by viewport
255 /// culling and hit-testing.
256 pub spatial: CachedIndex,
257 pub tool: Tool,
258 /// The past rev on the canvas, folded — the time machine. `None` at head,
259 /// which is where an ordinary session stays.
260 pub time_machine: Option<TimeMachine>,
261 /// The session's keyed easings: the table outlives any one frame's canvas,
262 /// so an affordance that started growing on one frame is read back on the
263 /// next.
264 pub easing: Rc<RefCell<Easing>>,
265 pub diagnostic: Diagnostic,
266 pub frame_rate: FrameRate,
267 /// Where the diagram is shown from: the session's own, moved by the
268 /// host's pointer through [`Self::moves`] and framed by what the
269 /// document does.
270 pub(crate) camera: Camera,
271 /// Where the pointer is over the canvas, in screen space, as the last
272 /// [`Self::resolve`] left it.
273 pointer_at: Option<Pos2>,
274 refit: Refit,
275 tick: Tick,
276 repaint: Option<Duration>,
277 /// What the editor has to hand back, until the host takes it.
278 handoffs: Vec<Handoff>,
279 /// The artwork whose bytes have already crossed to the host: a hash in
280 /// here is one the host can draw without being told again.
281 sent_assets: BTreeSet<AssetHash>,
282 /// What an export of this session says about itself beyond the document —
283 /// stated by the host, the way the camera and the clock are.
284 pub sheet: Sheet,
285 /// Last world-space pointer hover over the canvas, used as the preferred
286 /// paste target so a pasted group lands under the cursor.
287 last_hover_world: Option<Pos2>,
288 /// Last world-space click over the canvas, used as the paste target when
289 /// no hover position is available.
290 last_click_world: Option<Pos2>,
291 /// The failures this session has owned up to, and the user has not yet
292 /// acknowledged.
293 pub failures: crate::chrome::Notices,
294 /// The pointer between frames: the press being held, the click a second
295 /// one could double.
296 pointer: crate::pointer::Resolver,
297 /// The in-place editor the last frame asked for, which is the one the
298 /// front end's field can be reporting on.
299 editor: Option<EditId>,
300 /// How that editor ended since the last frame, landing on the next
301 /// frame's interaction.
302 text_outcome: Option<TextOutcome>,
303}
304
305impl Session {
306 /// A session over `doc`, opened inside the document's designated top
307 /// block, with the undo depth the container was closed with.
308 pub fn opening(doc: Doc, identity: Identity) -> Self {
309 let path = BlockPath::opening(doc.document());
310 let vantage = Vantage::resting();
311 let undo_stack = history::UndoStack::reconstructed(
312 doc.trail(),
313 &history::State {
314 camera: vantage,
315 scope: path.clone(),
316 stood: history::Stood::of(doc.trail()),
317 named: doc.container_name(),
318 moved: history::Moved::default(),
319 selection: None,
320 },
321 );
322 Self {
323 recorded_through: doc.repo().rev(),
324 naming: history::Naming::Settled(doc.container_name()),
325 doc,
326 identity,
327 path,
328 doc_index: DocIndex::default(),
329 gesture: Gesture::idle(),
330 undo_stack,
331 recording: history::Recording::default(),
332 moved: history::Moved::default(),
333 spotlight: Spotlighter::default(),
334 theme: Theme::from_embedded(),
335 presentation: Presentation::default(),
336 spatial: CachedIndex::default(),
337 tool: Tool::Select(SelectTool),
338 time_machine: None,
339 easing: Rc::new(RefCell::new(Easing::default())),
340 diagnostic: Diagnostic::default(),
341 frame_rate: FrameRate::default(),
342 camera: Camera::resting(),
343 pointer_at: None,
344 refit: Refit::Idle,
345 tick: Tick::at(Duration::ZERO),
346 repaint: None,
347 handoffs: Vec::new(),
348 sent_assets: BTreeSet::new(),
349 sheet: Sheet::default(),
350 last_hover_world: None,
351 last_click_world: None,
352 failures: crate::chrome::Notices::default(),
353 pointer: crate::pointer::Resolver::default(),
354 editor: None,
355 text_outcome: None,
356 }
357 }
358
359 /// What this frame's pointer and keys come to for the tools, resolved
360 /// against the camera the session was last shown and dated by its
361 /// clock. Where the pointer is over the canvas is read off the same
362 /// pass, so the two cannot disagree.
363 pub fn resolve(&mut self, input: &blockworx_paint::Input) -> Interaction {
364 let mut interaction =
365 self.pointer
366 .resolve(input, self.now(), self.vantage(), self.viewport().min);
367 interaction.text = self.text_outcome.take();
368 self.pointer_at = self.pointer.latest();
369 interaction
370 }
371
372 // ── The in-place editor ────────────────────────────────────────────────
373
374 /// How the front end's editor ended, landing on the next frame's
375 /// interaction for the tool that opened it. A report on an editor the
376 /// last frame did not ask for is one that has already closed.
377 pub fn text(&mut self, event: &TextEvent) {
378 if self.editor == Some(event.id()) {
379 self.text_outcome = Some(event.clone().outcome());
380 }
381 }
382
383 /// The editor the frame's canvas pass asked for, if any.
384 pub fn asked_editor(&mut self, asked: Option<&EditText>) {
385 self.editor = asked.map(|edit| edit.id);
386 }
387
388 /// What the editor has to hand back — drained by the host each frame.
389 /// See [`Handoff`]: the host never asks the editor for a result, so a
390 /// result that took a thread to compute arrives here without the poll
391 /// changing.
392 pub fn take_handoffs(&mut self) -> Vec<Handoff> {
393 std::mem::take(&mut self.handoffs)
394 }
395
396 /// Where the pointer last hovered over the canvas, in world space.
397 pub fn hovered_world(&self) -> Option<Pos2> {
398 self.last_hover_world
399 }
400
401 /// Where the pointer last clicked on the canvas, in world space.
402 pub fn clicked_world(&self) -> Option<Pos2> {
403 self.last_click_world
404 }
405
406 /// Where the pointer is over the canvas, in screen space.
407 pub fn pointer_screen(&self) -> Option<Pos2> {
408 self.pointer_at
409 }
410
411 /// The same pointer, in world space — where the hover affordances light
412 /// up.
413 pub fn pointer_world(&self) -> Option<Pos2> {
414 self.pointer_at.map(|p| self.screen_to_world(p))
415 }
416
417 /// Leave a result for the host to take.
418 pub(crate) fn hands_back(&mut self, handoff: Handoff) {
419 self.handoffs.push(handoff);
420 }
421
422 /// Hand out the bytes of every asset a frame drew that the host has not
423 /// been given yet.
424 pub(crate) fn hand_out_assets(&mut self, drawn: BTreeMap<AssetHash, Asset>) {
425 for (hash, asset) in drawn {
426 if self.sent_assets.insert(hash) {
427 self.hands_back(Handoff::Asset { hash, asset });
428 }
429 }
430 }
431
432 /// Ask for the current drawing's contents to fill the view. Called
433 /// whenever the block path changes so each navigation lands on a
434 /// normalized, fully-visible view. The framing itself is taken in the
435 /// next canvas pass, where the diagram can be measured; it snaps
436 /// instantly, since the path changed underneath and animating across the
437 /// now-different content is jarring.
438 pub fn fit_view(&mut self) {
439 self.refit = Refit::Owed;
440 self.moved = history::Moved::Fit;
441 }
442
443 /// Whether a fit is owed, taking it: the caller measures what the frame
444 /// paints and frames the camera on it.
445 pub fn take_refit(&mut self) -> Refit {
446 std::mem::replace(&mut self.refit, Refit::Idle)
447 }
448
449 /// Where the pointer last rested over the canvas, in world space.
450 pub fn note_pointer(&mut self, event: Option<blockworx_paint::Event>) {
451 match event {
452 Some(blockworx_paint::Event::HoverAt(p)) => self.last_hover_world = Some(p),
453 Some(blockworx_paint::Event::Clicked { pos }) => self.last_click_world = Some(pos),
454 _ => {}
455 }
456 }
457
458 // ── What the session is looking at ─────────────────────────────────────
459
460 /// Where this session is standing, as a row carries it: advisory, so a
461 /// rev whose scope no longer exists still says where it was made.
462 fn scope_path(&self) -> blockworx_store::record::ScopePath {
463 self.path.segments().iter().copied().collect()
464 }
465
466 /// Where this session is standing, in both spellings a row records: the
467 /// ids the spotlight filters by and the names a history row's scope line
468 /// reads.
469 fn standing(&mut self) -> Standing {
470 Standing {
471 path: self.scope_path(),
472 names: self.scope_names(),
473 }
474 }
475
476 /// The scope path, as the content path spells it.
477 pub fn scope_names(&mut self) -> Vec<String> {
478 let doc = viewed(&self.doc, self.time_machine.as_ref()).document();
479 blockworx_tools::content_path::segments(&self.doc_index.view(doc), &self.path)
480 }
481
482 /// The scope an edit lands in, as a label reads it — the content path's
483 /// own spelling, empty at the document root.
484 pub fn scope_name(&mut self) -> String {
485 let doc = viewed(&self.doc, self.time_machine.as_ref()).document();
486 blockworx_tools::content_path::to_string(&self.doc_index.view(doc), &self.path)
487 }
488
489 /// What this session is looking at, as a row carries it: the author's own
490 /// framing at the moment of the edit, which is evidence of what they were
491 /// working on where a region computed from the change is only a guess.
492 fn camera(&self) -> blockworx_store::record::Camera {
493 crate::recorded_camera(self.vantage(), self.viewport().size())
494 }
495
496 /// Which document the canvas is showing.
497 pub fn viewing(&self) -> Viewing {
498 match &self.time_machine {
499 None => Viewing::Head,
500 Some(past) => Viewing::Past(past.at),
501 }
502 }
503
504 /// The repo the canvas draws, hit-tests, exports, and copies out of.
505 /// Writes never come through here — they go to [`Self::doc`], which is
506 /// always the head.
507 pub fn viewed_repo(&self) -> &Repo {
508 viewed(&self.doc, self.time_machine.as_ref())
509 }
510
511 pub fn viewed_document(&self) -> &Document {
512 self.viewed_repo().document()
513 }
514
515 /// Whether this frame may write the document at all: both the container's
516 /// answer and the time machine's, since either alone is enough to make
517 /// the canvas read-only.
518 pub fn may_write(&self) -> Writability {
519 match (self.doc.writability(), self.viewing().writability()) {
520 (Writability::Writable, Writability::Writable) => Writability::Writable,
521 _ => Writability::ReadOnly,
522 }
523 }
524
525 /// What the drawing is painted in: the roles and fonts, and the palette
526 /// the front end last told.
527 pub fn theme(&self) -> &Theme {
528 &self.theme
529 }
530
531 /// Paint in `palette` from here on. The call that told it has already
532 /// painted by the time its actions run, so the next frame is owed.
533 pub(crate) fn paint_in(&mut self, palette: Palette) {
534 self.theme.set_palette(palette);
535 self.repaint_after(Duration::ZERO);
536 }
537
538 /// Re-point the roles and re-size the canvas fonts — what the dev editors
539 /// (`--theme-editor`, `--font-editor`) author live over the tables the
540 /// engine embeds. The palette is not among them.
541 pub fn retune(
542 &mut self,
543 bases: impl IntoIterator<Item = (Role, Option<Base>)>,
544 sizes: FontSizes,
545 ) {
546 for (role, base) in bases {
547 self.theme.set_base(role, base);
548 }
549 self.theme.set_font_sizes(sizes);
550 }
551
552 /// The palette this frame paints through, drained under a lens.
553 pub fn palette(&self) -> Palette {
554 self.theme.palette().toned(saturation(self.viewing()))
555 }
556
557 /// One of the theme's colours, resolved through that palette — what the
558 /// host paints its own chrome (the canvas ground, the grid) with, so the
559 /// read-only drain cannot reach the diagram and miss the background.
560 pub fn chrome_color(&self, role: Role) -> blockworx_paint::Color {
561 self.palette().resolve(self.theme.swatch(role))
562 }
563
564 /// The document as it stood at `rev`. `None` — having said why — for a
565 /// rev this session cannot show.
566 pub fn document_at(&self, rev: Rev) -> Option<Document> {
567 let _span = tracing::info_span!("read_rev", rev = rev.get()).entered();
568 self.doc
569 .document_at(rev)
570 .inspect_err(|why| tracing::error!("{why}"))
571 .ok()
572 }
573
574 // ── The write door ─────────────────────────────────────────────────────
575
576 /// The current level's drawing. Borrows all of `self`, so passes that
577 /// also need the tool or the theme build their [`Drawing`] from the
578 /// fields directly instead.
579 pub fn drawing(&mut self) -> Drawing<'_> {
580 blockworx_editor::gesture::drawing(
581 viewed(&self.doc, self.time_machine.as_ref()),
582 &mut self.doc_index,
583 &self.path,
584 &mut self.presentation,
585 &mut self.gesture,
586 )
587 }
588
589 /// Open a gesture: everything written until [`Self::end_gesture`] lands in
590 /// one commit under `label`.
591 ///
592 /// The frame's writability rides on the sink, which is how it reaches the
593 /// tools: they read it back off the [`Drawing`] to decide what to offer,
594 /// and the sink itself declines whatever slips past them.
595 pub fn begin_gesture(&mut self, label: Label) {
596 debug_assert!(
597 self.gesture.ops().is_empty(),
598 "a gesture was left open — every begin_gesture is matched by an end_gesture",
599 );
600 self.gesture = Gesture::open(label, self.may_write());
601 }
602
603 #[tracing::instrument(level = "info", skip_all)]
604 pub fn end_gesture(&mut self) {
605 let sealed = blockworx_editor::gesture::seal(
606 viewed(&self.doc, self.time_machine.as_ref()).document(),
607 &mut self.doc_index,
608 &self.path,
609 &self.presentation,
610 &mut self.gesture,
611 );
612 if let Some(sealed) = sealed {
613 self.submit(sealed.commit);
614 // The gesture knows what it disturbed, so the wires outside that
615 // rectangle are drawn from geometry nothing moved near. Bringing
616 // the rest up to date here leaves the next borrow's stamp gate
617 // with nothing to do; a gesture that could not say what it reached
618 // leaves the gate to re-derive the document, as it always has.
619 if let Some(disturbed) = sealed.disturbed {
620 let indexed = {
621 let _s = tracing::info_span!("doc_index").entered();
622 self.doc_index
623 .view(viewed(&self.doc, self.time_machine.as_ref()).document())
624 };
625 self.presentation
626 .reconstruct_within(&indexed, &self.path, disturbed);
627 }
628 }
629 }
630
631 /// The editor's write door: whatever a gesture sealed goes to whichever
632 /// home this document has, attributed to this session's identity.
633 ///
634 /// A refusal is reported and dropped rather than asserted. Two of them
635 /// are reachable: a container that turned read-only under us (the canvas
636 /// still drags, even though the registry withholds the editing commands),
637 /// and a failed append, which has already cost the container its lock.
638 #[tracing::instrument(level = "info", skip_all)]
639 pub fn submit(&mut self, commit: blockworx_doc::commit::Commit) {
640 // The canvas keeps dragging in a read-only session — the registry
641 // withholds the commands, not the pointer — so a gesture can still
642 // seal here. While the time machine is open it would be worse than
643 // refused: the ops were built against a past rev, and the head they
644 // would land on is not what the user is looking at.
645 if self.may_write() == Writability::ReadOnly {
646 tracing::debug!("a read-only session sealed a gesture; it is not recorded");
647 return;
648 }
649 let standing = self.standing();
650 let camera = self.camera();
651 let by = blockworx_store::record::Attribution {
652 author: &self.identity,
653 standing: standing.borrowed(),
654 camera,
655 };
656 match self.doc.submit(commit, by) {
657 Ok(_) => {}
658 Err(Refusal::Fold(refusal)) => {
659 // Unreachable by construction: the emitters read the same
660 // document the fold will judge the commit against. Mirrored
661 // rather than asserted — dying over a broken invariant would
662 // cost the user the drawing.
663 tracing::error!("the document refused a gesture built on its own head: {refusal}");
664 }
665 Err(refusal) => tracing::warn!("the edit was not recorded: {refusal}"),
666 }
667 }
668
669 /// Edit the current level under `label` and seal the result — the shape
670 /// every document mutation outside the canvas pass takes.
671 pub fn commit_gesture<R>(
672 &mut self,
673 label: Label,
674 edit: impl FnOnce(&mut Drawing<'_>) -> R,
675 ) -> R {
676 self.begin_gesture(label);
677 let edited = edit(&mut self.drawing());
678 self.end_gesture();
679 edited
680 }
681
682 // ── History ────────────────────────────────────────────────────────────
683
684 /// What one press of undo and one of redo would do this frame — what the
685 /// registry gates on and what the cluster's tooltip says, answered once
686 /// so the two cannot disagree.
687 pub fn consequences(&self) -> Consequences {
688 let now = self.state();
689 let of = |direction| {
690 let target = self.undo_stack.peek(direction, &now)?;
691 let kind = history::Kind::of_step(&now, &target);
692 // A step is named by the move that reaches its later state, which
693 // going back is the state being left.
694 let (earlier, later) = match direction {
695 Direction::Back => (&target, &now),
696 Direction::Forward => (&now, &target),
697 };
698 let named = match kind {
699 history::Kind::View => later.moved.label().to_owned(),
700 history::Kind::Rename => earlier.rename_to(later)?.label(),
701 history::Kind::Doc => {
702 let trail = self.doc.trail();
703 let commit = match direction {
704 Direction::Back => trail.next_undo(),
705 Direction::Forward => trail.next_redo(),
706 };
707 step_label(&self.doc, commit).unwrap_or_default().to_owned()
708 }
709 };
710 Some(history::Consequence {
711 target: named,
712 kind,
713 })
714 };
715 Consequences {
716 undo: of(Direction::Back),
717 redo: of(Direction::Forward),
718 }
719 }
720
721 /// Take one step of history: restore the state the stack lands on, and
722 /// walk the trail only as far as that state's [`Stood`](history::Stood)
723 /// asks. A step that crosses no trail position touches no log at all.
724 ///
725 /// Recording stops for the rest of the frame; the restore below *is* the
726 /// step, and reading the inverse commits it authored as fresh edits would
727 /// push the step straight back onto the stack.
728 ///
729 /// A step across a rename cannot rename the container itself — that is
730 /// the front end's door — so it asks for one through the call's effects,
731 /// and the stack stands under the asked-for name from here on.
732 pub fn step_history(&mut self, direction: Direction) {
733 let now = self.state();
734 let Some(target) = self.undo_stack.peek(direction, &now) else {
735 return;
736 };
737 // Under the lens, and on a container this session may not write, undo
738 // still serves view entries — a camera move costs the log nothing.
739 // Checked here as well as in the registry, so a step asked for
740 // outright rather than through it cannot slip past.
741 if history::Kind::of_step(&now, &target).writes()
742 && self.may_write() == Writability::ReadOnly
743 {
744 return;
745 }
746 let Some(target) = self.undo_stack.step(direction, &now) else {
747 return;
748 };
749 self.recording = history::Recording::Suspended;
750 if let Some(rename) = now.rename_to(&target) {
751 self.naming = history::Naming::Asked(rename.to.clone());
752 }
753 let changed = self.walk_document(target.stood);
754 self.restore_view(&target, &now);
755 if let Some(spotlight) = changed {
756 self.show_what_changed(spotlight);
757 }
758 }
759
760 /// Walk the trail until the document stands where `target` says, and say
761 /// where the first step it took did its work.
762 ///
763 /// Each step submits an inverse as an ordinary commit, so the log records
764 /// the walk rather than erasing what it crossed. A step the trail will not
765 /// take ends the walk rather than spinning: the stack has already moved,
766 /// and the state fed at the end of the frame is what puts the two back in
767 /// step.
768 ///
769 /// The plan comes back from the *first* step because that is the one the
770 /// press names — a walk of several is the history panel jumping, and what
771 /// the hand asked to see is the near end of it.
772 pub fn walk_document(&mut self, target: history::Stood) -> Option<Spotlight> {
773 let standing = self.standing();
774 let camera = self.camera();
775 let mut changed = None;
776 while history::Stood::of(self.doc.trail()) != target {
777 let here = history::Stood::of(self.doc.trail());
778 let by = blockworx_store::record::Attribution {
779 author: &self.identity,
780 standing: standing.borrowed(),
781 camera,
782 };
783 // The inverse is written against the document as it stands now,
784 // which is what the spotlight reads its footprints off — so it is
785 // taken before the step, not after.
786 let against = self.doc.document().clone();
787 let stepped = if here > target {
788 self.doc
789 .trail()
790 .next_undo()
791 .map(|commit| self.doc.undo(commit, by))
792 } else {
793 self.doc
794 .trail()
795 .next_redo()
796 .map(|commit| self.doc.redo(commit, by))
797 };
798 match stepped {
799 Some(Ok(at)) if history::Stood::of(self.doc.trail()) != here => {
800 changed = changed.or_else(|| self.where_a_step_worked(&against, at));
801 }
802 Some(Ok(_)) => {
803 tracing::error!("a history step left the document where it was");
804 return changed;
805 }
806 Some(Err(refusal)) => {
807 tracing::debug!("the trail would not take the step: {refusal}");
808 return changed;
809 }
810 None => {
811 tracing::debug!("the trail no longer holds the step the stack asked for");
812 return changed;
813 }
814 }
815 }
816 changed
817 }
818
819 /// The scope and world region the step at `at` moved: the names its row
820 /// carries, measured across the step it made — `against` is the document
821 /// it was written against, and the head is where it left things.
822 fn where_a_step_worked(&self, against: &Document, at: Rev) -> Option<Spotlight> {
823 let worked = self.doc.worked_at(at)?;
824 spotlight::worked(
825 spotlight::Step {
826 before: against,
827 after: self.doc.document(),
828 },
829 &self.presentation.routes,
830 &worked,
831 )
832 }
833
834 /// A step that moved the document lands with what it moved in sight.
835 ///
836 /// The restored vantage wins wherever it is honest — it is where the hand
837 /// actually stood, and a step that changed something already on screen
838 /// must not jump the camera. This only aims when the region is out of
839 /// sight, or in a scope the restored one is not looking at.
840 ///
841 /// The scope is not optional. A change is *depicted* on the level it
842 /// happened on, so a viewer looking at another level is being shown a
843 /// drawing the step did not touch.
844 fn show_what_changed(&mut self, spotlight: Spotlight) {
845 let Some(path) = BlockPath::showing(self.viewed_document(), spotlight.scope) else {
846 return;
847 };
848 self.spotlight.light(self.now(), spotlight);
849 self.request_repaint();
850 let region = px_rect(spotlight.region);
851 if self.path == path {
852 self.bring_into_view(region, Glide::Snap);
853 } else {
854 self.path = path;
855 self.after_navigate();
856 // The arrival owes the level a fit; this framing *is* the
857 // arrival, and letting the fit land would show the level rather
858 // than the change.
859 self.refit = Refit::Idle;
860 self.focus_on(region, Glide::Snap);
861 }
862 self.moved = history::Moved::Focus;
863 }
864
865 /// Close the frame: offer the stack the state the frame ended in.
866 ///
867 /// Whether that becomes an entry is the stack's own call — its
868 /// coalescing window — except for a frame that edited, which punctuates
869 /// the stack either side of itself so two quick edits are still two
870 /// presses.
871 ///
872 /// The commits are read off the repo rather than tallied here: a frame
873 /// can submit through more than one door — a gesture, a dispatched
874 /// action, both — and a second tally kept by hand is the thing that
875 /// drifts.
876 ///
877 /// A suspended frame only feeds: the step it ran already moved the stack,
878 /// and the inverse commits it authored are that step, not a new edit.
879 pub fn record_history(&mut self, before: &history::State, at: Duration) {
880 let submitted = self.doc.repo().revs_after(self.recorded_through);
881 self.recorded_through = self.doc.repo().rev();
882 if self.recording == history::Recording::Suspended {
883 self.recording = history::Recording::On;
884 let now = self.state();
885 self.undo_stack.landed(&now);
886 debug_assert_eq!(
887 now.stood,
888 history::Stood::of(self.doc.trail()),
889 "the state fed back does not stand where the trail does",
890 );
891 return;
892 }
893 if self.camera.worked == CameraWork::Worked {
894 self.moved = history::Moved::Camera;
895 self.camera.worked = CameraWork::Idle;
896 }
897 if !submitted.is_empty() {
898 self.moved = history::Moved::Edit;
899 }
900 let now = self.state();
901 if submitted.is_empty() {
902 self.undo_stack.feed(at, &now);
903 } else {
904 self.undo_stack.edited(at, before, &now);
905 }
906 }
907
908 /// Account for a rename the front end performed since the last frame,
909 /// before the frame takes the state it starts from.
910 ///
911 /// A rename the user asked for lands as an entry of its own, punctuated
912 /// like an edit. One a step asked for *is* that step, so it lands nothing
913 /// — and a door that did not rename re-pins the stack on the name the
914 /// container kept, rather than reading as a rename back.
915 pub(crate) fn account_for_rename(&mut self) {
916 let called = self.doc.container_name();
917 let accounted =
918 std::mem::replace(&mut self.naming, history::Naming::Settled(called.clone()));
919 match accounted {
920 history::Naming::Asked(asked) => {
921 if called.as_ref() != Some(&asked) {
922 let now = self.state();
923 self.undo_stack.landed(&now);
924 }
925 }
926 history::Naming::Settled(was) => {
927 let before = history::State {
928 named: was,
929 ..self.state()
930 };
931 let now = history::State {
932 moved: history::Moved::Rename,
933 ..self.state()
934 };
935 if before.rename_to(&now).is_some() {
936 self.moved = now.moved;
937 let at = self.now();
938 self.undo_stack.edited(at, &before, &now);
939 }
940 }
941 }
942 }
943
944 /// The rename a history step asked for, while the container is not yet
945 /// called that — the front end's to perform.
946 pub(crate) fn owed_rename(&self) -> Option<Effect> {
947 match &self.naming {
948 history::Naming::Asked(to) if self.doc.container_name().as_ref() != Some(to) => {
949 Some(Effect::RenameDocument(to.as_str().to_owned()))
950 }
951 history::Naming::Asked(_) | history::Naming::Settled(_) => None,
952 }
953 }
954
955 /// Whether the stack has a step this way, of either kind.
956 pub fn has_step(&self, direction: Direction) -> bool {
957 self.undo_stack.peek(direction, &self.state()).is_some()
958 }
959
960 /// The editor's state as it stands now, for the stack to remember or
961 /// compare against.
962 pub fn state(&self) -> history::State {
963 history::State {
964 camera: self.vantage(),
965 scope: self.path.clone(),
966 stood: history::Stood::of(self.doc.trail()),
967 named: self.naming.stands_under().cloned(),
968 moved: self.moved,
969 selection: self.tool.selection().map(|what| history::Selection {
970 what,
971 anchor: self.tool.overlay_anchor(),
972 }),
973 }
974 }
975
976 /// Put the editor back where a step says it was: the scope, the camera,
977 /// then the best selection the document still holds — resolved through
978 /// the one selection-to-tool map, against the document as it stands
979 /// *after* the walk, so a selection the undo just removed gives way to
980 /// the next candidate rather than leaving a tool pointed at nothing.
981 ///
982 /// Both ends of the step offer a selection, best first. Undoing a *move*
983 /// puts the block back and the block is still there, so what the state
984 /// being left holds is the thing that changed and showing it selected is
985 /// how the undo shows its work — and mid-drag tools report no selection at
986 /// all, so the state the drag started in has none. Undoing a *delete*
987 /// restores what the delete removed, which is what the state being landed
988 /// on holds.
989 fn restore_view(&mut self, to: &history::State, leaving: &history::State) {
990 if self.path != to.scope {
991 self.path = to.scope.clone();
992 self.after_navigate();
993 }
994 self.stand_at(to.camera);
995 // `after_navigate` owes the drawing a fit; the camera this step
996 // restores *is* the framing, and letting the fit land would walk the
997 // camera off the state the stack believes it reached.
998 self.refit = Refit::Idle;
999 let candidates: Vec<&history::Selection> = leaving
1000 .selection
1001 .iter()
1002 .chain(to.selection.iter())
1003 .collect();
1004 let tool = {
1005 let drawing = self.drawing();
1006 history::tool_for(&drawing, &candidates)
1007 };
1008 self.tool = tool;
1009 self.moved = to.moved;
1010 }
1011
1012 // ── Navigation ─────────────────────────────────────────────────────────
1013
1014 /// Settle onto the level the block path now names: the selection belonged
1015 /// to the level we left, and the new one gets a fresh framing.
1016 pub fn after_navigate(&mut self) {
1017 self.tool = Tool::Select(SelectTool);
1018 self.fit_view();
1019 // The fit is part of arriving, not a move of its own, so the entry
1020 // this leaves is named for the arrival.
1021 self.moved = history::Moved::Scope;
1022 }
1023
1024 /// Select a block picked in the navigator. The tree spans the whole
1025 /// document, so the block may live below another level: jump the canvas to
1026 /// its parent diagram first (an extend can't carry across levels, so it
1027 /// falls back to a plain replace there), then frame the block.
1028 pub fn nav_select(&mut self, block: BlockId, pick: NavPick) {
1029 let path_to = BlockPath::to_parent_of(self.viewed_document(), block);
1030 let same_level = path_to.as_ref() == Some(&self.path);
1031 if let Some(path) = path_to
1032 && !same_level
1033 {
1034 self.path = path;
1035 }
1036 let shape = ShapeId::Rect(block);
1037 let base = match (pick, same_level) {
1038 (NavPick::Extend, true) => self
1039 .tool
1040 .selection()
1041 .and_then(|d| d.shapes())
1042 .unwrap_or_default(),
1043 _ => Vec::new(),
1044 };
1045 self.tool = blockworx_tools::select_tool::extend_with_shape(&base, shape);
1046 let world = self.drawing().shape(shape).map(|s| s.gui_rect());
1047 if let Some(world) = world {
1048 self.focus_on(world, Glide::Eased);
1049 self.moved = history::Moved::Focus;
1050 }
1051 }
1052
1053 /// Put `rev` on the canvas. The document is cached in [`TimeMachine`] and
1054 /// re-read only when the selection moves.
1055 ///
1056 /// The camera stays put where it honestly can: the point of the time
1057 /// machine is to watch one place in the diagram change. What it may not do
1058 /// is leave the viewer on a level the picked rev did not touch.
1059 pub fn view_rev(&mut self, rev: Rev) {
1060 if self.viewing() == Viewing::Past(rev) {
1061 return;
1062 }
1063 let worked = self.doc.worked_at(rev);
1064 // Two documents: the rev being viewed, and the one before it. The
1065 // spotlight needs both ends — a delete's subject stands only in the
1066 // departure.
1067 let (Some(previous), Some(after)) = (rev.prev(), self.document_at(rev)) else {
1068 return;
1069 };
1070 let Some(before) = self.document_at(previous) else {
1071 return;
1072 };
1073 let spotlight = worked.and_then(|worked| {
1074 spotlight::worked(
1075 spotlight::Step {
1076 before: &before,
1077 after: &after,
1078 },
1079 &self.presentation.routes,
1080 &worked,
1081 )
1082 });
1083 self.time_machine = Some(TimeMachine {
1084 at: rev,
1085 folded: Box::new(Repo::at(after)),
1086 });
1087 self.settle_on_viewed();
1088 if let Some(spotlight) = spotlight {
1089 match self.doc.camera_at(rev) {
1090 Some(camera) => self.stand_where_it_was_made(spotlight, camera),
1091 None => self.show_what_changed(spotlight),
1092 }
1093 }
1094 }
1095
1096 /// A rev pick lands on the author's own view: the level they were standing
1097 /// on, and the camera they had set when they made the change.
1098 ///
1099 /// The view a row carries is *evidence* of what its author was working on,
1100 /// where a region computed from the change is only a guess at what
1101 /// mattered — so a rev made while zoomed far out replays zoomed far out.
1102 fn stand_where_it_was_made(
1103 &mut self,
1104 spotlight: Spotlight,
1105 camera: blockworx_store::record::Camera,
1106 ) {
1107 let Some(path) = BlockPath::showing(self.viewed_document(), spotlight.scope) else {
1108 return;
1109 };
1110 self.spotlight.light(self.now(), spotlight);
1111 self.request_repaint();
1112 if self.path != path {
1113 self.path = path;
1114 self.after_navigate();
1115 }
1116 // The arrival owes the level a fit; the recorded camera *is* the
1117 // arrival, and letting the fit land would show the level rather than
1118 // where its author stood.
1119 self.refit = Refit::Idle;
1120 let stood = crate::vantage_of(camera, self.viewport().size());
1121 self.stand_at(stood);
1122 self.moved = history::Moved::Focus;
1123 }
1124
1125 /// Back to the writable present — which is also what clears the history
1126 /// panel's selection, since the panel reads [`Self::viewing`] rather than
1127 /// keeping one of its own.
1128 pub fn view_head(&mut self) {
1129 if self.time_machine.take().is_some() {
1130 self.settle_on_viewed();
1131 }
1132 }
1133
1134 /// What every change of viewed document has to settle: the selection
1135 /// named entities the other document may not hold, a popup hangs off that
1136 /// selection, and the block path may name a block that had not been drawn
1137 /// yet at the rev now on the canvas.
1138 pub fn settle_on_viewed(&mut self) {
1139 self.tool = Tool::Select(SelectTool);
1140 if !self.path.is_held_by(self.viewed_document()) {
1141 self.path = BlockPath::opening(self.viewed_document());
1142 self.fit_view();
1143 }
1144 }
1145
1146 /// Take `doc` as the session's document and hand back the one it
1147 /// displaces. The undo stack's watermarks move with it: the incoming
1148 /// document's existing commits are its past, not steps this editor took,
1149 /// and the name it arrives under is not a rename.
1150 pub fn adopt(&mut self, doc: Doc) -> Doc {
1151 self.recorded_through = doc.repo().rev();
1152 self.naming = history::Naming::Settled(doc.container_name());
1153 // A rev of the outgoing document's log names nothing in the incoming
1154 // one.
1155 self.time_machine = None;
1156 std::mem::replace(&mut self.doc, doc)
1157 }
1158
1159 /// Take `doc` as the session's document and stand the editor back up
1160 /// around it: the scope, the derived state, the gesture, the tool, the
1161 /// step stack and the notices all named shapes the last document had.
1162 ///
1163 /// A document that only moved house comes through [`Self::adopt`]
1164 /// instead — the view and the selection are still about it, which is the
1165 /// whole point of saving from inside the lens.
1166 pub fn opens(&mut self, doc: Doc) -> Doc {
1167 let was = self.adopt(doc);
1168 self.path = BlockPath::opening(self.doc.document());
1169 self.presentation = Presentation::default();
1170 self.gesture = Gesture::idle();
1171 // The steps on the old stack name revs of a trail this document does
1172 // not have; the new one's trail is what it stands ready to invert,
1173 // which for a reopened container is everything it was closed with.
1174 self.undo_stack = history::UndoStack::reconstructed(self.doc.trail(), &self.state());
1175 self.tool = Tool::Select(SelectTool);
1176 // The failures were about the document that just left.
1177 self.failures.forget();
1178 self.fit_view();
1179 was
1180 }
1181
1182 /// Put one of a rev's names on `at`, or take one off.
1183 ///
1184 /// Not routed through the gesture bracket: a tag is about history, not
1185 /// part of it, so nothing folds and the undo stack does not move.
1186 pub fn tag_rev(&mut self, at: Rev, name: &str, how: blockworx_store::tags::Tagging) {
1187 let standing = self.standing();
1188 let camera = self.camera();
1189 let by = blockworx_store::record::Attribution {
1190 author: &self.identity,
1191 standing: standing.borrowed(),
1192 camera,
1193 };
1194 if let Err(refusal) = self.doc.tag(at, name, how, by) {
1195 tracing::warn!("rev {} was not tagged: {refusal}", at.get());
1196 }
1197 }
1198
1199 // ── Editing ────────────────────────────────────────────────────────────
1200
1201 /// Arrow-key move of the current selection. Shapes shift by whole grid
1202 /// cells; a pin selection shifts vertically by whole slots (horizontal
1203 /// arrows are ignored for pins). The tool's selection ids are unchanged,
1204 /// so the selection persists across repeated nudges.
1205 #[tracing::instrument(level = "info", skip_all)]
1206 pub fn nudge_selection(&mut self, dx: i32, dy: i32) {
1207 let Some(sel) = self.tool.selection() else {
1208 return;
1209 };
1210 let delta = vec2(dx as f32 * GRID_SIZE, dy as f32 * GRID_SIZE);
1211 match sel {
1212 Deletable::Shape(id) => {
1213 self.commit_gesture(Label::verb("Nudge"), |d| d.move_shape(id, delta));
1214 }
1215 Deletable::Shapes(ids) => {
1216 self.commit_gesture(Label::verb("Nudge"), |d| d.move_shapes(&ids, delta));
1217 }
1218 Deletable::Pins(anchors) if dy != 0 => {
1219 self.commit_gesture(Label::verb("Nudge"), |d| d.nudge_pins(&anchors, dy));
1220 }
1221 Deletable::Pins(_) | Deletable::Route(_) => {}
1222 }
1223 }
1224
1225 /// Where something pasted, imported or inserted lands: under the pointer
1226 /// where there has been one, then the last click, then the middle of the
1227 /// view. One answer, so the three doors cannot drop things in three
1228 /// different places.
1229 pub fn paste_target(&self) -> Pos2 {
1230 self.last_hover_world
1231 .or(self.last_click_world)
1232 .unwrap_or_else(|| {
1233 let viewport = self.viewport();
1234 if viewport.is_positive() {
1235 self.vantage()
1236 .screen_to_world(viewport.min, viewport.center())
1237 } else {
1238 (-self.vantage().translation / self.vantage().zoom.get()).to_pos2()
1239 }
1240 })
1241 }
1242
1243 /// Point the camera at what a paste just dropped. A paste lands at the
1244 /// pointer, and a group wider than the gap between the pointer and the
1245 /// viewport edge lands partly outside it — which is indistinguishable,
1246 /// from the viewer's chair, from not having landed (G6).
1247 fn show_landed(&mut self, landed: &[ShapeId]) {
1248 let Some(bounds) = landed
1249 .iter()
1250 .filter_map(|&id| Some(self.drawing().shape(id)?.gui_rect()))
1251 .reduce(Rect::union)
1252 else {
1253 return;
1254 };
1255 self.bring_into_view(bounds, Glide::Eased);
1256 }
1257
1258 /// Paste a clipboard payload, selecting what landed. A payload a pin copy
1259 /// made pastes by the current selection — onto a single selected child
1260 /// block, else onto this scope's own boundary. Anything else pastes at the
1261 /// paste target.
1262 pub fn paste(&mut self, text: &str) {
1263 let Some(clip) = blockworx_editor::edit::clipboard::Clipboard::from_json(text) else {
1264 return;
1265 };
1266 let into = self.doc.session();
1267 if !clip.is_pin_paste() {
1268 let target = Some(self.paste_target());
1269 // Pasted images carry their own payload; the painter registers
1270 // sources lazily on first draw, so nothing to pre-register here.
1271 let shapes = self.commit_gesture(Label::verb("Paste"), |d| {
1272 d.paste_snapshot(&clip, into, target)
1273 });
1274 self.show_landed(&shapes);
1275 self.tool = select_shapes(shapes);
1276 return;
1277 }
1278 let target_block = match self.tool.selection().and_then(|d| d.shapes()) {
1279 Some(shapes) if shapes.len() == 1 => shapes[0].block(),
1280 _ => None,
1281 };
1282 let owner = target_block.map_or_else(|| self.path.scope(), Scope::Block);
1283 let pins = self.commit_gesture(Label::verb("Paste"), |d| {
1284 d.paste_pins(clip.snapshot(), owner)
1285 });
1286 self.tool = if pins.is_empty() {
1287 Tool::Select(SelectTool)
1288 } else if target_block.is_some() {
1289 Tool::MultiPinSelect(MultiPinSelect::Selected { pins })
1290 } else {
1291 select_shapes(pins.into_iter().map(ShapeId::Port).collect())
1292 };
1293 }
1294
1295 /// Add a picked PNG or SVG to the current view at the paste target, as
1296 /// its own gesture, so an import is one commit and undoes like any edit.
1297 pub fn handle_imported(&mut self, name: &str, bytes: Vec<u8>) {
1298 let target = self.paste_target();
1299 let Some(asset) = import::interpret(name, bytes) else {
1300 tracing::warn!("Unsupported or unreadable import: {name}");
1301 return;
1302 };
1303 let Ok(intrinsic) = blockworx_paint::image::image_intrinsic_size(&asset) else {
1304 tracing::error!("Failed to import image: not a valid image");
1305 return;
1306 };
1307 if !blockworx_editor::edit::lower::asset_within_limit(&asset, name) {
1308 return;
1309 }
1310 let scale = GRID_SIZE * IMPORTED_IMAGE_CELLS / intrinsic.x.max(intrinsic.y).max(1.0);
1311 let rect = Rect::from_center_size(target, intrinsic * scale);
1312 let shape = self.commit_gesture(Label::verb("Import"), |d| {
1313 d.add_image(blockworx_editor::edit::assets::Placement::Box(rect), &asset)
1314 });
1315 self.tool = blockworx_tools::resize_block::ResizeBlock::Selected { shape }.into();
1316 }
1317
1318 /// Embed a document as a block of this one, at the paste target, as its
1319 /// own gesture — so an embed is one commit and undoes like any edit.
1320 ///
1321 /// A copy: nothing records where the block came from, and editing the
1322 /// source afterwards does not touch it. The block is named after the
1323 /// file, the arriving drawing carrying no name of its own.
1324 pub fn handle_embedded(&mut self, name: &str, bytes: &[u8]) {
1325 let target = self.paste_target();
1326 let source = match blockworx_store::transfer::document_in(bytes) {
1327 Ok(source) => source,
1328 Err(why) => {
1329 tracing::warn!("Unreadable diagram: {name}: {why}");
1330 return;
1331 }
1332 };
1333 let title = embedded_title(name);
1334 let block = self.commit_gesture(Label::verb("Embed"), |d| d.embed(&source, &title, target));
1335 let landed = ShapeId::Rect(block);
1336 self.show_landed(&[landed]);
1337 self.tool = select_shapes(vec![landed]);
1338 }
1339
1340 // ── What the user can invoke ───────────────────────────────────────────
1341
1342 /// What the user can invoke this frame. One place, so the chrome, the
1343 /// chords, and the palette cannot disagree — and so a test can ask the
1344 /// registry the same question the toolbar does.
1345 pub fn available_commands(&mut self, current_lock: InterfaceLock) -> CommandSet {
1346 let history = self.consequences().history();
1347 let writability = self.doc.writability();
1348 let saving = self.doc.saving();
1349 let viewing = self.viewing();
1350 let indexed = self
1351 .doc_index
1352 .view(viewed(&self.doc, self.time_machine.as_ref()).document());
1353 let drawing = Drawing::new(
1354 indexed,
1355 &self.path,
1356 &mut self.presentation,
1357 &mut self.gesture,
1358 );
1359 CommandSet::available(&CommandContext {
1360 tool: &self.tool,
1361 data: &drawing,
1362 history,
1363 current_lock,
1364 writability,
1365 saving,
1366 viewing,
1367 })
1368 }
1369
1370 /// The lock the interface stands under right now — what the registry
1371 /// gates the editing commands on.
1372 pub fn current_lock(&mut self) -> InterfaceLock {
1373 self.drawing().current_locked().into()
1374 }
1375
1376 /// The window title: what was opened, and where the edits are going. An
1377 /// attached container names itself and says when it cannot be written; a
1378 /// scratch session keeps its "nothing persisted" marker, which is the
1379 /// literal truth about it.
1380 /// `opened` is the file a scratch document came from — the one fact about
1381 /// the title that belongs to the platform rather than the document.
1382 ///
1383 /// No dirty marker, deliberately: the log is written before an edit
1384 /// returns, and the projection keeps itself fresh — a staleness indicator
1385 /// would read as "unsaved changes", of which there are none.
1386 pub fn window_title(&self, opened: Option<&str>) -> String {
1387 if let Some(name) = self.doc.container_name() {
1388 return match self.doc.read_only_reason() {
1389 Some(_) => format!("{name} - BlockWorx [read-only]"),
1390 None => format!("{name} - BlockWorx"),
1391 };
1392 }
1393 let opened = match opened {
1394 Some(name) => format!("{name} - BlockWorx"),
1395 None => "BlockWorx".to_string(),
1396 };
1397 format!("{opened} [nothing persisted]")
1398 }
1399
1400 /// What the document is called, for the workspace key and an export's
1401 /// file name. `opened` is the scratch document's source file, if any.
1402 pub fn document_name(&self, opened: Option<&str>) -> String {
1403 if let Some(name) = self.doc.container_name() {
1404 return import::file_stem(name.as_str());
1405 }
1406 match opened {
1407 Some(opened) => import::file_stem(opened),
1408 None => import::UNTITLED.to_owned(),
1409 }
1410 }
1411
1412 /// Tell the session what time it is — where [`crate::Event::Tick`] lands,
1413 /// read before anything polls an easing.
1414 ///
1415 /// A tick that states no prediction of the next frame's length is given
1416 /// the interval since the last one, so a front end that knows only what
1417 /// time it is animates the way a host reading its own frame history does.
1418 pub fn ticks(&mut self, tick: Tick) {
1419 let elapsed = tick.now().saturating_sub(self.tick.now());
1420 self.tick = tick.after(self.tick);
1421 // A framing in flight moves by what the frame took, and asks for the
1422 // next frame while it has further to go.
1423 if self.camera.ease(elapsed) {
1424 self.request_repaint();
1425 }
1426 }
1427
1428 /// The frame clock: monotonic since the host started.
1429 pub fn now(&self) -> Duration {
1430 self.tick.now()
1431 }
1432
1433 pub fn tick(&self) -> Tick {
1434 self.tick
1435 }
1436
1437 /// Ask for another frame as soon as the host can give one.
1438 pub fn request_repaint(&mut self) {
1439 self.repaint_after(Duration::ZERO);
1440 }
1441
1442 fn repaint_after(&mut self, after: Duration) {
1443 self.repaint = Some(self.repaint.map_or(after, |soonest| soonest.min(after)));
1444 }
1445
1446 /// The soonest another frame was asked for since this was last drained.
1447 pub fn take_repaint(&mut self) -> Option<Duration> {
1448 self.repaint.take()
1449 }
1450
1451 /// Fold in what one canvas frame asked for.
1452 pub(crate) fn asked_repaint(&mut self, after: Option<Duration>) {
1453 if let Some(after) = after {
1454 self.repaint_after(after);
1455 }
1456 }
1457}
1458
1459/// What an embedded document's block is called: the file's name without the
1460/// suffixes it travelled under. `engine.bwx.zip` is the diagram "engine" —
1461/// both extensions come off, since a container's own name carries neither.
1462pub(crate) fn embedded_title(name: &str) -> String {
1463 let stem = std::path::Path::new(name)
1464 .file_name()
1465 .and_then(|name| name.to_str())
1466 .unwrap_or(name);
1467 let stem = stem.strip_suffix(".zip").unwrap_or(stem);
1468 let stem = stem.strip_suffix(".bwx").unwrap_or(stem);
1469 let stem = stem.strip_suffix(".json").unwrap_or(stem);
1470 if stem.is_empty() {
1471 name.to_owned()
1472 } else {
1473 stem.to_owned()
1474 }
1475}