1mod basic_chrome;
5mod basic_context_menu;
6mod chrome;
7mod context_menu;
8mod renderer;
9
10pub use basic_chrome::BasicChrome;
11pub use basic_context_menu::{BasicContextMenu, MenuItem};
12pub use chrome::{Chrome, ChromeAction, ChromeEventResult};
13pub use context_menu::{ClickContext, ContextMenu, MenuEventResult};
14pub use renderer::BrowserRenderer;
15
16use std::collections::HashMap;
17
18use url::Url;
19use winit::event::{ElementState, Ime, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent};
20use winit::window::CursorIcon;
21
22use crate::browser::Tab;
23use crate::browser::core::resource_loader::{BrowserNetworkError, BrowserResponse};
24use crate::browser::core::tab::{FetchKind, TabTask};
25use crate::engine::layouter::types::{ColorScheme, CursorKeyword, CursorStyle};
26use crate::engine::origin::Origin;
27use crate::engine::permissions::Permissions;
28use crate::engine::renderer_model::{DrawCommand, Rect};
29use crate::engine::ui::PointerEvent;
30use crate::engine::ui::input_text_types::{InputTextEvent, InputTextKey};
31use crate::platform::renderer::draw_sink::DrawSink;
32
33use super::BrowserCommand;
34
35#[derive(Debug, Clone)]
37pub struct RenderState {
38 pub draw_commands: Vec<DrawCommand>,
40 pub window_size: (u32, u32),
42 pub scale_factor: f64,
44 pub window_title: String,
46}
47
48impl Default for RenderState {
49 fn default() -> Self {
50 Self {
51 draw_commands: Vec::new(),
52 window_size: (1280, 800),
53 scale_factor: 1.0,
54 window_title: String::new(),
55 }
56 }
57}
58
59impl RenderState {
60 pub fn new(window_size: (u32, u32), scale_factor: f64, window_title: String) -> Self {
62 Self {
63 draw_commands: Vec::new(),
64 window_size,
65 scale_factor,
66 window_title,
67 }
68 }
69
70 pub fn viewport(&self) -> (f32, f32) {
72 let sf = self.scale_factor as f32;
73 (
74 self.window_size.0 as f32 / sf,
75 self.window_size.1 as f32 / sf,
76 )
77 }
78}
79
80#[derive(Default)]
82struct InputState {
83 mouse_position: (f64, f64),
85 modifiers: winit::keyboard::ModifiersState,
87}
88
89pub(crate) struct TabFetchRequest {
91 pub(crate) tab_id: TabId,
92 pub(crate) request: FetchRequest,
93}
94
95pub struct FetchRequest {
96 pub url: Url,
97 pub kind: FetchKind,
98 pub origin: crate::engine::origin::Origin,
100 pub permissions: crate::engine::permissions::Permissions,
102 pub generation: u64,
105}
106
107pub(crate) struct BrowserUiTick {
109 pub(crate) fetches: Vec<TabFetchRequest>,
110 pub(crate) needs_redraw: bool,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
115pub struct TabId(pub usize);
116
117impl std::fmt::Display for TabId {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 write!(f, "{:?}", self)
120 }
121}
122
123pub struct BrowserUi {
131 tabs: HashMap<TabId, Tab>,
132 active_tab_id: TabId,
133 next_tab_id: TabId,
134 renderer: BrowserRenderer,
135 input: InputState,
136 pending_cursor: Option<winit::window::CursorIcon>,
138
139 system_color_scheme: ColorScheme,
141}
142
143impl Default for BrowserUi {
144 fn default() -> Self {
145 Self::new()
146 }
147}
148
149fn cursor_style_to_icon(style: &CursorStyle) -> CursorIcon {
150 match style {
151 CursorStyle::Keyword(kw) => match kw {
152 CursorKeyword::Auto => CursorIcon::Default,
153 CursorKeyword::Default => CursorIcon::Default,
154 CursorKeyword::None => CursorIcon::Default,
155 CursorKeyword::Pointer => CursorIcon::Pointer,
156 CursorKeyword::Text => CursorIcon::Text,
157 CursorKeyword::Move => CursorIcon::Move,
158 CursorKeyword::NotAllowed => CursorIcon::NotAllowed,
159 CursorKeyword::Wait => CursorIcon::Wait,
160 CursorKeyword::Crosshair => CursorIcon::Crosshair,
161 CursorKeyword::Grab => CursorIcon::Grab,
162 CursorKeyword::Grabbing => CursorIcon::Grabbing,
163 },
164 CursorStyle::Custom { .. } => CursorIcon::Default,
165 }
166}
167
168pub(crate) struct FetchParams {
170 pub(crate) kind: FetchKind,
171 pub(crate) url: Url,
172 pub(crate) origin: Origin,
173 pub(crate) permissions: Permissions,
174 pub(crate) response: Result<BrowserResponse, BrowserNetworkError>,
175 pub(crate) generation: u64,
176}
177
178impl BrowserUi {
179 pub fn new() -> Self {
182 Self::with_chrome(Box::new(BasicChrome::new()))
183 }
184
185 pub fn with_tab(tab: Tab) -> Self {
187 Self::with_tab_and_chrome(tab, Box::new(BasicChrome::new()))
188 }
189
190 pub fn with_chrome(chrome: Box<dyn Chrome>) -> Self {
193 Self::with_chrome_and_menu(chrome, Box::new(BasicContextMenu::new()))
194 }
195
196 pub fn with_tab_and_chrome(tab: Tab, chrome: Box<dyn Chrome>) -> Self {
199 Self::with_tab_and_menu(tab, chrome, Box::new(BasicContextMenu::new()))
200 }
201
202 pub fn with_chrome_and_menu(chrome: Box<dyn Chrome>, menu: Box<dyn ContextMenu>) -> Self {
204 Self {
205 tabs: HashMap::new(),
206 active_tab_id: TabId(0),
207 next_tab_id: TabId(1),
208 renderer: BrowserRenderer::with_chrome_and_menu(chrome, menu),
209 input: InputState::default(),
210 system_color_scheme: dark_light::detect().map(Into::into).unwrap_or_else(|e| {
211 log::error!("Failed to detect system color scheme, using default: {e}");
212 Default::default()
213 }),
214 pending_cursor: None,
215 }
216 }
217
218 pub fn with_tab_and_menu(
220 mut tab: Tab,
221 chrome: Box<dyn Chrome>,
222 menu: Box<dyn ContextMenu>,
223 ) -> Self {
224 let system_color_scheme = dark_light::detect().map(Into::into).unwrap_or_else(|e| {
225 log::error!("Failed to detect system color scheme, using default: {e}");
226 Default::default()
227 });
228
229 tab.set_system_color_scheme(system_color_scheme);
230
231 let tab_id = TabId(1);
232
233 let mut tabs = HashMap::new();
234 tabs.insert(tab_id, tab);
235
236 Self {
237 tabs,
238 active_tab_id: tab_id,
239 next_tab_id: TabId(tab_id.0 + 1),
240 renderer: BrowserRenderer::with_chrome_and_menu(chrome, menu),
241 input: InputState::default(),
242 system_color_scheme,
243 pending_cursor: None,
244 }
245 }
246
247 pub fn active_tab_id(&self) -> Option<TabId> {
249 if self.active_tab_id.0 == 0 {
250 None
251 } else {
252 Some(self.active_tab_id)
253 }
254 }
255
256 pub fn active_tab(&self) -> Option<&Tab> {
257 self.active_tab_id().and_then(|id| self.tabs.get(&id))
258 }
259
260 pub fn active_tab_mut(&mut self) -> Option<&mut Tab> {
261 self.active_tab_id().and_then(|id| self.tabs.get_mut(&id))
262 }
263
264 pub fn tab(&self, id: &TabId) -> Option<&Tab> {
266 self.tabs.get(id)
267 }
268
269 pub fn tab_mut(&mut self, id: &TabId) -> Option<&mut Tab> {
270 self.tabs.get_mut(id)
271 }
272
273 pub fn add_tab(&mut self, mut tab: Tab) -> TabId {
278 tab.set_system_color_scheme(self.system_color_scheme);
279 let id = self.next_tab_id;
280 self.next_tab_id.0 += 1;
281 self.tabs.insert(id, tab);
282 id
283 }
284
285 pub fn set_window(&mut self, window_size: (u32, u32), scale_factor: f64, window_title: String) {
287 self.renderer
288 .set_window(window_size, scale_factor, window_title);
289 }
290
291 pub fn window_size(&self) -> (u32, u32) {
293 self.renderer.render_state.window_size
294 }
295
296 pub fn window_title(&self) -> String {
298 self.renderer.render_state.window_title.clone()
299 }
300
301 pub fn redraw(&mut self, sink: &mut dyn DrawSink) {
303 let id = self.active_tab_id();
304 self.renderer.redraw(&mut self.tabs, id, sink);
305 }
306
307 pub fn apply_draw_commands(&self, sink: &mut dyn DrawSink) {
309 self.renderer.apply_draw_commands(sink);
310 }
311
312 pub(crate) fn tick(&mut self) -> BrowserUiTick {
314 let mut fetches = Vec::new();
315 let mut needs_redraw = false;
316 let mut chrome_actions = Vec::new();
317
318 let tab_ids: Vec<_> = self.tabs.keys().copied().collect();
319
320 for tab_id in &tab_ids {
321 let is_active = self.active_tab_id().is_some_and(|active| active == *tab_id);
322 let Some(tab) = self.tab_mut(tab_id) else {
323 continue;
324 };
325 tab.set_animation_visible(is_active);
326 for task in tab.tick() {
327 match task {
328 TabTask::Fetch {
329 url,
330 kind,
331 origin,
332 permissions,
333 generation,
334 } => {
335 log::info!("Fetch requested in BrowserUi: url={}", url);
336 fetches.push(TabFetchRequest {
337 tab_id: *tab_id,
338 request: FetchRequest {
339 url,
340 kind,
341 origin,
342 permissions,
343 generation,
344 },
345 });
346 }
347 TabTask::NeedsRedraw => {
348 needs_redraw = true;
349 }
350 TabTask::DevToolsRequest { id, .. } => {
351 let response = serde_json::json!({
355 "ok": false,
356 "error": "no inspected page",
357 })
358 .to_string();
359
360 if let Some(requester) = self.tab_mut(tab_id) {
361 requester.on_devtools_response(id, response);
362 }
363 }
364 }
365 }
366 }
367
368 fetches.extend(
369 self.renderer
370 .chrome
371 .tick(&mut chrome_actions)
372 .0
373 .into_iter()
374 .map(|request| TabFetchRequest {
375 tab_id: TabId(0),
376 request,
377 }),
378 );
379
380 for action in chrome_actions {
381 match action {
382 ChromeAction::DevToolsRequest { id, method, params } => {
383 self.handle_devtools_request(id, method, params);
384 needs_redraw = true;
385 }
386 ChromeAction::Repaint => needs_redraw = true,
387 action => log::warn!("Ignoring unsupported action from chrome tick: {action:?}"),
388 }
389 }
390
391 BrowserUiTick {
392 fetches,
393 needs_redraw,
394 }
395 }
396
397 pub(crate) fn deliver_fetch(&mut self, tab_id: &TabId, params: FetchParams) {
399 let FetchParams {
400 kind,
401 url,
402 origin,
403 permissions,
404 response,
405 generation,
406 } = params;
407 if tab_id == &TabId(0) {
408 self.renderer.chrome.deliver_fetch(
409 kind,
410 url,
411 origin,
412 permissions,
413 response,
414 generation,
415 );
416 return;
417 }
418
419 let Some(tab) = self.tab_mut(tab_id) else {
420 log::warn!("There is no Tab called id={}", tab_id);
421 return;
422 };
423
424 log::info!("Delivering fetch result in BrowserUi for tab_id={}", tab_id);
425 tab.deliver_fetch(kind, url, origin, permissions, response, generation);
426 }
427
428 pub fn handle_window_event(
430 &mut self,
431 event: WindowEvent,
432 sink: &mut dyn DrawSink,
433 ) -> BrowserCommand {
434 match event {
435 WindowEvent::CloseRequested => BrowserCommand::Exit,
436
437 WindowEvent::RedrawRequested => {
438 self.redraw(sink);
439 BrowserCommand::RenameWindowTitle
440 }
441
442 WindowEvent::Resized(size) => {
443 self.renderer.render_state.window_size = (size.width, size.height);
444 sink.resize((size.width, size.height));
445 self.redraw(sink);
446 BrowserCommand::RequestRedraw
447 }
448
449 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
450 sink.set_scale_factor(scale_factor);
451 self.renderer.render_state.scale_factor = scale_factor;
452 self.redraw(sink);
453 BrowserCommand::RequestRedraw
454 }
455
456 WindowEvent::MouseWheel { delta, .. } => {
457 self.handle_scroll(delta);
458 BrowserCommand::RequestRedraw
459 }
460
461 WindowEvent::CursorMoved { position, .. } => {
462 self.input.mouse_position = (position.x, position.y);
463 if self.handle_pointer_move(position.x, position.y) {
464 self.pending_cursor
465 .take()
466 .map(BrowserCommand::SetCursorIcon)
467 .unwrap_or(BrowserCommand::RequestRedraw)
468 } else {
469 self.pending_cursor
470 .take()
471 .map(BrowserCommand::SetCursorIcon)
472 .unwrap_or(BrowserCommand::None)
473 }
474 }
475
476 WindowEvent::MouseInput { button, state, .. } => self.handle_mouse_input(button, state),
477
478 WindowEvent::ModifiersChanged(modifiers) => {
479 self.input.modifiers = modifiers.state();
480 BrowserCommand::None
481 }
482
483 WindowEvent::KeyboardInput { event, .. } => self.handle_keyboard_input(event),
484
485 WindowEvent::Ime(event) => self.handle_ime_input(event),
486
487 _ => BrowserCommand::None,
488 }
489 }
490
491 fn handle_keyboard_input(&mut self, event: KeyEvent) -> BrowserCommand {
493 if event.state != ElementState::Pressed {
494 return BrowserCommand::None;
495 }
496
497 let ctrl = self.input.modifiers.control_key();
498
499 let Some(tab_id) = &self.active_tab_id() else {
500 return BrowserCommand::None;
501 };
502
503 if self.renderer.chrome.accepts_text_input() {
506 let action = self.renderer.chrome.key_event(&event, ctrl);
507 return self.apply_chrome_action(action);
508 }
509
510 let Some(tab) = self.tab(tab_id) else {
511 return BrowserCommand::None;
512 };
513
514 let special = logical_key_to_special_event(&event.logical_key, ctrl);
515 let key = logical_key_to_text_key(&event.logical_key);
516 let handled = if let Some(special) = special {
517 tab.dispatch_text_input(special)
518 } else if let Some(key) = key {
519 tab.dispatch_text_input(InputTextEvent::Key(key))
520 } else if !ctrl && !tab.is_text_input_composing() {
521 event.text.as_ref().is_some_and(|text| {
522 tab.dispatch_text_input(InputTextEvent::Insert(text.to_string()))
523 })
524 } else {
525 false
526 };
527
528 if handled {
529 BrowserCommand::RequestRedraw
530 } else {
531 BrowserCommand::None
532 }
533 }
534
535 fn handle_ime_input(&mut self, event: Ime) -> BrowserCommand {
537 if self.renderer.chrome.accepts_text_input() {
540 let action = self.renderer.chrome.ime_event(&event);
541 return match action {
542 ChromeAction::Repaint => BrowserCommand::RequestRedraw,
543 _ => BrowserCommand::None,
544 };
545 }
546
547 let event = match event {
548 Ime::Preedit(text, _) => InputTextEvent::Preedit(text),
549 Ime::Commit(text) => InputTextEvent::Commit(text),
550 Ime::Disabled => InputTextEvent::CancelComposition,
551 Ime::Enabled => return BrowserCommand::None,
552 };
553
554 let Some(tab) = self.active_tab() else {
555 return BrowserCommand::None;
556 };
557
558 if tab.dispatch_text_input(event) {
559 BrowserCommand::RequestRedraw
560 } else {
561 BrowserCommand::None
562 }
563 }
564
565 fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState) -> BrowserCommand {
570 let (x, y, sf) = (
571 self.input.mouse_position.0,
572 self.input.mouse_position.1,
573 self.renderer.render_state.scale_factor,
574 );
575 let (px, py) = ((x / sf) as f32, (y / sf) as f32);
576
577 let width = self.renderer.render_state.viewport().0;
578 let height = self.renderer.render_state.viewport().1;
579
580 if self.renderer.menu.is_open() {
583 let window_event = match state {
584 ElementState::Pressed => PointerEvent::Down { x: px, y: py },
585 ElementState::Released => PointerEvent::Up { x: px, y: py },
586 };
587 let result = self
588 .renderer
589 .menu
590 .pointer_event(width, height, window_event);
591 if result.consumed {
592 return self.dispatch_action(result.action, ActionSource::ContextMenu, x, y);
593 }
594 } else if button == MouseButton::Right {
595 if state == ElementState::Pressed {
597 return self.open_context_menu(px, py);
598 }
599 return BrowserCommand::None;
600 }
601
602 if button != MouseButton::Left {
603 return BrowserCommand::None;
604 }
605
606 let window_event = match state {
608 ElementState::Pressed => PointerEvent::Down { x: px, y: py },
609 ElementState::Released => PointerEvent::Up { x: px, y: py },
610 };
611 let result = self
612 .renderer
613 .chrome
614 .pointer_event(width, height, window_event, state);
615 if result.consumed {
616 return self.dispatch_action(result.action, ActionSource::Chrome, x, y);
617 }
618
619 let Rect { x: dx, y: dy, .. } = self.renderer.chrome.content_rect(width, height);
621
622 let (px, py) = (px - dx, py - dy);
623
624 let (tab_redraw, input_focused) = if let Some(tab) = self.active_tab_mut() {
625 tab.handle_mouse_input(px, py, state)
626 } else {
627 (false, false)
628 };
629
630 self.renderer.chrome.blur();
632
633 if tab_redraw {
634 BrowserCommand::RequestRedraw
635 } else {
636 BrowserCommand::SetImeAllowed {
637 allowed: input_focused,
638 position: (x, y),
639 }
640 }
641 }
642
643 fn apply_chrome_action(&mut self, action: ChromeAction) -> BrowserCommand {
650 match action {
651 ChromeAction::Repaint => BrowserCommand::RequestRedraw,
652 ChromeAction::None => BrowserCommand::None,
653 action => {
654 let (x, y) = self.input.mouse_position;
655 self.dispatch_action(action, ActionSource::Chrome, x, y)
656 }
657 }
658 }
659
660 fn dispatch_action(
666 &mut self,
667 action: ChromeAction,
668 source: ActionSource,
669 x: f64,
670 y: f64,
671 ) -> BrowserCommand {
672 match action {
673 ChromeAction::EnableIme => {
677 if let Some(tab) = self.active_tab() {
678 tab.defocus_text_input();
679 }
680 return BrowserCommand::SetImeAllowed {
681 allowed: true,
682 position: (x, y),
683 };
684 }
685 ChromeAction::Back => {
686 if let Some(tab) = self.active_tab_mut() {
687 tab.go_back();
688 }
689 }
690 ChromeAction::Reload => {
691 if let Some(tab) = self.active_tab_mut() {
692 tab.reload();
693 }
694 }
695 ChromeAction::Navigate(url) => {
696 if let Some(tab) = self.active_tab_mut() {
697 tab.navigate(url);
698 }
699 }
700 ChromeAction::SetJsPolicy(policy) => {
701 if let Some(tab) = self.active_tab_mut() {
702 tab.set_js_policy(policy);
703 tab.reload();
704 }
705 }
706 ChromeAction::DevToolsRequest { id, method, params } => {
707 self.handle_devtools_request(id, method, params)
708 }
709 ChromeAction::Repaint | ChromeAction::None => {}
710 }
711
712 let needs_repaint = match source {
713 ActionSource::Chrome => self.renderer.chrome.needs_repaint(),
714 ActionSource::ContextMenu => self.renderer.menu.needs_repaint(),
715 };
716 if needs_repaint {
717 BrowserCommand::RequestRedraw
718 } else {
719 BrowserCommand::SetImeAllowed {
720 allowed: false,
721 position: (x, y),
722 }
723 }
724 }
725
726 fn open_context_menu(&mut self, px: f32, py: f32) -> BrowserCommand {
732 let width = self.renderer.render_state.viewport().0;
733 let height = self.renderer.render_state.viewport().1;
734
735 let Rect {
736 x: dx,
737 y: dy,
738 width: content_width,
739 height: content_height,
740 } = self.renderer.chrome.content_rect(width, height);
741 if px < dx || py < dy || px > dx + content_width || py > dy + content_height {
742 return BrowserCommand::None;
743 }
744
745 let page_pos = (px - dx, py - dy);
746
747 let Some(tab) = self.active_tab() else {
748 return BrowserCommand::None;
749 };
750 let document_url = tab.document_url().map(|url| url.to_string());
751 let link_url = tab.link_at(page_pos.0, page_pos.1);
752
753 let ctx = ClickContext {
754 window_pos: (px, py),
755 page_pos,
756 link_url,
757 document_url,
758 };
759
760 if self.renderer.menu.open(&ctx) {
761 BrowserCommand::RequestRedraw
762 } else {
763 BrowserCommand::None
764 }
765 }
766
767 fn handle_pointer_move(&mut self, x: f64, y: f64) -> bool {
772 let sf = self.renderer.render_state.scale_factor;
773 let (px, py) = ((x / sf) as f32, (y / sf) as f32);
774 let v_width = self.renderer.render_state.viewport().0;
775 let v_height = self.renderer.render_state.viewport().1;
776
777 if self.renderer.menu.is_open()
779 && self
780 .renderer
781 .menu
782 .pointer_event(v_width, v_height, PointerEvent::Move { x: px, y: py })
783 .consumed
784 {
785 return self.renderer.menu.needs_repaint();
786 }
787
788 let result = self.renderer.chrome.pointer_event(
790 v_width,
791 v_height,
792 PointerEvent::Move { x: px, y: py },
793 ElementState::Released,
794 );
795
796 if result.consumed {
797 return self.renderer.chrome.needs_repaint();
798 }
799
800 let Rect { x: dx, y: dy, .. } = self.renderer.chrome.content_rect(v_width, v_height);
802 let (px, py) = (px - dx, py - dy);
803 let tab_repaint = self
804 .active_tab_mut()
805 .is_some_and(|tab| tab.handle_pointer_move(px, py).0);
806
807 self.pending_cursor = self
809 .active_tab_mut()
810 .and_then(|tab| tab.cursor_at(px, py))
811 .map(|cs| cursor_style_to_icon(&cs));
812
813 tab_repaint || self.renderer.chrome.needs_repaint()
814 }
815
816 fn handle_scroll(&mut self, delta: MouseScrollDelta) {
819 let (scroll_x, scroll_y) = match delta {
820 MouseScrollDelta::LineDelta(x, y) => (-x * 60.0, -y * 60.0),
821 MouseScrollDelta::PixelDelta(pos) => (-pos.x as f32, -pos.y as f32),
822 };
823
824 let (w_width, w_height) = self.renderer.render_state.viewport();
825 let Rect {
826 x: sx,
827 y: sy,
828 width,
829 height,
830 } = self.renderer.chrome.content_rect(w_width, w_height);
831
832 let sf = self.renderer.render_state.scale_factor;
833 let (mouse_x, mouse_y) = (
834 (self.input.mouse_position.0 / sf) as f32 - sx,
835 (self.input.mouse_position.1 / sf) as f32 - sy,
836 );
837
838 let outside_content = mouse_x < 0.0 || mouse_y < 0.0 || mouse_x > width || mouse_y > height;
840
841 if outside_content {
842 self.renderer
843 .chrome
844 .handle_scroll(w_width, w_height, mouse_x, mouse_y, scroll_x, scroll_y);
845 return;
846 }
847
848 let Some(tab) = self.active_tab_mut() else {
849 return;
850 };
851
852 tab.scroll_at(mouse_x, mouse_y, scroll_x, scroll_y, (width, height));
854 }
855
856 fn handle_devtools_request(&mut self, id: u64, method: String, params: String) {
857 if method == "getVersion" {
862 if let Some(target_tab) = self.active_tab_mut()
863 && let Ok(data) = target_tab.inspect("getVersion", "{}")
864 {
865 let response = serde_json::json!({ "ok": true, "data": data }).to_string();
866 self.renderer.chrome.on_devtools_response(id, response);
867 return;
868 }
869 let response = serde_json::json!({
870 "ok": true,
871 "data": {
872 "domVersion": 0,
873 "layoutVersion": 0,
874 "url": null,
875 }
876 })
877 .to_string();
878 self.renderer.chrome.on_devtools_response(id, response);
879 return;
880 }
881
882 let response = match self.active_tab_mut() {
883 Some(target_tab) => match target_tab.inspect(&method, ¶ms) {
884 Ok(data) => serde_json::json!({ "ok": true, "data": data }).to_string(),
885 Err(error) => serde_json::json!({ "ok": false, "error": error }).to_string(),
886 },
887 None => serde_json::json!({
888 "ok": false,
889 "error": "no inspected page",
890 })
891 .to_string(),
892 };
893 self.renderer.chrome.on_devtools_response(id, response);
894 }
895}
896
897#[derive(Debug, Clone, Copy, PartialEq)]
900enum ActionSource {
901 Chrome,
903 ContextMenu,
905}
906
907fn logical_key_to_text_key(key: &winit::keyboard::Key) -> Option<InputTextKey> {
909 match key {
910 winit::keyboard::Key::Named(winit::keyboard::NamedKey::Backspace) => {
911 Some(InputTextKey::Backspace)
912 }
913 winit::keyboard::Key::Named(winit::keyboard::NamedKey::Delete) => {
914 Some(InputTextKey::Delete)
915 }
916 winit::keyboard::Key::Named(winit::keyboard::NamedKey::ArrowLeft) => {
917 Some(InputTextKey::Left)
918 }
919 winit::keyboard::Key::Named(winit::keyboard::NamedKey::ArrowRight) => {
920 Some(InputTextKey::Right)
921 }
922 winit::keyboard::Key::Named(winit::keyboard::NamedKey::Home) => Some(InputTextKey::Home),
923 winit::keyboard::Key::Named(winit::keyboard::NamedKey::End) => Some(InputTextKey::End),
924 _ => None,
925 }
926}
927
928fn logical_key_to_special_event(key: &winit::keyboard::Key, ctrl: bool) -> Option<InputTextEvent> {
930 if ctrl && let winit::keyboard::Key::Character(ch) = key {
931 match ch.as_str() {
932 "z" | "Z" => Some(InputTextEvent::Undo),
933 "y" | "Y" => Some(InputTextEvent::Redo),
934 _ => None,
935 }
936 } else if let winit::keyboard::Key::Named(winit::keyboard::NamedKey::Enter) = key {
937 Some(InputTextEvent::Enter)
938 } else {
939 None
940 }
941}
942
943#[cfg(test)]
944mod tests {
945 use super::*;
946 use crate::browser::core::webview::JsPolicy;
947 use crate::engine::renderer_model::DrawCommand;
948
949 fn ui_with_one_tab() -> BrowserUi {
950 let tab = Tab::new(ColorScheme::default(), JsPolicy::default());
951 BrowserUi::with_tab(tab)
952 }
953
954 #[derive(Default)]
960 struct RecordingSink {
961 frames: Vec<Vec<crate::engine::renderer_model::DrawCommand>>,
962 pending: Vec<crate::engine::renderer_model::DrawCommand>,
963 size: (u32, u32),
964 scale_factor: f64,
965 }
966
967 impl DrawSink for RecordingSink {
968 fn upload(&mut self, commands: &[crate::engine::renderer_model::DrawCommand]) {
969 self.pending.extend_from_slice(commands);
970 }
971
972 fn present(&mut self) -> Result<(), crate::platform::renderer::draw_sink::DrawError> {
973 self.frames.push(std::mem::take(&mut self.pending));
974 Ok(())
975 }
976
977 fn resize(&mut self, size: (u32, u32)) {
978 self.size = size;
979 }
980
981 fn set_scale_factor(&mut self, scale_factor: f64) {
982 self.scale_factor = scale_factor;
983 }
984 }
985
986 #[test]
987 fn redraw_produces_a_frame_without_a_gpu() {
988 let mut ui = ui_with_one_tab();
989 ui.set_window((800, 600), 1.0, "test".to_string());
990 let mut sink = RecordingSink::default();
991
992 ui.redraw(&mut sink);
993
994 assert_eq!(
995 sink.frames.len(),
996 1,
997 "redraw must present exactly one frame"
998 );
999 let frame = &sink.frames[0];
1000 assert!(!frame.is_empty());
1001 assert!(matches!(frame.first(), Some(DrawCommand::PushClip { .. })));
1004 assert_eq!(
1005 frame
1006 .iter()
1007 .filter(|c| matches!(c, DrawCommand::PopClip))
1008 .count(),
1009 frame
1010 .iter()
1011 .filter(|c| matches!(c, DrawCommand::PushClip { .. }))
1012 .count(),
1013 "clip scopes must be balanced: {frame:?}"
1014 );
1015 }
1016
1017 #[test]
1018 fn apply_draw_commands_does_not_present() {
1019 let mut ui = ui_with_one_tab();
1020 ui.set_window((800, 600), 1.0, "test".to_string());
1021
1022 let mut priming = RecordingSink::default();
1025 ui.redraw(&mut priming);
1026 assert!(!priming.frames.is_empty());
1027
1028 let mut sink = RecordingSink::default();
1029 ui.apply_draw_commands(&mut sink);
1030
1031 assert!(
1032 sink.frames.is_empty(),
1033 "apply_draw_commands records without presenting"
1034 );
1035 assert!(
1036 !sink.pending.is_empty(),
1037 "apply_draw_commands must upload the recorded frame"
1038 );
1039 }
1040
1041 #[test]
1042 fn resize_reaches_the_sink_as_plain_pixels() {
1043 let mut ui = ui_with_one_tab();
1048 ui.set_window((800, 600), 1.0, "test".to_string());
1049 let mut sink = RecordingSink::default();
1050
1051 let cmd = ui.handle_window_event(
1052 WindowEvent::Resized(winit::dpi::PhysicalSize::new(1024, 768)),
1053 &mut sink,
1054 );
1055
1056 assert!(matches!(cmd, BrowserCommand::RequestRedraw));
1057 assert_eq!(sink.size, (1024, 768));
1060 assert_eq!(sink.frames.len(), 1, "a resize repaints");
1061 }
1062
1063 #[test]
1064 fn redraw_requested_event_presents_a_frame() {
1065 let mut ui = ui_with_one_tab();
1066 ui.set_window((800, 600), 1.0, "test".to_string());
1067 let mut sink = RecordingSink::default();
1068
1069 let cmd = ui.handle_window_event(WindowEvent::RedrawRequested, &mut sink);
1070
1071 assert!(matches!(cmd, BrowserCommand::RenameWindowTitle));
1072 assert_eq!(sink.frames.len(), 1);
1073 }
1074
1075 #[test]
1078 fn add_tab_inserts_without_stealing_focus() {
1079 let mut ui = ui_with_one_tab();
1080 let original = ui.active_tab_id().expect("the fixture has a tab");
1081
1082 let first = ui.add_tab(Tab::new(ColorScheme::default(), JsPolicy::default()));
1083 let second = ui.add_tab(Tab::new(ColorScheme::default(), JsPolicy::default()));
1084
1085 assert_ne!(first, second, "each tab gets its own id");
1086 assert_ne!(first, original);
1087 assert_ne!(second, first);
1088 assert!(ui.tab(&first).is_some(), "first tab is retrievable");
1089 assert!(ui.tab(&second).is_some(), "second tab is retrievable");
1090 assert_eq!(
1091 ui.active_tab_id(),
1092 Some(original),
1093 "adding a tab must not move the active tab"
1094 );
1095 }
1096
1097 #[test]
1098 fn tab_ids_are_not_reused() {
1099 let mut ui = BrowserUi::with_chrome(Box::new(BasicChrome::new()));
1100 let first = ui.add_tab(Tab::new(ColorScheme::default(), JsPolicy::default()));
1101 let second = ui.add_tab(Tab::new(ColorScheme::default(), JsPolicy::default()));
1102
1103 assert_ne!(first, second);
1104 assert!(ui.active_tab_id().is_none(), "no tab is active yet");
1105 }
1106
1107 #[test]
1108 fn get_document_serializes_children_under_the_document_root() {
1109 let mut ui = ui_with_one_tab();
1110 ui.active_tab_mut()
1111 .unwrap()
1112 .navigate("https://example.test/index.html".parse().expect("url"));
1113 ui.active_tab_mut()
1114 .unwrap()
1115 .on_fetch_succeeded_html("<html><body><p id=\"a\">hello</p></body></html>".to_string());
1116
1117 let doc = ui
1118 .active_tab_mut()
1119 .unwrap()
1120 .inspect("getDocument", "{}")
1121 .expect("document payload");
1122 assert_eq!(doc["type"], "document");
1123
1124 let children = doc["children"].as_array().expect("children array");
1127 assert!(
1128 children
1129 .iter()
1130 .any(|child| child["type"] == "element" && child["tag"] == "html"),
1131 "document root must expose the <html> element: {doc}"
1132 );
1133 }
1134
1135 #[test]
1136 fn get_version_reports_document_url() {
1137 let mut ui = ui_with_one_tab();
1138 ui.active_tab_mut()
1139 .unwrap()
1140 .navigate("https://example.test/index.html".parse().expect("url"));
1141 ui.active_tab_mut()
1142 .unwrap()
1143 .on_fetch_succeeded_html("<html><body>hi</body></html>".to_string());
1144
1145 let version = ui
1146 .active_tab_mut()
1147 .unwrap()
1148 .inspect("getVersion", "{}")
1149 .expect("version payload");
1150 assert_eq!(
1151 version["url"].as_str(),
1152 Some("https://example.test/index.html"),
1153 "getVersion must expose the inspected document URL: {version}"
1154 );
1155 assert!(
1156 version["domVersion"].is_u64(),
1157 "domVersion must be a number: {version}"
1158 );
1159 assert!(
1160 version["layoutVersion"].is_u64(),
1161 "layoutVersion must be a number: {version}"
1162 );
1163 }
1164}