Skip to main content

atmos/apps/browser/
mod.rs

1//! AtmOS 内蔵 Webブラウザアプリ。
2//!
3//! [`crate::os_lib::web_engine::WebEngine`](HTML/CSS パーサ・レイアウト・レンダラ・
4//! JavaScript 連携)をタブごとに1インスタンス保持し、戻る/進む/アドレスバー等の
5//! ブラウザ UI を提供する。各タブは完全に独立した `WebEngine` を持つため
6//! ([`TabSlot`])、タブ切替時にネットワーク再取得は発生しない。
7//!
8//! 詳細なアーキテクチャ・座標規約・境界仕様は `spec/DESIGN.md` §3「ブラウザエンジン
9//! アーキテクチャ」を参照。
10// browser.rs - Web Browser Application utilizing WebEngine for AtmOS
11#![allow(dead_code)]
12
13use crate::kernel::draw;
14use crate::os_lib::web_engine::{draw_border, url_encode, WebEngine};
15use alloc::string::String;
16use alloc::vec::Vec;
17
18/// コントロールバー (戻る/進む/アドレスバー等) の高さ。
19/// コンテンツ描画の開始 Y 座標でもあり、描画・クリック判定・レイアウトが共通参照する。
20const CONTROL_BAR_H: i32 = 40;
21
22/// タブごとのページ状態を保存する
23/// 各タブのスロット。タブごとに独立した WebEngine を保持する(完全分離方式)。
24/// アクティブタブの engine は `WebBrowser::engine`(ライブ)へ「チェックアウト」され、
25/// このスロットの `engine` は `None` になる。非アクティブタブは描画済み DOM・スクロール・
26/// フォーム入力をそのまま保持するため、タブ切替でネットワーク再取得は発生しない。
27struct TabSlot {
28    title: String,
29    engine: Option<WebEngine>,
30}
31
32static SVG_BACK: &[u8] = b"<svg width=\"20\" height=\"20\" viewBox=\"0 0 20 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M12.5 16.5L6 10L12.5 3.5\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>";
33
34static SVG_FORWARD: &[u8] = b"<svg width=\"20\" height=\"20\" viewBox=\"0 0 20 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M7.5 3.5L14 10L7.5 16.5\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>";
35
36static SVG_HOME: &[u8] = b"<svg width=\"20\" height=\"20\" viewBox=\"0 0 20 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M10 2.5L3.5 8V16.5C3.5 17 4 17.5 4.5 17.5H7.5V12.5H12.5V17.5H15.5C16 17.5 16.5 17 16.5 16.5V8L10 2.5Z\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linejoin=\"round\"/></svg>";
37
38static SVG_SHIELD_SECURE: &[u8] = b"<svg width=\"20\" height=\"20\" viewBox=\"0 0 20 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M10 2.5C13 3.5 16.5 3.5 16.5 3.5V8.5C16.5 13 13 16.5 10 17.5C7 16.5 3.5 13 3.5 8.5V3.5C3.5 3.5 7 3.5 10 2.5Z\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linejoin=\"round\"/><path d=\"M7 10L9 12L13 8\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>";
39
40static SVG_SHIELD_WARN: &[u8] = b"<svg width=\"20\" height=\"20\" viewBox=\"0 0 20 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M10 2.5C13 3.5 16.5 3.5 16.5 3.5V8.5C16.5 13 13 16.5 10 17.5C7 16.5 3.5 13 3.5 8.5V3.5C3.5 3.5 7 3.5 10 2.5Z\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linejoin=\"round\"/><path d=\"M4.5 4.5L15.5 15.5\" stroke=\"currentColor\" stroke-width=\"2\"/></svg>";
41
42static SVG_RELOAD: &[u8] = b"<svg width=\"20\" height=\"20\" viewBox=\"0 0 20 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M17 10C17 13.866 13.866 17 10 17C6.13401 17 3 13.866 3 10C3 6.13401 6.13401 3 10 3C12.186 3 14.136 4.003 15.42 5.58M17 3V6H14\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>";
43
44/// OS 内蔵の静的ホームページをエンジンへロードする(ネットワーク不要)。
45fn render_home(engine: &mut WebEngine, top: i32) {
46    engine.current_is_https = false;
47    engine.load_html(crate::os_lib::web_engine::HOME_HTML, "home", top);
48    engine.title = String::from("AtmOS Browser");
49}
50
51// 新しい WebBrowser (マルチタブ・アドレスバー等を持つ具体的なブラウザアプリケーション)
52pub struct WebBrowser {
53    /// アクティブタブのライブエンジン。tabs[active_tab_idx].engine は None。
54    pub engine: WebEngine,
55    pub address_bar_focused: bool,
56    pub address_box: crate::kernel::text_box::TextBox,
57    pub active_tab_idx: usize,
58    tabs: Vec<TabSlot>,
59}
60
61/// 【2026-08-01計測】再描画に入った理由の内訳。
62/// 描画 1 回が約 130 tick かかるので、冗長な再描画は直接コストになる。
63static REDRAW_LOADING: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0);
64static REDRAW_DIRTY: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0);
65static REDRAW_VIDEO: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0);
66static REDRAW_IMG: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0);
67
68/// 内訳をログへ出す(描画側から一定間隔で呼ぶ)。
69pub fn log_redraw_reasons() {
70}
71
72impl WebBrowser {
73    /// コンテンツ描画の開始 Y 座標 (コントロールバーの直下)。
74    /// 描画・クリック判定・レイアウトすべてがこの値を共通参照する。
75    pub fn top_offset(&self) -> i32 {
76        CONTROL_BAR_H
77    }
78
79    /// アクティブエンジンのタイトルを現タブスロットに同期する(タブバー表示用)。
80    fn sync_active_title(&mut self) {
81        let title = if self.engine.title.is_empty() {
82            self.engine.current_host.clone()
83        } else {
84            self.engine.title.clone()
85        };
86        if let Some(slot) = self.tabs.get_mut(self.active_tab_idx) {
87            slot.title = title;
88        }
89    }
90
91    /// アクティブタブを `idx` へ切り替える。ライブエンジンを現タブスロットへ戻し、
92    /// 切替先スロットのエンジンを mem::swap でライブへ取り出す(クローン・再取得なし)。
93    fn activate(&mut self, idx: usize) {
94        if idx == self.active_tab_idx || idx >= self.tabs.len() {
95            return;
96        }
97        self.sync_active_title();
98        // 切替先のエンジンを取り出す
99        if let Some(mut next) = self.tabs[idx].engine.take() {
100            // self.engine(現アクティブ=A) と next(切替先=B) を入れ替える
101            core::mem::swap(&mut self.engine, &mut next);
102            // next は今や A のエンジン。元のアクティブスロットへ戻す
103            self.tabs[self.active_tab_idx].engine = Some(next);
104            self.active_tab_idx = idx;
105        }
106        self.engine.active = true;
107        self.engine.dirty = true;
108        self.engine.dirty_reason = "mod.rs:105";
109        self.address_box.set_text(&self.engine.get_url());
110        self.address_bar_focused = false;
111    }
112}
113
114impl Default for WebBrowser {
115    fn default() -> Self {
116        Self::new()
117    }
118}
119
120impl WebBrowser {
121    pub fn new() -> Self {
122        // 起動時に前回の古いキャッシュをクリア(壊れたキャッシュによる誤表示を防ぐ)
123        crate::os_lib::web_engine::clear_browser_cache();
124        let mut engine = WebEngine::new();
125        engine.win_w = 800;
126        engine.win_h = 600;
127        // 起動時は OS 内蔵の静的ホームページを表示する(ネットワーク不要)
128        render_home(&mut engine, CONTROL_BAR_H);
129        let initial_url = engine.get_url();
130        // 初期タブスロット(アクティブなので engine は None = ライブへチェックアウト済み)
131        let initial_slot = TabSlot {
132            title: String::from("AtmOS Browser"),
133            engine: None,
134        };
135        Self {
136            engine,
137            address_bar_focused: false,
138            address_box: crate::kernel::text_box::TextBox::with_text(&initial_url),
139            active_tab_idx: 0,
140            tabs: alloc::vec![initial_slot],
141        }
142    }
143}
144
145// グローバルなブラウザインスタンス
146static mut GLOBAL_BROWSER: Option<WebBrowser> = None;
147
148// グローバルインスタンスの初期化と取得
149pub fn init() {
150    unsafe {
151        GLOBAL_BROWSER = Some(WebBrowser::new());
152    }
153}
154
155pub fn get_instance() -> &'static mut WebBrowser {
156    // init() 前に呼ばれた場合も panic せず、その場で初期化する
157    unsafe { GLOBAL_BROWSER.get_or_insert_with(WebBrowser::new) }
158}
159
160pub fn is_active() -> bool {
161    unsafe {
162        match &GLOBAL_BROWSER {
163            Some(b) => b.engine.active,
164            None => false,
165        }
166    }
167}
168
169/// CSS `cursor: text` の要素上にマウスがあるか(OS 側の実マウスカーソル形状決定用)。
170pub fn hovered_cursor_is_text() -> bool {
171    unsafe {
172        match &GLOBAL_BROWSER {
173            Some(b) => b.engine.active && b.engine.hovered_cursor_is_text,
174            None => false,
175        }
176    }
177}
178
179pub fn needs_render() -> bool {
180    unsafe {
181        match &GLOBAL_BROWSER {
182            Some(b) => {
183                // 【2026-08-01計測】実際に全画面描画を起動しているのは
184                // `needs_timer_redraw` ではなく**こちら**。
185                // 先に `needs_timer_redraw` 側で数えたが、呼ばれる頻度が
186                // 違うため全画面描画の回数と突き合わせられなかった。
187                let r = b.engine.active && (b.engine.loading || b.engine.dirty);
188                if r {
189                    if b.engine.loading {
190                        RENDER_LOADING.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
191                    }
192                    if b.engine.dirty {
193                        RENDER_DIRTY.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
194                    }
195                }
196                r
197            }
198            None => false,
199        }
200    }
201}
202
203/// 【2026-08-01計測】`needs_render` が true を返した理由の内訳。
204static RENDER_LOADING: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0);
205static RENDER_DIRTY: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0);
206
207// === 新しい WindowManager への対応 ===
208impl crate::kernel::window_mgr::App for WebBrowser {
209    fn name(&self) -> &str {
210        "browser"
211    }
212
213    fn instance_info(&self) -> alloc::string::String {
214        self.engine.title.clone()
215    }
216
217    fn on_resize(&mut self, width: u32, height: u32) {
218        let top = self.top_offset();
219        self.engine.win_w = width;
220        self.engine.win_h = height;
221        let html = self.engine.last_html.clone();
222        self.engine.parse_and_layout(&html, None, top, width, height);
223        self.engine.js_runtime.fire_resize(width as i32, height as i32);
224        self.engine.dirty = true;
225        self.engine.dirty_reason = "mod.rs:202";
226    }
227
228    fn needs_timer_redraw(&self) -> bool {
229        // loading 中、dirty、画像ロード完了、または動画再生中であればタイマー再描画を有効にする。
230        // 【2026-08-01計測】描画が実行時間の約半分(95 回 × 約 130 tick)を
231        // 占めている。そのうち何回が本当に必要だったかを知るため、
232        // **どの条件で再描画に入ったか**を数える。
233        // 推測で条件を外すと画面が更新されなくなるので、まず数える。
234        let loading = self.engine.loading;
235        let dirty = self.engine.dirty;
236        let video = self.engine.video_playing;
237        let img = crate::os_lib::web_engine::image_results_ready();
238        // JS が canvas へ描いた分。画像完了と同じくエッジトリガで、
239        // 変化があった一度だけ再描画する(`draw` が旗を降ろす)。
240        let canvas = crate::os_lib::canvas2d::canvas_dirty();
241        // 【2026-09-06 バグ修正】フォントの取得待ちを再描画条件に入れる。
242        //
243        // 取得のポンプ(`font_queue::pump`)は `draw()` の中にある。
244        // 内容が出揃って他に変化が無くなると `draw()` が呼ばれなくなり、
245        // **フォントが残っていても誰もポンプを回さなくなる**。
246        // その結果、待ち行列に仕事があるのにワーカーが起きず、
247        // 稼働本数だけが古いまま残って上限に張り付く。
248        //
249        // 実測(停滞時): `font_pending=1 fw=4 ff=0`、
250        // `[FQDIAG]` はページ読み込み前の 4 行しか出ておらず
251        // (=`draw()` が止まっている)、提示ループのフレームだけが進む。
252        // 断続的に見えるのは、描画が静かになる瞬間にフォントが
253        // 残っているかどうかで決まるため。
254        let font_wait = crate::os_lib::web_engine::font_pending() > 0;
255        if loading {
256            REDRAW_LOADING.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
257        }
258        if dirty {
259            REDRAW_DIRTY.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
260            // どこが立てたかをそのまま出す(推測せず現物を見る)。
261        }
262        if video {
263            REDRAW_VIDEO.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
264        }
265        if img {
266            REDRAW_IMG.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
267        }
268        // 【2026-08-01 性能修正】`loading` 単独で全フレーム強制再描画していた。
269        //
270        // 実測: 再描画 94 回すべてで `loading=true`。フォントの背景取得が
271        // 続いている間ずっと true のままなので、**変化が無くても毎フレーム
272        // 全画面を描き直していた**(1 回あたり約 130 tick)。
273        //
274        // ただしコンテンツがまだ 1 つも無い間は**ローディング表示の
275        // アニメーション**を回す必要があるので、そこは従来どおり強制する。
276        // コンテンツが出た後は `dirty` / 画像到着 / 動画という
277        // **実際の変化シグナル**だけで再描画する。
278        //
279        // 取得を打ち切るわけではない(取得は背景スレッドで最後まで走り、
280        // 届いた時点で `dirty` が立って反映される)。
281        let showing_placeholder = loading && self.engine.elements.is_empty();
282        showing_placeholder || dirty || video || img || canvas || font_wait
283    }
284
285    /// 【2026-08-01計測】再描画要求の内訳カウンタ。
286    fn draw(
287        &mut self,
288        screen: &crate::kernel::draw::Screen,
289        win_x: u32,
290        win_y: u32,
291        win_w: u32,
292        win_h: u32,
293    ) {
294        crate::os_lib::web_engine::set_draw_stage(1);
295        // canvas の変化はこの描画で画面に出る。ここで降ろさないと
296        // 毎フレーム再描画が続いて画面が波打つ。
297        let _ = crate::os_lib::canvas2d::take_canvas_dirty();
298        let top = self.top_offset();
299        // ウィンドウサイズまたは境界が変わったら再レイアウト
300        if self.engine.win_w != win_w || self.engine.win_h != win_h || self.engine.top_offset != top
301        {
302            // ビューポートの幅・高さが実際に変化したか(top_offset のみの変化は resize ではない)。
303            let size_changed = self.engine.win_w != win_w || self.engine.win_h != win_h;
304            self.engine.win_w = win_w;
305            self.engine.win_h = win_h;
306            let html = self.engine.last_html.clone();
307            self.engine.parse_and_layout(&html, None, top, win_w, win_h);
308            // 再レイアウト後に window.resize リスナを発火(サイズ変化時のみ)。
309            if size_changed {
310                self.engine
311                    .js_runtime
312                    .fire_resize(win_w as i32, win_h as i32);
313            }
314        }
315        let theme = crate::kernel::config::get_config().theme;
316        screen.boxfill(
317            win_x,
318            win_y,
319            win_x + win_w,
320            win_y + win_h,
321            crate::kernel::draw::Color(theme.app_bg),
322        );
323
324        // 1. コンテンツの描画 (Y=top 以降)。
325        // バー類より先に描くことで、境界からはみ出した要素は後段のバー描画が
326        // 必ず上書きする (バーが最前面のZ順になる)。
327        self.engine.draw(screen, win_x, win_y, win_w, win_h, top);
328
329        // 2. コントロールバーの描画 (上部 40px)
330        screen.boxfill(
331            win_x,
332            win_y,
333            win_x + win_w,
334            win_y + CONTROL_BAR_H as u32,
335            crate::kernel::draw::Color(theme.tab_area_bg),
336        );
337
338        // [Back] ボタン
339        let can_back = self.engine.can_go_back();
340        let back_fg = if can_back { theme.button_fg } else { theme.non_active_window_border };
341        screen.boxfill(
342            win_x + 10,
343            win_y + 8,
344            win_x + 42,
345            win_y + 32,
346            draw::Color(theme.button_bg),
347        );
348        draw_border(
349            screen,
350            (win_x + 10) as i32,
351            (win_y + 8) as i32,
352            (win_x + 42) as i32,
353            (win_y + 32) as i32,
354            theme.non_active_window_border,
355        );
356        screen.draw_svg_icon(win_x + 16, win_y + 10, SVG_BACK, 20, draw::Color(back_fg));
357
358        // [Forward] ボタン
359        let can_fwd = self.engine.can_go_forward();
360        let fwd_fg = if can_fwd { theme.button_fg } else { theme.non_active_window_border };
361        screen.boxfill(
362            win_x + 48,
363            win_y + 8,
364            win_x + 80,
365            win_y + 32,
366            draw::Color(theme.button_bg),
367        );
368        draw_border(
369            screen,
370            (win_x + 48) as i32,
371            (win_y + 8) as i32,
372            (win_x + 80) as i32,
373            (win_y + 32) as i32,
374            theme.non_active_window_border,
375        );
376        screen.draw_svg_icon(win_x + 54, win_y + 10, SVG_FORWARD, 20, draw::Color(fwd_fg));
377
378        // [Home] ボタン
379        screen.boxfill(
380            win_x + 86,
381            win_y + 8,
382            win_x + 118,
383            win_y + 32,
384            draw::Color(theme.button_bg),
385        );
386        draw_border(
387            screen,
388            (win_x + 86) as i32,
389            (win_y + 8) as i32,
390            (win_x + 118) as i32,
391            (win_y + 32) as i32,
392            theme.non_active_window_border,
393        );
394        screen.draw_svg_icon(
395            win_x + 92,
396            win_y + 10,
397            SVG_HOME,
398            20,
399            draw::Color(theme.button_fg),
400        );
401
402        // [Reload] ボタン
403        screen.boxfill(
404            win_x + 124,
405            win_y + 8,
406            win_x + 156,
407            win_y + 32,
408            draw::Color(theme.button_bg),
409        );
410        draw_border(
411            screen,
412            (win_x + 124) as i32,
413            (win_y + 8) as i32,
414            (win_x + 156) as i32,
415            (win_y + 32) as i32,
416            theme.non_active_window_border,
417        );
418        screen.draw_svg_icon(
419            win_x + 130,
420            win_y + 10,
421            SVG_RELOAD,
422            20,
423            draw::Color(theme.button_fg),
424        );
425
426        // [HTTPS/HTTP] セキュリティ盾バッジ
427        let is_https = self.engine.current_is_https;
428        let badge_w = if is_https { 95 } else { 125 };
429        let badge_bg = if is_https { 0xFF1B4D3E } else { 0xFF4C1D1D };
430        let badge_border = if is_https { 0xFF50FA7B } else { 0xFFFF5555 };
431        let badge_lbl = if is_https { "Secure" } else { "Not Secure" };
432        let badge_fg = if is_https { 0xFFECFDF5 } else { 0xFFFEE2E2 };
433        let badge_svg = if is_https {
434            SVG_SHIELD_SECURE
435        } else {
436            SVG_SHIELD_WARN
437        };
438
439        screen.boxfill(
440            win_x + 162,
441            win_y + 8,
442            win_x + 162 + badge_w,
443            win_y + 32,
444            draw::Color(badge_bg),
445        );
446        draw_border(
447            screen,
448            (win_x + 162) as i32,
449            (win_y + 8) as i32,
450            (win_x + 162 + badge_w) as i32,
451            (win_y + 32) as i32,
452            badge_border,
453        );
454        screen.draw_svg_icon(
455            win_x + 164,
456            win_y + 10,
457            badge_svg,
458            20,
459            draw::Color(badge_border),
460        );
461        screen.draw_string_vector(
462            win_x + 189,
463            win_y + 13,
464            badge_lbl,
465            draw::Color(badge_fg),
466            11,
467        );
468
469        let badge_end = 162 + badge_w;
470
471        // アドレスバー (Omnibar): セキュリティバッジの直後から右端まで
472        let bar_right = (win_x + win_w).saturating_sub(10);
473        let bar_left = win_x + badge_end + 6;
474        if bar_right > bar_left {
475            let addr_bg = if self.address_bar_focused {
476                theme.terminal_bg
477            } else {
478                theme.tab_area_bg
479            };
480            let addr_border = if self.address_bar_focused {
481                theme.focus_highlight
482            } else {
483                theme.non_active_window_border
484            };
485            screen.boxfill(
486                bar_left,
487                win_y + 8,
488                bar_right,
489                win_y + 32,
490                draw::Color(addr_bg),
491            );
492            draw_border(
493                screen,
494                bar_left as i32,
495                (win_y + 8) as i32,
496                bar_right as i32,
497                (win_y + 32) as i32,
498                addr_border,
499            );
500
501            let is_placeholder = self.address_box.text.is_empty() && !self.address_bar_focused;
502            let display_text = if is_placeholder {
503                String::from("Search or enter address...")
504            } else {
505                self.address_box.text.clone()
506            };
507            let text_color = if is_placeholder {
508                theme.non_active_window_border
509            } else {
510                theme.terminal_fg
511            };
512            screen.draw_string_vector(
513                bar_left + 10,
514                win_y + 12,
515                &display_text,
516                draw::Color(text_color),
517                12,
518            );
519
520            // フォーカス中はカーソル位置に縦棒カーソルを描画(色はターミナルと共通の 0xFF756CE0)
521            if self.address_bar_focused {
522                let prefix: String = self
523                    .address_box
524                    .text
525                    .chars()
526                    .take(self.address_box.cursor_pos)
527                    .collect();
528                let pw = crate::kernel::vector_font::get_vector_string_width(&prefix, 12);
529                let cursor_x = bar_left + 10 + pw;
530                if cursor_x < bar_right {
531                    screen.boxfill(
532                        cursor_x,
533                        win_y + 11,
534                        cursor_x + 1,
535                        win_y + 28,
536                        draw::Color(crate::kernel::ime::cursor_color()),
537                    );
538                }
539            }
540        }
541
542        screen.boxfill(
543            win_x,
544            win_y + 39,
545            win_x + win_w,
546            win_y + 40,
547            crate::kernel::draw::Color(theme.tab_area_border),
548        );
549
550        // アドレス・タイトルを同調
551        if !self.address_bar_focused && self.engine.dirty {
552            self.address_box.set_text(&self.engine.get_url());
553        }
554        if !self.engine.title.is_empty() {
555            if let Some(slot) = self.tabs.get_mut(self.active_tab_idx) {
556                slot.title = self.engine.title.clone();
557            }
558        }
559        crate::os_lib::web_engine::set_draw_stage(0);
560    }
561
562    fn on_mouse(
563        &mut self,
564        local_x: i32,
565        local_y: i32,
566        btn_left: bool,
567        btn_right: bool,
568        wheel: i32,
569    ) {
570        let top = self.top_offset();
571
572        if btn_left && (0..CONTROL_BAR_H).contains(&local_y) {
573            if (8..=32).contains(&local_y) {
574                if (10..=42).contains(&local_x) {
575                    self.engine.go_back(top);
576                    self.address_bar_focused = false;
577                    self.address_box.set_text(&self.engine.get_url());
578                    self.engine.dirty = true;
579                    self.engine.dirty_reason = "mod.rs:532";
580                    return;
581                }
582                if (48..=80).contains(&local_x) {
583                    self.engine.go_forward(top);
584                    self.address_bar_focused = false;
585                    self.address_box.set_text(&self.engine.get_url());
586                    self.engine.dirty = true;
587                    self.engine.dirty_reason = "mod.rs:539";
588                    return;
589                }
590                if (86..=118).contains(&local_x) {
591                    // [Home]: OS 内蔵の静的ホームページを表示
592                    render_home(&mut self.engine, top);
593                    self.address_bar_focused = false;
594                    self.address_box.set_text(&self.engine.get_url());
595                    self.engine.dirty = true;
596                    self.engine.dirty_reason = "mod.rs:547";
597                    return;
598                }
599                if (124..=156).contains(&local_x) {
600                    // リロード
601                    if self.engine.current_host == "localhost" {
602                        let path = self.engine.current_path.clone();
603                        if path == "/home" {
604                            // 内蔵ホームページの再描画
605                            render_home(&mut self.engine, top);
606                        } else {
607                            // 【2026-08-25 バグ修正】FS_LOCK を取らずに読んでいた。
608                            // 前ページの画像・フォントワーカーはまだ生きており、
609                            // 同時に FS_LOCK 下で書き込む。読み取りの間だけ保持する。
610                            let loaded = {
611                                let _fs_guard = crate::kernel::fs::FS_LOCK.lock();
612                                crate::kernel::fs::get_fs().read_file(&path)
613                            };
614                            if let Some((_meta, content)) = loaded {
615                                if let Ok(html) = core::str::from_utf8(&content) {
616                                    self.engine.load_html(
617                                        html,
618                                        path.strip_prefix('/').unwrap_or(&path),
619                                        top,
620                                    );
621                                }
622                            }
623                        }
624                    } else {
625                        self.engine.load_page(
626                            self.engine.current_is_https,
627                            &self.engine.current_host.clone(),
628                            &self.engine.current_path.clone(),
629                            false,
630                            top,
631                        );
632                    }
633                    self.address_bar_focused = false;
634                    self.address_box.set_text(&self.engine.get_url());
635                    return;
636                }
637                // アドレスバーはセキュリティバッジの直後から始まる (描画と同じ座標)
638                let badge_end = 162
639                    + if self.engine.current_is_https {
640                        95
641                    } else {
642                        125
643                    };
644                let bar_left = badge_end + 6;
645                if local_x >= bar_left {
646                    self.address_bar_focused = true;
647                    self.address_box.set_text(&self.engine.get_url());
648                    self.engine.dirty = true;
649                    self.engine.dirty_reason = "mod.rs:593";
650                    return;
651                }
652            }
653            self.address_bar_focused = false;
654            self.engine.dirty = true;
655            self.engine.dirty_reason = "mod.rs:598";
656            return;
657        }
658
659        if btn_left {
660            self.address_bar_focused = false;
661            self.engine.dirty = true;
662            self.engine.dirty_reason = "mod.rs:604";
663        }
664
665        self.engine
666            .on_mouse(local_x, local_y, btn_left, btn_right, wheel, top);
667    }
668
669    fn on_key(&mut self, keycode: u8, pressed: bool, ascii: Option<char>) {
670        if !pressed {
671            return;
672        }
673
674        if self.address_bar_focused {
675            if keycode == 0x1C {
676                // Enter
677                let trimmed = self.address_box.text.trim();
678                if !trimmed.is_empty() {
679                    let input = String::from(trimmed);
680                    let top = self.top_offset();
681                    if let Some(rest) = input.strip_prefix('/') {
682                        // ローカル SylFS パス(例: /apps/imgtest/index.html)
683                        // 【2026-08-25 バグ修正】同上。FS_LOCK を取ってから読む。
684                        let loaded = {
685                            let _fs_guard = crate::kernel::fs::FS_LOCK.lock();
686                            crate::kernel::fs::get_fs().read_file(&input)
687                        };
688                        if let Some((_meta, content)) = loaded {
689                            if let Ok(html) = core::str::from_utf8(&content) {
690                                self.engine.load_html(html, rest, top);
691                            }
692                        }
693                    } else if input.starts_with("http://") || input.starts_with("https://") {
694                        // "http://" または "https://" で始まるURLの解析
695                        let is_https = input.starts_with("https://");
696                        let clean = if is_https {
697                            input.get(8..).unwrap_or("")
698                        } else {
699                            input.get(7..).unwrap_or("")
700                        };
701                        // スラッシュでホスト名とパス名部分を分離する(最大2分割)
702                        let parts: Vec<&str> = clean.splitn(2, '/').collect();
703                        let host = parts[0];
704                        let path = if parts.len() > 1 {
705                            alloc::format!("/{}", parts[1])
706                        } else {
707                            String::from("/")
708                        };
709                        self.engine.load_page(is_https, host, &path, true, top);
710                    } else if input.contains('.') && !input.contains(' ') {
711                        // スキーマ無しだがドットを含みスペースを含まない場合(例: sugi-lab.net/abc)
712                        // スラッシュでホスト名とパス名部分を分離する(最大2分割)
713                        let parts: Vec<&str> = input.splitn(2, '/').collect();
714                        let host = parts[0];
715                        let path = if parts.len() > 1 {
716                            alloc::format!("/{}", parts[1])
717                        } else {
718                            String::from("/")
719                        };
720                        let is_https = !host.starts_with("localhost");
721                        self.engine.load_page(is_https, host, &path, true, top);
722                    } else {
723                        let query = url_encode(&input);
724                        let path = alloc::format!("/lite/?q={}", query);
725                        self.engine
726                            .load_page(true, "lite.duckduckgo.com", &path, true, top);
727                    }
728                }
729                self.address_bar_focused = false;
730                self.address_box.set_text(&self.engine.get_url());
731                self.engine.dirty = true;
732                self.engine.dirty_reason = "mod.rs:669";
733            } else {
734                if self.address_box.handle_key(keycode, ascii) {
735                    self.engine.dirty = true;
736                    self.engine.dirty_reason = "mod.rs:672";
737                }
738            }
739            return;
740        }
741
742        self.engine.on_key(keycode, pressed, ascii);
743
744        if keycode == 0x0E && self.engine.focused_id.is_none() {
745            let top = self.top_offset();
746            self.engine.go_back(top);
747            self.address_box.set_text(&self.engine.get_url());
748        }
749
750        // 【2026-08-03】キーボードによるスクロールが**丸ごと未対応**だった。
751        //
752        // ブラウザが処理していたキーは Enter(0x1C) と Backspace(0x0E) の 2 つだけで、
753        // 矢印キーも PageDown も無視していた。実ブラウザなら当然動く操作であり、
754        // 「ページ下部が正しく描けているか」を確認する手段も無かった
755        // (`browser.scroll()` を端末へ送ろうとしても、ブラウザに
756        //  フォーカスがあるためキーが端末へ届かない)。
757        //
758        // 入力欄にフォーカスがあるときは文字入力を優先するため何もしない。
759        if self.engine.focused_id.is_none() && !self.address_bar_focused {
760            // Set-1 スキャンコード。
761            let page = (self.engine.win_h as i32 - self.top_offset()).max(80);
762            let dy = match keycode {
763                0x48 => Some(-60),          // ↑
764                0x50 => Some(60),           // ↓
765                0x49 => Some(-page),        // PageUp
766                0x51 => Some(page),         // PageDown
767                0x47 => Some(-self.engine.max_scroll_y), // Home
768                0x4F => Some(self.engine.max_scroll_y),  // End
769                _ => None,
770            };
771            if let Some(dy) = dy {
772                self.engine.scroll(dy);
773                self.engine.dirty = true;
774                self.engine.dirty_reason = "browser/mod.rs:key_scroll";
775            }
776        }
777    }
778
779    fn tabs(&self) -> alloc::vec::Vec<alloc::string::String> {
780        // アクティブタブのタイトルはライブエンジンから取る(スロットは古い可能性がある)
781        self.tabs
782            .iter()
783            .enumerate()
784            .map(|(i, t)| {
785                if i == self.active_tab_idx {
786                    if self.engine.title.is_empty() {
787                        self.engine.current_host.clone()
788                    } else {
789                        self.engine.title.clone()
790                    }
791                } else {
792                    t.title.clone()
793                }
794            })
795            .collect()
796    }
797
798    fn active_tab(&self) -> usize {
799        self.active_tab_idx
800    }
801
802    fn close_tab(&mut self, idx: usize) -> bool {
803        if idx >= self.tabs.len() {
804            return false;
805        }
806
807        let was_active = idx == self.active_tab_idx;
808        self.tabs.remove(idx);
809
810        if self.tabs.is_empty() {
811            self.engine.active = false;
812            crate::os_lib::web_engine::clear_browser_cache();
813            return true;
814        }
815
816        if was_active {
817            // 閉じたのがアクティブタブ: 隣のタブのエンジンをライブへ取り出す
818            let new_idx = idx.min(self.tabs.len() - 1);
819            if let Some(mut next) = self.tabs[new_idx].engine.take() {
820                core::mem::swap(&mut self.engine, &mut next);
821                // next(旧アクティブのエンジン)はタブ削除済みなので破棄される
822            }
823            self.active_tab_idx = new_idx;
824            self.engine.active = true;
825            self.engine.dirty = true;
826            self.engine.dirty_reason = "mod.rs:733";
827            self.address_box.set_text(&self.engine.get_url());
828            self.address_bar_focused = false;
829        } else if idx < self.active_tab_idx {
830            // アクティブより前のタブが消えたのでインデックスを詰める
831            self.active_tab_idx -= 1;
832        }
833        false
834    }
835
836    fn switch_tab(&mut self, idx: usize) {
837        self.activate(idx);
838    }
839
840    fn add_tab(&mut self) {
841        if self.tabs.len() >= 16 {
842            return;
843        }
844        // 現アクティブエンジンを現スロットへ戻す
845        self.sync_active_title();
846        let old_active = self.active_tab_idx;
847        let w = self.engine.win_w;
848        let h = self.engine.win_h;
849
850        // 新しい独立エンジンを生成
851        let mut new_engine = WebEngine::new();
852        new_engine.win_w = w;
853        new_engine.win_h = h;
854        new_engine.current_host = String::from("newtab");
855        new_engine.current_path = String::from("/");
856        new_engine.title = String::from("New Tab");
857        new_engine.active = true;
858        let top = self.top_offset();
859        new_engine.parse_and_layout(
860            "<h1>New Tab</h1><p>Enter URL in the address bar.</p>",
861            None,
862            top,
863            w,
864            h,
865        );
866
867        // 旧アクティブエンジンをスロットへ退避し、新エンジンをライブにする
868        let prev = core::mem::replace(&mut self.engine, new_engine);
869        self.tabs[old_active].engine = Some(prev);
870
871        self.tabs.push(TabSlot {
872            title: String::from("New Tab"),
873            engine: None,
874        });
875        self.active_tab_idx = self.tabs.len() - 1;
876
877        self.address_box.set_text("");
878        self.address_bar_focused = true;
879        self.engine.dirty = true;
880        self.engine.dirty_reason = "mod.rs:786";
881    }
882
883    fn get_dom_value(&self, id: &str) -> Option<alloc::string::String> {
884        self.engine.form_values.get(id).cloned()
885    }
886
887    fn set_dom_value(&mut self, id: &str, val: &str) {
888        self.engine.form_values.insert(
889            alloc::string::String::from(id),
890            alloc::string::String::from(val),
891        );
892        self.engine.dirty = true;
893        self.engine.dirty_reason = "mod.rs:798";
894    }
895
896    fn get_dom_content(&self, id: &str) -> Option<alloc::string::String> {
897        self.engine.dom_contents.get(id).cloned()
898    }
899
900    fn set_dom_content(&mut self, id: &str, val: &str) {
901        self.engine.set_dom_content(id, val);
902    }
903}