teksilo_widgets/primitives/
rect_widget.rs1use teksilo_canvas::{Canvas, Paint, Rect, Size, SizeProposal};
29use teksilo_tokens::{Color, CornerRadius};
30
31use teksilo_core::accessibility::AccessNodeBuilder;
32use teksilo_core::color_prop::ColorProp;
33use teksilo_core::paint_prop::PaintProp;
34use teksilo_core::signal::Prop;
35use teksilo_core::styles::{BorderPosition, BorderSides, apply_border_position};
36use teksilo_core::widget::{LayoutContext, PaintContext, Widget};
37
38pub struct RectWidget {
46 background: PaintProp,
47 border_color: ColorProp,
48 border_width: Prop<f32>,
49 corner_radius: Prop<CornerRadius>,
50 border_sides: Prop<Option<BorderSides>>,
54 border_position: BorderPosition,
57}
58
59impl RectWidget {
60 pub fn new() -> Self {
62 Self {
63 background: PaintProp::Solid(ColorProp::Static(Color::TRANSPARENT)),
64 border_color: ColorProp::Static(Color::TRANSPARENT),
65 border_width: Prop::Static(0.0),
66 corner_radius: Prop::Static(CornerRadius::ZERO),
67 border_sides: Prop::Static(None),
68 border_position: BorderPosition::Center,
69 }
70 }
71
72 pub fn background(mut self, paint: impl Into<PaintProp>) -> Self {
75 self.background = paint.into();
76 self
77 }
78
79 pub fn border_sides(mut self, sides: impl Into<Prop<Option<BorderSides>>>) -> Self {
83 self.border_sides = sides.into();
84 self
85 }
86
87 pub fn border_position(mut self, position: BorderPosition) -> Self {
90 self.border_position = position;
91 self
92 }
93
94 pub fn border_color(mut self, color: impl Into<ColorProp>) -> Self {
97 self.border_color = color.into();
98 self
99 }
100
101 pub fn border_width(mut self, width: impl Into<Prop<f32>>) -> Self {
103 self.border_width = width.into();
104 self
105 }
106
107 pub fn corner_radius(mut self, radius: impl Into<Prop<CornerRadius>>) -> Self {
110 self.corner_radius = radius.into();
111 self
112 }
113}
114
115impl Default for RectWidget {
116 fn default() -> Self {
117 Self::new()
118 }
119}
120
121impl std::fmt::Debug for RectWidget {
122 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 f.debug_struct("RectWidget").finish()
124 }
125}
126
127impl Widget for RectWidget {
128 fn build(
129 &mut self,
130 ctx: &mut teksilo_core::build_context::BuildContext,
131 ) -> Vec<teksilo_core::widget_id::WidgetId> {
132 let self_id = ctx.self_id();
133 let registry = ctx.binding_registry();
134 self.background.register_if_bound(
135 self_id,
136 registry,
137 teksilo_core::binding::BindingLevel::RepaintOnly,
138 );
139 self.border_color.register_if_bound(
140 self_id,
141 registry,
142 teksilo_core::binding::BindingLevel::RepaintOnly,
143 );
144 self.border_width.register_if_bound(
145 self_id,
146 registry,
147 teksilo_core::binding::BindingLevel::RepaintOnly,
148 );
149 self.corner_radius.register_if_bound(
150 self_id,
151 registry,
152 teksilo_core::binding::BindingLevel::RepaintOnly,
153 );
154 self.border_sides.register_if_bound(
155 self_id,
156 registry,
157 teksilo_core::binding::BindingLevel::RepaintOnly,
158 );
159 Vec::new()
160 }
161
162 fn layout_response(
163 &self,
164 proposal: SizeProposal,
165 _ctx: &LayoutContext,
166 ) -> teksilo_core::widget::LayoutResponse {
167 proposal.resolve(0.0, 0.0).into()
170 }
171
172 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
173 let radius = self.corner_radius.get();
174
175 let paint = self.background.resolve(
178 ctx.theme,
179 ctx.effective_enabled,
180 Size::new(bounds.width, bounds.height),
181 );
182 let skip_fill = matches!(&paint, Paint::Solid(c) if c.a() <= 0.0);
183 if !skip_fill {
184 canvas.fill_rounded_rect(bounds, radius, paint);
185 }
186
187 let bc = self.border_color.resolve(ctx.theme, ctx.effective_enabled);
189 if bc.a() <= 0.0 {
190 return;
191 }
192 match self.border_sides.get() {
193 Some(sides) => paint_border_sides(canvas, bounds, sides, bc),
194 None => {
195 let bw = self.border_width.get();
196 if bw > 0.0 {
197 let stroke_rect = apply_border_position(bounds, bw, self.border_position);
198 canvas.stroke_rounded_rect(stroke_rect, radius, bc, bw);
199 }
200 }
201 }
202 }
203
204 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {}
205}
206
207fn paint_border_sides(canvas: &mut Canvas, bounds: Rect, sides: BorderSides, color: Color) {
211 if sides.top > 0.0 {
212 canvas.fill_rect(
213 Rect::new(bounds.x, bounds.y, bounds.width, sides.top),
214 color,
215 );
216 }
217 if sides.bottom > 0.0 {
218 canvas.fill_rect(
219 Rect::new(
220 bounds.x,
221 bounds.y + bounds.height - sides.bottom,
222 bounds.width,
223 sides.bottom,
224 ),
225 color,
226 );
227 }
228 if sides.leading > 0.0 {
229 canvas.fill_rect(
230 Rect::new(bounds.x, bounds.y, sides.leading, bounds.height),
231 color,
232 );
233 }
234 if sides.trailing > 0.0 {
235 canvas.fill_rect(
236 Rect::new(
237 bounds.x + bounds.width - sides.trailing,
238 bounds.y,
239 sides.trailing,
240 bounds.height,
241 ),
242 color,
243 );
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use teksilo_core::signal::Signal;
251 use teksilo_core::widget_tree::WidgetTree;
252
253 #[test]
254 fn static_background_paints_correctly() {
255 let mut tree = WidgetTree::new();
256 tree.add(
257 RectWidget::new()
258 .background(Color::RED)
259 .corner_radius(CornerRadius::uniform(4.0)),
260 );
261 tree.layout(SizeProposal::exact(100.0, 40.0));
262 let frame = tree.render();
263 assert_eq!(frame.shapes.len(), 1);
264 assert_eq!(frame.shapes[0].color, Color::RED.to_array());
265 }
266
267 #[test]
268 fn accent_role_desaturates_when_window_inactive() {
269 use teksilo_tokens::SurfaceRole;
270
271 let theme = teksilo_core::presets::intui::light();
272 let accent = theme.colors.accent.to_array();
273 let inactive_accent = theme
274 .for_paint_state(teksilo_core::styles::PaintState {
275 window_active: false,
276 high_contrast: false,
277 })
278 .colors
279 .accent
280 .to_array();
281 assert_ne!(accent, inactive_accent);
282
283 let mut tree = WidgetTree::new().with_theme(theme);
284 tree.add(
285 RectWidget::new()
286 .background(SurfaceRole::Accent)
287 .corner_radius(CornerRadius::uniform(4.0)),
288 );
289 tree.layout(SizeProposal::exact(100.0, 40.0));
290
291 let frame = tree.render();
293 assert_eq!(frame.shapes.len(), 1);
294 assert_eq!(
295 frame.shapes[0].color, accent,
296 "active window paints the vivid accent"
297 );
298
299 tree.set_window_active(false);
305 let frame = tree.render();
306 assert_eq!(frame.shapes.len(), 1);
307 assert_eq!(
308 frame.shapes[0].color, inactive_accent,
309 "inactive window desaturates the accent"
310 );
311
312 tree.set_window_active(true);
314 let frame = tree.render();
315 assert_eq!(frame.shapes[0].color, accent);
316 }
317
318 #[test]
319 fn background_reads_from_state() {
320 let color = Signal::new(Color::BLUE);
321 let mut tree = WidgetTree::new();
322 let w = tree.add(
323 RectWidget::new()
324 .background(color.clone())
325 .corner_radius(CornerRadius::uniform(4.0)),
326 );
327 color.bind_to(
328 w,
329 tree.binding_registry(),
330 teksilo_core::binding::BindingLevel::RepaintOnly,
331 );
332 tree.layout(SizeProposal::exact(100.0, 40.0));
333 let frame = tree.render();
334 assert_eq!(frame.shapes[0].color, Color::BLUE.to_array());
335 }
336
337 #[test]
338 fn underline_draws_a_bottom_decoration() {
339 let mut tree = WidgetTree::new();
340 tree.add(
341 RectWidget::new()
342 .border_color(Color::RED)
343 .border_sides(Some(BorderSides::bottom(2.0))),
344 );
345 tree.layout(SizeProposal::exact(100.0, 40.0));
346 let frame = tree.render();
347 let underline = frame
349 .decorations
350 .iter()
351 .find(|d| d.color == Color::RED.to_array())
352 .expect("underline decoration present");
353 assert_eq!(underline.rect[1], 38.0);
355 assert_eq!(underline.rect[3], 2.0);
356 assert!(frame.shapes.iter().all(|s| s.stroke_width == 0.0));
358 }
359
360 #[test]
361 fn gradient_background_emits_linear_gradient_paint() {
362 use teksilo_canvas::render_frame::PaintData;
363 use teksilo_core::paint_prop::{GradientStopProp, PaintProp};
364
365 let mut tree = WidgetTree::new();
366 tree.add(RectWidget::new().background(PaintProp::Linear {
367 stops: vec![
368 GradientStopProp {
369 offset: 0.0,
370 color: Color::RED.into(),
371 },
372 GradientStopProp {
373 offset: 1.0,
374 color: Color::BLUE.into(),
375 },
376 ],
377 angle_deg: 90.0,
378 }));
379 tree.layout(SizeProposal::exact(100.0, 40.0));
380 let frame = tree.render();
381 assert_eq!(frame.shapes.len(), 1);
382 assert!(matches!(
383 frame.shapes[0].paint_data,
384 PaintData::LinearGradient { .. }
385 ));
386 }
387
388 #[test]
389 fn background_updates_on_state_change() {
390 let color = Signal::new(Color::RED);
391 let mut tree = WidgetTree::new();
392 let w = tree.add(
393 RectWidget::new()
394 .background(color.clone())
395 .corner_radius(CornerRadius::uniform(4.0)),
396 );
397 color.bind_to(
398 w,
399 tree.binding_registry(),
400 teksilo_core::binding::BindingLevel::RepaintOnly,
401 );
402
403 tree.layout(SizeProposal::exact(100.0, 40.0));
404 let frame = tree.render();
405 assert_eq!(frame.shapes[0].color, Color::RED.to_array());
406
407 color.set(Color::GREEN);
409 tree.layout(SizeProposal::exact(100.0, 40.0));
410 let frame = tree.render();
411 assert_eq!(frame.shapes[0].color, Color::GREEN.to_array());
412 }
413}