9.1: Add content_hash field to Element, computed during VDOM evaluation from type_name + properties + children content_hashes via DefaultHasher. 9.2: Assign stable iced::widget::Id to text_input and scrollable widgets so Iced preserves widget state (cursor position, scroll offset) across frames. Ids are cached in a thread_local HashMap to avoid leaks. 9.3 skipped: iced::Element is not Clone and contains Box<dyn Widget>, making element-level caching impractical without unsafe lifetime hacks.
1232 lines
43 KiB
Rust
1232 lines
43 KiB
Rust
use crate::Message;
|
|
use crate::interpreter::Element;
|
|
use crate::interpreter::style::{ComputedStyle, ContentAlign, Display, LayoutDirection, Position, Overflow, StructuralContext, StyleSheet, TextAlign, resolve_size};
|
|
use iced::widget::container::Style as ContainerStyle;
|
|
use iced::widget::{
|
|
button, checkbox, column, container, image, progress_bar, row, scrollable, slider, svg, text,
|
|
text::Wrapping, text_input,
|
|
};
|
|
use iced::{Alignment, Background, Border, Length, Theme};
|
|
use iced::font::Weight;
|
|
use std::borrow::Cow;
|
|
use std::cell::RefCell;
|
|
use std::collections::HashMap;
|
|
|
|
thread_local! {
|
|
static WIDGET_ID_CACHE: RefCell<HashMap<(u32, &'static str), iced::widget::Id>> = RefCell::new(HashMap::new());
|
|
}
|
|
|
|
fn get_or_create_widget_id(key: u32, prefix: &'static str) -> iced::widget::Id {
|
|
WIDGET_ID_CACHE.with(|cache| {
|
|
cache.borrow_mut()
|
|
.entry((key, prefix))
|
|
.or_insert_with(|| {
|
|
let s = Box::leak(format!("{prefix}:{key}").into_boxed_str());
|
|
iced::widget::Id::new(s)
|
|
})
|
|
.clone()
|
|
})
|
|
}
|
|
|
|
fn extract_var_binding(el: &Element, prop: &str) -> Option<String> {
|
|
el.properties.iter()
|
|
.find_map(|(k, v)| {
|
|
k.strip_prefix("__bind:")
|
|
.filter(|&p| p == prop)
|
|
.map(|_| v.to_string())
|
|
})
|
|
}
|
|
|
|
impl<'a> Element<'a> {
|
|
#[inline]
|
|
pub fn get_prop(&self, key: &str) -> Option<&str> {
|
|
self.properties.iter()
|
|
.find(|(k, _)| **k == *key)
|
|
.map(|(_, v)| v.as_ref())
|
|
}
|
|
|
|
pub fn push_prop<K: Into<Cow<'a, str>>, V: Into<Cow<'a, str>>>(&mut self, key: K, val: V) {
|
|
self.properties.push((key.into(), val.into()));
|
|
}
|
|
|
|
pub fn set_prop<K: Into<Cow<'a, str>>, V: Into<Cow<'a, str>>>(&mut self, key: K, val: V) {
|
|
let key_cow = key.into();
|
|
if let Some(entry) = self.properties.iter_mut().find(|(k,_)| *k == key_cow) {
|
|
entry.1 = val.into();
|
|
} else {
|
|
self.properties.push((key_cow, val.into()));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn collect_hover_active<'a>(
|
|
el: &'a Element,
|
|
stylesheet: &StyleSheet,
|
|
) -> (HashMap<String, String>, HashMap<String, String>) {
|
|
let el_id = el.id();
|
|
let classes: Vec<&str> = el
|
|
.get_prop("class")
|
|
.map(|s| s.split_whitespace().collect())
|
|
.unwrap_or_default();
|
|
|
|
let default_struct = StructuralContext::default();
|
|
|
|
let el_attributes: HashMap<String, String> = el.properties.iter()
|
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
|
.collect();
|
|
|
|
let hover_props = stylesheet.matching_pseudo_rules(
|
|
"hover", el.type_name, el_id, &classes, &default_struct, &[], &[], &el_attributes,
|
|
);
|
|
let active_props = stylesheet.matching_pseudo_rules(
|
|
"active", el.type_name, el_id, &classes, &default_struct, &[], &[], &el_attributes,
|
|
);
|
|
|
|
(hover_props, active_props)
|
|
}
|
|
|
|
fn make_hoverable<'a>(
|
|
widget: iced::Element<'a, crate::Message, Theme, iced::Renderer>,
|
|
el: &Element,
|
|
hover_props: &HashMap<String, String>,
|
|
active_props: &HashMap<String, String>,
|
|
base_cs: &ComputedStyle,
|
|
) -> iced::Element<'a, crate::Message, Theme, iced::Renderer> {
|
|
let has_hover = !hover_props.is_empty();
|
|
let has_active = !active_props.is_empty();
|
|
|
|
if !has_hover && !has_active {
|
|
return widget;
|
|
}
|
|
|
|
let click_script = el.get_prop("__on:click").map(String::from);
|
|
|
|
if click_script.is_none() {
|
|
return widget;
|
|
}
|
|
|
|
let hover = hover_props.clone();
|
|
let active = active_props.clone();
|
|
let cs = base_cs.clone();
|
|
|
|
let mut btn = button(widget).padding(0);
|
|
btn = btn.on_press(crate::Message::EventTriggered(click_script.unwrap()));
|
|
|
|
btn.style(move |_, status| {
|
|
let mut style_cs = cs.clone();
|
|
match status {
|
|
button::Status::Hovered => {
|
|
if has_hover {
|
|
style_cs.apply_overrides(&hover);
|
|
}
|
|
}
|
|
button::Status::Pressed => {
|
|
if has_active {
|
|
style_cs.apply_overrides(&active);
|
|
} else if has_hover {
|
|
style_cs.apply_overrides(&hover);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
button::Style {
|
|
background: style_cs.background.map(Background::Color),
|
|
border: Border {
|
|
color: style_cs.border_color.unwrap_or(iced::Color::TRANSPARENT),
|
|
width: resolve_size(style_cs.border_width, None).unwrap_or(0.0),
|
|
radius: resolve_size(style_cs.border_radius, None).unwrap_or(0.0).into(),
|
|
},
|
|
text_color: style_cs.color.unwrap_or(iced::Color::WHITE),
|
|
..Default::default()
|
|
}
|
|
})
|
|
.into()
|
|
}
|
|
|
|
fn render_children<'a>(
|
|
children: &'a [Element],
|
|
parent_color: Option<iced::Color>,
|
|
parent_font_size: Option<f32>,
|
|
parent_direction: Option<LayoutDirection>,
|
|
fixed_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
|
|
abs_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
|
|
sticky_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
|
|
scroll_positions: &HashMap<u64, f32>,
|
|
container_id: u64,
|
|
stylesheet: &StyleSheet,
|
|
) -> Vec<iced::Element<'a, Message, Theme, iced::Renderer>> {
|
|
children
|
|
.iter()
|
|
.filter_map(|c| {
|
|
render_element(
|
|
c,
|
|
parent_color,
|
|
parent_font_size,
|
|
parent_direction,
|
|
fixed_layers,
|
|
abs_layers,
|
|
sticky_layers,
|
|
scroll_positions,
|
|
container_id,
|
|
stylesheet,
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn get_padding(cs: &ComputedStyle, default_pad: f32) -> iced::Padding {
|
|
let base = resolve_size(cs.padding, None).unwrap_or(default_pad);
|
|
let mut top = resolve_size(cs.padding_top, None).unwrap_or(base);
|
|
let mut right = resolve_size(cs.padding_right, None).unwrap_or(base);
|
|
let mut bottom = resolve_size(cs.padding_bottom, None).unwrap_or(base);
|
|
let mut left = resolve_size(cs.padding_left, None).unwrap_or(base);
|
|
|
|
let bwidth = resolve_size(cs.border_width, None).unwrap_or(0.0);
|
|
|
|
if let Some(iced::Length::Fixed(w)) = cs.width {
|
|
let total_h = left + right + bwidth * 2.0;
|
|
if total_h > w && w > 0.0 {
|
|
let scale = w / total_h;
|
|
left *= scale;
|
|
right *= scale;
|
|
}
|
|
}
|
|
|
|
if let Some(iced::Length::Fixed(h)) = cs.height {
|
|
let total_v = top + bottom + bwidth * 2.0;
|
|
if total_v > h && h > 0.0 {
|
|
let scale = h / total_v;
|
|
top *= scale;
|
|
bottom *= scale;
|
|
}
|
|
}
|
|
|
|
iced::Padding {
|
|
top,
|
|
right,
|
|
bottom,
|
|
left,
|
|
}
|
|
}
|
|
|
|
fn get_margin(cs: &ComputedStyle) -> iced::Padding {
|
|
let base = resolve_size(cs.margin, None).unwrap_or(0.0);
|
|
iced::Padding {
|
|
top: resolve_size(cs.margin_top, None).unwrap_or(base),
|
|
right: resolve_size(cs.margin_right, None).unwrap_or(base),
|
|
bottom: resolve_size(cs.margin_bottom, None).unwrap_or(base),
|
|
left: resolve_size(cs.margin_left, None).unwrap_or(base),
|
|
}
|
|
}
|
|
|
|
fn get_button_style(cs: &ComputedStyle, status: button::Status) -> button::Style {
|
|
let base_bg = cs
|
|
.background
|
|
.unwrap_or(iced::Color::from_rgb(0.25, 0.26, 0.35));
|
|
|
|
let bg_color = match status {
|
|
button::Status::Hovered => iced::Color {
|
|
r: (base_bg.r * 1.15).min(1.0),
|
|
g: (base_bg.g * 1.15).min(1.0),
|
|
b: (base_bg.b * 1.15).min(1.0),
|
|
a: base_bg.a,
|
|
},
|
|
button::Status::Pressed => iced::Color {
|
|
r: base_bg.r * 0.85,
|
|
g: base_bg.g * 0.85,
|
|
b: base_bg.b * 0.85,
|
|
a: base_bg.a,
|
|
},
|
|
_ => base_bg,
|
|
};
|
|
|
|
button::Style {
|
|
background: Some(Background::Color(bg_color)),
|
|
border: Border {
|
|
color: cs.border_color.unwrap_or(iced::Color::TRANSPARENT),
|
|
width: resolve_size(cs.border_width, None).unwrap_or(0.0),
|
|
radius: resolve_size(cs.border_radius, None).unwrap_or(0.0).into(),
|
|
},
|
|
text_color: cs.color.unwrap_or(iced::Color::WHITE),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn get_text_input_style(cs: &ComputedStyle) -> text_input::Style {
|
|
text_input::Style {
|
|
background: Background::Color(
|
|
cs.background
|
|
.unwrap_or(iced::Color::from_rgb(0.12, 0.12, 0.14)),
|
|
),
|
|
border: Border {
|
|
color: cs
|
|
.border_color
|
|
.unwrap_or(iced::Color::from_rgb(0.25, 0.25, 0.3)),
|
|
width: resolve_size(cs.border_width, None).unwrap_or(1.0),
|
|
radius: resolve_size(cs.border_radius, None).unwrap_or(0.0).into(),
|
|
},
|
|
icon: iced::Color::from_rgb(0.5, 0.5, 0.5),
|
|
placeholder: iced::Color::from_rgb(0.4, 0.4, 0.45),
|
|
value: cs.color.unwrap_or(iced::Color::WHITE),
|
|
selection: iced::Color::from_rgb(0.3, 0.3, 0.5),
|
|
}
|
|
}
|
|
|
|
fn estimate_element_height(cs: &ComputedStyle) -> f32 {
|
|
let pad_top = resolve_size(cs.padding_top.or(cs.padding), None).unwrap_or(0.0);
|
|
let pad_bottom = resolve_size(cs.padding_bottom.or(cs.padding), None).unwrap_or(0.0);
|
|
let bwidth = resolve_size(cs.border_width, None).unwrap_or(0.0);
|
|
let font_size = resolve_size(cs.font_size, None).unwrap_or(16.0);
|
|
let line_height = resolve_size(cs.line_height, None).unwrap_or(1.2);
|
|
pad_top + pad_bottom + bwidth * 2.0 + font_size * line_height
|
|
}
|
|
|
|
fn wrap_sticky_position<'a>(
|
|
widget: iced::Element<'a, Message, Theme, iced::Renderer>,
|
|
cs: &ComputedStyle,
|
|
) -> iced::Element<'a, Message, Theme, iced::Renderer> {
|
|
let top = resolve_size(cs.top, None).unwrap_or(0.0);
|
|
container(widget)
|
|
.width(Length::Fill)
|
|
.padding(iced::Padding {
|
|
top,
|
|
..Default::default()
|
|
})
|
|
.into()
|
|
}
|
|
|
|
fn wrap_fixed_position<'a>(
|
|
widget: iced::Element<'a, Message, Theme, iced::Renderer>,
|
|
cs: &ComputedStyle,
|
|
) -> iced::Element<'a, Message, Theme, iced::Renderer> {
|
|
use iced::alignment::{Horizontal, Vertical};
|
|
|
|
let top = resolve_size(cs.top, None).unwrap_or(0.0);
|
|
let left = resolve_size(cs.left, None).unwrap_or(0.0);
|
|
let right = resolve_size(cs.right, None).unwrap_or(0.0);
|
|
let bottom = resolve_size(cs.bottom, None).unwrap_or(0.0);
|
|
|
|
let has_top = cs.top.is_some();
|
|
let has_bottom = cs.bottom.is_some();
|
|
let has_left = cs.left.is_some();
|
|
let has_right = cs.right.is_some();
|
|
|
|
let horiz = match (has_left, has_right) {
|
|
(true, true) => Horizontal::Left,
|
|
(false, true) => Horizontal::Right,
|
|
_ => Horizontal::Left,
|
|
};
|
|
|
|
let vert = match (has_top, has_bottom) {
|
|
(true, true) => Vertical::Top,
|
|
(false, true) => Vertical::Bottom,
|
|
_ => Vertical::Top,
|
|
};
|
|
|
|
let padding = iced::Padding {
|
|
top: if has_top { top } else { 0.0 },
|
|
bottom: if has_bottom { bottom } else { 0.0 },
|
|
left: if has_left { left } else { 0.0 },
|
|
right: if has_right { right } else { 0.0 },
|
|
};
|
|
|
|
container(widget)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.align_x(horiz)
|
|
.align_y(vert)
|
|
.padding(padding)
|
|
.into()
|
|
}
|
|
|
|
fn apply_universal_box_model<'a>(
|
|
widget: impl Into<iced::Element<'a, Message, Theme, iced::Renderer>>,
|
|
cs: &ComputedStyle,
|
|
is_window: bool,
|
|
default_padding: f32,
|
|
scrollable_id: Option<u64>,
|
|
) -> iced::Element<'a, Message, Theme, iced::Renderer> {
|
|
let bg = cs.background;
|
|
let radius = resolve_size(cs.border_radius, None).unwrap_or(0.0);
|
|
let bwidth = resolve_size(cs.border_width, None).unwrap_or(0.0);
|
|
|
|
let bcolor = cs.border_color.unwrap_or_else(|| {
|
|
if bwidth > 0.0 {
|
|
iced::Color::from_rgb(0.4, 0.4, 0.45)
|
|
} else {
|
|
iced::Color::TRANSPARENT
|
|
}
|
|
});
|
|
|
|
let needs_style = bg.is_some() || radius != 0.0 || bwidth != 0.0;
|
|
let padding = get_padding(cs, default_padding);
|
|
|
|
let mut padded_content = container(widget).padding(padding);
|
|
|
|
let ox = cs.overflow_x.unwrap_or(Overflow::Visible);
|
|
let oy = cs.overflow_y.unwrap_or(if is_window { Overflow::Auto } else { Overflow::Visible });
|
|
|
|
let scroll_v = oy == Overflow::Scroll || oy == Overflow::Auto;
|
|
let scroll_h = ox == Overflow::Scroll || ox == Overflow::Auto;
|
|
|
|
if !scroll_h {
|
|
padded_content = padded_content.width(Length::Fill);
|
|
}
|
|
|
|
let content_layer: iced::Element<'a, Message, Theme, iced::Renderer> = if scroll_v || scroll_h {
|
|
use iced::widget::scrollable::{Direction, Scrollbar};
|
|
|
|
let direction = match (scroll_v, scroll_h) {
|
|
(true, true) => Direction::Both {
|
|
vertical: Default::default(),
|
|
horizontal: Default::default(),
|
|
},
|
|
(true, false) => Direction::Vertical(Default::default()),
|
|
(false, true) => Direction::Horizontal(Default::default()),
|
|
(false, false) => unreachable!(),
|
|
};
|
|
|
|
let mut s = scrollable(padded_content)
|
|
.direction(direction)
|
|
.width(Length::Fill);
|
|
|
|
if scroll_v {
|
|
s = s.height(Length::Fill);
|
|
}
|
|
|
|
if let Some(sid) = scrollable_id {
|
|
s = s.id(get_or_create_widget_id(sid as u32, "sc"))
|
|
.on_scroll(move |viewport| Message::ScrollableScrolled(sid, viewport.absolute_offset().y));
|
|
}
|
|
|
|
s.into()
|
|
} else {
|
|
padded_content.into()
|
|
};
|
|
|
|
let mut inner = container(content_layer);
|
|
|
|
if ox == Overflow::Hidden || oy == Overflow::Hidden {
|
|
inner = inner.clip(true);
|
|
}
|
|
|
|
if needs_style {
|
|
inner = inner.style(move |_theme| ContainerStyle {
|
|
background: bg.map(Background::Color),
|
|
border: Border {
|
|
color: bcolor,
|
|
width: bwidth,
|
|
radius: radius.into(),
|
|
},
|
|
..Default::default()
|
|
});
|
|
} else {
|
|
inner = inner.style(|_theme| ContainerStyle::default());
|
|
}
|
|
|
|
if is_window {
|
|
inner = inner.width(Length::Fill).height(Length::Fill);
|
|
} else {
|
|
if let Some(w) = cs.width {
|
|
inner = inner.width(w);
|
|
} else if cs.content_align.is_some() {
|
|
inner = inner.width(Length::Fill);
|
|
}
|
|
|
|
if let Some(h) = cs.height {
|
|
inner = inner.height(h);
|
|
}
|
|
if let Some(max_w) = resolve_size(cs.max_width, None) {
|
|
inner = inner.max_width(max_w);
|
|
}
|
|
if let Some(max_h) = resolve_size(cs.max_height, None) {
|
|
inner = inner.max_height(max_h);
|
|
}
|
|
|
|
if let Some(ca) = cs.content_align {
|
|
match ca {
|
|
ContentAlign::Start => inner = inner.align_x(iced::alignment::Horizontal::Left),
|
|
ContentAlign::Center => inner = inner.align_x(iced::alignment::Horizontal::Center),
|
|
ContentAlign::End => inner = inner.align_x(iced::alignment::Horizontal::Right),
|
|
}
|
|
}
|
|
}
|
|
|
|
let margin = get_margin(cs);
|
|
let has_margin = margin.top > 0.0 || margin.right > 0.0 || margin.bottom > 0.0 || margin.left > 0.0;
|
|
|
|
let result: iced::Element<'a, Message, Theme, iced::Renderer> = if has_margin && !is_window {
|
|
let mut outer = container(inner).padding(margin);
|
|
|
|
if let Some(w) = cs.width {
|
|
if matches!(w, Length::Fill | Length::FillPortion(_)) {
|
|
outer = outer.width(w);
|
|
}
|
|
} else if cs.content_align.is_some() {
|
|
outer = outer.width(Length::Fill);
|
|
}
|
|
|
|
if let Some(h) = cs.height {
|
|
if matches!(h, Length::Fill | Length::FillPortion(_)) {
|
|
outer = outer.height(h);
|
|
}
|
|
}
|
|
|
|
outer.into()
|
|
} else {
|
|
inner.into()
|
|
};
|
|
|
|
|
|
result
|
|
}
|
|
|
|
pub fn render_element<'a>(
|
|
el: &'a Element,
|
|
parent_color: Option<iced::Color>,
|
|
parent_font_size: Option<f32>,
|
|
parent_direction: Option<LayoutDirection>,
|
|
fixed_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
|
|
abs_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
|
|
sticky_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
|
|
scroll_positions: &HashMap<u64, f32>,
|
|
container_id: u64,
|
|
stylesheet: &StyleSheet,
|
|
) -> Option<iced::Element<'a, Message, Theme, iced::Renderer>> {
|
|
let mut cs = el.computed_style;
|
|
|
|
if cs.display == Some(Display::None) {
|
|
return None;
|
|
}
|
|
|
|
let (hover_props, active_props) = collect_hover_active(el, stylesheet);
|
|
|
|
let is_fixed = cs.position == Some(Position::Fixed) && el.type_name != "Window";
|
|
let is_absolute = cs.position == Some(Position::Absolute) && el.type_name != "Window";
|
|
let is_sticky = cs.position == Some(Position::Sticky) && el.type_name != "Window";
|
|
|
|
if is_fixed || is_absolute || is_sticky {
|
|
if cs.left.is_some() && cs.right.is_some() && cs.width.is_none() {
|
|
cs.width = Some(Length::Fill);
|
|
}
|
|
if cs.top.is_some() && cs.bottom.is_some() && cs.height.is_none() {
|
|
cs.height = Some(Length::Fill);
|
|
}
|
|
}
|
|
|
|
if let Some(grow_value) = cs.flex_grow {
|
|
if cs.width.is_none() && parent_direction == Some(LayoutDirection::Row) {
|
|
cs.width = Some(Length::FillPortion(grow_value));
|
|
}
|
|
if cs.height.is_none() && parent_direction == Some(LayoutDirection::Column) {
|
|
cs.height = Some(Length::FillPortion(grow_value));
|
|
}
|
|
}
|
|
|
|
let current_color = cs.color.or(parent_color);
|
|
let current_font_size = resolve_size(cs.font_size, parent_font_size).or(parent_font_size);
|
|
|
|
if el.type_name == "Window" {
|
|
let spacing = resolve_size(cs.spacing, None).unwrap_or(12.0);
|
|
let mut col = column![].spacing(spacing).width(Length::Fill);
|
|
|
|
let window_id = el.element_id.0 as u64;
|
|
let mut window_fixed_layers = Vec::new();
|
|
let mut window_abs_layers = Vec::new();
|
|
let mut window_sticky_layers = Vec::new();
|
|
|
|
for child in render_children(
|
|
&el.children,
|
|
current_color,
|
|
current_font_size,
|
|
Some(LayoutDirection::Column),
|
|
&mut window_fixed_layers,
|
|
&mut window_abs_layers,
|
|
&mut window_sticky_layers,
|
|
scroll_positions,
|
|
window_id,
|
|
stylesheet,
|
|
) {
|
|
col = col.push(child);
|
|
}
|
|
|
|
let main_flow = apply_universal_box_model(col, &cs, true, 0.0, Some(window_id));
|
|
|
|
let mut stack_widget = iced::widget::stack![main_flow];
|
|
for layer in window_abs_layers {
|
|
stack_widget = stack_widget.push(layer);
|
|
}
|
|
for layer in window_sticky_layers {
|
|
stack_widget = stack_widget.push(layer);
|
|
}
|
|
for layer in window_fixed_layers {
|
|
stack_widget = stack_widget.push(layer);
|
|
}
|
|
return Some(stack_widget.into());
|
|
}
|
|
|
|
let mut final_widget_opt = None;
|
|
|
|
if el.type_name == "Panel" {
|
|
let mut panel_abs = Vec::new();
|
|
let mut panel_sticky = Vec::new();
|
|
let widget = render_panel(
|
|
el,
|
|
cs,
|
|
current_color,
|
|
current_font_size,
|
|
fixed_layers,
|
|
&mut panel_abs,
|
|
&mut panel_sticky,
|
|
scroll_positions,
|
|
container_id,
|
|
stylesheet,
|
|
);
|
|
if panel_abs.is_empty() {
|
|
final_widget_opt = Some(widget);
|
|
} else {
|
|
let mut stack_widget = iced::widget::stack![widget];
|
|
for layer in panel_abs {
|
|
stack_widget = stack_widget.push(layer);
|
|
}
|
|
final_widget_opt = Some(stack_widget.into());
|
|
}
|
|
} else if el.type_name == "Button" {
|
|
let mut btn_abs = Vec::new();
|
|
let mut btn_sticky = Vec::new();
|
|
let widget = render_button(
|
|
el,
|
|
cs,
|
|
current_color,
|
|
current_font_size,
|
|
fixed_layers,
|
|
&mut btn_abs,
|
|
&mut btn_sticky,
|
|
scroll_positions,
|
|
container_id,
|
|
stylesheet,
|
|
);
|
|
if btn_abs.is_empty() && btn_sticky.is_empty() {
|
|
final_widget_opt = Some(widget);
|
|
} else {
|
|
let mut stack_widget = iced::widget::stack![widget];
|
|
for layer in btn_abs {
|
|
stack_widget = stack_widget.push(layer);
|
|
}
|
|
for layer in btn_sticky {
|
|
stack_widget = stack_widget.push(layer);
|
|
}
|
|
final_widget_opt = Some(stack_widget.into());
|
|
}
|
|
} else if el.type_name == "Input" {
|
|
final_widget_opt = Some(render_input(
|
|
el, cs, current_color, current_font_size, &hover_props, &active_props,
|
|
));
|
|
} else {
|
|
let element_opt: Option<iced::Element<'a, Message, Theme, iced::Renderer>> =
|
|
match el.type_name {
|
|
"Title" | "Header" => {
|
|
let content = el.get_prop("text").unwrap_or("Title");
|
|
let mut widget = text(content).size(current_font_size.unwrap_or(24.0));
|
|
if let Some(col) = current_color {
|
|
widget = widget.color(col);
|
|
}
|
|
if let Some(fw) = cs.font_weight {
|
|
let w = if fw <= 399 { Weight::Light }
|
|
else if fw <= 599 { Weight::Normal }
|
|
else if fw <= 799 { Weight::Bold }
|
|
else { Weight::ExtraBold };
|
|
widget = widget.font(iced::font::Font { weight: w, ..Default::default() });
|
|
}
|
|
if let Some(ta) = cs.text_align {
|
|
widget = widget.align_x(iced::alignment::Horizontal::from(ta));
|
|
widget = widget.width(Length::Fill);
|
|
}
|
|
if let Some(lh) = resolve_size(cs.line_height, current_font_size) {
|
|
widget = widget.line_height(lh);
|
|
widget = widget.width(Length::Fill);
|
|
widget = widget.wrapping(Wrapping::Word);
|
|
}
|
|
Some(widget.into())
|
|
}
|
|
|
|
"Text" | "Label" | "#text" => {
|
|
let content = el.get_prop("text").unwrap_or("");
|
|
let mut widget = text(content).size(current_font_size.unwrap_or(16.0));
|
|
if let Some(col) = current_color {
|
|
widget = widget.color(col);
|
|
}
|
|
if let Some(fw) = cs.font_weight {
|
|
let w = if fw <= 399 { Weight::Light }
|
|
else if fw <= 599 { Weight::Normal }
|
|
else if fw <= 799 { Weight::Bold }
|
|
else { Weight::ExtraBold };
|
|
widget = widget.font(iced::font::Font { weight: w, ..Default::default() });
|
|
}
|
|
if let Some(ta) = cs.text_align {
|
|
widget = widget.align_x(iced::alignment::Horizontal::from(ta));
|
|
widget = widget.width(Length::Fill);
|
|
}
|
|
if let Some(lh) = resolve_size(cs.line_height, current_font_size) {
|
|
widget = widget.line_height(lh);
|
|
widget = widget.width(Length::Fill);
|
|
widget = widget.wrapping(Wrapping::Word);
|
|
}
|
|
Some(widget.into())
|
|
}
|
|
|
|
"Image" => {
|
|
let src = el.get_prop("src").unwrap_or("");
|
|
let path = src.strip_prefix("fs:").unwrap_or(src);
|
|
let radius = resolve_size(cs.border_radius, None).unwrap_or(0.0);
|
|
let padding = get_padding(&cs, 0.0);
|
|
let bwidth = resolve_size(cs.border_width, None).unwrap_or(0.0);
|
|
|
|
let content_w = cs.width.map(|len| {
|
|
if let iced::Length::Fixed(w) = len {
|
|
iced::Length::Fixed(
|
|
(w - padding.left - padding.right - bwidth * 2.0).max(0.0),
|
|
)
|
|
} else {
|
|
len
|
|
}
|
|
});
|
|
|
|
let content_h = cs.height.map(|len| {
|
|
if let iced::Length::Fixed(h) = len {
|
|
iced::Length::Fixed(
|
|
(h - padding.top - padding.bottom - bwidth * 2.0).max(0.0),
|
|
)
|
|
} else {
|
|
len
|
|
}
|
|
});
|
|
|
|
if path.ends_with(".svg") {
|
|
let mut svg_widget = svg(svg::Handle::from_path(path));
|
|
if let Some(w) = content_w {
|
|
svg_widget = svg_widget.width(w);
|
|
} else {
|
|
svg_widget = svg_widget.width(Length::Shrink);
|
|
}
|
|
|
|
if let Some(h) = content_h {
|
|
svg_widget = svg_widget.height(h);
|
|
} else {
|
|
svg_widget = svg_widget.height(Length::Shrink);
|
|
}
|
|
|
|
Some(svg_widget.into())
|
|
} else {
|
|
let mut img_widget = image(path);
|
|
if let Some(w) = content_w {
|
|
img_widget = img_widget.width(w);
|
|
} else {
|
|
img_widget = img_widget.width(Length::Shrink);
|
|
}
|
|
|
|
if let Some(h) = content_h {
|
|
img_widget = img_widget.height(h);
|
|
} else {
|
|
img_widget = img_widget.height(Length::Shrink);
|
|
}
|
|
|
|
if radius > 0.0 {
|
|
img_widget = img_widget.border_radius(radius);
|
|
}
|
|
|
|
Some(img_widget.into())
|
|
}
|
|
}
|
|
|
|
"Toggle" => Some(render_toggle(el, current_color, current_font_size)),
|
|
"Slider" => Some(render_slider(el)),
|
|
|
|
"Icon" => {
|
|
let mut widget = text("🔹").size(current_font_size.unwrap_or(18.0));
|
|
if let Some(col) = current_color {
|
|
widget = widget.color(col);
|
|
}
|
|
Some(widget.into())
|
|
}
|
|
|
|
"ProgressBar" => {
|
|
let value = el.get_prop("value")
|
|
.and_then(|v| v.parse::<f32>().ok())
|
|
.unwrap_or(0.0);
|
|
Some(progress_bar(0.0..=100.0, value).into())
|
|
}
|
|
|
|
"Divider" | "Separator" => Some(iced::widget::rule::horizontal(1).into()),
|
|
|
|
_ => {
|
|
if el.children.is_empty() {
|
|
None
|
|
} else {
|
|
let spacing = resolve_size(cs.spacing, None).unwrap_or(10.0);
|
|
let mut col = column![].spacing(spacing);
|
|
let mut default_abs = Vec::new();
|
|
let mut default_sticky = Vec::new();
|
|
for child in render_children(
|
|
&el.children,
|
|
current_color,
|
|
current_font_size,
|
|
Some(LayoutDirection::Column),
|
|
fixed_layers,
|
|
&mut default_abs,
|
|
&mut default_sticky,
|
|
scroll_positions,
|
|
container_id,
|
|
stylesheet,
|
|
) {
|
|
col = col.push(child);
|
|
}
|
|
// Abs inside content (before box model = inside scrollable)
|
|
let content_widget: iced::Element<'a, Message, Theme, iced::Renderer> =
|
|
if default_abs.is_empty() {
|
|
col.into()
|
|
} else {
|
|
let mut stack = iced::widget::stack![col];
|
|
for layer in default_abs {
|
|
stack = stack.push(layer);
|
|
}
|
|
stack.into()
|
|
};
|
|
let is_scrollable = cs.overflow_y.map(|o| o == Overflow::Scroll || o == Overflow::Auto).unwrap_or(false);
|
|
let sc_id: Option<u64> = if is_scrollable { Some(el.element_id.0 as u64) } else { None };
|
|
let boxed = apply_universal_box_model(content_widget, &cs, false, 0.0, sc_id);
|
|
let mut stack = iced::widget::stack![boxed];
|
|
for layer in default_sticky {
|
|
stack = stack.push(layer);
|
|
}
|
|
final_widget_opt = Some(stack.into());
|
|
None
|
|
}
|
|
}
|
|
};
|
|
|
|
if let Some(element) = element_opt {
|
|
let is_scrollable = cs.overflow_y.map(|o| o == Overflow::Scroll || o == Overflow::Auto).unwrap_or(false);
|
|
let sc_id: Option<u64> = if is_scrollable { Some(el.element_id.0 as u64) } else { None };
|
|
final_widget_opt = Some(apply_universal_box_model(element, &cs, false, 0.0, sc_id));
|
|
}
|
|
}
|
|
|
|
if el.type_name != "Button" && el.type_name != "Input" {
|
|
if let Some(w) = final_widget_opt.take() {
|
|
let wrapped = make_hoverable(w, el, &hover_props, &active_props, &cs);
|
|
final_widget_opt = Some(wrapped);
|
|
}
|
|
}
|
|
|
|
if let Some(final_widget) = final_widget_opt {
|
|
if is_fixed {
|
|
let wrapped = wrap_fixed_position(final_widget, &cs);
|
|
fixed_layers.push(wrapped);
|
|
None
|
|
} else if is_absolute {
|
|
let wrapped = wrap_fixed_position(final_widget, &cs);
|
|
abs_layers.push(wrapped);
|
|
None
|
|
} else if is_sticky {
|
|
let threshold = resolve_size(cs.top, None).unwrap_or(0.0);
|
|
let scroll_y = scroll_positions.get(&container_id).copied().unwrap_or(0.0);
|
|
if scroll_y > threshold {
|
|
let wrapped = wrap_sticky_position(final_widget, &cs);
|
|
sticky_layers.push(wrapped);
|
|
let spacer_h = estimate_element_height(&cs);
|
|
let spacer: iced::Element<'a, Message, Theme, iced::Renderer> =
|
|
container(iced::widget::text(""))
|
|
.width(Length::Fill)
|
|
.height(Length::Fixed(spacer_h))
|
|
.into();
|
|
Some(spacer)
|
|
} else {
|
|
Some(final_widget)
|
|
}
|
|
} else {
|
|
Some(final_widget)
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn render_panel<'a>(
|
|
el: &'a Element,
|
|
cs: ComputedStyle,
|
|
parent_color: Option<iced::Color>,
|
|
parent_font_size: Option<f32>,
|
|
fixed_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
|
|
abs_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
|
|
sticky_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
|
|
scroll_positions: &HashMap<u64, f32>,
|
|
container_id: u64,
|
|
stylesheet: &StyleSheet,
|
|
) -> iced::Element<'a, Message, Theme, iced::Renderer> {
|
|
let spacing = resolve_size(cs.spacing, None).unwrap_or(10.0);
|
|
|
|
let is_horizontal = el.children.iter().all(|c| {
|
|
matches!(
|
|
c.type_name,
|
|
"Icon" | "Button" | "MenuItem" | "Label" | "Toggle" | "Text" | "#text"
|
|
)
|
|
});
|
|
|
|
let direction = cs.direction.unwrap_or(if is_horizontal {
|
|
LayoutDirection::Row
|
|
} else {
|
|
LayoutDirection::Column
|
|
});
|
|
|
|
let is_scrollable = cs.overflow_y.map(|o| o == Overflow::Scroll || o == Overflow::Auto).unwrap_or(false);
|
|
let child_container: u64 = if is_scrollable { el.element_id.0 as u64 } else { container_id };
|
|
let sc_id: Option<u64> = if is_scrollable { Some(el.element_id.0 as u64) } else { None };
|
|
|
|
let content: iced::Element<'a, Message, Theme, iced::Renderer> = match direction {
|
|
LayoutDirection::Row => {
|
|
let mut r = row![].spacing(spacing);
|
|
if let Some(align) = cs.align_items {
|
|
r = r.align_y(align);
|
|
} else {
|
|
r = r.align_y(Alignment::Center);
|
|
}
|
|
for child in render_children(
|
|
&el.children,
|
|
parent_color,
|
|
parent_font_size,
|
|
Some(LayoutDirection::Row),
|
|
fixed_layers,
|
|
abs_layers,
|
|
sticky_layers,
|
|
scroll_positions,
|
|
child_container,
|
|
stylesheet,
|
|
) {
|
|
r = r.push(child);
|
|
}
|
|
if abs_layers.is_empty() {
|
|
r.into()
|
|
} else {
|
|
let layers = std::mem::take(abs_layers);
|
|
let mut stack = iced::widget::stack![r];
|
|
for layer in layers {
|
|
stack = stack.push(layer);
|
|
}
|
|
stack.into()
|
|
}
|
|
}
|
|
LayoutDirection::Column => {
|
|
let mut c = column![].spacing(spacing);
|
|
if let Some(align) = cs.align_items {
|
|
c = c.align_x(align);
|
|
}
|
|
for child in render_children(
|
|
&el.children,
|
|
parent_color,
|
|
parent_font_size,
|
|
Some(LayoutDirection::Column),
|
|
fixed_layers,
|
|
abs_layers,
|
|
sticky_layers,
|
|
scroll_positions,
|
|
child_container,
|
|
stylesheet,
|
|
) {
|
|
c = c.push(child);
|
|
}
|
|
if abs_layers.is_empty() {
|
|
c.into()
|
|
} else {
|
|
let layers = std::mem::take(abs_layers);
|
|
let mut stack = iced::widget::stack![c];
|
|
for layer in layers {
|
|
stack = stack.push(layer);
|
|
}
|
|
stack.into()
|
|
}
|
|
}
|
|
LayoutDirection::Grid => {
|
|
let mut c = column![].spacing(spacing);
|
|
let cols = el
|
|
.get_prop("columns")
|
|
.and_then(|v| v.parse::<usize>().ok())
|
|
.unwrap_or(3);
|
|
|
|
for chunk in el.children.chunks(cols) {
|
|
let mut r = row![].spacing(spacing);
|
|
if let Some(align) = cs.align_items {
|
|
r = r.align_y(align);
|
|
} else {
|
|
r = r.align_y(Alignment::Center);
|
|
}
|
|
let mut chunk_abs = Vec::new();
|
|
let mut chunk_sticky = Vec::new();
|
|
for child in render_children(
|
|
chunk,
|
|
parent_color,
|
|
parent_font_size,
|
|
Some(LayoutDirection::Row),
|
|
fixed_layers,
|
|
&mut chunk_abs,
|
|
&mut chunk_sticky,
|
|
scroll_positions,
|
|
child_container,
|
|
stylesheet,
|
|
) {
|
|
r = r.push(child);
|
|
}
|
|
if chunk_abs.is_empty() && chunk_sticky.is_empty() {
|
|
c = c.push(r);
|
|
} else {
|
|
let mut stack = iced::widget::stack![r];
|
|
for layer in chunk_abs {
|
|
stack = stack.push(layer);
|
|
}
|
|
for layer in chunk_sticky {
|
|
stack = stack.push(layer);
|
|
}
|
|
c = c.push(stack);
|
|
}
|
|
}
|
|
c.into()
|
|
}
|
|
};
|
|
|
|
let boxed = apply_universal_box_model(content, &cs, false, 5.0, sc_id);
|
|
|
|
let layers = std::mem::take(sticky_layers);
|
|
let mut stack = iced::widget::stack![boxed];
|
|
for layer in layers {
|
|
stack = stack.push(layer);
|
|
}
|
|
stack.into()
|
|
}
|
|
|
|
fn render_button<'a>(
|
|
el: &'a Element,
|
|
cs: ComputedStyle,
|
|
parent_color: Option<iced::Color>,
|
|
parent_font_size: Option<f32>,
|
|
fixed_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
|
|
abs_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
|
|
sticky_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
|
|
scroll_positions: &HashMap<u64, f32>,
|
|
container_id: u64,
|
|
stylesheet: &StyleSheet,
|
|
) -> iced::Element<'a, Message, Theme, iced::Renderer> {
|
|
let final_color = cs.color.or(parent_color);
|
|
|
|
let content: iced::Element<'a, _, _, _> = if el.children.is_empty() {
|
|
let label = el.get_prop("label").unwrap_or("Button");
|
|
let mut t = text(label).size(parent_font_size.unwrap_or(14.0));
|
|
if let Some(col) = final_color {
|
|
t = t.color(col);
|
|
}
|
|
t.into()
|
|
} else {
|
|
let mut r = row![].spacing(8);
|
|
for child in render_children(
|
|
&el.children,
|
|
final_color,
|
|
parent_font_size,
|
|
Some(LayoutDirection::Row),
|
|
fixed_layers,
|
|
abs_layers,
|
|
sticky_layers,
|
|
scroll_positions,
|
|
container_id,
|
|
stylesheet,
|
|
) {
|
|
r = r.push(child);
|
|
}
|
|
if abs_layers.is_empty() && sticky_layers.is_empty() {
|
|
r.into()
|
|
} else {
|
|
let mut stack = iced::widget::stack![r];
|
|
for layer in std::mem::take(abs_layers) {
|
|
stack = stack.push(layer);
|
|
}
|
|
for layer in std::mem::take(sticky_layers) {
|
|
stack = stack.push(layer);
|
|
}
|
|
stack.into()
|
|
}
|
|
};
|
|
|
|
let base_px = resolve_size(cs.padding, None).unwrap_or(8.0);
|
|
let mut padding = iced::Padding {
|
|
top: resolve_size(cs.padding_top, None).unwrap_or(base_px),
|
|
bottom: resolve_size(cs.padding_bottom, None).unwrap_or(base_px),
|
|
left: resolve_size(cs.padding_left, None).unwrap_or(base_px * 2.0),
|
|
right: resolve_size(cs.padding_right, None).unwrap_or(base_px * 2.0),
|
|
};
|
|
|
|
let bwidth = resolve_size(cs.border_width, None).unwrap_or(0.0);
|
|
if let Some(iced::Length::Fixed(w)) = cs.width {
|
|
let total_h = padding.left + padding.right + bwidth * 2.0;
|
|
if total_h > w && w > 0.0 {
|
|
let scale = w / total_h;
|
|
padding.left *= scale;
|
|
padding.right *= scale;
|
|
}
|
|
}
|
|
if let Some(iced::Length::Fixed(h)) = cs.height {
|
|
let total_v = padding.top + padding.bottom + bwidth * 2.0;
|
|
if total_v > h && h > 0.0 {
|
|
let scale = h / total_v;
|
|
padding.top *= scale;
|
|
padding.bottom *= scale;
|
|
}
|
|
}
|
|
|
|
let mut btn = button(content).padding(padding);
|
|
|
|
if let Some(script) = el.get_prop("__on:click") {
|
|
btn = btn.on_press(Message::EventTriggered(script.to_string()));
|
|
}
|
|
|
|
if let Some(w) = cs.width {
|
|
btn = btn.width(w);
|
|
}
|
|
if let Some(h) = cs.height {
|
|
btn = btn.height(h);
|
|
}
|
|
|
|
let el_id = el.id();
|
|
let classes: Vec<&str> = el
|
|
.get_prop("class")
|
|
.map(|s| s.split_whitespace().collect())
|
|
.unwrap_or_default();
|
|
|
|
let default_struct = StructuralContext::default();
|
|
let el_attributes: HashMap<String, String> = el.properties.iter()
|
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
|
.collect();
|
|
let hover_sheets: Vec<&HashMap<String, String>> =
|
|
stylesheet.matching_rules(el.type_name, el_id, &classes, &["hover"], &default_struct, &[], &[], &el_attributes);
|
|
let active_sheets: Vec<&HashMap<String, String>> =
|
|
stylesheet.matching_rules(el.type_name, el_id, &classes, &["active"], &default_struct, &[], &[], &el_attributes);
|
|
|
|
let mut hover_props = std::collections::HashMap::new();
|
|
for sheet in &hover_sheets {
|
|
hover_props.extend((*sheet).clone());
|
|
}
|
|
let mut active_props = std::collections::HashMap::new();
|
|
for sheet in &active_sheets {
|
|
active_props.extend((*sheet).clone());
|
|
}
|
|
|
|
let mut cs_clone = cs;
|
|
cs_clone.color = final_color;
|
|
|
|
btn.style(move |_, status| {
|
|
let mut style_cs = cs_clone.clone();
|
|
match status {
|
|
button::Status::Hovered => {
|
|
if !hover_props.is_empty() {
|
|
style_cs.apply_overrides(&hover_props);
|
|
}
|
|
}
|
|
button::Status::Pressed => {
|
|
if !active_props.is_empty() {
|
|
style_cs.apply_overrides(&active_props);
|
|
} else if !hover_props.is_empty() {
|
|
style_cs.apply_overrides(&hover_props);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
get_button_style(&style_cs, status)
|
|
}).into()
|
|
}
|
|
|
|
fn render_toggle<'a>(
|
|
el: &'a Element,
|
|
parent_color: Option<iced::Color>,
|
|
parent_font_size: Option<f32>,
|
|
) -> iced::Element<'a, Message, Theme, iced::Renderer> {
|
|
let label = el.get_prop("label").unwrap_or("");
|
|
let value = el.get_prop("value")
|
|
.and_then(|v| v.parse::<bool>().ok())
|
|
.unwrap_or(false);
|
|
let var_name = extract_var_binding(el, "value");
|
|
|
|
let cb = checkbox(value).on_toggle(move |v| Message::ToggleChanged(var_name.clone(), v));
|
|
|
|
if label.is_empty() {
|
|
cb.into()
|
|
} else {
|
|
let mut label_widget = text(label).size(parent_font_size.unwrap_or(16.0));
|
|
if let Some(color) = parent_color {
|
|
label_widget = label_widget.color(color);
|
|
}
|
|
|
|
row![cb, label_widget]
|
|
.spacing(8)
|
|
.align_y(iced::Alignment::Center)
|
|
.into()
|
|
}
|
|
}
|
|
|
|
fn render_input<'a>(
|
|
el: &'a Element,
|
|
cs: ComputedStyle,
|
|
parent_color: Option<iced::Color>,
|
|
_parent_font_size: Option<f32>,
|
|
hover_props: &HashMap<String, String>,
|
|
active_props: &HashMap<String, String>,
|
|
) -> iced::Element<'a, Message, Theme, iced::Renderer> {
|
|
let placeholder = el.get_prop("placeholder").unwrap_or("Type here…");
|
|
let value = el.get_prop("value").unwrap_or("");
|
|
let var_name = extract_var_binding(el, "value");
|
|
|
|
let padding = get_padding(&cs, 10.0);
|
|
|
|
let mut input = text_input(&placeholder, &value)
|
|
.on_input(move |v| Message::InputChanged(var_name.clone(), v))
|
|
.id(get_or_create_widget_id(el.element_id.0, "ti"))
|
|
.padding(padding);
|
|
|
|
if let Some(w) = cs.width {
|
|
input = input.width(w);
|
|
}
|
|
|
|
let mut base_cs = cs;
|
|
base_cs.color = parent_color;
|
|
|
|
let has_any = !hover_props.is_empty() || !active_props.is_empty();
|
|
let hover = hover_props.clone();
|
|
|
|
if has_any {
|
|
input = input.style(move |_, status| {
|
|
let mut style_cs = base_cs.clone();
|
|
match status {
|
|
text_input::Status::Hovered => {
|
|
if !hover.is_empty() {
|
|
style_cs.apply_overrides(&hover);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
get_text_input_style(&style_cs)
|
|
});
|
|
} else {
|
|
input = input.style(move |_, _| get_text_input_style(&base_cs));
|
|
}
|
|
|
|
input.into()
|
|
}
|
|
|
|
fn render_slider<'a>(el: &'a Element) -> iced::Element<'a, Message, Theme, iced::Renderer> {
|
|
let value = el.get_prop("value")
|
|
.and_then(|v| v.parse::<f32>().ok())
|
|
.unwrap_or(0.0);
|
|
let var_name = extract_var_binding(el, "value");
|
|
|
|
slider(0.0..=100.0, value, move |v| {
|
|
Message::SliderChanged(var_name.clone(), v as f64)
|
|
})
|
|
.into()
|
|
}
|