Skip to main content

orinium_browser/browser/core/webview/
mod.rs

1//! ブラウザのwebview機能。タスクとレンダリング情報の管理を行う。
2
3use 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    /// A script requested a top-level navigation (e.g. `form.submit()`);
59    /// the tab must re-fetch the page at this URL.
60    Navigate {
61        url: Url,
62    },
63    /// A page asked the DevTools bridge to inspect rendered state.
64    DevToolsRequest {
65        id: u64,
66        method: String,
67        params: String,
68    },
69}
70
71mod inspector;
72
73/// Manual, network-dependent end-to-end probes.
74#[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    /// An `@import`-ed stylesheet inside another stylesheet. `target` is the
91    /// sheet URL to fetch; the import resolves relative to its own sheet, not
92    /// the document base.
93    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/// CSS application strategy.
114///
115/// - `Batch`: wait for all external CSS to be fetched, then process everything
116///   at once on a background thread and apply the result.
117/// - `Incremental`: process each CSS file on a background thread as it arrives,
118///   applying results progressively.
119#[derive(Debug, Clone, Copy, PartialEq)]
120pub enum CssApplicationStrategy {
121    Batch,
122    Incremental,
123}
124
125/// Where a top-level stylesheet's rules finally land once its `@import`s have
126/// all resolved.
127#[derive(Debug, Clone, Copy)]
128enum CssSink {
129    /// A ``<link rel="stylesheet">`` sheet, applied through the CSS processor.
130    External,
131    /// A JS-inserted `<link>`/`stylesheet`, applied by re-resolving styles and
132    /// firing the element's `load` event afterwards.
133    Dynamic(u64),
134}
135
136/// One `@import` statement inside a sheet, paired with the flattened text of
137/// the imported sheet once all of *its* imports have resolved.
138#[derive(Debug)]
139struct CssImportEntry {
140    url: Url,
141    text: Option<String>,
142}
143
144/// Processing state of one stylesheet unit (keyed by its URL).
145///
146/// A sheet whose `@import`s are still loading is `Deferred`; as its imports
147/// complete they become `Applied` under their own URLs and the sheet settles
148/// into a single flattened text.
149#[derive(Debug)]
150enum CssSheetState {
151    Deferred {
152        /// This sheet's own rules with every `@import` statement removed.
153        source: String,
154        entries: Vec<CssImportEntry>,
155        sink: Option<CssSink>,
156    },
157    Applied {
158        /// The fully flattened rule text (its imports first, then its own).
159        text: String,
160        sink: Option<CssSink>,
161    },
162}
163
164/// JavaScript execution policy for a page.
165///
166/// This drives both the HTML parser's scripting mode (so `<noscript>` fallbacks
167/// are either hidden or shown) and whether the WebView executes scripts.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
169pub enum JsPolicy {
170    /// Scripts run and `<noscript>` contents are kept as raw text.
171    #[default]
172    Enabled,
173    /// Scripts are never executed and `<noscript>` fallbacks are shown.
174    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    /// `@import` targets waiting to be fetched, as `(importing sheet url,
218    /// imported sheet url)` pairs.
219    pending_css_imports: Vec<(Url, Url)>,
220    /// URL → stylesheet unit currently being (or already) processed for
221    /// `@import` flattening. Keyed by the sheet's own URL so imports resolve
222    /// relative to it and cycles / duplicate imports collapse to one unit.
223    css_sheets: HashMap<Url, CssSheetState>,
224    /// Imported sheet URLs whose fetch is already in flight, to avoid
225    /// re-requesting the same sheet from multiple importers.
226    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    /// Parsed `@keyframes` rules available to the animation runtime.
237    resolved_keyframes: Arc<layouter::css_resolver::KeyframesRegistry>,
238    /// Monotonic version of `resolved_styles`, bumped on every in-place or
239    /// wholesale mutation so the layout processor can detect stale rule sets.
240    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    /// Builds one audio sink per `<audio>` element during layout.
248    ///
249    /// Held as a trait object so the layout task stays free of any concrete
250    /// platform audio type. The factory is stateless and cheap, so it is
251    /// built once per `WebView` and survives navigation resets.
252    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    /// Policy controlling whether page scripts are executed and how
263    /// `<noscript>` contents are parsed.
264    js_policy: JsPolicy,
265
266    /// Whether this webview renders a browser-created chrome document (shell or
267    /// DevTools). Chrome webviews hold [`Permissions::chrome`].
268    privileged: bool,
269
270    layout_processor: layouter::LayoutProcessor,
271    layout_pending: bool,
272    layout_requested_version: u64,
273    layout_applied_version: u64,
274    /// The `(layout version, viewport)` the current tree was last positioned
275    /// for. Applied background results start out unpositioned; hit-testing
276    /// must never observe them before [`WebView::position_layout_if_needed`]
277    /// has run, so this memo gate runs the positioning pass eagerly.
278    positioned_layout: Option<(u64, (f32, f32))>,
279    /// Live DOM references for the latest snapshot, used to apply write-backs.
280    layout_dom_refs: Vec<Weak<RefCell<TreeNode<HtmlNodeType>>>>,
281    interaction_state: Arc<InteractionState>,
282    interaction_version: u64,
283    /// Cached DOM snapshot, reused while the tree's mutation version is
284    /// unchanged so that CSS/image-driven relayouts skip the full clone.
285    snapshot_cache: Option<SnapshotCache>,
286    /// The most recent serialized content documents of any `<iframe>`s, keyed
287    /// by the iframe's JS-facing dom id. Used by layout to render them nested.
288    iframe_content: HashMap<u64, DomSnapshot>,
289
290    /// Engine animation runtime (clock-injected, sampled from tick).
291    animation_runtime: AnimationRuntime,
292    /// Last instant used to advance the animation clock while visible.
293    animation_last_tick: Option<Instant>,
294    /// Accumulated animation time in milliseconds (advances only while visible).
295    animation_time_ms: f64,
296    /// Current per-element animated style overrides applied to layout builds.
297    animated_overrides: AnimatedOverrides,
298    /// Whether the hosting view is visible; when hidden, timelines are paused.
299    animation_visible: bool,
300    /// Latest per-element animation inputs from layout build.
301    latest_animation_inputs: crate::engine::animation::AnimationInputs,
302    /// Channel on which text inputs report value write-backs (received here).
303    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 runtime on a background thread, sharing a mirror of the current
306    /// document's DOM. Results are applied in [`WebView::try_apply_js_results`].
307    js_processor: Option<JsProcessor>,
308    /// JS-facing dom id per live node address of the committed tree.
309    ///
310    /// Rebuilt whenever a JS result is committed, so hit-tested layout
311    /// nodes and write-back serialization can be translated to JS dom ids.
312    js_dom_ids: HashMap<usize, u64>,
313    /// Ordered JS tasks sent but not yet applied. Write-backs are only synced
314    /// to the JS thread once this reaches zero, so the mirror and the real tree
315    /// cannot diverge mid-task.
316    pending_js_tasks: usize,
317    /// The real DOM diverged from the JS thread's mirror and needs syncing.
318    js_dom_dirty: bool,
319    /// Version of the newest `RunTimers` poke still in flight. Write-backs are
320    /// not synced while one is pending: a timer callback can mutate the mirror,
321    /// and a snapshot produced behind the sync would clobber it.
322    in_flight_timer_version: Option<u64>,
323    /// Whether the window `load` event has been dispatched for the current page.
324    window_load_dispatched: bool,
325    /// `fetch()` requests collected from applied JS results.
326    pending_js_fetches: Vec<JsFetchRequest>,
327    /// DevTools inspection requests collected from applied JS results.
328    pending_devtools_requests: Vec<JsDevToolsRequest>,
329    /// JavaScript execution trace events collected from applied JS results.
330    js_trace_events: Vec<JsTraceEvent>,
331    /// JavaScript operations currently in progress (best-effort snapshot).
332    js_trace_active: Vec<crate::engine::js::JsActiveTrace>,
333    /// Whether the JS thread should record execution trace events.
334    js_trace_enabled: bool,
335    /// Total events recorded since the trace was cleared.
336    js_trace_total: u64,
337    /// Events evicted by the JS thread's ring buffer before reaching the UI.
338    js_trace_dropped: u64,
339    /// Results waiting for DevTools console polling.
340    js_console_results: HashMap<u64, String>,
341    /// Stable DOM ids for the inspector, assigned lazily over the live tree.
342    inspector_ids: RefCell<inspector::DomIdRegistry>,
343    /// Dynamically inserted scripts collected from applied JS results.
344    pending_dynamic_scripts: Vec<JsDynamicScriptRequest>,
345    /// Dynamically inserted stylesheet links collected from JS results.
346    pending_dynamic_styles: Vec<JsDynamicStyleRequest>,
347    /// Images created or populated by scripts, awaiting network scheduling.
348    pending_dynamic_images: Vec<JsDynamicImageRequest>,
349    /// Script-initiated top-level navigation URLs awaiting dispatch.
350    pending_navigations: Vec<String>,
351    /// `<iframe src="...">` requests queued by JS results, awaiting fetch.
352    pending_iframe_fetches: Vec<JsIframeFetchRequest>,
353    /// Classic scripts in document order. Execution starts after CSS is applied.
354    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    /// Fragment to reveal once the document has a completed layout.
361    pending_fragment_scroll: Option<String>,
362    /// Layout generation that contains every initially linked stylesheet.
363    fragment_ready_version: Option<u64>,
364
365    /// Embedder-provided callbacks for events the WebView cannot handle itself.
366    /// When `None`, the corresponding `WebViewTask` is still emitted from
367    /// [`tick`](Self::tick) for the in-tree browser core to handle.
368    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
392/// A DOM snapshot paired with the tree mutation version it was built from.
393///
394/// The snapshot and its live references stay valid as long as the DOM has not
395/// mutated (`Tree::version()` unchanged). They are shared with layout tasks via
396/// `Arc` instead of being cloned per task.
397struct 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
413/// Grafts each committed iframe's content document into its host `<iframe>`
414/// node so the normal layout/paint pipeline renders the content nested inside
415/// the host box. The JS domain keeps iframe documents in a separate tree, so we
416/// splice their `<html>` subtree under the matching host node here.
417fn 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    // Build a reverse map: js_dom_id -> live node, so host lookups are O(1)
426    // per iframe instead of O(n) DOM traversals.
427    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/// DocumentInfo holds basic information about the HTML document.
458/// It includes the document URL, base URL, title, and DOM tree.
459///
460/// - document_url: The URL of the document.
461/// - base_url: The base URL for resolving relative URLs.
462/// - origin: The origin of the document.
463/// - title: The title of the document.
464/// - dom: The DOM tree of the document.
465#[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
474/// ParsedDocument holds the result of parsing an HTML document.
475/// It includes the document URL, base URL, DOM tree, title, style links, and inline styles.
476///
477/// - document_url: The URL of the document.
478/// - base_url: The base URL for resolving relative URLs.
479/// - dom: The DOM tree of the document.
480/// - title: The title of the document.
481/// - style_links: A list of URLs for linked stylesheets.
482/// - inline_styles: A list of inline CSS styles.
483/// - scripts: A list of inline script sources.
484struct 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    /// Set the CSS application strategy.
600    ///
601    /// Default is `Incremental`.
602    pub fn set_css_strategy(&mut self, strategy: CssApplicationStrategy) {
603        self.css_strategy = strategy;
604    }
605
606    /// Sets the JavaScript execution policy.
607    ///
608    /// The policy is applied to `<noscript>` parsing on the next document load
609    /// and takes effect immediately for execution: disabling it drops the JS
610    /// runtime, cancels pending script work, and stops running scripts.
611    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            // Re-enabling: install a processor so DOM APIs work again, without
623            // replaying scripts that were skipped while disabled.
624            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    /// Applies the webview's tracing preference to a freshly created processor.
667    ///
668    /// New runtimes start with [`crate::engine::js::default_trace_enabled()`],
669    /// so a task is only needed when the user has chosen otherwise.
670    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    /// Enables or disables JavaScript execution tracing.
683    ///
684    /// The preference is kept across navigations; buffered events belong to
685    /// the current page and are cleared on load.
686    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    /// Clears the buffered JavaScript execution trace.
698    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    /// Returns the current JavaScript execution policy.
707    pub fn js_policy(&self) -> JsPolicy {
708        self.js_policy
709    }
710
711    /// Marks this webview as rendering a browser-created chrome document.
712    ///
713    /// Chrome webviews derive [`Permissions::chrome`] instead of the ordinary
714    /// content capabilities for their document URL.
715    pub fn set_privileged(&mut self, privileged: bool) {
716        self.privileged = privileged;
717    }
718
719    /// Effective capabilities for a document loaded from `url` in this webview.
720    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    /// Stops script execution immediately, dropping the runtime and any
729    /// pending script work.
730    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        // Nothing will advance past CssApplied anymore.
752        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                // Phase 1: UA.css only layout
782                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                // CSS fetch を要求
818                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                // Poll for CSS processor results (Incremental strategy)
839                self.try_apply_css_results();
840            }
841
842            PagePhase::CssProcessing => {
843                // Poll for the single batch result (Batch strategy)
844                self.try_apply_batch_result();
845            }
846
847            PagePhase::CssApplied => {
848                self.advance_classic_scripts(&mut tasks);
849            }
850
851            PagePhase::ScriptApplied => {
852                // 安定状態
853            }
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        // Window `load` fires once the page is stable: after DOMContentLoaded
881        // (phase `ScriptApplied`), with no JS-visible subresource work still in
882        // flight. Reaching `ScriptApplied` guarantees the page scripts and
883        // DOMContentLoaded listeners have already been applied, so the `onload`
884        // handler is in place before we dispatch.
885        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        // Wait out any @imports the sheet declares: its rules (with the import
1009        // rules inlined before them) only reach the resolver once every import
1010        // has been fetched and flattened too.
1011        self.accept_stylesheet(stylesheet_url, css, Some(CssSink::External));
1012    }
1013
1014    /// Registers a stylesheet under its own URL, resolving any `@import`
1015    /// statements it declares.
1016    ///
1017    /// Imports are resolved against the *sheet's* URL (not the document base),
1018    /// fetched once per URL, and flattened in document order before the sheet's
1019    /// own rules. `sink` describes where a top-level sheet's rules finally
1020    /// land; `None` marks a sheet reachable only via `@import`, whose text is
1021    /// inlined into its importers instead.
1022    fn accept_stylesheet(&mut self, url: &Url, css: String, sink: Option<CssSink>) {
1023        if let Some(existing) = self.css_sheets.get_mut(url) {
1024            // A URL can be both linked directly and @import-ed. If the sheet
1025            // was already handled as a plain import, promote it to a top-level
1026            // sheet now; if it is still deferred, the application sink is
1027            // recorded so its rules land once its own imports settle.
1028            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            // A sheet cannot import itself; drop the statement rather than
1069            // deferring forever on a cycle back to the root.
1070            if target == *url {
1071                continue;
1072            }
1073            let text = match self.css_sheets.get(&target) {
1074                // Already-flattened sheet: reuse its text directly.
1075                Some(CssSheetState::Applied { text, .. }) => Some(text.clone()),
1076                // A sheet still being resolved is either part of an import
1077                // cycle (A -> B -> A) or otherwise already loading; waiting on
1078                // it can only deadlock, so it contributes nothing here.
1079                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    /// Records a fetched `@import`-ed stylesheet under its own URL.
1100    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    /// Records a failed `@import` fetch: the sheet contributes nothing, and
1106    /// its importers proceed without it instead of blocking the load.
1107    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    /// Flattens every sheet whose imports have all resolved, applying top-level
1129    /// sheets when they do. Iterates until no progress: settling one sheet can
1130    /// unblock the parents that imported it.
1131    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    /// Decodes a fetched image and rebuilds layout using its intrinsic size.
1214    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    /// Stores fetched audio bytes for the matching `<audio>` control.
1220    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    /// Executes or queues a fetched external classic script by scheduling mode.
1226    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    /// Records a failed external classic script without aborting page loading.
1256    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    /// Executes a fetched dynamically inserted script and dispatches `load`.
1278    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    /// Dispatches `error` for a dynamically inserted script that failed to load.
1284    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        // Resolve relative url() inside the dynamic sheet against the sheet's
1290        // own location (not the document base), like a real browser does.
1291        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    /// Resolves a JavaScript `fetch()` request with a network response.
1299    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    /// Rejects a JavaScript `fetch()` request after a network failure.
1310    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    /// Installs parsed iframe HTML as the host element's `contentDocument` and
1321    /// fires its `load` event.
1322    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    /// Marks an iframe load as failed so later `contentDocument` accesses do
1334    /// not keep re-queuing a fetch.
1335    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    /// Settles a DevTools inspection request with its JSON envelope.
1343    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    /// Answers a DevTools inspection query against this page's live state.
1351    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    /// Update page (e.g. DOM changed)
1382    ///
1383    /// This is a stub method for now.
1384    pub fn update_page(&mut self) {
1385        self.ensure_text_measurer();
1386        // The caller mutated the DOM (e.g. TreeNode::replace_child), which does
1387        // not bump Tree::version on its own. Mark the tree dirty so the cached
1388        // snapshot is rebuilt instead of reused stale.
1389        if let Some(doc_info) = self.docment_info.as_mut() {
1390            doc_info.dom.mark_dirty();
1391        }
1392        self.update_layout();
1393        // The JS thread's mirror must reflect the external DOM mutation too.
1394        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    /// Sends a script to the JS thread for ordered execution.
1564    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    /// Whether any JS-visible subresource work still awaits its round trip
1600    /// (classic/dynamic scripts, stylesheets, images, or `fetch()` requests).
1601    ///
1602    /// When this returns `false` the JS thread is idle, so its `mirror` DOM
1603    /// matches the committed tree and the page can be considered fully loaded.
1604    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            // A write-back sync is owed, or a timer tick is already in flight.
1616            // Avoid queuing redundant timer pokes while the JS worker is busy.
1617            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    /// Emits queued script-initiated top-level navigations (form submits).
1659    /// Each navigation restarts the page-load cycle for the target URL.
1660    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    /// Dispatches a click on the given DOM snapshot node id to the page's JS.
1825    ///
1826    /// Resolves the live DOM node behind the snapshot id, translates it to the
1827    /// JS-facing dom id and hands the click to the JS thread. Returns whether
1828    /// a redraw is needed; the JS result triggers the relayout once applied.
1829    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    /// Dispatches a `scroll` event on the given DOM snapshot node id to the
1849    /// page's JS.
1850    ///
1851    /// Resolves the live DOM node behind the snapshot id, translates it to the
1852    /// JS-facing dom id and hands the scroll to the JS thread. Returns whether
1853    /// a redraw is needed; the JS result triggers the relayout once applied.
1854    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    /// Installs a text measurer created by the owning [`Tab`], so its shape
1880    /// cache survives across navigations instead of being rebuilt (and
1881    /// re-shaped from scratch) for every document.
1882    ///
1883    /// A `None` measurer leaves the web view to create its own on first use.
1884    ///
1885    /// [`Tab`]: crate::browser::core::tab::Tab
1886    pub fn set_text_measurer(&mut self, measurer: Option<Arc<PlatformTextMeasurer>>) {
1887        self.text_measurer = measurer;
1888    }
1889
1890    /// The measurer this web view is using, if one has been created yet.
1891    pub fn text_measurer(&self) -> Option<&Arc<PlatformTextMeasurer>> {
1892        self.text_measurer.as_ref()
1893    }
1894
1895    /// Builds a snapshot and hands the heavy tree construction to the background.
1896    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        // Snapshot construction happens at function scope so the profile log
1905        // below can read accumulators regardless of which branch ran.
1906        #[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            // The DOM is unchanged since the last snapshot: reuse it instead of
1913            // re-cloning the whole tree (CSS/image relayouts dominate).
1914            && 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    /// Takes decoded images from the background thread and triggers a relayout.
2008    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    /// Takes completed layout results from the thread and makes them drawable.
2023    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            // The builder initializes every node's scroll offset to 0, so a
2035            // rebuilt tree would otherwise drop the scroll position (e.g. the
2036            // viewport change on a window resize). Re-apply the offsets of the
2037            // previous tree before swapping the new one in.
2038            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            // The fresh tree has no geometry yet (positioning normally happens
2050            // during draws). Position it right away so input events arriving
2051            // before the next redraw still hit-test against real boxes.
2052            self.position_layout_if_needed();
2053        }
2054    }
2055
2056    /// Takes completed JS results from the thread and commits them.
2057    ///
2058    /// A result that mutated the DOM carries a snapshot of the thread's mirror;
2059    /// committing it replaces the authoritative tree, re-registers the JS dom
2060    /// id map and triggers a relayout.
2061    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                // The newest timer poke has been processed (run or superseded),
2104                // so its snapshot can no longer race the write-back sync.
2105                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            // Commit the thread's mirror as the new authoritative tree. The
2115            // rebuilt tree starts with a fresh version, so the cached snapshot
2116            // and live layout references are stale and must be dropped.
2117            let (tree, dom_ids) = snapshot.into_tree();
2118            // Retain only iframe content for iframes still present; stale
2119            // entries from removed iframes are dropped on the next nav anyway.
2120            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            // Splice committed iframe content under the host <iframe> nodes so
2128            // layout/paint render it nested.
2129            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    /// Syncs UI-side DOM mutations (write-backs, `update_page`) to the JS thread.
2141    ///
2142    /// Only runs when no JS task is in flight: mid-task the thread's mirror
2143    /// legitimately diverges from the real tree, and committing an in-flight
2144    /// snapshot after this sync would clobber the thread's newer mutations.
2145    /// An in-flight `RunTimers` poke counts as in flight for the same reason:
2146    /// its callback may mutate the mirror and commit a stale snapshot.
2147    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    /// Applies form-control write-backs reported by custom components to the live DOM.
2167    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        // The DOM mutated, so the cached snapshot is stale and must be rebuilt
2200        // on the next relayout. Future JS mutations must also bump the version.
2201        if applied && let Some(doc_info) = self.docment_info.as_mut() {
2202            doc_info.dom.mark_dirty();
2203        }
2204        // The JS thread's mirror must reflect the new state; synced once
2205        // the in-flight JS tasks have all been applied.
2206        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    /// Returns the cursor style for the element under the given point, if any.
2252    ///
2253    /// The point is in CSS pixels relative to the viewport origin.
2254    ///
2255    /// Walks the hit path from innermost to outermost. The first node with an
2256    /// explicit `cursor` (anything other than `auto`) wins. If every node is
2257    /// `auto`, text nodes get `text` (I-beam) and everything else gets
2258    /// `default`.
2259    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    /// Loads a URL, resetting the WebView state and notifying the embedder via
2291    /// [`on_navigation`](Self::set_on_navigation).
2292    ///
2293    /// The embedder is responsible for fetching the document and calling
2294    /// [`on_html_fetched`](Self::on_html_fetched) with the result. If no
2295    /// `on_navigation` callback is set, this method only resets internal state
2296    /// without requesting a fetch.
2297    pub fn load_url(&mut self, url: &Url) {
2298        if let Some(cb) = &self.on_navigation {
2299            cb(url);
2300        } else {
2301            // No embedder callback: just reset state, waiting for explicit
2302            // on_html_fetched from the in-tree browser core.
2303            self.reset_for_navigation();
2304        }
2305    }
2306
2307    /// Sets the callback invoked when the WebView requests navigation to a URL.
2308    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    /// Sets the callback invoked when the WebView needs to fetch a resource.
2316    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    /// Sets the callback invoked when a page script logs a console message.
2324    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    /// Sets the callback invoked when page script requests DevTools inspection.
2332    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    /// Runs the box-positioning pass over the current layout tree unless it
2422    /// has already been positioned for the current version + viewport.
2423    ///
2424    /// Background layout results arrive unpositioned (geometry is computed on
2425    /// the main thread), so this must run before the tree is used for anything
2426    /// geometry-sensitive — drawing, but crucially also hit-testing. Without
2427    /// the eager call in [`WebView::try_apply_layout_results`], a click landing
2428    /// between a result being applied and the next draw would walk boxes with
2429    /// no geometry and find nothing.
2430    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    /// Updates pointer interaction state used for `:hover`/`:active` resolution.
2452    ///
2453    /// Returns `true` when the state changed and a relayout was requested.
2454    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    /// Scrolls the already-loaded document to the fragment in `url` without
2533    /// reloading it (same-document anchor navigation, e.g. `<a href="#id">`).
2534    ///
2535    /// The page stays loaded and only the scroll position and URL change; an
2536    /// empty fragment scrolls back to the top of the document. Fragments are
2537    /// percent-decoded before matching, so `#my%20id` reaches
2538    /// `<div id="my id">`.
2539    ///
2540    /// The current document URL is recorded so page scripts (`location.hash`,
2541    /// `location.href`) and the address bar reflect the new location.
2542    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        // The document is already laid out; do not wait for a fresh styling
2554        // pass before revealing the fragment.
2555        self.fragment_ready_version = Some(self.layout_applied_version);
2556        self.needs_redraw = true;
2557    }
2558
2559    /// 現在描画可能な Layout / Info を返す(なければ None)
2560    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    /// Returns document info
2569    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    /// Sets whether this view is visible for animation timing.
2582    ///
2583    /// When hidden, animation timelines are paused and no continuous redraw is
2584    /// requested. Visibility changes take effect on the next tick/sample.
2585    pub fn set_animation_visible(&mut self, visible: bool) {
2586        if self.animation_visible == visible {
2587            return;
2588        }
2589        self.animation_visible = visible;
2590        // Reset tick marker so visibility transitions do not create a time jump.
2591        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            // Avoid counting time spent before first visible sample and prevent
2601            // large first-frame deltas after (re)becoming visible.
2602            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        // Apply override changes even when runtime reports idle (e.g. final frame
2616        // clears overrides after a transition/animation completes).
2617        if sample.dirty || sample.overrides != self.animated_overrides {
2618            self.animated_overrides = sample.overrides;
2619            // Rebuild layout so animated values flow into layout/paint trees.
2620            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
2650/// Builds the geometry snapshot used by DOM measurement APIs from the same
2651/// layout boxes and scroll offsets consumed by painting and hit testing.
2652///
2653/// Sticky-position paint offsets are applied exactly like the renderer applies
2654/// them (same scrollport resolution), so `getBoundingClientRect()` agrees with
2655/// what is drawn.
2656fn 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    // Shift sticky boxes so each specified inset stays within the visible area
2721    // of the nearest scrollport, matching the renderer's paint offset. Fixed
2722    // boxes are positioned relative to the viewport and never stick.
2723    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        // Sticky viewport state for this subtree, rebased into each child's
2779        // parent-content space exactly as the renderer does: a node that
2780        // scrolls its own content (or the root) becomes the scrollport for its
2781        // descendants; otherwise the inherited visible region shifts by the
2782        // node's content-box offset.
2783        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
2841/// Serializes each laid-out node's computed CSS values (resolved cascade
2842/// result plus box geometry) for `getComputedStyle`, keyed by stable
2843/// JS-facing DOM id.
2844fn 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
2893/// Computes the `name: value` pair set for one laid-out node, merging the
2894/// layout-level (`ui_layout::Style`), appearance (`ContainerStyle`) and text
2895/// style results of the cascade into CSS property strings.
2896fn 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    // Text-rendering values live on text nodes and custom elements.
2917    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    // Overflow flags are the layout's scroll-ability record; they cannot
2933    // distinguish `hidden` from `scroll`, so report boolean width.
2934    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
3123/// Records the nonzero scroll offsets of every scrollable node in `info`,
3124/// keyed by the node's DOM snapshot id.
3125///
3126/// The layout builder initializes each node's `scroll_offset` to 0, so a
3127/// rebuild would otherwise drop the scroll position (e.g. after a window
3128/// resize). `dom_id` stays stable across rebuilds while the DOM is unchanged,
3129/// which makes it a reliable key for restoring state onto the new tree.
3130fn 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
3142/// Restores scroll offsets captured by [`capture_scroll_offsets`] onto a newly
3143/// built tree.
3144///
3145/// Offsets are copied verbatim for every matching node regardless of its
3146/// `scroll_x`/`scroll_y` flags: the flags describe whether an axis *can*
3147/// scroll, not whether a scroll position was captured, so gating on them here
3148/// would drop positions (e.g. the viewport/page scroll carried by the root).
3149fn 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
3196/// Scrolls the target element into view inside every scrollable ancestor on
3197/// its path. Only containers that actually scroll (their `scroll_x`/`scroll_y`
3198/// flags are set, e.g. a scrollable `body` or an `overflow: auto` box) move;
3199/// the document root and other un-flagged boxes stay put, so the page is never
3200/// force-scrolled as a whole.
3201///
3202/// Each container scrolls by only what its own view still needs, after the
3203/// scroll applied by every scrollable container below it. The target therefore
3204/// stays inside every ancestor's clip port instead of being scrolled out of
3205/// view by the accumulated displacements.
3206///
3207/// Returns the target's border-box top in this node's content space after this
3208/// node's and its descendants' scroll offsets are applied; `None` when the
3209/// target is not part of this subtree.
3210fn 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            // `child_pos` is measured from the child's content origin; add the
3230            // child's box offset to get the target's position in this node's
3231            // content space, before this node scrolls.
3232            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    // Only scrollable ancestors of the target may scroll; a sibling's scroll
3240    // container and un-flagged boxes (including the root) must not move.
3241    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
3266/// Scrolls the page back to the top (an empty fragment or `#top`).
3267///
3268/// Every scrollable container on the page resets (most commonly the `body`,
3269/// where a real browser puts the viewport scroll); other boxes already sit at
3270/// offset 0, so resetting them is a no-op.
3271fn 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
3293/// Returns whether `pending` still needs an element lookup to be resolved.
3294///
3295/// An empty fragment (or the legacy `#top` anchor with no matching element)
3296/// scrolls straight back to the top; otherwise the decoded fragment is looked
3297/// up among the live DOM ids and its element is scrolled into view.
3298fn 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        // No fragment was requested: leave the scroll position untouched.
3307        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
3319/// Percent-decodes a URL fragment before it is matched against element `id`
3320/// attributes, so `#my%20id` finds `<div id="my id">`.
3321fn 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
3401/// Splits a stylesheet into the raw URLs of its top-level `@import` statements
3402/// and the remaining rule text with those statements removed.
3403///
3404/// Only real top-level at-rules count: `@import` inside a rule block, comment
3405/// or string is left untouched. The import statement itself (up to and
3406/// including its terminating `;`) is stripped from the returned body so the
3407/// flattening never re-processes it.
3408fn 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
3487/// Returns `(start, end)` byte bounds of the `@import` statement beginning at
3488/// `at`, including its terminating `;`.
3489fn 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
3527/// Extracts the URL from an `@import` statement: either a string literal or a
3528/// `url(...)` function, each optionally followed by a media query list.
3529fn 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    // --- DOM パース ---
3598    let mut parser = HtmlParser::new(html).with_scripting_mode(scripting_mode);
3599    let dom = Rc::new(parser.parse());
3600
3601    // --- base_url ---
3602    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    // --- title 抽出 ---
3614    let title = dom
3615        .collect_text_by_tag("title")
3616        .first()
3617        .cloned()
3618        .unwrap_or("".into());
3619
3620    // --- Style links ---
3621    // <link rel="stylesheet" href="...">
3622    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    // --- Inline styles ---
3649    let inline_styles = dom.collect_text_by_tag("style");
3650
3651    // --- Classic scripts ---
3652    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    // absolute URL(scheme を持つ)
3713    if let Ok(url) = Url::parse(path) {
3714        return Ok(url);
3715    }
3716
3717    // relative URL
3718    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    /// Drives `tick()` until `done` holds, or panics after a timeout.
3730    ///
3731    /// JS runs on a background thread, so effects (DOM commits, relayouts) are
3732    /// observed a few ticks after the task that produced them was sent.
3733    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    /// Drives `tick()` collecting tasks until `done` accepts one, then returns it.
3743    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        // The Init phase injects the user-agent stylesheet into resolved_styles.
3784        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        // Resolve the inline <style> block and rebuild snapshot + layout inputs.
3795        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        // The heavy tree build runs on the background thread; wait for it.
3818        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", &params).expect("box model")["model"].clone();
3841
3842        // Declared margins come through as text, auto stays readable.
3843        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        // Default box-sizing is content-box: content keeps the declared size.
3851        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", &params)
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        // Regression: background layout results replaced the tree unpositioned
3944        // (geometry was only computed during draws), so a click landing between
3945        // an application and the next redraw hit-tested against boxes without
3946        // geometry and found nothing.
3947        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        // The user-agent sheet also styles `p`; pick the author rule and
3987        // check its color declaration specifically.
3988        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        // User-agent rules participate in the report too.
4001        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    /// Total vertical page scroll: the root plus every scrollable container on
4114    /// the page (typically the `body`, which carries the page scroll).
4115    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        // A `position: sticky; top: 10px` header whose natural position is 5px
4261        // into a tall document. The renderer pushes it down to `top: 10px`
4262        // relative to the root scrollport, so the DOM rect must show 10, not 5.
4263        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        // The layout mirrors a real page: an un-flagged document root (the
4354        // `html` box) containing a scrollable wrapper (the `body`) that fills
4355        // it, with a nested scroll box near the top of the page holding the
4356        // target. Scrolling an ancestor by the target's *unscrolled* position
4357        // (900) would carry the target past its inner clip port; each container
4358        // must only scroll by what is still needed after its descendants
4359        // scrolled, and un-flagged boxes must not move at all.
4360        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        // The inner scroll box reveals its own content (700, its max) and the
4401        // body-like wrapper pans by only the remaining 200 so the target reaches
4402        // its top; the un-flagged root stays put.
4403        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        // A sibling scroll container is not on the target's path: untouched.
4407        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        // A long page without overflow rules: the unflagged `html` root stays
4422        // put and the `body` (scrolled natively) carries the page scroll, so
4423        // fragment navigation must scroll that flagged container.
4424        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        // Same-document anchor navigation: no new load, just a scroll.
4499        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        // `url#` scrolls back to the top of the same document.
4531        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        // A scroll container needs a constrained height so its content_box
4540        // stays smaller than children_box (auto-height boxes stretch to their
4541        // content in this engine and are never scrollable).
4542        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        // A plain page without overflow rules: the wheel handler stores the
4582        // page scroll on the root InfoNode, which has no scroll flags set.
4583        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        // Old tree: parent scrolled to 100, child to 50.
4620        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        // New tree: same DOM, offsets reset to 0 by the builder.
4631        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        // Old tree: the x axis was scrollable and scrolled to 50.
4643        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        // New tree: the x axis no longer scrolls. apply restores the captured
4661        // position verbatim; range enforcement is clamp's job, so the flag
4662        // change alone must not drop the offset.
4663        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    /// Concatenates every text node under an info subtree.
4731    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    /// Whether any box in the layout is sized 300×150 — the content-box size
4743    /// the builder gives an `<iframe>` by default (the border box is larger
4744    /// because the UA stylesheet adds a 2px border).
4745    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    /// Whether some dual-axis scroll container (the `<iframe>` box, or the
4757    /// nested `<html>` document root grafted into it) holds only the given
4758    /// nested text and none of the host page's text.
4759    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    /// Runs the committed layout through draw-command generation and returns
4777    /// the whitespace-stripped text payload of every `DrawText` command — i.e.
4778    /// the text that would actually be rasterized on screen.
4779    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        // Create the iframe from JS so the src attribute is set on a live node
4809        // (the same path the acid3 harness exercises).
4810        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        // The JS thread reports the iframe's src as a fetch request.
4820        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        // The fetched HTML is parsed on the JS thread and installed as the
4843        // iframe's content document; the committed result must carry it back so
4844        // the browser can graft it under the host <iframe> node.
4845        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        // The nested content must be reachable by the paint pass, not just the
4865        // layout tree.
4866        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        // A plain `<iframe src>` in the parsed HTML must load like any other
4882        // subresource: real pages declare frames in markup, and nothing sets
4883        // their `src` property from JavaScript.
4884        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, &params)
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        // The statements (with their terminating `;`) are stripped from the body,
5701        // while nested and string occurrences are preserved.
5702        assert!(!body.contains("@import url(\"a.css\")"));
5703        assert!(!body.contains("@import \"sub/b.css\""));
5704        assert!(!body.contains("@import url(c.css)"));
5705        // Nested and string occurrences are preserved.
5706        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        // Nothing applied yet: the import is still outstanding.
5752        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        // The import resolved against the importing sheet's own URL.
5776        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        // The parent sheet now lands in linked_css with its import inlined
5784        // before its own rules.
5785        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        // A imports B; B imports A back.
5802        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        // B's import back to A is a cycle: it contributes nothing, and both
5812        // sheets settle immediately instead of deadlocking.
5813        assert_eq!(wv.linked_css.len(), 1);
5814        assert_eq!(wv.linked_css[0], "b { color: b; }a { color: a; }");
5815
5816        // Only B is ever requested for fetching; the cycle-back to A schedules
5817        // nothing new.
5818        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        // The shared URL is only requested once, even though two sheets import it.
5885        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}