Skip to main content

mittens_engine/engine/ecs/system/layout/
mod.rs

1pub mod block;
2pub mod box_model_viz;
3pub mod flex;
4pub mod inline;
5pub mod measure;
6
7use crate::engine::ecs::ComponentId;
8use crate::engine::ecs::EventSignal;
9use crate::engine::ecs::SignalEmitter;
10use crate::engine::ecs::World;
11use crate::engine::ecs::component::LayoutComponent;
12use crate::engine::ecs::component::style::Display;
13use crate::engine::ecs::component::{HtmlElementComponent, StyleComponent};
14use measure::{MeasuredItem, measure_container_items};
15
16/// Approximate average character width in glyph-local units (pre-transform).
17const CHAR_WIDTH_GLYPH: f32 = 0.55;
18/// Fallback panel column budget when `wrap_at = 0` means "no authored cap".
19const DEFAULT_PANEL_WIDTH_CHARS: usize = 40;
20
21/// Local-Z step between consecutive layout-managed styled siblings.
22///
23/// Authors no longer need to hand-author small Z nudges like
24/// `T.position(_, _, 0.05)` to keep text above generated backgrounds: layout
25/// stamps `resolved_z = layer_index * LAYER_DISTANCE` onto each styled item TC
26/// and places its `__bg` quad at `resolved_z - 0.5 * LAYER_DISTANCE`.
27/// See `docs/spec/layout-stacking-z-index.md`.
28pub(crate) const LAYER_DISTANCE: f32 = 0.05;
29
30/// Local-Z lift applied by layout to the first non-styled TC descendant of a
31/// styled item when the author hasn't written their own Z offset. Keeps text
32/// (which usually lives one TC deep inside the styled item) clearly ahead of
33/// the item's `__bg` quad at `-0.5 * LAYER_DISTANCE`, without overflowing into
34/// the next layer's content plane at `+1.0 * LAYER_DISTANCE`.
35pub(crate) const AUTO_TEXT_LIFT_Z: f32 = 0.4 * LAYER_DISTANCE;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub(crate) enum FormattingContext {
39    Block,
40    Inline,
41    Flex,
42}
43
44pub(crate) fn formatting_context_for_container(
45    world: &World,
46    container_id: ComponentId,
47    items: &[MeasuredItem],
48) -> FormattingContext {
49    if matches!(container_display(world, container_id), Some(Display::Flex)) {
50        return FormattingContext::Flex;
51    }
52
53    let all_inline_block = !items.is_empty()
54        && items
55            .iter()
56            .all(|it| matches!(it.display, Some(Display::InlineBlock | Display::Inline)));
57
58    if all_inline_block {
59        FormattingContext::Inline
60    } else {
61        FormattingContext::Block
62    }
63}
64
65fn container_display(world: &World, container_id: ComponentId) -> Option<Display> {
66    let children = world.children_of(container_id);
67    let style_display = children.iter().find_map(|&child| {
68        world
69            .get_component_by_id_as::<StyleComponent>(child)
70            .and_then(|style| style.display)
71    });
72    if style_display.is_some() {
73        return style_display;
74    }
75    children.iter().find_map(|&child| {
76        world
77            .get_component_by_id_as::<HtmlElementComponent>(child)
78            .and_then(|el| el.element_type.default_display())
79    })
80}
81
82pub(crate) fn layout_container_items(
83    world: &mut World,
84    emit: &mut dyn SignalEmitter,
85    container_id: ComponentId,
86    items: &[MeasuredItem],
87    avail_w_gu: f32,
88    avail_h_gu: Option<f32>,
89    unit_scale: f32,
90    axis_scales: (f32, f32),
91    depth: i32,
92    parent_depth: i32,
93    viz: bool,
94) -> (f32, f32) {
95    match formatting_context_for_container(world, container_id, items) {
96        FormattingContext::Block => {
97            block::layout_items_for(
98                world,
99                emit,
100                items,
101                unit_scale,
102                axis_scales,
103                depth,
104                parent_depth,
105                viz,
106            );
107            let height = items.iter().map(|i| i.margin_box_height_gu).sum();
108            (avail_w_gu, height)
109        }
110        FormattingContext::Inline => inline::layout_items(
111            world,
112            emit,
113            items,
114            avail_w_gu,
115            unit_scale,
116            axis_scales,
117            depth,
118            parent_depth,
119            viz,
120        ),
121        FormattingContext::Flex => flex::layout_items(
122            world,
123            emit,
124            container_id,
125            items,
126            avail_w_gu,
127            avail_h_gu,
128            unit_scale,
129            axis_scales,
130            depth,
131            parent_depth,
132            viz,
133        ),
134    }
135}
136
137/// Drives CSS-like layout for all dirty [`LayoutComponent`] subtrees.
138///
139/// Each tick, dirty roots are found and dispatched to the appropriate
140/// formatting-context algorithm (`block`, `flex`, or `inline`) based on
141/// the container's display mode. Each algorithm emits `UpdateTransform`
142/// intents to position TC children.
143#[derive(Debug, Default)]
144pub struct LayoutSystem;
145
146impl LayoutSystem {
147    pub fn new() -> Self {
148        Self
149    }
150
151    /// Process all dirty [`LayoutComponent`] roots.
152    pub fn tick(&mut self, world: &mut World, emit: &mut dyn SignalEmitter) {
153        let dirty: Vec<ComponentId> = world
154            .all_components()
155            .filter(|&id| {
156                world
157                    .get_component_by_id_as::<LayoutComponent>(id)
158                    .map(|l| l.dirty)
159                    .unwrap_or(false)
160            })
161            .collect();
162
163        for &layout_id in &dirty {
164            let (width_gu, height_gu) = Self::run_layout(world, emit, layout_id);
165            let unit_scale = world
166                .get_component_by_id_as::<LayoutComponent>(layout_id)
167                .map(|lc| lc.unit_scale)
168                .unwrap_or(1.0);
169            let size_wu = (width_gu * unit_scale, height_gu * unit_scale);
170
171            if let Some(lc) = world.get_component_by_id_as_mut::<LayoutComponent>(layout_id) {
172                lc.computed_size_wu = Some(size_wu);
173            }
174
175            emit.push_event(
176                layout_id,
177                EventSignal::LayoutRootSizeAvailable {
178                    layout_id,
179                    width_wu: size_wu.0,
180                    height_wu: size_wu.1,
181                },
182            );
183        }
184
185        for layout_id in dirty {
186            if let Some(lc) = world.get_component_by_id_as_mut::<LayoutComponent>(layout_id) {
187                lc.dirty = false;
188            }
189        }
190    }
191
192    /// Dispatch to the correct formatting-context algorithm for `layout_id`.
193    ///
194    /// Returns `(total_width_gu, total_height_gu)` — the total extent of the
195    /// layout root's direct children in glyph units.
196    ///
197    /// Currently always uses block layout. Future: read the container's
198    /// `StyleComponent.display` (Flex, Block, etc.) to select the algorithm.
199    fn run_layout(
200        world: &mut World,
201        emit: &mut dyn SignalEmitter,
202        layout_id: ComponentId,
203    ) -> (f32, f32) {
204        // Guard: skip if the LayoutComponent is gone.
205        if world
206            .get_component_by_id_as::<LayoutComponent>(layout_id)
207            .is_none()
208        {
209            return (0.0, 0.0);
210        }
211
212        let (avail_w, avail_h, unit_scale) =
213            measure::layout_root_available_bounds(world, layout_id);
214        let items = measure_container_items(world, layout_id, avail_w, avail_h, unit_scale);
215        let viz = block::layout_root_has_inspect(world, layout_id);
216        let axis_scales = measure::layout_root_axis_scales(world, layout_id);
217        layout_container_items(
218            world,
219            emit,
220            layout_id,
221            &items,
222            avail_w,
223            avail_h,
224            unit_scale,
225            axis_scales,
226            0,
227            0,
228            viz,
229        )
230    }
231
232    /// Estimate the overlay-space width of a text panel without world matrices.
233    /// Used during panel setup before transforms are propagated.
234    pub fn estimate_panel_width(max_chars: usize, text_scale: f32, indent_width: f32) -> f32 {
235        let panel_chars = if max_chars == 0 {
236            DEFAULT_PANEL_WIDTH_CHARS
237        } else {
238            max_chars
239        };
240        indent_width + panel_chars as f32 * CHAR_WIDTH_GLYPH * text_scale
241    }
242
243    pub fn default_panel_width_chars() -> usize {
244        DEFAULT_PANEL_WIDTH_CHARS
245    }
246}