Skip to main content

orinium_browser/browser/core/ui/
mod.rs

1//! Browser UI components and window render state.
2
3// Sub-modules
4mod basic_chrome;
5mod basic_context_menu;
6mod chrome;
7mod context_menu;
8mod renderer;
9
10pub use basic_chrome::BasicChrome;
11pub use basic_context_menu::{BasicContextMenu, MenuItem};
12pub use chrome::{Chrome, ChromeAction, ChromeEventResult};
13pub use context_menu::{ClickContext, ContextMenu, MenuEventResult};
14pub use renderer::BrowserRenderer;
15
16use std::collections::HashMap;
17
18use url::Url;
19use winit::event::{ElementState, Ime, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent};
20
21use crate::browser::Tab;
22use crate::browser::core::resource_loader::{BrowserNetworkError, BrowserResponse};
23use crate::browser::core::tab::{FetchKind, TabTask};
24use crate::engine::layouter::types::ColorScheme;
25use crate::engine::renderer_model::{DrawCommand, Rect};
26use crate::engine::ui::PointerEvent;
27use crate::engine::ui::input_text_types::{InputTextEvent, InputTextKey};
28use crate::platform::renderer::gpu::GpuRenderer;
29
30use super::BrowserCommand;
31
32/// Render state for a browser window.
33#[derive(Debug, Clone)]
34pub struct RenderState {
35    /// List of draw commands generated from the layout engine.
36    pub draw_commands: Vec<DrawCommand>,
37    /// Current window size in pixels (width, height).
38    pub window_size: (u32, u32),
39    /// Current scale factor (for HiDPI displays).
40    pub scale_factor: f64,
41    /// Current window title.
42    pub window_title: String,
43}
44
45impl Default for RenderState {
46    fn default() -> Self {
47        Self {
48            draw_commands: Vec::new(),
49            window_size: (1280, 800),
50            scale_factor: 1.0,
51            window_title: String::new(),
52        }
53    }
54}
55
56impl RenderState {
57    /// Creates a new `RenderState` with the specified size, scale factor, and title.
58    pub fn new(window_size: (u32, u32), scale_factor: f64, window_title: String) -> Self {
59        Self {
60            draw_commands: Vec::new(),
61            window_size,
62            scale_factor,
63            window_title,
64        }
65    }
66
67    /// Calculates the viewport dimensions in scaled logical pixels.
68    pub fn viewport(&self) -> (f32, f32) {
69        let sf = self.scale_factor as f32;
70        (
71            self.window_size.0 as f32 / sf,
72            self.window_size.1 as f32 / sf,
73        )
74    }
75}
76
77/// Stores input-related state for a single browser window.
78#[derive(Default)]
79struct InputState {
80    /// Current mouse position in window coordinates.
81    mouse_position: (f64, f64),
82    /// Current keyboard modifier state (Ctrl, Shift, Alt, etc.).
83    modifiers: winit::keyboard::ModifiersState,
84}
85
86/// タブから発生したリソース取得リクエスト。
87pub(crate) struct TabFetchRequest {
88    pub(crate) tab_id: TabId,
89    pub(crate) request: FetchRequest,
90}
91
92pub struct FetchRequest {
93    pub url: Url,
94    pub kind: FetchKind,
95    /// The origin of the document that requested this resource.
96    pub origin: crate::engine::origin::Origin,
97}
98
99/// [`BrowserUi::tick`] の結果。
100pub(crate) struct BrowserUiTick {
101    pub(crate) fetches: Vec<TabFetchRequest>,
102    pub(crate) needs_redraw: bool,
103}
104
105/// TabId(0) is BrowserChrome.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
107pub struct TabId(pub usize);
108
109impl std::fmt::Display for TabId {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        write!(f, "{:?}", self)
112    }
113}
114
115/// BrowserUi は 1 ウィンドウ分の状態管理を担当する。
116///
117/// 責務:
118/// - タブとアクティブタブの管理
119/// - ウィンドウ・入力イベント(キーボード / IME / マウス / スクロール)の処理
120/// - タブの tick と fetch 結果の配送
121/// - 実際の描画は [`BrowserRenderer`] へ委譲する
122pub struct BrowserUi {
123    tabs: HashMap<TabId, Tab>,
124    active_tab_id: TabId,
125    next_tab_id: TabId,
126    renderer: BrowserRenderer,
127    input: InputState,
128
129    /// ToDo: add set color scheme event
130    system_color_scheme: ColorScheme,
131}
132
133impl Default for BrowserUi {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139impl BrowserUi {
140    /// Creates a UI with the default [`BasicChrome`], the default
141    /// [`BasicContextMenu`] and no tabs.
142    pub fn new() -> Self {
143        Self::with_chrome(Box::new(BasicChrome::new()))
144    }
145
146    /// Creates a UI with one tab and the default [`BasicChrome`].
147    pub fn with_tab(tab: Tab) -> Self {
148        Self::with_tab_and_chrome(tab, Box::new(BasicChrome::new()))
149    }
150
151    /// Creates a UI with a custom chrome, the default [`BasicContextMenu`]
152    /// and no tabs.
153    pub fn with_chrome(chrome: Box<dyn Chrome>) -> Self {
154        Self::with_chrome_and_menu(chrome, Box::new(BasicContextMenu::new()))
155    }
156
157    /// Creates a UI with one tab, a custom chrome and the default
158    /// [`BasicContextMenu`].
159    pub fn with_tab_and_chrome(tab: Tab, chrome: Box<dyn Chrome>) -> Self {
160        Self::with_tab_and_menu(tab, chrome, Box::new(BasicContextMenu::new()))
161    }
162
163    /// Creates a UI with a custom chrome, a custom context menu and no tabs.
164    pub fn with_chrome_and_menu(chrome: Box<dyn Chrome>, menu: Box<dyn ContextMenu>) -> Self {
165        Self {
166            tabs: HashMap::new(),
167            active_tab_id: TabId(0),
168            next_tab_id: TabId(1),
169            renderer: BrowserRenderer::with_chrome_and_menu(chrome, menu),
170            input: InputState::default(),
171            system_color_scheme: dark_light::detect().map(Into::into).unwrap_or_else(|e| {
172                log::error!("Failed to detect system color scheme, using default: {e}");
173                Default::default()
174            }),
175        }
176    }
177
178    /// Creates a UI with one tab, a custom chrome and a custom context menu.
179    pub fn with_tab_and_menu(
180        mut tab: Tab,
181        chrome: Box<dyn Chrome>,
182        menu: Box<dyn ContextMenu>,
183    ) -> Self {
184        let system_color_scheme = dark_light::detect().map(Into::into).unwrap_or_else(|e| {
185            log::error!("Failed to detect system color scheme, using default: {e}");
186            Default::default()
187        });
188
189        tab.set_system_color_scheme(system_color_scheme);
190
191        let tab_id = TabId(1);
192
193        let mut tabs = HashMap::new();
194        tabs.insert(tab_id, tab);
195
196        Self {
197            tabs,
198            active_tab_id: tab_id,
199            next_tab_id: TabId(tab_id.0 + 1),
200            renderer: BrowserRenderer::with_chrome_and_menu(chrome, menu),
201            input: InputState::default(),
202            system_color_scheme,
203        }
204    }
205
206    /// Returns the active tab, if any.
207    pub fn active_tab_id(&self) -> Option<TabId> {
208        if self.active_tab_id.0 == 0 {
209            None
210        } else {
211            Some(self.active_tab_id)
212        }
213    }
214
215    pub fn active_tab(&self) -> Option<&Tab> {
216        self.active_tab_id().and_then(|id| self.tabs.get(&id))
217    }
218
219    pub fn active_tab_mut(&mut self) -> Option<&mut Tab> {
220        self.active_tab_id().and_then(|id| self.tabs.get_mut(&id))
221    }
222
223    /// Returns the tab list.
224    pub fn tab(&self, id: &TabId) -> Option<&Tab> {
225        self.tabs.get(id)
226    }
227
228    pub fn tab_mut(&mut self, id: &TabId) -> Option<&mut Tab> {
229        self.tabs.get_mut(id)
230    }
231
232    pub fn add_tab(&mut self, mut tab: Tab) {
233        tab.set_system_color_scheme(self.system_color_scheme);
234        self.next_tab_id.0 += 1;
235    }
236
237    /// ウィンドウの初期サイズ・スケール・タイトルを設定する。
238    pub fn set_window(&mut self, window_size: (u32, u32), scale_factor: f64, window_title: String) {
239        self.renderer
240            .set_window(window_size, scale_factor, window_title);
241    }
242
243    /// Returns the window size in physical pixels.
244    pub fn window_size(&self) -> (u32, u32) {
245        self.renderer.render_state.window_size
246    }
247
248    /// Returns the current window title.
249    pub fn window_title(&self) -> String {
250        self.renderer.render_state.window_title.clone()
251    }
252
253    /// Rebuilds the render tree and sends draw commands to the GPU for this window.
254    pub fn redraw(&mut self, gpu: &mut GpuRenderer) {
255        let id = self.active_tab_id();
256        self.renderer.redraw(&mut self.tabs, id, gpu);
257    }
258
259    /// Applies the current draw commands to the GPU renderer.
260    pub fn apply_draw_commands(&self, gpu: &mut GpuRenderer) {
261        self.renderer.apply_draw_commands(gpu);
262    }
263
264    /// Ticks all tabs and collects fetch requests and redraw demands.
265    pub(crate) fn tick(&mut self) -> BrowserUiTick {
266        let mut fetches = Vec::new();
267        let mut needs_redraw = false;
268        let mut chrome_actions = Vec::new();
269
270        let tab_ids: Vec<_> = self.tabs.keys().copied().collect();
271
272        for tab_id in &tab_ids {
273            let Some(tab) = self.tab_mut(tab_id) else {
274                continue;
275            };
276            for task in tab.tick() {
277                match task {
278                    TabTask::Fetch { url, kind, origin } => {
279                        log::info!("Fetch requested in BrowserUi: url={}", url);
280                        fetches.push(TabFetchRequest {
281                            tab_id: *tab_id,
282                            request: FetchRequest { url, kind, origin },
283                        });
284                    }
285                    TabTask::NeedsRedraw => {
286                        needs_redraw = true;
287                    }
288                    TabTask::DevToolsRequest { id, .. } => {
289                        // The DevTools pane inspects the visible page; any
290                        // other tab answers for itself.
291
292                        let response = serde_json::json!({
293                            "ok": false,
294                            "error": "no inspected page",
295                        })
296                        .to_string();
297
298                        if let Some(requester) = self.tab_mut(tab_id) {
299                            requester.on_devtools_response(id, response);
300                        }
301                    }
302                }
303            }
304        }
305
306        fetches.extend(
307            self.renderer
308                .chrome
309                .tick(&mut chrome_actions)
310                .0
311                .into_iter()
312                .map(|request| TabFetchRequest {
313                    tab_id: TabId(0),
314                    request,
315                }),
316        );
317
318        for action in chrome_actions {
319            match action {
320                ChromeAction::DevToolsRequest { id, method, params } => {
321                    self.handle_devtools_request(id, method, params);
322                    needs_redraw = true;
323                }
324                ChromeAction::Repaint => needs_redraw = true,
325                action => log::warn!("Ignoring unsupported action from chrome tick: {action:?}"),
326            }
327        }
328
329        BrowserUiTick {
330            fetches,
331            needs_redraw,
332        }
333    }
334
335    /// Delivers a fetched resource to the target tab.
336    pub(crate) fn deliver_fetch(
337        &mut self,
338        tab_id: &TabId,
339        kind: FetchKind,
340        url: Url,
341        response: Result<BrowserResponse, BrowserNetworkError>,
342    ) {
343        if tab_id == &TabId(0) {
344            self.renderer.chrome.deliver_fetch(kind, url, response);
345            return;
346        }
347
348        let Some(tab) = self.tab_mut(tab_id) else {
349            log::warn!("There is no Tab called id={}", tab_id);
350            return;
351        };
352
353        log::info!("Delivering fetch result in BrowserUi for tab_id={}", tab_id);
354        tab.deliver_fetch(kind, url, response);
355    }
356
357    /// Handles a `winit` window event for this window and returns a `BrowserCommand`.
358    pub fn handle_window_event(
359        &mut self,
360        event: WindowEvent,
361        gpu: &mut GpuRenderer,
362    ) -> BrowserCommand {
363        match event {
364            WindowEvent::CloseRequested => BrowserCommand::Exit,
365
366            WindowEvent::RedrawRequested => {
367                self.redraw(gpu);
368                BrowserCommand::RenameWindowTitle
369            }
370
371            WindowEvent::Resized(size) => {
372                self.renderer.render_state.window_size = (size.width, size.height);
373                gpu.resize(size);
374                self.redraw(gpu);
375                BrowserCommand::RequestRedraw
376            }
377
378            WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
379                gpu.set_scale_factor(scale_factor);
380                self.renderer.render_state.scale_factor = scale_factor;
381                self.redraw(gpu);
382                BrowserCommand::RequestRedraw
383            }
384
385            WindowEvent::MouseWheel { delta, .. } => {
386                self.handle_scroll(delta);
387                BrowserCommand::RequestRedraw
388            }
389
390            WindowEvent::CursorMoved { position, .. } => {
391                self.input.mouse_position = (position.x, position.y);
392                if self.handle_pointer_move(position.x, position.y) {
393                    BrowserCommand::RequestRedraw
394                } else {
395                    BrowserCommand::None
396                }
397            }
398
399            WindowEvent::MouseInput { button, state, .. } => self.handle_mouse_input(button, state),
400
401            WindowEvent::ModifiersChanged(modifiers) => {
402                self.input.modifiers = modifiers.state();
403                BrowserCommand::None
404            }
405
406            WindowEvent::KeyboardInput { event, .. } => self.handle_keyboard_input(event),
407
408            WindowEvent::Ime(event) => self.handle_ime_input(event),
409
410            _ => BrowserCommand::None,
411        }
412    }
413
414    /// Handles keyboard input events and returns a `BrowserCommand`.
415    fn handle_keyboard_input(&mut self, event: KeyEvent) -> BrowserCommand {
416        // TODO: あとで消す
417        const KEY_NEW_WINDOW: &str = "n";
418
419        if event.state != ElementState::Pressed {
420            return BrowserCommand::None;
421        }
422
423        let ctrl = self.input.modifiers.control_key();
424
425        if ctrl
426            && let winit::keyboard::Key::Character(ch) = &event.logical_key
427            && ch.as_str().eq_ignore_ascii_case(KEY_NEW_WINDOW)
428        {
429            return BrowserCommand::OpenNewWindow;
430        }
431
432        let Some(tab_id) = &self.active_tab_id() else {
433            return BrowserCommand::None;
434        };
435
436        // While the chrome owns text input (e.g. the address bar is focused),
437        // keyboard input drives the chrome instead of the page.
438        if self.renderer.chrome.accepts_text_input() {
439            let action = self.renderer.chrome.key_event(&event, ctrl);
440            return self.apply_chrome_action(action);
441        }
442
443        let Some(tab) = self.tab(tab_id) else {
444            return BrowserCommand::None;
445        };
446
447        let special = logical_key_to_special_event(&event.logical_key, ctrl);
448        let key = logical_key_to_text_key(&event.logical_key);
449        let handled = if let Some(special) = special {
450            tab.dispatch_text_input(special)
451        } else if let Some(key) = key {
452            tab.dispatch_text_input(InputTextEvent::Key(key))
453        } else if !ctrl && !tab.is_text_input_composing() {
454            event.text.as_ref().is_some_and(|text| {
455                tab.dispatch_text_input(InputTextEvent::Insert(text.to_string()))
456            })
457        } else {
458            false
459        };
460
461        if handled {
462            BrowserCommand::RequestRedraw
463        } else {
464            BrowserCommand::None
465        }
466    }
467
468    /// Handles IME composition updates for the focused text input.
469    fn handle_ime_input(&mut self, event: Ime) -> BrowserCommand {
470        // While the chrome owns text input (e.g. the address bar is focused),
471        // IME events drive the chrome instead of the page.
472        if self.renderer.chrome.accepts_text_input() {
473            let action = self.renderer.chrome.ime_event(&event);
474            return match action {
475                ChromeAction::Repaint => BrowserCommand::RequestRedraw,
476                _ => BrowserCommand::None,
477            };
478        }
479
480        let event = match event {
481            Ime::Preedit(text, _) => InputTextEvent::Preedit(text),
482            Ime::Commit(text) => InputTextEvent::Commit(text),
483            Ime::Disabled => InputTextEvent::CancelComposition,
484            Ime::Enabled => return BrowserCommand::None,
485        };
486
487        let Some(tab) = self.active_tab() else {
488            return BrowserCommand::None;
489        };
490
491        if tab.dispatch_text_input(event) {
492            BrowserCommand::RequestRedraw
493        } else {
494            BrowserCommand::None
495        }
496    }
497
498    /// Handles mouse input events for the active tab.
499    ///
500    /// An open context menu intercepts every press and release before the
501    /// chrome and the page; a right-press over the web content opens it.
502    fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState) -> BrowserCommand {
503        let (x, y, sf) = (
504            self.input.mouse_position.0,
505            self.input.mouse_position.1,
506            self.renderer.render_state.scale_factor,
507        );
508        let (px, py) = ((x / sf) as f32, (y / sf) as f32);
509
510        let width = self.renderer.render_state.viewport().0;
511        let height = self.renderer.render_state.viewport().1;
512
513        // An open context menu gets every press/release before the chrome
514        // and the page. Events it declines fall through to the normal flow.
515        if self.renderer.menu.is_open() {
516            let window_event = match state {
517                ElementState::Pressed => PointerEvent::Down { x: px, y: py },
518                ElementState::Released => PointerEvent::Up { x: px, y: py },
519            };
520            let result = self
521                .renderer
522                .menu
523                .pointer_event(width, height, window_event);
524            if result.consumed {
525                return self.dispatch_action(result.action, ActionSource::ContextMenu, x, y);
526            }
527        } else if button == MouseButton::Right {
528            // A right-press over the web content opens the context menu.
529            if state == ElementState::Pressed {
530                return self.open_context_menu(px, py);
531            }
532            return BrowserCommand::None;
533        }
534
535        if button != MouseButton::Left {
536            return BrowserCommand::None;
537        }
538
539        // Click inside the chrome.
540        let window_event = match state {
541            ElementState::Pressed => PointerEvent::Down { x: px, y: py },
542            ElementState::Released => PointerEvent::Up { x: px, y: py },
543        };
544        let result = self
545            .renderer
546            .chrome
547            .pointer_event(width, height, window_event, state);
548        if result.consumed {
549            return self.dispatch_action(result.action, ActionSource::Chrome, x, y);
550        }
551
552        // Content area: dispatch to the active tab in page coordinates.
553        let Rect { x: dx, y: dy, .. } = self.renderer.chrome.content_rect(width, height);
554
555        let (px, py) = (px - dx, py - dy);
556
557        let (tab_redraw, input_focused) = if let Some(tab) = self.active_tab_mut() {
558            tab.handle_mouse_input(px, py, state)
559        } else {
560            (false, false)
561        };
562
563        // Clicking the page unfocuses the chrome's text input.
564        self.renderer.chrome.blur();
565
566        if tab_redraw {
567            BrowserCommand::RequestRedraw
568        } else {
569            BrowserCommand::SetImeAllowed {
570                allowed: input_focused,
571                position: (x, y),
572            }
573        }
574    }
575
576    /// Applies an action produced by the chrome while it owns text input
577    /// (e.g. Enter in the address bar).
578    ///
579    /// Chrome-originated actions always target the active page tab — never
580    /// the pane that happens to own keyboard focus — so navigating from the
581    /// address bar cannot load into the hidden DevTools pane tab.
582    fn apply_chrome_action(&mut self, action: ChromeAction) -> BrowserCommand {
583        match action {
584            ChromeAction::Repaint => BrowserCommand::RequestRedraw,
585            ChromeAction::None => BrowserCommand::None,
586            action => {
587                let (x, y) = self.input.mouse_position;
588                self.dispatch_action(action, ActionSource::Chrome, x, y)
589            }
590        }
591    }
592
593    /// Applies a [`ChromeAction`] produced by the chrome or the context menu
594    /// to the active tab.
595    ///
596    /// `source` decides whose repaint flag is consumed to close the event
597    /// handling; `(x, y)` are window coordinates for the IME request.
598    fn dispatch_action(
599        &mut self,
600        action: ChromeAction,
601        source: ActionSource,
602        x: f64,
603        y: f64,
604    ) -> BrowserCommand {
605        match action {
606            // Pressing the URL bar enables the OS IME so the caret and
607            // input methods work; the platform handler also requests a
608            // redraw.
609            ChromeAction::EnableIme => {
610                if let Some(tab) = self.active_tab() {
611                    tab.defocus_text_input();
612                }
613                return BrowserCommand::SetImeAllowed {
614                    allowed: true,
615                    position: (x, y),
616                };
617            }
618            ChromeAction::Back => {
619                if let Some(tab) = self.active_tab_mut() {
620                    tab.go_back();
621                }
622            }
623            ChromeAction::Reload => {
624                if let Some(tab) = self.active_tab_mut() {
625                    tab.reload();
626                }
627            }
628            ChromeAction::Navigate(url) => {
629                if let Some(tab) = self.active_tab_mut() {
630                    tab.navigate(url);
631                }
632            }
633            ChromeAction::SetJsPolicy(policy) => {
634                if let Some(tab) = self.active_tab_mut() {
635                    tab.set_js_policy(policy);
636                    tab.reload();
637                }
638            }
639            ChromeAction::DevToolsRequest { id, method, params } => {
640                self.handle_devtools_request(id, method, params)
641            }
642            ChromeAction::Repaint | ChromeAction::None => {}
643        }
644
645        let needs_repaint = match source {
646            ActionSource::Chrome => self.renderer.chrome.needs_repaint(),
647            ActionSource::ContextMenu => self.renderer.menu.needs_repaint(),
648        };
649        if needs_repaint {
650            BrowserCommand::RequestRedraw
651        } else {
652            BrowserCommand::SetImeAllowed {
653                allowed: false,
654                position: (x, y),
655            }
656        }
657    }
658
659    /// Opens the context menu for a right-press at window logical `(px, py)`.
660    ///
661    /// Builds a [`ClickContext`] (positions, link under the cursor, document
662    /// URL) and hands it to the menu. The menu only opens over the inspected
663    /// page pane, never over the chrome or the DevTools pane.
664    fn open_context_menu(&mut self, px: f32, py: f32) -> BrowserCommand {
665        let width = self.renderer.render_state.viewport().0;
666        let height = self.renderer.render_state.viewport().1;
667
668        let Rect {
669            x: dx,
670            y: dy,
671            width: content_width,
672            height: content_height,
673        } = self.renderer.chrome.content_rect(width, height);
674        if px < dx || py < dy || px > dx + content_width || py > dy + content_height {
675            return BrowserCommand::None;
676        }
677
678        let page_pos = (px - dx, py - dy);
679
680        let Some(tab) = self.active_tab() else {
681            return BrowserCommand::None;
682        };
683        let document_url = tab.document_url().map(|url| url.to_string());
684        let link_url = tab.link_at(page_pos.0, page_pos.1);
685
686        let ctx = ClickContext {
687            window_pos: (px, py),
688            page_pos,
689            link_url,
690            document_url,
691        };
692
693        if self.renderer.menu.open(&ctx) {
694            BrowserCommand::RequestRedraw
695        } else {
696            BrowserCommand::None
697        }
698    }
699
700    /// Dispatches a pointer move and updates hover state for the active tab.
701    ///
702    /// Returns whether the move changed any visual state (and thus requires a
703    /// repaint).
704    fn handle_pointer_move(&mut self, x: f64, y: f64) -> bool {
705        let sf = self.renderer.render_state.scale_factor;
706        let (px, py) = ((x / sf) as f32, (y / sf) as f32);
707        let v_width = self.renderer.render_state.viewport().0;
708        let v_height = self.renderer.render_state.viewport().1;
709
710        // An open context menu intercepts every move before chrome/page.
711        if self.renderer.menu.is_open()
712            && self
713                .renderer
714                .menu
715                .pointer_event(v_width, v_height, PointerEvent::Move { x: px, y: py })
716                .consumed
717        {
718            return self.renderer.menu.needs_repaint();
719        }
720
721        // The chrome receives every move so it can track its own hover state.
722        let result = self.renderer.chrome.pointer_event(
723            v_width,
724            v_height,
725            PointerEvent::Move { x: px, y: py },
726            ElementState::Released,
727        );
728
729        if result.consumed {
730            return self.renderer.chrome.needs_repaint();
731        }
732
733        // Forward to the active tab for page hover tracking.
734        let Rect { x: dx, y: dy, .. } = self.renderer.chrome.content_rect(v_width, v_height);
735        let (px, py) = (px - dx, py - dy);
736        let tab_repaint = self
737            .active_tab_mut()
738            .is_some_and(|tab| tab.handle_pointer_move(px, py).0);
739
740        tab_repaint || self.renderer.chrome.needs_repaint()
741    }
742
743    /// Handles scrolling for the pane under the pointer, updating its layout
744    /// container offsets; scrolls outside web content go to the chrome.
745    fn handle_scroll(&mut self, delta: MouseScrollDelta) {
746        let (scroll_x, scroll_y) = match delta {
747            MouseScrollDelta::LineDelta(x, y) => (-x * 60.0, -y * 60.0),
748            MouseScrollDelta::PixelDelta(pos) => (-pos.x as f32, -pos.y as f32),
749        };
750
751        let (w_width, w_height) = self.renderer.render_state.viewport();
752        let Rect {
753            x: sx,
754            y: sy,
755            width,
756            height,
757        } = self.renderer.chrome.content_rect(w_width, w_height);
758
759        let sf = self.renderer.render_state.scale_factor;
760        let (mouse_x, mouse_y) = (
761            (self.input.mouse_position.0 / sf) as f32 - sx,
762            (self.input.mouse_position.1 / sf) as f32 - sy,
763        );
764
765        // `mouse_x`/`mouse_y` are already relative to the content rect.
766        let outside_content = mouse_x < 0.0 || mouse_y < 0.0 || mouse_x > width || mouse_y > height;
767
768        if outside_content {
769            self.renderer
770                .chrome
771                .handle_scroll(w_width, w_height, mouse_x, mouse_y, scroll_x, scroll_y);
772            return;
773        }
774
775        let Some(tab) = self.active_tab_mut() else {
776            return;
777        };
778
779        // Prefer the scrollable container under the cursor.
780        tab.scroll_at(mouse_x, mouse_y, scroll_x, scroll_y, (width, height));
781    }
782
783    fn handle_devtools_request(&mut self, id: u64, method: String, params: String) {
784        let response = match self.active_tab_mut() {
785            Some(target_tab) => match target_tab.inspect(&method, &params) {
786                Ok(data) => serde_json::json!({ "ok": true, "data": data }).to_string(),
787                Err(error) => serde_json::json!({ "ok": false, "error": error }).to_string(),
788            },
789            None => serde_json::json!({
790                "ok": false,
791                "error": "no inspected page",
792            })
793            .to_string(),
794        };
795        self.renderer.chrome.on_devtools_response(id, response);
796    }
797}
798
799/// Where a [`ChromeAction`] came from; decides whose repaint flag closes the
800/// event handling in [`BrowserUi::dispatch_action`].
801#[derive(Debug, Clone, Copy, PartialEq)]
802enum ActionSource {
803    /// The action was produced by the chrome.
804    Chrome,
805    /// The action was produced by the context menu.
806    ContextMenu,
807}
808
809/// Maps a logical key to a text-editing navigation key, if any.
810fn logical_key_to_text_key(key: &winit::keyboard::Key) -> Option<InputTextKey> {
811    match key {
812        winit::keyboard::Key::Named(winit::keyboard::NamedKey::Backspace) => {
813            Some(InputTextKey::Backspace)
814        }
815        winit::keyboard::Key::Named(winit::keyboard::NamedKey::Delete) => {
816            Some(InputTextKey::Delete)
817        }
818        winit::keyboard::Key::Named(winit::keyboard::NamedKey::ArrowLeft) => {
819            Some(InputTextKey::Left)
820        }
821        winit::keyboard::Key::Named(winit::keyboard::NamedKey::ArrowRight) => {
822            Some(InputTextKey::Right)
823        }
824        winit::keyboard::Key::Named(winit::keyboard::NamedKey::Home) => Some(InputTextKey::Home),
825        winit::keyboard::Key::Named(winit::keyboard::NamedKey::End) => Some(InputTextKey::End),
826        _ => None,
827    }
828}
829
830/// Maps a logical key to a text-editing special event (undo/redo/enter).
831fn logical_key_to_special_event(key: &winit::keyboard::Key, ctrl: bool) -> Option<InputTextEvent> {
832    if ctrl && let winit::keyboard::Key::Character(ch) = key {
833        match ch.as_str() {
834            "z" | "Z" => Some(InputTextEvent::Undo),
835            "y" | "Y" => Some(InputTextEvent::Redo),
836            _ => None,
837        }
838    } else if let winit::keyboard::Key::Named(winit::keyboard::NamedKey::Enter) = key {
839        Some(InputTextEvent::Enter)
840    } else {
841        None
842    }
843}
844
845#[cfg(test)]
846mod tests {
847    use super::*;
848    use crate::browser::core::webview::JsPolicy;
849    use crate::engine::layouter::types::ColorScheme;
850
851    fn ui_with_one_tab() -> BrowserUi {
852        let tab = Tab::new(ColorScheme::default(), JsPolicy::default());
853        BrowserUi::with_tab(tab)
854    }
855
856    #[test]
857    fn get_document_serializes_children_under_the_document_root() {
858        let mut ui = ui_with_one_tab();
859        ui.active_tab_mut()
860            .unwrap()
861            .navigate("https://example.test/index.html".parse().expect("url"));
862        ui.active_tab_mut()
863            .unwrap()
864            .on_fetch_succeeded_html("<html><body><p id=\"a\">hello</p></body></html>".to_string());
865
866        let doc = ui
867            .active_tab_mut()
868            .unwrap()
869            .inspect("getDocument", "{}")
870            .expect("document payload");
871        assert_eq!(doc["type"], "document");
872
873        // The frontend descends into synthetic roots, so the document node
874        // must expose element children (e.g. <html>).
875        let children = doc["children"].as_array().expect("children array");
876        assert!(
877            children
878                .iter()
879                .any(|child| child["type"] == "element" && child["tag"] == "html"),
880            "document root must expose the <html> element: {doc}"
881        );
882    }
883}