Skip to main content

blockworx_paint/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 [`Theme`] with a borrowed
5//! renderer backend and resolves every [`Role`] / [`RoleStroke`] to a
6//! [`Swatch`] / [`PaletteStroke`] before forwarding the draw down to the canvas
7//! — so the canvas never sees a role or a theme, and the whole render path can
8//! name a `Role` instead of a literal color. It adds no drawing vocabulary of
9//! its own — its one piece of state beyond the two borrows is the opacity a
10//! [`Style::with_opacity`] run folds into every swatch it resolves, which is why
11//! fading needs no backend support.
12//!
13//! The methods mirror the [`Renderer`] API name-for-name (taking a `Role` where
14//! the renderer takes a `Swatch`), so a geometry helper drives a `Style` with no
15//! body changes. Where the backend is a live [`Canvas`], the second impl block
16//! forwards the on-screen conveniences — cursor, in-place editor, pointer,
17//! animation, repaint — so a tool reaches them through the `Style` it already
18//! holds and never names a toolkit.
19
20use std::time::Duration;
21
22use blockworx_doc::block_model::Asset;
23use blockworx_geom::{Align2, Angle, Pos2, Rect, Vec2, WorldPx};
24
25use crate::{
26    AnimKey, Animator, Canvas, Cursor, EditText, Font, PointerKind, Renderer,
27    palette::{PaletteStroke, Swatch},
28    text::Layout,
29    theme::{Role, RoleStroke, Theme},
30};
31
32/// Theme-aware drawing surface: a `&Theme` paired with a `&mut` renderer
33/// backend. See the module docs.
34pub struct Style<'a, R: Renderer> {
35    theme: &'a Theme,
36    inner: &'a mut R,
37    /// Dims every color resolved through this `Style`. `1.0` unless the draw is
38    /// inside a [`Style::with_opacity`] run.
39    opacity: f32,
40}
41
42impl<'a, R: Renderer> Style<'a, R> {
43    pub fn new(theme: &'a Theme, inner: &'a mut R) -> Self {
44        Self {
45            theme,
46            inner,
47            opacity: 1.0,
48        }
49    }
50
51    /// The active theme — used by the render path for fonts.
52    pub fn theme(&self) -> &Theme {
53        self.theme
54    }
55
56    /// The backend underneath, for a pass that needs to measure through it
57    /// while drawing somewhere else (see [`Extent`](crate::Extent)).
58    pub fn renderer(&self) -> &R {
59        self.inner
60    }
61
62    // ── Role → palette resolution ────────────────────────────────────────────
63
64    fn swatch(&self, role: Role) -> Swatch {
65        let s = self.theme.swatch(role);
66        Swatch {
67            base: s.base,
68            alpha: s.alpha * self.opacity,
69        }
70    }
71
72    fn pstroke(&self, stroke: impl Into<RoleStroke>) -> PaletteStroke {
73        let s = stroke.into();
74        PaletteStroke {
75            width: s.width,
76            color: self.swatch(s.role),
77        }
78    }
79
80    // ── Primitive draws (mirror `Renderer`, but role-colored) ────────────────
81
82    pub fn rect(&self, rect: Rect, rounding: WorldPx, fill: Role, stroke: impl Into<RoleStroke>) {
83        self.inner
84            .rect(rect, rounding, self.swatch(fill), self.pstroke(stroke));
85    }
86
87    pub fn line(&self, points: Vec<Pos2>, stroke: impl Into<RoleStroke>) {
88        self.inner.line(points, self.pstroke(stroke));
89    }
90
91    pub fn line_segment(&self, points: [Pos2; 2], stroke: impl Into<RoleStroke>) {
92        self.inner.line_segment(points, self.pstroke(stroke));
93    }
94
95    pub fn circle(&self, center: Pos2, radius: WorldPx, fill: Role, stroke: impl Into<RoleStroke>) {
96        self.inner
97            .circle(center, radius, self.swatch(fill), self.pstroke(stroke));
98    }
99
100    pub fn circle_filled(&self, center: Pos2, radius: WorldPx, fill: Role) {
101        self.inner
102            .circle(center, radius, self.swatch(fill), PaletteStroke::NONE);
103    }
104
105    pub fn add_convex_polygon(&self, points: Vec<Pos2>, fill: Role, stroke: impl Into<RoleStroke>) {
106        self.inner
107            .add_convex_polygon(points, self.swatch(fill), self.pstroke(stroke));
108    }
109
110    // Mirrors a toolkit painter's `text(pos, anchor, text, font, color)`, extended
111    // with the wrap width / rotation this canvas needs. Grouping these into a
112    // struct would make every call site diverge from the API it wraps.
113    #[expect(clippy::too_many_arguments)]
114    pub fn text(
115        &self,
116        pos: Pos2,
117        anchor: Align2,
118        text: impl ToString,
119        font: &Font,
120        color: Role,
121    ) -> Rect {
122        self.inner.text(pos, anchor, text, font, self.swatch(color))
123    }
124
125    // Mirrors a toolkit painter's `text(pos, anchor, text, font, color)`, extended
126    // with the wrap width / rotation this canvas needs. Grouping these into a
127    // struct would make every call site diverge from the API it wraps.
128    #[expect(clippy::too_many_arguments)]
129    pub fn rotated_text(
130        &self,
131        pos: Pos2,
132        anchor: Align2,
133        text: impl ToString,
134        font: &Font,
135        color: Role,
136        angle: Angle,
137    ) {
138        self.inner
139            .rotated_text(pos, anchor, text, font, self.swatch(color), angle);
140    }
141
142    // Mirrors a toolkit painter's `text(pos, anchor, text, font, color)`, extended
143    // with the wrap width / rotation this canvas needs. Grouping these into a
144    // struct would make every call site diverge from the API it wraps.
145    #[expect(clippy::too_many_arguments)]
146    pub fn text_wrapped(
147        &self,
148        pos: Pos2,
149        anchor: Align2,
150        text: impl ToString,
151        font: &Font,
152        color: Role,
153        max_width: WorldPx,
154    ) -> Rect {
155        self.inner
156            .text_wrapped(pos, anchor, text, font, self.swatch(color), max_width)
157    }
158
159    pub fn text_size(&self, text: impl ToString, font: &Font) -> Vec2 {
160        self.inner.text_size(text, font)
161    }
162
163    pub fn text_size_wrapped(&self, text: impl ToString, font: &Font, max_width: WorldPx) -> Vec2 {
164        self.inner.text_size_wrapped(text, font, max_width)
165    }
166
167    pub fn text_layout(&self, text: &str, font: &Font, max_width: WorldPx) -> Layout {
168        self.inner.text_layout(text, font, max_width)
169    }
170
171    pub fn draw_image(&self, rect: Rect, image: &Asset) {
172        self.inner.draw_image(rect, image);
173    }
174
175    pub fn image_intrinsic_size(&self, image: &Asset) -> Option<Vec2> {
176        self.inner.image_intrinsic_size(image)
177    }
178
179    /// The frame animation behind this backend, or `None` offline. See
180    /// [`Renderer::animator`].
181    pub fn animator(&self) -> Option<&dyn Animator> {
182        self.inner.animator()
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/// Live-canvas conveniences: when the backend is a [`Canvas`], a tool reaches
207/// the cursor, the in-place editor, the pointer, the keyed easings and the
208/// repaint schedule straight through the `Style` it already holds.
209impl<C: Canvas> Style<'_, C> {
210    pub fn set_edit_text(&mut self, edit: EditText) {
211        self.inner.set_edit_text(edit);
212    }
213
214    pub fn set_cursor(&mut self, cursor: Cursor) {
215        self.inner.set_cursor(cursor);
216    }
217
218    pub fn cursor(&self) -> Option<Cursor> {
219        self.inner.cursor()
220    }
221
222    /// The pointer in world space. See [`Animator::pointer_world`].
223    pub fn pointer_world(&self) -> Option<Pos2> {
224        self.inner.pointer_world()
225    }
226
227    /// World-space rect → screen-space rect, for placing screen-space overlays
228    /// (e.g. the selection bar) relative to a selection's on-canvas bounds.
229    pub fn remap_rect(&self, rect: Rect) -> Rect {
230        self.inner.remap_rect(rect)
231    }
232
233    /// The current value of the easing keyed by `key`. See [`Animator::animate`].
234    pub fn animate(&self, key: AnimKey, goal: f32, over: Duration) -> f32 {
235        self.inner.animate(key, goal, over)
236    }
237
238    pub fn request_repaint(&self) {
239        self.inner.request_repaint();
240    }
241
242    pub fn request_repaint_after(&self, after: Duration) {
243        self.inner.request_repaint_after(after);
244    }
245
246    /// The frame clock. See [`Canvas::now`].
247    pub fn now(&self) -> Duration {
248        self.inner.now()
249    }
250
251    pub fn pointer_kind(&self) -> PointerKind {
252        self.inner.pointer_kind()
253    }
254}