Skip to main content

atmos/os_lib/web_engine/
render.rs

1// web_engine/render.rs - draw, scroll, mouse/key event handlers
2#![allow(dead_code, unused_variables, unused_imports)]
3
4use crate::kernel::draw;
5use crate::kernel::vector_font;
6
7/// 【2026-08-05 診断】`draw()` の中で再レイアウトに費やした tick の累計。
8///
9/// `draw()` はフォント到着・画像到着を検知して再レイアウトを起こすため、
10/// 「描画が重い」のか「再レイアウトが重い」のかが混ざって見えない。
11/// 目的: その切り分け。破棄条件: 判断がついたら削除。
12pub static RELAYOUT_IN_DRAW_TICKS: core::sync::atomic::AtomicU32 =
13    core::sync::atomic::AtomicU32::new(0);
14
15/// 【2026-08-05 診断】前回の `draw()` 終了から今回の開始までの累計 / 最大。
16/// `draw()` の**回数**が減る理由を、draw の外側で追う。
17/// 前回まとめて出した時刻(tick)。0 なら未出力。
18static LAST_DUMP_TICK: core::sync::atomic::AtomicUsize =
19    core::sync::atomic::AtomicUsize::new(0);
20/// まとめて出す間隔(tick)。5 秒。
21const DUMP_INTERVAL_TICKS: usize = 500;
22
23pub static DRAW_GAP_SUM: core::sync::atomic::AtomicUsize =
24    core::sync::atomic::AtomicUsize::new(0);
25pub static DRAW_GAP_MAX: core::sync::atomic::AtomicUsize =
26    core::sync::atomic::AtomicUsize::new(0);
27static DRAW_LAST_END: core::sync::atomic::AtomicUsize =
28    core::sync::atomic::AtomicUsize::new(0);
29use crate::os_lib::css::parse_color;
30use alloc::format;
31use alloc::string::String;
32use alloc::vec::Vec;
33
34use super::draw_helpers::{
35    bg_clip_rect, bg_origin_shift, blend_pixel, clip_inside, draw_background_image, draw_border,
36    draw_border_with_style, draw_decoration_line, draw_gradation, draw_img_object_fit,
37    draw_individual_borders, draw_outline, draw_rotated_rect_filled, draw_skewed_rect_filled, extract_border_color,
38    extract_border_style, parse_clip_shape, point_in_rounded_rect,
39};
40use super::helpers::{extract_url_path, hash_str, parse_font_size, url_encode};
41use super::image::{enqueue_image, ImageFetchReq, IMAGE_QUEUE};
42use super::video::{
43    stop_video_playback, video_playback_thread, VideoPlayState, CURRENT_VIDEO_FRAME, VIDEO_STATE,
44};
45use super::{
46    key_name_for, HyperlinkArea, ASYNC_LOAD, NEXT_ENGINE_ID, SVG_LOAD, SVG_SAVE, SVG_WRAP,
47};
48
49/// dblclick と判定する2クリック間の最大 tick 数(タイマは 10ms/tick なので 50 tick = 500ms)。
50const DBLCLICK_MAX_TICKS: usize = 50;
51
52/// 【2026-07-28診断】ヒーローテキスト矩形 (x<<48|y<<32|w<<16|h) と文字色。
53/// 描画直後と `draw()` 終了時点の 2 地点で同じ矩形を読み返し、
54/// 後続の描画が塗り潰しているかを一意に判定するために使う。
55
56/// 【2026-08-02】背景画像がキャッシュから引けなかったことを**パスごとに 1 回だけ**記録する。
57///
58/// 引けないと `if let Some(img)` が黙って落ちるだけで何も出ず、
59/// 「取得できていない」のか「取得できたのにキーが合わない」のかを
60/// 切り分けられなかった(`spec/logging_policy.md` L-1)。
61fn log_bg_image_miss(path: &str, cache_len: usize, site: &str) {
62    static MISS: spin::Mutex<alloc::collections::BTreeSet<alloc::string::String>> =
63        spin::Mutex::new(alloc::collections::BTreeSet::new());
64    let key = alloc::format!("{}@{}", site, path);
65    let mut seen = MISS.lock();
66    if seen.insert(key) {
67        crate::warn!(
68            "[IMG][MISS] 背景画像がキャッシュに無い site={} path={} cache_entries={}",
69            site,
70            path,
71            cache_len
72        );
73    }
74}
75
76impl super::WebEngine {
77    /// 計算量: **O(E log E + Σᵢ Wᵢ×Hᵢ)** — E は描画要素数、
78    /// Wᵢ×Hᵢ は要素 i の塗り面積。
79    ///
80    /// - `E log E`: z-index による安定ソート
81    /// - **`Σ Wᵢ×Hᵢ` が支配項**。要素「数」ではなく**面積の総和**で決まるため、
82    ///   画面を覆う大きな要素(ヒーローの背景・グラデーション)が効く
83    ///
84    /// 下層の積み上げ(規約は `spec/complexity_annotation.md`):
85    /// `draw_pixel` O(1) → `boxfill` O(W×H) → 要素ループ O(Σ W×H)。
86    ///
87    /// 実測(sugi-lab.net、E=369、1 フレーム約 243 tick):
88    ///
89    /// | 内訳 | tick | 割合 |
90    /// |------|------|------|
91    /// | グラデーション | 56 | 23% |
92    /// | 背景画像 | 35 | 14% |
93    /// | 矩形塗り | 13 | 5% |
94    /// | clip/opacity の毎ピクセル塗り | 17〜21 | 7% |
95    /// | **未特定** | **約 140** | **57%** |
96    /// | テキスト・ボーダー・シャドウ・角丸 | 0〜2 | ほぼ 0 |
97    ///
98    /// テキスト描画が支配的だという直感は**実測で否定**された
99    /// (グリフキャッシュが効いている)。詳細は `spec/resource_loading.md`。
100    pub fn draw(
101        &mut self,
102        screen: &draw::Screen,
103        win_x: u32,
104        win_y: u32,
105        win_w: u32,
106        win_h: u32,
107        top_offset: i32,
108    ) {
109        let draw_start_tick = crate::kernel::timer::get_ticks();
110        // 【2026-08-05 計測】`draw()` の**回数**が減る理由を追う。
111        // 1 回の中身ではなく、**前回の終わりから今回の始まりまで**を測る。
112        // ここが大きければ、GUI スレッドは draw の外で時間を使っている。
113        // 目的: 非同期化時に draw の回数が減る理由の特定。
114        // 破棄条件: 特定でき次第削除。
115        {
116            use core::sync::atomic::Ordering;
117            let last = DRAW_LAST_END.load(Ordering::Relaxed);
118            if last != 0 {
119                let gap = draw_start_tick.wrapping_sub(last);
120                DRAW_GAP_SUM.fetch_add(gap, Ordering::Relaxed);
121                DRAW_GAP_MAX.fetch_max(gap, Ordering::Relaxed);
122            }
123        }
124        // 【2026-08-05 計測】`draw()` の中には再レイアウトも含まれる。
125        // 「描画が重い」のか「再レイアウトが重い」のかを分ける。
126        let relayout_before = RELAYOUT_IN_DRAW_TICKS.load(core::sync::atomic::Ordering::Relaxed);
127        let theme = &crate::kernel::config::get_config().theme;
128        // 【2026-08-03】フォント取得完了はここで確認する。
129        //
130        // 以前は `parse_and_layout` の内側で確認していたため、
131        // レイアウトが動いていないと再レイアウトを起こせなかった。
132        // 実測ではフォント 8 本の取得完了が最後のレイアウトより後に起き、
133        // **取り込む機会が一度も来なかった**(キャッシュ命中 1 件)。
134        //
135        // `draw()` は毎フレーム呼ばれるので、ここなら確実に拾える。
136        // 再レイアウトは重い(実測約 6 秒)が、フォントが揃うのは
137        // 1 ページにつき数回なので許容する。打ち切りはしない。
138        let now_tick = crate::kernel::timer::get_ticks();
139        // 【2026-08-05】門(`fetch_limit`)が閉じている間に投入された取得要求を
140        // 拾い直す。投入時にしかワーカーを起こさない作りなので、
141        // これが無いと門が閉じていた分が永久に残る。
142        super::fetch_limit::set_draw_stage(2);
143
144        super::font_queue::pump();
145        // 【2026-08-05 観測】ワーカーのループが回っているかを見る。
146        // 25 フレームに 1 行だけ(L-3: 毎フレームは出さない)。
147        {
148            use core::sync::atomic::{AtomicU32, Ordering};
149            static N: AtomicU32 = AtomicU32::new(0);
150            if N.fetch_add(1, Ordering::Relaxed) % 25 == 0 {
151                super::font_queue::diag_line();
152            }
153        }
154        super::image::pump();
155        if super::font_queue::take_fonts_updated() {
156            crate::warn!("[FONT] 取得完了を検知");
157            if self.pending_reflow_since == 0 {
158                self.pending_reflow_since = now_tick;
159            }
160            self.pending_reflow_last = now_tick;
161        }
162
163        // --- 画像非同期ロード結果のポーリング(ノンブロッキング)---
164        if self.poll_image_results() {
165            // 【2026-08-05 発見・修正】以前は再描画フラグを立てるだけだった。
166            //
167            // 寸法を書いていない `<img>` の箱は、画像が届くまで
168            // プレースホルダの寸法で確定している。届いた画像を
169            // その箱へ描くので、**本来の大きさを無視して引き伸ばされる**。
170            // 実測: `App_Store_Badge_US-UK_135x40.svg`(本来 135×40)が
171            // 画面外へはみ出すほど大きく描かれていた。
172            //
173            // 実ブラウザも寸法未指定の画像が届くとリフローする。
174            // 同じく再レイアウトして、要素に本来の寸法を持たせる。
175            // DOM は組み直す。キャッシュは `is_document_changed` で守られる。
176            if self.pending_reflow_since == 0 {
177                self.pending_reflow_since = now_tick;
178            }
179            self.pending_reflow_last = now_tick;
180            self.dirty = true;
181            self.dirty_reason = "render.rs:image_arrived";
182        }
183
184        // --- 溜まった到着をまとめて 1 回の再レイアウトにする ---
185        //
186        // 【2026-08-05】以前はフォント 1 本・画像 1 枚が届くたびに
187        // その場で再レイアウトしていた。1 回が実測 4.6 秒かかるため、
188        // 4 並列で次々届くと**そのたびに 4.6 秒**を払っていた
189        // (実測: 100 フレーム中 5972 tick、`draw()` の 44%)。
190        //
191        // 到着が途切れてから `QUIET` だけ待って 1 回にまとめる。
192        // ただし届き続ける限り待ち続けると画面が更新されないので、
193        // 最初の到着から `MAX_WAIT` を超えたら待たずに実行する。
194        {
195            /// 到着が途切れたと判断するまでの静穏時間(tick, 10ms 単位)。
196            ///
197            /// 【2026-08-05 調整】最初 30(0.3 秒)にしたが**まとまらなかった**
198            /// (relayout 5972 → 6418 tick で変化なし)。
199            /// フォントは 4 並列でそれぞれ数秒かけて届くので、
200            /// 到着間隔は 0.3 秒よりずっと長い。
201            ///
202            /// 再レイアウト 1 回が実測 4.6 秒なので、
203            /// **2 秒待って 2 回を 1 回にまとめられれば元が取れる**。
204            const QUIET: usize = 200;
205            /// 最初の到着から必ず 1 回は反映するまでの上限(6 秒)。
206            /// 届き続ける限り待ち続けて画面が更新されない事態を防ぐ。
207            const MAX_WAIT: usize = 600;
208            if self.pending_reflow_since != 0 {
209                let quiet_enough = now_tick.wrapping_sub(self.pending_reflow_last) >= QUIET;
210                let waited_enough = now_tick.wrapping_sub(self.pending_reflow_since) >= MAX_WAIT;
211                // 【2026-08-05 検証済み・入れていない】
212                // 「取得ワーカーが動いている間は再レイアウトしない」を試した。
213                // カスケードがヒープの spin ロックで競合しているという読みだったが、
214                // `fetch_limit::active() == 0` を条件に足しても
215                // **2403 ルールのカスケードは完了しなかった**。
216                // 競合説は否定された(`layout.rs` の `fetch_script_text` 参照)。
217                if quiet_enough || waited_enough {
218                    self.pending_reflow_since = 0;
219                    self.pending_reflow_last = 0;
220                    // DOM は組み直す。キャッシュは `is_document_changed` で守られる
221                    // (`layout.rs` 参照)。
222                    self.dom_needs_rebuild = true;
223                    // 【2026-08-05】CSS を非同期取得にしたので、
224                    // **外部 CSS の抽出結果キャッシュも必ず捨てる**。
225                    //
226                    // `external_css_cache` は「初回だけ取得して以後は使い回す」
227                    // ためのもので、文書が変わらない限り保持される。
228                    // 同期取得の頃は初回で CSS が揃っていたので問題なかったが、
229                    // 非同期では**初回が空**になり、その空が居座ると
230                    // 以後ずっと素の HTML のまま描かれる。
231                    let arrive = super::font_queue::STYLE_ARRIVE_TICK
232                        .load(core::sync::atomic::Ordering::Acquire);
233                    crate::warn!(
234                        "[CSSTRACE] 到着による再レイアウト 到着から={}tick",
235                        if arrive == 0 {
236                            0
237                        } else {
238                            now_tick.wrapping_sub(arrive)
239                        }
240                    );
241                    // 【2026-08-25 バグ修正】ここで毎回 CSS を捨てていた。
242                    // 捨てると `parse_and_layout` が外部 CSS を**全部取り直す**。
243                    // フォントや画像が 1 つ届くたびにページ全体を
244                    // 取り込み直すことになり、遅いだけでなく、
245                    // 取り直しの最中の中途半端な状態が描かれてしまう。
246                    //
247                    // 空や欠けた結果が居座る問題は、保存する側
248                    // (`layout.rs`:全部揃ったときだけ保存)で塞いだ。
249                    // よってここは捨てない。欠けていれば
250                    // `external_css_cache` は `None` のままなので、
251                    // 次の再レイアウトが自然に取り直す。
252                    let html = self.last_html.clone();
253                    let _t0 = crate::kernel::timer::get_ticks();
254                    super::fetch_limit::set_draw_stage(3);
255
256                    self.parse_and_layout(&html, None, top_offset, self.win_w, self.win_h);
257
258                    super::fetch_limit::set_draw_stage(4);
259                    RELAYOUT_IN_DRAW_TICKS.fetch_add(
260                        crate::kernel::timer::get_ticks().wrapping_sub(_t0) as u32,
261                        core::sync::atomic::Ordering::Relaxed,
262                    );
263                    self.dirty = true;
264                    self.dirty_reason = "render.rs:reflow_coalesced";
265                }
266            }
267        }
268
269        // --- CSS アニメーション/トランジションの駆動 ---
270        // 進行中のアニメがあれば現在時刻で評価し、算出値を反映して再レイアウトする。
271        if self.anim_engine.is_active() {
272            self.tick_animations(top_offset);
273        }
274
275        // --- scroll-behavior:smooth によるスムーススクロールの駆動 ---
276        if self.scroll_anim_active {
277            self.tick_scroll_anim();
278        }
279
280        // --- `window.scrollTo()`/`window.scrollBy()` の保留分を消費・反映 ---
281        // (丸ごと未対応だった。以前は孤立した `SCROLL_Y` static へ書くだけ/
282        // `dom_noop`のままで、JS から呼んでも実際の描画位置にも
283        // `window.scrollY`にも一切反映されなかった。`.focus()`と同じ
284        // JS→レンダラの片方向同期パターンだが、`focused_idx`と違い同一
285        // フレーム内の即時反映が不要なため、`draw()`冒頭のこの1箇所だけで
286        // 消費すればよい。2026-07-16 発見・実装)。
287        {
288            let (pending_abs, pending_by) = {
289                let mut dom = self.js_runtime.dom.borrow_mut();
290                (dom.pending_scroll_abs_y.take(), dom.pending_scroll_by_y.take())
291            };
292            if pending_abs.is_some() || pending_by.is_some() {
293                let base = pending_abs.unwrap_or(self.scroll_y);
294                let target = (base + pending_by.unwrap_or(0)).max(0).min(self.max_scroll_y);
295                if target != self.scroll_y {
296                    self.scroll_y = target;
297                    self.js_runtime.fire_scroll(self.scroll_y);
298                    self.dirty = true;
299                    self.dirty_reason = "render.rs:102";
300                }
301            }
302        }
303
304        // --- 非同期ロードのポーリングチェック(自分のタブ ID の完了結果だけ消費)---
305        let mut loaded_result = None;
306        {
307            let mut lock = ASYNC_LOAD.lock();
308            if let Some(pos) = lock
309                .requests
310                .iter()
311                .position(|r| r.owner == self.engine_id && (r.status == 2 || r.status == 3))
312            {
313                let mut req = lock.requests.remove(pos);
314                if req.status == 2 {
315                    loaded_result = Some(Ok((req.result_body.unwrap_or_default(), req.result_dom.take())));
316                } else {
317                    loaded_result = Some(Err(req.error_msg.unwrap_or_default()));
318                }
319            }
320        }
321
322        if let Some(res) = loaded_result {
323            self.loading = false;
324            match res {
325                Ok((body, pre_parsed_dom)) => {
326                    crate::warn!("[NET] BROWSER: Load page SUCCESS. body_len={}", body.len());
327                    crate::warn!("[GPU] DEBUG: calling parse_and_layout next");
328            {
329                let _t0 = crate::kernel::timer::get_ticks();
330                    self.parse_and_layout(&body, pre_parsed_dom, top_offset, self.win_w, self.win_h);
331                RELAYOUT_IN_DRAW_TICKS.fetch_add(
332                    crate::kernel::timer::get_ticks().wrapping_sub(_t0) as u32,
333                    core::sync::atomic::Ordering::Relaxed,
334                );
335            }
336                    crate::warn!("[GPU] DEBUG: parse_and_layout returned");
337                }
338                Err(e) => {
339                    crate::warn!("[NET] BROWSER: Load page FAILED. err={}", e);
340                    let schema = if self.current_is_https {
341                        "https://"
342                    } else {
343                        "http://"
344                    };
345                    let err_html = alloc::format!(
346                        "<html><title>Connection Error</title><body style=\"background-color: #282a36; color: #f8f8f2;\"><h1 style=\"color: #ff5555;\">HTTP Request Failed</h1><p>Failed to connect to {}{}{}</p><p>Error detail: {}</p><hr style=\"color: #6272a4;\"><p><a href=\"/\">Go Home</a></p></body></html>",
347                        schema, self.current_host, self.current_path, e
348                    );
349            {
350                let _t0 = crate::kernel::timer::get_ticks();
351                    self.parse_and_layout(&err_html, None, top_offset, self.win_w, self.win_h);
352                RELAYOUT_IN_DRAW_TICKS.fetch_add(
353                    crate::kernel::timer::get_ticks().wrapping_sub(_t0) as u32,
354                    core::sync::atomic::Ordering::Relaxed,
355                );
356            }
357                }
358            }
359        }
360
361        // 1. 全体背景の描画(body/html の background-color を優先、なければデフォルト白)
362        let bg = self.page_bg_color.unwrap_or(0xFFFFFFFF);
363        screen.boxfill(
364            win_x,
365            win_y + top_offset as u32,
366            win_x + win_w,
367            win_y + win_h,
368            draw::Color(bg),
369        );
370
371        // --- ローディングアニメーションの描画 ---
372        if self.loading {
373            let lx = win_w / 2;
374            let ly = (win_h + top_offset as u32) / 2;
375
376            let mx_rel = self.mouse_x;
377            let my_rel = self.mouse_y;
378
379            let dx = mx_rel - lx as i32;
380            let dy = my_rel - ly as i32;
381            let dist = libm::sqrtf((dx * dx + dy * dy) as f32);
382
383            let pull = if dist < 160.0f32 {
384                (160.0f32 - dist) / 160.0f32
385            } else {
386                0.0f32
387            };
388
389            // 近づくほどスピナー中心がマウスへ吸い寄せられる (最大 14 ピクセル)
390            let pull_x = (dx as f32 * 0.12f32 * pull) as i32;
391            let pull_y = (dy as f32 * 0.12f32 * pull) as i32;
392
393            let cx = (win_x as i32 + lx as i32 + pull_x) as u32;
394            let cy = (win_y as i32 + ly as i32 + pull_y) as u32;
395
396            // マウス近接度に応じて回転速度が 1.0倍 〜 2.5倍 に加速
397            let speed_mult = 1.0f32 + pull * 1.5f32;
398            self.loading_tick = self.loading_tick.wrapping_add(speed_mult as u32);
399
400            // --- Draculaシームレス・カラーシフト (Pink -> Purple -> Cyan -> Green) ---
401            let t = (self.loading_tick as f32) * 0.015f32;
402            let phase = (t as i32) % 4;
403            let ratio = t - libm::floorf(t);
404
405            let c0 = (255f32, 121f32, 198f32); // Pink (#ff79c6)
406            let c1 = (189f32, 147f32, 249f32); // Purple (#bd93f9)
407            let c2 = (139f32, 233f32, 253f32); // Cyan (#8be9fd)
408            let c3 = (80f32, 250f32, 123f32); // Green (#50fa7b)
409
410            let (from, to) = match phase {
411                0 => (c0, c1),
412                1 => (c1, c2),
413                2 => (c2, c3),
414                _ => (c3, c0),
415            };
416
417            let r_col = from.0 + (to.0 - from.0) * ratio;
418            let g_col = from.1 + (to.1 - from.1) * ratio;
419            let b_col = from.2 + (to.2 - from.2) * ratio;
420
421            let base_color = ((r_col as u32) << 16) | ((g_col as u32) << 8) | (b_col as u32);
422            let inner_color = (((r_col * 0.7f32) as u32) << 16)
423                | (((g_col * 0.9f32 + 25f32) as u32) << 8)
424                | ((b_col as u32).max(180));
425
426            let draw_dot = |px: u32, py: u32, alpha: u8, color_val: u32| {
427                for dy in -3..=3 {
428                    for dx in -3..=3 {
429                        let dist = dx * dx + dy * dy;
430                        if dist <= 9 {
431                            let a = if dist <= 4 { alpha } else { alpha / 2 };
432                            screen.draw_pixel_alpha(
433                                (px as i32 + dx) as u32,
434                                (py as i32 + dy) as u32,
435                                draw::Color(color_val),
436                                a,
437                            );
438                        }
439                    }
440                }
441            };
442
443            // 1. 外側のサークル (8ドット, 時計回り)
444            let r_out = 28.0f32 + libm::sinf(self.loading_tick as f32 * 0.05f32) * 5.0f32;
445            for i in 0..8 {
446                let angle = (i as f32) * (core::f32::consts::PI / 4.0)
447                    + (self.loading_tick as f32 * 0.02f32);
448                let dx = libm::cosf(angle) * r_out;
449                let dy = libm::sinf(angle) * r_out;
450
451                let active_idx = (self.loading_tick / 4) % 8;
452                let distance = (i - active_idx as i32 + 8) % 8;
453                let alpha = match distance {
454                    0 => 255,
455                    1 => 180,
456                    2 => 120,
457                    3 => 70,
458                    _ => 25,
459                };
460
461                let dot_x = (cx as f32 + dx) as u32;
462                let dot_y = (cy as f32 + dy) as u32;
463                draw_dot(dot_x, dot_y, alpha, base_color);
464            }
465
466            // 2. 内側のサークル (6ドット, 反時計回り)
467            let r_in = 14.0f32 - libm::cosf(self.loading_tick as f32 * 0.07f32) * 3.0f32;
468            for i in 0..6 {
469                let angle = (i as f32) * (core::f32::consts::PI / 3.0)
470                    - (self.loading_tick as f32 * 0.035f32);
471                let dx = libm::cosf(angle) * r_in;
472                let dy = libm::sinf(angle) * r_in;
473
474                let active_idx = (self.loading_tick / 5) % 6;
475                let distance = (i - active_idx as i32 + 6) % 6;
476                let alpha = match distance {
477                    0 => 230,
478                    1 => 160,
479                    2 => 100,
480                    _ => 20,
481                };
482
483                let dot_x = (cx as f32 + dx) as u32;
484                let dot_y = (cy as f32 + dy) as u32;
485                draw_dot(dot_x, dot_y, alpha, inner_color);
486            }
487
488            screen.draw_string_ja_size_bg(
489                cx - 75,
490                cy + 45,
491                "Loading Secure Page...",
492                14,
493                draw::Color(theme.sysmon_accent),
494                draw::Color(bg),
495            );
496            self.dirty = true;
497            self.dirty_reason = "render.rs:285";
498            return;
499        }
500
501        // 【2026-07-26診断】実機で `.hero` の背景は描画されるのに内部テキストが
502        // 見えない件の切り分け。要素の並び順(=描画順)と座標・背景色・テキストを
503        // 先頭から一定数だけ出力し、(a) テキスト要素がそもそも存在するか、
504        // (b) 存在するなら座標が可視範囲か、(c) 背景矩形より後に描かれているか、
505        // を確定させる。
506        {
507            static DIAG_ONCE: core::sync::atomic::AtomicBool =
508                core::sync::atomic::AtomicBool::new(false);
509            // `.hero` の背景色(#ff6b6b)を持つ要素が現れた回=実サイトのCSSが
510            // 実際に適用された回だけを対象にする(CSS 未適用の初回描画で
511            // 発火してしまうと欲しい情報が取れないため)。
512            let hero_idx = self
513                .elements
514                .iter()
515                .position(|e| e.bg_color == Some(0xFF6B6B) || e.bg_color == Some(0xFFFF6B6B));
516            if let Some(hi) = hero_idx {
517                if !DIAG_ONCE.swap(true, core::sync::atomic::Ordering::Relaxed) {
518                    crate::warn!(
519                        "[HERO][DIAG] elements={} hero_idx={} scroll_y={} top_offset={} win_h={}",
520                        self.elements.len(),
521                        hi,
522                        self.scroll_y,
523                        top_offset,
524                        win_h
525                    );
526                    // ヒーロー要素とその直後(子要素が続くはず)を出力する。
527                    let start = hi.saturating_sub(2);
528                    for (i, el) in self.elements.iter().enumerate().skip(start).take(22) {
529                        let t: String = el.text.trim().chars().take(18).collect();
530                        crate::warn!(
531                            "[HERO][DIAG] #{} y={} h={} x={} w={} bg={:?} color=0x{:06x} fs={} text={:?}",
532                            i,
533                            el.y_offset,
534                            el.height,
535                            el.x_offset,
536                            el.width,
537                            el.bg_color,
538                            el.color & 0xFFFFFF,
539                            el.font_size,
540                            t
541                        );
542                    }
543                }
544            }
545        }
546
547        // 必要な画像を事前にロードしてキャッシュに入れる
548        // 【2026-07-31計測】draw が 1 フレーム約 3 秒かかる内訳を切り分ける。
549        // `get_ticks` は atomic load なので安価(`get_system_time_us` は
550        // `mrs` 2 回+128bit 除算で、細かく呼ぶと計測対象を歪める)。
551        let t_pre = crate::kernel::timer::get_ticks();
552        // 【2026-08-05】ここは**毎フレーム**、可視要素すべての URL を
553        // `String` で複製していた(`ensure_image_cached` が `&mut self` を
554        // 要るので、`self.elements` を借りたまま呼べないため)。
555        //
556        // だが `ensure_image_cached` は「取得済み」「取得試行済み」なら
557        // 何もせず戻る。**その判定を複製の前に持ってくれば、
558        // 定常状態では 1 つも複製しなくて済む**。
559        // 判定に使う `image_cache` / `image_attempted` の参照は
560        // `self.elements` の参照と両立する(どちらも不変借用)。
561        //
562        // 同じ理由で `background_image_urls` も、まだ入っていないものだけ積む。
563        let mut needed_images = alloc::vec::Vec::new();
564        let mut bg_urls: alloc::vec::Vec<alloc::string::String> = alloc::vec::Vec::new();
565        {
566            // 取得を要求する必要があるか(複製する価値があるか)。
567            let want = |me: &Self, u: &str| {
568                !u.is_empty() && !me.image_cache.contains_key(u) && !me.image_attempted.contains(u)
569            };
570            for el in &self.elements {
571                let el_y = el.y_offset - self.scroll_y + top_offset;
572                if el_y + el.height < top_offset || el_y > win_h as i32 {
573                    continue;
574                }
575                if el.is_img && want(self, &el.video_src) {
576                    needed_images.push(el.video_src.clone());
577                }
578                // 背景画像は取得を省略する対象として記録しておく
579                // (`ensure_image_cached` が参照する)。
580                if let Some(ref bg_img) = el.bg_image {
581                    if !self.background_image_urls.contains(bg_img.as_str()) {
582                        bg_urls.push(bg_img.clone());
583                    }
584                    if want(self, bg_img) {
585                        needed_images.push(bg_img.clone());
586                    }
587                }
588                for extra in &el.bg_images {
589                    if !self.background_image_urls.contains(extra.as_str()) {
590                        bg_urls.push(extra.clone());
591                    }
592                    if want(self, extra) {
593                        needed_images.push(extra.clone());
594                    }
595                }
596                if el.is_li && want(self, &el.list_style_image) {
597                    needed_images.push(el.list_style_image.clone());
598                }
599            }
600        }
601        for u in bg_urls {
602            self.background_image_urls.insert(u);
603        }
604        for img_path in needed_images {
605            self.ensure_image_cached(&img_path);
606        }
607
608        self.links.clear();
609
610        // CSS `cursor: text` の判定を毎フレーム作り直す(後述のループ内で
611        // ホバー中の要素が見つかれば true に立てる。「後勝ち=最前面寄り」で、
612        // z_index 昇順ループの最後にマッチした要素を優先する既存の他の
613        // ホバー判定と同じ方針)。
614        self.hovered_cursor_is_text = false;
615
616        let t_imgprep = crate::kernel::timer::get_ticks();
617        super::draw_helpers::BG_IMG_TICKS.store(0, core::sync::atomic::Ordering::Relaxed);
618        super::draw_helpers::GRAD_TICKS.store(0, core::sync::atomic::Ordering::Relaxed);
619        crate::kernel::draw::TEXT_DRAW_TICKS.store(0, core::sync::atomic::Ordering::Relaxed);
620        super::draw_helpers::IMG_TICKS.store(0, core::sync::atomic::Ordering::Relaxed);
621        crate::kernel::draw::BOXFILL_TICKS.store(0, core::sync::atomic::Ordering::Relaxed);
622        crate::kernel::draw::SHADOW_TICKS.store(0, core::sync::atomic::Ordering::Relaxed);
623        crate::kernel::draw::RRECT_TICKS.store(0, core::sync::atomic::Ordering::Relaxed);
624        crate::kernel::draw::PIXBG_TICKS.store(0, core::sync::atomic::Ordering::Relaxed);
625        super::draw_helpers::OTHERFILL_TICKS.store(0, core::sync::atomic::Ordering::Relaxed);
626        super::draw_helpers::BORDER_TICKS.store(0, core::sync::atomic::Ordering::Relaxed);
627        // Sort elements by z_index (stable sort preserves document order within same z)
628        let mut sorted_indices: Vec<usize> = (0..self.elements.len()).collect();
629        // `(実効 z, ツリー深さ)` の昇順。深さを第 2 キーにするのが要点で、
630        // 要素列は祖先が先に積まれるとは限らないため、z だけで並べると
631        // 祖先の背景が子孫を塗り潰す(`spec/paint_order.md` 不変条件 I-1)。
632        sorted_indices.sort_by_key(|&i| {
633            super::paint_order::paint_sort_key(
634                self.elements[i].z_index,
635                self.elements[i].paint_depth,
636            )
637        });
638
639        for &ei in &sorted_indices {
640            let el = &self.elements[ei];
641            let el_start = crate::kernel::timer::get_ticks();
642
643
644            // visibility: hidden — skip drawing but keep space
645            if el.visibility_hidden {
646                continue;
647            }
648
649            // 位置確定は layout 側で完了(relative/absolute/fixed は content.x/y に反映済み。
650            // absolute は最近傍 positioned 祖先基準、fixed は viewport 原点基準)。
651            // 描画側は fixed のスクロール非追従のみ扱う(absolute/relative はページと共にスクロール)。
652            // サブスクロールコンテナに属する子孫はコンテナ固有の scroll_y も引く。
653            let sub_scroll_y = if el.scroll_container_id.is_empty() {
654                0
655            } else {
656                self.scroll_regions
657                    .get(&el.scroll_container_id)
658                    .copied()
659                    .unwrap_or(0)
660            };
661            let natural_y = el.y_offset + el.transform_ty - self.scroll_y - sub_scroll_y + top_offset;
662            let el_y = if el.is_fixed {
663                el.y_offset + el.transform_ty + top_offset
664            } else if el.is_sticky {
665                // 計算本体は純粋モジュールへ切り出した(試験は `test_sticky.rs`)。
666                if let Some(top) = el.sticky_top {
667                    super::sticky::sticky_top_y(
668                        natural_y,
669                        top_offset,
670                        top,
671                        el.sticky_bound_max_y,
672                        el.transform_ty,
673                        self.scroll_y,
674                        sub_scroll_y,
675                    )
676                } else if let Some(bottom) = el.sticky_bottom {
677                    super::sticky::sticky_bottom_y(natural_y, win_h as i32, bottom, el.height)
678                } else {
679                    natural_y
680                }
681            } else {
682                natural_y
683            };
684            let el_x = el.x_offset + el.transform_tx;
685
686            if el_y + el.height < top_offset || el_y > win_h as i32 {
687                continue;
688            }
689            // overflow: hidden / scroll のピクセルクリップ
690            // 【2026-07-25発見・修正】`el.clip_y1`はクリップ未設定時の既定値として
691            // `i32::MAX`(実質「無制限」を表す)が入るが、以前は
692            // `el.clip_y1 - self.scroll_y + top_offset`を素の`+`/`-`で計算していた
693            // ため、`i32::MAX + top_offset`が符号付き整数オーバーフローを起こし、
694            // 巨大な負の値へ折り返っていた。この結果`el_y >= clip_y1`が常に
695            // 真になり、実サイトの`overflow:hidden`を持つ要素(nav等)の
696            // 子孫テキストがすべて「クリップ範囲外」と誤判定されて描画から
697            // 除外される、実サイトのレンダリングを丸ごと壊す重大バグだった。
698            // `saturating_add`/`saturating_sub`でオーバーフロー時は
699            // `i32::MAX`/`i32::MIN`に飽和させることで解消する。
700            let has_overflow_clip = if el.overflow_hidden {
701                let clip_y = el.clip_y0.saturating_sub(self.scroll_y).saturating_add(top_offset);
702                let clip_y1 = el.clip_y1.saturating_sub(self.scroll_y).saturating_add(top_offset);
703                if el_y + el.height <= clip_y || el_y >= clip_y1 {
704                    continue;
705                }
706                if el_x + el.width <= el.clip_x0 || el_x >= el.clip_x1 {
707                    continue;
708                }
709                // 部分重複要素のピクセル精度クリップ(`clip_x1`/`clip_y1`も
710                // 未設定時`i32::MAX`のため、上記と同様`saturating_add`で防ぐ)。
711                let sx0 = (win_x as i32).saturating_add(el.clip_x0).max(0) as u32;
712                let sy0 = (win_y as i32).saturating_add(clip_y).max(0) as u32;
713                let sx1 = (win_x as i32).saturating_add(el.clip_x1).max(0) as u32;
714                let sy1 = (win_y as i32).saturating_add(clip_y1).max(0) as u32;
715                screen.push_clip(sx0, sy0, sx1, sy1);
716                true
717            } else {
718                false
719            };
720
721            let draw_y = win_y + el_y as u32;
722            // position オフセットを el 参照から隠蔽して以降のコードで透過的に使えるようにする
723            let draw_x_base = win_x as i32 + el_x;
724
725            // CSS `cursor: text` の判定(要素カテゴリに関わらず共通の位置確定後に1回だけ行う)。
726            if el.cursor_style == "text"
727                && self.mouse_y >= draw_y as i32
728                && self.mouse_y <= (draw_y + el.height as u32) as i32
729                && self.mouse_x >= draw_x_base
730                && self.mouse_x <= draw_x_base + el.width
731            {
732                self.hovered_cursor_is_text = true;
733            }
734
735            // CSS transform: scale(X/Y) — transform-origin を基準点に軸ごとに独立してサイズを拡縮。scale=1.0 なら無変換。
736            let scale_x = el.transform_scale_x;
737            let scale_y = el.transform_scale_y;
738            let eff_w = if (scale_x - 1.0f32).abs() < 0.001 {
739                el.width
740            } else {
741                (el.width as f32 * scale_x) as i32
742            };
743            let eff_h = if (scale_y - 1.0f32).abs() < 0.001 {
744                el.height
745            } else {
746                (el.height as f32 * scale_y) as i32
747            };
748            // scale ≠ 1 のとき: transform-origin の位置を保つよう draw_x_base / draw_y を再調整。
749            // 既定 (0.5, 0.5) は従来どおり中心固定と等価。
750            let draw_x_base =
751                draw_x_base + ((el.width - eff_w) as f32 * el.transform_origin_x) as i32;
752            // 【2026-08-04 発見・修正】ここで無条件に `.max(0)` していた。
753            //
754            // `draw_y` は `win_y + el_y as u32` で作られており、
755            // 画面より上へスクロールアウトした要素では `el_y` が負のため
756            // u32 へ折り返している(`draw_y as i32` は負)。
757            // そこへ `.max(0)` を掛けると **0 に固定**され、
758            // 高さはそのままなので `y=0..height` を塗ってしまう。
759            //
760            // 実測: 要素 #50(`y_offset=529 h=740`、スクロール後 `el_y=-700`)が
761            // `boxfill(100, 0, 1024, 740)` を実行し、**可視領域を丸ごと覆っていた**。
762            // `z=10` なので研究カード(`z=5`)の後に塗られ、カードが消えていた。
763            //
764            // この補正が要るのは `transform: scale()` で寸法が変わったときだけ。
765            // 補正量が 0 なら元の値(折り返し済み u32)をそのまま使う。
766            // 折り返した値は `boxfill` の `cy0 >= h` で早期 return されるため、
767            // 画面外の要素は正しく描かれない。
768            let origin_adj = ((el.height - eff_h) as f32 * el.transform_origin_y) as i32;
769            let draw_y = if origin_adj == 0 {
770                draw_y
771            } else {
772                (draw_y as i32).wrapping_add(origin_adj) as u32
773            };
774
775            if el.is_hr {
776                screen.boxfill(
777                    draw_x_base as u32,
778                    draw_y + 3,
779                    win_x + win_w - 40,
780                    draw_y + 5,
781                    draw::Color(el.color),
782                );
783            } else if let Some(cid) = el.canvas_ctx_id {
784                // 【2026-09-03】`<canvas>` のバッキングストアを画面へ貼る。
785                //
786                // JS が描いた内容は `os_lib/canvas2d` の registry にある
787                // `Surface`(ARGB8888)に入っている。ここで要素の矩形へ
788                // 転送することで、初めて画面に出る。
789                //
790                // 面のサイズと要素のサイズが違う場合は最近傍で拡縮する
791                // (CSS で `width`/`height` を指定した canvas は、属性の
792                // 解像度と表示サイズが食い違うのが普通)。
793                crate::os_lib::canvas2d::with_context(cid, |c| {
794                    let sw = c.surface.width as i32;
795                    let sh = c.surface.height as i32;
796                    if sw <= 0 || sh <= 0 || el.width <= 0 || el.height <= 0 {
797                        return;
798                    }
799                    for dy in 0..el.height {
800                        let py = el_y + dy;
801                        if py < top_offset || py >= win_h as i32 {
802                            continue;
803                        }
804                        // 最近傍サンプリング。
805                        let sy = (dy as i64 * sh as i64 / el.height as i64) as i32;
806                        for dx in 0..el.width {
807                            let px = el_x + dx;
808                            let sx = (dx as i64 * sw as i64 / el.width as i64) as i32;
809                            if sx < 0 || sy < 0 || sx >= sw || sy >= sh {
810                                continue;
811                            }
812                            let argb = c.surface.pixels[(sy * sw + sx) as usize];
813                            let a = ((argb >> 24) & 0xFF) as u8;
814                            if a == 0 {
815                                continue;
816                            }
817                            screen.draw_pixel_alpha(
818                                px as u32,
819                                py as u32,
820                                draw::Color(argb & 0x00FF_FFFF),
821                                a,
822                            );
823                        }
824                    }
825                });
826            } else if el.is_img {
827                let img_clip = if el.clip_path.is_empty() {
828                    None
829                } else {
830                    parse_clip_shape(&el.clip_path, el.width, el.height)
831                };
832                let mut bmp_drawn = false;
833                if !el.video_src.is_empty() {
834                    // キャッシュから object-fit/object-position を反映して描画する
835                    if let Some(img) = self.image_cache.get(&el.video_src) {
836                        draw_img_object_fit(
837                            screen,
838                            img,
839                            el,
840                            draw_x_base,
841                            draw_y as i32,
842                            &img_clip,
843                        );
844                        bmp_drawn = true;
845                    }
846                }
847
848                if !bmp_drawn {
849                    let hash = hash_str(&el.img_alt);
850                    let r1 = (hash & 0x7F) + 0x80;
851                    let g1 = ((hash >> 8) & 0x7F) + 0x80;
852                    let b1 = ((hash >> 16) & 0x7F) + 0x80;
853                    let c1 = 0xFF000000 | (r1 << 16) | (g1 << 8) | b1;
854
855                    let r2 = ((hash >> 4) & 0x7F) + 0x60;
856                    let g2 = ((hash >> 12) & 0x7F) + 0x60;
857                    let b2 = ((hash >> 20) & 0x7F) + 0x60;
858                    let c2 = 0xFF000000 | (r2 << 16) | (g2 << 8) | b2;
859
860                    let x0 = (draw_x_base as u32) as i32;
861                    let y0 = draw_y as i32;
862                    let x1 = (draw_x_base as u32 + el.width as u32) as i32;
863                    let y1 = (draw_y + el.height as u32) as i32;
864
865                    if el.border_radius != (0, 0, 0, 0) {
866                        screen.draw_rounded_rect_fill(
867                            x0,
868                            y0,
869                            x1,
870                            y1,
871                            el.border_radius,
872                            draw::Color(c1),
873                        );
874                        screen.draw_rounded_rect_border(
875                            x0,
876                            y0,
877                            x1,
878                            y1,
879                            el.border_radius,
880                            draw::Color(el.border_color.unwrap_or(theme.browser_secondary)),
881                            el.border_style.as_str(),
882                        );
883                    } else {
884                        draw_gradation(screen, x0, y0, el.width, el.height, c1, c2);
885                        draw_border_with_style(
886                            screen,
887                            x0,
888                            y0,
889                            x1,
890                            y1,
891                            el.border_color.unwrap_or(theme.browser_secondary),
892                            el.border_style,
893                        );
894                    }
895
896                    let label = alloc::format!("[Image: {}]", el.img_alt);
897                    screen.draw_string_ja_size_bg(
898                        draw_x_base as u32 + 13,
899                        draw_y + 9,
900                        &label,
901                        16,
902                        draw::Color(theme.browser_bg),
903                        draw::Color(c1),
904                    ); // 影
905                    screen.draw_string_ja_size_bg(
906                        draw_x_base as u32 + 12,
907                        draw_y + 8,
908                        &label,
909                        16,
910                        draw::Color(theme.browser_fg),
911                        draw::Color(c1),
912                    ); // 前景
913                }
914
915                let is_mixed = self.current_is_https && el.video_src.starts_with("http://");
916                if is_mixed {
917                    screen.boxfill(
918                        draw_x_base as u32,
919                        draw_y,
920                        draw_x_base as u32 + 220,
921                        draw_y + 20,
922                        draw::Color(theme.error_fg),
923                    );
924                    screen.draw_string_ja_size_bg(
925                        draw_x_base as u32 + 4,
926                        draw_y + 2,
927                        "⚠️ HTTP Mixed Blocked",
928                        12,
929                        draw::Color(theme.browser_fg),
930                        draw::Color(theme.error_fg),
931                    );
932                }
933            } else if el.is_video {
934                let v_width = (win_w as i32 - 80).clamp(400, 800);
935                let x0 = draw_x_base as u32;
936                let y0 = draw_y;
937                let x1 = draw_x_base as u32 + v_width as u32;
938                let y1 = draw_y + el.height as u32;
939
940                if el.border_radius != (0, 0, 0, 0) {
941                    screen.draw_rounded_rect_fill(
942                        x0 as i32,
943                        y0 as i32,
944                        x1 as i32,
945                        y1 as i32,
946                        el.border_radius,
947                        draw::Color(theme.browser_bg),
948                    );
949                    screen.draw_rounded_rect_border(
950                        x0 as i32,
951                        y0 as i32,
952                        x1 as i32,
953                        y1 as i32,
954                        el.border_radius,
955                        draw::Color(el.border_color.unwrap_or(theme.browser_secondary)),
956                        el.border_style.as_str(),
957                    );
958                } else {
959                    screen.boxfill(x0, y0, x1, y1, draw::Color(theme.browser_bg));
960                    draw_border_with_style(
961                        screen,
962                        x0 as i32,
963                        y0 as i32,
964                        x1 as i32,
965                        y1 as i32,
966                        el.border_color.unwrap_or(theme.browser_secondary),
967                        el.border_style,
968                    );
969                }
970
971                if self.video_playing {
972                    let frame_opt = CURRENT_VIDEO_FRAME.lock().clone();
973                    if let Some(frame) = frame_opt {
974                        let dst_w = v_width as u32;
975                        let dst_h = el.height as u32;
976                        for row in 0..dst_h {
977                            let src_row = (row * frame.height) / dst_h;
978                            for col in 0..dst_w {
979                                let src_col = (col * frame.width) / dst_w;
980                                let idx = ((src_row * frame.width + src_col) as usize) * 3;
981                                if idx + 3 <= frame.rgb_data.len() {
982                                    let r = frame.rgb_data[idx];
983                                    let g = frame.rgb_data[idx + 1];
984                                    let b = frame.rgb_data[idx + 2];
985                                    let color = draw::Color(
986                                        0xFF000000
987                                            | ((r as u32) << 16)
988                                            | ((g as u32) << 8)
989                                            | (b as u32),
990                                    );
991                                    screen.draw_pixel(x0 + col, y0 + row, color);
992                                }
993                            }
994                        }
995                    } else {
996                        // ダウンロードまたはデコード待ちの間は黒画面+ローディング表示
997                        screen.boxfill(x0, y0, x1, y1, draw::Color(theme.video_bar_bg));
998                        let cx = x0 + v_width as u32 / 2;
999                        let cy = y0 + el.height as u32 / 2;
1000                        screen.draw_string_ja_size_bg(
1001                            cx - 50,
1002                            cy - 10,
1003                            "Loading Video...",
1004                            16,
1005                            draw::Color(theme.fn_bar_key_fg),
1006                            draw::Color(theme.video_bar_bg),
1007                        );
1008                    }
1009                    self.video_tick = self.video_tick.wrapping_add(1);
1010                    self.dirty = true; // 次フレーム描画のために再描画要求を立て続ける
1011                } else {
1012                    let cx = draw_x_base as u32 + v_width as u32 / 2;
1013                    let cy = draw_y + el.height as u32 / 2;
1014                    screen.boxfill(cx - 50, cy - 20, cx + 50, cy + 20, draw::Color(theme.browser_secondary));
1015                    draw_border_with_style(
1016                        screen,
1017                        (cx - 50) as i32,
1018                        (cy - 20) as i32,
1019                        (cx + 50) as i32,
1020                        (cy + 20) as i32,
1021                        el.border_color.unwrap_or(theme.success_fg),
1022                        el.border_style,
1023                    );
1024                    screen.draw_string_ja_size_bg(
1025                        cx - 30,
1026                        cy - 10,
1027                        "PLAY ▶",
1028                        16,
1029                        draw::Color(theme.success_fg),
1030                        draw::Color(theme.browser_secondary),
1031                    );
1032                }
1033
1034                let bar_y = draw_y + 200;
1035                screen.boxfill(
1036                    draw_x_base as u32,
1037                    bar_y,
1038                    draw_x_base as u32 + v_width as u32,
1039                    bar_y + 40,
1040                    draw::Color(theme.video_bar_bg),
1041                );
1042                screen.boxfill(
1043                    draw_x_base as u32,
1044                    bar_y,
1045                    draw_x_base as u32 + v_width as u32,
1046                    bar_y + 1,
1047                    draw::Color(theme.video_bar_sep),
1048                );
1049
1050                let play_lbl = if self.video_playing { "‖" } else { "▶" };
1051                screen.draw_string_ja_size_bg(
1052                    win_x + 55,
1053                    bar_y + 10,
1054                    play_lbl,
1055                    16,
1056                    draw::Color(theme.video_thumb_fg),
1057                    draw::Color(theme.video_bar_bg),
1058                );
1059
1060                let sb_w = v_width - 320;
1061                if sb_w > 50 {
1062                    screen.boxfill(
1063                        win_x + 80,
1064                        bar_y + 18,
1065                        win_x + 80 + sb_w as u32,
1066                        bar_y + 22,
1067                        draw::Color(theme.video_bar_sep),
1068                    );
1069                    let progress = if self.video_playing {
1070                        (self.video_tick * 4) % sb_w as u32
1071                    } else {
1072                        (sb_w / 3) as u32
1073                    };
1074                    screen.boxfill(
1075                        win_x + 80,
1076                        bar_y + 18,
1077                        win_x + 80 + progress,
1078                        bar_y + 22,
1079                        draw::Color(theme.video_progress_fg),
1080                    );
1081                    screen.boxfill(
1082                        win_x + 80 + progress - 4,
1083                        bar_y + 14,
1084                        win_x + 80 + progress + 4,
1085                        bar_y + 26,
1086                        draw::Color(theme.video_thumb_fg),
1087                    );
1088                }
1089
1090                if v_width > 260 {
1091                    let badge_x = draw_x_base as u32 + v_width as u32 - 240;
1092                    screen.boxfill(
1093                        badge_x,
1094                        bar_y + 8,
1095                        badge_x + 230,
1096                        bar_y + 32,
1097                        draw::Color(theme.privacy_badge_bg),
1098                    );
1099                    draw_border(
1100                        screen,
1101                        badge_x as i32,
1102                        (bar_y + 8) as i32,
1103                        (badge_x + 230) as i32,
1104                        (bar_y + 32) as i32,
1105                        theme.video_progress_fg,
1106                    );
1107                    screen.draw_string_ja_size_bg(
1108                        badge_x + 12,
1109                        bar_y + 12,
1110                        "🔒 Privacy Shield Active",
1111                        12,
1112                        draw::Color(theme.privacy_badge_fg),
1113                        draw::Color(theme.privacy_badge_bg),
1114                    );
1115                }
1116            } else if el.input_kind == 1 || el.input_kind == 2 {
1117                // checkbox (input_kind=1) / radio (input_kind=2) の描画。
1118                // form_values が非空ならチェック状態。box は左寄せの小さな四角/円。
1119                let checked = !self
1120                    .form_values
1121                    .get(&el.element_id)
1122                    .cloned()
1123                    .unwrap_or_default()
1124                    .is_empty();
1125                let box_sz: u32 = 16;
1126                let bx0 = draw_x_base as u32;
1127                let by0 = draw_y + (el.height as u32).saturating_sub(box_sz) / 2;
1128                let bx1 = bx0 + box_sz;
1129                let by1 = by0 + box_sz;
1130                let border_col = theme.form_input_border;
1131                // accent-color: チェック状態の塗り色を上書きできる(未指定ならテーマ既定色)。
1132                let fill_col = if checked {
1133                    el.accent_color.unwrap_or(theme.form_checkbox_checked)
1134                } else {
1135                    theme.form_checkbox_unchecked
1136                };
1137                if el.input_kind == 2 {
1138                    // radio: 角丸の強い四角で円を近似。チェック時は中央にドット。
1139                    screen.draw_rounded_rect_fill(
1140                        bx0 as i32,
1141                        by0 as i32,
1142                        bx1 as i32,
1143                        by1 as i32,
1144                        ((box_sz / 2) as i32, (box_sz / 2) as i32, (box_sz / 2) as i32, (box_sz / 2) as i32),
1145                        draw::Color(fill_col),
1146                    );
1147                    screen.draw_rounded_rect_border(
1148                        bx0 as i32,
1149                        by0 as i32,
1150                        bx1 as i32,
1151                        by1 as i32,
1152                        ((box_sz / 2) as i32, (box_sz / 2) as i32, (box_sz / 2) as i32, (box_sz / 2) as i32),
1153                        draw::Color(border_col),
1154                        "solid",
1155                    );
1156                    if checked {
1157                        let cx = (bx0 + bx1) / 2;
1158                        let cy = (by0 + by1) / 2;
1159                        screen.draw_rounded_rect_fill(
1160                            (cx - 3) as i32,
1161                            (cy - 3) as i32,
1162                            (cx + 3) as i32,
1163                            (cy + 3) as i32,
1164                            (3, 3, 3, 3),
1165                            draw::Color(theme.form_input_bg),
1166                        );
1167                    }
1168                } else {
1169                    // checkbox: 四角。チェック時は中央に塗りつぶしマーク。
1170                    screen.boxfill(bx0, by0, bx1, by1, draw::Color(fill_col));
1171                    draw_border_with_style(
1172                        screen, bx0 as i32, by0 as i32, bx1 as i32, by1 as i32, border_col, super::BorderStyle::Solid,
1173                    );
1174                    if checked {
1175                        screen.boxfill(bx0 + 4, by0 + 4, bx1 - 4, by1 - 4, draw::Color(theme.form_input_bg));
1176                    }
1177                }
1178            } else if el.input_kind == 3 {
1179                // select の描画: 角丸ボックス + 選択中ラベル + 右端に下向き矢印。
1180                let bg_col = theme.form_input_bg;
1181                let border_col = theme.form_input_border;
1182                let x0 = draw_x_base as u32;
1183                let y0 = draw_y;
1184                let x1 = draw_x_base as u32 + el.width as u32;
1185                let y1 = draw_y + el.height as u32;
1186                screen.boxfill(x0, y0, x1, y1, draw::Color(bg_col));
1187                draw_border_with_style(
1188                    screen, x0 as i32, y0 as i32, x1 as i32, y1 as i32, border_col, super::BorderStyle::Solid,
1189                );
1190                // 選択中の value からラベルを解決。
1191                let cur_val = self
1192                    .form_values
1193                    .get(&el.element_id)
1194                    .cloned()
1195                    .unwrap_or_default();
1196                let label = el
1197                    .select_options
1198                    .iter()
1199                    .find(|(v, _)| *v == cur_val)
1200                    .map(|(_, l)| l.clone())
1201                    .or_else(|| el.select_options.first().map(|(_, l)| l.clone()))
1202                    .unwrap_or_default();
1203                let text_y = y0 + (el.height as u32).saturating_sub(16) / 2;
1204                screen.push_clip(x0, y0, x1.saturating_sub(18), y1);
1205                screen.draw_string_ja_size_bg(
1206                    x0 + 6,
1207                    text_y,
1208                    &label,
1209                    16,
1210                    draw::Color(theme.form_input_fg),
1211                    draw::Color(bg_col),
1212                );
1213                screen.pop_clip();
1214                // 下向き矢印(▼)を右端に。
1215                let arrow_x = x1.saturating_sub(16);
1216                screen.draw_string_ja_size_bg(
1217                    arrow_x,
1218                    text_y,
1219                    "▼",
1220                    12,
1221                    draw::Color(theme.form_input_accent),
1222                    draw::Color(bg_col),
1223                );
1224            } else if el.input_kind == 4 {
1225                // <progress>/<meter> の描画: トラック(背景)+ 塗りつぶし(比率分の幅)。
1226                // 比率は layout 側で input_value に文字列化して格納済み(0.0..1.0)。
1227                let ratio: f32 = el.input_value.parse().unwrap_or(0.0);
1228                let x0 = draw_x_base as u32;
1229                let y0 = draw_y;
1230                let x1 = draw_x_base as u32 + el.width as u32;
1231                let y1 = draw_y + el.height as u32;
1232                let track_col = theme.form_input_bg;
1233                let fill_col = el.accent_color.unwrap_or(theme.form_checkbox_checked);
1234                screen.boxfill(x0, y0, x1, y1, draw::Color(track_col));
1235                let fill_x1 = x0 + ((x1 - x0) as f32 * ratio) as u32;
1236                if fill_x1 > x0 {
1237                    screen.boxfill(x0, y0, fill_x1, y1, draw::Color(fill_col));
1238                }
1239                draw_border_with_style(
1240                    screen, x0 as i32, y0 as i32, x1 as i32, y1 as i32,
1241                    theme.form_input_border, super::BorderStyle::Solid,
1242                );
1243            } else if el.input_kind == 5 {
1244                // <input type="range"> の描画: 中央の細いトラック + 現在値位置のつまみ(丸)。
1245                // min/max は layout 側で select_options に (name, value) として格納済み。
1246                let get = |k: &str, d: f32| -> f32 {
1247                    el.select_options
1248                        .iter()
1249                        .find(|(n, _)| n == k)
1250                        .and_then(|(_, v)| v.parse::<f32>().ok())
1251                        .unwrap_or(d)
1252                };
1253                let min = get("min", 0.0);
1254                let max = get("max", 100.0);
1255                let cur: f32 = self
1256                    .form_values
1257                    .get(&el.element_id)
1258                    .and_then(|v| v.parse().ok())
1259                    .unwrap_or((min + max) / 2.0);
1260                let ratio = if max > min { ((cur - min) / (max - min)).clamp(0.0, 1.0) } else { 0.0 };
1261                let x0 = draw_x_base as u32;
1262                let x1 = draw_x_base as u32 + el.width as u32;
1263                let mid_y = draw_y + el.height as u32 / 2;
1264                let track_h = 4u32;
1265                screen.boxfill(x0, mid_y.saturating_sub(track_h / 2), x1, mid_y + track_h / 2, draw::Color(theme.form_input_bg));
1266                let thumb_r = (el.height as u32 / 2).clamp(6, 10);
1267                let thumb_x = (x0 + ((x1 - x0) as f32 * ratio) as u32).clamp(x0, x1);
1268                let thumb_col = el.accent_color.unwrap_or(theme.form_checkbox_checked);
1269                screen.draw_rounded_rect_fill(
1270                    (thumb_x.saturating_sub(thumb_r)) as i32,
1271                    (mid_y.saturating_sub(thumb_r)) as i32,
1272                    (thumb_x.saturating_add(thumb_r)) as i32,
1273                    (mid_y.saturating_add(thumb_r)) as i32,
1274                    (thumb_r as i32, thumb_r as i32, thumb_r as i32, thumb_r as i32),
1275                    draw::Color(thumb_col),
1276                );
1277                draw_border_with_style(
1278                    screen,
1279                    (thumb_x.saturating_sub(thumb_r)) as i32,
1280                    (mid_y.saturating_sub(thumb_r)) as i32,
1281                    (thumb_x.saturating_add(thumb_r)) as i32,
1282                    (mid_y.saturating_add(thumb_r)) as i32,
1283                    theme.form_input_border,
1284                    super::BorderStyle::Solid,
1285                );
1286            } else if el.is_input || el.is_textarea {
1287                let is_focused = self.focused_id.as_ref() == Some(&el.element_id);
1288                let bg_col = theme.form_input_bg;
1289                let border_col = if is_focused { theme.form_input_border_focus } else { theme.form_input_border };
1290
1291                let x0 = draw_x_base as u32;
1292                let y0 = draw_y;
1293                let x1 = draw_x_base as u32 + el.width as u32;
1294                let y1 = draw_y + el.height as u32;
1295
1296                if el.border_radius != (0, 0, 0, 0) {
1297                    screen.draw_rounded_rect_fill(
1298                        x0 as i32,
1299                        y0 as i32,
1300                        x1 as i32,
1301                        y1 as i32,
1302                        el.border_radius,
1303                        draw::Color(bg_col),
1304                    );
1305                    screen.draw_rounded_rect_border(
1306                        x0 as i32,
1307                        y0 as i32,
1308                        x1 as i32,
1309                        y1 as i32,
1310                        el.border_radius,
1311                        draw::Color(el.border_color.unwrap_or(border_col)),
1312                        el.border_style.as_str(),
1313                    );
1314                } else {
1315                    screen.boxfill(x0, y0, x1, y1, draw::Color(bg_col));
1316                    draw_border_with_style(
1317                        screen,
1318                        x0 as i32,
1319                        y0 as i32,
1320                        x1 as i32,
1321                        y1 as i32,
1322                        el.border_color.unwrap_or(border_col),
1323                        el.border_style,
1324                    );
1325                }
1326
1327                let mut show_cursor = false;
1328                let mut cursor_pos = 0;
1329
1330                let text_val = if is_focused {
1331                    if self.active_text_box.is_none() && !el.element_id.is_empty() {
1332                        let val = self
1333                            .form_values
1334                            .get(&el.element_id)
1335                            .cloned()
1336                            .unwrap_or_default();
1337                        let restore = if self.focus_caret_owner == el.element_id {
1338                            Some(self.focus_cursor_pos)
1339                        } else {
1340                            None
1341                        };
1342                        self.active_text_box =
1343                            Some(Self::build_text_box(&val, el.is_textarea, restore));
1344                    }
1345
1346                    if let Some(ref tb) = self.active_text_box {
1347                        show_cursor = (self.loading_tick / 15).is_multiple_of(2);
1348                        if show_cursor {
1349                            self.dirty = true;
1350                            self.dirty_reason = "render.rs:1055";
1351                        }
1352                        cursor_pos = tb.cursor_pos;
1353                        tb.text.clone()
1354                    } else {
1355                        String::new()
1356                    }
1357                } else if !el.element_id.is_empty() {
1358                    self.form_values
1359                        .get(&el.element_id)
1360                        .cloned()
1361                        .unwrap_or(String::new())
1362                } else {
1363                    String::new()
1364                };
1365
1366                if el.is_textarea {
1367                    let lines = Self::wrap_editor_lines(&text_val, el.width, self.editor_wrap);
1368
1369                    let max_scroll_y = (lines.len() as i32 - (el.height / 20) + 1).max(0);
1370                    if self.editor_scroll_y > max_scroll_y {
1371                        self.editor_scroll_y = max_scroll_y;
1372                    }
1373                    if self.editor_scroll_y < 0 {
1374                        self.editor_scroll_y = 0;
1375                    }
1376
1377                    let start_line = self.editor_scroll_y as usize;
1378                    let mut line_y = y0 + 8;
1379
1380                    let cx0 = x0;
1381                    let cy0 = y0;
1382                    let cx1 = x1;
1383                    let cy1 = y1;
1384                    screen.push_clip(cx0, cy0, cx1, cy1);
1385
1386                    if text_val.is_empty() && !el.placeholder.is_empty() {
1387                        screen.draw_string_ja_size_bg(
1388                            x0 + 8,
1389                            y0 + 8,
1390                            &el.placeholder,
1391                            16,
1392                            draw::Color(el.placeholder_color.unwrap_or(theme.editor_dim_fg)),
1393                            draw::Color(bg_col),
1394                        );
1395                    }
1396
1397                    let has_crlf = if is_focused {
1398                        self.active_text_box
1399                            .as_ref()
1400                            .map(|tb| tb.text.contains("\r\n"))
1401                            .unwrap_or(false)
1402                    } else if !el.element_id.is_empty() {
1403                        self.form_values
1404                            .get(&el.element_id)
1405                            .map(|s| s.contains("\r\n"))
1406                            .unwrap_or(false)
1407                    } else {
1408                        false
1409                    };
1410                    let has_cr = if is_focused {
1411                        self.active_text_box
1412                            .as_ref()
1413                            .map(|tb| tb.text.contains('\r'))
1414                            .unwrap_or(false)
1415                    } else if !el.element_id.is_empty() {
1416                        self.form_values
1417                            .get(&el.element_id)
1418                            .map(|s| s.contains('\r'))
1419                            .unwrap_or(false)
1420                    } else {
1421                        false
1422                    };
1423
1424                    let newline_mark = if has_crlf {
1425                        "↵"
1426                    } else if has_cr {
1427                        "←"
1428                    } else {
1429                        "↓"
1430                    };
1431
1432                    let filename = self
1433                        .form_values
1434                        .get("filename")
1435                        .map(|s| s.as_str())
1436                        .unwrap_or("Untitled");
1437                    let end_marker = if filename == "Untitled" {
1438                        "[EOB]"
1439                    } else {
1440                        "[EOF]"
1441                    };
1442
1443                    let total_chars = text_val.chars().count();
1444                    let mut global_char_idx = 0;
1445                    for i in 0..lines.len() {
1446                        let (line_text, is_phys_end) = &lines[i];
1447                        let is_visible = i >= start_line && line_y + 16 <= y1;
1448                        let draw_x = if self.editor_wrap {
1449                            x0 + 8
1450                        } else {
1451                            (x0 as i32 + 8 - self.editor_scroll_x) as u32
1452                        };
1453
1454                        let line_start_idx = global_char_idx;
1455                        let line_len = line_text.chars().count();
1456                        let has_newline = *is_phys_end && (line_start_idx + line_len < total_chars);
1457                        let line_end_idx =
1458                            line_start_idx + line_len + (if has_newline { 1 } else { 0 });
1459
1460                        let is_last_line = i == lines.len() - 1;
1461                        let is_cursor_line = is_focused
1462                            && ((cursor_pos >= line_start_idx && cursor_pos < line_end_idx)
1463                                || (is_last_line && cursor_pos == line_end_idx));
1464
1465                        if is_cursor_line && is_visible {
1466                            // 下線を描画(テキストエリアの左マージン2pxからスクロールバー手前まで)
1467                            screen.boxfill(
1468                                x0 + 2,
1469                                line_y + 18,
1470                                x1.saturating_sub(8),
1471                                line_y + 19,
1472                                draw::Color(theme.editor_line_highlight),
1473                            );
1474                        }
1475
1476                        let mut cur_x = draw_x;
1477                        for c in line_text.chars() {
1478                            if is_focused
1479                                && global_char_idx == cursor_pos
1480                                && is_visible
1481                                && show_cursor
1482                            {
1483                                // 縦棒カーソル。半角=青系 / 全角(FEP)=赤系。
1484                                screen.boxfill(
1485                                    cur_x,
1486                                    line_y,
1487                                    cur_x + 1,
1488                                    line_y + 16,
1489                                    draw::Color(el.caret_color.unwrap_or_else(crate::kernel::ime::cursor_color)),
1490                                );
1491                            }
1492
1493                            let cw = crate::kernel::vector_font::get_vector_char_width(c, 16);
1494                            if is_visible {
1495                                let (draw_char, char_color) = match c {
1496                                    ' ' => ('_', draw::Color(theme.editor_dim_fg)),
1497                                    '\u{3000}' => ('□', draw::Color(theme.editor_dim_fg)),
1498                                    '\t' => ('⇥', draw::Color(theme.editor_dim_fg)),
1499                                    _ => (c, draw::Color(theme.form_input_fg)),
1500                                };
1501                                let s_char = String::from(draw_char);
1502                                screen.draw_string_ja_size_bg(
1503                                    cur_x,
1504                                    line_y,
1505                                    &s_char,
1506                                    16,
1507                                    char_color,
1508                                    draw::Color(bg_col),
1509                                );
1510                            }
1511                            cur_x += cw;
1512                            global_char_idx += 1;
1513                        }
1514
1515                        if is_focused && global_char_idx == cursor_pos && is_visible && show_cursor
1516                        {
1517                            screen.boxfill(
1518                                cur_x,
1519                                line_y,
1520                                cur_x + 1,
1521                                line_y + 16,
1522                                draw::Color(el.caret_color.unwrap_or_else(crate::kernel::ime::cursor_color)),
1523                            );
1524                        }
1525
1526                        if *is_phys_end {
1527                            if is_visible {
1528                                screen.draw_string_ja_size_bg(
1529                                    cur_x,
1530                                    line_y,
1531                                    newline_mark,
1532                                    16,
1533                                    draw::Color(theme.editor_dim_fg),
1534                                    draw::Color(bg_col),
1535                                );
1536                            }
1537                            if let Some(mark_ch) = newline_mark.chars().next() {
1538                                cur_x +=
1539                                    crate::kernel::vector_font::get_vector_char_width(mark_ch, 16);
1540                            }
1541                            if global_char_idx < total_chars {
1542                                global_char_idx += 1;
1543                            }
1544                        }
1545
1546                        if i == lines.len() - 1 && is_visible {
1547                            screen.draw_string_ja_size_bg(
1548                                cur_x,
1549                                line_y,
1550                                end_marker,
1551                                16,
1552                                draw::Color(theme.editor_dim_fg),
1553                                draw::Color(bg_col),
1554                            );
1555                        }
1556
1557                        if is_visible {
1558                            line_y += 20;
1559                        }
1560                    }
1561
1562                    screen.pop_clip();
1563
1564                    // textarea のスクロールバー(行単位スクロールを px に換算して共通ヘルパーで描画)
1565                    let content_px = lines.len() as i32 * 20 + 16;
1566                    screen.draw_vscrollbar(
1567                        x1.saturating_sub(8),
1568                        y0 as i32,
1569                        el.height,
1570                        content_px,
1571                        el.height,
1572                        self.editor_scroll_y * 20,
1573                    );
1574                } else {
1575                    // 単一行 input: テキストとカーソル(縦棒)を描画する。
1576                    // カーソル色は半角=青系 / 全角(FEP)=赤系。
1577                    // 単一行ではカーソル行表示(下線)は行わない(マルチライン textarea のみ)。
1578                    let text_y = y0 + (el.height as u32 - 16) / 2;
1579                    screen.push_clip(x0, y0, x1, y1);
1580                    // 値が空なら placeholder 属性のテキストを薄色で表示する(未フォーカス/フォーカス
1581                    // どちらでも、実際の値が入力されるまで表示し続けるのが標準ブラウザの挙動)。
1582                    if text_val.is_empty() && !el.placeholder.is_empty() {
1583                        screen.draw_string_ja_size_bg(
1584                            x0 + 8,
1585                            text_y,
1586                            &el.placeholder,
1587                            16,
1588                            draw::Color(el.placeholder_color.unwrap_or(theme.editor_dim_fg)),
1589                            draw::Color(bg_col),
1590                        );
1591                    }
1592                    let mut cur_x = x0 + 8;
1593                    let mut idx = 0usize;
1594                    for c in text_val.chars() {
1595                        if is_focused && idx == cursor_pos && show_cursor {
1596                            screen.boxfill(
1597                                cur_x,
1598                                text_y,
1599                                cur_x + 1,
1600                                text_y + 16,
1601                                draw::Color(el.caret_color.unwrap_or_else(crate::kernel::ime::cursor_color)),
1602                            );
1603                        }
1604                        let cw = crate::kernel::vector_font::get_vector_char_width(c, 16);
1605                        screen.draw_string_ja_size_bg(
1606                            cur_x,
1607                            text_y,
1608                            &String::from(c),
1609                            16,
1610                            draw::Color(theme.form_input_fg),
1611                            draw::Color(bg_col),
1612                        );
1613                        cur_x += cw;
1614                        idx += 1;
1615                    }
1616                    if is_focused && idx == cursor_pos && show_cursor {
1617                        screen.boxfill(
1618                            cur_x,
1619                            text_y,
1620                            cur_x + 1,
1621                            text_y + 16,
1622                            draw::Color(el.caret_color.unwrap_or_else(crate::kernel::ime::cursor_color)),
1623                        );
1624                    }
1625                    screen.pop_clip();
1626                }
1627            } else if el.is_button {
1628                let is_hovered = self.mouse_y >= draw_y as i32
1629                    && self.mouse_y <= (draw_y + el.height as u32) as i32
1630                    && self.mouse_x >= (draw_x_base)
1631                    && self.mouse_x <= (draw_x_base + el.width);
1632                let is_focused = !el.element_id.is_empty()
1633                    && self.focused_id.as_deref() == Some(el.element_id.as_str());
1634                // :active はホバー中かつマウス左ボタン押下中の要素にのみ適用(実ブラウザの
1635                // LVHA 順に合わせ hover よりも優先度を高くする)。
1636                let is_active = is_hovered && self.mouse_btn_left_down;
1637                // 適用順: base → :focus → :hover → :active(:active を最終上書きとする。
1638                // カスケード順の厳密な再現ではなく、実用上の即時フィードバックを優先する簡易規則)。
1639                let base_bg = el.bg_color.unwrap_or(theme.btn_default_bg);
1640                let bg_col = if is_active {
1641                    el.active_bg_color.unwrap_or(base_bg)
1642                } else if is_hovered {
1643                    el.hover_bg_color.unwrap_or(base_bg)
1644                } else if is_focused {
1645                    el.focus_bg_color.unwrap_or(base_bg)
1646                } else {
1647                    base_bg
1648                };
1649                let base_border = el.border_color.unwrap_or(theme.btn_default_border);
1650                let border_col = if is_active {
1651                    el.active_border_color.unwrap_or(base_border)
1652                } else if is_hovered {
1653                    el.hover_border_color.unwrap_or(base_border)
1654                } else if is_focused {
1655                    el.focus_border_color.unwrap_or(base_border)
1656                } else {
1657                    base_border
1658                };
1659
1660                let x0 = draw_x_base as u32;
1661                let y0 = draw_y;
1662                let x1 = draw_x_base as u32 + el.width as u32;
1663                let y1 = draw_y + el.height as u32;
1664
1665                if el.border_radius != (0, 0, 0, 0) {
1666                    screen.draw_rounded_rect_fill(
1667                        x0 as i32,
1668                        y0 as i32,
1669                        x1 as i32,
1670                        y1 as i32,
1671                        el.border_radius,
1672                        draw::Color(bg_col),
1673                    );
1674                    screen.draw_rounded_rect_border(
1675                        x0 as i32,
1676                        y0 as i32,
1677                        x1 as i32,
1678                        y1 as i32,
1679                        el.border_radius,
1680                        draw::Color(border_col),
1681                        el.border_style.as_str(),
1682                    );
1683                } else {
1684                    screen.boxfill(x0, y0, x1, y1, draw::Color(bg_col));
1685                    draw_border_with_style(
1686                        screen,
1687                        x0 as i32,
1688                        y0 as i32,
1689                        x1 as i32,
1690                        y1 as i32,
1691                        border_col,
1692                        el.border_style,
1693                    );
1694                }
1695
1696                let has_icon = el.button_icon > 0;
1697                let icon_size = 16;
1698                let icon_spacing = 6;
1699
1700                let text_w =
1701                    crate::kernel::vector_font::get_vector_string_width(&el.text, el.font_size)
1702                        as i32;
1703                let content_w = text_w
1704                    + if has_icon {
1705                        (icon_size + icon_spacing) as i32
1706                    } else {
1707                        0
1708                    };
1709
1710                let draw_content_x = x0 + ((el.width - content_w).max(0) as u32) / 2;
1711                let draw_text_y = y0 + ((el.height - el.font_size as i32).max(0) as u32) / 2;
1712
1713                let mut draw_text_x = draw_content_x;
1714
1715                if has_icon {
1716                    let icon_y = y0 + ((el.height - icon_size as i32).max(0) as u32) / 2;
1717                    let svg_data = match el.button_icon {
1718                        1 => SVG_SAVE,
1719                        2 => SVG_LOAD,
1720                        3 => SVG_WRAP,
1721                        _ => &[],
1722                    };
1723                    if !svg_data.is_empty() {
1724                        screen.draw_svg_icon(
1725                            draw_content_x,
1726                            icon_y,
1727                            svg_data,
1728                            icon_size,
1729                            draw::Color(theme.success_fg),
1730                        );
1731                    }
1732                    draw_text_x += icon_size + icon_spacing;
1733                }
1734
1735                screen.draw_string_ja_size_bg(
1736                    draw_text_x,
1737                    draw_text_y,
1738                    &el.text,
1739                    el.font_size,
1740                    draw::Color(theme.browser_fg),
1741                    draw::Color(bg_col),
1742                );
1743            } else {
1744                let is_hovered = self.mouse_y >= draw_y as i32
1745                    && self.mouse_y <= (draw_y + eff_h as u32) as i32
1746                    && self.mouse_x >= (draw_x_base)
1747                    && self.mouse_x <= (draw_x_base + eff_w);
1748                let is_focused = !el.element_id.is_empty()
1749                    && self.focused_id.as_deref() == Some(el.element_id.as_str());
1750                // :active はホバー中かつマウス左ボタン押下中の要素にのみ適用(is_button
1751                // 分岐と同じ規則)。
1752                let is_active = is_hovered && self.mouse_btn_left_down;
1753
1754                // 適用順: base → :focus → :hover → :active(:active を最終上書きとする
1755                // 簡易規則。詳細は is_button 分岐のコメント参照)。
1756                let actual_bg_color = if is_active {
1757                    el.active_bg_color.or(el.bg_color)
1758                } else if is_hovered {
1759                    el.hover_bg_color.or(el.bg_color)
1760                } else if is_focused {
1761                    el.focus_bg_color.or(el.bg_color)
1762                } else {
1763                    el.bg_color
1764                }
1765                .map(|c| super::draw_helpers::apply_css_filters_argb(&el.filter, c));
1766                let actual_color_raw = if is_active {
1767                    el.active_color.unwrap_or(el.color)
1768                } else if is_hovered {
1769                    el.hover_color.unwrap_or(el.color)
1770                } else if is_focused {
1771                    el.focus_color.unwrap_or(el.color)
1772                } else {
1773                    el.color
1774                };
1775                // Apply opacity to color channels
1776                let actual_color = if el.opacity < 255 {
1777                    let a = el.opacity as u32;
1778                    let r = (actual_color_raw >> 16) & 0xFF;
1779                    let g = (actual_color_raw >> 8) & 0xFF;
1780                    let b = actual_color_raw & 0xFF;
1781                    (a << 24) | (r << 16) | (g << 8) | b
1782                } else {
1783                    actual_color_raw
1784                };
1785                let w = if eff_w > 0 {
1786                    eff_w
1787                } else {
1788                    vector_font::get_vector_string_width(&el.text, el.font_size) as i32
1789                };
1790
1791                let x0_rect = (draw_x_base as u32) as i32;
1792                let y0_rect = draw_y as i32;
1793                let x1_rect = (draw_x_base as u32 + w as u32) as i32;
1794                let y1_rect = (draw_y + eff_h as u32) as i32;
1795
1796                // CSS `backdrop-filter`(グラスモーフィズム Glassmorphism 効果)。
1797                // 背景要素を反転/ぼかし/色彩強調し、モダンなガラス風ビジュアルを再現。
1798                if !el.backdrop_filter.is_empty() {
1799                    let (cx0, cy0, cx1, cy1) = screen
1800                        .clip
1801                        .get()
1802                        .map(|(c0, c1, c2, c3)| (c0 as i32, c1 as i32, c2 as i32, c3 as i32))
1803                        .unwrap_or((0, 0, screen.width as i32, screen.height as i32));
1804                    let bx0 = x0_rect.max(cx0);
1805                    let by0 = y0_rect.max(cy0);
1806                    let bx1 = x1_rect.min(cx1);
1807                    let by1 = y1_rect.min(cy1);
1808                    if bx1 > bx0 && by1 > by0 {
1809                        crate::os_lib::web_engine::draw_helpers::apply_backdrop_filters_to_screen_rect(
1810                            screen, bx0, by0, bx1, by1, &el.backdrop_filter,
1811                        );
1812                    }
1813                }
1814
1815                // Draw outer box-shadow before background with corner radius support.
1816                // カンマ区切りの複数シャドウ対応: Vec を逆順に走査して先頭のシャドウが一番上に来るようにする。
1817                for &(ox, oy, _blur, s_color, is_inset) in el.box_shadow.iter().rev() {
1818                    if !is_inset {
1819                        screen.draw_shadow_rect_rounded(
1820                            x0_rect,
1821                            y0_rect,
1822                            x1_rect,
1823                            y1_rect,
1824                            ox,
1825                            oy,
1826                            el.border_radius,
1827                            draw::Color(s_color),
1828                        );
1829                    }
1830                }
1831
1832                // clip-path: parse once for background and bg-image drawing
1833                let clip_s = if el.clip_path.is_empty() {
1834                    None
1835                } else {
1836                    parse_clip_shape(&el.clip_path, w, eff_h)
1837                };
1838
1839                // rotate 付き背景: 逆変換サンプリングで回転矩形を描く。
1840                // rotate ≠ 0 かつ bg_color あり の場合のみ別処理。テキストは非回転。
1841                let rotate_deg = el.transform_rotate_deg;
1842                if rotate_deg.abs() > 0.1 {
1843                    if let Some(bg) = actual_bg_color {
1844                        let cx = x0_rect + w / 2;
1845                        let cy = y0_rect + eff_h / 2;
1846                        draw_rotated_rect_filled(screen, cx, cy, w, eff_h, rotate_deg, bg);
1847                    }
1848                }
1849                let skew_x = el.transform_skew_x;
1850                let skew_y = el.transform_skew_y;
1851                if skew_x.abs() > 0.1 || skew_y.abs() > 0.1 {
1852                    if let Some(bg) = actual_bg_color {
1853                        let cx = x0_rect + w / 2;
1854                        let cy = y0_rect + eff_h / 2;
1855                        draw_skewed_rect_filled(screen, cx, cy, w, eff_h, skew_x, skew_y, bg);
1856                    }
1857                }
1858
1859                if el.border_radius != (0, 0, 0, 0) {
1860                    // background-clip: border-box 以外なら背景描画だけを内側の box に制限する
1861                    // (境界線自体はこの後 border-box のまま描くため、border 描画前に pop する)。
1862                    let bg_clipped = el.bg_clip != "border-box";
1863                    if bg_clipped {
1864                        let (cx0, cy0, cx1, cy1) = bg_clip_rect(el, x0_rect, y0_rect, x1_rect, y1_rect);
1865                        screen.push_clip(cx0, cy0, cx1, cy1);
1866                    }
1867                    // clip-path + border-radius の組み合わせ: 両マスクを満たすピクセルのみ描画。
1868                    if clip_s.is_some() {
1869                        let bg_col = actual_bg_color
1870                            .or(el.linear_gradient.as_ref().map(|g| g.stops.first().map_or(0, |s| s.color)))
1871                            .or(el.radial_gradient.as_ref().map(|g| g.stops.first().map_or(0, |s| s.color)))
1872                            .or(el.conic_gradient.as_ref().and_then(|stops| stops.first().map(|s| s.color)))
1873                            .or(el.repeating_linear_gradient.as_ref().and_then(|(colors, _, _)| colors.first().copied()))
1874                            .or(el.repeating_radial_gradient.as_ref().and_then(|(colors, _)| colors.first().copied()));
1875                        if let Some(bg) = bg_col {
1876                            let bg_with_opacity = if el.opacity < 255 {
1877                                let a = el.opacity as u32;
1878                                let r = (bg >> 16) & 0xFF;
1879                                let g = (bg >> 8) & 0xFF;
1880                                let b = bg & 0xFF;
1881                                (a << 24) | (r << 16) | (g << 8) | b
1882                            } else { bg };
1883                            // 【2026-08-01計測】角丸/クリップ付き背景は
1884                            // ボックス全体を毎ピクセル判定しながら塗っている。
1885                            // これが paint の残り 58% の正体かを測る。
1886                            let _t_rr = crate::kernel::draw::RRectTimer::new();
1887                            for ry in 0..eff_h {
1888                                for rx in 0..w {
1889                                    let in_clip = clip_s.as_ref().map(|cs| clip_inside(cs, rx, ry)).unwrap_or(true);
1890                                    let in_rr = point_in_rounded_rect(rx, ry, w, eff_h, el.border_radius);
1891                                    if in_clip && in_rr {
1892                                        screen.draw_pixel_alpha(
1893                                            (x0_rect + rx) as u32,
1894                                            (y0_rect + ry) as u32,
1895                                            draw::Color(bg_with_opacity),
1896                                            el.opacity,
1897                                        );
1898                                    }
1899                                }
1900                            }
1901                        }
1902                    } else {
1903                    // linear-gradient takes priority over solid bg
1904                    if let Some(grad) = &el.linear_gradient {
1905                        let c1 = grad.stops.first().map_or(0, |s| s.color);
1906                        // 【2026-07-28】従来は角丸の有無に関わらず**最初の色停止点の単色**で
1907                        // 塗っており、linear-gradient が実質グラデーションになっていなかった。
1908                        // 角丸が無い場合は本物のグラデーションを描く。
1909                        // (角丸との組み合わせは未対応のまま単色にフォールバックする。)
1910                        let c2 = grad.stops.last().map_or(c1, |s| s.color);
1911                        let is_vertical = {
1912                            // 角度 90/270 付近を横方向、それ以外を縦方向として近似する。
1913                            // 135deg のような斜めは縦方向で近似(実装が縦横のみ対応のため)。
1914                            let a = ((grad.angle_deg % 360.0) + 360.0) % 360.0;
1915                            !((45.0..135.0).contains(&a) || (225.0..315.0).contains(&a))
1916                        };
1917                        if el.border_radius == (0, 0, 0, 0) && grad.stops.len() >= 2 && x1_rect > x0_rect && y1_rect > y0_rect {
1918                            screen.draw_gradient_rect(
1919                                x0_rect as u32,
1920                                y0_rect as u32,
1921                                x1_rect as u32,
1922                                y1_rect as u32,
1923                                draw::Color(c1),
1924                                draw::Color(c2),
1925                                is_vertical,
1926                            );
1927                        } else {
1928                            screen.draw_rounded_rect_fill(
1929                                x0_rect,
1930                                y0_rect,
1931                                x1_rect,
1932                                y1_rect,
1933                                el.border_radius,
1934                                draw::Color(c1),
1935                            );
1936                        }
1937                    } else if let Some(grad) = &el.radial_gradient {
1938                        // 角丸との組み合わせは非対応につき、linear-gradient と同様 c1 を単色として使う簡略実装。
1939                        let c1 = grad.stops.first().map_or(0, |s| s.color);
1940                        screen.draw_rounded_rect_fill(
1941                            x0_rect,
1942                            y0_rect,
1943                            x1_rect,
1944                            y1_rect,
1945                            el.border_radius,
1946                            draw::Color(c1),
1947                        );
1948                    } else if let Some(stops) = &el.conic_gradient {
1949                        // 角丸との組み合わせは非対応につき、他のグラデーションと同様 先頭色を単色として使う簡略実装。
1950                        let c1 = stops.first().map_or(0, |s| s.color);
1951                        screen.draw_rounded_rect_fill(
1952                            x0_rect,
1953                            y0_rect,
1954                            x1_rect,
1955                            y1_rect,
1956                            el.border_radius,
1957                            draw::Color(c1),
1958                        );
1959                    } else if let Some((colors, _, _)) = &el.repeating_linear_gradient {
1960                        // 角丸との組み合わせは非対応につき、他のグラデーションと同様 先頭色を単色として使う簡略実装。
1961                        let c1 = colors.first().copied().unwrap_or(0);
1962                        screen.draw_rounded_rect_fill(
1963                            x0_rect,
1964                            y0_rect,
1965                            x1_rect,
1966                            y1_rect,
1967                            el.border_radius,
1968                            draw::Color(c1),
1969                        );
1970                    } else if let Some((colors, _)) = &el.repeating_radial_gradient {
1971                        // 角丸との組み合わせは非対応につき、他のグラデーションと同様 先頭色を単色として使う簡略実装。
1972                        let c1 = colors.first().copied().unwrap_or(0);
1973                        screen.draw_rounded_rect_fill(
1974                            x0_rect,
1975                            y0_rect,
1976                            x1_rect,
1977                            y1_rect,
1978                            el.border_radius,
1979                            draw::Color(c1),
1980                        );
1981                    } else if let Some(bg) = actual_bg_color {
1982                        let bg_with_opacity = if el.opacity < 255 {
1983                            let a = el.opacity as u32;
1984                            let r = (bg >> 16) & 0xFF;
1985                            let g = (bg >> 8) & 0xFF;
1986                            let b = bg & 0xFF;
1987                            (a << 24) | (r << 16) | (g << 8) | b
1988                        } else {
1989                            bg
1990                        };
1991                        if rotate_deg.abs() < 0.1 {
1992                            screen.draw_rounded_rect_fill(
1993                                x0_rect,
1994                                y0_rect,
1995                                x1_rect,
1996                                y1_rect,
1997                                el.border_radius,
1998                                draw::Color(bg_with_opacity),
1999                            );
2000                        }
2001                    }
2002                    } // end else (no clip_s)
2003                    // background-attachment: fixed のスクロール打ち消し量(scroll なら常に0)。
2004                    let bg_attach_shift_y = if el.bg_attachment == "fixed" {
2005                        self.scroll_y
2006                    } else {
2007                        0
2008                    };
2009                    // background-origin: position/size の基準 box(既定 padding-box)。
2010                    let (bg_origin_sx, bg_origin_sy, bg_origin_w, bg_origin_h) =
2011                        bg_origin_shift(el, eff_w, eff_h);
2012                    if el.bg_images.len() > 1 {
2013                        // 複数レイヤー: CSS の重なり順(先頭が最前面)どおりに描くため、
2014                        // 背面(リスト末尾)から順に描画して先頭を最後に重ねる。
2015                        // レイヤーごとの size/repeat/position は `bg_layers`(インデックス対応)を使う。
2016                        for (i, layer_path) in el.bg_images.iter().enumerate().rev() {
2017                            if self.image_cache.get(layer_path).is_none() {
2018                                log_bg_image_miss(layer_path, self.image_cache.len(), "layer");
2019                            }
2020                            if let Some(img) = self.image_cache.get(layer_path) {
2021                                let layer_override = el.bg_layers.get(i).copied();
2022                                draw_background_image(
2023                                    screen, img, el, x0_rect, y0_rect, eff_w, eff_h, &clip_s,
2024                                    bg_attach_shift_y, bg_origin_sx, bg_origin_sy, bg_origin_w,
2025                                    bg_origin_h, layer_override,
2026                                );
2027                            }
2028                        }
2029                    } else if let Some(ref bg_img_path) = el.bg_image {
2030                        // 【2026-08-02】キャッシュに無いとき**何も起きず何も出ない**ため、
2031                        // 「取得できていない」のか「取得できたのにキーが合わない」のかを
2032                        // 切り分けられなかった。実際 `/img/IMG_6530.jpg` は
2033                        // 取得・デコード成功しているのに背景が出ていない。
2034                        // パスごとに 1 回だけ記録する(`spec/logging_policy.md` L-1)。
2035                        if self.image_cache.get(bg_img_path).is_none() {
2036                            static MISS: spin::Mutex<alloc::collections::BTreeSet<String>> =
2037                                spin::Mutex::new(alloc::collections::BTreeSet::new());
2038                            let mut seen = MISS.lock();
2039                            if seen.insert(bg_img_path.clone()) {
2040                                crate::warn!(
2041                                    "[IMG][MISS] 背景画像がキャッシュに無い path={} cache_keys={}",
2042                                    bg_img_path,
2043                                    self.image_cache.len()
2044                                );
2045                            }
2046                        }
2047                        if let Some(img) = self.image_cache.get(bg_img_path) {
2048                            draw_background_image(
2049                                screen, img, el, x0_rect, y0_rect, eff_w, eff_h, &clip_s,
2050                                bg_attach_shift_y, bg_origin_sx, bg_origin_sy, bg_origin_w,
2051                                bg_origin_h, None,
2052                            );
2053                        }
2054                    }
2055                    if bg_clipped {
2056                        screen.pop_clip();
2057                    }
2058                    // Draw inset box-shadow inside border-box if specified
2059                    for &(ox, oy, blur, s_color, is_inset) in el.box_shadow.iter().rev() {
2060                        if is_inset {
2061                            screen.draw_inset_shadow_rect(
2062                                x0_rect,
2063                                y0_rect,
2064                                x1_rect,
2065                                y1_rect,
2066                                ox,
2067                                oy,
2068                                blur,
2069                                el.border_radius,
2070                                draw::Color(s_color),
2071                            );
2072                        }
2073                    }
2074
2075                    let actual_border_color = if is_active {
2076                        el.active_border_color.or(el.border_color)
2077                    } else if is_hovered {
2078                        el.hover_border_color.or(el.border_color)
2079                    } else if is_focused {
2080                        el.focus_border_color.or(el.border_color)
2081                    } else {
2082                        el.border_color
2083                    };
2084                    if let Some(border_col) = actual_border_color {
2085                        screen.draw_rounded_rect_border(
2086                            x0_rect,
2087                            y0_rect,
2088                            x1_rect,
2089                            y1_rect,
2090                            el.border_radius,
2091                            draw::Color(border_col),
2092                            el.border_style.as_str(),
2093                        );
2094                    }
2095                } else {
2096                    // background-clip: border-box 以外なら背景描画だけを内側の box に制限する
2097                    // (境界線自体は border-box のまま描くため、border 描画前に pop する)。
2098                    let bg_clipped = el.bg_clip != "border-box";
2099                    if bg_clipped {
2100                        let (cx0, cy0, cx1, cy1) = bg_clip_rect(el, x0_rect, y0_rect, x1_rect, y1_rect);
2101                        screen.push_clip(cx0, cy0, cx1, cy1);
2102                    }
2103                    // linear-gradient / radial-gradient
2104                    if let Some(grad) = &el.linear_gradient {
2105                        if rotate_deg.abs() < 0.1 {
2106                            crate::os_lib::web_engine::draw_helpers::draw_linear_gradient(
2107                                screen,
2108                                x0_rect,
2109                                y0_rect,
2110                                x1_rect - x0_rect,
2111                                y1_rect - y0_rect,
2112                                grad,
2113                            );
2114                        }
2115                    } else if let Some(grad) = &el.radial_gradient {
2116                        if rotate_deg.abs() < 0.1 {
2117                            crate::os_lib::web_engine::draw_helpers::draw_radial_gradient(
2118                                screen,
2119                                x0_rect,
2120                                y0_rect,
2121                                x1_rect - x0_rect,
2122                                y1_rect - y0_rect,
2123                                grad,
2124                            );
2125                        }
2126                    } else if let Some(stops) = &el.conic_gradient {
2127                        if rotate_deg.abs() < 0.1 {
2128                            crate::os_lib::web_engine::draw_helpers::draw_conic_gradient(
2129                                screen,
2130                                x0_rect,
2131                                y0_rect,
2132                                x1_rect - x0_rect,
2133                                y1_rect - y0_rect,
2134                                stops,
2135                            );
2136                        }
2137                    } else if let Some((colors, cycle, is_vert)) = &el.repeating_linear_gradient {
2138                        if rotate_deg.abs() < 0.1 {
2139                            let draw_colors: alloc::vec::Vec<draw::Color> =
2140                                colors.iter().map(|c| draw::Color(*c)).collect();
2141                            screen.draw_repeating_linear_gradient_rect(
2142                                x0_rect as u32,
2143                                y0_rect as u32,
2144                                x1_rect as u32,
2145                                y1_rect as u32,
2146                                &draw_colors,
2147                                *cycle,
2148                                *is_vert,
2149                            );
2150                        }
2151                    } else if let Some((colors, cycle)) = &el.repeating_radial_gradient {
2152                        if rotate_deg.abs() < 0.1 {
2153                            let draw_colors: alloc::vec::Vec<draw::Color> =
2154                                colors.iter().map(|c| draw::Color(*c)).collect();
2155                            screen.draw_repeating_radial_gradient_rect(
2156                                x0_rect as u32,
2157                                y0_rect as u32,
2158                                x1_rect as u32,
2159                                y1_rect as u32,
2160                                &draw_colors,
2161                                *cycle,
2162                            );
2163                        }
2164                    } else if let Some(bg) = actual_bg_color {
2165                        let bg_with_opacity = if el.opacity < 255 {
2166                            let a = el.opacity as u32;
2167                            let r = (bg >> 16) & 0xFF;
2168                            let g = (bg >> 8) & 0xFF;
2169                            let b = bg & 0xFF;
2170                            (a << 24) | (r << 16) | (g << 8) | b
2171                        } else {
2172                            bg
2173                        };
2174                        // rotate あり: 既に draw_rotated_rect_filled で描画済み → skip
2175                        if rotate_deg.abs() < 0.1 {
2176                            let _t_pix = crate::kernel::draw::PixBgTimer::new();
2177                            if let Some(ref cs) = clip_s {
2178                                // clip-path: per-pixel rendering
2179                                for ry in 0..eff_h {
2180                                    for rx in 0..w {
2181                                        if clip_inside(cs, rx, ry) {
2182                                            screen.draw_pixel_alpha(
2183                                                (x0_rect + rx) as u32,
2184                                                (y0_rect + ry) as u32,
2185                                                draw::Color(bg_with_opacity),
2186                                                el.opacity,
2187                                            );
2188                                        }
2189                                    }
2190                                }
2191                            } else if el.opacity < 255 {
2192                                // Alpha-blend background
2193                                for ry in y0_rect as u32..y1_rect as u32 {
2194                                    for rx in x0_rect as u32..x1_rect as u32 {
2195                                        screen.draw_pixel_alpha(
2196                                            rx,
2197                                            ry,
2198                                            draw::Color(bg_with_opacity),
2199                                            el.opacity,
2200                                        );
2201                                    }
2202                                }
2203                            } else if !el.mix_blend_mode.is_empty()
2204                                && el.mix_blend_mode != "normal"
2205                            {
2206                                // mix-blend-mode: 背景色を既存フレームバッファの色と合成する
2207                                // 簡略実装(クリップ無し・不透明・角丸無しの経路のみ対応)。
2208                                let src = (
2209                                    ((bg >> 16) & 0xFF) as u8,
2210                                    ((bg >> 8) & 0xFF) as u8,
2211                                    (bg & 0xFF) as u8,
2212                                );
2213                                for ry in y0_rect as u32..y1_rect as u32 {
2214                                    for rx in x0_rect as u32..x1_rect as u32 {
2215                                        let dst_c = screen.read_pixel(rx, ry).0;
2216                                        let dst = (
2217                                            ((dst_c >> 16) & 0xFF) as u8,
2218                                            ((dst_c >> 8) & 0xFF) as u8,
2219                                            (dst_c & 0xFF) as u8,
2220                                        );
2221                                        let (r, g, b) =
2222                                            blend_pixel(&el.mix_blend_mode, src, dst);
2223                                        screen.draw_pixel(
2224                                            rx,
2225                                            ry,
2226                                            draw::Color(
2227                                                0xFF000000
2228                                                    | ((r as u32) << 16)
2229                                                    | ((g as u32) << 8)
2230                                                    | (b as u32),
2231                                            ),
2232                                        );
2233                                    }
2234                                }
2235                            } else {
2236                                screen.boxfill(
2237                                    x0_rect as u32,
2238                                    y0_rect as u32,
2239                                    x1_rect as u32,
2240                                    y1_rect as u32,
2241                                    draw::Color(bg),
2242                                );
2243                            }
2244                        }
2245                    }
2246                    // background-attachment: fixed のスクロール打ち消し量(scroll なら常に0)。
2247                    let bg_attach_shift_y = if el.bg_attachment == "fixed" {
2248                        self.scroll_y
2249                    } else {
2250                        0
2251                    };
2252                    // background-origin: position/size の基準 box(既定 padding-box)。
2253                    let (bg_origin_sx, bg_origin_sy, bg_origin_w, bg_origin_h) =
2254                        bg_origin_shift(el, eff_w, eff_h);
2255                    if el.bg_images.len() > 1 {
2256                        // 複数レイヤー: CSS の重なり順(先頭が最前面)どおりに描くため、
2257                        // 背面(リスト末尾)から順に描画して先頭を最後に重ねる。
2258                        // レイヤーごとの size/repeat/position は `bg_layers`(インデックス対応)を使う。
2259                        for (i, layer_path) in el.bg_images.iter().enumerate().rev() {
2260                            if self.image_cache.get(layer_path).is_none() {
2261                                log_bg_image_miss(layer_path, self.image_cache.len(), "layer");
2262                            }
2263                            if let Some(img) = self.image_cache.get(layer_path) {
2264                                let layer_override = el.bg_layers.get(i).copied();
2265                                draw_background_image(
2266                                    screen, img, el, x0_rect, y0_rect, eff_w, eff_h, &clip_s,
2267                                    bg_attach_shift_y, bg_origin_sx, bg_origin_sy, bg_origin_w,
2268                                    bg_origin_h, layer_override,
2269                                );
2270                            }
2271                        }
2272                    } else if let Some(ref bg_img_path) = el.bg_image {
2273                        if let Some(img) = self.image_cache.get(bg_img_path) {
2274                            draw_background_image(
2275                                screen, img, el, x0_rect, y0_rect, eff_w, eff_h, &clip_s,
2276                                bg_attach_shift_y, bg_origin_sx, bg_origin_sy, bg_origin_w,
2277                                bg_origin_h, None,
2278                            );
2279                        }
2280                    }
2281                    if bg_clipped {
2282                        screen.pop_clip();
2283                    }
2284                    // Draw inset box-shadow inside border-box if specified
2285                    for &(ox, oy, blur, s_color, is_inset) in el.box_shadow.iter().rev() {
2286                        if is_inset {
2287                            screen.draw_inset_shadow_rect(
2288                                x0_rect,
2289                                y0_rect,
2290                                x1_rect,
2291                                y1_rect,
2292                                ox,
2293                                oy,
2294                                blur,
2295                                el.border_radius,
2296                                draw::Color(s_color),
2297                            );
2298                        }
2299                    }
2300                    draw_individual_borders(
2301                        screen, x0_rect, y0_rect, x1_rect, y1_rect, el, is_hovered, is_focused,
2302                        is_active,
2303                    );
2304                    if let Some(outline_color) = el.outline_color {
2305                        if el.outline_width > 0 {
2306                            draw_outline(
2307                                screen,
2308                                x0_rect,
2309                                y0_rect,
2310                                x1_rect,
2311                                y1_rect,
2312                                el.outline_width,
2313                                el.outline_offset,
2314                                outline_color,
2315                                &el.outline_style,
2316                            );
2317                        }
2318                    }
2319                }
2320
2321                if el.is_blockquote {
2322                    let line_x0 = x0_rect as u32;
2323                    let line_y0 = y0_rect as u32;
2324                    let line_x1 = (x0_rect + 4) as u32;
2325                    let line_y1 = y1_rect as u32;
2326                    screen.boxfill(line_x0, line_y0, line_x1, line_y1, draw::Color(theme.sysmon_accent));
2327                }
2328
2329                if !el.text.is_empty()
2330                    || !el.before_content.is_empty()
2331                    || !el.after_content.is_empty()
2332                {
2333                    let mut text_draw_x = draw_x_base as u32;
2334                    let list_style_image_cached = if el.is_li && !el.list_style_image.is_empty() {
2335                        self.image_cache.get(&el.list_style_image)
2336                    } else {
2337                        None
2338                    };
2339                    if let (true, Some(img)) = (el.is_li, list_style_image_cached) {
2340                        // list-style-image: 画像がロード済みなら glyph の代わりにアイコンを描く。
2341                        let size = el.font_size.max(1);
2342                        let marker_x = if el.list_style_inside {
2343                            text_draw_x
2344                        } else {
2345                            text_draw_x.saturating_sub(size + 4)
2346                        };
2347                        crate::os_lib::web_engine::draw_helpers::draw_image_nn_blit(
2348                            screen, img, marker_x as i32, draw_y as i32, size, size,
2349                        );
2350                        if el.list_style_inside {
2351                            text_draw_x += size + 4;
2352                        }
2353                    } else if el.is_li && el.list_style_type != super::ListStyleType::None {
2354                        // マーカーは既定でテキストと同じ色・サイズを継承する(CSS `color`/`font-size`
2355                        // の指定に追従)。`::marker{color:...}`/`::marker{font-size:...}` が
2356                        // 指定されていればそちらを優先する。
2357                        let marker_color = el.marker_color.unwrap_or(el.color);
2358                        let marker_font_size = el.marker_font_size.unwrap_or(el.font_size);
2359                        // decimal/alpha/roman 明示 or 順序リスト(li_num あり)は番号系。それ以外はマーカー文字。
2360                        let is_ordinal_style = matches!(
2361                            el.list_style_type,
2362                            super::ListStyleType::Decimal
2363                                | super::ListStyleType::UpperAlpha
2364                                | super::ListStyleType::LowerAlpha
2365                                | super::ListStyleType::UpperRoman
2366                                | super::ListStyleType::LowerRoman
2367                        );
2368                        let use_number = is_ordinal_style
2369                            || (el.li_num.is_some() && el.list_style_type == super::ListStyleType::Unset);
2370                        let bg_color = el.bg_color.unwrap_or_else(|| self.effective_bg_behind(el));
2371                        let marker_str = if let Some(custom) = &el.marker_content {
2372                            custom.clone()
2373                        } else if use_number {
2374                            let n = el.li_num.unwrap_or(1);
2375                            match el.list_style_type {
2376                                super::ListStyleType::UpperAlpha => {
2377                                    alloc::format!("{}.", super::number_to_alpha(n, true))
2378                                }
2379                                super::ListStyleType::LowerAlpha => {
2380                                    alloc::format!("{}.", super::number_to_alpha(n, false))
2381                                }
2382                                super::ListStyleType::UpperRoman => {
2383                                    alloc::format!("{}.", super::number_to_roman(n, true))
2384                                }
2385                                super::ListStyleType::LowerRoman => {
2386                                    alloc::format!("{}.", super::number_to_roman(n, false))
2387                                }
2388                                _ => alloc::format!("{}.", n),
2389                            }
2390                        } else {
2391                            match el.list_style_type {
2392                                super::ListStyleType::Circle => alloc::string::String::from("◦"),
2393                                super::ListStyleType::Square => alloc::string::String::from("▪"),
2394                                _ => alloc::string::String::from("•"), // disc / 未指定
2395                            }
2396                        };
2397                        let marker_w = crate::kernel::vector_font::get_vector_string_width(
2398                            &marker_str,
2399                            marker_font_size,
2400                        ) as u32;
2401                        // list-style-position: outside(既定)はマーカーを content の外
2402                        // (左マージン領域)へ、inside は content 内でテキストの直前に描く。
2403                        let marker_x = if el.list_style_inside {
2404                            text_draw_x
2405                        } else {
2406                            text_draw_x.saturating_sub(marker_w + 4)
2407                        };
2408                        screen.draw_string_ja_size_bg(
2409                            marker_x,
2410                            draw_y,
2411                            &marker_str,
2412                            marker_font_size,
2413                            draw::Color(marker_color),
2414                            draw::Color(bg_color),
2415                        );
2416                        if el.list_style_inside {
2417                            text_draw_x += marker_w + 4;
2418                        }
2419                    }
2420
2421                    // ::before / ::after の content を実テキストの前後に合成する。
2422                    // 既存のレイアウト幅計測は el.text のみを基準にしているため、
2423                    // 長い content を付けると box をわずかにはみ出す場合があるが、
2424                    // アイコン/マーカー用途の短い文字列では実用上問題にならない。
2425                    // 【2026-08-05 発見・修正】`::before` の content を本文と連結して
2426                    // **1 つのフォントで**描いていた。
2427                    //
2428                    // Font Awesome のアイコンは `::before { content:"015";
2429                    // font-family:"Font Awesome 7 Free" }` で出す。連結してしまうと
2430                    // 要素本体のフォント(Noto Sans JP)で描かれ、
2431                    // 私用領域の符号位置に字形が無いため **☐ になる**。
2432                    // 実測: `#apps` の一覧先頭が ☐ で表示されていた。
2433                    //
2434                    // `before_font_family` が本体と違うときだけ分けて描く。
2435                    // 同じなら従来どおり連結する(1 回の描画で済ませる)。
2436                    let split_before = !el.before_content.is_empty()
2437                        && !el.before_font_family.is_empty()
2438                        && !el
2439                            .font_family
2440                            .to_lowercase()
2441                            .contains(&el.before_font_family.to_lowercase());
2442                    if split_before {
2443                        screen.draw_string_family(
2444                            text_draw_x,
2445                            draw_y,
2446                            &el.before_content,
2447                            el.font_size,
2448                            draw::Color(el.color),
2449                            el.font_italic,
2450                            draw::Color(el.bg_color.unwrap_or_else(|| self.effective_bg_behind(el))),
2451                            el.letter_spacing,
2452                            el.word_spacing,
2453                            &el.before_font_family,
2454                            el.font_bold,
2455                        );
2456                        text_draw_x += crate::kernel::vector_font::get_vector_string_width(
2457                            &el.before_content,
2458                            el.font_size,
2459                        );
2460                    }
2461                    let text_with_pseudo = if (el.before_content.is_empty() || split_before)
2462                        && el.after_content.is_empty()
2463                    {
2464                        el.text.clone()
2465                    } else if split_before {
2466                        alloc::format!("{}{}", el.text, el.after_content)
2467                    } else {
2468                        alloc::format!("{}{}{}", el.before_content, el.text, el.after_content)
2469                    };
2470
2471                    // text-overflow: ellipsis support
2472                    let draw_text = if el.text_overflow_ellipsis && eff_w > 0 {
2473                        let full_w = crate::kernel::vector_font::get_vector_string_width(
2474                            &text_with_pseudo,
2475                            el.font_size,
2476                        ) as i32;
2477                        if full_w > eff_w {
2478                            let ellipsis = "…";
2479                            let ellipsis_w = crate::kernel::vector_font::get_vector_string_width(
2480                                ellipsis,
2481                                el.font_size,
2482                            ) as i32;
2483                            let max_w = eff_w - ellipsis_w;
2484                            let mut truncated = String::new();
2485                            let mut tw = 0i32;
2486                            // `direction:rtl` では省略記号は先頭(視覚上の左)に出し、
2487                            // 末尾側ではなく先頭側の文字を切り詰める(CSS仕様)。
2488                            if el.text_overflow_rtl {
2489                                for c in text_with_pseudo.chars().rev() {
2490                                    let mut cs = String::new();
2491                                    cs.push(c);
2492                                    let cw = crate::kernel::vector_font::get_vector_string_width(
2493                                        &cs,
2494                                        el.font_size,
2495                                    ) as i32;
2496                                    if tw + cw > max_w {
2497                                        break;
2498                                    }
2499                                    truncated.push(c);
2500                                    tw += cw;
2501                                }
2502                                let kept: String = truncated.chars().rev().collect();
2503                                alloc::format!("{}{}", ellipsis, kept)
2504                            } else {
2505                                for c in text_with_pseudo.chars() {
2506                                    let mut cs = String::new();
2507                                    cs.push(c);
2508                                    let cw = crate::kernel::vector_font::get_vector_string_width(
2509                                        &cs,
2510                                        el.font_size,
2511                                    ) as i32;
2512                                    if tw + cw > max_w {
2513                                        break;
2514                                    }
2515                                    truncated.push(c);
2516                                    tw += cw;
2517                                }
2518                                truncated.push_str(ellipsis);
2519                                truncated
2520                            }
2521                        } else {
2522                            text_with_pseudo
2523                        }
2524                    } else {
2525                        text_with_pseudo
2526                    };
2527                    let bg_color = el.bg_color.unwrap_or_else(|| self.effective_bg_behind(el));
2528                    // text-shadow: box-shadow と同様、ぼかし(blur)は無視して単純なオフセット
2529                    // 描画のみ行う(本文の描画前に影を描くことで、本文の不透明背景で
2530                    // 重なり部分が自然にマスクされる)。カンマ区切りの複数シャドウは
2531                    // box-shadow と同じく Vec を逆順に走査し、先頭のシャドウが最前面に来るようにする。
2532                    for &(sx, sy, _blur, s_color) in el.text_shadow.iter().rev() {
2533                        screen.draw_string_ja_size_ext_bg_ls_ws(
2534                            (text_draw_x as i32 + sx).max(0) as u32,
2535                            (draw_y as i32 + sy).max(0) as u32,
2536                            &draw_text,
2537                            el.font_size,
2538                            draw::Color(s_color),
2539                            el.font_italic,
2540                            draw::Color(0),
2541                            el.letter_spacing,
2542                            el.word_spacing,
2543                        );
2544                    }
2545
2546                    if el.text_stroke_width > 0 {
2547                        let stroke_color = el.text_stroke_color.unwrap_or(0xFF000000);
2548                        let sw = el.text_stroke_width.min(4);
2549                        for dy in -sw..=sw {
2550                            for dx in -sw..=sw {
2551                                if dx == 0 && dy == 0 { continue; }
2552                                screen.draw_string_ja_size_ext_bg_ls_ws(
2553                                    (text_draw_x as i32 + dx).max(0) as u32,
2554                                    (draw_y as i32 + dy).max(0) as u32,
2555                                    &draw_text,
2556                                    el.font_size,
2557                                    draw::Color(stroke_color),
2558                                    el.font_italic,
2559                                    draw::Color(0),
2560                                    el.letter_spacing,
2561                                    el.word_spacing,
2562                                );
2563                            }
2564                        }
2565                    }
2566
2567                    // 【2026-07-26診断】ヒーロー見出しが画面に出ない件の切り分け。
2568                    // 実際に描画呼び出しへ到達しているか、その時点の座標・色を記録する。
2569                    if draw_text.starts_with("IoTで未来") {
2570                        crate::warn!(
2571                            "[HERO][DRAW] x={} y={} fs={} color=0x{:08x} bg=0x{:08x} win_h={} text={:?}",
2572                            text_draw_x,
2573                            draw_y,
2574                            el.font_size,
2575                            actual_color,
2576                            bg_color,
2577                            win_h,
2578                            draw_text
2579                        );
2580                    }
2581
2582
2583                    // 【2026-08-03】`font-family` を渡して Web フォントを反映する。
2584                    // 登録済みの family が無ければ既定フォントへフォールバックする。
2585                    screen.draw_string_family(
2586                        text_draw_x,
2587                        draw_y,
2588                        &draw_text,
2589                        el.font_size,
2590                        draw::Color(actual_color),
2591                        el.font_italic,
2592                        draw::Color(bg_color),
2593                        el.letter_spacing,
2594                        el.word_spacing,
2595                        &el.font_family,
2596                        el.font_bold,
2597                    );
2598                    // font-weight: bold のシミュレーション(1px右にずらして重ね描き)
2599                    if el.font_bold {
2600                        screen.draw_string_family(
2601                            text_draw_x + 1,
2602                            draw_y,
2603                            &draw_text,
2604                            el.font_size,
2605                            draw::Color(actual_color),
2606                            el.font_italic,
2607                            draw::Color(bg_color),
2608                            el.letter_spacing,
2609                            el.word_spacing,
2610                            &el.font_family,
2611                            el.font_bold,
2612                        );
2613                    }
2614                    // text-emphasis-style: 各文字の上に傍点/圏点等を描く。
2615                    if !el.text_emphasis_style.is_empty() {
2616                        screen.draw_text_emphasis(
2617                            text_draw_x,
2618                            draw_y,
2619                            &draw_text,
2620                            el.font_size,
2621                            &el.text_emphasis_style,
2622                            draw::Color(el.text_emphasis_color.unwrap_or(actual_color)),
2623                            el.letter_spacing,
2624                            el.word_spacing,
2625                        );
2626                    }
2627                }
2628
2629                let decoration_color = el.decoration_color.unwrap_or(actual_color);
2630                if el.underline {
2631                    // text-underline-offset は負値も許容するため、u32 の draw_y に符号付きで
2632                    // 加算する(0未満にクランプ)。
2633                    let line_y = ((draw_y as i32) + el.font_size as i32 + 2
2634                        + el.decoration_underline_offset)
2635                        .max(0) as u32;
2636                    if line_y < win_y + win_h {
2637                        draw_decoration_line(
2638                            screen,
2639                            draw_x_base as u32,
2640                            line_y,
2641                            draw_x_base as u32 + w as u32,
2642                            &el.decoration_style,
2643                            decoration_color,
2644                            el.decoration_thickness as u32,
2645                        );
2646                    }
2647                }
2648
2649                if el.line_through {
2650                    let line_y = draw_y + el.font_size / 2 + 1;
2651                    if line_y < win_y + win_h {
2652                        draw_decoration_line(
2653                            screen,
2654                            draw_x_base as u32,
2655                            line_y,
2656                            draw_x_base as u32 + w as u32,
2657                            &el.decoration_style,
2658                            decoration_color,
2659                            el.decoration_thickness as u32,
2660                        );
2661                    }
2662                }
2663
2664                if el.overline {
2665                    let line_y = draw_y;
2666                    if line_y < win_y + win_h {
2667                        draw_decoration_line(
2668                            screen,
2669                            draw_x_base as u32,
2670                            line_y,
2671                            draw_x_base as u32 + w as u32,
2672                            &el.decoration_style,
2673                            decoration_color,
2674                            el.decoration_thickness as u32,
2675                        );
2676                    }
2677                }
2678
2679                if el.is_link && !el.pointer_events_none {
2680                    self.links.push(HyperlinkArea {
2681                        href: el.href.clone(),
2682                        x0: el.x_offset,
2683                        y0: el.y_offset,
2684                        x1: el.x_offset + w,
2685                        y1: el.y_offset + el.height,
2686                    });
2687                }
2688            }
2689
2690            // --- CSS filter: blur ---
2691            let blur_radius = crate::os_lib::web_engine::draw_helpers::parse_blur_radius(&el.filter);
2692            if blur_radius > 0 {
2693                let x0_rect = draw_x_base;
2694                let y0_rect = draw_y as i32;
2695                let x1_rect = x0_rect + el.width;
2696                let y1_rect = y0_rect + el.height;
2697                // クリップ領域と交差をとる
2698                let (cx0, cy0, cx1, cy1) = screen
2699                    .clip
2700                    .get()
2701                    .map(|(c0, c1, c2, c3)| (c0 as i32, c1 as i32, c2 as i32, c3 as i32))
2702                    .unwrap_or((0, 0, screen.width as i32, screen.height as i32));
2703                let bx0 = x0_rect.max(cx0);
2704                let by0 = y0_rect.max(cy0);
2705                let bx1 = x1_rect.min(cx1);
2706                let by1 = y1_rect.min(cy1);
2707                if bx1 > bx0 && by1 > by0 {
2708                    crate::os_lib::web_engine::draw_helpers::blur_screen_rect(screen, bx0, by0, bx1, by1, blur_radius);
2709                }
2710            }
2711
2712            if has_overflow_clip {
2713                screen.pop_clip();
2714            }
2715            let _ = el_start;
2716        }
2717
2718        if self.max_scroll_y > 0 && !self.scrollbar_width_none {
2719            // ページスクロールバー(共通ヘルパーで描画。`scrollbar-width:none`が
2720            // ルート要素に指定されている場合は非表示。2026-07-18 発見・実装)
2721            let bar_track_h = win_h as i32 - top_offset;
2722            let bar_track_y = (win_y + top_offset as u32) as i32;
2723            let content_h = self.max_scroll_y + bar_track_h;
2724            screen.draw_vscrollbar(
2725                win_x + win_w - 10,
2726                bar_track_y,
2727                bar_track_h,
2728                content_h,
2729                bar_track_h,
2730                self.scroll_y,
2731            );
2732        }
2733
2734        // サブスクロールコンテナのスクロールバーを描画。
2735        for (cid, max_y, cx, cy, cw, ch) in &self.scroll_containers {
2736            if *max_y <= 0 {
2737                continue;
2738            }
2739            let cur = self.scroll_regions.get(cid).copied().unwrap_or(0);
2740            let bar_x = win_x as i32 + cx + cw - 10;
2741            let bar_y = cy - self.scroll_y + top_offset;
2742            let content_h = max_y + ch;
2743            screen.draw_vscrollbar(bar_x as u32, bar_y, *ch, content_h, *ch, cur);
2744        }
2745
2746
2747        let draw_end_tick = crate::kernel::timer::get_ticks();
2748        DRAW_LAST_END.store(draw_end_tick, core::sync::atomic::Ordering::Relaxed);
2749        let elapsed = draw_end_tick.wrapping_sub(draw_start_tick);
2750        // 【2026-08-05 計測】描画が最適化に値するかを判断する材料。
2751        // 毎フレーム出すのは規約違反(L-3)なので 25 フレームに 1 行だけ要約する。
2752        // 目的: 描画コストの把握。破棄条件: 判断がついたら削除。
2753        {
2754            use core::sync::atomic::{AtomicU32, Ordering};
2755            static FRAMES: AtomicU32 = AtomicU32::new(0);
2756            static TICKS: AtomicU32 = AtomicU32::new(0);
2757            static MAXT: AtomicU32 = AtomicU32::new(0);
2758            // 【2026-08-05 計測】最大を更新した回だけ、その 1 回の内訳を出す。
2759            // 「数十秒かかる `draw()` が存在する」ことは分かったので、
2760            // その 1 回で何をしていたのかを見る。
2761            // 目的: 長い draw の正体の特定。破棄条件: 特定でき次第削除。
2762            let prev_max = MAXT.load(Ordering::Relaxed);
2763            if elapsed as u32 > prev_max && elapsed > 50 {
2764                let relayout_this = RELAYOUT_IN_DRAW_TICKS
2765                    .load(Ordering::Relaxed)
2766                    .wrapping_sub(relayout_before);
2767                crate::warn!(
2768                    "[PERF][DRAW] 最長更新 {}tick(うち再レイアウト {}tick, 要素={})",
2769                    elapsed,
2770                    relayout_this,
2771                    self.elements.len()
2772                );
2773            }
2774            let n = FRAMES.fetch_add(1, Ordering::Relaxed) + 1;
2775            TICKS.fetch_add(elapsed as u32, Ordering::Relaxed);
2776            MAXT.fetch_max(elapsed as u32, Ordering::Relaxed);
2777            // 【2026-08-05】100 フレームだと 1 ページ読み込みで到達しない回があり
2778            // (実測 0.5fps 前後)、要約が出ないまま終わる。25 に下げる。
2779            // 【2026-08-05】要約の間隔は**フレーム数ではなく時間**で決める。
2780            //
2781            // 25 フレームごとにしていたが、重い回は 1 ページ読み込みで
2782            // 25 回も描かない(実測 10 回未満の回があった)。
2783            // 実機 1 回が数分かかるのに**計測が取れずに終わる**のは無駄。
2784            // 時間基準なら描画回数によらず必ず出る。
2785            let last_dump = LAST_DUMP_TICK.load(Ordering::Relaxed);
2786            let due = last_dump == 0
2787                || draw_end_tick.wrapping_sub(last_dump) >= DUMP_INTERVAL_TICKS;
2788            if due {
2789                LAST_DUMP_TICK.store(draw_end_tick, Ordering::Relaxed);
2790            }
2791            if due {
2792                crate::warn!(
2793                    "[PERF][DRAW] 要素={} frames={} total={}tick max={}tick relayout={}tick 間隔合計={}tick 間隔最大={}tick 送信待ち={}tick/{}回 再送={}回",
2794                    self.elements.len(),
2795                    n,
2796                    TICKS.load(Ordering::Relaxed),
2797                    MAXT.load(Ordering::Relaxed),
2798                    RELAYOUT_IN_DRAW_TICKS.load(Ordering::Relaxed),
2799                    DRAW_GAP_SUM.load(Ordering::Relaxed),
2800                    DRAW_GAP_MAX.load(Ordering::Relaxed),
2801                    crate::kernel::tls::WRITE_WAIT_TICKS.load(Ordering::Relaxed),
2802                    crate::kernel::tls::WRITE_WAIT_CALLS.load(Ordering::Relaxed),
2803                    crate::kernel::tls::WRITE_RETX_COUNT.load(Ordering::Relaxed)
2804                );
2805                // 関連する計測点を 1 行でまとめて出す(`perf.rs`)。
2806                super::perf::dump("frame");
2807            }
2808        }
2809        if elapsed > 1 {
2810            // 【2026-07-31計測】レイアウトは 6 秒まで下がったが、
2811            // 1 サイクル 26 秒のうち 20 秒がレイアウトの外にある。
2812            // 描画がその何割かを見るため warn へ上げる。
2813            crate::apps::browser::log_redraw_reasons();
2814        }
2815        self.dirty = false;
2816    }
2817
2818    /// 現在のマウス座標 (mouse_x, mouse_y) がいずれかのサブスクロールコンテナの
2819    /// 表示領域内にあれば、そのコンテナ element_id を返す。
2820    fn hovered_scroll_container(&self, top_offset: i32) -> Option<String> {
2821        let mx = self.mouse_x;
2822        let my = self.mouse_y;
2823        for (cid, max_y, cx, cy, cw, ch) in &self.scroll_containers {
2824            if *max_y <= 0 {
2825                continue;
2826            }
2827            let screen_y = cy - self.scroll_y + top_offset;
2828            if mx >= *cx && mx < cx + cw && my >= screen_y && my < screen_y + ch {
2829                return Some(cid.clone());
2830            }
2831        }
2832        None
2833    }
2834
2835    pub fn scroll(&mut self, dy: i32) {
2836        let top = self.top_offset;
2837        self.scroll_with_top(dy, top);
2838    }
2839
2840    pub fn scroll_with_top(&mut self, dy: i32, top_offset: i32) {
2841        // マウスがサブスクロールコンテナの上にあればそちらをスクロール。
2842        if let Some(cid) = self.hovered_scroll_container(top_offset) {
2843            let max = self.scroll_containers
2844                .iter()
2845                .find(|(id, ..)| *id == cid)
2846                .map(|(_, m, ..)| *m)
2847                .unwrap_or(0);
2848            let entry = self.scroll_regions.entry(cid).or_insert(0);
2849            let prev = *entry;
2850            *entry = (*entry + dy).max(0).min(max);
2851            if *entry != prev {
2852                self.dirty = true;
2853                self.dirty_reason = "render.rs:2462";
2854            }
2855            return;
2856        }
2857        // ページ全体のスクロール。
2858        let prev = self.scroll_y;
2859        self.scroll_y = (self.scroll_y + dy).max(0).min(self.max_scroll_y);
2860        if self.scroll_y != prev {
2861            self.dirty = true;
2862            self.dirty_reason = "render.rs:2470";
2863            // window.scrollY を更新し、scroll リスナを発火(位置が変化したときのみ)。
2864            self.js_runtime.fire_scroll(self.scroll_y);
2865        }
2866    }
2867
2868    pub fn handle_mouse_click(&mut self, mx: u32, my: u32, top_offset: i32) {
2869        let x = mx as i32;
2870        let y = my as i32;
2871
2872        self.focused_id = None;
2873        self.dirty = true;
2874        self.dirty_reason = "render.rs:2481";
2875
2876        if y < top_offset {
2877            return;
2878        }
2879
2880        for link in &self.links {
2881            if x >= link.x0 && x <= link.x1 && y >= link.y0 && y <= link.y1 {
2882                let href = link.href.clone();
2883                self.navigate_to_href(&href, top_offset);
2884                break;
2885            }
2886        }
2887    }
2888
2889    pub fn navigate_to_href(&mut self, href: &str, top_offset: i32) {
2890        if href.is_empty() {
2891            return;
2892        }
2893
2894        // 同一ページ内アンカーナビ(href="#" / "#section")。フルリロードせず、
2895        // 対象 id 要素へスクロールし、location.hash を更新して hashchange を発火する。
2896        if href.starts_with('#') {
2897            self.navigate_to_fragment(href);
2898            return;
2899        }
2900
2901        if href.starts_with('/') {
2902            let host = self.current_host.clone();
2903            self.load_page(self.current_is_https, &host, href, true, top_offset);
2904        } else if href.starts_with("http://") || href.starts_with("https://") {
2905            let is_https = href.starts_with("https://");
2906            let clean_url = if is_https {
2907                href.get(8..).unwrap_or("")
2908            } else {
2909                href.get(7..).unwrap_or("")
2910            };
2911
2912            let parts: Vec<&str> = clean_url.splitn(2, '/').collect();
2913            let host = parts[0];
2914            let path = if parts.len() > 1 {
2915                alloc::format!("/{}", parts[1])
2916            } else {
2917                String::from("/")
2918            };
2919            self.load_page(is_https, host, &path, true, top_offset);
2920        } else {
2921            if href.contains('.') && !href.contains('/') {
2922                self.load_page(self.current_is_https, href, "/", true, top_offset);
2923            } else {
2924                let host = self.current_host.clone();
2925                let path = alloc::format!("{}/{}", self.current_path.trim_end_matches('/'), href);
2926                self.load_page(self.current_is_https, &host, &path, true, top_offset);
2927            }
2928        }
2929    }
2930
2931    /// 同一ページ内アンカーナビ(href が "#..." のリンク)。フルページ再読込を避け、
2932    /// 1) 対象 id 要素があればその位置へスクロール、2) location.hash を更新、
2933    /// 3) hashchange イベントを発火する。href="#"(空フラグメント)はページ先頭へ。
2934    pub(crate) fn navigate_to_fragment(&mut self, href: &str) {
2935        let frag = href.trim_start_matches('#');
2936        // 現在URL(hash除去済み)を組み立てる。
2937        let scheme = if self.current_is_https {
2938            "https://"
2939        } else {
2940            "http://"
2941        };
2942        let base = alloc::format!("{}{}{}", scheme, self.current_host, self.current_path);
2943        let base = match base.split_once('#') {
2944            Some((before, _)) => String::from(before),
2945            None => base,
2946        };
2947        let old_url = self.current_url_with_hash.clone();
2948        let new_url = alloc::format!("{}#{}", base, frag);
2949
2950        // 対象 id を持つ要素へスクロール(先頭フラグメントが空なら最上部)。
2951        if frag.is_empty() {
2952            if self.scroll_y != 0 {
2953                self.scroll_y = 0;
2954                self.dirty = true;
2955                self.dirty_reason = "render.rs:2561";
2956            }
2957        } else if let Some(el) = self.elements.iter().find(|e| e.element_id == frag) {
2958            // scroll-margin-top: 固定ヘッダー等の下にターゲットが隠れないよう、
2959            // 通常のスクロール位置からこの分だけ引く。
2960            let target = (el.y_offset - el.scroll_margin_top)
2961                .max(0)
2962                .min(self.max_scroll_y);
2963            if self.scroll_y != target {
2964                if self.scroll_smooth {
2965                    // scroll-behavior:smooth — 即座にジャンプせず、次フレームから
2966                    // tick_scroll_anim() でイージング付きに遷移させる。
2967                    self.scroll_anim_active = true;
2968                    self.scroll_anim_from = self.scroll_y;
2969                    self.scroll_anim_to = target;
2970                    self.scroll_anim_start_ms = self.now_ms();
2971                    self.dirty = true;
2972                    self.dirty_reason = "render.rs:2577";
2973                } else {
2974                    self.scroll_y = target;
2975                    self.dirty = true;
2976                    self.dirty_reason = "render.rs:2580";
2977                    self.js_runtime.fire_scroll(self.scroll_y);
2978                }
2979            }
2980        }
2981
2982        // location.hash を更新し hashchange を発火(hash が実際に変わったときのみ)。
2983        if old_url != new_url {
2984            self.current_url_with_hash = new_url.clone();
2985            self.js_runtime.fire_hashchange(&old_url, &new_url);
2986            self.dirty = true;
2987            self.dirty_reason = "render.rs:2590";
2988        }
2989
2990        // `:target` 疑似クラスの再判定のため、現在のフラグメントを更新して再スタイル/
2991        // 再レイアウトを要求する(フラグメントが実際に変わった場合のみ)。
2992        {
2993            let mut current = crate::os_lib::layout::CURRENT_TARGET_ID.lock();
2994            if current.as_str() != frag {
2995                *current = String::from(frag);
2996                drop(current);
2997                self.dom_needs_rebuild = true;
2998            }
2999        }
3000    }
3001
3002    /// `scroll-behavior:smooth` によるスムーススクロールを1フレームぶん進める。
3003    /// ease-out(`1 - (1-t)^2`)で約300msかけて目標位置まで遷移する。
3004    /// ホイール/スクロールバードラッグには適用しない(アンカー遷移専用、実ブラウザの
3005    /// `scrollIntoView`/アンカー遷移のみスムーススクロール対象という挙動に合わせる)。
3006    fn tick_scroll_anim(&mut self) {
3007        const DURATION_MS: f32 = 300.0;
3008        let now = self.now_ms();
3009        let elapsed = (now - self.scroll_anim_start_ms).max(0.0);
3010        let t = (elapsed / DURATION_MS).min(1.0);
3011        let eased = 1.0 - (1.0 - t) * (1.0 - t);
3012        let from = self.scroll_anim_from as f32;
3013        let to = self.scroll_anim_to as f32;
3014        self.scroll_y = libm::roundf(from + (to - from) * eased) as i32;
3015        self.dirty = true;
3016        self.dirty_reason = "render.rs:2618";
3017        if t >= 1.0 {
3018            self.scroll_y = self.scroll_anim_to;
3019            self.scroll_anim_active = false;
3020            self.js_runtime.fire_scroll(self.scroll_y);
3021        }
3022    }
3023
3024    /// URL 文字列を (is_https, host, path) に分解。相対パスは現在の host を使用。
3025    /// URL を (https か, ホスト, パス) へ分解する。
3026    ///
3027    /// 【2026-08-03】**フラグメント(`#id`)を落とすようにした**。
3028    /// 以前は `#` をパスに含めたまま HTTP 要求に載せており、
3029    /// `https://example.com/#research` のような URL が正しく扱えなかった。
3030    /// フラグメントはサーバへ送るものではなく、
3031    /// 取得後にページ内でスクロールするための指定である。
3032    pub(crate) fn resolve_url_parts(&self, url: &str) -> (bool, String, String) {
3033        let url = match url.split_once('#') {
3034            Some((before, _)) => before,
3035            None => url,
3036        };
3037        if let Some(rest) = url.strip_prefix("https://") {
3038            if let Some(idx) = rest.find('/') {
3039                (
3040                    true,
3041                    String::from(rest.get(..idx).unwrap_or("")),
3042                    String::from(rest.get(idx..).unwrap_or("")),
3043                )
3044            } else {
3045                (true, String::from(rest), String::from("/"))
3046            }
3047        } else if let Some(rest) = url.strip_prefix("http://") {
3048            if let Some(idx) = rest.find('/') {
3049                (
3050                    false,
3051                    String::from(rest.get(..idx).unwrap_or("")),
3052                    String::from(rest.get(idx..).unwrap_or("")),
3053                )
3054            } else {
3055                (false, String::from(rest), String::from("/"))
3056            }
3057        } else if let Some(rest) = url.strip_prefix("//") {
3058            // プロトコル相対URL(`//host/path`)が、`url.starts_with('/')` の
3059            // 判定に巻き込まれて現在のホスト配下のルート相対パスとして誤解決
3060            // されるバグだった(`os_lib/js/builtins.rs` の `resolve_url` で
3061            // 発見・修正済みの同型バグ。こちらは実際のページ描画が使う別系統の
3062            // URL 解決関数のため個別に修正が必要だった)。現在のスキームを
3063            // 引き継ぎ、`//` 以降を別ホスト+パスとして扱う。
3064            if let Some(idx) = rest.find('/') {
3065                (
3066                    self.current_is_https,
3067                    String::from(rest.get(..idx).unwrap_or("")),
3068                    String::from(rest.get(idx..).unwrap_or("")),
3069                )
3070            } else {
3071                (self.current_is_https, String::from(rest), String::from("/"))
3072            }
3073        } else if url.starts_with('/') {
3074            (
3075                self.current_is_https,
3076                self.current_host.clone(),
3077                String::from(url),
3078            )
3079        } else {
3080            let path = alloc::format!("{}/{}", self.current_path.trim_end_matches('/'), url);
3081            (self.current_is_https, self.current_host.clone(), path)
3082        }
3083    }
3084
3085    fn url_encode(s: &str) -> String {
3086        let mut out = String::new();
3087        for b in s.bytes() {
3088            match b {
3089                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
3090                    out.push(b as char);
3091                }
3092                b' ' => {
3093                    out.push('+');
3094                }
3095                _ => {
3096                    out.push_str(&alloc::format!("%{:02X}", b));
3097                }
3098            }
3099        }
3100        out
3101    }
3102
3103    /// textarea の表示用折返し計算(描画・スクロール上限・キー操作で共通使用)。
3104    /// `wrap` が無効なら物理行をそのまま返す。
3105    /// 1 文字ずつ advance 幅を加算する O(n) 実装。
3106    /// 注意: ここでの行分割はエディタ表示専用で、Web ページ本文の折返し
3107    /// (flatten_layout / get_string_wrapped_size ペア) とは独立した規則。
3108    fn wrap_editor_lines(text_val: &str, el_width: i32, wrap: bool) -> Vec<(String, bool)> {
3109        if !wrap {
3110            return text_val
3111                .split('\n')
3112                .map(|s| (String::from(s), true))
3113                .collect();
3114        }
3115        let max_w = (el_width - 16).max(50) as u32;
3116        let mut lines = Vec::new();
3117        for phys_line in text_val.split('\n') {
3118            if phys_line.is_empty() {
3119                lines.push((String::new(), true));
3120                continue;
3121            }
3122            let mut current_line = String::new();
3123            let mut cur_w = 0u32;
3124            let mut sub_lines = Vec::new();
3125            for c in phys_line.chars() {
3126                let cw = crate::kernel::vector_font::get_vector_char_width(c, 16);
3127                if cur_w + cw > max_w {
3128                    sub_lines.push(current_line);
3129                    current_line = String::from(c);
3130                    cur_w = cw;
3131                } else {
3132                    current_line.push(c);
3133                    cur_w += cw;
3134                }
3135            }
3136            if !current_line.is_empty() {
3137                sub_lines.push(current_line);
3138            }
3139            let sub_len = sub_lines.len();
3140            for (idx, line) in sub_lines.into_iter().enumerate() {
3141                let is_phys_end = idx == sub_len - 1;
3142                lines.push((line, is_phys_end));
3143            }
3144        }
3145        lines
3146    }
3147
3148    /// カーソルインデックス(文字ベース)が表示上の何行目(0-indexed)にあるかを計算する。
3149    fn get_cursor_line_idx(&self, text_val: &str, cursor_pos: usize, el_width: i32) -> usize {
3150        let wrap = self.editor_wrap;
3151        if !wrap {
3152            let mut line_idx = 0;
3153            for (idx, c) in text_val.chars().enumerate() {
3154                if idx >= cursor_pos {
3155                    break;
3156                }
3157                if c == '\n' {
3158                    line_idx += 1;
3159                }
3160            }
3161            return line_idx;
3162        }
3163
3164        let max_w = (el_width - 16).max(50) as u32;
3165        let mut line_idx = 0;
3166        let mut cur_w = 0u32;
3167
3168        for (idx, c) in text_val.chars().enumerate() {
3169            if idx >= cursor_pos {
3170                break;
3171            }
3172            if c == '\n' {
3173                line_idx += 1;
3174                cur_w = 0;
3175                continue;
3176            }
3177            let cw = crate::kernel::vector_font::get_vector_char_width(c, 16);
3178            if cur_w + cw > max_w {
3179                line_idx += 1;
3180                cur_w = cw;
3181            } else {
3182                cur_w += cw;
3183            }
3184        }
3185        line_idx
3186    }
3187
3188
3189    /// フォーカス中入力欄の TextBox を作る。**キャレット位置は `restore` から
3190    /// 復元する**(同じ要素に留まっている場合のみ呼び出し側が Some を渡す)。
3191    ///
3192    /// `parse_and_layout` が `active_text_box` を毎フレーム捨てるため、
3193    /// この復元が無いと入力のたびにカーソルが末尾へ飛ぶ。
3194    /// `&self` を取らない関連関数にしてあるのは、呼び出し側が
3195    /// `self.elements` を借用したまま `self.active_text_box` へ代入できるようにするため。
3196    fn build_text_box(
3197        val: &str,
3198        is_textarea: bool,
3199        restore: Option<usize>,
3200    ) -> crate::kernel::text_box::TextBox {
3201        let mut tb = if is_textarea {
3202            crate::kernel::text_box::TextBox::with_text_multiline(val)
3203        } else {
3204            crate::kernel::text_box::TextBox::with_text(val)
3205        };
3206        if let Some(pos) = restore {
3207            tb.cursor_pos = pos.min(tb.text.chars().count());
3208        }
3209        tb
3210    }
3211
3212    /// フォーカス中の入力欄の編集中テキストを form_values へ確定させる。
3213    /// フォーカス移動を伴わない送信(Enter キー等)でも入力値が確実に反映される。
3214    fn flush_focused_input(&mut self) {
3215        if let Some(ref fid) = self.focused_id {
3216            if let Some(ref tb) = self.active_text_box {
3217                self.form_values.insert(fid.clone(), tb.text.clone());
3218            }
3219        }
3220    }
3221
3222    pub fn submit_form(&mut self, action: &str, method: &str, top_offset: i32) {
3223        // 送信直前に編集中テキストを確定(空クエリ送信=検索ページに戻る不具合の防止)
3224        self.flush_focused_input();
3225
3226        // 送信対象の <form> を特定(フォーカス入力の祖先、無ければ最初の form)。
3227        let form_idx = {
3228            let dom = self.js_runtime.dom.borrow();
3229            let from_focus = self
3230                .focused_id
3231                .as_ref()
3232                .and_then(|fid| dom.get_element_by_id(fid))
3233                .and_then(|i| dom.closest_tag(i, "form"));
3234            from_focus.or_else(|| dom.first_by_tag("form"))
3235        };
3236
3237        // HTML5 制約バリデーション(submit イベントより前に実行)。form に novalidate が
3238        // 無い場合のみ。最初に違反した要素へフォーカスを移し、invalid イベントを発火して送信中止。
3239        if let Some(fi) = form_idx {
3240            let novalidate = self.js_runtime.dom.borrow().has_attr(fi, "novalidate");
3241            if !novalidate {
3242                // (field_node_idx, element_id, message) を収集。
3243                let invalid_fields: Vec<(usize, String, String)> = {
3244                    let dom = self.js_runtime.dom.borrow();
3245                    let fields = dom.descendants_by_tags(fi, &["input", "textarea", "select"]);
3246                    let mut out = Vec::new();
3247                    for node in fields {
3248                        // element_id(id 優先、無ければ name)で form_values を引く。
3249                        let eid = dom
3250                            .get_attr(node, "id")
3251                            .filter(|s| !s.is_empty())
3252                            .or_else(|| dom.get_attr(node, "name"))
3253                            .unwrap_or_default();
3254                        let value = if eid.is_empty() {
3255                            String::new()
3256                        } else {
3257                            self.form_values.get(&eid).cloned().unwrap_or_default()
3258                        };
3259                        if let Some(msg) = dom.validate_field(node, &value) {
3260                            out.push((node, eid, msg));
3261                        }
3262                    }
3263                    out
3264                };
3265                if let Some((node, eid, msg)) = invalid_fields.into_iter().next() {
3266                    // invalid イベントを発火(JS がカスタム処理する余地を与える)。
3267                    self.js_runtime.dispatch_event(node, "invalid");
3268                    // 最初の不正フィールドへフォーカスを移す。
3269                    if !eid.is_empty() {
3270                        self.focused_id = Some(eid.clone());
3271                        self.dom_needs_rebuild = true; // :focus-within の再判定に必要
3272                        let cur = self.form_values.get(&eid).cloned().unwrap_or_default();
3273                        self.active_text_box =
3274                            Some(crate::kernel::text_box::TextBox::with_text(&cur));
3275                        self.focus_value = cur;
3276                    }
3277                    // 検証メッセージを表示(コンソール出力)。送信は中止し、不正フィールドへフォーカス。
3278                    crate::warn!("[GPU] Form validation failed [{}]: {}", eid, msg);
3279                    self.dirty = true;
3280                    self.dirty_reason = "render.rs:2846";
3281                    return;
3282                }
3283            }
3284        }
3285
3286        // <form> へ submit を発火。preventDefault されたら送信をキャンセル(SPA がフォーム送信を
3287        // JS で乗っ取る定番パターン)。
3288        if let Some(fi) = form_idx {
3289            let (_fired, prevented) = self.js_runtime.dispatch_event_with(fi, "submit", &[]);
3290            if self.js_runtime.dom.borrow().dirty {
3291                self.dom_needs_rebuild = true;
3292                self.dirty = true;
3293                self.dirty_reason = "render.rs:2858";
3294            }
3295            if prevented {
3296                return; // 既定の送信を抑制
3297            }
3298        }
3299
3300        let mut query = String::new();
3301        let mut first = true;
3302        // checkbox/radio で未チェック(値が空)のフィールドは送信に含めない(HTML仕様)。
3303        let unchecked: alloc::collections::BTreeSet<String> = self
3304            .elements
3305            .iter()
3306            .filter(|e| (e.input_kind == 1 || e.input_kind == 2) && !e.element_id.is_empty())
3307            .filter(|e| {
3308                self.form_values
3309                    .get(&e.element_id)
3310                    .map(|v| v.is_empty())
3311                    .unwrap_or(true)
3312            })
3313            .map(|e| e.element_id.clone())
3314            .collect();
3315        for (k, v) in &self.form_values {
3316            // 未チェックの checkbox/radio はスキップ。
3317            if v.is_empty() && unchecked.contains(k) {
3318                continue;
3319            }
3320            // パラメータ名は `name` 属性(field_names)。無ければ element_id へフォールバック。
3321            let param_name = self
3322                .field_names
3323                .get(k)
3324                .cloned()
3325                .unwrap_or_else(|| k.clone());
3326            if param_name.is_empty() {
3327                continue;
3328            }
3329            if !first {
3330                query.push('&');
3331            }
3332            let enc_k = Self::url_encode(&param_name);
3333            let enc_v = Self::url_encode(v);
3334            query.push_str(&enc_k);
3335            query.push('=');
3336            query.push_str(&enc_v);
3337            first = false;
3338        }
3339
3340        let mut target_action = String::from(action);
3341        if target_action.is_empty() {
3342            target_action = self.current_path.clone();
3343        }
3344
3345        crate::info!(
3346            "SUBMIT_FORM: {} {} ({} fields)",
3347            method,
3348            target_action,
3349            self.form_values.len()
3350        );
3351
3352        if method.eq_ignore_ascii_case("POST") {
3353            // action URL を解決して POST リクエスト
3354            let (is_https, host, path) = self.resolve_url_parts(&target_action);
3355            self.load_page_post(is_https, &host, &path, &query, top_offset);
3356        } else {
3357            let separator = if target_action.contains('?') {
3358                "&"
3359            } else {
3360                "?"
3361            };
3362            let full_url = alloc::format!("{}{}{}", target_action, separator, query);
3363            self.navigate_to_href(&full_url, top_offset);
3364        }
3365    }
3366
3367    /// type=reset ボタン押下時の処理。ボタン id から所属 <form> を特定し、配下の
3368    /// input/textarea/select を HTML 既定値(initial_form_values)へ戻す。DOM 属性も
3369    /// 同期し、reset イベントを発火してから再レイアウトを要求する。
3370    pub fn reset_form(&mut self, button_id: &str) {
3371        // 所属 <form> を特定(ボタンの祖先 form、無ければ最初の form)。
3372        let form_idx = {
3373            let dom = self.js_runtime.dom.borrow();
3374            let from_btn = (!button_id.is_empty())
3375                .then(|| dom.get_element_by_id(button_id))
3376                .flatten()
3377                .and_then(|i| dom.closest_tag(i, "form"));
3378            from_btn.or_else(|| dom.first_by_tag("form"))
3379        };
3380        if let Some(fi) = form_idx {
3381            self.reset_form_node(fi);
3382        }
3383    }
3384
3385    /// 指定した <form> ノード(node idx)配下のコントロールを初期値へ戻す。
3386    pub fn reset_form_node(&mut self, fi: usize) {
3387        // 編集中テキストの確定状態を破棄(reset 後に上書きされるため)。
3388        self.active_text_box = None;
3389        self.focus_value = String::new();
3390
3391        // フォーム配下の全コントロールを (node_idx, element_id) で収集。
3392        let fields: Vec<(usize, String)> = {
3393            let dom = self.js_runtime.dom.borrow();
3394            dom.descendants_by_tags(fi, &["input", "textarea", "select"])
3395                .into_iter()
3396                .map(|node| {
3397                    let eid = dom
3398                        .get_attr(node, "id")
3399                        .filter(|s| !s.is_empty())
3400                        .or_else(|| dom.get_attr(node, "name"))
3401                        .unwrap_or_default();
3402                    (node, eid)
3403                })
3404                .collect()
3405        };
3406
3407        // 各コントロールを初期値へ戻し、DOM 属性も同期。
3408        for (node, eid) in &fields {
3409            if eid.is_empty() {
3410                continue;
3411            }
3412            let init = self
3413                .initial_form_values
3414                .get(eid)
3415                .cloned()
3416                .unwrap_or_default();
3417            self.form_values.insert(eid.clone(), init.clone());
3418
3419            // 描画要素種別を引いて DOM 属性の戻し方を分岐。
3420            let kind = self
3421                .elements
3422                .iter()
3423                .find(|e| &e.element_id == eid)
3424                .map(|e| e.input_kind)
3425                .unwrap_or(0);
3426            let mut dom = self.js_runtime.dom.borrow_mut();
3427            dom.remove_attr(*node, "_live_value");
3428            dom.remove_attr(*node, "_live_checked");
3429            match kind {
3430                1 | 2 => {
3431                    // checkbox / radio: 初期値が空なら unchecked。
3432                    if init.is_empty() {
3433                        dom.remove_attr(*node, "checked");
3434                    } else {
3435                        dom.set_attr(*node, "checked", "");
3436                    }
3437                }
3438                _ => {
3439                    // text / textarea / select: value 属性を初期値へ。
3440                    dom.set_attr(*node, "value", &init);
3441                }
3442            }
3443        }
3444
3445        // フォーカス中フィールドが reset 対象なら表示テキストも初期値へ。
3446        if let Some(fid) = self.focused_id.clone() {
3447            if fields.iter().any(|(_, e)| e == &fid) {
3448                let init = self
3449                    .initial_form_values
3450                    .get(&fid)
3451                    .cloned()
3452                    .unwrap_or_default();
3453                let is_ta = self
3454                    .elements
3455                    .iter()
3456                    .find(|e| e.element_id == fid)
3457                    .map(|e| e.is_textarea)
3458                    .unwrap_or(false);
3459                self.active_text_box = Some(if is_ta {
3460                    crate::kernel::text_box::TextBox::with_text_multiline(&init)
3461                } else {
3462                    crate::kernel::text_box::TextBox::with_text(&init)
3463                });
3464                self.focus_value = init;
3465            }
3466        }
3467
3468        // reset イベントを発火(JS がカスタム処理する余地を与える)。
3469        self.js_runtime.dispatch_event(fi, "reset");
3470        if self.js_runtime.dom.borrow().dirty {
3471            self.dom_needs_rebuild = true;
3472        }
3473        self.dom_needs_rebuild = true;
3474        self.dirty = true;
3475        self.dirty_reason = "render.rs:3039";
3476    }
3477
3478    /// 指定要素の**背後に実際にある背景色**を返す。
3479    ///
3480    /// 【2026-07-26 バグ修正】従来は自前の背景を持たない要素について
3481    /// `page_bg_color`(ページ全体の背景=多くの場合白)へフォールバックしていた。
3482    /// しかしテキスト描画はこの色を「グリフの下地」として使うため、
3483    /// 実際には色付きの領域(例: `.hero { background-color:#ff6b6b }`)の上に
3484    /// 乗っている文字でも下地が白として扱われてしまう。
3485    /// とくにベクタフォントに該当グリフが無くフォールバック描画へ落ちる場合、
3486    /// `draw_string_*` は下地色で矩形を塗ってから文字を描くため、
3487    /// **白い矩形を塗った上に白い文字**という完全に不可視な描画になっていた
3488    /// (実サイトのヒーロー見出しが表示されなかった原因)。
3489    ///
3490    /// 対象要素の矩形を内包する要素のうち、背景色を持ち面積が最小のもの
3491    /// (=最も内側の背景)を採用する。見つからなければ従来どおりページ背景。
3492    fn effective_bg_behind(&self, target: &super::RenderElement) -> u32 {
3493        let tx0 = target.x_offset;
3494        let ty0 = target.y_offset;
3495        let tx1 = tx0 + target.width;
3496        let ty1 = ty0 + target.height;
3497
3498        let mut best: Option<(i64, u32)> = None;
3499        for el in &self.elements {
3500            // 【2026-07-28】グラデーションを持つ要素は、実際に画面へ塗られるのは
3501            // グラデーション(描画側は単色 bg より優先する)。ここで background-color を
3502            // 返すと、グリフのアンチエイリアス色が実際の背景と食い違う。
3503            // 実測: `.hero` は bg-color=#ff6b6b・実塗り=青系グラデで、
3504            let grad_bg = el
3505                .linear_gradient
3506                .as_ref()
3507                .and_then(|g| g.stops.first().map(|s| s.color));
3508            let Some(bg) = grad_bg.or(el.bg_color) else {
3509                continue;
3510            };
3511            // 完全に不透明でない背景は下地として信頼できないので採用しない。
3512            if (bg >> 24) != 0xFF && (bg >> 24) != 0x00 {
3513                continue;
3514            }
3515            let x0 = el.x_offset;
3516            let y0 = el.y_offset;
3517            let x1 = x0 + el.width;
3518            let y1 = y0 + el.height;
3519            if x0 <= tx0 && y0 <= ty0 && x1 >= tx1 && y1 >= ty1 {
3520                let area = (x1 - x0) as i64 * (y1 - y0) as i64;
3521                if best.is_none_or(|(a, _)| area < a) {
3522                    best = Some((area, bg));
3523                }
3524            }
3525        }
3526        best.map(|(_, bg)| bg)
3527            .unwrap_or(self.page_bg_color.unwrap_or(0xFFFFFFFF))
3528    }
3529
3530    /// コンテンツ座標 (local_x, local_y) でヒットする最前面の要素 id を返す(id を持つもの)。
3531    /// マウスイベントのヒットテストに使用。ヒット無しなら None。
3532    fn hit_test_element_id(&self, local_x: i32, local_y: i32, top_offset: i32) -> Option<String> {
3533        let hx = local_x - 20;
3534        let hy = local_y - top_offset + self.scroll_y;
3535        let mut found: Option<String> = None;
3536        for (idx, el) in self.elements.iter().enumerate() {
3537            // Use element_id if present; otherwise generate a synthetic id based on the element index.
3538            let element_id = if el.element_id.is_empty() {
3539                format!("_ta_{}", idx)
3540            } else {
3541                el.element_id.clone()
3542            };
3543            let hit_y = hy >= el.y_offset && hy < el.y_offset + el.height;
3544            let hit_x = if el.x_offset > 0 || el.width < 1000 {
3545                hx >= el.x_offset && hx < el.x_offset + el.width
3546            } else {
3547                hx >= 0
3548            };
3549            if hit_y && hit_x {
3550                found = Some(element_id); // 後勝ち=最前面寄り
3551            }
3552        }
3553        found
3554    }
3555
3556    pub fn on_mouse(
3557        &mut self,
3558        local_x: i32,
3559        local_y: i32,
3560        btn_left: bool,
3561        btn_right: bool,
3562        wheel: i32,
3563        top_offset: i32,
3564    ) {
3565        crate::debug!(
3566            "[GPU] TRACE: on_mouse x={}, y={}, btn={}, wheel={}",
3567            local_x,
3568            local_y,
3569            btn_left,
3570            wheel
3571        );
3572        self.mouse_x = local_x;
3573        self.mouse_y = local_y;
3574        // `:active` 疑似クラス判定用の現在フレームの押下状態。
3575        self.mouse_btn_left_down = btn_left;
3576
3577        // 左ボタンの押下/解放エッジを検出(WM は毎フレーム現在値で on_mouse を呼ぶ)。
3578        let pressed_edge = btn_left && !self.prev_btn_left;
3579        let released_edge = !btn_left && self.prev_btn_left;
3580        self.prev_btn_left = btn_left;
3581
3582        // mousedown: 押下の瞬間に、ヒット要素へ発火(リスナがある場合のみヒットテスト)。
3583        if pressed_edge && self.js_runtime.dom.borrow().has_listener("mousedown") {
3584            if let Some(id) = self.hit_test_element_id(local_x, local_y, top_offset) {
3585                let node = self.js_runtime.dom.borrow().get_element_by_id(&id);
3586                if let Some(i) = node {
3587                    self.js_runtime
3588                        .dispatch_mouse(i, "mousedown", local_x, local_y);
3589                }
3590                self.relayout_if_dom_dirty(top_offset, self.win_w, self.win_h);
3591            }
3592        }
3593        // mouseup: 解放の瞬間に、ヒット要素へ発火。
3594        if released_edge && self.js_runtime.dom.borrow().has_listener("mouseup") {
3595            if let Some(id) = self.hit_test_element_id(local_x, local_y, top_offset) {
3596                let node = self.js_runtime.dom.borrow().get_element_by_id(&id);
3597                if let Some(i) = node {
3598                    self.js_runtime
3599                        .dispatch_mouse(i, "mouseup", local_x, local_y);
3600                }
3601                self.relayout_if_dom_dirty(top_offset, self.win_w, self.win_h);
3602            }
3603        }
3604        // contextmenu: 右ボタンの押下エッジで、ヒット要素へ発火(右クリックメニュー)。
3605        let right_pressed_edge = btn_right && !self.prev_btn_right;
3606        self.prev_btn_right = btn_right;
3607        if right_pressed_edge && self.js_runtime.dom.borrow().has_listener("contextmenu") {
3608            if let Some(id) = self.hit_test_element_id(local_x, local_y, top_offset) {
3609                let node = self.js_runtime.dom.borrow().get_element_by_id(&id);
3610                if let Some(i) = node {
3611                    self.js_runtime
3612                        .dispatch_mouse_full(i, "contextmenu", local_x, local_y, 2, 0);
3613                }
3614                self.relayout_if_dom_dirty(top_offset, self.win_w, self.win_h);
3615            }
3616        }
3617        // dblclick: 押下エッジで、同一要素を一定 tick 以内に2回クリックしたら発火。
3618        if pressed_edge {
3619            let now = crate::kernel::timer::get_ticks();
3620            let hit = self.hit_test_element_id(local_x, local_y, top_offset);
3621            if let Some(ref id) = hit {
3622                let is_double = self.last_click_id.as_ref() == Some(id)
3623                    && now.saturating_sub(self.last_click_tick) <= DBLCLICK_MAX_TICKS;
3624                if is_double {
3625                    let has = self.js_runtime.dom.borrow().has_listener("dblclick");
3626                    if has {
3627                        let node = self.js_runtime.dom.borrow().get_element_by_id(id);
3628                        if let Some(i) = node {
3629                            self.js_runtime
3630                                .dispatch_mouse(i, "dblclick", local_x, local_y);
3631                            self.relayout_if_dom_dirty(top_offset, self.win_w, self.win_h);
3632                        }
3633                    }
3634                    // 3連続クリックを2回目+2回目…と誤判定しないようリセット。
3635                    self.last_click_id = None;
3636                    self.last_click_tick = 0;
3637                } else {
3638                    self.last_click_id = hit.clone();
3639                    self.last_click_tick = now;
3640                }
3641            } else {
3642                self.last_click_id = None;
3643            }
3644        }
3645
3646        // マウスイベント: ホバー要素の変化で mouseout/mouseover、現在要素へ mousemove を発火。
3647        // リスナが無いページではヒットテスト自体を省略(毎フレームのコスト回避)。
3648        let want_mouse_events = {
3649            let d = self.js_runtime.dom.borrow();
3650            d.has_listener("mouseover")
3651                || d.has_listener("mouseout")
3652                || d.has_listener("mousemove")
3653                || d.has_listener("mouseenter")
3654                || d.has_listener("mouseleave")
3655        };
3656        if want_mouse_events {
3657            let hx = local_x - 20;
3658            let hy = local_y - top_offset + self.scroll_y;
3659            let mut new_hover: Option<String> = None;
3660            for el in &self.elements {
3661                if el.element_id.is_empty() {
3662                    continue;
3663                }
3664                let hit_y = hy >= el.y_offset && hy < el.y_offset + el.height;
3665                let hit_x = if el.x_offset > 0 || el.width < 1000 {
3666                    hx >= el.x_offset && hx < el.x_offset + el.width
3667                } else {
3668                    hx >= 0
3669                };
3670                if hit_y && hit_x {
3671                    new_hover = Some(el.element_id.clone()); // 後勝ち=最前面寄り
3672                }
3673            }
3674            if new_hover != self.hovered_id {
3675                if let Some(old) = self.hovered_id.clone() {
3676                    let oi = self.js_runtime.dom.borrow().get_element_by_id(&old);
3677                    if let Some(i) = oi {
3678                        self.js_runtime
3679                            .dispatch_mouse(i, "mouseout", local_x, local_y);
3680                    }
3681                }
3682                if let Some(nh) = new_hover.clone() {
3683                    let ni = self.js_runtime.dom.borrow().get_element_by_id(&nh);
3684                    if let Some(i) = ni {
3685                        self.js_runtime
3686                            .dispatch_mouse(i, "mouseover", local_x, local_y);
3687                    }
3688                }
3689                self.hovered_id = new_hover.clone();
3690            }
3691            if let Some(nh) = new_hover {
3692                let ni = self.js_runtime.dom.borrow().get_element_by_id(&nh);
3693                if let Some(i) = ni {
3694                    self.js_runtime
3695                        .dispatch_mouse(i, "mousemove", local_x, local_y);
3696                }
3697            }
3698            if self.js_runtime.dom.borrow().dirty {
3699                self.dom_needs_rebuild = true;
3700                self.dirty = true;
3701                self.dirty_reason = "render.rs:3264";
3702            }
3703        }
3704
3705        if wheel != 0 {
3706            // wheel イベントをヒット要素(無ければフォーカス要素)へ発火。
3707            // preventDefault されたら既定のスクロールを抑制する。
3708            let mut wheel_prevented = false;
3709            if self.js_runtime.dom.borrow().has_listener("wheel") {
3710                if let Some(id) = self.hit_test_element_id(local_x, local_y, top_offset) {
3711                    let idx = self.js_runtime.dom.borrow().get_element_by_id(&id);
3712                    if let Some(i) = idx {
3713                        let (_f, prevented) = self
3714                            .js_runtime
3715                            .dispatch_mouse_full(i, "wheel", local_x, local_y, 0, wheel);
3716                        wheel_prevented = prevented;
3717                        self.relayout_if_dom_dirty(top_offset, self.win_w, self.win_h);
3718                    }
3719                }
3720            }
3721            if wheel_prevented {
3722                return;
3723            }
3724            let mut focus_on_textarea = false;
3725            let mut text_lines_len = 0;
3726            let mut text_area_h = 200;
3727            if let Some(ref focused_id) = self.focused_id {
3728                if let Some(el) = self.elements.iter().find(|e| &e.element_id == focused_id) {
3729                    if el.is_textarea {
3730                        focus_on_textarea = true;
3731                        text_area_h = el.height;
3732                        if let Some(val) = self.form_values.get(&el.element_id) {
3733                            text_lines_len =
3734                                Self::wrap_editor_lines(val, el.width, self.editor_wrap).len();
3735                        }
3736                    }
3737                }
3738            }
3739
3740            if focus_on_textarea {
3741                let max_scroll_y = (text_lines_len as i32 - (text_area_h / 20) + 1).max(0);
3742                self.editor_scroll_y = (self.editor_scroll_y + wheel).max(0).min(max_scroll_y);
3743                self.dirty = true;
3744                self.dirty_reason = "render.rs:3306";
3745            } else {
3746                self.scroll_with_top(wheel * 24, top_offset);
3747            }
3748        }
3749        if !btn_left {
3750            // ボタン離し: 全ドラッグ状態をリセット。
3751            self.dragging_scrollbar = false;
3752            self.dragging_sub_scrollbar = None;
3753        }
3754
3755        if btn_left {
3756            // サブスクロールバードラッグ: 開始判定。
3757            if self.dragging_sub_scrollbar.is_none() && !self.dragging_scrollbar {
3758                for (cid, max_y, cx, cy, cw, ch) in &self.scroll_containers {
3759                    if *max_y <= 0 {
3760                        continue;
3761                    }
3762                    let bar_x = cx + cw - 12;
3763                    let bar_y = cy - self.scroll_y + top_offset;
3764                    if local_x >= bar_x && local_x <= cx + cw
3765                        && local_y >= bar_y && local_y < bar_y + ch
3766                    {
3767                        self.dragging_sub_scrollbar = Some(cid.clone());
3768                        break;
3769                    }
3770                }
3771            }
3772            // サブスクロールバードラッグ: 進行中。
3773            if let Some(cid) = self.dragging_sub_scrollbar.clone() {
3774                let meta = self.scroll_containers.iter()
3775                    .find(|(id, ..)| *id == cid)
3776                    .cloned();
3777                if let Some((_cid, max_y, _cx, cy, _cw, ch)) = meta {
3778                    let content_h = max_y + ch;
3779                    let thumb_h = ((ch * ch) / content_h).max(20);
3780                    let bar_y = cy - self.scroll_y + top_offset;
3781                    let ratio = (local_y - bar_y - thumb_h / 2) as f32
3782                        / (ch - thumb_h) as f32;
3783                    let entry = self.scroll_regions.entry(cid).or_insert(0);
3784                    *entry = (ratio.clamp(0.0, 1.0) * max_y as f32) as i32;
3785                    self.dirty = true;
3786                    self.dirty_reason = "render.rs:3347";
3787                }
3788                return;
3789            }
3790
3791            if !self.dragging_scrollbar && self.max_scroll_y > 0 && local_y >= top_offset {
3792                // スクロールバー領域(右端12px)をクリックしたかチェック
3793                if local_x >= (self.win_w as i32 - 12) && local_x <= self.win_w as i32 {
3794                    self.dragging_scrollbar = true;
3795                }
3796            }
3797
3798            if self.dragging_scrollbar {
3799                let bar_track_h = self.win_h as i32 - top_offset;
3800                let content_h = self.max_scroll_y + bar_track_h;
3801                let thumb_h = ((bar_track_h * bar_track_h) / content_h).max(30);
3802
3803                let scroll_ratio =
3804                    (local_y - top_offset - (thumb_h / 2)) as f32 / (bar_track_h - thumb_h) as f32;
3805                let prev = self.scroll_y;
3806                self.scroll_y = (scroll_ratio.clamp(0.0, 1.0) * self.max_scroll_y as f32) as i32;
3807                if self.scroll_y != prev {
3808                    self.dirty = true;
3809                    self.dirty_reason = "render.rs:3369";
3810                }
3811                return;
3812            }
3813
3814            let click_y = local_y - top_offset + self.scroll_y;
3815            // コンテンツ領域の左端は描画時に x+20 オフセットしているため合わせる
3816            let click_x = local_x - 20;
3817
3818            let mut clicked_href = None;
3819            let mut new_focus = None;
3820            let mut script_to_run = None;
3821            let mut form_to_submit = None;
3822            let mut focused_input = false; // input/textarea をクリックした場合は button を無視
3823                                           // addEventListener('click') 発火用: ヒットした要素の id を収集(id を持つもの)。
3824            let mut clicked_ids: Vec<String> = Vec::new();
3825            // checkbox/radio/select のトグル動作: (id, input_kind, radio_group, input_value, options)
3826            let mut toggle_action: Option<(String, u8, String, String, Vec<(String, String)>)> =
3827                None;
3828            let mut reset_target_id: Option<String> = None;
3829            // <input type="range"> のクリック位置ジャンプ動作: (id, 新しい値の文字列)。
3830            // ドラッグには対応せず、クリック位置に応じて値を1回だけ設定する簡略実装。
3831            let mut range_action: Option<(String, String)> = None;
3832
3833            for el in &self.elements {
3834                let hit_y = click_y >= el.y_offset && click_y < el.y_offset + el.height;
3835                // x_offset が未設定(0)の要素は行全体ヒット可とし、設定済みなら正確な矩形判定
3836                let hit_x = if el.x_offset > 0 || el.width < 1000 {
3837                    click_x >= el.x_offset && click_x < el.x_offset + el.width
3838                } else {
3839                    click_x >= 0
3840                };
3841
3842                if hit_y && hit_x {
3843                    if !el.element_id.is_empty() {
3844                        clicked_ids.push(el.element_id.clone());
3845                    }
3846                    // <label> テキストのクリック: 関連付けられた input へ動作を転送する。
3847                    if !el.label_for.is_empty() {
3848                        let target_id = el.label_for.clone();
3849                        if let Some(t) = self.elements.iter().find(|e| e.element_id == target_id) {
3850                            if t.input_kind == 1 || t.input_kind == 2 || t.input_kind == 3 {
3851                                toggle_action = Some((
3852                                    t.element_id.clone(),
3853                                    t.input_kind,
3854                                    t.radio_group.clone(),
3855                                    t.input_value.clone(),
3856                                    t.select_options.clone(),
3857                                ));
3858                            } else if t.is_input || t.is_textarea {
3859                                new_focus = Some(t.element_id.clone());
3860                                focused_input = true;
3861                            }
3862                            break;
3863                        }
3864                    }
3865                    if el.input_kind == 1 || el.input_kind == 2 || el.input_kind == 3 {
3866                        // checkbox/radio/select: トグル動作をループ後に適用(self の二重借用回避)。
3867                        toggle_action = Some((
3868                            el.element_id.clone(),
3869                            el.input_kind,
3870                            el.radio_group.clone(),
3871                            el.input_value.clone(),
3872                            el.select_options.clone(),
3873                        ));
3874                        break;
3875                    } else if el.input_kind == 5 && !el.element_id.is_empty() {
3876                        // range: クリック x 位置から min/max/step(select_options 流用)に
3877                        // 基づいて新しい値を計算する。
3878                        let get = |k: &str, d: f32| -> f32 {
3879                            el.select_options
3880                                .iter()
3881                                .find(|(n, _)| n == k)
3882                                .and_then(|(_, v)| v.parse::<f32>().ok())
3883                                .unwrap_or(d)
3884                        };
3885                        let min = get("min", 0.0);
3886                        let max = get("max", 100.0);
3887                        let step = get("step", 1.0).max(0.0001);
3888                        let ratio = ((click_x - el.x_offset) as f32 / (el.width.max(1) as f32))
3889                            .clamp(0.0, 1.0);
3890                        let raw = min + (max - min) * ratio;
3891                        let stepped = (libm::roundf((raw - min) / step) * step + min).clamp(min, max);
3892                        range_action = Some((el.element_id.clone(), alloc::format!("{}", stepped)));
3893                        break;
3894                    } else if el.is_input || el.is_textarea {
3895                        new_focus = Some(el.element_id.clone());
3896                        focused_input = true;
3897                        break; // input にフォーカスしたら他の要素(button等)は無視
3898                    } else if el.is_video {
3899                        self.video_playing = !self.video_playing;
3900                        if self.video_playing {
3901                            let v_width = (self.win_w as i32 - 80).clamp(400, 800);
3902                            let mut state = VIDEO_STATE.lock();
3903                            *state = Some(VideoPlayState {
3904                                src_url: el.video_src.clone(),
3905                                x: el.x_offset.max(0) as u32,
3906                                y: el.y_offset.max(0) as u32,
3907                                width: v_width as u32,
3908                                height: el.height as u32,
3909                                playing: true,
3910                                stop_request: false,
3911                            });
3912                            // 【2026-08-05 バグ修正】`spawn` はヒープ不足で **0 を返す**。
3913                            // 直前に `playing: true` を立てているので、失敗したまま
3914                            // 戻さないと「再生スレッドは居ないのに再生中」になり、
3915                            // 停止も再開もできなくなる。
3916                            let pid = crate::kernel::scheduler::spawn(
3917                                video_playback_thread,
3918                                crate::kernel::scheduler::Priority::High,
3919                                "video_playback",
3920                            );
3921                            if pid == 0 {
3922                                crate::warn!("[VIDEO] 再生スレッドを起動できません(ヒープ不足)");
3923                                stop_video_playback();
3924                            }
3925                        } else {
3926                            stop_video_playback();
3927                        }
3928                        self.dirty = true;
3929                        self.dirty_reason = "render.rs:3480";
3930                        return;
3931                    } else if el.is_button && !focused_input {
3932                        script_to_run = Some(el.onclick.clone());
3933                        if el.is_reset {
3934                            // type=reset: 送信せず、所属 form を初期値へ戻す。
3935                            reset_target_id = Some(el.element_id.clone());
3936                            break;
3937                        }
3938                        // フォームアクションが明示されている場合のみ送信
3939                        if !el.form_action.is_empty() {
3940                            let method = if el.form_method.is_empty() {
3941                                String::from("GET")
3942                            } else {
3943                                el.form_method.clone()
3944                            };
3945                            form_to_submit = Some((el.form_action.clone(), method));
3946                        } else {
3947                            // form_action 未指定でも submit type なら現在パスへ送信
3948                            let method = if el.form_method.is_empty() {
3949                                String::from("GET")
3950                            } else {
3951                                el.form_method.clone()
3952                            };
3953                            form_to_submit = Some((self.current_path.clone(), method));
3954                        }
3955                        break;
3956                    }
3957                }
3958            }
3959
3960            // --- details / summary の開閉トグル ---
3961            let mut details_toggle_id = None;
3962            for id in &clicked_ids {
3963                let node = self.js_runtime.dom.borrow().get_element_by_id(id);
3964                if let Some(idx) = node {
3965                    let dom = self.js_runtime.dom.borrow();
3966                    let n = &dom.nodes[idx];
3967                    if n.tag == "summary" {
3968                        if let Some(parent_idx) = n.parent {
3969                            let parent = &dom.nodes[parent_idx];
3970                            if parent.tag == "details" {
3971                                let p_id = if parent.id.is_empty() {
3972                                    alloc::format!("_node_idx_{}", parent_idx)
3973                                } else {
3974                                    parent.id.clone()
3975                                };
3976                                details_toggle_id = Some(p_id);
3977                                break;
3978                            }
3979                        }
3980                    }
3981                }
3982            }
3983
3984            if let Some(pid) = details_toggle_id {
3985                let node = self.js_runtime.dom.borrow().get_element_by_id(&pid);
3986                if let Some(i) = node {
3987                    let mut closed_group_siblings = alloc::vec::Vec::new();
3988                    {
3989                        let mut dom = self.js_runtime.dom.borrow_mut();
3990                        if dom.has_attr(i, "open") {
3991                            dom.remove_attr(i, "open");
3992                        } else {
3993                            dom.set_attr(i, "open", "");
3994                            // `<details name="...">`排他グループ(丸ごと未対応
3995                            // だった。2026-07-18 発見・実装)。自身を開いたら
3996                            // 同じグループの他の`<details>`を閉じる。
3997                            closed_group_siblings = dom.close_details_group_siblings(i);
3998                        }
3999                    }
4000                    for &sibling in &closed_group_siblings {
4001                        self.js_runtime.dispatch_event_with(
4002                            sibling,
4003                            "toggle",
4004                            &[
4005                                (alloc::string::String::from("oldState"), crate::os_lib::js::value::Value::str("open")),
4006                                (alloc::string::String::from("newState"), crate::os_lib::js::value::Value::str("closed")),
4007                            ],
4008                        );
4009                    }
4010                    self.dirty = true;
4011                    self.dirty_reason = "render.rs:3561";
4012                    // `<details>.addEventListener('toggle', ...)`(丸ごと
4013                    // 未対応だった。開閉状態の切り替え自体は上で属性の
4014                    // 付け外しとして実装済みだったが、対になるイベント
4015                    // 発火が無く、JS側から開閉タイミングを検知する定番
4016                    // パターンが使えなかった。仕様どおり状態変更「後」に
4017                    // 発火する。2026-07-17 発見・実装)。仕様上の
4018                    // `ToggleEvent`(`oldState`/`newState`)専用コンストラクタ
4019                    // は無いため、プレーンな`Event`へ同名プロパティを追加する
4020                    // 簡略実装で代用する。
4021                    let new_state = if self.js_runtime.dom.borrow().has_attr(i, "open") {
4022                        "open"
4023                    } else {
4024                        "closed"
4025                    };
4026                    let old_state = if new_state == "open" { "closed" } else { "open" };
4027                    self.js_runtime.dispatch_event_with(
4028                        i,
4029                        "toggle",
4030                        &[
4031                            (alloc::string::String::from("oldState"), crate::os_lib::js::value::Value::str(old_state)),
4032                            (alloc::string::String::from("newState"), crate::os_lib::js::value::Value::str(new_state)),
4033                        ],
4034                    );
4035                }
4036                if self.js_runtime.dom.borrow().dirty {
4037                    self.dom_needs_rebuild = true;
4038                }
4039                return;
4040            }
4041
4042            // --- `popovertarget`/`popovertargetaction`(Popover API の宣言的
4043            // トリガー。丸ごと未対応だった)。`<button popovertarget="id">` の
4044            // クリックで対象要素の内部専用マーカー属性 `_popover_open` を
4045            // `popovertargetaction`("show"/"hide"/既定"toggle")に応じて
4046            // 付け外しする。JS 側の `showPopover()`/`hidePopover()`/
4047            // `togglePopover()`(builtins.rs)と全く同じ状態表現を共有する。
4048            let mut popover_toggle: Option<(usize, &'static str)> = None;
4049            for id in &clicked_ids {
4050                let node = self.js_runtime.dom.borrow().get_element_by_id(id);
4051                if let Some(idx) = node {
4052                    let dom = self.js_runtime.dom.borrow();
4053                    if let Some(target_id) = dom.get_attr(idx, "popovertarget") {
4054                        if let Some(target_idx) = dom.get_element_by_id(&target_id) {
4055                            let action = match dom.get_attr(idx, "popovertargetaction").as_deref() {
4056                                Some("show") => "show",
4057                                Some("hide") => "hide",
4058                                _ => "toggle",
4059                            };
4060                            popover_toggle = Some((target_idx, action));
4061                            break;
4062                        }
4063                    }
4064                }
4065            }
4066            if let Some((idx, action)) = popover_toggle {
4067                {
4068                    let mut dom = self.js_runtime.dom.borrow_mut();
4069                    let is_open = dom.has_attr(idx, "_popover_open");
4070                    let open = match action {
4071                        "show" => true,
4072                        "hide" => false,
4073                        _ => !is_open,
4074                    };
4075                    if open {
4076                        dom.set_attr(idx, "_popover_open", "");
4077                    } else {
4078                        dom.remove_attr(idx, "_popover_open");
4079                    }
4080                }
4081                self.dirty = true;
4082                self.dirty_reason = "render.rs:3631";
4083                if self.js_runtime.dom.borrow().dirty {
4084                    self.dom_needs_rebuild = true;
4085                }
4086                return;
4087            }
4088
4089            // --- checkbox / radio / select のトグル適用 + change イベント発火 ---
4090            if let Some((tid, kind, group, value, options)) = toggle_action {
4091                if !tid.is_empty() {
4092                    if kind == 1 {
4093                        // checkbox: チェック状態を反転(空 <-> value)。
4094                        let cur = self.form_values.get(&tid).cloned().unwrap_or_default();
4095                        let next = if cur.is_empty() { value } else { String::new() };
4096                        self.form_values.insert(tid.clone(), next);
4097                    } else if kind == 2 {
4098                        // radio: 同一グループ内の他要素を空にし、自身を value にする。
4099                        if !group.is_empty() {
4100                            let group_ids: Vec<String> = self
4101                                .elements
4102                                .iter()
4103                                .filter(|e| {
4104                                    e.input_kind == 2
4105                                        && e.radio_group == group
4106                                        && !e.element_id.is_empty()
4107                                })
4108                                .map(|e| e.element_id.clone())
4109                                .collect();
4110                            for gid in group_ids {
4111                                self.form_values.insert(gid, String::new());
4112                            }
4113                        }
4114                        self.form_values.insert(tid.clone(), value);
4115                    } else if kind == 3 {
4116                        // select: 現在値の次の option へ循環(簡易ドロップダウン代替)。
4117                        if !options.is_empty() {
4118                            let cur = self.form_values.get(&tid).cloned().unwrap_or_default();
4119                            let cur_idx = options.iter().position(|(v, _)| *v == cur).unwrap_or(0);
4120                            let next_idx = (cur_idx + 1) % options.len();
4121                            self.form_values
4122                                .insert(tid.clone(), options[next_idx].0.clone());
4123                        }
4124                    }
4125                    // change イベントを発火(DOM 値も同期)。
4126                    let new_val = self.form_values.get(&tid).cloned().unwrap_or_default();
4127                    let node = self.js_runtime.dom.borrow().get_element_by_id(&tid);
4128                    if let Some(i) = node {
4129                        // checkbox/radio の checked、select の value を DOM 属性へ反映。
4130                        {
4131                            let mut dom = self.js_runtime.dom.borrow_mut();
4132                            if kind == 1 || kind == 2 {
4133                                if new_val.is_empty() {
4134                                    dom.remove_attr(i, "checked");
4135                                } else {
4136                                    dom.set_attr(i, "checked", "");
4137                                }
4138                            } else {
4139                                dom.set_attr(i, "value", &new_val);
4140                            }
4141                        }
4142                        self.js_runtime.dispatch_event(i, "change");
4143                    }
4144                    self.dirty = true;
4145                    self.dirty_reason = "render.rs:3693";
4146                }
4147                if self.js_runtime.dom.borrow().dirty {
4148                    self.dom_needs_rebuild = true;
4149                }
4150                return;
4151            }
4152
4153            if let Some((tid, new_val)) = range_action {
4154                self.form_values.insert(tid.clone(), new_val.clone());
4155                let node = self.js_runtime.dom.borrow().get_element_by_id(&tid);
4156                if let Some(i) = node {
4157                    {
4158                        let mut dom = self.js_runtime.dom.borrow_mut();
4159                        dom.set_attr(i, "value", &new_val);
4160                    }
4161                    self.js_runtime.dispatch_event(i, "input");
4162                    self.js_runtime.dispatch_event(i, "change");
4163                }
4164                self.dirty = true;
4165                self.dirty_reason = "render.rs:3712";
4166                if self.js_runtime.dom.borrow().dirty {
4167                    self.dom_needs_rebuild = true;
4168                }
4169                return;
4170            }
4171
4172            // フォーカス変更処理
4173            let old_focus = self.focused_id.clone();
4174            self.focused_id = new_focus;
4175            // `document.activeElement`(HTML5。丸ごと未対応だった)が実際の
4176            // クリック起点フォーカス変更も反映できるよう、JS 側の
4177            // `DomBridge::focused_idx` にも同期する。
4178            {
4179                let didx = match &self.focused_id {
4180                    Some(fid) => self.js_runtime.dom.borrow().get_element_by_id(fid),
4181                    None => None,
4182                };
4183                self.js_runtime.dom.borrow_mut().focused_idx = didx;
4184            }
4185
4186            if old_focus != self.focused_id {
4187                // `:focus-within` は要素自身の specified_values へ直接反映される
4188                // (:hover/:focus のような「事前計算した2値を描画時に選ぶ」方式ではなく、
4189                // style_tree 実行時に一度だけ判定される)ため、フォーカス変更のたびに
4190                // 再スタイル/再レイアウトを要求する(`:target` の navigate_to_fragment と
4191                // 同じ理由・同じ方式)。
4192                self.dom_needs_rebuild = true;
4193                // 古いフォーカス要素のテキストを保存
4194                if let Some(ref fid) = old_focus {
4195                    if let Some(ref tb) = self.active_text_box {
4196                        self.form_values.insert(fid.clone(), tb.text.clone());
4197                    }
4198                }
4199                // 旧要素: blur を発火し、値が変わっていれば change も発火。
4200                if let Some(fid) = old_focus.clone() {
4201                    let node = self.js_runtime.dom.borrow().get_element_by_id(&fid);
4202                    if let Some(i) = node {
4203                        let cur = self.form_values.get(&fid).cloned().unwrap_or_default();
4204                        self.js_runtime.dispatch_event(i, "blur");
4205                        // focusout: blur のバブリング版(順序は blur の直後)。
4206                        self.js_runtime.dispatch_event(i, "focusout");
4207                        if cur != self.focus_value {
4208                            self.js_runtime.dispatch_event(i, "change");
4209                        }
4210                    }
4211                }
4212                // 新しいフォーカス要素のテキストをロード。
4213                // 【2026-09-08 修正】ここは常に `with_text()`(単一行)で作っていたため、
4214                // <textarea> をクリックして入り直すと multiline フラグが落ち、
4215                // Tab のタブストップ展開が効かなくなっていた。要素種別を見て作る。
4216                self.active_text_box = None;
4217                if let Some(fid) = self.focused_id.clone() {
4218                    let is_ta = self
4219                        .elements
4220                        .iter()
4221                        .find(|e| e.element_id == fid)
4222                        .map(|e| e.is_textarea)
4223                        .unwrap_or(false);
4224                    let val = self.form_values.get(&fid).cloned().unwrap_or_default();
4225                    // フォーカス移動なのでキャレットは復元せず末尾から始める。
4226                    let tb = Self::build_text_box(&val, is_ta, None);
4227                    self.focus_cursor_pos = tb.cursor_pos;
4228                    self.focus_caret_owner = fid;
4229                    self.active_text_box = Some(tb);
4230                }
4231                // 新要素: focus を発火し、change 判定用に現在値を記録。
4232                if let Some(fid) = self.focused_id.clone() {
4233                    self.focus_value = self.form_values.get(&fid).cloned().unwrap_or_default();
4234                    let node = self.js_runtime.dom.borrow().get_element_by_id(&fid);
4235                    if let Some(i) = node {
4236                        self.js_runtime.dispatch_event(i, "focus");
4237                        // focusin: focus のバブリング版(focus の直後)。
4238                        self.js_runtime.dispatch_event(i, "focusin");
4239                    }
4240                } else {
4241                    self.focus_value = String::new();
4242                }
4243                if self.js_runtime.dom.borrow().dirty {
4244                    self.dom_needs_rebuild = true;
4245                }
4246                self.dirty = true;
4247                self.dirty_reason = "render.rs:3782";
4248            }
4249
4250            // type=reset ボタン: フォームを初期値へ戻す(送信・遷移は行わない)。
4251            if let Some(rid) = reset_target_id {
4252                self.reset_form(&rid);
4253                if let Some(script) = script_to_run {
4254                    if !script.is_empty() {
4255                        // 【2026-08-31 バグ修正】onclick/onreset ハンドラの実行エラーを
4256                        // 握り潰していた。捕捉されなかった例外があってもログが残らず、
4257                        // 原因調査の手がかりが消える。layout.rs の同種経路(GPU TRACE)
4258                        // に合わせて記録する。
4259                        if let Err(e) = crate::os_lib::js::eval(&script, &mut self.js_runtime) {
4260                            crate::debug!("[JS] onclick/onreset handler error: {}", e);
4261                        }
4262                    }
4263                }
4264                self.dirty = true;
4265                self.dirty_reason = "render.rs:3793";
4266                return;
4267            }
4268
4269            // input にフォーカスしたケースでは送信しない
4270            if !focused_input {
4271                if let Some((action, method)) = form_to_submit {
4272                    self.submit_form(&action, &method, top_offset);
4273                    // フォーム送信したら同一クリックでのリンク遷移は行わない
4274                    // (送信が後続の navigate_to_href に上書きされる不具合を防ぐ)
4275                    if let Some(script) = script_to_run {
4276                        if !script.is_empty() {
4277                            // 【2026-08-31 バグ修正】onclick/onreset ハンドラの実行エラーを
4278                            // 握り潰していた。捕捉されなかった例外があってもログが残らず、
4279                            // 原因調査の手がかりが消える。layout.rs の同種経路(GPU TRACE)
4280                            // に合わせて記録する。
4281                            if let Err(e) = crate::os_lib::js::eval(&script, &mut self.js_runtime) {
4282                                crate::debug!("[JS] onclick/onreset handler error: {}", e);
4283                            }
4284                        }
4285                    }
4286                    return;
4287                }
4288            }
4289
4290            // リンク判定(click_x ではなく local_x で保存されている)
4291            for link in &self.links {
4292                if click_y >= link.y0
4293                    && click_y < link.y1
4294                    && local_x >= link.x0
4295                    && local_x < link.x1
4296                {
4297                    clicked_href = Some(link.href.clone());
4298                    break;
4299                }
4300            }
4301            if let Some(href) = clicked_href {
4302                self.navigate_to_href(&href, top_offset);
4303            }
4304
4305            if let Some(script) = script_to_run {
4306                if !script.is_empty() {
4307                    // 【2026-08-31 バグ修正】onclick/onreset ハンドラの実行エラーを
4308                    // 握り潰していた。捕捉されなかった例外があってもログが残らず、
4309                    // 原因調査の手がかりが消える。layout.rs の同種経路(GPU TRACE)
4310                    // に合わせて記録する。
4311                    if let Err(e) = crate::os_lib::js::eval(&script, &mut self.js_runtime) {
4312                        crate::debug!("[JS] onclick/onreset handler error: {}", e);
4313                    }
4314                }
4315            }
4316
4317            // addEventListener('click') の発火: ヒットした要素 id を順に試し、
4318            // リスナを持つ最初の要素で発火する(id ベース)。
4319            for id in &clicked_ids {
4320                let node = self.js_runtime.dom.borrow().get_element_by_id(id);
4321                if let Some(idx) = node {
4322                    if self
4323                        .js_runtime
4324                        .dispatch_mouse(idx, "click", local_x, local_y)
4325                        .0
4326                    {
4327                        break;
4328                    }
4329                }
4330            }
4331
4332            // JS の form.reset() による要求を処理(DOM を初期値へ戻す)。
4333            self.process_pending_reset();
4334
4335            // onclick / リスナによる DOM 変更を画面へ反映。
4336            self.relayout_if_dom_dirty(top_offset, self.win_w, self.win_h);
4337
4338            // JS の history.back/forward/go() による戻る/進む要求を処理(popstate 発火含む)。
4339            self.process_pending_nav(top_offset);
4340            // location 代入 / assign / replace / reload によるナビゲーション要求を処理。
4341            self.process_pending_location(top_offset);
4342        } else {
4343            self.dragging_scrollbar = false;
4344            self.dragging_sub_scrollbar = None;
4345        }
4346    }
4347
4348    pub fn on_key(&mut self, keycode: u8, _pressed: bool, ascii: Option<char>) {
4349        if !_pressed {
4350            return;
4351        }
4352
4353        // フォーカス要素に keydown を発火(バブリング)。preventDefault されたら既定の編集を抑制。
4354        if let Some(fid) = self.focused_id.clone() {
4355            let node = self.js_runtime.dom.borrow().get_element_by_id(&fid);
4356            if let Some(idx) = node {
4357                let key_name = match ascii {
4358                    Some(c) => {
4359                        let mut s = String::new();
4360                        s.push(c);
4361                        s
4362                    }
4363                    None => String::from(key_name_for(keycode)),
4364                };
4365                let (_fired, prevented) = self.js_runtime.dispatch_key(idx, "keydown", &key_name);
4366                self.relayout_if_dom_dirty(self.top_offset, self.win_w, self.win_h);
4367                // keydown リスナが history.back/forward/go() を呼んだ場合の戻る/進むを処理。
4368                if self.process_pending_nav(self.top_offset) {
4369                    return;
4370                }
4371                self.process_pending_reset();
4372                // keydown リスナが location 代入等で遷移要求を出した場合を処理。
4373                if self.process_pending_location(self.top_offset) {
4374                    return;
4375                }
4376                if prevented {
4377                    return; // 既定動作(文字入力・送信)を抑制
4378                }
4379                // keypress: 文字を生成するキー(ascii あり)のみ、keydown の直後に発火。
4380                // 修飾のみのキーや矢印キーでは発火しない(仕様準拠の簡易版)。
4381                if ascii.is_some() {
4382                    let (_f, kp_prevented) =
4383                        self.js_runtime.dispatch_key(idx, "keypress", &key_name);
4384                    self.relayout_if_dom_dirty(self.top_offset, self.win_w, self.win_h);
4385                    if kp_prevented {
4386                        return;
4387                    }
4388                }
4389            }
4390        }
4391
4392        let mut focus_on_textarea = false;
4393        let mut textarea_el = None;
4394        if let Some(ref focused_id) = self.focused_id {
4395            if let Some(el) = self.elements.iter().find(|e| &e.element_id == focused_id) {
4396                if el.is_textarea {
4397                    focus_on_textarea = true;
4398                    textarea_el = Some(el.clone());
4399                }
4400            }
4401        }
4402
4403        // フォーカス中の input(テキスト) で Enter → フォーム送信。
4404        // textarea は改行入力のため対象外。送信先はページ内の送信ボタン
4405        // (form_action を持つ is_button) から解決する。
4406        if keycode == 0x1C && !focus_on_textarea && self.focused_id.is_some() {
4407            self.flush_focused_input();
4408            let submit = self
4409                .elements
4410                .iter()
4411                .find(|e| e.is_button && !e.form_action.is_empty())
4412                .map(|e| {
4413                    let m = if e.form_method.is_empty() {
4414                        String::from("GET")
4415                    } else {
4416                        e.form_method.clone()
4417                    };
4418                    (e.form_action.clone(), m)
4419                });
4420            if let Some((action, method)) = submit {
4421                let top = self.top_offset;
4422                self.submit_form(&action, &method, top);
4423                return;
4424            }
4425        }
4426
4427        // 共通テキストボックスでの編集処理
4428        if let Some(ref focused_id) = self.focused_id {
4429            if self.active_text_box.is_none() {
4430                let val = self
4431                    .form_values
4432                    .get(focused_id)
4433                    .cloned()
4434                    .unwrap_or_default();
4435                let is_ta = self
4436                    .elements
4437                    .iter()
4438                    .find(|e| &e.element_id == focused_id)
4439                    .map(|e| e.is_textarea)
4440                    .unwrap_or(false);
4441                let restore = if &self.focus_caret_owner == focused_id {
4442                    Some(self.focus_cursor_pos)
4443                } else {
4444                    None
4445                };
4446                self.active_text_box = Some(Self::build_text_box(&val, is_ta, restore));
4447            }
4448            let mut handled = false;
4449            let mut text_changed = false;
4450            let mut cursor_pos = 0;
4451            let mut new_text = String::new();
4452            if let Some(ref mut tb) = self.active_text_box {
4453                let outcome = tb.handle_key_outcome(keycode, ascii);
4454                if outcome != crate::kernel::text_box::KeyOutcome::Ignored {
4455                    self.form_values.insert(focused_id.clone(), tb.text.clone());
4456                    self.dirty = true;
4457                    self.dirty_reason = "render.rs:3969";
4458                    handled = true;
4459                    text_changed =
4460                        outcome == crate::kernel::text_box::KeyOutcome::TextChanged;
4461                    cursor_pos = tb.cursor_pos;
4462                    new_text = tb.text.clone();
4463                }
4464            }
4465            if handled {
4466                // キャレット位置を退避しておく。次の relayout で TextBox が
4467                // 捨てられても、作り直すときにここから復元する。
4468                self.focus_cursor_pos = cursor_pos;
4469                self.focus_caret_owner = focused_id.clone();
4470                // DOM の value 属性へ反映し(JS の el.value 用)、input イベントを発火。
4471                // relayout は &mut self で focused_id 借用と競合するため、フラグで次描画に委ねる。
4472                // 矢印キー等の**カーソル移動だけ**では value は変わっていない。
4473                // それでも set_attr + input を撃つと DOM が dirty になり、
4474                // 再レイアウトで編集状態が壊れる(note の ↑ が効かない不具合)。
4475                if text_changed {
4476                    let didx = self.js_runtime.dom.borrow().get_element_by_id(focused_id);
4477                    if let Some(i) = didx {
4478                        self.js_runtime
4479                            .dom
4480                            .borrow_mut()
4481                            .set_attr(i, "value", &new_text);
4482                        self.js_runtime.dispatch_event(i, "input");
4483                        if self.js_runtime.dom.borrow().dirty {
4484                            self.dom_needs_rebuild = true;
4485                        }
4486                        self.dirty = true;
4487                        self.dirty_reason = "render.rs:3988";
4488                    }
4489                }
4490                if focus_on_textarea {
4491                    if let Some(ref el) = textarea_el {
4492                        let text_val = self
4493                            .form_values
4494                            .get(focused_id)
4495                            .cloned()
4496                            .unwrap_or(String::new());
4497                        let cursor_line = self.get_cursor_line_idx(&text_val, cursor_pos, el.width);
4498
4499                        let visible_lines = ((el.height - 16) / 20).max(1);
4500                        let scroll_y = self.editor_scroll_y;
4501
4502                        if (cursor_line as i32) < scroll_y {
4503                            self.editor_scroll_y = cursor_line as i32;
4504                        } else if (cursor_line as i32) >= scroll_y + visible_lines {
4505                            self.editor_scroll_y = cursor_line as i32 - visible_lines + 1;
4506                        }
4507
4508                        // スクロール上限の再計算
4509                        let lines_len =
4510                            Self::wrap_editor_lines(&text_val, el.width, self.editor_wrap).len();
4511                        let max_scroll_y = (lines_len as i32 - visible_lines + 1).max(0);
4512                        self.editor_scroll_y = self.editor_scroll_y.min(max_scroll_y).max(0);
4513                    }
4514                }
4515                return;
4516            }
4517        }
4518
4519        if keycode == 0x48 {
4520            // Up
4521            self.scroll_with_top(-24, self.top_offset);
4522        } else if keycode == 0x50 {
4523            // Down
4524            self.scroll_with_top(24, self.top_offset);
4525        }
4526    }
4527
4528    /// 離鍵(keyup)をフォーカス要素へ発火する。押下系の既定動作(文字入力・スクロール等)は
4529    /// 行わず、JS リスナへの通知のみを担う。keyup リスナが history.back 等を呼んだ場合も処理する。
4530    pub fn on_key_up(&mut self, keycode: u8, ascii: Option<char>) {
4531        if let Some(fid) = self.focused_id.clone() {
4532            let node = self.js_runtime.dom.borrow().get_element_by_id(&fid);
4533            if let Some(idx) = node {
4534                let key_name = match ascii {
4535                    Some(c) => {
4536                        let mut s = String::new();
4537                        s.push(c);
4538                        s
4539                    }
4540                    None => String::from(key_name_for(keycode)),
4541                };
4542                let _ = self.js_runtime.dispatch_key(idx, "keyup", &key_name);
4543                self.relayout_if_dom_dirty(self.top_offset, self.win_w, self.win_h);
4544                self.process_pending_reset();
4545                self.process_pending_nav(self.top_offset);
4546                self.process_pending_location(self.top_offset);
4547            }
4548        }
4549    }
4550}