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}