1use crate::ir::*;
16use crate::palette::Palette;
17use crate::shader::*;
18use crate::state::{EnvelopeKind, UiState};
19use crate::text::atlas::RunStyle;
20use crate::text::metrics as text_metrics;
21use crate::theme::Theme;
22use crate::tokens;
23use crate::tree::*;
24use crate::widgets::text_area::{TEXT_AREA_CARET_LAYER, TEXT_AREA_SELECTION_LAYER};
25
26pub fn draw_ops(root: &El, ui_state: &UiState) -> Vec<DrawOp> {
28 draw_ops_with_theme(root, ui_state, &Theme::default())
29}
30
31pub fn draw_ops_with_theme(root: &El, ui_state: &UiState, theme: &Theme) -> Vec<DrawOp> {
33 let mut stats = DrawOpsStats::default();
34 draw_ops_with_theme_and_stats(root, ui_state, theme, &mut stats)
35}
36
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
38pub struct DrawOpsStats {
39 pub culled_text_ops: u64,
40}
41
42pub fn draw_ops_with_theme_and_stats(
45 root: &El,
46 ui_state: &UiState,
47 theme: &Theme,
48 stats: &mut DrawOpsStats,
49) -> Vec<DrawOp> {
50 let mut out = Vec::new();
51 push_node(
52 root,
53 ui_state,
54 theme,
55 &mut out,
56 None,
57 (0.0, 0.0),
58 1.0,
59 1.0,
60 0.0,
61 0.0,
62 0.0,
63 stats,
64 );
65 resolve_palette(&mut out, theme.palette());
66 out
67}
68
69pub fn resolve_palette(ops: &mut [DrawOp], palette: &Palette) {
79 for op in ops {
80 match op {
81 DrawOp::Quad { uniforms, .. } => {
82 resolve_uniform_block(uniforms, palette);
83 }
84 DrawOp::GlyphRun { color, .. } => {
85 *color = palette.resolve(*color);
86 }
87 DrawOp::AttributedText { runs, .. } => {
88 for (_, style) in runs {
89 style.color = palette.resolve(style.color);
90 if let Some(bg) = &mut style.bg {
91 *bg = palette.resolve(*bg);
92 }
93 }
94 }
95 DrawOp::Icon { color, .. } => {
96 *color = palette.resolve(*color);
97 }
98 DrawOp::Image { tint, .. } => {
99 if let Some(t) = tint {
100 *t = palette.resolve(*t);
101 }
102 }
103 DrawOp::AppTexture { .. } => {}
104 DrawOp::Vector {
105 asset, render_mode, ..
106 } => {
107 *render_mode = render_mode.resolved_palette(palette);
108 if matches!(render_mode, crate::vector::VectorRenderMode::Painted) {
109 *asset = std::sync::Arc::new(asset.resolved_palette(palette));
110 }
111 }
112 DrawOp::BackdropSnapshot => {}
113 }
114 }
115}
116
117fn resolve_uniform_block(uniforms: &mut UniformBlock, palette: &Palette) {
118 let keys: Vec<&'static str> = uniforms
119 .iter()
120 .filter_map(|(k, v)| matches!(v, UniformValue::Color(_)).then_some(*k))
121 .collect();
122 for k in keys {
123 if let Some(UniformValue::Color(c)) = uniforms.get(k).copied() {
124 uniforms.insert(k, UniformValue::Color(palette.resolve(c)));
125 }
126 }
127}
128
129#[allow(clippy::too_many_arguments)]
137fn push_node(
138 n: &El,
139 ui_state: &UiState,
140 theme: &Theme,
141 out: &mut Vec<DrawOp>,
142 inherited_scissor: Option<Rect>,
143 inherited_translate: (f32, f32),
144 inherited_opacity: f32,
145 inherited_focus_envelope: f32,
146 inherited_hover_envelope: f32,
147 inherited_press_envelope: f32,
148 inherited_interaction_envelope: f32,
149 stats: &mut DrawOpsStats,
150) {
151 let computed = ui_state.rect(&n.computed_id);
152 let state = ui_state.node_state(&n.computed_id);
153 let hover_amount = ui_state.envelope(&n.computed_id, EnvelopeKind::Hover);
154 let press_amount = ui_state.envelope(&n.computed_id, EnvelopeKind::Press);
155 let focus_ring_alpha = ui_state.envelope(&n.computed_id, EnvelopeKind::FocusRing);
156
157 let (effective_hover, effective_press) = if n.state_follows_interactive_ancestor {
164 (inherited_hover_envelope, inherited_press_envelope)
165 } else {
166 (hover_amount, press_amount)
167 };
168
169 let (fill, stroke, text_color, weight, suffix) =
170 apply_state(n, state, effective_hover, effective_press, theme.palette());
171
172 let total_translate = (
179 inherited_translate.0 + n.translate.0,
180 inherited_translate.1 + n.translate.1,
181 );
182 let focus_alpha_mul = if n.alpha_follows_focused_ancestor {
188 inherited_focus_envelope
189 } else {
190 1.0
191 };
192 let blink_alpha_mul = if n.blink_when_focused {
197 if ui_state.caret.activity_at.is_some() {
201 ui_state.caret.blink_alpha
202 } else {
203 1.0
204 }
205 } else {
206 1.0
207 };
208 let self_interaction_envelope = ui_state
215 .envelope(&n.computed_id, EnvelopeKind::SubtreeHover)
216 .max(ui_state.envelope(&n.computed_id, EnvelopeKind::SubtreePress))
217 .max(ui_state.envelope(&n.computed_id, EnvelopeKind::SubtreeFocus));
218 let hover_alpha_mul = match n.hover_alpha {
246 Some(cfg) => {
247 let combined = inherited_interaction_envelope.max(self_interaction_envelope);
248 cfg.rest + (cfg.peak - cfg.rest) * combined
249 }
250 None => 1.0,
251 };
252 let opacity =
253 inherited_opacity * n.opacity * focus_alpha_mul * blink_alpha_mul * hover_alpha_mul;
254 let child_focus_envelope = if n.focusable {
261 focus_ring_alpha
262 } else {
263 inherited_focus_envelope
264 };
265 let child_hover_envelope = if n.focusable {
266 hover_amount
267 } else {
268 inherited_hover_envelope
269 };
270 let child_press_envelope = if n.focusable {
271 press_amount
272 } else {
273 inherited_press_envelope
274 };
275 let child_interaction_envelope = if n.focusable {
282 self_interaction_envelope
283 } else {
284 inherited_interaction_envelope
285 };
286
287 let translated_rect = translated(computed, total_translate);
288 let inner_painted_rect = scaled_around_center(translated_rect, n.scale);
299 let painted_font_size = n.font_size * n.scale;
300
301 let own_scissor = if n.clip {
306 intersect_scissor(inherited_scissor, inner_painted_rect)
307 } else {
308 inherited_scissor
309 };
310
311 if matches!(
312 n.kind,
313 Kind::Custom(TEXT_AREA_SELECTION_LAYER) | Kind::Custom(TEXT_AREA_CARET_LAYER)
314 ) {
315 push_text_area_editor_overlay(
316 n,
317 ui_state,
318 theme,
319 out,
320 inner_painted_rect,
321 own_scissor,
322 opacity,
323 inherited_focus_envelope,
324 painted_font_size,
325 weight,
326 );
327 }
328
329 if let Some(custom) = &n.shader_override {
332 let painted_rect = inner_painted_rect.outset(n.paint_overflow);
336 let mut uniforms = custom.uniforms.clone();
337 uniforms.insert("inner_rect", inner_rect_uniform(inner_painted_rect));
338 out.push(DrawOp::Quad {
339 id: n.computed_id.clone(),
340 rect: painted_rect,
341 scissor: own_scissor,
342 shader: custom.handle,
343 uniforms,
344 });
345 } else if fill.is_some() || stroke.is_some() || focus_ring_alpha > 0.0 {
346 let mut uniforms = UniformBlock::new();
347 if let Some(c) = fill {
348 let resolved = match n.dim_fill {
361 Some(dim) => theme.resolve(dim).mix(c, inherited_focus_envelope),
362 None => c,
363 };
364 uniforms.insert("fill", UniformValue::Color(opaque(resolved, opacity)));
365 }
366 if let Some(c) = stroke {
367 uniforms.insert("stroke", UniformValue::Color(opaque(c, opacity)));
368 uniforms.insert("stroke_width", UniformValue::F32(n.stroke_width));
369 }
370 uniforms.insert("radius", UniformValue::F32(n.radius.max()));
376 uniforms.insert("radii", UniformValue::Vec4(n.radius.to_array()));
377 if n.shadow > 0.0 {
378 uniforms.insert("shadow", UniformValue::F32(n.shadow));
379 }
380 uniforms.insert("inner_rect", inner_rect_uniform(inner_painted_rect));
381 if n.focusable && focus_ring_alpha > 0.0 {
388 let base = tokens::RING;
389 let eased_alpha = (base.a as f32 * focus_ring_alpha * opacity)
390 .round()
391 .clamp(0.0, 255.0) as u8;
392 uniforms.insert(
393 "focus_color",
394 UniformValue::Color(base.with_alpha(eased_alpha)),
395 );
396 let focus_width = match n.focus_ring_placement {
397 FocusRingPlacement::Outside => tokens::RING_WIDTH,
398 FocusRingPlacement::Inside => -tokens::RING_WIDTH,
399 };
400 uniforms.insert("focus_width", UniformValue::F32(focus_width));
401 }
402 theme.apply_surface_uniforms(n.surface_role, &mut uniforms);
403 let effective_shadow = match uniforms.get("shadow") {
407 Some(UniformValue::F32(s)) => *s,
408 _ => 0.0,
409 };
410 let effective_stroke_width = if uniforms.contains_key("stroke") {
411 match uniforms.get("stroke_width") {
412 Some(UniformValue::F32(w)) => *w,
413 _ => 0.0,
414 }
415 } else {
416 0.0
417 };
418 let focus_width = if n.focusable
419 && focus_ring_alpha > 0.0
420 && matches!(n.focus_ring_placement, FocusRingPlacement::Outside)
421 {
422 tokens::RING_WIDTH
423 } else {
424 0.0
425 };
426 let painted_rect = inner_painted_rect.outset(combined_overflow(
427 n.paint_overflow,
428 effective_shadow,
429 effective_stroke_width,
430 focus_width,
431 ));
432 out.push(DrawOp::Quad {
433 id: n.computed_id.clone(),
434 rect: painted_rect,
435 scissor: own_scissor,
436 shader: theme.surface_handle(n.surface_role),
437 uniforms,
438 });
439 }
440
441 if let Some(text) = &n.text {
442 let glyph_rect = inner_painted_rect.inset(n.padding);
449 if !rect_visible_in_scissor(glyph_rect, own_scissor) {
450 stats.culled_text_ops += 1;
451 } else {
452 let display = match suffix {
453 Some(s) => format!("{text}{s}"),
454 None => text.clone(),
455 };
456 let display = match (n.text_wrap, n.text_max_lines) {
457 (TextWrap::Wrap, Some(max_lines)) => text_metrics::clamp_text_to_lines_with_family(
458 &display,
459 painted_font_size,
460 n.font_family,
461 weight,
462 n.font_mono,
463 glyph_rect.w,
464 max_lines,
465 ),
466 _ => display,
467 };
468 let display = match (n.text_wrap, n.text_overflow) {
469 (TextWrap::NoWrap, TextOverflow::Ellipsis) => {
470 text_metrics::ellipsize_text_with_family(
471 &display,
472 painted_font_size,
473 n.font_family,
474 weight,
475 n.font_mono,
476 glyph_rect.w,
477 )
478 }
479 _ => display,
480 };
481 let anchor = match n.text_align {
482 TextAlign::Start => TextAnchor::Start,
483 TextAlign::Center => TextAnchor::Middle,
484 TextAlign::End => TextAnchor::End,
485 };
486 let text_color = opaque(text_color.unwrap_or(tokens::FOREGROUND), opacity);
487 let layout = text_metrics::layout_text_with_line_height_and_family(
488 &display,
489 painted_font_size,
490 n.line_height * n.scale,
491 n.font_family,
492 weight,
493 n.font_mono,
494 n.text_wrap,
495 match n.text_wrap {
496 TextWrap::NoWrap => None,
497 TextWrap::Wrap => Some(glyph_rect.w),
498 },
499 );
500
501 push_selection_bands_for_text(
502 n,
503 ui_state,
504 out,
505 glyph_rect,
506 own_scissor,
507 opacity,
508 &display,
509 painted_font_size,
510 effective_text_family(n),
511 weight,
512 n.text_wrap,
513 );
514
515 out.push(DrawOp::GlyphRun {
516 id: n.computed_id.clone(),
517 rect: glyph_rect,
518 scissor: own_scissor,
519 shader: ShaderHandle::Stock(StockShader::Text),
520 color: text_color,
521 text: display,
522 size: painted_font_size,
523 line_height: n.line_height * n.scale,
524 family: n.font_family,
525 mono_family: n.mono_font_family,
526 weight,
527 mono: n.font_mono,
528 wrap: n.text_wrap,
529 anchor,
530 layout,
531 underline: n.text_underline,
532 strikethrough: n.text_strikethrough,
533 link: n.text_link.clone(),
534 });
535 }
536 }
537
538 if let Some(source) = &n.icon {
539 let color = opaque(text_color.unwrap_or(tokens::FOREGROUND), opacity);
540 let inner = inner_painted_rect.inset(n.padding);
541 let icon_size = painted_font_size.min(inner.w).min(inner.h).max(1.0);
542 let icon_rect = Rect::new(
543 inner.center_x() - icon_size * 0.5,
544 inner.center_y() - icon_size * 0.5,
545 icon_size,
546 icon_size,
547 );
548 out.push(DrawOp::Icon {
549 id: n.computed_id.clone(),
550 rect: icon_rect,
551 scissor: own_scissor,
552 source: source.clone(),
553 color,
554 size: icon_size,
555 stroke_width: n.icon_stroke_width * n.scale,
556 });
557 }
558
559 if let Some(image) = &n.image {
560 let inner = inner_painted_rect.inset(n.padding);
561 let dest = n.image_fit.project(image.width(), image.height(), inner);
562 let scissor = intersect_scissor(own_scissor, inner);
571 let tint = n.image_tint.map(|c| opaque(c, opacity));
572 out.push(DrawOp::Image {
573 id: n.computed_id.clone(),
574 rect: dest,
575 scissor,
576 image: image.clone(),
577 tint,
578 radius: n.radius,
579 fit: n.image_fit,
580 });
581 }
582
583 if let Some(crate::surface::SurfaceSource::Texture(tex)) = &n.surface_source {
584 let inner = inner_painted_rect.inset(n.padding);
585 let (tw, th) = tex.size_px();
586 let dest = n.surface_fit.project(tw, th, inner);
587 let scissor = intersect_scissor(own_scissor, inner);
595 out.push(DrawOp::AppTexture {
596 id: n.computed_id.clone(),
597 rect: dest,
598 scissor,
599 texture: tex.clone(),
600 alpha: n.surface_alpha,
601 fit: n.surface_fit,
602 transform: n.surface_transform,
603 });
604 }
605
606 if let Some(asset) = &n.vector_source {
607 let inner = inner_painted_rect.inset(n.padding);
608 let scissor = intersect_scissor(own_scissor, inner);
611 out.push(DrawOp::Vector {
612 id: n.computed_id.clone(),
613 rect: inner,
614 scissor,
615 asset: asset.clone(),
616 render_mode: n.vector_render_mode,
617 });
618 }
619
620 if matches!(n.kind, Kind::Math) {
621 if let Some(source) = &n.selection_source {
622 push_atomic_selection_band(
623 n,
624 ui_state,
625 out,
626 inner_painted_rect.inset(n.padding),
627 own_scissor,
628 opacity,
629 source.visible_len(),
630 );
631 }
632 if let Some(expr) = &n.math {
633 push_math_ops(
634 n,
635 expr,
636 inner_painted_rect.inset(n.padding),
637 own_scissor,
638 opacity,
639 out,
640 );
641 }
642 return;
643 }
644
645 if matches!(n.kind, Kind::Inlines) {
651 let glyph_rect = inner_painted_rect.inset(n.padding);
652 if !rect_visible_in_scissor(glyph_rect, own_scissor) {
653 stats.culled_text_ops += 1;
654 return;
655 }
656 let inline_size = inline_paragraph_font_size(n) * n.scale;
657 let inline_line_height = inline_paragraph_line_height(n) * n.scale;
658 if n.children.iter().any(|c| matches!(c.kind, Kind::Math)) {
659 push_inline_mixed_ops(n, ui_state, glyph_rect, own_scissor, opacity, out);
660 return;
661 }
662 if let Some(source) = &n.selection_source {
663 if matches!(n.text_wrap, TextWrap::NoWrap) {
664 push_selection_bands_for_inlines(
665 n,
666 ui_state,
667 out,
668 glyph_rect,
669 own_scissor,
670 opacity,
671 &source.visible,
672 inline_size,
673 inline_line_height,
674 );
675 } else {
676 push_selection_bands_for_text(
677 n,
678 ui_state,
679 out,
680 glyph_rect,
681 own_scissor,
682 opacity,
683 &source.visible,
684 inline_size,
685 effective_text_family(n),
686 FontWeight::Regular,
687 n.text_wrap,
688 );
689 }
690 }
691 let runs = collect_inline_runs(n, opacity);
692 let concat: String = runs.iter().map(|(t, _)| t.as_str()).collect();
693 let anchor = match n.text_align {
694 TextAlign::Start => TextAnchor::Start,
695 TextAlign::Center => TextAnchor::Middle,
696 TextAlign::End => TextAnchor::End,
697 };
698 let layout = text_metrics::layout_text_with_line_height_and_family(
699 &concat,
700 inline_size,
701 inline_line_height,
702 n.font_family,
703 FontWeight::Regular,
704 false,
705 n.text_wrap,
706 match n.text_wrap {
707 TextWrap::NoWrap => None,
708 TextWrap::Wrap => Some(glyph_rect.w),
709 },
710 );
711 out.push(DrawOp::AttributedText {
712 id: n.computed_id.clone(),
713 rect: glyph_rect,
714 scissor: own_scissor,
715 shader: ShaderHandle::Stock(StockShader::Text),
716 runs,
717 size: inline_size,
718 line_height: inline_line_height,
719 wrap: n.text_wrap,
720 anchor,
721 layout,
722 });
723 return;
724 }
725
726 for c in &n.children {
727 push_node(
728 c,
729 ui_state,
730 theme,
731 out,
732 own_scissor,
733 total_translate,
734 opacity,
735 child_focus_envelope,
736 child_hover_envelope,
737 child_press_envelope,
738 child_interaction_envelope,
739 stats,
740 );
741 }
742
743 if let Some(thumb_rect) = ui_state.scroll.thumb_rects.get(&n.computed_id) {
754 let active = thumb_is_active(n, ui_state);
755 let visible = if active {
756 let new_w = tokens::SCROLLBAR_THUMB_WIDTH_ACTIVE.max(thumb_rect.w);
757 Rect::new(
758 thumb_rect.right() - new_w,
759 thumb_rect.y,
760 new_w,
761 thumb_rect.h,
762 )
763 } else {
764 *thumb_rect
765 };
766 let painted_thumb = translated(visible, total_translate);
767 let base_fill = if active {
768 tokens::SCROLLBAR_THUMB_FILL_ACTIVE
769 } else {
770 tokens::SCROLLBAR_THUMB_FILL
771 };
772 let mut uniforms = UniformBlock::new();
773 uniforms.insert("fill", UniformValue::Color(opaque(base_fill, opacity)));
774 uniforms.insert("radius", UniformValue::F32(visible.w.min(visible.h) * 0.5));
775 uniforms.insert("inner_rect", inner_rect_uniform(painted_thumb));
776 out.push(DrawOp::Quad {
777 id: format!("{}.scrollbar-thumb", n.computed_id),
778 rect: painted_thumb,
779 scissor: own_scissor,
780 shader: ShaderHandle::Stock(StockShader::RoundedRect),
781 uniforms,
782 });
783 }
784}
785
786fn push_math_ops(
787 n: &El,
788 expr: &crate::math::MathExpr,
789 rect: Rect,
790 scissor: Option<Rect>,
791 opacity: f32,
792 out: &mut Vec<DrawOp>,
793) {
794 let layout = crate::math::layout_math(expr, n.font_size * n.scale, n.math_display);
795 let origin_x = match n.math_display {
796 crate::math::MathDisplay::Inline => rect.x,
797 crate::math::MathDisplay::Block => rect.x + ((rect.w - layout.width) * 0.5).max(0.0),
798 };
799 let baseline_y = rect.y + layout.ascent;
800 let color = opaque(crate::math::resolved_math_color(n.text_color), opacity);
801 for (i, atom) in layout.atoms.iter().enumerate() {
802 match atom {
803 crate::math::MathAtom::Glyph {
804 text,
805 x,
806 y_baseline,
807 size,
808 weight,
809 ..
810 } => {
811 let glyph_layout = crate::math::math_glyph_layout(text, *size, *weight);
812 let glyph_baseline = glyph_layout
813 .lines
814 .first()
815 .map(|line| line.baseline)
816 .unwrap_or_else(|| crate::text::metrics::line_height(*size) * 0.75);
817 let glyph_rect = Rect::new(
818 origin_x + x,
819 baseline_y + y_baseline - glyph_baseline,
820 glyph_layout.width,
821 glyph_layout.height,
822 );
823 out.push(DrawOp::GlyphRun {
824 id: format!("{}.math-glyph.{i}", n.computed_id),
825 rect: glyph_rect,
826 scissor,
827 shader: ShaderHandle::Stock(StockShader::Text),
828 color,
829 text: text.clone(),
830 size: *size,
831 line_height: crate::text::metrics::line_height(*size),
832 family: n.font_family,
833 mono_family: n.mono_font_family,
834 weight: *weight,
835 mono: false,
836 wrap: TextWrap::NoWrap,
837 anchor: TextAnchor::Start,
838 layout: glyph_layout,
839 underline: false,
840 strikethrough: false,
841 link: None,
842 });
843 }
844 crate::math::MathAtom::GlyphId {
845 glyph_id,
846 rect,
847 view_box,
848 } => {
849 push_math_glyph_id_op(
850 n, *glyph_id, *rect, *view_box, origin_x, baseline_y, scissor, color, i, out,
851 );
852 }
853 crate::math::MathAtom::Rule { rect: atom_rect } => {
854 let rule_rect = Rect::new(
855 origin_x + atom_rect.x,
856 baseline_y + atom_rect.y,
857 atom_rect.w,
858 atom_rect.h,
859 );
860 let mut uniforms = UniformBlock::new();
861 uniforms.insert("fill", UniformValue::Color(color));
862 uniforms.insert("radius", UniformValue::F32(0.0));
863 uniforms.insert("inner_rect", inner_rect_uniform(rule_rect));
864 out.push(DrawOp::Quad {
865 id: format!("{}.math-rule.{i}", n.computed_id),
866 rect: rule_rect,
867 scissor,
868 shader: ShaderHandle::Stock(StockShader::RoundedRect),
869 uniforms,
870 });
871 }
872 crate::math::MathAtom::Radical { points, thickness } => {
873 push_math_radical_op(
874 n, points, *thickness, origin_x, baseline_y, scissor, color, i, out,
875 );
876 }
877 crate::math::MathAtom::Delimiter {
878 delimiter,
879 rect,
880 thickness,
881 } => {
882 push_math_delimiter_op(
883 n, delimiter, *rect, *thickness, origin_x, baseline_y, scissor, color, i, out,
884 );
885 }
886 }
887 }
888}
889
890#[allow(clippy::too_many_arguments)]
891fn push_math_glyph_id_op(
892 n: &El,
893 glyph_id: u16,
894 atom_rect: Rect,
895 view_box: Rect,
896 origin_x: f32,
897 baseline_y: f32,
898 scissor: Option<Rect>,
899 color: Color,
900 atom_index: usize,
901 out: &mut Vec<DrawOp>,
902) {
903 use crate::vector::VectorRenderMode;
904
905 let Some(asset) = math_glyph_vector_asset(glyph_id, view_box) else {
906 return;
907 };
908 out.push(DrawOp::Vector {
909 id: format!("{}.math-glyph-id.{atom_index}", n.computed_id),
910 rect: Rect::new(
911 origin_x + atom_rect.x,
912 baseline_y + atom_rect.y,
913 atom_rect.w,
914 atom_rect.h,
915 ),
916 scissor,
917 asset: std::sync::Arc::new(asset),
918 render_mode: VectorRenderMode::Mask { color },
919 });
920}
921
922fn math_glyph_vector_asset(glyph_id: u16, view_box: Rect) -> Option<crate::vector::VectorAsset> {
923 use crate::vector::{
924 VectorAsset, VectorColor, VectorFill, VectorFillRule, VectorPath, VectorSegment,
925 };
926
927 const MAX_SOURCE_DIM: f32 = 24.0;
928
929 struct Outline {
930 segments: Vec<VectorSegment>,
931 }
932
933 impl ttf_parser::OutlineBuilder for Outline {
934 fn move_to(&mut self, x: f32, y: f32) {
935 self.segments.push(VectorSegment::MoveTo([x, -y]));
936 }
937
938 fn line_to(&mut self, x: f32, y: f32) {
939 self.segments.push(VectorSegment::LineTo([x, -y]));
940 }
941
942 fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
943 self.segments
944 .push(VectorSegment::QuadTo([x1, -y1], [x, -y]));
945 }
946
947 fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
948 self.segments
949 .push(VectorSegment::CubicTo([x1, -y1], [x2, -y2], [x, -y]));
950 }
951
952 fn close(&mut self) {
953 self.segments.push(VectorSegment::Close);
954 }
955 }
956
957 let Ok(face) = ttf_parser::Face::parse(aetna_fonts::NOTO_SANS_MATH_REGULAR, 0) else {
958 return None;
959 };
960 let mut outline = Outline {
961 segments: Vec::new(),
962 };
963 let _ = face.outline_glyph(ttf_parser::GlyphId(glyph_id), &mut outline)?;
964 if outline.segments.is_empty() {
965 return None;
966 }
967 if view_box.w <= 0.0 || view_box.h <= 0.0 {
968 return None;
969 }
970 let scale = MAX_SOURCE_DIM / view_box.w.max(view_box.h);
971 normalize_vector_segments(&mut outline.segments, view_box, scale);
972 let normalized_view_box = [0.0, 0.0, view_box.w * scale, view_box.h * scale];
973 let path = VectorPath {
974 segments: outline.segments,
975 fill: Some(VectorFill {
976 color: VectorColor::CurrentColor,
977 opacity: 1.0,
978 rule: VectorFillRule::NonZero,
979 }),
980 stroke: None,
981 };
982 Some(VectorAsset::from_paths(normalized_view_box, vec![path]))
983}
984
985fn normalize_vector_segments(
986 segments: &mut [crate::vector::VectorSegment],
987 view_box: Rect,
988 scale: f32,
989) {
990 use crate::vector::VectorSegment;
991
992 let normalize = |point: &mut [f32; 2]| {
993 point[0] = (point[0] - view_box.x) * scale;
994 point[1] = (point[1] - view_box.y) * scale;
995 };
996 for segment in segments {
997 match segment {
998 VectorSegment::MoveTo(point) | VectorSegment::LineTo(point) => normalize(point),
999 VectorSegment::QuadTo(control, point) => {
1000 normalize(control);
1001 normalize(point);
1002 }
1003 VectorSegment::CubicTo(control_a, control_b, point) => {
1004 normalize(control_a);
1005 normalize(control_b);
1006 normalize(point);
1007 }
1008 VectorSegment::Close => {}
1009 }
1010 }
1011}
1012
1013#[allow(clippy::too_many_arguments)]
1014fn push_math_radical_op(
1015 n: &El,
1016 points: &[[f32; 2]; 5],
1017 thickness: f32,
1018 origin_x: f32,
1019 baseline_y: f32,
1020 scissor: Option<Rect>,
1021 color: Color,
1022 atom_index: usize,
1023 out: &mut Vec<DrawOp>,
1024) {
1025 use crate::vector::{PathBuilder, VectorAsset, VectorLineJoin, VectorRenderMode};
1026
1027 let min_x = points.iter().map(|p| p[0]).fold(f32::INFINITY, f32::min);
1028 let max_x = points
1029 .iter()
1030 .map(|p| p[0])
1031 .fold(f32::NEG_INFINITY, f32::max);
1032 let min_y = points.iter().map(|p| p[1]).fold(f32::INFINITY, f32::min);
1033 let max_y = points
1034 .iter()
1035 .map(|p| p[1])
1036 .fold(f32::NEG_INFINITY, f32::max);
1037 let pad = thickness * 0.5;
1038 let local = |p: [f32; 2]| [p[0] - min_x + pad, p[1] - min_y + pad];
1039 let [p0, p1, p2, p3, p4] = points.map(local);
1040 let rect = Rect::new(
1041 origin_x + min_x - pad,
1042 baseline_y + min_y - pad,
1043 max_x - min_x + pad * 2.0,
1044 max_y - min_y + pad * 2.0,
1045 );
1046 let path = PathBuilder::new()
1047 .move_to(p0[0], p0[1])
1048 .line_to(p1[0], p1[1])
1049 .line_to(p2[0], p2[1])
1050 .line_to(p3[0], p3[1])
1051 .line_to(p4[0], p4[1])
1052 .stroke_solid(color, thickness)
1053 .stroke_line_join(VectorLineJoin::Miter)
1054 .build();
1055 let asset = VectorAsset::from_paths([0.0, 0.0, rect.w, rect.h], vec![path]);
1056 out.push(DrawOp::Vector {
1057 id: format!("{}.math-radical.{atom_index}", n.computed_id),
1058 rect,
1059 scissor,
1060 asset: std::sync::Arc::new(asset),
1061 render_mode: VectorRenderMode::Painted,
1062 });
1063}
1064
1065#[allow(clippy::too_many_arguments)]
1066fn push_math_delimiter_op(
1067 n: &El,
1068 delimiter: &str,
1069 atom_rect: Rect,
1070 thickness: f32,
1071 origin_x: f32,
1072 baseline_y: f32,
1073 scissor: Option<Rect>,
1074 color: Color,
1075 atom_index: usize,
1076 out: &mut Vec<DrawOp>,
1077) {
1078 use crate::vector::{
1079 PathBuilder, VectorAsset, VectorLineCap, VectorLineJoin, VectorRenderMode,
1080 };
1081
1082 let pad = thickness * 0.5;
1083 let rect = Rect::new(
1084 origin_x + atom_rect.x - pad,
1085 baseline_y + atom_rect.y - pad,
1086 atom_rect.w + pad * 2.0,
1087 atom_rect.h + pad * 2.0,
1088 );
1089 let w = atom_rect.w;
1090 let h = atom_rect.h;
1091 let x = |v: f32| v + pad;
1092 let y = |v: f32| v + pad;
1093 let base = PathBuilder::new();
1094 let path = match delimiter {
1095 "(" => base.move_to(x(w * 0.86), y(0.0)).cubic_to(
1096 x(w * 0.10),
1097 y(h * 0.10),
1098 x(w * 0.10),
1099 y(h * 0.90),
1100 x(w * 0.86),
1101 y(h),
1102 ),
1103 ")" => base.move_to(x(w * 0.14), y(0.0)).cubic_to(
1104 x(w * 0.90),
1105 y(h * 0.10),
1106 x(w * 0.90),
1107 y(h * 0.90),
1108 x(w * 0.14),
1109 y(h),
1110 ),
1111 "[" => base
1112 .move_to(x(w * 0.88), y(0.0))
1113 .line_to(x(w * 0.12), y(0.0))
1114 .line_to(x(w * 0.12), y(h))
1115 .line_to(x(w * 0.88), y(h)),
1116 "]" => base
1117 .move_to(x(w * 0.12), y(0.0))
1118 .line_to(x(w * 0.88), y(0.0))
1119 .line_to(x(w * 0.88), y(h))
1120 .line_to(x(w * 0.12), y(h)),
1121 "{" => base
1122 .move_to(x(w * 0.86), y(0.0))
1123 .cubic_to(
1124 x(w * 0.20),
1125 y(h * 0.04),
1126 x(w * 0.56),
1127 y(h * 0.39),
1128 x(w * 0.18),
1129 y(h * 0.48),
1130 )
1131 .quad_to(x(w * 0.04), y(h * 0.50), x(w * 0.18), y(h * 0.52))
1132 .cubic_to(
1133 x(w * 0.56),
1134 y(h * 0.61),
1135 x(w * 0.20),
1136 y(h * 0.96),
1137 x(w * 0.86),
1138 y(h),
1139 ),
1140 "}" => base
1141 .move_to(x(w * 0.14), y(0.0))
1142 .cubic_to(
1143 x(w * 0.80),
1144 y(h * 0.04),
1145 x(w * 0.44),
1146 y(h * 0.39),
1147 x(w * 0.82),
1148 y(h * 0.48),
1149 )
1150 .quad_to(x(w * 0.96), y(h * 0.50), x(w * 0.82), y(h * 0.52))
1151 .cubic_to(
1152 x(w * 0.44),
1153 y(h * 0.61),
1154 x(w * 0.80),
1155 y(h * 0.96),
1156 x(w * 0.14),
1157 y(h),
1158 ),
1159 "|" => base.move_to(x(w * 0.5), y(0.0)).line_to(x(w * 0.5), y(h)),
1160 "‖" => base
1161 .move_to(x(w * 0.34), y(0.0))
1162 .line_to(x(w * 0.34), y(h))
1163 .move_to(x(w * 0.66), y(0.0))
1164 .line_to(x(w * 0.66), y(h)),
1165 "⟨" => base
1166 .move_to(x(w * 0.84), y(0.0))
1167 .line_to(x(w * 0.18), y(h * 0.5))
1168 .line_to(x(w * 0.84), y(h)),
1169 "⟩" => base
1170 .move_to(x(w * 0.16), y(0.0))
1171 .line_to(x(w * 0.82), y(h * 0.5))
1172 .line_to(x(w * 0.16), y(h)),
1173 "⌊" => base
1174 .move_to(x(w * 0.18), y(0.0))
1175 .line_to(x(w * 0.18), y(h))
1176 .line_to(x(w * 0.88), y(h)),
1177 "⌋" => base
1178 .move_to(x(w * 0.82), y(0.0))
1179 .line_to(x(w * 0.82), y(h))
1180 .line_to(x(w * 0.12), y(h)),
1181 "⌈" => base
1182 .move_to(x(w * 0.88), y(0.0))
1183 .line_to(x(w * 0.18), y(0.0))
1184 .line_to(x(w * 0.18), y(h)),
1185 "⌉" => base
1186 .move_to(x(w * 0.12), y(0.0))
1187 .line_to(x(w * 0.82), y(0.0))
1188 .line_to(x(w * 0.82), y(h)),
1189 _ => return,
1190 }
1191 .stroke_solid(color, thickness)
1192 .stroke_line_cap(VectorLineCap::Round)
1193 .stroke_line_join(VectorLineJoin::Round)
1194 .build();
1195
1196 let asset = VectorAsset::from_paths([0.0, 0.0, rect.w, rect.h], vec![path]);
1197 out.push(DrawOp::Vector {
1198 id: format!("{}.math-delimiter.{atom_index}", n.computed_id),
1199 rect,
1200 scissor,
1201 asset: std::sync::Arc::new(asset),
1202 render_mode: VectorRenderMode::Painted,
1203 });
1204}
1205
1206fn push_inline_mixed_ops(
1207 n: &El,
1208 ui_state: &UiState,
1209 rect: Rect,
1210 scissor: Option<Rect>,
1211 opacity: f32,
1212 out: &mut Vec<DrawOp>,
1213) {
1214 let mut breaker = crate::text::inline_mixed::MixedInlineBreaker::new(
1215 n.text_wrap,
1216 Some(rect.w),
1217 n.font_size * 0.82,
1218 n.font_size * 0.22,
1219 n.line_height,
1220 );
1221 let mut line_items = Vec::new();
1222 let selected = n.selection_source.as_ref().and_then(|source| {
1223 selection_range_for_node(n, ui_state, source.visible_len()).map(|(lo, hi)| lo..hi)
1224 });
1225 let paint = InlineMixedLinePaint {
1226 parent: n,
1227 rect,
1228 scissor,
1229 opacity,
1230 selected: selected.as_ref(),
1231 };
1232 let mut visible_cursor = 0usize;
1233
1234 let finish_line =
1235 |line_items: &mut Vec<InlineMixedItem>,
1236 out: &mut Vec<DrawOp>,
1237 breaker: &mut crate::text::inline_mixed::MixedInlineBreaker| {
1238 let line = breaker.finish_line();
1239 flush_inline_mixed_line(&paint, line.top, line.ascent, line_items, out);
1240 };
1241
1242 for (i, child) in n.children.iter().enumerate() {
1243 match child.kind {
1244 Kind::HardBreak => {
1245 finish_line(&mut line_items, out, &mut breaker);
1246 visible_cursor += "\n".len();
1247 continue;
1248 }
1249 Kind::Text => {
1250 if let Some(text) = &child.text {
1251 for (chunk_i, chunk) in inline_text_chunks(text).into_iter().enumerate() {
1252 let chunk_visible = visible_cursor..(visible_cursor + chunk.len());
1253 visible_cursor += chunk.len();
1254 let is_space = chunk.chars().all(char::is_whitespace);
1255 if breaker.skips_leading_space(is_space) {
1256 continue;
1257 }
1258 let (w, ascent, descent) = inline_text_chunk_paint_metrics(child, chunk);
1259 if breaker.wraps_before(is_space, w) {
1260 finish_line(&mut line_items, out, &mut breaker);
1261 }
1262 if breaker.skips_overflowing_space(is_space, w) {
1263 continue;
1264 }
1265 if is_space && !matches!(line_items.last(), Some(InlineMixedItem::Text(_)))
1266 {
1267 breaker.push(w, ascent, descent);
1268 continue;
1269 }
1270 push_inline_text_item(
1271 &mut line_items,
1272 child,
1273 i,
1274 chunk_i,
1275 chunk,
1276 chunk_visible,
1277 breaker.x(),
1278 );
1279 breaker.push(w, ascent, descent);
1280 }
1281 }
1282 continue;
1283 }
1284 Kind::Math => {
1285 if let Some(expr) = &child.math {
1286 let layout =
1287 crate::math::layout_math(expr, child.font_size, child.math_display);
1288 if breaker.wraps_before(false, layout.width) {
1289 finish_line(&mut line_items, out, &mut breaker);
1290 }
1291 let width = layout.width;
1292 let ascent = layout.ascent;
1293 let descent = layout.descent;
1294 let visible_len = "\u{fffc}".len();
1295 let visible = visible_cursor..(visible_cursor + visible_len);
1296 visible_cursor += visible_len;
1297 line_items.push(InlineMixedItem::Math {
1298 child: child.clone(),
1299 expr: expr.clone(),
1300 x: breaker.x(),
1301 layout,
1302 visible,
1303 });
1304 breaker.push(width, ascent, descent);
1305 }
1306 }
1307 _ => {
1308 let (w, ascent, descent) = inline_child_paint_metrics(child);
1309 if breaker.wraps_before(false, w) {
1310 finish_line(&mut line_items, out, &mut breaker);
1311 }
1312 breaker.push(w, ascent, descent);
1313 }
1314 }
1315 }
1316 let line = breaker.finish_line();
1317 flush_inline_mixed_line(&paint, line.top, line.ascent, &mut line_items, out);
1318}
1319
1320enum InlineMixedItem {
1321 Text(InlineTextItem),
1322 Math {
1323 child: El,
1324 expr: std::sync::Arc<crate::math::MathExpr>,
1325 x: f32,
1326 layout: crate::math::MathLayout,
1327 visible: std::ops::Range<usize>,
1328 },
1329}
1330
1331struct InlineTextItem {
1332 child: El,
1333 text: String,
1334 x: f32,
1335 child_index: usize,
1336 chunk_index: usize,
1337 visible: std::ops::Range<usize>,
1338}
1339
1340struct InlineMixedLinePaint<'a> {
1341 parent: &'a El,
1342 rect: Rect,
1343 scissor: Option<Rect>,
1344 opacity: f32,
1345 selected: Option<&'a std::ops::Range<usize>>,
1346}
1347
1348fn push_inline_text_item(
1349 items: &mut Vec<InlineMixedItem>,
1350 child: &El,
1351 child_index: usize,
1352 chunk_index: usize,
1353 text: &str,
1354 visible: std::ops::Range<usize>,
1355 x: f32,
1356) {
1357 if text.is_empty() {
1358 return;
1359 }
1360 if let Some(InlineMixedItem::Text(prev)) = items.last_mut()
1361 && same_inline_text_style(&prev.child, child)
1362 {
1363 prev.text.push_str(text);
1364 prev.visible.end = visible.end;
1365 return;
1366 }
1367 items.push(InlineMixedItem::Text(InlineTextItem {
1368 child: child.clone(),
1369 text: text.to_string(),
1370 x,
1371 child_index,
1372 chunk_index,
1373 visible,
1374 }));
1375}
1376
1377fn flush_inline_mixed_line(
1378 paint: &InlineMixedLinePaint<'_>,
1379 line_top: f32,
1380 line_ascent: f32,
1381 items: &mut Vec<InlineMixedItem>,
1382 out: &mut Vec<DrawOp>,
1383) {
1384 let baseline_y = paint.rect.y + line_top + line_ascent;
1385 for item in items.drain(..) {
1386 match item {
1387 InlineMixedItem::Text(item) => {
1388 push_inline_text_chunk(
1389 paint.parent,
1390 &item.child,
1391 &item.text,
1392 item.child_index,
1393 item.chunk_index,
1394 selection_overlap(paint.selected, &item.visible),
1395 paint.rect,
1396 paint.scissor,
1397 paint.opacity,
1398 item.x,
1399 baseline_y,
1400 out,
1401 );
1402 }
1403 InlineMixedItem::Math {
1404 child,
1405 expr,
1406 x,
1407 layout,
1408 visible,
1409 } => {
1410 let math_rect = Rect::new(
1411 paint.rect.x + x,
1412 baseline_y - layout.ascent,
1413 layout.width,
1414 layout.height(),
1415 );
1416 if selection_overlap(paint.selected, &visible).is_some() {
1417 push_selection_band_rect(
1418 paint.parent,
1419 out,
1420 math_rect,
1421 paint.scissor,
1422 paint.opacity,
1423 );
1424 }
1425 push_math_ops(&child, &expr, math_rect, paint.scissor, paint.opacity, out);
1426 }
1427 }
1428 }
1429}
1430
1431fn same_inline_text_style(a: &El, b: &El) -> bool {
1432 a.font_size == b.font_size
1433 && a.line_height == b.line_height
1434 && a.font_family == b.font_family
1435 && a.mono_font_family == b.mono_font_family
1436 && a.font_weight == b.font_weight
1437 && a.font_mono == b.font_mono
1438 && a.text_color == b.text_color
1439 && a.text_underline == b.text_underline
1440 && a.text_strikethrough == b.text_strikethrough
1441 && a.text_link == b.text_link
1442}
1443
1444#[allow(clippy::too_many_arguments)]
1445fn push_inline_text_chunk(
1446 parent: &El,
1447 child: &El,
1448 text: &str,
1449 child_index: usize,
1450 chunk_index: usize,
1451 selected: Option<std::ops::Range<usize>>,
1452 rect: Rect,
1453 scissor: Option<Rect>,
1454 opacity: f32,
1455 x: f32,
1456 baseline_y: f32,
1457 out: &mut Vec<DrawOp>,
1458) {
1459 let size = child.font_size * parent.scale;
1460 let glyph_layout = crate::text::metrics::layout_text_with_line_height_and_family(
1461 text,
1462 size,
1463 child.line_height * parent.scale,
1464 child.font_family,
1465 child.font_weight,
1466 child.font_mono,
1467 TextWrap::NoWrap,
1468 None,
1469 );
1470 let glyph_baseline = glyph_layout
1471 .lines
1472 .first()
1473 .map(|line| line.baseline)
1474 .unwrap_or_else(|| crate::text::metrics::line_height(size) * 0.75);
1475 let glyph_rect = Rect::new(
1476 rect.x + x,
1477 baseline_y - glyph_baseline,
1478 glyph_layout.width,
1479 glyph_layout.height,
1480 );
1481 if let Some(selected) = selected {
1482 let lo = clamp_to_char_boundary(text, selected.start.min(text.len()));
1483 let hi = clamp_to_char_boundary(text, selected.end.min(text.len()));
1484 if lo < hi {
1485 let prefix = &text[..lo];
1486 let slice = &text[lo..hi];
1487 let band_x = glyph_rect.x
1488 + crate::text::metrics::line_width_with_family(
1489 prefix,
1490 size,
1491 child.font_family,
1492 child.font_weight,
1493 child.font_mono,
1494 );
1495 let band_w = crate::text::metrics::line_width_with_family(
1496 slice,
1497 size,
1498 child.font_family,
1499 child.font_weight,
1500 child.font_mono,
1501 );
1502 push_selection_band_rect(
1503 parent,
1504 out,
1505 Rect::new(band_x, glyph_rect.y, band_w, glyph_rect.h),
1506 scissor,
1507 opacity,
1508 );
1509 }
1510 }
1511 let color = opaque(child.text_color.unwrap_or(tokens::FOREGROUND), opacity);
1512 out.push(DrawOp::GlyphRun {
1513 id: format!(
1514 "{}.inline-text.{child_index}.{chunk_index}",
1515 parent.computed_id
1516 ),
1517 rect: glyph_rect,
1518 scissor,
1519 shader: ShaderHandle::Stock(StockShader::Text),
1520 color,
1521 text: text.to_string(),
1522 size,
1523 line_height: child.line_height * parent.scale,
1524 family: child.font_family,
1525 mono_family: child.mono_font_family,
1526 weight: child.font_weight,
1527 mono: child.font_mono,
1528 wrap: TextWrap::NoWrap,
1529 anchor: TextAnchor::Start,
1530 layout: glyph_layout,
1531 underline: child.text_underline || child.text_link.is_some(),
1532 strikethrough: child.text_strikethrough,
1533 link: child.text_link.clone(),
1534 });
1535}
1536
1537fn inline_text_chunks(text: &str) -> Vec<&str> {
1538 let mut chunks = Vec::new();
1539 let mut start = 0;
1540 let mut last_space = None;
1541 for (i, ch) in text.char_indices() {
1542 let is_space = ch.is_whitespace();
1543 match last_space {
1544 None => last_space = Some(is_space),
1545 Some(prev) if prev != is_space => {
1546 chunks.push(&text[start..i]);
1547 start = i;
1548 last_space = Some(is_space);
1549 }
1550 _ => {}
1551 }
1552 }
1553 if start < text.len() {
1554 chunks.push(&text[start..]);
1555 }
1556 chunks
1557}
1558
1559fn inline_text_chunk_paint_metrics(child: &El, text: &str) -> (f32, f32, f32) {
1560 let layout = crate::text::metrics::layout_text_with_line_height_and_family(
1561 text,
1562 child.font_size,
1563 child.line_height,
1564 child.font_family,
1565 child.font_weight,
1566 child.font_mono,
1567 TextWrap::NoWrap,
1568 None,
1569 );
1570 (layout.width, child.font_size * 0.82, child.font_size * 0.22)
1571}
1572
1573fn inline_child_paint_metrics(child: &El) -> (f32, f32, f32) {
1574 match child.kind {
1575 Kind::Text => inline_text_chunk_paint_metrics(child, child.text.as_deref().unwrap_or("")),
1576 Kind::Math => {
1577 if let Some(expr) = &child.math {
1578 let layout = crate::math::layout_math(expr, child.font_size, child.math_display);
1579 (layout.width, layout.ascent, layout.descent)
1580 } else {
1581 (0.0, 0.0, 0.0)
1582 }
1583 }
1584 _ => (0.0, 0.0, 0.0),
1585 }
1586}
1587
1588fn thumb_is_active(n: &El, ui_state: &UiState) -> bool {
1594 if let Some(drag) = ui_state.scroll.thumb_drag.as_ref()
1595 && drag.scroll_id == n.computed_id
1596 {
1597 return true;
1598 }
1599 if let (Some((px, py)), Some(track)) = (
1600 ui_state.pointer_pos,
1601 ui_state.scroll.thumb_tracks.get(&n.computed_id),
1602 ) {
1603 return track.contains(px, py);
1604 }
1605 false
1606}
1607
1608fn collect_inline_runs(node: &El, opacity: f32) -> Vec<(String, RunStyle)> {
1615 let mut runs: Vec<(String, RunStyle)> = Vec::with_capacity(node.children.len());
1616 for c in &node.children {
1617 match c.kind {
1618 Kind::Text => {
1619 if let Some(text) = &c.text {
1620 let color = opaque(c.text_color.unwrap_or(tokens::FOREGROUND), opacity);
1621 let mut style = RunStyle::new(c.font_weight, color)
1622 .family(c.font_family)
1623 .mono_family(c.mono_font_family);
1624 if c.text_italic {
1625 style = style.italic();
1626 }
1627 if c.font_mono {
1628 style = style.mono();
1629 }
1630 if let Some(bg) = c.text_bg {
1631 style = style.with_bg(opaque(bg, opacity));
1632 }
1633 if let Some(url) = &c.text_link {
1634 style = style.with_link(url.clone());
1639 }
1640 if c.text_underline {
1641 style = style.underline();
1642 }
1643 if c.text_strikethrough {
1644 style = style.strikethrough();
1645 }
1646 runs.push((text.clone(), style));
1647 }
1648 }
1649 Kind::HardBreak => {
1650 runs.push((
1651 "\n".to_string(),
1652 RunStyle::new(FontWeight::Regular, tokens::FOREGROUND),
1653 ));
1654 }
1655 _ => {}
1656 }
1657 }
1658 runs
1659}
1660
1661fn inline_paragraph_font_size(node: &El) -> f32 {
1666 let mut size: f32 = node.font_size;
1667 for c in &node.children {
1668 if matches!(c.kind, Kind::Text) {
1669 size = size.max(c.font_size);
1670 }
1671 }
1672 size
1673}
1674
1675fn inline_paragraph_line_height(node: &El) -> f32 {
1676 let mut line_height: f32 = node.line_height;
1677 let mut max_size: f32 = node.font_size;
1678 for c in &node.children {
1679 if matches!(c.kind, Kind::Text) && c.font_size >= max_size {
1680 max_size = c.font_size;
1681 line_height = c.line_height;
1682 }
1683 }
1684 line_height
1685}
1686
1687#[allow(clippy::too_many_arguments)]
1688fn push_selection_bands_for_inlines(
1689 n: &El,
1690 ui_state: &UiState,
1691 out: &mut Vec<DrawOp>,
1692 glyph_rect: Rect,
1693 scissor: Option<Rect>,
1694 opacity: f32,
1695 visible: &str,
1696 font_size: f32,
1697 line_height: f32,
1698) {
1699 let Some((lo, hi)) = selection_range_for_node(n, ui_state, visible.len()) else {
1700 return;
1701 };
1702
1703 let mut lines = vec![InlineSelectionLine::default()];
1704 let mut visible_cursor = 0usize;
1705 for child in &n.children {
1706 match child.kind {
1707 Kind::Text => {
1708 let Some(text) = child.text.as_deref() else {
1709 continue;
1710 };
1711 for segment in text.split_inclusive('\n') {
1712 let (segment, hard_break) = segment
1713 .strip_suffix('\n')
1714 .map(|line| (line, true))
1715 .unwrap_or((segment, false));
1716 if !segment.is_empty() {
1717 let line_index = lines.len() - 1;
1718 let x = lines[line_index].width;
1719 let width = inline_selection_run_width(child, segment, font_size);
1720 let end = visible_cursor + segment.len();
1721 lines[line_index].runs.push(InlineSelectionRun {
1722 child: child.clone(),
1723 text: segment.to_string(),
1724 visible: visible_cursor..end,
1725 x,
1726 });
1727 lines[line_index].width += width;
1728 visible_cursor = end;
1729 }
1730 if hard_break {
1731 visible_cursor += "\n".len();
1732 lines.push(InlineSelectionLine::default());
1733 }
1734 }
1735 }
1736 Kind::HardBreak => {
1737 visible_cursor += "\n".len();
1738 lines.push(InlineSelectionLine::default());
1739 }
1740 _ => {}
1741 }
1742 }
1743
1744 for (line_index, line) in lines.into_iter().enumerate() {
1745 let line_x = match n.text_align {
1746 TextAlign::Start => 0.0,
1747 TextAlign::Center => (glyph_rect.w - line.width).max(0.0) * 0.5,
1748 TextAlign::End => (glyph_rect.w - line.width).max(0.0),
1749 };
1750 let line_y = line_index as f32 * line_height;
1751 for run in line.runs {
1752 let Some(selected) = selection_overlap(Some(&(lo..hi)), &run.visible) else {
1753 continue;
1754 };
1755 let lo = clamp_to_char_boundary(&run.text, selected.start.min(run.text.len()));
1756 let hi = clamp_to_char_boundary(&run.text, selected.end.min(run.text.len()));
1757 if lo >= hi {
1758 continue;
1759 }
1760 let prefix = &run.text[..lo];
1761 let slice = &run.text[lo..hi];
1762 let band_x = glyph_rect.x
1763 + line_x
1764 + run.x
1765 + inline_selection_run_width(&run.child, prefix, font_size);
1766 let band_w = inline_selection_run_width(&run.child, slice, font_size);
1767 push_selection_band_rect(
1768 n,
1769 out,
1770 Rect::new(band_x, glyph_rect.y + line_y, band_w, line_height),
1771 scissor,
1772 opacity,
1773 );
1774 }
1775 }
1776}
1777
1778#[derive(Default)]
1779struct InlineSelectionLine {
1780 width: f32,
1781 runs: Vec<InlineSelectionRun>,
1782}
1783
1784struct InlineSelectionRun {
1785 child: El,
1786 text: String,
1787 visible: std::ops::Range<usize>,
1788 x: f32,
1789}
1790
1791fn inline_selection_run_width(child: &El, text: &str, font_size: f32) -> f32 {
1792 text_metrics::line_width_with_family(
1793 text,
1794 font_size,
1795 child.font_family,
1796 child.font_weight,
1797 child.font_mono,
1798 )
1799}
1800
1801#[allow(clippy::too_many_arguments)]
1802fn push_selection_bands_for_text(
1803 n: &El,
1804 ui_state: &UiState,
1805 out: &mut Vec<DrawOp>,
1806 glyph_rect: Rect,
1807 scissor: Option<Rect>,
1808 opacity: f32,
1809 display: &str,
1810 font_size: f32,
1811 family: FontFamily,
1812 weight: FontWeight,
1813 wrap: TextWrap,
1814) {
1815 if n.selectable
1820 && let Some(key) = &n.key
1821 && let Some((lo, hi)) = crate::selection::slice_for_leaf(
1822 &ui_state.current_selection,
1823 &ui_state.selection.order,
1824 key,
1825 display.len(),
1826 )
1827 {
1828 let rects = text_metrics::selection_rects_with_family(
1829 display,
1830 lo,
1831 hi,
1832 font_size,
1833 family,
1834 weight,
1835 wrap,
1836 match wrap {
1837 TextWrap::NoWrap => None,
1838 TextWrap::Wrap => Some(glyph_rect.w),
1839 },
1840 );
1841 for (rx, ry, rw, rh) in rects {
1842 let band = Rect::new(glyph_rect.x + rx, glyph_rect.y + ry, rw, rh);
1843 let mut band_uniforms = UniformBlock::new();
1844 band_uniforms.insert(
1845 "fill",
1846 UniformValue::Color(opaque(tokens::SELECTION_BG, opacity)),
1847 );
1848 band_uniforms.insert("radius", UniformValue::F32(2.0));
1849 band_uniforms.insert("inner_rect", inner_rect_uniform(band));
1850 out.push(DrawOp::Quad {
1851 id: format!("{}.selection-band", n.computed_id),
1852 rect: band,
1853 scissor,
1854 shader: ShaderHandle::Stock(StockShader::RoundedRect),
1855 uniforms: band_uniforms,
1856 });
1857 }
1858 }
1859}
1860
1861fn effective_text_family(n: &El) -> FontFamily {
1862 if n.font_mono {
1863 n.mono_font_family
1864 } else {
1865 n.font_family
1866 }
1867}
1868
1869fn selection_range_for_node(
1870 n: &El,
1871 ui_state: &UiState,
1872 visible_len: usize,
1873) -> Option<(usize, usize)> {
1874 let key = n.key.as_ref()?;
1875 crate::selection::slice_for_leaf(
1876 &ui_state.current_selection,
1877 &ui_state.selection.order,
1878 key,
1879 visible_len,
1880 )
1881}
1882
1883fn selection_overlap(
1884 selected: Option<&std::ops::Range<usize>>,
1885 item: &std::ops::Range<usize>,
1886) -> Option<std::ops::Range<usize>> {
1887 let selected = selected?;
1888 let start = selected.start.max(item.start);
1889 let end = selected.end.min(item.end);
1890 if start < end {
1891 Some((start - item.start)..(end - item.start))
1892 } else {
1893 None
1894 }
1895}
1896
1897fn push_selection_band_rect(
1898 n: &El,
1899 out: &mut Vec<DrawOp>,
1900 rect: Rect,
1901 scissor: Option<Rect>,
1902 opacity: f32,
1903) {
1904 let mut band_uniforms = UniformBlock::new();
1905 band_uniforms.insert(
1906 "fill",
1907 UniformValue::Color(opaque(tokens::SELECTION_BG, opacity)),
1908 );
1909 band_uniforms.insert("radius", UniformValue::F32(4.0));
1910 band_uniforms.insert("inner_rect", inner_rect_uniform(rect));
1911 out.push(DrawOp::Quad {
1912 id: format!("{}.selection-band", n.computed_id),
1913 rect,
1914 scissor,
1915 shader: ShaderHandle::Stock(StockShader::RoundedRect),
1916 uniforms: band_uniforms,
1917 });
1918}
1919
1920fn push_atomic_selection_band(
1921 n: &El,
1922 ui_state: &UiState,
1923 out: &mut Vec<DrawOp>,
1924 rect: Rect,
1925 scissor: Option<Rect>,
1926 opacity: f32,
1927 visible_len: usize,
1928) {
1929 if visible_len == 0 {
1930 return;
1931 }
1932 if n.selectable
1933 && let Some(key) = &n.key
1934 && crate::selection::slice_for_leaf(
1935 &ui_state.current_selection,
1936 &ui_state.selection.order,
1937 key,
1938 visible_len,
1939 )
1940 .is_some()
1941 {
1942 push_selection_band_rect(n, out, rect, scissor, opacity);
1943 }
1944}
1945
1946fn clamp_to_char_boundary(text: &str, byte: usize) -> usize {
1947 let mut byte = byte.min(text.len());
1948 while byte > 0 && !text.is_char_boundary(byte) {
1949 byte -= 1;
1950 }
1951 byte
1952}
1953
1954#[allow(clippy::too_many_arguments)]
1955fn push_text_area_editor_overlay(
1956 n: &El,
1957 ui_state: &UiState,
1958 theme: &Theme,
1959 out: &mut Vec<DrawOp>,
1960 rect: Rect,
1961 scissor: Option<Rect>,
1962 opacity: f32,
1963 focus_envelope: f32,
1964 font_size: f32,
1965 weight: FontWeight,
1966) {
1967 let (Some(key), Some(value)) = (n.text_link.as_deref(), n.tooltip.as_deref()) else {
1968 return;
1969 };
1970 let Some(view) = ui_state.current_selection.within(key) else {
1971 return;
1972 };
1973 match n.kind {
1974 Kind::Custom(TEXT_AREA_SELECTION_LAYER) => {
1975 if view.is_collapsed() {
1976 return;
1977 }
1978 let (lo, hi) = view.ordered();
1979 let rects = text_metrics::selection_rects(
1980 value,
1981 lo.min(value.len()),
1982 hi.min(value.len()),
1983 font_size,
1984 weight,
1985 TextWrap::Wrap,
1986 Some(rect.w.max(1.0)),
1987 );
1988 let fill = theme
1989 .resolve(tokens::SELECTION_BG_UNFOCUSED)
1990 .mix(theme.resolve(tokens::SELECTION_BG), focus_envelope);
1991 for (i, (rx, ry, rw, rh)) in rects.into_iter().enumerate() {
1992 let band = Rect::new(rect.x + rx, rect.y + ry, rw, rh);
1993 let mut uniforms = UniformBlock::new();
1994 uniforms.insert("fill", UniformValue::Color(opaque(fill, opacity)));
1995 uniforms.insert("radius", UniformValue::F32(2.0));
1996 uniforms.insert("inner_rect", inner_rect_uniform(band));
1997 out.push(DrawOp::Quad {
1998 id: format!("{}.selection-band.{i}", n.computed_id),
1999 rect: band,
2000 scissor,
2001 shader: ShaderHandle::Stock(StockShader::RoundedRect),
2002 uniforms,
2003 });
2004 }
2005 }
2006 Kind::Custom(TEXT_AREA_CARET_LAYER) => {
2007 let head = view.head.min(value.len());
2008 let (x, y) = text_metrics::caret_xy(
2009 value,
2010 head,
2011 font_size,
2012 weight,
2013 TextWrap::Wrap,
2014 Some(rect.w.max(1.0)),
2015 );
2016 let caret = Rect::new(rect.x + x, rect.y + y, 2.0, tokens::TEXT_SM.line_height);
2017 let mut uniforms = UniformBlock::new();
2018 uniforms.insert(
2019 "fill",
2020 UniformValue::Color(opaque(theme.resolve(tokens::FOREGROUND), opacity)),
2021 );
2022 uniforms.insert("radius", UniformValue::F32(1.0));
2023 uniforms.insert("inner_rect", inner_rect_uniform(caret));
2024 out.push(DrawOp::Quad {
2025 id: format!("{}.caret", n.computed_id),
2026 rect: caret,
2027 scissor,
2028 shader: ShaderHandle::Stock(StockShader::RoundedRect),
2029 uniforms,
2030 });
2031 }
2032 _ => {}
2033 }
2034}
2035
2036fn translated(r: Rect, offset: (f32, f32)) -> Rect {
2037 if offset.0 == 0.0 && offset.1 == 0.0 {
2038 return r;
2039 }
2040 Rect::new(r.x + offset.0, r.y + offset.1, r.w, r.h)
2041}
2042
2043fn combined_overflow(
2058 paint_overflow: Sides,
2059 shadow: f32,
2060 stroke_width: f32,
2061 focus_width: f32,
2062) -> Sides {
2063 let stroke_halo = if stroke_width > 0.0 {
2064 stroke_width * 0.5 + 1.0
2065 } else {
2066 0.0
2067 };
2068 let halo = stroke_halo.max(focus_width);
2069 let stroked = if halo > 0.0 {
2070 Sides {
2071 left: paint_overflow.left.max(halo),
2072 right: paint_overflow.right.max(halo),
2073 top: paint_overflow.top.max(halo),
2074 bottom: paint_overflow.bottom.max(halo),
2075 }
2076 } else {
2077 paint_overflow
2078 };
2079 if shadow <= 0.0 {
2080 return stroked;
2081 }
2082 Sides {
2083 left: stroked.left.max(shadow),
2084 right: stroked.right.max(shadow),
2085 top: stroked.top.max(shadow * 0.5),
2086 bottom: stroked.bottom.max(shadow * 1.5),
2087 }
2088}
2089
2090fn scaled_around_center(r: Rect, s: f32) -> Rect {
2093 if (s - 1.0).abs() < f32::EPSILON {
2094 return r;
2095 }
2096 let cx = r.center_x();
2097 let cy = r.center_y();
2098 let w = r.w * s;
2099 let h = r.h * s;
2100 Rect::new(cx - w * 0.5, cy - h * 0.5, w, h)
2101}
2102
2103fn opaque(c: Color, opacity: f32) -> Color {
2104 if (opacity - 1.0).abs() < f32::EPSILON {
2105 return c;
2106 }
2107 let a = (c.a as f32 * opacity.clamp(0.0, 1.0)).round() as u8;
2108 c.with_alpha(a)
2109}
2110
2111fn apply_state(
2140 n: &El,
2141 state: InteractionState,
2142 hover: f32,
2143 press: f32,
2144 palette: &Palette,
2145) -> (
2146 Option<Color>,
2147 Option<Color>,
2148 Option<Color>,
2149 FontWeight,
2150 Option<&'static str>,
2151) {
2152 let mut fill = n.fill.map(|c| palette.resolve(c));
2158 let mut stroke = n.stroke.map(|c| palette.resolve(c));
2159 let mut text_color = n.text_color.map(|c| palette.resolve(c));
2160 let weight = n.font_weight;
2161 let mut suffix = None;
2162
2163 if hover > 0.0 {
2164 fill = fill.map(|c| c.mix(c.lighten(tokens::HOVER_LIGHTEN), hover));
2165 stroke = stroke.map(|c| c.mix(c.lighten(tokens::HOVER_LIGHTEN), hover));
2166 text_color = text_color.map(|c| c.mix(c.lighten(tokens::HOVER_LIGHTEN * 0.5), hover));
2167 }
2168 if press > 0.0 {
2169 fill = fill.map(|c| c.mix(c.darken(tokens::PRESS_DARKEN), press));
2170 stroke = stroke.map(|c| c.mix(c.darken(tokens::PRESS_DARKEN), press));
2171 text_color = text_color.map(|c| c.mix(c.darken(tokens::PRESS_DARKEN * 0.5), press));
2172 }
2173 if n.fill.is_none()
2174 && (hover > 0.0 || press > 0.0)
2175 && (n.radius.any_nonzero() || n.stroke.is_some())
2176 {
2177 let alpha = (hover * tokens::STATE_FILL_HOVER_ALPHA
2178 + press * tokens::STATE_FILL_PRESS_ALPHA)
2179 .clamp(0.0, 1.0);
2180 fill = Some(tokens::ACCENT.with_alpha((alpha * 255.0).round() as u8));
2183 }
2184
2185 match state {
2186 InteractionState::Default
2187 | InteractionState::Focus
2188 | InteractionState::Hover
2189 | InteractionState::Press => {}
2190 InteractionState::Disabled => {
2191 let alpha = (255.0 * tokens::DISABLED_ALPHA) as u8;
2192 fill = fill.map(|c| c.with_alpha(((c.a as u32 * alpha as u32) / 255) as u8));
2193 stroke = stroke.map(|c| c.with_alpha(((c.a as u32 * alpha as u32) / 255) as u8));
2194 text_color =
2195 text_color.map(|c| c.with_alpha(((c.a as u32 * alpha as u32) / 255) as u8));
2196 }
2197 InteractionState::Loading => {
2198 text_color = text_color.map(|c| c.with_alpha(((c.a as u32 * 200) / 255) as u8));
2199 suffix = Some(" ⋯");
2200 }
2201 }
2202 (fill, stroke, text_color, weight, suffix)
2203}
2204
2205fn inner_rect_uniform(r: Rect) -> UniformValue {
2207 UniformValue::Vec4([r.x, r.y, r.w, r.h])
2208}
2209
2210fn intersect_scissor(current: Option<Rect>, next: Rect) -> Option<Rect> {
2211 match current {
2212 Some(r) => Some(r.intersect(next).unwrap_or(Rect::new(0.0, 0.0, 0.0, 0.0))),
2213 None => Some(next),
2214 }
2215}
2216
2217fn rect_visible_in_scissor(rect: Rect, scissor: Option<Rect>) -> bool {
2218 if rect.w <= 0.0 || rect.h <= 0.0 {
2219 return false;
2220 }
2221 match scissor {
2222 Some(clip) => rect.intersect(clip).is_some(),
2223 None => true,
2224 }
2225}
2226
2227#[cfg(test)]
2228mod tests {
2229 use super::*;
2230 use crate::state::UiState;
2231 use crate::{button, column, row};
2232
2233 #[test]
2234 fn ghost_surface_synthesizes_state_fill_for_hover_and_press() {
2235 let ghost = El::new(Kind::Custom("tab_trigger"))
2245 .ghost()
2246 .radius(tokens::RADIUS_SM);
2247 assert!(ghost.fill.is_none(), "ghost has no resting fill");
2248
2249 let (rest_fill, ..) = apply_state(
2250 &ghost,
2251 InteractionState::Default,
2252 0.0,
2253 0.0,
2254 &Palette::aetna_dark(),
2255 );
2256 assert_eq!(rest_fill, None, "no envelope, no synthesized fill");
2257
2258 let (hover_fill, ..) = apply_state(
2259 &ghost,
2260 InteractionState::Hover,
2261 1.0,
2262 0.0,
2263 &Palette::aetna_dark(),
2264 );
2265 let hover_alpha = (tokens::STATE_FILL_HOVER_ALPHA * 255.0).round() as u8;
2266 assert_eq!(
2267 hover_fill,
2268 Some(tokens::ACCENT.with_alpha(hover_alpha)),
2269 "hover at peak fades a faint ACCENT in",
2270 );
2271
2272 let (press_fill, ..) = apply_state(
2273 &ghost,
2274 InteractionState::Press,
2275 1.0,
2276 1.0,
2277 &Palette::aetna_dark(),
2278 );
2279 let press_alpha = ((tokens::STATE_FILL_HOVER_ALPHA + tokens::STATE_FILL_PRESS_ALPHA)
2280 * 255.0)
2281 .round() as u8;
2282 assert_eq!(
2283 press_fill,
2284 Some(tokens::ACCENT.with_alpha(press_alpha)),
2285 "press while hovered sums the two envelope contributions",
2286 );
2287 }
2288
2289 #[test]
2290 fn hover_alpha_fades_child_with_focusable_ancestor_envelope() {
2291 use crate::layout::layout;
2298
2299 let make_tree = || {
2300 column([row([crate::stack([El::new(Kind::Custom("badge"))
2301 .width(Size::Fixed(14.0))
2302 .height(Size::Fixed(14.0))
2303 .fill(tokens::FOREGROUND)
2304 .hover_alpha(0.25, 1.0)])
2305 .key("container")
2306 .focusable()
2307 .width(Size::Fixed(120.0))
2308 .height(Size::Fixed(18.0))])])
2309 .padding(20.0)
2310 };
2311
2312 {
2314 let mut tree = make_tree();
2315 let mut state = UiState::new();
2316 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2317 state.set_animation_mode(crate::state::AnimationMode::Settled);
2318 state.tick_visual_animations(&mut tree, web_time::Instant::now());
2319
2320 let ops = draw_ops(&tree, &state);
2321 let badge = find_quad(&ops, "badge").expect("badge quad");
2322 let DrawOp::Quad { uniforms, .. } = badge else {
2323 unreachable!()
2324 };
2325 let UniformValue::Color(fill) = uniforms.get("fill").expect("badge fill") else {
2326 panic!("expected color uniform");
2327 };
2328 let expected = (255.0_f32 * 0.25).round() as u8;
2331 assert!(
2332 (fill.a as i32 - expected as i32).abs() <= 2,
2333 "rest opacity should hold the child near 0.25 alpha; got {}",
2334 fill.a,
2335 );
2336 }
2337
2338 {
2340 let mut tree = make_tree();
2341 let mut state = UiState::new();
2342 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2343 let container_target = state
2344 .target_of_key(&tree, "container")
2345 .expect("container target");
2346 state.hovered = Some(container_target);
2347 state.apply_to_state();
2348 state.set_animation_mode(crate::state::AnimationMode::Settled);
2349 state.tick_visual_animations(&mut tree, web_time::Instant::now());
2350
2351 let ops = draw_ops(&tree, &state);
2352 let badge = find_quad(&ops, "badge").expect("badge quad");
2353 let DrawOp::Quad { uniforms, .. } = badge else {
2354 unreachable!()
2355 };
2356 let UniformValue::Color(fill) = uniforms.get("fill").expect("badge fill") else {
2357 panic!("expected color uniform");
2358 };
2359 assert_eq!(
2360 fill.a, 255,
2361 "ancestor hover should pull the child's alpha to full",
2362 );
2363 }
2364 }
2365
2366 #[test]
2367 fn hover_alpha_keeps_child_visible_while_self_hovered() {
2368 use crate::layout::layout;
2374
2375 let mut tree = column([row([crate::stack([El::new(Kind::Custom("close"))
2376 .key("close")
2377 .focusable()
2378 .width(Size::Fixed(14.0))
2379 .height(Size::Fixed(14.0))
2380 .fill(tokens::FOREGROUND)
2381 .hover_alpha(0.0, 1.0)])
2382 .key("container")
2383 .focusable()
2384 .width(Size::Fixed(120.0))
2385 .height(Size::Fixed(18.0))])])
2386 .padding(20.0);
2387
2388 let mut state = UiState::new();
2389 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2390 let close_target = state.target_of_key(&tree, "close").expect("close target");
2393 state.hovered = Some(close_target);
2394 state.apply_to_state();
2395 state.set_animation_mode(crate::state::AnimationMode::Settled);
2396 state.tick_visual_animations(&mut tree, web_time::Instant::now());
2397
2398 let ops = draw_ops(&tree, &state);
2399 let close = find_quad(&ops, "close").expect("close quad");
2400 let DrawOp::Quad { uniforms, .. } = close else {
2401 unreachable!()
2402 };
2403 let UniformValue::Color(fill) = uniforms.get("fill").expect("close fill") else {
2404 panic!("expected color uniform");
2405 };
2406 assert_eq!(
2407 fill.a, 255,
2408 "self-hover should keep a hover_alpha element fully visible \
2409 even when no ancestor is hovered",
2410 );
2411 }
2412
2413 #[test]
2414 fn hover_alpha_does_not_affect_unmarked_descendants() {
2415 use crate::layout::layout;
2419
2420 let mut tree = column([row([crate::stack([
2421 El::new(Kind::Custom("tagged"))
2422 .width(Size::Fixed(8.0))
2423 .height(Size::Fixed(8.0))
2424 .fill(tokens::FOREGROUND)
2425 .hover_alpha(0.0, 1.0),
2426 El::new(Kind::Custom("plain"))
2427 .width(Size::Fixed(8.0))
2428 .height(Size::Fixed(8.0))
2429 .fill(tokens::FOREGROUND),
2430 ])
2431 .key("container")
2432 .focusable()
2433 .width(Size::Fixed(120.0))
2434 .height(Size::Fixed(18.0))])])
2435 .padding(20.0);
2436
2437 let mut state = UiState::new();
2438 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2439 state.set_animation_mode(crate::state::AnimationMode::Settled);
2440 state.tick_visual_animations(&mut tree, web_time::Instant::now());
2441
2442 let ops = draw_ops(&tree, &state);
2443 let tagged = find_quad(&ops, "tagged").expect("tagged quad");
2444 let plain = find_quad(&ops, "plain").expect("plain quad");
2445 let DrawOp::Quad {
2446 uniforms: tagged_u, ..
2447 } = tagged
2448 else {
2449 unreachable!()
2450 };
2451 let DrawOp::Quad {
2452 uniforms: plain_u, ..
2453 } = plain
2454 else {
2455 unreachable!()
2456 };
2457 let UniformValue::Color(t) = tagged_u.get("fill").unwrap() else {
2458 panic!()
2459 };
2460 let UniformValue::Color(p) = plain_u.get("fill").unwrap() else {
2461 panic!()
2462 };
2463 assert_eq!(t.a, 0, "tagged child invisible at rest with rest=0");
2464 assert_eq!(p.a, 255, "unmarked sibling unaffected");
2465 }
2466
2467 #[test]
2468 fn hover_alpha_stays_revealed_when_focusable_descendant_is_hovered() {
2469 use crate::layout::layout;
2476
2477 let mut tree = column([row([crate::stack([
2478 El::new(Kind::Custom("pill"))
2481 .width(Size::Fixed(80.0))
2482 .height(Size::Fixed(20.0))
2483 .fill(tokens::FOREGROUND)
2484 .hover_alpha(0.0, 1.0)
2485 .axis(crate::tree::Axis::Row),
2486 ])
2487 .key("card")
2488 .focusable()
2489 .width(Size::Fixed(160.0))
2490 .height(Size::Fixed(40.0))])])
2491 .padding(20.0);
2492 tree.children[0].children[0].children[0]
2494 .children
2495 .push(El::new(Kind::Custom("play")).key("play").focusable());
2496 tree.children[0].children[0].children[0]
2497 .children
2498 .push(El::new(Kind::Custom("more")).key("more").focusable());
2499
2500 let mut state = UiState::new();
2501 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2502 let play = state.target_of_key(&tree, "play").expect("play target");
2507 state.hovered = Some(play);
2508 state.apply_to_state();
2509 state.set_animation_mode(crate::state::AnimationMode::Settled);
2510 state.tick_visual_animations(&mut tree, web_time::Instant::now());
2511
2512 let ops = draw_ops(&tree, &state);
2513 let pill = find_quad(&ops, "pill").expect("pill quad");
2514 let DrawOp::Quad { uniforms, .. } = pill else {
2515 unreachable!()
2516 };
2517 let UniformValue::Color(fill) = uniforms.get("fill").expect("pill fill") else {
2518 panic!("expected color uniform");
2519 };
2520 assert_eq!(
2521 fill.a, 255,
2522 "pill must stay fully revealed while a focusable descendant is hovered",
2523 );
2524 }
2525
2526 #[test]
2527 fn hover_alpha_reveals_on_keyboard_focus_of_focusable_ancestor() {
2528 use crate::layout::layout;
2534
2535 let mut tree = column([row([crate::stack([El::new(Kind::Custom("close"))
2536 .key("close")
2537 .focusable()
2538 .width(Size::Fixed(14.0))
2539 .height(Size::Fixed(14.0))
2540 .fill(tokens::FOREGROUND)
2541 .hover_alpha(0.0, 1.0)])
2542 .key("tab")
2543 .focusable()
2544 .width(Size::Fixed(120.0))
2545 .height(Size::Fixed(28.0))])])
2546 .padding(20.0);
2547
2548 let mut state = UiState::new();
2549 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2550 let tab = state.target_of_key(&tree, "tab").expect("tab target");
2551 state.focused = Some(tab);
2552 state.focus_visible = true;
2553 state.apply_to_state();
2554 state.set_animation_mode(crate::state::AnimationMode::Settled);
2555 state.tick_visual_animations(&mut tree, web_time::Instant::now());
2556
2557 let ops = draw_ops(&tree, &state);
2558 let close = find_quad(&ops, "close").expect("close quad");
2559 let DrawOp::Quad { uniforms, .. } = close else {
2560 unreachable!()
2561 };
2562 let UniformValue::Color(fill) = uniforms.get("fill").expect("close fill") else {
2563 panic!("expected color uniform");
2564 };
2565 assert_eq!(
2566 fill.a, 255,
2567 "keyboard focus on the tab should reveal the close affordance",
2568 );
2569 }
2570
2571 #[test]
2572 fn hover_alpha_returns_to_rest_when_subtree_loses_interaction() {
2573 use crate::layout::layout;
2576
2577 let mut tree = column([
2578 row([crate::stack([El::new(Kind::Custom("badge"))
2579 .width(Size::Fixed(14.0))
2580 .height(Size::Fixed(14.0))
2581 .fill(tokens::FOREGROUND)
2582 .hover_alpha(0.25, 1.0)])
2583 .key("container")
2584 .focusable()
2585 .width(Size::Fixed(120.0))
2586 .height(Size::Fixed(18.0))]),
2587 row([
2591 crate::stack([El::new(Kind::Custom("other_body")).width(Size::Fixed(80.0))])
2592 .key("other")
2593 .focusable()
2594 .width(Size::Fixed(120.0))
2595 .height(Size::Fixed(18.0)),
2596 ]),
2597 ])
2598 .padding(20.0);
2599
2600 let mut state = UiState::new();
2601 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2602 let other = state.target_of_key(&tree, "other").expect("other target");
2603 state.hovered = Some(other);
2604 state.apply_to_state();
2605 state.set_animation_mode(crate::state::AnimationMode::Settled);
2606 state.tick_visual_animations(&mut tree, web_time::Instant::now());
2607
2608 let ops = draw_ops(&tree, &state);
2609 let badge = find_quad(&ops, "badge").expect("badge quad");
2610 let DrawOp::Quad { uniforms, .. } = badge else {
2611 unreachable!()
2612 };
2613 let UniformValue::Color(fill) = uniforms.get("fill").expect("badge fill") else {
2614 panic!("expected color uniform");
2615 };
2616 let expected = (255.0_f32 * 0.25).round() as u8;
2617 assert!(
2618 (fill.a as i32 - expected as i32).abs() <= 2,
2619 "badge should be at rest opacity when interaction is on a sibling region; got {}",
2620 fill.a,
2621 );
2622 }
2623
2624 fn find_quad<'a>(ops: &'a [DrawOp], id_substr: &str) -> Option<&'a DrawOp> {
2625 ops.iter().find(|op| op.id().contains(id_substr))
2626 }
2627
2628 #[test]
2629 fn state_follows_interactive_ancestor_borrows_envelopes() {
2630 use crate::layout::layout;
2635
2636 let mut tree = column([row([crate::stack([El::new(Kind::Custom("thumb"))
2637 .key("thumb")
2638 .width(Size::Fixed(14.0))
2639 .height(Size::Fixed(14.0))
2640 .fill(tokens::FOREGROUND)
2641 .radius(tokens::RADIUS_PILL)
2642 .state_follows_interactive_ancestor()])
2643 .key("container")
2644 .focusable()
2645 .width(Size::Fixed(120.0))
2646 .height(Size::Fixed(18.0))])])
2647 .padding(20.0);
2648 let mut state = UiState::new();
2649 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2650
2651 let container_target = state
2655 .target_of_key(&tree, "container")
2656 .expect("container target");
2657 state.hovered = Some(container_target.clone());
2658 state.pressed = Some(container_target);
2659 state.apply_to_state();
2660 state.set_animation_mode(crate::state::AnimationMode::Settled);
2661 state.tick_visual_animations(&mut tree, web_time::Instant::now());
2662
2663 let ops = draw_ops(&tree, &state);
2667 let thumb_op = ops
2668 .iter()
2669 .find(|op| op.id().contains("thumb"))
2670 .expect("thumb quad");
2671 let DrawOp::Quad { uniforms, .. } = thumb_op else {
2672 panic!("expected thumb quad");
2673 };
2674 let UniformValue::Color(thumb_fill) = uniforms.get("fill").expect("thumb fill") else {
2675 panic!("expected color uniform");
2676 };
2677 let expected = tokens::FOREGROUND.mix(tokens::FOREGROUND.darken(tokens::PRESS_DARKEN), 1.0);
2680 assert_eq!(
2681 (thumb_fill.r, thumb_fill.g, thumb_fill.b),
2682 (expected.r, expected.g, expected.b),
2683 "flagged thumb borrows the container's press envelope",
2684 );
2685 }
2686
2687 #[test]
2688 fn cross_leaf_selection_paints_a_band_on_each_spanned_leaf() {
2689 use crate::selection::{Selection, SelectionPoint, SelectionRange};
2690
2691 let mut tree = column([
2692 crate::widgets::text::paragraph("First")
2693 .key("a")
2694 .selectable(),
2695 crate::widgets::text::paragraph("Second")
2696 .key("b")
2697 .selectable(),
2698 crate::widgets::text::paragraph("Third")
2699 .key("c")
2700 .selectable(),
2701 ])
2702 .padding(20.0);
2703 let mut state = UiState::new();
2704 crate::layout::layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 300.0));
2705 state.sync_selection_order(&tree);
2706
2707 state.current_selection = Selection {
2710 range: Some(SelectionRange {
2711 anchor: SelectionPoint::new("a", 2),
2712 head: SelectionPoint::new("c", 3),
2713 }),
2714 };
2715
2716 let ops = draw_ops(&tree, &state);
2717 let band_ids: Vec<&str> = ops
2718 .iter()
2719 .filter_map(|op| {
2720 if let DrawOp::Quad { id, .. } = op
2721 && id.contains("selection-band")
2722 {
2723 Some(id.as_str())
2724 } else {
2725 None
2726 }
2727 })
2728 .collect();
2729 assert_eq!(
2731 band_ids.len(),
2732 3,
2733 "cross-leaf selection should emit a band on each of {{a, b, c}}; got {band_ids:?}"
2734 );
2735 }
2736
2737 #[test]
2738 fn mixed_inline_math_selection_band_uses_math_rect() {
2739 use crate::selection::{Selection, SelectionPoint, SelectionRange, SelectionSource};
2740
2741 let object = "\u{fffc}";
2742 let visible = format!("Inline {object} math");
2743 let mut source = SelectionSource::new("Inline $\\frac{a+b}{c+d}$ math", visible.clone());
2744 let math_start = "Inline ".len();
2745 let math_end = math_start + object.len();
2746 source.push_span(0..math_start, 0.."Inline ".len(), false);
2747 source.push_span(
2748 math_start..math_end,
2749 "Inline $".len()..(source.source.len() - " math".len()),
2750 true,
2751 );
2752 source.push_span(
2753 math_end..visible.len(),
2754 (source.source.len() - " math".len())..source.source.len(),
2755 false,
2756 );
2757
2758 let expr = crate::math::parse_tex(r"\frac{a+b}{c+d}").expect("fixture TeX parses");
2759 let mut tree = crate::text_runs([
2760 crate::text("Inline "),
2761 crate::math_inline(expr),
2762 crate::text(" math"),
2763 ])
2764 .key("p")
2765 .selectable()
2766 .selection_source(source)
2767 .padding(20.0);
2768 let mut state = UiState::new();
2769 crate::layout::layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
2770 state.sync_selection_order(&tree);
2771 state.current_selection = Selection {
2772 range: Some(SelectionRange {
2773 anchor: SelectionPoint::new("p", math_start),
2774 head: SelectionPoint::new("p", math_end),
2775 }),
2776 };
2777
2778 let ops = draw_ops(&tree, &state);
2779 let bands: Vec<Rect> = ops
2780 .iter()
2781 .filter_map(|op| {
2782 if let DrawOp::Quad { id, rect, .. } = op
2783 && id.contains("selection-band")
2784 {
2785 Some(*rect)
2786 } else {
2787 None
2788 }
2789 })
2790 .collect();
2791 assert_eq!(bands.len(), 1, "expected one atomic math band");
2792 let placeholder_width =
2793 crate::text::metrics::line_width(object, 16.0, FontWeight::Regular, false);
2794 assert!(
2795 bands[0].w > placeholder_width * 1.5,
2796 "inline math selection band should cover the rendered fraction box instead of the placeholder glyph, got {:?}",
2797 bands[0],
2798 );
2799 }
2800
2801 #[test]
2802 fn source_backed_mono_inlines_measure_selection_with_mono_family() {
2803 use crate::selection::{Selection, SelectionPoint, SelectionRange, SelectionSource};
2804
2805 let visible = "iiii\nwwww";
2806 let mut tree = crate::text_runs([
2807 crate::text("iiii").mono(),
2808 crate::hard_break(),
2809 crate::text("wwww").mono(),
2810 ])
2811 .mono()
2812 .font_size(16.0)
2813 .nowrap_text()
2814 .key("code")
2815 .selectable()
2816 .selection_source(SelectionSource::identity(visible))
2817 .padding(20.0);
2818 let mut state = UiState::new();
2819 crate::layout::layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
2820 state.sync_selection_order(&tree);
2821
2822 let selected_band_width = |state: &mut UiState, start: usize, end: usize| {
2823 state.current_selection = Selection {
2824 range: Some(SelectionRange {
2825 anchor: SelectionPoint::new("code", start),
2826 head: SelectionPoint::new("code", end),
2827 }),
2828 };
2829 let ops = draw_ops(&tree, state);
2830 let bands: Vec<Rect> = ops
2831 .iter()
2832 .filter_map(|op| {
2833 if let DrawOp::Quad { id, rect, .. } = op
2834 && id.contains("selection-band")
2835 {
2836 Some(*rect)
2837 } else {
2838 None
2839 }
2840 })
2841 .collect();
2842 assert_eq!(bands.len(), 1, "expected one selected visual line");
2843 bands[0].w
2844 };
2845
2846 let i_width = selected_band_width(&mut state, 0, 4);
2847 let w_width = selected_band_width(&mut state, 5, visible.len());
2848 assert!(
2849 (i_width - w_width).abs() <= 0.5,
2850 "mono code selection should measure equal-length lines equally; got iiii={i_width}, wwww={w_width}",
2851 );
2852 }
2853
2854 #[test]
2855 fn source_backed_attributed_inlines_measure_selection_per_run_style() {
2856 use crate::selection::{Selection, SelectionPoint, SelectionRange, SelectionSource};
2857
2858 let visible = "prefix iiii suffix";
2859 let code_start = "prefix ".len();
2860 let code_end = code_start + "iiii".len();
2861 let mut tree = crate::text_runs([
2862 crate::text("prefix "),
2863 crate::text("iiii").code(),
2864 crate::text(" suffix"),
2865 ])
2866 .font_size(16.0)
2867 .nowrap_text()
2868 .key("rich")
2869 .selectable()
2870 .selection_source(SelectionSource::identity(visible))
2871 .padding(20.0);
2872 let mut state = UiState::new();
2873 crate::layout::layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
2874 state.sync_selection_order(&tree);
2875 state.current_selection = Selection {
2876 range: Some(SelectionRange {
2877 anchor: SelectionPoint::new("rich", code_start),
2878 head: SelectionPoint::new("rich", code_end),
2879 }),
2880 };
2881
2882 let ops = draw_ops(&tree, &state);
2883 let bands: Vec<Rect> = ops
2884 .iter()
2885 .filter_map(|op| {
2886 if let DrawOp::Quad { id, rect, .. } = op
2887 && id.contains("selection-band")
2888 {
2889 Some(*rect)
2890 } else {
2891 None
2892 }
2893 })
2894 .collect();
2895 assert_eq!(bands.len(), 1, "expected one inline-code selection band");
2896
2897 let regular_width = crate::text::metrics::line_width_with_family(
2898 "iiii",
2899 16.0,
2900 FontFamily::Inter,
2901 FontWeight::Regular,
2902 false,
2903 );
2904 let mono_width = crate::text::metrics::line_width_with_family(
2905 "iiii",
2906 16.0,
2907 FontFamily::Inter,
2908 FontWeight::Regular,
2909 true,
2910 );
2911 assert!(
2912 (bands[0].w - mono_width).abs() <= 0.75,
2913 "inline-code selection should use mono run width; band={:?}, mono={mono_width}, regular={regular_width}",
2914 bands[0],
2915 );
2916 assert!(
2917 bands[0].w > regular_width * 1.5,
2918 "regression guard: measuring the code run as regular text would be visibly too short"
2919 );
2920 }
2921
2922 #[test]
2923 fn drag_select_through_runtime_paints_band_in_next_frame() {
2924 use crate::event::{Pointer, PointerButton};
2931 use crate::runtime::{PrepareTimings, RunnerCore};
2932
2933 let mut core = RunnerCore::new();
2934 let mut tree = column([crate::widgets::text::paragraph("Hello, world!")
2935 .key("p")
2936 .selectable()])
2937 .padding(20.0);
2938 let viewport = Rect::new(0.0, 0.0, 400.0, 200.0);
2939 let mut t = PrepareTimings::default();
2941 let _ = core.prepare_layout(
2942 &mut tree,
2943 viewport,
2944 1.0,
2945 &mut t,
2946 RunnerCore::no_time_shaders,
2947 );
2948 core.snapshot(&tree, &mut t);
2950
2951 let p_rect = core.rect_of_key("p").expect("p rect");
2952 let cy = p_rect.y + p_rect.h * 0.5;
2953 let _ = core.pointer_down(Pointer::mouse(p_rect.x + 4.0, cy, PointerButton::Primary));
2954 let _ = core.pointer_moved(Pointer::moving(p_rect.x + p_rect.w - 8.0, cy));
2956
2957 let sel = &core.ui_state.current_selection;
2959 let r = sel.range.as_ref().expect("selection set");
2960 assert!(
2961 r.anchor.byte != r.head.byte,
2962 "drag should extend head past anchor (anchor={}, head={})",
2963 r.anchor.byte,
2964 r.head.byte
2965 );
2966
2967 let mut t2 = PrepareTimings::default();
2970 let crate::runtime::LayoutPrepared { ops, .. } = core.prepare_layout(
2971 &mut tree,
2972 viewport,
2973 1.0,
2974 &mut t2,
2975 RunnerCore::no_time_shaders,
2976 );
2977 let bands: Vec<&DrawOp> = ops
2978 .iter()
2979 .filter(|op| matches!(op, DrawOp::Quad { id, .. } if id.contains("selection-band")))
2980 .collect();
2981 assert!(
2982 !bands.is_empty(),
2983 "after drag-select, prepare_layout should emit a selection band Quad"
2984 );
2985 if let DrawOp::Quad { rect, .. } = bands[0] {
2988 assert!(
2989 rect.intersect(p_rect).is_some(),
2990 "band rect = {rect:?} doesn't overlap leaf rect = {p_rect:?}"
2991 );
2992 }
2993 }
2994
2995 #[test]
2996 fn selectable_leaf_paints_selection_band_when_key_matches_active_selection() {
2997 use crate::selection::{Selection, SelectionPoint, SelectionRange};
2998
2999 let mut tree = column([crate::widgets::text::paragraph("Hello, world!")
3000 .key("p")
3001 .selectable()])
3002 .padding(20.0);
3003 let mut state = UiState::new();
3004 crate::layout::layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
3005 let ops_pre = draw_ops(&tree, &state);
3007 let bands_pre = ops_pre
3008 .iter()
3009 .filter(|op| matches!(op, DrawOp::Quad { id, .. } if id.contains("selection-band")))
3010 .count();
3011 assert_eq!(bands_pre, 0, "no band should paint when selection is empty");
3012
3013 state.current_selection = Selection {
3014 range: Some(SelectionRange {
3015 anchor: SelectionPoint::new("p", 0),
3016 head: SelectionPoint::new("p", 5),
3017 }),
3018 };
3019 let ops = draw_ops(&tree, &state);
3020 let bands: Vec<&DrawOp> = ops
3021 .iter()
3022 .filter(|op| matches!(op, DrawOp::Quad { id, .. } if id.contains("selection-band")))
3023 .collect();
3024 assert!(
3025 !bands.is_empty(),
3026 "selection range over keyed selectable leaf should emit at least one band Quad"
3027 );
3028 if let DrawOp::Quad { rect, .. } = bands[0] {
3029 assert!(rect.w > 0.0 && rect.h > 0.0, "band rect = {rect:?}");
3031 }
3032 }
3033
3034 #[test]
3035 fn layout_only_focusable_container_does_not_synthesize_fill() {
3036 let layout_only = El::new(Kind::Custom("slider")).focusable();
3045 assert!(layout_only.fill.is_none());
3046 assert_eq!(layout_only.radius, crate::tree::Corners::ZERO);
3047 assert!(layout_only.stroke.is_none());
3048
3049 let (rest_fill, ..) = apply_state(
3050 &layout_only,
3051 InteractionState::Default,
3052 0.0,
3053 0.0,
3054 &Palette::aetna_dark(),
3055 );
3056 let (hover_fill, ..) = apply_state(
3057 &layout_only,
3058 InteractionState::Hover,
3059 1.0,
3060 0.0,
3061 &Palette::aetna_dark(),
3062 );
3063 let (press_fill, ..) = apply_state(
3064 &layout_only,
3065 InteractionState::Press,
3066 1.0,
3067 1.0,
3068 &Palette::aetna_dark(),
3069 );
3070 assert_eq!(rest_fill, None);
3071 assert_eq!(hover_fill, None);
3072 assert_eq!(press_fill, None);
3073 }
3074
3075 #[test]
3076 fn solid_surface_keeps_envelope_mix_unchanged() {
3077 let solid = El::new(Kind::Custom("button")).fill(tokens::MUTED);
3081 let (rest_fill, ..) = apply_state(
3082 &solid,
3083 InteractionState::Default,
3084 0.0,
3085 0.0,
3086 &Palette::aetna_dark(),
3087 );
3088 assert_eq!(rest_fill, Some(tokens::MUTED));
3089
3090 let (hover_fill, ..) = apply_state(
3091 &solid,
3092 InteractionState::Hover,
3093 1.0,
3094 0.0,
3095 &Palette::aetna_dark(),
3096 );
3097 assert_eq!(
3098 hover_fill,
3099 Some(tokens::MUTED.mix(tokens::MUTED.lighten(tokens::HOVER_LIGHTEN), 1.0)),
3100 "solid surfaces lighten existing fill, not synthesize a new one",
3101 );
3102 }
3103
3104 #[test]
3105 fn state_envelope_composes_against_active_palette() {
3106 let solid = El::new(Kind::Custom("button")).fill(tokens::MUTED);
3110 let light = Palette::aetna_light();
3111 let (hover_fill, ..) = apply_state(&solid, InteractionState::Hover, 1.0, 0.0, &light);
3112 let expected = light
3113 .muted
3114 .mix(light.muted.lighten(tokens::HOVER_LIGHTEN), 1.0);
3115 assert_eq!(
3116 hover_fill,
3117 Some(expected),
3118 "hover lighten composes against the active palette",
3119 );
3120 }
3121
3122 #[test]
3123 fn clip_sets_scissor_on_descendant_ops() {
3124 let mut root = column([row([
3125 button("Inside").key("inside"),
3126 button("Too wide").key("outside").width(Size::Fixed(300.0)),
3127 ])
3128 .clip()
3129 .width(Size::Fixed(120.0))]);
3130 let mut state = UiState::new();
3131 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
3132
3133 let ops = draw_ops(&root, &state);
3134 let clipped = ops
3135 .iter()
3136 .find(|op| op.id().contains("outside"))
3137 .expect("outside button op");
3138 let DrawOp::Quad { scissor, .. } = clipped else {
3139 panic!("expected button surface quad");
3140 };
3141 assert_eq!(*scissor, Some(Rect::new(0.0, 0.0, 120.0, 32.0)));
3142 }
3143
3144 #[test]
3145 fn draw_ops_culls_text_fully_outside_inherited_clip() {
3146 let clipped = column([
3147 crate::widgets::text::text("visible").key("visible"),
3148 crate::tree::spacer().height(Size::Fixed(40.0)),
3149 crate::widgets::text::text("offscreen").key("offscreen"),
3150 ])
3151 .clip()
3152 .width(Size::Fixed(200.0))
3153 .height(Size::Fixed(24.0));
3154 let mut root = column([clipped]);
3155 let mut state = UiState::new();
3156 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 240.0, 80.0));
3157
3158 let mut stats = DrawOpsStats::default();
3159 let ops = draw_ops_with_theme_and_stats(&root, &state, &Theme::default(), &mut stats);
3160
3161 assert_eq!(stats.culled_text_ops, 1);
3162 assert!(
3163 ops.iter().any(|op| op.id().contains("visible")),
3164 "visible text still emits a draw op"
3165 );
3166 assert!(
3167 !ops.iter().any(|op| op.id().contains("offscreen")),
3168 "fully clipped text should not reach draw ops"
3169 );
3170 }
3171
3172 #[test]
3173 fn inline_text_bg_propagates_to_run_style() {
3174 let highlight = Color::rgb(220, 200, 60);
3178 let mut root = crate::text_runs([
3179 crate::text("plain "),
3180 crate::text("marked").background(highlight),
3181 crate::text(" rest"),
3182 ]);
3183 let mut state = UiState::new();
3184 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 80.0));
3185
3186 let ops = draw_ops(&root, &state);
3187 let DrawOp::AttributedText { runs, .. } = ops
3188 .iter()
3189 .find(|op| matches!(op, DrawOp::AttributedText { .. }))
3190 .expect("attr op")
3191 else {
3192 unreachable!()
3193 };
3194 assert_eq!(runs.len(), 3);
3195 assert_eq!(runs[0].1.bg, None);
3196 assert_eq!(runs[1].1.bg, Some(highlight));
3197 assert_eq!(runs[2].1.bg, None);
3198 }
3199
3200 #[test]
3201 fn text_align_center_emits_middle_anchor() {
3202 let mut root = crate::text("Centered").center_text();
3203 let mut state = UiState::new();
3204 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 80.0));
3205
3206 let ops = draw_ops(&root, &state);
3207 let DrawOp::GlyphRun { anchor, .. } = &ops[0] else {
3208 panic!("expected glyph run");
3209 };
3210 assert_eq!(*anchor, TextAnchor::Middle);
3211 }
3212
3213 #[test]
3214 fn paragraph_emits_wrapped_glyph_run() {
3215 let mut root = crate::paragraph("This sentence should wrap in a narrow box.")
3216 .width(Size::Fixed(120.0));
3217 let mut state = UiState::new();
3218 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 120.0, 120.0));
3219
3220 let ops = draw_ops(&root, &state);
3221 let DrawOp::GlyphRun { wrap, .. } = &ops[0] else {
3222 panic!("expected glyph run");
3223 };
3224 assert_eq!(*wrap, TextWrap::Wrap);
3225 }
3226
3227 #[test]
3228 fn inline_math_batches_same_style_text_runs() {
3229 let expr = crate::math::parse_tex("x_1+x_2").expect("valid tex");
3230 let mut root = crate::text_runs([
3231 crate::text("Alpha beta gamma "),
3232 crate::math_inline(expr),
3233 crate::text(" delta epsilon"),
3234 ])
3235 .width(Size::Fixed(600.0));
3236 let mut state = UiState::new();
3237 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 80.0));
3238
3239 let ops = draw_ops(&root, &state);
3240 let inline_runs: Vec<(&str, Rect)> = ops
3241 .iter()
3242 .filter_map(|op| {
3243 let DrawOp::GlyphRun { id, text, rect, .. } = op else {
3244 return None;
3245 };
3246 if id.contains(".inline-text.") {
3247 return Some((text.as_str(), *rect));
3248 }
3249 None
3250 })
3251 .collect();
3252
3253 assert_eq!(inline_runs.len(), 2);
3254 assert_eq!(inline_runs[0].0, "Alpha beta gamma ");
3255 assert_eq!(inline_runs[1].0, "delta epsilon");
3256 assert!(
3257 inline_runs[1].1.x > inline_runs[0].1.right(),
3258 "post-math text keeps the leading-space advance without painting a separate space run"
3259 );
3260 }
3261
3262 #[test]
3263 fn inline_math_uses_line_ascent_for_mixed_baseline() {
3264 let expr = crate::math::parse_tex(r"\frac{a+b}{c+d}").expect("valid tex");
3265 let mut root = crate::text_runs([
3266 crate::text("Before "),
3267 crate::math_inline(expr),
3268 crate::text(" after"),
3269 ])
3270 .width(Size::Fixed(600.0));
3271 let mut state = UiState::new();
3272 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 120.0));
3273
3274 let ops = draw_ops(&root, &state);
3275 let min_math_y = ops
3276 .iter()
3277 .filter_map(|op| match op {
3278 DrawOp::GlyphRun { id, rect, .. } if id.contains(".math-glyph.") => Some(rect.y),
3279 DrawOp::Quad { id, rect, .. } if id.contains(".math-rule.") => Some(rect.y),
3280 DrawOp::Vector { id, rect, .. } if id.contains(".math-") => Some(rect.y),
3281 _ => None,
3282 })
3283 .fold(f32::INFINITY, f32::min);
3284
3285 assert!(
3286 min_math_y >= -3.0,
3287 "built-up inline math should sit inside the line box, min y = {min_math_y}"
3288 );
3289 }
3290
3291 #[test]
3292 fn mixed_inline_wrap_paint_stays_inside_layout_height() {
3293 let expr = crate::math::parse_tex(r"\frac{a+b}{c+d}").expect("valid tex");
3294 let mut root = crate::text_runs([
3295 crate::text("Alpha beta "),
3296 crate::math_inline(expr),
3297 crate::text(" after wrap"),
3298 ])
3299 .width(Size::Fixed(116.0));
3300 let mut state = UiState::new();
3301 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 116.0, 200.0));
3302
3303 let root_rect = state
3304 .layout
3305 .computed_rects
3306 .get(&root.computed_id)
3307 .copied()
3308 .expect("root rect");
3309 let ops = draw_ops(&root, &state);
3310 let paint_bounds = mixed_inline_paint_bounds(&ops).expect("mixed inline paint bounds");
3311
3312 assert!(
3313 paint_bounds.bottom() <= root_rect.bottom() + 3.0,
3314 "paint bounds {paint_bounds:?} should fit layout rect {root_rect:?}"
3315 );
3316 }
3317
3318 #[test]
3319 fn mixed_inline_hard_break_paint_stays_inside_layout_height() {
3320 let expr = crate::math::parse_tex(r"\frac{a+b}{c+d}").expect("valid tex");
3321 let mut root = crate::text_runs([
3322 crate::text("Before "),
3323 crate::math_inline(expr),
3324 crate::hard_break(),
3325 crate::text("after"),
3326 ])
3327 .width(Size::Fixed(400.0));
3328 let mut state = UiState::new();
3329 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
3330
3331 let root_rect = state
3332 .layout
3333 .computed_rects
3334 .get(&root.computed_id)
3335 .copied()
3336 .expect("root rect");
3337 let ops = draw_ops(&root, &state);
3338 let paint_bounds = mixed_inline_paint_bounds(&ops).expect("mixed inline paint bounds");
3339
3340 assert!(
3341 paint_bounds.bottom() <= root_rect.bottom() + 3.0,
3342 "paint bounds {paint_bounds:?} should fit layout rect {root_rect:?}"
3343 );
3344 }
3345
3346 fn mixed_inline_paint_bounds(ops: &[DrawOp]) -> Option<Rect> {
3347 let mut bounds: Option<Rect> = None;
3348 for op in ops {
3349 let candidate = match op {
3350 DrawOp::GlyphRun { id, rect, .. }
3351 if id.contains(".inline-text.") || id.contains(".math-glyph.") =>
3352 {
3353 Some(*rect)
3354 }
3355 DrawOp::Quad { id, rect, .. } if id.contains(".math-rule.") => Some(*rect),
3356 DrawOp::Vector { id, rect, .. } if id.contains(".math-") => Some(*rect),
3357 _ => None,
3358 };
3359 if let Some(rect) = candidate {
3360 bounds = Some(match bounds {
3361 Some(prev) => union_rect(prev, rect),
3362 None => rect,
3363 });
3364 }
3365 }
3366 bounds
3367 }
3368
3369 fn union_rect(a: Rect, b: Rect) -> Rect {
3370 let left = a.x.min(b.x);
3371 let top = a.y.min(b.y);
3372 let right = a.right().max(b.right());
3373 let bottom = a.bottom().max(b.bottom());
3374 Rect::new(left, top, right - left, bottom - top)
3375 }
3376
3377 #[test]
3378 fn padding_on_text_node_insets_glyph_rect() {
3379 let mut root = column([crate::text("Chat").padding(Sides::xy(12.0, 8.0))])
3386 .width(Size::Fixed(320.0))
3387 .height(Size::Fill(1.0));
3388 let mut state = UiState::new();
3389 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 600.0));
3390
3391 let ops = draw_ops(&root, &state);
3392 let DrawOp::GlyphRun { rect, .. } = ops
3393 .iter()
3394 .find(|op| matches!(op, DrawOp::GlyphRun { .. }))
3395 .expect("text node emits a glyph run")
3396 else {
3397 unreachable!()
3398 };
3399 assert!(
3402 (rect.x - 12.0).abs() < 1e-3,
3403 "glyph rect.x = {}, expected 12 (left padding)",
3404 rect.x,
3405 );
3406 assert!(
3407 (rect.w - (320.0 - 24.0)).abs() < 1e-3,
3408 "glyph rect.w = {}, expected 296 (320 minus 12+12)",
3409 rect.w,
3410 );
3411 assert!(
3412 (rect.y - 8.0).abs() < 1e-3,
3413 "glyph rect.y = {}, expected 8 (top padding)",
3414 rect.y,
3415 );
3416 }
3417
3418 #[test]
3419 fn padding_on_icon_node_insets_icon_rect() {
3420 let mut root = column([crate::icon(IconName::Folder)
3426 .icon_size(crate::tokens::ICON_SM)
3427 .width(Size::Fixed(80.0))
3428 .height(Size::Fixed(40.0))
3429 .padding(Sides::xy(20.0, 0.0))]);
3430 let mut state = UiState::new();
3431 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
3432
3433 let ops = draw_ops(&root, &state);
3434 let DrawOp::Icon { rect, .. } = ops
3435 .iter()
3436 .find(|op| matches!(op, DrawOp::Icon { .. }))
3437 .expect("icon node emits an icon op")
3438 else {
3439 unreachable!()
3440 };
3441 assert!(
3444 (rect.x - 32.0).abs() < 1e-3,
3445 "icon rect.x = {}, expected 32 (centered in inset rect)",
3446 rect.x,
3447 );
3448 }
3449
3450 #[test]
3451 fn image_intrinsic_is_natural_pixel_size() {
3452 let pixels = vec![0u8; 80 * 40 * 4];
3453 let img = crate::image::Image::from_rgba8(80, 40, pixels);
3454 let el = crate::tree::image(img);
3455 let (w, h) = crate::layout::intrinsic(&el);
3456 assert!((w - 80.0).abs() < 1e-3, "intrinsic w = {w}");
3457 assert!((h - 40.0).abs() < 1e-3, "intrinsic h = {h}");
3458 }
3459
3460 #[test]
3461 fn image_emits_draw_op_with_fit_projection() {
3462 let pixels = vec![0u8; 100 * 50 * 4];
3464 let img = crate::image::Image::from_rgba8(100, 50, pixels);
3465 let mut root = crate::row([crate::tree::image(img)
3466 .image_fit(crate::image::ImageFit::Cover)
3467 .width(Size::Fixed(400.0))
3468 .height(Size::Fixed(400.0))]);
3469 let mut state = UiState::new();
3470 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 600.0));
3471 let ops = draw_ops(&root, &state);
3472 let img_op = ops
3473 .iter()
3474 .find(|op| matches!(op, DrawOp::Image { .. }))
3475 .expect("image El emits a DrawOp::Image");
3476 let DrawOp::Image {
3477 rect, scissor, fit, ..
3478 } = img_op
3479 else {
3480 unreachable!()
3481 };
3482 assert_eq!(*fit, crate::image::ImageFit::Cover);
3483 assert!((rect.w - 800.0).abs() < 1e-3, "rect.w = {}", rect.w);
3485 assert!((rect.h - 400.0).abs() < 1e-3, "rect.h = {}", rect.h);
3486 let s = scissor.expect("image draw op carries a scissor");
3489 assert!((s.w - 400.0).abs() < 1e-3, "scissor.w = {}", s.w);
3490 assert!((s.h - 400.0).abs() < 1e-3, "scissor.h = {}", s.h);
3491 }
3492
3493 #[test]
3494 fn image_fully_outside_inherited_clip_emits_zero_scissor_not_none() {
3495 let pixels = vec![0u8; 10 * 10 * 4];
3508 let img = crate::image::Image::from_rgba8(10, 10, pixels);
3509 let mut root = crate::column([crate::row([
3515 crate::column(Vec::<El>::new())
3516 .width(Size::Fixed(150.0))
3517 .height(Size::Fixed(50.0)),
3518 crate::tree::image(img)
3519 .width(Size::Fixed(60.0))
3520 .height(Size::Fixed(50.0)),
3521 ])
3522 .width(Size::Fixed(100.0))
3523 .height(Size::Fixed(100.0))
3524 .clip()]);
3525 let mut state = UiState::new();
3526 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
3527 let ops = draw_ops(&root, &state);
3528 let DrawOp::Image { scissor, .. } = ops
3529 .iter()
3530 .find(|op| matches!(op, DrawOp::Image { .. }))
3531 .expect("image El still emits a DrawOp::Image when fully clipped")
3532 else {
3533 unreachable!()
3534 };
3535 let s = scissor.expect(
3536 "scissor must be Some(_) so the renderer drops the draw — \
3537 None would let it paint past the ancestor clip",
3538 );
3539 assert!(
3540 s.w <= 0.0 || s.h <= 0.0,
3541 "image fully outside ancestor clip must yield a zero-sized scissor, got {s:?}",
3542 );
3543 }
3544
3545 #[test]
3546 fn image_tint_propagates_with_opacity() {
3547 let pixels = vec![0u8; 4 * 4 * 4];
3548 let img = crate::image::Image::from_rgba8(4, 4, pixels);
3549 let mut root = crate::tree::image(img)
3550 .image_tint(Color::rgb(200, 100, 50))
3551 .opacity(0.5);
3552 let mut state = UiState::new();
3553 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
3554 let ops = draw_ops(&root, &state);
3555 let DrawOp::Image { tint, .. } = ops
3556 .iter()
3557 .find(|op| matches!(op, DrawOp::Image { .. }))
3558 .expect("image emits draw op")
3559 else {
3560 unreachable!()
3561 };
3562 let tint = tint.expect("image_tint set, draw op carries tint");
3563 assert_eq!(tint.a, 128, "tint.a after 0.5 opacity = {}", tint.a);
3565 assert_eq!((tint.r, tint.g, tint.b), (200, 100, 50));
3566 }
3567
3568 #[derive(Debug)]
3571 struct StubAppTextureBackend {
3572 id: crate::surface::AppTextureId,
3573 size: (u32, u32),
3574 }
3575
3576 impl crate::surface::AppTextureBackend for StubAppTextureBackend {
3577 fn id(&self) -> crate::surface::AppTextureId {
3578 self.id
3579 }
3580 fn size_px(&self) -> (u32, u32) {
3581 self.size
3582 }
3583 fn format(&self) -> crate::surface::SurfaceFormat {
3584 crate::surface::SurfaceFormat::Rgba8UnormSrgb
3585 }
3586 fn as_any(&self) -> &dyn std::any::Any {
3587 self
3588 }
3589 }
3590
3591 fn stub_app_texture(w: u32, h: u32) -> crate::surface::AppTexture {
3592 crate::surface::AppTexture::from_backend(std::sync::Arc::new(StubAppTextureBackend {
3593 id: crate::surface::next_app_texture_id(),
3594 size: (w, h),
3595 }))
3596 }
3597
3598 #[test]
3599 fn surface_emits_app_texture_op_filling_rect() {
3600 let tex = stub_app_texture(64, 32);
3601 let mut root = crate::row([crate::tree::surface(tex)
3602 .width(Size::Fixed(200.0))
3603 .height(Size::Fixed(100.0))
3604 .surface_alpha(crate::surface::SurfaceAlpha::Opaque)]);
3605 let mut state = UiState::new();
3606 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
3607 let ops = draw_ops(&root, &state);
3608 let surf_op = ops
3609 .iter()
3610 .find(|op| matches!(op, DrawOp::AppTexture { .. }))
3611 .expect("Kind::Surface emits a DrawOp::AppTexture");
3612 let DrawOp::AppTexture {
3613 rect,
3614 scissor,
3615 alpha,
3616 fit,
3617 transform,
3618 ..
3619 } = surf_op
3620 else {
3621 unreachable!()
3622 };
3623 assert_eq!(*fit, crate::image::ImageFit::Fill);
3625 assert!((rect.w - 200.0).abs() < 1e-3, "rect.w = {}", rect.w);
3626 assert!((rect.h - 100.0).abs() < 1e-3, "rect.h = {}", rect.h);
3627 assert!(transform.is_identity());
3629 let s = scissor.expect("surface op carries a scissor");
3631 assert!((s.w - 200.0).abs() < 1e-3, "scissor.w = {}", s.w);
3632 assert_eq!(*alpha, crate::surface::SurfaceAlpha::Opaque);
3633 }
3634
3635 #[test]
3636 fn surface_fit_contain_letterboxes_aspect_mismatch() {
3637 let tex = stub_app_texture(100, 50);
3640 let mut root = crate::row([crate::tree::surface(tex)
3641 .surface_fit(crate::image::ImageFit::Contain)
3642 .width(Size::Fixed(400.0))
3643 .height(Size::Fixed(400.0))]);
3644 let mut state = UiState::new();
3645 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 600.0));
3646 let ops = draw_ops(&root, &state);
3647 let DrawOp::AppTexture {
3648 rect, scissor, fit, ..
3649 } = ops
3650 .iter()
3651 .find(|op| matches!(op, DrawOp::AppTexture { .. }))
3652 .expect("surface emits a DrawOp::AppTexture")
3653 else {
3654 unreachable!()
3655 };
3656 assert_eq!(*fit, crate::image::ImageFit::Contain);
3657 assert!((rect.w - 400.0).abs() < 1e-3, "rect.w = {}", rect.w);
3658 assert!((rect.h - 200.0).abs() < 1e-3, "rect.h = {}", rect.h);
3659 let s = scissor.expect("surface op carries a scissor");
3661 assert!((s.h - 400.0).abs() < 1e-3, "scissor.h = {}", s.h);
3662 }
3663
3664 #[test]
3665 fn surface_fit_cover_overflows_rect_with_scissor_clamp() {
3666 let tex = stub_app_texture(100, 50);
3669 let mut root = crate::row([crate::tree::surface(tex)
3670 .surface_fit(crate::image::ImageFit::Cover)
3671 .width(Size::Fixed(400.0))
3672 .height(Size::Fixed(400.0))]);
3673 let mut state = UiState::new();
3674 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 600.0));
3675 let ops = draw_ops(&root, &state);
3676 let DrawOp::AppTexture {
3677 rect, scissor, fit, ..
3678 } = ops
3679 .iter()
3680 .find(|op| matches!(op, DrawOp::AppTexture { .. }))
3681 .expect("surface emits a DrawOp::AppTexture")
3682 else {
3683 unreachable!()
3684 };
3685 assert_eq!(*fit, crate::image::ImageFit::Cover);
3686 assert!((rect.w - 800.0).abs() < 1e-3, "rect.w = {}", rect.w);
3687 assert!((rect.h - 400.0).abs() < 1e-3, "rect.h = {}", rect.h);
3688 let s = scissor.expect("surface op carries a scissor");
3689 assert!((s.w - 400.0).abs() < 1e-3, "scissor.w = {}", s.w);
3690 }
3691
3692 #[test]
3693 fn surface_transform_propagates_through_to_draw_op() {
3694 let tex = stub_app_texture(64, 32);
3695 let m = crate::affine::Affine2::rotate(0.5);
3696 let mut root = crate::row([crate::tree::surface(tex)
3697 .surface_transform(m)
3698 .width(Size::Fixed(200.0))
3699 .height(Size::Fixed(100.0))]);
3700 let mut state = UiState::new();
3701 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
3702 let ops = draw_ops(&root, &state);
3703 let DrawOp::AppTexture { transform, .. } = ops
3704 .iter()
3705 .find(|op| matches!(op, DrawOp::AppTexture { .. }))
3706 .expect("surface emits a DrawOp::AppTexture")
3707 else {
3708 unreachable!()
3709 };
3710 assert_eq!(*transform, m);
3711 }
3712
3713 #[test]
3714 fn vector_emits_draw_op_carrying_asset() {
3715 use crate::vector::{PathBuilder, VectorAsset};
3716 let curve = PathBuilder::new()
3717 .move_to(0.0, 0.0)
3718 .cubic_to(20.0, 0.0, 0.0, 60.0, 20.0, 60.0)
3719 .stroke_solid(Color::rgb(80, 200, 240), 2.0)
3720 .build();
3721 let asset = VectorAsset::from_paths([0.0, 0.0, 20.0, 60.0], vec![curve]);
3722 let expected_hash = asset.content_hash();
3723 let mut root = crate::row([crate::tree::vector(asset)
3724 .width(Size::Fixed(40.0))
3725 .height(Size::Fixed(120.0))]);
3726 let mut state = UiState::new();
3727 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
3728 let ops = draw_ops(&root, &state);
3729 let op = ops
3730 .iter()
3731 .find(|op| matches!(op, DrawOp::Vector { .. }))
3732 .expect("Kind::Vector emits a DrawOp::Vector");
3733 let DrawOp::Vector {
3734 rect,
3735 scissor,
3736 asset,
3737 render_mode,
3738 ..
3739 } = op
3740 else {
3741 unreachable!()
3742 };
3743 assert!((rect.w - 40.0).abs() < 1e-3, "rect.w = {}", rect.w);
3745 assert!((rect.h - 120.0).abs() < 1e-3, "rect.h = {}", rect.h);
3746 let s = scissor.expect("vector op carries a scissor");
3748 assert!((s.w - 40.0).abs() < 1e-3, "scissor.w = {}", s.w);
3749 assert_eq!(asset.content_hash(), expected_hash);
3751 assert_eq!(
3752 *render_mode,
3753 crate::vector::VectorRenderMode::Painted,
3754 "app vectors default to painted rendering"
3755 );
3756 let first_seg = asset.paths[0].segments.first().copied();
3759 assert_eq!(
3760 first_seg,
3761 Some(crate::vector::VectorSegment::MoveTo([0.0, 0.0]))
3762 );
3763 }
3764
3765 #[test]
3766 fn vector_asset_colors_resolve_against_active_palette() {
3767 use crate::vector::{PathBuilder, VectorAsset, VectorColor};
3768
3769 let path = PathBuilder::new()
3770 .move_to(0.0, 0.0)
3771 .line_to(10.0, 10.0)
3772 .stroke_solid(tokens::PRIMARY, 1.0)
3773 .build();
3774 let mut root =
3775 crate::tree::vector(VectorAsset::from_paths([0.0, 0.0, 10.0, 10.0], vec![path]));
3776 let mut state = UiState::new();
3777 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
3778
3779 let ops = draw_ops_with_theme(&root, &state, &Theme::aetna_light());
3780 let DrawOp::Vector { asset, .. } = ops
3781 .iter()
3782 .find(|op| matches!(op, DrawOp::Vector { .. }))
3783 .expect("vector op")
3784 else {
3785 unreachable!()
3786 };
3787 let stroke = asset.paths[0].stroke.expect("stroke");
3788 assert_eq!(
3789 stroke.color,
3790 VectorColor::Solid(crate::Palette::aetna_light().primary),
3791 "vector token colors should resolve through the active palette"
3792 );
3793 }
3794
3795 #[test]
3796 fn vector_mask_mode_resolves_mask_color_against_active_palette() {
3797 use crate::vector::{PathBuilder, VectorAsset, VectorRenderMode};
3798
3799 let path = PathBuilder::new()
3800 .move_to(0.0, 0.0)
3801 .line_to(10.0, 10.0)
3802 .stroke_solid(Color::rgb(1, 2, 3), 1.0)
3803 .build();
3804 let mut root =
3805 crate::tree::vector(VectorAsset::from_paths([0.0, 0.0, 10.0, 10.0], vec![path]))
3806 .vector_mask(tokens::PRIMARY);
3807 let mut state = UiState::new();
3808 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
3809
3810 let ops = draw_ops_with_theme(&root, &state, &Theme::aetna_light());
3811 let DrawOp::Vector { render_mode, .. } = ops
3812 .iter()
3813 .find(|op| matches!(op, DrawOp::Vector { .. }))
3814 .expect("vector op")
3815 else {
3816 unreachable!()
3817 };
3818 assert_eq!(
3819 *render_mode,
3820 VectorRenderMode::Mask {
3821 color: crate::Palette::aetna_light().primary
3822 }
3823 );
3824 }
3825
3826 #[test]
3827 fn math_exact_glyph_assets_are_normalized_before_msdf_rasterization() {
3828 let face = ttf_parser::Face::parse(aetna_fonts::NOTO_SANS_MATH_REGULAR, 0).unwrap();
3829 let glyph_id = face.glyph_index('√').expect("math radical glyph").0;
3830 let asset = math_glyph_vector_asset(glyph_id, Rect::new(-64.0, -3200.0, 1280.0, 4096.0))
3831 .expect("math glyph vector asset");
3832
3833 assert!(
3834 asset.view_box[2].max(asset.view_box[3]) <= 24.001,
3835 "font-unit view box should be normalized before hitting the icon MSDF path: {:?}",
3836 asset.view_box
3837 );
3838
3839 let mut atlas = crate::icons::msdf_atlas::IconMsdfAtlas::default();
3840 let slot = atlas
3841 .ensure_vector_asset(&asset)
3842 .expect("normalized glyph should rasterize");
3843 assert!(
3844 slot.rect.w <= 80 && slot.rect.h <= 80,
3845 "normalized math glyph should produce icon-sized MSDFs, got {:?}",
3846 slot.rect
3847 );
3848 }
3849
3850 #[test]
3851 fn vector_asset_content_hash_is_stable_and_distinguishing() {
3852 use crate::vector::{PathBuilder, VectorAsset};
3853 let make = |sx: f32| {
3854 let p = PathBuilder::new()
3855 .move_to(0.0, 0.0)
3856 .line_to(sx, 1.0)
3857 .stroke_solid(Color::rgb(0, 0, 0), 1.0)
3858 .build();
3859 VectorAsset::from_paths([0.0, 0.0, 10.0, 10.0], vec![p])
3860 };
3861 assert_eq!(make(1.0).content_hash(), make(1.0).content_hash());
3863 assert_ne!(make(1.0).content_hash(), make(2.0).content_hash());
3865 }
3866
3867 #[test]
3868 fn opacity_multiplies_alpha_on_quad_uniforms() {
3869 let mut root = button("X")
3870 .fill(Color::rgba(200, 100, 50, 200))
3871 .opacity(0.5);
3872 let mut state = UiState::new();
3873 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
3874 let ops = draw_ops(&root, &state);
3875 let DrawOp::Quad { uniforms, .. } = &ops[0] else {
3876 panic!("expected quad op");
3877 };
3878 let UniformValue::Color(c) = uniforms.get("fill").expect("fill") else {
3879 panic!("fill should be a colour");
3880 };
3881 assert_eq!(c.a, 100, "alpha should be halved by opacity 0.5");
3883 }
3884
3885 #[test]
3886 fn theme_can_route_implicit_surfaces_to_custom_shader() {
3887 let mut root = button("X").primary();
3888 let mut state = UiState::new();
3889 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
3890
3891 let theme = Theme::default()
3892 .with_surface_shader("xp_surface")
3893 .with_surface_uniform("theme_strength", UniformValue::F32(0.75));
3894 let ops = draw_ops_with_theme(&root, &state, &theme);
3895 let DrawOp::Quad {
3896 shader, uniforms, ..
3897 } = &ops[0]
3898 else {
3899 panic!("expected themed surface quad");
3900 };
3901
3902 assert_eq!(*shader, ShaderHandle::Custom("xp_surface"));
3903 assert_eq!(
3904 uniforms.get("theme_strength"),
3905 Some(&UniformValue::F32(0.75))
3906 );
3907 assert!(
3908 matches!(uniforms.get("fill"), Some(UniformValue::Color(_))),
3909 "familiar rounded-rect uniforms should stay available for manifests"
3910 );
3911 assert!(
3912 matches!(uniforms.get("vec_a"), Some(UniformValue::Color(_))),
3913 "custom surface shaders should also receive packed instance slots"
3914 );
3915 assert_eq!(
3916 uniforms.get("vec_c"),
3917 Some(&UniformValue::Vec4([
3918 1.0,
3919 tokens::RADIUS_MD,
3920 tokens::SHADOW_SM * 0.5,
3921 0.0
3922 ]))
3923 );
3924 }
3925
3926 #[test]
3927 fn theme_can_route_surface_role_to_custom_shader() {
3928 let mut root = crate::titled_card("Panel", [crate::text("Body")])
3929 .surface_role(SurfaceRole::Popover)
3930 .key("panel");
3931 let mut state = UiState::new();
3932 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 240.0, 120.0));
3933
3934 let theme = Theme::default()
3935 .with_role_shader(SurfaceRole::Popover, "popover_surface")
3936 .with_role_uniform(SurfaceRole::Popover, "elevation", UniformValue::F32(2.0));
3937 let ops = draw_ops_with_theme(&root, &state, &theme);
3938 let DrawOp::Quad {
3939 shader, uniforms, ..
3940 } = &ops[0]
3941 else {
3942 panic!("expected themed surface quad");
3943 };
3944
3945 assert_eq!(*shader, ShaderHandle::Custom("popover_surface"));
3946 assert_eq!(uniforms.get("elevation"), Some(&UniformValue::F32(2.0)));
3947 assert_eq!(
3948 uniforms.get("surface_role"),
3949 Some(&UniformValue::F32(SurfaceRole::Popover.uniform_id()))
3950 );
3951 assert!(
3952 matches!(uniforms.get("vec_a"), Some(UniformValue::Color(_))),
3953 "role-routed custom shaders should receive packed rect slots"
3954 );
3955 assert_eq!(
3956 uniforms.get("vec_c"),
3957 Some(&UniformValue::Vec4([
3958 1.0,
3959 tokens::RADIUS_LG,
3960 tokens::SHADOW_LG,
3961 0.0
3962 ]))
3963 );
3964 }
3965
3966 #[test]
3967 fn translate_offsets_paint_rect_and_inherits_to_children() {
3968 let mut root = column([button("X").key("x")]).translate(50.0, 30.0);
3974 let mut state = UiState::new();
3975 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
3976 let inner = inner_rect_quad_for(&root, &state, "x").expect("x quad inner_rect");
3977 let untranslated = find_computed(&root, &state, "x").expect("x computed");
3978
3979 assert!((inner.x - (untranslated.x + 50.0)).abs() < 0.5);
3980 assert!((inner.y - (untranslated.y + 30.0)).abs() < 0.5);
3981 }
3982
3983 #[test]
3984 fn scale_scales_rect_around_center() {
3985 let mut root = column([button("X").key("x").scale(2.0).width(Size::Fixed(40.0))]);
3986 let mut state = UiState::new();
3987 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
3988 let pre = find_computed(&root, &state, "x").expect("computed");
3989 let post = inner_rect_quad_for(&root, &state, "x").expect("painted inner_rect");
3990
3991 assert!((post.w - pre.w * 2.0).abs() < 0.5);
3993 assert!((post.h - pre.h * 2.0).abs() < 0.5);
3994 let pre_cx = pre.center_x();
3995 let post_cx = post.center_x();
3996 assert!(
3997 (pre_cx - post_cx).abs() < 0.5,
3998 "centre should be preserved by scale-around-centre",
3999 );
4000 }
4001
4002 #[test]
4003 fn shadow_auto_expands_painted_rect_around_inner_rect() {
4004 let mut root = column([El::new(Kind::Group)
4010 .key("c")
4011 .fill(tokens::CARD)
4012 .radius(tokens::RADIUS_LG)
4013 .shadow(tokens::SHADOW_MD)
4014 .width(Size::Fixed(80.0))
4015 .height(Size::Fixed(40.0))]);
4016 let mut state = UiState::new();
4017 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4018 let ops = draw_ops(&root, &state);
4019 let (painted, inner) = ops
4020 .iter()
4021 .find_map(|op| match op {
4022 DrawOp::Quad {
4023 id, rect, uniforms, ..
4024 } if id.contains("c") => {
4025 let UniformValue::Vec4(v) = uniforms.get("inner_rect")? else {
4026 return None;
4027 };
4028 Some((*rect, Rect::new(v[0], v[1], v[2], v[3])))
4029 }
4030 _ => None,
4031 })
4032 .expect("shadowed quad with inner_rect");
4033
4034 let blur = tokens::SHADOW_MD;
4036 assert!(
4037 (inner.x - painted.x - blur).abs() < 0.5,
4038 "left halo == blur, painted.x={}, inner.x={}",
4039 painted.x,
4040 inner.x,
4041 );
4042 assert!(
4043 (painted.right() - inner.right() - blur).abs() < 0.5,
4044 "right halo == blur",
4045 );
4046 assert!(
4047 (inner.y - painted.y - blur * 0.5).abs() < 0.5,
4048 "top halo == blur * 0.5",
4049 );
4050 assert!(
4051 (painted.bottom() - inner.bottom() - blur * 1.5).abs() < 0.5,
4052 "bottom halo == blur * 1.5",
4053 );
4054 }
4055
4056 #[test]
4057 fn shadow_overflow_takes_per_side_max_with_explicit_paint_overflow() {
4058 let combined =
4062 super::combined_overflow(crate::tree::Sides::all(8.0), tokens::SHADOW_MD, 0.0, 0.0);
4063 assert!((combined.left - 12.0).abs() < f32::EPSILON);
4064 assert!((combined.right - 12.0).abs() < f32::EPSILON);
4065 assert!((combined.top - 8.0).abs() < f32::EPSILON);
4066 assert!((combined.bottom - 18.0).abs() < f32::EPSILON);
4067 }
4068
4069 #[test]
4070 fn shadow_overflow_is_zero_when_shadow_is_zero() {
4071 let combined = super::combined_overflow(crate::tree::Sides::zero(), 0.0, 0.0, 0.0);
4072 assert_eq!(combined, crate::tree::Sides::zero());
4073 }
4074
4075 #[test]
4076 fn focus_overflow_outsets_painted_rect_by_ring_width() {
4077 let combined =
4078 super::combined_overflow(crate::tree::Sides::zero(), 0.0, 0.0, tokens::RING_WIDTH);
4079 assert_eq!(combined, crate::tree::Sides::all(tokens::RING_WIDTH));
4080 }
4081
4082 #[test]
4083 fn inside_focus_ring_does_not_outset_painted_rect() {
4084 use crate::layout::layout;
4085
4086 let mut tree = column([crate::menu_item("Open")
4087 .key("item")
4088 .width(Size::Fixed(100.0))])
4089 .padding(20.0);
4090 let mut state = UiState::new();
4091 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
4092 let target = state.target_of_key(&tree, "item").expect("item target");
4093 state.focused = Some(target);
4094 state.focus_visible = true;
4095 state.apply_to_state();
4096 state.set_animation_mode(crate::state::AnimationMode::Settled);
4097 state.tick_visual_animations(&mut tree, web_time::Instant::now());
4098
4099 let item_rect = state.rect_of_key(&tree, "item").expect("item rect");
4100 let ops = draw_ops(&tree, &state);
4101 let DrawOp::Quad { rect, uniforms, .. } =
4102 find_quad(&ops, "menu_item[item]").expect("menu item quad")
4103 else {
4104 panic!("expected menu item quad");
4105 };
4106 assert_eq!(*rect, item_rect);
4107 assert_eq!(
4108 uniforms.get("focus_width"),
4109 Some(&UniformValue::F32(-tokens::RING_WIDTH))
4110 );
4111 }
4112
4113 #[test]
4114 fn stroke_overflow_outsets_painted_rect_by_half_width_plus_aa_tail() {
4115 let combined = super::combined_overflow(crate::tree::Sides::zero(), 0.0, 1.0, 0.0);
4121 let halo = 1.0 * 0.5 + 1.0;
4122 assert!((combined.left - halo).abs() < f32::EPSILON);
4123 assert!((combined.right - halo).abs() < f32::EPSILON);
4124 assert!((combined.top - halo).abs() < f32::EPSILON);
4125 assert!((combined.bottom - halo).abs() < f32::EPSILON);
4126 }
4127
4128 #[test]
4129 fn stroke_and_shadow_take_per_side_max() {
4130 let combined = super::combined_overflow(crate::tree::Sides::zero(), 1.0, 4.0, 0.0);
4135 assert!(
4138 (combined.top - 3.0).abs() < f32::EPSILON,
4139 "top = {}",
4140 combined.top
4141 );
4142 assert!((combined.left - 3.0).abs() < f32::EPSILON);
4143 assert!((combined.right - 3.0).abs() < f32::EPSILON);
4144 assert!((combined.bottom - 3.0).abs() < f32::EPSILON);
4146 }
4147
4148 #[test]
4149 fn stroked_indicator_painted_rect_outsets_layout_rect() {
4150 let mut root = column([El::new(Kind::Custom("radio-indicator"))
4157 .key("indicator")
4158 .width(Size::Fixed(16.0))
4159 .height(Size::Fixed(16.0))
4160 .radius(tokens::RADIUS_PILL)
4161 .fill(tokens::CARD)
4162 .stroke(tokens::INPUT)]);
4163 let mut state = UiState::new();
4164 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
4165
4166 let ops = draw_ops(&root, &state);
4167 let (painted, inner) = ops
4168 .iter()
4169 .find_map(|op| match op {
4170 DrawOp::Quad {
4171 id, rect, uniforms, ..
4172 } if id.contains("indicator") => {
4173 let UniformValue::Vec4(v) = uniforms.get("inner_rect")? else {
4174 return None;
4175 };
4176 Some((*rect, Rect::new(v[0], v[1], v[2], v[3])))
4177 }
4178 _ => None,
4179 })
4180 .expect("stroked indicator quad with inner_rect");
4181
4182 let halo = 1.5;
4184 assert!(
4185 (inner.x - painted.x - halo).abs() < 1e-3,
4186 "left halo, painted.x={}, inner.x={}",
4187 painted.x,
4188 inner.x,
4189 );
4190 assert!(
4191 (painted.right() - inner.right() - halo).abs() < 1e-3,
4192 "right halo",
4193 );
4194 assert!((inner.y - painted.y - halo).abs() < 1e-3, "top halo",);
4195 assert!(
4196 (painted.bottom() - inner.bottom() - halo).abs() < 1e-3,
4197 "bottom halo",
4198 );
4199 assert!((inner.w - 16.0).abs() < 1e-3);
4202 assert!((inner.h - 16.0).abs() < 1e-3);
4203 }
4204
4205 #[test]
4206 fn shadow_uniform_is_set_when_n_shadow_is_nonzero() {
4207 let mut root = column([El::new(Kind::Group)
4208 .key("c")
4209 .fill(tokens::CARD)
4210 .radius(tokens::RADIUS_LG)
4211 .shadow(tokens::SHADOW_MD)
4212 .width(Size::Fixed(80.0))
4213 .height(Size::Fixed(40.0))]);
4214 let mut state = UiState::new();
4215 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4216 let ops = draw_ops(&root, &state);
4217 let uniforms = ops
4218 .iter()
4219 .find_map(|op| match op {
4220 DrawOp::Quad { id, uniforms, .. } if id.contains("c") => Some(uniforms.clone()),
4221 _ => None,
4222 })
4223 .expect("shadowed quad");
4224 assert_eq!(
4225 uniforms.get("shadow"),
4226 Some(&UniformValue::F32(tokens::SHADOW_MD)),
4227 ".shadow(SHADOW_MD) on a node without surface_role must reach the shader unchanged",
4228 );
4229 }
4230
4231 #[test]
4232 fn theme_role_override_propagates_to_painted_rect() {
4233 let mut root = column([crate::titled_card("Card", [crate::text("Body")]).key("c")]);
4239 let mut state = UiState::new();
4240 crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4241 let ops = draw_ops(&root, &state);
4242 let (painted, inner) = ops
4243 .iter()
4244 .find_map(|op| match op {
4245 DrawOp::Quad {
4246 id, rect, uniforms, ..
4247 } if id.contains("c") => {
4248 let UniformValue::Vec4(v) = uniforms.get("inner_rect")? else {
4249 return None;
4250 };
4251 Some((*rect, Rect::new(v[0], v[1], v[2], v[3])))
4252 }
4253 _ => None,
4254 })
4255 .expect("card quad with inner_rect");
4256
4257 let blur = tokens::SHADOW_SM;
4258 assert!(
4259 (inner.x - painted.x - blur).abs() < 0.5,
4260 "left halo == effective (theme-resolved) shadow, painted.x={}, inner.x={}",
4261 painted.x,
4262 inner.x,
4263 );
4264 assert!(
4265 (painted.bottom() - inner.bottom() - blur * 1.5).abs() < 0.5,
4266 "bottom halo == effective shadow * 1.5",
4267 );
4268 }
4269
4270 fn inner_rect_quad_for(root: &El, ui_state: &UiState, key: &str) -> Option<Rect> {
4274 use crate::shader::UniformValue;
4275 let ops = draw_ops(root, ui_state);
4276 for op in ops {
4277 if let DrawOp::Quad {
4278 id, rect, uniforms, ..
4279 } = op
4280 && id.contains(key)
4281 {
4282 if let Some(UniformValue::Vec4(v)) = uniforms.get("inner_rect") {
4283 return Some(Rect::new(v[0], v[1], v[2], v[3]));
4284 }
4285 return Some(rect);
4286 }
4287 }
4288 None
4289 }
4290
4291 fn find_computed(node: &El, ui_state: &UiState, key: &str) -> Option<Rect> {
4292 if node.key.as_deref() == Some(key) {
4293 return Some(ui_state.rect(&node.computed_id));
4294 }
4295 node.children
4296 .iter()
4297 .find_map(|c| find_computed(c, ui_state, key))
4298 }
4299}