atmos/os_lib/layout/multicol.rs
1//! CSS 多段組(`column-count` / `column-width` / `columns` / `column-gap`)の
2//! 列幾何計算(純粋モジュール)。
3//!
4//! ハードウェアにもグローバル状態にも依存しない算術だけなので、
5//! QEMU 抜きでホスト側から検証できる。
6//!
7//! # 仕様の要点(CSS Multi-column Layout Level 1)
8//!
9//! - `column-count` は列数の指定、`column-width` は 1 列の**最小**目安幅。
10//! 両方指定されたときは「`column-width` から求まる列数」と
11//! `column-count` の**小さい方**を採る(`column-count` は上限として働く)。
12//! - `column-gap` の既定は `normal`=`1em` 相当。ここでは呼び出し側が
13//! 解決したピクセル値を受け取る。
14//! - 列は等幅で、`gap` は列と列の**間だけ**に入る(外側には入らない)。
15//! よって `列幅 = (使用可能幅 - gap * (列数 - 1)) / 列数`。
16//!
17//! # 割り切り
18//!
19//! 段抜き(`column-span`)・段区切り(`break-inside`)・段罫線
20//! (`column-rule`)は扱わない。本モジュールは「何列で、各列はどの
21//! x 座標にどの幅で並ぶか」だけを決める。
22
23extern crate alloc;
24use alloc::vec::Vec;
25
26/// 多段組の指定。いずれも解決済みのピクセル値/個数。
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct MultiColSpec {
29 /// `column-count`。未指定は `None`。
30 pub count: Option<i32>,
31 /// `column-width`(1 列の最小目安幅、px)。未指定は `None`。
32 pub width: Option<i32>,
33 /// `column-gap`(px)。`normal` は呼び出し側で 1em 相当へ解決しておく。
34 pub gap: i32,
35}
36
37/// 列の位置と幅。
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct Column {
40 /// コンテンツボックス左端からの x オフセット(px)。
41 pub x: i32,
42 /// 列の幅(px)。
43 pub width: i32,
44}
45
46/// 実際に使う列数を決める(純粋関数)。
47///
48/// - どちらも未指定なら 1 列(多段組ではない)。
49/// - `count` だけなら その値(1 未満は 1 に切り上げ)。
50/// - `width` だけなら 使用可能幅から入るだけ詰める。
51/// - 両方あれば **小さい方**(`count` が上限)。
52///
53/// `avail_w` が 0 以下、または `width` が 0 以下のときは 1 列へ倒す
54/// (ゼロ除算と無限列を防ぐ)。
55pub fn resolve_column_count(spec: &MultiColSpec, avail_w: i32) -> i32 {
56 if avail_w <= 0 {
57 return 1;
58 }
59 // `column-width` から入る列数を求める。gap も 1 列分ずつ消費する。
60 let from_width = spec.width.and_then(|w| {
61 if w <= 0 {
62 return None;
63 }
64 // n 列入る条件: n*w + (n-1)*gap <= avail_w
65 // → n <= (avail_w + gap) / (w + gap)
66 let denom = w.saturating_add(spec.gap.max(0));
67 if denom <= 0 {
68 return None;
69 }
70 let n = (avail_w.saturating_add(spec.gap.max(0))) / denom;
71 Some(n.max(1))
72 });
73 let from_count = spec.count.map(|c| c.max(1));
74
75 match (from_count, from_width) {
76 (None, None) => 1,
77 (Some(c), None) => c,
78 (None, Some(w)) => w,
79 // 仕様上 `column-count` は上限として働く。
80 (Some(c), Some(w)) => c.min(w),
81 }
82}
83
84/// 列の x 座標と幅を計算する(純粋関数)。
85///
86/// `gap` は列間にだけ入る。端数は最後の列へ寄せず、各列を等幅にしたうえで
87/// 余りぶんだけ最終列を広げる(合計が `avail_w` を超えないようにする)。
88/// 列幅が負になる(gap が広すぎる)場合は 0 幅の列を返し、パニックしない。
89pub fn layout_columns(spec: &MultiColSpec, avail_w: i32) -> Vec<Column> {
90 let n = resolve_column_count(spec, avail_w);
91 let gap = spec.gap.max(0);
92 if n <= 1 {
93 return alloc::vec![Column {
94 x: 0,
95 width: avail_w.max(0),
96 }];
97 }
98 let total_gap = gap.saturating_mul(n - 1);
99 let usable = avail_w.saturating_sub(total_gap);
100 if usable <= 0 {
101 // gap だけで埋まってしまう場合。列は作るが幅 0 にする。
102 return (0..n)
103 .map(|i| Column {
104 x: (gap.saturating_add(0)).saturating_mul(i),
105 width: 0,
106 })
107 .collect();
108 }
109 let base = usable / n;
110 let mut cols = Vec::with_capacity(n as usize);
111 let mut x = 0;
112 for i in 0..n {
113 // 端数(usable % n)は最終列で吸収し、合計が avail_w を超えないようにする。
114 let w = if i == n - 1 { usable - base * (n - 1) } else { base };
115 cols.push(Column { x, width: w });
116 x = x.saturating_add(w).saturating_add(gap);
117 }
118 cols
119}
120
121/// `columns` ショートハンドを `column-width` と `column-count` へ分解する。
122///
123/// 仕様上、値は「長さ」と「整数」が任意順で高々 1 つずつ。
124/// `auto` は「その成分は未指定」を意味する。
125/// 解決できない字句は無視する(不正値でページ全体を壊さない)。
126///
127/// 長さの解決(`px` 以外の単位)は呼び出し側の責務。ここでは
128/// **末尾が `px` か単位無しの数値**だけを長さとして受け取り、
129/// 整数だけの値は列数として扱う。
130pub fn parse_columns_shorthand(s: &str) -> (Option<i32>, Option<i32>) {
131 let mut width = None;
132 let mut count = None;
133 for tok in s.split_whitespace() {
134 let t = tok.trim();
135 if t.is_empty() || t.eq_ignore_ascii_case("auto") {
136 continue;
137 }
138 if let Some(px) = t.strip_suffix("px") {
139 if let Ok(v) = px.trim().parse::<f32>() {
140 if v.is_finite() && v > 0.0 {
141 width = Some(v as i32);
142 }
143 }
144 continue;
145 }
146 // 単位無しの整数は列数。小数や負数は無視する。
147 if let Ok(v) = t.parse::<i32>() {
148 if v > 0 {
149 count = Some(v);
150 }
151 continue;
152 }
153 // それ以外(`2em` 等の未対応単位)は長さとして扱えないので無視する。
154 }
155 (width, count)
156}
157
158/// 子ボックスを列へ振り分けた結果。
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub struct Placement {
161 /// 何列目へ入れるか(0 起点)。
162 pub col: usize,
163 /// その列の中での上端オフセット(px)。
164 pub y: i32,
165}
166
167/// 子の高さ列を受け取り、各子をどの列のどの高さへ置くかを決める(純粋関数)。
168///
169/// 不変条件 M-6 のとおり「その時点で高さが最小の列」へ順に入れる貪欲法。
170/// 同じ高さの列が複数あるときは**左の列を優先**する(見た目の安定のため。
171/// 右から埋まると 1 件だけのときに不自然に右へ寄る)。
172///
173/// 返り値の長さは `heights` と同じ。列数が 0 のときは全て 0 列目へ倒す。
174///
175/// 計算量: **O(子数 × 列数)**。列数は高々数十なので実用上問題ない。
176pub fn assign_to_columns(heights: &[i32], n_cols: usize) -> Vec<Placement> {
177 let n = n_cols.max(1);
178 let mut bottoms = alloc::vec![0i32; n];
179 let mut out = Vec::with_capacity(heights.len());
180 for &h in heights {
181 // 最小の列を探す。`<` で比較するので同値なら先に見た(左の)列が残る。
182 let mut min_i = 0usize;
183 for i in 1..n {
184 if bottoms[i] < bottoms[min_i] {
185 min_i = i;
186 }
187 }
188 out.push(Placement {
189 col: min_i,
190 y: bottoms[min_i],
191 });
192 bottoms[min_i] = bottoms[min_i].saturating_add(h.max(0));
193 }
194 out
195}
196
197/// 振り分け後の各列の高さを返す(純粋関数)。
198///
199/// 多段組ブロック自身の高さは、この最大値になる。
200pub fn column_heights(heights: &[i32], n_cols: usize) -> Vec<i32> {
201 let n = n_cols.max(1);
202 let mut bottoms = alloc::vec![0i32; n];
203 for &h in heights {
204 let mut min_i = 0usize;
205 for i in 1..n {
206 if bottoms[i] < bottoms[min_i] {
207 min_i = i;
208 }
209 }
210 bottoms[min_i] = bottoms[min_i].saturating_add(h.max(0));
211 }
212 bottoms
213}