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, Insets, 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    /// Inline alignment applied to every resolved cell.
25    pub align_x: crate::Alignment,
26    /// Resolved per-side cell padding.
27    #[serde(default)]
28    pub padding: Insets,
29}
30
31/// Resolves fixed, bounded-auto, and weighted-flex columns in declaration order.
32pub fn resolve_table_columns(
33    spec: &TableSpec,
34    dataset: &dyn Dataset,
35    available_width: Unit,
36    logical_unit: Unit,
37    measure: &mut dyn FnMut(&str) -> Result<Unit>,
38) -> Result<Vec<ResolvedTableColumn>> {
39    spec.validate()?;
40    if available_width <= Unit::ZERO || logical_unit <= Unit::ZERO {
41        return Err(column_error("table column dimensions must be positive"));
42    }
43    let mut widths = Vec::with_capacity(spec.columns.len());
44    let mut flex_total = 0_u64;
45    for column in &spec.columns {
46        let width = match column.width {
47            ColumnWidth::Fixed(length) => length
48                .resolve(available_width, logical_unit)?
49                .ok_or_else(|| column_error("fixed table column cannot be auto"))?,
50            ColumnWidth::Auto => checked_measure(measure, &column.header)?
51                .checked_add(horizontal_padding(column, logical_unit)?)?,
52            ColumnWidth::Flex(weight) => {
53                flex_total = flex_total
54                    .checked_add(u64::from(weight))
55                    .ok_or_else(|| column_error("table flex weight overflow"))?;
56                Unit::ZERO
57            }
58        };
59        if !matches!(column.width, ColumnWidth::Flex(_)) && width <= Unit::ZERO {
60            return Err(column_error(
61                "fixed and automatic column widths must be positive",
62            ));
63        }
64        widths.push(width);
65    }
66    if spec
67        .columns
68        .iter()
69        .any(|column| matches!(column.width, ColumnWidth::Auto))
70    {
71        spec.visit_bounded_until(dataset, &mut |index, row| {
72            for (column_index, column) in spec.columns.iter().enumerate() {
73                if matches!(column.width, ColumnWidth::Auto) {
74                    let value = row
75                        .get(&column.field)
76                        .map_or_else(String::new, crate::DataValue::display);
77                    widths[column_index] = widths[column_index].max(
78                        checked_measure(measure, &value)?
79                            .checked_add(horizontal_padding(column, logical_unit)?)?,
80                    );
81                }
82            }
83            Ok(index + 1 < u64::try_from(spec.auto_sample_rows).unwrap_or(u64::MAX))
84        })?;
85    }
86    let fixed_total = widths
87        .iter()
88        .try_fold(Unit::ZERO, |total, width| total.checked_add(*width))?;
89    let remaining = available_width.checked_sub(fixed_total)?;
90    if remaining < Unit::ZERO {
91        return Err(column_error(
92            "fixed and automatic columns exceed table width",
93        ));
94    }
95    if flex_total > 0 {
96        distribute_flex(spec, &mut widths, remaining, flex_total)?;
97    }
98    if widths.iter().any(|width| *width <= Unit::ZERO) {
99        return Err(column_error(
100            "resolved table columns must have positive width",
101        ));
102    }
103    spec.columns
104        .iter()
105        .zip(widths)
106        .map(|(column, width)| {
107            Ok(ResolvedTableColumn {
108                field: column.field.clone(),
109                header: column.header.clone(),
110                width,
111                align_x: column.align_x,
112                padding: column.padding.resolve(logical_unit)?,
113            })
114        })
115        .collect::<Result<Vec<_>>>()
116}
117
118fn horizontal_padding(column: &crate::TableColumn, logical_unit: Unit) -> Result<Unit> {
119    let padding = column.padding.resolve(logical_unit)?;
120    padding.left.checked_add(padding.right)
121}
122
123fn distribute_flex(
124    spec: &TableSpec,
125    widths: &mut [Unit],
126    remaining: Unit,
127    flex_total: u64,
128) -> Result<()> {
129    let last_flex = spec
130        .columns
131        .iter()
132        .rposition(|column| matches!(column.width, ColumnWidth::Flex(_)))
133        .ok_or_else(|| column_error("flex total has no flex column"))?;
134    let mut assigned = Unit::ZERO;
135    for (index, column) in spec.columns.iter().enumerate() {
136        let ColumnWidth::Flex(weight) = column.width else {
137            continue;
138        };
139        let width = if index == last_flex {
140            remaining.checked_sub(assigned)?
141        } else {
142            Unit::from_ratio(
143                i128::from(remaining.raw()) * i128::from(weight),
144                i128::from(flex_total) * i128::from(Unit::PER_POINT),
145            )?
146        };
147        widths[index] = width;
148        assigned = assigned.checked_add(width)?;
149    }
150    Ok(())
151}
152
153fn checked_measure(measure: &mut dyn FnMut(&str) -> Result<Unit>, value: &str) -> Result<Unit> {
154    let width = measure(value)?;
155    if width < Unit::ZERO {
156        return Err(column_error("table text measurement cannot be negative"));
157    }
158    Ok(width)
159}
160
161fn column_error(message: impl Into<String>) -> FileMakerError {
162    FileMakerError::new(ErrorCode::LayoutInvalid, message)
163}