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};
20use winit::window::CursorIcon;
21
22use crate::browser::Tab;
23use crate::browser::core::resource_loader::{BrowserNetworkError, BrowserResponse};
24use crate::browser::core::tab::{FetchKind, TabTask};
25use crate::engine::layouter::types::{ColorScheme, CursorKeyword, CursorStyle};
26use crate::engine::origin::Origin;
27use crate::engine::permissions::Permissions;
28use crate::engine::renderer_model::{DrawCommand, Rect};
29use crate::engine::ui::PointerEvent;
30use crate::engine::ui::input_text_types::{InputTextEvent, InputTextKey};
31use crate::platform::renderer::draw_sink::DrawSink;
32
33use super::BrowserCommand;
34
35/// Render state for a browser window.
36#[derive(Debug, Clone)]
37pub struct RenderState {
38    /// List of draw commands generated from the layout engine.
39    pub draw_commands: Vec<DrawCommand>,
40    /// Current window size in pixels (width, height).
41    pub window_size: (u32, u32),
42    /// Current scale factor (for HiDPI displays).
43    pub scale_factor: f64,
44    /// Current window title.
45    pub window_title: String,
46}
47
48impl Default for RenderState {
49    fn default() -> Self {
50        Self {
51            draw_commands: Vec::new(),
52            window_size: (1280, 800),
53            scale_factor: 1.0,
54            window_title: String::new(),
55        }
56    }
57}
58
59impl RenderState {
60    /// Creates a new `RenderState` with the specified size, scale factor, and title.
61    pub fn new(window_size: (u32, u32), scale_factor: f64, window_title: String) -> Self {
62        Self {
63            draw_commands: Vec::new(),
64            window_size,
65            scale_factor,
66            window_title,
67        }
68    }
69
70    /// Calculates the viewport dimensions in scaled logical pixels.
71    pub fn viewport(&self) -> (f32, f32) {
72        let sf = self.scale_factor as f32;
73        (
74            self.window_size.0 as f32 / sf,
75            self.window_size.1 as f32 / sf,
76        )
77    }
78}
79
80/// Stores input-related state for a single browser window.
81#[derive(Default)]
82struct InputState {
83    /// Current mouse position in window coordinates.
84    mouse_position: (f64, f64),
85    /// Current keyboard modifier state (Ctrl, Shift, Alt, etc.).
86    modifiers: winit::keyboard::ModifiersState,
87}
88
89/// タブから発生したリソース取得リクエスト。
90pub(crate) struct TabFetchRequest {
91    pub(crate) tab_id: TabId,
92    pub(crate) request: FetchRequest,
93}
94
95pub struct FetchRequest {
96    pub url: Url,
97    pub kind: FetchKind,
98    /// The origin of the document that requested this resource.
99    pub origin: crate::engine::origin::Origin,
100    /// The effective capabilities of the requesting frame.
101    pub permissions: crate::engine::permissions::Permissions,
102    /// Generation of the document that created this request. Stale responses
103    /// from older navigations are ignored.
104    pub generation: u64,
105}
106
107/// [`BrowserUi::tick`] の結果。
108pub(crate) struct BrowserUiTick {
109    pub(crate) fetches: Vec<TabFetchRequest>,
110    pub(crate) needs_redraw: bool,
111}
112
113/// TabId(0) is BrowserChrome.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
115pub struct TabId(pub usize);
116
117impl std::fmt::Display for TabId {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        write!(f, "{:?}", self)
120    }
121}
122
123/// BrowserUi は 1 ウィンドウ分の状態管理を担当する。
124///
125/// 責務:
126/// - タブとアクティブタブの管理
127/// - ウィンドウ・入力イベント(キーボード / IME / マウス / スクロール)の処理
128/// - タブの tick と fetch 結果の配送
129/// - 実際の描画は [`BrowserRenderer`] へ委譲する
130pub struct BrowserUi {
131    tabs: HashMap<TabId, Tab>,
132    active_tab_id: TabId,
133    next_tab_id: TabId,
134    renderer: BrowserRenderer,
135    input: InputState,
136    /// Pending cursor icon to apply to the window on the next frame.
137    pending_cursor: Option<winit::window::CursorIcon>,
138
139    /// ToDo: add set color scheme event
140    system_color_scheme: ColorScheme,
141}
142
143impl Default for BrowserUi {
144    fn default() -> Self {
145        Self::new()
146    }
147}
148
149fn cursor_style_to_icon(style: &CursorStyle) -> CursorIcon {
150    match style {
151        CursorStyle::Keyword(kw) => match kw {
152            CursorKeyword::Auto => CursorIcon::Default,
153            CursorKeyword::Default => CursorIcon::Default,
154            CursorKeyword::None => CursorIcon::Default,
155            CursorKeyword::Pointer => CursorIcon::Pointer,
156            CursorKeyword::Text => CursorIcon::Text,
157            CursorKeyword::Move => CursorIcon::Move,
158            CursorKeyword::NotAllowed => CursorIcon::NotAllowed,
159            CursorKeyword::Wait => CursorIcon::Wait,
160            CursorKeyword::Crosshair => CursorIcon::Crosshair,
161            CursorKeyword::Grab => CursorIcon::Grab,
162            CursorKeyword::Grabbing => CursorIcon::Grabbing,
163        },
164        CursorStyle::Custom { .. } => CursorIcon::Default,
165    }
166}
167
168/// Parameters for delivering a fetched resource to a tab.
169pub(crate) struct FetchParams {
170    pub(crate) kind: FetchKind,
171    pub(crate) url: Url,
172    pub(crate) origin: Origin,
173    pub(crate) permissions: Permissions,
174    pub(crate) response: Result<BrowserResponse, BrowserNetworkError>,
175    pub(crate) generation: u64,
176}
177
178impl BrowserUi {
179    /// Creates a UI with the default [`BasicChrome`], the default
180    /// [`BasicContextMenu`] and no tabs.
181    pub fn new() -> Self {
182        Self::with_chrome(Box::new(BasicChrome::new()))
183    }
184
185    /// Creates a UI with one tab and the default [`BasicChrome`].
186    pub fn with_tab(tab: Tab) -> Self {
187        Self::with_tab_and_chrome(tab, Box::new(BasicChrome::new()))
188    }
189
190    /// Creates a UI with a custom chrome, the default [`BasicContextMenu`]
191    /// and no tabs.
192    pub fn with_chrome(chrome: Box<dyn Chrome>) -> Self {
193        Self::with_chrome_and_menu(chrome, Box::new(BasicContextMenu::new()))
194    }
195
196    /// Creates a UI with one tab, a custom chrome and the default
197    /// [`BasicContextMenu`].
198    pub fn with_tab_and_chrome(tab: Tab, chrome: Box<dyn Chrome>) -> Self {
199        Self::with_tab_and_menu(tab, chrome, Box::new(BasicContextMenu::new()))
200    }
201
202    /// Creates a UI with a custom chrome, a custom context menu and no tabs.
203    pub fn with_chrome_and_menu(chrome: Box<dyn Chrome>, menu: Box<dyn ContextMenu>) -> Self {
204        Self {
205            tabs: HashMap::new(),
206            active_tab_id: TabId(0),
207            next_tab_id: TabId(1),
208            renderer: BrowserRenderer::with_chrome_and_menu(chrome, menu),
209            input: InputState::default(),
210            system_color_scheme: dark_light::detect().map(Into::into).unwrap_or_else(|e| {
211                log::error!("Failed to detect system color scheme, using default: {e}");
212                Default::default()
213            }),
214            pending_cursor: None,
215        }
216    }
217
218    /// Creates a UI with one tab, a custom chrome and a custom context menu.
219    pub fn with_tab_and_menu(
220        mut tab: Tab,
221        chrome: Box<dyn Chrome>,
222        menu: Box<dyn ContextMenu>,
223    ) -> Self {
224        let system_color_scheme = dark_light::detect().map(Into::into).unwrap_or_else(|e| {
225            log::error!("Failed to detect system color scheme, using default: {e}");
226            Default::default()
227        });
228
229        tab.set_system_color_scheme(system_color_scheme);
230
231        let tab_id = TabId(1);
232
233        let mut tabs = HashMap::new();
234        tabs.insert(tab_id, tab);
235
236        Self {
237            tabs,
238            active_tab_id: tab_id,
239            next_tab_id: TabId(tab_id.0 + 1),
240            renderer: BrowserRenderer::with_chrome_and_menu(chrome, menu),
241            input: InputState::default(),
242            system_color_scheme,
243            pending_cursor: None,
244        }
245    }
246
247    /// Returns the active tab, if any.
248    pub fn active_tab_id(&self) -> Option<TabId> {
249        if self.active_tab_id.0 == 0 {
250            None
251        } else {
252            Some(self.active_tab_id)
253        }
254    }
255
256    pub fn active_tab(&self) -> Option<&Tab> {
257        self.active_tab_id().and_then(|id| self.tabs.get(&id))
258    }
259
260    pub fn active_tab_mut(&mut self) -> Option<&mut Tab> {
261        self.active_tab_id().and_then(|id| self.tabs.get_mut(&id))
262    }
263
264    /// Returns the tab list.
265    pub fn tab(&self, id: &TabId) -> Option<&Tab> {
266        self.tabs.get(id)
267    }
268
269    pub fn tab_mut(&mut self, id: &TabId) -> Option<&mut Tab> {
270        self.tabs.get_mut(id)
271    }
272
273    /// Adds a tab and returns its id.
274    ///
275    /// The active tab is left alone: opening a second window should not yank
276    /// focus away from the page the user is reading.
277    pub fn add_tab(&mut self, mut tab: Tab) -> TabId {
278        tab.set_system_color_scheme(self.system_color_scheme);
279        let id = self.next_tab_id;
280        self.next_tab_id.0 += 1;
281        self.tabs.insert(id, tab);
282        id
283    }
284
285    /// ウィンドウの初期サイズ・スケール・タイトルを設定する。
286    pub fn set_window(&mut self, window_size: (u32, u32), scale_factor: f64, window_title: String) {
287        self.renderer
288            .set_window(window_size, scale_factor, window_title);
289    }
290
291    /// Returns the window size in physical pixels.
292    pub fn window_size(&self) -> (u32, u32) {
293        self.renderer.render_state.window_size
294    }
295
296    /// Returns the current window title.
297    pub fn window_title(&self) -> String {
298        self.renderer.render_state.window_title.clone()
299    }
300
301    /// Rebuilds the render tree and sends draw commands to `sink`.
302    pub fn redraw(&mut self, sink: &mut dyn DrawSink) {
303        let id = self.active_tab_id();
304        self.renderer.redraw(&mut self.tabs, id, sink);
305    }
306
307    /// Applies the current draw commands to `sink` without presenting.
308    pub fn apply_draw_commands(&self, sink: &mut dyn DrawSink) {
309        self.renderer.apply_draw_commands(sink);
310    }
311
312    /// Ticks all tabs and collects fetch requests and redraw demands.
313    pub(crate) fn tick(&mut self) -> BrowserUiTick {
314        let mut fetches = Vec::new();
315        let mut needs_redraw = false;
316        let mut chrome_actions = Vec::new();
317
318        let tab_ids: Vec<_> = self.tabs.keys().copied().collect();
319
320        for tab_id in &tab_ids {
321            let is_active = self.active_tab_id().is_some_and(|active| active == *tab_id);
322            let Some(tab) = self.tab_mut(tab_id) else {
323                continue;
324            };
325            tab.set_animation_visible(is_active);
326            for task in tab.tick() {
327                match task {
328                    TabTask::Fetch {
329                        url,
330                        kind,
331                        origin,
332                        permissions,
333                        generation,
334                    } => {
335                        log::info!("Fetch requested in BrowserUi: url={}", url);
336                        fetches.push(TabFetchRequest {
337                            tab_id: *tab_id,
338                            request: FetchRequest {
339                                url,
340                                kind,
341                                origin,
342                                permissions,
343                                generation,
344                            },
345                        });
346                    }
347                    TabTask::NeedsRedraw => {
348                        needs_redraw = true;
349                    }
350                    TabTask::DevToolsRequest { id, .. } => {
351                        // The DevTools pane inspects the visible page; any
352                        // other tab answers for itself.
353
354                        let response = serde_json::json!({
355                            "ok": false,
356                            "error": "no inspected page",
357                        })
358                        .to_string();
359
360                        if let Some(requester) = self.tab_mut(tab_id) {
361                            requester.on_devtools_response(id, response);
362                        }
363                    }
364                }
365            }
366        }
367
368        fetches.extend(
369            self.renderer
370                .chrome
371                .tick(&mut chrome_actions)
372                .0
373                .into_iter()
374                .map(|request| TabFetchRequest {
375                    tab_id: TabId(0),
376                    request,
377                }),
378        );
379
380        for action in chrome_actions {
381            match action {
382                ChromeAction::DevToolsRequest { id, method, params } => {
383                    self.handle_devtools_request(id, method, params);
384                    needs_redraw = true;
385                }
386                ChromeAction::Repaint => needs_redraw = true,
387                action => log::warn!("Ignoring unsupported action from chrome tick: {action:?}"),
388            }
389        }
390
391        BrowserUiTick {
392            fetches,
393            needs_redraw,
394        }
395    }
396
397    /// Delivers a fetched resource to the target tab.
398    pub(crate) fn deliver_fetch(&mut self, tab_id: &TabId, params: FetchParams) {
399        let FetchParams {
400            kind,
401            url,
402            origin,
403            permissions,
404            response,
405            generation,
406        } = params;
407        if tab_id == &TabId(0) {
408            self.renderer.chrome.deliver_fetch(
409                kind,
410                url,
411                origin,
412                permissions,
413                response,
414                generation,
415            );
416            return;
417        }
418
419        let Some(tab) = self.tab_mut(tab_id) else {
420            log::warn!("There is no Tab called id={}", tab_id);
421            return;
422        };
423
424        log::info!("Delivering fetch result in BrowserUi for tab_id={}", tab_id);
425        tab.deliver_fetch(kind, url, origin, permissions, response, generation);
426    }
427
428    /// Handles a `winit` window event for this window and returns a `BrowserCommand`.
429    pub fn handle_window_event(
430        &mut self,
431        event: WindowEvent,
432        sink: &mut dyn DrawSink,
433    ) -> BrowserCommand {
434        match event {
435            WindowEvent::CloseRequested => BrowserCommand::Exit,
436
437            WindowEvent::RedrawRequested => {
438                self.redraw(sink);
439                BrowserCommand::RenameWindowTitle
440            }
441
442            WindowEvent::Resized(size) => {
443                self.renderer.render_state.window_size = (size.width, size.height);
444                sink.resize((size.width, size.height));
445                self.redraw(sink);
446                BrowserCommand::RequestRedraw
447            }
448
449            WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
450                sink.set_scale_factor(scale_factor);
451                self.renderer.render_state.scale_factor = scale_factor;
452                self.redraw(sink);
453                BrowserCommand::RequestRedraw
454            }
455
456            WindowEvent::MouseWheel { delta, .. } => {
457                self.handle_scroll(delta);
458                BrowserCommand::RequestRedraw
459            }
460
461            WindowEvent::CursorMoved { position, .. } => {
462                self.input.mouse_position = (position.x, position.y);
463                if self.handle_pointer_move(position.x, position.y) {
464                    self.pending_cursor
465                        .take()
466                        .map(BrowserCommand::SetCursorIcon)
467                        .unwrap_or(BrowserCommand::RequestRedraw)
468                } else {
469                    self.pending_cursor
470                        .take()
471                        .map(BrowserCommand::SetCursorIcon)
472                        .unwrap_or(BrowserCommand::None)
473                }
474            }
475
476            WindowEvent::MouseInput { button, state, .. } => self.handle_mouse_input(button, state),
477
478            WindowEvent::ModifiersChanged(modifiers) => {
479                self.input.modifiers = modifiers.state();
480                BrowserCommand::None
481            }
482
483            WindowEvent::KeyboardInput { event, .. } => self.handle_keyboard_input(event),
484
485            WindowEvent::Ime(event) => self.handle_ime_input(event),
486
487            _ => BrowserCommand::None,
488        }
489    }
490
491    /// Handles keyboard input events and returns a `BrowserCommand`.
492    fn handle_keyboard_input(&mut self, event: KeyEvent) -> BrowserCommand {
493        if event.state != ElementState::Pressed {
494            return BrowserCommand::None;
495        }
496
497        let ctrl = self.input.modifiers.control_key();
498
499        let Some(tab_id) = &self.active_tab_id() else {
500            return BrowserCommand::None;
501        };
502
503        // While the chrome owns text input (e.g. the address bar is focused),
504        // keyboard input drives the chrome instead of the page.
505        if self.renderer.chrome.accepts_text_input() {
506            let action = self.renderer.chrome.key_event(&event, ctrl);
507            return self.apply_chrome_action(action);
508        }
509
510        let Some(tab) = self.tab(tab_id) else {
511            return BrowserCommand::None;
512        };
513
514        let special = logical_key_to_special_event(&event.logical_key, ctrl);
515        let key = logical_key_to_text_key(&event.logical_key);
516        let handled = if let Some(special) = special {
517            tab.dispatch_text_input(special)
518        } else if let Some(key) = key {
519            tab.dispatch_text_input(InputTextEvent::Key(key))
520        } else if !ctrl && !tab.is_text_input_composing() {
521            event.text.as_ref().is_some_and(|text| {
522                tab.dispatch_text_input(InputTextEvent::Insert(text.to_string()))
523            })
524        } else {
525            false
526        };
527
528        if handled {
529            BrowserCommand::RequestRedraw
530        } else {
531            BrowserCommand::None
532        }
533    }
534
535    /// Handles IME composition updates for the focused text input.
536    fn handle_ime_input(&mut self, event: Ime) -> BrowserCommand {
537        // While the chrome owns text input (e.g. the address bar is focused),
538        // IME events drive the chrome instead of the page.
539        if self.renderer.chrome.accepts_text_input() {
540            let action = self.renderer.chrome.ime_event(&event);
541            return match action {
542                ChromeAction::Repaint => BrowserCommand::RequestRedraw,
543                _ => BrowserCommand::None,
544            };
545        }
546
547        let event = match event {
548            Ime::Preedit(text, _) => InputTextEvent::Preedit(text),
549            Ime::Commit(text) => InputTextEvent::Commit(text),
550            Ime::Disabled => InputTextEvent::CancelComposition,
551            Ime::Enabled => return BrowserCommand::None,
552        };
553
554        let Some(tab) = self.active_tab() else {
555            return BrowserCommand::None;
556        };
557
558        if tab.dispatch_text_input(event) {
559            BrowserCommand::RequestRedraw
560        } else {
561            BrowserCommand::None
562        }
563    }
564
565    /// Handles mouse input events for the active tab.
566    ///
567    /// An open context menu intercepts every press and release before the
568    /// chrome and the page; a right-press over the web content opens it.
569    fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState) -> BrowserCommand {
570        let (x, y, sf) = (
571            self.input.mouse_position.0,
572            self.input.mouse_position.1,
573            self.renderer.render_state.scale_factor,
574        );
575        let (px, py) = ((x / sf) as f32, (y / sf) as f32);
576
577        let width = self.renderer.render_state.viewport().0;
578        let height = self.renderer.render_state.viewport().1;
579
580        // An open context menu gets every press/release before the chrome
581        // and the page. Events it declines fall through to the normal flow.
582        if self.renderer.menu.is_open() {
583            let window_event = match state {
584                ElementState::Pressed => PointerEvent::Down { x: px, y: py },
585                ElementState::Released => PointerEvent::Up { x: px, y: py },
586            };
587            let result = self
588                .renderer
589                .menu
590                .pointer_event(width, height, window_event);
591            if result.consumed {
592                return self.dispatch_action(result.action, ActionSource::ContextMenu, x, y);
593            }
594        } else if button == MouseButton::Right {
595            // A right-press over the web content opens the context menu.
596            if state == ElementState::Pressed {
597                return self.open_context_menu(px, py);
598            }
599            return BrowserCommand::None;
600        }
601
602        if button != MouseButton::Left {
603            return BrowserCommand::None;
604        }
605
606        // Click inside the chrome.
607        let window_event = match state {
608            ElementState::Pressed => PointerEvent::Down { x: px, y: py },
609            ElementState::Released => PointerEvent::Up { x: px, y: py },
610        };
611        let result = self
612            .renderer
613            .chrome
614            .pointer_event(width, height, window_event, state);
615        if result.consumed {
616            return self.dispatch_action(result.action, ActionSource::Chrome, x, y);
617        }
618
619        // Content area: dispatch to the active tab in page coordinates.
620        let Rect { x: dx, y: dy, .. } = self.renderer.chrome.content_rect(width, height);
621
622        let (px, py) = (px - dx, py - dy);
623
624        let (tab_redraw, input_focused) = if let Some(tab) = self.active_tab_mut() {
625            tab.handle_mouse_input(px, py, state)
626        } else {
627            (false, false)
628        };
629
630        // Clicking the page unfocuses the chrome's text input.
631        self.renderer.chrome.blur();
632
633        if tab_redraw {
634            BrowserCommand::RequestRedraw
635        } else {
636            BrowserCommand::SetImeAllowed {
637                allowed: input_focused,
638                position: (x, y),
639            }
640        }
641    }
642
643    /// Applies an action produced by the chrome while it owns text input
644    /// (e.g. Enter in the address bar).
645    ///
646    /// Chrome-originated actions always target the active page tab — never
647    /// the pane that happens to own keyboard focus — so navigating from the
648    /// address bar cannot load into the hidden DevTools pane tab.
649    fn apply_chrome_action(&mut self, action: ChromeAction) -> BrowserCommand {
650        match action {
651            ChromeAction::Repaint => BrowserCommand::RequestRedraw,
652            ChromeAction::None => BrowserCommand::None,
653            action => {
654                let (x, y) = self.input.mouse_position;
655                self.dispatch_action(action, ActionSource::Chrome, x, y)
656            }
657        }
658    }
659
660    /// Applies a [`ChromeAction`] produced by the chrome or the context menu
661    /// to the active tab.
662    ///
663    /// `source` decides whose repaint flag is consumed to close the event
664    /// handling; `(x, y)` are window coordinates for the IME request.
665    fn dispatch_action(
666        &mut self,
667        action: ChromeAction,
668        source: ActionSource,
669        x: f64,
670        y: f64,
671    ) -> BrowserCommand {
672        match action {
673            // Pressing the URL bar enables the OS IME so the caret and
674            // input methods work; the platform handler also requests a
675            // redraw.
676            ChromeAction::EnableIme => {
677                if let Some(tab) = self.active_tab() {
678                    tab.defocus_text_input();
679                }
680                return BrowserCommand::SetImeAllowed {
681                    allowed: true,
682                    position: (x, y),
683                };
684            }
685            ChromeAction::Back => {
686                if let Some(tab) = self.active_tab_mut() {
687                    tab.go_back();
688                }
689            }
690            ChromeAction::Reload => {
691                if let Some(tab) = self.active_tab_mut() {
692                    tab.reload();
693                }
694            }
695            ChromeAction::Navigate(url) => {
696                if let Some(tab) = self.active_tab_mut() {
697                    tab.navigate(url);
698                }
699            }
700            ChromeAction::SetJsPolicy(policy) => {
701                if let Some(tab) = self.active_tab_mut() {
702                    tab.set_js_policy(policy);
703                    tab.reload();
704                }
705            }
706            ChromeAction::DevToolsRequest { id, method, params } => {
707                self.handle_devtools_request(id, method, params)
708            }
709            ChromeAction::Repaint | ChromeAction::None => {}
710        }
711
712        let needs_repaint = match source {
713            ActionSource::Chrome => self.renderer.chrome.needs_repaint(),
714            ActionSource::ContextMenu => self.renderer.menu.needs_repaint(),
715        };
716        if needs_repaint {
717            BrowserCommand::RequestRedraw
718        } else {
719            BrowserCommand::SetImeAllowed {
720                allowed: false,
721                position: (x, y),
722            }
723        }
724    }
725
726    /// Opens the context menu for a right-press at window logical `(px, py)`.
727    ///
728    /// Builds a [`ClickContext`] (positions, link under the cursor, document
729    /// URL) and hands it to the menu. The menu only opens over the inspected
730    /// page pane, never over the chrome or the DevTools pane.
731    fn open_context_menu(&mut self, px: f32, py: f32) -> BrowserCommand {
732        let width = self.renderer.render_state.viewport().0;
733        let height = self.renderer.render_state.viewport().1;
734
735        let Rect {
736            x: dx,
737            y: dy,
738            width: content_width,
739            height: content_height,
740        } = self.renderer.chrome.content_rect(width, height);
741        if px < dx || py < dy || px > dx + content_width || py > dy + content_height {
742            return BrowserCommand::None;
743        }
744
745        let page_pos = (px - dx, py - dy);
746
747        let Some(tab) = self.active_tab() else {
748            return BrowserCommand::None;
749        };
750        let document_url = tab.document_url().map(|url| url.to_string());
751        let link_url = tab.link_at(page_pos.0, page_pos.1);
752
753        let ctx = ClickContext {
754            window_pos: (px, py),
755            page_pos,
756            link_url,
757            document_url,
758        };
759
760        if self.renderer.menu.open(&ctx) {
761            BrowserCommand::RequestRedraw
762        } else {
763            BrowserCommand::None
764        }
765    }
766
767    /// Dispatches a pointer move and updates hover state for the active tab.
768    ///
769    /// Returns whether the move changed any visual state (and thus requires a
770    /// repaint).
771    fn handle_pointer_move(&mut self, x: f64, y: f64) -> bool {
772        let sf = self.renderer.render_state.scale_factor;
773        let (px, py) = ((x / sf) as f32, (y / sf) as f32);
774        let v_width = self.renderer.render_state.viewport().0;
775        let v_height = self.renderer.render_state.viewport().1;
776
777        // An open context menu intercepts every move before chrome/page.
778        if self.renderer.menu.is_open()
779            && self
780                .renderer
781                .menu
782                .pointer_event(v_width, v_height, PointerEvent::Move { x: px, y: py })
783                .consumed
784        {
785            return self.renderer.menu.needs_repaint();
786        }
787
788        // The chrome receives every move so it can track its own hover state.
789        let result = self.renderer.chrome.pointer_event(
790            v_width,
791            v_height,
792            PointerEvent::Move { x: px, y: py },
793            ElementState::Released,
794        );
795
796        if result.consumed {
797            return self.renderer.chrome.needs_repaint();
798        }
799
800        // Forward to the active tab for page hover tracking.
801        let Rect { x: dx, y: dy, .. } = self.renderer.chrome.content_rect(v_width, v_height);
802        let (px, py) = (px - dx, py - dy);
803        let tab_repaint = self
804            .active_tab_mut()
805            .is_some_and(|tab| tab.handle_pointer_move(px, py).0);
806
807        // Update the pending cursor from the element under the pointer.
808        self.pending_cursor = self
809            .active_tab_mut()
810            .and_then(|tab| tab.cursor_at(px, py))
811            .map(|cs| cursor_style_to_icon(&cs));
812
813        tab_repaint || self.renderer.chrome.needs_repaint()
814    }
815
816    /// Handles scrolling for the pane under the pointer, updating its layout
817    /// container offsets; scrolls outside web content go to the chrome.
818    fn handle_scroll(&mut self, delta: MouseScrollDelta) {
819        let (scroll_x, scroll_y) = match delta {
820            MouseScrollDelta::LineDelta(x, y) => (-x * 60.0, -y * 60.0),
821            MouseScrollDelta::PixelDelta(pos) => (-pos.x as f32, -pos.y as f32),
822        };
823
824        let (w_width, w_height) = self.renderer.render_state.viewport();
825        let Rect {
826            x: sx,
827            y: sy,
828            width,
829            height,
830        } = self.renderer.chrome.content_rect(w_width, w_height);
831
832        let sf = self.renderer.render_state.scale_factor;
833        let (mouse_x, mouse_y) = (
834            (self.input.mouse_position.0 / sf) as f32 - sx,
835            (self.input.mouse_position.1 / sf) as f32 - sy,
836        );
837
838        // `mouse_x`/`mouse_y` are already relative to the content rect.
839        let outside_content = mouse_x < 0.0 || mouse_y < 0.0 || mouse_x > width || mouse_y > height;
840
841        if outside_content {
842            self.renderer
843                .chrome
844                .handle_scroll(w_width, w_height, mouse_x, mouse_y, scroll_x, scroll_y);
845            return;
846        }
847
848        let Some(tab) = self.active_tab_mut() else {
849            return;
850        };
851
852        // Prefer the scrollable container under the cursor.
853        tab.scroll_at(mouse_x, mouse_y, scroll_x, scroll_y, (width, height));
854    }
855
856    fn handle_devtools_request(&mut self, id: u64, method: String, params: String) {
857        // getVersion does not strictly require a page tab: when a page is
858        // loaded, return its real version info so the DevTools pane can load
859        // the DOM tree. When no page is loaded yet, return a minimal response
860        // so the DevTools pane can connect and show "connected" status.
861        if method == "getVersion" {
862            if let Some(target_tab) = self.active_tab_mut()
863                && let Ok(data) = target_tab.inspect("getVersion", "{}")
864            {
865                let response = serde_json::json!({ "ok": true, "data": data }).to_string();
866                self.renderer.chrome.on_devtools_response(id, response);
867                return;
868            }
869            let response = serde_json::json!({
870                "ok": true,
871                "data": {
872                    "domVersion": 0,
873                    "layoutVersion": 0,
874                    "url": null,
875                }
876            })
877            .to_string();
878            self.renderer.chrome.on_devtools_response(id, response);
879            return;
880        }
881
882        let response = match self.active_tab_mut() {
883            Some(target_tab) => match target_tab.inspect(&method, &params) {
884                Ok(data) => serde_json::json!({ "ok": true, "data": data }).to_string(),
885                Err(error) => serde_json::json!({ "ok": false, "error": error }).to_string(),
886            },
887            None => serde_json::json!({
888                "ok": false,
889                "error": "no inspected page",
890            })
891            .to_string(),
892        };
893        self.renderer.chrome.on_devtools_response(id, response);
894    }
895}
896
897/// Where a [`ChromeAction`] came from; decides whose repaint flag closes the
898/// event handling in [`BrowserUi::dispatch_action`].
899#[derive(Debug, Clone, Copy, PartialEq)]
900enum ActionSource {
901    /// The action was produced by the chrome.
902    Chrome,
903    /// The action was produced by the context menu.
904    ContextMenu,
905}
906
907/// Maps a logical key to a text-editing navigation key, if any.
908fn logical_key_to_text_key(key: &winit::keyboard::Key) -> Option<InputTextKey> {
909    match key {
910        winit::keyboard::Key::Named(winit::keyboard::NamedKey::Backspace) => {
911            Some(InputTextKey::Backspace)
912        }
913        winit::keyboard::Key::Named(winit::keyboard::NamedKey::Delete) => {
914            Some(InputTextKey::Delete)
915        }
916        winit::keyboard::Key::Named(winit::keyboard::NamedKey::ArrowLeft) => {
917            Some(InputTextKey::Left)
918        }
919        winit::keyboard::Key::Named(winit::keyboard::NamedKey::ArrowRight) => {
920            Some(InputTextKey::Right)
921        }
922        winit::keyboard::Key::Named(winit::keyboard::NamedKey::Home) => Some(InputTextKey::Home),
923        winit::keyboard::Key::Named(winit::keyboard::NamedKey::End) => Some(InputTextKey::End),
924        _ => None,
925    }
926}
927
928/// Maps a logical key to a text-editing special event (undo/redo/enter).
929fn logical_key_to_special_event(key: &winit::keyboard::Key, ctrl: bool) -> Option<InputTextEvent> {
930    if ctrl && let winit::keyboard::Key::Character(ch) = key {
931        match ch.as_str() {
932            "z" | "Z" => Some(InputTextEvent::Undo),
933            "y" | "Y" => Some(InputTextEvent::Redo),
934            _ => None,
935        }
936    } else if let winit::keyboard::Key::Named(winit::keyboard::NamedKey::Enter) = key {
937        Some(InputTextEvent::Enter)
938    } else {
939        None
940    }
941}
942
943#[cfg(test)]
944mod tests {
945    use super::*;
946    use crate::browser::core::webview::JsPolicy;
947    use crate::engine::renderer_model::DrawCommand;
948
949    fn ui_with_one_tab() -> BrowserUi {
950        let tab = Tab::new(ColorScheme::default(), JsPolicy::default());
951        BrowserUi::with_tab(tab)
952    }
953
954    /// A sink that records frames instead of presenting them.
955    ///
956    /// Before the `DrawSink` port the whole redraw path required a real
957    /// `wgpu` surface, so none of it was reachable from a unit test. Recording
958    /// the frames is enough to assert what the browser layer produced.
959    #[derive(Default)]
960    struct RecordingSink {
961        frames: Vec<Vec<crate::engine::renderer_model::DrawCommand>>,
962        pending: Vec<crate::engine::renderer_model::DrawCommand>,
963        size: (u32, u32),
964        scale_factor: f64,
965    }
966
967    impl DrawSink for RecordingSink {
968        fn upload(&mut self, commands: &[crate::engine::renderer_model::DrawCommand]) {
969            self.pending.extend_from_slice(commands);
970        }
971
972        fn present(&mut self) -> Result<(), crate::platform::renderer::draw_sink::DrawError> {
973            self.frames.push(std::mem::take(&mut self.pending));
974            Ok(())
975        }
976
977        fn resize(&mut self, size: (u32, u32)) {
978            self.size = size;
979        }
980
981        fn set_scale_factor(&mut self, scale_factor: f64) {
982            self.scale_factor = scale_factor;
983        }
984    }
985
986    #[test]
987    fn redraw_produces_a_frame_without_a_gpu() {
988        let mut ui = ui_with_one_tab();
989        ui.set_window((800, 600), 1.0, "test".to_string());
990        let mut sink = RecordingSink::default();
991
992        ui.redraw(&mut sink);
993
994        assert_eq!(
995            sink.frames.len(),
996            1,
997            "redraw must present exactly one frame"
998        );
999        let frame = &sink.frames[0];
1000        assert!(!frame.is_empty());
1001        // The frame is a balanced clip/transform scope: page, then chrome, then
1002        // the context menu on top.
1003        assert!(matches!(frame.first(), Some(DrawCommand::PushClip { .. })));
1004        assert_eq!(
1005            frame
1006                .iter()
1007                .filter(|c| matches!(c, DrawCommand::PopClip))
1008                .count(),
1009            frame
1010                .iter()
1011                .filter(|c| matches!(c, DrawCommand::PushClip { .. }))
1012                .count(),
1013            "clip scopes must be balanced: {frame:?}"
1014        );
1015    }
1016
1017    #[test]
1018    fn apply_draw_commands_does_not_present() {
1019        let mut ui = ui_with_one_tab();
1020        ui.set_window((800, 600), 1.0, "test".to_string());
1021
1022        // `apply_draw_commands` re-sends what is already recorded rather than
1023        // rebuilding, so the buffer has to be populated first.
1024        let mut priming = RecordingSink::default();
1025        ui.redraw(&mut priming);
1026        assert!(!priming.frames.is_empty());
1027
1028        let mut sink = RecordingSink::default();
1029        ui.apply_draw_commands(&mut sink);
1030
1031        assert!(
1032            sink.frames.is_empty(),
1033            "apply_draw_commands records without presenting"
1034        );
1035        assert!(
1036            !sink.pending.is_empty(),
1037            "apply_draw_commands must upload the recorded frame"
1038        );
1039    }
1040
1041    #[test]
1042    fn resize_reaches_the_sink_as_plain_pixels() {
1043        // `WindowEvent::ScaleFactorChanged` carries a winit-internal
1044        // `InnerSizeWriter` that cannot be constructed outside winit, so the
1045        // scale-factor half of the forwarding is covered by
1046        // `platform::renderer::draw_sink` instead.
1047        let mut ui = ui_with_one_tab();
1048        ui.set_window((800, 600), 1.0, "test".to_string());
1049        let mut sink = RecordingSink::default();
1050
1051        let cmd = ui.handle_window_event(
1052            WindowEvent::Resized(winit::dpi::PhysicalSize::new(1024, 768)),
1053            &mut sink,
1054        );
1055
1056        assert!(matches!(cmd, BrowserCommand::RequestRedraw));
1057        // The port speaks `(u32, u32)`, not `PhysicalSize<u32>`: the browser
1058        // layer stays free of winit's pixel types.
1059        assert_eq!(sink.size, (1024, 768));
1060        assert_eq!(sink.frames.len(), 1, "a resize repaints");
1061    }
1062
1063    #[test]
1064    fn redraw_requested_event_presents_a_frame() {
1065        let mut ui = ui_with_one_tab();
1066        ui.set_window((800, 600), 1.0, "test".to_string());
1067        let mut sink = RecordingSink::default();
1068
1069        let cmd = ui.handle_window_event(WindowEvent::RedrawRequested, &mut sink);
1070
1071        assert!(matches!(cmd, BrowserCommand::RenameWindowTitle));
1072        assert_eq!(sink.frames.len(), 1);
1073    }
1074
1075    /// `add_tab` used to bump the id counter and drop the tab on the floor, so
1076    /// a second tab could never be reached.
1077    #[test]
1078    fn add_tab_inserts_without_stealing_focus() {
1079        let mut ui = ui_with_one_tab();
1080        let original = ui.active_tab_id().expect("the fixture has a tab");
1081
1082        let first = ui.add_tab(Tab::new(ColorScheme::default(), JsPolicy::default()));
1083        let second = ui.add_tab(Tab::new(ColorScheme::default(), JsPolicy::default()));
1084
1085        assert_ne!(first, second, "each tab gets its own id");
1086        assert_ne!(first, original);
1087        assert_ne!(second, first);
1088        assert!(ui.tab(&first).is_some(), "first tab is retrievable");
1089        assert!(ui.tab(&second).is_some(), "second tab is retrievable");
1090        assert_eq!(
1091            ui.active_tab_id(),
1092            Some(original),
1093            "adding a tab must not move the active tab"
1094        );
1095    }
1096
1097    #[test]
1098    fn tab_ids_are_not_reused() {
1099        let mut ui = BrowserUi::with_chrome(Box::new(BasicChrome::new()));
1100        let first = ui.add_tab(Tab::new(ColorScheme::default(), JsPolicy::default()));
1101        let second = ui.add_tab(Tab::new(ColorScheme::default(), JsPolicy::default()));
1102
1103        assert_ne!(first, second);
1104        assert!(ui.active_tab_id().is_none(), "no tab is active yet");
1105    }
1106
1107    #[test]
1108    fn get_document_serializes_children_under_the_document_root() {
1109        let mut ui = ui_with_one_tab();
1110        ui.active_tab_mut()
1111            .unwrap()
1112            .navigate("https://example.test/index.html".parse().expect("url"));
1113        ui.active_tab_mut()
1114            .unwrap()
1115            .on_fetch_succeeded_html("<html><body><p id=\"a\">hello</p></body></html>".to_string());
1116
1117        let doc = ui
1118            .active_tab_mut()
1119            .unwrap()
1120            .inspect("getDocument", "{}")
1121            .expect("document payload");
1122        assert_eq!(doc["type"], "document");
1123
1124        // The frontend descends into synthetic roots, so the document node
1125        // must expose element children (e.g. <html>).
1126        let children = doc["children"].as_array().expect("children array");
1127        assert!(
1128            children
1129                .iter()
1130                .any(|child| child["type"] == "element" && child["tag"] == "html"),
1131            "document root must expose the <html> element: {doc}"
1132        );
1133    }
1134
1135    #[test]
1136    fn get_version_reports_document_url() {
1137        let mut ui = ui_with_one_tab();
1138        ui.active_tab_mut()
1139            .unwrap()
1140            .navigate("https://example.test/index.html".parse().expect("url"));
1141        ui.active_tab_mut()
1142            .unwrap()
1143            .on_fetch_succeeded_html("<html><body>hi</body></html>".to_string());
1144
1145        let version = ui
1146            .active_tab_mut()
1147            .unwrap()
1148            .inspect("getVersion", "{}")
1149            .expect("version payload");
1150        assert_eq!(
1151            version["url"].as_str(),
1152            Some("https://example.test/index.html"),
1153            "getVersion must expose the inspected document URL: {version}"
1154        );
1155        assert!(
1156            version["domVersion"].is_u64(),
1157            "domVersion must be a number: {version}"
1158        );
1159        assert!(
1160            version["layoutVersion"].is_u64(),
1161            "layoutVersion must be a number: {version}"
1162        );
1163    }
1164}