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