1use std::cell::RefCell;
4use std::collections::{HashMap, HashSet};
5use std::rc::{Rc, Weak};
6use std::sync::{Arc, mpsc};
7use std::time::Instant;
8
9use crate::engine::animation::{AnimatedOverrides, AnimationRuntime};
10use crate::engine::bridge::audio::AudioSinkFactory;
11use crate::engine::image_decoder::ImageDecoder;
12use crate::engine::layouter::types::{
13 Color, ColorScheme, ContainerStyle, CursorKeyword, CursorStyle, FontStyle, TextAlign,
14 TextFlowStyle, TextStyle, Visibility, WhiteSpace,
15};
16use crate::engine::{
17 css::{
18 self,
19 matcher::ElementChain,
20 parser::{CssNode, CssNodeType, Parser as CssParser},
21 values::CssValue,
22 },
23 html::HtmlNodeType,
24 html::parser::{
25 ClassicScriptExecution, ClassicScriptSource, DomTree, Parser as HtmlParser, ScriptingMode,
26 },
27 input::hit_test,
28 js::{
29 JsDevToolsRequest, JsDynamicImageRequest, JsDynamicScriptRequest, JsDynamicScriptSource,
30 JsDynamicStyleRequest, JsFetchRequest, JsFetchResponse, JsIframeFetchRequest,
31 JsLayoutMetrics, JsProcessor, JsTask, JsTaskResult, JsTraceEvent,
32 },
33 layouter::{
34 self, InheritedCss, LayoutResult, NodeId,
35 dom_snapshot::DomSnapshot,
36 types::{InfoNode, InteractionState, NodeKind},
37 },
38 origin::Origin,
39 permissions::Permissions,
40 renderer_model::{Image, StickyViewport, is_scrollport, sticky_offset},
41 tree::{NodeRef, TreeNode},
42};
43use crate::platform::{
44 audio::default_audio_sink_factory, locale, renderer::text_measurer::PlatformTextMeasurer,
45};
46use crate::{perf_scope, profile_log};
47use ui_layout::{Display, InnerDisplay, LayoutChild, LayoutNode, OuterDisplay, Position};
48use url::Url;
49
50const USER_AGENT_CSS: &str = include_str!("../../../../resource/user-agent.css");
51
52pub enum WebViewTask {
53 AskTabHtml,
54 Fetch {
55 url: Url,
56 kind: FetchKind,
57 },
58 Navigate {
61 url: Url,
62 },
63 DevToolsRequest {
65 id: u64,
66 method: String,
67 params: String,
68 },
69}
70
71mod inspector;
72
73#[cfg(test)]
75mod probe_tests;
76
77#[derive(Debug, Clone, PartialEq)]
78pub enum FetchKind {
79 Html,
80 Css,
81 Script {
82 index: usize,
83 },
84 DynamicScript {
85 node_id: u64,
86 },
87 DynamicCss {
88 node_id: u64,
89 },
90 CssImport {
94 target: Url,
95 },
96 Image {
97 source: String,
98 },
99 Audio {
100 source: String,
101 },
102 JavaScript {
103 request_id: u64,
104 method: String,
105 headers: Vec<(String, String)>,
106 body: Vec<u8>,
107 },
108 Iframe {
109 dom_id: u64,
110 },
111}
112
113#[derive(Debug, Clone, Copy, PartialEq)]
120pub enum CssApplicationStrategy {
121 Batch,
122 Incremental,
123}
124
125#[derive(Debug, Clone, Copy)]
128enum CssSink {
129 External,
131 Dynamic(u64),
134}
135
136#[derive(Debug)]
139struct CssImportEntry {
140 url: Url,
141 text: Option<String>,
142}
143
144#[derive(Debug)]
150enum CssSheetState {
151 Deferred {
152 source: String,
154 entries: Vec<CssImportEntry>,
155 sink: Option<CssSink>,
156 },
157 Applied {
158 text: String,
160 sink: Option<CssSink>,
161 },
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
169pub enum JsPolicy {
170 #[default]
172 Enabled,
173 Disabled,
175}
176
177impl From<JsPolicy> for ScriptingMode {
178 fn from(value: JsPolicy) -> ScriptingMode {
179 match value {
180 JsPolicy::Enabled => ScriptingMode::Enabled,
181 JsPolicy::Disabled => ScriptingMode::Disabled,
182 }
183 }
184}
185
186impl From<ScriptingMode> for JsPolicy {
187 fn from(value: ScriptingMode) -> JsPolicy {
188 match value {
189 ScriptingMode::Enabled => JsPolicy::Enabled,
190 ScriptingMode::Disabled => JsPolicy::Disabled,
191 }
192 }
193}
194
195#[derive(Debug, Clone, PartialEq)]
196enum PagePhase {
197 Init,
198 BeforeHtmlParsing,
199 HtmlParsed,
200 CssPending,
201 CssProcessing,
202 CssApplied,
203 ScriptApplied,
204}
205
206type NavigationCb = Box<dyn Fn(&Url)>;
207type FetchCb = Box<dyn Fn(&Url, FetchKind)>;
208type ConsoleCb = Box<dyn Fn(u32, &str)>;
209type InspectRequestCb = Box<dyn Fn(u64, &str, &str)>;
210
211pub struct WebView {
212 phase: PagePhase,
213
214 docment_info: Option<DocumentInfo>,
215
216 pending_css_urls: Vec<Url>,
217 pending_css_imports: Vec<(Url, Url)>,
220 css_sheets: HashMap<Url, CssSheetState>,
224 css_import_fetches: HashSet<Url>,
227 pending_images: Vec<(String, Url)>,
228 pending_audio: Vec<(String, Url)>,
229 loaded_css: Vec<String>,
230 linked_css: Vec<String>,
231 images: HashMap<String, Image>,
232 image_decoder: ImageDecoder,
233 audio: HashMap<String, Arc<[u8]>>,
234
235 resolved_styles: Arc<layouter::css_resolver::ResolvedStyles>,
236 resolved_keyframes: Arc<layouter::css_resolver::KeyframesRegistry>,
238 resolved_styles_version: u64,
241 layout_and_info: Option<(LayoutNode, InfoNode)>,
242
243 needs_redraw: bool,
244
245 text_measurer: Option<Arc<PlatformTextMeasurer>>,
246
247 audio_sinks: Arc<dyn AudioSinkFactory>,
253
254 system_color_scheme: ColorScheme,
255 viewport: (f32, f32),
256
257 css_processor: css::processor::CssProcessor,
258 css_strategy: CssApplicationStrategy,
259 css_results_expected: usize,
260 css_results_received: usize,
261
262 js_policy: JsPolicy,
265
266 privileged: bool,
269
270 layout_processor: layouter::LayoutProcessor,
271 layout_pending: bool,
272 layout_requested_version: u64,
273 layout_applied_version: u64,
274 positioned_layout: Option<(u64, (f32, f32))>,
279 layout_dom_refs: Vec<Weak<RefCell<TreeNode<HtmlNodeType>>>>,
281 interaction_state: Arc<InteractionState>,
282 interaction_version: u64,
283 snapshot_cache: Option<SnapshotCache>,
286 iframe_content: HashMap<u64, DomSnapshot>,
289
290 animation_runtime: AnimationRuntime,
292 animation_last_tick: Option<Instant>,
294 animation_time_ms: f64,
296 animated_overrides: AnimatedOverrides,
298 animation_visible: bool,
300 latest_animation_inputs: crate::engine::animation::AnimationInputs,
302 write_back_tx: mpsc::Sender<(u32, crate::engine::ui::registry::DomWrite)>,
304 write_back_rx: mpsc::Receiver<(u32, crate::engine::ui::registry::DomWrite)>,
305 js_processor: Option<JsProcessor>,
308 js_dom_ids: HashMap<usize, u64>,
313 pending_js_tasks: usize,
317 js_dom_dirty: bool,
319 in_flight_timer_version: Option<u64>,
323 window_load_dispatched: bool,
325 pending_js_fetches: Vec<JsFetchRequest>,
327 pending_devtools_requests: Vec<JsDevToolsRequest>,
329 js_trace_events: Vec<JsTraceEvent>,
331 js_trace_active: Vec<crate::engine::js::JsActiveTrace>,
333 js_trace_enabled: bool,
335 js_trace_total: u64,
337 js_trace_dropped: u64,
339 js_console_results: HashMap<u64, String>,
341 inspector_ids: RefCell<inspector::DomIdRegistry>,
343 pending_dynamic_scripts: Vec<JsDynamicScriptRequest>,
345 pending_dynamic_styles: Vec<JsDynamicStyleRequest>,
347 pending_dynamic_images: Vec<JsDynamicImageRequest>,
349 pending_navigations: Vec<String>,
351 pending_iframe_fetches: Vec<JsIframeFetchRequest>,
353 classic_scripts: Vec<ClassicScript>,
355 next_script_index: usize,
356 pending_script_fetches: HashMap<usize, ClassicScriptExecution>,
357 non_blocking_scripts_scheduled: bool,
358 deferred_script_results: HashMap<usize, (Option<String>, Option<String>)>,
359 next_deferred_script_index: usize,
360 pending_fragment_scroll: Option<String>,
362 fragment_ready_version: Option<u64>,
364
365 on_navigation: Option<NavigationCb>,
369 on_fetch: Option<FetchCb>,
370 on_console: Option<ConsoleCb>,
371 on_inspect_request: Option<InspectRequestCb>,
372}
373
374#[derive(Debug, Clone, PartialEq)]
375enum ClassicScript {
376 Inline(String),
377 External {
378 url: Url,
379 execution: ClassicScriptExecution,
380 },
381}
382
383impl ClassicScript {
384 fn label(&self) -> Option<String> {
385 match self {
386 Self::Inline(_) => Some("inline script".to_string()),
387 Self::External { url, .. } => Some(url.as_str().to_string()),
388 }
389 }
390}
391
392struct SnapshotCache {
398 dom_version: u64,
399 snapshot: Arc<DomSnapshot>,
400 dom_refs: Vec<Weak<RefCell<TreeNode<HtmlNodeType>>>>,
401}
402
403fn js_snapshot_from_tree(dom: &DomTree) -> (DomSnapshot, HashMap<usize, u64>) {
404 let mut dom_ids = HashMap::new();
405 let mut next_id = 1u64;
406 dom.traverse(|node| {
407 dom_ids.insert(Rc::as_ptr(node) as usize, next_id);
408 next_id += 1;
409 });
410 (DomSnapshot::from_mirror(&dom.root, &dom_ids), dom_ids)
411}
412
413fn graft_iframe_documents(
418 dom: &Rc<DomTree>,
419 js_dom_ids: &HashMap<usize, u64>,
420 iframes: &HashMap<u64, DomSnapshot>,
421) {
422 if iframes.is_empty() {
423 return;
424 }
425 let mut node_by_dom_id: HashMap<u64, NodeRef<HtmlNodeType>> = HashMap::new();
428 dom.traverse(|node| {
429 if let Some(&dom_id) = js_dom_ids.get(&(Rc::as_ptr(node) as usize)) {
430 node_by_dom_id.insert(dom_id, Rc::clone(node));
431 }
432 });
433 for (iframe_dom_id, content) in iframes {
434 let (content_tree, _ids) = content.into_tree();
435 let Some(html) = content_tree.query_selector("html") else {
436 continue;
437 };
438 let Some(host) = node_by_dom_id.get(iframe_dom_id) else {
439 continue;
440 };
441 if host.borrow().value.tag_name() != Some("iframe") {
442 continue;
443 }
444 TreeNode::add_child(host, html);
445 }
446}
447
448impl std::fmt::Debug for SnapshotCache {
449 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
450 f.debug_struct("SnapshotCache")
451 .field("dom_version", &self.dom_version)
452 .field("snapshot", &self.snapshot)
453 .finish()
454 }
455}
456
457#[derive(Debug)]
466pub struct DocumentInfo {
467 document_url: Url,
468 base_url: Url,
469 pub origin: crate::engine::origin::Origin,
470 title: String,
471 pub dom: Rc<DomTree>,
472}
473
474struct ParsedDocument {
485 document_url: Url,
486 base_url: Url,
487 dom: Rc<DomTree>,
488 title: String,
489 style_links: Vec<Url>,
490 inline_styles: Vec<String>,
491 image_sources: Vec<(String, Url)>,
492 audio_sources: Vec<(String, Url)>,
493 scripts: Vec<ClassicScript>,
494}
495
496impl Default for WebView {
497 fn default() -> Self {
498 Self::new(ColorScheme::default(), JsPolicy::default())
499 }
500}
501
502impl WebView {
503 pub fn new(system_color_scheme: ColorScheme, js_policy: JsPolicy) -> Self {
504 let (write_back_tx, write_back_rx) = mpsc::channel();
505 Self {
506 phase: PagePhase::Init,
507
508 docment_info: None,
509
510 pending_css_urls: Vec::new(),
511 pending_css_imports: Vec::new(),
512 css_sheets: HashMap::new(),
513 css_import_fetches: HashSet::new(),
514 pending_images: Vec::new(),
515 pending_audio: Vec::new(),
516 loaded_css: Vec::new(),
517 linked_css: Vec::new(),
518 images: HashMap::new(),
519 image_decoder: ImageDecoder::new(),
520 audio: HashMap::new(),
521
522 resolved_styles: Arc::new(layouter::css_resolver::ResolvedStyles::default()),
523 resolved_keyframes: Arc::new(layouter::css_resolver::KeyframesRegistry::new()),
524 resolved_styles_version: 0,
525 layout_and_info: None,
526
527 needs_redraw: false,
528
529 text_measurer: None,
530 audio_sinks: default_audio_sink_factory(),
531
532 system_color_scheme,
533 viewport: (800.0, 600.0),
534
535 css_processor: css::processor::CssProcessor::new(),
536 css_strategy: CssApplicationStrategy::Incremental,
537 css_results_expected: 0,
538 css_results_received: 0,
539
540 js_policy,
541
542 privileged: false,
543
544 layout_processor: layouter::LayoutProcessor::new(),
545 layout_pending: false,
546 layout_requested_version: 0,
547 layout_applied_version: 0,
548 positioned_layout: None,
549 layout_dom_refs: Vec::new(),
550 interaction_state: Arc::new(InteractionState::default()),
551 interaction_version: 0,
552 snapshot_cache: None,
553 iframe_content: HashMap::new(),
554
555 animation_runtime: AnimationRuntime::new(),
556 animation_last_tick: None,
557 animation_time_ms: 0.0,
558 animated_overrides: HashMap::new(),
559 animation_visible: true,
560 latest_animation_inputs: std::collections::HashMap::new(),
561 write_back_tx,
562 write_back_rx,
563 js_processor: None,
564 js_dom_ids: HashMap::new(),
565 pending_js_tasks: 0,
566 js_dom_dirty: false,
567 in_flight_timer_version: None,
568 window_load_dispatched: false,
569 pending_js_fetches: Vec::new(),
570 pending_devtools_requests: Vec::new(),
571 js_trace_events: Vec::new(),
572 js_trace_active: Vec::new(),
573 js_trace_enabled: crate::engine::js::default_trace_enabled(),
574 js_trace_total: 0,
575 js_trace_dropped: 0,
576 js_console_results: HashMap::new(),
577 inspector_ids: RefCell::new(inspector::DomIdRegistry::default()),
578 pending_dynamic_scripts: Vec::new(),
579 pending_dynamic_styles: Vec::new(),
580 pending_dynamic_images: Vec::new(),
581 pending_navigations: Vec::new(),
582 pending_iframe_fetches: Vec::new(),
583 classic_scripts: Vec::new(),
584 next_script_index: 0,
585 pending_script_fetches: HashMap::new(),
586 non_blocking_scripts_scheduled: false,
587 deferred_script_results: HashMap::new(),
588 next_deferred_script_index: 0,
589 pending_fragment_scroll: None,
590 fragment_ready_version: None,
591
592 on_navigation: None,
593 on_fetch: None,
594 on_console: None,
595 on_inspect_request: None,
596 }
597 }
598
599 pub fn set_css_strategy(&mut self, strategy: CssApplicationStrategy) {
603 self.css_strategy = strategy;
604 }
605
606 pub fn set_js_policy(&mut self, policy: JsPolicy) {
612 if self.js_policy == policy {
613 return;
614 }
615 self.js_policy = policy;
616
617 if policy == JsPolicy::Disabled {
618 self.teardown_script_execution();
619 } else if self.js_processor.is_none()
620 && let Some(dom) = self.docment_info.as_ref().map(|info| Rc::clone(&info.dom))
621 {
622 let document_url = self
625 .docment_info
626 .as_ref()
627 .map(|info| info.document_url.to_string())
628 .unwrap_or_default();
629 let origin = self
630 .docment_info
631 .as_ref()
632 .map(|info| info.origin.ascii_serialization())
633 .unwrap_or_else(|| "null".to_string());
634 let (snapshot, dom_ids) = js_snapshot_from_tree(&dom);
635 let processor = JsProcessor::new(snapshot);
636 processor.send(JsTask::SetDocumentUrl { url: document_url });
637 processor.send(JsTask::SetOrigin { origin });
638 let permissions = self
639 .docment_info
640 .as_ref()
641 .map(|info| self.page_permissions_for(&info.document_url))
642 .unwrap_or_else(Permissions::none);
643 processor.send(JsTask::SetPermissions { permissions });
644 processor.send(JsTask::SetViewport {
645 width: self.viewport.0,
646 height: self.viewport.1,
647 });
648 processor.send(JsTask::SetLanguage {
649 language: locale::preferred_language(),
650 });
651 self.js_processor = Some(processor);
652 self.js_dom_ids = dom_ids;
653 self.pending_js_tasks = 5;
654 self.reset_js_trace_buffer();
655 self.sync_initial_js_tracing();
656 }
657 }
658
659 fn reset_js_trace_buffer(&mut self) {
660 self.js_trace_events.clear();
661 self.js_trace_active.clear();
662 self.js_trace_total = 0;
663 self.js_trace_dropped = 0;
664 }
665
666 fn sync_initial_js_tracing(&mut self) {
671 if self.js_trace_enabled == crate::engine::js::default_trace_enabled() {
672 return;
673 }
674 if let Some(processor) = self.js_processor.as_ref() {
675 processor.send(JsTask::SetTracing {
676 enabled: self.js_trace_enabled,
677 });
678 self.pending_js_tasks += 1;
679 }
680 }
681
682 pub fn set_js_tracing(&mut self, enabled: bool) {
687 if self.js_trace_enabled == enabled {
688 return;
689 }
690 self.js_trace_enabled = enabled;
691 if let Some(processor) = self.js_processor.as_ref() {
692 processor.send(JsTask::SetTracing { enabled });
693 self.pending_js_tasks += 1;
694 }
695 }
696
697 pub fn clear_js_trace(&mut self) {
699 self.reset_js_trace_buffer();
700 if let Some(processor) = self.js_processor.as_ref() {
701 processor.send(JsTask::ClearTrace);
702 self.pending_js_tasks += 1;
703 }
704 }
705
706 pub fn js_policy(&self) -> JsPolicy {
708 self.js_policy
709 }
710
711 pub fn set_privileged(&mut self, privileged: bool) {
716 self.privileged = privileged;
717 }
718
719 fn page_permissions_for(&self, url: &Url) -> Permissions {
721 if self.privileged {
722 Permissions::chrome()
723 } else {
724 Permissions::content_for(url)
725 }
726 }
727
728 fn teardown_script_execution(&mut self) {
731 self.js_processor = None;
732 self.pending_js_tasks = 0;
733 self.js_dom_dirty = false;
734 self.in_flight_timer_version = None;
735 self.pending_js_fetches.clear();
736 self.pending_devtools_requests.clear();
737 self.reset_js_trace_buffer();
738 self.inspector_ids.borrow_mut().clear();
739 self.pending_dynamic_scripts.clear();
740 self.pending_dynamic_styles.clear();
741 self.pending_dynamic_images.clear();
742 self.pending_iframe_fetches.clear();
743 self.js_dom_ids.clear();
744 self.classic_scripts.clear();
745 self.next_script_index = 0;
746 self.pending_script_fetches.clear();
747 self.non_blocking_scripts_scheduled = false;
748 self.deferred_script_results.clear();
749 self.next_deferred_script_index = 0;
750
751 if self.phase == PagePhase::CssApplied {
753 self.phase = PagePhase::ScriptApplied;
754 }
755 }
756
757 pub fn tick(&mut self) -> Vec<WebViewTask> {
758 let mut tasks = Vec::new();
759
760 match self.phase {
761 PagePhase::Init => {
762 let ua_styles = layouter::css_resolver::CssResolver::resolve_stylesheet_with_origin(
763 &CssParser::new(USER_AGENT_CSS).parse().unwrap(),
764 layouter::css_resolver::StyleOrigin::UserAgent,
765 );
766 layouter::css_resolver::append_resolved_styles(
767 Arc::make_mut(&mut self.resolved_styles),
768 ua_styles.declarations,
769 );
770 Arc::make_mut(&mut self.resolved_keyframes).extend(ua_styles.keyframes);
771 self.resolved_styles_version += 1;
772
773 tasks.push(WebViewTask::AskTabHtml);
774
775 self.phase = PagePhase::BeforeHtmlParsing;
776 }
777
778 PagePhase::BeforeHtmlParsing => {}
779
780 PagePhase::HtmlParsed => {
781 self.ensure_text_measurer();
783 self.update_layout();
784
785 for (source, url) in std::mem::take(&mut self.pending_images) {
786 log::info!("Image fetch requested in WebView: url={}", url);
787 if let Some(cb) = &self.on_fetch {
788 cb(
789 &url,
790 FetchKind::Image {
791 source: source.clone(),
792 },
793 );
794 }
795 tasks.push(WebViewTask::Fetch {
796 url,
797 kind: FetchKind::Image { source },
798 });
799 }
800
801 for (source, url) in std::mem::take(&mut self.pending_audio) {
802 log::info!("Audio fetch requested in WebView: url={}", url);
803 if let Some(cb) = &self.on_fetch {
804 cb(
805 &url,
806 FetchKind::Audio {
807 source: source.clone(),
808 },
809 );
810 }
811 tasks.push(WebViewTask::Fetch {
812 url,
813 kind: FetchKind::Audio { source },
814 });
815 }
816
817 if self.pending_css_urls.is_empty() {
819 self.fragment_ready_version = Some(self.layout_requested_version);
820 self.phase = PagePhase::CssApplied;
821 } else {
822 for url in &self.pending_css_urls {
823 log::info!("Fetch requested in WebView: url={}", url);
824 if let Some(cb) = &self.on_fetch {
825 cb(url, FetchKind::Css);
826 }
827 tasks.push(WebViewTask::Fetch {
828 url: url.clone(),
829 kind: FetchKind::Css,
830 });
831 }
832
833 self.phase = PagePhase::CssPending;
834 }
835 }
836
837 PagePhase::CssPending => {
838 self.try_apply_css_results();
840 }
841
842 PagePhase::CssProcessing => {
843 self.try_apply_batch_result();
845 }
846
847 PagePhase::CssApplied => {
848 self.advance_classic_scripts(&mut tasks);
849 }
850
851 PagePhase::ScriptApplied => {
852 }
854 }
855
856 self.try_apply_js_results();
857 self.schedule_js_fetches(&mut tasks);
858 self.schedule_iframe_fetches(&mut tasks);
859 self.schedule_navigations(&mut tasks);
860 self.schedule_dynamic_scripts(&mut tasks);
861 for request in std::mem::take(&mut self.pending_devtools_requests) {
862 tasks.push(WebViewTask::DevToolsRequest {
863 id: request.id,
864 method: request.method,
865 params: request.params,
866 });
867 }
868 self.schedule_dynamic_styles(&mut tasks);
869 self.schedule_dynamic_images(&mut tasks);
870 self.schedule_css_imports(&mut tasks);
871 self.schedule_pending_images(&mut tasks);
872 self.try_apply_decoded_images();
873 self.run_due_js_timers();
874 self.try_apply_layout_results();
875 self.drain_write_backs();
876 self.sync_dom_to_worker();
877
878 self.sample_animation_runtime();
879
880 if !self.window_load_dispatched
886 && self.phase == PagePhase::ScriptApplied
887 && self.pending_js_tasks == 0
888 && !self.has_pending_subresource_work()
889 {
890 self.dispatch_window_load();
891 }
892
893 tasks
894 }
895
896 pub fn on_html_fetched(&mut self, html: String, document_url: Url) {
897 log::info!("Fetched HTML: {}", document_url);
898 self.pending_fragment_scroll = document_url.fragment().map(str::to_string);
899 self.fragment_ready_version = None;
900 perf_scope!(html_parse);
901 let parsed = parse_html(&html, document_url, self.js_policy.into());
902 #[cfg(any(feature = "profile", debug_assertions))]
903 let html_parse_time = html_parse.elapsed();
904
905 self.pending_css_urls = parsed.style_links;
906 self.pending_images = parsed.image_sources;
907 self.pending_audio = parsed.audio_sources;
908 self.classic_scripts = parsed.scripts;
909 self.next_script_index = 0;
910 self.pending_script_fetches.clear();
911 self.non_blocking_scripts_scheduled = false;
912 self.deferred_script_results.clear();
913 self.next_deferred_script_index = 0;
914 self.css_results_expected = self.pending_css_urls.len();
915
916 let mut initial_js_tasks = 0;
917 let mut initial_js_dom_ids = HashMap::new();
918 #[cfg(any(feature = "profile", debug_assertions))]
919 let mut js_snapshot_time = std::time::Duration::ZERO;
920 self.js_processor = if self.js_policy == JsPolicy::Enabled {
921 perf_scope!(js_snapshot);
922 let (snapshot, dom_ids) = js_snapshot_from_tree(&parsed.dom);
923 #[cfg(any(feature = "profile", debug_assertions))]
924 {
925 js_snapshot_time = js_snapshot.elapsed();
926 }
927 initial_js_dom_ids = dom_ids;
928 let processor = JsProcessor::new(snapshot);
929 processor.send(JsTask::SetDocumentUrl {
930 url: parsed.document_url.to_string(),
931 });
932 processor.send(JsTask::SetOrigin {
933 origin: Origin::from_url(&parsed.document_url).ascii_serialization(),
934 });
935 processor.send(JsTask::SetPermissions {
936 permissions: self.page_permissions_for(&parsed.document_url),
937 });
938 processor.send(JsTask::SetViewport {
939 width: self.viewport.0,
940 height: self.viewport.1,
941 });
942 processor.send(JsTask::SetLanguage {
943 language: locale::preferred_language(),
944 });
945 initial_js_tasks = 5;
946 Some(processor)
947 } else {
948 None
949 };
950 self.pending_js_tasks = initial_js_tasks;
951 self.js_dom_ids = initial_js_dom_ids;
952 self.js_dom_dirty = false;
953 self.in_flight_timer_version = None;
954 self.window_load_dispatched = false;
955 self.pending_js_fetches.clear();
956 self.pending_devtools_requests.clear();
957 self.reset_js_trace_buffer();
958 self.sync_initial_js_tracing();
959 self.inspector_ids.borrow_mut().clear();
960 self.pending_dynamic_scripts.clear();
961 self.pending_dynamic_styles.clear();
962 self.pending_dynamic_images.clear();
963 self.pending_iframe_fetches.clear();
964 self.iframe_content.clear();
965
966 let css_base_url = parsed.base_url.clone();
967 let docment_info = DocumentInfo {
968 origin: Origin::from_url(&parsed.document_url),
969 document_url: parsed.document_url,
970 base_url: parsed.base_url,
971 dom: parsed.dom,
972 title: parsed.title,
973 };
974 self.docment_info = Some(docment_info);
975 self.snapshot_cache = None;
976
977 for inline_css in &parsed.inline_styles {
978 self.queue_css_images(inline_css, &css_base_url);
979 let sheet = CssParser::new(inline_css).parse_lossy();
980 let resolved = layouter::css_resolver::CssResolver::resolve_stylesheet(&sheet);
981 layouter::css_resolver::append_resolved_styles(
982 Arc::make_mut(&mut self.resolved_styles),
983 resolved.declarations,
984 );
985 Arc::make_mut(&mut self.resolved_keyframes).extend(resolved.keyframes);
986 self.resolved_styles_version += 1;
987 }
988 self.phase = PagePhase::HtmlParsed;
989 profile_log!(
990 target: "PageLoad",
991 log::Level::Info,
992 "[HtmlParse] html_parse: {:?} | js_snapshot: {:?}",
993 html_parse_time,
994 js_snapshot_time,
995 );
996 }
997
998 pub fn on_css_fetched(&mut self, css: String) {
999 let base_url = self
1000 .docment_info
1001 .as_ref()
1002 .map(|info| info.base_url.clone())
1003 .unwrap_or_else(|| Url::parse("about:blank").expect("valid fallback URL"));
1004 self.on_css_fetched_from(css, &base_url);
1005 }
1006
1007 pub fn on_css_fetched_from(&mut self, css: String, stylesheet_url: &Url) {
1008 self.accept_stylesheet(stylesheet_url, css, Some(CssSink::External));
1012 }
1013
1014 fn accept_stylesheet(&mut self, url: &Url, css: String, sink: Option<CssSink>) {
1023 if let Some(existing) = self.css_sheets.get_mut(url) {
1024 match existing {
1029 CssSheetState::Applied {
1030 text,
1031 sink: current,
1032 } if sink.is_some() => {
1033 *current = sink;
1034 if let Some(sink) = sink {
1035 let text = text.clone();
1036 self.apply_stylesheet(text, sink);
1037 }
1038 }
1039 CssSheetState::Deferred { sink: current, .. } if current.is_none() => {
1040 *current = sink;
1041 }
1042 _ => {}
1043 }
1044 return;
1045 }
1046
1047 self.queue_css_images(&css, url);
1048 let (imports, body) = split_css_imports(&css);
1049 if imports.is_empty() {
1050 self.css_sheets.insert(
1051 url.clone(),
1052 CssSheetState::Applied {
1053 text: css.clone(),
1054 sink,
1055 },
1056 );
1057 if let Some(sink) = sink {
1058 self.apply_stylesheet(css, sink);
1059 }
1060 return;
1061 }
1062
1063 let mut entries = Vec::new();
1064 for raw in imports {
1065 let Ok(target) = resolve_url(url, &raw) else {
1066 continue;
1067 };
1068 if target == *url {
1071 continue;
1072 }
1073 let text = match self.css_sheets.get(&target) {
1074 Some(CssSheetState::Applied { text, .. }) => Some(text.clone()),
1076 Some(CssSheetState::Deferred { .. }) => Some(String::new()),
1080 _ => None,
1081 };
1082 if text.is_none() && self.css_import_fetches.insert(target.clone()) {
1083 self.pending_css_imports.push((url.clone(), target.clone()));
1084 }
1085 entries.push(CssImportEntry { url: target, text });
1086 }
1087
1088 self.css_sheets.insert(
1089 url.clone(),
1090 CssSheetState::Deferred {
1091 source: body,
1092 entries,
1093 sink,
1094 },
1095 );
1096 self.settle_css_imports();
1097 }
1098
1099 pub fn on_css_import_fetched(&mut self, source: String, url: &Url) {
1101 self.accept_stylesheet(url, source, None);
1102 self.settle_css_imports();
1103 }
1104
1105 pub fn on_css_import_fetch_failed(&mut self, url: &Url) {
1108 self.css_import_fetches.remove(url);
1109 self.css_sheets.insert(
1110 url.clone(),
1111 CssSheetState::Applied {
1112 text: String::new(),
1113 sink: None,
1114 },
1115 );
1116 self.settle_css_imports();
1117 }
1118
1119 fn schedule_css_imports(&mut self, tasks: &mut Vec<WebViewTask>) {
1120 for (_, target) in std::mem::take(&mut self.pending_css_imports) {
1121 tasks.push(WebViewTask::Fetch {
1122 url: target.clone(),
1123 kind: FetchKind::CssImport { target },
1124 });
1125 }
1126 }
1127
1128 fn settle_css_imports(&mut self) {
1132 loop {
1133 let mut ready: Vec<(Url, String, Option<CssSink>)> = Vec::new();
1134 for (url, state) in &self.css_sheets {
1135 let CssSheetState::Deferred {
1136 source,
1137 entries,
1138 sink,
1139 } = state
1140 else {
1141 continue;
1142 };
1143 let mut text = String::new();
1144 let mut done = true;
1145 for entry in entries {
1146 let resolved = match &entry.text {
1147 Some(text) => text.clone(),
1148 None => match self.css_sheets.get(&entry.url) {
1149 Some(CssSheetState::Applied { text, .. }) => text.clone(),
1150 _ => {
1151 done = false;
1152 break;
1153 }
1154 },
1155 };
1156 text.push_str(&resolved);
1157 }
1158 if done {
1159 text.push_str(source);
1160 ready.push((url.clone(), text, *sink));
1161 }
1162 }
1163
1164 if ready.is_empty() {
1165 break;
1166 }
1167 for (url, text, sink) in ready {
1168 self.css_import_fetches.remove(&url);
1169 self.css_sheets.insert(
1170 url.clone(),
1171 CssSheetState::Applied {
1172 text: text.clone(),
1173 sink,
1174 },
1175 );
1176 if let Some(sink) = sink {
1177 self.apply_stylesheet(text, sink);
1178 }
1179 }
1180 }
1181 }
1182
1183 fn apply_stylesheet(&mut self, css: String, sink: CssSink) {
1184 match sink {
1185 CssSink::External => {
1186 self.linked_css.push(css.clone());
1187 match self.css_strategy {
1188 CssApplicationStrategy::Batch => {
1189 self.loaded_css.push(css);
1190
1191 if self.loaded_css.len() == self.pending_css_urls.len() {
1192 let all_css = std::mem::take(&mut self.loaded_css);
1193 self.css_results_expected = 1;
1194 self.css_results_received = 0;
1195 self.css_processor.process(all_css);
1196 self.phase = PagePhase::CssProcessing;
1197 }
1198 }
1199 CssApplicationStrategy::Incremental => {
1200 self.css_processor.process(vec![css]);
1201 }
1202 }
1203 }
1204 CssSink::Dynamic(node_id) => {
1205 self.linked_css.push(css);
1206 self.rebuild_styles_and_layout();
1207 self.needs_redraw = true;
1208 self.dispatch_js_element_event(node_id, "load");
1209 }
1210 }
1211 }
1212
1213 pub fn on_image_fetched(&mut self, source: String, bytes: &[u8]) -> anyhow::Result<()> {
1215 self.image_decoder.decode(source, bytes.to_vec());
1216 Ok(())
1217 }
1218
1219 pub fn on_audio_fetched(&mut self, source: String, bytes: &[u8]) {
1221 self.audio.insert(source, Arc::from(bytes));
1222 self.update_layout();
1223 }
1224
1225 pub fn on_script_fetched(&mut self, index: usize, source: String) {
1227 let Some(execution) = self.pending_script_fetches.get(&index).copied() else {
1228 log::warn!("Ignoring unexpected classic script response at index {index}");
1229 return;
1230 };
1231
1232 if execution == ClassicScriptExecution::Default && self.next_script_index != index {
1233 log::warn!("Ignoring out-of-order blocking script response at index {index}");
1234 return;
1235 }
1236 self.pending_script_fetches.remove(&index);
1237 let label = self
1238 .classic_scripts
1239 .get(index)
1240 .and_then(ClassicScript::label);
1241
1242 match execution {
1243 ClassicScriptExecution::Default => {
1244 self.next_script_index += 1;
1245 self.send_script(&source, label.as_deref());
1246 }
1247 ClassicScriptExecution::Defer => {
1248 self.deferred_script_results
1249 .insert(index, (Some(source), label));
1250 }
1251 ClassicScriptExecution::Async => self.send_script(&source, label.as_deref()),
1252 }
1253 }
1254
1255 pub fn on_script_fetch_failed(&mut self, index: usize) {
1257 let Some(execution) = self.pending_script_fetches.get(&index).copied() else {
1258 log::warn!("Ignoring unexpected classic script failure at index {index}");
1259 return;
1260 };
1261
1262 if execution == ClassicScriptExecution::Default && self.next_script_index != index {
1263 log::warn!("Ignoring out-of-order blocking script failure at index {index}");
1264 return;
1265 }
1266 self.pending_script_fetches.remove(&index);
1267
1268 match execution {
1269 ClassicScriptExecution::Default => self.next_script_index += 1,
1270 ClassicScriptExecution::Defer => {
1271 self.deferred_script_results.insert(index, (None, None));
1272 }
1273 ClassicScriptExecution::Async => {}
1274 }
1275 }
1276
1277 pub fn on_dynamic_script_fetched(&mut self, node_id: u64, source: String, url: &Url) {
1279 self.send_script(&source, Some(url.as_str()));
1280 self.dispatch_js_element_event(node_id, "load");
1281 }
1282
1283 pub fn on_dynamic_script_fetch_failed(&mut self, node_id: u64) {
1285 self.dispatch_js_element_event(node_id, "error");
1286 }
1287
1288 pub fn on_dynamic_style_fetched(&mut self, node_id: u64, source: String, stylesheet_url: &Url) {
1289 self.accept_stylesheet(stylesheet_url, source, Some(CssSink::Dynamic(node_id)));
1292 }
1293
1294 pub fn on_dynamic_style_fetch_failed(&mut self, node_id: u64) {
1295 self.dispatch_js_element_event(node_id, "error");
1296 }
1297
1298 pub fn on_js_fetch_succeeded(&mut self, request_id: u64, response: JsFetchResponse) {
1300 if let Some(processor) = self.js_processor.as_ref() {
1301 processor.send(JsTask::ResolveFetch {
1302 id: request_id,
1303 response,
1304 });
1305 self.pending_js_tasks += 1;
1306 }
1307 }
1308
1309 pub fn on_js_fetch_failed(&mut self, request_id: u64, reason: String) {
1311 if let Some(processor) = self.js_processor.as_ref() {
1312 processor.send(JsTask::RejectFetch {
1313 id: request_id,
1314 reason,
1315 });
1316 self.pending_js_tasks += 1;
1317 }
1318 }
1319
1320 pub fn on_iframe_fetched(&mut self, dom_id: u64, html: String, final_url: Url) {
1323 if let Some(processor) = self.js_processor.as_ref() {
1324 processor.send(JsTask::ResolveIframe {
1325 dom_id,
1326 html,
1327 url: final_url.to_string(),
1328 });
1329 self.pending_js_tasks += 1;
1330 }
1331 }
1332
1333 pub fn on_iframe_fetch_failed(&mut self, dom_id: u64) {
1336 if let Some(processor) = self.js_processor.as_ref() {
1337 processor.send(JsTask::RejectIframe { dom_id });
1338 self.pending_js_tasks += 1;
1339 }
1340 }
1341
1342 pub fn on_devtools_response(&mut self, id: u64, result: String) {
1344 if let Some(processor) = self.js_processor.as_ref() {
1345 processor.send(JsTask::ResolveDevTools { id, result });
1346 self.pending_js_tasks += 1;
1347 }
1348 }
1349
1350 pub(crate) fn inspect(
1352 &mut self,
1353 method: &str,
1354 params: &str,
1355 ) -> Result<serde_json::Value, String> {
1356 if method == "evaluate" {
1357 let parsed: serde_json::Value =
1358 serde_json::from_str(params).map_err(|error| error.to_string())?;
1359 let id = parsed
1360 .get("id")
1361 .and_then(serde_json::Value::as_u64)
1362 .ok_or_else(|| "params must include an integer id".to_string())?;
1363 let source = parsed
1364 .get("source")
1365 .and_then(serde_json::Value::as_str)
1366 .ok_or_else(|| "params must include a source string".to_string())?;
1367 let processor = self
1368 .js_processor
1369 .as_ref()
1370 .ok_or_else(|| "page JavaScript runtime is not ready".to_string())?;
1371 processor.send(JsTask::EvaluateConsole {
1372 id,
1373 source: source.to_string(),
1374 });
1375 self.pending_js_tasks += 1;
1376 return Ok(serde_json::json!({"id": id, "pending": true}));
1377 }
1378 inspector::handle(self, method, params)
1379 }
1380
1381 pub fn update_page(&mut self) {
1385 self.ensure_text_measurer();
1386 if let Some(doc_info) = self.docment_info.as_mut() {
1390 doc_info.dom.mark_dirty();
1391 }
1392 self.update_layout();
1393 self.js_dom_dirty = true;
1395 }
1396
1397 fn apply_resolved_styles_and_relayout(
1398 &mut self,
1399 resolved: layouter::css_resolver::StylesheetResolution,
1400 ) {
1401 layouter::css_resolver::append_resolved_styles(
1402 Arc::make_mut(&mut self.resolved_styles),
1403 resolved.declarations,
1404 );
1405 Arc::make_mut(&mut self.resolved_keyframes).extend(resolved.keyframes);
1406 self.resolved_styles_version += 1;
1407 self.update_layout();
1408 }
1409
1410 fn rebuild_styles_and_layout(&mut self) {
1411 let Some(document) = self.docment_info.as_ref() else {
1412 self.update_layout();
1413 return;
1414 };
1415 perf_scope!(resolve_styles);
1416 let mut resolved = layouter::css_resolver::CssResolver::resolve_stylesheet_with_origin(
1417 &CssParser::new(USER_AGENT_CSS).parse().unwrap(),
1418 layouter::css_resolver::StyleOrigin::UserAgent,
1419 );
1420 let mut stylesheet_count = 1;
1421 for source in &self.linked_css {
1422 let sheet = CssParser::new(source).parse_lossy();
1423 resolved.append(layouter::css_resolver::CssResolver::resolve_stylesheet(
1424 &sheet,
1425 ));
1426 stylesheet_count += 1;
1427 }
1428 for source in document.dom.collect_text_by_tag("style") {
1429 let sheet = CssParser::new(&source).parse_lossy();
1430 resolved.append(layouter::css_resolver::CssResolver::resolve_stylesheet(
1431 &sheet,
1432 ));
1433 stylesheet_count += 1;
1434 }
1435 #[cfg(any(feature = "profile", debug_assertions))]
1436 let resolve_styles_time = resolve_styles.elapsed();
1437 profile_log!(
1438 target: "PageLoad",
1439 log::Level::Info,
1440 "[StyleResolve] stylesheet_resolve: {:?} (sheets: {})",
1441 resolve_styles_time,
1442 stylesheet_count,
1443 );
1444 self.resolved_styles = Arc::new(resolved.declarations);
1445 self.resolved_keyframes = Arc::new(resolved.keyframes);
1446 self.resolved_styles_version += 1;
1447 self.update_layout();
1448 }
1449
1450 fn try_apply_css_results(&mut self) {
1451 while let Some(resolved) = self.css_processor.try_receive() {
1452 self.css_results_received += 1;
1453 self.apply_resolved_styles_and_relayout(resolved);
1454 self.needs_redraw = true;
1455
1456 if self.css_results_received >= self.css_results_expected {
1457 self.fragment_ready_version = Some(self.layout_requested_version);
1458 self.phase = PagePhase::CssApplied;
1459 }
1460 }
1461 }
1462
1463 fn try_apply_batch_result(&mut self) {
1464 if let Some(resolved) = self.css_processor.try_receive() {
1465 self.css_results_received += 1;
1466 self.apply_resolved_styles_and_relayout(resolved);
1467 self.needs_redraw = true;
1468 self.fragment_ready_version = Some(self.layout_requested_version);
1469 self.phase = PagePhase::CssApplied;
1470 }
1471 }
1472
1473 fn advance_classic_scripts(&mut self, tasks: &mut Vec<WebViewTask>) {
1474 if self.js_policy == JsPolicy::Disabled {
1475 self.phase = PagePhase::ScriptApplied;
1476 return;
1477 }
1478
1479 self.schedule_non_blocking_scripts(tasks);
1480
1481 while self.next_script_index < self.classic_scripts.len() {
1482 let script = self.classic_scripts[self.next_script_index].clone();
1483 let label = script.label();
1484 match script {
1485 ClassicScript::Inline(source) => {
1486 self.next_script_index += 1;
1487 self.send_script(&source, label.as_deref());
1488 }
1489 ClassicScript::External { url, execution } => {
1490 if execution != ClassicScriptExecution::Default {
1491 self.next_script_index += 1;
1492 continue;
1493 }
1494
1495 let index = self.next_script_index;
1496 if let std::collections::hash_map::Entry::Vacant(entry) =
1497 self.pending_script_fetches.entry(index)
1498 {
1499 entry.insert(ClassicScriptExecution::Default);
1500 tasks.push(WebViewTask::Fetch {
1501 url,
1502 kind: FetchKind::Script { index },
1503 });
1504 }
1505 return;
1506 }
1507 }
1508 }
1509
1510 self.advance_deferred_scripts();
1511 }
1512
1513 fn schedule_non_blocking_scripts(&mut self, tasks: &mut Vec<WebViewTask>) {
1514 if self.non_blocking_scripts_scheduled {
1515 return;
1516 }
1517 self.non_blocking_scripts_scheduled = true;
1518
1519 for (index, script) in self.classic_scripts.iter().enumerate() {
1520 let ClassicScript::External { url, execution } = script else {
1521 continue;
1522 };
1523 if *execution == ClassicScriptExecution::Default {
1524 continue;
1525 }
1526
1527 self.pending_script_fetches.insert(index, *execution);
1528 tasks.push(WebViewTask::Fetch {
1529 url: url.clone(),
1530 kind: FetchKind::Script { index },
1531 });
1532 }
1533 }
1534
1535 fn advance_deferred_scripts(&mut self) {
1536 loop {
1537 let Some(index) =
1538 (self.next_deferred_script_index..self.classic_scripts.len()).find(|&index| {
1539 matches!(
1540 self.classic_scripts.get(index),
1541 Some(ClassicScript::External {
1542 execution: ClassicScriptExecution::Defer,
1543 ..
1544 })
1545 )
1546 })
1547 else {
1548 self.dispatch_dom_content_loaded();
1549 self.phase = PagePhase::ScriptApplied;
1550 return;
1551 };
1552
1553 let Some((source_opt, label)) = self.deferred_script_results.remove(&index) else {
1554 return;
1555 };
1556 self.next_deferred_script_index = index + 1;
1557 if let Some(source) = source_opt {
1558 self.send_script(&source, label.as_deref());
1559 }
1560 }
1561 }
1562
1563 fn send_script(&mut self, source: &str, label: Option<&str>) {
1565 if let Some(processor) = self.js_processor.as_ref() {
1566 processor.send(JsTask::RunScript {
1567 source: source.to_string(),
1568 label: label.map(str::to_string),
1569 });
1570 self.pending_js_tasks += 1;
1571 }
1572 }
1573
1574 fn dispatch_js_element_event(&mut self, dom_id: u64, event_type: &str) {
1575 if let Some(processor) = self.js_processor.as_ref() {
1576 processor.send(JsTask::DispatchElementEvent {
1577 dom_id,
1578 event_type: event_type.to_string(),
1579 });
1580 self.pending_js_tasks += 1;
1581 }
1582 }
1583
1584 fn dispatch_dom_content_loaded(&mut self) {
1585 if let Some(processor) = self.js_processor.as_ref() {
1586 processor.send(JsTask::DispatchDomContentLoaded);
1587 self.pending_js_tasks += 1;
1588 }
1589 }
1590
1591 fn dispatch_window_load(&mut self) {
1592 if let Some(processor) = self.js_processor.as_ref() {
1593 processor.send(JsTask::DispatchWindowLoad);
1594 self.pending_js_tasks += 1;
1595 self.window_load_dispatched = true;
1596 }
1597 }
1598
1599 fn has_pending_subresource_work(&self) -> bool {
1605 !self.pending_script_fetches.is_empty()
1606 || !self.pending_js_fetches.is_empty()
1607 || !self.pending_iframe_fetches.is_empty()
1608 || !self.pending_dynamic_scripts.is_empty()
1609 || !self.pending_dynamic_styles.is_empty()
1610 || !self.pending_dynamic_images.is_empty()
1611 }
1612
1613 fn run_due_js_timers(&mut self) {
1614 if self.js_dom_dirty || self.in_flight_timer_version.is_some() {
1615 return;
1618 }
1619 if let Some(processor) = self.js_processor.as_ref() {
1620 let version = processor.send(JsTask::RunTimers);
1621 self.in_flight_timer_version = Some(version);
1622 }
1623 }
1624
1625 fn schedule_js_fetches(&mut self, tasks: &mut Vec<WebViewTask>) {
1626 let requests = std::mem::take(&mut self.pending_js_fetches);
1627
1628 for request in requests {
1629 match self.resolve_url(&request.url) {
1630 Ok(url) => {
1631 if let Some(cb) = &self.on_fetch {
1632 cb(
1633 &url,
1634 FetchKind::JavaScript {
1635 request_id: request.id,
1636 method: request.method.clone(),
1637 headers: request.headers.clone(),
1638 body: request.body.clone(),
1639 },
1640 );
1641 }
1642 tasks.push(WebViewTask::Fetch {
1643 url,
1644 kind: FetchKind::JavaScript {
1645 request_id: request.id,
1646 method: request.method,
1647 headers: request.headers,
1648 body: request.body,
1649 },
1650 });
1651 }
1652 Err(error) => self
1653 .on_js_fetch_failed(request.id, format!("Failed to parse fetch URL: {error}")),
1654 }
1655 }
1656 }
1657
1658 fn schedule_navigations(&mut self, tasks: &mut Vec<WebViewTask>) {
1661 let requests = std::mem::take(&mut self.pending_navigations);
1662 for url_text in requests {
1663 match self.resolve_url(&url_text) {
1664 Ok(url) => {
1665 log::info!("Script navigation requested: {url}");
1666 if let Some(cb) = &self.on_navigation {
1667 cb(&url);
1668 }
1669 tasks.push(WebViewTask::Navigate { url });
1670 }
1671 Err(error) => {
1672 log::warn!("Dropping script navigation to {url_text}: {error}");
1673 }
1674 }
1675 }
1676 }
1677
1678 fn schedule_iframe_fetches(&mut self, tasks: &mut Vec<WebViewTask>) {
1679 let requests = std::mem::take(&mut self.pending_iframe_fetches);
1680
1681 for request in requests {
1682 match Url::parse(&request.url) {
1683 Ok(url) => {
1684 log::info!("Iframe fetch requested in WebView: url={}", url);
1685 if let Some(cb) = &self.on_fetch {
1686 cb(
1687 &url,
1688 FetchKind::Iframe {
1689 dom_id: request.dom_id,
1690 },
1691 );
1692 }
1693 tasks.push(WebViewTask::Fetch {
1694 url,
1695 kind: FetchKind::Iframe {
1696 dom_id: request.dom_id,
1697 },
1698 })
1699 }
1700 Err(error) => {
1701 log::warn!("Failed to parse iframe URL: {error}");
1702 self.on_iframe_fetch_failed(request.dom_id);
1703 }
1704 }
1705 }
1706 }
1707
1708 fn schedule_dynamic_scripts(&mut self, tasks: &mut Vec<WebViewTask>) {
1709 let requests = std::mem::take(&mut self.pending_dynamic_scripts);
1710
1711 for request in requests {
1712 match request.source {
1713 JsDynamicScriptSource::Inline(source) => {
1714 let label = format!("dynamic inline script #{}", request.node_id);
1715 self.send_script(&source, Some(&label));
1716 self.dispatch_js_element_event(request.node_id, "load");
1717 }
1718 JsDynamicScriptSource::External(source) => match self.resolve_url(&source) {
1719 Ok(url) => {
1720 if let Some(cb) = &self.on_fetch {
1721 cb(
1722 &url,
1723 FetchKind::DynamicScript {
1724 node_id: request.node_id,
1725 },
1726 );
1727 }
1728 tasks.push(WebViewTask::Fetch {
1729 url,
1730 kind: FetchKind::DynamicScript {
1731 node_id: request.node_id,
1732 },
1733 });
1734 }
1735 Err(error) => {
1736 log::warn!("Failed to resolve dynamic script URL: {error}");
1737 self.on_dynamic_script_fetch_failed(request.node_id);
1738 }
1739 },
1740 }
1741 }
1742 }
1743
1744 fn schedule_dynamic_styles(&mut self, tasks: &mut Vec<WebViewTask>) {
1745 let requests = std::mem::take(&mut self.pending_dynamic_styles);
1746
1747 for request in requests {
1748 match self.resolve_url(&request.url) {
1749 Ok(url) => {
1750 if let Some(cb) = &self.on_fetch {
1751 cb(
1752 &url,
1753 FetchKind::DynamicCss {
1754 node_id: request.node_id,
1755 },
1756 );
1757 }
1758 tasks.push(WebViewTask::Fetch {
1759 url,
1760 kind: FetchKind::DynamicCss {
1761 node_id: request.node_id,
1762 },
1763 });
1764 }
1765 Err(error) => {
1766 log::warn!("Failed to resolve dynamic stylesheet URL: {error}");
1767 self.on_dynamic_style_fetch_failed(request.node_id);
1768 }
1769 }
1770 }
1771 }
1772
1773 fn schedule_dynamic_images(&mut self, tasks: &mut Vec<WebViewTask>) {
1774 let requests = std::mem::take(&mut self.pending_dynamic_images);
1775
1776 for request in requests {
1777 match self.resolve_url(&request.source) {
1778 Ok(url) => {
1779 if let Some(cb) = &self.on_fetch {
1780 cb(
1781 &url,
1782 FetchKind::Image {
1783 source: request.source.clone(),
1784 },
1785 );
1786 }
1787 tasks.push(WebViewTask::Fetch {
1788 url,
1789 kind: FetchKind::Image {
1790 source: request.source,
1791 },
1792 });
1793 }
1794 Err(error) => log::warn!("Failed to resolve dynamic image URL: {error}"),
1795 }
1796 }
1797 }
1798
1799 fn queue_css_images(&mut self, css: &str, base_url: &Url) {
1800 for source in collect_css_image_sources(css) {
1801 if self.images.contains_key(&source)
1802 || self
1803 .pending_images
1804 .iter()
1805 .any(|(pending, _)| pending == &source)
1806 {
1807 continue;
1808 }
1809 if let Ok(url) = resolve_url(base_url, &source) {
1810 self.pending_images.push((source, url));
1811 }
1812 }
1813 }
1814
1815 fn schedule_pending_images(&mut self, tasks: &mut Vec<WebViewTask>) {
1816 for (source, url) in std::mem::take(&mut self.pending_images) {
1817 tasks.push(WebViewTask::Fetch {
1818 url,
1819 kind: FetchKind::Image { source },
1820 });
1821 }
1822 }
1823
1824 pub fn on_js_click(&mut self, dom_id: u32) -> bool {
1830 let Some(processor) = self.js_processor.as_ref() else {
1831 return false;
1832 };
1833 let Some(node) = self
1834 .layout_dom_refs
1835 .get(dom_id as usize)
1836 .and_then(|weak| weak.upgrade())
1837 else {
1838 return false;
1839 };
1840 let Some(js_dom_id) = self.js_dom_ids.get(&(Rc::as_ptr(&node) as usize)) else {
1841 return false;
1842 };
1843 processor.send(JsTask::Click { dom_id: *js_dom_id });
1844 self.pending_js_tasks += 1;
1845 false
1846 }
1847
1848 pub fn on_js_scroll(&mut self, dom_id: u32) -> bool {
1855 let Some(processor) = self.js_processor.as_ref() else {
1856 return false;
1857 };
1858 let Some(node) = self
1859 .layout_dom_refs
1860 .get(dom_id as usize)
1861 .and_then(|weak| weak.upgrade())
1862 else {
1863 return false;
1864 };
1865 let Some(js_dom_id) = self.js_dom_ids.get(&(Rc::as_ptr(&node) as usize)) else {
1866 return false;
1867 };
1868 processor.send(JsTask::Scroll { dom_id: *js_dom_id });
1869 self.pending_js_tasks += 1;
1870 false
1871 }
1872
1873 fn ensure_text_measurer(&mut self) {
1874 if self.text_measurer.is_none() {
1875 self.text_measurer = Some(Arc::new(PlatformTextMeasurer::new().unwrap()));
1876 }
1877 }
1878
1879 pub fn set_text_measurer(&mut self, measurer: Option<Arc<PlatformTextMeasurer>>) {
1887 self.text_measurer = measurer;
1888 }
1889
1890 pub fn text_measurer(&self) -> Option<&Arc<PlatformTextMeasurer>> {
1892 self.text_measurer.as_ref()
1893 }
1894
1895 fn update_layout(&mut self) {
1897 if self.docment_info.is_none() {
1898 return;
1899 }
1900 self.ensure_text_measurer();
1901
1902 let doc_info = self.docment_info.as_ref().unwrap();
1903 let dom_version = doc_info.dom.version();
1904 #[cfg(any(feature = "profile", debug_assertions))]
1907 let mut snapshot_build_time = std::time::Duration::ZERO;
1908 #[cfg(any(feature = "profile", debug_assertions))]
1909 let mut snapshot_cached = false;
1910
1911 let (snapshot, dom_refs) = if let Some(cache) = &self.snapshot_cache
1912 && cache.dom_version == dom_version
1915 {
1916 #[cfg(any(feature = "profile", debug_assertions))]
1917 {
1918 snapshot_cached = true;
1919 }
1920 (Arc::clone(&cache.snapshot), cache.dom_refs.clone())
1921 } else {
1922 perf_scope!(snapshot_build);
1923 let (snapshot, dom_refs) = DomSnapshot::from_tree(&doc_info.dom.root);
1924 #[cfg(any(feature = "profile", debug_assertions))]
1925 {
1926 snapshot_build_time = snapshot_build.elapsed();
1927 }
1928 let snapshot = Arc::new(snapshot);
1929 self.snapshot_cache = Some(SnapshotCache {
1930 dom_version,
1931 snapshot: Arc::clone(&snapshot),
1932 dom_refs: dom_refs.clone(),
1933 });
1934 (snapshot, dom_refs)
1935 };
1936 profile_log!(
1937 target: "PageLoad",
1938 log::Level::Info,
1939 "[DomSnapshot] build: {:?} (cache hit: {})",
1940 snapshot_build_time,
1941 snapshot_cached,
1942 );
1943 let root = snapshot.roots()[0];
1944
1945 let media_environment =
1946 layouter::css_resolver::MediaEnvironment::new(self.viewport, self.system_color_scheme);
1947 let existing_custom_nodes = self.collect_custom_nodes();
1948 let task = layouter::LayoutTask {
1949 snapshot,
1950 root,
1951 resolved_styles: Arc::clone(&self.resolved_styles),
1952 media_environment,
1953 measurer: self.text_measurer.clone().unwrap(),
1954 audio_sinks: Arc::clone(&self.audio_sinks),
1955 system_color_scheme: self.system_color_scheme,
1956 scripting_mode: self.js_policy.into(),
1957 images: self.images.clone(),
1958 audio: self.audio.clone(),
1959 parent: InheritedCss {
1960 text_flow_style: TextFlowStyle {
1961 font_size: 16.0,
1962 ..Default::default()
1963 },
1964 ..Default::default()
1965 },
1966 chain: ElementChain::default(),
1967 write_back_sender: Some(self.write_back_tx.clone()),
1968 styles_version: self.resolved_styles_version,
1969 dom_version,
1970 version: 0,
1971 interaction: Arc::clone(&self.interaction_state),
1972 interaction_version: self.interaction_version,
1973 existing_custom_nodes,
1974 animated_overrides: self.animated_overrides.clone(),
1975 };
1976 self.layout_dom_refs = dom_refs;
1977 self.layout_requested_version = self.layout_processor.send(task);
1978 self.layout_pending = true;
1979 }
1980
1981 fn collect_custom_nodes(
1982 &self,
1983 ) -> std::collections::HashMap<u32, Arc<dyn crate::engine::ui::custom_node::CustomNode>> {
1984 fn walk(
1985 info: &InfoNode,
1986 out: &mut std::collections::HashMap<
1987 u32,
1988 Arc<dyn crate::engine::ui::custom_node::CustomNode>,
1989 >,
1990 ) {
1991 if let NodeKind::Custom { node, .. } = &info.kind
1992 && let Some(dom_id) = info.dom_id
1993 {
1994 out.entry(dom_id).or_insert_with(|| Arc::clone(node));
1995 }
1996 for child in &info.children {
1997 walk(child, out);
1998 }
1999 }
2000 let mut out = std::collections::HashMap::new();
2001 if let Some((_, info)) = &self.layout_and_info {
2002 walk(info, &mut out);
2003 }
2004 out
2005 }
2006
2007 fn try_apply_decoded_images(&mut self) {
2009 while let Some((source, result)) = self.image_decoder.try_receive() {
2010 match result {
2011 Ok(image) => {
2012 self.images.insert(source, image);
2013 self.update_layout();
2014 }
2015 Err(error) => {
2016 log::warn!("Image decode failed: {error:#}");
2017 }
2018 }
2019 }
2020 }
2021
2022 fn try_apply_layout_results(&mut self) {
2024 while let Some(result) = self.layout_processor.try_receive() {
2025 let LayoutResult {
2026 layout,
2027 mut info,
2028 animation_inputs,
2029 version,
2030 } = result;
2031 if version < self.layout_requested_version {
2032 continue;
2033 }
2034 if let Some((_, old_info)) = self.layout_and_info.as_ref() {
2039 let mut scroll_offsets = HashMap::new();
2040 capture_scroll_offsets(old_info, &mut scroll_offsets);
2041 apply_scroll_offsets(&mut info, &scroll_offsets);
2042 }
2043
2044 self.layout_and_info = Some((layout, info));
2045 self.latest_animation_inputs = animation_inputs;
2046 self.layout_applied_version = version;
2047 self.layout_pending = false;
2048 self.needs_redraw = true;
2049 self.position_layout_if_needed();
2053 }
2054 }
2055
2056 fn try_apply_js_results(&mut self) {
2062 let results: Vec<JsTaskResult> = {
2063 let Some(processor) = self.js_processor.as_ref() else {
2064 return;
2065 };
2066 let mut results = Vec::new();
2067 while let Some(result) = processor.try_receive() {
2068 results.push(result);
2069 }
2070 results
2071 };
2072 for result in results {
2073 self.pending_js_tasks = self.pending_js_tasks.saturating_sub(1);
2074 self.pending_js_fetches.extend(result.fetch_requests);
2075 self.pending_devtools_requests
2076 .extend(result.devtools_requests);
2077 let trace = result.js_trace;
2078 self.js_trace_total = trace.total;
2079 self.js_trace_dropped = trace.dropped;
2080 self.js_trace_active = trace.active;
2081 if !trace.events.is_empty() {
2082 self.js_trace_events.extend(trace.events);
2083 const MAX_UI_TRACE_EVENTS: usize = 1024;
2084 if self.js_trace_events.len() > MAX_UI_TRACE_EVENTS {
2085 let excess = self.js_trace_events.len() - MAX_UI_TRACE_EVENTS;
2086 self.js_trace_events.drain(..excess);
2087 }
2088 }
2089 self.js_console_results.extend(result.console_results);
2090 self.pending_dynamic_scripts
2091 .extend(result.dynamic_script_requests);
2092 self.pending_dynamic_styles
2093 .extend(result.dynamic_style_requests);
2094 self.pending_dynamic_images
2095 .extend(result.dynamic_image_requests);
2096 self.pending_iframe_fetches
2097 .extend(result.iframe_fetch_requests);
2098 self.pending_navigations.extend(result.navigation_requests);
2099
2100 if let Some(in_flight) = self.in_flight_timer_version
2101 && result.version >= in_flight
2102 {
2103 self.in_flight_timer_version = None;
2106 }
2107
2108 let Some(snapshot) = result.dom else {
2109 continue;
2110 };
2111 let Some(info) = self.docment_info.as_mut() else {
2112 continue;
2113 };
2114 let (tree, dom_ids) = snapshot.into_tree();
2118 self.iframe_content.clear();
2121 for iframe_doc in result.iframe_documents {
2122 self.iframe_content
2123 .insert(iframe_doc.iframe_dom_id, iframe_doc.content);
2124 }
2125 info.dom = Rc::new(tree);
2126 self.js_dom_ids = dom_ids;
2127 graft_iframe_documents(&info.dom, &self.js_dom_ids, &self.iframe_content);
2130 self.snapshot_cache = None;
2131 self.layout_dom_refs.clear();
2132 self.interaction_state = Arc::new(InteractionState::default());
2133 self.interaction_version = 0;
2134
2135 self.rebuild_styles_and_layout();
2136 self.needs_redraw = true;
2137 }
2138 }
2139
2140 fn sync_dom_to_worker(&mut self) {
2148 if !self.js_dom_dirty
2149 || self.pending_js_tasks != 0
2150 || self.in_flight_timer_version.is_some()
2151 {
2152 return;
2153 }
2154 let Some(processor) = self.js_processor.as_ref() else {
2155 return;
2156 };
2157 let Some(doc_info) = self.docment_info.as_ref() else {
2158 return;
2159 };
2160 let snapshot = DomSnapshot::from_mirror(&doc_info.dom.root, &self.js_dom_ids);
2161 processor.send(JsTask::UpdateDom { snapshot });
2162 self.pending_js_tasks += 1;
2163 self.js_dom_dirty = false;
2164 }
2165
2166 fn drain_write_backs(&mut self) {
2168 use crate::engine::ui::registry::DomWrite;
2169 let mut applied = false;
2170 while let Ok((node_id, write)) = self.write_back_rx.try_recv() {
2171 if let Some(weak) = self.layout_dom_refs.get(node_id as usize)
2172 && let Some(node) = weak.upgrade()
2173 {
2174 match write {
2175 DomWrite::Value(value) => node.borrow_mut().value.set_attr("value", value),
2176 DomWrite::Checked(checked) => {
2177 if checked
2178 && node.borrow().value.tag_name() == Some("input")
2179 && node
2180 .borrow()
2181 .value
2182 .get_attr("type")
2183 .is_some_and(|t| t.eq_ignore_ascii_case("radio"))
2184 {
2185 Self::select_radio_group(&node);
2186 } else {
2187 node.borrow().set_checkedness(checked);
2188 }
2189 }
2190 }
2191 applied = true;
2192 }
2193 self.needs_redraw = true;
2194 }
2195
2196 if applied {
2197 self.update_layout();
2198 }
2199 if applied && let Some(doc_info) = self.docment_info.as_mut() {
2202 doc_info.dom.mark_dirty();
2203 }
2204 if applied {
2207 self.js_dom_dirty = true;
2208 }
2209 }
2210
2211 fn select_radio_group(node: &NodeRef<HtmlNodeType>) {
2212 let name = node.borrow().value.get_attr("name").map(str::to_owned);
2213 if let Some(name) = name {
2214 let root = Self::tree_root_of(node);
2215 let mut stack = vec![root];
2216 while let Some(current) = stack.pop() {
2217 let children = current.borrow().children().to_vec();
2218 for child in children {
2219 let borrow = child.borrow();
2220 let is_radio = borrow.value.tag_name() == Some("input")
2221 && borrow
2222 .value
2223 .get_attr("type")
2224 .is_some_and(|t| t.eq_ignore_ascii_case("radio"))
2225 && borrow.value.get_attr("name") == Some(name.as_str());
2226 drop(borrow);
2227 if is_radio {
2228 child
2229 .borrow()
2230 .set_checkedness(std::rc::Rc::ptr_eq(&child, node));
2231 }
2232 stack.push(child);
2233 }
2234 }
2235 } else {
2236 node.borrow().set_checkedness(true);
2237 }
2238 }
2239
2240 fn tree_root_of(node: &NodeRef<HtmlNodeType>) -> NodeRef<HtmlNodeType> {
2241 let mut current = std::rc::Rc::clone(node);
2242 loop {
2243 let parent = current.borrow().parent();
2244 match parent {
2245 Some(p) => current = p,
2246 None => return current,
2247 }
2248 }
2249 }
2250
2251 pub fn cursor_at(&self, x: f32, y: f32) -> Option<CursorStyle> {
2260 let (layout, info) = self.layout_and_info.as_ref()?;
2261 let path = hit_test(layout, info, x, y);
2262
2263 let mut has_text_node = false;
2264 for hit in path.iter() {
2265 match &hit.info.kind {
2266 NodeKind::Text { .. } => {
2267 has_text_node = true;
2268 }
2269 NodeKind::Container { style, .. }
2270 | NodeKind::Custom { style, .. }
2271 | NodeKind::Svg { style, .. } => match &style.cursor {
2272 CursorStyle::Keyword(CursorKeyword::Auto) => continue,
2273 other => return Some(other.clone()),
2274 },
2275 _ => continue,
2276 }
2277 }
2278
2279 if has_text_node {
2280 Some(CursorStyle::Keyword(CursorKeyword::Text))
2281 } else {
2282 Some(CursorStyle::Keyword(CursorKeyword::Default))
2283 }
2284 }
2285
2286 pub fn navigate(&mut self) {
2287 self.reset_for_navigation();
2288 }
2289
2290 pub fn load_url(&mut self, url: &Url) {
2298 if let Some(cb) = &self.on_navigation {
2299 cb(url);
2300 } else {
2301 self.reset_for_navigation();
2304 }
2305 }
2306
2307 pub fn set_on_navigation<F>(&mut self, cb: F)
2309 where
2310 F: Fn(&Url) + 'static,
2311 {
2312 self.on_navigation = Some(Box::new(cb));
2313 }
2314
2315 pub fn set_on_fetch<F>(&mut self, cb: F)
2317 where
2318 F: Fn(&Url, FetchKind) + 'static,
2319 {
2320 self.on_fetch = Some(Box::new(cb));
2321 }
2322
2323 pub fn set_on_console<F>(&mut self, cb: F)
2325 where
2326 F: Fn(u32, &str) + 'static,
2327 {
2328 self.on_console = Some(Box::new(cb));
2329 }
2330
2331 pub fn set_on_inspect_request<F>(&mut self, cb: F)
2333 where
2334 F: Fn(u64, &str, &str) + 'static,
2335 {
2336 self.on_inspect_request = Some(Box::new(cb));
2337 }
2338
2339 fn reset_for_navigation(&mut self) {
2340 if self.phase != PagePhase::Init {
2341 self.phase = PagePhase::BeforeHtmlParsing;
2342 }
2343
2344 self.docment_info = None;
2345 self.pending_css_urls.clear();
2346 self.pending_css_imports.clear();
2347 self.css_sheets.clear();
2348 self.css_import_fetches.clear();
2349 self.pending_images.clear();
2350 self.pending_audio.clear();
2351 self.pending_navigations.clear();
2352 self.loaded_css.clear();
2353 self.linked_css.clear();
2354 self.images.clear();
2355 self.audio.clear();
2356 Arc::make_mut(&mut self.resolved_styles).clear();
2357 Arc::make_mut(&mut self.resolved_keyframes).clear();
2358 self.layout_and_info = None;
2359
2360 self.needs_redraw = false;
2361
2362 self.css_processor = css::processor::CssProcessor::new();
2363 self.css_results_expected = 0;
2364 self.css_results_received = 0;
2365
2366 self.layout_processor = layouter::LayoutProcessor::new();
2367 self.layout_pending = false;
2368 self.layout_requested_version = 0;
2369 self.layout_applied_version = 0;
2370 self.positioned_layout = None;
2371 self.layout_dom_refs.clear();
2372 self.interaction_state = Arc::new(InteractionState::default());
2373 self.interaction_version = 0;
2374 self.snapshot_cache = None;
2375
2376 self.animation_runtime.clear();
2377 self.animation_last_tick = None;
2378 self.animation_time_ms = 0.0;
2379 self.animated_overrides.clear();
2380 self.animation_visible = true;
2381 self.latest_animation_inputs.clear();
2382 self.js_processor = None;
2383 self.js_dom_ids.clear();
2384 self.pending_js_tasks = 0;
2385 self.js_dom_dirty = false;
2386 self.in_flight_timer_version = None;
2387 self.pending_js_fetches.clear();
2388 self.pending_devtools_requests.clear();
2389 self.reset_js_trace_buffer();
2390 self.inspector_ids.borrow_mut().clear();
2391 self.pending_dynamic_scripts.clear();
2392 self.pending_dynamic_styles.clear();
2393 self.pending_dynamic_images.clear();
2394 self.pending_iframe_fetches.clear();
2395 self.iframe_content.clear();
2396 self.classic_scripts.clear();
2397 self.next_script_index = 0;
2398 self.pending_script_fetches.clear();
2399 self.non_blocking_scripts_scheduled = false;
2400 self.deferred_script_results.clear();
2401 self.next_deferred_script_index = 0;
2402 self.pending_fragment_scroll = None;
2403 self.fragment_ready_version = None;
2404 let (write_back_tx, write_back_rx) = mpsc::channel();
2405 self.write_back_tx = write_back_tx;
2406 self.write_back_rx = write_back_rx;
2407 }
2408
2409 pub fn set_system_color_scheme(&mut self, scheme: ColorScheme) {
2410 if self.system_color_scheme == scheme {
2411 return;
2412 }
2413 self.system_color_scheme = scheme;
2414 self.update_layout();
2415 }
2416
2417 pub fn title(&self) -> Option<&String> {
2418 self.docment_info.as_ref().map(|d| &d.title)
2419 }
2420
2421 fn position_layout_if_needed(&mut self) {
2431 let viewport = self.viewport;
2432 if self.positioned_layout == Some((self.layout_applied_version, viewport)) {
2433 return;
2434 }
2435 let Some((layout, info)) = self.layout_and_info.as_mut() else {
2436 return;
2437 };
2438
2439 ui_layout::LayoutEngine::layout(layout, viewport.0, viewport.1);
2440 if layouter::constrain_auto_grid_track_items(layout) {
2441 ui_layout::LayoutEngine::layout(layout, viewport.0, viewport.1);
2442 }
2443 layouter::correct_atomic_inline_spacing_with_info(layout, info);
2444 layouter::align_table_columns(layout, info);
2445 layouter::refresh_missing_text_layout_results(layout, info, viewport);
2446 layouter::resolve_anchor_positioning(layout, info);
2447
2448 self.positioned_layout = Some((self.layout_applied_version, viewport));
2449 }
2450
2451 pub fn set_interaction_paths(&mut self, hovered_path: Vec<u32>, active_path: Vec<u32>) -> bool {
2455 let next = InteractionState {
2456 hovered_path,
2457 active_path,
2458 };
2459 if *self.interaction_state == next {
2460 return false;
2461 }
2462 self.interaction_state = Arc::new(next);
2463 self.interaction_version = self.interaction_version.saturating_add(1);
2464 self.update_layout();
2465 true
2466 }
2467
2468 pub fn clear_active_interaction(&mut self) -> bool {
2469 if self.interaction_state.active_path.is_empty() {
2470 return false;
2471 }
2472 let next = InteractionState {
2473 hovered_path: self.interaction_state.hovered_path.clone(),
2474 active_path: Vec::new(),
2475 };
2476 self.interaction_state = Arc::new(next);
2477 self.interaction_version = self.interaction_version.saturating_add(1);
2478 self.update_layout();
2479 true
2480 }
2481
2482 pub fn relayout(&mut self, viewport: (f32, f32)) {
2483 if self.viewport != viewport {
2484 self.viewport = viewport;
2485 if let Some(processor) = self.js_processor.as_ref() {
2486 processor.send(JsTask::SetViewport {
2487 width: viewport.0,
2488 height: viewport.1,
2489 });
2490 self.pending_js_tasks += 1;
2491 }
2492 self.update_layout();
2493 }
2494 self.position_layout_if_needed();
2495
2496 if fragment_layout_is_ready(self.fragment_ready_version, self.layout_applied_version)
2497 && let Some((layout, info)) = self.layout_and_info.as_mut()
2498 && fragment_scroll_update(
2499 layout,
2500 info,
2501 &self.layout_dom_refs,
2502 self.pending_fragment_scroll.as_deref(),
2503 viewport.1,
2504 )
2505 {
2506 self.pending_fragment_scroll = None;
2507 self.needs_redraw = true;
2508 }
2509
2510 let Some((layout, info)) = self.layout_and_info.as_mut() else {
2511 return;
2512 };
2513
2514 let layout_metrics = collect_js_layout_metrics(
2515 layout,
2516 info,
2517 &self.layout_dom_refs,
2518 &self.js_dom_ids,
2519 viewport,
2520 );
2521 let computed_styles =
2522 collect_js_computed_styles(layout, info, &self.layout_dom_refs, &self.js_dom_ids);
2523 if let Some(processor) = self.js_processor.as_ref() {
2524 processor.send(JsTask::SetLayoutMetrics {
2525 metrics: layout_metrics,
2526 computed_styles,
2527 });
2528 self.pending_js_tasks += 1;
2529 }
2530 }
2531
2532 pub fn scroll_to_fragment(&mut self, url: &Url) {
2543 if let Some(info) = self.docment_info.as_mut() {
2544 info.document_url = url.clone();
2545 }
2546 if let Some(processor) = self.js_processor.as_ref() {
2547 processor.send(JsTask::SetDocumentUrl {
2548 url: url.to_string(),
2549 });
2550 self.pending_js_tasks += 1;
2551 }
2552 self.pending_fragment_scroll = Some(url.fragment().unwrap_or("").to_string());
2553 self.fragment_ready_version = Some(self.layout_applied_version);
2556 self.needs_redraw = true;
2557 }
2558
2559 pub fn layout_and_info(&self) -> Option<(&LayoutNode, &InfoNode)> {
2561 self.layout_and_info.as_ref().map(|(l, i)| (l, i))
2562 }
2563
2564 pub fn layout_and_info_mut(&mut self) -> Option<(&LayoutNode, &mut InfoNode)> {
2565 self.layout_and_info.as_mut().map(|(l, i)| (&*l, i))
2566 }
2567
2568 pub fn document_info(&self) -> Option<&DocumentInfo> {
2570 self.docment_info.as_ref()
2571 }
2572
2573 pub fn document_url(&self) -> Option<&Url> {
2574 self.docment_info.as_ref().map(|info| &info.document_url)
2575 }
2576
2577 pub fn base_url(&self) -> Option<&Url> {
2578 self.docment_info.as_ref().map(|info| &info.base_url)
2579 }
2580
2581 pub fn set_animation_visible(&mut self, visible: bool) {
2586 if self.animation_visible == visible {
2587 return;
2588 }
2589 self.animation_visible = visible;
2590 self.animation_last_tick = None;
2592 }
2593
2594 fn sample_animation_runtime(&mut self) {
2595 if self.animation_visible {
2596 let now = Instant::now();
2597 if let Some(last) = self.animation_last_tick {
2598 self.animation_time_ms += now.duration_since(last).as_secs_f64() * 1000.0;
2599 }
2600 self.animation_last_tick = Some(now);
2603 } else {
2604 self.animation_last_tick = None;
2605 }
2606
2607 let sample = self.animation_runtime.sample(
2608 self.animation_time_ms,
2609 self.animation_visible,
2610 &self.latest_animation_inputs,
2611 &self.resolved_keyframes,
2612 self.system_color_scheme,
2613 );
2614
2615 if sample.dirty || sample.overrides != self.animated_overrides {
2618 self.animated_overrides = sample.overrides;
2619 self.update_layout();
2621 self.needs_redraw = true;
2622 } else if sample.active {
2623 self.needs_redraw = true;
2624 }
2625 }
2626
2627 pub fn needs_redraw(&self) -> bool {
2628 self.needs_redraw
2629 || self
2630 .layout_and_info
2631 .as_ref()
2632 .is_some_and(|(_, info)| crate::engine::input::any_custom_node_needs_repaint(info))
2633 }
2634
2635 pub fn clear_redraw_flag(&mut self) {
2636 self.needs_redraw = false;
2637 }
2638
2639 fn resolve_url(&self, url: &str) -> Result<Url, url::ParseError> {
2640 let base = self
2641 .docment_info
2642 .as_ref()
2643 .map(|info| &info.base_url)
2644 .ok_or(url::ParseError::RelativeUrlWithoutBase)?;
2645
2646 Url::parse(url).or_else(|_| base.join(url))
2647 }
2648}
2649
2650fn collect_js_layout_metrics(
2657 layout: &LayoutNode,
2658 info: &InfoNode,
2659 dom_refs: &[Weak<RefCell<TreeNode<HtmlNodeType>>>],
2660 js_dom_ids: &HashMap<usize, u64>,
2661 viewport: (f32, f32),
2662) -> HashMap<u64, JsLayoutMetrics> {
2663 let mut metrics = HashMap::new();
2664 let (scroll_x, scroll_y) = info.kind.scroll_offsets();
2665 let root_viewport = StickyViewport {
2666 top_left: (-scroll_x, scroll_y),
2667 size: viewport,
2668 };
2669 let containing = layout
2670 .layout_box
2671 .iter()
2672 .next()
2673 .map_or((0.0, 0.0), |b| (b.content_box.width, b.content_box.height));
2674 collect_js_layout_metrics_inner(
2675 layout,
2676 info,
2677 dom_refs,
2678 js_dom_ids,
2679 (0.0, 0.0),
2680 (0.0, 0.0),
2681 true,
2682 root_viewport,
2683 containing,
2684 viewport,
2685 &mut metrics,
2686 );
2687 metrics
2688}
2689
2690#[allow(clippy::too_many_arguments)]
2691fn collect_js_layout_metrics_inner(
2692 layout: &LayoutNode,
2693 info: &InfoNode,
2694 dom_refs: &[Weak<RefCell<TreeNode<HtmlNodeType>>>],
2695 js_dom_ids: &HashMap<usize, u64>,
2696 parent_content_origin: (f32, f32),
2697 inherited_scroll: (f32, f32),
2698 is_root: bool,
2699 sticky_viewport: StickyViewport,
2700 containing: (f32, f32),
2701 viewport_size: (f32, f32),
2702 metrics: &mut HashMap<u64, JsLayoutMetrics>,
2703) {
2704 let is_fixed = layout.style.position.kind == ui_layout::Position::Fixed;
2705 let effective_scroll = if is_fixed {
2706 (0.0, 0.0)
2707 } else {
2708 inherited_scroll
2709 };
2710 let own_scroll = info.kind.scroll_offsets();
2711 let child_scroll = if is_fixed {
2712 own_scroll
2713 } else {
2714 (
2715 inherited_scroll.0 + own_scroll.0,
2716 inherited_scroll.1 + own_scroll.1,
2717 )
2718 };
2719
2720 let is_sticky = layout.style.position.kind == ui_layout::Position::Sticky;
2724 let sticky_push = if is_sticky && !is_fixed {
2725 layout
2726 .layout_box
2727 .iter()
2728 .next()
2729 .and_then(|bm| {
2730 bm.sticky_edges
2731 .map(|edges| sticky_offset(&edges, &bm.border_box, sticky_viewport, containing))
2732 })
2733 .unwrap_or((0.0, 0.0))
2734 } else {
2735 (0.0, 0.0)
2736 };
2737
2738 let boxes: Vec<_> = layout.layout_box.iter().collect();
2739 if let Some(first) = boxes.first() {
2740 let mut page_left = parent_content_origin.0 + first.border_box.x + sticky_push.0;
2741 let mut page_top = parent_content_origin.1 + first.border_box.y + sticky_push.1;
2742 let mut page_right = page_left + first.border_box.width;
2743 let mut page_bottom = page_top + first.border_box.height;
2744 for model in boxes.iter().skip(1) {
2745 let left = parent_content_origin.0 + model.border_box.x + sticky_push.0;
2746 let top = parent_content_origin.1 + model.border_box.y + sticky_push.1;
2747 page_left = page_left.min(left);
2748 page_top = page_top.min(top);
2749 page_right = page_right.max(left + model.border_box.width);
2750 page_bottom = page_bottom.max(top + model.border_box.height);
2751 }
2752
2753 if let Some(node) = info
2754 .dom_id
2755 .and_then(|id| dom_refs.get(id as usize))
2756 .and_then(Weak::upgrade)
2757 {
2758 let node_key = Rc::as_ptr(&node) as usize;
2759 if let Some(dom_id) = js_dom_ids.get(&node_key).copied() {
2760 metrics.insert(
2761 dom_id,
2762 JsLayoutMetrics {
2763 offset_left: first.border_box.x as f64,
2764 offset_top: first.border_box.y as f64,
2765 offset_width: (page_right - page_left) as f64,
2766 offset_height: (page_bottom - page_top) as f64,
2767 client_width: first.padding_box.width as f64,
2768 client_height: first.padding_box.height as f64,
2769 rect_left: (page_left - effective_scroll.0) as f64,
2770 rect_top: (page_top - effective_scroll.1) as f64,
2771 rect_width: (page_right - page_left) as f64,
2772 rect_height: (page_bottom - page_top) as f64,
2773 },
2774 );
2775 }
2776 }
2777
2778 let child_viewport = if is_root || is_scrollport(&info.kind) {
2784 let bm = layout.layout_box.iter().next();
2785 StickyViewport {
2786 top_left: bm.as_ref().map_or((0.0, 0.0), |b| {
2787 (
2788 b.padding_box.x - b.content_box.x - own_scroll.0,
2789 b.padding_box.y - b.content_box.y + own_scroll.1,
2790 )
2791 }),
2792 size: if is_root {
2793 viewport_size
2794 } else {
2795 bm.map_or(viewport_size, |b| {
2796 (b.padding_box.width, b.padding_box.height)
2797 })
2798 },
2799 }
2800 } else {
2801 let bm = layout.layout_box.iter().next();
2802 StickyViewport {
2803 top_left: bm.map_or(sticky_viewport.top_left, |b| {
2804 (
2805 sticky_viewport.top_left.0 - b.content_box.x,
2806 sticky_viewport.top_left.1 - b.content_box.y,
2807 )
2808 }),
2809 size: sticky_viewport.size,
2810 }
2811 };
2812 let child_containing = layout
2813 .layout_box
2814 .iter()
2815 .next()
2816 .map_or(containing, |b| (b.content_box.width, b.content_box.height));
2817 let child_origin = (
2818 parent_content_origin.0 + first.content_box.x + sticky_push.0,
2819 parent_content_origin.1 + first.content_box.y + sticky_push.1,
2820 );
2821 for (child_layout, child_info) in layout.children.iter().zip(&info.children) {
2822 if let Some(child_layout) = child_layout.node() {
2823 collect_js_layout_metrics_inner(
2824 child_layout,
2825 child_info,
2826 dom_refs,
2827 js_dom_ids,
2828 child_origin,
2829 child_scroll,
2830 false,
2831 child_viewport,
2832 child_containing,
2833 viewport_size,
2834 metrics,
2835 );
2836 }
2837 }
2838 }
2839}
2840
2841fn collect_js_computed_styles(
2845 layout: &LayoutNode,
2846 info: &InfoNode,
2847 dom_refs: &[Weak<RefCell<TreeNode<HtmlNodeType>>>],
2848 js_dom_ids: &HashMap<usize, u64>,
2849) -> HashMap<u64, Vec<(String, String)>> {
2850 let mut computed = HashMap::new();
2851 collect_js_computed_styles_inner(layout, info, dom_refs, js_dom_ids, &mut computed);
2852 computed
2853}
2854
2855fn collect_js_computed_styles_inner(
2856 layout: &LayoutNode,
2857 info: &InfoNode,
2858 dom_refs: &[Weak<RefCell<TreeNode<HtmlNodeType>>>],
2859 js_dom_ids: &HashMap<usize, u64>,
2860 computed: &mut HashMap<u64, Vec<(String, String)>>,
2861) {
2862 if layout.layout_box.is_empty() {
2863 return;
2864 }
2865
2866 if let Some(node) = info
2867 .dom_id
2868 .and_then(|id| dom_refs.get(id as usize))
2869 .and_then(Weak::upgrade)
2870 {
2871 let node_key = Rc::as_ptr(&node) as usize;
2872 if let Some(dom_id) = js_dom_ids.get(&node_key).copied() {
2873 let declarations = computed_style_declarations(layout, info);
2874 if !declarations.is_empty() {
2875 computed.insert(dom_id, declarations);
2876 }
2877 }
2878 }
2879
2880 for (child_layout, child_info) in layout.children.iter().zip(&info.children) {
2881 if let Some(child_layout) = child_layout.node() {
2882 collect_js_computed_styles_inner(
2883 child_layout,
2884 child_info,
2885 dom_refs,
2886 js_dom_ids,
2887 computed,
2888 );
2889 }
2890 }
2891}
2892
2893fn computed_style_declarations(layout: &LayoutNode, info: &InfoNode) -> Vec<(String, String)> {
2897 let mut declarations: Vec<(String, String)> = Vec::new();
2898
2899 let layout_style = &layout.style;
2900 declarations.push(("display".into(), css_display(layout_style.display)));
2901 declarations.push(("position".into(), css_position(layout_style.position.kind)));
2902 if let Some(box_model) = layout.layout_box.iter().next() {
2903 declarations.push(("width".into(), css_px(box_model.content_box.width)));
2904 declarations.push(("height".into(), css_px(box_model.content_box.height)));
2905 }
2906
2907 match &info.kind {
2908 NodeKind::Container { style, .. }
2909 | NodeKind::Custom { style, .. }
2910 | NodeKind::Svg { style, .. } => {
2911 push_container_computed(&mut declarations, style);
2912 }
2913 _ => {}
2914 }
2915
2916 let text_style = match &info.kind {
2918 NodeKind::Text {
2919 style, flow_style, ..
2920 } => Some((style, flow_style)),
2921 NodeKind::Custom {
2922 text_style,
2923 text_flow_style,
2924 ..
2925 } => Some((text_style, text_flow_style)),
2926 _ => None,
2927 };
2928 if let Some((text_style, flow_style)) = text_style {
2929 push_text_computed(&mut declarations, text_style, flow_style);
2930 }
2931
2932 let overflow_x = container_overflow(&info.kind, true);
2935 let overflow_y = container_overflow(&info.kind, false);
2936 declarations.push(("overflow-x".into(), overflow_x));
2937 declarations.push(("overflow-y".into(), overflow_y));
2938
2939 declarations
2940}
2941
2942fn container_overflow(info: &NodeKind, horizontal: bool) -> String {
2943 let scrollable = match info {
2944 NodeKind::Container {
2945 scroll_x, scroll_y, ..
2946 }
2947 | NodeKind::Custom {
2948 scroll_x, scroll_y, ..
2949 }
2950 | NodeKind::Svg {
2951 scroll_x, scroll_y, ..
2952 } => {
2953 if horizontal {
2954 *scroll_x
2955 } else {
2956 *scroll_y
2957 }
2958 }
2959 _ => false,
2960 };
2961 if scrollable {
2962 "auto".to_string()
2963 } else {
2964 "visible".to_string()
2965 }
2966}
2967
2968fn push_container_computed(declarations: &mut Vec<(String, String)>, style: &ContainerStyle) {
2969 declarations.push(("opacity".into(), css_opacity(style.opacity)));
2970 declarations.push(("visibility".into(), css_visibility(style.visibility)));
2971 declarations.push((
2972 "z-index".into(),
2973 style
2974 .z_index
2975 .map(|z| z.to_string())
2976 .unwrap_or_else(|| "auto".to_string()),
2977 ));
2978 declarations.push(("cursor".into(), css_cursor(style.cursor.clone())));
2979 declarations.push(("text-align".into(), css_text_align(style.text_align)));
2980 if !style.anchor.anchors.is_empty() {
2981 declarations.push(("anchor-name".into(), style.anchor.anchors.join(", ")));
2982 }
2983 if let Some(ref name) = style.anchor.position_anchor {
2984 declarations.push(("position-anchor".into(), name.clone()));
2985 }
2986 if let Some(color) = style.background.color
2987 && color.3 > 0
2988 {
2989 declarations.push(("background-color".into(), css_color(&color)));
2990 }
2991}
2992
2993fn push_text_computed(
2994 declarations: &mut Vec<(String, String)>,
2995 style: &TextStyle,
2996 flow_style: &TextFlowStyle,
2997) {
2998 declarations.push(("color".into(), css_color(&style.color)));
2999 declarations.push(("font-size".into(), css_px(flow_style.font_size)));
3000 declarations.push(("font-weight".into(), style.font_weight.0.to_string()));
3001 declarations.push(("font-style".into(), css_font_style(style.font_style)));
3002 declarations.push(("text-align".into(), css_text_align(flow_style.text_align)));
3003 declarations.push((
3004 "white-space".into(),
3005 css_white_space(flow_style.white_space),
3006 ));
3007 if !style.font_families.is_empty() {
3008 declarations.push(("font-family".into(), style.font_families.join(", ")));
3009 }
3010 let line_height = match flow_style.line_height {
3011 crate::engine::layouter::types::LineHeight::Normal => "normal".to_string(),
3012 crate::engine::layouter::types::LineHeight::Number(n) => n.to_string(),
3013 crate::engine::layouter::types::LineHeight::Px(px) => css_px(px),
3014 };
3015 declarations.push(("line-height".into(), line_height));
3016}
3017
3018fn css_display(display: Display) -> String {
3019 match display {
3020 Display::None => "none".to_string(),
3021 Display::Contents => "contents".to_string(),
3022 Display::OutsideInner { outer, inner } => match (outer, inner) {
3023 (OuterDisplay::Block, InnerDisplay::Flex) => "flex".to_string(),
3024 (OuterDisplay::Inline, InnerDisplay::Flex) => "inline-flex".to_string(),
3025 (OuterDisplay::Block, InnerDisplay::Grid) => "grid".to_string(),
3026 (OuterDisplay::Inline, InnerDisplay::Grid) => "inline-grid".to_string(),
3027 (OuterDisplay::Block, InnerDisplay::FlowRoot) => "flow-root".to_string(),
3028 (OuterDisplay::Inline, InnerDisplay::FlowRoot) => "inline-block".to_string(),
3029 (_, InnerDisplay::Flow) => match outer {
3030 OuterDisplay::Inline => "inline".to_string(),
3031 OuterDisplay::Block => "block".to_string(),
3032 },
3033 },
3034 }
3035}
3036
3037fn css_position(position: Position) -> String {
3038 match position {
3039 Position::Static => "static".to_string(),
3040 Position::Relative => "relative".to_string(),
3041 Position::Absolute => "absolute".to_string(),
3042 Position::Fixed => "fixed".to_string(),
3043 Position::Sticky => "sticky".to_string(),
3044 }
3045}
3046
3047fn css_opacity(opacity: f32) -> String {
3048 format!("{opacity}")
3049}
3050
3051fn css_visibility(visibility: Visibility) -> String {
3052 match visibility {
3053 Visibility::Visible => "visible".to_string(),
3054 Visibility::Hidden => "hidden".to_string(),
3055 Visibility::Collapse => "collapse".to_string(),
3056 }
3057}
3058
3059fn css_cursor(cursor: CursorStyle) -> String {
3060 match cursor {
3061 CursorStyle::Keyword(kw) => match kw {
3062 CursorKeyword::Auto => "auto".to_string(),
3063 CursorKeyword::Default => "default".to_string(),
3064 CursorKeyword::None => "none".to_string(),
3065 CursorKeyword::Pointer => "pointer".to_string(),
3066 CursorKeyword::Text => "text".to_string(),
3067 CursorKeyword::Move => "move".to_string(),
3068 CursorKeyword::NotAllowed => "not-allowed".to_string(),
3069 CursorKeyword::Wait => "wait".to_string(),
3070 CursorKeyword::Crosshair => "crosshair".to_string(),
3071 CursorKeyword::Grab => "grab".to_string(),
3072 CursorKeyword::Grabbing => "grabbing".to_string(),
3073 },
3074 CursorStyle::Custom { images, .. } => {
3075 if let Some(img) = images.first() {
3076 format!("url(\"{}\")", img.source)
3077 } else {
3078 "auto".to_string()
3079 }
3080 }
3081 }
3082}
3083
3084fn css_text_align(align: TextAlign) -> String {
3085 match align {
3086 TextAlign::Left => "left".to_string(),
3087 TextAlign::Center => "center".to_string(),
3088 TextAlign::Right => "right".to_string(),
3089 }
3090}
3091
3092fn css_white_space(white_space: WhiteSpace) -> String {
3093 match white_space {
3094 WhiteSpace::Normal => "normal".to_string(),
3095 WhiteSpace::Nowrap => "nowrap".to_string(),
3096 WhiteSpace::Pre => "pre".to_string(),
3097 WhiteSpace::PreWrap => "pre-wrap".to_string(),
3098 WhiteSpace::PreLine => "pre-line".to_string(),
3099 WhiteSpace::BreakSpaces => "break-spaces".to_string(),
3100 }
3101}
3102
3103fn css_font_style(font_style: FontStyle) -> String {
3104 match font_style {
3105 FontStyle::Normal => "normal".to_string(),
3106 FontStyle::Italic => "italic".to_string(),
3107 FontStyle::Oblique => "oblique".to_string(),
3108 }
3109}
3110
3111fn css_color(color: &Color) -> String {
3112 if color.3 == 255 {
3113 format!("#{:02x}{:02x}{:02x}", color.0, color.1, color.2)
3114 } else {
3115 format!("rgba({}, {}, {}, {})", color.0, color.1, color.2, color.3)
3116 }
3117}
3118
3119fn css_px(value: f32) -> String {
3120 format!("{value}px")
3121}
3122
3123fn capture_scroll_offsets(info: &InfoNode, offsets: &mut HashMap<NodeId, (f32, f32)>) {
3131 let (x, y) = info.kind.scroll_offsets();
3132 if (x != 0.0 || y != 0.0)
3133 && let Some(dom_id) = info.dom_id
3134 {
3135 offsets.insert(dom_id, (x, y));
3136 }
3137 for child in &info.children {
3138 capture_scroll_offsets(child, offsets);
3139 }
3140}
3141
3142fn apply_scroll_offsets(info: &mut InfoNode, offsets: &HashMap<NodeId, (f32, f32)>) {
3150 if let Some((x, y)) = info.dom_id.and_then(|id| offsets.get(&id)) {
3151 match &mut info.kind {
3152 NodeKind::Container {
3153 scroll_offset_x,
3154 scroll_offset_y,
3155 ..
3156 }
3157 | NodeKind::Custom {
3158 scroll_offset_x,
3159 scroll_offset_y,
3160 ..
3161 } => {
3162 *scroll_offset_x = *x;
3163 *scroll_offset_y = *y;
3164 }
3165 _ => {}
3166 }
3167 }
3168 for child in &mut info.children {
3169 apply_scroll_offsets(child, offsets);
3170 }
3171}
3172
3173fn fragment_layout_is_ready(ready_version: Option<u64>, applied_version: u64) -> bool {
3174 ready_version.is_some_and(|ready_version| applied_version >= ready_version)
3175}
3176
3177fn find_fragment_target_dom_id(
3178 dom_refs: &[Weak<RefCell<TreeNode<HtmlNodeType>>>],
3179 fragment: &str,
3180) -> Option<NodeId> {
3181 dom_refs.iter().enumerate().find_map(|(index, node)| {
3182 let node = node.upgrade()?;
3183 (node.borrow().value.get_attr("id") == Some(fragment)).then_some(index as NodeId)
3184 })
3185}
3186
3187fn apply_fragment_scroll(
3188 layout: &LayoutNode,
3189 info: &mut InfoNode,
3190 target: NodeId,
3191 viewport_height: f32,
3192) -> bool {
3193 scroll_to_fragment_target(layout, info, target, viewport_height).is_some()
3194}
3195
3196fn scroll_to_fragment_target(
3211 layout: &LayoutNode,
3212 info: &mut InfoNode,
3213 target: NodeId,
3214 viewport_height: f32,
3215) -> Option<f32> {
3216 let model = layout.layout_box.iter().next();
3217 if info.dom_id == Some(target) {
3218 return model.map(|model| model.border_box.y - model.content_box.y);
3219 }
3220
3221 let mut found = None;
3222 for (layout_child, info_child) in layout.children.iter().zip(&mut info.children) {
3223 let LayoutChild::Node(layout_child) = layout_child else {
3224 continue;
3225 };
3226 if let Some(child_pos) =
3227 scroll_to_fragment_target(layout_child, info_child, target, viewport_height)
3228 {
3229 let child_box = layout_child.layout_box.iter().next();
3233 found = Some(child_box.map_or(0.0, |b| b.content_box.y) + child_pos);
3234 break;
3235 }
3236 }
3237 let mut target_y = found?;
3238
3239 let scrollable = matches!(
3242 &info.kind,
3243 NodeKind::Container { scroll_y: true, .. } | NodeKind::Custom { scroll_y: true, .. }
3244 );
3245 if scrollable && let Some(model) = model {
3246 let max_scroll =
3247 (model.children_box.height - model.content_box.height.min(viewport_height)).max(0.0);
3248 let offset = target_y.clamp(0.0, max_scroll);
3249 match &mut info.kind {
3250 NodeKind::Container {
3251 scroll_offset_y, ..
3252 }
3253 | NodeKind::Custom {
3254 scroll_offset_y, ..
3255 } => {
3256 *scroll_offset_y = offset;
3257 }
3258 _ => {}
3259 }
3260 target_y -= offset;
3261 }
3262
3263 Some(target_y)
3264}
3265
3266fn scroll_page_to_top(info: &mut InfoNode) -> bool {
3272 fn reset_y(kind: &mut NodeKind) -> bool {
3273 match kind {
3274 NodeKind::Container {
3275 scroll_offset_y, ..
3276 }
3277 | NodeKind::Custom {
3278 scroll_offset_y, ..
3279 } => {
3280 *scroll_offset_y = 0.0;
3281 true
3282 }
3283 _ => false,
3284 }
3285 }
3286 let mut reset = reset_y(&mut info.kind);
3287 for child in &mut info.children {
3288 reset |= scroll_page_to_top(child);
3289 }
3290 reset
3291}
3292
3293fn fragment_scroll_update(
3299 layout: &LayoutNode,
3300 info: &mut InfoNode,
3301 dom_refs: &[Weak<RefCell<TreeNode<HtmlNodeType>>>],
3302 pending: Option<&str>,
3303 viewport_height: f32,
3304) -> bool {
3305 let Some(fragment) = pending else {
3306 return false;
3308 };
3309 if fragment.is_empty() {
3310 return scroll_page_to_top(info);
3311 }
3312 let decoded = percent_decode_fragment(fragment);
3313 let Some(target) = find_fragment_target_dom_id(dom_refs, &decoded) else {
3314 return fragment.eq_ignore_ascii_case("top") && scroll_page_to_top(info);
3315 };
3316 apply_fragment_scroll(layout, info, target, viewport_height)
3317}
3318
3319fn percent_decode_fragment(fragment: &str) -> String {
3322 let bytes = fragment.as_bytes();
3323 let mut out = Vec::with_capacity(bytes.len());
3324 let mut i = 0;
3325 while i < bytes.len() {
3326 if bytes[i] == b'%'
3327 && i + 2 < bytes.len()
3328 && let (Some(hi), Some(lo)) = (hex_value(bytes[i + 1]), hex_value(bytes[i + 2]))
3329 {
3330 out.push(hi * 16 + lo);
3331 i += 3;
3332 } else {
3333 out.push(bytes[i]);
3334 i += 1;
3335 }
3336 }
3337 String::from_utf8_lossy(&out).into_owned()
3338}
3339
3340fn hex_value(b: u8) -> Option<u8> {
3341 match b {
3342 b'0'..=b'9' => Some(b - b'0'),
3343 b'a'..=b'f' => Some(b - b'a' + 10),
3344 b'A'..=b'F' => Some(b - b'A' + 10),
3345 _ => None,
3346 }
3347}
3348
3349fn collect_css_image_sources(css: &str) -> Vec<String> {
3350 fn collect_value(value: &CssValue, sources: &mut Vec<String>) {
3351 match value {
3352 CssValue::Function(name, arguments) if name.eq_ignore_ascii_case("url") => {
3353 if let Some(source) = arguments
3354 .iter()
3355 .flatten()
3356 .find_map(|argument| match argument {
3357 CssValue::String(source) => Some(source.clone()),
3358 CssValue::Keyword(source) => Some(source.to_string()),
3359 _ => None,
3360 })
3361 && !source.is_empty()
3362 && !sources.contains(&source)
3363 {
3364 sources.push(source);
3365 }
3366 }
3367 CssValue::Function(_, arguments) => {
3368 for argument in arguments.iter().flatten() {
3369 collect_value(argument, sources);
3370 }
3371 }
3372 CssValue::List(arguments) => {
3373 for argument in arguments {
3374 collect_value(argument, sources);
3375 }
3376 }
3377 _ => {}
3378 }
3379 }
3380
3381 fn visit(node: &CssNode, sources: &mut Vec<String>) {
3382 if let CssNodeType::Declaration { name, value } = node.node()
3383 && matches!(
3384 name.to_ascii_lowercase().as_str(),
3385 "background" | "background-image"
3386 )
3387 {
3388 collect_value(value, sources);
3389 }
3390 for child in node.children() {
3391 visit(child, sources);
3392 }
3393 }
3394
3395 let stylesheet = CssParser::new(css).parse_lossy();
3396 let mut sources = Vec::new();
3397 visit(&stylesheet, &mut sources);
3398 sources
3399}
3400
3401fn split_css_imports(css: &str) -> (Vec<String>, String) {
3409 let bytes = css.as_bytes();
3410 let mut imports = Vec::new();
3411 let mut removed: Vec<(usize, usize)> = Vec::new();
3412 let mut i = 0usize;
3413 let mut depth: i32 = 0;
3414 let mut quote: Option<u8> = None;
3415 let mut in_comment = false;
3416 let mut escaped = false;
3417
3418 while i < bytes.len() {
3419 let b = bytes[i];
3420 if let Some(q) = quote {
3421 if escaped {
3422 escaped = false;
3423 } else if b == b'\\' {
3424 escaped = true;
3425 } else if b == q {
3426 quote = None;
3427 }
3428 i += 1;
3429 continue;
3430 }
3431 if in_comment {
3432 if b == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
3433 in_comment = false;
3434 i += 2;
3435 } else {
3436 i += 1;
3437 }
3438 continue;
3439 }
3440 match b {
3441 b'"' | b'\'' => {
3442 quote = Some(b);
3443 i += 1;
3444 }
3445 b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
3446 in_comment = true;
3447 i += 2;
3448 }
3449 b'{' => {
3450 depth += 1;
3451 i += 1;
3452 }
3453 b'}' => {
3454 depth = (depth - 1).max(0);
3455 i += 1;
3456 }
3457 b'@' if depth == 0 => {
3458 let tail = &bytes[i..];
3459 if tail.starts_with(b"@import")
3460 && (tail.len() == 7
3461 || !tail[7].is_ascii_alphanumeric() && tail[7] != b'_' && tail[7] != b'-')
3462 {
3463 let (start, end) = import_statement_bounds(bytes, i);
3464 if let Some(raw) = css_import_url(&css[start..end]) {
3465 imports.push(raw);
3466 removed.push((start, end));
3467 }
3468 i = end;
3469 continue;
3470 }
3471 i += 1;
3472 }
3473 _ => i += 1,
3474 }
3475 }
3476
3477 let mut body = String::with_capacity(css.len());
3478 let mut prev = 0;
3479 for (start, end) in removed {
3480 body.push_str(&css[prev..start]);
3481 prev = end;
3482 }
3483 body.push_str(&css[prev..]);
3484 (imports, body)
3485}
3486
3487fn import_statement_bounds(bytes: &[u8], at: usize) -> (usize, usize) {
3490 let mut j = at;
3491 let mut quote: Option<u8> = None;
3492 let mut escaped = false;
3493 let mut parens: i32 = 0;
3494 while j < bytes.len() {
3495 let c = bytes[j];
3496 if let Some(q) = quote {
3497 if escaped {
3498 escaped = false;
3499 } else if c == b'\\' {
3500 escaped = true;
3501 } else if c == q {
3502 quote = None;
3503 }
3504 j += 1;
3505 continue;
3506 }
3507 match c {
3508 b'"' | b'\'' => {
3509 quote = Some(c);
3510 j += 1;
3511 }
3512 b'(' => {
3513 parens += 1;
3514 j += 1;
3515 }
3516 b')' => {
3517 parens = (parens - 1).max(0);
3518 j += 1;
3519 }
3520 b';' if parens <= 0 => return (at, j + 1),
3521 _ => j += 1,
3522 }
3523 }
3524 (at, bytes.len())
3525}
3526
3527fn css_import_url(stmt: &str) -> Option<String> {
3530 let rest = stmt.trim_start();
3531 let rest = rest.strip_prefix("@import")?;
3532 let rest = rest.trim_start();
3533 let bytes = rest.as_bytes();
3534 match bytes.first()? {
3535 b'"' | b'\'' => {
3536 let quote = bytes[0];
3537 let mut out = String::new();
3538 let mut i = 1;
3539 let mut escaped = false;
3540 while i < bytes.len() {
3541 let c = bytes[i];
3542 if escaped {
3543 out.push(c as char);
3544 escaped = false;
3545 } else if c == b'\\' {
3546 escaped = true;
3547 } else if c == quote {
3548 return Some(out);
3549 } else {
3550 out.push(c as char);
3551 }
3552 i += 1;
3553 }
3554 None
3555 }
3556 _ => {
3557 let paren = rest.find('(')?;
3558 let func = rest[..paren].trim_end();
3559 if !func.eq_ignore_ascii_case("url") {
3560 return None;
3561 }
3562 let inner_start = paren + 1;
3563 let mut i = inner_start;
3564 let mut quote: Option<u8> = None;
3565 let mut escaped = false;
3566 while i < bytes.len() {
3567 let c = bytes[i];
3568 if let Some(q) = quote {
3569 if escaped {
3570 escaped = false;
3571 } else if c == b'\\' {
3572 escaped = true;
3573 } else if c == q {
3574 quote = None;
3575 }
3576 } else {
3577 match c {
3578 b'"' | b'\'' => quote = Some(c),
3579 b')' => {
3580 let inner = rest[inner_start..i]
3581 .trim()
3582 .trim_matches(['"', '\''])
3583 .to_string();
3584 return Some(inner);
3585 }
3586 _ => {}
3587 }
3588 }
3589 i += 1;
3590 }
3591 None
3592 }
3593 }
3594}
3595
3596fn parse_html(html: &str, document_url: Url, scripting_mode: ScriptingMode) -> ParsedDocument {
3597 let mut parser = HtmlParser::new(html).with_scripting_mode(scripting_mode);
3599 let dom = Rc::new(parser.parse());
3600
3601 let base_url = dom
3603 .find_all(|n| n.tag_name() == Some("base"))
3604 .iter()
3605 .filter_map(|node_ref| {
3606 let html_node = &node_ref.borrow().value;
3607 let href = html_node.get_attr("href")?;
3608 document_url.join(href).ok()
3609 })
3610 .next()
3611 .unwrap_or_else(|| document_url.clone());
3612
3613 let title = dom
3615 .collect_text_by_tag("title")
3616 .first()
3617 .cloned()
3618 .unwrap_or("".into());
3619
3620 let link_nodes = dom.find_all(|n| n.tag_name() == Some("link"));
3623 let mut style_links = Vec::new();
3624
3625 for node in link_nodes {
3626 let (rel, href) = {
3627 let node_ref = node.borrow();
3628 let html_node = &node_ref.value;
3629
3630 let rel = html_node.get_attr("rel").map(|s| s.to_string());
3631 let href = html_node.get_attr("href").map(|s| s.to_string());
3632 (rel, href)
3633 };
3634
3635 if let (Some(rel), Some(href)) = (rel, href)
3636 && rel
3637 .split_ascii_whitespace()
3638 .any(|token| token.eq_ignore_ascii_case("stylesheet"))
3639 {
3640 let css_url = match resolve_url(&base_url, &href) {
3641 Ok(url) => url,
3642 Err(_) => continue,
3643 };
3644 style_links.push(css_url);
3645 }
3646 }
3647
3648 let inline_styles = dom.collect_text_by_tag("style");
3650
3651 let scripts = dom
3653 .collect_classic_script_descriptors()
3654 .into_iter()
3655 .filter_map(|script| match script.source {
3656 ClassicScriptSource::Inline(source) => Some(ClassicScript::Inline(source)),
3657 ClassicScriptSource::External(source) => {
3658 resolve_url(&base_url, &source)
3659 .ok()
3660 .map(|url| ClassicScript::External {
3661 url,
3662 execution: script.execution,
3663 })
3664 }
3665 })
3666 .collect();
3667
3668 let image_sources = dom
3669 .get_elements_by_tag_name("img")
3670 .into_iter()
3671 .filter_map(|node| {
3672 let source = node.borrow().value.get_attr("src")?.to_string();
3673 let url = resolve_url(&base_url, &source).ok()?;
3674 Some((source, url))
3675 })
3676 .collect();
3677
3678 let audio_sources = dom
3679 .get_elements_by_tag_name("audio")
3680 .into_iter()
3681 .filter_map(|node| {
3682 let source = {
3683 let audio = node.borrow();
3684 audio.value.get_attr("src").map(str::to_string).or_else(|| {
3685 audio.children().iter().find_map(|child| {
3686 let child = child.borrow();
3687 (child.value.tag_name() == Some("source"))
3688 .then(|| child.value.get_attr("src").map(str::to_string))
3689 .flatten()
3690 })
3691 })
3692 }?;
3693 let url = resolve_url(&base_url, &source).ok()?;
3694 Some((source, url))
3695 })
3696 .collect();
3697
3698 ParsedDocument {
3699 document_url,
3700 base_url,
3701 dom,
3702 title,
3703 style_links,
3704 inline_styles,
3705 image_sources,
3706 audio_sources,
3707 scripts,
3708 }
3709}
3710
3711pub fn resolve_url(base_url: &Url, path: &str) -> Result<Url, url::ParseError> {
3712 if let Ok(url) = Url::parse(path) {
3714 return Ok(url);
3715 }
3716
3717 base_url.join(path)
3719}
3720
3721#[cfg(test)]
3722mod tests {
3723 use super::*;
3724 use crate::engine::layouter::types::{ContainerRole, ContainerStyle};
3725 use serde_json::{Value, json};
3726 use std::time::{Duration, Instant};
3727 use ui_layout::{Length, LengthOrAuto};
3728
3729 fn pump_until(webview: &mut WebView, mut done: impl FnMut(&mut WebView) -> bool, why: &str) {
3734 let deadline = Instant::now() + Duration::from_secs(5);
3735 while !done(webview) {
3736 assert!(Instant::now() < deadline, "timed out waiting for {why}");
3737 webview.tick();
3738 std::thread::sleep(Duration::from_millis(1));
3739 }
3740 }
3741
3742 fn pump_for_task(
3744 webview: &mut WebView,
3745 mut done: impl FnMut(&WebViewTask) -> bool,
3746 why: &str,
3747 ) -> WebViewTask {
3748 let deadline = Instant::now() + Duration::from_secs(5);
3749 loop {
3750 let tasks = webview.tick();
3751 if let Some(task) = tasks.into_iter().find(&mut done) {
3752 return task;
3753 }
3754 assert!(Instant::now() < deadline, "timed out waiting for {why}");
3755 std::thread::sleep(Duration::from_millis(1));
3756 }
3757 }
3758
3759 fn find_json_by_attribute<'a>(value: &'a Value, name: &str, wanted: &str) -> Option<&'a Value> {
3760 if value
3761 .get("attributes")
3762 .and_then(Value::as_array)
3763 .is_some_and(|attrs| {
3764 attrs
3765 .iter()
3766 .any(|attr| attr[0] == *name && attr[1] == *wanted)
3767 })
3768 {
3769 return Some(value);
3770 }
3771 value
3772 .get("children")
3773 .and_then(Value::as_array)
3774 .and_then(|children| {
3775 children
3776 .iter()
3777 .find_map(|child| find_json_by_attribute(child, name, wanted))
3778 })
3779 }
3780
3781 fn styles_webview() -> WebView {
3782 let mut webview = WebView::default();
3783 webview.tick();
3785 webview.on_html_fetched(
3786 r#"<html><head><style>
3787 p { color: red; }
3788 .box { color: blue; }
3789 </style></head>
3790 <body><p id="t" class="box" style="margin-top: 7px">x</p></body></html>"#
3791 .to_string(),
3792 Url::parse("https://example.test/").unwrap(),
3793 );
3794 webview.rebuild_styles_and_layout();
3796 webview
3797 }
3798
3799 fn box_model_webview() -> WebView {
3800 let mut webview = WebView::default();
3801 webview.tick();
3802 webview.on_html_fetched(
3803 r#"<html><head><style>
3804 #box {
3805 width: 100px;
3806 height: 50px;
3807 padding: 10px;
3808 border: 2px solid red;
3809 margin-top: 7px;
3810 }
3811 </style></head>
3812 <body><div id="box">x</div></body></html>"#
3813 .to_string(),
3814 Url::parse("https://example.test/box").unwrap(),
3815 );
3816 webview.rebuild_styles_and_layout();
3817 pump_until(
3819 &mut webview,
3820 |webview| webview.layout_and_info.is_some(),
3821 "the first background layout build",
3822 );
3823 webview
3824 }
3825
3826 fn dom_id_for_attribute(webview: &mut WebView, name: &str, wanted: &str) -> u64 {
3827 let document = webview.inspect("getDocument", "{}").expect("document");
3828 find_json_by_attribute(&document, name, wanted).map_or_else(
3829 || panic!("no element with {name}={wanted}"),
3830 |node| node["id"].as_u64().unwrap(),
3831 )
3832 }
3833
3834 #[test]
3835 fn box_model_reports_rings_from_laid_out_geometry() {
3836 let mut webview = box_model_webview();
3837 let dom_id = dom_id_for_attribute(&mut webview, "id", "box");
3838 let params = format!(r#"{{"domId":{dom_id}}}"#);
3839
3840 let model = webview.inspect("getBoxModel", ¶ms).expect("box model")["model"].clone();
3841
3842 assert_eq!(model["margin"][0], "7");
3844 assert_eq!(
3845 model["padding"],
3846 json!([10.0, 10.0, 10.0, 10.0]),
3847 "padding ring derives from padding vs content boxes"
3848 );
3849 assert_eq!(model["border"], json!([2.0, 2.0, 2.0, 2.0]));
3850 assert_eq!(model["content"], json!([100.0, 50.0]));
3852 assert_eq!(model["size"], json!([124.0, 74.0]));
3853
3854 let info = webview
3855 .inspect("getLayoutInfo", ¶ms)
3856 .expect("layout info")["info"]
3857 .clone();
3858 assert_eq!(info["width"], "100");
3859 assert_eq!(info["height"], "50");
3860 assert_eq!(info["scroll"], json!([0.0, 0.0]));
3861 }
3862
3863 #[test]
3864 fn devtools_positions_match_rendered_inline_buttons() {
3865 let mut webview = WebView::default();
3866 webview.tick();
3867 webview.viewport = (720.0, 2200.0);
3868 webview.on_html_fetched(
3869 r#"<html><head><style>
3870 body { margin: 0; padding: 32px; }
3871 section { border: 1px solid; padding: 22px; }
3872 .grid { display: grid; grid-template-columns: 297px 297px; gap: 16px; }
3873 .card { border: 1px solid; padding: 16px; }
3874 button { margin: 3px; padding: 8px 13px; border: 1px solid; }
3875 </style></head><body><section><div class="grid">
3876 <div class="card"><button id="first">Toggle</button><div>first</div></div>
3877 <div class="card"><button id="second">Toggle</button><div>second</div></div>
3878 </div></section></body></html>"#
3879 .to_string(),
3880 Url::parse("https://example.test/").unwrap(),
3881 );
3882 webview.rebuild_styles_and_layout();
3883 pump_until(
3884 &mut webview,
3885 |webview| webview.layout_and_info.is_some(),
3886 "the inline button layout",
3887 );
3888 webview.relayout((720.0, 2200.0));
3889
3890 let first = dom_id_for_attribute(&mut webview, "id", "first");
3891 let second = dom_id_for_attribute(&mut webview, "id", "second");
3892 let position = |webview: &mut WebView, dom_id| {
3893 webview
3894 .inspect("getBoxModel", &format!(r#"{{"domId":{dom_id}}}"#))
3895 .expect("box model")["model"]["position"]
3896 .clone()
3897 };
3898
3899 let (layout, info) = webview.layout_and_info().expect("positioned layout");
3900 let mut commands = Vec::new();
3901 crate::engine::renderer_model::generate_draw_commands(
3902 &mut commands,
3903 layout,
3904 info,
3905 (720.0, 2200.0),
3906 );
3907 let mut transforms = vec![crate::engine::renderer_model::AffineTransform::identity()];
3908 let mut toggle_text_x = Vec::new();
3909 for command in commands {
3910 match command {
3911 crate::engine::renderer_model::DrawCommand::PushTransform { transform } => {
3912 let composed = transforms.last().unwrap().then(&transform);
3913 transforms.push(composed);
3914 }
3915 crate::engine::renderer_model::DrawCommand::PopTransform => {
3916 transforms.pop().expect("balanced transform stack");
3917 }
3918 crate::engine::renderer_model::DrawCommand::DrawText { x, text, .. }
3919 if text.as_str() == "Toggle" =>
3920 {
3921 toggle_text_x.push(transforms.last().unwrap().apply(x, 0.0).0);
3922 }
3923 _ => {}
3924 }
3925 }
3926
3927 assert_eq!(position(&mut webview, first)[0], 75.0);
3928 assert_eq!(position(&mut webview, second)[0], 388.0);
3929 assert_eq!(&toggle_text_x[..2], &[89.0, 402.0]);
3930 }
3931
3932 #[test]
3933 fn box_model_rejects_ids_outside_the_current_layout() {
3934 let mut webview = box_model_webview();
3935 let error = webview
3936 .inspect("getBoxModel", r#"{"domId":99999}"#)
3937 .expect_err("unknown id must fail");
3938 assert!(error.contains("unknown domId"), "unexpected error: {error}");
3939 }
3940
3941 #[test]
3942 fn applied_layout_results_are_positioned_before_any_draw() {
3943 let webview = box_model_webview();
3948 let (layout, info) = webview.layout_and_info().expect("layout applied");
3949
3950 let path = crate::engine::input::hit_test(layout, info, 50.0, 25.0);
3951 assert!(
3952 crate::engine::input::hit_dom_id(&path).is_some(),
3953 "boxes must carry geometry as soon as a background result lands"
3954 );
3955 }
3956
3957 #[test]
3958 fn matched_rules_report_winners_overrides_and_inline_styles() {
3959 let mut webview = styles_webview();
3960
3961 let document = webview.inspect("getDocument", "{}").expect("document");
3962 let paragraph = find_json_by_attribute(&document, "id", "t").expect("<p id=t>");
3963 let dom_id = paragraph["id"].as_u64().unwrap();
3964
3965 let rules = webview
3966 .inspect("getMatchedRules", &format!(r#"{{"domId":{dom_id}}}"#))
3967 .expect("matched rules");
3968 let rules = rules["rules"].as_array().unwrap();
3969
3970 let inline = rules
3971 .iter()
3972 .find(|rule| rule["inline"] == Value::Bool(true))
3973 .expect("inline entry");
3974 assert_eq!(inline["selector"], "element.style");
3975 assert_eq!(inline["declarations"][0]["name"], "margin-top");
3976 assert_eq!(inline["declarations"][0]["value"], "7px");
3977 assert_eq!(inline["declarations"][0]["applied"], true);
3978
3979 let class_rule = rules
3980 .iter()
3981 .find(|rule| rule["selector"] == ".box")
3982 .expect(".box rule");
3983 assert_eq!(class_rule["origin"], "author");
3984 assert_eq!(class_rule["declarations"][0]["applied"], true);
3985
3986 let tag_rule = rules
3989 .iter()
3990 .find(|rule| rule["selector"] == "p" && rule["origin"] == "author")
3991 .expect("author p rule");
3992 let color = tag_rule["declarations"]
3993 .as_array()
3994 .unwrap()
3995 .iter()
3996 .find(|declaration| declaration["name"] == "color")
3997 .unwrap();
3998 assert_eq!(color["applied"], false, ".box must override the p color");
3999
4000 assert!(
4002 rules.iter().any(|rule| rule["origin"] == "user-agent"),
4003 "user-agent origin rules must be reported"
4004 );
4005 }
4006
4007 #[test]
4008 fn computed_style_lists_winning_declarations_sorted_by_name() {
4009 let mut webview = styles_webview();
4010
4011 let document = webview.inspect("getDocument", "{}").expect("document");
4012 let paragraph = find_json_by_attribute(&document, "id", "t").expect("<p id=t>");
4013 let dom_id = paragraph["id"].as_u64().unwrap();
4014
4015 let computed = webview
4016 .inspect("getComputedStyle", &format!(r#"{{"domId":{dom_id}}}"#))
4017 .expect("computed style");
4018 let properties = computed["properties"].as_array().unwrap();
4019
4020 let names: Vec<&str> = properties
4021 .iter()
4022 .map(|property| property["name"].as_str().unwrap())
4023 .collect();
4024 assert_eq!(names, {
4025 let mut sorted = names.clone();
4026 sorted.sort();
4027 sorted
4028 });
4029
4030 let winner = |name: &str| {
4031 properties
4032 .iter()
4033 .find(|property| property["name"] == *name)
4034 .map(|property| property["value"].as_str().unwrap().to_string())
4035 };
4036 assert_eq!(winner("color").as_deref(), Some("blue"));
4037 assert_eq!(winner("margin-top").as_deref(), Some("7px"));
4038 }
4039
4040 #[test]
4041 fn collects_background_images_without_treating_fonts_as_page_images() {
4042 let sources = collect_css_image_sources(
4043 r#"
4044 @font-face { src: url("/fonts/scratch.woff2"); }
4045 .logo { background: white url('/images/logo.svg') no-repeat center; }
4046 .hero { background-image: url("../images/hero.png"); }
4047 "#,
4048 );
4049 assert_eq!(
4050 sources,
4051 vec![
4052 "/images/logo.svg".to_string(),
4053 "../images/hero.png".to_string()
4054 ]
4055 );
4056 }
4057
4058 fn scrollable_info(dom_id: Option<NodeId>, scroll_y: bool, offset_y: f32) -> InfoNode {
4059 InfoNode {
4060 kind: NodeKind::Container {
4061 scroll_x: false,
4062 scroll_y,
4063 scroll_offset_x: 0.0,
4064 scroll_offset_y: offset_y,
4065 style: ContainerStyle::default(),
4066 role: ContainerRole::Normal,
4067 },
4068 children: Vec::new(),
4069 dom_id,
4070 }
4071 }
4072
4073 fn scroll_offsets(info: &InfoNode) -> (f32, f32) {
4074 info.kind.scroll_offsets()
4075 }
4076
4077 fn set_root_scroll_offset(info: &mut InfoNode, offset: f32) {
4078 match &mut info.kind {
4079 NodeKind::Container {
4080 scroll_offset_y, ..
4081 }
4082 | NodeKind::Custom {
4083 scroll_offset_y, ..
4084 } => *scroll_offset_y = offset,
4085 _ => panic!("expected a container root"),
4086 }
4087 }
4088
4089 fn root_scroll_offset(info: &InfoNode) -> Option<f32> {
4090 match &info.kind {
4091 NodeKind::Container {
4092 scroll_offset_y, ..
4093 }
4094 | NodeKind::Custom {
4095 scroll_offset_y, ..
4096 } => Some(*scroll_offset_y),
4097 _ => None,
4098 }
4099 }
4100
4101 fn find_scrollable_offset(info: &InfoNode) -> Option<f32> {
4102 if let NodeKind::Container {
4103 scroll_y: true,
4104 scroll_offset_y,
4105 ..
4106 } = &info.kind
4107 {
4108 return Some(*scroll_offset_y);
4109 }
4110 info.children.iter().find_map(find_scrollable_offset)
4111 }
4112
4113 fn page_scroll_total(info: &InfoNode) -> f32 {
4116 fn sum_scrollable(info: &InfoNode, acc: &mut f32) {
4117 if let NodeKind::Container {
4118 scroll_y: true,
4119 scroll_offset_y,
4120 ..
4121 }
4122 | NodeKind::Custom {
4123 scroll_y: true,
4124 scroll_offset_y,
4125 ..
4126 } = &info.kind
4127 {
4128 *acc += *scroll_offset_y;
4129 }
4130 for child in &info.children {
4131 sum_scrollable(child, acc);
4132 }
4133 }
4134 let mut nested = 0.0;
4135 sum_scrollable(info, &mut nested);
4136 info.kind.scroll_offsets().1 + nested
4137 }
4138
4139 fn set_first_scrollable_offset(info: &mut InfoNode, offset: f32) -> bool {
4140 if let NodeKind::Container {
4141 scroll_y: true,
4142 scroll_offset_y,
4143 ..
4144 } = &mut info.kind
4145 {
4146 *scroll_offset_y = offset;
4147 return true;
4148 }
4149 info.children
4150 .iter_mut()
4151 .any(|c| set_first_scrollable_offset(c, offset))
4152 }
4153
4154 fn layout_box(y: f32, height: f32, children_height: f32) -> ui_layout::LayoutBox {
4155 let rect = |y, height| ui_layout::Rect {
4156 x: 0.0,
4157 y,
4158 width: 800.0,
4159 height,
4160 };
4161 ui_layout::LayoutBox::BlockBox(ui_layout::BoxModel {
4162 sticky_edges: None,
4163 border_box: rect(y, height),
4164 padding_box: rect(y, height),
4165 content_box: rect(y, height),
4166 children_box: rect(y, children_height),
4167 })
4168 }
4169
4170 #[test]
4171 fn dom_layout_metrics_follow_box_geometry_and_scroll_offsets() {
4172 let mut parser = HtmlParser::new(r#"<div id="target"></div>"#);
4173 let dom = Rc::new(parser.parse());
4174 let target = dom.get_element_by_id("target").unwrap();
4175 let dom_refs = vec![Rc::downgrade(&target)];
4176
4177 let mut child = LayoutNode::new(ui_layout::Style::default());
4178 child.layout_box = ui_layout::LayoutBox::BlockBox(ui_layout::BoxModel {
4179 sticky_edges: None,
4180 border_box: ui_layout::Rect {
4181 x: 30.0,
4182 y: 40.0,
4183 width: 120.0,
4184 height: 80.0,
4185 },
4186 padding_box: ui_layout::Rect {
4187 x: 32.0,
4188 y: 42.0,
4189 width: 116.0,
4190 height: 76.0,
4191 },
4192 content_box: ui_layout::Rect {
4193 x: 36.0,
4194 y: 46.0,
4195 width: 108.0,
4196 height: 68.0,
4197 },
4198 children_box: ui_layout::Rect::default(),
4199 });
4200 let mut root = LayoutNode::with_children(ui_layout::Style::default(), [child]);
4201 root.layout_box = ui_layout::LayoutBox::BlockBox(ui_layout::BoxModel {
4202 sticky_edges: None,
4203 border_box: ui_layout::Rect {
4204 width: 800.0,
4205 height: 600.0,
4206 ..Default::default()
4207 },
4208 padding_box: ui_layout::Rect {
4209 width: 800.0,
4210 height: 600.0,
4211 ..Default::default()
4212 },
4213 content_box: ui_layout::Rect {
4214 x: 10.0,
4215 y: 20.0,
4216 width: 780.0,
4217 height: 580.0,
4218 },
4219 children_box: ui_layout::Rect::default(),
4220 });
4221
4222 let mut root_info = scrollable_info(None, true, 7.0);
4223 if let NodeKind::Container {
4224 scroll_offset_x, ..
4225 } = &mut root_info.kind
4226 {
4227 *scroll_offset_x = 5.0;
4228 }
4229 root_info
4230 .children
4231 .push(scrollable_info(Some(0), false, 0.0));
4232
4233 let js_dom_ids = HashMap::from([(Rc::as_ptr(&target) as usize, 42)]);
4234 let measurements =
4235 collect_js_layout_metrics(&root, &root_info, &dom_refs, &js_dom_ids, (800.0, 600.0));
4236 assert_eq!(
4237 measurements.get(&42),
4238 Some(&JsLayoutMetrics {
4239 offset_left: 30.0,
4240 offset_top: 40.0,
4241 offset_width: 120.0,
4242 offset_height: 80.0,
4243 client_width: 116.0,
4244 client_height: 76.0,
4245 rect_left: 35.0,
4246 rect_top: 53.0,
4247 rect_width: 120.0,
4248 rect_height: 80.0,
4249 })
4250 );
4251 }
4252
4253 #[test]
4254 fn dom_layout_metrics_apply_sticky_paint_offsets() {
4255 let mut parser = HtmlParser::new(r#"<div id="target"></div>"#);
4256 let dom = Rc::new(parser.parse());
4257 let target = dom.get_element_by_id("target").unwrap();
4258 let dom_refs = vec![Rc::downgrade(&target)];
4259
4260 let sticky_edges = Some(ui_layout::EdgeOption {
4264 top: Some(10.0),
4265 ..Default::default()
4266 });
4267 let sticky_rect = |y| ui_layout::Rect {
4268 x: 0.0,
4269 y,
4270 width: 60.0,
4271 height: 40.0,
4272 };
4273 let mut sticky_layout = LayoutNode::new(ui_layout::Style {
4274 position: ui_layout::PositionStyle {
4275 kind: ui_layout::Position::Sticky,
4276 ..Default::default()
4277 },
4278 ..Default::default()
4279 });
4280 sticky_layout.layout_box = ui_layout::LayoutBox::BlockBox(ui_layout::BoxModel {
4281 sticky_edges,
4282 border_box: sticky_rect(5.0),
4283 padding_box: sticky_rect(5.0),
4284 content_box: sticky_rect(5.0),
4285 children_box: sticky_rect(5.0),
4286 });
4287 let mut root_layout =
4288 LayoutNode::with_children(ui_layout::Style::default(), [sticky_layout]);
4289 root_layout.layout_box = layout_box(0.0, 3000.0, 3000.0);
4290
4291 let mut root_info = scrollable_info(None, true, 0.0);
4292 root_info
4293 .children
4294 .push(scrollable_info(Some(0), false, 0.0));
4295
4296 let js_dom_ids = HashMap::from([(Rc::as_ptr(&target) as usize, 42)]);
4297 let measurements = collect_js_layout_metrics(
4298 &root_layout,
4299 &root_info,
4300 &dom_refs,
4301 &js_dom_ids,
4302 (800.0, 600.0),
4303 );
4304 assert_eq!(
4305 measurements.get(&42),
4306 Some(&JsLayoutMetrics {
4307 offset_left: 0.0,
4308 offset_top: 5.0,
4309 offset_width: 60.0,
4310 offset_height: 40.0,
4311 client_width: 60.0,
4312 client_height: 40.0,
4313 rect_left: 0.0,
4314 rect_top: 10.0,
4315 rect_width: 60.0,
4316 rect_height: 40.0,
4317 })
4318 );
4319 }
4320
4321 #[test]
4322 fn fragment_waits_for_the_styled_layout_generation() {
4323 assert!(!fragment_layout_is_ready(None, 5));
4324 assert!(!fragment_layout_is_ready(Some(5), 4));
4325 assert!(fragment_layout_is_ready(Some(5), 5));
4326 assert!(fragment_layout_is_ready(Some(5), 6));
4327 }
4328
4329 #[test]
4330 fn fragment_target_scrolls_the_page_to_its_border_box() {
4331 let mut target_layout = LayoutNode::new(ui_layout::Style::default());
4332 target_layout.layout_box = layout_box(900.0, 100.0, 100.0);
4333 let mut root_layout =
4334 LayoutNode::with_children(ui_layout::Style::default(), [target_layout]);
4335 root_layout.layout_box = layout_box(0.0, 600.0, 2000.0);
4336
4337 let mut root_info = scrollable_info(Some(1), true, 0.0);
4338 root_info
4339 .children
4340 .push(scrollable_info(Some(7), false, 0.0));
4341
4342 assert!(apply_fragment_scroll(
4343 &root_layout,
4344 &mut root_info,
4345 7,
4346 600.0
4347 ));
4348 assert_eq!(root_scroll_offset(&root_info), Some(900.0));
4349 }
4350
4351 #[test]
4352 fn fragment_scroll_scrolls_each_container_only_for_its_own_view() {
4353 let mut target_layout = LayoutNode::new(ui_layout::Style::default());
4361 target_layout.layout_box = layout_box(800.0, 100.0, 100.0);
4362 let mut inner_layout =
4363 LayoutNode::with_children(ui_layout::Style::default(), [target_layout]);
4364 inner_layout.layout_box = layout_box(100.0, 300.0, 1000.0);
4365 let mut outer_layout =
4366 LayoutNode::with_children(ui_layout::Style::default(), [inner_layout]);
4367 outer_layout.layout_box = layout_box(0.0, 600.0, 3000.0);
4368 let mut sibling_layout = LayoutNode::new(ui_layout::Style::default());
4369 sibling_layout.layout_box = layout_box(0.0, 300.0, 300.0);
4370 let mut root_layout =
4371 LayoutNode::with_children(ui_layout::Style::default(), [outer_layout, sibling_layout]);
4372 root_layout.layout_box = layout_box(0.0, 600.0, 3000.0);
4373
4374 let target_info = scrollable_info(Some(7), false, 0.0);
4375 let sibling_info = scrollable_info(Some(8), true, 0.0);
4376 let mut inner_info = scrollable_info(Some(2), true, 0.0);
4377 inner_info.children.push(target_info);
4378 let mut outer_info = scrollable_info(Some(3), true, 0.0);
4379 outer_info.children.push(inner_info);
4380 let mut root_info = scrollable_info(Some(1), false, 0.0);
4381 root_info.children.push(outer_info);
4382 root_info.children.push(sibling_info);
4383
4384 assert!(apply_fragment_scroll(
4385 &root_layout,
4386 &mut root_info,
4387 7,
4388 600.0
4389 ));
4390
4391 let offset = |node: &InfoNode| {
4392 let NodeKind::Container {
4393 scroll_offset_y, ..
4394 } = &node.kind
4395 else {
4396 panic!("expected a container");
4397 };
4398 *scroll_offset_y
4399 };
4400 assert_eq!(offset(&root_info.children[0].children[0]), 700.0);
4404 assert_eq!(offset(&root_info.children[0]), 200.0);
4405 assert!(offset(&root_info).abs() < f32::EPSILON);
4406 assert_eq!(offset(&root_info.children[1]), 0.0);
4408 }
4409
4410 #[test]
4411 fn percent_encoded_fragment_is_decoded_for_id_lookup() {
4412 assert_eq!(percent_decode_fragment("my%20target"), "my target");
4413 assert_eq!(percent_decode_fragment("plain"), "plain");
4414 assert_eq!(percent_decode_fragment("%E3%81%82"), "あ");
4415 assert_eq!(percent_decode_fragment("100%"), "100%");
4416 assert_eq!(percent_decode_fragment("a%2Fb"), "a/b");
4417 }
4418
4419 #[test]
4420 fn loaded_document_scrolls_plain_page_to_its_url_fragment() {
4421 let html = r#"<html><body><div style="height: 2000px;"></div><div id="deep">deep</div></body></html>"#;
4425 let mut wv = WebView::new(ColorScheme::Light, JsPolicy::default());
4426 wv.tick();
4427 wv.on_html_fetched(
4428 html.to_string(),
4429 Url::parse("https://example.test/page#deep").unwrap(),
4430 );
4431 for _ in 0..500 {
4432 wv.tick();
4433 if !wv.layout_pending && wv.layout_and_info().is_some() {
4434 break;
4435 }
4436 std::thread::sleep(std::time::Duration::from_millis(2));
4437 }
4438
4439 wv.relayout((800.0, 600.0));
4440
4441 let (_, info) = wv.layout_and_info().expect("layout not ready");
4442 let y = page_scroll_total(info);
4443 assert!(
4444 y > 1000.0,
4445 "expected a deep scroll near the target, got {y}"
4446 );
4447 }
4448
4449 #[test]
4450 fn percent_encoded_fragment_reaches_the_decoded_element() {
4451 let html = r#"<html><body><div style="height: 2000px;"></div><div id="my target">t</div></body></html>"#;
4452 let mut wv = WebView::new(ColorScheme::Light, JsPolicy::default());
4453 wv.tick();
4454 wv.on_html_fetched(
4455 html.to_string(),
4456 Url::parse("https://example.test/page#my%20target").unwrap(),
4457 );
4458 for _ in 0..500 {
4459 wv.tick();
4460 if !wv.layout_pending && wv.layout_and_info().is_some() {
4461 break;
4462 }
4463 std::thread::sleep(std::time::Duration::from_millis(2));
4464 }
4465
4466 wv.relayout((800.0, 600.0));
4467
4468 let (_, info) = wv.layout_and_info().expect("layout not ready");
4469 let y = page_scroll_total(info);
4470 assert!(
4471 y > 1000.0,
4472 "expected a deep scroll near the target, got {y}"
4473 );
4474 }
4475
4476 #[test]
4477 fn scroll_to_fragment_scrolls_existing_document_without_reload() {
4478 let html = r#"<html><body><div style="height: 2000px;"></div><div id="another">a</div></body></html>"#;
4479 let mut wv = WebView::new(ColorScheme::Light, JsPolicy::default());
4480 wv.tick();
4481 wv.on_html_fetched(
4482 html.to_string(),
4483 Url::parse("https://example.test/page").unwrap(),
4484 );
4485 for _ in 0..500 {
4486 wv.tick();
4487 if !wv.layout_pending && wv.layout_and_info().is_some() {
4488 break;
4489 }
4490 std::thread::sleep(std::time::Duration::from_millis(2));
4491 }
4492 wv.relayout((800.0, 600.0));
4493 assert_eq!(
4494 root_scroll_offset(wv.layout_and_info().unwrap().1),
4495 Some(0.0)
4496 );
4497
4498 wv.scroll_to_fragment(&Url::parse("https://example.test/page#another").unwrap());
4500 wv.relayout((800.0, 600.0));
4501
4502 let (_, info) = wv.layout_and_info().expect("layout");
4503 let y = page_scroll_total(info);
4504 assert!(y > 1000.0, "expected a deep scroll, got {y}");
4505 assert_eq!(
4506 wv.document_url().map(Url::as_str),
4507 Some("https://example.test/page#another")
4508 );
4509 }
4510
4511 #[test]
4512 fn scroll_to_empty_fragment_returns_to_the_top() {
4513 let html = r#"<html><body><div style="height: 2000px;"></div><div id="deep">deep</div></body></html>"#;
4514 let mut wv = WebView::new(ColorScheme::Light, JsPolicy::default());
4515 wv.tick();
4516 wv.on_html_fetched(
4517 html.to_string(),
4518 Url::parse("https://example.test/page#deep").unwrap(),
4519 );
4520 for _ in 0..500 {
4521 wv.tick();
4522 if !wv.layout_pending && wv.layout_and_info().is_some() {
4523 break;
4524 }
4525 std::thread::sleep(std::time::Duration::from_millis(2));
4526 }
4527 wv.relayout((800.0, 600.0));
4528 assert!(page_scroll_total(wv.layout_and_info().unwrap().1) > 1000.0);
4529
4530 wv.scroll_to_fragment(&Url::parse("https://example.test/page").unwrap());
4532 wv.relayout((800.0, 600.0));
4533
4534 assert_eq!(page_scroll_total(wv.layout_and_info().unwrap().1), 0.0);
4535 }
4536
4537 #[test]
4538 fn resize_preserves_scroll_offset() {
4539 let html = r#"<html><body><div style="height: 300px; overflow-y: auto;"><div style="height: 3000px;"></div></div></body></html>"#;
4543 let mut wv = WebView::new(ColorScheme::Light, JsPolicy::default());
4544 wv.tick();
4545 wv.on_html_fetched(
4546 html.to_string(),
4547 Url::parse("https://example.test/").unwrap(),
4548 );
4549
4550 for _ in 0..500 {
4551 wv.tick();
4552 if !wv.layout_pending && wv.layout_and_info().is_some() {
4553 break;
4554 }
4555 std::thread::sleep(std::time::Duration::from_millis(2));
4556 }
4557 {
4558 let (_, info) = wv.layout_and_info_mut().expect("layout not ready");
4559 assert!(
4560 set_first_scrollable_offset(info, 500.0),
4561 "expected a scrollable container"
4562 );
4563 }
4564
4565 wv.relayout((1000.0, 700.0));
4566
4567 for _ in 0..500 {
4568 wv.tick();
4569 if !wv.layout_pending {
4570 break;
4571 }
4572 std::thread::sleep(std::time::Duration::from_millis(2));
4573 }
4574
4575 let (_, info) = wv.layout_and_info().expect("layout not ready after resize");
4576 assert_eq!(find_scrollable_offset(info), Some(500.0));
4577 }
4578
4579 #[test]
4580 fn resize_preserves_page_scroll_on_root() {
4581 let html = r#"<html><body><div style="height: 3000px;"></div></body></html>"#;
4584 let mut wv = WebView::new(ColorScheme::Light, JsPolicy::default());
4585 wv.tick();
4586 wv.on_html_fetched(
4587 html.to_string(),
4588 Url::parse("https://example.test/").unwrap(),
4589 );
4590
4591 for _ in 0..500 {
4592 wv.tick();
4593 if !wv.layout_pending && wv.layout_and_info().is_some() {
4594 break;
4595 }
4596 std::thread::sleep(std::time::Duration::from_millis(2));
4597 }
4598 {
4599 let (_, info) = wv.layout_and_info_mut().expect("layout not ready");
4600 set_root_scroll_offset(info, 500.0);
4601 }
4602
4603 wv.relayout((1000.0, 700.0));
4604
4605 for _ in 0..500 {
4606 wv.tick();
4607 if !wv.layout_pending {
4608 break;
4609 }
4610 std::thread::sleep(std::time::Duration::from_millis(2));
4611 }
4612
4613 let (_, info) = wv.layout_and_info().expect("layout not ready after resize");
4614 assert_eq!(root_scroll_offset(info), Some(500.0));
4615 }
4616
4617 #[test]
4618 fn captures_and_restores_scroll_offsets_across_rebuild() {
4619 let mut old_info = scrollable_info(Some(1), true, 100.0);
4621 old_info.children.push(scrollable_info(Some(2), true, 50.0));
4622
4623 let mut offsets = HashMap::new();
4624 capture_scroll_offsets(&old_info, &mut offsets);
4625 assert_eq!(
4626 offsets,
4627 HashMap::from([(1u32, (0.0, 100.0)), (2u32, (0.0, 50.0))])
4628 );
4629
4630 let mut new_info = scrollable_info(Some(1), true, 0.0);
4632 new_info.children.push(scrollable_info(Some(2), true, 0.0));
4633
4634 apply_scroll_offsets(&mut new_info, &offsets);
4635
4636 assert_eq!(scroll_offsets(&new_info), (0.0, 100.0));
4637 assert_eq!(scroll_offsets(&new_info.children[0]), (0.0, 50.0));
4638 }
4639
4640 #[test]
4641 fn offsets_are_restored_verbatim_even_on_non_scrollable_axes() {
4642 let old_info = InfoNode {
4644 kind: NodeKind::Container {
4645 scroll_x: true,
4646 scroll_y: false,
4647 scroll_offset_x: 50.0,
4648 scroll_offset_y: 0.0,
4649 style: ContainerStyle::default(),
4650 role: ContainerRole::Normal,
4651 },
4652 children: Vec::new(),
4653 dom_id: Some(3),
4654 };
4655
4656 let mut offsets = HashMap::new();
4657 capture_scroll_offsets(&old_info, &mut offsets);
4658 assert_eq!(offsets, HashMap::from([(3u32, (50.0, 0.0))]));
4659
4660 let mut new_info = InfoNode {
4664 kind: NodeKind::Container {
4665 scroll_x: false,
4666 scroll_y: false,
4667 scroll_offset_x: 0.0,
4668 scroll_offset_y: 0.0,
4669 style: ContainerStyle::default(),
4670 role: ContainerRole::Normal,
4671 },
4672 children: Vec::new(),
4673 dom_id: Some(3),
4674 };
4675
4676 apply_scroll_offsets(&mut new_info, &offsets);
4677
4678 assert_eq!(scroll_offsets(&new_info), (50.0, 0.0));
4679 }
4680
4681 #[test]
4682 fn inline_styles_recover_after_an_unsupported_rule() {
4683 let mut webview = WebView::default();
4684 webview.on_html_fetched(
4685 r#"<style>@media { @broken } .valid { color: green; }</style><div class="valid">ok</div>"#
4686 .to_string(),
4687 Url::parse("https://example.test/").unwrap(),
4688 );
4689
4690 assert!(webview.resolved_styles.iter().any(|declaration| {
4691 declaration.name == "color"
4692 && declaration
4693 .selector
4694 .parts
4695 .iter()
4696 .any(|part| part.selector.classes.iter().any(|class| class == "valid"))
4697 }));
4698 }
4699
4700 #[test]
4701 fn javascript_inserted_style_elements_are_resolved() {
4702 let mut webview = WebView::default();
4703 webview.on_html_fetched(
4704 r#"<html><body><div class="dynamic">ok</div></body></html>"#.to_string(),
4705 Url::parse("https://example.test/").unwrap(),
4706 );
4707 webview.send_script(
4708 r#"
4709 const style = document.createElement("style");
4710 style.textContent = ".dynamic { color: red; }";
4711 document.documentElement.appendChild(style);
4712 "#,
4713 Some("test: inserted style"),
4714 );
4715
4716 pump_until(
4717 &mut webview,
4718 |wv| {
4719 wv.resolved_styles.iter().any(|declaration| {
4720 declaration.name == "color"
4721 && declaration.selector.parts.iter().any(|part| {
4722 part.selector.classes.iter().any(|class| class == "dynamic")
4723 })
4724 })
4725 },
4726 "JS-inserted style element to be resolved",
4727 );
4728 }
4729
4730 fn collect_text(info: &InfoNode) -> String {
4732 let mut text = match &info.kind {
4733 NodeKind::Text { text, .. } => text.clone(),
4734 _ => String::new(),
4735 };
4736 for child in &info.children {
4737 text.push_str(&collect_text(child));
4738 }
4739 text
4740 }
4741
4742 fn has_300x150_box(node: &LayoutNode) -> bool {
4746 let sized = matches!(node.style.size.width, LengthOrAuto::Length(Length::Px(w)) if (w - 300.0).abs() < 0.001)
4747 && matches!(node.style.size.height, LengthOrAuto::Length(Length::Px(h)) if (h - 150.0).abs() < 0.001);
4748 sized
4749 || node
4750 .children
4751 .iter()
4752 .filter_map(LayoutChild::node)
4753 .any(has_300x150_box)
4754 }
4755
4756 fn iframe_holds_grafted_content(info: &InfoNode) -> bool {
4760 if matches!(
4761 info.kind,
4762 NodeKind::Container {
4763 scroll_x: true,
4764 scroll_y: true,
4765 ..
4766 }
4767 ) {
4768 let text = collect_text(info);
4769 if text.contains("grafted inner paragraph") && !text.contains("host paragraph") {
4770 return true;
4771 }
4772 }
4773 info.children.iter().any(iframe_holds_grafted_content)
4774 }
4775
4776 fn paint_text(webview: &WebView) -> String {
4780 let Some((layout, info)) = webview.layout_and_info() else {
4781 return String::new();
4782 };
4783 let mut commands = Vec::new();
4784 crate::engine::renderer_model::generate_draw_commands(
4785 &mut commands,
4786 layout,
4787 info,
4788 (800.0, 600.0),
4789 );
4790 let mut text = String::new();
4791 for command in &commands {
4792 if let crate::engine::renderer_model::DrawCommand::DrawText { text: run, .. } = command
4793 {
4794 text.push_str(run);
4795 }
4796 }
4797 text.chars().filter(|c| !c.is_whitespace()).collect()
4798 }
4799
4800 #[test]
4801 fn iframe_content_documents_are_fetched_grafted_and_laid_out() {
4802 let mut webview = WebView::default();
4803 webview.tick();
4804 webview.on_html_fetched(
4805 r#"<html><body><p>host paragraph</p></body></html>"#.to_string(),
4806 Url::parse("https://example.test/inside-host.html").unwrap(),
4807 );
4808 webview.send_script(
4811 r#"
4812 const frame = document.createElement("iframe");
4813 document.body.appendChild(frame);
4814 frame.src = "https://example.test/inside.html";
4815 "#,
4816 Some("test: iframe src"),
4817 );
4818
4819 let task = pump_for_task(
4821 &mut webview,
4822 |task| {
4823 matches!(
4824 task,
4825 WebViewTask::Fetch {
4826 kind: FetchKind::Iframe { .. },
4827 ..
4828 }
4829 )
4830 },
4831 "iframe fetch request",
4832 );
4833 let (url, dom_id) = match task {
4834 WebViewTask::Fetch {
4835 url,
4836 kind: FetchKind::Iframe { dom_id },
4837 } => (url, dom_id),
4838 _ => unreachable!("pump_for_task only returns matching tasks"),
4839 };
4840 assert_eq!(url.as_str(), "https://example.test/inside.html");
4841
4842 webview.on_iframe_fetched(
4846 dom_id,
4847 r#"<html><body><p>grafted inner paragraph</p></body></html>"#.to_string(),
4848 url,
4849 );
4850
4851 pump_until(
4852 &mut webview,
4853 |wv| {
4854 let Some((layout, info)) = wv.layout_and_info() else {
4855 return false;
4856 };
4857 iframe_holds_grafted_content(info)
4858 && collect_text(info).contains("host paragraph")
4859 && has_300x150_box(layout)
4860 },
4861 "grafted iframe content to reach the layout",
4862 );
4863
4864 let painted = paint_text(&webview);
4867 assert!(
4868 painted.contains("hostparagraph"),
4869 "host page text must be painted"
4870 );
4871 assert!(
4872 painted.contains("graftedinnerparagraph"),
4873 "iframe content text must be painted"
4874 );
4875 }
4876
4877 #[test]
4878 fn markup_declared_iframes_load_content_without_javascript() {
4879 let mut webview = WebView::default();
4880 webview.tick();
4881 webview.on_html_fetched(
4885 r#"<html><body><p>host paragraph</p><iframe src="https://example.test/inside.html"></iframe></body></html>"#.to_string(),
4886 Url::parse("https://example.test/inside-host.html").unwrap(),
4887 );
4888
4889 let task = pump_for_task(
4890 &mut webview,
4891 |task| {
4892 matches!(
4893 task,
4894 WebViewTask::Fetch {
4895 kind: FetchKind::Iframe { .. },
4896 ..
4897 }
4898 )
4899 },
4900 "fetch request for a markup-declared iframe",
4901 );
4902 let (url, dom_id) = match task {
4903 WebViewTask::Fetch {
4904 url,
4905 kind: FetchKind::Iframe { dom_id },
4906 } => (url, dom_id),
4907 _ => unreachable!("pump_for_task only returns matching tasks"),
4908 };
4909 assert_eq!(url.as_str(), "https://example.test/inside.html");
4910
4911 webview.on_iframe_fetched(
4912 dom_id,
4913 r#"<html><body><p>grafted inner paragraph</p></body></html>"#.to_string(),
4914 url,
4915 );
4916 pump_until(
4917 &mut webview,
4918 |wv| {
4919 let Some((layout, info)) = wv.layout_and_info() else {
4920 return false;
4921 };
4922 iframe_holds_grafted_content(info)
4923 && collect_text(info).contains("host paragraph")
4924 && has_300x150_box(layout)
4925 },
4926 "markup-declared iframe content to reach the layout",
4927 );
4928
4929 let painted = paint_text(&webview);
4930 assert!(
4931 painted.contains("hostparagraph"),
4932 "host page text must be painted"
4933 );
4934 assert!(
4935 painted.contains("graftedinnerparagraph"),
4936 "iframe content text must be painted"
4937 );
4938 }
4939
4940 #[test]
4941 fn parse_html_resolves_image_sources_against_base_url() {
4942 let parsed = parse_html(
4943 r#"<base href="https://cdn.example/assets/"><img src="logo.png"><img>"#,
4944 Url::parse("https://example.test/page/index.html").unwrap(),
4945 ScriptingMode::Enabled,
4946 );
4947
4948 assert_eq!(parsed.image_sources.len(), 1);
4949 assert_eq!(parsed.image_sources[0].0, "logo.png");
4950 assert_eq!(
4951 parsed.image_sources[0].1.as_str(),
4952 "https://cdn.example/assets/logo.png"
4953 );
4954 }
4955
4956 #[test]
4957 fn parse_html_resolves_audio_and_child_source_urls() {
4958 let parsed = parse_html(
4959 r#"<base href="https://cdn.example/media/"><audio src="one.mp3"></audio><audio><source src="two.ogg"></audio>"#,
4960 Url::parse("https://example.test/index.html").unwrap(),
4961 ScriptingMode::Enabled,
4962 );
4963
4964 assert_eq!(
4965 parsed.audio_sources,
4966 [
4967 (
4968 "one.mp3".to_string(),
4969 Url::parse("https://cdn.example/media/one.mp3").unwrap()
4970 ),
4971 (
4972 "two.ogg".to_string(),
4973 Url::parse("https://cdn.example/media/two.ogg").unwrap()
4974 ),
4975 ]
4976 );
4977 }
4978
4979 #[test]
4980 fn parse_html_resolves_external_classic_scripts_in_document_order() {
4981 let parsed = parse_html(
4982 r#"<base href="https://cdn.example/js/"><script>let a = 1;</script><script src="one.js"></script><script src="/two.js"></script>"#,
4983 Url::parse("https://example.test/page/index.html").unwrap(),
4984 ScriptingMode::Enabled,
4985 );
4986
4987 assert_eq!(
4988 parsed.scripts,
4989 [
4990 ClassicScript::Inline("let a = 1;".to_string()),
4991 ClassicScript::External {
4992 url: Url::parse("https://cdn.example/js/one.js").unwrap(),
4993 execution: ClassicScriptExecution::Default,
4994 },
4995 ClassicScript::External {
4996 url: Url::parse("https://cdn.example/two.js").unwrap(),
4997 execution: ClassicScriptExecution::Default,
4998 },
4999 ]
5000 );
5001 }
5002
5003 #[test]
5004 fn external_classic_scripts_fetch_and_execute_in_document_order() {
5005 let mut webview = WebView::default();
5006 webview.on_html_fetched(
5007 r#"
5008 <div id="result"></div>
5009 <script>let order = "a";</script>
5010 <script src="one.js"></script>
5011 <script>order = order + "c";</script>
5012 <script src="two.js"></script>
5013 "#
5014 .to_string(),
5015 Url::parse("https://example.test/path/index.html").unwrap(),
5016 );
5017
5018 assert!(webview.tick().is_empty());
5019 let first_tasks = webview.tick();
5020 assert_eq!(first_tasks.len(), 1);
5021 match &first_tasks[0] {
5022 WebViewTask::Fetch {
5023 url,
5024 kind: FetchKind::Script { index },
5025 } => {
5026 assert_eq!(*index, 1);
5027 assert_eq!(url.as_str(), "https://example.test/path/one.js");
5028 }
5029 _ => panic!("expected first external classic script fetch"),
5030 }
5031
5032 webview.on_script_fetched(1, r#"order = order + "b";"#.to_string());
5033 let second_tasks = webview.tick();
5034 assert_eq!(second_tasks.len(), 1);
5035 match &second_tasks[0] {
5036 WebViewTask::Fetch {
5037 url,
5038 kind: FetchKind::Script { index },
5039 } => {
5040 assert_eq!(*index, 3);
5041 assert_eq!(url.as_str(), "https://example.test/path/two.js");
5042 }
5043 _ => panic!("expected second external classic script fetch"),
5044 }
5045
5046 let result = webview
5047 .document_info()
5048 .unwrap()
5049 .dom
5050 .get_element_by_id("result")
5051 .unwrap();
5052 assert_eq!(result.borrow().value.get_attr("data-order"), None);
5053
5054 webview.on_script_fetched(
5055 3,
5056 r#"document.getElementById("result").setAttribute("data-order", order + "d");"#
5057 .to_string(),
5058 );
5059 pump_until(
5060 &mut webview,
5061 |wv| {
5062 wv.document_info()
5063 .unwrap()
5064 .dom
5065 .get_element_by_id("result")
5066 .is_some_and(|node| node.borrow().value.get_attr("data-order").is_some())
5067 },
5068 "final classic script result to be committed",
5069 );
5070 let result = webview
5071 .document_info()
5072 .unwrap()
5073 .dom
5074 .get_element_by_id("result")
5075 .unwrap();
5076 assert_eq!(result.borrow().value.get_attr("data-order"), Some("abcd"));
5077 assert_eq!(webview.phase, PagePhase::ScriptApplied);
5078 }
5079
5080 #[test]
5081 fn failed_external_classic_script_does_not_block_later_scripts() {
5082 let mut webview = WebView::default();
5083 webview.on_html_fetched(
5084 r#"
5085 <div id="result"></div>
5086 <script src="missing.js"></script>
5087 <script>document.getElementById("result").setAttribute("data-ran", "yes");</script>
5088 "#
5089 .to_string(),
5090 Url::parse("https://example.test/index.html").unwrap(),
5091 );
5092
5093 assert!(webview.tick().is_empty());
5094 let tasks = webview.tick();
5095 assert!(matches!(
5096 tasks.as_slice(),
5097 [WebViewTask::Fetch {
5098 kind: FetchKind::Script { index: 0 },
5099 ..
5100 }]
5101 ));
5102
5103 webview.on_script_fetch_failed(0);
5104 pump_until(
5105 &mut webview,
5106 |wv| {
5107 wv.document_info()
5108 .unwrap()
5109 .dom
5110 .get_element_by_id("result")
5111 .is_some_and(|node| node.borrow().value.get_attr("data-ran").is_some())
5112 },
5113 "inline script after a failed external script",
5114 );
5115 let result = webview
5116 .document_info()
5117 .unwrap()
5118 .dom
5119 .get_element_by_id("result")
5120 .unwrap();
5121 assert_eq!(result.borrow().value.get_attr("data-ran"), Some("yes"));
5122 }
5123
5124 #[test]
5125 fn dom_content_loaded_fires_after_external_classic_scripts_finish() {
5126 let mut webview = WebView::default();
5127 webview.on_html_fetched(
5128 r#"
5129 <div id="result"></div>
5130 <script>
5131 document.addEventListener("DOMContentLoaded", function () {
5132 const result = document.getElementById("result");
5133 result.setAttribute("data-ready", result.getAttribute("data-external"));
5134 });
5135 </script>
5136 <script src="setup.js"></script>
5137 "#
5138 .to_string(),
5139 Url::parse("https://example.test/index.html").unwrap(),
5140 );
5141
5142 assert!(webview.tick().is_empty());
5143 let tasks = webview.tick();
5144 assert!(matches!(
5145 tasks.as_slice(),
5146 [WebViewTask::Fetch {
5147 kind: FetchKind::Script { index: 1 },
5148 ..
5149 }]
5150 ));
5151
5152 let result = webview
5153 .document_info()
5154 .unwrap()
5155 .dom
5156 .get_element_by_id("result")
5157 .unwrap();
5158 assert_eq!(result.borrow().value.get_attr("data-ready"), None);
5159
5160 webview.on_script_fetched(
5161 1,
5162 r#"document.getElementById("result").setAttribute("data-external", "yes");"#
5163 .to_string(),
5164 );
5165 assert_eq!(result.borrow().value.get_attr("data-ready"), None);
5166
5167 pump_until(
5168 &mut webview,
5169 |wv| {
5170 wv.document_info()
5171 .unwrap()
5172 .dom
5173 .get_element_by_id("result")
5174 .is_some_and(|node| node.borrow().value.get_attr("data-ready").is_some())
5175 },
5176 "DOMContentLoaded listener to run",
5177 );
5178 let result = webview
5179 .document_info()
5180 .unwrap()
5181 .dom
5182 .get_element_by_id("result")
5183 .unwrap();
5184 assert_eq!(result.borrow().value.get_attr("data-ready"), Some("yes"));
5185 assert_eq!(webview.phase, PagePhase::ScriptApplied);
5186 }
5187
5188 #[test]
5189 fn window_onload_fires_after_the_page_stabilizes() {
5190 let mut webview = WebView::default();
5191 webview.on_html_fetched(
5192 r#"
5193 <div id="result"></div>
5194 <script>
5195 window.onload = function () {
5196 document.getElementById("result").setAttribute("data-loaded", "yes");
5197 };
5198 </script>
5199 "#
5200 .to_string(),
5201 Url::parse("https://example.test/index.html").unwrap(),
5202 );
5203
5204 pump_until(
5205 &mut webview,
5206 |wv| {
5207 wv.document_info()
5208 .unwrap()
5209 .dom
5210 .get_element_by_id("result")
5211 .is_some_and(|node| node.borrow().value.get_attr("data-loaded").is_some())
5212 },
5213 "window.onload to run",
5214 );
5215 let result = webview
5216 .document_info()
5217 .unwrap()
5218 .dom
5219 .get_element_by_id("result")
5220 .unwrap();
5221 assert_eq!(result.borrow().value.get_attr("data-loaded"), Some("yes"));
5222 assert_eq!(webview.phase, PagePhase::ScriptApplied);
5223 }
5224
5225 #[test]
5226 fn deferred_scripts_fetch_in_parallel_and_execute_in_document_order() {
5227 let mut webview = WebView::default();
5228 webview.on_html_fetched(
5229 r#"
5230 <div id="result"></div>
5231 <script>let order = "inline";</script>
5232 <script defer src="first.js"></script>
5233 <script defer src="second.js"></script>
5234 <script>
5235 document.addEventListener("DOMContentLoaded", function () {
5236 document.getElementById("result").setAttribute("data-order", order);
5237 });
5238 </script>
5239 "#
5240 .to_string(),
5241 Url::parse("https://example.test/index.html").unwrap(),
5242 );
5243
5244 assert!(webview.tick().is_empty());
5245 let tasks = webview.tick();
5246 assert_eq!(tasks.len(), 2);
5247 assert!(matches!(
5248 tasks[0],
5249 WebViewTask::Fetch {
5250 kind: FetchKind::Script { index: 1 },
5251 ..
5252 }
5253 ));
5254 assert!(matches!(
5255 tasks[1],
5256 WebViewTask::Fetch {
5257 kind: FetchKind::Script { index: 2 },
5258 ..
5259 }
5260 ));
5261
5262 webview.on_script_fetched(2, r#"order = order + " > second";"#.to_string());
5263 assert!(webview.tick().is_empty());
5264 assert_ne!(webview.phase, PagePhase::ScriptApplied);
5265
5266 webview.on_script_fetched(1, r#"order = order + " > first";"#.to_string());
5267 pump_until(
5268 &mut webview,
5269 |wv| {
5270 wv.document_info()
5271 .unwrap()
5272 .dom
5273 .get_element_by_id("result")
5274 .is_some_and(|node| node.borrow().value.get_attr("data-order").is_some())
5275 },
5276 "deferred scripts and DOMContentLoaded to run",
5277 );
5278 let result = webview
5279 .document_info()
5280 .unwrap()
5281 .dom
5282 .get_element_by_id("result")
5283 .unwrap();
5284 assert_eq!(
5285 result.borrow().value.get_attr("data-order"),
5286 Some("inline > first > second")
5287 );
5288 assert_eq!(webview.phase, PagePhase::ScriptApplied);
5289 }
5290
5291 #[test]
5292 fn async_script_executes_on_arrival_without_blocking_dom_content_loaded() {
5293 let mut webview = WebView::default();
5294 webview.on_html_fetched(
5295 r#"
5296 <div id="result"></div>
5297 <script async src="async.js"></script>
5298 <script>
5299 document.addEventListener("DOMContentLoaded", function () {
5300 document.getElementById("result").setAttribute("data-ready", "yes");
5301 });
5302 </script>
5303 "#
5304 .to_string(),
5305 Url::parse("https://example.test/index.html").unwrap(),
5306 );
5307
5308 assert!(webview.tick().is_empty());
5309 let tasks = webview.tick();
5310 assert!(matches!(
5311 tasks.as_slice(),
5312 [WebViewTask::Fetch {
5313 kind: FetchKind::Script { index: 0 },
5314 ..
5315 }]
5316 ));
5317
5318 pump_until(
5319 &mut webview,
5320 |wv| {
5321 wv.document_info()
5322 .unwrap()
5323 .dom
5324 .get_element_by_id("result")
5325 .is_some_and(|node| node.borrow().value.get_attr("data-ready").is_some())
5326 },
5327 "DOMContentLoaded before the async script arrives",
5328 );
5329 let result = webview
5330 .document_info()
5331 .unwrap()
5332 .dom
5333 .get_element_by_id("result")
5334 .unwrap();
5335 assert_eq!(result.borrow().value.get_attr("data-ready"), Some("yes"));
5336 assert_eq!(result.borrow().value.get_attr("data-async"), None);
5337 assert_eq!(webview.phase, PagePhase::ScriptApplied);
5338
5339 webview.on_script_fetched(
5340 0,
5341 r#"document.getElementById("result").setAttribute("data-async", "yes");"#.to_string(),
5342 );
5343 pump_until(
5344 &mut webview,
5345 |wv| {
5346 wv.document_info()
5347 .unwrap()
5348 .dom
5349 .get_element_by_id("result")
5350 .is_some_and(|node| node.borrow().value.get_attr("data-async").is_some())
5351 },
5352 "async script to run after DOMContentLoaded",
5353 );
5354 let result = webview
5355 .document_info()
5356 .unwrap()
5357 .dom
5358 .get_element_by_id("result")
5359 .unwrap();
5360 assert_eq!(result.borrow().value.get_attr("data-async"), Some("yes"));
5361 }
5362
5363 #[test]
5364 fn javascript_fetch_uses_document_url_and_resolves_response() {
5365 let mut webview = WebView::default();
5366 webview.on_html_fetched(
5367 r#"
5368 <div id="result"></div>
5369 <script>
5370 fetch("../message.txt")
5371 .then(response => response.text())
5372 .then(text => {
5373 document.getElementById("result").setAttribute("data-text", text);
5374 });
5375 </script>
5376 "#
5377 .to_string(),
5378 Url::parse("https://example.test/path/index.html").unwrap(),
5379 );
5380
5381 let fetch_task = pump_for_task(
5382 &mut webview,
5383 |task| {
5384 matches!(
5385 task,
5386 WebViewTask::Fetch {
5387 kind: FetchKind::JavaScript { .. },
5388 ..
5389 }
5390 )
5391 },
5392 "the page's fetch() to be dispatched",
5393 );
5394 let request_id = match fetch_task {
5395 WebViewTask::Fetch {
5396 url,
5397 kind: FetchKind::JavaScript { request_id, .. },
5398 } => {
5399 assert_eq!(url.as_str(), "https://example.test/message.txt");
5400 request_id
5401 }
5402 _ => unreachable!("guarded by pump_for_task"),
5403 };
5404
5405 webview.on_js_fetch_succeeded(
5406 request_id,
5407 JsFetchResponse {
5408 url: "https://example.test/message.txt".to_string(),
5409 status: 200,
5410 status_text: "OK".to_string(),
5411 redirected: false,
5412 body: b"hello from fetch".to_vec(),
5413 headers: Vec::new(),
5414 },
5415 );
5416
5417 pump_until(
5418 &mut webview,
5419 |wv| {
5420 wv.document_info()
5421 .unwrap()
5422 .dom
5423 .get_element_by_id("result")
5424 .is_some_and(|node| node.borrow().value.get_attr("data-text").is_some())
5425 },
5426 "the fetch response microtask to run",
5427 );
5428 let result = webview
5429 .document_info()
5430 .unwrap()
5431 .dom
5432 .get_element_by_id("result")
5433 .unwrap();
5434 assert_eq!(
5435 result.borrow().value.get_attr("data-text"),
5436 Some("hello from fetch")
5437 );
5438 }
5439
5440 #[test]
5441 fn failed_javascript_fetch_rejects_without_navigating() {
5442 let mut webview = WebView::default();
5443 webview.on_html_fetched(
5444 r#"
5445 <div id="result"></div>
5446 <script>
5447 fetch("missing.txt").catch(error => {
5448 document.getElementById("result").setAttribute("data-error", error);
5449 });
5450 </script>
5451 "#
5452 .to_string(),
5453 Url::parse("https://example.test/index.html").unwrap(),
5454 );
5455
5456 let fetch_task = pump_for_task(
5457 &mut webview,
5458 |task| {
5459 matches!(
5460 task,
5461 WebViewTask::Fetch {
5462 kind: FetchKind::JavaScript { .. },
5463 ..
5464 }
5465 )
5466 },
5467 "the page's fetch() to be dispatched",
5468 );
5469 let request_id = match fetch_task {
5470 WebViewTask::Fetch {
5471 kind: FetchKind::JavaScript { request_id, .. },
5472 ..
5473 } => request_id,
5474 _ => unreachable!("guarded by pump_for_task"),
5475 };
5476
5477 webview.on_js_fetch_failed(request_id, "network error".to_string());
5478
5479 pump_until(
5480 &mut webview,
5481 |wv| {
5482 wv.document_info()
5483 .unwrap()
5484 .dom
5485 .get_element_by_id("result")
5486 .is_some_and(|node| node.borrow().value.get_attr("data-error").is_some())
5487 },
5488 "the fetch rejection to run",
5489 );
5490 let result = webview
5491 .document_info()
5492 .unwrap()
5493 .dom
5494 .get_element_by_id("result")
5495 .unwrap();
5496 assert_eq!(
5497 result.borrow().value.get_attr("data-error"),
5498 Some("network error")
5499 );
5500 assert_eq!(
5501 webview.document_info().unwrap().base_url.as_str(),
5502 "https://example.test/index.html"
5503 );
5504 }
5505
5506 #[test]
5507 fn document_origin_is_exposed_to_page_scripts() {
5508 let mut webview = WebView::default();
5509 webview.on_html_fetched(
5510 r#"
5511 <div id="result"></div>
5512 <script>
5513 document.getElementById("result").setAttribute(
5514 "data-origin",
5515 location.origin + ":" + window.origin + ":" + document.origin
5516 );
5517 </script>
5518 "#
5519 .to_string(),
5520 Url::parse("https://example.test/path/index.html").unwrap(),
5521 );
5522
5523 pump_until(
5524 &mut webview,
5525 |wv| {
5526 wv.document_info()
5527 .unwrap()
5528 .dom
5529 .get_element_by_id("result")
5530 .is_some_and(|node| node.borrow().value.get_attr("data-origin").is_some())
5531 },
5532 "the origin-handling script to run",
5533 );
5534 let result = webview
5535 .document_info()
5536 .unwrap()
5537 .dom
5538 .get_element_by_id("result")
5539 .unwrap();
5540 assert_eq!(
5541 result.borrow().value.get_attr("data-origin"),
5542 Some("https://example.test:https://example.test:https://example.test")
5543 );
5544 }
5545
5546 #[test]
5547 fn internal_document_reports_null_origin() {
5548 let mut webview = WebView::default();
5549 webview.on_html_fetched(
5550 r#"
5551 <div id="result"></div>
5552 <script>
5553 document.getElementById("result").setAttribute(
5554 "data-origin",
5555 location.origin + ":" + window.origin + ":" + document.origin
5556 );
5557 </script>
5558 "#
5559 .to_string(),
5560 Url::parse("resource:///devtools/index.html").unwrap(),
5561 );
5562
5563 pump_until(
5564 &mut webview,
5565 |wv| {
5566 wv.document_info()
5567 .unwrap()
5568 .dom
5569 .get_element_by_id("result")
5570 .is_some_and(|node| node.borrow().value.get_attr("data-origin").is_some())
5571 },
5572 "the origin-handling script to run",
5573 );
5574 let result = webview
5575 .document_info()
5576 .unwrap()
5577 .dom
5578 .get_element_by_id("result")
5579 .unwrap();
5580 assert_eq!(
5581 result.borrow().value.get_attr("data-origin"),
5582 Some("null:null:null")
5583 );
5584 }
5585
5586 #[test]
5587 fn zero_delay_timer_runs_from_webview_tick_and_updates_dom() {
5588 let mut webview = WebView::default();
5589 webview.on_html_fetched(
5590 r##"
5591 <div id="result"></div>
5592 <script>
5593 setTimeout(function () {
5594 document.querySelector("#result").setAttribute("data-timer", "ran");
5595 }, 0);
5596 </script>
5597 "##
5598 .to_string(),
5599 Url::parse("https://example.test/index.html").unwrap(),
5600 );
5601
5602 assert!(webview.tick().is_empty());
5603 assert!(webview.tick().is_empty());
5604 pump_until(
5605 &mut webview,
5606 |wv| {
5607 wv.document_info()
5608 .unwrap()
5609 .dom
5610 .get_element_by_id("result")
5611 .is_some_and(|node| node.borrow().value.get_attr("data-timer").is_some())
5612 },
5613 "the zero-delay timer callback to run",
5614 );
5615 let result = webview
5616 .document_info()
5617 .unwrap()
5618 .dom
5619 .get_element_by_id("result")
5620 .unwrap();
5621 assert_eq!(result.borrow().value.get_attr("data-timer"), Some("ran"));
5622 assert!(webview.needs_redraw());
5623 }
5624
5625 #[test]
5626 fn devtools_request_round_trips_through_inspection_and_back() {
5627 let mut webview = WebView::default();
5628 webview.on_html_fetched(
5629 r##"
5630 <div id="probe"></div>
5631 <script>
5632 __orinium_devtools("getVersion").then(function (json) {
5633 const envelope = JSON.parse(json);
5634 document.getElementById("probe")
5635 .setAttribute("data-ok", envelope.ok ? "yes" : "no");
5636 });
5637 </script>
5638 "##
5639 .to_string(),
5640 Url::parse("https://example.test/index.html").unwrap(),
5641 );
5642
5643 let task = pump_for_task(
5644 &mut webview,
5645 |task| matches!(task, WebViewTask::DevToolsRequest { .. }),
5646 "the page's DevTools inspection request",
5647 );
5648 let WebViewTask::DevToolsRequest { id, method, params } = task else {
5649 unreachable!("pump_for_task matched this variant");
5650 };
5651 assert_eq!(method, "getVersion");
5652 assert_eq!(params, "{}");
5653
5654 let data = webview
5655 .inspect(&method, ¶ms)
5656 .expect("inspection answer");
5657 webview.on_devtools_response(
5658 id,
5659 serde_json::json!({ "ok": true, "data": data }).to_string(),
5660 );
5661
5662 pump_until(
5663 &mut webview,
5664 |wv| {
5665 wv.document_info()
5666 .unwrap()
5667 .dom
5668 .get_element_by_id("probe")
5669 .is_some_and(|node| node.borrow().value.get_attr("data-ok").is_some())
5670 },
5671 "the resolved promise callback to mark the probe element",
5672 );
5673 let probe = webview
5674 .document_info()
5675 .unwrap()
5676 .dom
5677 .get_element_by_id("probe")
5678 .unwrap();
5679 assert_eq!(probe.borrow().value.get_attr("data-ok"), Some("yes"));
5680 }
5681
5682 #[test]
5683 fn split_css_imports_extracts_top_level_imports_only() {
5684 let css = r#"
5685 /* keep comments: @import not allowed inside */
5686 @import url("a.css") screen;
5687 @import "sub/b.css";
5688 @media print {
5689 @import "nested.css";
5690 .x { content: "@import 'not-real.sass'"; }
5691 }
5692 .rule { background: url("img.png"); }
5693 body { color: red; }
5694 @import url(c.css);
5695 "#;
5696
5697 let (imports, body) = split_css_imports(css);
5698 assert_eq!(imports, vec!["a.css", "sub/b.css", "c.css"]);
5699
5700 assert!(!body.contains("@import url(\"a.css\")"));
5703 assert!(!body.contains("@import \"sub/b.css\""));
5704 assert!(!body.contains("@import url(c.css)"));
5705 assert!(body.contains("@import \"nested.css\""));
5707 assert!(body.contains("@import 'not-real.sass'"));
5708 assert!(body.contains("background: url(\"img.png\");"));
5709 assert!(body.contains("body { color: red; }"));
5710 }
5711
5712 #[test]
5713 fn css_import_url_recognizes_url_and_string_forms() {
5714 assert_eq!(
5715 css_import_url("@import url(\"fonts/webfont.woff2\") screen;").as_deref(),
5716 Some("fonts/webfont.woff2")
5717 );
5718 assert_eq!(
5719 css_import_url("@import 'style.css';").as_deref(),
5720 Some("style.css")
5721 );
5722 assert_eq!(
5723 css_import_url("@import url(plain.css);").as_deref(),
5724 Some("plain.css")
5725 );
5726 assert_eq!(css_import_url("@import;"), None);
5727 assert_eq!(css_import_url("@import url();").as_deref(), Some(""));
5728 }
5729
5730 #[test]
5731 fn split_css_imports_ignores_pseudo_import_keywords() {
5732 let css = "@importurl('x.css'); .foo { color: @import-thing; } @import url(y.css);";
5733 let (imports, body) = split_css_imports(css);
5734 assert_eq!(imports, vec!["y.css"]);
5735 assert!(body.contains("@importurl('x.css')"));
5736 assert!(body.contains("@import-thing"));
5737 }
5738
5739 #[test]
5740 fn stylesheet_import_deferred_until_import_applies_and_resolves_relative() {
5741 let mut wv = WebView::new(
5742 crate::engine::layouter::types::ColorScheme::Light,
5743 JsPolicy::Disabled,
5744 );
5745 let sheet_url = Url::parse("https://example.test/css/main.css").unwrap();
5746
5747 wv.on_css_fetched_from(
5748 "@import url(\"../sub/base.css\");body { color: red; }".to_string(),
5749 &sheet_url,
5750 );
5751 assert!(wv.linked_css.is_empty());
5753
5754 let task = pump_for_task(
5755 &mut wv,
5756 |task| {
5757 matches!(
5758 task,
5759 WebViewTask::Fetch {
5760 url,
5761 kind: FetchKind::CssImport { target },
5762 } if url.as_str() == "https://example.test/sub/base.css"
5763 && target == url
5764 )
5765 },
5766 "the @import to be scheduled",
5767 );
5768 let WebViewTask::Fetch {
5769 url: fetched,
5770 kind: FetchKind::CssImport { .. },
5771 } = task
5772 else {
5773 unreachable!()
5774 };
5775 assert_eq!(fetched.as_str(), "https://example.test/sub/base.css");
5777
5778 wv.on_css_import_fetched(
5779 "div { font-weight: bold; }\n".to_string(),
5780 &Url::parse("https://example.test/sub/base.css").unwrap(),
5781 );
5782
5783 assert_eq!(wv.linked_css.len(), 1);
5786 assert_eq!(
5787 wv.linked_css[0],
5788 "div { font-weight: bold; }\nbody { color: red; }"
5789 );
5790 }
5791
5792 #[test]
5793 fn duplicate_import_requests_one_fetch_and_settles_without_cycle() {
5794 let mut wv = WebView::new(
5795 crate::engine::layouter::types::ColorScheme::Light,
5796 JsPolicy::Disabled,
5797 );
5798 let sheet_a = Url::parse("https://example.test/css/a.css").unwrap();
5799 let sheet_b = Url::parse("https://example.test/css/b.css").unwrap();
5800
5801 wv.on_css_fetched_from(
5803 "@import url(\"b.css\");a { color: a; }".to_string(),
5804 &sheet_a,
5805 );
5806 wv.on_css_import_fetched(
5807 "@import url(\"a.css\");b { color: b; }".to_string(),
5808 &sheet_b,
5809 );
5810
5811 assert_eq!(wv.linked_css.len(), 1);
5814 assert_eq!(wv.linked_css[0], "b { color: b; }a { color: a; }");
5815
5816 let mut fetched = Vec::new();
5819 for _ in 0..4 {
5820 for task in wv.tick() {
5821 if let WebViewTask::Fetch {
5822 url,
5823 kind: FetchKind::CssImport { .. },
5824 } = task
5825 {
5826 fetched.push(url.as_str().to_string());
5827 }
5828 }
5829 }
5830 assert_eq!(fetched, vec!["https://example.test/css/b.css"]);
5831 assert!(wv.pending_css_imports.is_empty());
5832 }
5833
5834 #[test]
5835 fn failed_import_does_not_block_the_importing_sheet() {
5836 let mut wv = WebView::new(
5837 crate::engine::layouter::types::ColorScheme::Light,
5838 JsPolicy::Disabled,
5839 );
5840 let sheet_url = Url::parse("https://example.test/css/main.css").unwrap();
5841 wv.on_css_fetched_from(
5842 "@import url(\"missing.css\");p { margin: 0; }".to_string(),
5843 &sheet_url,
5844 );
5845 assert!(wv.linked_css.is_empty());
5846
5847 wv.on_css_import_fetch_failed(&Url::parse("https://example.test/css/missing.css").unwrap());
5848 assert_eq!(wv.linked_css.len(), 1);
5849 assert_eq!(wv.linked_css[0], "p { margin: 0; }");
5850 }
5851
5852 #[test]
5853 fn shared_import_is_applied_once_across_two_sheets() {
5854 let mut wv = WebView::new(
5855 crate::engine::layouter::types::ColorScheme::Light,
5856 JsPolicy::Disabled,
5857 );
5858 let sheet_a = Url::parse("https://example.test/css/a.css").unwrap();
5859 let sheet_b = Url::parse("https://example.test/css/b.css").unwrap();
5860 let shared = Url::parse("https://example.test/css/shared.css").unwrap();
5861
5862 wv.on_css_fetched_from(
5863 "@import url(\"shared.css\");a { color: a; }".to_string(),
5864 &sheet_a,
5865 );
5866 wv.on_css_fetched_from(
5867 "@import url(\"shared.css\");b { color: b; }".to_string(),
5868 &sheet_b,
5869 );
5870
5871 let mut import_tasks = Vec::new();
5872 for _ in 0..4 {
5873 let tasks = wv.tick();
5874 for task in tasks {
5875 if let WebViewTask::Fetch {
5876 kind: FetchKind::CssImport { .. },
5877 ..
5878 } = task
5879 {
5880 import_tasks.push(());
5881 }
5882 }
5883 }
5884 assert_eq!(import_tasks.len(), 1, "duplicate import fetch scheduled");
5886
5887 wv.on_css_import_fetched("s { color: s; }".to_string(), &shared);
5888 let mut applied = wv.linked_css.clone();
5889 applied.sort();
5890 assert_eq!(
5891 applied,
5892 vec![
5893 "s { color: s; }a { color: a; }",
5894 "s { color: s; }b { color: b; }"
5895 ]
5896 );
5897 }
5898}