1use super::decoration::{Shadow, TextDecoration};
2use super::font::{FontFamily, FontStyle, FontSynthesis, FontWeight};
3use super::paragraph::{Hyphens, LineBreak, TextAlign, TextDirection, TextIndent};
4use super::unit::TextUnit;
5use crate::modifier::{Brush, Color};
6use cranpose_core::hash::default;
7use cranpose_ui_graphics::RenderHash;
8use std::hash::{Hash, Hasher};
9
10#[derive(Clone, Copy, Debug, PartialEq)]
11pub struct BaselineShift(pub f32);
12
13impl BaselineShift {
14 pub const SUPERSCRIPT: Self = Self(0.5);
15 pub const SUBSCRIPT: Self = Self(-0.5);
16 pub const NONE: Self = Self(0.0);
17 pub const UNSPECIFIED: Self = Self(f32::NAN);
18
19 pub fn is_specified(self) -> bool {
20 !self.0.is_nan()
21 }
22}
23
24#[derive(Clone, Copy, Debug, PartialEq)]
25pub struct TextGeometricTransform {
26 pub scale_x: f32,
27 pub skew_x: f32,
28}
29
30impl Default for TextGeometricTransform {
31 fn default() -> Self {
32 Self {
33 scale_x: 1.0,
34 skew_x: 0.0,
35 }
36 }
37}
38
39#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
40pub struct LocaleList {
41 locales: Vec<String>,
42}
43
44impl LocaleList {
45 pub fn new(locales: Vec<String>) -> Self {
46 Self { locales }
47 }
48
49 pub fn from_language_tags(tags: &str) -> Self {
50 let locales = tags
51 .split(',')
52 .map(str::trim)
53 .filter(|tag| !tag.is_empty())
54 .map(ToString::to_string)
55 .collect();
56 Self { locales }
57 }
58
59 pub fn locales(&self) -> &[String] {
60 &self.locales
61 }
62
63 pub fn is_empty(&self) -> bool {
64 self.locales.is_empty()
65 }
66}
67
68#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
69pub enum LineHeightAlignment {
70 Top,
71 Center,
72 #[default]
73 Proportional,
74 Bottom,
75}
76
77#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
78pub enum LineHeightTrim {
79 FirstLineTop,
80 LastLineBottom,
81 #[default]
82 Both,
83 None,
84}
85
86#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
87pub enum LineHeightMode {
88 #[default]
89 Fixed,
90 Minimum,
91 Tight,
92}
93
94#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
95pub struct LineHeightStyle {
96 pub alignment: LineHeightAlignment,
97 pub trim: LineHeightTrim,
98 pub mode: LineHeightMode,
99}
100
101impl Default for LineHeightStyle {
102 fn default() -> Self {
103 Self {
104 alignment: LineHeightAlignment::Proportional,
105 trim: LineHeightTrim::Both,
106 mode: LineHeightMode::Fixed,
107 }
108 }
109}
110
111#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
112pub enum TextMotion {
113 #[default]
114 Static,
115 Animated,
116}
117
118#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
119pub struct PlatformSpanStyle;
120
121#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
122pub enum TextShaping {
123 Basic,
124 Advanced,
125}
126
127#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
128pub struct PlatformParagraphStyle {
129 pub include_font_padding: Option<bool>,
130 pub shaping: Option<TextShaping>,
131}
132
133#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
134pub struct PlatformTextStyle {
135 pub span_style: Option<PlatformSpanStyle>,
136 pub paragraph_style: Option<PlatformParagraphStyle>,
137}
138
139#[derive(Clone, Copy, Debug, PartialEq, Default)]
140pub enum TextDrawStyle {
141 #[default]
142 Fill,
143 Stroke {
144 width: f32,
145 },
146}
147
148#[derive(Clone, Debug, PartialEq)]
149pub struct SpanStyle {
150 pub color: Option<Color>,
151 pub brush: Option<Brush>,
152 pub alpha: Option<f32>,
153 pub font_size: TextUnit,
154 pub font_weight: Option<FontWeight>,
155 pub font_style: Option<FontStyle>,
156 pub font_synthesis: Option<FontSynthesis>,
157 pub font_family: Option<FontFamily>,
158 pub font_feature_settings: Option<String>,
159 pub letter_spacing: TextUnit,
160 pub baseline_shift: Option<BaselineShift>,
161 pub text_geometric_transform: Option<TextGeometricTransform>,
162 pub locale_list: Option<LocaleList>,
163 pub background: Option<Color>,
164 pub text_decoration: Option<TextDecoration>,
165 pub shadow: Option<Shadow>,
166 pub platform_style: Option<PlatformSpanStyle>,
167 pub draw_style: Option<TextDrawStyle>,
168}
169
170impl Default for SpanStyle {
171 fn default() -> Self {
172 Self {
173 color: None,
174 brush: None,
175 alpha: None,
176 font_size: TextUnit::Unspecified,
177 font_weight: None,
178 font_style: None,
179 font_synthesis: None,
180 font_family: None,
181 font_feature_settings: None,
182 letter_spacing: TextUnit::Unspecified,
183 baseline_shift: None,
184 text_geometric_transform: None,
185 locale_list: None,
186 background: None,
187 text_decoration: None,
188 shadow: None,
189 platform_style: None,
190 draw_style: None,
191 }
192 }
193}
194
195impl SpanStyle {
196 pub fn merge(&self, other: &SpanStyle) -> SpanStyle {
197 let (merged_color, merged_brush) = merge_foreground_style(self, other);
198 SpanStyle {
199 color: merged_color,
200 brush: merged_brush,
201 alpha: other.alpha.or(self.alpha),
202 font_size: merge_text_unit(self.font_size, other.font_size),
203 font_weight: other.font_weight.or(self.font_weight),
204 font_style: other.font_style.or(self.font_style),
205 font_synthesis: other.font_synthesis.or(self.font_synthesis),
206 font_family: other.font_family.clone().or(self.font_family.clone()),
207 font_feature_settings: other
208 .font_feature_settings
209 .clone()
210 .or(self.font_feature_settings.clone()),
211 letter_spacing: merge_text_unit(self.letter_spacing, other.letter_spacing),
212 baseline_shift: other.baseline_shift.or(self.baseline_shift),
213 text_geometric_transform: other
214 .text_geometric_transform
215 .or(self.text_geometric_transform),
216 locale_list: other.locale_list.clone().or(self.locale_list.clone()),
217 background: other.background.or(self.background),
218 text_decoration: other.text_decoration.or(self.text_decoration),
219 shadow: other.shadow.or(self.shadow),
220 platform_style: other.platform_style.or(self.platform_style),
221 draw_style: other.draw_style.or(self.draw_style),
222 }
223 }
224
225 pub fn plus(&self, other: &SpanStyle) -> SpanStyle {
226 self.merge(other)
227 }
228
229 pub fn resolve_font_size(&self, default_size: f32) -> f32 {
230 let fallback = if default_size.is_finite() && default_size > 0.0 {
231 default_size
232 } else {
233 14.0
234 };
235 match self.font_size {
236 TextUnit::Sp(value) if value.is_finite() && value > 0.0 => value,
237 TextUnit::Em(value) if value.is_finite() && value > 0.0 => value * fallback,
238 _ => fallback,
239 }
240 }
241
242 pub fn resolve_foreground_color(&self, default_color: Color) -> Color {
243 let mut color = self
244 .color
245 .or_else(|| solid_brush_color(self.brush.as_ref()))
246 .unwrap_or(default_color);
247 if let Some(alpha) = self.alpha {
248 color.3 *= alpha.clamp(0.0, 1.0);
249 }
250 color
251 }
252
253 pub fn render_hash(&self) -> u64 {
254 let mut hasher = default::new();
255 hash_span_style(self, &mut hasher);
256 hasher.finish()
257 }
258}
259
260#[derive(Clone, Debug, PartialEq)]
261pub struct ParagraphStyle {
262 pub text_align: TextAlign,
263 pub text_direction: TextDirection,
264 pub line_height: TextUnit,
265 pub text_indent: Option<TextIndent>,
266 pub platform_style: Option<PlatformParagraphStyle>,
267 pub line_height_style: Option<LineHeightStyle>,
268 pub line_break: LineBreak,
269 pub hyphens: Hyphens,
270 pub text_motion: Option<TextMotion>,
271}
272
273impl Default for ParagraphStyle {
274 fn default() -> Self {
275 Self {
276 text_align: TextAlign::Unspecified,
277 text_direction: TextDirection::Unspecified,
278 line_height: TextUnit::Unspecified,
279 text_indent: None,
280 platform_style: None,
281 line_height_style: None,
282 line_break: LineBreak::Unspecified,
283 hyphens: Hyphens::Unspecified,
284 text_motion: None,
285 }
286 }
287}
288
289impl ParagraphStyle {
290 pub fn merge(&self, other: &ParagraphStyle) -> ParagraphStyle {
291 ParagraphStyle {
292 text_align: merge_text_align(self.text_align, other.text_align),
293 text_direction: merge_text_direction(self.text_direction, other.text_direction),
294 line_height: merge_text_unit(self.line_height, other.line_height),
295 text_indent: other.text_indent.or(self.text_indent),
296 platform_style: other.platform_style.or(self.platform_style),
297 line_height_style: other.line_height_style.or(self.line_height_style),
298 line_break: merge_line_break(self.line_break, other.line_break),
299 hyphens: merge_hyphens(self.hyphens, other.hyphens),
300 text_motion: other.text_motion.or(self.text_motion),
301 }
302 }
303
304 pub fn plus(&self, other: &ParagraphStyle) -> ParagraphStyle {
305 self.merge(other)
306 }
307
308 pub fn render_hash(&self) -> u64 {
309 let mut hasher = default::new();
310 hash_paragraph_style(self, &mut hasher);
311 hasher.finish()
312 }
313}
314
315#[derive(Clone, Debug, PartialEq, Default)]
316pub struct TextStyle {
317 pub span_style: SpanStyle,
318 pub paragraph_style: ParagraphStyle,
319}
320
321impl TextStyle {
322 pub fn new(span_style: SpanStyle, paragraph_style: ParagraphStyle) -> Self {
323 Self {
324 span_style,
325 paragraph_style,
326 }
327 }
328
329 pub fn from_span_style(span_style: SpanStyle) -> Self {
330 Self::new(span_style, ParagraphStyle::default())
331 }
332
333 pub fn from_paragraph_style(paragraph_style: ParagraphStyle) -> Self {
334 Self::new(SpanStyle::default(), paragraph_style)
335 }
336
337 pub fn merge(&self, other: &TextStyle) -> TextStyle {
338 TextStyle {
339 span_style: self.span_style.merge(&other.span_style),
340 paragraph_style: self.paragraph_style.merge(&other.paragraph_style),
341 }
342 }
343
344 pub fn plus(&self, other: &TextStyle) -> TextStyle {
345 self.merge(other)
346 }
347
348 pub fn to_span_style(&self) -> SpanStyle {
349 self.span_style.clone()
350 }
351
352 pub fn to_paragraph_style(&self) -> ParagraphStyle {
353 self.paragraph_style.clone()
354 }
355
356 pub fn platform_style(&self) -> Option<PlatformTextStyle> {
357 create_platform_text_style(
358 None,
359 self.span_style.platform_style,
360 self.paragraph_style.platform_style,
361 )
362 }
363
364 pub fn with_platform_style(mut self, platform_style: Option<PlatformTextStyle>) -> Self {
365 self.span_style.platform_style = platform_style.and_then(|style| style.span_style);
366 self.paragraph_style.platform_style =
367 platform_style.and_then(|style| style.paragraph_style);
368 self
369 }
370
371 pub fn resolve_font_size(&self, default_size: f32) -> f32 {
372 self.span_style.resolve_font_size(default_size)
373 }
374
375 pub fn resolve_line_height(&self, default_size: f32, natural_line_height: f32) -> f32 {
376 let fallback = if natural_line_height.is_finite() && natural_line_height > 0.0 {
377 natural_line_height
378 } else {
379 self.resolve_font_size(default_size)
380 };
381 match self.paragraph_style.line_height {
382 TextUnit::Sp(value) if value.is_finite() && value > 0.0 => value,
383 TextUnit::Em(value) if value.is_finite() && value > 0.0 => {
384 value * self.resolve_font_size(default_size)
385 }
386 _ => fallback,
387 }
388 }
389
390 pub fn resolve_letter_spacing(&self, default_size: f32) -> f32 {
391 let font_size = self.resolve_font_size(default_size);
392 match self.span_style.letter_spacing {
393 TextUnit::Sp(value) if value.is_finite() => value,
394 TextUnit::Em(value) if value.is_finite() => value * font_size,
395 _ => 0.0,
396 }
397 }
398
399 pub fn resolve_text_color(&self, default_color: Color) -> Color {
400 self.span_style.resolve_foreground_color(default_color)
401 }
402
403 pub fn measurement_hash(&self) -> u64 {
404 let mut hasher = default::new();
405 let span = &self.span_style;
406 let paragraph = &self.paragraph_style;
407
408 hash_text_unit(span.font_size, &mut hasher);
409 span.font_weight.hash(&mut hasher);
410 span.font_style.hash(&mut hasher);
411 span.font_synthesis.hash(&mut hasher);
412 span.font_family.hash(&mut hasher);
413 span.font_feature_settings.hash(&mut hasher);
414 hash_text_unit(span.letter_spacing, &mut hasher);
415 hash_option_baseline_shift(&span.baseline_shift, &mut hasher);
416 hash_option_geometric_transform(&span.text_geometric_transform, &mut hasher);
417 span.locale_list.hash(&mut hasher);
418 span.platform_style.hash(&mut hasher);
419
420 paragraph.text_align.hash(&mut hasher);
421 paragraph.text_direction.hash(&mut hasher);
422 hash_text_unit(paragraph.line_height, &mut hasher);
423 hash_option_text_indent(¶graph.text_indent, &mut hasher);
424 paragraph.platform_style.hash(&mut hasher);
425 paragraph.line_height_style.hash(&mut hasher);
426 paragraph.line_break.hash(&mut hasher);
427 paragraph.hyphens.hash(&mut hasher);
428 paragraph.text_motion.hash(&mut hasher);
429
430 hasher.finish()
431 }
432
433 pub fn render_hash(&self) -> u64 {
434 let mut hasher = default::new();
435 hash_span_style(&self.span_style, &mut hasher);
436 hash_paragraph_style(&self.paragraph_style, &mut hasher);
437 hasher.finish()
438 }
439}
440
441fn merge_foreground_style(
442 current: &SpanStyle,
443 incoming: &SpanStyle,
444) -> (Option<Color>, Option<Brush>) {
445 if let Some(brush) = incoming.brush.clone() {
446 return (None, Some(brush));
447 }
448 if let Some(color) = incoming.color {
449 return (Some(color), None);
450 }
451 (current.color, current.brush.clone())
452}
453
454fn solid_brush_color(brush: Option<&Brush>) -> Option<Color> {
455 match brush {
456 Some(Brush::Solid(color)) => Some(*color),
457 _ => None,
458 }
459}
460
461fn create_platform_text_style(
462 explicit: Option<PlatformTextStyle>,
463 span_style: Option<PlatformSpanStyle>,
464 paragraph_style: Option<PlatformParagraphStyle>,
465) -> Option<PlatformTextStyle> {
466 let explicit_span = explicit.and_then(|style| style.span_style);
467 let explicit_paragraph = explicit.and_then(|style| style.paragraph_style);
468 let span = span_style.or(explicit_span);
469 let paragraph = paragraph_style.or(explicit_paragraph);
470 if span.is_none() && paragraph.is_none() {
471 None
472 } else {
473 Some(PlatformTextStyle {
474 span_style: span,
475 paragraph_style: paragraph,
476 })
477 }
478}
479
480fn merge_text_unit(current: TextUnit, incoming: TextUnit) -> TextUnit {
481 if matches!(incoming, TextUnit::Unspecified) {
482 current
483 } else {
484 incoming
485 }
486}
487
488fn merge_text_align(current: TextAlign, incoming: TextAlign) -> TextAlign {
489 if matches!(incoming, TextAlign::Unspecified) {
490 current
491 } else {
492 incoming
493 }
494}
495
496fn merge_text_direction(current: TextDirection, incoming: TextDirection) -> TextDirection {
497 if matches!(incoming, TextDirection::Unspecified) {
498 current
499 } else {
500 incoming
501 }
502}
503
504fn merge_line_break(current: LineBreak, incoming: LineBreak) -> LineBreak {
505 if matches!(incoming, LineBreak::Unspecified) {
506 current
507 } else {
508 incoming
509 }
510}
511
512fn merge_hyphens(current: Hyphens, incoming: Hyphens) -> Hyphens {
513 if matches!(incoming, Hyphens::Unspecified) {
514 current
515 } else {
516 incoming
517 }
518}
519
520fn hash_f32_bits<H: Hasher>(value: f32, state: &mut H) {
521 value.to_bits().hash(state);
522}
523
524fn hash_option_color<H: Hasher>(color: &Option<Color>, state: &mut H) {
525 match color {
526 Some(color) => {
527 1u8.hash(state);
528 color.render_hash().hash(state);
529 }
530 None => 0u8.hash(state),
531 }
532}
533
534fn hash_option_brush<H: Hasher>(brush: &Option<Brush>, state: &mut H) {
535 match brush {
536 Some(brush) => {
537 1u8.hash(state);
538 brush.render_hash().hash(state);
539 }
540 None => 0u8.hash(state),
541 }
542}
543
544fn hash_option_alpha<H: Hasher>(alpha: &Option<f32>, state: &mut H) {
545 match alpha {
546 Some(alpha) => {
547 1u8.hash(state);
548 hash_f32_bits(*alpha, state);
549 }
550 None => 0u8.hash(state),
551 }
552}
553
554fn hash_text_unit<H: Hasher>(unit: TextUnit, state: &mut H) {
555 match unit {
556 TextUnit::Unspecified => 0u8.hash(state),
557 TextUnit::Sp(value) => {
558 1u8.hash(state);
559 hash_f32_bits(value, state);
560 }
561 TextUnit::Em(value) => {
562 2u8.hash(state);
563 hash_f32_bits(value, state);
564 }
565 }
566}
567
568fn hash_option_baseline_shift<H: Hasher>(shift: &Option<BaselineShift>, state: &mut H) {
569 match shift {
570 Some(shift) => {
571 1u8.hash(state);
572 hash_f32_bits(shift.0, state);
573 }
574 None => 0u8.hash(state),
575 }
576}
577
578fn hash_option_geometric_transform<H: Hasher>(
579 transform: &Option<TextGeometricTransform>,
580 state: &mut H,
581) {
582 match transform {
583 Some(transform) => {
584 1u8.hash(state);
585 hash_f32_bits(transform.scale_x, state);
586 hash_f32_bits(transform.skew_x, state);
587 }
588 None => 0u8.hash(state),
589 }
590}
591
592fn hash_option_text_indent<H: Hasher>(indent: &Option<TextIndent>, state: &mut H) {
593 match indent {
594 Some(indent) => {
595 1u8.hash(state);
596 hash_text_unit(indent.first_line, state);
597 hash_text_unit(indent.rest_line, state);
598 }
599 None => 0u8.hash(state),
600 }
601}
602
603fn hash_option_shadow<H: Hasher>(shadow: &Option<Shadow>, state: &mut H) {
604 match shadow {
605 Some(shadow) => {
606 1u8.hash(state);
607 shadow.color.render_hash().hash(state);
608 hash_f32_bits(shadow.offset.x, state);
609 hash_f32_bits(shadow.offset.y, state);
610 hash_f32_bits(shadow.blur_radius, state);
611 }
612 None => 0u8.hash(state),
613 }
614}
615
616fn hash_option_text_draw_style<H: Hasher>(draw_style: &Option<TextDrawStyle>, state: &mut H) {
617 match draw_style {
618 Some(TextDrawStyle::Fill) => {
619 1u8.hash(state);
620 0u8.hash(state);
621 }
622 Some(TextDrawStyle::Stroke { width }) => {
623 1u8.hash(state);
624 1u8.hash(state);
625 hash_f32_bits(*width, state);
626 }
627 None => 0u8.hash(state),
628 }
629}
630
631fn hash_span_style<H: Hasher>(span: &SpanStyle, state: &mut H) {
632 hash_option_color(&span.color, state);
633 hash_option_brush(&span.brush, state);
634 hash_option_alpha(&span.alpha, state);
635 hash_text_unit(span.font_size, state);
636 span.font_weight.hash(state);
637 span.font_style.hash(state);
638 span.font_synthesis.hash(state);
639 span.font_family.hash(state);
640 span.font_feature_settings.hash(state);
641 hash_text_unit(span.letter_spacing, state);
642 hash_option_baseline_shift(&span.baseline_shift, state);
643 hash_option_geometric_transform(&span.text_geometric_transform, state);
644 span.locale_list.hash(state);
645 hash_option_color(&span.background, state);
646 span.text_decoration.hash(state);
647 hash_option_shadow(&span.shadow, state);
648 span.platform_style.hash(state);
649 hash_option_text_draw_style(&span.draw_style, state);
650}
651
652fn hash_paragraph_style<H: Hasher>(paragraph: &ParagraphStyle, state: &mut H) {
653 paragraph.text_align.hash(state);
654 paragraph.text_direction.hash(state);
655 hash_text_unit(paragraph.line_height, state);
656 hash_option_text_indent(¶graph.text_indent, state);
657 paragraph.platform_style.hash(state);
658 paragraph.line_height_style.hash(state);
659 paragraph.line_break.hash(state);
660 paragraph.hyphens.hash(state);
661 paragraph.text_motion.hash(state);
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667 use crate::modifier::Brush;
668 use crate::text::{FontFamily, TextDirection};
669
670 #[test]
671 fn baseline_shift_reports_specified() {
672 assert!(BaselineShift::SUPERSCRIPT.is_specified());
673 assert!(!BaselineShift::UNSPECIFIED.is_specified());
674 }
675
676 #[test]
677 fn locale_list_parses_language_tags() {
678 let locale_list = LocaleList::from_language_tags("en-US, ar-EG, ja-JP");
679 assert_eq!(locale_list.locales(), &["en-US", "ar-EG", "ja-JP"]);
680 }
681
682 #[test]
683 fn span_style_merge_prefers_incoming_specified_values() {
684 let base = SpanStyle {
685 font_size: TextUnit::Sp(14.0),
686 font_family: Some(FontFamily::Serif),
687 ..Default::default()
688 };
689 let incoming = SpanStyle {
690 font_size: TextUnit::Unspecified,
691 letter_spacing: TextUnit::Em(0.1),
692 ..Default::default()
693 };
694
695 let merged = base.merge(&incoming);
696 assert_eq!(merged.font_size, TextUnit::Sp(14.0));
697 assert_eq!(merged.letter_spacing, TextUnit::Em(0.1));
698 assert_eq!(merged.font_family, Some(FontFamily::Serif));
699 }
700
701 #[test]
702 fn span_style_merge_switches_foreground_kind() {
703 let base = SpanStyle {
704 color: Some(Color(1.0, 0.0, 0.0, 1.0)),
705 ..Default::default()
706 };
707 let incoming = SpanStyle {
708 brush: Some(Brush::solid(Color(0.0, 1.0, 0.0, 1.0))),
709 ..Default::default()
710 };
711
712 let merged = base.merge(&incoming);
713 assert_eq!(merged.color, None);
714 assert_eq!(merged.brush, incoming.brush);
715 }
716
717 #[test]
718 fn span_style_plus_matches_merge() {
719 let base = SpanStyle {
720 font_size: TextUnit::Sp(12.0),
721 ..Default::default()
722 };
723 let incoming = SpanStyle {
724 letter_spacing: TextUnit::Em(0.2),
725 ..Default::default()
726 };
727 assert_eq!(base.plus(&incoming), base.merge(&incoming));
728 }
729
730 #[test]
731 fn paragraph_style_merge_prefers_specified_values() {
732 let base = ParagraphStyle {
733 text_direction: TextDirection::Ltr,
734 line_height: TextUnit::Sp(18.0),
735 ..Default::default()
736 };
737 let incoming = ParagraphStyle {
738 text_direction: TextDirection::Unspecified,
739 line_height: TextUnit::Em(1.4),
740 ..Default::default()
741 };
742
743 let merged = base.merge(&incoming);
744 assert_eq!(merged.text_direction, TextDirection::Ltr);
745 assert_eq!(merged.line_height, TextUnit::Em(1.4));
746 }
747
748 #[test]
749 fn paragraph_style_plus_matches_merge() {
750 let base = ParagraphStyle {
751 text_align: TextAlign::Start,
752 ..Default::default()
753 };
754 let incoming = ParagraphStyle {
755 text_direction: TextDirection::Rtl,
756 ..Default::default()
757 };
758 assert_eq!(base.plus(&incoming), base.merge(&incoming));
759 }
760
761 #[test]
762 fn resolve_font_size_uses_specified_value() {
763 let style = TextStyle::new(
764 SpanStyle {
765 font_size: TextUnit::Sp(18.0),
766 ..Default::default()
767 },
768 ParagraphStyle::default(),
769 );
770 assert_eq!(style.resolve_font_size(14.0), 18.0);
771 }
772
773 #[test]
774 fn resolve_font_size_handles_em_units() {
775 let style = TextStyle::new(
776 SpanStyle {
777 font_size: TextUnit::Em(1.5),
778 ..Default::default()
779 },
780 ParagraphStyle::default(),
781 );
782 assert_eq!(style.resolve_font_size(16.0), 24.0);
783 }
784
785 #[test]
786 fn resolve_line_height_uses_style_value() {
787 let style = TextStyle::new(
788 SpanStyle {
789 font_size: TextUnit::Sp(20.0),
790 ..Default::default()
791 },
792 ParagraphStyle {
793 line_height: TextUnit::Em(1.2),
794 ..Default::default()
795 },
796 );
797 assert_eq!(style.resolve_line_height(14.0, 18.0), 24.0);
798 }
799
800 #[test]
801 fn resolve_foreground_color_supports_solid_brush_with_alpha() {
802 let style = SpanStyle {
803 brush: Some(Brush::solid(Color(0.2, 0.4, 0.6, 1.0))),
804 alpha: Some(0.5),
805 ..Default::default()
806 };
807 assert_eq!(
808 style.resolve_foreground_color(Color(1.0, 1.0, 1.0, 1.0)),
809 Color(0.2, 0.4, 0.6, 0.5)
810 );
811 }
812
813 #[test]
814 fn resolve_foreground_color_keeps_default_color_for_gradient_brush() {
815 let style = SpanStyle {
816 brush: Some(Brush::linear_gradient(vec![
817 Color(0.1, 0.2, 0.3, 1.0),
818 Color(0.9, 0.8, 0.7, 1.0),
819 ])),
820 alpha: Some(0.25),
821 ..Default::default()
822 };
823
824 assert_eq!(
825 style.resolve_foreground_color(Color(1.0, 1.0, 1.0, 1.0)),
826 Color(1.0, 1.0, 1.0, 0.25)
827 );
828 }
829
830 #[test]
831 fn text_style_merge_combines_span_and_paragraph() {
832 let base = TextStyle::new(
833 SpanStyle {
834 font_family: Some(FontFamily::SansSerif),
835 ..Default::default()
836 },
837 ParagraphStyle {
838 text_direction: TextDirection::Ltr,
839 ..Default::default()
840 },
841 );
842 let incoming = TextStyle::new(
843 SpanStyle {
844 letter_spacing: TextUnit::Em(0.2),
845 ..Default::default()
846 },
847 ParagraphStyle {
848 line_height: TextUnit::Sp(22.0),
849 ..Default::default()
850 },
851 );
852
853 let merged = base.merge(&incoming);
854 assert_eq!(merged.span_style.font_family, Some(FontFamily::SansSerif));
855 assert_eq!(merged.span_style.letter_spacing, TextUnit::Em(0.2));
856 assert_eq!(merged.paragraph_style.text_direction, TextDirection::Ltr);
857 assert_eq!(merged.paragraph_style.line_height, TextUnit::Sp(22.0));
858 }
859
860 #[test]
861 fn text_style_from_and_to_style_helpers_work() {
862 let span_style = SpanStyle {
863 font_size: TextUnit::Sp(12.0),
864 ..Default::default()
865 };
866 let from_span = TextStyle::from_span_style(span_style.clone());
867 assert_eq!(from_span.to_span_style(), span_style);
868
869 let paragraph_style = ParagraphStyle {
870 text_direction: TextDirection::Rtl,
871 ..Default::default()
872 };
873 let from_paragraph = TextStyle::from_paragraph_style(paragraph_style.clone());
874 assert_eq!(from_paragraph.to_paragraph_style(), paragraph_style);
875 }
876
877 #[test]
878 fn text_style_plus_matches_merge() {
879 let base = TextStyle::from_span_style(SpanStyle {
880 font_size: TextUnit::Sp(10.0),
881 ..Default::default()
882 });
883 let incoming = TextStyle::from_paragraph_style(ParagraphStyle {
884 text_direction: TextDirection::Ltr,
885 ..Default::default()
886 });
887 assert_eq!(base.plus(&incoming), base.merge(&incoming));
888 }
889
890 #[test]
891 fn text_style_platform_style_helpers_roundtrip() {
892 let style = TextStyle::default().with_platform_style(Some(PlatformTextStyle {
893 span_style: Some(PlatformSpanStyle),
894 paragraph_style: Some(PlatformParagraphStyle {
895 include_font_padding: Some(false),
896 shaping: Some(TextShaping::Basic),
897 }),
898 }));
899 assert_eq!(
900 style.platform_style(),
901 Some(PlatformTextStyle {
902 span_style: Some(PlatformSpanStyle),
903 paragraph_style: Some(PlatformParagraphStyle {
904 include_font_padding: Some(false),
905 shaping: Some(TextShaping::Basic),
906 }),
907 })
908 );
909 }
910
911 #[test]
912 fn measurement_hash_changes_when_measurement_attributes_change() {
913 let style_a = TextStyle::default();
914 let style_b = TextStyle::new(
915 SpanStyle {
916 font_family: Some(FontFamily::SansSerif),
917 ..Default::default()
918 },
919 ParagraphStyle {
920 text_direction: TextDirection::Rtl,
921 ..Default::default()
922 },
923 );
924
925 assert_ne!(style_a.measurement_hash(), style_b.measurement_hash());
926 }
927
928 #[test]
929 fn measurement_hash_includes_platform_style() {
930 let style_a = TextStyle::default();
931 let style_b = TextStyle::new(
932 SpanStyle {
933 platform_style: Some(PlatformSpanStyle),
934 ..Default::default()
935 },
936 ParagraphStyle::default(),
937 );
938 assert_ne!(style_a.measurement_hash(), style_b.measurement_hash());
939 }
940
941 #[test]
942 fn measurement_hash_includes_platform_paragraph_shaping() {
943 let style_a = TextStyle::default();
944 let style_b = TextStyle::from_paragraph_style(ParagraphStyle {
945 platform_style: Some(PlatformParagraphStyle {
946 include_font_padding: None,
947 shaping: Some(TextShaping::Basic),
948 }),
949 ..Default::default()
950 });
951 assert_ne!(style_a.measurement_hash(), style_b.measurement_hash());
952 }
953
954 #[test]
955 fn span_style_render_hash_changes_for_visual_attributes() {
956 let plain = SpanStyle::default();
957 let decorated = SpanStyle {
958 shadow: Some(Shadow {
959 color: Color(1.0, 0.0, 0.0, 0.5),
960 offset: crate::modifier::Point::new(2.0, 3.0),
961 blur_radius: 4.0,
962 }),
963 draw_style: Some(TextDrawStyle::Stroke { width: 2.0 }),
964 ..Default::default()
965 };
966
967 assert_ne!(plain.render_hash(), decorated.render_hash());
968 }
969
970 #[test]
971 fn paragraph_style_render_hash_changes_for_paragraph_attributes() {
972 let base = ParagraphStyle::default();
973 let aligned = ParagraphStyle {
974 text_align: TextAlign::Center,
975 text_direction: TextDirection::Rtl,
976 ..Default::default()
977 };
978
979 assert_ne!(base.render_hash(), aligned.render_hash());
980 }
981
982 #[test]
983 fn text_style_render_hash_includes_visual_attributes() {
984 let base = TextStyle::default();
985 let tinted = TextStyle::from_span_style(SpanStyle {
986 color: Some(Color(0.1, 0.2, 0.3, 1.0)),
987 background: Some(Color(0.9, 0.8, 0.7, 1.0)),
988 ..Default::default()
989 });
990
991 assert_ne!(base.render_hash(), tinted.render_hash());
992 }
993}