diff --git a/ROADMAP.md b/ROADMAP.md index 6bc115b..278e7e2 100755 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -154,14 +154,30 @@ The long-term goal is to enable Rust developers to create modern desktop applica | --------------- | ------ | ------------------------------- | | Language Server | ✅ | LSP support for `.dampen` files | +### v0.3.1 - Advanced Widgets (In progress) + +**Objective**: Enrich available widget library + +| Milestone | Status | Description | +| --------- | ------ | ------------------- | +| Tabs | 🔲 | Tabs/TabBar widgets | + +### v0.3.2 - Advanced Widgets (planned) + +**Objective**: Enhance widget Datatable + +| Milestone | Status | Description | +| --------- | ------ | ------------------------ | +| DataTable | 🔲 | Add missing click events | + ### Developer Experience (planned) **Objective**: Improve tooling and DX | Milestone | Status | Priority | Description | | -------------------- | ------ | -------- | --------------------------------- | -| Zed Extension | 🔲 | High | Official Zed extension | -| VS Code Extension | 🔲 | High | Official VS Code extension | +| Zed Extension | ✅ | High | Official Zed extension | +| VS Code Extension | ✅ | High | Official VS Code extension | | Interactive CLI | 🔲 | Low | Interactive mode for `dampen new` | | Visual Hot Reload | 🔲 | Medium | Improved error overlay | | Debugger Integration | 🔲 | Low | IDE debugging support | diff --git a/crates/dampen-cli/src/commands/check/main_command.rs b/crates/dampen-cli/src/commands/check/main_command.rs index 72b2506..81efd5f 100755 --- a/crates/dampen-cli/src/commands/check/main_command.rs +++ b/crates/dampen-cli/src/commands/check/main_command.rs @@ -1281,6 +1281,8 @@ impl WidgetKindExt for WidgetKind { WidgetKind::DataColumn, WidgetKind::TreeView, WidgetKind::TreeNode, + WidgetKind::TabBar, + WidgetKind::Tab, WidgetKind::For, WidgetKind::If, ] diff --git a/crates/dampen-core/src/codegen/view.rs b/crates/dampen-core/src/codegen/view.rs index c85e0ac..67fa414 100755 --- a/crates/dampen-core/src/codegen/view.rs +++ b/crates/dampen-core/src/codegen/view.rs @@ -247,6 +247,19 @@ fn generate_widget_with_locals( node.kind ))) } + WidgetKind::TabBar => generate_tab_bar_with_locals( + node, + model_ident, + message_ident, + style_classes, + local_vars, + ), + WidgetKind::Tab => { + // Tab must be inside TabBar, handled by generate_tab_bar + Err(super::CodegenError::InvalidWidget( + "Tab must be inside TabBar".to_string(), + )) + } } } @@ -2030,17 +2043,191 @@ fn generate_progress_bar( } }); + // Parse style attribute (default to "primary") + let style_str = node + .attributes + .get("style") + .and_then(|attr| { + if let AttributeValue::Static(s) = attr { + Some(s.clone()) + } else { + None + } + }) + .unwrap_or_else(|| "primary".to_string()); + + // Parse custom colors + let bar_color = node.attributes.get("bar_color").and_then(|attr| { + if let AttributeValue::Static(s) = attr { + parse_color_to_tokens(s) + } else { + None + } + }); + + let background_color = node.attributes.get("background_color").and_then(|attr| { + if let AttributeValue::Static(s) = attr { + parse_color_to_tokens(s) + } else { + None + } + }); + + // Parse border radius + let border_radius = node.attributes.get("border_radius").and_then(|attr| { + if let AttributeValue::Static(s) = attr { + s.parse::().ok() + } else { + None + } + }); + + // Parse height (girth) + let height = node.attributes.get("height").and_then(|attr| { + if let AttributeValue::Static(s) = attr { + s.parse::().ok() + } else { + None + } + }); + + // Generate style closure based on style attribute + let bar_color_expr = if let Some(color_tokens) = bar_color { + quote! { #color_tokens } + } else { + match style_str.as_str() { + "success" => quote! { palette.success.base.color }, + "warning" => quote! { palette.warning.base.color }, + "danger" => quote! { palette.danger.base.color }, + "secondary" => quote! { palette.secondary.base.color }, + _ => quote! { palette.primary.base.color }, // default to primary + } + }; + + // Generate background color expression + let background_color_expr = if let Some(color_tokens) = background_color { + quote! { #color_tokens } + } else { + quote! { palette.background.weak.color } + }; + + // Generate border expression + let border_expr = if let Some(radius) = border_radius { + quote! { iced::Border::default().rounded(#radius) } + } else { + quote! { iced::Border::default() } + }; + + // Generate height/girth expression + let girth_expr = if let Some(h) = height { + quote! { .girth(#h) } + } else { + quote! {} + }; + if let Some(max) = max_attr { Ok(quote! { - iced::widget::progress_bar(0.0..=#max, #value_expr).into() + iced::widget::progress_bar(0.0..=#max, #value_expr) + #girth_expr + .style(|theme: &iced::Theme| { + let palette = theme.extended_palette(); + iced::widget::progress_bar::Style { + background: iced::Background::Color(#background_color_expr), + bar: iced::Background::Color(#bar_color_expr), + border: #border_expr, + } + }) + .into() }) } else { Ok(quote! { - iced::widget::progress_bar(0.0..=100.0, #value_expr).into() + iced::widget::progress_bar(0.0..=100.0, #value_expr) + #girth_expr + .style(|theme: &iced::Theme| { + let palette = theme.extended_palette(); + iced::widget::progress_bar::Style { + background: iced::Background::Color(#background_color_expr), + bar: iced::Background::Color(#bar_color_expr), + border: #border_expr, + } + }) + .into() }) } } +/// Parse a color string into TokenStream for code generation +fn parse_color_to_tokens(color_str: &str) -> Option { + // Try hex color (#RRGGBB or #RRGGBBAA) + if color_str.starts_with('#') { + let hex = &color_str[1..]; + if hex.len() == 6 { + if let (Ok(r), Ok(g), Ok(b)) = ( + u8::from_str_radix(&hex[0..2], 16), + u8::from_str_radix(&hex[2..4], 16), + u8::from_str_radix(&hex[4..6], 16), + ) { + let rf = r as f32 / 255.0; + let gf = g as f32 / 255.0; + let bf = b as f32 / 255.0; + return Some(quote! { iced::Color::from_rgb(#rf, #gf, #bf) }); + } + } else if hex.len() == 8 { + if let (Ok(r), Ok(g), Ok(b), Ok(a)) = ( + u8::from_str_radix(&hex[0..2], 16), + u8::from_str_radix(&hex[2..4], 16), + u8::from_str_radix(&hex[4..6], 16), + u8::from_str_radix(&hex[6..8], 16), + ) { + let rf = r as f32 / 255.0; + let gf = g as f32 / 255.0; + let bf = b as f32 / 255.0; + let af = a as f32 / 255.0; + return Some(quote! { iced::Color::from_rgba(#rf, #gf, #bf, #af) }); + } + } + } + + // Try RGB format: rgb(r,g,b) + if color_str.starts_with("rgb(") && color_str.ends_with(')') { + let inner = &color_str[4..color_str.len() - 1]; + let parts: Vec<&str> = inner.split(',').map(|s| s.trim()).collect(); + if parts.len() == 3 { + if let (Ok(r), Ok(g), Ok(b)) = ( + parts[0].parse::(), + parts[1].parse::(), + parts[2].parse::(), + ) { + let rf = r as f32 / 255.0; + let gf = g as f32 / 255.0; + let bf = b as f32 / 255.0; + return Some(quote! { iced::Color::from_rgb(#rf, #gf, #bf) }); + } + } + } + + // Try RGBA format: rgba(r,g,b,a) + if color_str.starts_with("rgba(") && color_str.ends_with(')') { + let inner = &color_str[5..color_str.len() - 1]; + let parts: Vec<&str> = inner.split(',').map(|s| s.trim()).collect(); + if parts.len() == 4 { + if let (Ok(r), Ok(g), Ok(b), Ok(a)) = ( + parts[0].parse::(), + parts[1].parse::(), + parts[2].parse::(), + parts[3].parse::(), + ) { + let rf = r as f32 / 255.0; + let gf = g as f32 / 255.0; + let bf = b as f32 / 255.0; + return Some(quote! { iced::Color::from_rgba(#rf, #gf, #bf, #a) }); + } + } + } + + None +} + /// Generate text input widget fn generate_text_input( node: &crate::WidgetNode, @@ -4921,3 +5108,221 @@ mod tests { assert!(code.contains("border")); } } + +/// Generate TabBar widget code with content +fn generate_tab_bar_with_locals( + node: &crate::WidgetNode, + model_ident: &syn::Ident, + message_ident: &syn::Ident, + style_classes: &HashMap, + local_vars: &std::collections::HashSet, +) -> Result { + use proc_macro2::Span; + use quote::quote; + + // Get selected index attribute + let selected_attr = node.attributes.get("selected").ok_or_else(|| { + super::CodegenError::InvalidWidget("TabBar requires 'selected' attribute".to_string()) + })?; + + // Generate selected index expression + let selected_expr = match selected_attr { + AttributeValue::Static(s) => { + let idx: usize = s.parse().map_err(|_| { + super::CodegenError::InvalidWidget(format!("Invalid selected index: {}", s)) + })?; + quote! { #idx } + } + AttributeValue::Binding(binding) => { + // Generate binding expression - generate_expr returns a TokenStream that produces a String + let binding_expr = generate_expr(&binding.expr); + quote! { (#binding_expr).parse::().unwrap_or(0) } + } + _ => quote! { 0usize }, + }; + + // Find on_select event handler + let on_select_handler = node + .events + .iter() + .find(|e| matches!(e.event, crate::ir::EventKind::Select)) + .map(|e| syn::Ident::new(&e.handler, Span::call_site())); + + // Generate tab labels and content + let _tab_count = node.children.len(); + let tab_labels: Vec<_> = node + .children + .iter() + .enumerate() + .map(|(idx, child)| { + let idx_lit = proc_macro2::Literal::usize_unsuffixed(idx); + + // Get label from tab + let label_expr = if let Some(label_attr) = child.attributes.get("label") { + match label_attr { + AttributeValue::Static(s) => Some(quote! { #s.to_string() }), + _ => None, + } + } else { + None + }; + + // Get icon from tab + let icon_expr = if let Some(icon_attr) = child.attributes.get("icon") { + match icon_attr { + AttributeValue::Static(s) => { + let icon_char = resolve_icon_for_codegen(s); + Some(quote! { #icon_char }) + } + _ => None, + } + } else { + None + }; + + // Build TabLabel expression based on what we have + let tab_label_expr = match (icon_expr, label_expr) { + (Some(icon), Some(label)) => { + quote! { iced_aw::tab_bar::TabLabel::IconText(#icon, #label) } + } + (Some(icon), None) => { + quote! { iced_aw::tab_bar::TabLabel::Icon(#icon) } + } + (None, Some(label)) => { + quote! { iced_aw::tab_bar::TabLabel::Text(#label) } + } + (None, None) => { + quote! { iced_aw::tab_bar::TabLabel::Text("Tab".to_string()) } + } + }; + + quote! { + tab_bar = tab_bar.push(#idx_lit, #tab_label_expr); + } + }) + .collect(); + + // Generate content for each tab + let tab_content_arms: Vec<_> = node + .children + .iter() + .enumerate() + .map(|(idx, child)| { + let idx_lit = proc_macro2::Literal::usize_unsuffixed(idx); + + // Generate content for this tab's children + let content_widgets: Vec<_> = child + .children + .iter() + .map(|child_node| { + generate_widget_with_locals( + child_node, + model_ident, + message_ident, + style_classes, + local_vars, + ) + }) + .collect::, _>>()?; + + Ok::<_, super::CodegenError>(quote! { + #idx_lit => iced::widget::column(vec![#(#content_widgets),*]).into() + }) + }) + .collect::, super::CodegenError>>()?; + + // Generate on_select callback if handler exists + let on_select_expr = if let Some(handler) = on_select_handler { + quote! { + .on_select(|idx| #message_ident::#handler(idx)) + } + } else { + quote! {} + }; + + // Generate icon_size if specified + let icon_size_expr = if let Some(icon_size_attr) = node.attributes.get("icon_size") { + match icon_size_attr { + AttributeValue::Static(s) => { + if let Ok(icon_size) = s.parse::() { + Some(quote! { .icon_size(#icon_size) }) + } else { + None + } + } + _ => None, + } + } else { + None + }; + + // Generate text_size if specified + let text_size_expr = if let Some(text_size_attr) = node.attributes.get("text_size") { + match text_size_attr { + AttributeValue::Static(s) => { + if let Ok(text_size) = s.parse::() { + Some(quote! { .text_size(#text_size) }) + } else { + None + } + } + _ => None, + } + } else { + None + }; + + // Build the complete TabBar widget with content + let tab_bar_widget = quote! { + { + let mut tab_bar = iced_aw::TabBar::new(#selected_expr) + #on_select_expr + #icon_size_expr + #text_size_expr; + + #(#tab_labels)* + + tab_bar + } + }; + + // Build content element using match on selected index + let content_element = if tab_content_arms.is_empty() { + quote! { iced::widget::column(vec![]).into() } + } else { + quote! { + match #selected_expr { + #(#tab_content_arms,)* + _ => iced::widget::column(vec![]).into(), + } + } + }; + + // Combine TabBar and content in a column + let result = quote! { + iced::widget::column![ + #tab_bar_widget, + #content_element + ] + }; + + Ok(result) +} + +/// Resolve icon name to Unicode character for codegen +fn resolve_icon_for_codegen(name: &str) -> char { + match name { + "home" => '\u{F015}', + "settings" => '\u{F013}', + "user" => '\u{F007}', + "search" => '\u{F002}', + "add" => '\u{F067}', + "delete" => '\u{F1F8}', + "edit" => '\u{F044}', + "save" => '\u{F0C7}', + "close" => '\u{F00D}', + "back" => '\u{F060}', + "forward" => '\u{F061}', + _ => '\u{F111}', // Circle as fallback + } +} diff --git a/crates/dampen-core/src/ir/node.rs b/crates/dampen-core/src/ir/node.rs index 427fa86..e22e086 100755 --- a/crates/dampen-core/src/ir/node.rs +++ b/crates/dampen-core/src/ir/node.rs @@ -74,6 +74,9 @@ pub enum WidgetKind { // Tree widget TreeView, TreeNode, + // Tab widgets + TabBar, + Tab, // Control flow For, If, @@ -263,6 +266,8 @@ impl std::fmt::Display for WidgetKind { WidgetKind::DataColumn => "data_column", WidgetKind::TreeView => "tree_view", WidgetKind::TreeNode => "tree_node", + WidgetKind::TabBar => "tab_bar", + WidgetKind::Tab => "tab", WidgetKind::For => "for", WidgetKind::If => "if", WidgetKind::Custom(name) => return write!(f, "{}", name), @@ -314,6 +319,8 @@ impl WidgetKind { "data_column", "tree_view", "tree_node", + "tab_bar", + "tab", "for", "if", ] @@ -363,7 +370,9 @@ impl WidgetKind { | WidgetKind::DataTable | WidgetKind::DataColumn | WidgetKind::TreeView - | WidgetKind::TreeNode => crate::ir::SchemaVersion { major: 1, minor: 1 }, + | WidgetKind::TreeNode + | WidgetKind::TabBar + | WidgetKind::Tab => crate::ir::SchemaVersion { major: 1, minor: 1 }, _ => crate::ir::SchemaVersion { major: 1, minor: 0 }, } } diff --git a/crates/dampen-core/src/parser/mod.rs b/crates/dampen-core/src/parser/mod.rs index 65b9440..090443a 100755 --- a/crates/dampen-core/src/parser/mod.rs +++ b/crates/dampen-core/src/parser/mod.rs @@ -735,6 +735,8 @@ fn parse_node(node: Node, source: &str) -> Result { "data_column" => WidgetKind::DataColumn, "tree_view" => WidgetKind::TreeView, "tree_node" => WidgetKind::TreeNode, + "tab_bar" => WidgetKind::TabBar, + "tab" => WidgetKind::Tab, "template" => WidgetKind::Custom("template".to_string()), "for" => WidgetKind::For, "if" => WidgetKind::If, @@ -1142,6 +1144,30 @@ fn validate_nesting_constraints( }); } + // Rule: Tab must be inside TabBar + if node.kind == WidgetKind::Tab && parent_kind != Some(&WidgetKind::TabBar) { + return Err(ParseError { + kind: ParseErrorKind::InvalidChild, + message: "Tab must be inside TabBar".to_string(), + span: node.span, + suggestion: Some("Wrap this tab in a ".to_string()), + }); + } + + // Rule: TabBar must contain only Tab children + if node.kind == WidgetKind::TabBar { + for child in &node.children { + if child.kind != WidgetKind::Tab { + return Err(ParseError { + kind: ParseErrorKind::InvalidChild, + message: "TabBar can only contain Tab widgets".to_string(), + span: child.span, + suggestion: Some("Use elements inside ".to_string()), + }); + } + } + } + // Recurse for child in &node.children { validate_nesting_constraints(child, Some(&node.kind))?; diff --git a/crates/dampen-core/src/schema/mod.rs b/crates/dampen-core/src/schema/mod.rs index 1a6e227..2dbb567 100755 --- a/crates/dampen-core/src/schema/mod.rs +++ b/crates/dampen-core/src/schema/mod.rs @@ -260,7 +260,16 @@ pub fn get_widget_schema(kind: &WidgetKind) -> WidgetSchema { }, WidgetKind::ProgressBar => WidgetSchema { required: &[], - optional: &["value", "min", "max", "style"], + optional: &[ + "value", + "min", + "max", + "style", + "bar_color", + "background_color", + "border_radius", + "height", + ], events: &[], style_attributes: COMMON_STYLE_ATTRIBUTES, layout_attributes: COMMON_LAYOUT_ATTRIBUTES, @@ -437,6 +446,28 @@ pub fn get_widget_schema(kind: &WidgetKind) -> WidgetSchema { style_attributes: &[], layout_attributes: &[], }, + WidgetKind::TabBar => WidgetSchema { + required: &["selected"], + optional: &[ + "spacing", + "padding", + "icon_size", + "text_size", + "width", + "height", + "class", + ], + events: &["on_select"], + style_attributes: COMMON_STYLE_ATTRIBUTES, + layout_attributes: &["width", "height"], + }, + WidgetKind::Tab => WidgetSchema { + required: &[], + optional: &["label", "icon", "enabled"], + events: &["on_click"], + style_attributes: COMMON_STYLE_ATTRIBUTES, + layout_attributes: &[], + }, WidgetKind::Custom(_) => WidgetSchema { required: &[], optional: &[], diff --git a/crates/dampen-core/tests/codegen_tab_bar.rs b/crates/dampen-core/tests/codegen_tab_bar.rs new file mode 100644 index 0000000..59a5f4a --- /dev/null +++ b/crates/dampen-core/tests/codegen_tab_bar.rs @@ -0,0 +1,128 @@ +//! Codegen tests for TabBar widget + +use dampen_core::{HandlerSignature, generate_application, parse}; + +#[test] +fn test_codegen_tab_bar_basic() { + let xml = r#" + + + + + + + + "#; + + let doc = parse(xml).unwrap(); + let handlers = vec![HandlerSignature { + name: "on_tab_selected".to_string(), + param_type: Some("usize".to_string()), + returns_command: false, + }]; + + let result = generate_application(&doc, "Model", "Message", &handlers); + + assert!(result.is_ok(), "Codegen should succeed for basic TabBar"); + + let output = result.unwrap(); + let code = output.code.to_string(); + + // Verify the generated code contains expected elements + assert!( + code.contains("TabBar"), + "Generated code should contain TabBar" + ); + assert!( + code.contains("on_tab_selected"), + "Generated code should reference the handler" + ); +} + +#[test] +fn test_codegen_tab_bar_with_binding() { + let xml = r#" + + + + + + + "#; + + let doc = parse(xml).unwrap(); + let handlers = vec![HandlerSignature { + name: "on_tab_selected".to_string(), + param_type: Some("usize".to_string()), + returns_command: false, + }]; + + let result = generate_application(&doc, "Model", "Message", &handlers); + + assert!(result.is_ok(), "Codegen should succeed for empty TabBar"); +} + +#[test] +fn test_codegen_tab_bar_with_icons() { + let xml = r#" + + + + + + + + "#; + + let doc = parse(xml).unwrap(); + let handlers = vec![HandlerSignature { + name: "on_tab_selected".to_string(), + param_type: Some("usize".to_string()), + returns_command: false, + }]; + + let result = generate_application(&doc, "Model", "Message", &handlers); + + assert!( + result.is_ok(), + "Codegen should succeed for TabBar with icons" + ); + + let output = result.unwrap(); + let code = output.code.to_string(); + + // Verify the generated code contains expected icon-related elements + assert!( + code.contains("TabLabel"), + "Generated code should contain TabLabel" + ); + assert!( + code.contains("IconText"), + "Generated code should contain IconText for icon+label tabs" + ); + assert!( + code.contains("Icon ("), + "Generated code should contain Icon for icon-only tabs" + ); +} + +#[test] +fn test_codegen_tab_bar_empty() { + let xml = r#" + + + + + "#; + + let doc = parse(xml).unwrap(); + let handlers = vec![HandlerSignature { + name: "on_tab_selected".to_string(), + param_type: Some("usize".to_string()), + returns_command: false, + }]; + + let result = generate_application(&doc, "Model", "Message", &handlers); + + assert!(result.is_ok(), "Codegen should succeed for empty TabBar"); +} diff --git a/crates/dampen-core/tests/parser_tab_bar.rs b/crates/dampen-core/tests/parser_tab_bar.rs new file mode 100644 index 0000000..0dacd54 --- /dev/null +++ b/crates/dampen-core/tests/parser_tab_bar.rs @@ -0,0 +1,163 @@ +//! Parser tests for TabBar and Tab widgets +//! +//! Tests validation of Tab/TabBar structure and constraints. + +use dampen_core::{ir::WidgetKind, parse, parser::error::ParseErrorKind}; + +#[test] +fn test_parse_valid_tab_bar_with_tabs() { + let xml = r#" + + + + + + "#; + + let result = parse(xml); + assert!( + result.is_ok(), + "Should parse valid TabBar with Tab children" + ); + + let doc = result.unwrap(); + assert_eq!(doc.root.kind, WidgetKind::TabBar); + assert_eq!(doc.root.children.len(), 3); + + for child in &doc.root.children { + assert_eq!(child.kind, WidgetKind::Tab); + } +} + +#[test] +fn test_parse_tab_outside_tab_bar_fails() { + let xml = r#" + + + + "#; + + let result = parse(xml); + assert!(result.is_err(), "Tab outside TabBar should fail"); + + let err = result.unwrap_err(); + assert_eq!(err.kind, ParseErrorKind::InvalidChild); + assert!(err.message.contains("Tab must be inside TabBar")); +} + +#[test] +fn test_parse_tab_bar_with_non_tab_child_fails() { + let xml = r#" + + + + + "#; + + let result = parse(xml); + assert!(result.is_err(), "TabBar with non-Tab child should fail"); + + let err = result.unwrap_err(); + assert_eq!(err.kind, ParseErrorKind::InvalidChild); + assert!(err.message.contains("TabBar can only contain Tab widgets")); +} + +#[test] +fn test_parse_tab_bar_with_icon_attribute() { + let xml = r#" + + + + + "#; + + let result = parse(xml); + assert!(result.is_ok(), "Should parse TabBar with icon attributes"); + + let doc = result.unwrap(); + let first_tab = &doc.root.children[0]; + assert!(first_tab.attributes.contains_key("icon")); + assert!(first_tab.attributes.contains_key("label")); +} + +#[test] +fn test_parse_tab_bar_with_binding() { + let xml = r#" + + + + + "#; + + let result = parse(xml); + assert!( + result.is_ok(), + "Should parse TabBar with binding expression" + ); + + let doc = result.unwrap(); + assert!(doc.root.attributes.contains_key("selected")); +} + +#[test] +fn test_parse_empty_tab_bar() { + let xml = r#" + + + "#; + + let result = parse(xml); + assert!( + result.is_ok(), + "Should parse empty TabBar (graceful degradation)" + ); + + let doc = result.unwrap(); + assert_eq!(doc.root.children.len(), 0); +} + +#[test] +fn test_parse_tab_with_enabled_attribute() { + let xml = r#" + + + + + "#; + + let result = parse(xml); + assert!(result.is_ok(), "Should parse Tab with enabled attribute"); + + let doc = result.unwrap(); + let admin_tab = &doc.root.children[1]; + assert!(admin_tab.attributes.contains_key("enabled")); +} + +#[test] +fn test_tab_bar_requires_v1_1() { + // TabBar requires v1.1, so using v1.0 should fail + let xml = r#" + + + + "#; + + let result = parse(xml); + assert!(result.is_err(), "TabBar should require schema v1.1"); + + let err = result.unwrap_err(); + assert_eq!(err.kind, ParseErrorKind::UnsupportedVersion); +} + +#[test] +fn test_tab_requires_v1_1() { + // Tab requires v1.1, so using v1.0 should fail + let xml = r#" + + + + "#; + + let result = parse(xml); + assert!(result.is_err(), "Tab should require schema v1.1"); +} diff --git a/crates/dampen-core/tests/snapshots/codegen_snapshot_tests__codegen_complex_nested.snap b/crates/dampen-core/tests/snapshots/codegen_snapshot_tests__codegen_complex_nested.snap index e84261f..62a3a4d 100755 --- a/crates/dampen-core/tests/snapshots/codegen_snapshot_tests__codegen_complex_nested.snap +++ b/crates/dampen-core/tests/snapshots/codegen_snapshot_tests__codegen_complex_nested.snap @@ -2,4 +2,4 @@ source: crates/dampen-core/tests/codegen_snapshot_tests.rs expression: output.code --- -use iced :: { Element , Task } ; use crate :: ui :: window :: * ; # [derive (Debug , Clone)] pub enum Message { Toggle (bool) , UpdateValue (f32) , Save , Cancel } pub fn new_model () -> (Model , Task < Message >) { (Model :: default () , Task :: none ()) } pub fn update_model (model : & mut Model , message : Message) -> Task < Message > { match message { Message :: Toggle (value) => { toggle (model , value) ; iced :: Task :: none () } Message :: UpdateValue (value) => { update_value (model , value) ; iced :: Task :: none () } Message :: Save => { save (model) ; iced :: Task :: none () } Message :: Cancel => { cancel (model) ; iced :: Task :: none () } } } pub fn view_model (model : & Model) -> Element < '_ , Message > { Into :: < Element < '_ , Message >> :: into (iced :: widget :: scrollable (iced :: widget :: column ({ let children : Vec < Element < '_ , Message >> = vec ! [Into :: < Element < '_ , Message >> :: into (iced :: widget :: column ({ let children : Vec < Element < '_ , Message >> = vec ! [iced :: widget :: text ("Dashboard" . to_string ()) . size (32f32) . font (iced :: Font { weight : iced :: font :: Weight :: Bold , .. Default :: default () }) . into () , iced :: widget :: rule :: horizontal (1f32) . into () , Into :: < Element < '_ , Message >> :: into (iced :: widget :: row ({ let children : Vec < Element < '_ , Message >> = vec ! [Into :: < Element < '_ , Message >> :: into (iced :: widget :: container (Into :: < Element < '_ , Message >> :: into (iced :: widget :: column ({ let children : Vec < Element < '_ , Message >> = vec ! [iced :: widget :: text ("Stats" . to_string ()) . size (20f32) . font (iced :: Font { weight : iced :: font :: Weight :: Bold , .. Default :: default () }) . into () , iced :: widget :: text (format ! ("Total: {}" , model . total . to_string ())) . into () , iced :: widget :: text (format ! ("Active: {}" , model . active . to_string ())) . into () , iced :: widget :: progress_bar (0.0 ..= 100f32 , model . progress . to_string ()) . into ()] ; children }) . spacing (10f32))) . padding (15f32) . width (iced :: Length :: Fixed (300f32))) , Into :: < Element < '_ , Message >> :: into (iced :: widget :: container (Into :: < Element < '_ , Message >> :: into (iced :: widget :: column ({ let children : Vec < Element < '_ , Message >> = vec ! [iced :: widget :: text ("Actions" . to_string ()) . size (20f32) . font (iced :: Font { weight : iced :: font :: Weight :: Bold , .. Default :: default () }) . into () , Into :: < Element < '_ , Message >> :: into (iced :: widget :: checkbox (model . enabled)) , iced :: widget :: slider (0.0 ..= 100.0 , model . value . to_string () , | v | Message :: UpdateValue (v)) . into () , Into :: < Element < '_ , Message >> :: into (iced :: widget :: row ({ let children : Vec < Element < '_ , Message >> = vec ! [Into :: < Element < '_ , Message >> :: into (iced :: widget :: button (iced :: widget :: text ("Save" . to_string ())) . on_press (Message :: Save)) , Into :: < Element < '_ , Message >> :: into (iced :: widget :: button (iced :: widget :: text ("Cancel" . to_string ())) . on_press (Message :: Cancel))] ; children }) . spacing (10f32))] ; children }) . spacing (10f32))) . padding (15f32) . width (iced :: Length :: Fixed (400f32)))] ; children }) . spacing (20f32))] ; children }) . spacing (10f32) . padding (20f32))] ; children })) . height (iced :: Length :: Fixed (600f32))) } +use iced :: { Element , Task } ; use crate :: ui :: window :: * ; # [derive (Debug , Clone)] pub enum Message { Toggle (bool) , UpdateValue (f32) , Save , Cancel } pub fn new_model () -> (Model , Task < Message >) { (Model :: default () , Task :: none ()) } pub fn update_model (model : & mut Model , message : Message) -> Task < Message > { match message { Message :: Toggle (value) => { toggle (model , value) ; iced :: Task :: none () } Message :: UpdateValue (value) => { update_value (model , value) ; iced :: Task :: none () } Message :: Save => { save (model) ; iced :: Task :: none () } Message :: Cancel => { cancel (model) ; iced :: Task :: none () } } } pub fn view_model (model : & Model) -> Element < '_ , Message > { Into :: < Element < '_ , Message >> :: into (iced :: widget :: scrollable (iced :: widget :: column ({ let children : Vec < Element < '_ , Message >> = vec ! [Into :: < Element < '_ , Message >> :: into (iced :: widget :: column ({ let children : Vec < Element < '_ , Message >> = vec ! [iced :: widget :: text ("Dashboard" . to_string ()) . size (32f32) . font (iced :: Font { weight : iced :: font :: Weight :: Bold , .. Default :: default () }) . into () , iced :: widget :: rule :: horizontal (1f32) . into () , Into :: < Element < '_ , Message >> :: into (iced :: widget :: row ({ let children : Vec < Element < '_ , Message >> = vec ! [Into :: < Element < '_ , Message >> :: into (iced :: widget :: container (Into :: < Element < '_ , Message >> :: into (iced :: widget :: column ({ let children : Vec < Element < '_ , Message >> = vec ! [iced :: widget :: text ("Stats" . to_string ()) . size (20f32) . font (iced :: Font { weight : iced :: font :: Weight :: Bold , .. Default :: default () }) . into () , iced :: widget :: text (format ! ("Total: {}" , model . total . to_string ())) . into () , iced :: widget :: text (format ! ("Active: {}" , model . active . to_string ())) . into () , iced :: widget :: progress_bar (0.0 ..= 100f32 , model . progress . to_string ()) . style (| theme : & iced :: Theme | { let palette = theme . extended_palette () ; iced :: widget :: progress_bar :: Style { background : iced :: Background :: Color (palette . background . weak . color) , bar : iced :: Background :: Color (palette . primary . base . color) , border : iced :: Border :: default () , } }) . into ()] ; children }) . spacing (10f32))) . padding (15f32) . width (iced :: Length :: Fixed (300f32))) , Into :: < Element < '_ , Message >> :: into (iced :: widget :: container (Into :: < Element < '_ , Message >> :: into (iced :: widget :: column ({ let children : Vec < Element < '_ , Message >> = vec ! [iced :: widget :: text ("Actions" . to_string ()) . size (20f32) . font (iced :: Font { weight : iced :: font :: Weight :: Bold , .. Default :: default () }) . into () , Into :: < Element < '_ , Message >> :: into (iced :: widget :: checkbox (model . enabled)) , iced :: widget :: slider (0.0 ..= 100.0 , model . value . to_string () , | v | Message :: UpdateValue (v)) . into () , Into :: < Element < '_ , Message >> :: into (iced :: widget :: row ({ let children : Vec < Element < '_ , Message >> = vec ! [Into :: < Element < '_ , Message >> :: into (iced :: widget :: button (iced :: widget :: text ("Save" . to_string ())) . on_press (Message :: Save)) , Into :: < Element < '_ , Message >> :: into (iced :: widget :: button (iced :: widget :: text ("Cancel" . to_string ())) . on_press (Message :: Cancel))] ; children }) . spacing (10f32))] ; children }) . spacing (10f32))) . padding (15f32) . width (iced :: Length :: Fixed (400f32)))] ; children }) . spacing (20f32))] ; children }) . spacing (10f32) . padding (20f32))] ; children })) . height (iced :: Length :: Fixed (600f32))) } diff --git a/crates/dampen-core/tests/snapshots/codegen_snapshot_tests__codegen_progress_bar.snap b/crates/dampen-core/tests/snapshots/codegen_snapshot_tests__codegen_progress_bar.snap index 8e00bc1..50ec810 100755 --- a/crates/dampen-core/tests/snapshots/codegen_snapshot_tests__codegen_progress_bar.snap +++ b/crates/dampen-core/tests/snapshots/codegen_snapshot_tests__codegen_progress_bar.snap @@ -2,4 +2,4 @@ source: crates/dampen-core/tests/codegen_snapshot_tests.rs expression: output.code --- -use iced :: { Element , Task } ; use crate :: ui :: window :: * ; # [derive (Debug , Clone)] pub enum Message { } pub fn new_model () -> (Model , Task < Message >) { (Model :: default () , Task :: none ()) } pub fn update_model (model : & mut Model , message : Message) -> Task < Message > { match message { } } pub fn view_model (model : & Model) -> Element < '_ , Message > { iced :: widget :: progress_bar (0.0 ..= 100f32 , model . progress . to_string ()) . into () } +use iced :: { Element , Task } ; use crate :: ui :: window :: * ; # [derive (Debug , Clone)] pub enum Message { } pub fn new_model () -> (Model , Task < Message >) { (Model :: default () , Task :: none ()) } pub fn update_model (model : & mut Model , message : Message) -> Task < Message > { match message { } } pub fn view_model (model : & Model) -> Element < '_ , Message > { iced :: widget :: progress_bar (0.0 ..= 100f32 , model . progress . to_string ()) . style (| theme : & iced :: Theme | { let palette = theme . extended_palette () ; iced :: widget :: progress_bar :: Style { background : iced :: Background :: Color (palette . background . weak . color) , bar : iced :: Background :: Color (palette . primary . base . color) , border : iced :: Border :: default () , } }) . into () } diff --git a/crates/dampen-iced/Cargo.toml b/crates/dampen-iced/Cargo.toml index 9b9b756..1afd918 100755 --- a/crates/dampen-iced/Cargo.toml +++ b/crates/dampen-iced/Cargo.toml @@ -16,7 +16,7 @@ categories = { workspace = true } dampen-core = { workspace = true } iced = { workspace = true } serde_json = { workspace = true } -iced_aw = { version = "0.13", default-features = false, features = ["date_picker", "time_picker", "color_picker", "context_menu", "menu"] } +iced_aw = { version = "0.13", default-features = false, features = ["date_picker", "time_picker", "color_picker", "context_menu", "menu", "tab_bar"] } chrono = { version = "0.4", features = ["serde"] } [dev-dependencies] diff --git a/crates/dampen-iced/src/builder/mod.rs b/crates/dampen-iced/src/builder/mod.rs index f93eeb6..3cd9765 100755 --- a/crates/dampen-iced/src/builder/mod.rs +++ b/crates/dampen-iced/src/builder/mod.rs @@ -525,6 +525,11 @@ impl<'a> DampenWidgetBuilder<'a> { // TreeNode is handled within build_tree_view, shouldn't appear as top-level iced::widget::column(Vec::new()).into() } + WidgetKind::TabBar => self.build_tab_bar(node), + WidgetKind::Tab => { + // Tab is handled within build_tab_bar, shouldn't appear as top-level + iced::widget::column(Vec::new()).into() + } WidgetKind::DataColumn | WidgetKind::CanvasRect | WidgetKind::CanvasCircle diff --git a/crates/dampen-iced/src/builder/widgets/mod.rs b/crates/dampen-iced/src/builder/widgets/mod.rs index c51cef9..8023293 100755 --- a/crates/dampen-iced/src/builder/widgets/mod.rs +++ b/crates/dampen-iced/src/builder/widgets/mod.rs @@ -29,6 +29,7 @@ mod slider; mod space; mod stack; mod svg; +mod tab_bar; mod text; mod text_input; mod time_picker; diff --git a/crates/dampen-iced/src/builder/widgets/progress_bar.rs b/crates/dampen-iced/src/builder/widgets/progress_bar.rs index 1006046..a601366 100755 --- a/crates/dampen-iced/src/builder/widgets/progress_bar.rs +++ b/crates/dampen-iced/src/builder/widgets/progress_bar.rs @@ -5,6 +5,116 @@ use crate::builder::DampenWidgetBuilder; use dampen_core::ir::node::{AttributeValue, WidgetNode}; use iced::{Element, Renderer, Theme}; +/// Style variants for progress bar +#[derive(Clone, Copy)] +enum ProgressBarStyle { + Primary, + Success, + Warning, + Danger, + Secondary, +} + +impl ProgressBarStyle { + fn from_str(s: &str) -> Self { + match s { + "success" => Self::Success, + "warning" => Self::Warning, + "danger" => Self::Danger, + "secondary" => Self::Secondary, + _ => Self::Primary, + } + } + + fn get_bar_color(self, theme: &Theme) -> iced::Color { + let palette = theme.extended_palette(); + match self { + Self::Success => palette.success.base.color, + Self::Warning => palette.warning.base.color, + Self::Danger => palette.danger.base.color, + Self::Secondary => palette.secondary.base.color, + Self::Primary => palette.primary.base.color, + } + } +} + +/// Parse a color string into an iced Color +fn parse_color(color_str: &str) -> Option { + // Try hex color (#RRGGBB or #RRGGBBAA) + if color_str.starts_with('#') { + let hex = &color_str[1..]; + if hex.len() == 6 { + if let (Ok(r), Ok(g), Ok(b)) = ( + u8::from_str_radix(&hex[0..2], 16), + u8::from_str_radix(&hex[2..4], 16), + u8::from_str_radix(&hex[4..6], 16), + ) { + return Some(iced::Color::from_rgb( + r as f32 / 255.0, + g as f32 / 255.0, + b as f32 / 255.0, + )); + } + } else if hex.len() == 8 { + if let (Ok(r), Ok(g), Ok(b), Ok(a)) = ( + u8::from_str_radix(&hex[0..2], 16), + u8::from_str_radix(&hex[2..4], 16), + u8::from_str_radix(&hex[4..6], 16), + u8::from_str_radix(&hex[6..8], 16), + ) { + return Some(iced::Color::from_rgba( + r as f32 / 255.0, + g as f32 / 255.0, + b as f32 / 255.0, + a as f32 / 255.0, + )); + } + } + } + + // Try RGB format: rgb(r,g,b) + if color_str.starts_with("rgb(") && color_str.ends_with(')') { + let inner = &color_str[4..color_str.len() - 1]; + let parts: Vec<&str> = inner.split(',').map(|s| s.trim()).collect(); + if parts.len() == 3 { + if let (Ok(r), Ok(g), Ok(b)) = ( + parts[0].parse::(), + parts[1].parse::(), + parts[2].parse::(), + ) { + return Some(iced::Color::from_rgb( + r as f32 / 255.0, + g as f32 / 255.0, + b as f32 / 255.0, + )); + } + } + } + + // Try RGBA format: rgba(r,g,b,a) + if color_str.starts_with("rgba(") && color_str.ends_with(')') { + let inner = &color_str[5..color_str.len() - 1]; + let parts: Vec<&str> = inner.split(',').map(|s| s.trim()).collect(); + if parts.len() == 4 { + if let (Ok(r), Ok(g), Ok(b), Ok(a)) = ( + parts[0].parse::(), + parts[1].parse::(), + parts[2].parse::(), + parts[3].parse::(), + ) { + return Some(iced::Color::from_rgba( + r as f32 / 255.0, + g as f32 / 255.0, + b as f32 / 255.0, + a, + )); + } + } + } + + None +} + impl<'a> DampenWidgetBuilder<'a> { pub(in crate::builder) fn build_progress_bar( &self, @@ -33,8 +143,93 @@ impl<'a> DampenWidgetBuilder<'a> { // Clamp value to [min, max] range let clamped_value = value.min(max).max(min); + // Parse style attribute + let style = node + .attributes + .get("style") + .and_then(|attr| { + if let AttributeValue::Static(s) = attr { + Some(ProgressBarStyle::from_str(s)) + } else { + None + } + }) + .unwrap_or(ProgressBarStyle::Primary); + + // Parse custom colors + let bar_color = node.attributes.get("bar_color").and_then(|attr| { + if let AttributeValue::Static(s) = attr { + parse_color(s) + } else { + None + } + }); + + let background_color = node.attributes.get("background_color").and_then(|attr| { + if let AttributeValue::Static(s) = attr { + parse_color(s) + } else { + None + } + }); + + // Parse border radius + let border_radius = node.attributes.get("border_radius").and_then(|attr| { + if let AttributeValue::Static(s) = attr { + s.parse::().ok() + } else { + None + } + }); + + // Parse height (girth) + let height = node.attributes.get("height").and_then(|attr| { + if let AttributeValue::Static(s) = attr { + s.parse::().ok() + } else { + None + } + }); + // Create progress bar - let progress_bar = iced::widget::progress_bar(min..=max, clamped_value); + let mut progress_bar = iced::widget::progress_bar(min..=max, clamped_value); + + // Apply height if specified + if let Some(h) = height { + progress_bar = progress_bar.girth(h); + } + + // Apply style + progress_bar = progress_bar.style(move |theme: &Theme| { + let palette = theme.extended_palette(); + + // Determine bar color (custom or from style) + let bar = if let Some(color) = bar_color { + iced::Background::Color(color) + } else { + iced::Background::Color(style.get_bar_color(theme)) + }; + + // Determine background color (custom or default) + let background = if let Some(color) = background_color { + iced::Background::Color(color) + } else { + iced::Background::Color(palette.background.weak.color) + }; + + // Build border with optional radius + let border = if let Some(radius) = border_radius { + iced::Border::default().rounded(radius) + } else { + iced::Border::default() + }; + + iced::widget::progress_bar::Style { + background, + bar, + border, + } + }); progress_bar.into() } diff --git a/crates/dampen-iced/src/builder/widgets/tab_bar.rs b/crates/dampen-iced/src/builder/widgets/tab_bar.rs new file mode 100644 index 0000000..5453266 --- /dev/null +++ b/crates/dampen-iced/src/builder/widgets/tab_bar.rs @@ -0,0 +1,279 @@ +//! TabBar widget builder + +use crate::HandlerMessage; +use crate::builder::DampenWidgetBuilder; +use dampen_core::ir::node::WidgetNode; +use iced::{Element, Padding, Renderer, Theme}; + +/// Maps icon names to Unicode characters +fn resolve_icon(name: &str) -> char { + match name { + "home" => '\u{F015}', + "settings" => '\u{F013}', + "user" => '\u{F007}', + "search" => '\u{F002}', + "add" => '\u{F067}', + "delete" => '\u{F1F8}', + "edit" => '\u{F044}', + "save" => '\u{F0C7}', + "close" => '\u{F00D}', + "back" => '\u{F060}', + "forward" => '\u{F061}', + _ => '\u{F111}', // Circle as fallback for unknown icons + } +} + +/// Parse padding value from string +/// Supports: "10" (all sides) or "10 20 10 20" (top right bottom left) +fn parse_padding(value: &str) -> Padding { + let parts: Vec = value + .split_whitespace() + .filter_map(|s| s.parse().ok()) + .collect(); + + match parts.len() { + 1 => Padding::new(parts[0]), + 2 => Padding::from([parts[0], parts[1]]), + 4 => Padding { + top: parts[0], + right: parts[1], + bottom: parts[2], + left: parts[3], + }, + _ => Padding::new(0.0), + } +} + +/// Parse length value from string +/// Supports: "fill", "shrink", or numeric value (pixels) +fn parse_length(value: &str) -> iced::Length { + match value.trim() { + "fill" => iced::Length::Fill, + "shrink" => iced::Length::Shrink, + _ => { + if let Ok(pixels) = value.parse::() { + iced::Length::Fixed(pixels) + } else { + iced::Length::Shrink + } + } + } +} + +impl<'a> DampenWidgetBuilder<'a> { + /// Build a TabBar widget from a WidgetNode + pub(in crate::builder) fn build_tab_bar( + &self, + node: &WidgetNode, + ) -> Element<'a, HandlerMessage, Theme, Renderer> + where + HandlerMessage: Clone + 'static, + { + #[cfg(debug_assertions)] + eprintln!("[DampenWidgetBuilder] Building TabBar"); + + // Get selected index (default to 0) + let selected_index = node + .attributes + .get("selected") + .and_then(|attr| { + let value = self.evaluate_attribute(attr); + value.parse::().ok() + }) + .unwrap_or(0); + + // T038: Clamp selected index to valid range [0, num_tabs) + let num_tabs = node.children.len(); + let selected_index = if num_tabs == 0 { + 0 // Empty TabBar, keep at 0 + } else if selected_index >= num_tabs { + // Index out of bounds, clamp to last valid index + num_tabs - 1 + } else { + selected_index + }; + + #[cfg(debug_assertions)] + eprintln!( + "[DampenWidgetBuilder] TabBar selected index: {}", + selected_index + ); + + // Find on_select event handler + let on_select_event = node + .events + .iter() + .find(|e| e.event == dampen_core::EventKind::Select); + + // Build tab labels + let tab_labels: Vec = node + .children + .iter() + .map(|child| self.build_tab_label(child)) + .collect(); + + // Build tab contents for each tab + let mut tab_contents: Vec>> = node + .children + .iter() + .map(|child| { + child + .children + .iter() + .map(|child_node| self.build_widget(child_node)) + .collect() + }) + .collect(); + + // Create TabBar with on_select callback + // The iced_aw API requires the callback in the constructor + let mut tab_bar = if let Some(event_binding) = on_select_event { + if self.handler_registry.is_some() { + let handler_name = event_binding.handler.clone(); + + #[cfg(debug_assertions)] + eprintln!( + "[DampenWidgetBuilder] TabBar: Attaching on_select with handler '{}'", + handler_name + ); + + iced_aw::TabBar::new(move |idx: usize| { + HandlerMessage::Handler(handler_name.clone(), Some(idx.to_string())) + }) + } else { + iced_aw::TabBar::new(|_idx: usize| { + HandlerMessage::Handler("noop".to_string(), None) + }) + } + } else { + iced_aw::TabBar::new(|_idx: usize| HandlerMessage::Handler("noop".to_string(), None)) + }; + + // Set active tab + tab_bar = tab_bar.set_active_tab(&selected_index); + + // Apply icon_size if specified + if let Some(icon_size_attr) = node.attributes.get("icon_size") { + let icon_size_value = self.evaluate_attribute(icon_size_attr); + if let Ok(icon_size) = icon_size_value.parse::() { + tab_bar = tab_bar.icon_size(icon_size); + } + } + + // Apply text_size if specified + if let Some(text_size_attr) = node.attributes.get("text_size") { + let text_size_value = self.evaluate_attribute(text_size_attr); + if let Ok(text_size) = text_size_value.parse::() { + tab_bar = tab_bar.text_size(text_size); + } + } + + // T045: Apply spacing if specified + if let Some(spacing_attr) = node.attributes.get("spacing") { + let spacing_value = self.evaluate_attribute(spacing_attr); + if let Ok(spacing) = spacing_value.parse::() { + tab_bar = tab_bar.spacing(iced::Pixels(spacing)); + } + } + + // T045: Apply padding if specified + if let Some(padding_attr) = node.attributes.get("padding") { + let padding_value = self.evaluate_attribute(padding_attr); + // Parse padding - can be a single value or four values (top right bottom left) + let padding = parse_padding(&padding_value); + tab_bar = tab_bar.padding(padding); + } + + // T045: Apply width if specified + if let Some(width_attr) = node.attributes.get("width") { + let width_value = self.evaluate_attribute(width_attr); + let length = parse_length(&width_value); + tab_bar = tab_bar.width(length); + } + + // T045: Apply height if specified + if let Some(height_attr) = node.attributes.get("height") { + let height_value = self.evaluate_attribute(height_attr); + let length = parse_length(&height_value); + tab_bar = tab_bar.height(length); + } + + // Add tabs + for (idx, label) in tab_labels.into_iter().enumerate() { + tab_bar = tab_bar.push(idx, label); + } + + // Apply custom style to highlight selected tab + tab_bar = tab_bar.style(move |theme: &iced::Theme, status| { + let base_style = iced_aw::style::tab_bar::Style::default(); + + match status { + iced_aw::style::Status::Selected => iced_aw::style::tab_bar::Style { + tab_label_background: iced::Background::Color(theme.palette().primary), + tab_label_border_color: theme.palette().primary, + text_color: iced::Color::WHITE, + icon_color: iced::Color::WHITE, + ..base_style + }, + _ => base_style, + } + }); + + // Build content column for the selected tab + // We need to move the content widgets out of tab_contents since Element doesn't implement Clone + let content_element: Element<'a, HandlerMessage, Theme, Renderer> = + if selected_index < tab_contents.len() { + let content_widgets = std::mem::take(&mut tab_contents[selected_index]); + match content_widgets.len() { + 0 => iced::widget::column![].into(), + 1 => content_widgets + .into_iter() + .next() + .unwrap_or_else(|| iced::widget::column![].into()), + _ => iced::widget::Column::with_children(content_widgets).into(), + } + } else { + iced::widget::column![].into() + }; + + // Combine TabBar and content in a column + let result = iced::widget::column![tab_bar, content_element]; + + result.into() + } + + /// Build a TabLabel from a Tab widget node + fn build_tab_label(&self, node: &WidgetNode) -> iced_aw::tab_bar::TabLabel { + // Get label text + let label_text = node + .attributes + .get("label") + .map(|attr| self.evaluate_attribute(attr)); + + // Get icon if specified + let icon_char = node.attributes.get("icon").map(|attr| { + let icon_name = self.evaluate_attribute(attr); + resolve_icon(&icon_name) + }); + + // Build TabLabel based on what we have + match (icon_char, label_text) { + (Some(icon), Some(label)) => { + // Both icon and label + iced_aw::tab_bar::TabLabel::IconText(icon, label) + } + (Some(icon), None) => { + // Icon only + iced_aw::tab_bar::TabLabel::Icon(icon) + } + (None, Some(label)) => { + // Text only + iced_aw::tab_bar::TabLabel::Text(label) + } + (None, None) => { + // Default fallback + iced_aw::tab_bar::TabLabel::Text("Tab".to_string()) + } + } + } +} diff --git a/crates/dampen-iced/src/lib.rs b/crates/dampen-iced/src/lib.rs index e33a35a..1157d23 100755 --- a/crates/dampen-iced/src/lib.rs +++ b/crates/dampen-iced/src/lib.rs @@ -528,7 +528,9 @@ pub fn render<'a>( | WidgetKind::DataTable | WidgetKind::DataColumn | WidgetKind::TreeView - | WidgetKind::TreeNode => backend.column(Vec::new()), + | WidgetKind::TreeNode + | WidgetKind::TabBar + | WidgetKind::Tab => backend.column(Vec::new()), } } diff --git a/crates/dampen-iced/tests/builder_tab_bar.rs b/crates/dampen-iced/tests/builder_tab_bar.rs new file mode 100644 index 0000000..4881ef2 --- /dev/null +++ b/crates/dampen-iced/tests/builder_tab_bar.rs @@ -0,0 +1,122 @@ +//! Builder tests for TabBar and Tab widgets + +use dampen_core::binding::{BindingValue, UiBindable}; +use dampen_core::{HandlerRegistry, parse}; +use dampen_iced::{DampenWidgetBuilder, HandlerMessage}; +use iced::{Element, Renderer, Theme}; + +/// Simple test model +#[derive(Clone)] +struct TestModel { + selected_tab: usize, +} + +impl UiBindable for TestModel { + fn get_field(&self, path: &[&str]) -> Option { + match path { + ["selected_tab"] => Some(BindingValue::Integer(self.selected_tab as i64)), + _ => None, + } + } + + fn available_fields() -> Vec { + vec!["selected_tab".to_string()] + } +} + +fn create_model() -> TestModel { + TestModel { selected_tab: 0 } +} + +fn create_registry() -> HandlerRegistry { + HandlerRegistry::new() +} + +#[test] +fn test_tab_bar_construction_with_selected_attribute() { + let xml = r#" + + + + + + + + "#; + let doc = parse(xml).unwrap(); + let model = create_model(); + let registry = create_registry(); + + let builder = DampenWidgetBuilder::new(&doc, &model, Some(®istry)); + let _element: Element<'_, HandlerMessage, Theme, Renderer> = builder.build(); +} + +#[test] +fn test_tab_bar_with_binding() { + let xml = r#" + + + + + + + "#; + let doc = parse(xml).unwrap(); + let model = create_model(); + let registry = create_registry(); + + let builder = DampenWidgetBuilder::new(&doc, &model, Some(®istry)); + let _element: Element<'_, HandlerMessage, Theme, Renderer> = builder.build(); +} + +#[test] +fn test_tab_bar_on_select_event_handler() { + let xml = r#" + + + + + + + "#; + let doc = parse(xml).unwrap(); + let model = create_model(); + let registry = create_registry(); + + let builder = DampenWidgetBuilder::new(&doc, &model, Some(®istry)); + let _element: Element<'_, HandlerMessage, Theme, Renderer> = builder.build(); +} + +#[test] +fn test_tab_bar_empty() { + let xml = r#" + + + + + "#; + let doc = parse(xml).unwrap(); + let model = create_model(); + let registry = create_registry(); + + let builder = DampenWidgetBuilder::new(&doc, &model, Some(®istry)); + let _element: Element<'_, HandlerMessage, Theme, Renderer> = builder.build(); +} + +#[test] +fn test_tab_bar_without_handler() { + let xml = r#" + + + + + + + "#; + let doc = parse(xml).unwrap(); + let model = create_model(); + let registry = create_registry(); + + let builder = DampenWidgetBuilder::new(&doc, &model, Some(®istry)); + let _element: Element<'_, HandlerMessage, Theme, Renderer> = builder.build(); +} diff --git a/crates/dampen-iced/tests/builder_tab_icons.rs b/crates/dampen-iced/tests/builder_tab_icons.rs new file mode 100644 index 0000000..49bf1ec --- /dev/null +++ b/crates/dampen-iced/tests/builder_tab_icons.rs @@ -0,0 +1,166 @@ +//! Builder tests for Tab icons + +use dampen_core::binding::{BindingValue, UiBindable}; +use dampen_core::{HandlerRegistry, parse}; +use dampen_iced::{DampenWidgetBuilder, HandlerMessage}; +use iced::{Element, Renderer, Theme}; + +/// Simple test model +#[derive(Clone)] +struct TestModel { + selected_tab: usize, +} + +impl UiBindable for TestModel { + fn get_field(&self, path: &[&str]) -> Option { + match path { + ["selected_tab"] => Some(BindingValue::Integer(self.selected_tab as i64)), + _ => None, + } + } + + fn available_fields() -> Vec { + vec!["selected_tab".to_string()] + } +} + +fn create_model() -> TestModel { + TestModel { selected_tab: 0 } +} + +fn create_registry() -> HandlerRegistry { + HandlerRegistry::new() +} + +#[test] +fn test_icon_resolution_all_supported_icons() { + // Test all 10 supported icons + let icons = vec![ + ("home", '\u{F015}'), + ("settings", '\u{F013}'), + ("user", '\u{F007}'), + ("search", '\u{F002}'), + ("add", '\u{F067}'), + ("delete", '\u{F1F8}'), + ("edit", '\u{F044}'), + ("save", '\u{F0C7}'), + ("close", '\u{F00D}'), + ("back", '\u{F060}'), + ("forward", '\u{F061}'), + ]; + + for (icon_name, expected_unicode) in icons { + let xml = format!( + r#" + + + + + "#, + icon_name + ); + + let doc = parse(&xml).unwrap(); + let model = create_model(); + let registry = create_registry(); + + let builder = DampenWidgetBuilder::new(&doc, &model, Some(®istry)); + let _element: Element<'_, HandlerMessage, Theme, Renderer> = builder.build(); + + // The icon should be resolved correctly during build + // We verify the build succeeds without errors + } +} + +#[test] +fn test_tab_label_icon_text_construction() { + let xml = r#" + + + + + + + "#; + + let doc = parse(xml).unwrap(); + let model = create_model(); + let registry = create_registry(); + + let builder = DampenWidgetBuilder::new(&doc, &model, Some(®istry)); + let _element: Element<'_, HandlerMessage, Theme, Renderer> = builder.build(); +} + +#[test] +fn test_tab_with_only_icon_no_label() { + let xml = r#" + + + + + + + "#; + + let doc = parse(xml).unwrap(); + let model = create_model(); + let registry = create_registry(); + + let builder = DampenWidgetBuilder::new(&doc, &model, Some(®istry)); + let _element: Element<'_, HandlerMessage, Theme, Renderer> = builder.build(); +} + +#[test] +fn test_unknown_icon_fallback() { + // Unknown icons should fallback to circle (\u{F111}) + let xml = r#" + + + + + + "#; + + let doc = parse(xml).unwrap(); + let model = create_model(); + let registry = create_registry(); + + let builder = DampenWidgetBuilder::new(&doc, &model, Some(®istry)); + let _element: Element<'_, HandlerMessage, Theme, Renderer> = builder.build(); +} + +#[test] +fn test_icon_size_attribute() { + let xml = r#" + + + + + + "#; + + let doc = parse(xml).unwrap(); + let model = create_model(); + let registry = create_registry(); + + let builder = DampenWidgetBuilder::new(&doc, &model, Some(®istry)); + let _element: Element<'_, HandlerMessage, Theme, Renderer> = builder.build(); +} + +#[test] +fn test_text_size_attribute() { + let xml = r#" + + + + + + "#; + + let doc = parse(xml).unwrap(); + let model = create_model(); + let registry = create_registry(); + + let builder = DampenWidgetBuilder::new(&doc, &model, Some(®istry)); + let _element: Element<'_, HandlerMessage, Theme, Renderer> = builder.build(); +} diff --git a/docs/STYLING.md b/docs/STYLING.md index 6e6ed35..79aaccb 100755 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -892,6 +892,107 @@ All style properties can have state variants using either syntax: - `disabled_state_opacity`, etc. - Combined: `hover_state_active_state_background`, etc. +### Widget-Specific Styling + +#### Progress Bar Styling + +The `progress_bar` widget supports extensive customization: + +**Predefined Styles:** +```xml + + + + + + +``` + +**Custom Colors:** +```xml + + + + + + + + +``` + +**Border Radius (Rounded Corners):** +```xml + + + + + + + + +``` + +**Custom Height:** +```xml + + + + + + + + +``` + +**Complete Examples:** +```xml + + + + + + + + +``` + +**Color Formats Supported:** +- Hex: `#RRGGBB` (e.g., `#FF5733`) +- Hex with alpha: `#RRGGBBAA` (e.g., `#FF573380`) +- RGB: `rgb(r, g, b)` (e.g., `rgb(255, 87, 51)`) +- RGBA: `rgba(r, g, b, a)` (e.g., `rgba(255, 87, 51, 0.5)`) + +**Note:** Custom colors (`bar_color`, `background_color`) override the predefined `style` colors. If both are specified, custom colors take precedence. + ### Layout Attributes - `width`, `height` - dimensions (pixels, fill, shrink, fill_portion(n), %) diff --git a/docs/XML_SCHEMA.md b/docs/XML_SCHEMA.md index d1f84ba..5b896e9 100755 --- a/docs/XML_SCHEMA.md +++ b/docs/XML_SCHEMA.md @@ -398,6 +398,47 @@ Individual radio button widget. | `min` | number | 0 | Minimum value | | `max` | number | 100 | Maximum value | | `value` | number/binding | 0 | Current progress | +| `style` | string | primary | Predefined style: `primary`, `success`, `warning`, `danger`, `secondary` | +| `bar_color` | color | - | Custom bar color (overrides style). Formats: `#RRGGBB`, `#RRGGBBAA`, `rgb(r,g,b)`, `rgba(r,g,b,a)` | +| `background_color` | color | - | Custom background color. Formats: `#RRGGBB`, `#RRGGBBAA`, `rgb(r,g,b)`, `rgba(r,g,b,a)` | +| `border_radius` | number | 0 | Corner radius in pixels | +| `height` | number | - | Bar height in pixels (girth) | + +**Style Attribute:** +The `style` attribute provides predefined color schemes from the theme palette: +- `primary` - Primary theme color +- `success` - Green/success color +- `warning` - Orange/warning color +- `danger` - Red/danger color +- `secondary` - Secondary theme color + +**Custom Styling:** +When `bar_color` or `background_color` are specified, they override the style colors: + +```xml + + + + + + + + + + + +``` ### `` - Iteration Widget @@ -889,6 +930,86 @@ TreeNode { --- +### `` - Tab Bar Widget + +A tab bar widget that displays a row of selectable tabs with associated content. + +```xml + + + + + + + + + + + + + + + + + + + + +``` + +**Attributes:** +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `selected` | number/binding | **required** | Index of the currently selected tab (0-based) | +| `icon_size` | number | 20 | Size of tab icons in pixels | +| `text_size` | number | 14 | Size of tab text in pixels | +| `spacing` | number | 0 | Spacing between tabs | +| `padding` | length/box | 0 | Padding around the tab bar | +| `width` | length | auto | Width constraint | +| `height` | length | auto | Height constraint | +| `class` | string | - | CSS class for styling | + +**Events:** +| Event | Description | +|-------|-------------| +| `on_select` | Called when a tab is selected, receives the tab index | + +**Child Elements:** + +The `` must contain one or more `` elements as children. + +### `` - Tab Item + +Individual tab within a ``. Each tab defines its label, optional icon, and content. + +```xml + + + + + + +``` + +**Attributes:** +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `label` | string | **required** | Tab label text | +| `icon` | string | - | Icon name (e.g., "settings", "user", "home") | +| `enabled` | bool | true | Whether the tab is clickable | + +**Content:** + +Each `` can contain any number of child widgets. The content of the selected tab is automatically displayed below the tab bar. + +**Notes:** +- The `selected` attribute of `` should be bound to a numeric field in your model +- When `on_select` is triggered, update the bound field to change the displayed tab +- Tab content is rendered dynamically based on the selected index +- Icons are resolved using the framework's icon mapping system + +--- + ### `` - Conditional Rendering Conditionally renders content based on a condition. diff --git a/examples/macro-shared-state/src/main.rs b/examples/macro-shared-state/src/main.rs index dc783b4..4a126fb 100755 --- a/examples/macro-shared-state/src/main.rs +++ b/examples/macro-shared-state/src/main.rs @@ -10,7 +10,6 @@ // Allow cfg warnings from dampen_ui macro (internal feature checks) #![allow(unexpected_cfgs)] - mod shared; // Ensure codegen and interpreted are mutually exclusive @@ -48,6 +47,8 @@ enum Message { DismissError, /// System theme change SystemThemeChanged(String), + // Window persistence: + Window(iced::window::Id, iced::window::Event), } /// Main application structure with auto-generated view management (interpreted mode) @@ -62,6 +63,8 @@ enum Message { exclude = ["theme/*"], switch_view_variant = "SwitchToView", default_view = "window", + persistence = true, + app_name = "macro-shared-state", shared_model = "SharedState" // ← The magic happens here! )] struct MacroSharedStateApp; diff --git a/examples/widget-showcase/README.md b/examples/widget-showcase/README.md index 85df722..16e125b 100755 --- a/examples/widget-showcase/README.md +++ b/examples/widget-showcase/README.md @@ -13,45 +13,55 @@ This example serves as a reference implementation and testing ground for all Dam The following widget examples are available in the `ui/` directory: #### ProgressBar (`ui/progressbar.dampen`) + Demonstrates progress indicators with different styles and value ranges. **Features**: + - Multiple style variants (primary, success, warning, danger, secondary) - Custom value ranges - Percentage display - Value clamping behavior #### Tooltip (`ui/tooltip.dampen`) + Shows contextual help text on hover with different positioning options. **Features**: + - Multiple position variants (top, bottom, left, right, follow_cursor) - Custom delay settings - Wrapping different widget types - Hover interaction #### Canvas (`ui/canvas.dampen`) + Custom drawing surface for graphics and visualizations. **Features**: + - Custom `canvas::Program` implementation - Drawing primitives (paths, fills, strokes) - Interactive click handling - Real-time rendering #### PickList (`ui/picklist.dampen`) + Dropdown selection from a list of options. **Features**: + - Static option lists - Selected value binding - Event handling on selection change - Placeholder text #### ComboBox (`ui/combobox.dampen`) + Searchable dropdown with type-ahead functionality. **Features**: + - Search filtering - Dynamic option list - Selected value binding @@ -60,9 +70,11 @@ Searchable dropdown with type-ahead functionality. **Note**: ComboBox rendering is not yet implemented. This file demonstrates the XML syntax. #### Float (`ui/float.dampen`) + Positioned overlay elements like floating action buttons. **Features**: + - Corner positioning (top-left, top-right, bottom-left, bottom-right) - Custom offset control - Z-index layering @@ -71,9 +83,11 @@ Positioned overlay elements like floating action buttons. **Note**: Float rendering is not yet implemented. This file demonstrates the XML syntax. #### Grid (`ui/grid.dampen`) + Multi-column responsive layout. **Features**: + - Configurable column count - Automatic wrapping - Spacing and padding @@ -95,6 +109,7 @@ The application will display examples of all implemented widgets. ### Running in Different Modes **Development Mode (Interpreted with Hot-Reload):** + ```bash cd examples/widget-showcase dampen run @@ -103,6 +118,7 @@ dampen run The UI will reload automatically when you modify `.dampen` files. **Production Mode (Codegen):** + ```bash # Debug build dampen build -p widget-showcase @@ -117,6 +133,7 @@ dampen release -p widget-showcase ``` **Framework Development (using cargo directly):** + ```bash # Interpreted mode cargo run -p widget-showcase @@ -219,7 +236,6 @@ When implementing new widget renderers: ## References - [Dampen Documentation](../../docs/) -- [Widget XML Schema](../../specs/004-advanced-widgets-todo/contracts/xml-schema.md) - [Iced Widget Documentation](https://docs.rs/iced/latest/iced/widget/) ## License diff --git a/examples/widget-showcase/src/ui/mod.rs b/examples/widget-showcase/src/ui/mod.rs index ebdd53a..784746d 100755 --- a/examples/widget-showcase/src/ui/mod.rs +++ b/examples/widget-showcase/src/ui/mod.rs @@ -22,6 +22,7 @@ pub mod slider; pub mod space; pub mod stack; pub mod svg; +pub mod tab_bar; pub mod text; pub mod textinput; pub mod toggler; diff --git a/examples/widget-showcase/src/ui/progressbar.dampen b/examples/widget-showcase/src/ui/progressbar.dampen index 47632f4..1dbf711 100755 --- a/examples/widget-showcase/src/ui/progressbar.dampen +++ b/examples/widget-showcase/src/ui/progressbar.dampen @@ -1,35 +1,55 @@ - -