Skip to main content

blockworx_web/
chrome.rs

1//! The chrome model: a [`View`] minus its diagram.
2//!
3//! The display list never enters the component tree — the backend paints it
4//! onto the canvas — so what the components bind is this: the same fields,
5//! cloned off the answer and compared, so a frame that only panned the diagram
6//! re-renders no HTML.
7
8use blockworx_geom::Rect;
9use blockworx_kernel::{FrameRate, NavTree, Notice, Overlay, Reading, TopBar, View};
10use blockworx_paint::{Chord, Cursor, EditField, Vantage};
11use blockworx_store::doc::{Viewing, Writability};
12use blockworx_store::history::Row;
13use blockworx_tools::commands::{BINDINGS, Command, CommandId, CommandSet, Precedence, Rendered};
14use blockworx_tools::names::ToolName;
15
16/// What the components read and raise against.
17#[derive(Clone, PartialEq, Debug)]
18pub struct Chrome {
19    pub title: String,
20    pub tool: ToolName,
21    pub top_bar: TopBar,
22    pub status: Reading,
23    pub history: Vec<Row>,
24    pub nav_tree: NavTree,
25    pub overlay: Option<Overlay>,
26    pub notices: Vec<Notice>,
27    pub landed: Option<String>,
28    pub commands: Commands,
29    /// The in-place editor this call asks for, which the front end runs.
30    pub edit_text: Option<EditField>,
31    pub cursor: Option<Cursor>,
32    pub selection_bounds: Option<Rect>,
33    /// The camera the answer was painted under: what the field's own font and
34    /// wrap width are scaled by, as they are in world units.
35    pub vantage: Vantage,
36    pub writable: Writability,
37    pub selected: usize,
38    /// Whether the frame-rate readout is up.
39    pub frame_rate: FrameRate,
40}
41
42impl Chrome {
43    #[must_use]
44    pub fn of(view: &View) -> Self {
45        Self {
46            title: view.title.clone(),
47            tool: view.tool,
48            top_bar: view.top_bar.clone(),
49            status: view.status.clone(),
50            history: view.history.clone(),
51            nav_tree: view.nav_tree.clone(),
52            overlay: view.overlay.clone(),
53            notices: view.notices.clone(),
54            landed: view.landed.clone(),
55            commands: Commands::of(&view.commands),
56            edit_text: view.edit_text.clone(),
57            cursor: view.cursor,
58            selection_bounds: view.selection_bounds,
59            vantage: view.vantage,
60            writable: view.writable,
61            selected: view.selected,
62            frame_rate: view.frame_rate,
63        }
64    }
65}
66
67/// Whether this session offers a command or only shows it.
68#[derive(Clone, Copy, PartialEq, Eq, Debug)]
69pub enum Availability {
70    Live,
71    /// Known but uninvocable — drawn dead rather than left as a hole, which
72    /// is the familiar paradigm the spec asks for.
73    Withheld,
74}
75
76impl Availability {
77    fn of(command: &Command) -> Self {
78        if command.withheld() {
79            Self::Withheld
80        } else {
81            Self::Live
82        }
83    }
84
85    /// For a control's `disabled` attribute.
86    #[must_use]
87    pub fn disabled(self) -> bool {
88        self == Self::Withheld
89    }
90}
91
92/// Why this call withholds what it withholds.
93///
94/// The registry withholds on one rule with two causes, and a control drawn
95/// dead has to say which — invariant 8 wants the reason on the control
96/// itself. Both are facts of the session the chrome already carries, so the
97/// sentence is resolved here rather than asked of the registry.
98#[derive(Clone, Copy, PartialEq, Eq, Debug)]
99pub enum Withholding {
100    /// An earlier rev is on the canvas.
101    Lens,
102    /// The container is not this session's to write.
103    ReadOnly,
104    /// Nothing is withheld for either reason; a dead control is dead because
105    /// it has nothing to act on.
106    Nothing,
107}
108
109impl Withholding {
110    /// What the session is doing, read off the same two facts the registry
111    /// withholds on.
112    #[must_use]
113    pub fn of(viewing: Viewing, writable: Writability) -> Self {
114        match (viewing, writable) {
115            (Viewing::Past(_), _) => Withholding::Lens,
116            (_, Writability::ReadOnly) => Withholding::ReadOnly,
117            (_, Writability::Writable) => Withholding::Nothing,
118        }
119    }
120
121    /// The reason a withheld control carries.
122    #[must_use]
123    pub fn says(self) -> &'static str {
124        match self {
125            Withholding::Lens => {
126                "not while an earlier rev is on the canvas \u{2014} Return to edit"
127            }
128            Withholding::ReadOnly => "this document was opened without a write lock",
129            Withholding::Nothing => "nothing to do it to",
130        }
131    }
132}
133
134/// One command as a control shows it. The [`Act`](blockworx_tools::commands::Act)
135/// behind it stays in the registry: a control raises the command's *name*, and
136/// the call it reaches resolves that against the session as it then stands.
137#[derive(Clone, PartialEq, Eq, Debug)]
138pub struct Face {
139    pub id: CommandId,
140    pub label: String,
141    pub availability: Availability,
142    /// Where this command stands in the selection overlay's order — the
143    /// registry's, so the bar and the right-click menu read one policy.
144    pub precedence: Precedence,
145    /// Whether this command draws a control, or only answers to its name.
146    /// The model carries both halves: a chord bound to a by-name-only
147    /// command has to resolve here, and the palette lists what it can run.
148    pub rendered: Rendered,
149}
150
151impl Face {
152    /// Whether a press of it would reach the session.
153    #[must_use]
154    pub fn live(&self) -> bool {
155        self.availability == Availability::Live
156    }
157}
158
159/// The commands this call offers, as the chrome reads them.
160#[derive(Clone, PartialEq, Eq, Debug, Default)]
161pub struct Commands(Vec<Face>);
162
163impl Commands {
164    /// The commands a fixture says this call offers.
165    #[cfg(test)]
166    #[must_use]
167    pub fn showing(faces: Vec<Face>) -> Self {
168        Self(faces)
169    }
170
171    fn of(set: &CommandSet) -> Self {
172        Self(
173            set.iter_all()
174                .map(|command| Face {
175                    id: command.id,
176                    label: command.label.to_string(),
177                    availability: Availability::of(command),
178                    precedence: command.precedence,
179                    rendered: command.rendered(),
180                })
181                .collect(),
182        )
183    }
184
185    /// Every command this call draws a control for, the withheld ones
186    /// included — those are drawn dead rather than left as a hole.
187    pub fn drawn(&self) -> impl Iterator<Item = &Face> {
188        self.0
189            .iter()
190            .filter(|face| face.rendered == Rendered::AsAButton)
191    }
192
193    /// Only what a press of it would actually reach — the palette's rows and
194    /// a chord's answer.
195    pub fn live(&self) -> impl Iterator<Item = &Face> {
196        self.0.iter().filter(|face| face.live())
197    }
198
199    /// How `id` is shown, or `None` where this call does not offer it at all.
200    #[must_use]
201    pub fn face(&self, id: CommandId) -> Option<&Face> {
202        self.0.iter().find(|face| face.id == id)
203    }
204
205    /// The command `chord` raises, if this call has one to raise. Read off
206    /// the tools' own binding table, so a keystroke here and a keystroke on
207    /// the desktop invoke the same thing.
208    #[must_use]
209    pub fn bound(&self, chord: Chord) -> Option<CommandId> {
210        BINDINGS
211            .iter()
212            .find(|(bound, _)| *bound == chord)
213            .map(|(_, id)| *id)
214            .filter(|id| {
215                self.face(*id)
216                    .is_some_and(|face| face.availability == Availability::Live)
217            })
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use blockworx_canvas2d::Glyphs;
224    use blockworx_geom::{Rect, pos2};
225    use blockworx_kernel::{Event, Session, kernel};
226    use blockworx_paint::{FontChoice, Key, Modifiers};
227    use blockworx_store::{doc::Doc, record::Identity};
228
229    use super::*;
230
231    const VIEWPORT: Rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0));
232
233    /// A session over a document with something in it, seeded through a
234    /// `Repo` the way every document reaches the editor, so the answers below
235    /// have a diagram and a tree rather than nothing.
236    fn opened() -> (Session, Glyphs) {
237        let scene = vec![
238            blockworx_editor::widget::test_fixtures::block_in(
239                1,
240                blockworx_editor::path::Scope::Root,
241                Rect::from_min_max(pos2(40.0, 40.0), pos2(200.0, 160.0)),
242            ),
243            blockworx_editor::widget::test_fixtures::titled(1, "Rig"),
244        ];
245        let commit = blockworx_doc::commit::Commit::new("Built a scene".into(), scene);
246        let repo = blockworx_doc::repo::Repo::folding(&[commit]).expect("the scene folds");
247        (
248            Session::opening(Doc::scratch(repo), Identity::new("tester")),
249            Glyphs::new(FontChoice::default()),
250        )
251    }
252
253    /// A view of a fresh session at a real size, as the kernel's own tests
254    /// build one.
255    fn viewed(events: Vec<Event>) -> View {
256        let (mut session, glyphs) = opened();
257        let mut batch = vec![Event::Viewport(VIEWPORT)];
258        batch.extend(events);
259        kernel(&mut session, batch, &glyphs)
260    }
261
262    fn chord(key: Key) -> Chord {
263        Chord {
264            modifiers: Modifiers::Command,
265            key,
266        }
267    }
268
269    #[test]
270    fn the_model_is_the_answer_minus_its_diagram() {
271        let view = viewed(Vec::new());
272        let chrome = Chrome::of(&view);
273        assert!(
274            !view.draw_list.is_empty(),
275            "a view with nothing drawn would prove nothing about what is left out",
276        );
277        assert_eq!(chrome.title, view.title);
278        assert_eq!(chrome.tool, view.tool);
279        assert_eq!(chrome.top_bar, view.top_bar);
280        assert_eq!(chrome.status, view.status);
281        assert_eq!(chrome.nav_tree, view.nav_tree);
282        assert_eq!(chrome.vantage, view.vantage);
283        assert_eq!(chrome.cursor, view.cursor);
284        assert_eq!(chrome.selected, view.selected);
285    }
286
287    /// What the signal's write is skipped on: a call that changed nothing
288    /// answers a model equal to the last one, whatever the diagram did.
289    #[test]
290    fn a_call_that_changed_nothing_answers_the_same_model() {
291        let (mut session, glyphs) = opened();
292        let first = Chrome::of(&kernel(
293            &mut session,
294            vec![Event::Viewport(VIEWPORT)],
295            &glyphs,
296        ));
297        let again = Chrome::of(&kernel(&mut session, Vec::new(), &glyphs));
298        assert_eq!(first, again);
299    }
300
301    /// And a call that changed something answers a model that differs, so
302    /// the comparison cannot be skipping a real change.
303    #[test]
304    fn two_answers_that_differ_are_not_equal() {
305        let resting = Chrome::of(&viewed(Vec::new()));
306        let armed = Chrome::of(&viewed(vec![Event::Command(CommandId::Arm(
307            ToolName::NewBlock,
308        ))]));
309        assert_ne!(resting.tool, armed.tool);
310        assert_ne!(resting, armed);
311    }
312
313    #[test]
314    fn a_bound_chord_names_the_command_the_table_binds_it_to() {
315        let commands = Chrome::of(&viewed(Vec::new())).commands;
316        assert_eq!(
317            commands.bound(chord(Key::B)),
318            Some(CommandId::Arm(ToolName::NewBlock)),
319        );
320        assert_eq!(
321            commands.bound(Chord {
322                modifiers: Modifiers::None,
323                key: Key::Num4,
324            }),
325            Some(CommandId::Arm(ToolName::Route)),
326        );
327        assert_eq!(
328            commands.bound(Chord {
329                modifiers: Modifiers::Command,
330                key: Key::Num0,
331            }),
332            Some(CommandId::FitView),
333        );
334    }
335
336    /// A chord bound to a command this call does not offer raises nothing, so
337    /// the canvas lets the keystroke be.
338    #[test]
339    fn a_chord_this_call_does_not_offer_raises_nothing() {
340        let commands = Chrome::of(&viewed(Vec::new())).commands;
341        assert_eq!(
342            commands.face(CommandId::Undo),
343            None,
344            "a fresh session has nothing to undo",
345        );
346        assert!(commands.bound(chord(Key::E)).is_some());
347        let undone = Chrome::of(&viewed(vec![Event::Move(blockworx_paint::Move::Pan(
348            blockworx_geom::Vec2::new(40.0, 0.0),
349        ))]));
350        assert_eq!(
351            undone
352                .commands
353                .face(CommandId::Undo)
354                .map(|face| face.availability),
355            Some(Availability::Live),
356            "a camera move is a step the session can take back",
357        );
358    }
359}