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