diff --git a/crates/grida-canvas/src/htmlcss/collect.rs b/crates/grida-canvas/src/htmlcss/collect.rs
index a9d5750dc..2ad820e71 100644
--- a/crates/grida-canvas/src/htmlcss/collect.rs
+++ b/crates/grida-canvas/src/htmlcss/collect.rs
@@ -9,7 +9,7 @@ use crate::cg::prelude::*;
use super::style::GradientStop;
use csscascade::adapter::{self, HtmlElement};
-use csscascade::dom::{DemoDom, DemoNodeData};
+use csscascade::dom::{DemoDom, DemoNode, DemoNodeData};
use style::color::{AbsoluteColor, ColorSpace};
use style::dom::TElement;
@@ -150,6 +150,36 @@ fn to_roman(mut n: i32) -> String {
result
}
+// ─── HTML attribute helpers ─────────────────────────────────────────
+
+/// Get an HTML attribute value from a DOM node.
+fn get_element_attr(node: &DemoNode, name: &str) -> Option {
+ match &node.data {
+ DemoNodeData::Element(data) => data
+ .attrs
+ .iter()
+ .find(|a| a.name.local.as_ref().eq_ignore_ascii_case(name))
+ .map(|a| a.value.to_string()),
+ _ => None,
+ }
+}
+
+/// Check if an HTML attribute is present (boolean attribute like `checked`, `disabled`).
+fn has_element_attr(node: &DemoNode, name: &str) -> bool {
+ get_element_attr(node, name).is_some()
+}
+
+/// Collect the concatenated text content of a DOM node's children (shallow).
+fn collect_text_content(dom: &DemoDom, node: &DemoNode) -> String {
+ let mut text = String::new();
+ for child_id in &node.children {
+ if let DemoNodeData::Text(t) = &dom.node(*child_id).data {
+ text.push_str(t);
+ }
+ }
+ text
+}
+
fn collect_element(element: HtmlElement) -> StyledElement {
collect_element_with_counter(element, &mut None)
}
@@ -214,10 +244,13 @@ fn collect_element_with_counter(
None
};
- // Collect children, merging consecutive inline content into InlineGroups
+ // ── Widget detection (form controls) ──
let dom = adapter::dom();
let node_data = dom.node(element.node_id());
+ let is_void_widget = detect_widget(&tag, node_data, dom, &mut el);
+
+ // Collect children, merging consecutive inline content into InlineGroups
let mut pending_inline: Vec = Vec::new();
let parent_text_align = el.font.text_align;
let parent_font = el.font.clone();
@@ -234,40 +267,46 @@ fn collect_element_with_counter(
}));
}
- for child_id in &node_data.children {
- let child_node = dom.node(*child_id);
- match &child_node.data {
- DemoNodeData::Text(text) => {
- let processed = process_whitespace(text, parent_white_space);
- if !processed.is_empty() {
- pending_inline.push(InlineRunItem::Text(TextRun {
- text: processed,
- font: parent_font.clone(),
- color: parent_color,
- decoration: None,
- }));
- }
- }
- DemoNodeData::Element(_) => {
- let child_el = HtmlElement::from_node_id(*child_id);
- let child = collect_element_with_counter(child_el, &mut child_counter);
- if child.display == types::Display::None {
- continue;
+ // Void widget elements () have no DOM children to collect.
+ if !is_void_widget {
+ for child_id in &node_data.children {
+ let child_node = dom.node(*child_id);
+ match &child_node.data {
+ DemoNodeData::Text(text) => {
+ let processed = process_whitespace(text, parent_white_space);
+ if !processed.is_empty() {
+ pending_inline.push(InlineRunItem::Text(TextRun {
+ text: processed,
+ font: parent_font.clone(),
+ color: parent_color,
+ decoration: None,
+ }));
+ }
}
+ DemoNodeData::Element(_) => {
+ let child_el = HtmlElement::from_node_id(*child_id);
+ let child = collect_element_with_counter(child_el, &mut child_counter);
+ if child.display == types::Display::None {
+ continue;
+ }
- if child.display == types::Display::Inline
- || child.display == types::Display::InlineBlock
- {
- // Flatten inline element's content into the pending items
- // (Chromium: InlineItemsBuilder flattens DOM → kOpenTag/kText/kCloseTag)
- collect_inline_items(&child, &mut pending_inline);
- } else {
- // Block child — flush pending inline content first
- flush_inline_group(&mut pending_inline, parent_text_align, &mut el.children);
- el.children.push(StyledNode::Element(child));
+ // Widgets with intrinsic sizes need their own Taffy node
+ // for sizing to work — don't flatten them into inline groups.
+ let is_inline = child.display == types::Display::Inline
+ || child.display == types::Display::InlineBlock;
+ if is_inline && !child.widget.is_widget() {
+ collect_inline_items(&child, &mut pending_inline);
+ } else {
+ flush_inline_group(
+ &mut pending_inline,
+ parent_text_align,
+ &mut el.children,
+ );
+ el.children.push(StyledNode::Element(child));
+ }
}
+ _ => {}
}
- _ => {} // comments, doctypes
}
}
@@ -277,6 +316,320 @@ fn collect_element_with_counter(
el
}
+// ─── Widget (form control) detection ────────────────────────────────
+
+/// Chromium placeholder color (#757575).
+const PLACEHOLDER_COLOR: CGColor = CGColor {
+ r: 117,
+ g: 117,
+ b: 117,
+ a: 255,
+};
+
+/// Detect form control elements and populate `StyledElement::widget`.
+///
+/// Returns `true` for void elements (like ``) whose DOM children
+/// should be skipped.
+fn detect_widget(tag: &str, node_data: &DemoNode, dom: &DemoDom, el: &mut StyledElement) -> bool {
+ match tag {
+ "input" => {
+ detect_input_widget(node_data, el);
+ true // is a void element
+ }
+ "textarea" => {
+ detect_textarea_widget(node_data, dom, el);
+ true // skip text children — value already injected
+ }
+ "select" => {
+ detect_select_widget(node_data, dom, el);
+ true // skip