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, 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    pub omnibox: crate::os_lib::web_engine::omnibox::OmniboxEngine,
60    pub omnibox_items: Vec<crate::os_lib::web_engine::omnibox::OmniboxItem>,
61    pub selected_suggestion: Option<usize>,
62}
63
64/// 【2026-08-01計測】再描画に入った理由の内訳。
65/// 描画 1 回が約 130 tick かかるので、冗長な再描画は直接コストになる。
66static REDRAW_LOADING: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0);
67static REDRAW_DIRTY: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0);
68static REDRAW_VIDEO: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0);
69static REDRAW_IMG: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0);
70
71/// 内訳をログへ出す(描画側から一定間隔で呼ぶ)。
72pub fn log_redraw_reasons() {
73}
74
75impl WebBrowser {
76    pub fn execute_search_or_navigate(&mut self, text: &str) {
77        let input = text.trim();
78        if input.is_empty() {
79            return;
80        }
81        let top = self.top_offset();
82        if let Some(rest) = input.strip_prefix('/') {
83            let loaded = {
84                let _fs_guard = crate::kernel::fs::FS_LOCK.lock();
85                crate::kernel::fs::get_fs().read_file(input)
86            };
87            if let Some((_meta, content)) = loaded {
88                if let Ok(html) = core::str::from_utf8(&content) {
89                    self.engine.load_html(html, rest, top);
90                }
91            }
92        } else if input.starts_with("http://") || input.starts_with("https://") {
93            let is_https = input.starts_with("https://");
94            let clean = if is_https {
95                input.get(8..).unwrap_or("")
96            } else {
97                input.get(7..).unwrap_or("")
98            };
99            let parts: Vec<&str> = clean.splitn(2, '/').collect();
100            let host = parts[0];
101            let path = if parts.len() > 1 {
102                alloc::format!("/{}", parts[1])
103            } else {
104                String::from("/")
105            };
106            self.engine.load_page(is_https, host, &path, true, top);
107        } else if input.contains('.') && !input.contains(' ') {
108            let parts: Vec<&str> = input.splitn(2, '/').collect();
109            let host = parts[0];
110            let path = if parts.len() > 1 {
111                alloc::format!("/{}", parts[1])
112            } else {
113                String::from("/")
114            };
115            let is_https = !host.starts_with("localhost");
116            self.engine.load_page(is_https, host, &path, true, top);
117        } else {
118            let query = crate::os_lib::web_engine::duckduckgo::url_encode(input);
119            let path = alloc::format!("/?q={}", query);
120            self.engine
121                .load_page(true, "duckduckgo.com", &path, true, top);
122        }
123        self.address_bar_focused = false;
124        self.omnibox_items.clear();
125        self.selected_suggestion = None;
126        let final_url = self.engine.get_url();
127        self.omnibox.record_visit(&self.engine.title, &final_url);
128        self.address_box.set_text(&final_url);
129        self.engine.dirty = true;
130        self.engine.dirty_reason = "mod.rs:exec_nav";
131    }
132
133    /// コンテンツ描画の開始 Y 座標 (コントロールバーの直下)。
134    /// 描画・クリック判定・レイアウトすべてがこの値を共通参照する。
135    pub fn top_offset(&self) -> i32 {
136        CONTROL_BAR_H
137    }
138
139    /// アクティブエンジンのタイトルを現タブスロットに同期する(タブバー表示用)。
140    fn sync_active_title(&mut self) {
141        let title = if self.engine.title.is_empty() {
142            self.engine.current_host.clone()
143        } else {
144            self.engine.title.clone()
145        };
146        if let Some(slot) = self.tabs.get_mut(self.active_tab_idx) {
147            slot.title = title;
148        }
149    }
150
151    /// アクティブタブを `idx` へ切り替える。ライブエンジンを現タブスロットへ戻し、
152    /// 切替先スロットのエンジンを mem::swap でライブへ取り出す(クローン・再取得なし)。
153    fn activate(&mut self, idx: usize) {
154        if idx == self.active_tab_idx || idx >= self.tabs.len() {
155            return;
156        }
157        self.sync_active_title();
158        // 切替先のエンジンを取り出す
159        if let Some(mut next) = self.tabs[idx].engine.take() {
160            // self.engine(現アクティブ=A) と next(切替先=B) を入れ替える
161            core::mem::swap(&mut self.engine, &mut next);
162            // next は今や A のエンジン。元のアクティブスロットへ戻す
163            self.tabs[self.active_tab_idx].engine = Some(next);
164            self.active_tab_idx = idx;
165        }
166        self.engine.active = true;
167        self.engine.dirty = true;
168        self.engine.dirty_reason = "mod.rs:105";
169        self.address_box.set_text(&self.engine.get_url());
170        self.address_bar_focused = false;
171    }
172}
173
174impl Default for WebBrowser {
175    fn default() -> Self {
176        Self::new()
177    }
178}
179
180impl WebBrowser {
181    pub fn new() -> Self {
182        // 起動時に前回の古いキャッシュをクリア(壊れたキャッシュによる誤表示を防ぐ)
183        crate::os_lib::web_engine::clear_browser_cache();
184        let mut engine = WebEngine::new();
185        engine.win_w = 800;
186        engine.win_h = 600;
187        // 起動時は OS 内蔵の静的ホームページを表示する(ネットワーク不要)
188        render_home(&mut engine, CONTROL_BAR_H);
189        let initial_url = engine.get_url();
190        // 初期タブスロット(アクティブなので engine は None = ライブへチェックアウト済み)
191        let initial_slot = TabSlot {
192            title: String::from("AtmOS Browser"),
193            engine: None,
194        };
195        Self {
196            engine,
197            address_bar_focused: false,
198            address_box: crate::kernel::text_box::TextBox::with_text(&initial_url),
199            active_tab_idx: 0,
200            tabs: alloc::vec![initial_slot],
201            omnibox: crate::os_lib::web_engine::omnibox::OmniboxEngine::new(),
202            omnibox_items: Vec::new(),
203            selected_suggestion: None,
204        }
205    }
206}
207
208// グローバルなブラウザインスタンス
209static mut GLOBAL_BROWSER: Option<WebBrowser> = None;
210
211// グローバルインスタンスの初期化と取得
212pub fn init() {
213    unsafe {
214        GLOBAL_BROWSER = Some(WebBrowser::new());
215    }
216}
217
218pub fn get_instance() -> &'static mut WebBrowser {
219    // init() 前に呼ばれた場合も panic せず、その場で初期化する
220    unsafe { GLOBAL_BROWSER.get_or_insert_with(WebBrowser::new) }
221}
222
223pub fn is_active() -> bool {
224    unsafe {
225        match &GLOBAL_BROWSER {
226            Some(b) => b.engine.active,
227            None => false,
228        }
229    }
230}
231
232/// CSS `cursor: text` の要素上にマウスがあるか(OS 側の実マウスカーソル形状決定用)。
233pub fn hovered_cursor_is_text() -> bool {
234    unsafe {
235        match &GLOBAL_BROWSER {
236            Some(b) => b.engine.active && b.engine.hovered_cursor_is_text,
237            None => false,
238        }
239    }
240}
241
242pub fn needs_render() -> bool {
243    unsafe {
244        match &GLOBAL_BROWSER {
245            Some(b) => {
246                // 【2026-08-01計測】実際に全画面描画を起動しているのは
247                // `needs_timer_redraw` ではなく**こちら**。
248                // 先に `needs_timer_redraw` 側で数えたが、呼ばれる頻度が
249                // 違うため全画面描画の回数と突き合わせられなかった。
250                let r = b.engine.active && (b.engine.loading || b.engine.dirty);
251                if r {
252                    if b.engine.loading {
253                        RENDER_LOADING.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
254                    }
255                    if b.engine.dirty {
256                        RENDER_DIRTY.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
257                    }
258                }
259                r
260            }
261            None => false,
262        }
263    }
264}
265
266/// 【2026-08-01計測】`needs_render` が true を返した理由の内訳。
267static RENDER_LOADING: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0);
268static RENDER_DIRTY: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0);
269
270// === 新しい WindowManager への対応 ===
271impl crate::kernel::window_mgr::App for WebBrowser {
272    fn name(&self) -> &str {
273        "browser"
274    }
275
276    fn instance_info(&self) -> alloc::string::String {
277        self.engine.title.clone()
278    }
279
280    fn on_resize(&mut self, width: u32, height: u32) {
281        let top = self.top_offset();
282        self.engine.win_w = width;
283        self.engine.win_h = height;
284        let html = self.engine.last_html.clone();
285        self.engine.parse_and_layout(&html, None, top, width, height);
286        let viewport_h = height.saturating_sub(top as u32);
287        self.engine.js_runtime.fire_resize(width as i32, viewport_h as i32);
288        self.engine.dirty = true;
289        self.engine.dirty_reason = "mod.rs:202";
290    }
291
292    fn needs_timer_redraw(&self) -> bool {
293        // loading 中、dirty、画像ロード完了、または動画再生中であればタイマー再描画を有効にする。
294        // 【2026-08-01計測】描画が実行時間の約半分(95 回 × 約 130 tick)を
295        // 占めている。そのうち何回が本当に必要だったかを知るため、
296        // **どの条件で再描画に入ったか**を数える。
297        // 推測で条件を外すと画面が更新されなくなるので、まず数える。
298        let loading = self.engine.loading;
299        let dirty = self.engine.dirty;
300        let video = self.engine.video_playing;
301        let img = crate::os_lib::web_engine::image_results_ready();
302        // JS が canvas へ描いた分。画像完了と同じくエッジトリガで、
303        // 変化があった一度だけ再描画する(`draw` が旗を降ろす)。
304        let canvas = crate::os_lib::canvas2d::canvas_dirty();
305        // 【2026-09-06 バグ修正】フォントの取得待ちを再描画条件に入れる。
306        //
307        // 取得のポンプ(`font_queue::pump`)は `draw()` の中にある。
308        // 内容が出揃って他に変化が無くなると `draw()` が呼ばれなくなり、
309        // **フォントが残っていても誰もポンプを回さなくなる**。
310        // その結果、待ち行列に仕事があるのにワーカーが起きず、
311        // 稼働本数だけが古いまま残って上限に張り付く。
312        //
313        // 実測(停滞時): `font_pending=1 fw=4 ff=0`、
314        // `[FQDIAG]` はページ読み込み前の 4 行しか出ておらず
315        // (=`draw()` が止まっている)、提示ループのフレームだけが進む。
316        // 断続的に見えるのは、描画が静かになる瞬間にフォントが
317        // 残っているかどうかで決まるため。
318        let font_wait = crate::os_lib::web_engine::font_pending() > 0;
319        if loading {
320            REDRAW_LOADING.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
321        }
322        if dirty {
323            REDRAW_DIRTY.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
324            // どこが立てたかをそのまま出す(推測せず現物を見る)。
325        }
326        if video {
327            REDRAW_VIDEO.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
328        }
329        if img {
330            REDRAW_IMG.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
331        }
332        // 【2026-08-01 性能修正】`loading` 単独で全フレーム強制再描画していた。
333        //
334        // 実測: 再描画 94 回すべてで `loading=true`。フォントの背景取得が
335        // 続いている間ずっと true のままなので、**変化が無くても毎フレーム
336        // 全画面を描き直していた**(1 回あたり約 130 tick)。
337        //
338        // ただしコンテンツがまだ 1 つも無い間は**ローディング表示の
339        // アニメーション**を回す必要があるので、そこは従来どおり強制する。
340        // コンテンツが出た後は `dirty` / 画像到着 / 動画という
341        // **実際の変化シグナル**だけで再描画する。
342        //
343        // 取得を打ち切るわけではない(取得は背景スレッドで最後まで走り、
344        // 届いた時点で `dirty` が立って反映される)。
345        let showing_placeholder = loading && self.engine.elements.is_empty();
346        // 期限の来た JS のタイマー(setTimeout/setInterval/rAF)は `draw()` が流す。
347        let timers = self.engine.js_runtime.has_due_timers();
348        // iframe の文書(子フレーム)は親の `draw()` の中で進む。
349        let frames = self.engine.frames_want_redraw() || self.engine.has_pending_dynamic_scripts();
350        // 先送りした DOM 変更の再レイアウト(`relayout_if_dom_dirty` の間引き)を反映する描画が要る。
351        let dom_pending = self.engine.js_runtime.dom.borrow().dirty;
352        showing_placeholder || dirty || video || img || canvas || font_wait || timers || frames || dom_pending
353    }
354
355    /// 【2026-08-01計測】再描画要求の内訳カウンタ。
356    fn draw(
357        &mut self,
358        screen: &crate::kernel::draw::Screen,
359        win_x: u32,
360        win_y: u32,
361        win_w: u32,
362        win_h: u32,
363    ) {
364        crate::os_lib::web_engine::set_draw_stage(1);
365        // canvas の変化はこの描画で画面に出る。ここで降ろさないと
366        // 毎フレーム再描画が続いて画面が波打つ。
367        let _ = crate::os_lib::canvas2d::take_canvas_dirty();
368        let top = self.top_offset();
369        // ウィンドウサイズまたは境界が変わったら再レイアウト
370        if self.engine.win_w != win_w || self.engine.win_h != win_h || self.engine.top_offset != top
371        {
372            // ビューポートの幅・高さが実際に変化したか(top_offset のみの変化は resize ではない)。
373            let size_changed = self.engine.win_w != win_w || self.engine.win_h != win_h;
374            self.engine.win_w = win_w;
375            self.engine.win_h = win_h;
376            let html = self.engine.last_html.clone();
377            self.engine.parse_and_layout(&html, None, top, win_w, win_h);
378            // 再レイアウト後に window.resize リスナを発火(サイズ変化時のみ)。
379            if size_changed {
380                let viewport_h = win_h.saturating_sub(top as u32);
381                self.engine
382                    .js_runtime
383                    .fire_resize(win_w as i32, viewport_h as i32);
384            }
385        }
386        let theme = crate::kernel::config::get_config().theme;
387        // ウィンドウバッファは window_mgr がクリア済みであり、
388        // 直後に self.engine.draw がコンテンツ領域 (Y=top..win_h) を、
389        // 続いてコントロールバー (Y=0..top) が全面を上書きするため、
390        // ここでの全画面 boxfill は不要(二重クリアの削減)。
391
392
393        // 1. コンテンツの描画 (Y=top 以降)。
394        // バー類より先に描くことで、境界からはみ出した要素は後段のバー描画が
395        // 必ず上書きする (バーが最前面のZ順になる)。
396        self.engine.draw(screen, win_x, win_y, win_w, win_h, top);
397
398        // 2. コントロールバーの描画 (上部 40px)
399        screen.boxfill(
400            win_x,
401            win_y,
402            win_x + win_w,
403            win_y + CONTROL_BAR_H as u32,
404            crate::kernel::draw::Color(theme.tab_area_bg),
405        );
406
407        // [Back] ボタン
408        let can_back = self.engine.can_go_back();
409        let back_fg = if can_back { theme.button_fg } else { theme.non_active_window_border };
410        screen.boxfill(
411            win_x + 10,
412            win_y + 8,
413            win_x + 42,
414            win_y + 32,
415            draw::Color(theme.button_bg),
416        );
417        draw_border(
418            screen,
419            (win_x + 10) as i32,
420            (win_y + 8) as i32,
421            (win_x + 42) as i32,
422            (win_y + 32) as i32,
423            theme.non_active_window_border,
424        );
425        screen.draw_svg_icon(win_x + 16, win_y + 10, SVG_BACK, 20, draw::Color(back_fg));
426
427        // [Forward] ボタン
428        let can_fwd = self.engine.can_go_forward();
429        let fwd_fg = if can_fwd { theme.button_fg } else { theme.non_active_window_border };
430        screen.boxfill(
431            win_x + 48,
432            win_y + 8,
433            win_x + 80,
434            win_y + 32,
435            draw::Color(theme.button_bg),
436        );
437        draw_border(
438            screen,
439            (win_x + 48) as i32,
440            (win_y + 8) as i32,
441            (win_x + 80) as i32,
442            (win_y + 32) as i32,
443            theme.non_active_window_border,
444        );
445        screen.draw_svg_icon(win_x + 54, win_y + 10, SVG_FORWARD, 20, draw::Color(fwd_fg));
446
447        // [Home] ボタン
448        screen.boxfill(
449            win_x + 86,
450            win_y + 8,
451            win_x + 118,
452            win_y + 32,
453            draw::Color(theme.button_bg),
454        );
455        draw_border(
456            screen,
457            (win_x + 86) as i32,
458            (win_y + 8) as i32,
459            (win_x + 118) as i32,
460            (win_y + 32) as i32,
461            theme.non_active_window_border,
462        );
463        screen.draw_svg_icon(
464            win_x + 92,
465            win_y + 10,
466            SVG_HOME,
467            20,
468            draw::Color(theme.button_fg),
469        );
470
471        // [Reload] ボタン
472        screen.boxfill(
473            win_x + 124,
474            win_y + 8,
475            win_x + 156,
476            win_y + 32,
477            draw::Color(theme.button_bg),
478        );
479        draw_border(
480            screen,
481            (win_x + 124) as i32,
482            (win_y + 8) as i32,
483            (win_x + 156) as i32,
484            (win_y + 32) as i32,
485            theme.non_active_window_border,
486        );
487        screen.draw_svg_icon(
488            win_x + 130,
489            win_y + 10,
490            SVG_RELOAD,
491            20,
492            draw::Color(theme.button_fg),
493        );
494
495        // [HTTPS/HTTP] セキュリティ盾バッジ
496        let is_https = self.engine.current_is_https;
497        let badge_w = if is_https { 95 } else { 125 };
498        let badge_bg = if is_https { 0xFF1B4D3E } else { 0xFF4C1D1D };
499        let badge_border = if is_https { 0xFF50FA7B } else { 0xFFFF5555 };
500        let badge_lbl = if is_https { "Secure" } else { "Not Secure" };
501        let badge_fg = if is_https { 0xFFECFDF5 } else { 0xFFFEE2E2 };
502        let badge_svg = if is_https {
503            SVG_SHIELD_SECURE
504        } else {
505            SVG_SHIELD_WARN
506        };
507
508        screen.boxfill(
509            win_x + 162,
510            win_y + 8,
511            win_x + 162 + badge_w,
512            win_y + 32,
513            draw::Color(badge_bg),
514        );
515        draw_border(
516            screen,
517            (win_x + 162) as i32,
518            (win_y + 8) as i32,
519            (win_x + 162 + badge_w) as i32,
520            (win_y + 32) as i32,
521            badge_border,
522        );
523        screen.draw_svg_icon(
524            win_x + 164,
525            win_y + 10,
526            badge_svg,
527            20,
528            draw::Color(badge_border),
529        );
530        screen.draw_string_vector(
531            win_x + 189,
532            win_y + 13,
533            badge_lbl,
534            draw::Color(badge_fg),
535            11,
536        );
537
538        let badge_end = 162 + badge_w;
539
540        // アドレスバー (Omnibar): セキュリティバッジの直後から右端まで
541        let bar_right = (win_x + win_w).saturating_sub(10);
542        let bar_left = win_x + badge_end + 6;
543        if bar_right > bar_left {
544            let addr_bg = if self.address_bar_focused {
545                theme.terminal_bg
546            } else {
547                theme.tab_area_bg
548            };
549            let addr_border = if self.address_bar_focused {
550                theme.focus_highlight
551            } else {
552                theme.non_active_window_border
553            };
554            screen.boxfill(
555                bar_left,
556                win_y + 8,
557                bar_right,
558                win_y + 32,
559                draw::Color(addr_bg),
560            );
561            draw_border(
562                screen,
563                bar_left as i32,
564                (win_y + 8) as i32,
565                bar_right as i32,
566                (win_y + 32) as i32,
567                addr_border,
568            );
569
570            let is_placeholder = self.address_box.text.is_empty() && !self.address_bar_focused;
571            let display_text = if is_placeholder {
572                String::from("Search or enter address...")
573            } else {
574                self.address_box.text.clone()
575            };
576            let text_color = if is_placeholder {
577                theme.non_active_window_border
578            } else {
579                theme.terminal_fg
580            };
581            screen.draw_string_vector(
582                bar_left + 10,
583                win_y + 12,
584                &display_text,
585                draw::Color(text_color),
586                12,
587            );
588
589            // フォーカス中はカーソル位置に縦棒カーソルを描画(色はターミナルと共通の 0xFF756CE0)
590            if self.address_bar_focused {
591                let prefix: String = self
592                    .address_box
593                    .text
594                    .chars()
595                    .take(self.address_box.cursor_pos)
596                    .collect();
597                let pw = crate::kernel::vector_font::get_vector_string_width(&prefix, 12);
598                let cursor_x = bar_left + 10 + pw;
599                if cursor_x < bar_right {
600                    screen.boxfill(
601                        cursor_x,
602                        win_y + 11,
603                        cursor_x + 1,
604                        win_y + 28,
605                        draw::Color(crate::kernel::ime::cursor_color()),
606                    );
607                }
608            }
609        }
610
611        screen.boxfill(
612            win_x,
613            win_y + 39,
614            win_x + win_w,
615            win_y + 40,
616            crate::kernel::draw::Color(theme.tab_area_border),
617        );
618
619        // --- アドレスバー・Omnibox(サジェスト/履歴/ブックマーク)ドロップダウンの描画 ---
620        if self.address_bar_focused && !self.omnibox_items.is_empty() {
621            let badge_w = if self.engine.current_is_https { 95 } else { 125 };
622            let bar_left = win_x + 162 + badge_w + 6;
623            let bar_right = (win_x + win_w).saturating_sub(10);
624            if bar_right > bar_left {
625                let drop_x = bar_left;
626                let drop_w = bar_right - bar_left;
627                let item_h = 24u32;
628                let drop_h = (self.omnibox_items.len() as u32) * item_h + 4;
629                let drop_y = win_y + 34;
630
631                // 外枠と背景(半透明ダーク)
632                screen.boxfill_alpha(
633                    drop_x,
634                    drop_y,
635                    drop_x + drop_w,
636                    drop_y + drop_h,
637                    draw::Color(0xFF1E1E28),
638                    248,
639                );
640                draw_border(
641                    screen,
642                    drop_x as i32,
643                    drop_y as i32,
644                    (drop_x + drop_w) as i32,
645                    (drop_y + drop_h) as i32,
646                    0xFF3D3D4E,
647                );
648
649                for (i, item) in self.omnibox_items.iter().enumerate() {
650                    let iy = drop_y + 2 + (i as u32) * item_h;
651                    let is_selected = self.selected_suggestion == Some(i);
652
653                    if is_selected {
654                        screen.boxfill(
655                            drop_x + 1,
656                            iy,
657                            drop_x + drop_w - 1,
658                            iy + item_h,
659                            draw::Color(0xFF353548),
660                        );
661                    }
662
663                    // 種別アイコン
664                    let (icon_sym, icon_col) = match item.kind {
665                        crate::os_lib::web_engine::omnibox::OmniboxItemKind::Bookmark => ("*", 0xFFFFD700),
666                        crate::os_lib::web_engine::omnibox::OmniboxItemKind::History => (">", 0xFF6BA4E8),
667                        crate::os_lib::web_engine::omnibox::OmniboxItemKind::Search => ("?", 0xFFDE5833),
668                    };
669
670                    screen.draw_string_vector(
671                        drop_x + 8,
672                        iy + 5,
673                        icon_sym,
674                        draw::Color(icon_col),
675                        11,
676                    );
677
678                    // タイトル / URL
679                    let text_col = if is_selected { 0xFFFFFFFF } else { 0xFFD0D0E0 };
680                    screen.draw_string_vector(
681                        drop_x + 22,
682                        iy + 5,
683                        &item.title,
684                        draw::Color(text_col),
685                        12,
686                    );
687
688                    // 右端バッジラベル
689                    let badge_text = item.kind.badge_label();
690                    if drop_w > 140 {
691                        screen.draw_string_vector(
692                            drop_x + drop_w - 85,
693                            iy + 6,
694                            badge_text,
695                            draw::Color(icon_col),
696                            10,
697                        );
698                    }
699                }
700            }
701        }
702
703        // アドレス・タイトルを同調
704        if !self.address_bar_focused && self.engine.dirty {
705            self.address_box.set_text(&self.engine.get_url());
706        }
707        if !self.engine.title.is_empty() {
708            if let Some(slot) = self.tabs.get_mut(self.active_tab_idx) {
709                slot.title = self.engine.title.clone();
710            }
711        }
712        crate::os_lib::web_engine::set_draw_stage(0);
713    }
714
715    fn on_mouse(
716        &mut self,
717        local_x: i32,
718        local_y: i32,
719        btn_left: bool,
720        btn_right: bool,
721        wheel: i32,
722    ) {
723        let top = self.top_offset();
724
725        if btn_left && self.address_bar_focused && !self.omnibox_items.is_empty() {
726            let bar_left = 162 + if self.engine.current_is_https { 95 } else { 125 } + 6;
727            let bar_right = (self.engine.win_w as i32).saturating_sub(10).max(bar_left + 100);
728            let drop_y = 34;
729            let item_h = 24;
730            let drop_h = (self.omnibox_items.len() as i32) * item_h + 4;
731            if (bar_left..=bar_right).contains(&local_x) && (drop_y..=drop_y + drop_h).contains(&local_y) {
732                let clicked_idx = ((local_y - drop_y - 2) / item_h) as usize;
733                if let Some(item) = self.omnibox_items.get(clicked_idx).cloned() {
734                    self.execute_search_or_navigate(&item.url);
735                    return;
736                }
737            }
738        }
739
740        if btn_left && (0..CONTROL_BAR_H).contains(&local_y) {
741            if (8..=32).contains(&local_y) {
742                if (10..=42).contains(&local_x) {
743                    self.engine.go_back(top);
744                    self.address_bar_focused = false;
745                    self.address_box.set_text(&self.engine.get_url());
746                    self.engine.dirty = true;
747                    self.engine.dirty_reason = "mod.rs:532";
748                    return;
749                }
750                if (48..=80).contains(&local_x) {
751                    self.engine.go_forward(top);
752                    self.address_bar_focused = false;
753                    self.address_box.set_text(&self.engine.get_url());
754                    self.engine.dirty = true;
755                    self.engine.dirty_reason = "mod.rs:539";
756                    return;
757                }
758                if (86..=118).contains(&local_x) {
759                    // [Home]: OS 内蔵の静的ホームページを表示
760                    render_home(&mut self.engine, top);
761                    self.address_bar_focused = false;
762                    self.address_box.set_text(&self.engine.get_url());
763                    self.engine.dirty = true;
764                    self.engine.dirty_reason = "mod.rs:547";
765                    return;
766                }
767                if (124..=156).contains(&local_x) {
768                    // リロード
769                    if self.engine.current_host == "localhost" {
770                        let path = self.engine.current_path.clone();
771                        if path == "/home" {
772                            // 内蔵ホームページの再描画
773                            render_home(&mut self.engine, top);
774                        } else {
775                            // 【2026-08-25 バグ修正】FS_LOCK を取らずに読んでいた。
776                            // 前ページの画像・フォントワーカーはまだ生きており、
777                            // 同時に FS_LOCK 下で書き込む。読み取りの間だけ保持する。
778                            let loaded = {
779                                let _fs_guard = crate::kernel::fs::FS_LOCK.lock();
780                                crate::kernel::fs::get_fs().read_file(&path)
781                            };
782                            if let Some((_meta, content)) = loaded {
783                                if let Ok(html) = core::str::from_utf8(&content) {
784                                    self.engine.load_html(
785                                        html,
786                                        path.strip_prefix('/').unwrap_or(&path),
787                                        top,
788                                    );
789                                }
790                            }
791                        }
792                    } else {
793                        self.engine.load_page(
794                            self.engine.current_is_https,
795                            &self.engine.current_host.clone(),
796                            &self.engine.current_path.clone(),
797                            false,
798                            top,
799                        );
800                    }
801                    self.address_bar_focused = false;
802                    self.address_box.set_text(&self.engine.get_url());
803                    return;
804                }
805                // アドレスバーはセキュリティバッジの直後から始まる (描画と同じ座標)
806                let badge_end = 162
807                    + if self.engine.current_is_https {
808                        95
809                    } else {
810                        125
811                    };
812                let bar_left = badge_end + 6;
813                if local_x >= bar_left {
814                    self.address_bar_focused = true;
815                    self.address_box.set_text(&self.engine.get_url());
816                    self.engine.dirty = true;
817                    self.engine.dirty_reason = "mod.rs:593";
818                    return;
819                }
820            }
821            self.address_bar_focused = false;
822            self.engine.dirty = true;
823            self.engine.dirty_reason = "mod.rs:598";
824            return;
825        }
826
827        if btn_left {
828            self.address_bar_focused = false;
829            self.engine.dirty = true;
830            self.engine.dirty_reason = "mod.rs:604";
831        }
832
833        self.engine
834            .on_mouse(local_x, local_y, btn_left, btn_right, wheel, top);
835    }
836
837    fn on_key(&mut self, keycode: u8, pressed: bool, ascii: Option<char>) {
838        if !pressed {
839            return;
840        }
841
842        if self.address_bar_focused {
843            if keycode == 0x1C {
844                // Enter
845                let chosen_url = if let Some(idx) = self.selected_suggestion {
846                    self.omnibox_items.get(idx).map(|item| item.url.clone())
847                } else {
848                    None
849                };
850                let text_to_nav = chosen_url.unwrap_or_else(|| self.address_box.text.clone());
851                self.execute_search_or_navigate(&text_to_nav);
852            } else if keycode == 0x50 {
853                // Down arrow
854                if !self.omnibox_items.is_empty() {
855                    let next = match self.selected_suggestion {
856                        Some(i) => (i + 1) % self.omnibox_items.len(),
857                        None => 0,
858                    };
859                    self.selected_suggestion = Some(next);
860                    if let Some(item) = self.omnibox_items.get(next) {
861                        self.address_box.set_text(&item.title);
862                    }
863                    self.engine.dirty = true;
864                    self.engine.dirty_reason = "mod.rs:sugg_down";
865                }
866            } else if keycode == 0x48 {
867                // Up arrow
868                if !self.omnibox_items.is_empty() {
869                    let prev = match self.selected_suggestion {
870                        Some(0) | None => self.omnibox_items.len().saturating_sub(1),
871                        Some(i) => i - 1,
872                    };
873                    self.selected_suggestion = Some(prev);
874                    if let Some(item) = self.omnibox_items.get(prev) {
875                        self.address_box.set_text(&item.title);
876                    }
877                    self.engine.dirty = true;
878                    self.engine.dirty_reason = "mod.rs:sugg_up";
879                }
880            } else if keycode == 0x01 {
881                // ESC: サジェスト閉じる
882                self.omnibox_items.clear();
883                self.selected_suggestion = None;
884                self.engine.dirty = true;
885                self.engine.dirty_reason = "mod.rs:sugg_esc";
886            } else {
887                let changed = self.address_box.handle_key(keycode, ascii);
888                if changed {
889                    self.selected_suggestion = None;
890                    let text = self.address_box.text.trim();
891                    if !text.is_empty() {
892                        let search_cands = if !text.starts_with("http://") && !text.starts_with("https://") && !text.starts_with('/') {
893                            crate::os_lib::web_engine::duckduckgo::get_mock_suggestions(text)
894                        } else {
895                            alloc::vec![]
896                        };
897                        self.omnibox_items = self.omnibox.get_suggestions(text, &search_cands, 6);
898                    } else {
899                        self.omnibox_items.clear();
900                    }
901                    self.engine.dirty = true;
902                    self.engine.dirty_reason = "mod.rs:text_input";
903                }
904            }
905            return;
906        }
907
908        self.engine.on_key(keycode, pressed, ascii);
909
910        // テキスト選択コピー操作(Ctrl+C または 0x2E 'c' キーで選択テキストがある場合)
911        if (ascii == Some('\x03') || keycode == 0x2E) && self.engine.focused_id.is_none() && !self.address_bar_focused {
912            let selected = self.engine.get_selected_text();
913            if !selected.is_empty() {
914                crate::os_lib::clipboard::set_text(&selected);
915                crate::info!("[BROWSER] Copied {} bytes to clipboard: {:?}", selected.len(), selected);
916            }
917        }
918
919        // ESC (0x01): テキスト選択解除
920        if keycode == 0x01 && !self.address_bar_focused {
921            self.engine.clear_selection();
922        }
923
924        if keycode == 0x0E && self.engine.focused_id.is_none() {
925            let top = self.top_offset();
926            self.engine.go_back(top);
927            self.address_box.set_text(&self.engine.get_url());
928        }
929
930        // 【2026-08-03】キーボードによるスクロールが**丸ごと未対応**だった。
931        //
932        // ブラウザが処理していたキーは Enter(0x1C) と Backspace(0x0E) の 2 つだけで、
933        // 矢印キーも PageDown も無視していた。実ブラウザなら当然動く操作であり、
934        // 「ページ下部が正しく描けているか」を確認する手段も無かった
935        // (`browser.scroll()` を端末へ送ろうとしても、ブラウザに
936        //  フォーカスがあるためキーが端末へ届かない)。
937        //
938        // 入力欄にフォーカスがあるときは文字入力を優先するため何もしない。
939        if self.engine.focused_id.is_none() && !self.address_bar_focused {
940            // Set-1 スキャンコード。
941            let page = (self.engine.win_h as i32 - self.top_offset()).max(80);
942            let dy = match keycode {
943                0x48 => Some(-60),          // ↑
944                0x50 => Some(60),           // ↓
945                0x49 => Some(-page),        // PageUp
946                0x51 => Some(page),         // PageDown
947                0x47 => Some(-self.engine.max_scroll_y), // Home
948                0x4F => Some(self.engine.max_scroll_y),  // End
949                _ => None,
950            };
951            if let Some(dy) = dy {
952                self.engine.scroll(dy);
953                self.engine.dirty = true;
954                self.engine.dirty_reason = "browser/mod.rs:key_scroll";
955            }
956        }
957    }
958
959    fn tabs(&self) -> alloc::vec::Vec<alloc::string::String> {
960        // アクティブタブのタイトルはライブエンジンから取る(スロットは古い可能性がある)
961        self.tabs
962            .iter()
963            .enumerate()
964            .map(|(i, t)| {
965                if i == self.active_tab_idx {
966                    if self.engine.title.is_empty() {
967                        self.engine.current_host.clone()
968                    } else {
969                        self.engine.title.clone()
970                    }
971                } else {
972                    t.title.clone()
973                }
974            })
975            .collect()
976    }
977
978    fn active_tab(&self) -> usize {
979        self.active_tab_idx
980    }
981
982    fn close_tab(&mut self, idx: usize) -> bool {
983        if idx >= self.tabs.len() {
984            return false;
985        }
986
987        let was_active = idx == self.active_tab_idx;
988        self.tabs.remove(idx);
989
990        if self.tabs.is_empty() {
991            self.engine.active = false;
992            crate::os_lib::web_engine::clear_browser_cache();
993            return true;
994        }
995
996        if was_active {
997            // 閉じたのがアクティブタブ: 隣のタブのエンジンをライブへ取り出す
998            let new_idx = idx.min(self.tabs.len() - 1);
999            if let Some(mut next) = self.tabs[new_idx].engine.take() {
1000                core::mem::swap(&mut self.engine, &mut next);
1001                // next(旧アクティブのエンジン)はタブ削除済みなので破棄される
1002            }
1003            self.active_tab_idx = new_idx;
1004            self.engine.active = true;
1005            self.engine.dirty = true;
1006            self.engine.dirty_reason = "mod.rs:733";
1007            self.address_box.set_text(&self.engine.get_url());
1008            self.address_bar_focused = false;
1009        } else if idx < self.active_tab_idx {
1010            // アクティブより前のタブが消えたのでインデックスを詰める
1011            self.active_tab_idx -= 1;
1012        }
1013        false
1014    }
1015
1016    fn switch_tab(&mut self, idx: usize) {
1017        self.activate(idx);
1018    }
1019
1020    fn add_tab(&mut self) {
1021        if self.tabs.len() >= 16 {
1022            return;
1023        }
1024        // 現アクティブエンジンを現スロットへ戻す
1025        self.sync_active_title();
1026        let old_active = self.active_tab_idx;
1027        let w = self.engine.win_w;
1028        let h = self.engine.win_h;
1029
1030        // 新しい独立エンジンを生成
1031        let mut new_engine = WebEngine::new();
1032        new_engine.win_w = w;
1033        new_engine.win_h = h;
1034        new_engine.current_host = String::from("newtab");
1035        new_engine.current_path = String::from("/");
1036        new_engine.title = String::from("New Tab");
1037        new_engine.active = true;
1038        let top = self.top_offset();
1039        new_engine.parse_and_layout(
1040            "<h1>New Tab</h1><p>Enter URL in the address bar.</p>",
1041            None,
1042            top,
1043            w,
1044            h,
1045        );
1046
1047        // 旧アクティブエンジンをスロットへ退避し、新エンジンをライブにする
1048        let prev = core::mem::replace(&mut self.engine, new_engine);
1049        self.tabs[old_active].engine = Some(prev);
1050
1051        self.tabs.push(TabSlot {
1052            title: String::from("New Tab"),
1053            engine: None,
1054        });
1055        self.active_tab_idx = self.tabs.len() - 1;
1056
1057        self.address_box.set_text("");
1058        self.address_bar_focused = true;
1059        self.engine.dirty = true;
1060        self.engine.dirty_reason = "mod.rs:786";
1061    }
1062
1063    fn get_dom_value(&self, id: &str) -> Option<alloc::string::String> {
1064        self.engine.form_values.get(id).cloned()
1065    }
1066
1067    fn set_dom_value(&mut self, id: &str, val: &str) {
1068        self.engine.form_values.insert(
1069            alloc::string::String::from(id),
1070            alloc::string::String::from(val),
1071        );
1072        self.engine.dirty = true;
1073        self.engine.dirty_reason = "mod.rs:798";
1074    }
1075
1076    fn get_dom_content(&self, id: &str) -> Option<alloc::string::String> {
1077        self.engine.dom_contents.get(id).cloned()
1078    }
1079
1080    fn set_dom_content(&mut self, id: &str, val: &str) {
1081        self.engine.set_dom_content(id, val);
1082    }
1083}