Skip to main content

atmos/os_lib/layout/
types.rs

1// 分割: layout.rs より機械的に移動(2026-07-16 リファクタ フェーズ4)。
2// ロジック不変。可視性のみ pub(crate) へ昇格し、親が pub(crate) use で再エクスポート。
3use super::*;
4
5pub(crate) static IMAGE_SIZE_CACHE: Mutex<Option<BTreeMap<String, (i32, i32)>>> = Mutex::new(None);
6
7/// 【2026-08-05】ネットワークから取得した画像の自然サイズを登録する。
8///
9/// `<img>` に `width`/`height` が無い場合、レイアウトは画像の自然サイズを
10/// 使うべきだが、`values.rs` はローカルファイルシステム(`src` が `/` 始まり)
11/// しか見ていなかった。ネットワーク画像は**既定の 400×200** に落ちるため、
12/// `App_Store_Badge_US-UK_135x40.svg`(本来 135×40)が
13/// 画面外へはみ出すほど大きく描かれていた。
14///
15/// デコード完了時にここへ登録し、次のレイアウトで拾わせる。
16pub fn register_image_natural_size(src: &str, w: i32, h: i32) {
17    let mut cache = IMAGE_SIZE_CACHE.lock();
18    if cache.is_none() {
19        *cache = Some(BTreeMap::new());
20    }
21    if let Some(ref mut c) = *cache {
22        c.insert(alloc::string::String::from(src), (w, h));
23    }
24}
25
26#[derive(Debug, Clone, Default)]
27pub struct Rect {
28    pub x: i32,
29    pub y: i32,
30    pub width: i32,
31    pub height: i32,
32}
33
34#[derive(Debug, Clone, Default)]
35pub struct EdgeSizes {
36    pub left: i32,
37    pub right: i32,
38    pub top: i32,
39    pub bottom: i32,
40}
41
42#[derive(Debug, Clone, Default)]
43pub struct Dimensions {
44    pub content: Rect,
45    pub padding: EdgeSizes,
46    pub border: EdgeSizes,
47    pub margin: EdgeSizes,
48}
49
50impl Dimensions {
51    /// パディングボックス(content + padding)。絶対配置の包含ブロックに使う。
52    pub fn padding_box(&self) -> Rect {
53        Rect {
54            x: self.content.x - self.padding.left,
55            y: self.content.y - self.padding.top,
56            width: self.content.width + self.padding.left + self.padding.right,
57            height: self.content.height + self.padding.top + self.padding.bottom,
58        }
59    }
60
61    pub fn margin_box(&self) -> Rect {
62        Rect {
63            x: self.content.x - self.padding.left - self.border.left - self.margin.left,
64            y: self.content.y - self.padding.top - self.border.top - self.margin.top,
65            width: self.content.width
66                + self.padding.left
67                + self.padding.right
68                + self.border.left
69                + self.border.right
70                + self.margin.left
71                + self.margin.right,
72            height: self.content.height
73                + self.padding.top
74                + self.padding.bottom
75                + self.border.top
76                + self.border.bottom
77                + self.margin.top
78                + self.margin.bottom,
79        }
80    }
81}
82
83// Clone: inline-flex の寸法計測で試し組みするため(2026-09-26)。
84#[derive(Clone)]
85pub struct LayoutBox<'a> {
86    pub dimensions: Dimensions,
87    pub box_type: BoxType<'a>,
88    pub children: Vec<LayoutBox<'a>>,
89    /// 文字ノードの先頭の空白(畳まれた 1 つ)を計測にも描画にも含めない印。
90    /// 行頭に来た文字(CSS Text 3 §4.1.2)と、flex/grid 直下の匿名アイテムの文字に立つ。
91    /// 【2026-10-14】従来は箱の幅だけ空白ぶん縮め、描画は空白込みで箱の左端から
92    /// 描いていたため、行頭の文字が空白 1 つぶん右へずれた(index.htm のロゴ「IoT」)。
93    pub trim_leading_space: bool,
94}
95
96#[derive(Clone, Copy)]
97pub enum BoxType<'a> {
98    BlockNode(&'a StyledNode<'a>),
99    InlineNode(&'a StyledNode<'a>),
100    InlineBlockNode(&'a StyledNode<'a>),
101    FlexNode(&'a StyledNode<'a>),
102    GridNode(&'a StyledNode<'a>),
103    TableNode(&'a StyledNode<'a>),
104    TableRowGroupNode(&'a StyledNode<'a>),
105    TableRowNode(&'a StyledNode<'a>),
106    TableCellNode(&'a StyledNode<'a>),
107    AnonymousBlock,
108}
109
110/// タグ名から UA デフォルトの display を返す (build_layout_tree 用)。
111/// CSS で display が明示されていない場合のフォールバック。
112pub(crate) fn default_display_for(tag_name: &str) -> &'static str {
113    match tag_name {
114        "head" | "title" | "meta" | "link" | "style" | "script" => "none",
115        "html" | "body" | "div" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "ul" | "ol"
116        | "li" | "form" | "center" | "header" | "footer" | "main" | "section" | "nav" | "hr"
117        | "caption" | "details" | "summary" => "block",
118        "table" => "table",
119        "thead" | "tbody" | "tfoot" => "table-row-group",
120        "tr" => "table-row",
121        "td" | "th" => "table-cell",
122        _ => "inline",
123    }
124}
125
126/// セルの colspan / rowspan を読む (HTML 属性)。1 未満や巨大値はクランプ。
127pub(crate) fn cell_spans(cell: &LayoutBox) -> (usize, usize) {
128    if let BoxType::TableCellNode(styled) = &cell.box_type {
129        if let NodeType::Element { attributes, .. } = &styled.node.node_type {
130            let cs = attributes
131                .get("colspan")
132                .and_then(|v| v.trim().parse::<usize>().ok())
133                .unwrap_or(1)
134                .clamp(1, 256);
135            let rs = attributes
136                .get("rowspan")
137                .and_then(|v| v.trim().parse::<usize>().ok())
138                .unwrap_or(1)
139                .clamp(1, 1024);
140            return (cs, rs);
141        }
142    }
143    (1, 1)
144}
145
146/// LayoutBox が表す要素のタグ名 (text ノードや AnonymousBlock は None)。
147pub(crate) fn box_tag_name<'a>(b: &LayoutBox<'a>) -> Option<&'a str> {
148    let styled = match &b.box_type {
149        BoxType::BlockNode(s)
150        | BoxType::InlineNode(s)
151        | BoxType::InlineBlockNode(s)
152        | BoxType::FlexNode(s)
153        | BoxType::GridNode(s)
154        | BoxType::TableNode(s)
155        | BoxType::TableRowGroupNode(s)
156        | BoxType::TableRowNode(s)
157        | BoxType::TableCellNode(s) => *s,
158        BoxType::AnonymousBlock => return None,
159    };
160    match &styled.node.node_type {
161        NodeType::Element { tag_name, .. } => Some(tag_name.as_str()),
162        NodeType::Text(_) => None,
163    }
164}
165
166/// LayoutBox が表す要素の CSS プロパティ値(flex の子サイズ判定用)。
167pub(crate) fn box_style_value<'a>(b: &LayoutBox<'a>, prop: &str) -> Option<String> {
168    let styled = match &b.box_type {
169        BoxType::BlockNode(s)
170        | BoxType::InlineNode(s)
171        | BoxType::InlineBlockNode(s)
172        | BoxType::FlexNode(s)
173        | BoxType::GridNode(s)
174        | BoxType::TableNode(s)
175        | BoxType::TableRowGroupNode(s)
176        | BoxType::TableRowNode(s)
177        | BoxType::TableCellNode(s) => *s,
178        BoxType::AnonymousBlock => return None,
179    };
180    styled.value(prop)
181}
182
183/// `box_style_value` の借用版(値を写さない。レイアウトの頻出経路用)。
184pub(crate) fn box_style_value_ref<'a>(b: &LayoutBox<'a>, prop: &str) -> Option<&'a str> {
185    match &b.box_type {
186        BoxType::BlockNode(s)
187        | BoxType::InlineNode(s)
188        | BoxType::InlineBlockNode(s)
189        | BoxType::FlexNode(s)
190        | BoxType::GridNode(s)
191        | BoxType::TableNode(s)
192        | BoxType::TableRowGroupNode(s)
193        | BoxType::TableRowNode(s)
194        | BoxType::TableCellNode(s) => s.value_ref(prop),
195        BoxType::AnonymousBlock => None,
196    }
197}
198
199/// LayoutBox が表す要素の計算済み font-size (px)。デフォルト 16.0。
200pub(crate) fn box_font_size(b: &LayoutBox) -> f32 {
201    box_style_value(b, "font-size")
202        .and_then(|f| parse_font_size_u32(&f))
203        .unwrap_or(16) as f32
204}
205
206impl<'a> LayoutBox<'a> {
207    pub(crate) fn font_size(&self) -> f32 {
208        box_font_size(self)
209    }
210
211    /// W3C CSSOM View: 要素の padding box の幅 (px)。
212    pub fn client_width(&self) -> i32 {
213        self.dimensions.padding_box().width
214    }
215
216    /// W3C CSSOM View: 要素の padding box の高さ (px)。
217    pub fn client_height(&self) -> i32 {
218        self.dimensions.padding_box().height
219    }
220
221    /// W3C CSSOM View: 要素のパディングボックス基準での全コンテンツの最大右端・下端から、
222    /// スクロール可能領域の幅・高さを算出 (clientWidth / clientHeight 以上の値)。
223    /// 戻り値: (client_width, client_height, scroll_width, scroll_height)
224    pub fn scroll_dimensions(&self) -> (i32, i32, i32, i32) {
225        let cw = self.client_width();
226        let ch = self.client_height();
227        let pbox = self.dimensions.padding_box();
228
229        // 子孫要素の margin_box() の最大右端・下端を親の padding_box 原点基準で集計
230        let mut max_right = pbox.width;
231        let mut max_bottom = pbox.height;
232
233        fn collect_max_bounds(parent_pbox: &Rect, b: &LayoutBox, max_r: &mut i32, max_b: &mut i32) {
234            for child in &b.children {
235                let mbox = child.dimensions.margin_box();
236                let r = mbox.x + mbox.width - parent_pbox.x;
237                let b_edge = mbox.y + mbox.height - parent_pbox.y;
238                if r > *max_r {
239                    *max_r = r;
240                }
241                if b_edge > *max_b {
242                    *max_b = b_edge;
243                }
244                // 子要素がそれ自体 overflow: scroll/hidden のスクロールコンテナでない場合、
245                // さらにその子孫のはみ出しもこのコンテナのスクロール領域に寄与する。
246                let is_child_scroll = match &child.box_type {
247                    BoxType::BlockNode(s)
248                    | BoxType::InlineNode(s)
249                    | BoxType::InlineBlockNode(s)
250                    | BoxType::FlexNode(s)
251                    | BoxType::GridNode(s)
252                    | BoxType::TableNode(s)
253                    | BoxType::TableRowGroupNode(s)
254                    | BoxType::TableRowNode(s)
255                    | BoxType::TableCellNode(s) => {
256                        ["overflow", "overflow-x", "overflow-y"].iter().any(|k| {
257                            s.value(k).is_some_and(|v| matches!(v.trim(), "hidden" | "scroll" | "auto"))
258                        })
259                    }
260                    BoxType::AnonymousBlock => false,
261                };
262                if !is_child_scroll {
263                    collect_max_bounds(parent_pbox, child, max_r, max_b);
264                }
265            }
266        }
267
268        collect_max_bounds(&pbox, self, &mut max_right, &mut max_bottom);
269
270        (cw, ch, max_right.max(cw), max_bottom.max(ch))
271    }
272
273    /// W3C CSSOM View: スクロール領域の幅 (px)。
274    pub fn scroll_width(&self) -> i32 {
275        self.scroll_dimensions().2
276    }
277
278    /// W3C CSSOM View: スクロール領域の高さ (px)。
279    pub fn scroll_height(&self) -> i32 {
280        self.scroll_dimensions().3
281    }
282}
283
284/// 指定プロパティが明示指定されているか(auto/空は未指定扱い)。
285pub(crate) fn box_has_explicit(b: &LayoutBox, prop: &str) -> bool {
286    box_style_value(b, prop)
287        .map(|v| {
288            let t = v.trim().to_lowercase();
289            !t.is_empty() && t != "auto"
290        })
291        .unwrap_or(false)
292}
293
294/// aspect-ratio の (num, den) を取得する。
295pub(crate) fn box_aspect_ratio(b: &LayoutBox) -> Option<(f32, f32)> {
296    box_style_value(b, "aspect-ratio").and_then(|v| parse_aspect_ratio(&v))
297}
298
299/// flex アイテムの flex-grow 係数(`flex-grow` 優先、無ければ `flex` ショートハンド先頭)。
300pub(crate) fn box_flex_grow(b: &LayoutBox) -> i32 {
301    if let Some(g) = box_style_value(b, "flex-grow") {
302        return g.trim().parse::<i32>().unwrap_or(0).max(0);
303    }
304    if let Some(f) = box_style_value(b, "flex") {
305        let t = f.trim().to_lowercase();
306        if t == "none" || t == "initial" {
307            return 0;
308        }
309        if t == "auto" {
310            return 1; // flex:auto = 1 1 auto
311        }
312        if let Some(first) = t.split_whitespace().next() {
313            return first.parse::<i32>().unwrap_or(0).max(0);
314        }
315    }
316    0
317}
318
319/// flex アイテムの flex-shrink 係数(`flex-shrink` 優先、無ければ `flex` ショートハンドの
320/// 2 番目、無指定は 1 = 既定で縮小可)。`flex:none` は 0、`flex:N`(単一数値)は 1。
321/// トークンが `flex` ショートハンドの basis に使える長さ値(px/%/em/rem/vh/vw)かどうかを
322/// 判定する。以前は `px`/`%`/`em` の3種のみ対応しており、`flex: 1 40vw` のような
323/// vw/vh/rem 単位の basis が length として認識されず、`box_flex_shrink` では誤って
324/// shrink の数値パースに回されて(失敗し既定値1にフォールバック)、`box_flex_basis` では
325/// 該当トークンが見つからず basis が丸ごと失われる、という実害のあるバグがあった
326/// (`transition`/`animation` の時間値誤分類と同種の「対応単位の一部だけを見る」バグ)。
327/// 末尾を剥がした残りが実際に数値としてパースできることまで確認する。
328pub(crate) fn is_flex_length_token(tok: &str) -> bool {
329    for suffix in ["px", "%", "rem", "em", "vh", "vw"] {
330        if let Some(n) = tok.strip_suffix(suffix) {
331            if n.trim().parse::<f32>().is_ok() {
332                return true;
333            }
334        }
335    }
336    false
337}
338
339pub(crate) fn box_flex_shrink(b: &LayoutBox) -> i32 {
340    if let Some(s) = box_style_value(b, "flex-shrink") {
341        return s.trim().parse::<i32>().unwrap_or(1).max(0);
342    }
343    if let Some(f) = box_style_value(b, "flex") {
344        let t = f.trim().to_lowercase();
345        if t == "none" {
346            return 0; // flex:none = 0 0 auto
347        }
348        if t == "initial" {
349            return 1; // flex:initial = 0 1 auto
350        }
351        if t == "auto" {
352            return 1; // flex:auto = 1 1 auto
353        }
354        // `flex: <grow> <shrink> <basis>` の 2 番目があれば採用。
355        let parts: Vec<&str> = t.split_whitespace().collect();
356        if let Some(second) = parts.get(1) {
357            // 2 番目が単位付き(basis)なら shrink 省略 → 既定 1。
358            if !is_flex_length_token(second) && *second != "auto" {
359                return second.parse::<i32>().unwrap_or(1).max(0);
360            }
361        }
362        // `flex: N`(単一数値)は 1 1 0 相当 → shrink=1。
363        return 1;
364    }
365    1
366}
367
368/// flex/grid アイテムの `order`(描画順の並び替え)。未指定/不正は 0。負値可。
369pub(crate) fn box_order(b: &LayoutBox) -> i32 {
370    box_style_value(b, "order")
371        .and_then(|v| v.trim().parse::<i32>().ok())
372        .unwrap_or(0)
373}
374
375/// flex アイテムの flex-basis(主軸初期サイズ)。`flex-basis` 優先、無ければ `flex`
376/// ショートハンドの長さトークン(px/%)。`auto`/`content`/未指定は None(width/height に従う)。
377/// `main_size` は % 解決の基準(コンテナ主軸長)。
378pub(crate) fn box_flex_basis(b: &LayoutBox, main_size: i32) -> Option<i32> {
379    let font_px = box_style_value(b, "font-size")
380        .and_then(|f| parse_font_size_u32(&f))
381        .unwrap_or(16) as f32;
382    // 明示 flex-basis を最優先。
383    if let Some(v) = box_style_value(b, "flex-basis") {
384        let t = v.trim().to_lowercase();
385        if t.is_empty() || t == "auto" || t == "content" {
386            return None;
387        }
388        if t == "min-content" {
389            return Some(b.block_intrinsic_widths().0);
390        }
391        if t == "max-content" {
392            return Some(b.block_intrinsic_widths().1);
393        }
394        if t == "fit-content" {
395            let (minc, maxc) = b.block_intrinsic_widths();
396            return Some(maxc.min(main_size.max(0)).max(minc));
397        }
398        if let Some(fit) = t.strip_prefix("fit-content(").and_then(|s| s.strip_suffix(')')) {
399            let limit = resolve_basis_len_with_font(fit, main_size, font_px).unwrap_or(main_size);
400            let (minc, maxc) = b.block_intrinsic_widths();
401            return Some(maxc.min(limit.max(0)).max(minc));
402        }
403        return resolve_basis_len_with_font(&t, main_size, font_px);
404    }
405    // flex ショートハンドの第3トークン(長さ)から basis を拾う。
406    if let Some(f) = box_style_value(b, "flex") {
407        let t = f.trim().to_lowercase();
408        if t == "none" || t == "auto" || t == "initial" {
409            return None; // basis=auto 相当
410        }
411        // `flex: <grow> <shrink>? <basis>?` の末尾トークンが長さなら basis。
412        for tok in t.split_whitespace() {
413            if tok == "min-content" {
414                return Some(b.block_intrinsic_widths().0);
415            }
416            if tok == "max-content" {
417                return Some(b.block_intrinsic_widths().1);
418            }
419            if tok == "fit-content" {
420                let (minc, maxc) = b.block_intrinsic_widths();
421                return Some(maxc.min(main_size.max(0)).max(minc));
422            }
423            if is_flex_length_token(tok) {
424                return resolve_basis_len_with_font(tok, main_size, font_px);
425            }
426        }
427        // `flex: N`(単一数値)は basis=0。
428        if t.split_whitespace().count() == 1 && t.parse::<i32>().is_ok() {
429            return Some(0);
430        }
431    }
432    None
433}
434
435/// flex-basis のトークン("40px" / "25%" / "2em")を px へ解決する。
436/// `font_px` は要素自身の算出フォントサイズ(`em` 単位の計算に使用)。
437pub(crate) fn resolve_basis_len_with_font(tok: &str, main_size: i32, font_px: f32) -> Option<i32> {
438    let t = tok.trim();
439    if let Some(p) = t.strip_suffix('%') {
440        return p
441            .trim()
442            .parse::<f32>()
443            .ok()
444            .map(|pct| round_f32_to_i32(main_size as f32 * pct / 100.0).max(0));
445    }
446    if let Some(num) = t.strip_suffix("em") {
447        return num
448            .trim()
449            .parse::<f32>()
450            .ok()
451            .map(|v| round_f32_to_i32(v * font_px).max(0));
452    }
453    parse_px_like(t).map(|v| v.max(0))
454}
455
456pub(crate) fn resolve_basis_len(tok: &str, main_size: i32) -> Option<i32> {
457    resolve_basis_len_with_font(tok, main_size, 16.0)
458}
459
460/// flex アイテムの align-self(個別交差軸整列)。未指定/auto なら None(コンテナの align-items に従う)。
461pub(crate) fn box_align_self(b: &LayoutBox) -> Option<String> {
462    box_style_value(b, "align-self").and_then(|v| {
463        let t = v.trim().to_lowercase();
464        if t.is_empty() || t == "auto" {
465            None
466        } else {
467            Some(t)
468        }
469    })
470}
471
472/// grid アイテムの整列値を解決する。`self_prop`(justify-self/align-self)が
473/// auto/未指定なら、コンテナの `items_default`(justify-items/align-items の値)へ委譲。
474/// 返り値を正規化: "start"/"end"/"center"/"stretch"。`flex-start`/`flex-end`/`self-start`/
475/// `self-end` も start/end に正規化する。既定は stretch。
476pub(crate) fn grid_item_align(b: &LayoutBox, self_prop: &str, items_default: &str) -> &'static str {
477    let raw = match box_style_value(b, self_prop) {
478        Some(v) => {
479            let t = v.trim().to_lowercase();
480            if t.is_empty() || t == "auto" {
481                String::from(items_default)
482            } else {
483                t
484            }
485        }
486        None => String::from(items_default),
487    };
488    match raw.as_str() {
489        "center" => "center",
490        "end" | "flex-end" | "self-end" | "right" => "end",
491        "start" | "flex-start" | "self-start" | "left" => "start",
492        _ => "stretch",
493    }
494}
495