1use 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
35pub struct Style<'a, R: Renderer> {
38 theme: &'a Theme,
39 inner: &'a mut R,
40 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 pub fn theme(&self) -> &Theme {
56 self.theme
57 }
58
59 pub fn renderer(&self) -> &R {
62 self.inner
63 }
64
65 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 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 #[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 #[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 #[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 pub fn anim_target(&self) -> Option<(egui::Context, Option<Pos2>)> {
182 self.inner.anim_target()
183 }
184
185 pub fn visible_world_bounds(&self) -> Option<Rect> {
188 self.inner.visible_world_bounds()
189 }
190
191 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
206impl Style<'_, Painter> {
210 pub fn set_edit_text(&mut self, edit: EditText) {
211 self.inner.set_edit_text(edit);
212 }
213
214 pub fn ctx(&self) -> &egui::Context {
217 self.inner.ctx()
218 }
219
220 pub fn pointer_world(&self) -> Option<Pos2> {
223 self.inner.pointer_world()
224 }
225
226 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 #[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 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 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}