Skip to main content

cotis_web/
rendering.rs

1//! DOM canvas and drawable trait for mapping Cotis render commands to HTML/CSS.
2//!
3//! The typical app path is [`HTMLRenderer::draw_frame`](crate::renderer::HTMLRenderer) →
4//! [`draw_html_render_commands`], which creates an [`HTMLCanvas`], draws each
5//! [`HTMLDrawable`] command, then calls [`HTMLCanvas::finish`].
6//!
7//! Default drawables for [`cotis_defaults::render_commands`]
8//! live in [`drawable_defaults`].
9//!
10//! ## Frame lifecycle
11//!
12//! 1. [`HTMLCanvas::new`] → JS `beginFrame`
13//! 2. For each command: [`HTMLCanvas::ensure_command_element`] + CSS pushes (or clip stack ops)
14//! 3. [`HTMLCanvas::finish`] → finalize DOM nodes → JS `endFrame` removes unused `cotis-*` ids
15//!
16//! ## Quick start
17//!
18//! ```rust,ignore
19//! use cotis_web::rendering::{HTMLCanvas, HTMLDrawable};
20//!
21//! let mut canvas = HTMLCanvas::new();
22//! canvas
23//!     .ensure_command_element(1, "div", None)
24//!     .current_element_css_push("position: absolute; left: 10px; top: 10px")
25//!     .current_element_css_push("width: 100px; height: 50px")
26//!     .current_element_css_push("background-color: red; border-radius: 5px");
27//! canvas.finish();
28//! ```
29//!
30//! ## Core API
31//!
32//! - [`HTMLCanvas::ensure_command_element`] — open or reuse host for a command id (`cotis-{id}`)
33//! - [`HTMLCanvas::new_element`] — ad-hoc child element with generated id
34//! - [`HTMLCanvas::current_element_css_push`] — append/overwrite CSS on current host
35//! - [`HTMLCanvas::finish`] — commit frame and remove stale DOM nodes
36
37pub mod drawable_defaults;
38
39use crate::web_functions::primitives::{
40    append_to_current_container, begin_frame, end_frame, get_or_create_host_element,
41    scissor_stack_pop, scissor_stack_push,
42};
43use cotis_defaults::render_commands::CornerRadii;
44use js_sys::Set;
45use std::collections::HashMap;
46use wasm_bindgen::{JsCast, JsValue, closure::Closure};
47
48/// Per-frame DOM builder: creates/updates `cotis-{id}` elements and manages clip stacks.
49///
50/// One instance should be created per frame via [`HTMLCanvas::new`] and consumed with
51/// [`HTMLCanvas::finish`]. Commands that share a render id reuse the same host element.
52pub struct HTMLCanvas {
53    // Tracks all used element IDs for cleanup
54    used_element_ids: Set,
55
56    // Current element being built
57    current_element: Option<ElementBuilder>,
58
59    /// When set, `current_element` belongs to this render command id (`cotis-{id}` in the DOM).
60    active_command_element_id: Option<u32>,
61
62    // Counter for generating unique element IDs (e.g. clip containers)
63    element_counter: usize,
64}
65
66struct ElementBuilder {
67    id: String,
68    tag_name: String,
69    extra_style: Option<String>,
70    css_properties: HashMap<String, String>,
71    text_content: Option<String>,
72    inner_html: Option<String>,
73}
74
75impl Default for HTMLCanvas {
76    fn default() -> Self {
77        Self::new()
78    }
79}
80
81impl HTMLCanvas {
82    /// Starts a new rendering frame (calls JS `beginFrame`).
83    ///
84    /// # WASM
85    ///
86    /// Requires an initialized HTML root (`init_html_root` in `renderer.js`).
87    pub fn new() -> Self {
88        begin_frame();
89        Self {
90            used_element_ids: Set::new(&JsValue::NULL),
91            current_element: None,
92            active_command_element_id: None,
93            element_counter: 0,
94        }
95    }
96
97    /// Opens or reuses the host element for a render command. Multiple drawables that share the
98    /// same `command_id` accumulate `current_element_css_push` on one DOM node (`cotis-{id}`).
99    pub fn ensure_command_element(
100        &mut self,
101        command_id: u32,
102        html_type: &str,
103        extra_style: Option<&str>,
104    ) {
105        if self.active_command_element_id == Some(command_id) {
106            return;
107        }
108        self.finalize_current_element();
109        self.active_command_element_id = Some(command_id);
110        let dom_id = format!("cotis-{}", command_id);
111        self.current_element = Some(ElementBuilder {
112            id: dom_id,
113            tag_name: html_type.to_string(),
114            extra_style: extra_style.map(String::from),
115            css_properties: HashMap::new(),
116            text_content: None,
117            inner_html: None,
118        });
119    }
120
121    /// Add CSS properties to the current element. Properties are parsed from CSS syntax.
122    /// If a property already exists, it will be overwritten.
123    /// Example: "background-color: red; padding: 10px"
124    pub fn current_element_css_push(&mut self, value: &str) -> &mut Self {
125        if let Some(builder) = &mut self.current_element {
126            // Parse CSS string and add to properties map
127            for declaration in value.split(';') {
128                let declaration = declaration.trim();
129                if declaration.is_empty() {
130                    continue;
131                }
132
133                if let Some((prop, val)) = declaration.split_once(':') {
134                    let prop = prop.trim().to_string();
135                    let val = val.trim().to_string();
136                    builder.css_properties.insert(prop, val);
137                }
138            }
139        }
140        self
141    }
142
143    /// Set the text content of the current element (internal use)
144    pub fn set_text_content(&mut self, text: String) {
145        if let Some(builder) = &mut self.current_element {
146            builder.text_content = Some(text);
147        }
148    }
149
150    /// Set `innerHTML` for the current element (e.g. custom HTML hosts)
151    pub fn set_inner_html(&mut self, html: String) {
152        if let Some(builder) = &mut self.current_element {
153            builder.inner_html = Some(html);
154        }
155    }
156
157    /// Create a new child element. This finalizes the current element and starts building a new one.
158    ///
159    /// # Arguments
160    /// * `prefix` - A prefix for the element ID (for debugging/identification)
161    /// * `html_type` - The HTML tag name (e.g., "div", "button", "span")
162    pub fn new_element(&mut self, prefix: &str, html_type: &str) -> &mut Self {
163        // Finalize the current element if one exists
164        self.finalize_current_element();
165
166        // Start building a new element
167        self.element_counter += 1;
168        let id = format!("{}_{}", prefix, self.element_counter);
169
170        self.current_element = Some(ElementBuilder {
171            id,
172            tag_name: html_type.to_string(),
173            extra_style: None,
174            css_properties: HashMap::new(),
175            text_content: None,
176            inner_html: None,
177        });
178
179        self
180    }
181
182    /// Finalize the current element by creating/updating the DOM element and appending it
183    fn finalize_current_element(&mut self) {
184        if let Some(builder) = self.current_element.take() {
185            self.active_command_element_id = None;
186            // Collect CSS properties
187            let css_map = builder.css_properties.clone();
188            let text_content = builder.text_content.clone();
189            let inner_html = builder.inner_html.clone();
190            let id_str = builder.id.clone();
191
192            // Create closure to apply styles
193            let apply_style_fn = Closure::wrap(Box::new(move |el: web_sys::Element| {
194                if let Some(html) = &inner_html {
195                    if let Some(html_el) = el.dyn_ref::<web_sys::HtmlElement>() {
196                        html_el.set_inner_html(html);
197                    }
198                } else if let Some(text) = &text_content {
199                    el.set_text_content(Some(text));
200                }
201
202                // Apply CSS properties
203                if let Some(html_el) = el.dyn_ref::<web_sys::HtmlElement>() {
204                    let style = html_el.style();
205                    for (prop, value) in &css_map {
206                        let _ = style.set_property(prop, value);
207                    }
208                }
209            }) as Box<dyn FnMut(web_sys::Element)>);
210
211            // Get or create the element
212            let el = get_or_create_host_element(
213                &builder.id,
214                &builder.tag_name,
215                builder.extra_style.as_deref(),
216                apply_style_fn.as_ref().unchecked_ref(),
217            );
218
219            // Append to current container (handles scissor stack)
220            append_to_current_container(&el);
221
222            // Track this element ID for cleanup
223            self.used_element_ids.add(&JsValue::from_str(&id_str));
224
225            // Clean up the closure
226            apply_style_fn.forget();
227        }
228    }
229
230    /// Commits the frame and removes DOM nodes not referenced in `used_element_ids`.
231    ///
232    /// Consumes `self`. Must be called once per frame after all drawables have run.
233    pub fn finish(mut self) {
234        // Finalize any remaining element
235        self.finalize_current_element();
236
237        // End frame and cleanup unused elements
238        end_frame(&self.used_element_ids);
239    }
240
241    /// Pushes a clip region ([`ClipStart`](cotis_defaults::render_commands::ClipStart)).
242    ///
243    /// Creates an overflow-hidden container and pushes it onto the JS scissor stack so
244    /// subsequent elements are clipped. Used by [`drawable_defaults`](drawable_defaults).
245    pub(crate) fn push_scissor(
246        &mut self,
247        command_id: u32,
248        x: f32,
249        y: f32,
250        width: f32,
251        height: f32,
252        corner_radii: CornerRadii,
253    ) {
254        // Finalize current element first
255        self.finalize_current_element();
256
257        let id = format!("cotis-{}", command_id);
258
259        let container = web_sys::window()
260            .unwrap()
261            .document()
262            .unwrap()
263            .create_element("div")
264            .unwrap();
265        container.set_id(&id);
266
267        let style = container.dyn_ref::<web_sys::HtmlElement>().unwrap().style();
268        let _ = style.set_property("position", "absolute");
269        let _ = style.set_property("left", &format!("{}px", x));
270        let _ = style.set_property("top", &format!("{}px", y));
271        let _ = style.set_property("width", &format!("{}px", width));
272        let _ = style.set_property("height", &format!("{}px", height));
273        let _ = style.set_property("overflow", "hidden");
274        let _ = style.set_property(
275            "border-radius",
276            &format!(
277                "{}px {}px {}px {}px",
278                corner_radii.top_left,
279                corner_radii.top_right,
280                corner_radii.bottom_right,
281                corner_radii.bottom_left
282            ),
283        );
284        let _ = style.set_property("box-sizing", "border-box");
285
286        append_to_current_container(&container);
287        scissor_stack_push(&container, x as f64, y as f64);
288        self.used_element_ids.add(&JsValue::from_str(&id));
289    }
290
291    /// Pops the top clip region ([`ClipEnd`](cotis_defaults::render_commands::ClipEnd)).
292    pub(crate) fn pop_scissor(&mut self) {
293        // Finalize current element first
294        self.finalize_current_element();
295
296        scissor_stack_pop();
297    }
298}
299
300/// Types that can draw themselves onto an [`HTMLCanvas`] (typically `cotis-defaults` commands).
301pub trait HTMLDrawable {
302    /// Renders this command onto `canvas`, usually via `ensure_command_element` and CSS pushes.
303    fn draw(self, canvas: &mut HTMLCanvas);
304}
305
306/// Renders an iterator of drawables as one DOM frame.
307///
308/// Creates an [`HTMLCanvas`], draws each item (e.g. a [`RenderTList`](cotis_defaults::render_commands::render_t_list::RenderTList)
309/// from layout output), then calls [`HTMLCanvas::finish`].
310pub fn draw_html_render_commands(commands: impl Iterator<Item = impl HTMLDrawable>) {
311    let mut canvas = HTMLCanvas::new();
312    for cmd in commands {
313        cmd.draw(&mut canvas);
314    }
315    canvas.finish();
316}