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.colors.for_inactive_window().accent.to_array();
274 assert_ne!(accent, inactive_accent);
275
276 let mut tree = WidgetTree::new().with_theme(theme);
277 tree.add(
278 RectWidget::new()
279 .background(SurfaceRole::Accent)
280 .corner_radius(CornerRadius::uniform(4.0)),
281 );
282 tree.layout(SizeProposal::exact(100.0, 40.0));
283
284 let frame = tree.render();
286 assert_eq!(frame.shapes.len(), 1);
287 assert_eq!(
288 frame.shapes[0].color, accent,
289 "active window paints the vivid accent"
290 );
291
292 tree.set_window_active(false);
298 let frame = tree.render();
299 assert_eq!(frame.shapes.len(), 1);
300 assert_eq!(
301 frame.shapes[0].color, inactive_accent,
302 "inactive window desaturates the accent"
303 );
304
305 tree.set_window_active(true);
307 let frame = tree.render();
308 assert_eq!(frame.shapes[0].color, accent);
309 }
310
311 #[test]
312 fn background_reads_from_state() {
313 let color = Signal::new(Color::BLUE);
314 let mut tree = WidgetTree::new();
315 let w = tree.add(
316 RectWidget::new()
317 .background(color.clone())
318 .corner_radius(CornerRadius::uniform(4.0)),
319 );
320 color.bind_to(
321 w,
322 tree.binding_registry(),
323 teksilo_core::binding::BindingLevel::RepaintOnly,
324 );
325 tree.layout(SizeProposal::exact(100.0, 40.0));
326 let frame = tree.render();
327 assert_eq!(frame.shapes[0].color, Color::BLUE.to_array());
328 }
329
330 #[test]
331 fn underline_draws_a_bottom_decoration() {
332 let mut tree = WidgetTree::new();
333 tree.add(
334 RectWidget::new()
335 .border_color(Color::RED)
336 .border_sides(Some(BorderSides::bottom(2.0))),
337 );
338 tree.layout(SizeProposal::exact(100.0, 40.0));
339 let frame = tree.render();
340 let underline = frame
342 .decorations
343 .iter()
344 .find(|d| d.color == Color::RED.to_array())
345 .expect("underline decoration present");
346 assert_eq!(underline.rect[1], 38.0);
348 assert_eq!(underline.rect[3], 2.0);
349 assert!(frame.shapes.iter().all(|s| s.stroke_width == 0.0));
351 }
352
353 #[test]
354 fn gradient_background_emits_linear_gradient_paint() {
355 use teksilo_canvas::render_frame::PaintData;
356 use teksilo_core::paint_prop::{GradientStopProp, PaintProp};
357
358 let mut tree = WidgetTree::new();
359 tree.add(RectWidget::new().background(PaintProp::Linear {
360 stops: vec![
361 GradientStopProp {
362 offset: 0.0,
363 color: Color::RED.into(),
364 },
365 GradientStopProp {
366 offset: 1.0,
367 color: Color::BLUE.into(),
368 },
369 ],
370 angle_deg: 90.0,
371 }));
372 tree.layout(SizeProposal::exact(100.0, 40.0));
373 let frame = tree.render();
374 assert_eq!(frame.shapes.len(), 1);
375 assert!(matches!(
376 frame.shapes[0].paint_data,
377 PaintData::LinearGradient { .. }
378 ));
379 }
380
381 #[test]
382 fn background_updates_on_state_change() {
383 let color = Signal::new(Color::RED);
384 let mut tree = WidgetTree::new();
385 let w = tree.add(
386 RectWidget::new()
387 .background(color.clone())
388 .corner_radius(CornerRadius::uniform(4.0)),
389 );
390 color.bind_to(
391 w,
392 tree.binding_registry(),
393 teksilo_core::binding::BindingLevel::RepaintOnly,
394 );
395
396 tree.layout(SizeProposal::exact(100.0, 40.0));
397 let frame = tree.render();
398 assert_eq!(frame.shapes[0].color, Color::RED.to_array());
399
400 color.set(Color::GREEN);
402 tree.layout(SizeProposal::exact(100.0, 40.0));
403 let frame = tree.render();
404 assert_eq!(frame.shapes[0].color, Color::GREEN.to_array());
405 }
406}