Skip to main content

appcore_filemaker/
table_columns.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: table_columns.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 table columns contracts and behavior for this crate.
12
13use crate::{ColumnWidth, Dataset, ErrorCode, FileMakerError, Result, TableSpec, Unit};
14
15/// One exporter-neutral table column with a fixed resolved width.
16#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
17pub struct ResolvedTableColumn {
18    /// Stable data field.
19    pub field: String,
20    /// Header text.
21    pub header: String,
22    /// Final fixed-point width.
23    pub width: Unit,
24}
25
26/// Resolves fixed, bounded-auto, and weighted-flex columns in declaration order.
27pub fn resolve_table_columns(
28    spec: &TableSpec,
29    dataset: &dyn Dataset,
30    available_width: Unit,
31    logical_unit: Unit,
32    measure: &mut dyn FnMut(&str) -> Result<Unit>,
33) -> Result<Vec<ResolvedTableColumn>> {
34    spec.validate()?;
35    if available_width <= Unit::ZERO || logical_unit <= Unit::ZERO {
36        return Err(column_error("table column dimensions must be positive"));
37    }
38    let mut widths = Vec::with_capacity(spec.columns.len());
39    let mut flex_total = 0_u64;
40    for column in &spec.columns {
41        let width = match column.width {
42            ColumnWidth::Fixed(length) => length
43                .resolve(available_width, logical_unit)?
44                .ok_or_else(|| column_error("fixed table column cannot be auto"))?,
45            ColumnWidth::Auto => checked_measure(measure, &column.header)?,
46            ColumnWidth::Flex(weight) => {
47                flex_total = flex_total
48                    .checked_add(u64::from(weight))
49                    .ok_or_else(|| column_error("table flex weight overflow"))?;
50                Unit::ZERO
51            }
52        };
53        if !matches!(column.width, ColumnWidth::Flex(_)) && width <= Unit::ZERO {
54            return Err(column_error(
55                "fixed and automatic column widths must be positive",
56            ));
57        }
58        widths.push(width);
59    }
60    if spec
61        .columns
62        .iter()
63        .any(|column| matches!(column.width, ColumnWidth::Auto))
64    {
65        spec.visit_bounded_until(dataset, &mut |index, row| {
66            for (column_index, column) in spec.columns.iter().enumerate() {
67                if matches!(column.width, ColumnWidth::Auto) {
68                    let value = row
69                        .get(&column.field)
70                        .map_or_else(String::new, crate::DataValue::display);
71                    widths[column_index] =
72                        widths[column_index].max(checked_measure(measure, &value)?);
73                }
74            }
75            Ok(index + 1 < u64::try_from(spec.auto_sample_rows).unwrap_or(u64::MAX))
76        })?;
77    }
78    let fixed_total = widths
79        .iter()
80        .try_fold(Unit::ZERO, |total, width| total.checked_add(*width))?;
81    let remaining = available_width.checked_sub(fixed_total)?;
82    if remaining < Unit::ZERO {
83        return Err(column_error(
84            "fixed and automatic columns exceed table width",
85        ));
86    }
87    if flex_total > 0 {
88        distribute_flex(spec, &mut widths, remaining, flex_total)?;
89    }
90    if widths.iter().any(|width| *width <= Unit::ZERO) {
91        return Err(column_error(
92            "resolved table columns must have positive width",
93        ));
94    }
95    Ok(spec
96        .columns
97        .iter()
98        .zip(widths)
99        .map(|(column, width)| ResolvedTableColumn {
100            field: column.field.clone(),
101            header: column.header.clone(),
102            width,
103        })
104        .collect())
105}
106
107fn distribute_flex(
108    spec: &TableSpec,
109    widths: &mut [Unit],
110    remaining: Unit,
111    flex_total: u64,
112) -> Result<()> {
113    let last_flex = spec
114        .columns
115        .iter()
116        .rposition(|column| matches!(column.width, ColumnWidth::Flex(_)))
117        .ok_or_else(|| column_error("flex total has no flex column"))?;
118    let mut assigned = Unit::ZERO;
119    for (index, column) in spec.columns.iter().enumerate() {
120        let ColumnWidth::Flex(weight) = column.width else {
121            continue;
122        };
123        let width = if index == last_flex {
124            remaining.checked_sub(assigned)?
125        } else {
126            Unit::from_ratio(
127                i128::from(remaining.raw()) * i128::from(weight),
128                i128::from(flex_total) * i128::from(Unit::PER_POINT),
129            )?
130        };
131        widths[index] = width;
132        assigned = assigned.checked_add(width)?;
133    }
134    Ok(())
135}
136
137fn checked_measure(measure: &mut dyn FnMut(&str) -> Result<Unit>, value: &str) -> Result<Unit> {
138    let width = measure(value)?;
139    if width < Unit::ZERO {
140        return Err(column_error("table text measurement cannot be negative"));
141    }
142    Ok(width)
143}
144
145fn column_error(message: impl Into<String>) -> FileMakerError {
146    FileMakerError::new(ErrorCode::LayoutInvalid, message)
147}