Skip to main content

atmos/os_lib/web_engine/
mod.rs

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