Skip to main content

blockworx/theme/
style.rs

1//! The theme-aware drawing adapter.
2//!
3//! [`Style`] is a thin bridge between the app's semantic *roles* and the
4//! palette-based [`Renderer`]. It bundles a borrowed
5//! [`Theme`] with a borrowed renderer backend and resolves every [`Role`] /
6//! [`RoleStroke`] to a [`Swatch`] / [`PaletteStroke`] before forwarding the
7//! draw down to the canvas — so the canvas never sees a role or a theme, and the
8//! whole render path can name a `Role` instead of a literal color. It adds no
9//! drawing vocabulary of its own — its one piece of state beyond the two borrows
10//! is the opacity a [`Style::with_opacity`] run folds into every swatch it
11//! resolves, which is why fading needs no backend support.
12//!
13//! The methods mirror [`Painter`]'s drawing API
14//! name-for-name (taking a `Role` where the painter takes a `Swatch`), so the
15//! geometry helpers in [`render`](crate::render) drive a `Style` with
16//! no body changes. Tools hold a `Style<Painter>` and reach the egui-only
17//! conveniences (cursor, in-place editor, image handles) through the forwarding
18//! impl at the bottom.
19
20use blockworx_doc::block_model::Asset;
21use blockworx_geom::{Align2, Pos2, Rect, Vec2, WorldPx};
22use blockworx_paint::Font;
23
24use crate::{
25    canvas::{
26        Renderer,
27        image::ImageHandle,
28        painter::{EditText, Painter},
29        palette::{PaletteStroke, Swatch},
30    },
31    icons::Icon,
32    theme::{Role, RoleStroke, Theme},
33};
34
35/// Theme-aware drawing surface: a `&Theme` paired with a `&mut` renderer
36/// backend. See the module docs.
37pub struct Style<'a, R: Renderer> {
38    theme: &'a Theme,
39    inner: &'a mut R,
40    /// Dims every color resolved through this `Style`. `1.0` unless the draw is
41    /// inside a [`Style::with_opacity`] run.
42    opacity: f32,
43}
44
45impl<'a, R: Renderer> Style<'a, R> {
46    pub fn new(theme: &'a Theme, inner: &'a mut R) -> Self {
47        Self {
48            theme,
49            inner,
50            opacity: 1.0,
51        }
52    }
53
54    /// The active theme — used by the render path for fonts.
55    pub fn theme(&self) -> &Theme {
56        self.theme
57    }
58
59    /// The backend underneath, for a pass that needs to measure through it
60    /// while drawing somewhere else (see [`Extent`](crate::canvas::Extent)).
61    pub fn renderer(&self) -> &R {
62        self.inner
63    }
64
65    // ── Role → palette resolution ────────────────────────────────────────────
66
67    fn swatch(&self, role: Role) -> Swatch {
68        let s = self.theme.swatch(role);
69        Swatch {
70            base: s.base,
71            alpha: s.alpha * self.opacity,
72        }
73    }
74
75    fn pstroke(&self, stroke: impl Into<RoleStroke>) -> PaletteStroke {
76        let s = stroke.into();
77        PaletteStroke {
78            width: s.width,
79            color: self.swatch(s.role),
80        }
81    }
82
83    // ── Primitive draws (mirror `Painter`, but role-colored) ─────────────────
84
85    pub fn rect(&self, rect: Rect, rounding: WorldPx, fill: Role, stroke: impl Into<RoleStroke>) {
86        self.inner
87            .rect(rect, rounding, self.swatch(fill), self.pstroke(stroke));
88    }
89
90    pub fn line(&self, points: Vec<Pos2>, stroke: impl Into<RoleStroke>) {
91        self.inner.line(points, self.pstroke(stroke));
92    }
93
94    pub fn line_segment(&self, points: [Pos2; 2], stroke: impl Into<RoleStroke>) {
95        self.inner.line_segment(points, self.pstroke(stroke));
96    }
97
98    pub fn circle(&self, center: Pos2, radius: WorldPx, fill: Role, stroke: impl Into<RoleStroke>) {
99        self.inner
100            .circle(center, radius, self.swatch(fill), self.pstroke(stroke));
101    }
102
103    pub fn circle_filled(&self, center: Pos2, radius: WorldPx, fill: Role) {
104        self.inner
105            .circle(center, radius, self.swatch(fill), PaletteStroke::NONE);
106    }
107
108    pub fn add_convex_polygon(&self, points: Vec<Pos2>, fill: Role, stroke: impl Into<RoleStroke>) {
109        self.inner
110            .add_convex_polygon(points, self.swatch(fill), self.pstroke(stroke));
111    }
112
113    // Mirrors egui's `Painter::text(pos, anchor, text, font, color)`, extended with
114    // the wrap width / rotation this canvas needs. Grouping these into a struct
115    // would make every call site diverge from the API it wraps.
116    #[expect(clippy::too_many_arguments)]
117    pub fn text(
118        &self,
119        pos: Pos2,
120        anchor: Align2,
121        text: impl ToString,
122        font: &Font,
123        color: Role,
124    ) -> Rect {
125        self.inner.text(pos, anchor, text, font, self.swatch(color))
126    }
127
128    // Mirrors egui's `Painter::text(pos, anchor, text, font, color)`, extended with
129    // the wrap width / rotation this canvas needs. Grouping these into a struct
130    // would make every call site diverge from the API it wraps.
131    #[expect(clippy::too_many_arguments)]
132    pub fn rotated_text(
133        &self,
134        pos: Pos2,
135        anchor: Align2,
136        text: impl ToString,
137        font: &Font,
138        color: Role,
139        angle: f32,
140    ) {
141        self.inner
142            .rotated_text(pos, anchor, text, font, self.swatch(color), angle);
143    }
144
145    // Mirrors egui's `Painter::text(pos, anchor, text, font, color)`, extended with
146    // the wrap width / rotation this canvas needs. Grouping these into a struct
147    // would make every call site diverge from the API it wraps.
148    #[expect(clippy::too_many_arguments)]
149    pub fn text_wrapped(
150        &self,
151        pos: Pos2,
152        anchor: Align2,
153        text: impl ToString,
154        font: &Font,
155        color: Role,
156        max_width: WorldPx,
157    ) -> Rect {
158        self.inner
159            .text_wrapped(pos, anchor, text, font, self.swatch(color), max_width)
160    }
161
162    pub fn text_size(&self, text: impl ToString, font: &Font) -> Vec2 {
163        self.inner.text_size(text, font)
164    }
165
166    pub fn text_size_wrapped(&self, text: impl ToString, font: &Font, max_width: WorldPx) -> Vec2 {
167        self.inner.text_size_wrapped(text, font, max_width)
168    }
169
170    pub fn draw_image(&self, rect: Rect, image: &Asset) {
171        self.inner.draw_image(rect, image);
172    }
173
174    pub fn image_intrinsic_size(&self, image: &Asset) -> Option<Vec2> {
175        self.inner.image_intrinsic_size(image)
176    }
177
178    /// Frame-animation inputs (egui context + world-space pointer) for
179    /// interactive overlays drawn on the generic render path; `None` on offline
180    /// backends. See [`Renderer::anim_target`].
181    pub fn anim_target(&self) -> Option<(egui::Context, Option<Pos2>)> {
182        self.inner.anim_target()
183    }
184
185    /// The visible world-space rect for viewport culling, or `None` on backends
186    /// with no viewport (SVG export). See [`Renderer::visible_world_bounds`].
187    pub fn visible_world_bounds(&self) -> Option<Rect> {
188        self.inner.visible_world_bounds()
189    }
190
191    /// Draw a faded run: `draw` receives a `Style` whose colors are dimmed by
192    /// `factor` (composing with each role's own alpha). The fade is a swatch
193    /// transform applied where roles resolve, so no backend holds fade state and
194    /// every backend fades identically. Nestable — a nested run multiplies the
195    /// factors. Used for ghost/dimmed previews and faint overlays.
196    pub fn with_opacity<T>(&mut self, factor: f32, draw: impl FnOnce(&mut Style<'_, R>) -> T) -> T {
197        let mut faded = Style {
198            theme: self.theme,
199            inner: &mut *self.inner,
200            opacity: self.opacity * factor,
201        };
202        draw(&mut faded)
203    }
204}
205
206/// On-screen-only conveniences: when the backend is the egui [`Painter`], tools
207/// reach its world↔screen remaps, in-place text editor, cursor and image
208/// handles straight through the `Style` they already hold.
209impl Style<'_, Painter> {
210    pub fn set_edit_text(&mut self, edit: EditText) {
211        self.inner.set_edit_text(edit);
212    }
213
214    /// The egui [`Context`](egui::Context), for frame-driven animation and
215    /// repaint scheduling. Reaches the backend painter's context directly.
216    pub fn ctx(&self) -> &egui::Context {
217        self.inner.ctx()
218    }
219
220    /// The pointer in world space — real on the live canvas, synthetic in a
221    /// scripted session. See [`Painter::pointer_world`].
222    pub fn pointer_world(&self) -> Option<Pos2> {
223        self.inner.pointer_world()
224    }
225
226    /// World-space rect → screen-space rect, for placing screen-space overlays
227    /// (e.g. the selection bar) relative to a selection's on-canvas bounds.
228    pub fn remap_rect(&self, rect: Rect) -> Rect {
229        self.inner.remap_rect(rect)
230    }
231
232    pub fn set_cursor(&mut self, cursor: egui::CursorIcon) {
233        self.inner.set_cursor(cursor);
234    }
235
236    pub fn cursor(&self) -> Option<egui::CursorIcon> {
237        self.inner.cursor()
238    }
239
240    // Icon/image drawing is unused since the tag overlays were retired; kept for
241    // the planned toolbar icons.
242    #[allow(dead_code)]
243    pub fn icon(&self, icon: Icon) -> Option<&ImageHandle> {
244        self.inner.icon(icon)
245    }
246
247    #[allow(dead_code)]
248    pub fn image(&self, rect: Rect, handle: &ImageHandle) {
249        self.inner.image(rect, handle);
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use crate::canvas::SvgRenderer;
257    use crate::preferences::FontChoice;
258    use blockworx_geom::{pos2, vec2};
259
260    /// Draw one `Role::Accent0` rect (B08, `#ff757f` in the default palette)
261    /// through `draw` and return the exported SVG.
262    fn faded_rect(draw: impl FnOnce(&mut Style<'_, SvgRenderer>)) -> String {
263        let theme = Theme::default();
264        let mut svg = SvgRenderer::new(theme.palette().clone(), FontChoice::Sketchy);
265        let mut style = Style::new(&theme, &mut svg);
266        draw(&mut style);
267        svg.finish().0
268    }
269
270    fn unit_rect() -> Rect {
271        Rect::from_min_size(pos2(0.0, 0.0), vec2(10.0, 10.0))
272    }
273
274    #[test]
275    fn opacity_folds_into_the_swatch_alpha() {
276        let opaque = faded_rect(|s| {
277            s.rect(unit_rect(), WorldPx::ZERO, Role::Accent0, RoleStroke::NONE);
278        });
279        assert!(opaque.contains("fill=\"#ff757f\""));
280        assert!(
281            !opaque.contains("fill-opacity"),
282            "a solid role is fully opaque:\n{opaque}"
283        );
284
285        // The hex shifts a little through the premultiplied-alpha round trip, so
286        // the opacity attribute — not the color — is the signal.
287        let faded = faded_rect(|s| {
288            s.with_opacity(0.5, |p| {
289                p.rect(unit_rect(), WorldPx::ZERO, Role::Accent0, RoleStroke::NONE);
290            });
291        });
292        assert!(
293            faded.contains("fill-opacity=\"0.50"),
294            "the fade halves the effective alpha:\n{faded}"
295        );
296    }
297
298    #[test]
299    fn nested_opacity_runs_multiply() {
300        let faded = faded_rect(|s| {
301            s.with_opacity(0.5, |p| {
302                p.with_opacity(0.5, |q| {
303                    q.rect(unit_rect(), WorldPx::ZERO, Role::Accent0, RoleStroke::NONE);
304                });
305            });
306        });
307        assert!(
308            faded.contains("fill-opacity=\"0.25"),
309            "nested fades compose:\n{faded}"
310        );
311    }
312
313    #[test]
314    fn a_faded_run_does_not_outlive_its_closure() {
315        let after = faded_rect(|s| {
316            s.with_opacity(0.5, |p| {
317                p.rect(
318                    unit_rect(),
319                    WorldPx::ZERO,
320                    Role::Transparent,
321                    RoleStroke::NONE,
322                );
323            });
324            s.rect(unit_rect(), WorldPx::ZERO, Role::Accent0, RoleStroke::NONE);
325        });
326        assert!(
327            !after.contains("fill-opacity"),
328            "the draw after the run is undimmed:\n{after}"
329        );
330    }
331}