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/// `grid-template-columns` を解析して列幅 Vec を返す。
6/// `repeat(N, W)` / `repeat(auto-fit, minmax(Npx, 1fr))` / `1fr 2fr` / 固定 px に対応。
7pub(crate) fn parse_grid_template_columns(s: &str, avail_w: i32) -> Vec<i32> {
8    parse_grid_template_columns_gap(s, avail_w, 0)
9}
10
11/// `parse_grid_template_columns` の列間 gap 考慮版。
12///
13/// 【2026-09-26 バグ修正】従来は gap を知らずに列幅を決めていたため、
14/// `1fr 1fr; gap:4rem` で各列がコンテナの半分になり、gap の分だけ右へはみ出した。
15/// CSS Grid 仕様どおり、(a) fr へ配る残り幅から列間 gap の合計を引き、
16/// (b) `repeat(auto-fit|auto-fill, minmax(M, ..))` の列数を
17/// `floor((幅 + gap) / (M + gap))` で決める(n 列 + (n-1) gap が幅に収まる最大の n)。
18pub(crate) fn parse_grid_template_columns_gap(s: &str, avail_w: i32, gap: i32) -> Vec<i32> {
19    let gap = gap.max(0);
20    let s = s.trim();
21    if s.is_empty() {
22        return alloc::vec![avail_w];
23    }
24    // `repeat(N, W)` or `repeat(auto-fit, minmax(min_px, 1fr))`
25    if let Some(inner) = s.strip_prefix("repeat(").and_then(|t| t.strip_suffix(')')) {
26        if let Some((count_str, rest)) = inner.split_once(',') {
27            let count_s = count_str.trim();
28            let col_def = rest.trim();
29            // minmax(Xpx, 1fr) → auto-fit では コンテナ幅から min で列数を決める。
30            let min_w = if let Some(mm) = col_def
31                .strip_prefix("minmax(")
32                .and_then(|t| t.strip_suffix(')'))
33            {
34                mm.split_once(',')
35                    .and_then(|(a, _)| resolve_track_tok_opt(a.trim(), avail_w))
36                    .unwrap_or(200)
37            } else {
38                parse_px_like(col_def).unwrap_or(avail_w)
39            };
40            let n: usize = if count_s == "auto-fit" || count_s == "auto-fill" {
41                (libm::floorf((avail_w + gap) as f32 / (min_w.max(1) + gap) as f32) as usize).max(1)
42            } else {
43                count_s.parse::<usize>().unwrap_or(1).max(1)
44            };
45            // 固定 px 列定義(`repeat(3,100px)`)はその幅を尊重。fr/minmax/% は均等割り。
46            let is_fixed_px = !col_def.ends_with("fr")
47                && !col_def.starts_with("minmax(")
48                && !col_def.ends_with('%')
49                && parse_px_like(col_def).is_some();
50            let col_w = if is_fixed_px {
51                parse_px_like(col_def).unwrap_or(1).max(1)
52            } else {
53                ((avail_w - gap * (n as i32 - 1)) / n as i32).max(1)
54            };
55            return (0..n).map(|_| col_w).collect();
56        }
57    }
58    // スペース区切りのトークン(1fr / 2fr / 200px / % など)
59    let tokens: alloc::vec::Vec<&str> = s.split_whitespace().collect();
60    if tokens.is_empty() {
61        return alloc::vec![avail_w];
62    }
63    // `%` トラック(例: `50% 1fr`)を avail_w 基準で px へ解決する。`parse_px_like` は
64    // px/vh/vw/rem/em のみ対応し `%` を扱わないため(`repeat()`/`minmax()` 経由の
65    // 固定px判定でも同様に `%` は別扱いされている、既存の `is_fixed_px` 参照)、
66    // ここで専用に解決する。
67    fn resolve_track_tok_opt(tok: &str, avail_w: i32) -> Option<i32> {
68        if let Some(pct) = tok.strip_suffix('%') {
69            return pct
70                .trim()
71                .parse::<f32>()
72                .ok()
73                .map(|p| round_f32_to_i32(avail_w as f32 * p / 100.0));
74        }
75        parse_px_like(tok)
76    }
77    fn resolve_track_tok(tok: &str, avail_w: i32) -> i32 {
78        resolve_track_tok_opt(tok, avail_w).unwrap_or(0)
79    }
80    // fr 合計と固定幅を分けて残り幅を分配。
81    let mut fixed_total = 0i32;
82    let mut fr_total = 0.0f32;
83    for tok in &tokens {
84        if let Some(fr) = tok.strip_suffix("fr") {
85            fr_total += fr.trim().parse::<f32>().unwrap_or(1.0);
86        } else {
87            fixed_total += resolve_track_tok(tok, avail_w);
88        }
89    }
90    let gaps_total = gap * (tokens.len() as i32 - 1).max(0);
91    let remaining = (avail_w - fixed_total - gaps_total).max(0);
92    tokens
93        .iter()
94        .map(|tok| {
95            if let Some(fr) = tok.strip_suffix("fr") {
96                let f = fr.trim().parse::<f32>().unwrap_or(1.0);
97                if fr_total > 0.0 {
98                    round_f32_to_i32(remaining as f32 * f / fr_total)
99                } else {
100                    remaining
101                }
102            } else {
103                resolve_track_tok(tok, avail_w)
104            }
105        })
106        .collect()
107}
108
109/// グリッドトラック(行/列の 1 トラック)のサイズ種別。
110#[derive(Clone, Copy)]
111pub(crate) enum GridTrack {
112    Px(i32),
113    Fr(f32),
114    Auto,
115    /// minmax(min_px, max): max が fr なら伸長可能だが最低 min_px を保証。
116    /// max が auto/px のときは Fr(0) 相当で扱い、行高さは内容 or max_px と min_px の間。
117    MinMax {
118        min_px: i32,
119        max_fr: f32,
120        max_px: i32,
121        max_is_fr: bool,
122        max_is_auto: bool,
123    },
124}
125
126/// トップレベル(括弧の外)の空白でトークン分割する。`minmax(a, b)` / `repeat(n, x)`
127/// の括弧内の空白では分割しない。`string_slice` lint 回避のため char_indices + get を使う。
128pub(crate) fn split_top_level_ws(s: &str) -> Vec<&str> {
129    let mut out = Vec::new();
130    let mut depth = 0i32;
131    let mut start = 0usize;
132    let mut in_tok = false;
133    for (i, ch) in s.char_indices() {
134        match ch {
135            '(' => depth += 1,
136            ')' => depth -= 1,
137            c if c.is_whitespace() && depth == 0 => {
138                if in_tok {
139                    if let Some(t) = s.get(start..i) {
140                        out.push(t);
141                    }
142                    in_tok = false;
143                }
144                continue;
145            }
146            _ => {}
147        }
148        if !in_tok {
149            start = i;
150            in_tok = true;
151        }
152    }
153    if in_tok {
154        if let Some(t) = s.get(start..) {
155            out.push(t);
156        }
157    }
158    out
159}
160
161/// 単一トラック定義(`100px` / `2fr` / `auto` / `50%` / `minmax(min,max)`)を解釈。
162/// base は % 解決と auto 折衝の基準サイズ。
163pub(crate) fn parse_one_grid_track(tok: &str, base: i32) -> GridTrack {
164    let tok = tok.trim();
165    if tok.is_empty() || tok.eq_ignore_ascii_case("auto") {
166        return GridTrack::Auto;
167    }
168    if let Some(fr) = tok.strip_suffix("fr") {
169        return GridTrack::Fr(fr.trim().parse::<f32>().unwrap_or(1.0).max(0.0));
170    }
171    if let Some(mm) = tok
172        .strip_prefix("minmax(")
173        .and_then(|t| t.strip_suffix(')'))
174    {
175        // minmax(min, max): min を下限 px、max を上限(fr/px/auto)として保持。
176        if let Some((a, b)) = mm.split_once(',') {
177            let min_px = match parse_length_value(Some(a.trim())) {
178                Some(LengthValue::Px(px)) => px.max(0),
179                Some(LengthValue::Percent(p)) => round_f32_to_i32(base as f32 * p).max(0),
180                _ => 0, // auto/min-content 等は 0 下限扱い
181            };
182            let bt = b.trim();
183            if let Some(fr) = bt.strip_suffix("fr") {
184                return GridTrack::MinMax {
185                    min_px,
186                    max_fr: fr.trim().parse::<f32>().unwrap_or(1.0).max(0.0),
187                    max_px: 0,
188                    max_is_fr: true,
189                    max_is_auto: false,
190                };
191            }
192            let (max_px, max_is_auto) = match parse_length_value(Some(bt)) {
193                Some(LengthValue::Px(px)) => (px.max(0), false),
194                Some(LengthValue::Percent(p)) => (round_f32_to_i32(base as f32 * p).max(0), false),
195                _ => (0, true), // auto/max-content
196            };
197            return GridTrack::MinMax {
198                min_px,
199                max_fr: 0.0,
200                max_px,
201                max_is_fr: false,
202                max_is_auto,
203            };
204        }
205    }
206    match parse_length_value(Some(tok)) {
207        Some(LengthValue::Px(px)) => GridTrack::Px(px),
208        Some(LengthValue::Percent(p)) => GridTrack::Px(round_f32_to_i32(base as f32 * p)),
209        _ => GridTrack::Auto,
210    }
211}
212
213/// `grid-template-rows` / `grid-template-columns` を GridTrack のリストへ展開。
214/// `repeat(N, def)` / `repeat(auto-fit|auto-fill, def)` を展開する。base は %/auto-fit 用。
215pub(crate) fn parse_grid_tracks(s: &str, base: i32) -> Vec<GridTrack> {
216    let s = s.trim();
217    if s.is_empty() {
218        return Vec::new();
219    }
220    let mut out = Vec::new();
221    for tok in split_top_level_ws(s) {
222        if let Some(inner) = tok
223            .strip_prefix("repeat(")
224            .and_then(|t| t.strip_suffix(')'))
225        {
226            if let Some((count_str, rest)) = inner.split_once(',') {
227                let count_s = count_str.trim();
228                let track = parse_one_grid_track(rest.trim(), base);
229                let n: usize = if count_s == "auto-fit" || count_s == "auto-fill" {
230                    let min_w = match track {
231                        GridTrack::Px(px) => px.max(1),
232                        GridTrack::MinMax { min_px, .. } => min_px.max(1),
233                        _ => 1,
234                    };
235                    (libm::floorf(base as f32 / min_w as f32) as usize).max(1)
236                } else {
237                    count_s.parse::<usize>().unwrap_or(1).max(1)
238                };
239                for _ in 0..n {
240                    out.push(track);
241                }
242                continue;
243            }
244        }
245        out.push(parse_one_grid_track(tok, base));
246    }
247    out
248}
249
250/// グリッドアイテムの 1 軸の配置を解決する。
251/// `short`(grid-row / grid-column のショートハンド `a / b`)を優先し、無ければ
252/// longhand(`*-start` / `*-end`)を読む。戻り値は (明示開始行0始まり, スパン)。
253/// 明示開始が無い(auto)の場合は None。
254pub(crate) fn grid_axis_placement(
255    b: &LayoutBox,
256    short: &str,
257    start_p: &str,
258    end_p: &str,
259) -> (Option<usize>, usize) {
260    let (sv, ev) = if let Some(v) = box_style_value(b, short) {
261        if let Some((a, c)) = v.split_once('/') {
262            (Some(String::from(a.trim())), Some(String::from(c.trim())))
263        } else {
264            (Some(String::from(v.trim())), None)
265        }
266    } else {
267        (
268            box_style_value(b, start_p).map(|s| String::from(s.trim())),
269            box_style_value(b, end_p).map(|s| String::from(s.trim())),
270        )
271    };
272    let mut start: Option<usize> = None;
273    let mut span: usize = 1;
274    if let Some(sv) = &sv {
275        if let Some(n) = sv.strip_prefix("span ") {
276            span = n.trim().parse::<usize>().unwrap_or(1).max(1);
277        } else if let Ok(line) = sv.parse::<i32>() {
278            if line >= 1 {
279                start = Some((line - 1) as usize);
280            }
281        }
282    }
283    if let Some(ev) = &ev {
284        if let Some(n) = ev.strip_prefix("span ") {
285            span = n.trim().parse::<usize>().unwrap_or(1).max(1);
286        } else if let Ok(eline) = ev.parse::<i32>() {
287            if let Some(s0) = start {
288                let e0 = (eline - 1).max(0) as usize;
289                if e0 > s0 {
290                    span = e0 - s0;
291                }
292            }
293        }
294    }
295    (start, span)
296}
297
298/// `grid-template-areas` を解析する。各文字列リテラル(クォートされた1行)が1グリッド行、
299/// 空白区切りのトークンがその行の各列のセル名になる。`.` は未使用セル(名前なし)扱い。
300/// 例: `"header header" "sidebar content"` → [["header","header"],["sidebar","content"]]
301pub(crate) fn parse_grid_template_areas(s: &str) -> Vec<Vec<String>> {
302    let mut rows = Vec::new();
303    let mut chars = s.chars().peekable();
304    while let Some(c) = chars.next() {
305        if c == '"' || c == '\'' {
306            let quote = c;
307            let mut row_str = String::new();
308            for c2 in chars.by_ref() {
309                if c2 == quote {
310                    break;
311                }
312                row_str.push(c2);
313            }
314            let row: Vec<String> = row_str
315                .split_whitespace()
316                .map(String::from)
317                .collect();
318            if !row.is_empty() {
319                rows.push(row);
320            }
321        }
322    }
323    rows
324}
325
326/// `template_areas` 内で `name` という名前のセルが占める矩形のバウンディングボックスを返す。
327/// (row_start, col_start, row_span, col_span)(いずれも0始まり)。`.` は名前なしセルなので対象外。
328pub(crate) fn find_grid_area(
329    template_areas: &[Vec<String>],
330    name: &str,
331) -> Option<(usize, usize, usize, usize)> {
332    if name == "." {
333        return None;
334    }
335    let mut min_r = usize::MAX;
336    let mut max_r = 0usize;
337    let mut min_c = usize::MAX;
338    let mut max_c = 0usize;
339    let mut found = false;
340    for (r, row) in template_areas.iter().enumerate() {
341        for (c, cell) in row.iter().enumerate() {
342            if cell == name {
343                found = true;
344                min_r = min_r.min(r);
345                max_r = max_r.max(r);
346                min_c = min_c.min(c);
347                max_c = max_c.max(c);
348            }
349        }
350    }
351    if !found {
352        return None;
353    }
354    Some((min_r, min_c, max_r - min_r + 1, max_c - min_c + 1))
355}
356
357/// occupancy 行を必要分まで確保(n_cols 幅の false 行を追加)。
358pub(crate) fn grid_ensure_row(occ: &mut Vec<Vec<bool>>, r: usize, n_cols: usize) {
359    while occ.len() <= r {
360        occ.push(alloc::vec![false; n_cols]);
361    }
362}
363
364/// `grid-auto-flow: column` が明示列数を超えて新しい列を作った際、既存の全行の
365/// occupancy 幅を min_cols まで広げる(新規に作る行は grid_ensure_row 側で
366/// 正しい幅で作られるため対象外。既存行だけ広げれば足りる)。
367pub(crate) fn grid_ensure_col(occ: &mut [Vec<bool>], min_cols: usize) {
368    for row in occ.iter_mut() {
369        while row.len() < min_cols {
370            row.push(false);
371        }
372    }
373}
374
375/// (r,c) から row_span×col_span の矩形が全て空いているか。
376pub(crate) fn grid_cells_free(
377    occ: &[Vec<bool>],
378    r: usize,
379    c: usize,
380    row_span: usize,
381    col_span: usize,
382    n_cols: usize,
383) -> bool {
384    if c + col_span > n_cols {
385        return false;
386    }
387    for rr in r..r + row_span {
388        let Some(row) = occ.get(rr) else { continue };
389        for cc in c..c + col_span {
390            if row.get(cc).copied().unwrap_or(false) {
391                return false;
392            }
393        }
394    }
395    true
396}
397
398/// (r,c) から row_span×col_span を占有済みにする。
399pub(crate) fn grid_mark(
400    occ: &mut [Vec<bool>],
401    r: usize,
402    c: usize,
403    row_span: usize,
404    col_span: usize,
405    n_cols: usize,
406) {
407    for rr in r..r + row_span {
408        let Some(row) = occ.get_mut(rr) else { continue };
409        for cc in c..(c + col_span).min(n_cols) {
410            if let Some(cell) = row.get_mut(cc) {
411                *cell = true;
412            }
413        }
414    }
415}
416
417/// 列インデックス col の左端 x オフセット(列幅 + col_gap の累積)。
418pub(crate) fn grid_col_x(col_widths: &[i32], col_gap: i32, col: usize) -> i32 {
419    let mut x = 0i32;
420    for (i, w) in col_widths.iter().enumerate() {
421        if i >= col {
422            break;
423        }
424        x += w + col_gap;
425    }
426    x
427}
428
429/// col から col_span 列ぶんの合計幅(内側の col_gap を含む)。
430pub(crate) fn grid_span_width(col_widths: &[i32], col_gap: i32, col: usize, col_span: usize) -> i32 {
431    let n = col_widths.len();
432    if n == 0 {
433        return 1;
434    }
435    let end = (col + col_span).min(n);
436    let start = col.min(n.saturating_sub(1));
437    let mut w = 0i32;
438    for item in col_widths.iter().take(end).skip(start) {
439        w += *item;
440    }
441    w += (end.saturating_sub(start).saturating_sub(1) as i32) * col_gap;
442    w.max(1)
443}
444