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