Skip to main content

blockworx/
surface.rs

1//! The drawing surface and the glass over it: the camera, the floating bands
2//! of `docs/cad-ui-spec.md` §2, the selection popups, the command palette and
3//! the navigator. Everything here is what a toolkit shows and what a pointer
4//! or a key reaches — none of it is the document.
5
6use blockworx_geom::{Pos2, vec2};
7use blockworx_paint::Cursor;
8
9use crate::canvas::View;
10use crate::canvas::convert::{IntoEgui as _, IntoGeom};
11use crate::io_pin_picker::{self, PinTypePick};
12use crate::kernel::{Overlay, View as Shown};
13use crate::panels::overlay::{OpenPicker, RightClick, selection_overlay};
14use crate::panels::palette::{Palette, PaletteOutcome};
15use crate::preferences::Preferences;
16use crate::theme::Theme;
17use crate::tools::commands::CommandSet;
18use crate::tools::commands::{Act, CommandId, Effect};
19use crate::tools::names::ToolName;
20use crate::tools::tool::{Action, RoleTarget};
21use blockworx_doc::id::PinId;
22
23/// The eframe storage key the workspace panel's per-document state lives
24/// under, beside the preferences blob and the recent-files list.
25const WORKSPACES: &str = "workspaces";
26
27#[derive(Default)]
28pub(crate) struct Surface {
29    /// The canvas the diagram is drawn on: the vantage, the viewport, the
30    /// in-place text editor and the interaction this frame's pointer made.
31    pub(crate) canvas: View,
32    /// The open selection popup, if any. Set by an [`Action`] and read one frame
33    /// later so the click that opened it isn't mistaken for a click-outside
34    /// dismiss. The popup anchors itself above the selection overlay via
35    /// [`Self::overlay_top_right`].
36    popup: Option<Popup>,
37    /// The selection overlay's top-right corner from the latest frame it was
38    /// drawn, used to anchor the accent popup directly above it. `None` when
39    /// nothing is selected.
40    overlay_top_right: Option<Pos2>,
41    /// The command palette while open (Ctrl+K toggles it).
42    palette: Option<Palette>,
43    /// Which view the navigator is showing, whether it is showing at all, and how
44    /// wide it was left — for the document that is open.
45    pub(crate) workspace: crate::shell::workspace::Workspace,
46    /// The same, for every document this session has opened. Persisted
47    /// through the eframe storage DB beside the recent-files list, so a
48    /// document reopens with the panel it was left with.
49    workspaces: std::collections::BTreeMap<String, crate::shell::workspace::Workspace>,
50    /// What the floating chrome left the canvas last frame. Read by the
51    /// framing that has to land inside it, and by the overlay that has to
52    /// clamp into it.
53    pub(crate) safe: crate::shell::SafeArea,
54    /// The history panel's search box. Kept here rather than in
55    /// egui's transient store because a query the user typed must survive
56    /// the panel being collapsed and reopened.
57    history_search: String,
58}
59
60/// A selection popup and the selection it acts on. At most one is open at a
61/// time, so opening either closes the other.
62pub(crate) enum Popup {
63    /// The accent-color picker, for one block, port, route, area, or text box.
64    Role(RoleTarget),
65    /// The pin-type (I/O style) picker, for the pins it retypes (a single pin, a
66    /// pin group, or a port's pin).
67    PinType(Vec<PinId>),
68}
69
70impl Popup {
71    fn picker(&self) -> OpenPicker {
72        match self {
73            Popup::Role(_) => OpenPicker::Role,
74            Popup::PinType(_) => OpenPicker::PinType,
75        }
76    }
77}
78
79/// What the top bar reads that the surface does not own: how the shell names
80/// this document, the appearance menu it edits in place, and — where there
81/// are files — the containers to reopen and the rename box.
82pub(crate) struct BarState<'a> {
83    pub theme: &'a Theme,
84    pub preferences: &'a mut Preferences,
85    #[cfg(not(target_arch = "wasm32"))]
86    pub recent: &'a [std::path::PathBuf],
87    #[cfg(not(target_arch = "wasm32"))]
88    pub rename_draft: &'a mut String,
89}
90
91/// What the canvas pass left for the chrome floating over it: which picker is
92/// open, so the overlay draws it active, and what the tool asked for, which
93/// any layer above may override.
94pub(crate) struct OverCanvas {
95    pub picker: Option<OpenPicker>,
96    pub act: Option<Act>,
97}
98
99impl Surface {
100    /// This frame's selection popup, driven from state set on a previous frame
101    /// (so the click that opened it isn't read as a click-outside dismiss).
102    /// Returns which picker was open on entry: the toolbar's Accent/I/O buttons
103    /// show active from it, and it has to be captured before a dismissal here
104    /// clears it, or the dismissing click reads as a request to reopen — and
105    /// whatever it reported, which is dispatched with the frame's other
106    /// actions rather than written here, so it takes the same road as the rest.
107    pub(crate) fn show_popups(
108        &mut self,
109        ctx: &egui::Context,
110        bar: Option<&Overlay>,
111        theme: &Theme,
112    ) -> (Option<OpenPicker>, Option<Action>) {
113        let was_open = self.popup.as_ref().map(Popup::picker);
114        // A popup anchors above the selection overlay, whose corner was captured
115        // last frame; with no overlay there is nowhere to put it.
116        let Some(corner) = self.overlay_top_right else {
117            return (was_open, None);
118        };
119        let at = corner - vec2(0.0, crate::grid::GRID_SIZE);
120        let (still_open, picked) = match self.popup.take() {
121            Some(Popup::Role(target)) => show_role_picker(ctx, bar, theme, at, target),
122            Some(Popup::PinType(pins)) => show_pin_type_picker(ctx, bar, at, pins),
123            None => (None, None),
124        };
125        self.popup = still_open;
126        (was_open, picked)
127    }
128
129    /// While an in-canvas text editor has focus, a plain-text paste should feed
130    /// the `TextEdit`, but an OBJECT paste (our clipboard format) should behave
131    /// like a normal deselected object paste instead of dumping JSON into the
132    /// box. Consume that event here and surrender editor focus so the tool
133    /// commits the box's current text this frame; the payload is returned to be
134    /// pasted after the canvas pass.
135    pub(crate) fn intercept_object_paste(&self, ctx: &egui::Context) -> Option<String> {
136        if !ctx.egui_wants_keyboard_input() {
137            return None;
138        }
139        let text = latest_paste(ctx)?;
140        if !crate::widget::clipboard::is_object_clipboard(&text) {
141            return None;
142        }
143        ctx.input_mut(|i| {
144            i.events.retain(|e| !matches!(e, egui::Event::Paste(_)));
145            i.raw.events.retain(|e| !matches!(e, egui::Event::Paste(_)));
146        });
147        let focused = ctx
148            .memory(egui::Memory::focused)
149            .or_else(|| self.canvas.focused_edit_id());
150        if let Some(id) = focused {
151            ctx.memory_mut(|m| m.surrender_focus(id));
152        }
153        Some(text)
154    }
155
156    /// The chrome that still floats over the canvas: the selection overlay
157    /// and its pickers, and the command palette. Everything docked went into
158    /// [`crate::shell`]. Each layer's request overrides the one below it,
159    /// starting from what the tool asked for on the canvas.
160    pub(crate) fn show_canvas_chrome(
161        &mut self,
162        ui: &mut egui::Ui,
163        shown: &mut Shown,
164        over: OverCanvas,
165    ) -> Option<Act> {
166        let ctx_owned = ui.ctx().clone();
167        let ctx = &ctx_owned;
168        let OverCanvas {
169            picker: open_picker,
170            act: mut action,
171        } = over;
172        let Shown {
173            commands,
174            overlay: bar,
175            history,
176            nav_tree,
177            viewport,
178            ..
179        } = shown;
180        let viewport = *viewport;
181        // The right-click menu is a pointer-platform duplicate of the bar, so
182        // the gesture is read where the canvas is: a right-click over a
183        // floating piece of chrome belongs to that piece, and a right *drag*
184        // pans instead.
185        let right_click = RightClick::from(
186            self.canvas.canvas_hovered() && ctx.input(|i| i.pointer.secondary_clicked()),
187        );
188        let (overlay_action, overlay_corner) = selection_overlay(
189            ui,
190            crate::panels::overlay::Overlay {
191                commands,
192                bar: bar.as_ref(),
193                open_picker,
194                safe: self.safe,
195                camera: self.canvas.camera(),
196                right_click,
197            },
198        );
199        if let Some(overlay_action) = overlay_action {
200            action = Some(overlay_action);
201        }
202        self.overlay_top_right = overlay_corner.map(IntoGeom::geom);
203        // A popup can't outlive the selection it targets.
204        if overlay_corner.is_none() {
205            self.popup = None;
206        }
207        let scope = crate::panels::palette::PaletteScope {
208            tree: nav_tree,
209            revs: history,
210        };
211        let outcome = self
212            .palette
213            .as_mut()
214            .map(|palette| palette.show(ctx, commands, scope, viewport.egui()));
215        match outcome {
216            None | Some(PaletteOutcome::Open) => {}
217            Some(PaletteOutcome::Close) => self.palette = None,
218            Some(PaletteOutcome::Dispatch(palette_action)) => {
219                action = Some(*palette_action);
220                self.palette = None;
221            }
222        }
223        action
224    }
225
226    /// Open the palette, or shut the one that is open. The chord that asks
227    /// for this is a row of the binding table like any other, so the command
228    /// arrives here rather than being read off the keyboard in place.
229    pub(crate) fn toggles_the_palette(&mut self) {
230        self.palette = match self.palette {
231            None => Some(Palette::new()),
232            Some(_) => None,
233        };
234    }
235
236    /// The top bar: the document menu and its name, the liveness dot, the
237    /// breadcrumb, the viewing mode, and the right-hand actions.
238    pub(crate) fn show_top_bar(
239        &mut self,
240        chrome: &mut crate::shell::Chrome,
241        model: &crate::kernel::TopBar,
242        commands: &mut CommandSet,
243        bar: BarState<'_>,
244    ) -> Option<Act> {
245        let BarState {
246            theme,
247            preferences,
248            #[cfg(not(target_arch = "wasm32"))]
249            recent,
250            #[cfg(not(target_arch = "wasm32"))]
251            rename_draft,
252        } = bar;
253        let clicked = crate::shell::top_bar::top_bar(
254            chrome,
255            commands,
256            crate::shell::top_bar::Docked {
257                model,
258                navigator: self.workspace.open().into(),
259                theme,
260                prefs: preferences,
261                #[cfg(not(target_arch = "wasm32"))]
262                recent,
263                #[cfg(not(target_arch = "wasm32"))]
264                document: crate::shell::top_bar::Document {
265                    draft: rename_draft,
266                    renaming: model.renaming,
267                },
268            },
269        );
270        if clicked.browse {
271            self.workspace.toggle();
272        }
273        clicked.action
274    }
275
276    /// Put the navigator away, because the user is going back to work.
277    ///
278    /// More than shutting it: the Hierarchy filter is a transient search, not
279    /// a persistent view, so it goes too rather than surviving Escape.
280    pub(crate) fn dismiss_navigator(&mut self, ctx: &egui::Context) {
281        if !self.workspace.open() {
282            return;
283        }
284        self.workspace.close();
285        crate::panels::nav_tree::clear_filter(ctx);
286    }
287
288    /// The navigator's body: whichever view its segments have open.
289    pub(crate) fn show_navigator_body(
290        &mut self,
291        ui: &mut egui::Ui,
292        shown: &Shown,
293        theme: &Theme,
294    ) -> Option<Act> {
295        let view = self.workspace.view;
296        match view {
297            crate::shell::workspace::PanelView::History => crate::panels::history_panel::body(
298                ui,
299                crate::panels::history_panel::HistoryPanel {
300                    scene: crate::panels::history_panel::HistoryScene {
301                        rows: &shown.history,
302                        viewing: shown.top_bar.lens.viewing,
303                        head: shown.top_bar.lens.head,
304                        now: blockworx_store::history::now(),
305                        theme,
306                    },
307                    search: &mut self.history_search,
308                },
309            ),
310            crate::shell::workspace::PanelView::Hierarchy => crate::panels::nav_tree::body(
311                ui,
312                crate::panels::nav_tree::NavScene {
313                    tree: &shown.nav_tree,
314                    theme,
315                },
316            )
317            .map(Act::from),
318        }
319    }
320
321    /// What a cell carried off the tool cluster and released comes to: a
322    /// stamp at the drop point, in the canvas's own coordinates. The image
323    /// cell has nothing to stamp — there is no image until one is picked — so
324    /// its drop opens the picker, the same as pressing it does.
325    ///
326    /// `None` where the drop landed on the glass, on the navigator, or off the
327    /// window — the gesture ends and the cell springs back, because a
328    /// release the canvas never saw must not become a thing on it.
329    pub(crate) fn dropped(
330        ctx: &egui::Context,
331        shown: &Shown,
332        carried: crate::shell::tool_cluster::DragOut,
333    ) -> Option<Act> {
334        let onto_the_canvas = shown.viewport.contains(carried.at.geom())
335            && !crate::shell::over_the_chrome(ctx, carried.at);
336        if !onto_the_canvas {
337            return None;
338        }
339        let at = shown
340            .vantage
341            .screen_to_world(shown.viewport.min, carried.at.geom());
342        Some(match carried.tool {
343            ToolName::NewImage => Effect::AddImage.into(),
344            tool => Action::StampTool { tool, at }.into(),
345        })
346    }
347
348    /// Drop the popup, because what it hung off is gone.
349    #[cfg(not(target_arch = "wasm32"))]
350    pub(crate) fn forget_popup(&mut self) {
351        self.popup = None;
352    }
353
354    /// Open a selection popup, replacing whatever was open. It renders from the
355    /// top of the next frame so this opening click isn't read as a
356    /// click-outside dismiss; the selection stays in the tool behind it.
357    pub(crate) fn open_popup(&mut self, ctx: &egui::Context, popup: Popup) {
358        self.popup = Some(popup);
359        ctx.request_repaint();
360    }
361
362    /// File away the panel state the document `was` was left with, and adopt
363    /// the one `now` was. Called wherever the document is replaced or
364    /// renamed, so a panel never follows the wrong document.
365    // Both of those are container doors, which the browser has none of.
366    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
367    pub(crate) fn settle_workspace(&mut self, was: &str, now: &str) {
368        self.workspaces.insert(was.to_owned(), self.workspace);
369        self.workspace = self.workspaces.get(now).copied().unwrap_or_default();
370    }
371
372    /// Restore the per-document panel state from the eframe storage DB, and
373    /// adopt the one the document named `key` was left with.
374    pub(crate) fn restore(&mut self, storage: &dyn eframe::Storage, key: &str) {
375        if let Some(s) = storage.get_string(WORKSPACES)
376            && let Ok(workspaces) = serde_json::from_str(&s)
377        {
378            self.workspaces = workspaces;
379            self.workspace = self.workspaces.get(key).copied().unwrap_or_default();
380        }
381    }
382
383    pub(crate) fn save(&mut self, storage: &mut dyn eframe::Storage, key: &str) {
384        self.workspaces.insert(key.to_owned(), self.workspace);
385        match serde_json::to_string(&self.workspaces) {
386            Ok(s) => storage.set_string(WORKSPACES, s),
387            Err(e) => tracing::error!("Failed to serialize workspaces: {e}"),
388        }
389    }
390}
391
392/// Keyboard copy/paste/nudge, independent of the mouse-driven actions — the
393/// keys egui reports as events rather than as keystrokes, which a binding
394/// table cannot name. Skipped while a rename `TextEdit` holds focus so Cmd+C/V
395/// edits its text instead of the canvas selection (the canvas itself runs
396/// unfocused).
397pub(crate) fn handle_keyboard(ctx: &egui::Context, shown: &mut Shown) -> Option<Act> {
398    if ctx.egui_wants_keyboard_input() {
399        return None;
400    }
401    let paste_txt = latest_paste(ctx);
402    // Copy is the one chord a read-only session keeps: it is how the
403    // time machine pays off (copy out of the past, paste into the
404    // present). The chords are consumed either way, so a withheld one
405    // does nothing rather than falling through to another handler.
406    let writable = shown.writable == blockworx_store::doc::Writability::Writable;
407    if let Some(text) = paste_txt {
408        writable.then_some(Action::Paste(text).into())
409    } else if ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Copy))) {
410        // The registry already says what a copy of this selection is — the
411        // shapes, or the pins of a pin group.
412        shown.commands.take(CommandId::Copy)
413    } else if ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Cut))) {
414        shown.commands.take(CommandId::Cut)
415    } else {
416        None
417    }
418}
419
420/// The accent-color picker for `target`. The targeted shape stays selected
421/// behind it. `None` once it is dismissed.
422fn show_role_picker(
423    ctx: &egui::Context,
424    bar: Option<&Overlay>,
425    theme: &Theme,
426    at: Pos2,
427    target: RoleTarget,
428) -> (Option<Popup>, Option<Action>) {
429    let current = bar.and_then(|bar| bar.accent);
430    let picked = match crate::role_picker::show(ctx, at.egui(), theme, current, target) {
431        crate::role_picker::RolePick::Set(role) => Some(Action::SetRole { target, role }),
432        crate::role_picker::RolePick::Dismiss => return (None, None),
433        crate::role_picker::RolePick::None => None,
434    };
435    (Some(Popup::Role(target)), picked)
436}
437
438/// The pin-type (I/O style) picker for `pins`, which stay selected behind
439/// it. The current cell is the pins' shared type, or none when they
440/// disagree; a click sets every one of them. `None` once dismissed.
441fn show_pin_type_picker(
442    ctx: &egui::Context,
443    bar: Option<&Overlay>,
444    at: Pos2,
445    pins: Vec<PinId>,
446) -> (Option<Popup>, Option<Action>) {
447    let current = bar.and_then(|bar| bar.pin_dir);
448    let picked = match io_pin_picker::show(ctx, at.egui(), current) {
449        PinTypePick::Set(kind) => Some(Action::SetPinsKind {
450            pins: pins.clone(),
451            kind,
452        }),
453        PinTypePick::Dismiss => return (None, None),
454        PinTypePick::None => None,
455    };
456    (Some(Popup::PinType(pins)), picked)
457}
458
459/// The cursor to actually publish for the active tool: the tool's requested
460/// cursor while the pointer is over the canvas, and none otherwise so the tool
461/// cursor never overrides an overlay's own cursor.
462pub(crate) fn effective_cursor(
463    tool_cursor: Option<Cursor>,
464    pointer: PointerOver,
465) -> Option<Cursor> {
466    match pointer {
467        PointerOver::Canvas => tool_cursor,
468        PointerOver::Elsewhere => None,
469    }
470}
471
472/// Where the pointer sits: over the canvas, or over an overlay (which owns its
473/// own cursor).
474#[derive(Clone, Copy, PartialEq, Eq)]
475pub(crate) enum PointerOver {
476    Canvas,
477    Elsewhere,
478}
479
480/// The most recent paste event's text this frame, if any.
481fn latest_paste(ctx: &egui::Context) -> Option<String> {
482    ctx.input(|i| {
483        i.events.iter().rev().find_map(|e| match e {
484            egui::Event::Paste(s) => Some(s.clone()),
485            _ => None,
486        })
487    })
488}