Skip to main content

blockworx/widget/
display.rs

1use crate::theme::Style;
2use blockworx_geom::{Rect, WorldPx, vec2};
3use blockworx_paint::{Interaction, Renderer};
4
5use crate::{
6    grid::{HIT_RADIUS, SHIM, px_point},
7    presentation::RouteDirection,
8    shape::ShapeId,
9    state::RenderMode,
10    theme::{Role, RoleStroke},
11    widget::drawing::Drawing,
12};
13
14/// Draw the current level's shapes and routes in their normal (non-interactive)
15/// state, in the canonical layer order. Generic over the [`Renderer`] backend so
16/// it drives both the on-screen `Painter` and the SVG exporter. The layer order
17/// itself lives in [`DrawingPasses`](crate::widget::DrawingPasses).
18#[tracing::instrument(level = "info", skip_all)]
19pub fn render(data: &Drawing, painter: &mut Style<'_, impl Renderer>) {
20    crate::widget::DrawingPasses::new(data).draw(painter);
21}
22
23/// One level as the SVG exporter drew it: the source, the world-space frame
24/// its `viewBox` covers, and where each child block landed inside that frame.
25/// The rects come from the very [`Drawing`] that was drawn, so a consumer
26/// placing them back onto the picture (the PDF export's scope links) cannot
27/// disagree with it.
28pub struct RenderedLevel {
29    pub svg: String,
30    pub frame: Rect,
31    pub blocks: Vec<(blockworx_doc::id::BlockId, Rect)>,
32}
33
34/// Render the level framed by `path` of `indexed` to an SVG string (also the
35/// source PNG export rasterizes). The sink is local because a render authors nothing:
36/// whatever it were handed would come back empty.
37pub fn render_svg(
38    theme: &crate::theme::Theme,
39    font: blockworx_paint::FontChoice,
40    indexed: blockworx_doc::document::IndexedDocument<'_>,
41    path: &crate::path::BlockPath,
42    presentation: &mut crate::presentation::Presentation,
43) -> String {
44    render_level(theme, font, indexed, path, presentation).svg
45}
46
47/// [`render_svg`], keeping what the render learned about the picture's extent
48/// and the blocks in it.
49pub fn render_level(
50    theme: &crate::theme::Theme,
51    font: blockworx_paint::FontChoice,
52    indexed: blockworx_doc::document::IndexedDocument<'_>,
53    path: &crate::path::BlockPath,
54    presentation: &mut crate::presentation::Presentation,
55) -> RenderedLevel {
56    // A read-only pass: the gesture it opens is never sealed.
57    let mut gesture = crate::gesture::Gesture::idle();
58    let mut drawing = Drawing::new(indexed, path, presentation, &mut gesture);
59    let mut svg = crate::export::svg::SvgRenderer::new(theme.palette().clone(), font);
60    {
61        let mut style = Style::new(theme, &mut svg);
62        // An export is a frame too: measure before drawing, or a box the editor
63        // never typed into exports at its estimated size.
64        drawing.refresh_text_extents(&style);
65        render(&drawing, &mut style);
66    }
67    let blocks = drawing
68        .blocks_layer()
69        .filter_map(|(id, shape)| Some((id.block()?, shape.gui_rect())))
70        .collect();
71    let (svg, frame) = svg.finish();
72    RenderedLevel { svg, frame, blocks }
73}
74
75/// Draw the current level with one shape highlighted in
76/// [`RenderMode::Selected`], in the canonical layer order. Shared by the
77/// selection tools (which layer their own overlays on top) so the selected-state
78/// render lives in one place.
79pub fn render_selected(data: &Drawing, selected: ShapeId, painter: &mut Style<'_, impl Renderer>) {
80    let authoring = data.authoring_of(selected);
81    crate::widget::DrawingPasses::new(data)
82        .shape_mode(move |id| {
83            if id == selected {
84                RenderMode::Selected { authoring }
85            } else {
86                RenderMode::Normal
87            }
88        })
89        .draw(painter);
90}
91
92pub fn widget(data: &Drawing, _interaction: &Interaction, painter: &mut Style<'_, impl Renderer>) {
93    render(data, painter);
94}
95
96/// Draw the current level with one shape omitted — used while an in-place editor
97/// stands in for it (a text box being edited), so the editor isn't drawn over the
98/// shape it replaces.
99pub fn render_hidden(data: &Drawing, hidden: ShapeId, painter: &mut Style<'_, impl Renderer>) {
100    crate::widget::DrawingPasses::new(data)
101        .shape_mode(move |id| {
102            if id == hidden {
103                RenderMode::Hidden
104            } else {
105                RenderMode::Normal
106            }
107        })
108        .draw(painter);
109}
110
111pub fn draw_text_bboxes(data: &Drawing, painter: &mut Style<'_, impl Renderer>) {
112    let stroke = RoleStroke::from((0.5, Role::DebugTextBbox));
113
114    for (_id, shape) in data.shapes() {
115        shape.with_pins(|pid, _pin| {
116            if let Some(r) = shape.pin_text_rect(pid, painter) {
117                painter.rect(r, WorldPx::ZERO, Role::Transparent, stroke);
118            }
119        });
120        if let Some(title) = shape.title() {
121            let title_size = painter.text_size(title.name, &painter.theme().title_font.clone());
122            let (title_pos, title_align) =
123                crate::render::clamped_block_title_position(shape.gui_rect(), &title, title_size.x);
124            let r = title_align.anchor_size(
125                title_pos,
126                vec2(
127                    (title_size.x + 10.0).max(20.0),
128                    crate::grid::TITLE_TEXT_SIZE,
129                ),
130            );
131            painter.rect(r, WorldPx::ZERO, Role::Transparent, stroke);
132        }
133    }
134
135    for (rid, wire) in data.auto_routes() {
136        let Some(geometry) = data.route_geometry(rid) else {
137            continue;
138        };
139        let measured =
140            painter.text_size(wire.route.name.clone(), &painter.theme().route_font.clone());
141        for &(_lid, label) in &wire.labels {
142            let loc = geometry.map_linear_distance_to_position(label);
143            let center = loc.location
144                + match loc.direction {
145                    RouteDirection::Horizontal => vec2(0.0, -measured.y / 2.0 - SHIM / 4.0),
146                    RouteDirection::Vertical => vec2(measured.y / 2.0 + SHIM / 4.0, 0.0),
147                };
148            let label_size = match loc.direction {
149                RouteDirection::Horizontal => measured,
150                RouteDirection::Vertical => vec2(measured.y, measured.x),
151            };
152            painter.rect(
153                Rect::from_center_size(center, label_size),
154                WorldPx::ZERO,
155                Role::Transparent,
156                stroke,
157            );
158        }
159    }
160}
161
162/// Overlay `HIT_RADIUS` circles at the four corner resize handles of each
163/// selected resizable shape. Only meaningful while a shape is selected — pass
164/// the IDs from `Tool::selection()`. Visible only when debug marks are on.
165pub fn draw_resize_hit_targets(
166    data: &Drawing,
167    selected: &[ShapeId],
168    painter: &mut Style<'_, impl Renderer>,
169) {
170    let stroke = RoleStroke::from((0.5, Role::DebugMark));
171    for &id in selected {
172        let Some(shape) = data.shape(id) else {
173            continue;
174        };
175        if !shape.resizable() {
176            continue;
177        }
178        let r = shape.gui_rect();
179        for corner in [
180            r.left_top(),
181            r.right_top(),
182            r.left_bottom(),
183            r.right_bottom(),
184        ] {
185            painter.circle(corner, HIT_RADIUS, Role::Transparent, stroke);
186        }
187    }
188}
189
190/// Overlay a circle at `HIT_RADIUS` around every interactive control point —
191/// pin/port anchors and route waypoints. Visible only when debug marks are on,
192/// so testers can verify hit targets without guessing.
193pub fn draw_hit_targets(data: &Drawing, painter: &mut Style<'_, impl Renderer>) {
194    let stroke = RoleStroke::from((0.5, Role::DebugMark));
195    for (_id, shape) in data.shapes() {
196        let rect = shape.gui_rect();
197        shape.with_pins(|pid, _pin| {
198            if let Some(pos) = shape.anchor_point_with_rect(rect, pid) {
199                painter.circle(pos, HIT_RADIUS, Role::Transparent, stroke);
200            }
201        });
202    }
203    for (_rid, wire) in data.auto_routes() {
204        for wp in wire.route.waypoints.clone() {
205            painter.circle(px_point(wp.pos), HIT_RADIUS, Role::Transparent, stroke);
206        }
207    }
208}