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