1use teksilo_canvas::{Canvas, Path, Point, Rect, Size, SizeProposal};
19use teksilo_core::accessibility::AccessNodeBuilder;
20use teksilo_core::binding::BindingLevel;
21use teksilo_core::build_context::BuildContext;
22use teksilo_core::focus::FocusOrigin;
23use teksilo_core::signal::Signal;
24use teksilo_core::styles::density::{dp, spacing};
25use teksilo_core::styles::{CheckboxState, CheckboxStyle, CheckboxStyleConfig, CheckboxVariant};
26use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
27use teksilo_core::widget_id::WidgetId;
28use teksilo_tokens::{Color, CornerRadius, InputTokens, TargetRole};
29
30pub const CHECKBOX_BOX_VISUAL_SIZE: f32 = 19.0;
32pub const CHECKBOX_BOX_HIT_AREA: f32 = 24.0;
33pub const CHECKBOX_LABEL_GAP: f32 = 6.0;
34pub const CHECKBOX_CORNER_RADIUS: f32 = 3.0;
35
36#[derive(Debug, Clone, Copy, PartialEq)]
41pub struct CheckboxRecipe {
42 pub box_visual_size: f32,
43 pub box_hit_area: f32,
44 pub label_gap: f32,
45 pub corner_radius: f32,
46}
47
48impl CheckboxRecipe {
49 pub fn for_tokens(tokens: &InputTokens) -> Self {
55 Self {
56 box_visual_size: CHECKBOX_BOX_VISUAL_SIZE,
57 box_hit_area: dp(CHECKBOX_BOX_HIT_AREA, TargetRole::Target, tokens),
58 label_gap: spacing(CHECKBOX_LABEL_GAP, tokens),
59 corner_radius: CHECKBOX_CORNER_RADIUS,
60 }
61 }
62}
63
64impl Default for CheckboxRecipe {
65 fn default() -> Self {
66 Self::for_tokens(&InputTokens::default())
67 }
68}
69
70#[derive(Debug, Default, Clone, Copy)]
74pub struct RecipeCheckboxStyle {
75 pub recipe: CheckboxRecipe,
76}
77
78impl RecipeCheckboxStyle {
79 pub fn new(recipe: CheckboxRecipe) -> Self {
80 Self { recipe }
81 }
82
83 pub fn for_tokens(tokens: &InputTokens) -> Self {
90 Self {
91 recipe: CheckboxRecipe::for_tokens(tokens),
92 }
93 }
94}
95
96impl CheckboxStyle for RecipeCheckboxStyle {
97 fn make_body(&self, cfg: &CheckboxStyleConfig, ctx: &mut BuildContext) -> WidgetId {
98 let focus_origin = cfg
99 .is_focused
100 .zip(&cfg.is_hovered)
101 .map(|(focused, hovered)| {
102 if *focused {
103 Some(if *hovered {
104 FocusOrigin::POINTER
105 } else {
106 FocusOrigin::Keyboard
107 })
108 } else {
109 None
110 }
111 });
112
113 ctx.add(CheckboxBody {
114 state: cfg.state.clone(),
115 is_hovered: cfg.is_hovered.clone(),
116 is_pressed: cfg.is_pressed.clone(),
117 is_disabled: cfg.is_disabled.clone(),
118 focus_origin,
119 variant: cfg.variant,
120 recipe: self.recipe,
121 })
122 }
123}
124
125struct CheckboxBody {
126 state: Signal<CheckboxState>,
127 is_hovered: Signal<bool>,
128 is_pressed: Signal<bool>,
129 is_disabled: Signal<bool>,
130 focus_origin: Signal<Option<FocusOrigin>>,
131 variant: CheckboxVariant,
132 recipe: CheckboxRecipe,
133}
134
135impl std::fmt::Debug for CheckboxBody {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 f.debug_struct("CheckboxBody")
138 .field("variant", &self.variant)
139 .finish()
140 }
141}
142
143impl Widget for CheckboxBody {
144 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
145 let id = ctx.self_id();
146 let registry = ctx.binding_registry();
147 self.state.bind_to(id, registry, BindingLevel::RepaintOnly);
148 self.is_hovered
149 .bind_to(id, registry, BindingLevel::RepaintOnly);
150 self.is_pressed
151 .bind_to(id, registry, BindingLevel::RepaintOnly);
152 self.is_disabled
153 .bind_to(id, registry, BindingLevel::RepaintOnly);
154 self.focus_origin
155 .bind_to(id, registry, BindingLevel::RepaintOnly);
156 vec![]
157 }
158
159 fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
160 Size::new(self.recipe.box_visual_size, self.recipe.box_visual_size).into()
161 }
162
163 fn place_children(
164 &self,
165 _bounds: Rect,
166 _proposal: SizeProposal,
167 _children: &mut [WidgetPlacement],
168 _ctx: &LayoutContext,
169 ) {
170 }
171
172 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
173 let colors = &ctx.theme.colors;
174 let state = self.state.get();
175 let is_filled = matches!(state, CheckboxState::Checked | CheckboxState::Indeterminate);
176 let disabled = self.is_disabled.get();
177 let pressed = self.is_pressed.get();
178 let hovered = self.is_hovered.get();
179 let focused = self.focus_origin.get().is_some();
180
181 let bg = if disabled {
184 if is_filled {
185 colors.accent_disabled
186 } else {
187 Color::TRANSPARENT
188 }
189 } else if is_filled {
190 if pressed {
191 colors.accent_pressed
192 } else if hovered {
193 colors.accent_hover
194 } else {
195 colors.accent
196 }
197 } else {
198 Color::TRANSPARENT
199 };
200
201 let border_color = if focused {
204 colors.border_focused
205 } else if disabled {
206 colors.accent_disabled
207 } else if is_filled {
208 Color::TRANSPARENT
209 } else if hovered {
210 colors.border_strong
211 } else {
212 colors.border
213 };
214
215 let border_width = if focused {
216 ctx.theme.shape.focus_ring_width
217 } else {
218 ctx.theme.shape.border_width
219 };
220
221 let corner = match self.variant {
225 CheckboxVariant::Square => CornerRadius::uniform(self.recipe.corner_radius),
226 CheckboxVariant::Rounded => CornerRadius::uniform(self.recipe.corner_radius * 2.0),
227 CheckboxVariant::Circle => CornerRadius::uniform(self.recipe.box_visual_size / 2.0),
228 };
229
230 canvas.fill_rounded_rect(bounds, corner, bg);
231 if border_width > 0.0 && border_color != Color::TRANSPARENT {
232 canvas.stroke_rounded_rect(bounds, corner, border_color, border_width);
233 }
234
235 if matches!(state, CheckboxState::Unchecked) {
238 return;
239 }
240 let glyph_color = if disabled {
241 colors.text_disabled
242 } else {
243 colors.text_on_accent
244 };
245 let glyph_size = self.recipe.box_visual_size * 0.75;
246 let glyph_rect = Rect::new(
247 bounds.x + (bounds.width - glyph_size) / 2.0,
248 bounds.y + (bounds.height - glyph_size) / 2.0,
249 glyph_size,
250 glyph_size,
251 );
252 let stroke_w = (glyph_size * 0.18).max(1.5);
253
254 let mut path = Path::new();
255 match state {
256 CheckboxState::Checked => {
257 path.move_to(Point::new(
259 glyph_rect.x + glyph_size * 0.20,
260 glyph_rect.y + glyph_size * 0.55,
261 ));
262 path.line_to(Point::new(
263 glyph_rect.x + glyph_size * 0.43,
264 glyph_rect.y + glyph_size * 0.78,
265 ));
266 path.line_to(Point::new(
267 glyph_rect.x + glyph_size * 0.80,
268 glyph_rect.y + glyph_size * 0.28,
269 ));
270 }
271 CheckboxState::Indeterminate => {
272 path.move_to(Point::new(
274 glyph_rect.x + glyph_size * 0.20,
275 glyph_rect.y + glyph_size * 0.50,
276 ));
277 path.line_to(Point::new(
278 glyph_rect.x + glyph_size * 0.80,
279 glyph_rect.y + glyph_size * 0.50,
280 ));
281 }
282 CheckboxState::Unchecked => unreachable!(),
283 }
284 canvas.stroke_path(&path, glyph_color, stroke_w);
285 }
286
287 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
288 }
291}