Skip to main content

blockworx_kernel/
chrome.rs

1//! The chrome model: what a front end shows around the canvas, as values
2//! the session builds and a [`View`](crate::View) carries.
3//!
4//! Every type here is owned and toolkit-free. The bar's words, the status
5//! line's fields, the history's rows, the navigator's tree, the selection
6//! bar's swatch and the notices are all facts about the session, so they
7//! are read off it in one place each; what a widget adds — which panel is
8//! open, where the pointer is over the chrome, a rename box's draft — stays
9//! with the widget.
10
11use std::borrow::Cow;
12
13use blockworx_doc::{
14    id::BlockId,
15    rev::Rev,
16    values::{PinDir, Role as AccentRole},
17};
18use blockworx_editor::{
19    content_path,
20    edit::lower::accent_from_role,
21    path::{BlockPath, Scope, child_blocks, tree_root},
22    shape::{ShapeId, ShapeRef},
23    widget::drawing::Drawing,
24};
25use blockworx_geom::{Rect, grid::GridCell};
26use blockworx_paint::{
27    Color, Zoom,
28    theme::{Role, accent_role},
29};
30use blockworx_store::{
31    doc::{Attachment, Doc, Renaming, Viewing, Writability},
32    history::Row,
33    record::WallTime,
34};
35use blockworx_tools::{
36    commands::{Act, CommandId, CommandSet, Effect},
37    names::{ToolName, displayed_tool, instruction},
38    tool::{RoleTarget, ToolTrait},
39};
40
41use crate::session::{Consequences, Session, viewed};
42
43/// Everything the top bar says: the breadcrumb's root and its levels, the
44/// undo and redo buttons' entries, the lens, and the liveness dot.
45#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
46pub struct TopBar {
47    /// What the document calls itself — the breadcrumb's root segment.
48    pub name: String,
49    pub scope: ScopePath,
50    pub steps: Consequences,
51    pub lens: Lens,
52    pub liveness: Liveness,
53    /// Whether the name can be typed over: only a container has a name to
54    /// change.
55    pub renaming: Renaming,
56}
57
58/// Where the canvas is standing: the levels that reach it, outermost first,
59/// and the path that names them.
60#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
61pub struct ScopePath {
62    pub path: BlockPath,
63    pub names: Vec<String>,
64}
65
66/// Past this many levels the breadcrumb collapses from the middle.
67const MAX_SEGMENTS: usize = 4;
68/// How many levels above the current one survive the collapse.
69const TAIL_SEGMENTS: usize = 2;
70
71/// One thing a breadcrumb draws.
72#[derive(Clone, PartialEq, Eq, Debug)]
73pub enum Crumb {
74    Root,
75    /// A level, by how many levels down from the root it stands.
76    Level(usize),
77    /// The middle the collapse hid, by the same measure.
78    Elided(Vec<usize>),
79}
80
81impl ScopePath {
82    /// How many levels stand between the document and the canvas.
83    pub fn here(&self) -> usize {
84        self.names.len()
85    }
86
87    /// Which segments a breadcrumb of these levels actually draws.
88    ///
89    /// It collapses from the middle — the root and the level the canvas is
90    /// standing on are never hidden. Past four levels the run between them
91    /// goes behind one ellipsis that lists it.
92    pub fn collapsed(&self) -> Vec<Crumb> {
93        let here = self.here();
94        let depths = 1..=here;
95        if here < MAX_SEGMENTS {
96            return std::iter::once(Crumb::Root)
97                .chain(depths.map(Crumb::Level))
98                .collect();
99        }
100        let tail = here.saturating_sub(TAIL_SEGMENTS - 1);
101        vec![
102            Crumb::Root,
103            Crumb::Elided((1..tail).collect()),
104            Crumb::Level(tail),
105            Crumb::Level(here),
106        ]
107    }
108
109    /// What a click on the segment standing `depth` levels down from the
110    /// document asks for. The immediate parent is a rise; anything further
111    /// out names the whole path, since going up twice would take two frames.
112    pub fn up_to(&self, depth: usize) -> blockworx_tools::tool::Action {
113        if depth + 1 == self.path.segments().len() {
114            return blockworx_tools::tool::Action::GoUp;
115        }
116        let mut to = BlockPath::empty();
117        for &id in &self.path.segments()[..depth] {
118            to.push(id);
119        }
120        blockworx_tools::tool::Action::GoToPath(to)
121    }
122}
123
124/// What the lens is showing, and what the bar says about it.
125#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
126pub struct Lens {
127    pub viewing: Viewing,
128    /// The newest rev in the log — the far end the stepper walks toward.
129    pub head: Rev,
130    /// How long ago the viewed rev was written, in words. Empty for a scratch
131    /// session, which keeps no clock to measure against, and the bar then
132    /// says nothing rather than guessing.
133    pub age: String,
134}
135
136/// What the document's own state is, as the dot beside its name reports it.
137///
138/// The user's reading, adopted whole: *"I expect it to be green (all is
139/// good), red (no editing allowed), and yellow (update to the disk
140/// representation in flight). But I also get gray."* The grey is the fourth
141/// state they had not been told about — a session with no file behind it —
142/// which keeps its colour and gains its words.
143#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
144pub enum Liveness {
145    /// Everything the log holds is on disk.
146    Recorded,
147    /// Nothing may be written, for the reason it names.
148    ReadOnly(Locked),
149    /// No container at all: the log lives and dies with the process.
150    Scratch,
151}
152
153/// Why a document may not be written.
154#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
155pub enum Locked {
156    /// A past rev is on the canvas.
157    Lens,
158    /// The container itself is read-only.
159    Container,
160}
161
162impl Liveness {
163    /// The one resolver, so the dot and the words under it cannot disagree
164    /// — and neither can any other surface that comes to ask.
165    ///
166    /// The lens outranks everything: what is on the canvas cannot be edited,
167    /// whatever stands behind it.
168    ///
169    /// A rev is written as it is committed, so an attached document this
170    /// session may write is recorded by the time anyone asks. Where a host
171    /// writes through a queue — the browser's journal — how much it still
172    /// owes is the journal's own count, not this.
173    pub fn of(viewing: Viewing, writability: Writability, attachment: Attachment) -> Self {
174        if matches!(viewing, Viewing::Past(_)) {
175            return Liveness::ReadOnly(Locked::Lens);
176        }
177        if writability == Writability::ReadOnly {
178            return Liveness::ReadOnly(Locked::Container);
179        }
180        match attachment {
181            Attachment::Scratch => Liveness::Scratch,
182            Attachment::Attached => Liveness::Recorded,
183        }
184    }
185}
186
187/// Everything the status line could say, before its priority picks one.
188#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
189pub struct Reading {
190    /// How to use the armed tool, while one is armed.
191    pub tool: Option<Cow<'static, str>>,
192    /// The path to what is selected, while something is.
193    pub selection: Option<String>,
194    pub zoom: Zoom,
195    /// The grid cell under the pointer, while it is over the canvas.
196    pub cursor: Option<GridCell>,
197    pub title: TitleBlock,
198}
199
200/// The status line's second line: *"the text in the lower left corner
201/// (let's consider that the title block) should also indicate on a second
202/// line, the author, current rev, and date/time of that rev."*
203///
204/// The rev is the one **on the canvas**, not the one at the head of the log:
205/// a title block describes the drawing being looked at, and under the lens
206/// that is an earlier rev.
207#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
208pub struct TitleBlock {
209    /// Who this session attributes its work to.
210    pub author: String,
211    pub rev: Rev,
212    /// When `rev` was written, as its own record stamped it. Empty for a
213    /// scratch session, which keeps no records and so no clock — and the
214    /// line then says nothing rather than guessing at one.
215    pub written: String,
216}
217
218/// The navigator's tree: every block the document holds under its top,
219/// nested as the document nests them, beside where the canvas is standing
220/// and what is selected on it.
221#[derive(Clone, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
222pub struct NavTree {
223    /// What the tree hangs from. The top block is never a row of its own —
224    /// its children are the first rows — so a path from the document root
225    /// passes through it by name here.
226    pub hung: Hung,
227    pub nodes: Vec<NavNode>,
228    pub path: BlockPath,
229    pub selected: Vec<BlockId>,
230}
231
232/// Where a tree hangs from: the document's own root, or its designated top.
233#[derive(Clone, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
234pub enum Hung {
235    #[default]
236    FromRoot,
237    FromTop {
238        id: BlockId,
239        /// The top's name, as a content path spells it.
240        name: String,
241    },
242}
243
244impl Hung {
245    pub fn scope(&self) -> Scope {
246        match self {
247            Hung::FromRoot => Scope::Root,
248            Hung::FromTop { id, .. } => Scope::Block(*id),
249        }
250    }
251}
252
253/// One block as the tree lists it, with the blocks it holds beneath it in
254/// the document's one draw order.
255#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
256pub struct NavNode {
257    pub id: BlockId,
258    /// The block's name as a content path spells it: its title, or its id
259    /// where it has none.
260    pub name: String,
261    /// The block's display label: its name, then its type, joined with a
262    /// slash.
263    pub label: String,
264    /// The block's own accent index, or `None` where it has taken none.
265    pub accent: Option<u8>,
266    pub children: Vec<NavNode>,
267}
268
269impl NavNode {
270    /// Whether the tree hangs a disclosure triangle on this row — the same
271    /// answer the canvas's double border and the PDF's page cut give.
272    pub fn opens_a_scope(&self) -> bool {
273        !self.children.is_empty()
274    }
275
276    /// How many leaves this subtree holds — what a closed branch prints so
277    /// its size is legible without opening it. A leaf counts itself.
278    pub fn leaves(&self) -> usize {
279        if self.children.is_empty() {
280            return 1;
281        }
282        self.children.iter().map(NavNode::leaves).sum()
283    }
284}
285
286impl NavTree {
287    /// The tree over `document`, under its top block: every block the
288    /// document holds, nested as the document nests them.
289    pub fn of(
290        document: &blockworx_doc::document::IndexedDocument<'_>,
291        path: BlockPath,
292        selected: Vec<BlockId>,
293    ) -> Self {
294        fn nodes(
295            document: &blockworx_doc::document::IndexedDocument<'_>,
296            scope: Scope,
297        ) -> Vec<NavNode> {
298            child_blocks(document, scope)
299                .into_iter()
300                .map(|id| NavNode {
301                    id,
302                    name: content_path::name_of(document, id),
303                    label: block_label(document, id),
304                    accent: document
305                        .doc
306                        .block(&id)
307                        .and_then(|block| accent_from_role(block.role)),
308                    children: nodes(document, Scope::Block(id)),
309                })
310                .collect()
311        }
312        let root = tree_root(document);
313        NavTree {
314            hung: match root {
315                Scope::Root => Hung::FromRoot,
316                Scope::Block(id) => Hung::FromTop {
317                    id,
318                    name: content_path::name_of(document, id),
319                },
320            },
321            nodes: nodes(document, root),
322            path,
323            selected,
324        }
325    }
326
327    /// Resolve a path string back to a [`BlockPath`], matching names down
328    /// from the document root — through the top, where one is designated —
329    /// case-insensitively after trimming; the first sibling that matches
330    /// wins. `None` if any name names nothing the tree holds.
331    pub fn path_of(&self, text: &str) -> Option<BlockPath> {
332        let mut names = text
333            .split(content_path::SEPARATOR)
334            .map(str::trim)
335            .filter(|name| !name.is_empty())
336            .peekable();
337        let mut path = BlockPath::empty();
338        if let Hung::FromTop { id, name } = &self.hung {
339            let first = names.next()?;
340            if !name.eq_ignore_ascii_case(first) {
341                return None;
342            }
343            path.push(*id);
344        }
345        let mut level = self.nodes.as_slice();
346        for name in names {
347            let node = level
348                .iter()
349                .find(|node| node.name.eq_ignore_ascii_case(name))?;
350            path.push(node.id);
351            level = &node.children;
352        }
353        Some(path)
354    }
355
356    /// Every block the tree holds with its display label, depth first — the
357    /// palette's search space for `find`.
358    pub fn all_blocks(&self) -> Vec<(BlockId, String)> {
359        self.walk()
360            .map(|(node, _)| (node.id, node.label.clone()))
361            .collect()
362    }
363
364    /// The blocks on the level the canvas is standing on, with their display
365    /// labels — the palette's `expand <block>` targets, since only a direct
366    /// child can be descended into.
367    pub fn level_blocks(&self) -> Vec<(BlockId, String)> {
368        self.children_of(self.path.scope())
369            .iter()
370            .map(|node| (node.id, node.label.clone()))
371            .collect()
372    }
373
374    /// Every node, depth first, each with the ids of its ancestors between
375    /// the tree's root and it, outermost first.
376    pub fn walk(&self) -> impl Iterator<Item = (&NavNode, Vec<BlockId>)> {
377        fn descend<'a>(
378            nodes: &'a [NavNode],
379            above: &mut Vec<BlockId>,
380            out: &mut Vec<(&'a NavNode, Vec<BlockId>)>,
381        ) {
382            for node in nodes {
383                out.push((node, above.clone()));
384                above.push(node.id);
385                descend(&node.children, above, out);
386                above.pop();
387            }
388        }
389        let mut out = Vec::new();
390        descend(&self.nodes, &mut Vec::new(), &mut out);
391        out.into_iter()
392    }
393
394    /// The node for `id`, wherever it is in the tree.
395    pub fn node(&self, id: BlockId) -> Option<&NavNode> {
396        self.walk().map(|(node, _)| node).find(|node| node.id == id)
397    }
398
399    /// The blocks a scope holds: the tree's own rows for [`Scope::Root`] —
400    /// the root a walk over the tree hangs from — and for the scope the
401    /// tree hangs from in the document, which names the same rows for a
402    /// path standing inside the top; a node's children for a block within.
403    pub fn children_of(&self, scope: Scope) -> &[NavNode] {
404        if scope == Scope::Root || scope == self.hung.scope() {
405            return &self.nodes;
406        }
407        match scope {
408            Scope::Root => &[],
409            Scope::Block(id) => self.node(id).map_or(&[], |node| node.children.as_slice()),
410        }
411    }
412}
413
414/// A block's display label: its name, then its type, joined as `name/type`,
415/// skipping an empty type. The name falls back to the block's id (`b3`)
416/// when unnamed, so the label is never empty — and so a block that has
417/// left the document still names a row.
418pub fn block_label(document: &blockworx_doc::document::IndexedDocument<'_>, id: BlockId) -> String {
419    let Some(block) = document.doc.block(&id) else {
420        return id.to_string();
421    };
422    let name = block.title.name.trim();
423    let head = if name.is_empty() {
424        id.to_string()
425    } else {
426        name.to_owned()
427    };
428    let kind = block.type_label.name.trim();
429    if kind.is_empty() {
430        head
431    } else {
432        format!("{head}/{kind}")
433    }
434}
435
436/// The selection bar's contents: the selection it stands over, and the
437/// readings of the document its controls and pickers show — the accent
438/// swatch's colour, the accent index behind it, and the direction the I/O
439/// toggle reports.
440#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
441pub struct Overlay {
442    pub selection: Selection,
443    /// The colour the accent swatch shows, where an accent command is
444    /// offered: the selection's own accent, or its un-accented stroke.
445    pub swatch: Option<Color>,
446    /// The accent index the selection carries, where an accent command is
447    /// offered — `None` for no accent, which the picker shows as the
448    /// target's own un-accented stroke.
449    pub accent: Option<u8>,
450    /// The one direction every pin the I/O command stands over is facing,
451    /// where one is offered and they agree.
452    pub pin_dir: Option<PinDir>,
453}
454
455/// The selection the bar is drawn for: where it sits on screen, and how
456/// many things it holds.
457#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
458pub struct Selection {
459    pub screen: Rect,
460    pub count: usize,
461}
462
463// The overlay's own `PartialEq` is by value; `Rect` is floats, so `Eq` is
464// not derivable and the overlay states it explicitly.
465impl Eq for Selection {}
466
467impl Overlay {
468    /// The bar's contents for `selection`, read off the drawing the commands
469    /// were offered against: the accent swatch's colour where an accent
470    /// command is offered, and the I/O toggle's direction where one is.
471    pub fn of(
472        drawing: &Drawing<'_>,
473        theme: &blockworx_paint::theme::Theme,
474        selection: Selection,
475        commands: &CommandSet,
476    ) -> Self {
477        // The drawn commands, the withheld ones included: the bar draws
478        // those dead rather than leaving a hole, so the swatch still shows
479        // its colour on a read-only canvas.
480        let accent = commands
481            .iter_drawn()
482            .find_map(|cmd| match (cmd.id, &cmd.act) {
483                (CommandId::Accent, Act::Effect(Effect::Accent(target))) => Some(*target),
484                _ => None,
485            });
486        let pins = commands
487            .iter_drawn()
488            .find_map(|cmd| match (cmd.id, &cmd.act) {
489                (CommandId::PinType, Act::Effect(Effect::PinType(pins))) => Some(pins.as_slice()),
490                _ => None,
491            });
492        let current = accent.map(|target| (target, current_accent(drawing, target)));
493        Overlay {
494            selection,
495            swatch: current
496                .map(|(target, index)| theme.resolve(accent_display_role(target, index))),
497            accent: current.and_then(|(_, index)| index),
498            pin_dir: pins.and_then(|pins| shared_dir(drawing, pins)),
499        }
500    }
501}
502
503/// What the session has to tell the user about its document: why the canvas
504/// is read-only, what will be overwritten by the next save, and the load
505/// that did not work.
506///
507/// Two kinds, because they end differently. A standing fact stands until the
508/// fact does — dismissing "read-only" would not make the container writable.
509/// A failure happened once, so it carries an acknowledgement and is gone for
510/// the rest of the session once it is given.
511#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
512pub enum Notice {
513    /// A failure that happened once — a container that would not open, a file
514    /// that would not be written.
515    Failure(String),
516    /// A standing fact about the document, true until it stops being true.
517    Standing(String),
518}
519
520/// What this session has owned up to and the user has not yet acknowledged:
521/// an error nobody can dismiss makes the editor unusable
522/// (docs/ui-issues.md). Dropped with the document it was about.
523#[derive(Default, Debug)]
524pub struct Notices(Vec<String>);
525
526impl Notices {
527    /// What the startup could not do, already said where it happened — so it
528    /// is seeded rather than reported.
529    pub fn opening(failure: Option<String>) -> Self {
530        Self(failure.into_iter().collect())
531    }
532
533    /// Say on the canvas — and on the console — that something did not work.
534    /// It stands until the user acknowledges it, or until the document it was
535    /// about is replaced.
536    pub fn report(&mut self, failure: String) {
537        tracing::error!("{failure}");
538        self.0.push(failure);
539    }
540
541    /// Drop them all, because the document they were about has gone.
542    pub fn forget(&mut self) {
543        self.0.clear();
544    }
545
546    pub fn acknowledge(&mut self, Acknowledged(failure): Acknowledged) {
547        if failure < self.0.len() {
548            self.0.remove(failure);
549        }
550    }
551
552    pub fn failures(&self) -> &[String] {
553        &self.0
554    }
555}
556
557/// The failure the user acknowledged, by its position among the failures.
558#[derive(Clone, Copy, Debug, PartialEq, Eq)]
559pub struct Acknowledged(pub usize);
560
561impl Session {
562    /// The top bar's words, read off the session once so the breadcrumb, the
563    /// lens and the dot cannot disagree with the document.
564    pub fn top_bar(&mut self) -> TopBar {
565        let viewing = self.viewing();
566        // The rev's own row, so the bar's age is the history panel's, read
567        // the same way from the same rows.
568        let age = match viewing {
569            Viewing::Head => None,
570            Viewing::Past(at) => self
571                .history_rows()
572                .iter()
573                .find(|row| row.rev == at)
574                .map(|row| row.since(blockworx_store::history::now())),
575        };
576        TopBar {
577            name: self.sheet.name.clone(),
578            scope: ScopePath {
579                path: self.path.clone(),
580                names: self.scope_names(),
581            },
582            steps: self.consequences(),
583            lens: Lens {
584                viewing,
585                head: self.doc.repo().rev(),
586                age: age.unwrap_or_default(),
587            },
588            liveness: Liveness::of(viewing, self.doc.writability(), self.doc.attachment()),
589            renaming: self.doc.renaming(),
590        }
591    }
592
593    /// The colours under the diagram: the canvas ground and the grid over
594    /// it, resolved through the same palette the diagram is — so the
595    /// read-only drain cannot reach one and miss the other.
596    pub fn ground(&self) -> Ground {
597        Ground {
598            background: self.chrome_color(Role::CanvasBackground),
599            grid: self.chrome_color(Role::GridLine),
600        }
601    }
602
603    /// What a frame wrote, where the log moved from `stood`: the confirmation
604    /// the status line shows. Read off the log rather than off what did it,
605    /// so the confirmation and the history row word one edit the same way,
606    /// and a frame that turned out to write nothing says nothing.
607    pub fn landed(&self, stood: Rev) -> Option<String> {
608        let now = self.doc.repo().rev();
609        if now == stood {
610            return None;
611        }
612        let take_back = self.doc.trail().next_undo();
613        Some(match crate::session::step_label(&self.doc, take_back) {
614            Some(label) => format!("{label} \u{2014} rev {}", now.get()),
615            None => format!("Rev {}", now.get()),
616        })
617    }
618
619    /// The document's history as its readers see it, oldest first.
620    pub fn history_rows(&self) -> Vec<Row> {
621        blockworx_store::history::rows(self.doc.journal(), self.doc.tags())
622    }
623
624    /// The tool the rail shows armed.
625    pub fn displayed_tool(&self) -> ToolName {
626        displayed_tool(self.tool.name())
627    }
628
629    /// Everything the status line could say this frame.
630    pub fn reading(&mut self) -> Reading {
631        let cursor = self
632            .pointer_screen()
633            .and(self.hovered_world())
634            .map(GridCell::at);
635        Reading {
636            tool: instruction(self.displayed_tool()).map(Cow::Borrowed),
637            selection: self.selection_path(),
638            zoom: self.vantage().zoom,
639            cursor,
640            title: self.title_block(),
641        }
642    }
643
644    /// What is selected, as a path — the ephemeral half of the status line,
645    /// which is why it cannot live in the bar's own breadcrumb.
646    ///
647    /// One shape is named where the document names it; several are counted,
648    /// since a list of names is not a path and would not fit on a line.
649    fn selection_path(&mut self) -> Option<String> {
650        let count = self.tool.selection().map_or(0, |sel| sel.count());
651        if count == 0 {
652            return None;
653        }
654        let named = (count == 1)
655            .then(|| {
656                let shape = self.tool.selection()?.shapes()?.into_iter().next()?;
657                let title = self.drawing().shape(shape)?.title()?.name.to_owned();
658                let mut path = self.scope_names();
659                path.push(title);
660                Some(path.join(&format!(" {SELECTION_SEPARATOR} ")))
661            })
662            .flatten();
663        Some(named.unwrap_or_else(|| format!("{count} selected")))
664    }
665
666    /// The status line's title block: who this session attributes its work
667    /// to, the rev **on the canvas**, and when that rev was written. Under
668    /// the lens that is the rev being looked at, because a title block
669    /// describes the drawing in front of the reader.
670    fn title_block(&self) -> TitleBlock {
671        let rev = self.viewed_repo().rev();
672        TitleBlock {
673            author: self.identity.name.clone(),
674            rev,
675            written: self
676                .written_at(rev)
677                .map(blockworx_store::history::written_at)
678                .unwrap_or_default(),
679        }
680    }
681
682    /// When `rev` was written, where this session keeps rows. A scratch
683    /// session keeps none, and answers `None` rather than the wall clock —
684    /// which would make two readings of one rev differ.
685    pub fn written_at(&self, rev: Rev) -> Option<WallTime> {
686        match &self.doc {
687            Doc::Scratch { .. } => None,
688            Doc::Attached { store, .. } => store.row(rev).map(|row| row.wall_time),
689        }
690    }
691
692    /// The navigator's tree over the document on the canvas.
693    pub fn nav_tree(&mut self) -> NavTree {
694        let selected = self
695            .tool
696            .selection()
697            .and_then(|d| d.shapes())
698            .unwrap_or_default()
699            .into_iter()
700            .filter_map(ShapeId::block)
701            .collect();
702        let document = self
703            .doc_index
704            .view(viewed(&self.doc, self.time_machine.as_ref()).document());
705        NavTree::of(&document, self.path.clone(), selected)
706    }
707
708    /// The selection bar's contents, for the selection whose on-screen
709    /// bounds the canvas pass measured — `None` while nothing is selected.
710    /// `commands` is the frame's registry: the swatch and the I/O toggle
711    /// read the targets of the commands it offers.
712    pub fn overlay(&mut self, bounds: Option<Rect>, commands: &CommandSet) -> Option<Overlay> {
713        let count = self.tool.selection()?.count();
714        let screen = bounds?;
715        let Session {
716            doc,
717            time_machine,
718            doc_index,
719            presentation,
720            gesture,
721            path,
722            theme,
723            ..
724        } = self;
725        let document = viewed(doc, time_machine.as_ref()).document();
726        let drawing = Drawing::new(doc_index.view(document), path, presentation, gesture);
727        Some(Overlay::of(
728            &drawing,
729            theme,
730            Selection { screen, count },
731            commands,
732        ))
733    }
734
735    /// What this session has to tell the user: its failures first, since a
736    /// failure is news, then the standing facts about its files.
737    pub fn notices(&self) -> Vec<Notice> {
738        self.failures
739            .failures()
740            .iter()
741            .map(|failure| Notice::Failure(failure.clone()))
742            .chain(self.file_notices().into_iter().map(Notice::Standing))
743            .collect()
744    }
745
746    /// The standing facts about the files under this document. A session
747    /// with no container has none, so it has nothing to say.
748    fn file_notices(&self) -> Vec<String> {
749        let mut notices = Vec::new();
750        if let Some(reason) = self.doc.read_only_reason() {
751            notices.push(format!("Read-only \u{2014} {reason}"));
752        }
753        notices
754    }
755}
756
757/// What separates the levels of a selection path in the status line — the
758/// mockup's own `join(" / ")`.
759const SELECTION_SEPARATOR: &str = "/";
760
761/// The colours under the diagram.
762#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
763pub struct Ground {
764    pub background: Color,
765    pub grid: Color,
766}
767
768/// The accent index `target` carries now, or `None` when it has none.
769fn current_accent(data: &Drawing<'_>, target: RoleTarget) -> Option<u8> {
770    let accent = |role: AccentRole| accent_from_role(role);
771    match target {
772        RoleTarget::Block(rid) => data.block(rid).and_then(|b| accent(b.role)),
773        RoleTarget::Port(pid) => match data.shape(ShapeId::Port(pid)) {
774            Some(ShapeRef::Port(port)) => accent(port.pin.port_accent),
775            _ => None,
776        },
777        RoleTarget::Route(rid) => data.auto_route(rid).and_then(|w| accent(w.route.role)),
778        RoleTarget::Area(cid) => match data.shape(ShapeId::Area(cid)) {
779            Some(ShapeRef::Area(area)) => accent(area.role),
780            _ => None,
781        },
782        RoleTarget::Text(tid) => match data.shape(ShapeId::Text(tid)) {
783            Some(ShapeRef::Text(text)) => accent(text.text.role),
784            _ => None,
785        },
786    }
787}
788
789/// The [`Role`] the accent swatch displays for `target` given its current
790/// accent index `current`: the mapped accent role, or — when unset — the
791/// target's own un-accented stroke.
792pub fn accent_display_role(target: RoleTarget, current: Option<u8>) -> Role {
793    let default_role = match target {
794        RoleTarget::Area(_) => Role::AreaStroke,
795        RoleTarget::Text(_) => Role::TextBoxStroke,
796        RoleTarget::Block(_) | RoleTarget::Port(_) | RoleTarget::Route(_) => Role::AccentDefault,
797    };
798    accent_role(current).unwrap_or(default_role)
799}
800
801/// The one direction every pin in `pins` is facing, or `None` where they
802/// disagree or the selection has gone.
803fn shared_dir(data: &Drawing<'_>, pins: &[blockworx_doc::id::PinId]) -> Option<PinDir> {
804    let mut dirs = pins
805        .iter()
806        .map(|&pin| data.pin_on_shape(pin).map(|(_, pin)| pin.dir));
807    let first = dirs.next()??;
808    dirs.all(|dir| dir == Some(first)).then_some(first)
809}
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814    use blockworx_doc::fixtures::block_id;
815    use blockworx_editor::widget::test_fixtures::{self as fx, Scene};
816    use blockworx_geom::{Rect, pos2};
817
818    fn levels_deep(levels: usize) -> ScopePath {
819        let path = (1..=levels).fold(BlockPath::empty(), |mut path, at| {
820            path.push(block_id(at as u32));
821            path
822        });
823        ScopePath {
824            names: (1..=levels).map(|at| format!("Level {at}")).collect(),
825            path,
826        }
827    }
828
829    /// The breadcrumb collapses from the middle: the root and the level the
830    /// canvas is standing on are never hidden, and what falls out between
831    /// them is exactly the middle.
832    #[test]
833    fn a_deep_path_collapses_from_the_middle_and_keeps_its_ends() {
834        assert_eq!(
835            levels_deep(2).collapsed(),
836            vec![Crumb::Root, Crumb::Level(1), Crumb::Level(2)],
837            "a path that fits was collapsed anyway",
838        );
839        let deep = levels_deep(5).collapsed();
840        assert_eq!(
841            deep,
842            vec![
843                Crumb::Root,
844                Crumb::Elided(vec![1, 2, 3]),
845                Crumb::Level(4),
846                Crumb::Level(5),
847            ],
848        );
849        assert_eq!(deep.first(), Some(&Crumb::Root), "the root was hidden");
850        assert_eq!(
851            deep.last(),
852            Some(&Crumb::Level(5)),
853            "the current level was hidden",
854        );
855    }
856
857    /// The immediate parent is exactly what "go up a level" means, so it is
858    /// dispatched as that rather than as a second spelling of the same move;
859    /// anything further out names the whole path.
860    #[test]
861    fn a_click_on_the_parent_rises_and_a_click_further_out_names_the_path() {
862        let scope = levels_deep(3);
863        assert!(matches!(
864            scope.up_to(2),
865            blockworx_tools::tool::Action::GoUp
866        ));
867        let blockworx_tools::tool::Action::GoToPath(to) = scope.up_to(1) else {
868            panic!("a jump past the parent is a whole path");
869        };
870        assert_eq!(to.segments(), &[block_id(1)]);
871        let blockworx_tools::tool::Action::GoToPath(root) = scope.up_to(0) else {
872            panic!("the document's own segment is the root");
873        };
874        assert!(root.segments().is_empty());
875    }
876
877    fn body() -> Rect {
878        Rect::from_min_max(pos2(0.0, 0.0), pos2(60.0, 60.0))
879    }
880
881    /// `top → Thing 1 → Core`, returning the ids in path order.
882    fn nested() -> (Scene, Vec<BlockId>) {
883        let scene = Scene::new(vec![
884            fx::block_in(1, Scope::Root, body()),
885            fx::titled(1, "top"),
886            fx::block_in(2, Scope::Block(block_id(1)), body()),
887            fx::titled(2, "Thing 1"),
888            fx::block_in(3, Scope::Block(block_id(2)), body()),
889            fx::titled(3, "Core"),
890        ]);
891        (scene, vec![block_id(1), block_id(2), block_id(3)])
892    }
893
894    fn path_of(ids: &[BlockId]) -> BlockPath {
895        let mut path = BlockPath::empty();
896        for &id in ids {
897            path.push(id);
898        }
899        path
900    }
901
902    fn tree_of(scene: &mut Scene) -> NavTree {
903        NavTree::of(&scene.indexed(), BlockPath::empty(), Vec::new())
904    }
905
906    /// What the display shows round-trips back to the path it came from —
907    /// the whole point of copying the string out.
908    #[test]
909    fn a_displayed_path_parses_back_to_itself() {
910        let (mut scene, ids) = nested();
911        let text = content_path::to_string(&scene.indexed(), &path_of(&ids));
912        let tree = tree_of(&mut scene);
913        let path = path_of(&ids);
914        assert_eq!(tree.path_of(&text), Some(path.clone()));
915        // Spacing and case are forgiven.
916        assert_eq!(tree.path_of(" top / thing 1 / core "), Some(path));
917        assert_eq!(tree.path_of("top"), Some(path_of(&ids[..1])));
918        assert_eq!(tree.path_of("top/Thing 1/Nope"), None);
919        assert_eq!(tree.path_of("Core"), None, "names resolve level by level");
920    }
921
922    /// An untitled block still names a level: its id stands in, so the
923    /// string never has an empty segment to parse back.
924    #[test]
925    fn an_untitled_block_falls_back_to_its_id() {
926        let id = block_id(1);
927        let mut scene = Scene::new(vec![
928            fx::block_in(1, Scope::Root, body()),
929            fx::titled(1, ""),
930        ]);
931        let tree = tree_of(&mut scene);
932        assert_eq!(tree.path_of(&id.to_string()), Some(path_of(&[id])));
933    }
934
935    /// A designated top is the tree's invisible root: a path passes through
936    /// it by name, and the level under it is the tree's first rows.
937    #[test]
938    fn a_path_passes_through_the_designated_top() {
939        let (mut scene, ids) = nested();
940        scene.apply(vec![fx::top(1)]);
941        let tree = tree_of(&mut scene);
942        assert!(matches!(tree.hung, Hung::FromTop { .. }));
943        assert_eq!(tree.path_of("top/Thing 1"), Some(path_of(&ids[..2])));
944        assert_eq!(tree.path_of("Thing 1"), None, "the top is not skipped");
945        assert_eq!(
946            tree.children_of(Scope::Block(ids[0]))
947                .iter()
948                .map(|node| node.id)
949                .collect::<Vec<_>>(),
950            vec![ids[1]],
951        );
952    }
953}