1use 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
38const CHROME_PADDING: f32 = 8.0;
40const CHROME_GAP: f32 = 8.0;
42const TOOLBAR_BACKGROUND: Color = Color(210, 210, 214, 255);
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48enum ChromeHit {
49 Back,
51 Reload,
53 Scripting,
55 DevTools,
57 DevToolsOrientation,
59 UrlBar,
61}
62
63#[derive(Debug, Clone, Copy)]
65struct ToolbarRects {
66 toolbar: Rect,
68 back: Rect,
70 reload: Rect,
72 scripting: Rect,
74 devtools: Rect,
76 devtools_orientation: Rect,
78 url_bar: Rect,
80}
81
82impl ToolbarRects {
83 fn height(&self) -> f32 {
85 self.toolbar.height
86 }
87}
88
89#[derive(Debug)]
91struct BrowserToolbar {
92 back_button: ButtonComponent,
94 reload_button: ButtonComponent,
96 devtools_button: ButtonComponent,
98 devtools_orientation_button: ButtonComponent,
100 scripting_button: ButtonComponent,
102 url_bar: InputTextComponent,
104}
105
106impl BrowserToolbar {
107 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 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 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#[derive(Debug)]
215pub struct BasicChrome {
216 toolbar: BrowserToolbar,
217 last_url: Option<String>,
220
221 is_debug_open: bool,
222 debug_pane: Tab,
223
224 debug_press_active: bool,
228
229 debug_pane_vertical: bool,
231 debug_pane_ratio: f32,
233 debug_dragging: bool,
235 debug_drag_anchor: Option<(f32, f32)>,
237
238 scripting_mode: ScriptingMode,
239 hovered: Option<ChromeHit>,
241}
242
243impl BasicChrome {
244 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 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 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 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 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 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 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 let on_divider = divider.as_ref().is_some_and(|d| d.contains(x, y));
534
535 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 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 if !inside && !self.debug_press_active {
587 return ChromeEventResult::none();
588 }
589
590 let px = x - pane.x;
592 let py = y - pane.y;
593
594 match event {
595 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 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 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 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 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 assert!(rects.back.x < rects.reload.x);
833 assert!(rects.reload.x + rects.reload.width < rects.url_bar.x);
834
835 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 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 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 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 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 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 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 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 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 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 let action = click(&mut chrome, rects.back.x + 1.0, rects.back.y + 1.0);
1040 assert_eq!(action, ChromeAction::Back);
1041 }
1042
1043 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 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 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 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 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 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 cursor_to(&mut rig, W / 2.0 + 30.0, th + 14.0);
1206 press_left(&mut rig);
1207
1208 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 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 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 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}