1use crate::layout_ir::Attributes;
12use lumen_core::components::{Fill, FlexDirection, Gap, ShadowSpec, Style, TextStyle, Visuals};
13
14pub fn typography_role_to_px(role: &str) -> Option<f32> {
27 Some(match role {
28 "display-xl" => 128.0,
29 "display-lg" => 96.0,
30 "display-md" => 64.0,
31 "display-sm" => 48.0,
32 "headline-lg" => 40.0,
33 "headline-md" => 32.0,
34 "headline-sm" => 26.0,
35 "title-lg" => 22.0,
36 "title-md" => 18.0,
37 "title-sm" => 16.0,
38 "body-lg" => 18.0,
39 "body-md" => 16.0,
40 "body-sm" => 14.0,
41 "label-lg" => 14.0,
42 "label-md" => 13.0,
43 "label-sm" => 12.0,
44 "caption" => 13.0,
45 "overline" => 11.0,
46 _ => return None,
47 })
48}
49
50impl From<&Attributes> for Style {
51 fn from(attrs: &Attributes) -> Self {
62 let mut style = Style::default();
63 if let Some(d) = attrs.display {
64 style.display = d.into();
65 }
66 if let Some(w) = attrs.width {
67 style.width = w.into();
68 }
69 if let Some(h) = attrs.height {
70 style.height = h.into();
71 }
72 if let Some(f) = attrs.flex {
73 style.flex_direction = FlexDirection::from(f);
74 }
75 if let Some(p) = attrs.padding {
76 style.padding = p.into();
77 }
78 if let Some(m) = attrs.margin {
79 style.margin = m.into();
80 }
81 if let Some(v) = attrs.gap {
84 style.gap = Gap::from(v);
85 }
86 if let Some(r) = attrs.gap_row {
87 style.gap.row = r;
88 }
89 if let Some(c) = attrs.gap_column {
90 style.gap.column = c;
91 }
92 if let Some(p) = attrs.gap_row_pct.or(attrs.gap_pct) {
93 style.gap.row_pct = Some(p);
94 }
95 if let Some(p) = attrs.gap_column_pct.or(attrs.gap_pct) {
96 style.gap.column_pct = Some(p);
97 }
98 if let Some(g) = attrs.grow {
99 style.grow = g;
100 }
101 if let Some(a) = attrs.align {
102 style.align = a.into();
103 }
104 if let Some(j) = attrs.justify {
105 style.justify = j.into();
106 }
107 if let Some(a) = attrs.align_self {
108 style.align_self = Some(a.into());
109 }
110 if let Some(j) = attrs.justify_items {
111 style.justify_items = Some(j.into());
112 }
113 if let Some(j) = attrs.justify_self {
114 style.justify_self = Some(j.into());
115 }
116 if let Some(t) = attrs.grid_template.as_ref() {
117 style.grid_template = Some(t.into());
118 }
119 if let Some(r) = attrs.grid_row {
120 style.grid_row = r;
121 }
122 if let Some(c) = attrs.grid_column {
123 style.grid_column = c;
124 }
125 if let Some(p) = attrs.position {
126 style.position = p.into();
127 }
128 if let Some(i) = attrs.inset {
129 style.inset = i.into();
130 }
131 if let Some(v) = attrs.min_width {
132 style.min_width = v.into();
133 }
134 if let Some(v) = attrs.min_height {
135 style.min_height = v.into();
136 }
137 if let Some(v) = attrs.max_width {
138 style.max_width = v.into();
139 }
140 if let Some(v) = attrs.max_height {
141 style.max_height = v.into();
142 }
143 if attrs.aspect_ratio.is_some() {
144 style.aspect_ratio = attrs.aspect_ratio;
145 }
146 if let Some(o) = attrs.overflow_x.or(attrs.overflow) {
149 style.overflow_x = o.into();
150 }
151 if let Some(o) = attrs.overflow_y.or(attrs.overflow) {
152 style.overflow_y = o.into();
153 }
154 if let Some(s) = attrs.shrink {
155 style.shrink = s;
156 }
157 if let Some(b) = attrs.basis {
158 style.basis = b.into();
159 }
160 if let Some(w) = attrs.flex_wrap {
161 style.flex_wrap = w.into();
162 }
163 if let Some(a) = attrs.align_content {
164 style.align_content = Some(a.into());
165 }
166 if attrs.border_style.is_some() || attrs.border_width.is_some() {
169 style.border = attrs
170 .effective_border()
171 .map(|(widths, _)| widths.into())
172 .unwrap_or_default();
173 }
174 if let Some(b) = attrs.box_sizing {
175 style.box_sizing = b.into();
176 }
177 style
178 }
179}
180
181impl From<&Attributes> for Option<Visuals> {
186 fn from(attrs: &Attributes) -> Self {
187 let fill = attrs.bg.as_ref().and_then(|bg| Fill::try_from(bg).ok());
188 let radius = attrs.radius.unwrap_or(0.0);
189 let corner_radii = attrs.radius_corners;
190 let shadows: Vec<ShadowSpec> = attrs.shadows.iter().copied().map(Into::into).collect();
191 let border =
192 attrs
193 .effective_border()
194 .map(|(widths, color)| lumen_core::components::Border {
195 widths: widths.into(),
196 color: color.into(),
197 side_colors: attrs
198 .effective_border_colors(color)
199 .map(|cs| cs.map(Into::into)),
200 });
201 if fill.is_none()
202 && radius == 0.0
203 && corner_radii.is_none()
204 && shadows.is_empty()
205 && border.is_none()
206 {
207 return None;
208 }
209 Some(Visuals {
210 fill,
211 radius,
212 corner_radii,
213 shadows,
214 border,
215 })
216 }
217}
218
219impl<'a> TryFrom<&'a crate::layout_ir::BgSpec> for Fill {
223 type Error = &'a str;
224
225 fn try_from(spec: &'a crate::layout_ir::BgSpec) -> Result<Self, Self::Error> {
226 Ok(match spec {
227 crate::layout_ir::BgSpec::Image(path) => return Err(path),
228 crate::layout_ir::BgSpec::Solid(rgba) => Fill::Solid((*rgba).into()),
229 crate::layout_ir::BgSpec::Linear { angle_deg, stops } => Fill::Linear {
230 angle_deg: *angle_deg,
231 stops: stops.iter().map(|(o, c)| (*o, (*c).into())).collect(),
232 },
233 crate::layout_ir::BgSpec::Radial { radius, stops } => Fill::Radial {
234 radius: *radius,
235 stops: stops.iter().map(|(o, c)| (*o, (*c).into())).collect(),
236 },
237 crate::layout_ir::BgSpec::Conic { from_deg, stops } => Fill::Conic {
238 from_deg: *from_deg,
239 stops: stops.iter().map(|(o, c)| (*o, (*c).into())).collect(),
240 },
241 })
242 }
243}
244
245impl From<crate::layout_ir::ShadowSpec> for ShadowSpec {
246 fn from(s: crate::layout_ir::ShadowSpec) -> Self {
247 ShadowSpec {
248 offset_x: s.offset_x,
249 offset_y: s.offset_y,
250 blur: s.blur,
251 spread: s.spread,
252 color: s.color.into(),
253 inner: s.inner,
254 }
255 }
256}
257
258impl From<&Attributes> for Option<TextStyle> {
263 fn from(attrs: &Attributes) -> Self {
264 let role_size = attrs.style_role.as_deref().and_then(typography_role_to_px);
265 let size_px = attrs.font_size.or(role_size);
266 let ellipsis = matches!(
267 attrs.text_overflow,
268 Some(crate::layout_ir::TextOverflowSpec::Ellipsis)
269 );
270 if attrs.text_color.is_none()
271 && size_px.is_none()
272 && attrs.text_align.is_none()
273 && attrs.text_wrap.is_none()
274 && attrs.max_lines.is_none()
275 && !ellipsis
276 && attrs.font_family.is_none()
277 && attrs.font_weight.is_none()
278 && attrs.selection_color.is_none()
279 && attrs.line_height.is_none()
280 {
281 return None;
282 }
283 let defaults = TextStyle::default();
284 let line_height = attrs.line_height.map(|lh| match lh {
291 crate::layout_ir::LineHeightSpec::Multiplier(m) => {
292 lumen_core::components::LineHeightSpec::Multiplier(m)
293 }
294 crate::layout_ir::LineHeightSpec::Px(px) => {
295 lumen_core::components::LineHeightSpec::Px(px)
296 }
297 });
298 let (wrap, max_lines) = if ellipsis {
306 (
307 attrs
308 .text_wrap
309 .map(Into::into)
310 .unwrap_or(lumen_core::components::TextWrap::Glyph),
311 Some(attrs.max_lines.unwrap_or(1)),
312 )
313 } else {
314 (
315 attrs.text_wrap.map(Into::into).unwrap_or(defaults.wrap),
316 attrs.max_lines,
317 )
318 };
319 Some(TextStyle {
320 color: attrs.text_color.map(Into::into).unwrap_or(defaults.color),
321 size_px: size_px.unwrap_or(defaults.size_px),
322 align: attrs.text_align.map(Into::into).unwrap_or(defaults.align),
323 wrap,
324 max_lines,
325 family: attrs
326 .font_family
327 .as_deref()
328 .map(std::sync::Arc::<str>::from),
329 weight: attrs.font_weight.unwrap_or(defaults.weight),
330 selection_color: attrs.selection_color.map(Into::into),
331 line_height,
332 })
333 }
334}
335
336impl From<&Attributes> for Option<lumen_primitives::Interaction> {
341 fn from(attrs: &Attributes) -> Self {
342 let hover_tint = attrs.hover_bg.map(Into::into);
343 let press_tint = attrs.press_bg.map(Into::into);
344 let focus_outline = attrs.focus_outline.map(Into::into);
345 let focus_visible_outline = attrs.focus_visible_outline.map(Into::into);
346 let hover_border = attrs.hover_border.map(Into::into);
347 let focus_border = attrs.focus_border.map(Into::into);
348 if hover_tint.is_none()
349 && press_tint.is_none()
350 && focus_outline.is_none()
351 && focus_visible_outline.is_none()
352 && hover_border.is_none()
353 && focus_border.is_none()
354 {
355 return None;
356 }
357 Some(lumen_primitives::Interaction {
358 hover_tint,
359 press_tint,
360 focus_outline,
361 focus_visible_outline,
362 hover_border,
363 focus_border,
364 })
365 }
366}
367
368impl From<&Attributes> for lumen_core::components::SliderValue {
375 fn from(attrs: &Attributes) -> Self {
376 let min = attrs.min.unwrap_or(0.0);
377 let max = attrs.max.unwrap_or(1.0);
378 let slider = Self {
379 value: 0.0,
380 min,
381 max,
382 step: attrs.step,
383 };
384 Self {
385 value: slider.clamp(attrs.value.unwrap_or(min)),
386 ..slider
387 }
388 }
389}
390
391impl From<&Attributes> for lumen_primitives::ProgressBar {
397 fn from(attrs: &Attributes) -> Self {
398 Self {
399 value: attrs.value,
400 max: attrs.max.unwrap_or(1.0),
401 period_ms: attrs
402 .progress_duration
403 .unwrap_or(lumen_primitives::PROGRESS_PERIOD_MS),
404 }
405 }
406}
407
408impl From<crate::layout_ir::OutlineSpec> for lumen_primitives::FocusOutlineSpec {
409 fn from(spec: crate::layout_ir::OutlineSpec) -> Self {
410 lumen_primitives::FocusOutlineSpec {
411 width: spec.width,
412 color: spec.color.into(),
413 offset: spec.offset,
414 }
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421 use crate::layout_ir::LineHeightSpec as IrLineHeightSpec;
422 use lumen_core::components::LineHeightSpec as CoreLineHeightSpec;
423
424 #[test]
431 fn a_slider_with_no_bounds_runs_from_zero_to_one() {
432 let slider = lumen_core::components::SliderValue::from(&Attributes::default());
433 assert_eq!((slider.min, slider.max), (0.0, 1.0));
434 assert_eq!(slider.value, 0.0, "and starts at the bottom of its range");
435 assert_eq!(slider.step, None);
436 }
437
438 #[test]
439 fn a_slider_starts_inside_its_own_bounds() {
440 let attrs = Attributes {
441 min: Some(0.0),
442 max: Some(100.0),
443 value: Some(250.0),
444 ..Attributes::default()
445 };
446 assert_eq!(
447 lumen_core::components::SliderValue::from(&attrs).value,
448 100.0,
449 "an authored value past the top is held to it, not carried"
450 );
451 }
452
453 #[test]
457 fn a_progress_bar_with_no_value_is_indeterminate() {
458 let bar = lumen_primitives::ProgressBar::from(&Attributes::default());
459 assert_eq!(bar.value, None);
460 assert_eq!(bar.max, 1.0);
461 assert_eq!(bar.period_ms, lumen_primitives::PROGRESS_PERIOD_MS);
462 }
463
464 #[test]
472 fn line_height_alone_produces_a_text_style() {
473 let attrs = Attributes {
474 line_height: Some(IrLineHeightSpec::Multiplier(1.5)),
475 ..Attributes::default()
476 };
477 let ts = Option::<TextStyle>::from(&attrs).expect("line-height alone must produce Some");
478 assert_eq!(
479 ts.line_height,
480 Some(CoreLineHeightSpec::Multiplier(1.5)),
481 "the authored value must survive, not just trigger Some(..)"
482 );
483 }
484
485 #[test]
490 fn line_height_px_variant_survives_conversion() {
491 let attrs = Attributes {
492 line_height: Some(IrLineHeightSpec::Px(19.0)),
493 ..Attributes::default()
494 };
495 let ts = Option::<TextStyle>::from(&attrs).expect("line-height alone must produce Some");
496 assert_eq!(ts.line_height, Some(CoreLineHeightSpec::Px(19.0)));
497 }
498
499 #[test]
503 fn no_text_properties_returns_none() {
504 let attrs = Attributes::default();
505 assert!(Option::<TextStyle>::from(&attrs).is_none());
506 }
507
508 #[test]
513 fn line_height_combines_with_other_text_properties() {
514 let attrs = Attributes {
515 font_size: Some(20.0),
516 line_height: Some(IrLineHeightSpec::Multiplier(1.4)),
517 ..Attributes::default()
518 };
519 let ts = Option::<TextStyle>::from(&attrs).expect("must produce Some");
520 assert_eq!(ts.size_px, 20.0);
521 assert_eq!(ts.line_height, Some(CoreLineHeightSpec::Multiplier(1.4)));
522 }
523}