use rdom_core::{NodeMut, NodeRef};
use rdom_style::TuiStyle;
use rdom_style::property_dispatch;
use rdom_style::property_dispatch::DispatchError;
use crate::TuiExt;
use crate::node::TuiNodeExt;
#[derive(Debug)]
pub enum SetPropertyError {
Parse(DispatchError),
Tree(rdom_core::DomError),
}
impl From<DispatchError> for SetPropertyError {
fn from(e: DispatchError) -> Self {
Self::Parse(e)
}
}
impl From<rdom_core::DomError> for SetPropertyError {
fn from(e: rdom_core::DomError) -> Self {
Self::Tree(e)
}
}
impl core::fmt::Display for SetPropertyError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Parse(DispatchError::UnknownProperty) => {
write!(f, "unknown CSS property")
}
Self::Parse(DispatchError::InvalidValue) => {
write!(f, "invalid value for property")
}
Self::Tree(e) => write!(f, "DOM tree error: {e:?}"),
}
}
}
impl std::error::Error for SetPropertyError {}
pub struct StyleDeclaration {
inline: TuiStyle,
}
impl StyleDeclaration {
pub(crate) fn new(inline: TuiStyle) -> Self {
Self { inline }
}
pub fn get_property_value(&self, name: &str) -> String {
property_dispatch::serialize(name, &self.inline).unwrap_or_default()
}
pub fn get_property_priority(&self, name: &str) -> &'static str {
match property_dispatch::property_mask(name) {
Some(mask) if self.inline.important.contains(mask) => "important",
_ => "",
}
}
pub fn css_text(&self) -> String {
css_text_of(&self.inline)
}
pub fn length(&self) -> usize {
property_dispatch::property_names()
.iter()
.filter(|&&name| property_dispatch::serialize(name, &self.inline).is_some())
.count()
}
pub fn item(&self, index: usize) -> Option<&'static str> {
property_dispatch::property_names()
.iter()
.copied()
.filter(|&name| property_dispatch::serialize(name, &self.inline).is_some())
.nth(index)
}
}
pub struct StyleDeclarationMut<'a> {
node: NodeMut<'a, TuiExt>,
}
impl<'a> StyleDeclarationMut<'a> {
pub(crate) fn new(node: NodeMut<'a, TuiExt>) -> Self {
Self { node }
}
pub fn set_property(&mut self, name: &str, value: &str) -> rdom_core::Result<()> {
swallow_parse_errors(self.try_write_inner(name, value, false))
}
pub fn set_property_important(&mut self, name: &str, value: &str) -> rdom_core::Result<()> {
swallow_parse_errors(self.try_write_inner(name, value, true))
}
pub fn try_set_property(&mut self, name: &str, value: &str) -> Result<(), SetPropertyError> {
self.try_write_inner(name, value, false)
}
pub fn try_set_property_important(
&mut self,
name: &str,
value: &str,
) -> Result<(), SetPropertyError> {
self.try_write_inner(name, value, true)
}
pub fn remove_property(&mut self, name: &str) -> rdom_core::Result<String> {
let id = self.node.id();
let dom = self.node.dom_mut();
let mut nm = dom.node_mut(id);
let Some(ext) = nm.ext_mut() else {
return Ok(String::new());
};
let prev = property_dispatch::serialize(name, &ext.inline_style).unwrap_or_default();
let removed = property_dispatch::remove(name, &mut ext.inline_style);
if !removed {
return Ok(prev);
}
let css_text = css_text_of(&ext.inline_style);
let _ = nm;
write_style_attribute(dom, id, &css_text)?;
Ok(prev)
}
pub fn set_css_text(&mut self, css: &str) -> rdom_core::Result<()> {
let id = self.node.id();
let dom = self.node.dom_mut();
let parsed = rdom_css::parse_inline(css);
let mut nm = dom.node_mut(id);
let Some(ext) = nm.ext_mut() else {
return Ok(());
};
ext.inline_style = parsed.style;
let css_text = css_text_of(&ext.inline_style);
let _ = nm;
write_style_attribute(dom, id, &css_text)?;
Ok(())
}
fn try_write_inner(
&mut self,
name: &str,
value: &str,
important: bool,
) -> Result<(), SetPropertyError> {
let id = self.node.id();
let dom = self.node.dom_mut();
let mut nm = dom.node_mut(id);
let Some(ext) = nm.ext_mut() else {
return Ok(());
};
property_dispatch::set(name, value, &mut ext.inline_style)?;
if let Some(mask) = property_dispatch::property_mask(name) {
if important {
ext.inline_style.important |= mask;
} else {
ext.inline_style.important = ext.inline_style.important.without(mask);
}
}
let css_text = css_text_of(&ext.inline_style);
let _ = nm;
write_style_attribute(dom, id, &css_text)?;
Ok(())
}
}
fn swallow_parse_errors(result: Result<(), SetPropertyError>) -> rdom_core::Result<()> {
match result {
Ok(()) | Err(SetPropertyError::Parse(_)) => Ok(()),
Err(SetPropertyError::Tree(e)) => Err(e),
}
}
fn write_style_attribute(
dom: &mut crate::TuiDom,
id: rdom_core::NodeId,
css_text: &str,
) -> rdom_core::Result<()> {
let _g = super::reentry::ReentryGuard::enter();
dom.set_attribute(id, "style", css_text)
}
fn css_text_of(style: &TuiStyle) -> String {
let mut out = String::new();
for &name in property_dispatch::property_names() {
if let Some(shorthand) = shorthand_family_of(name)
&& property_dispatch::serialize(shorthand, style).is_some()
{
continue;
}
if let Some(value) = property_dispatch::serialize(name, style) {
if !out.is_empty() {
out.push(' ');
}
out.push_str(name);
out.push_str(": ");
out.push_str(&value);
if let Some(mask) = property_dispatch::property_mask(name)
&& style.important.contains(mask)
{
out.push_str(" !important");
}
out.push(';');
}
}
out
}
fn shorthand_family_of(name: &str) -> Option<&'static str> {
match name {
"padding-top" | "padding-right" | "padding-bottom" | "padding-left" => Some("padding"),
"top" | "right" | "bottom" | "left" => Some("inset"),
"overflow-x" | "overflow-y" => Some("overflow"),
"transition-property"
| "transition-duration"
| "transition-timing-function"
| "transition-delay" => Some("transition"),
_ => None,
}
}
pub(crate) fn from_node_ref(node: &NodeRef<'_, TuiExt>) -> Option<StyleDeclaration> {
Some(StyleDeclaration::new(node.tui_ext()?.inline_style.clone()))
}
#[cfg(test)]
mod tests {
use crate::TuiDom;
use crate::{TuiAccessors, TuiAccessorsMut};
fn dom_with(tag: &str) -> (TuiDom, rdom_core::NodeId) {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let el = dom.create_element(tag);
dom.append_child(root, el).unwrap();
(dom, el)
}
#[test]
fn get_property_value_returns_empty_when_unset() {
let (dom, div) = dom_with("div");
let style = dom.node(div).style().expect("element has style");
assert_eq!(style.get_property_value("color"), "");
}
#[test]
fn style_returns_none_for_non_element() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let text = dom.create_text_node("hi");
dom.append_child(root, text).unwrap();
assert!(dom.node(text).style().is_none());
}
#[test]
fn get_property_priority_empty_by_default() {
let (dom, div) = dom_with("div");
let style = dom.node(div).style().unwrap();
assert_eq!(style.get_property_priority("color"), "");
}
#[test]
fn length_zero_on_fresh_element() {
let (dom, div) = dom_with("div");
assert_eq!(dom.node(div).style().unwrap().length(), 0);
}
#[test]
fn css_text_empty_on_fresh_element() {
let (dom, div) = dom_with("div");
assert_eq!(dom.node(div).style().unwrap().css_text(), "");
}
#[test]
fn item_out_of_range_is_none() {
let (dom, div) = dom_with("div");
assert!(dom.node(div).style().unwrap().item(0).is_none());
}
#[test]
fn set_property_writes_inline_style_field() {
let (mut dom, div) = dom_with("div");
dom.node_mut(div)
.style_mut()
.unwrap()
.set_property("color", "red")
.unwrap();
assert_eq!(
dom.node(div).style().unwrap().get_property_value("color"),
"red"
);
}
#[test]
fn set_property_writes_style_attribute_atomically() {
let (mut dom, div) = dom_with("div");
dom.node_mut(div)
.style_mut()
.unwrap()
.set_property("color", "red")
.unwrap();
assert_eq!(
dom.node(div).style().unwrap().get_property_value("color"),
"red"
);
let attr = dom.node(div).get_attribute("style").unwrap_or("");
assert!(
attr.contains("color: red"),
"style attribute should contain `color: red`, got {attr:?}"
);
}
#[test]
fn set_property_invalid_value_is_silent_no_op() {
let (mut dom, div) = dom_with("div");
let r = dom
.node_mut(div)
.style_mut()
.unwrap()
.set_property("color", "not-a-color");
assert!(r.is_ok());
assert_eq!(
dom.node(div).style().unwrap().get_property_value("color"),
"",
"inline_style.fg should be unset after invalid value"
);
assert_eq!(dom.node(div).get_attribute("style"), None);
}
#[test]
fn set_property_unknown_name_is_silent_no_op() {
let (mut dom, div) = dom_with("div");
let r = dom
.node_mut(div)
.style_mut()
.unwrap()
.set_property("not-a-property", "x");
assert!(r.is_ok());
assert_eq!(dom.node(div).get_attribute("style"), None);
}
#[test]
fn set_property_clears_prior_important_bit() {
let (mut dom, div) = dom_with("div");
dom.node_mut(div)
.style_mut()
.unwrap()
.set_property_important("color", "red")
.unwrap();
assert_eq!(
dom.node(div)
.style()
.unwrap()
.get_property_priority("color"),
"important"
);
dom.node_mut(div)
.style_mut()
.unwrap()
.set_property("color", "blue")
.unwrap();
assert_eq!(
dom.node(div)
.style()
.unwrap()
.get_property_priority("color"),
""
);
}
#[test]
fn set_property_important_raises_priority() {
let (mut dom, div) = dom_with("div");
dom.node_mut(div)
.style_mut()
.unwrap()
.set_property_important("color", "red")
.unwrap();
let style = dom.node(div).style().unwrap();
assert_eq!(style.get_property_value("color"), "red");
assert_eq!(style.get_property_priority("color"), "important");
assert!(style.css_text().contains("!important"));
}
#[test]
fn set_property_important_writes_attribute_with_marker() {
let (mut dom, div) = dom_with("div");
dom.node_mut(div)
.style_mut()
.unwrap()
.set_property_important("color", "red")
.unwrap();
let attr = dom.node(div).get_attribute("style").unwrap_or("");
assert!(
attr.contains("!important"),
"style attribute should include !important, got {attr:?}"
);
}
#[test]
fn remove_property_returns_previous_value() {
let (mut dom, div) = dom_with("div");
dom.node_mut(div)
.style_mut()
.unwrap()
.set_property("color", "red")
.unwrap();
let prev = dom
.node_mut(div)
.style_mut()
.unwrap()
.remove_property("color")
.unwrap();
assert_eq!(prev, "red");
assert_eq!(
dom.node(div)
.style()
.unwrap()
.get_property_priority("color"),
""
);
}
#[test]
fn remove_property_clears_field_and_important_bit() {
let (mut dom, div) = dom_with("div");
{
let mut nm = dom.node_mut(div);
let mut sd = nm.style_mut().unwrap();
sd.set_property_important("color", "red").unwrap();
sd.remove_property("color").unwrap();
}
let style = dom.node(div).style().unwrap();
assert_eq!(style.get_property_value("color"), "");
assert_eq!(style.get_property_priority("color"), "");
}
#[test]
fn remove_property_unset_returns_empty_string() {
let (mut dom, div) = dom_with("div");
let prev = dom
.node_mut(div)
.style_mut()
.unwrap()
.remove_property("color")
.unwrap();
assert_eq!(prev, "");
}
#[test]
fn remove_property_unknown_returns_empty_string() {
let (mut dom, div) = dom_with("div");
let prev = dom
.node_mut(div)
.style_mut()
.unwrap()
.remove_property("bogus")
.unwrap();
assert_eq!(prev, "");
}
#[test]
fn set_css_text_replaces_entire_declaration() {
let (mut dom, div) = dom_with("div");
{
let mut nm = dom.node_mut(div);
let mut sd = nm.style_mut().unwrap();
sd.set_property("color", "red").unwrap();
sd.set_css_text("display: block; gap: 2").unwrap();
}
let style = dom.node(div).style().unwrap();
assert_eq!(style.get_property_value("color"), "");
assert_eq!(style.get_property_value("display"), "block");
assert_eq!(style.get_property_value("gap"), "2");
}
#[test]
fn set_css_text_serializes_back_to_attribute() {
let (mut dom, div) = dom_with("div");
dom.node_mut(div)
.style_mut()
.unwrap()
.set_css_text("color: red; gap: 3")
.unwrap();
let attr = dom.node(div).get_attribute("style").unwrap_or("");
assert!(attr.contains("color: red"));
assert!(attr.contains("gap: 3"));
}
#[test]
fn length_counts_set_properties() {
let (mut dom, div) = dom_with("div");
{
let mut nm = dom.node_mut(div);
let mut sd = nm.style_mut().unwrap();
sd.set_property("color", "red").unwrap();
sd.set_property("gap", "2").unwrap();
}
assert_eq!(dom.node(div).style().unwrap().length(), 2);
}
#[test]
fn item_returns_property_names_in_canonical_order() {
let (mut dom, div) = dom_with("div");
{
let mut nm = dom.node_mut(div);
let mut sd = nm.style_mut().unwrap();
sd.set_property("gap", "2").unwrap();
sd.set_property("color", "red").unwrap();
}
let style = dom.node(div).style().unwrap();
assert_eq!(style.item(0), Some("color"));
assert_eq!(style.item(1), Some("gap"));
assert_eq!(style.item(2), None);
}
#[test]
fn css_text_round_trips_through_set_css_text() {
let (mut dom, div) = dom_with("div");
{
let mut nm = dom.node_mut(div);
let mut sd = nm.style_mut().unwrap();
sd.set_property("color", "red").unwrap();
sd.set_property("gap", "2").unwrap();
}
let serialized = dom.node(div).style().unwrap().css_text();
let root = dom.root();
let other = dom.create_element("div");
dom.append_child(root, other).unwrap();
dom.node_mut(other)
.style_mut()
.unwrap()
.set_css_text(&serialized)
.unwrap();
let s = dom.node(other).style().unwrap();
assert_eq!(s.get_property_value("color"), "red");
assert_eq!(s.get_property_value("gap"), "2");
}
#[test]
fn css_text_includes_important_marker() {
let (mut dom, div) = dom_with("div");
dom.node_mut(div)
.style_mut()
.unwrap()
.set_property_important("color", "red")
.unwrap();
let text = dom.node(div).style().unwrap().css_text();
assert!(text.contains("color: red !important"));
}
#[test]
fn alias_color_delegates_to_get_property_value() {
let (mut dom, div) = dom_with("div");
dom.node_mut(div)
.style_mut()
.unwrap()
.set_property("color", "red")
.unwrap();
let style = dom.node(div).style().unwrap();
assert_eq!(style.color(), "red");
assert_eq!(style.color(), style.get_property_value("color"));
}
#[test]
fn alias_background_color_handles_kebab_to_snake_conversion() {
let (mut dom, div) = dom_with("div");
dom.node_mut(div)
.style_mut()
.unwrap()
.set_property("background-color", "blue")
.unwrap();
let style = dom.node(div).style().unwrap();
assert_eq!(style.background_color(), "blue");
assert_eq!(
style.background_color(),
style.get_property_value("background-color")
);
}
#[test]
fn alias_transition_timing_function_handles_multi_hyphen() {
let (mut dom, div) = dom_with("div");
dom.node_mut(div)
.style_mut()
.unwrap()
.set_property("transition-timing-function", "ease-in-out")
.unwrap();
let style = dom.node(div).style().unwrap();
assert_eq!(style.transition_timing_function(), "ease-in-out");
}
#[test]
fn alias_z_index_handles_short_compound() {
let (mut dom, div) = dom_with("div");
dom.node_mut(div)
.style_mut()
.unwrap()
.set_property("z-index", "3")
.unwrap();
assert_eq!(dom.node(div).style().unwrap().z_index(), "3");
}
#[test]
fn alias_returns_empty_when_property_unset() {
let (dom, div) = dom_with("div");
let style = dom.node(div).style().unwrap();
assert_eq!(style.color(), "");
assert_eq!(style.padding(), "");
assert_eq!(style.font_weight(), "");
}
#[test]
fn css_text_padding_shorthand_skips_longhands() {
let (mut dom, div) = dom_with("div");
dom.node_mut(div)
.style_mut()
.unwrap()
.set_property("padding", "1 2 3 4")
.unwrap();
let css = dom.node(div).style().unwrap().css_text();
assert!(
css.contains("padding: 1 2 3 4;"),
"shorthand should emit, got {css:?}"
);
assert!(
!css.contains("padding-top"),
"padding-top longhand must not be emitted when shorthand fires, got {css:?}"
);
assert!(
!css.contains("padding-right"),
"padding-right longhand must not be emitted when shorthand fires, got {css:?}"
);
assert!(
!css.contains("padding-bottom"),
"padding-bottom longhand must not be emitted when shorthand fires, got {css:?}"
);
assert!(
!css.contains("padding-left"),
"padding-left longhand must not be emitted when shorthand fires, got {css:?}"
);
}
#[test]
fn css_text_inset_shorthand_skips_longhands_when_all_four_set() {
let (mut dom, div) = dom_with("div");
dom.node_mut(div)
.style_mut()
.unwrap()
.set_property("inset", "1 2 3 4")
.unwrap();
let css = dom.node(div).style().unwrap().css_text();
assert!(css.contains("inset:"), "inset shorthand should emit");
assert!(
!css.contains("top: 1"),
"top longhand must not emit when inset shorthand fires, got {css:?}"
);
assert!(
!css.contains("right: 2"),
"right longhand must not emit when inset shorthand fires, got {css:?}"
);
assert!(
!css.contains("bottom: 3"),
"bottom longhand must not emit when inset shorthand fires, got {css:?}"
);
assert!(
!css.contains("left: 4"),
"left longhand must not emit when inset shorthand fires, got {css:?}"
);
}
#[test]
fn css_text_inset_longhand_only_emits_set_sides() {
let (mut dom, div) = dom_with("div");
dom.node_mut(div)
.style_mut()
.unwrap()
.set_property("top", "5")
.unwrap();
let css = dom.node(div).style().unwrap().css_text();
assert!(css.contains("top: 5"), "lone top should emit, got {css:?}");
assert!(
!css.contains("inset:"),
"inset must not emit when only top is set, got {css:?}"
);
}
#[test]
fn css_text_overflow_shorthand_skips_longhands() {
let (mut dom, div) = dom_with("div");
dom.node_mut(div)
.style_mut()
.unwrap()
.set_property("overflow", "scroll")
.unwrap();
let css = dom.node(div).style().unwrap().css_text();
assert!(css.contains("overflow: scroll"));
assert!(
!css.contains("overflow-x"),
"overflow-x longhand must not emit when overflow shorthand fires, got {css:?}"
);
assert!(
!css.contains("overflow-y"),
"overflow-y longhand must not emit when overflow shorthand fires, got {css:?}"
);
}
#[test]
fn css_text_transition_shorthand_skips_longhands() {
let (mut dom, div) = dom_with("div");
dom.node_mut(div)
.style_mut()
.unwrap()
.set_property("transition", "color 200ms ease 0ms")
.unwrap();
let css = dom.node(div).style().unwrap().css_text();
assert!(
css.contains("transition:"),
"transition shorthand should emit, got {css:?}"
);
assert!(
!css.contains("transition-property"),
"transition-property longhand must not emit when shorthand fires, got {css:?}"
);
assert!(
!css.contains("transition-duration"),
"transition-duration longhand must not emit when shorthand fires, got {css:?}"
);
assert!(
!css.contains("transition-timing-function"),
"transition-timing-function longhand must not emit when shorthand fires, got {css:?}"
);
assert!(
!css.contains("transition-delay"),
"transition-delay longhand must not emit when shorthand fires, got {css:?}"
);
}
#[test]
fn try_set_property_returns_unknown_property() {
use crate::cssom::{DispatchError, SetPropertyError};
let (mut dom, div) = dom_with("div");
let mut nm = dom.node_mut(div);
let r = nm.style_mut().unwrap().try_set_property("colour", "red");
assert!(matches!(
r,
Err(SetPropertyError::Parse(DispatchError::UnknownProperty))
));
}
#[test]
fn try_set_property_returns_invalid_value() {
use crate::cssom::{DispatchError, SetPropertyError};
let (mut dom, div) = dom_with("div");
let mut nm = dom.node_mut(div);
let r = nm
.style_mut()
.unwrap()
.try_set_property("color", "not-a-color");
assert!(matches!(
r,
Err(SetPropertyError::Parse(DispatchError::InvalidValue))
));
}
#[test]
fn try_set_property_succeeds_on_valid_input() {
let (mut dom, div) = dom_with("div");
{
let mut nm = dom.node_mut(div);
nm.style_mut()
.unwrap()
.try_set_property("color", "red")
.unwrap();
}
assert_eq!(
dom.node(div).style().unwrap().get_property_value("color"),
"red"
);
}
#[test]
fn try_set_property_important_raises_priority_and_surfaces_errors() {
use crate::cssom::{DispatchError, SetPropertyError};
let (mut dom, div) = dom_with("div");
dom.node_mut(div)
.style_mut()
.unwrap()
.try_set_property_important("color", "red")
.unwrap();
assert_eq!(
dom.node(div)
.style()
.unwrap()
.get_property_priority("color"),
"important"
);
let r = dom
.node_mut(div)
.style_mut()
.unwrap()
.try_set_property_important("colour", "red");
assert!(matches!(
r,
Err(SetPropertyError::Parse(DispatchError::UnknownProperty))
));
}
#[test]
fn set_property_remains_silent_on_parse_failure() {
let (mut dom, div) = dom_with("div");
let r = dom
.node_mut(div)
.style_mut()
.unwrap()
.set_property("colour", "red");
assert!(r.is_ok(), "set_property must silently swallow parse errors");
assert_eq!(
dom.node(div).style().unwrap().get_property_value("color"),
""
);
}
#[test]
fn css_text_round_trip_padding_is_lossless() {
let (mut dom, div) = dom_with("div");
dom.node_mut(div)
.style_mut()
.unwrap()
.set_property("padding", "1 2 3 4")
.unwrap();
let css_first = dom.node(div).style().unwrap().css_text();
let (mut dom2, div2) = dom_with("div");
dom2.node_mut(div2)
.style_mut()
.unwrap()
.set_css_text(&css_first)
.unwrap();
let css_second = dom2.node(div2).style().unwrap().css_text();
assert_eq!(
css_first, css_second,
"cssText round-trip must be lossless (was broken pre-D-M4-2)"
);
}
}