Skip to main content

atmos/os_lib/css/
container_query.rs

1//! CSS コンテナクエリ(`@container`)の条件評価(純粋モジュール)。
2//!
3//! `@media` との決定的な違いは**基準がビューポートではなくコンテナ**である
4//! こと。`@media` はグローバルなビューポート寸法を見ればよいが、
5//! `@container` は「その要素にとっての問い合わせコンテナ」の寸法に依存する
6//! ため、評価にはコンテナ寸法を**引数で**渡す必要がある。
7//! グローバル状態に触らないので、ホスト側から検証できる。
8//!
9//! # 対応する構文
10//!
11//! - `@container (min-width: 400px)`
12//! - `@container (width > 400px)` … レンジ構文
13//! - `@container card (min-width: 400px)` … 名前付きコンテナ
14//! - `and` / `or` / `not` の論理結合
15//!
16//! # 未対応(意図的)
17//!
18//! - スタイルクエリ(`@container style(--foo: bar)`)
19//! - `inline-size`/`block-size` の書字方向依存(横書き前提で `width`/`height`
20//!   と同一視する)
21//! - `aspect-ratio` / `orientation`
22
23extern crate alloc;
24use alloc::string::String;
25
26/// 問い合わせコンテナの寸法(px)。
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct ContainerSize {
29    pub width: i32,
30    pub height: i32,
31}
32
33/// `@container` のプレリュードを「コンテナ名」と「条件式」へ分ける。
34///
35/// 名前は省略可能で、その場合は最も近い祖先の問い合わせコンテナが対象。
36pub fn split_name_and_condition(prelude: &str) -> (Option<String>, String) {
37    let s = prelude.trim();
38    if s.is_empty() {
39        return (None, String::new());
40    }
41    let first = s.split_whitespace().next().unwrap_or("");
42    // 先頭トークンが識別子(`(` でも `not` でもない)ならコンテナ名。
43    if !first.starts_with('(') && !first.eq_ignore_ascii_case("not") {
44        // `get()` で境界安全に切る(`first` は空白区切りなので通常は
45        // 境界上だが、`&str` の添字は UTF-8 境界を外すとパニックするため
46        // 常に安全側の API を使う)。
47        let rest = s.get(first.len()..).unwrap_or("").trim();
48        return (Some(String::from(first)), String::from(rest));
49    }
50    (None, String::from(s))
51}
52
53/// 条件式がコンテナ寸法に対して真かを判定する。
54///
55/// 評価順序は CSS の論理演算に合わせ `or` → `and` → `not`。
56/// 解釈できない条件は **false**(=当てない)。`@media` は互換性のため
57/// 不明な条件を true へ倒す箇所があるが、コンテナクエリは新しい構文で
58/// あり、解釈できないものを適用すると意図しないスタイルが当たるため、
59/// 安全側は「当てない」になる。
60pub fn condition_matches(cond: &str, size: ContainerSize) -> bool {
61    let s = cond.trim();
62    if s.is_empty() {
63        return false;
64    }
65    // `or` が最も弱い結合。
66    if let Some(parts) = split_top_level(s, " or ") {
67        return parts.iter().any(|p| condition_matches(p, size));
68    }
69    if let Some(parts) = split_top_level(s, " and ") {
70        return parts.iter().all(|p| condition_matches(p, size));
71    }
72    if let Some(rest) = strip_kw_prefix(s, "not") {
73        return !condition_matches(rest, size);
74    }
75    // 括弧をひと皮むく。中に論理演算が残っていれば再帰で拾う。
76    if s.starts_with('(') && s.ends_with(')') && s.len() >= 2 {
77        let Some(inner) = s.get(1..s.len() - 1) else {
78            return false;
79        };
80        if inner.contains(" and ") || inner.contains(" or ") || starts_with_kw(inner, "not") {
81            return condition_matches(inner, size);
82        }
83        return eval_single(inner.trim(), size);
84    }
85    eval_single(s, size)
86}
87
88/// 括弧の深さを考慮してトップレベルの区切りで分割する。区切りが無ければ `None`。
89fn split_top_level<'a>(s: &'a str, sep: &str) -> Option<alloc::vec::Vec<&'a str>> {
90    let bytes = s.as_bytes();
91    let sep_bytes = sep.as_bytes();
92    let mut depth = 0i32;
93    let mut parts = alloc::vec::Vec::new();
94    let mut start = 0usize;
95    let mut i = 0usize;
96    while i < bytes.len() {
97        match bytes[i] {
98            b'(' => depth += 1,
99            b')' => depth -= 1,
100            _ => {}
101        }
102        if depth == 0
103            && i + sep_bytes.len() <= bytes.len()
104            && bytes.get(i..i + sep_bytes.len()) == Some(sep_bytes)
105        {
106            // 区切りは ASCII なので、その前後は必ず UTF-8 境界になる。
107            // それでも `get()` で受けてパニック経路を残さない。
108            if let Some(part) = s.get(start..i) {
109                parts.push(part.trim());
110            }
111            i += sep_bytes.len();
112            start = i;
113            continue;
114        }
115        i += 1;
116    }
117    if parts.is_empty() {
118        return None;
119    }
120    if let Some(tail) = s.get(start..) {
121        parts.push(tail.trim());
122    }
123    Some(parts)
124}
125
126/// 先頭がキーワード(後ろに空白か `(` が続く)か。
127fn starts_with_kw(s: &str, kw: &str) -> bool {
128    let s = s.trim_start();
129    s.get(..kw.len()).is_some_and(|h| h.eq_ignore_ascii_case(kw))
130        && s.get(kw.len()..)
131            .and_then(|r| r.chars().next())
132            .is_some_and(|c| c.is_whitespace() || c == '(')
133}
134
135/// 先頭のキーワードを取り除いた残りを返す。
136fn strip_kw_prefix<'a>(s: &'a str, kw: &str) -> Option<&'a str> {
137    if starts_with_kw(s, kw) {
138        s.trim_start().get(kw.len()..).map(|r| r.trim())
139    } else {
140        None
141    }
142}
143
144/// 単一条件(`min-width: 400px` / `width > 400px` 等)を評価する。
145fn eval_single(cond: &str, size: ContainerSize) -> bool {
146    let c = cond.trim().trim_matches(|ch| ch == '(' || ch == ')').trim();
147    if c.is_empty() {
148        return false;
149    }
150    // `min-`/`max-` 前置構文。`inline-size`/`block-size` は横書き前提で
151    // `width`/`height` と同一視する。
152    for (prefix, is_w, is_min) in [
153        ("min-width:", true, true),
154        ("max-width:", true, false),
155        ("min-height:", false, true),
156        ("max-height:", false, false),
157        ("min-inline-size:", true, true),
158        ("max-inline-size:", true, false),
159        ("min-block-size:", false, true),
160        ("max-block-size:", false, false),
161    ] {
162        if let Some(rest) = strip_ci_prefix(c, prefix) {
163            let v = if is_w { size.width } else { size.height };
164            return parse_px(rest).is_some_and(|px| if is_min { v >= px } else { v <= px });
165        }
166    }
167    // レンジ構文。長い演算子から先に見ないと `>=` が `>` と誤解釈される。
168    for op in [">=", "<=", ">", "<", "="] {
169        if let Some((lhs, rhs)) = c.split_once(op) {
170            let axis = lhs.trim().to_ascii_lowercase();
171            let v = match axis.as_str() {
172                "width" | "inline-size" => size.width,
173                "height" | "block-size" => size.height,
174                _ => return false,
175            };
176            let Some(px) = parse_px(rhs) else {
177                return false;
178            };
179            return match op {
180                ">=" => v >= px,
181                "<=" => v <= px,
182                ">" => v > px,
183                "<" => v < px,
184                _ => v == px,
185            };
186        }
187    }
188    // 解釈できない条件は当てない。
189    false
190}
191
192/// 大文字小文字を無視して前置詞を剥がす。`min-width :` のような空白も許容。
193fn strip_ci_prefix<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
194    if s.get(..prefix.len())
195        .is_some_and(|h| h.eq_ignore_ascii_case(prefix))
196    {
197        return s.get(prefix.len()..);
198    }
199    // `min-width :` のように名前と `:` の間へ空白が入る書き方にも耐える。
200    let name = prefix.trim_end_matches(':');
201    let s_trim = s.trim_start();
202    if s_trim
203        .get(..name.len())
204        .is_some_and(|h| h.eq_ignore_ascii_case(name))
205    {
206        let rest = s_trim.get(name.len()..).unwrap_or("").trim_start();
207        if let Some(after) = rest.strip_prefix(':') {
208            return Some(after);
209        }
210    }
211    None
212}
213
214/// `400px` / `400` を px として読む。解釈できなければ `None`。
215fn parse_px(s: &str) -> Option<i32> {
216    let t = s.trim().trim_end_matches("px").trim();
217    if t.is_empty() {
218        return None;
219    }
220    t.parse::<f32>()
221        .ok()
222        .filter(|v| v.is_finite())
223        .map(|v| v as i32)
224}