Skip to main content

atmos/os_lib/web_engine/
mod.rs

1//! HTML パーサ・レイアウトエンジン・レンダラ(ブラウザエンジン本体)。
2//!
3//! [`WebEngine`] が HTML 文字列を受け取り、パース → CSS カスケード → レイアウト →
4//! 描画までの一連のパイプラインを提供する。JavaScript 実行は [`crate::os_lib::js`] へ、
5//! ネットワーク取得は [`crate::kernel::net_stack`] へ委譲する。
6//!
7//! アーキテクチャ詳細(コンポーネント構成・タブモデル・座標規約・テーブルレイアウト等)は
8//! `spec/DESIGN.md` §3「ブラウザエンジン アーキテクチャ」を参照。
9//!
10//! 暴走ページ対策として [`MAX_RENDER_ELEMENTS`](要素数上限)と [`MAX_HTML_BYTES`]
11//! (HTML サイズ上限)で打ち切る。
12// web_engine.rs - HTML Parser & Vector Font HTML Renderer Engine for AtmOS
13#![allow(dead_code)]
14
15use alloc::string::String;
16use alloc::vec::Vec;
17use spin::Mutex;
18
19/// flatten 時に生成する RenderElement の上限。病的なページでのヒープ枯渇を防ぐ。
20/// 1 要素あたり数百バイト程度なので、2 万要素でも十数 MB に収まる。
21const MAX_RENDER_ELEMENTS: usize = 20000;
22/// パース対象 HTML の上限バイト数。これを超える分は切り捨てる。
23const MAX_HTML_BYTES: usize = 2 * 1024 * 1024;
24
25/// ブラウザ内蔵のホームページ(OS 同梱 of 静的ページ)。
26/// YouTubeへのリンクは、DNS解決フォールバックやYouTubeモック解除の修正に伴い、
27/// ダミーURLではなく公式のYouTubeアドレスに設定されています。
28pub const HOME_HTML: &str = r#"<html><title>AtmOS Browser</title><body style="background-color: #282a36; color: #f8f8f2;"><h1 style="color: #bd93f9;">AtmOS Browser</h1><p style="color: #f8f8f2;">アトモス(AtmOS)の内蔵ブラウザへようこそ。HTML/CSS パーサ・レイアウトエンジン・レンダラと JavaScript エンジンをすべて自前実装しています。</p><hr style="color: #44475a;"><h2 style="color: #8be9fd;">リンク</h2><div style="background-color: #1e1f29; color: #f8f8f2;"><p><a href="https://www.sugi-lab.net/">www.sugi-lab.net</a></p><p style="color: #6272a4;">Sugimura Lab の Web サイト。AtmOS の開発元です。研究・教育・各種プロジェクト of 情報を掲載しています。</p><p><a href="https://www.youtube.com/">YouTube を観る</a></p><p style="color: #6272a4;">YouTube の Web サイトを開きます。</p></div><h2 style="color: #8be9fd;">検索</h2><p><a href="https://lite.duckduckgo.com/lite/">DuckDuckGo で検索する</a></p><p style="color: #6272a4;">上のアドレスバーにキーワードを入力しても、そのまま DuckDuckGo で検索できます。</p><hr style="color: #44475a;"><p style="font-size: 12; color: #6272a4;">AtmOS — bare-metal OS on Raspberry Pi 3B+ / ARM64 + Rust</p></body></html>"#;
29
30// ---------------------------------------------------------------------------
31// ディスクキャッシュ (SylFS /cache/browser/<host>/<path_hash>)
32// ---------------------------------------------------------------------------
33
34// URL を SylFS のキャッシュパスへ変換する
35mod cache;
36pub(crate) mod draw_helpers;
37pub(crate) mod helpers;
38mod image;
39mod layout;
40mod render;
41pub(crate) mod anim_budget;
42pub(crate) mod css_units;
43pub(crate) mod font_budget;
44pub(crate) mod cache_path;
45pub(crate) mod rel_path;
46pub(crate) mod sticky;
47pub(crate) mod fetch_limit;
48pub(crate) mod queue_batch;
49pub(crate) mod perf;
50pub(crate) mod font_queue;
51pub(crate) mod image_budget;
52pub(crate) mod alpha_blend;
53pub(crate) mod paint_order;
54pub(crate) mod repaint_scope;
55pub(crate) mod gradient_lut;
56pub(crate) mod blur_box;
57pub(crate) mod box_shadow;
58pub(crate) mod osm_embed;
59pub(crate) mod paint_cull;
60pub(crate) mod url_resolve;
61mod video;
62
63pub use cache::clear_browser_cache;
64pub use draw_helpers::{draw_border, draw_gradation};
65// 自己テスト(`css/selftest.rs`)から`line-height`/`text-indent`の`%`/`em`
66// 単位対応、`box-shadow`/`text-shadow`/`border-radius`の`rem`単位対応
67// (いずれも2026-07-17)を直接検証するための再エクスポート。
68pub(crate) use layout::{parse_line_height, parse_shadow_len_tok, resolve_border_radius, resolve_text_indent};
69pub use helpers::{hash_str, url_encode};
70pub use image::image_results_ready;
71pub use image::shared_image;
72pub use fetch_limit::set_draw_stage;
73
74/// 取得待ちのフォント件数。再描画の判定から使う。
75pub fn font_pending() -> usize {
76    font_queue::pending_len()
77}
78
79// Bring submodule items into scope for use within this module
80use cache::{cache_path_for, read_cache, write_cache};
81use image::{enqueue_image, ImageFetchReq, IMAGE_QUEUE};
82use video::stop_video_playback;
83
84fn parse_url(url: &str) -> Option<(alloc::string::String, alloc::string::String)> {
85    let rest = url.strip_prefix("http://")?;
86    if let Some(slash_idx) = rest.find('/') {
87        let (host, path) = rest.split_at(slash_idx);
88        Some((
89            alloc::string::String::from(host),
90            alloc::string::String::from(path),
91        ))
92    } else {
93        Some((
94            alloc::string::String::from(rest),
95            alloc::string::String::from("/"),
96        ))
97    }
98}
99
100/// 非印字キーの keycode を DOM の `KeyboardEvent.key` 風の名前に変換する。
101fn key_name_for(keycode: u8) -> &'static str {
102    match keycode {
103        0x1C => "Enter",
104        0x0E => "Backspace",
105        0x0F => "Tab",
106        0x01 => "Escape",
107        0x39 => " ", // Space
108        0x53 => "Delete",
109        0x48 => "ArrowUp",
110        0x50 => "ArrowDown",
111        0x4B => "ArrowLeft",
112        0x4D => "ArrowRight",
113        0x47 => "Home",
114        0x4F => "End",
115        _ => "Unidentified",
116    }
117}
118
119#[derive(Clone)]
120struct AsyncLoadRequest {
121    owner: u64, // 発行元エンジン(タブ)の一意 ID。結果は同じ owner だけが消費する
122    is_https: bool,
123    host: String,
124    path: String,
125    method: String,       // "GET" or "POST"
126    body: Option<String>, // POST body (application/x-www-form-urlencoded)
127    status: u8,           // 0: 空, 1: 処理中, 2: 成功, 3: 失敗
128    result_body: Option<String>,
129    result_dom: Option<crate::os_lib::dom::Node>,
130    error_msg: Option<String>,
131}
132
133/// 全タブ共有の非同期ロードキュー。owner ID で各タブの結果を分離する。
134struct AsyncLoadState {
135    requests: Vec<AsyncLoadRequest>,
136    thread_active: bool,
137}
138
139// ---------------------------------------------------------------------------
140// 画像非同期ロードキュー
141// ---------------------------------------------------------------------------
142
143static SVG_BACK: &[u8] = b"<svg width=\"20\" height=\"20\" viewBox=\"0 0 20 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M12.5 16.5L6 10L12.5 3.5\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>";
144
145static SVG_HOME: &[u8] = b"<svg width=\"20\" height=\"20\" viewBox=\"0 0 20 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M10 2.5L3.5 8V16.5C3.5 17 4 17.5 4.5 17.5H7.5V12.5H12.5V17.5H15.5C16 17.5 16.5 17 16.5 16.5V8L10 2.5Z\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linejoin=\"round\"/></svg>";
146
147static SVG_SHIELD_SECURE: &[u8] = b"<svg width=\"20\" height=\"20\" viewBox=\"0 0 20 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M10 2.5C13 3.5 16.5 3.5 16.5 3.5V8.5C16.5 13 13 16.5 10 17.5C7 16.5 3.5 13 3.5 8.5V3.5C3.5 3.5 7 3.5 10 2.5Z\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linejoin=\"round\"/><path d=\"M7 10L9 12L13 8\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>";
148
149static SVG_SHIELD_WARN: &[u8] = b"<svg width=\"20\" height=\"20\" viewBox=\"0 0 20 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M10 2.5C13 3.5 16.5 3.5 16.5 3.5V8.5C16.5 13 13 16.5 10 17.5C7 16.5 3.5 13 3.5 8.5V3.5C3.5 3.5 7 3.5 10 2.5Z\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linejoin=\"round\"/><path d=\"M4.5 4.5L15.5 15.5\" stroke=\"currentColor\" stroke-width=\"2\"/></svg>";
150
151static SVG_SAVE: &[u8] = b"<svg width=\"20\" height=\"20\" viewBox=\"0 0 20 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M16 18H4C3 18 2 17 2 16V4C2 3 3 2 4 2H13L18 7V16C18 17 17 18 16 18Z\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M14 18V11H6V18\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M6 2V6H12\" stroke=\"currentColor\" stroke-width=\"2\"/></svg>";
152
153static SVG_LOAD: &[u8] = b"<svg width=\"20\" height=\"20\" viewBox=\"0 0 20 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M18 16C18 17 17 18 16 18H4C3 18 2 17 2 16V4C2 3 3 2 4 2H8L10 4H16C17 4 18 5 18 6V16Z\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M2 8H18\" stroke=\"currentColor\" stroke-width=\"2\"/></svg>";
154
155static SVG_WRAP: &[u8] = b"<svg width=\"20\" height=\"20\" viewBox=\"0 0 20 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M3 5H17\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M3 10H13C15 10 16 11 16 13C16 15 15 16 13 16H11\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M13 14L11 16L13 18\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M3 15H6\" stroke=\"currentColor\" stroke-width=\"2\"/></svg>";
156
157static ASYNC_LOAD: Mutex<AsyncLoadState> = Mutex::new(AsyncLoadState {
158    requests: Vec::new(),
159    thread_active: false,
160});
161static NEXT_ENGINE_ID: spin::Mutex<u64> = spin::Mutex::new(1);
162
163fn async_load_thread_entry() {
164    loop {
165        let req_opt = {
166            let lock = ASYNC_LOAD.lock();
167            // 処理中(status 1)のリクエストを1件複製して処理する
168            lock.requests.iter().find(|r| r.status == 1).cloned()
169        };
170
171        let req = match req_opt {
172            Some(r) => r,
173            None => {
174                // 処理待ちが無くなったらスレッド終了
175                let mut lock = ASYNC_LOAD.lock();
176                if !lock.requests.iter().any(|r| r.status == 1) {
177                    lock.thread_active = false;
178                    drop(lock);
179                    // 【2026-08-05】メイン HTML の取得が終わったので、
180                    // 止めていたリソース取得を再開させる(`fetch_limit` 参照)。
181                    fetch_limit::set_main_load_in_flight(false);
182                    crate::kernel::scheduler::exit();
183                }
184                fetch_limit::set_main_load_in_flight(false);
185                continue;
186            }
187        };
188        // 【2026-08-05】メイン HTML の取得中はリソース取得を新たに始めない。
189        //
190        // フォント 4 + 画像 4 で最大 8 本の TLS 接続を張れており、
191        // この自作スタックは同時接続に弱い。CSS/JS も非同期化したときに
192        // **メイン HTML が読み込めなくなった**(`[NET][STATS] total=3`)。
193        // ページの他の何よりもメイン HTML が優先される
194        // (それが無ければ描くものが無い)。
195        fetch_limit::set_main_load_in_flight(true);
196
197        {
198            crate::warn!(
199                "BROWSER_LOADER: thread started. is_https={}, host={}, path={}",
200                req.is_https,
201                req.host,
202                req.path
203            );
204
205            let mut current_is_https = req.is_https;
206            let mut current_host = req.host.clone();
207            let mut current_path = req.path.clone();
208
209            let is_post = req.method.eq_ignore_ascii_case("POST");
210            let post_body = req.body.clone().unwrap_or_default();
211
212            // --- POST は常にネットワーク(キャッシュしない) ---
213            // --- GET: キャッシュチェック(デコード失敗時はネットワーク取得にフォールスルー)---
214            let res = if !is_post {
215                if let Some(cached) = read_cache(&current_host, &current_path) {
216                    match alloc::string::String::from_utf8(cached) {
217                        Ok(s) => {
218                            crate::info!(
219                                "BROWSER_LOADER: cache hit. host={}, path={}",
220                                current_host,
221                                current_path
222                            );
223                            Some(Ok(s))
224                        }
225                        Err(_) => {
226                            // 壊れたキャッシュは削除してネットワーク取得
227                            crate::warn!("[NET] BROWSER_LOADER: cache decode error, fetching from network. host={}", current_host);
228                            let cp = cache_path_for(&current_host, &current_path);
229                            let _fs_guard = crate::kernel::fs::FS_LOCK.lock();
230                            let _ = crate::kernel::fs::get_fs().delete_file(&cp);
231                            None
232                        }
233                    }
234                } else {
235                    None
236                }
237            } else {
238                None
239            };
240
241            let res = if let Some(r) = res {
242                r
243            } else {
244                // --- ネットワーク取得 ---
245                // リンクが up していなければ即エラー
246                if !crate::kernel::net::status().link_up {
247                    crate::warn!("[NET] BROWSER_LOADER: network link is down");
248                    Err(String::from("Network link is down. Check QEMU network settings or USB-Ethernet adapter."))
249                } else {
250                    let stack = crate::kernel::net_stack::TcpIpStack::new();
251
252                    // POST の場合は1回だけ送信(リダイレクトは GET に変換)
253                    if is_post {
254                        crate::info!(
255                            "BROWSER_LOADER: POST {} host={}, path={}",
256                            if current_is_https { "HTTPS" } else { "HTTP" },
257                            current_host,
258                            current_path
259                        );
260                        match stack.web_post(
261                            current_is_https,
262                            &current_host,
263                            &current_path,
264                            "application/x-www-form-urlencoded",
265                            post_body.as_bytes(),
266                        ) {
267                            Ok(r) => {
268                                // 303 See Other は GET でリダイレクト
269                                if r.status_code == 303
270                                    || r.status_code == 301
271                                    || r.status_code == 302
272                                {
273                                    if let Some(loc) = r.headers.get("location") {
274                                        let loc_str = loc.as_str();
275                                        if let Some(no_proto) = loc_str.strip_prefix("https://") {
276                                            current_is_https = true;
277                                            if let Some(idx) = no_proto.find('/') {
278                                                let (h, p) = no_proto.split_at(idx);
279                                                current_host = String::from(h);
280                                                current_path = String::from(p);
281                                            } else {
282                                                current_host = String::from(no_proto);
283                                                current_path = String::from("/");
284                                            }
285                                        } else if let Some(no_proto) =
286                                            loc_str.strip_prefix("http://")
287                                        {
288                                            current_is_https = false;
289                                            if let Some(idx) = no_proto.find('/') {
290                                                let (h, p) = no_proto.split_at(idx);
291                                                current_host = String::from(h);
292                                                current_path = String::from(p);
293                                            } else {
294                                                current_host = String::from(no_proto);
295                                                current_path = String::from("/");
296                                            }
297                                        } else if loc_str.starts_with('/') {
298                                            current_path = String::from(loc_str);
299                                        }
300                                        // GET でリダイレクト先を取得
301                                        match stack.web_get(
302                                            current_is_https,
303                                            &current_host,
304                                            &current_path,
305                                        ) {
306                                            Ok(r2) => {
307                                                write_cache(
308                                                    &current_host,
309                                                    &current_path,
310                                                    r2.body.as_bytes(),
311                                                );
312                                                Ok(r2.body)
313                                            }
314                                            Err(e) => Err(String::from(e.message)),
315                                        }
316                                    } else {
317                                        Ok(r.body)
318                                    }
319                                } else {
320                                    Ok(r.body)
321                                }
322                            }
323                            Err(e) => Err(String::from(e.message)),
324                        }
325                    } else {
326                        let mut final_res = None;
327                        // 【2026-07-26】従来はネットワークエラーが1回起きた時点で
328                        // 即座に失敗確定にしていた。この自作TCP/TLSスタックは
329                        // 実測でパケット消失・タイムアウトが頻発しており
330                        // (詳細は spec/TODO.md)、メインHTMLの取得が1回失敗する
331                        // だけでページ全体が表示されない。外部CSS取得側
332                        // (`https_get_binary`) は既に最大3回の再試行を持つのに、
333                        // 最も重要なメインHTMLだけが再試行なしだった。
334                        // リダイレクト追従とは別枠で再試行回数を確保する。
335                        let mut fetch_retries = 2u32;
336                        for _ in 0..6 {
337                            // リダイレクト追従(最大3回)+ 取得失敗時の再試行
338                            match stack.web_get(current_is_https, &current_host, &current_path) {
339                                Ok(r) => {
340                                    let code = r.status_code;
341                                    if code == 301
342                                        || code == 302
343                                        || code == 303
344                                        || code == 307
345                                        || code == 308
346                                    {
347                                        if let Some(loc) = r.headers.get("location") {
348                                            crate::info!("[NET] BROWSER_LOADER: Redirect to {}", loc);
349                                            let loc_str = loc.as_str();
350                                            if let Some(no_proto) = loc_str.strip_prefix("http://")
351                                            {
352                                                current_is_https = false;
353                                                if let Some(idx) = no_proto.find('/') {
354                                                    let (h, p) = no_proto.split_at(idx);
355                                                    current_host = String::from(h);
356                                                    current_path = String::from(p);
357                                                } else {
358                                                    current_host = String::from(no_proto);
359                                                    current_path = String::from("/");
360                                                }
361                                                continue;
362                                            } else if let Some(no_proto) =
363                                                loc_str.strip_prefix("https://")
364                                            {
365                                                current_is_https = true;
366                                                if let Some(idx) = no_proto.find('/') {
367                                                    let (h, p) = no_proto.split_at(idx);
368                                                    current_host = String::from(h);
369                                                    current_path = String::from(p);
370                                                } else {
371                                                    current_host = String::from(no_proto);
372                                                    current_path = String::from("/");
373                                                }
374                                                continue;
375                                            } else if loc_str.starts_with('/') {
376                                                current_path = String::from(loc_str);
377                                                continue;
378                                            }
379                                        }
380                                    }
381                                    // キャッシュに保存
382                                    write_cache(&current_host, &current_path, r.body.as_bytes());
383                                    final_res = Some(Ok(r.body));
384                                    break;
385                                }
386                                Err(e) => {
387                                    if fetch_retries > 0 {
388                                        fetch_retries -= 1;
389                                        crate::warn!(
390                                            "[NET] BROWSER_LOADER: fetch failed ({}), retrying ({} left) host={} path={}",
391                                            e.message,
392                                            fetch_retries,
393                                            current_host,
394                                            current_path
395                                        );
396                                        continue;
397                                    }
398                                    final_res = Some(Err(String::from(e.message)));
399                                    break;
400                                }
401                            }
402                        }
403                        final_res.unwrap_or_else(|| Err(String::from("Too many redirects")))
404                    } // end GET block
405                } // end link_up check
406            }; // end res
407
408            crate::warn!("[NET][DIAG] async_load: res computed, about to lock ASYNC_LOAD");
409            let mut lock = ASYNC_LOAD.lock();
410            crate::warn!("[NET][DIAG] async_load: ASYNC_LOAD locked");
411            // owner が一致し、まだ処理中(status 1)のリクエストへ結果を書き戻す。
412            // (処理中に同じタブが再ロードした場合は古いリクエストが置換されているため、
413            //   status==1 のものだけを対象にすることで取り違えを防ぐ)
414            if let Some(current_req) = lock
415                .requests
416                .iter_mut()
417                .find(|r| r.owner == req.owner && r.status == 1)
418            {
419                match res {
420                    Ok(body) => {
421                        crate::info!("[NET] BROWSER_LOADER: load done. size={}", body.len());
422                        let dom_tree = crate::os_lib::dom::parse_html(&body);
423                        current_req.status = 2; // 成功
424                        current_req.result_body = Some(body);
425                        current_req.result_dom = Some(dom_tree);
426                    }
427                    Err(e) => {
428                        crate::warn!("[NET] BROWSER_LOADER: load failed. err=[{}] len={}", e, e.len());
429                        current_req.status = 3; // 失敗
430                        current_req.error_msg = Some(e);
431                    }
432                }
433            }
434        }
435    } // loop
436}
437
438/// CSS `border-style` の値。RenderElement は要素あたり最大 5 個(全体 + 4辺)保持するため、
439/// String(24バイト)ではなく 1 バイトの enum にしてメモリを節約する。
440#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
441pub enum BorderStyle {
442    #[default]
443    None,
444    Solid,
445    Dashed,
446    Dotted,
447    Double,
448}
449
450impl BorderStyle {
451    pub fn as_str(self) -> &'static str {
452        match self {
453            BorderStyle::None => "none",
454            BorderStyle::Solid => "solid",
455            BorderStyle::Dashed => "dashed",
456            BorderStyle::Dotted => "dotted",
457            BorderStyle::Double => "double",
458        }
459    }
460
461    pub fn parse(s: &str) -> Self {
462        match s.trim().to_ascii_lowercase().as_str() {
463            "solid" => BorderStyle::Solid,
464            "dashed" => BorderStyle::Dashed,
465            "dotted" => BorderStyle::Dotted,
466            "double" => BorderStyle::Double,
467            _ => BorderStyle::None,
468        }
469    }
470
471    pub fn is_none(self) -> bool {
472        matches!(self, BorderStyle::None)
473    }
474}
475
476/// CSS `list-style-type` の値。同じ理由で String の代わりに enum を使う。
477#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
478pub enum ListStyleType {
479    /// 未指定(コンテキストに応じたデフォルトマーカーを使う)
480    #[default]
481    Unset,
482    None,
483    Disc,
484    Circle,
485    Square,
486    Decimal,
487    UpperAlpha,
488    LowerAlpha,
489    UpperRoman,
490    LowerRoman,
491}
492
493impl ListStyleType {
494    pub fn parse(s: &str) -> Self {
495        match s.trim().to_ascii_lowercase().as_str() {
496            "none" => ListStyleType::None,
497            "disc" => ListStyleType::Disc,
498            "circle" => ListStyleType::Circle,
499            "square" => ListStyleType::Square,
500            "decimal" => ListStyleType::Decimal,
501            "upper-alpha" | "upper-latin" => ListStyleType::UpperAlpha,
502            "lower-alpha" | "lower-latin" => ListStyleType::LowerAlpha,
503            "upper-roman" => ListStyleType::UpperRoman,
504            "lower-roman" => ListStyleType::LowerRoman,
505            _ => ListStyleType::Unset,
506        }
507    }
508}
509
510/// 1始まりの序数を A, B, ... Z, AA, AB, ... の26進表記に変換する(`upper-alpha`/`lower-alpha`)。
511pub fn number_to_alpha(mut n: u32, upper: bool) -> alloc::string::String {
512    if n == 0 {
513        n = 1;
514    }
515    let mut letters = alloc::vec::Vec::new();
516    while n > 0 {
517        let rem = ((n - 1) % 26) as u8;
518        let base = if upper { b'A' } else { b'a' };
519        letters.push((base + rem) as char);
520        n = (n - 1) / 26;
521    }
522    letters.iter().rev().collect()
523}
524
525/// 1〜3999 の序数をローマ数字表記に変換する(`upper-roman`/`lower-roman`)。範囲外は十進数にフォールバック。
526pub fn number_to_roman(n: u32, upper: bool) -> alloc::string::String {
527    if n == 0 || n > 3999 {
528        return alloc::format!("{}", n);
529    }
530    const VALUES: &[(u32, &str)] = &[
531        (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
532        (100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
533        (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"),
534    ];
535    let mut remaining = n;
536    let mut out = alloc::string::String::new();
537    for (value, symbol) in VALUES {
538        while remaining >= *value {
539            out.push_str(symbol);
540            remaining -= value;
541        }
542    }
543    if upper {
544        out
545    } else {
546        out.to_ascii_lowercase()
547    }
548}
549
550/// CSS `background-size` の1軸ぶんの値。
551#[derive(Clone, Copy, PartialEq, Debug)]
552pub enum BgSizeVal {
553    Auto,
554    Px(i32),
555    Percent(f32),
556}
557
558/// CSS `background-size` のモード。
559#[derive(Clone, Copy, PartialEq, Debug, Default)]
560pub enum BgSizeMode {
561    /// 未指定時の既定挙動(このエンジンの互換維持のため box 全面に引き伸ばす)。
562    #[default]
563    Stretch,
564    Cover,
565    Contain,
566    Explicit(BgSizeVal, BgSizeVal),
567}
568
569/// CSS `background-repeat` の1軸ぶんのモード。
570#[derive(Clone, Copy, PartialEq, Debug, Default)]
571pub enum BgRepeatMode {
572    #[default]
573    Repeat,
574    NoRepeat,
575    /// タイルを等間隔に敷き詰め、余った分は境界を切らずタイル間の隙間として配分する。
576    Space,
577    /// タイルの寸法をこの軸に整数個ちょうど収まるよう拡縮する。
578    Round,
579}
580
581/// CSS `object-fit`(<img> 要素の内容フィッティング)。
582#[derive(Clone, Copy, PartialEq, Debug, Default)]
583pub enum ObjectFit {
584    /// 既定値。box 全面へ引き伸ばす(従来互換)。
585    #[default]
586    Fill,
587    Contain,
588    Cover,
589    /// スケーリングせず原寸のまま配置。
590    None,
591    /// contain と none の小さい方(画像を原寸より拡大しない)。
592    ScaleDown,
593}
594
595// 描画されるフラットなレイアウト要素
596#[derive(Debug, Clone, PartialEq)]
597pub struct GradientStop {
598    pub color: u32,
599    pub position: f32, // 0.0 ~ 1.0
600}
601
602#[derive(Debug, Clone, PartialEq)]
603pub struct LinearGradient {
604    pub angle_deg: f32,
605    pub stops: alloc::vec::Vec<GradientStop>,
606}
607
608#[derive(Debug, Clone, PartialEq)]
609pub enum RadialShape {
610    Circle,
611    Ellipse,
612}
613
614#[derive(Debug, Clone, PartialEq)]
615pub struct RadialGradient {
616    pub shape: RadialShape,
617    pub pos_x: f32, // 0.0 ~ 1.0
618    pub pos_y: f32, // 0.0 ~ 1.0
619    pub stops: alloc::vec::Vec<GradientStop>,
620}
621
622#[derive(Debug, Clone, PartialEq)]
623pub enum CssFilter {
624    Blur(i32), // radius in px
625    HueRotate(f32), // angle in degrees
626    DropShadow(i32, i32, i32, u32), // ox, oy, blur, color
627}
628
629#[derive(Debug, Clone, PartialEq)]
630pub struct RenderElement {
631    pub text: String,
632    pub font_size: u32,
633    pub color: u32,
634    pub bg_color: Option<u32>, // 背景色の追加
635    pub hover_bg_color: Option<u32>,
636    pub hover_color: Option<u32>,
637    pub hover_border_color: Option<u32>,
638    /// :focus 時の算出値差分(hover 系と同じ仕組み。実際の適用可否は
639    /// 描画側で focused_id と element_id の一致を見て選ぶ)。
640    pub focus_bg_color: Option<u32>,
641    pub focus_color: Option<u32>,
642    pub focus_border_color: Option<u32>,
643    /// :active 時の算出値差分(hover/focus 系と同じ仕組み。実際の適用可否は
644    /// 描画側で「ホバー中 かつ マウス左ボタン押下中」を見て選ぶ)。
645    pub active_bg_color: Option<u32>,
646    pub active_color: Option<u32>,
647    pub active_border_color: Option<u32>,
648    /// `::marker{color:...}` による箇条書き/番号マーカーの色上書き(無指定なら `color` を継承)。
649    pub marker_color: Option<u32>,
650    /// `::marker{font-size:...}` によるマーカーのフォントサイズ上書き(無指定なら周囲のサイズを継承)。
651    pub marker_font_size: Option<u32>,
652    /// `::marker{content:...}` によるマーカー文字列そのものの上書き(無指定なら
653    /// `list-style-type` から導出した既定の記号/番号を使う)。
654    pub marker_content: Option<alloc::string::String>,
655    pub border_color: Option<u32>,
656    pub border_style: BorderStyle,
657    pub font_bold: bool,    // font-weight: bold
658    pub font_italic: bool,  // font-style: italic
659    pub underline: bool,    // text-decoration: underline
660    pub line_through: bool, // text-decoration: line-through
661    pub overline: bool,     // text-decoration: overline
662    /// text-decoration-color(未指定なら文字色を使う)。
663    pub decoration_color: Option<u32>,
664    /// text-decoration-style(solid/dotted/dashed/wavy/double、既定 solid)。
665    pub decoration_style: String,
666    /// text-decoration-thickness(px。`auto`/未指定は1px相当)。
667    pub decoration_thickness: i32,
668    /// text-underline-offset(px。underline のみに適用、0/負値も可。`auto`/未指定は0)。
669    pub decoration_underline_offset: i32,
670    /// `scroll-margin-top`(px。アンカー/フラグメントナビゲーションのスクロール位置から
671    /// この分だけ引く。固定ヘッダーの下にターゲットが隠れないようにする用途)。
672    pub scroll_margin_top: i32,
673    pub client_insets: Option<(i32, i32, i32, i32)>, // (top, right, bottom, left) in px
674    /// `caret-color`(テキスト入力のキャレット色。未指定なら IME 状態に応じた既定色を使う)。
675    pub caret_color: Option<u32>,
676    /// `accent-color`(checkbox/radio のチェック時の塗り色。未指定ならテーマ既定色を使う)。
677    pub accent_color: Option<u32>,
678    /// `::placeholder{color:...}`(合成プロパティ `x-placeholder-color` 経由)。
679    /// 未指定ならテーマの薄色(`editor_dim_fg`)を使う。
680    pub placeholder_color: Option<u32>,
681    /// `<input>`/`<textarea>` の `placeholder` 属性(値が空のときだけ薄色で表示する)。
682    pub placeholder: String,
683    pub is_video: bool,     // 動画要素フラグ
684    pub video_src: String,  // 動画ソースURL
685    pub is_iframe: bool,    // iframe 要素フラグ
686    pub iframe_src: String, // iframe の src URL
687    pub is_link: bool,
688    /// `pointer-events:none` — true ならこの要素はクリック/ホバー判定の対象から除外する
689    /// (リンク登録をスキップする簡略実装。子孫への `pointer-events:auto` による
690    /// 個別復元は非対応)。
691    pub pointer_events_none: bool,
692    pub href: String,
693    pub is_hr: bool,
694    pub is_img: bool,
695    /// `<img loading="lazy">`。これだけは画面に入ってから取得する(2026-09-26)。
696    pub img_lazy: bool,
697    pub img_alt: String,
698    /// `<canvas>` の 2D コンテキスト id(`os_lib/canvas2d` の registry)。
699    ///
700    /// JS が描いた内容はその registry の `Surface` にあり、ここに id が
701    /// 入っていれば描画時に要素の矩形へ転送する。`None` なら canvas 以外か、
702    /// まだ `getContext("2d")` が呼ばれていない。
703    pub canvas_ctx_id: Option<u32>,
704    /// `aspect-ratio`(CSS Box Sizing Level 4。幅と高さの比率 `width / height`)。
705    pub aspect_ratio: Option<f32>,
706    pub is_input: bool,
707    pub is_textarea: bool,
708    pub is_button: bool,
709    /// type=reset のボタンか。クリックで所属 form を初期値へ戻す(送信しない)。
710    pub is_reset: bool,
711    /// 入力種別: 0=テキスト, 1=checkbox, 2=radio, 3=select。
712    /// checkbox/radio/select は is_input=false(テキスト編集対象ではない)として扱い、
713    /// クリックでトグル/循環し change イベントを発火する。
714    pub input_kind: u8,
715    /// radio のグループ名(name 属性)。同一グループ内で1つだけ checked になる。
716    pub radio_group: String,
717    /// radio/checkbox の value 属性(送信値・radio選択値)。
718    pub input_value: String,
719    /// select の選択肢 (value, ラベル) 一覧。input_kind==3 のときのみ有効。
720    pub select_options: alloc::vec::Vec<(String, String)>,
721    /// <label> 要素の関連付け先。for 属性の値、または内包 input の id。
722    /// 非空なら、この要素クリックで該当 input をトグル/フォーカスする。
723    pub label_for: String,
724    pub button_icon: u8, // 0: なし, 1: Save, 2: Load, 3: Wrap
725    pub element_id: String,
726    pub onclick: String,
727    pub form_action: String,
728    pub form_method: String,
729    pub width: i32,
730    pub x_offset: i32, // コンテンツ全体でのX相対座標 (px)
731    pub y_offset: i32, // コンテンツ全体でのY相対座標 (px)
732    pub height: i32,
733    pub border_radius: (i32, i32, i32, i32), // (tl, tr, br, bl)
734    pub is_li: bool,
735    pub li_num: Option<u32>,
736    pub list_style_type: ListStyleType,
737    /// list-style-position: inside なら true(マーカーを content 内で本文の直前に描画)。
738    pub list_style_inside: bool,
739    /// `list-style-image: url(...)`(`list-style` ショートハンド内の url() も含む)で
740    /// 解決された画像パス。空文字なら未指定(従来通り list_style_type のグリフを使う)。
741    pub list_style_image: String,
742    pub is_blockquote: bool,
743    pub border_top_width: i32,
744    pub border_right_width: i32,
745    pub border_bottom_width: i32,
746    pub border_left_width: i32,
747    pub border_top_color: Option<u32>,
748    pub border_right_color: Option<u32>,
749    pub border_bottom_color: Option<u32>,
750    pub border_left_color: Option<u32>,
751    pub border_top_style: BorderStyle,
752    pub border_right_style: BorderStyle,
753    pub border_bottom_style: BorderStyle,
754    pub border_left_style: BorderStyle,
755    pub padding_top: i32,
756    pub padding_right: i32,
757    pub padding_bottom: i32,
758    pub padding_left: i32,
759    /// `background-clip`/`background-origin`(`"border-box"`/`"padding-box"`/`"content-box"`)。
760    /// `background-clip: text` は非対応で `border-box` にフォールバック。
761    pub bg_clip: String,
762    pub bg_origin: String,
763    /// `background-attachment`(`"scroll"`(既定)/`"fixed"`)。`local` は `scroll` と同一視する。
764    pub bg_attachment: String,
765    pub bg_image: Option<String>,
766    /// `background-image: url(a), url(b)` のような複数レイヤー全体(`bg_image` は先頭と同じ値)。
767    /// 全レイヤーが同じ position/size/repeat を共有する簡略実装(レイヤーごとの個別指定は非対応)。
768    pub bg_images: Vec<String>,
769    /// `background-blend-mode`(`background-image` を `background-color` に対して
770    /// 合成する際のブレンドモード。`mix-blend-mode` と同じ `blend_pixel` を再利用する)。
771    /// 空文字列/`"normal"` は通常のアルファ合成。
772    pub background_blend_mode: String,
773    /// 複数レイヤー(`bg_images.len() > 1`)向けの、レイヤーごとの
774    /// (size, repeat_x, repeat_y, pos_x, pos_y)。単一レイヤーの場合は空(`bg_size`/
775    /// `bg_repeat_*`/`bg_pos_*` をそのまま使う)。
776    pub bg_layers: alloc::vec::Vec<(BgSizeMode, BgRepeatMode, BgRepeatMode, f32, f32)>,
777    // --- New CSS feature fields ---
778    pub pos_top: Option<i32>,
779    pub pos_left: Option<i32>,
780    pub pos_right: Option<i32>,
781    pub pos_bottom: Option<i32>,
782    pub z_index: i32,
783    /// DOM ツリー上の深さ。描画ソートの第 2 キー。
784    /// 要素列は祖先が先に積まれるとは限らないため、同一 z 内で祖先(浅い方)を
785    /// 先に描くにはこれが要る(`spec/paint_order.md` の不変条件 I-1)。
786    pub paint_depth: u32,
787    pub is_fixed: bool,
788    pub overflow_hidden: bool,
789    pub clip_x0: i32,
790    pub clip_y0: i32,
791    pub clip_x1: i32,
792    pub clip_y1: i32,
793    /// box-shadow: カンマ区切りの複数シャドウに対応(各要素は (offset_x, offset_y, blur, color, is_inset))。
794    /// 空 Vec = 未指定。CSSの重なり順(先頭が最前面)どおり Vec の先頭から順に並ぶ。
795    pub box_shadow: alloc::vec::Vec<(i32, i32, i32, u32, bool)>,
796    pub white_space_nowrap: bool,
797    pub text_overflow_ellipsis: bool,
798    /// `direction:rtl` かどうか。`text-overflow:ellipsis` の省略記号を先頭側に出し、
799    /// 末尾ではなく先頭側の文字を切り詰める(CSS仕様どおりの挙動)ために使う。
800    pub text_overflow_rtl: bool,
801    pub linear_gradient: Option<LinearGradient>,
802    pub radial_gradient: Option<RadialGradient>,
803    /// `conic-gradient(c1, c2, ...)` の全カラーストップ(12時方向から時計回り、等間隔配置)。
804    /// 以前は先頭2色のみを保持する `(u32, u32)` で、3色目以降が丸ごと失われるバグがあった。
805    pub conic_gradient: Option<alloc::vec::Vec<GradientStop>>,
806    /// (全カラーストップ, cycle_px, is_vertical)。以前は先頭2色固定の `(u32,u32,i32,bool)`
807    /// で3色目以降が失われていた。周期内で全色を均等割りして補間する。
808    pub repeating_linear_gradient: Option<(alloc::vec::Vec<u32>, i32, bool)>,
809    /// (全カラーストップ, cycle_px)。以前は先頭2色固定の `(u32,u32,i32)` で
810    /// 3色目以降が失われていた。
811    pub repeating_radial_gradient: Option<(alloc::vec::Vec<u32>, i32)>,
812    pub opacity: u8,                               // 0=transparent, 255=opaque
813    pub visibility_hidden: bool,
814    /// text-shadow: カンマ区切りの複数シャドウに対応(各要素は (offset_x, offset_y, blur, color))。
815    /// 空 Vec = 未指定。CSSの重なり順(先頭が最前面)どおり Vec の先頭から順に並ぶ。
816    pub text_shadow: alloc::vec::Vec<(i32, i32, i32, u32)>,
817    /// ::before / ::after content string (empty = no pseudo element)
818    pub before_content: String,
819    pub after_content: String,
820    /// `::before { font-family: "Font Awesome 6 Free"; }` 等、疑似要素専用の
821    /// `font-family`(空 = 指定なし = 通常のグローバルフォントで描画)。
822    /// FontAwesome等のアイコンフォントは複数ファミリーが同じprivate-use-area
823    /// コードポイントに異なるグリフを割り当てるため、要素の`content`が
824    /// アイコン専用(`el.text`が空でicon用の`before_content`のみ)の場合に限り、
825    /// このファミリー名で登録済みカスタムフォントを優先的に探して描画する。
826    /// 【2026-08-03】要素自身の `font-family`(カンマ区切りのまま保持)。
827    ///
828    /// `@font-face` で登録した Web フォントを字形へ反映するには、
829    /// 描画時にどの family を使うかが要る。従来は `::before` 用の
830    /// `before_font_family` しか持っておらず、**本文の family がどこにも
831    /// 渡っていなかった**ため、フォントを取得・登録しても反映されなかった。
832    pub font_family: String,
833    pub before_font_family: String,
834    /// CSS transform: translate(x,y) / scale(sx,sy) / rotate(deg) の簡易表現
835    pub transform_tx: i32, // translateX px
836    pub transform_ty: i32,         // translateY px
837    pub transform_scale_x: f32,    // X軸 scale factor (1.0 = no scale)。scale()/scaleX() で設定
838    pub transform_scale_y: f32,    // Y軸 scale factor (1.0 = no scale)。scale()/scaleY() で設定
839    pub transform_rotate_deg: f32, // rotate degrees
840    /// transform: skewX(deg) せん断変換のX軸角度(度。既定 0.0 = 変換なし)。
841    pub transform_skew_x: f32,
842    /// transform: skewY(deg) せん断変換のY軸角度(度。既定 0.0 = 変換なし)。
843    pub transform_skew_y: f32,
844    /// transform-origin(scale の基準点。0.0-1.0 の割合、既定は (0.5,0.5)=中央)。
845    pub transform_origin_x: f32,
846    pub transform_origin_y: f32,
847    /// letter-spacing(文字間に加える px、負も可)。0 = normal。
848    pub letter_spacing: i32,
849    /// word-spacing(半角スペース1文字ごとに加える px、負も可)。0 = normal。
850    pub word_spacing: i32,
851    /// text-emphasis-style: 各文字の上に付す強調記号("dot"/"circle"/"double-circle"/
852    /// "triangle"/"sesame"、またはクォート付きカスタム1文字、"none"/空文字列 = 無し)。
853    /// `text-emphasis-position` は非対応で常に文字の上(横書き前提)に描く。
854    pub text_emphasis_style: String,
855    /// text-emphasis-color(未指定なら文字色 `color` を使う)。
856    pub text_emphasis_color: Option<u32>,
857    /// CSS `cursor`。`"text"` のみ実際のマウスカーソル形状(I-beam)へ反映する。
858    /// それ以外の非対応キーワード(`pointer`/`wait`/`help` 等)は既定の矢印にフォールバックする。
859    pub cursor_style: String,
860    /// このエレメントが所属するスクロールコンテナの element_id(空 = ページスクロール)。
861    pub scroll_container_id: String,
862    /// CSS clip-path value (e.g. "circle(50%)", empty = no clip)
863    pub clip_path: String,
864    /// position: sticky
865    pub is_sticky: bool,
866    /// sticky の吸着オフセット(top/bottom)。両方指定時は top を優先する。
867    pub sticky_top: Option<i32>,
868    pub sticky_bottom: Option<i32>,
869    /// sticky の可動範囲下限(吸着後にこれ以上下げない、含む要素の下端 - height)。
870    /// 未設定時(属するコンテナが無い等)は i32::MAX として無制限に振る舞う。
871    pub sticky_bound_max_y: i32,
872    /// background-size(既定は Stretch = box 全面に引き伸ばし、従来互換)。
873    pub bg_size: BgSizeMode,
874    /// background-repeat: x/y 各軸のモード(Stretch モードでは効果なし)。
875    pub bg_repeat_x: BgRepeatMode,
876    pub bg_repeat_y: BgRepeatMode,
877    /// background-position のアンカー位置(0.0=左/上, 1.0=右/下, 0.5=中央)。
878    pub bg_pos_x: f32,
879    pub bg_pos_y: f32,
880    /// outline(レイアウトに影響しない装飾線。border-box の外側に描画)。
881    pub outline_color: Option<u32>,
882    pub outline_width: i32,
883    /// outline-offset(border-box からの追加オフセット px)。
884    pub outline_offset: i32,
885    /// outline-style("solid"/"dashed"/"dotted"、既定 "solid")。
886    pub outline_style: String,
887    /// <img> 要素の object-fit / object-position。
888    pub object_fit: ObjectFit,
889    /// 既定は (0.5, 0.5)(中央)。background-position とは既定値が異なる点に注意。
890    pub object_pos_x: f32,
891    pub object_pos_y: f32,
892    /// CSS `filter`(生の宣言値、例 "grayscale(1) brightness(1.2)")。
893    /// <img> のピクセル描画時にのみ適用する簡略実装(grayscale/brightness/invert/sepia/contrast/saturate のみ対応)。
894    pub filter: String,
895    /// CSS `mix-blend-mode`(生の宣言値)。単色背景の塗り(クリップ無し・不透明・角丸無しの
896    /// 最も単純な経路)にのみ適用する簡略実装。multiply/screen/darken/lighten/difference のみ対応。
897    pub mix_blend_mode: String,
898    /// CSS `backdrop-filter`(生の宣言値)。
899    pub backdrop_filter: String,
900    /// CSS `image-rendering`("pixelated", "crisp-edges", "smooth" 等)。
901    pub image_rendering: String,
902    /// CSS `text-stroke` / `-webkit-text-stroke`(幅と色)。
903    pub text_stroke_width: i32,
904    pub text_stroke_color: Option<u32>,
905    /// `-webkit-line-clamp`(複数行テキストの末尾を `...` で省略表示する行数上限。
906    /// 0 = 無制限。`display: -webkit-box` かつ `-webkit-box-orient: vertical` と
907    /// 併用されるが、この実装では値が 0 でなければ常に有効にする簡略実装)。
908    pub line_clamp: i32,
909    pub node_idx: Option<usize>,
910}
911
912impl Default for RenderElement {
913    fn default() -> Self {
914        RenderElement {
915            node_idx: None,
916            text: String::new(),
917            font_size: 0,
918            color: 0,
919            bg_color: None,
920            hover_bg_color: None,
921            hover_color: None,
922            hover_border_color: None,
923            focus_bg_color: None,
924            focus_color: None,
925            focus_border_color: None,
926            active_bg_color: None,
927            active_color: None,
928            active_border_color: None,
929            marker_color: None,
930            marker_font_size: None,
931            marker_content: None,
932            border_color: None,
933            client_insets: None,
934            border_style: BorderStyle::None,
935            font_bold: false,
936            font_italic: false,
937            underline: false,
938            line_through: false,
939            overline: false,
940            decoration_color: None,
941            decoration_style: String::new(),
942            decoration_thickness: 1,
943            decoration_underline_offset: 0,
944            scroll_margin_top: 0,
945            caret_color: None,
946            accent_color: None,
947            placeholder_color: None,
948            placeholder: String::new(),
949            is_video: false,
950            video_src: String::new(),
951            is_iframe: false,
952            iframe_src: String::new(),
953            is_link: false,
954            pointer_events_none: false,
955            href: String::new(),
956            is_hr: false,
957            is_img: false,
958            img_lazy: false,
959            canvas_ctx_id: None,
960            img_alt: String::new(),
961            aspect_ratio: None,
962            is_input: false,
963            is_textarea: false,
964            is_button: false,
965            is_reset: false,
966            input_kind: 0,
967            radio_group: String::new(),
968            input_value: String::new(),
969            select_options: alloc::vec::Vec::new(),
970            label_for: String::new(),
971            button_icon: 0,
972            element_id: String::new(),
973            onclick: String::new(),
974            form_action: String::new(),
975            form_method: String::new(),
976            width: 0,
977            x_offset: 0,
978            y_offset: 0,
979            height: 0,
980            border_radius: (0, 0, 0, 0),
981            is_li: false,
982            li_num: None,
983            list_style_type: ListStyleType::Unset,
984            list_style_inside: false,
985            list_style_image: String::new(),
986            is_blockquote: false,
987            border_top_width: 0,
988            border_right_width: 0,
989            border_bottom_width: 0,
990            border_left_width: 0,
991            border_top_color: None,
992            border_right_color: None,
993            border_bottom_color: None,
994            border_left_color: None,
995            border_top_style: BorderStyle::None,
996            border_right_style: BorderStyle::None,
997            border_bottom_style: BorderStyle::None,
998            border_left_style: BorderStyle::None,
999            padding_top: 0,
1000            padding_right: 0,
1001            padding_bottom: 0,
1002            padding_left: 0,
1003            bg_clip: String::from("border-box"),
1004            bg_origin: String::from("padding-box"),
1005            bg_attachment: String::from("scroll"),
1006            bg_image: None,
1007            bg_images: Vec::new(),
1008            background_blend_mode: String::new(),
1009            bg_layers: alloc::vec::Vec::new(),
1010            pos_top: None,
1011            pos_left: None,
1012            pos_right: None,
1013            pos_bottom: None,
1014            z_index: 0,
1015            paint_depth: 0,
1016            is_fixed: false,
1017            overflow_hidden: false,
1018            clip_x0: 0,
1019            clip_y0: 0,
1020            clip_x1: i32::MAX,
1021            clip_y1: i32::MAX,
1022            box_shadow: alloc::vec::Vec::new(),
1023            white_space_nowrap: false,
1024            text_overflow_ellipsis: false,
1025            text_overflow_rtl: false,
1026            linear_gradient: None,
1027            radial_gradient: None,
1028            conic_gradient: None,
1029            repeating_linear_gradient: None,
1030            repeating_radial_gradient: None,
1031            opacity: 255,
1032            visibility_hidden: false,
1033            text_shadow: alloc::vec::Vec::new(),
1034            before_content: String::new(),
1035            after_content: String::new(),
1036            font_family: String::new(),
1037            before_font_family: String::new(),
1038            transform_tx: 0,
1039            transform_ty: 0,
1040            transform_scale_x: 1.0,
1041            transform_scale_y: 1.0,
1042            transform_rotate_deg: 0.0,
1043            transform_skew_x: 0.0,
1044            transform_skew_y: 0.0,
1045            transform_origin_x: 0.5,
1046            transform_origin_y: 0.5,
1047            letter_spacing: 0,
1048            word_spacing: 0,
1049            text_emphasis_style: String::new(),
1050            text_emphasis_color: None,
1051            cursor_style: String::new(),
1052            scroll_container_id: String::new(),
1053            clip_path: String::new(),
1054            is_sticky: false,
1055            sticky_top: None,
1056            sticky_bottom: None,
1057            sticky_bound_max_y: i32::MAX,
1058            bg_size: BgSizeMode::Stretch,
1059            bg_repeat_x: BgRepeatMode::Repeat,
1060            bg_repeat_y: BgRepeatMode::Repeat,
1061            bg_pos_x: 0.0,
1062            bg_pos_y: 0.0,
1063            outline_color: None,
1064            outline_width: 0,
1065            outline_offset: 0,
1066            outline_style: String::from("solid"),
1067            object_fit: ObjectFit::Fill,
1068            object_pos_x: 0.5,
1069            object_pos_y: 0.5,
1070            filter: String::new(),
1071            mix_blend_mode: String::new(),
1072            backdrop_filter: String::new(),
1073            image_rendering: String::new(),
1074            text_stroke_width: 0,
1075            text_stroke_color: None,
1076            line_clamp: 0,
1077        }
1078    }
1079}
1080
1081// クリック判定用のリンク座標領域
1082#[derive(Clone, Debug)]
1083pub struct HyperlinkArea {
1084    pub href: String,
1085    pub x0: i32,
1086    pub y0: i32,
1087    pub x1: i32,
1088    pub y1: i32,
1089}
1090
1091// ブラウザエンジン (HTML/CSS レンダラ・DOM操作コア)
1092#[derive(Clone)]
1093pub struct DecodedImage {
1094    pub width: u32,
1095    pub height: u32,
1096    pub rgba: alloc::vec::Vec<u8>,
1097}
1098
1099pub struct WebEngine {
1100    /// このエンジン(タブ)の一意 ID。非同期ロードの結果分離に使う。
1101    pub engine_id: u64,
1102    pub active: bool,
1103    pub current_is_https: bool,
1104    pub current_host: String,
1105    pub current_path: String,
1106    /// 現在ページの完全URL(フラグメント #hash を含む)。アンカーナビの
1107    /// hashchange 発火で oldURL/newURL を組み立てる基準として使う。
1108    pub current_url_with_hash: String,
1109    pub title: String,
1110    pub scroll_y: i32,
1111    pub max_scroll_y: i32,
1112    /// `html{scroll-behavior:smooth}` が指定されているか(ルート要素の算出値から判定)。
1113    /// アンカー/フラグメントナビゲーションのスクロールのみを対象とし、ホイール/スクロール
1114    /// バードラッグは対象外(実ブラウザでも `scrollIntoView`/アンカー遷移のみが対象)。
1115    pub scroll_smooth: bool,
1116    /// `html{scrollbar-width:none}`(CSS Scrollbars Module Level 1。ルート要素の
1117    /// 算出値から判定する簡略実装で、`scroll-behavior:smooth`と同じ方式)。
1118    /// `thin`/`auto`は見た目の太さの違いを描画で区別する手段が無いため
1119    /// 両方とも通常描画(既定太さ)にフォールバックし、`none`のみを
1120    /// 「非表示」として扱う。丸ごと未対応だった。2026-07-18 発見・実装。
1121    pub scrollbar_width_none: bool,
1122    /// スムーススクロール進行中かどうか。
1123    pub scroll_anim_active: bool,
1124    pub scroll_anim_from: i32,
1125    pub scroll_anim_to: i32,
1126    pub scroll_anim_start_ms: f32,
1127    /// 【2026-08-03】URL のフラグメント(`#id`)。レイアウト完了後に
1128    /// 対象要素までスクロールするために保持する。適用したら空にする。
1129    pub pending_fragment: String,
1130    pub elements: Vec<RenderElement>,
1131    /// 【2026-08-01計測】`dirty` を最後に立てた箇所(`ファイル:行`)。
1132    /// 再描画が毎フレーム走る原因を特定するため。
1133    pub dirty_reason: &'static str,
1134    pub links: Vec<HyperlinkArea>,
1135    pub history: Vec<(bool, String, String)>,
1136    pub forward_history: Vec<(bool, String, String)>,
1137    pub dirty: bool,
1138    pub form_values: alloc::collections::BTreeMap<String, String>,
1139    /// element_id → HTML 由来の初期値。フォーム reset でこの値へ戻す。
1140    pub initial_form_values: alloc::collections::BTreeMap<String, String>,
1141    /// element_id → input の `name` 属性。フォーム送信時のパラメータ名に使う。
1142    pub field_names: alloc::collections::BTreeMap<String, String>,
1143    pub dom_contents: alloc::collections::BTreeMap<String, String>,
1144    pub executing_script: bool,
1145    /// 次の parse_and_layout で DOM ブリッジを再構築するか(新ページ読込時に true)。
1146    pub dom_needs_rebuild: bool,
1147    /// リソース到着による再レイアウトを溜めておく(0 なら保留なし)。
1148    ///
1149    /// 【2026-08-05】フォントは 4 並列で 1 本ずつ届くため、
1150    /// 到着のたびに再レイアウトすると**1 回 4.6 秒**をその回数だけ払う。
1151    /// 実測では 100 フレーム中 5972 tick(全体の 44%)が
1152    /// `draw()` 内の再レイアウトだった。
1153    /// 少し待って**まとめて 1 回**にする。値は最初の到着時刻(tick)。
1154    pub pending_reflow_since: usize,
1155    /// 直近の到着時刻(tick)。これが止まったらまとめて実行する。
1156    pub pending_reflow_last: usize,
1157    pub focused_id: Option<String>,
1158    /// フォーカス取得時の入力値(blur 時の change 判定用)。
1159    pub focus_value: String,
1160    /// 現在マウスが乗っている要素 id(mouseover/mouseout 判定用)。
1161    pub hovered_id: Option<String>,
1162    pub cursor_pos: usize,
1163    pub env: crate::os_lib::aura::eval::Env,
1164    /// 組込み JavaScript ランタイム(<script>/onclick を実行)。aura は .aura 用に存続。
1165    pub js_runtime: crate::os_lib::js::JsRuntime,
1166    // --- Video Player Animation Fields ---
1167    pub video_playing: bool,
1168    pub video_tick: u32,
1169    // --- Async Loading Animation Fields ---
1170    /// 背景画像(`background-image`)として参照されている URL の集合。
1171    /// これらは取得を省略して描画を優先する(`ensure_image_cached` 参照)。
1172    pub background_image_urls: alloc::collections::BTreeSet<String>,
1173    pub loading: bool,
1174    pub loading_tick: u32,
1175    pub mouse_x: i32,
1176    pub mouse_y: i32,
1177    /// 直近の `draw()` 呼び出し時点でマウスが `cursor: text` を持つ要素の上にあったか。
1178    /// OS 側(`main.rs` の `gui_shell_process`)がマウスカーソルの実描画形状を
1179    /// 決めるために参照する(`apps::browser::hovered_cursor_is_text()` 経由)。
1180    pub hovered_cursor_is_text: bool,
1181    /// CSS `cursor` のキーワード(`"pointer"`/`"grab"`/`"crosshair"` 等)。
1182    /// ホバー中の要素が持つ `cursor_style` 値をそのまま格納する。
1183    /// `"text"` は従来どおり `hovered_cursor_is_text` でも判定可能。
1184    pub hovered_cursor_kind: alloc::string::String,
1185    /// 前フレームの左ボタン状態。押下/解放のエッジ検出に使う
1186    /// (on_mouse は WM から毎フレーム現在値で呼ばれるため)。
1187    pub prev_btn_left: bool,
1188    /// 現在フレームの左ボタン押下状態(`:active` 疑似クラス判定用。
1189    /// `is_hovered && mouse_btn_left_down` で押下中の要素を判定する)。
1190    pub mouse_btn_left_down: bool,
1191    /// 右ボタンの前フレーム状態(contextmenu の押下エッジ検出用)。
1192    pub prev_btn_right: bool,
1193    /// 最後にクリックした要素 id と、その時刻(tick)。dblclick 判定用。
1194    pub last_click_id: Option<String>,
1195    pub last_click_tick: usize,
1196    // --- Window and Editor Scroll / Wrap Fields ---
1197    pub win_w: u32,
1198    pub win_h: u32,
1199    pub top_offset: i32,
1200    pub editor_wrap: bool,
1201    pub editor_scroll_y: i32,
1202    pub editor_scroll_x: i32,
1203    pub last_html: String,
1204    pub dragging_scrollbar: bool,
1205    /// デコード済み画像。`Arc` なのは、JS の `drawImage` から引く
1206    /// 共有の置き場(`image::shared_image`)と**同じ画素を指す**ため。
1207    /// 複製すると数 MB の写真を二重に持つことになる。
1208    pub image_cache:
1209        alloc::collections::BTreeMap<String, alloc::sync::Arc<DecodedImage>>,
1210    /// 取得を試みた画像 src(成功・失敗を問わず)。毎フレームの再フェッチ暴走を防ぐ。
1211    pub image_attempted: alloc::collections::BTreeSet<String>,
1212    pub active_text_box: Option<crate::kernel::text_box::TextBox>,
1213    /// `active_text_box` が破棄されても保持し続けるキャレット位置(文字単位)。
1214    ///
1215    /// 【2026-09-08 バグ修正】`parse_and_layout` は `active_text_box` を毎回捨てる。
1216    /// autofocus 付きの入力欄はその直後に再フォーカスされるが、TextBox は
1217    /// `with_text*()` で作り直されるためカーソルが**末尾へ飛んでいた**。
1218    /// note アプリの「↑で 1 行しか上がらない」「行の途中で打つと末尾に入る」は
1219    /// これが原因。再生成時にこの値からキャレットを復元する。
1220    /// フォーカスが別要素へ移ったときは復元しない(`focus_caret_owner` で判定)。
1221    pub focus_cursor_pos: usize,
1222    /// `focus_cursor_pos` がどの element_id のものかを覚えておく。
1223    pub focus_caret_owner: String,
1224    /// body/html 要素から取り出したページ背景色(None の場合はデフォルト色を使用)
1225    pub page_bg_color: Option<u32>,
1226    /// body/html 要素から取り出したデフォルト文字色
1227    pub page_text_color: Option<u32>,
1228    /// CSS アニメーション/トランジション駆動エンジン。
1229    pub anim_engine: crate::os_lib::css::AnimationEngine,
1230    /// 直近の parse_and_layout で収集した、id付き要素ごとの (transition 指定, animation 指定)。
1231    /// element_id → (transition 文字列, animation 文字列)。
1232    pub anim_specs: alloc::collections::BTreeMap<String, (String, String)>,
1233    /// element_id → property → 直近の確定値(トランジション開始の from 算出に使う)。
1234    pub anim_prev_values:
1235        alloc::collections::BTreeMap<String, alloc::collections::BTreeMap<String, String>>,
1236    /// アニメ駆動の再レイアウト中は true。この間は sync_animations のトランジション
1237    /// 検出と prev 更新を抑止し、補間値による逆向きトランジションの誤発火を防ぐ。
1238    pub in_anim_relayout: bool,
1239    /// `flatten_layout` 再帰中の「祖先の実効 z」。描画順が祖先を追い越さないよう
1240    /// 各要素の z をこの値まで引き上げる(`spec/paint_order.md` の不変条件 I-1)。
1241    pub inherited_paint_z: i32,
1242    /// `flatten_layout` 再帰中の現在のツリー深さ。`RenderElement::paint_depth` の元。
1243    pub inherited_paint_depth: u32,
1244    /// 直近のアニメーション由来 `parse_and_layout` の所要時間 [ms]。
1245    /// 再レイアウトを実測コストに応じて間引くために使う。
1246    pub last_anim_layout_ms: f32,
1247    /// 最後にアニメーション由来の再レイアウトを完了した時刻 [ms]。
1248    pub last_anim_relayout_end_ms: f32,
1249    /// サブスクロール領域ごとの現在スクロール量 (element_id → scroll_y)。
1250    /// overflow:scroll/auto コンテナの独立スクロール位置を保持する。
1251    pub scroll_regions: alloc::collections::BTreeMap<String, i32>,
1252    /// サブスクロールバーをドラッグ中のコンテナ ID(None = ドラッグなし)。
1253    pub dragging_sub_scrollbar: Option<String>,
1254    /// parse_and_layout で収集したスクロールコンテナのメタ情報。
1255    /// (element_id, max_scroll_y, content_x, content_y, width, height)
1256    /// スクロールバー描画とホバー判定に使う。ページ再レイアウトのたびに再構築される。
1257    pub scroll_containers: alloc::vec::Vec<(String, i32, i32, i32, i32, i32)>,
1258    /// インクリメンタル再レイアウト用の CSS パースキャッシュ。
1259    /// 直近にパースした `<style>` 連結文字列をキーに、parse_css 結果を再利用する。
1260    /// 同一 HTML の再レイアウト(JS による textContent/style 変更など)では
1261    /// CSS パースを丸ごと省略でき、大きなスタイルシートで効く。
1262    pub cached_css_key: String,
1263    /// `cached_css_key` に対応する parse_css 結果。None なら未キャッシュ。
1264    pub cached_stylesheet: Option<crate::os_lib::css::StyleSheet>,
1265    /// 外部 `<link rel="stylesheet">` から取得した CSS を連結してキャッシュする
1266    /// (None = 未取得)。`parse_and_layout` は再レイアウトのたびに呼ばれ得るため、
1267    /// 毎回フェッチすると致命的に重く(かつ毎フレーム HTTP リクエストになる)、
1268    /// ここで初回だけ取得して以降は再利用する。ページ遷移時に None へリセットする。
1269    pub external_css_cache: Option<String>,
1270}
1271
1272impl Default for WebEngine {
1273    fn default() -> Self {
1274        Self::new()
1275    }
1276}
1277
1278impl WebEngine {
1279    pub fn new() -> Self {
1280        let env = crate::os_lib::aura::eval::Env::new();
1281        let engine_id = {
1282            let mut g = NEXT_ENGINE_ID.lock();
1283            let id = *g;
1284            *g += 1;
1285            id
1286        };
1287        Self {
1288            dirty_reason: "init",
1289            pending_fragment: String::new(),
1290            engine_id,
1291            active: false,
1292            current_is_https: true,
1293            current_host: String::from("lite.duckduckgo.com"),
1294            current_path: String::from("/"),
1295            current_url_with_hash: String::from("https://lite.duckduckgo.com/"),
1296            title: String::from("DuckDuckGo Lite"),
1297            scroll_y: 0,
1298            max_scroll_y: 0,
1299            scroll_smooth: false,
1300            scrollbar_width_none: false,
1301            scroll_anim_active: false,
1302            scroll_anim_from: 0,
1303            scroll_anim_to: 0,
1304            scroll_anim_start_ms: 0.0,
1305            elements: Vec::new(),
1306            links: Vec::new(),
1307            history: Vec::new(),
1308            forward_history: Vec::new(),
1309            dirty: true,
1310            form_values: alloc::collections::BTreeMap::new(),
1311            initial_form_values: alloc::collections::BTreeMap::new(),
1312            field_names: alloc::collections::BTreeMap::new(),
1313            dom_contents: alloc::collections::BTreeMap::new(),
1314            executing_script: false,
1315            dom_needs_rebuild: false,
1316            pending_reflow_since: 0,
1317            pending_reflow_last: 0,
1318            focused_id: None,
1319            focus_value: String::new(),
1320            hovered_id: None,
1321            cursor_pos: 0,
1322            env,
1323            js_runtime: crate::os_lib::js::JsRuntime::new(),
1324            video_playing: false,
1325            video_tick: 0,
1326            background_image_urls: alloc::collections::BTreeSet::new(),
1327            loading: false,
1328            loading_tick: 0,
1329            mouse_x: -1000,
1330            mouse_y: -1000,
1331            hovered_cursor_is_text: false,
1332            hovered_cursor_kind: alloc::string::String::new(),
1333            prev_btn_left: false,
1334            mouse_btn_left_down: false,
1335            prev_btn_right: false,
1336            last_click_id: None,
1337            last_click_tick: 0,
1338            win_w: 1366,
1339            win_h: 768,
1340            top_offset: 0,
1341            editor_wrap: true,
1342            editor_scroll_y: 0,
1343            editor_scroll_x: 0,
1344            last_html: String::new(),
1345            dragging_scrollbar: false,
1346            image_cache: alloc::collections::BTreeMap::new(),
1347            image_attempted: alloc::collections::BTreeSet::new(),
1348            active_text_box: None,
1349            focus_cursor_pos: 0,
1350            focus_caret_owner: String::new(),
1351            page_bg_color: None,
1352            page_text_color: None,
1353            anim_engine: crate::os_lib::css::AnimationEngine::new(),
1354            anim_specs: alloc::collections::BTreeMap::new(),
1355            anim_prev_values: alloc::collections::BTreeMap::new(),
1356            in_anim_relayout: false,
1357            inherited_paint_z: 0,
1358            inherited_paint_depth: 0,
1359            last_anim_layout_ms: 0.0,
1360            last_anim_relayout_end_ms: 0.0,
1361            scroll_regions: alloc::collections::BTreeMap::new(),
1362            dragging_sub_scrollbar: None,
1363            scroll_containers: alloc::vec::Vec::new(),
1364            cached_css_key: String::new(),
1365            cached_stylesheet: None,
1366            external_css_cache: None,
1367        }
1368    }
1369
1370    fn parse_image_url(url: &str) -> Option<(bool, String, String)> {
1371        if let Some(rest) = url.strip_prefix("http://") {
1372            if let Some(idx) = rest.find('/') {
1373                let (h, p) = rest.split_at(idx);
1374                let host = String::from(h);
1375                let path = String::from(p);
1376                Some((false, host, path))
1377            } else {
1378                let host = String::from(rest);
1379                let path = String::from("/");
1380                Some((false, host, path))
1381            }
1382        } else if let Some(rest) = url.strip_prefix("https://") {
1383            if let Some(idx) = rest.find('/') {
1384                let (h, p) = rest.split_at(idx);
1385                let host = String::from(h);
1386                let path = String::from(p);
1387                Some((true, host, path))
1388            } else {
1389                let host = String::from(rest);
1390                let path = String::from("/");
1391                Some((true, host, path))
1392            }
1393        } else {
1394            None
1395        }
1396    }
1397
1398    /// 画像ロードをノンブロッキングでリクエストする。
1399    /// キャッシュにあれば即返す。なければバックグラウンドスレッドにキューイングして即return。
1400    fn ensure_image_cached(&mut self, src: &str) {
1401        if src.is_empty() {
1402            return;
1403        }
1404        if self.image_cache.contains_key(src) {
1405            return;
1406        }
1407        // 既に取得を試みた src は再フェッチしない(毎フレームの再取得暴走を防止)。
1408        // デコード失敗(WebP 等の非対応)でも 1 回で打ち切る。
1409        if self.image_attempted.contains(src) {
1410            return;
1411        }
1412        self.image_attempted.insert(String::from(src));
1413
1414        let req = if src.starts_with("http://") || src.starts_with("https://") {
1415            if let Some((is_https, host, path)) = Self::parse_image_url(src) {
1416                if self.current_is_https && !is_https {
1417                    crate::warn!(
1418                        "MIXED CONTENT BLOCKED: Image {} over HTTP in HTTPS page",
1419                        src
1420                    );
1421                    return;
1422                }
1423                ImageFetchReq {
1424                    src_key: String::from(src),
1425                    is_https,
1426                    host,
1427                    path,
1428                    is_local: false,
1429                    owner: self.engine_id,
1430                }
1431            } else {
1432                return;
1433            }
1434        } else if self.current_host == "localhost" {
1435            ImageFetchReq {
1436                src_key: String::from(src),
1437                is_https: false,
1438                host: String::new(),
1439                path: String::from(src),
1440                is_local: true,
1441                owner: self.engine_id,
1442            }
1443        } else {
1444            let host = self.current_host.clone();
1445            let path = if src.starts_with('/') {
1446                String::from(src)
1447            } else {
1448                let base_dir = self
1449                    .current_path
1450                    .rfind('/')
1451                    .map(|i| self.current_path.get(..=i).unwrap_or("/"))
1452                    .unwrap_or("/");
1453                let combined = alloc::format!("{}{}", base_dir, src);
1454                let mut parts: alloc::vec::Vec<&str> = alloc::vec::Vec::new();
1455                for seg in combined.split('/') {
1456                    match seg {
1457                        "" | "." => {}
1458                        ".." => {
1459                            parts.pop();
1460                        }
1461                        s => parts.push(s),
1462                    }
1463                }
1464                alloc::format!("/{}", parts.join("/"))
1465            };
1466            ImageFetchReq {
1467                src_key: String::from(src),
1468                is_https: self.current_is_https,
1469                host,
1470                path,
1471                is_local: false,
1472                owner: self.engine_id,
1473            }
1474        };
1475
1476        enqueue_image(req);
1477    }
1478
1479    /// バックグラウンドスレッドが完了した画像を image_cache に取り込む。
1480    /// 新規取り込みがあった場合 true を返す(再描画が必要)。
1481    fn poll_image_results(&mut self) -> bool {
1482        let mut q = IMAGE_QUEUE.lock();
1483        if q.results.is_empty() {
1484            return false;
1485        }
1486        let results: Vec<(String, DecodedImage)> = core::mem::take(&mut q.results);
1487        // 取り出したら写しも 0 に戻す(再描画判定はこの写しだけを見る)。
1488        image::store_results_len(0);
1489        drop(q);
1490        for (key, decoded) in results {
1491            crate::info!(
1492                "IMAGE_LOADER: decoded '{}' {}x{}",
1493                key,
1494                decoded.width,
1495                decoded.height
1496            );
1497            // レイアウトが自然サイズを使えるよう登録する。
1498            // これが無いと寸法未指定の `<img>` が既定の 400×200 のままになる。
1499            crate::os_lib::layout::register_image_natural_size(
1500                &key,
1501                decoded.width as i32,
1502                decoded.height as i32,
1503            );
1504            let shared = alloc::sync::Arc::new(decoded);
1505            image::share_image(&key, shared.clone());
1506            self.image_cache.insert(key, shared);
1507        }
1508        true
1509    }
1510
1511    // ローカルHTMLファイルを直接パースして表示する処理
1512    pub fn load_html(&mut self, html: &str, filename: &str, top_offset: i32) {
1513        self.video_playing = false;
1514        stop_video_playback();
1515        self.dom_contents.clear();
1516        self.dom_needs_rebuild = true;
1517        // ページ遷移: 外部 CSS キャッシュを破棄し、新ページの <link> を取り直す。
1518        self.external_css_cache = None;
1519        // ローカルページへの遷移でも旧ページの画像取得とフォーム値を破棄する。
1520        IMAGE_QUEUE
1521            .lock()
1522            .pending
1523            .retain(|r| r.owner != self.engine_id);
1524        self.form_values.clear();
1525        self.initial_form_values.clear();
1526        self.field_names.clear();
1527        self.current_host = String::from("localhost");
1528        self.current_path = alloc::format!("/{}", filename);
1529        self.title = alloc::format!("Local: {}", filename);
1530        // 【2026-07-27診断】ローカルページ読み込み由来の再レイアウト回数。
1531        {
1532            static N: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0);
1533            let n = N.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
1534            if n < 8 {
1535                crate::warn!("[LAYOUT][SRC] load_html('{}') #{}", filename, n + 1);
1536            }
1537        }
1538        self.parse_and_layout(html, None, top_offset, self.win_w, self.win_h);
1539        self.dirty = true;
1540        self.dirty_reason = "mod.rs:1432";
1541    }
1542
1543    // 現在のURLを取得
1544    pub fn get_url(&self) -> String {
1545        let schema = if self.current_is_https {
1546            "https://"
1547        } else {
1548            "http://"
1549        };
1550        alloc::format!("{}{}{}", schema, self.current_host, self.current_path)
1551    }
1552
1553    // 新しいURL(ホスト&パス)のロード処理 (非同期化)
1554    pub fn load_page(
1555        &mut self,
1556        is_https: bool,
1557        host: &str,
1558        path: &str,
1559        save_history: bool,
1560        _top_offset: i32,
1561    ) {
1562        self.load_page_with_method(is_https, host, path, save_history, "GET", None);
1563    }
1564
1565    pub fn load_page_post(
1566        &mut self,
1567        is_https: bool,
1568        host: &str,
1569        path: &str,
1570        body: &str,
1571        _top_offset: i32,
1572    ) {
1573        self.load_page_with_method(is_https, host, path, true, "POST", Some(String::from(body)));
1574    }
1575
1576    fn load_page_with_method(
1577        &mut self,
1578        is_https: bool,
1579        host: &str,
1580        path: &str,
1581        save_history: bool,
1582        method: &str,
1583        body: Option<String>,
1584    ) {
1585        // 【2026-08-03】URL のフラグメント(`#id`)を分離して覚えておく。
1586        //
1587        // 従来は `#` を含んだままパスとして HTTP 要求に載せており、
1588        // `https://example.com/#research` のような URL が正しく扱えなかった。
1589        // フラグメントはサーバへ送るものではなく、
1590        // **取得後にページ内でスクロールする指定**である。
1591        // レイアウト完了後に `navigate_to_fragment` で適用する。
1592        let (path, frag) = match path.split_once('#') {
1593            Some((p, f)) => (p, String::from(f)),
1594            None => (path, String::new()),
1595        };
1596        let path = if path.is_empty() { "/" } else { path };
1597        self.pending_fragment = frag;
1598
1599        self.video_playing = false;
1600        stop_video_playback();
1601        self.dom_contents.clear();
1602        self.dom_needs_rebuild = true;
1603        // ページ遷移: 外部 CSS キャッシュを破棄し、新ページの <link> を取り直す。
1604        self.external_css_cache = None;
1605        // ページ遷移: 旧ページの未処理画像ダウンロードを破棄する。
1606        // これをしないと、遷移後も旧ページの画像取得が延々と続き OS 全体が重くなる。
1607        IMAGE_QUEUE
1608            .lock()
1609            .pending
1610            .retain(|r| r.owner != self.engine_id);
1611        // 旧ページのフォーム入力値・name マッピングは次ページへ持ち越さない。
1612        self.form_values.clear();
1613        self.initial_form_values.clear();
1614        self.field_names.clear();
1615        if save_history {
1616            self.history.push((
1617                self.current_is_https,
1618                self.current_host.clone(),
1619                self.current_path.clone(),
1620            ));
1621            self.forward_history.clear();
1622        }
1623
1624        if host == "localhost" {
1625            self.current_is_https = false;
1626            self.current_host = String::from("localhost");
1627            self.current_path = String::from(path);
1628            self.current_url_with_hash = alloc::format!("http://localhost{}", path);
1629
1630            let clean_path = path.strip_prefix('/').unwrap_or(path);
1631            if clean_path == "home" || clean_path.is_empty() {
1632                self.title = String::from("AtmOS Browser");
1633                let html = HOME_HTML;
1634                self.parse_and_layout(html, None, self.top_offset, self.win_w, self.win_h);
1635                self.dirty = true;
1636                self.dirty_reason = "mod.rs:1513";
1637            } else {
1638                let actual_path = if path.starts_with('/') {
1639                    String::from(path)
1640                } else {
1641                    alloc::format!("/{}", path)
1642                };
1643                // 【2026-08-25 バグ修正】以前は `FS_LOCK` を保持したまま
1644                // `parse_and_layout()` を呼んでいた。
1645                //
1646                // レイアウトは `<img>` の寸法解決(`get_replaced_element_size`)
1647                // から FS を読み、JS は `localStorage` の書き出しから FS を書く。
1648                // どちらも `FS_LOCK` を取るが、`spin::Mutex` は**再入できない**。
1649                // つまり画像 1 枚あるローカルページを開くだけで自分自身と
1650                // 取り合って固まる。ロックの保持時間が長いほど、
1651                // 「保持中に何を呼んでいるか」が見えなくなる。
1652                //
1653                // **読み込みだけを番人の下に置き、レイアウトは外へ出す**。
1654                let loaded = {
1655                    let _fs_guard = crate::kernel::fs::FS_LOCK.lock();
1656                    crate::kernel::fs::get_fs().read_file(&actual_path)
1657                };
1658                if let Some((_meta, content)) = loaded {
1659                    if let Ok(html) = core::str::from_utf8(&content) {
1660                        self.title = alloc::format!("Local: {}", clean_path);
1661                        self.parse_and_layout(html, None, self.top_offset, self.win_w, self.win_h);
1662                        self.dirty = true;
1663                        self.dirty_reason = "mod.rs:1525";
1664                    } else {
1665                        let err_html = alloc::format!("<html><title>Load Error</title><body style=\"background-color: #282a36; color: #ff5555;\"><h1>UTF-8 Decode Failed</h1><p>File '{}' is not valid UTF-8.</p></body></html>", actual_path);
1666                        self.title = String::from("Load Error");
1667                        self.parse_and_layout(&err_html, None, self.top_offset, self.win_w, self.win_h);
1668                        self.dirty = true;
1669                        self.dirty_reason = "mod.rs:1530";
1670                    }
1671                } else {
1672                    let err_html = alloc::format!("<html><title>Not Found</title><body style=\"background-color: #282a36; color: #ff5555;\"><h1>404 Not Found</h1><p>Local file '{}' not found in SylFS.</p></body></html>", actual_path);
1673                    self.title = String::from("404 Not Found");
1674                    self.parse_and_layout(&err_html, None, self.top_offset, self.win_w, self.win_h);
1675                    self.dirty = true;
1676                    self.dirty_reason = "mod.rs:1536";
1677                }
1678            }
1679            return;
1680        }
1681
1682        self.current_is_https = is_https;
1683        self.current_host = String::from(host);
1684        self.current_path = String::from(path);
1685        // 新ページ読込でフラグメントはリセット(アンカーナビの hashchange 基準を同期)。
1686        let scheme0 = if is_https { "https://" } else { "http://" };
1687        self.current_url_with_hash = alloc::format!("{}{}{}", scheme0, host, path);
1688
1689        let schema = if is_https { "https://" } else { "http://" };
1690        crate::info!(
1691            "BROWSER: Loading page {} {}{}{} (Async)",
1692            method,
1693            schema,
1694            host,
1695            path
1696        );
1697
1698        self.loading = true;
1699        self.loading_tick = 0;
1700
1701        let req = AsyncLoadRequest {
1702            owner: self.engine_id,
1703            is_https,
1704            host: String::from(host),
1705            path: String::from(path),
1706            method: String::from(method),
1707            body,
1708            status: 1, // 処理中
1709            result_body: None,
1710            result_dom: None,
1711            error_msg: None,
1712        };
1713
1714        let need_spawn = {
1715            let mut lock = ASYNC_LOAD.lock();
1716            // 同じタブの未消費リクエストは置換(連続ナビゲーションで古い結果を捨てる)
1717            lock.requests.retain(|r| r.owner != self.engine_id);
1718            lock.requests.push(req);
1719            if !lock.thread_active {
1720                lock.thread_active = true;
1721                true
1722            } else {
1723                false
1724            }
1725        };
1726
1727        // ローダースレッドが止まっている時だけ起動(多重起動を防ぐ)
1728        if need_spawn {
1729            // 【2026-08-05 バグ修正】`spawn` はヒープ不足で **0 を返す**。
1730            // `thread_active = true` を先に立てているので、失敗したまま
1731            // 戻さないと「ローダーは居ないのに稼働中」になり、
1732            // **以後メイン HTML の読み込みが永久に始まらない**
1733            // (`need_spawn` が二度と真にならない)。
1734            let pid = crate::kernel::scheduler::spawn(
1735                async_load_thread_entry,
1736                crate::kernel::scheduler::Priority::High,
1737                "browser_loader",
1738            );
1739            if pid == 0 {
1740                crate::warn!("[NET] ローダースレッドを起動できません(ヒープ不足)");
1741                ASYNC_LOAD.lock().thread_active = false;
1742            }
1743        }
1744        self.dirty = true;
1745        self.dirty_reason = "mod.rs:1595";
1746    }
1747
1748    pub fn set_dom_content(&mut self, id: &str, val: &str) {
1749        self.dom_contents
1750            .insert(String::from(id), String::from(val));
1751        let html = self.last_html.clone();
1752        let top_offset = self.top_offset;
1753        let win_w = self.win_w;
1754        let win_h = self.win_h;
1755        self.parse_and_layout(&html, None, top_offset, win_w, win_h);
1756        self.dirty = true;
1757        self.dirty_reason = "mod.rs:1606";
1758    }
1759
1760    // 戻るボタンの処理
1761    pub fn go_back(&mut self, _top_offset: i32) {
1762        // SPA pushState で積んだ履歴を優先して戻る(ページリロードなし)。
1763        if self.js_runtime.has_spa_back() {
1764            if let Some(url) = self.js_runtime.spa_go_back() {
1765                // URL からホスト/パスを更新して表示を合わせる(リロードはしない)。
1766                let (is_https, host, path) = self.resolve_url_parts(&url);
1767                self.current_is_https = is_https;
1768                self.current_host = host;
1769                self.current_path = path;
1770                self.dirty = true;
1771                self.dirty_reason = "mod.rs:1619";
1772            }
1773            return;
1774        }
1775        // 実ページ遷移の履歴から戻る。
1776        if let Some((prev_is_https, prev_host, prev_path)) = self.history.pop() {
1777            self.forward_history.push((
1778                self.current_is_https,
1779                self.current_host.clone(),
1780                self.current_path.clone(),
1781            ));
1782            self.load_page_with_method(prev_is_https, &prev_host, &prev_path, false, "GET", None);
1783        }
1784    }
1785
1786    pub fn go_forward(&mut self, _top_offset: i32) {
1787        // SPA forward 履歴を優先して進む。
1788        if self.js_runtime.has_spa_forward() {
1789            if let Some(url) = self.js_runtime.spa_go_forward() {
1790                let (is_https, host, path) = self.resolve_url_parts(&url);
1791                self.current_is_https = is_https;
1792                self.current_host = host;
1793                self.current_path = path;
1794                self.dirty = true;
1795                self.dirty_reason = "mod.rs:1642";
1796            }
1797            return;
1798        }
1799        // 実ページ遷移の forward 履歴から進む。
1800        if let Some((fwd_is_https, fwd_host, fwd_path)) = self.forward_history.pop() {
1801            self.history.push((
1802                self.current_is_https,
1803                self.current_host.clone(),
1804                self.current_path.clone(),
1805            ));
1806            self.load_page_with_method(fwd_is_https, &fwd_host, &fwd_path, false, "GET", None);
1807        }
1808    }
1809
1810    /// JS の form.reset() が積んだリセット要求を処理する。JS 実行後にホストが呼ぶ。
1811    /// 戻り値: 実際にリセットしたか。
1812    pub fn process_pending_reset(&mut self) -> bool {
1813        match self.js_runtime.take_pending_reset() {
1814            Some(fi) => {
1815                self.reset_form_node(fi);
1816                true
1817            }
1818            None => false,
1819        }
1820    }
1821
1822    pub fn can_go_back(&self) -> bool {
1823        !self.history.is_empty()
1824    }
1825    pub fn can_go_forward(&self) -> bool {
1826        !self.forward_history.is_empty()
1827    }
1828
1829    /// JS の history.back/forward/go() が積んだナビゲーション要求を処理する。
1830    /// JS 実行(onclick / イベントリスナ)後にホストが呼ぶ。実ナビ後に popstate を発火。
1831    /// 戻り値: 実際にナビゲートしたか(呼び元がアドレスバー更新等に使う)。
1832    pub fn process_pending_nav(&mut self, top_offset: i32) -> bool {
1833        let delta = self.js_runtime.take_pending_nav();
1834        if delta == 0 {
1835            return false;
1836        }
1837        let mut navigated = false;
1838        if delta < 0 {
1839            for _ in 0..(-delta) {
1840                if !self.can_go_back() {
1841                    break;
1842                }
1843                self.go_back(top_offset);
1844                navigated = true;
1845            }
1846        } else {
1847            for _ in 0..delta {
1848                if !self.can_go_forward() {
1849                    break;
1850                }
1851                self.go_forward(top_offset);
1852                navigated = true;
1853            }
1854        }
1855        if navigated {
1856            // 新ページURLを JS の location に反映してから popstate を発火。
1857            let url = self.get_url();
1858            self.js_runtime.set_page_url(&url);
1859            self.js_runtime.fire_popstate();
1860        }
1861        navigated
1862    }
1863
1864    /// location への代入や assign/replace/reload が積んだナビゲーション要求を処理する。
1865    /// ホストが JS 実行後(on_mouse / on_key / タイマ後など)に呼ぶ。戻り値は遷移したか否か。
1866    pub fn process_pending_location(&mut self, top_offset: i32) -> bool {
1867        let req = self.js_runtime.take_pending_location();
1868        let (url, mode) = match req {
1869            Some(v) => v,
1870            None => return false,
1871        };
1872        match mode.as_str() {
1873            // 同一ページ内フラグメント。フルリロードせず hashchange 経路へ。
1874            "hash" => {
1875                let frag = match url.split_once('#') {
1876                    Some((_, f)) => alloc::format!("#{}", f),
1877                    None => String::from("#"),
1878                };
1879                self.navigate_to_fragment(&frag);
1880                true
1881            }
1882            // 履歴を置換して遷移(save_history=false)。
1883            "replace" => {
1884                let (is_https, host, path) = self.resolve_url_parts(&url);
1885                self.load_page(is_https, &host, &path, false, top_offset);
1886                let new_url = self.get_url();
1887                self.js_runtime.set_page_url(&new_url);
1888                true
1889            }
1890            // 現在ページの再読込。
1891            "reload" => {
1892                let is_https = self.current_is_https;
1893                let host = self.current_host.clone();
1894                let path = self.current_path.clone();
1895                self.load_page(is_https, &host, &path, false, top_offset);
1896                true
1897            }
1898            // 通常の遷移(履歴に積む)。href 代入 / assign(url)。
1899            _ => {
1900                self.navigate_to_href(&url, top_offset);
1901                let new_url = self.get_url();
1902                self.js_runtime.set_page_url(&new_url);
1903                true
1904            }
1905        }
1906    }
1907}