Skip to main content

atmos/os_lib/layout/
grid.rs

1// 分割: layout.rs より機械的に移動(2026-07-16 リファクタ フェーズ4)。
2// ロジック不変。可視性のみ pub(crate) へ昇格し、親が pub(crate) use で再エクスポート。
3use super::*;
4
5/// 文字列が `subgrid` 構文であるかを判定する。
6/// W3C CSS Grid 2: `subgrid <line-name-list>?`。
7pub(crate) fn is_subgrid(s: &str) -> bool {
8    let t = s.trim();
9    if t.eq_ignore_ascii_case("subgrid") {
10        return true;
11    }
12    if t.get(..7).is_some_and(|h| h.eq_ignore_ascii_case("subgrid")) && t.as_bytes().get(7).is_some_and(|b| b.is_ascii_whitespace()) {
13        return true;
14    }
15    false
16}
17
18#[derive(Debug, Clone)]
19pub(crate) struct SubgridContext {
20    pub col_widths: Vec<i32>,
21    pub col_gap: i32,
22}
23
24pub(crate) static SUBGRID_CTX: spin::Mutex<Option<SubgridContext>> = spin::Mutex::new(None);
25
26pub(crate) fn set_subgrid_ctx(ctx: Option<SubgridContext>) -> Option<SubgridContext> {
27    let mut lock = SUBGRID_CTX.lock();
28    let prev = lock.clone();
29    *lock = ctx;
30    prev
31}
32
33pub(crate) fn get_subgrid_ctx() -> Option<SubgridContext> {
34    SUBGRID_CTX.lock().clone()
35}
36
37/// `grid-template-columns` を解析して列幅 Vec を返す。
38/// `repeat(N, W)` / `repeat(auto-fit, minmax(Npx, 1fr))` / `1fr 2fr` / 固定 px に対応。
39pub(crate) fn parse_grid_template_columns(s: &str, avail_w: i32) -> Vec<i32> {
40    parse_grid_template_columns_gap(s, avail_w, 0)
41}
42
43/// `parse_grid_template_columns` の列間 gap 考慮版。
44///
45/// 【2026-09-26 バグ修正】従来は gap を知らずに列幅を決めていたため、
46/// `1fr 1fr; gap:4rem` で各列がコンテナの半分になり、gap の分だけ右へはみ出した。
47/// CSS Grid 仕様どおり、(a) fr へ配る残り幅から列間 gap の合計を引き、
48/// (b) `repeat(auto-fit|auto-fill, minmax(M, ..))` の列数を
49/// `floor((幅 + gap) / (M + gap))` で決める(n 列 + (n-1) gap が幅に収まる最大の n)。
50pub(crate) fn parse_grid_template_columns_gap(s: &str, avail_w: i32, gap: i32) -> Vec<i32> {
51    let gap = gap.max(0);
52    let s = s.trim();
53    if s.is_empty() {
54        return alloc::vec![avail_w];
55    }
56    // `repeat(N, W)` or `repeat(auto-fit, minmax(min_px, 1fr))`
57    if let Some(inner) = s.strip_prefix("repeat(").and_then(|t| t.strip_suffix(')')) {
58        if let Some((count_str, rest)) = inner.split_once(',') {
59            let count_s = count_str.trim();
60            let col_def = rest.trim();
61            // minmax(Xpx, 1fr) → auto-fit では コンテナ幅から min で列数を決める。
62            let min_w = if let Some(mm) = col_def
63                .strip_prefix("minmax(")
64                .and_then(|t| t.strip_suffix(')'))
65            {
66                mm.split_once(',')
67                    .and_then(|(a, _)| resolve_track_tok_opt(a.trim(), avail_w))
68                    .unwrap_or(200)
69            } else {
70                parse_px_like(col_def).unwrap_or(avail_w)
71            };
72            let n: usize = if count_s == "auto-fit" || count_s == "auto-fill" {
73                (libm::floorf((avail_w + gap) as f32 / (min_w.max(1) + gap) as f32) as usize).max(1)
74            } else {
75                count_s.parse::<usize>().unwrap_or(1).max(1)
76            };
77            // 固定 px 列定義(`repeat(3,100px)`)はその幅を尊重。fr/minmax/% は均等割り。
78            let is_fixed_px = !col_def.ends_with("fr")
79                && !col_def.starts_with("minmax(")
80                && !col_def.ends_with('%')
81                && parse_px_like(col_def).is_some();
82            let col_w = if is_fixed_px {
83                parse_px_like(col_def).unwrap_or(1).max(1)
84            } else {
85                ((avail_w - gap * (n as i32 - 1)) / n as i32).max(1)
86            };
87            return (0..n).map(|_| col_w).collect();
88        }
89    }
90    // スペース区切りのトークン(1fr / 2fr / 200px / % など)
91    let tokens: alloc::vec::Vec<&str> = s.split_whitespace().collect();
92    if tokens.is_empty() {
93        return alloc::vec![avail_w];
94    }
95    // `%` トラック(例: `50% 1fr`)を avail_w 基準で px へ解決する。`parse_px_like` は
96    // px/vh/vw/rem/em のみ対応し `%` を扱わないため(`repeat()`/`minmax()` 経由の
97    // 固定px判定でも同様に `%` は別扱いされている、既存の `is_fixed_px` 参照)、
98    // ここで専用に解決する。
99    fn resolve_track_tok_opt(tok: &str, avail_w: i32) -> Option<i32> {
100        if let Some(pct) = tok.strip_suffix('%') {
101            return pct
102                .trim()
103                .parse::<f32>()
104                .ok()
105                .map(|p| round_f32_to_i32(avail_w as f32 * p / 100.0));
106        }
107        parse_px_like(tok)
108    }
109    fn resolve_track_tok(tok: &str, avail_w: i32) -> i32 {
110        resolve_track_tok_opt(tok, avail_w).unwrap_or(0)
111    }
112    // fr 合計と固定幅を分けて残り幅を分配。
113    let mut fixed_total = 0i32;
114    let mut fr_total = 0.0f32;
115    for tok in &tokens {
116        if let Some(fr) = tok.strip_suffix("fr") {
117            fr_total += fr.trim().parse::<f32>().unwrap_or(1.0);
118        } else {
119            fixed_total += resolve_track_tok(tok, avail_w);
120        }
121    }
122    let gaps_total = gap * (tokens.len() as i32 - 1).max(0);
123    let remaining = (avail_w - fixed_total - gaps_total).max(0);
124    tokens
125        .iter()
126        .map(|tok| {
127            if let Some(fr) = tok.strip_suffix("fr") {
128                let f = fr.trim().parse::<f32>().unwrap_or(1.0);
129                if fr_total > 0.0 {
130                    round_f32_to_i32(remaining as f32 * f / fr_total)
131                } else {
132                    remaining
133                }
134            } else {
135                resolve_track_tok(tok, avail_w)
136            }
137        })
138        .collect()
139}
140
141/// グリッドトラック(行/列の 1 トラック)のサイズ種別。
142#[derive(Clone, Copy, Debug, PartialEq)]
143pub(crate) enum GridTrack {
144    Px(i32),
145    Fr(f32),
146    Auto,
147    MinContent,
148    MaxContent,
149    FitContent(i32),
150    /// minmax(min_px, max): max が fr なら伸長可能だが最低 min_px を保証。
151    /// max が auto/px のときは Fr(0) 相当で扱い、行高さは内容 or max_px と min_px の間。
152    MinMax {
153        min_px: i32,
154        max_fr: f32,
155        max_px: i32,
156        max_is_fr: bool,
157        max_is_auto: bool,
158    },
159}
160
161/// トップレベル(括弧の外)の空白でトークン分割する。`minmax(a, b)` / `repeat(n, x)`
162/// の括弧内の空白では分割しない。`string_slice` lint 回避のため char_indices + get を使う。
163pub(crate) fn split_top_level_ws(s: &str) -> Vec<&str> {
164    let mut out = Vec::new();
165    let mut depth = 0i32;
166    let mut start = 0usize;
167    let mut in_tok = false;
168    for (i, ch) in s.char_indices() {
169        match ch {
170            '(' => depth += 1,
171            ')' => depth -= 1,
172            c if c.is_whitespace() && depth == 0 => {
173                if in_tok {
174                    if let Some(t) = s.get(start..i) {
175                        out.push(t);
176                    }
177                    in_tok = false;
178                }
179                continue;
180            }
181            _ => {}
182        }
183        if !in_tok {
184            start = i;
185            in_tok = true;
186        }
187    }
188    if in_tok {
189        if let Some(t) = s.get(start..) {
190            out.push(t);
191        }
192    }
193    out
194}
195
196/// 単一トラック定義(`100px` / `2fr` / `auto` / `50%` / `minmax(min,max)`)を解釈。
197/// base は % 解決と auto 折衝の基準サイズ。
198pub(crate) fn parse_one_grid_track(tok: &str, base: i32) -> GridTrack {
199    let tok = tok.trim();
200    if tok.is_empty() || tok.eq_ignore_ascii_case("auto") {
201        return GridTrack::Auto;
202    }
203    if tok.eq_ignore_ascii_case("min-content") {
204        return GridTrack::MinContent;
205    }
206    if tok.eq_ignore_ascii_case("max-content") {
207        return GridTrack::MaxContent;
208    }
209    if let Some(fit) = tok
210        .strip_prefix("fit-content(")
211        .and_then(|t| t.strip_suffix(')'))
212    {
213        let limit = match parse_length_value(Some(fit.trim())) {
214            Some(LengthValue::Px(px)) => px.max(0),
215            Some(LengthValue::Percent(p)) => round_f32_to_i32(base as f32 * p).max(0),
216            _ => parse_px_like(fit.trim()).unwrap_or(base).max(0),
217        };
218        return GridTrack::FitContent(limit);
219    }
220    if let Some(fr) = tok.strip_suffix("fr") {
221        return GridTrack::Fr(fr.trim().parse::<f32>().unwrap_or(1.0).max(0.0));
222    }
223    if let Some(mm) = tok
224        .strip_prefix("minmax(")
225        .and_then(|t| t.strip_suffix(')'))
226    {
227        // minmax(min, max): min を下限 px、max を上限(fr/px/auto)として保持。
228        if let Some((a, b)) = mm.split_once(',') {
229            let min_px = match parse_length_value(Some(a.trim())) {
230                Some(LengthValue::Px(px)) => px.max(0),
231                Some(LengthValue::Percent(p)) => round_f32_to_i32(base as f32 * p).max(0),
232                _ => 0, // auto/min-content 等は 0 下限扱い
233            };
234            let bt = b.trim();
235            if let Some(fr) = bt.strip_suffix("fr") {
236                return GridTrack::MinMax {
237                    min_px,
238                    max_fr: fr.trim().parse::<f32>().unwrap_or(1.0).max(0.0),
239                    max_px: 0,
240                    max_is_fr: true,
241                    max_is_auto: false,
242                };
243            }
244            let (max_px, max_is_auto) = match parse_length_value(Some(bt)) {
245                Some(LengthValue::Px(px)) => (px.max(0), false),
246                Some(LengthValue::Percent(p)) => (round_f32_to_i32(base as f32 * p).max(0), false),
247                _ => (0, true), // auto/max-content
248            };
249            return GridTrack::MinMax {
250                min_px,
251                max_fr: 0.0,
252                max_px,
253                max_is_fr: false,
254                max_is_auto,
255            };
256        }
257    }
258    match parse_length_value(Some(tok)) {
259        Some(LengthValue::Px(px)) => GridTrack::Px(px),
260        Some(LengthValue::Percent(p)) => GridTrack::Px(round_f32_to_i32(base as f32 * p)),
261        _ => GridTrack::Auto,
262    }
263}
264
265/// `grid-template-rows` / `grid-template-columns` を GridTrack のリストへ展開。
266/// `repeat(N, def)` / `repeat(auto-fit|auto-fill, def)` を展開する。base は %/auto-fit 用。
267pub(crate) fn parse_grid_tracks(s: &str, base: i32) -> Vec<GridTrack> {
268    let s = s.trim();
269    if s.is_empty() {
270        return Vec::new();
271    }
272    let mut out = Vec::new();
273    for tok in split_top_level_ws(s) {
274        if let Some(inner) = tok
275            .strip_prefix("repeat(")
276            .and_then(|t| t.strip_suffix(')'))
277        {
278            if let Some((count_str, rest)) = inner.split_once(',') {
279                let count_s = count_str.trim();
280                let track = parse_one_grid_track(rest.trim(), base);
281                let n: usize = if count_s == "auto-fit" || count_s == "auto-fill" {
282                    let min_w = match track {
283                        GridTrack::Px(px) => px.max(1),
284                        GridTrack::MinMax { min_px, .. } => min_px.max(1),
285                        _ => 1,
286                    };
287                    (libm::floorf(base as f32 / min_w as f32) as usize).max(1)
288                } else {
289                    count_s.parse::<usize>().unwrap_or(1).max(1)
290                };
291                for _ in 0..n {
292                    out.push(track);
293                }
294                continue;
295            }
296        }
297        out.push(parse_one_grid_track(tok, base));
298    }
299    out
300}
301
302/// グリッドアイテムの 1 軸の配置を解決する。
303/// `short`(grid-row / grid-column のショートハンド `a / b`)を優先し、無ければ
304/// longhand(`*-start` / `*-end`)を読む。戻り値は (明示開始行0始まり, スパン)。
305/// 明示開始が無い(auto)の場合は None。
306pub(crate) fn grid_axis_placement(
307    b: &LayoutBox,
308    short: &str,
309    start_p: &str,
310    end_p: &str,
311) -> (Option<usize>, usize) {
312    let (sv, ev) = if let Some(v) = box_style_value(b, short) {
313        if let Some((a, c)) = v.split_once('/') {
314            (Some(String::from(a.trim())), Some(String::from(c.trim())))
315        } else {
316            (Some(String::from(v.trim())), None)
317        }
318    } else {
319        (
320            box_style_value(b, start_p).map(|s| String::from(s.trim())),
321            box_style_value(b, end_p).map(|s| String::from(s.trim())),
322        )
323    };
324    let mut start: Option<usize> = None;
325    let mut span: usize = 1;
326    if let Some(sv) = &sv {
327        if let Some(n) = sv.strip_prefix("span ") {
328            span = n.trim().parse::<usize>().unwrap_or(1).max(1);
329        } else if let Ok(line) = sv.parse::<i32>() {
330            if line >= 1 {
331                start = Some((line - 1) as usize);
332            }
333        }
334    }
335    if let Some(ev) = &ev {
336        if let Some(n) = ev.strip_prefix("span ") {
337            span = n.trim().parse::<usize>().unwrap_or(1).max(1);
338        } else if let Ok(eline) = ev.parse::<i32>() {
339            if let Some(s0) = start {
340                let e0 = (eline - 1).max(0) as usize;
341                if e0 > s0 {
342                    span = e0 - s0;
343                }
344            }
345        }
346    }
347    (start, span)
348}
349
350/// `grid-template-areas` を解析する。各文字列リテラル(クォートされた1行)が1グリッド行、
351/// 空白区切りのトークンがその行の各列のセル名になる。`.` は未使用セル(名前なし)扱い。
352/// 例: `"header header" "sidebar content"` → [["header","header"],["sidebar","content"]]
353pub(crate) fn parse_grid_template_areas(s: &str) -> Vec<Vec<String>> {
354    let mut rows = Vec::new();
355    let mut chars = s.chars().peekable();
356    while let Some(c) = chars.next() {
357        if c == '"' || c == '\'' {
358            let quote = c;
359            let mut row_str = String::new();
360            for c2 in chars.by_ref() {
361                if c2 == quote {
362                    break;
363                }
364                row_str.push(c2);
365            }
366            let row: Vec<String> = row_str
367                .split_whitespace()
368                .map(String::from)
369                .collect();
370            if !row.is_empty() {
371                rows.push(row);
372            }
373        }
374    }
375    rows
376}
377
378/// `template_areas` 内で `name` という名前のセルが占める矩形のバウンディングボックスを返す。
379/// (row_start, col_start, row_span, col_span)(いずれも0始まり)。`.` は名前なしセルなので対象外。
380pub(crate) fn find_grid_area(
381    template_areas: &[Vec<String>],
382    name: &str,
383) -> Option<(usize, usize, usize, usize)> {
384    if name == "." {
385        return None;
386    }
387    let mut min_r = usize::MAX;
388    let mut max_r = 0usize;
389    let mut min_c = usize::MAX;
390    let mut max_c = 0usize;
391    let mut found = false;
392    for (r, row) in template_areas.iter().enumerate() {
393        for (c, cell) in row.iter().enumerate() {
394            if cell == name {
395                found = true;
396                min_r = min_r.min(r);
397                max_r = max_r.max(r);
398                min_c = min_c.min(c);
399                max_c = max_c.max(c);
400            }
401        }
402    }
403    if !found {
404        return None;
405    }
406    Some((min_r, min_c, max_r - min_r + 1, max_c - min_c + 1))
407}
408
409/// occupancy 行を必要分まで確保(n_cols 幅の false 行を追加)。
410pub(crate) fn grid_ensure_row(occ: &mut Vec<Vec<bool>>, r: usize, n_cols: usize) {
411    while occ.len() <= r {
412        occ.push(alloc::vec![false; n_cols]);
413    }
414}
415
416/// `grid-auto-flow: column` が明示列数を超えて新しい列を作った際、既存の全行の
417/// occupancy 幅を min_cols まで広げる(新規に作る行は grid_ensure_row 側で
418/// 正しい幅で作られるため対象外。既存行だけ広げれば足りる)。
419pub(crate) fn grid_ensure_col(occ: &mut [Vec<bool>], min_cols: usize) {
420    for row in occ.iter_mut() {
421        while row.len() < min_cols {
422            row.push(false);
423        }
424    }
425}
426
427/// (r,c) から row_span×col_span の矩形が全て空いているか。
428pub(crate) fn grid_cells_free(
429    occ: &[Vec<bool>],
430    r: usize,
431    c: usize,
432    row_span: usize,
433    col_span: usize,
434    n_cols: usize,
435) -> bool {
436    if c + col_span > n_cols {
437        return false;
438    }
439    for rr in r..r + row_span {
440        let Some(row) = occ.get(rr) else { continue };
441        for cc in c..c + col_span {
442            if row.get(cc).copied().unwrap_or(false) {
443                return false;
444            }
445        }
446    }
447    true
448}
449
450/// (r,c) から row_span×col_span を占有済みにする。
451pub(crate) fn grid_mark(
452    occ: &mut [Vec<bool>],
453    r: usize,
454    c: usize,
455    row_span: usize,
456    col_span: usize,
457    n_cols: usize,
458) {
459    for rr in r..r + row_span {
460        let Some(row) = occ.get_mut(rr) else { continue };
461        for cc in c..(c + col_span).min(n_cols) {
462            if let Some(cell) = row.get_mut(cc) {
463                *cell = true;
464            }
465        }
466    }
467}
468
469/// 列インデックス col の左端 x オフセット(列幅 + col_gap の累積)。
470pub(crate) fn grid_col_x(col_widths: &[i32], col_gap: i32, col: usize) -> i32 {
471    let mut x = 0i32;
472    for (i, w) in col_widths.iter().enumerate() {
473        if i >= col {
474            break;
475        }
476        x += w + col_gap;
477    }
478    x
479}
480
481/// col から col_span 列ぶんの合計幅(内側の col_gap を含む)。
482pub(crate) fn grid_span_width(col_widths: &[i32], col_gap: i32, col: usize, col_span: usize) -> i32 {
483    let n = col_widths.len();
484    if n == 0 {
485        return 1;
486    }
487    let end = (col + col_span).min(n);
488    let start = col.min(n.saturating_sub(1));
489    let mut w = 0i32;
490    for item in col_widths.iter().take(end).skip(start) {
491        w += *item;
492    }
493    w += (end.saturating_sub(start).saturating_sub(1) as i32) * col_gap;
494    w.max(1)
495}
496
497
498/// `grid-template-columns` の各列の fr 係数(fr でない列・暗黙列は `None`)。
499///
500/// `expand_flexible_tracks` で「自動最小(min-content)を持つ fr 列」を
501/// 見分けるのに使う。`minmax(固定, 1fr)` は下限が固定なので対象外(`None`)。
502pub(crate) fn flex_factors(template: &str, n: usize) -> Vec<Option<f32>> {
503    let fr_of = |tok: &str| -> Option<f32> {
504        let f = tok.trim().strip_suffix("fr")?.trim().parse::<f32>().ok()?;
505        (f > 0.0).then_some(f)
506    };
507    let t = template.trim();
508    let mut out: Vec<Option<f32>> =
509        if let Some(inner) = t.strip_prefix("repeat(").and_then(|x| x.strip_suffix(')')) {
510            let f = inner.split_once(',').and_then(|(_, def)| fr_of(def));
511            alloc::vec![f; n]
512        } else {
513            t.split_whitespace().map(fr_of).collect()
514        };
515    out.resize(n, None);
516    out
517}
518
519/// fr 列の自動最小を考慮して列幅を配り直す(CSS Grid §12.7.1「fr の大きさを求める」の簡略版)。
520///
521/// 【2026-10-04】`1fr` は `minmax(auto, 1fr)` で、列は中身の最小幅(min-content)より
522/// 狭くならない。従来は文字列だけで幅を配っていたため、sugi-lab.net トップの
523/// `.about-content{grid-template-columns:1fr 1fr}` が Chrome(左 443.9・右 376.1)と違い
524/// 410/410 の等分になり、左列の統計カードがはみ出していた。
525///
526/// - `base`: 文字列から配った列幅(fr 列は残り幅を係数比で配った値)
527/// - `flex`: 各列の fr 係数(`flex_factors`)。`None` の列は変えない
528/// - `mins`: 各列の最小幅(その列だけに入る要素の min-content の最大)
529///
530/// 最小幅を下回る fr 列をその幅に固定し、残りを固定していない fr 列へ係数比で配る。
531/// 固定したことで別の列が足りなくなれば繰り返す。全部固定して収まらなければ
532/// 最小幅のまま(はみ出す)。満たしていれば `base` をそのまま返す。
533pub(crate) fn expand_flexible_tracks(base: &[i32], flex: &[Option<f32>], mins: &[i32]) -> Vec<i32> {
534    let n = base.len();
535    let f = |i: usize| flex.get(i).copied().flatten();
536    let m = |i: usize| mins.get(i).copied().unwrap_or(0).max(0);
537    let flex_idx: Vec<usize> = (0..n).filter(|&i| f(i).is_some()).collect();
538    if flex_idx.iter().all(|&i| base[i] >= m(i)) {
539        return base.to_vec();
540    }
541    let space: i32 = flex_idx.iter().map(|&i| base[i]).sum();
542    let mut frozen = alloc::vec![false; n];
543    let mut hyp;
544    loop {
545        let used: i32 = flex_idx.iter().filter(|&&i| frozen[i]).map(|&i| m(i)).sum();
546        let sum_f: f32 = flex_idx
547            .iter()
548            .filter(|&&i| !frozen[i])
549            .filter_map(|&i| f(i))
550            .sum();
551        hyp = if sum_f > 0.0 { (space - used).max(0) as f32 / sum_f } else { 0.0 };
552        let mut changed = false;
553        for &i in &flex_idx {
554            if !frozen[i] && f(i).unwrap_or(0.0) * hyp < m(i) as f32 {
555                frozen[i] = true;
556                changed = true;
557            }
558        }
559        if !changed {
560            break;
561        }
562    }
563    let mut out = base.to_vec();
564    for &i in &flex_idx {
565        out[i] = if frozen[i] {
566            m(i)
567        } else {
568            round_f32_to_i32(f(i).unwrap_or(0.0) * hyp)
569        };
570    }
571    // 丸めの端数で合計がずれないよう、固定していない最後の fr 列で吸収する
572    // (全部固定してはみ出す場合は合わせない)。
573    if let Some(&last) = flex_idx.iter().rev().find(|&&i| !frozen[i]) {
574        let total: i32 = flex_idx.iter().map(|&i| out[i]).sum();
575        out[last] = (out[last] + space - total).max(0);
576    }
577    out
578}
579
580/// グリッド容器の内在幅を求めるときの列数。
581///
582/// `repeat(N, ..)` は N、空白区切りはトークン数。`auto-fit`/`auto-fill` は
583/// 内在幅の計測では 1 回の繰り返しとして数える(CSS Grid §7.2.3.2)。未指定は 1。
584pub(crate) fn grid_column_count_for_intrinsic(template: &str) -> usize {
585    let t = template.trim();
586    if t.is_empty() {
587        return 1;
588    }
589    if let Some(inner) = t.strip_prefix("repeat(").and_then(|x| x.strip_suffix(')')) {
590        let count = inner.split_once(',').map(|(c, _)| c.trim()).unwrap_or("1");
591        return count.parse::<usize>().unwrap_or(1).max(1);
592    }
593    split_top_level_ws(t).len().max(1)
594}
595
596/// グリッド容器の内在幅 (min-content, max-content)。
597///
598/// 【2026-10-04】従来は一般のブロックと同じく「子の最小幅の最大」で集計しており、
599/// 列が横に並ぶことを考えていなかった(統計カード 3 枚のグリッドの min-content が
600/// カード 1 枚分になった)。列ごとに項目の最大を取り、列の合計 + 列間 gap にする。
601///
602/// - `fixed`: 各列が固定幅ならその px(fr/auto は `None`)
603/// - `items`: (列番号, 余白込みの min-content, 余白込みの max-content)。1 列にだけ入る項目
604pub(crate) fn grid_intrinsic_widths(
605    n_cols: usize,
606    fixed: &[Option<i32>],
607    items: &[(usize, i32, i32)],
608    gap: i32,
609) -> (i32, i32) {
610    if n_cols == 0 {
611        return (0, 0);
612    }
613    let mut mins = alloc::vec![0i32; n_cols];
614    let mut maxs = alloc::vec![0i32; n_cols];
615    for &(c, mn, mx) in items {
616        if c < n_cols {
617            mins[c] = mins[c].max(mn.max(0));
618            maxs[c] = maxs[c].max(mx.max(0));
619        }
620    }
621    for c in 0..n_cols {
622        if let Some(px) = fixed.get(c).copied().flatten() {
623            mins[c] = px.max(0);
624            maxs[c] = px.max(0);
625        }
626    }
627    let gaps = gap.max(0) * (n_cols as i32 - 1);
628    let minc = mins.iter().sum::<i32>() + gaps;
629    let maxc = maxs.iter().sum::<i32>() + gaps;
630    (minc, maxc.max(minc))
631}