Skip to main content

orinium_browser/browser/core/ui/
basic_chrome.rs

1//! Default browser chrome: a toolbar with a back button, a reload button, and a
2//! URL bar, laid out above the page content area.
3//!
4//! This is the stock [`Chrome`] implementation used when no custom chrome is
5//! provided. It is deliberately simple and hardcoded; the core only talks to it
6//! through the [`Chrome`] trait, so it can be replaced by any user-designed UI.
7//!
8//! The chrome is drawn with direct [`DrawCommand`]s (a fixed row layout, not a
9//! full `ui_layout` tree): each component draws its own background and content
10//! inside a translated coordinate system, exactly as the engine does for
11//! replaced elements.
12
13use std::sync::Arc;
14
15use ui_layout::Style;
16use url::Url;
17use winit::event::{ElementState, Ime, KeyEvent};
18
19use crate::browser::Tab;
20use crate::browser::core::resource_loader::{BrowserNetworkError, BrowserResponse};
21use crate::browser::core::tab::{FetchKind, TabTask};
22use crate::browser::core::ui::chrome::{Chrome, ChromeAction, ChromeEventResult, DEVTOOLS_URL};
23use crate::browser::core::ui::{
24    FetchRequest, logical_key_to_special_event, logical_key_to_text_key,
25};
26use crate::engine::bridge::text::TextMeasurer;
27use crate::engine::html::ScriptingMode;
28use crate::engine::layouter::types::{Color, TextFlowStyle, TextStyle};
29use crate::engine::renderer_model::{
30    AffineTransform, Brush, DrawCommand, FillRule, Paint, Rect, rect_path,
31};
32use crate::engine::ui::button::ButtonComponent;
33use crate::engine::ui::custom_node::{ContentSize, CustomNode, PointerEvent};
34use crate::engine::ui::input_text::InputTextComponent;
35use crate::engine::ui::input_text_types::InputTextEvent;
36use crate::platform::renderer::text_measurer::PlatformTextMeasurer;
37
38/// Horizontal and vertical spacing between chrome elements.
39const CHROME_PADDING: f32 = 8.0;
40/// Gap between adjacent toolbar elements.
41const CHROME_GAP: f32 = 8.0;
42/// Toolbar background color.
43const TOOLBAR_BACKGROUND: Color = Color(210, 210, 214, 255);
44/// Button background color.
45const BUTTON_BACKGROUND: Color = Color(240, 240, 240, 255);
46/// Label color used by toolbar buttons.
47const LABEL_COLOR: Color = Color(20, 20, 20, 255);
48
49/// Identifies the toolbar element under a pointer position.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51enum ChromeHit {
52    /// Back navigation button.
53    Back,
54    /// Reload current page button.
55    Reload,
56    /// Scripting button (toggle the scripting mode).
57    Scripting,
58    /// DevTools pane toggle button.
59    DevTools,
60    /// DevTools orientation toggle (vertical ↔ horizontal).
61    DevToolsOrientation,
62    /// URL entry bar.
63    UrlBar,
64}
65
66/// Rectangles of the toolbar elements for a given window width.
67#[derive(Debug, Clone, Copy)]
68struct ToolbarRects {
69    /// The whole toolbar strip (top edge of the window).
70    toolbar: Rect,
71    /// Back button.
72    back: Rect,
73    /// Reload button.
74    reload: Rect,
75    /// Scrinpting button.
76    scripting: Rect,
77    /// DevTools pane toggle button.
78    devtools: Rect,
79    /// DevTools orientation toggle button.
80    devtools_orientation: Rect,
81    /// URL bar.
82    url_bar: Rect,
83}
84
85impl ToolbarRects {
86    /// Height of the toolbar strip in logical pixels.
87    fn height(&self) -> f32 {
88        self.toolbar.height
89    }
90}
91
92/// Represents the top toolbar of the browser.
93#[derive(Debug)]
94struct BrowserToolbar {
95    /// Back navigation button.
96    back_button: ButtonComponent,
97    /// Reload current page button.
98    reload_button: ButtonComponent,
99    /// DevTools pane toggle button.
100    devtools_button: ButtonComponent,
101    /// DevTools orientation toggle button.
102    devtools_orientation_button: ButtonComponent,
103    /// Switch the scripting mode.
104    scripting_button: ButtonComponent,
105    /// URL entry bar.
106    url_bar: InputTextComponent,
107}
108
109impl BrowserToolbar {
110    /// Create a new toolbar with placeholder components.
111    fn new() -> Self {
112        let measurer: Arc<dyn TextMeasurer> = Arc::new(PlatformTextMeasurer::new().unwrap());
113        let back_button = ButtonComponent::new(
114            "← Back",
115            BUTTON_BACKGROUND,
116            LABEL_COLOR,
117            Arc::clone(&measurer),
118        );
119        let reload_button = ButtonComponent::new(
120            "⟳ Reload",
121            BUTTON_BACKGROUND,
122            LABEL_COLOR,
123            Arc::clone(&measurer),
124        );
125        let scripting_button = ButtonComponent::new(
126            display_scripting(&ScriptingMode::default()),
127            BUTTON_BACKGROUND,
128            LABEL_COLOR,
129            Arc::clone(&measurer),
130        );
131        let devtools_button = ButtonComponent::new(
132            "DevTools",
133            BUTTON_BACKGROUND,
134            LABEL_COLOR,
135            Arc::clone(&measurer),
136        );
137        let devtools_orientation_button =
138            ButtonComponent::new("↔", BUTTON_BACKGROUND, LABEL_COLOR, Arc::clone(&measurer));
139        let url_bar = InputTextComponent::new("", "Enter URL", measurer);
140        Self {
141            back_button,
142            reload_button,
143            scripting_button,
144            devtools_button,
145            devtools_orientation_button,
146            url_bar,
147        }
148    }
149
150    /// Computes the layout of the toolbar row for the given window width.
151    fn rects(&self, width: f32) -> ToolbarRects {
152        let back_size = self.back_button.intrinsic_size();
153        let reload_size = self.reload_button.intrinsic_size();
154        let scripting_size = self.scripting_button.intrinsic_size();
155        let devtools_size = self.devtools_button.intrinsic_size();
156        let devtools_orientation_size = self.devtools_orientation_button.intrinsic_size();
157        let url_size = self.url_bar.intrinsic_size();
158
159        let row_height = [back_size.height, reload_size.height, url_size.height]
160            .into_iter()
161            .fold(0.0, f32::max);
162        let top = CHROME_PADDING;
163        let center_y = |height: f32| top + (row_height - height) * 0.5;
164
165        let back = Rect::new(
166            CHROME_PADDING,
167            center_y(back_size.height),
168            back_size.width,
169            back_size.height,
170        );
171        let reload = Rect::new(
172            back.x + back.width + CHROME_GAP,
173            center_y(reload_size.height),
174            reload_size.width,
175            reload_size.height,
176        );
177        let scripting = Rect::new(
178            reload.x + reload.width + CHROME_GAP,
179            center_y(scripting_size.height),
180            scripting_size.width,
181            scripting_size.height,
182        );
183        let devtools = Rect::new(
184            scripting.x + scripting.width + CHROME_GAP,
185            center_y(devtools_size.height),
186            devtools_size.width,
187            devtools_size.height,
188        );
189        let devtools_orientation = Rect::new(
190            devtools.x + devtools.width + CHROME_GAP,
191            center_y(devtools_orientation_size.height),
192            devtools_orientation_size.width,
193            devtools_orientation_size.height,
194        );
195        let url_x = (devtools_orientation.x + devtools_orientation.width + CHROME_GAP)
196            .min(width - CHROME_PADDING);
197        let url_width = (width - CHROME_PADDING - url_x).max(0.0);
198        let url_bar = Rect::new(url_x, center_y(url_size.height), url_width, url_size.height);
199
200        ToolbarRects {
201            toolbar: Rect::new(0.0, 0.0, width, row_height + CHROME_PADDING * 2.0),
202            back,
203            reload,
204            scripting,
205            devtools,
206            devtools_orientation,
207            url_bar,
208        }
209    }
210
211    /// Returns the toolbar element under `(x, y)`, or `None` when the point is
212    /// outside the chrome (i.e. over the page content).
213    fn hit_test(&self, x: f32, y: f32, width: f32) -> Option<ChromeHit> {
214        let rects = self.rects(width);
215        if rects.back.contains(x, y) {
216            Some(ChromeHit::Back)
217        } else if rects.reload.contains(x, y) {
218            Some(ChromeHit::Reload)
219        } else if rects.scripting.contains(x, y) {
220            Some(ChromeHit::Scripting)
221        } else if rects.devtools.contains(x, y) {
222            Some(ChromeHit::DevTools)
223        } else if rects.devtools_orientation.contains(x, y) {
224            Some(ChromeHit::DevToolsOrientation)
225        } else if rects.url_bar.contains(x, y) {
226            Some(ChromeHit::UrlBar)
227        } else {
228            None
229        }
230    }
231}
232
233/// The default chrome for a browser window: a top toolbar and the page content
234/// area below it.
235#[derive(Debug)]
236pub struct BasicChrome {
237    toolbar: BrowserToolbar,
238    /// URL currently shown in the address bar, used to avoid overwriting text
239    /// the user is editing.
240    last_url: Option<String>,
241
242    is_debug_open: bool,
243    debug_pane: Tab,
244
245    /// Whether a press that started inside the debug pane is still held.
246    /// Its release must be routed back to the pane even if the pointer has
247    /// since moved over the toolbar or the page, so the click completes.
248    debug_press_active: bool,
249
250    /// `true` = pane on the right (vertical split), `false` = pane on the bottom (horizontal split).
251    debug_pane_vertical: bool,
252    /// Split ratio (0.0–1.0) of the page area consumed by the pane.
253    debug_pane_ratio: f32,
254    /// Whether the user is currently dragging the pane divider.
255    debug_dragging: bool,
256    /// `(start_x_or_y, initial_ratio)` when a drag begins.
257    debug_drag_anchor: Option<(f32, f32)>,
258
259    scripting_mode: ScriptingMode,
260    /// Toolbar element currently under the pointer, if any.
261    hovered: Option<ChromeHit>,
262}
263
264impl BasicChrome {
265    /// Returns `(pane_x, pane_y, pane_w, pane_h)` when the debug pane is open,
266    /// or `None` when it is closed.
267    fn debug_pane_rect(&self, width: f32, height: f32) -> Option<Rect> {
268        if !self.is_debug_open {
269            return None;
270        }
271        let toolbar_h = self.toolbar.rects(width).height();
272        let area_h = height - toolbar_h;
273        if self.debug_pane_vertical {
274            let pane_w = width * self.debug_pane_ratio;
275            Some(Rect::new(width - pane_w, toolbar_h, pane_w, area_h))
276        } else {
277            let pane_h = area_h * self.debug_pane_ratio;
278            Some(Rect::new(0.0, toolbar_h + area_h - pane_h, width, pane_h))
279        }
280    }
281
282    /// Returns the divider rect (a thin strip along the pane border).
283    fn divider_rect(&self, width: f32, height: f32) -> Option<Rect> {
284        let pane = self.debug_pane_rect(width, height)?;
285        let half = 4.0;
286        if self.debug_pane_vertical {
287            Some(Rect::new(pane.x - half, pane.y, half * 2.0, pane.height))
288        } else {
289            Some(Rect::new(pane.x, pane.y - half, pane.width, half * 2.0))
290        }
291    }
292
293    /// Create a new default chrome with an empty toolbar.
294    pub fn new() -> Self {
295        let mut tab = Tab::default();
296        tab.navigate(DEVTOOLS_URL.parse().unwrap());
297
298        Self {
299            toolbar: BrowserToolbar::new(),
300            last_url: None,
301            is_debug_open: false,
302            debug_pane: tab,
303            debug_press_active: false,
304            debug_pane_vertical: true,
305            debug_pane_ratio: 0.5,
306            debug_dragging: false,
307            debug_drag_anchor: None,
308            scripting_mode: ScriptingMode::default(),
309            hovered: None,
310        }
311    }
312
313    /// Dispatches a pointer event to the element under `hit` and returns
314    /// whether the element consumed it. For [`PointerEvent::Up`] the value
315    /// doubles as "a click was completed".
316    fn dispatch(&self, hit: ChromeHit, event: PointerEvent) -> bool {
317        let node: &dyn CustomNode = match hit {
318            ChromeHit::Back => &self.toolbar.back_button,
319            ChromeHit::Reload => &self.toolbar.reload_button,
320            ChromeHit::Scripting => &self.toolbar.scripting_button,
321            ChromeHit::DevTools => &self.toolbar.devtools_button,
322            ChromeHit::DevToolsOrientation => &self.toolbar.devtools_orientation_button,
323            ChromeHit::UrlBar => &self.toolbar.url_bar,
324        };
325        node.on_pointer_event(event)
326    }
327
328    /// Drops any hover state when the pointer leaves the toolbar.
329    fn clear_hover(&mut self) {
330        if let Some(previous) = self.hovered.take() {
331            self.dispatch(previous, PointerEvent::Leave);
332        }
333    }
334}
335
336impl Default for BasicChrome {
337    fn default() -> Self {
338        Self::new()
339    }
340}
341
342impl Chrome for BasicChrome {
343    fn content_rect(&self, width: f32, height: f32) -> Rect {
344        let toolbar_height = self.toolbar.rects(width).height();
345        if self.is_debug_open {
346            let area_h = height - toolbar_height;
347            if self.debug_pane_vertical {
348                let pane_w = width * self.debug_pane_ratio;
349                Rect::new(0.0, toolbar_height, width - pane_w, area_h)
350            } else {
351                let pane_h = area_h * self.debug_pane_ratio;
352                Rect::new(0.0, toolbar_height, width, area_h - pane_h)
353            }
354        } else {
355            Rect::new(0.0, toolbar_height, width, height - toolbar_height)
356        }
357    }
358
359    fn draw(&mut self, cmd_buf: &mut Vec<DrawCommand>, width: f32, height: f32) {
360        let rects = self.toolbar.rects(width);
361
362        cmd_buf.push(DrawCommand::Fill {
363            path: rect_path(
364                rects.toolbar.x,
365                rects.toolbar.y,
366                rects.toolbar.width,
367                rects.toolbar.height,
368            ),
369            rule: FillRule::NonZero,
370            paint: Paint {
371                brush: Brush::Solid(TOOLBAR_BACKGROUND),
372                opacity: 1.0,
373            },
374        });
375
376        let text_style = TextStyle::default();
377        let text_flow_style = TextFlowStyle::default();
378        let style = Style::default();
379
380        let components: [(&dyn CustomNode, Rect); 6] = [
381            (&self.toolbar.back_button, rects.back),
382            (&self.toolbar.reload_button, rects.reload),
383            (&self.toolbar.scripting_button, rects.scripting),
384            (&self.toolbar.devtools_button, rects.devtools),
385            (
386                &self.toolbar.devtools_orientation_button,
387                rects.devtools_orientation,
388            ),
389            (&self.toolbar.url_bar, rects.url_bar),
390        ];
391
392        for (node, rect) in components {
393            cmd_buf.push(DrawCommand::PushTransform {
394                transform: AffineTransform::translate(rect.x, rect.y),
395            });
396            node.draw_sized(
397                cmd_buf,
398                &text_style,
399                &text_flow_style,
400                &style,
401                ContentSize {
402                    width: rect.width,
403                    height: rect.height,
404                },
405            );
406            cmd_buf.push(DrawCommand::PopTransform);
407        }
408
409        if self.is_debug_open {
410            let area_h = height - rects.height();
411            let (pane_x, pane_y, pane_w, pane_h) = if self.debug_pane_vertical {
412                let pane_w = width * self.debug_pane_ratio;
413                (width - pane_w, rects.height(), pane_w, area_h)
414            } else {
415                let pane_h = area_h * self.debug_pane_ratio;
416                (0.0, rects.height() + area_h - pane_h, width, pane_h)
417            };
418
419            cmd_buf.push(DrawCommand::PushTransform {
420                transform: AffineTransform::translate(pane_x, pane_y),
421            });
422
423            let pane_rect = rect_path(0.0, 0.0, pane_w, pane_h);
424            cmd_buf.push(DrawCommand::Fill {
425                path: pane_rect.clone(),
426                rule: FillRule::NonZero,
427                paint: Paint {
428                    brush: Brush::Solid(Color(200, 200, 200, 200)),
429                    opacity: 1.0,
430                },
431            });
432            cmd_buf.push(DrawCommand::PushClip {
433                path: pane_rect,
434                rule: FillRule::NonZero,
435            });
436
437            self.debug_pane.draw(cmd_buf, pane_w, pane_h);
438
439            cmd_buf.push(DrawCommand::PopClip);
440            cmd_buf.push(DrawCommand::PopTransform);
441
442            // Draw the drag divider when the pane is open.
443            let divider_color = Color(160, 160, 160, 255);
444            let divider_w = 4.0;
445            if self.debug_pane_vertical {
446                let dx = pane_x - divider_w * 0.5;
447                cmd_buf.push(DrawCommand::Fill {
448                    path: rect_path(dx, rects.height(), divider_w, area_h),
449                    rule: FillRule::NonZero,
450                    paint: Paint {
451                        brush: Brush::Solid(divider_color),
452                        opacity: 1.0,
453                    },
454                });
455            } else {
456                let dy = pane_y - divider_w * 0.5;
457                cmd_buf.push(DrawCommand::Fill {
458                    path: rect_path(0.0, dy, width, divider_w),
459                    rule: FillRule::NonZero,
460                    paint: Paint {
461                        brush: Brush::Solid(divider_color),
462                        opacity: 1.0,
463                    },
464                });
465            }
466        }
467    }
468
469    fn tick(&mut self, actions_buf: &mut Vec<ChromeAction>) -> (Vec<FetchRequest>, bool) {
470        let mut fetches_buf = Vec::new();
471        let mut redraw = false;
472        for task in self.debug_pane.tick() {
473            match task {
474                TabTask::Fetch { url, kind, origin } => {
475                    log::info!("Fetch requested in BasicChrome: url={}", url);
476                    fetches_buf.push(FetchRequest { url, kind, origin });
477                }
478                TabTask::NeedsRedraw => redraw = true,
479                TabTask::DevToolsRequest { id, method, params } => {
480                    actions_buf.push(ChromeAction::DevToolsRequest { id, method, params });
481                }
482            }
483        }
484
485        (fetches_buf, redraw)
486    }
487
488    fn deliver_fetch(
489        &mut self,
490        kind: FetchKind,
491        url: Url,
492        response: Result<BrowserResponse, BrowserNetworkError>,
493    ) {
494        self.debug_pane.deliver_fetch(kind, url, response);
495    }
496
497    fn sync_url(&mut self, url: Option<&str>) {
498        let url = url.map(str::to_string);
499        if self.last_url != url {
500            self.last_url.clone_from(&url);
501            self.toolbar.url_bar.set_value(url.unwrap_or_default());
502        }
503    }
504
505    fn pointer_event(
506        &mut self,
507        width: f32,
508        height: f32,
509        event: PointerEvent,
510        state: ElementState,
511    ) -> ChromeEventResult {
512        let (x, y) = match event {
513            PointerEvent::Move { x, y }
514            | PointerEvent::Down { x, y }
515            | PointerEvent::Up { x, y } => (x, y),
516            PointerEvent::Leave => {
517                self.clear_hover();
518                return ChromeEventResult::none();
519            }
520        };
521
522        let Some(hit) = self.toolbar.hit_test(x, y, width) else {
523            // Pointer over the page, the debug pane, or the divider:
524            // clear any chrome hover.
525            self.clear_hover();
526
527            if !self.is_debug_open {
528                return ChromeEventResult::none();
529            }
530
531            let pane = self
532                .debug_pane_rect(width, height)
533                .unwrap_or(Rect::new(0.0, 0.0, 0.0, 0.0));
534            let divider = self.divider_rect(width, height);
535
536            // Check if we're on the divider (drag area).
537            let on_divider = divider.as_ref().is_some_and(|d| d.contains(x, y));
538
539            // Handle divider drag.
540            if on_divider && let PointerEvent::Down { .. } = event {
541                let anchor = if self.debug_pane_vertical { x } else { y };
542                self.debug_dragging = true;
543                self.debug_drag_anchor = Some((anchor, self.debug_pane_ratio));
544                return ChromeEventResult {
545                    consumed: true,
546                    action: ChromeAction::None,
547                };
548            }
549
550            // Handle ongoing drag even if pointer moved off the divider.
551            if self.debug_dragging {
552                match event {
553                    PointerEvent::Move { .. } => {
554                        if let Some((anchor, initial_ratio)) = self.debug_drag_anchor {
555                            let delta = if self.debug_pane_vertical {
556                                x - anchor
557                            } else {
558                                y - anchor
559                            };
560                            let area = if self.debug_pane_vertical {
561                                width
562                            } else {
563                                height - self.toolbar.rects(width).height()
564                            };
565                            let ratio_delta = if area > 0.0 { -delta / area } else { 0.0 };
566                            self.debug_pane_ratio = (initial_ratio + ratio_delta).clamp(0.1, 0.9);
567                        }
568                        return ChromeEventResult {
569                            consumed: true,
570                            action: ChromeAction::None,
571                        };
572                    }
573                    PointerEvent::Up { .. } => {
574                        self.debug_dragging = false;
575                        self.debug_drag_anchor = None;
576                        return ChromeEventResult {
577                            consumed: true,
578                            action: ChromeAction::None,
579                        };
580                    }
581                    _ => {}
582                }
583            }
584
585            let inside = pane.contains(x, y);
586
587            // The page area must not be consumed, or clicks would never
588            // reach the browsed tab. Only the debug pane (and a press that
589            // started there) is handled by the chrome.
590            if !inside && !self.debug_press_active {
591                return ChromeEventResult::none();
592            }
593
594            // Debug-pane local coordinates.
595            let px = x - pane.x;
596            let py = y - pane.y;
597
598            match event {
599                // Moves only update hover; routing them through
600                // `handle_mouse_input` would synthesize pointer-ups and
601                // cancel in-flight clicks.
602                PointerEvent::Move { .. } => {
603                    self.debug_pane.handle_pointer_move(px, py);
604                }
605                PointerEvent::Down { .. } => {
606                    self.debug_press_active = inside;
607                    self.debug_pane.handle_mouse_input(px, py, state);
608                }
609                _ => {
610                    self.debug_press_active = false;
611                    self.debug_pane.handle_mouse_input(px, py, state);
612                }
613            }
614
615            return ChromeEventResult {
616                consumed: true,
617                action: ChromeAction::None,
618            };
619        };
620
621        // A release that belongs to a press started in the debug pane never
622        // activates chrome buttons the pointer happens to have drifted over.
623        if matches!(event, PointerEvent::Up { .. }) && self.debug_press_active {
624            self.debug_press_active = false;
625            if let Some(pane) = self.debug_pane_rect(width, height) {
626                self.debug_pane
627                    .handle_mouse_input(x - pane.x, y - pane.y, state);
628            }
629            return ChromeEventResult {
630                consumed: true,
631                action: ChromeAction::None,
632            };
633        }
634
635        let handled = self.dispatch(hit, event);
636        let clicked = matches!(event, PointerEvent::Up { .. }) && handled;
637
638        if matches!(event, PointerEvent::Move { .. })
639            && self.hovered != Some(hit)
640            && let Some(previous) = self.hovered.replace(hit)
641        {
642            self.dispatch(previous, PointerEvent::Leave);
643        }
644
645        let action = match hit {
646            ChromeHit::UrlBar if matches!(event, PointerEvent::Down { .. }) => {
647                self.toolbar.url_bar.set_focused(true);
648                ChromeAction::EnableIme
649            }
650            ChromeHit::Back if clicked => ChromeAction::Back,
651            ChromeHit::Reload if clicked => ChromeAction::Reload,
652            ChromeHit::Scripting if clicked => {
653                self.scripting_mode = if self.scripting_mode == ScriptingMode::Enabled {
654                    ScriptingMode::Disabled
655                } else {
656                    ScriptingMode::Enabled
657                };
658
659                self.toolbar.scripting_button.label =
660                    display_scripting(&self.scripting_mode).into();
661
662                ChromeAction::SetJsPolicy(self.scripting_mode.into())
663            }
664            ChromeHit::DevTools if clicked => {
665                self.is_debug_open = !self.is_debug_open;
666                ChromeAction::None
667            }
668            ChromeHit::DevToolsOrientation if clicked => {
669                self.debug_pane_vertical = !self.debug_pane_vertical;
670                // Update the orientation button label.
671                self.toolbar.devtools_orientation_button.label = if self.debug_pane_vertical {
672                    "↔"
673                } else {
674                    "↕"
675                }
676                .into();
677                ChromeAction::None
678            }
679            _ => ChromeAction::None,
680        };
681
682        ChromeEventResult {
683            consumed: true,
684            action,
685        }
686    }
687
688    fn handle_scroll(
689        &mut self,
690        width: f32,
691        height: f32,
692        mouse_x: f32,
693        mouse_y: f32,
694        scroll_x: f32,
695        scroll_y: f32,
696    ) {
697        if let Some(rect) = self.debug_pane_rect(width, height) {
698            let toolbar_h = self.toolbar.rects(width).height();
699            if rect.contains(mouse_x, mouse_y + toolbar_h) {
700                self.debug_pane.scroll_at(
701                    mouse_x - rect.x,
702                    mouse_y,
703                    scroll_x,
704                    scroll_y,
705                    (rect.width, rect.height),
706                );
707            }
708        }
709    }
710
711    fn accepts_text_input(&self) -> bool {
712        self.toolbar.url_bar.is_focused()
713    }
714
715    fn key_event(&mut self, event: &KeyEvent, ctrl: bool) -> ChromeAction {
716        if let winit::keyboard::Key::Named(winit::keyboard::NamedKey::Enter) = &event.logical_key {
717            let url = self.toolbar.url_bar.state().value;
718            self.toolbar
719                .url_bar
720                .handle_text_input(InputTextEvent::Enter);
721            match Url::parse(&url).or_else(|_| Url::parse(&format!("https://{url}"))) {
722                Ok(url) => ChromeAction::Navigate(url),
723                Err(_) => {
724                    log::warn!("Ignoring invalid URL entered in address bar: {}", url);
725                    ChromeAction::Repaint
726                }
727            }
728        } else {
729            let special = logical_key_to_special_event(&event.logical_key, ctrl);
730            let key = logical_key_to_text_key(&event.logical_key);
731            let handled = if let Some(special) = special {
732                self.toolbar.url_bar.handle_text_input(special)
733            } else if let Some(key) = key {
734                self.toolbar
735                    .url_bar
736                    .handle_text_input(InputTextEvent::Key(key))
737            } else if !ctrl && !self.toolbar.url_bar.is_composing() {
738                event.text.as_ref().is_some_and(|text| {
739                    self.toolbar
740                        .url_bar
741                        .handle_text_input(InputTextEvent::Insert(text.to_string()))
742                })
743            } else {
744                false
745            };
746
747            if handled {
748                ChromeAction::Repaint
749            } else {
750                ChromeAction::None
751            }
752        }
753    }
754
755    fn ime_event(&mut self, event: &Ime) -> ChromeAction {
756        let event = match event {
757            Ime::Preedit(text, _) => InputTextEvent::Preedit(text.clone()),
758            Ime::Commit(text) => InputTextEvent::Commit(text.clone()),
759            Ime::Disabled => InputTextEvent::CancelComposition,
760            Ime::Enabled => return ChromeAction::None,
761        };
762
763        if self.toolbar.url_bar.handle_text_input(event) {
764            ChromeAction::Repaint
765        } else {
766            ChromeAction::None
767        }
768    }
769
770    fn on_devtools_response(&mut self, id: u64, result: String) {
771        self.debug_pane.on_devtools_response(id, result);
772    }
773
774    fn blur(&mut self) {
775        self.toolbar.url_bar.set_focused(false);
776    }
777
778    fn needs_repaint(&self) -> bool {
779        self.toolbar.back_button.needs_repaint()
780            || self.toolbar.reload_button.needs_repaint()
781            || self.toolbar.url_bar.needs_repaint()
782    }
783}
784
785fn display_scripting(scripting_mode: &ScriptingMode) -> &'static str {
786    match scripting_mode {
787        ScriptingMode::Enabled => "JS: Enabled ",
788        ScriptingMode::Disabled => "JS: Disabled",
789    }
790}
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795
796    /// Completes a press/release cycle at `(x, y)` over the chrome.
797    fn click(chrome: &mut BasicChrome, x: f32, y: f32) -> ChromeAction {
798        let down = chrome.pointer_event(
799            800.0,
800            600.0,
801            PointerEvent::Down { x, y },
802            ElementState::Pressed,
803        );
804        assert!(down.consumed);
805
806        let up = chrome.pointer_event(
807            800.0,
808            600.0,
809            PointerEvent::Up { x, y },
810            ElementState::Released,
811        );
812        assert!(up.consumed);
813        up.action
814    }
815
816    /// Clicks the DevTools button so the debug pane is open afterwards.
817    fn open_debug_pane(chrome: &mut BasicChrome) -> ToolbarRects {
818        let rects = chrome.toolbar.rects(800.0);
819        let action = click(chrome, rects.devtools.x + 1.0, rects.devtools.y + 1.0);
820        assert_eq!(action, ChromeAction::None);
821        assert!(chrome.is_debug_open);
822        rects
823    }
824
825    #[test]
826    fn toolbar_rects_layout_left_to_right() {
827        let toolbar = BrowserToolbar::new();
828        let rects = toolbar.rects(800.0);
829
830        assert!(rects.back.width > 0.0);
831        assert!(rects.reload.width > 0.0);
832        assert!(rects.url_bar.width > 0.0);
833        assert!(rects.toolbar.width >= 800.0);
834
835        // Elements are placed left to right without overlap.
836        assert!(rects.back.x < rects.reload.x);
837        assert!(rects.reload.x + rects.reload.width < rects.url_bar.x);
838
839        // The URL bar reaches the right edge (minus padding).
840        assert!((rects.url_bar.x + rects.url_bar.width + CHROME_PADDING - 800.0).abs() < 0.001);
841    }
842
843    #[test]
844    fn toolbar_rects_fits_narrow_windows() {
845        let toolbar = BrowserToolbar::new();
846        let rects = toolbar.rects(120.0);
847        assert!(rects.toolbar.height > 0.0);
848        // URL bar must not extend past the window edge.
849        assert!(rects.url_bar.x + rects.url_bar.width <= 120.0 + 0.001);
850    }
851
852    #[test]
853    fn pointer_events_hit_chrome_and_content() {
854        let mut chrome = BasicChrome::new();
855        let rects = chrome.toolbar.rects(800.0);
856
857        // A completed click on the back button requests Back.
858        let result = chrome.pointer_event(
859            800.0,
860            600.0,
861            PointerEvent::Down {
862                x: rects.back.x + 1.0,
863                y: rects.back.y + 1.0,
864            },
865            ElementState::Pressed,
866        );
867        assert!(result.consumed);
868        assert_eq!(result.action, ChromeAction::None);
869
870        let result = chrome.pointer_event(
871            800.0,
872            600.0,
873            PointerEvent::Up {
874                x: rects.back.x + 1.0,
875                y: rects.back.y + 1.0,
876            },
877            ElementState::Released,
878        );
879        assert!(result.consumed);
880        assert_eq!(result.action, ChromeAction::Back);
881
882        // Pressing the URL bar requests OS-level IME.
883        let result = chrome.pointer_event(
884            800.0,
885            600.0,
886            PointerEvent::Down {
887                x: rects.url_bar.x + 1.0,
888                y: rects.url_bar.y + 1.0,
889            },
890            ElementState::Pressed,
891        );
892        assert!(result.consumed);
893        assert_eq!(result.action, ChromeAction::EnableIme);
894        assert!(chrome.accepts_text_input());
895
896        // Below the toolbar is the page content area.
897        let result = chrome.pointer_event(
898            800.0,
899            600.0,
900            PointerEvent::Down {
901                x: 400.0,
902                y: rects.toolbar.height + 10.0,
903            },
904            ElementState::Pressed,
905        );
906        assert!(!result.consumed);
907        assert_eq!(result.action, ChromeAction::None);
908    }
909
910    #[test]
911    fn hovering_tracks_toolbar_elements() {
912        let mut chrome = BasicChrome::new();
913        let rects = chrome.toolbar.rects(800.0);
914
915        // Move onto the back button, then onto the reload button: both become
916        // dirty, and no event falls through to the page.
917        let result = chrome.pointer_event(
918            800.0,
919            600.0,
920            PointerEvent::Move {
921                x: rects.back.x + 1.0,
922                y: rects.back.y + 1.0,
923            },
924            ElementState::Released,
925        );
926        assert!(result.consumed);
927
928        let result = chrome.pointer_event(
929            800.0,
930            600.0,
931            PointerEvent::Move {
932                x: rects.reload.x + 1.0,
933                y: rects.reload.y + 1.0,
934            },
935            ElementState::Released,
936        );
937        assert!(result.consumed);
938
939        // Leaving the toolbar clears hover state.
940        let result = chrome.pointer_event(
941            800.0,
942            600.0,
943            PointerEvent::Move {
944                x: 400.0,
945                y: rects.toolbar.height + 10.0,
946            },
947            ElementState::Released,
948        );
949        assert!(!result.consumed);
950        assert!(!chrome.toolbar.back_button.is_hovered());
951        assert!(!chrome.toolbar.reload_button.is_hovered());
952    }
953
954    #[test]
955    fn sync_url_updates_address_bar_once() {
956        let mut chrome = BasicChrome::new();
957        chrome.sync_url(Some("https://example.com"));
958        assert_eq!(chrome.toolbar.url_bar.state().value, "https://example.com");
959        // Syncing the same URL again must not clobber user edits.
960        chrome
961            .toolbar
962            .url_bar
963            .handle_text_input(InputTextEvent::Insert("zzz".into()));
964        chrome.sync_url(Some("https://example.com"));
965        assert_eq!(
966            chrome.toolbar.url_bar.state().value,
967            "https://example.comzzz"
968        );
969    }
970
971    #[test]
972    fn page_clicks_fall_through_while_debug_pane_is_open() {
973        let mut chrome = BasicChrome::new();
974        open_debug_pane(&mut chrome);
975
976        // The left half below the toolbar is still the browsed page; events
977        // there must not be swallowed by the chrome or they would never
978        // reach the active tab.
979        let down = chrome.pointer_event(
980            800.0,
981            600.0,
982            PointerEvent::Down { x: 200.0, y: 300.0 },
983            ElementState::Pressed,
984        );
985        assert!(!down.consumed);
986
987        let up = chrome.pointer_event(
988            800.0,
989            600.0,
990            PointerEvent::Up { x: 200.0, y: 300.0 },
991            ElementState::Released,
992        );
993        assert!(!up.consumed);
994    }
995
996    #[test]
997    fn debug_pane_click_survives_pointer_moves_and_outside_release() {
998        let mut chrome = BasicChrome::new();
999        let rects = open_debug_pane(&mut chrome);
1000        let pane_x = 800.0 / 2.0;
1001
1002        // Press inside the pane, jiggle the pointer around, and release
1003        // outside the pane (over the toolbar): the release belongs to the
1004        // pane and must not trigger the hovered chrome button.
1005        let down = chrome.pointer_event(
1006            800.0,
1007            600.0,
1008            PointerEvent::Down {
1009                x: pane_x + 100.0,
1010                y: rects.toolbar.height + 50.0,
1011            },
1012            ElementState::Pressed,
1013        );
1014        assert!(down.consumed);
1015
1016        for (dx, dy) in [(1.0, 2.0), (-3.0, 1.0), (2.0, -1.0)] {
1017            let mv = chrome.pointer_event(
1018                800.0,
1019                600.0,
1020                PointerEvent::Move {
1021                    x: pane_x + 100.0 + dx,
1022                    y: rects.toolbar.height + 50.0 + dy,
1023                },
1024                ElementState::Released,
1025            );
1026            assert!(mv.consumed);
1027        }
1028
1029        let up = chrome.pointer_event(
1030            800.0,
1031            600.0,
1032            PointerEvent::Up {
1033                x: rects.back.x + 1.0,
1034                y: rects.back.y + 1.0,
1035            },
1036            ElementState::Released,
1037        );
1038        assert!(up.consumed);
1039        assert_eq!(up.action, ChromeAction::None);
1040        assert!(!chrome.debug_press_active);
1041
1042        // The chrome is not stuck: a normal click on Back still works.
1043        let action = click(&mut chrome, rects.back.x + 1.0, rects.back.y + 1.0);
1044        assert_eq!(action, ChromeAction::Back);
1045    }
1046
1047    /// End-to-end fixtures driving [`BrowserUi`] exactly like the platform
1048    /// event loop does: pointer position bookkeeping, moves carrying a
1049    /// released button state, and press/release pairs through
1050    /// [`BrowserUi::handle_mouse_input`].
1051    ///
1052    /// Lives in this module because setup needs `BasicChrome` internals and
1053    /// the dispatch needs `BrowserUi` internals (both are visible to child
1054    /// modules of `ui`).
1055    mod e2e {
1056        use super::*;
1057        use crate::browser::BrowserCommand;
1058        use crate::browser::core::ui::{BasicContextMenu, BrowserUi, TabId};
1059        use std::time::Duration;
1060
1061        const W: f32 = 1280.0;
1062        const H: f32 = 800.0;
1063
1064        struct Rig {
1065            ui: BrowserUi,
1066            rects: ToolbarRects,
1067        }
1068
1069        fn response(body: &str) -> BrowserResponse {
1070            BrowserResponse {
1071                url: String::new(),
1072                status: hyper::StatusCode::OK.into(),
1073                status_text: "OK".to_string(),
1074                body: body.as_bytes().to_vec(),
1075                headers: vec![],
1076            }
1077        }
1078
1079        /// Relayouts `tab` and waits for the background layout thread, then
1080        /// draws again so the applied tree gets its boxes positioned.
1081        fn spin_layout(tab: &mut Tab, w: f32, h: f32) {
1082            let mut buf = Vec::new();
1083            tab.draw(&mut buf, w, h);
1084            for _ in 0..500 {
1085                for _ in tab.tick() {}
1086                if tab.layout_and_info().is_some() {
1087                    break;
1088                }
1089                std::thread::sleep(Duration::from_millis(2));
1090            }
1091            tab.draw(&mut buf, w, h);
1092            assert!(tab.layout_and_info().is_some(), "layout must be ready");
1093        }
1094
1095        /// Builds a UI whose page tab and debug pane both have loaded,
1096        /// positioned layouts — the state the real browser reaches before a
1097        /// user can click anything.
1098        fn rig(page_body: &str, pane_body: &str) -> Rig {
1099            let rects = BasicChrome::new().toolbar.rects(W);
1100            let toolbar_height = rects.toolbar.height;
1101
1102            let mut tab = Tab::default();
1103            tab.navigate("https://page.test/index.html".parse().unwrap());
1104            tab.on_fetch_succeeded_html(format!("<html><body>{page_body}</body></html>"));
1105            spin_layout(&mut tab, W, H - toolbar_height);
1106
1107            // Load the pane content through the chrome's own fetch pipeline.
1108            let mut chrome = BasicChrome::new();
1109            let mut actions = Vec::new();
1110            chrome.tick(&mut actions);
1111            chrome.deliver_fetch(
1112                FetchKind::Html,
1113                DEVTOOLS_URL.parse().unwrap(),
1114                Ok(response(&format!("<html><body>{pane_body}</body></html>"))),
1115            );
1116
1117            let mut pane_buf = Vec::new();
1118            for _ in 0..500 {
1119                actions.clear();
1120                let _ = chrome.tick(&mut actions);
1121                if chrome.debug_pane.layout_and_info().is_some() {
1122                    break;
1123                }
1124                std::thread::sleep(Duration::from_millis(2));
1125            }
1126            assert!(
1127                chrome.debug_pane.layout_and_info().is_some(),
1128                "pane layout must be ready"
1129            );
1130            // Position the pane boxes like a redraw would.
1131            chrome
1132                .debug_pane
1133                .draw(&mut pane_buf, W / 2.0, H - toolbar_height);
1134
1135            let ui = BrowserUi::with_tab_and_menu(
1136                tab,
1137                Box::new(chrome),
1138                Box::new(BasicContextMenu::new()),
1139            );
1140            let mut ui = ui;
1141            ui.set_window((W as u32, H as u32), 1.0, "test".into());
1142            Rig { ui, rects }
1143        }
1144
1145        fn cursor_to(rig: &mut Rig, x: f32, y: f32) {
1146            rig.ui.input.mouse_position = (x as f64, y as f64);
1147            rig.ui.handle_pointer_move(x as f64, y as f64);
1148        }
1149
1150        fn press_left(rig: &mut Rig) -> BrowserCommand {
1151            rig.ui
1152                .handle_mouse_input(winit::event::MouseButton::Left, ElementState::Pressed)
1153        }
1154
1155        fn release_left(rig: &mut Rig) -> BrowserCommand {
1156            rig.ui
1157                .handle_mouse_input(winit::event::MouseButton::Left, ElementState::Released)
1158        }
1159
1160        fn open_debug_pane(rig: &mut Rig) {
1161            cursor_to(rig, rig.rects.devtools.x + 1.0, rig.rects.devtools.y + 1.0);
1162            press_left(rig);
1163            release_left(rig);
1164        }
1165
1166        #[test]
1167        fn page_link_press_reaches_active_tab_while_pane_is_open() {
1168            let mut rig = rig(
1169                r#"<a href="https://page.test/target" style="font-size: 24px;">click me please</a>"#,
1170                "<p>devtools</p>",
1171            );
1172            let th = rig.rects.toolbar.height;
1173            open_debug_pane(&mut rig);
1174
1175            // Press the link on the browsed page (left half). The press must
1176            // navigate the active tab; swallowing it here was bug #2.
1177            cursor_to(&mut rig, 30.0, th + 14.0);
1178            press_left(&mut rig);
1179
1180            let url = rig
1181                .ui
1182                .active_tab()
1183                .and_then(|tab| tab.document_url().map(|u| u.to_string()));
1184            assert_eq!(url.as_deref(), Some("https://page.test/target"));
1185
1186            release_left(&mut rig);
1187            let url = rig
1188                .ui
1189                .active_tab()
1190                .and_then(|tab| tab.document_url().map(|u| u.to_string()))
1191                .unwrap();
1192            assert_eq!(url, "https://page.test/target");
1193        }
1194
1195        #[test]
1196        fn pane_link_press_routes_to_debug_pane_and_spares_the_page() {
1197            let mut rig = rig(
1198                "<p>hello</p>",
1199                r#"<a href="https://pane.test/target" style="font-size: 24px;">pane link</a>"#,
1200            );
1201            let th = rig.rects.toolbar.height;
1202            open_debug_pane(&mut rig);
1203
1204            // Press the link inside the pane (right half), jiggle the
1205            // pointer like a real hand, then release still inside.
1206            cursor_to(&mut rig, W / 2.0 + 30.0, th + 14.0);
1207            press_left(&mut rig);
1208
1209            // The pane navigated: its fetch surfaces with TabId(0).
1210            let mut pane_nav = false;
1211            for _ in 0..100 {
1212                let outcome = rig.ui.tick();
1213                pane_nav = outcome.fetches.iter().any(|fetch| {
1214                    fetch.tab_id == TabId(0)
1215                        && fetch.request.url.as_str() == "https://pane.test/target"
1216                });
1217                if pane_nav {
1218                    break;
1219                }
1220                std::thread::sleep(Duration::from_millis(2));
1221            }
1222            assert!(pane_nav, "pane link press must navigate the pane");
1223
1224            for (dx, dy) in [(2.0, 3.0), (-4.0, 1.0), (1.0, -2.0)] {
1225                cursor_to(&mut rig, W / 2.0 + 30.0 + dx, th + 14.0 + dy);
1226            }
1227            release_left(&mut rig);
1228
1229            // None of that may leak into the browsed page.
1230            let url = rig
1231                .ui
1232                .active_tab()
1233                .and_then(|tab| tab.document_url().map(|u| u.to_string()))
1234                .unwrap();
1235            assert_eq!(url, "https://page.test/index.html");
1236        }
1237
1238        #[test]
1239        fn back_button_still_works_while_pane_is_open() {
1240            let mut rig = rig("<p>history page</p>", "<p>devtools</p>");
1241            let th = rig.rects.toolbar.height;
1242
1243            // Seed one history entry so Back has somewhere to go.
1244            if let Some(tab) = rig.ui.active_tab_mut() {
1245                tab.navigate("https://page.test/second.html".parse().unwrap());
1246                tab.on_fetch_succeeded_html("<html><body><p>second</p></body></html>".into());
1247                spin_layout(tab, W, H - th);
1248            }
1249
1250            open_debug_pane(&mut rig);
1251
1252            // A completed click on Back while the pane is open must navigate
1253            // the active tab back to the first URL.
1254            let (bx, by) = (rig.rects.back.x + 1.0, rig.rects.back.y + 1.0);
1255            cursor_to(&mut rig, bx, by);
1256            press_left(&mut rig);
1257            release_left(&mut rig);
1258
1259            let url = rig
1260                .ui
1261                .active_tab()
1262                .and_then(|tab| tab.document_url().map(|u| u.to_string()));
1263            assert_eq!(url.as_deref(), Some("https://page.test/index.html"));
1264        }
1265    }
1266}