1use crate::metrics::ComponentSize;
29use crate::tokens;
30use crate::tree::*;
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
38#[non_exhaustive]
39pub enum StyleProfile {
40 Solid,
41 Tinted,
42 Surface,
43 #[default]
44 TextOnly,
45}
46
47impl El {
48 pub fn primary(self) -> Self {
51 tint(self, tokens::PRIMARY)
52 }
53 pub fn success(self) -> Self {
54 tint(self, tokens::SUCCESS)
55 }
56 pub fn warning(self) -> Self {
57 tint(self, tokens::WARNING)
58 }
59 pub fn destructive(self) -> Self {
60 tint(self, tokens::DESTRUCTIVE)
61 }
62 pub fn info(self) -> Self {
63 tint(self, tokens::INFO)
64 }
65
66 pub fn secondary(mut self) -> Self {
71 self.fill = Some(tokens::SECONDARY);
72 self.stroke = Some(tokens::BORDER);
73 self.stroke_width = 1.0;
74 set_content_color(&mut self, tokens::SECONDARY_FOREGROUND);
75 self.font_weight = FontWeight::Medium;
76 self
77 }
78
79 pub fn ghost(mut self) -> Self {
82 self.fill = None;
83 self.stroke = None;
84 self.stroke_width = 0.0;
85 set_content_color(&mut self, tokens::MUTED_FOREGROUND);
86 self
87 }
88
89 pub fn outline(mut self) -> Self {
91 self.fill = None;
92 self.stroke = Some(tokens::INPUT);
93 self.stroke_width = 1.0;
94 set_content_color(&mut self, tokens::FOREGROUND);
95 self
96 }
97
98 pub fn muted(mut self) -> Self {
102 match self.style_profile {
103 StyleProfile::Solid | StyleProfile::Tinted | StyleProfile::Surface => {
104 self.fill = Some(tokens::MUTED);
105 self.stroke = Some(tokens::BORDER);
106 self.stroke_width = 1.0;
107 set_content_color(&mut self, tokens::MUTED_FOREGROUND);
108 }
109 StyleProfile::TextOnly => {
110 set_content_color(&mut self, tokens::MUTED_FOREGROUND);
111 }
112 }
113 self
114 }
115
116 pub fn selected(mut self) -> Self {
121 if text_only_leaf(&self) {
122 self.text_color = Some(tokens::PRIMARY);
123 } else if matches!(self.kind, Kind::Custom("item")) {
124 self.style_profile = StyleProfile::Surface;
125 self.surface_role = SurfaceRole::Selected;
126 self.fill = Some(tokens::PRIMARY.with_alpha(18));
127 self.stroke = Some(tokens::PRIMARY.with_alpha(90));
128 self.stroke_width = 1.0;
129 set_content_color(&mut self, tokens::FOREGROUND);
130 set_item_rail(&mut self, tokens::PRIMARY);
131 } else {
132 match self.style_profile {
133 StyleProfile::TextOnly => {}
134 StyleProfile::Solid | StyleProfile::Tinted | StyleProfile::Surface => {}
135 }
136 {
137 self.style_profile = StyleProfile::Surface;
138 self.surface_role = SurfaceRole::Selected;
139 self.fill = Some(tokens::PRIMARY.with_alpha(28));
140 self.stroke = Some(tokens::PRIMARY.with_alpha(90));
141 self.stroke_width = 1.0;
142 set_content_color(&mut self, tokens::FOREGROUND);
143 }
144 }
145 self
146 }
147
148 pub fn current(mut self) -> Self {
151 if text_only_leaf(&self) {
152 self.text_color = Some(tokens::FOREGROUND);
153 self.font_weight = FontWeight::Semibold;
154 } else if matches!(self.kind, Kind::Custom("item")) {
155 self.style_profile = StyleProfile::Surface;
156 self.surface_role = SurfaceRole::Current;
157 self.fill = Some(tokens::ACCENT.with_alpha(24));
158 self.stroke = Some(tokens::BORDER);
159 self.stroke_width = 1.0;
160 set_content_color(&mut self, tokens::FOREGROUND);
161 set_item_rail(&mut self, tokens::PRIMARY);
162 } else {
163 self.style_profile = StyleProfile::Surface;
164 self.surface_role = SurfaceRole::Current;
165 self.fill = Some(tokens::ACCENT);
166 self.stroke = Some(tokens::BORDER);
167 self.stroke_width = 1.0;
168 set_content_color(&mut self, tokens::ACCENT_FOREGROUND);
169 self.font_weight = FontWeight::Semibold;
170 }
171 self
172 }
173
174 pub fn disabled(mut self) -> Self {
177 self.opacity = tokens::DISABLED_ALPHA;
178 self.focusable = false;
179 self.block_pointer = true;
180 if text_only_leaf(&self) {
181 self.text_color = Some(tokens::MUTED_FOREGROUND);
182 }
183 self
184 }
185
186 pub fn invalid(mut self) -> Self {
188 if !text_only_leaf(&self) {
189 self.style_profile = StyleProfile::Surface;
190 self.surface_role = SurfaceRole::Danger;
191 }
192 self.stroke = Some(tokens::DESTRUCTIVE);
193 self.stroke_width = 1.0;
194 if text_only_leaf(&self) {
195 self.text_color = Some(tokens::DESTRUCTIVE);
196 }
197 self
198 }
199
200 pub fn loading(mut self) -> Self {
204 self.opacity = self.opacity.min(0.78);
205 if let Some(label) = &mut self.text {
206 label.push_str("...");
207 }
208 self
209 }
210
211 pub fn text_role(mut self, role: TextRole) -> Self {
214 self.text_role = role;
215 apply_text_role(&mut self);
216 self
217 }
218
219 pub fn caption(self) -> Self {
220 self.text_role(TextRole::Caption)
221 }
222
223 pub fn label(self) -> Self {
224 self.text_role(TextRole::Label)
225 }
226
227 pub fn body(self) -> Self {
228 self.text_role(TextRole::Body)
229 }
230
231 pub fn title(self) -> Self {
232 self.text_role(TextRole::Title)
233 }
234
235 pub fn heading(self) -> Self {
236 self.text_role(TextRole::Heading)
237 }
238
239 pub fn display(self) -> Self {
240 self.text_role(TextRole::Display)
241 }
242
243 pub fn bold(mut self) -> Self {
246 self.font_weight = FontWeight::Bold;
247 self
248 }
249 pub fn semibold(mut self) -> Self {
250 self.font_weight = FontWeight::Semibold;
251 self
252 }
253 pub fn small(mut self) -> Self {
254 if text_only_leaf(&self) {
255 apply_type_token(&mut self, tokens::TEXT_SM);
256 } else {
257 self.component_size = Some(ComponentSize::Sm);
258 }
259 self
260 }
261 pub fn xsmall(mut self) -> Self {
262 if text_only_leaf(&self) {
263 apply_type_token(&mut self, tokens::TEXT_XS);
264 } else {
265 self.component_size = Some(ComponentSize::Xs);
266 }
267 self
268 }
269 pub fn color(mut self, c: Color) -> Self {
271 self.text_color = Some(c);
272 self
273 }
274}
275
276fn text_only_leaf(el: &El) -> bool {
277 matches!(el.style_profile, StyleProfile::TextOnly) && el.text.is_some()
278}
279
280fn apply_type_token(el: &mut El, token: tokens::TypeToken) {
281 el.font_size = token.size;
282 el.line_height = token.line_height;
283}
284
285fn apply_text_role(el: &mut El) {
286 let clear_mono = |el: &mut El| {
293 if !el.explicit_mono {
294 el.font_mono = false;
295 }
296 };
297 match el.text_role {
298 TextRole::Body => {
299 apply_type_token(el, tokens::TEXT_SM);
300 el.font_weight = FontWeight::Regular;
301 clear_mono(el);
302 el.text_color = Some(tokens::FOREGROUND);
303 }
304 TextRole::Caption => {
305 apply_type_token(el, tokens::TEXT_XS);
306 el.font_weight = FontWeight::Regular;
307 clear_mono(el);
308 el.text_color = Some(tokens::MUTED_FOREGROUND);
309 }
310 TextRole::Label => {
311 apply_type_token(el, tokens::TEXT_SM);
312 el.font_weight = FontWeight::Medium;
313 clear_mono(el);
314 el.text_color = Some(tokens::FOREGROUND);
315 }
316 TextRole::Title => {
317 apply_type_token(el, tokens::TEXT_BASE);
318 el.font_weight = FontWeight::Semibold;
319 clear_mono(el);
320 el.text_color = Some(tokens::FOREGROUND);
321 }
322 TextRole::Heading => {
323 apply_type_token(el, tokens::TEXT_2XL);
324 el.font_weight = FontWeight::Semibold;
325 clear_mono(el);
326 el.text_color = Some(tokens::FOREGROUND);
327 }
328 TextRole::Display => {
329 apply_type_token(el, tokens::TEXT_3XL);
330 el.font_weight = FontWeight::Bold;
331 clear_mono(el);
332 el.text_color = Some(tokens::FOREGROUND);
333 }
334 TextRole::Code => {
335 apply_type_token(el, tokens::TEXT_XS);
336 el.font_weight = FontWeight::Regular;
337 el.font_mono = true;
338 el.text_color = Some(tokens::FOREGROUND);
339 }
340 }
341}
342
343fn tint(mut el: El, c: Color) -> El {
344 match el.style_profile {
345 StyleProfile::Solid => {
346 el.fill = Some(c);
347 el.stroke = Some(c);
348 el.stroke_width = 1.0;
349 set_content_color(&mut el, text_on_solid(c));
350 el.font_weight = FontWeight::Semibold;
351 }
352 StyleProfile::Tinted => {
353 el.fill = Some(c.with_alpha(38));
354 el.stroke = Some(c.with_alpha(120));
355 el.stroke_width = 1.0;
356 set_content_color(&mut el, c);
357 }
358 StyleProfile::Surface => {
359 el.fill = Some(c.with_alpha(38));
360 el.stroke = Some(c.with_alpha(120));
361 el.stroke_width = 1.0;
362 set_content_color(&mut el, c);
363 }
364 StyleProfile::TextOnly => {
365 set_content_color(&mut el, c);
366 }
367 }
368 el
369}
370
371fn set_content_color(el: &mut El, color: Color) {
372 el.text_color = Some(color);
373 for child in &mut el.children {
374 if child.text.is_some() || child.icon.is_some() {
375 child.text_color = Some(color);
376 }
377 }
378}
379
380fn set_item_rail(el: &mut El, color: Color) {
381 for child in &mut el.children {
382 if matches!(child.kind, Kind::Custom("item_rail")) {
383 child.fill = Some(color);
384 child.opacity = 1.0;
385 }
386 }
387}
388
389fn text_on_solid(c: Color) -> Color {
395 match c.token {
396 Some("primary") => return tokens::PRIMARY_FOREGROUND,
397 Some("secondary") => return tokens::SECONDARY_FOREGROUND,
398 Some("accent") => return tokens::ACCENT_FOREGROUND,
399 Some("destructive") => return tokens::DESTRUCTIVE_FOREGROUND,
400 Some("success") => return tokens::SUCCESS_FOREGROUND,
401 Some("warning") => return tokens::WARNING_FOREGROUND,
402 Some("info") => return tokens::INFO_FOREGROUND,
403 _ => {}
404 }
405
406 let lum = 0.299 * c.r as f32 + 0.587 * c.g as f32 + 0.114 * c.b as f32;
407 if lum > 150.0 {
408 Color::rgba(8, 16, 25, 255)
409 } else {
410 Color::rgba(250, 250, 252, 255)
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use crate::{button, button_with_icon, icon_button, row, text};
418
419 #[test]
420 fn selected_marks_surface_with_accent_treatment() {
421 let el = row([text("Selected")]).selected();
422 assert_eq!(el.fill, Some(tokens::PRIMARY.with_alpha(28)));
423 assert_eq!(el.stroke, Some(tokens::PRIMARY.with_alpha(90)));
424 assert_eq!(el.stroke_width, 1.0);
425 assert_eq!(el.surface_role, SurfaceRole::Selected);
426 }
427
428 #[test]
429 fn current_marks_container_as_selected_surface_role() {
430 let el = row([text("Current")]).current();
431 assert_eq!(el.fill, Some(tokens::ACCENT));
432 assert_eq!(el.stroke, Some(tokens::BORDER));
433 assert_eq!(el.surface_role, SurfaceRole::Current);
434 assert_eq!(el.style_profile, StyleProfile::Surface);
435 }
436
437 #[test]
438 fn disabled_removes_focus_and_dims_control() {
439 let el = button("Disabled").disabled();
440 assert!(!el.focusable);
441 assert!(el.block_pointer);
442 assert_eq!(el.opacity, tokens::DISABLED_ALPHA);
443 }
444
445 #[test]
446 fn icon_button_uses_same_solid_style_surface_as_button() {
447 let el = icon_button("menu").primary();
448 assert_eq!(el.icon, Some(crate::IconSource::Builtin(IconName::Menu)));
449 assert_eq!(el.fill, Some(tokens::PRIMARY));
450 assert_eq!(el.text_color, Some(text_on_solid(tokens::PRIMARY)));
451 assert_eq!(el.surface_role, SurfaceRole::Raised);
452 }
453
454 #[test]
455 fn button_with_icon_propagates_variant_content_color() {
456 let el = button_with_icon("upload", "Publish").primary();
457 assert_eq!(el.fill, Some(tokens::PRIMARY));
458 assert_eq!(
459 el.children[0].icon,
460 Some(crate::IconSource::Builtin(IconName::Upload))
461 );
462 let expected = text_on_solid(tokens::PRIMARY);
463 assert_eq!(el.children[0].text_color, Some(expected));
464 assert_eq!(el.children[1].text.as_deref(), Some("Publish"));
465 assert_eq!(el.children[1].text_color, Some(expected));
466 }
467
468 #[test]
469 fn loading_appends_direct_label_text() {
470 let el = button("Save").loading();
471 assert_eq!(el.text.as_deref(), Some("Save..."));
472 assert_eq!(el.opacity, 0.78);
473 }
474
475 #[test]
476 fn text_roles_apply_inspectable_typographic_defaults() {
477 let caption = text("Caption").caption();
478 assert_eq!(caption.text_role, TextRole::Caption);
479 assert_eq!(caption.font_size, tokens::TEXT_XS.size);
480 assert_eq!(caption.line_height, tokens::TEXT_XS.line_height);
481 assert_eq!(caption.text_color, Some(tokens::MUTED_FOREGROUND));
482
483 let label = text("Label").label();
484 assert_eq!(label.text_role, TextRole::Label);
485 assert_eq!(label.font_size, tokens::TEXT_SM.size);
486 assert_eq!(label.line_height, tokens::TEXT_SM.line_height);
487 assert_eq!(label.font_weight, FontWeight::Medium);
488
489 let code = text("Code").code();
490 assert_eq!(code.text_role, TextRole::Code);
491 assert_eq!(code.font_size, tokens::TEXT_XS.size);
492 assert_eq!(code.line_height, tokens::TEXT_XS.line_height);
493 assert_eq!(code.font_weight, FontWeight::Regular);
494 assert_eq!(code.text_color, Some(tokens::FOREGROUND));
495 assert!(code.font_mono);
496 }
497
498 #[test]
499 fn explicit_mono_survives_subsequent_role_modifier() {
500 let mono_first = text("+2").mono().caption();
507 assert!(
508 mono_first.font_mono,
509 "`.mono()` chained before `.caption()` must keep mono on",
510 );
511 assert_eq!(mono_first.font_size, tokens::TEXT_XS.size);
513 assert_eq!(mono_first.text_color, Some(tokens::MUTED_FOREGROUND));
514
515 let role_first = text("+2").caption().mono();
517 assert!(role_first.font_mono);
518
519 for el in [
521 text("+1").mono().body(),
522 text("+1").mono().label(),
523 text("+1").mono().title(),
524 text("+1").mono().heading(),
525 text("+1").mono().display(),
526 ] {
527 assert!(
528 el.font_mono,
529 "explicit .mono() must survive every non-Code role",
530 );
531 }
532
533 assert!(text("x").mono().code().font_mono);
536 }
537}