Skip to main content

blockworx/widget/
document_ext.rs

1//! Widget-layer geometry and structural extensions for [`Document`]. These work
2//! in world (egui) coordinates and resolve shape geometry through [`BaseShape`],
3//! so they live here rather than on the pure-data `Document` in `document`.
4
5use egui::{Pos2, Rect};
6
7use crate::document::{
8    Block, Document, GridRect, GridSize, LineAnchor, TOP_BLOCK_DEFAULT_HEIGHT,
9    TOP_BLOCK_DEFAULT_RECT, TOP_BLOCK_DEFAULT_WIDTH,
10};
11use crate::grid::{GridRectExt, snap_block_height_cells};
12use crate::shape::BaseShape;
13use crate::shape::port::PORT_PIN_ID;
14use crate::store::{IdMapExt, RectId};
15
16/// World-space geometry over a [`Document`], plus the one structural edit that
17/// needs that geometry ([`wrap_top_in_new_parent`](DocumentExt::wrap_top_in_new_parent)).
18pub trait DocumentExt {
19    /// World-space bounding box enclosing block `id`'s interior shapes: its child
20    /// block rects, its own boundary pins (drawn as ports), its text
21    /// annotations, and its boundary comments. `None` when the block has no
22    /// interior shapes.
23    fn content_bounds(&self, id: RectId) -> Option<Rect>;
24
25    /// Resolve a [`LineAnchor`] (interpreted in the context of the current block
26    /// `current_id`) to its world position. `Port` reads the current block's own
27    /// pin; `Pin { block, .. }` reads a globally-identified child block.
28    fn anchor_pos(&self, current_id: RectId, anchor: LineAnchor) -> Option<Pos2>;
29
30    /// Add a level above the whole document: mint a fresh parent block whose sole
31    /// child is the current top, size the old top to its child-boundary rect so
32    /// it reads as a child, and point [`Document::top_id`] at the new parent. Used
33    /// by "Go Up" at the document root. The demoted block keeps all its own
34    /// ids/pins/children/routes (ids are document-global, so nothing is remapped).
35    fn wrap_top_in_new_parent(&mut self);
36}
37
38impl DocumentExt for Document {
39    fn content_bounds(&self, id: RectId) -> Option<Rect> {
40        let block = self.blocks.get(&id)?;
41        self.child_blocks(id)
42            .map(|(_, b)| b.inner.to_rect())
43            .chain(block.pins.values().map(|p| p.rect.to_rect()))
44            .chain(block.texts.values().map(|t| t.gui_rect()))
45            .chain(block.comments.values().map(|c| c.inner.to_rect()))
46            .reduce(egui::Rect::union)
47    }
48
49    fn anchor_pos(&self, current_id: RectId, anchor: LineAnchor) -> Option<Pos2> {
50        match anchor {
51            LineAnchor::Port(pid) => {
52                let cur = self.blocks.get(&current_id)?;
53                let p = cur.pins.get(&pid)?;
54                p.anchor_point_with_rect(p.rect.to_rect(), PORT_PIN_ID)
55            }
56            LineAnchor::Pin { block, pin } => {
57                let b = self.blocks.get(&block)?;
58                b.anchor_point_with_rect(b.inner.to_rect(), pin)
59            }
60        }
61    }
62
63    fn wrap_top_in_new_parent(&mut self) {
64        let old_top = self.top_id;
65        let boundary = child_boundary_rect(self, old_top);
66        if let Some(b) = self.blocks.get_mut(&old_top) {
67            b.inner = boundary;
68        }
69        // The new parent must not reuse the default "top" name: the old root keeps
70        // it, so two levels named "top" would make the nav breadcrumb ambiguous.
71        let name = next_top_name(self);
72        let mut parent = Block::default();
73        parent.decorations.title.name = name;
74        let new_id = self.blocks.insert_value(parent);
75        if let Some(parent) = self.blocks.get_mut(&new_id) {
76            parent.children.insert(old_top);
77        }
78        self.top_id = new_id;
79    }
80}
81
82/// The lowest `top_{n}` (n ≥ 1) not already used as a block title, so each new
83/// wrapped parent gets a distinct, stable name.
84fn next_top_name(doc: &Document) -> String {
85    // One more candidate than there are blocks, so at least one is always free;
86    // the fallback keeps the search total instead of unwrapping.
87    let last = doc.blocks.len() + 1;
88    (1..=last)
89        .map(|n| format!("top_{n}"))
90        .find(|candidate| {
91            !doc.blocks
92                .values()
93                .any(|b| b.decorations.title.name == *candidate)
94        })
95        .unwrap_or_else(|| format!("top_{}", last + 1))
96}
97
98/// The rect block `id` shows as once it is demoted to a child: the rect it
99/// already has, made tall enough that every boundary pin fits inside. Its size
100/// is *not* derived from what it contains — a block as large as the whole
101/// drawing is unwieldy, and this one keeps whatever size the user gave it.
102fn child_boundary_rect(doc: &Document, id: RectId) -> GridRect {
103    let Some(block) = doc.blocks.get(&id) else {
104        return TOP_BLOCK_DEFAULT_RECT;
105    };
106    let max_off = block.pins.values().map(|p| p.offset).max().unwrap_or(0);
107    let min_h = 2 * (max_off + 2); // one slot of margin below the lowest pin
108    let own = block.inner;
109    // Documents written before top blocks were born with a size carry `w=0 h=0`,
110    // which would demote to an invisible child.
111    let width = if own.size.w == 0 {
112        TOP_BLOCK_DEFAULT_WIDTH
113    } else {
114        own.size.w
115    };
116    let height = if own.size.h == 0 {
117        TOP_BLOCK_DEFAULT_HEIGHT
118    } else {
119        own.size.h
120    };
121    GridRect::new(
122        own.min,
123        GridSize::new(width, snap_block_height_cells(height.max(min_h))),
124    )
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::document::GridPos;
131
132    #[test]
133    fn repeated_wraps_give_each_new_parent_a_distinct_name() {
134        let mut doc = Document::default(); // top is named "top"
135        doc.wrap_top_in_new_parent();
136        let first = doc
137            .blocks
138            .get(&doc.top_id)
139            .unwrap()
140            .decorations
141            .title
142            .name
143            .clone();
144        doc.wrap_top_in_new_parent();
145        let second = doc
146            .blocks
147            .get(&doc.top_id)
148            .unwrap()
149            .decorations
150            .title
151            .name
152            .clone();
153        assert_eq!(first, "top_1");
154        assert_eq!(second, "top_2");
155        assert_ne!(first, second);
156    }
157
158    /// Going up over a drawing must not blow the demoted root up to the size of
159    /// its contents: it keeps its own (default) width, small enough to resize.
160    #[test]
161    fn a_demoted_root_keeps_its_own_width_not_its_contents() {
162        let mut doc = Document::default();
163        let old_top = doc.top_id;
164        doc.add_child(
165            old_top,
166            Block {
167                inner: GridRect::new(GridPos::new(4, 4), GridSize::new(40, 10)),
168                ..Block::default()
169            },
170        );
171        // Precondition: the contents really are wider than the top block, so
172        // content-derived sizing would be a visible difference.
173        let content = doc.content_bounds(old_top).expect("the child block");
174        let content_cells = GridRect::from_rect_snapped(content).size.w;
175        assert!(content_cells > TOP_BLOCK_DEFAULT_WIDTH);
176
177        doc.wrap_top_in_new_parent();
178
179        let demoted = doc.block(old_top).expect("the demoted root");
180        assert_eq!(demoted.inner.size.w, TOP_BLOCK_DEFAULT_WIDTH);
181        let new_top = doc.block(doc.top_id).expect("the new parent");
182        assert_eq!(new_top.inner.size.w, TOP_BLOCK_DEFAULT_WIDTH);
183    }
184
185    /// A `w=0 h=0` top (written before top blocks were born with a size) demotes
186    /// to a visible child rather than an invisible sliver.
187    #[test]
188    fn a_degenerate_top_demotes_to_the_default_size() {
189        let mut doc = Document::default();
190        let old_top = doc.top_id;
191        doc.block_mut(old_top).unwrap().inner = GridRect::default();
192        assert_eq!(doc.block(old_top).unwrap().inner.size.w, 0);
193
194        doc.wrap_top_in_new_parent();
195
196        let demoted = doc.block(old_top).expect("the demoted root");
197        assert_eq!(demoted.inner.size, TOP_BLOCK_DEFAULT_RECT.size);
198    }
199}