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::types::InfoNode;
27use crate::engine::background_worker::BackgroundWorker;
28use crate::engine::bridge::text::TextMeasurer;
29use crate::engine::css::matcher::ElementChain;
30use crate::engine::html::ScriptingMode;
31use crate::engine::layouter::css_resolver::RuleSet;
32use crate::engine::layouter::types::ColorScheme;
33use crate::engine::renderer_model::Image;
34use crate::engine::ui::registry::DomWriteBack;
35use crate::{perf_scope, profile_log};
36
37/// The complete set of inputs the builder needs to run on the background thread.
38pub struct LayoutTask {
39    pub snapshot: Arc<DomSnapshot>,
40    pub root: NodeId,
41    pub resolved_styles: Arc<ResolvedStyles>,
42    pub media_environment: MediaEnvironment,
43    pub measurer: Arc<dyn TextMeasurer>,
44    pub system_color_scheme: ColorScheme,
45    pub scripting_mode: ScriptingMode,
46    pub images: HashMap<String, Image>,
47    pub audio: HashMap<String, Arc<[u8]>>,
48    pub parent: InheritedCss,
49    pub chain: ElementChain,
50    pub write_back_sender: Option<DomWriteBack>,
51    /// Monotonic version of `resolved_styles` at task creation time. Assigned
52    /// by the UI thread; see [`LayoutProcessor`] for how it drives the
53    /// [`RuleSet`] cache.
54    pub styles_version: u64,
55    /// Monotonic sequence number used to coalesce stale tasks. Assigned by
56    /// [`LayoutProcessor::send`], ignore when constructing a task.
57    pub version: u64,
58}
59
60/// Per-worker cache of the last built [`RuleSet`].
61///
62/// Building a `RuleSet` (`@media` filtering, selector grouping and subject
63/// indexing) is the dominant fixed cost of every layout build. The styles
64/// only change on CSS application, so the rule set is reusable across the
65/// numerous visual-update tasks in between. The UI thread bumps
66/// [`LayoutTask::styles_version`] on every mutation of the styles, so the
67/// worker can tell a stale rule set from the current one even when the
68/// styles were changed in place (`Arc::make_mut`).
69struct RuleSetCache {
70    /// Styles version the cached rule set was built from.
71    styles_version: u64,
72    /// Media environment the cached rule set was built against.
73    media_environment: MediaEnvironment,
74    /// The cached rule set; valid only when `have_rule_set` is set.
75    rule_set: RuleSet,
76    /// Whether `rule_set` has been populated at least once.
77    have_rule_set: bool,
78}
79
80impl Default for RuleSetCache {
81    fn default() -> Self {
82        Self {
83            styles_version: 0,
84            media_environment: MediaEnvironment::new((0.0, 0.0), ColorScheme::Light),
85            rule_set: RuleSet::default(),
86            have_rule_set: false,
87        }
88    }
89}
90
91/// The layout the builder finished on the background thread.
92pub struct LayoutResult {
93    pub layout: LayoutNode,
94    pub info: InfoNode,
95    /// Sequence number of the task that produced this result.
96    pub version: u64,
97}
98
99enum LayoutCommand {
100    Build(LayoutTask),
101}
102
103/// A pointer wrapper that transfers a [`LayoutResult`] across a channel.
104///
105/// `LayoutNode` contains ui_layout's `Box<dyn CustomLayouter>`, which is not
106/// `Send`. The result is therefore leaked onto the heap with `Box::into_raw`
107/// and sent as a pointer; the receiving side rebuilds it with `Box::from_raw`
108/// as the single owner.
109///
110/// Safety is guaranteed by the command/response protocol:
111/// - The `Box` is allocated by the thread and leaked with `into_raw`.
112/// - The raw pointer is transferred through an `mpsc` channel (which
113///   establishes a happens-before relationship between send and receive).
114/// - The receiving (UI) thread rebuilds the `Box` exactly once, so there is
115///   exactly one owner at any point in time.
116struct SendableResult(*mut LayoutResult);
117
118// SAFETY: the pointed-to `Box` is accessed only by the receiving side after
119// the channel delivers it; no other thread touches it.
120unsafe impl Send for SendableResult {}
121
122/// A processor that accepts a [`DomSnapshot`] and returns the layout result
123/// produced by the thread.
124pub struct LayoutProcessor {
125    worker: BackgroundWorker<LayoutCommand, Option<SendableResult>>,
126    /// Latest task sequence number; shared with the thread so it can detect
127    /// and skip superseded tasks.
128    latest: Arc<AtomicU64>,
129}
130
131impl std::fmt::Debug for LayoutProcessor {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        f.write_str("LayoutProcessor")
134    }
135}
136
137impl Default for LayoutProcessor {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143impl LayoutProcessor {
144    pub fn new() -> Self {
145        let latest = Arc::new(AtomicU64::new(0));
146        let latest_clone = Arc::clone(&latest);
147        let cache = Arc::new(Mutex::new(RuleSetCache::default()));
148
149        let worker = BackgroundWorker::new(1, move |cmd: LayoutCommand| {
150            match cmd {
151                LayoutCommand::Build(task) => {
152                    // A newer task queued after this one supersedes it: the
153                    // newest task's snapshot/styles/images include every
154                    // change made up to that point, so skip the build.
155                    if task.version < latest_clone.load(Ordering::SeqCst) {
156                        return None;
157                    }
158                    let version = task.version;
159                    perf_scope!(worker_total);
160
161                    let mut cache = cache.lock().expect("layout rule-set cache poisoned");
162                    let cache_hit = cache.have_rule_set
163                        && cache.styles_version == task.styles_version
164                        && cache.media_environment == task.media_environment;
165
166                    #[cfg(any(feature = "profile", debug_assertions))]
167                    let ruleset_build_time;
168                    if cache_hit {
169                        #[cfg(any(feature = "profile", debug_assertions))]
170                        {
171                            ruleset_build_time = std::time::Duration::ZERO;
172                        }
173                    } else {
174                        // TODO: Add incremental RuleSet updates
175                        perf_scope!(ruleset_build);
176                        cache.rule_set = RuleSet::from_declarations(
177                            &task.resolved_styles,
178                            &task.media_environment,
179                        );
180                        #[cfg(any(feature = "profile", debug_assertions))]
181                        {
182                            ruleset_build_time = ruleset_build.elapsed();
183                        }
184                        cache.styles_version = task.styles_version;
185                        cache.media_environment = task.media_environment;
186                        cache.have_rule_set = true;
187                    }
188
189                    let (layout, info) = build_layout_and_info_from_snapshot(
190                        &task.snapshot,
191                        task.root,
192                        &cache.rule_set,
193                        task.measurer,
194                        task.parent,
195                        task.chain,
196                        task.system_color_scheme,
197                        task.scripting_mode,
198                        &task.images,
199                        &task.audio,
200                        task.write_back_sender,
201                    );
202                    let result = LayoutResult {
203                        layout,
204                        info,
205                        version,
206                    };
207
208                    profile_log!(
209                        target: "LayoutRun",
210                        log::Level::Info,
211                        "[LayoutRun] build: total {:?} | ruleset_build: {:?} | cache_hit: {}",
212                        worker_total.elapsed(),
213                        ruleset_build_time,
214                        cache_hit,
215                    );
216                    Some(SendableResult(Box::into_raw(Box::new(result))))
217                }
218            }
219        });
220
221        Self { worker, latest }
222    }
223
224    /// Sends a layout task to the thread.
225    ///
226    /// The task is stamped with a fresh sequence number; tasks that fall behind
227    /// the newest one are skipped by the thread.
228    pub fn send(&self, task: LayoutTask) -> u64 {
229        let mut task = task;
230        task.version = self.latest.fetch_add(1, Ordering::SeqCst) + 1;
231        let version = task.version;
232        self.worker.send(LayoutCommand::Build(task));
233        version
234    }
235
236    /// Returns a completed layout result, or `None` if none is ready yet.
237    pub fn try_receive(&self) -> Option<LayoutResult> {
238        let inner = self.worker.try_receive()?;
239        let SendableResult(ptr) = inner?;
240        // SAFETY: `ptr` was produced by the thread with `Box::into_raw` and
241        // has been delivered over the channel. We are the sole owner here.
242        let boxed = unsafe { Box::from_raw(ptr) };
243        Some(*boxed)
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use std::sync::mpsc;
250    use std::time::{Duration, Instant};
251
252    use super::*;
253    use crate::engine::bridge::text::FallbackTextMeasurer;
254    use crate::engine::html::parser::Parser as HtmlParser;
255    use crate::engine::layouter::types::NodeKind;
256    use crate::engine::ui::custom_node::CustomNode;
257    use crate::engine::ui::input_text_types::InputTextEvent;
258
259    fn sample_task(write_back_sender: Option<DomWriteBack>) -> LayoutTask {
260        let html = "<html><body><p>hello</p><input value='a'></body></html>";
261        let dom = HtmlParser::new(html).parse();
262        let (snapshot, _dom_refs) = DomSnapshot::from_tree(&dom.root);
263        let root = snapshot.roots()[0];
264        LayoutTask {
265            snapshot: Arc::new(snapshot),
266            root,
267            resolved_styles: Arc::new(ResolvedStyles::default()),
268            media_environment: MediaEnvironment::new((0.0, 0.0), ColorScheme::Light),
269            measurer: Arc::new(FallbackTextMeasurer),
270            system_color_scheme: ColorScheme::Light,
271            scripting_mode: ScriptingMode::default(),
272            images: HashMap::new(),
273            audio: HashMap::new(),
274            parent: InheritedCss::default(),
275            chain: ElementChain::default(),
276            write_back_sender,
277            styles_version: 0,
278            version: 0,
279        }
280    }
281
282    fn wait_for_result(processor: &LayoutProcessor) -> LayoutResult {
283        let deadline = Instant::now() + Duration::from_secs(5);
284        loop {
285            if let Some(result) = processor.try_receive() {
286                return result;
287            }
288            assert!(
289                Instant::now() < deadline,
290                "layout result did not arrive before the timeout"
291            );
292            std::thread::sleep(Duration::from_millis(1));
293        }
294    }
295
296    fn find_custom<'i>(info: &'i InfoNode, role: &str) -> Option<&'i dyn CustomNode> {
297        if let NodeKind::Custom { node, .. } = &info.kind
298            && node.role() == Some(role)
299        {
300            return Some(&**node);
301        }
302        info.children.iter().find_map(|c| find_custom(c, role))
303    }
304
305    #[test]
306    fn layout_task_round_trips_through_background_thread() {
307        let processor = LayoutProcessor::new();
308        let requested_version = processor.send(sample_task(None));
309
310        let result = wait_for_result(&processor);
311        assert_eq!(result.version, requested_version);
312        assert!(
313            !result.info.children.is_empty(),
314            "the built layout must not be empty"
315        );
316    }
317
318    #[test]
319    fn input_edits_are_reported_through_write_back_channel() {
320        let (tx, rx) = mpsc::channel::<(u32, String)>();
321        let processor = LayoutProcessor::new();
322        processor.send(sample_task(Some(tx)));
323
324        let result = wait_for_result(&processor);
325        let input = find_custom(&result.info, "textbox")
326            .expect("input component must exist in the Info tree");
327
328        input.handle_text_input(InputTextEvent::Insert("hello".into()));
329
330        let (node_id, value) = rx
331            .recv_timeout(Duration::from_secs(5))
332            .expect("write-back was never sent");
333        assert!(node_id > 0, "input node id is invalid");
334        assert_eq!(value, "ahello");
335    }
336}