Skip to main content

appcore_filemaker/
layout.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: layout.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/30 05:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/30 05:00:00 by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11//! Defines bounded layout contracts and behavior for this crate.
12
13use std::sync::Arc;
14
15use serde::{Deserialize, Serialize};
16
17use crate::layout_context::LayoutContext;
18use crate::layout_geometry::{layout_error, validate_anchor_graph};
19
20use crate::{
21    AssetResolver, CollisionPolicy, DocumentFingerprint, DocumentIr, ElementIr, FontManager,
22    LayoutMode, OperationControl, ProgressPhase, Rect, ResolvedScene, ResourceLimits, Result,
23    SceneCache, Transform, Unit, ENGINE_VERSION,
24};
25
26/// Explicit layout and reflow policy.
27#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
28pub struct LayoutOptions {
29    /// Default inherited collision policy.
30    pub collision: CollisionPolicy,
31    /// Logical unit used by `lu` lengths.
32    pub logical_unit: Unit,
33    /// Smallest dimension accepted by collision shrinking.
34    pub minimum_size: Unit,
35    /// Gap introduced by push collision resolution.
36    pub collision_gap: Unit,
37}
38
39impl Default for LayoutOptions {
40    fn default() -> Self {
41        Self {
42            collision: CollisionPolicy::default(),
43            logical_unit: Unit::from_raw(Unit::PER_POINT),
44            minimum_size: Unit::from_raw(Unit::PER_POINT),
45            collision_gap: Unit::ZERO,
46        }
47    }
48}
49
50/// Deterministic measure → propose → query → resolve → commit engine.
51pub struct LayoutEngine<'a> {
52    pub(crate) limits: &'a ResourceLimits,
53    pub(crate) fonts: &'a FontManager,
54    pub(crate) options: LayoutOptions,
55    pub(crate) control: OperationControl,
56    pub(crate) assets: Option<&'a dyn AssetResolver>,
57}
58
59#[derive(Clone)]
60pub(crate) struct ElementPlacement {
61    pub(crate) initial_page: usize,
62    pub(crate) page_index: usize,
63    pub(crate) proposed: Rect,
64    pub(crate) rect: Rect,
65    pub(crate) policy: CollisionPolicy,
66    pub(crate) transform: Transform,
67}
68
69impl<'a> LayoutEngine<'a> {
70    /// Creates an engine over explicit limits and fonts.
71    pub fn new(
72        limits: &'a ResourceLimits,
73        fonts: &'a FontManager,
74        options: LayoutOptions,
75    ) -> Result<Self> {
76        Self::new_controlled(limits, fonts, options, OperationControl::default())
77    }
78
79    /// Creates an engine with cooperative cancellation and progress controls.
80    pub fn new_controlled(
81        limits: &'a ResourceLimits,
82        fonts: &'a FontManager,
83        options: LayoutOptions,
84        control: OperationControl,
85    ) -> Result<Self> {
86        limits.validate()?;
87        if options.logical_unit <= Unit::ZERO
88            || options.minimum_size <= Unit::ZERO
89            || options.collision_gap < Unit::ZERO
90        {
91            return Err(layout_error("layout options contain invalid dimensions"));
92        }
93        Ok(Self {
94            limits,
95            fonts,
96            options,
97            control,
98            assets: None,
99        })
100    }
101
102    /// Supplies the explicit resolver needed to resolve image paint geometry.
103    #[must_use]
104    pub fn with_assets(mut self, assets: &'a dyn AssetResolver) -> Self {
105        self.assets = Some(assets);
106        self
107    }
108
109    /// Resolves all geometry before any exporter is selected.
110    pub fn resolve(&self, document: &DocumentIr) -> Result<ResolvedScene> {
111        self.control.checkpoint(ProgressPhase::Layout, 0, None)?;
112        let page_size = document
113            .page_size
114            .ok_or_else(|| layout_error("document/canvas requires an explicit page size"))?;
115        validate_anchor_graph(&document.elements)?;
116        let exclusions = crate::layout_exclusion::resolve_exclusions(
117            document,
118            page_size,
119            self.options.logical_unit,
120        )?;
121        let mut context = LayoutContext::new(
122            page_size,
123            document.page_template.clone(),
124            exclusions,
125            self.limits.max_pages,
126            self.limits.max_elements,
127        );
128        let page_rect = document.page_template.as_ref().map_or_else(
129            || Rect::new(Unit::ZERO, Unit::ZERO, page_size.width, page_size.height),
130            crate::PageTemplate::content_bounds,
131        )?;
132        let document_collision = document
133            .collision
134            .as_ref()
135            .unwrap_or(&self.options.collision);
136        let page_collision = document
137            .page_collision
138            .as_ref()
139            .unwrap_or(document_collision);
140        let regions =
141            crate::layout_region::resolve_regions(document, page_rect, self.options.logical_unit)?;
142        context.regions = regions;
143        self.layout_list(
144            &document.elements,
145            document,
146            page_rect,
147            0,
148            LayoutMode::Absolute,
149            crate::Distribution::Start,
150            Unit::ZERO,
151            page_collision,
152            Transform::IDENTITY,
153            &mut context,
154        )?;
155        context.remove_leading_empty_pages();
156        crate::layout_page::resolve_page_layers(self, document, page_collision, &mut context)?;
157        for page in &mut context.pages {
158            page.elements.sort_by(|left, right| {
159                (&left.layer, left.z_index, left.sequence).cmp(&(
160                    &right.layer,
161                    right.z_index,
162                    right.sequence,
163                ))
164            });
165        }
166        self.control.checkpoint(
167            ProgressPhase::Layout,
168            u64::try_from(context.sequence).unwrap_or(u64::MAX),
169            Some(u64::try_from(self.limits.max_elements).unwrap_or(u64::MAX)),
170        )?;
171        Ok(ResolvedScene {
172            template_id: document.template_id.clone(),
173            pages: context.pages,
174            engine_version: ENGINE_VERSION.to_owned(),
175        })
176    }
177
178    /// Resolves only on a fingerprint miss and returns an immutable shared scene.
179    ///
180    /// The fingerprint must be computed from the same template, data, patches,
181    /// assets, and fonts used to produce `document` and this engine.
182    pub fn resolve_cached(
183        &self,
184        document: &DocumentIr,
185        fingerprint: DocumentFingerprint,
186        cache: &mut SceneCache,
187    ) -> Result<Arc<ResolvedScene>> {
188        cache.get_or_try_insert_with(fingerprint, self.limits, || self.resolve(document))
189    }
190
191    #[allow(clippy::too_many_arguments)]
192    pub(crate) fn layout_list(
193        &self,
194        elements: &[ElementIr],
195        document: &DocumentIr,
196        container: Rect,
197        mut page_index: usize,
198        parent_layout: LayoutMode,
199        distribution: crate::Distribution,
200        gap: Unit,
201        inherited_collision: &CollisionPolicy,
202        parent_transform: Transform,
203        context: &mut LayoutContext,
204    ) -> Result<()> {
205        let flow = crate::layout_flow::plan_flow(
206            elements,
207            container,
208            parent_layout,
209            distribution,
210            gap,
211            self.options.logical_unit,
212        )?;
213        let mut flow_x = flow.x;
214        let mut flow_y = flow.y;
215        let effective_gap = flow.gap;
216        let visible: Vec<_> = elements
217            .iter()
218            .filter(|element| element.page_placement.is_none())
219            .filter(|element| !element.hidden)
220            .collect();
221        let mut index = 0;
222        while index < visible.len() {
223            let element = visible[index];
224            context.checkpoint(&self.control, self.limits.max_elements)?;
225            crate::layout_keep::validate_flow(parent_layout, element)?;
226            if element.keep_with_next {
227                (page_index, flow_y) = self.defer_kept_block(
228                    &visible,
229                    index,
230                    container,
231                    flow_x,
232                    flow_y,
233                    effective_gap,
234                    page_index,
235                    document,
236                    context,
237                )?;
238            }
239            page_index = self.layout_element_at_flow(
240                element,
241                document,
242                container,
243                page_index,
244                parent_layout,
245                &mut flow_x,
246                &mut flow_y,
247                effective_gap,
248                inherited_collision,
249                parent_transform,
250                context,
251            )?;
252            index += 1;
253        }
254        Ok(())
255    }
256
257    #[allow(clippy::too_many_arguments)]
258    fn defer_kept_block(
259        &self,
260        visible: &[&ElementIr],
261        index: usize,
262        container: Rect,
263        flow_x: Unit,
264        flow_y: Unit,
265        gap: Unit,
266        page_index: usize,
267        document: &DocumentIr,
268        context: &LayoutContext,
269    ) -> Result<(usize, Unit)> {
270        let mut end = index + 1;
271        while end < visible.len() && visible[end - 1].keep_with_next {
272            end += 1;
273        }
274        if end == index + 1 || visible[end - 1].keep_with_next {
275            return Err(
276                layout_error("keep_with_next must be followed by another visible sibling")
277                    .at(visible[index].id.as_str()),
278            );
279        }
280        let height = crate::layout_keep::block_height(
281            &visible[index..end],
282            container,
283            flow_x,
284            flow_y,
285            gap,
286            self.options.logical_unit,
287            &context.positions,
288            &document.guides,
289            self.fonts,
290        )?;
291        if crate::layout_keep::should_defer(height, flow_y, container)? {
292            Ok((
293                crate::layout_keep::next_page(page_index)?,
294                container.origin.y,
295            ))
296        } else {
297            Ok((page_index, flow_y))
298        }
299    }
300
301    #[allow(clippy::too_many_arguments)]
302    fn layout_element_at_flow(
303        &self,
304        element: &ElementIr,
305        document: &DocumentIr,
306        container: Rect,
307        page_index: usize,
308        parent_layout: LayoutMode,
309        flow_x: &mut Unit,
310        flow_y: &mut Unit,
311        gap: Unit,
312        inherited_collision: &CollisionPolicy,
313        parent_transform: Transform,
314        context: &mut LayoutContext,
315    ) -> Result<usize> {
316        let flow_origin = crate::Point {
317            x: *flow_x,
318            y: *flow_y,
319        };
320        if let Some(last) = self.commit_expanded_text_fragments(
321            element,
322            document,
323            container,
324            page_index,
325            parent_layout,
326            flow_origin,
327            inherited_collision,
328            parent_transform,
329            context,
330        )? {
331            self.record_flow_end(element, &last, parent_layout, gap, flow_x, flow_y, context)?;
332            return Ok(last.page_index);
333        }
334        let placement_page = crate::layout_geometry::anchor_page(
335            element,
336            &context.positions,
337            self.options.logical_unit,
338        )?
339        .unwrap_or(page_index);
340        let placement = self.place_element(
341            element,
342            document,
343            container,
344            placement_page,
345            parent_layout,
346            flow_origin,
347            inherited_collision,
348            parent_transform,
349            context,
350        )?;
351        if element.kind == crate::ElementKind::Table {
352            let last = self.commit_table_fragments(
353                element,
354                document,
355                container,
356                parent_layout,
357                flow_origin,
358                inherited_collision,
359                parent_transform,
360                placement,
361                context,
362            )?;
363            self.record_flow_end(element, &last, parent_layout, gap, flow_x, flow_y, context)?;
364            return Ok(last.page_index);
365        }
366        self.commit_regular_element(
367            element,
368            document,
369            placement,
370            parent_layout,
371            gap,
372            flow_x,
373            flow_y,
374            context,
375        )
376    }
377
378    #[allow(clippy::too_many_arguments)]
379    fn record_flow_end(
380        &self,
381        element: &ElementIr,
382        last: &ElementPlacement,
383        parent_layout: LayoutMode,
384        gap: Unit,
385        flow_x: &mut Unit,
386        flow_y: &mut Unit,
387        context: &mut LayoutContext,
388    ) -> Result<()> {
389        context
390            .positions
391            .insert(element.id.as_str().to_owned(), (last.page_index, last.rect));
392        advance_flow(parent_layout, last.rect, gap, flow_x, flow_y)?;
393        Ok(())
394    }
395
396    #[allow(clippy::too_many_arguments)]
397    fn commit_regular_element(
398        &self,
399        element: &ElementIr,
400        document: &DocumentIr,
401        placement: ElementPlacement,
402        parent_layout: LayoutMode,
403        gap: Unit,
404        flow_x: &mut Unit,
405        flow_y: &mut Unit,
406        context: &mut LayoutContext,
407    ) -> Result<usize> {
408        let resolved = self.build_resolved_element(element, &placement, context)?;
409        context.commit(placement.page_index, resolved, placement.policy.clone())?;
410        context.positions.insert(
411            element.id.as_str().to_owned(),
412            (placement.page_index, placement.rect),
413        );
414        advance_flow(parent_layout, placement.rect, gap, flow_x, flow_y)?;
415        let child_gap = element
416            .gap
417            .resolve(placement.rect.size.width, self.options.logical_unit)?
418            .unwrap_or(Unit::ZERO);
419        self.layout_list(
420            &element.children,
421            document,
422            placement.rect,
423            placement.page_index,
424            element.layout,
425            element.distribute,
426            child_gap,
427            &placement.policy,
428            placement.transform,
429            context,
430        )?;
431        Ok(placement.page_index)
432    }
433}
434
435fn advance_flow(
436    layout: LayoutMode,
437    rect: Rect,
438    gap: Unit,
439    flow_x: &mut Unit,
440    flow_y: &mut Unit,
441) -> Result<()> {
442    match layout {
443        LayoutMode::FlowVertical => *flow_y = rect.bottom()?.checked_add(gap)?,
444        LayoutMode::FlowHorizontal => *flow_x = rect.right()?.checked_add(gap)?,
445        LayoutMode::Absolute => {}
446    }
447    Ok(())
448}