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