Skip to main content

orinium_browser/engine/layouter/
processor.rs

1//! A processor that runs the layout builder on a background thread.
2//!
3//! `build_layout_and_info_*` is expensive (CSS cascade, text measurement,
4//! layout-tree construction), so it is offloaded to a background thread to
5//! keep the UI thread responsive. The builder walks a `Send` [`DomSnapshot`],
6//! so the snapshot is the only input that needs to be handed to the thread.
7//!
8//! Per-frame lightweight layout (`LayoutEngine::layout`) still runs on the UI
9//! thread as before. This processor only handles the heavier tree build.
10//!
11//! Tasks are coalesced: each task carries a monotonic sequence number, and the
12//! thread skips a task as soon as a newer one has been queued. The newest task
13//! always captures the latest DOM/styles/images, so the skipped build's work
14//! would be wasted anyway.
15
16use std::collections::HashMap;
17use std::sync::Arc;
18use std::sync::Mutex;
19use std::sync::atomic::{AtomicU64, Ordering};
20
21use ui_layout::LayoutNode;
22
23use super::builder::{InheritedCss, build_layout_and_info_from_snapshot};
24use super::css_resolver::{MediaEnvironment, ResolvedStyles};
25use super::dom_snapshot::{DomSnapshot, NodeId};
26use super::match_cache::SelectorMatchCache;
27use super::types::InfoNode;
28use crate::engine::background_worker::BackgroundWorker;
29use crate::engine::bridge::audio::AudioSinkFactory;
30use crate::engine::bridge::text::TextMeasurer;
31use crate::engine::css::matcher::ElementChain;
32use crate::engine::html::ScriptingMode;
33use crate::engine::layouter::css_resolver::RuleSet;
34use crate::engine::layouter::types::ColorScheme;
35use crate::engine::layouter::types::InteractionState;
36use crate::engine::renderer_model::Image;
37use crate::engine::ui::custom_node::CustomNode;
38use crate::engine::ui::registry::DomWriteBack;
39use crate::{perf_scope, profile_log};
40
41/// The complete set of inputs the builder needs to run on the background thread.
42pub struct LayoutTask {
43    pub snapshot: Arc<DomSnapshot>,
44    pub root: NodeId,
45    pub resolved_styles: Arc<ResolvedStyles>,
46    pub media_environment: MediaEnvironment,
47    pub measurer: Arc<dyn TextMeasurer>,
48    pub audio_sinks: Arc<dyn AudioSinkFactory>,
49    pub system_color_scheme: ColorScheme,
50    pub scripting_mode: ScriptingMode,
51    pub images: HashMap<String, Image>,
52    pub audio: HashMap<String, Arc<[u8]>>,
53    pub parent: InheritedCss,
54    pub chain: ElementChain,
55    pub write_back_sender: Option<DomWriteBack>,
56    /// Monotonic version of `resolved_styles` at task creation time. Assigned
57    /// by the UI thread; see [`LayoutProcessor`] for how it drives the
58    /// [`RuleSet`] cache.
59    pub styles_version: u64,
60    /// Monotonic version of the DOM snapshot handed to the task. Selector match
61    /// results are only reused across tasks with the same version.
62    pub dom_version: u64,
63    /// Monotonic sequence number used to coalesce stale tasks. Assigned by
64    /// [`LayoutProcessor::send`], ignore when constructing a task.
65    pub version: u64,
66    /// Pointer interaction state (hover/active) for CSS pseudo-class matching.
67    pub interaction: Arc<InteractionState>,
68    /// Monotonic version of `interaction`; selector match results depend on it.
69    pub interaction_version: u64,
70    /// Existing custom node instances keyed by snapshot DOM node id, reused
71    /// across relayouts to preserve component-local UI state.
72    pub existing_custom_nodes: HashMap<u32, Arc<dyn CustomNode>>,
73    /// Per-element animated style overrides applied on top of cascaded/base styles.
74    pub animated_overrides: HashMap<NodeId, HashMap<String, crate::engine::css::values::CssValue>>,
75}
76
77/// Per-worker caches reused across layout tasks.
78///
79/// The rule set and the selector match results are both derived from the
80/// resolved styles and the DOM snapshot, so they stay valid until the UI
81/// thread reports a newer [`LayoutTask::styles_version`] or
82/// [`LayoutTask::dom_version`].
83struct WorkerCache {
84    /// Styles version the cached rule set was built from.
85    styles_version: u64,
86    /// Media environment the cached rule set was built against.
87    media_environment: MediaEnvironment,
88    /// The cached rule set; valid only when `have_rule_set` is set.
89    rule_set: RuleSet,
90    /// Whether `rule_set` has been populated at least once.
91    have_rule_set: bool,
92    /// Memoized selector match results for the current rule set and DOM.
93    match_cache: SelectorMatchCache,
94    /// DOM version the match cache entries are valid for.
95    match_cache_dom_version: u64,
96    /// Interaction version the match cache entries are valid for.
97    match_cache_interaction_version: u64,
98}
99
100impl Default for WorkerCache {
101    fn default() -> Self {
102        Self {
103            styles_version: 0,
104            media_environment: MediaEnvironment::new((0.0, 0.0), ColorScheme::Light),
105            rule_set: RuleSet::default(),
106            have_rule_set: false,
107            match_cache: SelectorMatchCache::new(),
108            match_cache_dom_version: 0,
109            match_cache_interaction_version: 0,
110        }
111    }
112}
113
114/// The layout the builder finished on the background thread.
115pub struct LayoutResult {
116    pub layout: LayoutNode,
117    pub info: InfoNode,
118    /// Per-element animation inputs collected during style build.
119    pub animation_inputs: crate::engine::animation::AnimationInputs,
120    /// Sequence number of the task that produced this result.
121    pub version: u64,
122}
123
124enum LayoutCommand {
125    Build(LayoutTask),
126}
127
128/// A pointer wrapper that transfers a [`LayoutResult`] across a channel.
129///
130/// `LayoutNode` contains ui_layout's `Box<dyn CustomLayouter>`, which is not
131/// `Send`. The result is therefore leaked onto the heap with `Box::into_raw`
132/// and sent as a pointer; the receiving side rebuilds it with `Box::from_raw`
133/// as the single owner.
134///
135/// Safety is guaranteed by the command/response protocol:
136/// - The `Box` is allocated by the thread and leaked with `into_raw`.
137/// - The raw pointer is transferred through an `mpsc` channel (which
138///   establishes a happens-before relationship between send and receive).
139/// - The receiving (UI) thread rebuilds the `Box` exactly once, so there is
140///   exactly one owner at any point in time.
141///
142/// This holds only because producer and consumer are threads of one process
143/// sharing an address space. The `unsafe impl Send` below asserts addressable
144/// memory, not that the payload is meaningful anywhere else, so a
145/// `LayoutResult` must never be written to a socket, a file or shared memory.
146/// Moving layout across a process boundary requires a `Serialize`/
147/// `Deserialize` representation of the tree instead of this pointer, and the
148/// `CustomLayouter` callbacks it holds would have to be addressed by id
149/// rather than by vtable.
150struct SendableResult(*mut LayoutResult);
151
152// SAFETY: the pointed-to `Box` is accessed only by the receiving side after
153// the channel delivers it; no other thread touches it.
154unsafe impl Send for SendableResult {}
155
156/// A processor that accepts a [`DomSnapshot`] and returns the layout result
157/// produced by the thread.
158pub struct LayoutProcessor {
159    worker: BackgroundWorker<LayoutCommand, Option<SendableResult>>,
160    /// Latest task sequence number; shared with the thread so it can detect
161    /// and skip superseded tasks.
162    latest: Arc<AtomicU64>,
163}
164
165impl std::fmt::Debug for LayoutProcessor {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        f.write_str("LayoutProcessor")
168    }
169}
170
171impl Default for LayoutProcessor {
172    fn default() -> Self {
173        Self::new()
174    }
175}
176
177impl LayoutProcessor {
178    pub fn new() -> Self {
179        let latest = Arc::new(AtomicU64::new(0));
180        let latest_clone = Arc::clone(&latest);
181        let cache = Arc::new(Mutex::new(WorkerCache::default()));
182
183        let worker = BackgroundWorker::new(1, move |cmd: LayoutCommand| {
184            match cmd {
185                LayoutCommand::Build(task) => {
186                    // A newer task queued after this one supersedes it: the
187                    // newest task's snapshot/styles/images include every
188                    // change made up to that point, so skip the build.
189                    if task.version < latest_clone.load(Ordering::SeqCst) {
190                        return None;
191                    }
192                    let _version = task.version;
193                    perf_scope!(worker_total);
194
195                    let mut cache = cache.lock().expect("layout worker cache poisoned");
196                    let cache_hit = cache.have_rule_set
197                        && cache.styles_version == task.styles_version
198                        && cache.media_environment == task.media_environment;
199
200                    #[cfg(any(feature = "profile", debug_assertions))]
201                    let ruleset_build_time;
202                    if cache_hit {
203                        #[cfg(any(feature = "profile", debug_assertions))]
204                        {
205                            ruleset_build_time = std::time::Duration::ZERO;
206                        }
207                    } else {
208                        // TODO: Add incremental RuleSet updates
209                        perf_scope!(ruleset_build);
210                        cache.rule_set = RuleSet::from_declarations(
211                            &task.resolved_styles,
212                            &task.media_environment,
213                        );
214                        #[cfg(any(feature = "profile", debug_assertions))]
215                        {
216                            ruleset_build_time = ruleset_build.elapsed();
217                        }
218                        cache.styles_version = task.styles_version;
219                        cache.media_environment = task.media_environment;
220                        cache.have_rule_set = true;
221                        // Cached matches are keyed by selector allocation and
222                        // node id; a rebuilt rule set allocates new selectors.
223                        cache.match_cache.clear();
224                    }
225
226                    // Selector match results also embed the DOM (ancestors,
227                    // siblings, :checked, …), so a new snapshot invalidates
228                    // them even when the rule set is unchanged. Interaction state
229                    // affects :hover/:active matching, so it must invalidate too.
230                    if cache.match_cache_dom_version != task.dom_version
231                        || cache.match_cache_interaction_version != task.interaction_version
232                    {
233                        cache.match_cache.clear();
234                        cache.match_cache_dom_version = task.dom_version;
235                        cache.match_cache_interaction_version = task.interaction_version;
236                    }
237
238                    let WorkerCache {
239                        rule_set,
240                        match_cache,
241                        ..
242                    } = &mut *cache;
243                    let (layout, info, animation_inputs) = build_layout_and_info_from_snapshot(
244                        &task.snapshot,
245                        task.root,
246                        rule_set,
247                        task.measurer,
248                        task.audio_sinks,
249                        task.parent,
250                        task.chain,
251                        task.system_color_scheme,
252                        task.scripting_mode,
253                        &task.images,
254                        &task.audio,
255                        task.write_back_sender,
256                        Some(match_cache),
257                        &task.interaction,
258                        &task.existing_custom_nodes,
259                        &task.animated_overrides,
260                    );
261                    let result = LayoutResult {
262                        layout,
263                        info,
264                        animation_inputs,
265                        version: task.version,
266                    };
267
268                    profile_log!(
269                        target: "LayoutRun",
270                        log::Level::Info,
271                        "[LayoutRun] build: total {:?} | ruleset_build: {:?} | cache_hit: {} | {} | selector_match_cache: {}/{} (skips {})",
272                        worker_total.elapsed(),
273                        ruleset_build_time,
274                        cache_hit,
275                        cache.match_cache.stats(),
276                        cache.match_cache.len(),
277                        cache.match_cache.capacity(),
278                        cache.match_cache.stats().skips,
279                    );
280                    Some(SendableResult(Box::into_raw(Box::new(result))))
281                }
282            }
283        });
284
285        Self { worker, latest }
286    }
287
288    /// Sends a layout task to the thread.
289    ///
290    /// The task is stamped with a fresh sequence number; tasks that fall behind
291    /// the newest one are skipped by the thread.
292    pub fn send(&self, task: LayoutTask) -> u64 {
293        let mut task = task;
294        task.version = self.latest.fetch_add(1, Ordering::SeqCst) + 1;
295        let version = task.version;
296        self.worker.send(LayoutCommand::Build(task));
297        version
298    }
299
300    /// Returns a completed layout result, or `None` if none is ready yet.
301    pub fn try_receive(&self) -> Option<LayoutResult> {
302        let inner = self.worker.try_receive()?;
303        let SendableResult(ptr) = inner?;
304        // SAFETY: `ptr` was produced by the thread with `Box::into_raw` and
305        // has been delivered over the channel. We are the sole owner here.
306        let boxed = unsafe { Box::from_raw(ptr) };
307        Some(*boxed)
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use std::sync::mpsc;
314    use std::time::{Duration, Instant};
315
316    use super::*;
317    use crate::engine::bridge::audio::NullAudioSinkFactory;
318    use crate::engine::bridge::text::FallbackTextMeasurer;
319    use crate::engine::html::parser::Parser as HtmlParser;
320    use crate::engine::layouter::types::NodeKind;
321    use crate::engine::ui::custom_node::CustomNode;
322    use crate::engine::ui::input_text_types::InputTextEvent;
323
324    fn sample_task(write_back_sender: Option<DomWriteBack>) -> LayoutTask {
325        let html = "<html><body><p>hello</p><input value='a'></body></html>";
326        let dom = HtmlParser::new(html).parse();
327        let (snapshot, _dom_refs) = DomSnapshot::from_tree(&dom.root);
328        let root = snapshot.roots()[0];
329        LayoutTask {
330            snapshot: Arc::new(snapshot),
331            root,
332            resolved_styles: Arc::new(ResolvedStyles::default()),
333            media_environment: MediaEnvironment::new((0.0, 0.0), ColorScheme::Light),
334            measurer: Arc::new(FallbackTextMeasurer),
335            audio_sinks: Arc::new(NullAudioSinkFactory),
336            system_color_scheme: ColorScheme::Light,
337            scripting_mode: ScriptingMode::default(),
338            images: HashMap::new(),
339            audio: HashMap::new(),
340            parent: InheritedCss::default(),
341            chain: ElementChain::default(),
342            write_back_sender,
343            styles_version: 0,
344            dom_version: 0,
345            version: 0,
346            interaction: Arc::new(InteractionState::default()),
347            interaction_version: 0,
348            existing_custom_nodes: HashMap::new(),
349            animated_overrides: HashMap::new(),
350        }
351    }
352
353    fn wait_for_result(processor: &LayoutProcessor) -> LayoutResult {
354        let deadline = Instant::now() + Duration::from_secs(5);
355        loop {
356            if let Some(result) = processor.try_receive() {
357                return result;
358            }
359            assert!(
360                Instant::now() < deadline,
361                "layout result did not arrive before the timeout"
362            );
363            std::thread::sleep(Duration::from_millis(1));
364        }
365    }
366
367    fn find_custom<'i>(info: &'i InfoNode, role: &str) -> Option<&'i dyn CustomNode> {
368        if let NodeKind::Custom { node, .. } = &info.kind
369            && node.role() == Some(role)
370        {
371            return Some(&**node);
372        }
373        info.children.iter().find_map(|c| find_custom(c, role))
374    }
375
376    #[test]
377    fn layout_task_round_trips_through_background_thread() {
378        let processor = LayoutProcessor::new();
379        let requested_version = processor.send(sample_task(None));
380
381        let result = wait_for_result(&processor);
382        assert_eq!(result.version, requested_version);
383        assert!(
384            !result.info.children.is_empty(),
385            "the built layout must not be empty"
386        );
387    }
388
389    #[test]
390    fn input_edits_are_reported_through_write_back_channel() {
391        let (tx, rx) = mpsc::channel::<(u32, crate::engine::ui::registry::DomWrite)>();
392        let processor = LayoutProcessor::new();
393        processor.send(sample_task(Some(tx)));
394
395        let result = wait_for_result(&processor);
396        let input = find_custom(&result.info, "textbox")
397            .expect("input component must exist in the Info tree");
398
399        input.handle_text_input(InputTextEvent::Insert("hello".into()));
400
401        let (node_id, write) = rx
402            .recv_timeout(Duration::from_secs(5))
403            .expect("write-back was never sent");
404        assert!(node_id > 0, "input node id is invalid");
405        assert_eq!(
406            write,
407            crate::engine::ui::registry::DomWrite::Value("ahello".into())
408        );
409    }
410}