diff --git a/Cargo.lock b/Cargo.lock index 34372f54224..52c33952c2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1402,6 +1402,14 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "conditional-tabs" +version = "1.17.0" +dependencies = [ + "slint", + "slint-build", +] + [[package]] name = "condtype" version = "1.3.0" @@ -2323,6 +2331,14 @@ dependencies = [ "wio", ] +[[package]] +name = "editable-tabs" +version = "1.17.0" +dependencies = [ + "slint", + "slint-build", +] + [[package]] name = "eeprom24x" version = "0.7.2" diff --git a/examples/gallery/gallery.slint b/examples/gallery/gallery.slint index 7aa6246c8a0..35f01f256c6 100644 --- a/examples/gallery/gallery.slint +++ b/examples/gallery/gallery.slint @@ -2,11 +2,11 @@ // SPDX-License-Identifier: MIT import { CheckBox, StandardListView } from "std-widgets.slint"; -import { AboutPage, ControlsPage, EasingsPage, ListViewPage, StyledTextPage, TableViewPage, TableViewPageAdapter, TextEditPage } from "ui/pages/pages.slint"; +import { AboutPage, ControlsPage, ControlsPageLogic, EasingsPage, ListViewPage, StyledTextPage, TableViewPage, TableViewPageAdapter, TextEditPage } from "ui/pages/pages.slint"; import { GallerySettings } from "ui/gallery_settings.slint"; import { SideBar } from "ui/side_bar.slint"; -export { TableViewPageAdapter } +export { TableViewPageAdapter, ControlsPageLogic } export component App inherits Window { preferred-width: 700px; diff --git a/examples/gallery/main.rs b/examples/gallery/main.rs index b1013c52cbd..b2ccc59568f 100644 --- a/examples/gallery/main.rs +++ b/examples/gallery/main.rs @@ -93,10 +93,20 @@ pub fn main() { app.global::().set_row_data(row_data.clone().into()); app.global::().on_filter_sort_model(filter_sort_model); + app.global::().on_split_string(split_string); app.run().unwrap(); } +fn split_string(input: slint::SharedString) -> slint::ModelRc { + let items: Vec = input + .split(',') + .map(|s| slint::SharedString::from(s.trim())) + .filter(|s| !s.is_empty()) + .collect(); + slint::ModelRc::new(slint::VecModel::from(items)) +} + fn filter_sort_model( source_model: ModelRc>, filter: SharedString, diff --git a/examples/gallery/ui/pages/controls_page.slint b/examples/gallery/ui/pages/controls_page.slint index 51b838102b1..e62729d1d11 100644 --- a/examples/gallery/ui/pages/controls_page.slint +++ b/examples/gallery/ui/pages/controls_page.slint @@ -6,6 +6,10 @@ import { Button, GroupBox, SpinBox, ComboBox, CheckBox, LineEdit, TabWidget, Ver import { GallerySettings } from "../gallery_settings.slint"; import { Page } from "page.slint"; +export global ControlsPageLogic { + callback split-string(string) -> [string]; +} + export component ControlsPage inherits Page { title: @tr("Controls"); show-enable-read-only: true; @@ -188,58 +192,117 @@ export component ControlsPage inherits Page { } } - GroupBox { + tabGroup := GroupBox { title: @tr("TabWidget"); - TabWidget { - Tab { - title: @tr("Tab 1"); + in-out property show-tab-1: true; + in-out property show-tab-2: true; + in-out property show-tab-3: true; + + VerticalBox { + HorizontalBox { + padding-top: 10px; + padding-bottom: 10px; + + CheckBox { + text: "Show Tab 1"; + checked <=> tabGroup.show-tab-1; + } + CheckBox { + text: "Show Tab 2"; + checked <=> tabGroup.show-tab-2; + } + CheckBox { + text: "Show Tab 3"; + checked <=> tabGroup.show-tab-3; + } + } - VerticalBox { - alignment: start; + TabWidget { + if tabGroup.show-tab-1: Tab { + title: @tr("Tab 1"); - GroupBox { - title: @tr("Content of tab 1"); + VerticalBox { + alignment: start; - HorizontalBox { - alignment: start; + GroupBox { + title: @tr("Content of tab 1"); - Button { - text: @tr("Click me"); - enabled: GallerySettings.widgets-enabled; + HorizontalBox { + alignment: start; + + Button { + text: @tr("Click me"); + enabled: GallerySettings.widgets-enabled; + } } } } } - } - Tab { - title: @tr("Tab 2"); + if tabGroup.show-tab-2: Tab { + title: @tr("Tab 2"); - VerticalBox { - alignment: start; + VerticalBox { + alignment: start; - GroupBox { - title: @tr("Content of tab 2"); + GroupBox { + title: @tr("Content of tab 2"); - VerticalBox { - alignment: start; + VerticalBox { + alignment: start; - CheckBox { - text: @tr("Check me"); - enabled: GallerySettings.widgets-enabled; + CheckBox { + text: @tr("Check me"); + enabled: GallerySettings.widgets-enabled; + } } } } } + + if tabGroup.show-tab-3: Tab { + title: @tr("Tab 3"); + + VerticalBox { + Text { + text: @tr("Content of tab 3"); + } + } + } } + } + + editableTabs := VerticalBox { + in-out property tab-text: "Alpha,Beta,Gamma"; + in-out property <[string]> tab-names: ["Alpha", "Beta", "Gamma"]; + + HorizontalBox { + padding-top: 10px; + padding-bottom: 10px; - Tab { - title: @tr("Tab 3"); + Text { + text: "Tabs (comma separated):"; + vertical-alignment: center; + } + LineEdit { + text <=> editableTabs.tab-text; + edited(value) => { + editableTabs.tab-names = ControlsPageLogic.split-string(value); + } + } + } - VerticalBox { - Text { - text: @tr("Content of tab 3"); + TabWidget { + for tab-name[idx] in editableTabs.tab-names: Tab { + title: tab-name; + VerticalBox { + alignment: center; + Text { + text: tab-name; + font-size: 32px; + horizontal-alignment: center; + } } } } diff --git a/examples/gallery/ui/pages/pages.slint b/examples/gallery/ui/pages/pages.slint index 1c647a14203..6f98ed6dca6 100644 --- a/examples/gallery/ui/pages/pages.slint +++ b/examples/gallery/ui/pages/pages.slint @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT export { AboutPage } from "about_page.slint"; -export { ControlsPage } from "controls_page.slint"; +export { ControlsPage, ControlsPageLogic } from "controls_page.slint"; export { EasingsPage } from "easings_page.slint"; export { ListViewPage } from "list_view_page.slint"; export { StyledTextPage } from "styled_text_page.slint"; diff --git a/flake.nix b/flake.nix index 45963575c8a..bb24110f3c4 100644 --- a/flake.nix +++ b/flake.nix @@ -25,6 +25,7 @@ in mkShell { nativeBuildInputs = [ + cargo pkg-config perf ]; diff --git a/internal/compiler/passes/lower_tabwidget.rs b/internal/compiler/passes/lower_tabwidget.rs index 7f761ffd81b..745c852711e 100644 --- a/internal/compiler/passes/lower_tabwidget.rs +++ b/internal/compiler/passes/lower_tabwidget.rs @@ -6,7 +6,9 @@ //! This pass lowers the TabWidget to create the tabbar. //! //! Must be done before inlining and many other passes because the lowered code must -//! be further inlined as it may expand to a native widget that needs inlining +//! be further inlined as it may expand to a native widget that needs inlining. +//! +//! Supports both static tabs (defined inline) and dynamic tabs (using `for` or `if`). use crate::diagnostics::BuildDiagnostics; use crate::expression_tree::{BindingExpression, Expression, MinMaxOp, NamedReference, Unit}; @@ -14,6 +16,7 @@ use crate::langtype::{ElementType, Type}; use crate::object_tree::*; use smol_str::{SmolStr, format_smolstr}; use std::cell::RefCell; +use std::rc::Rc; pub async fn lower_tabwidget( doc: &Document, @@ -71,6 +74,90 @@ pub async fn lower_tabwidget( }); } +/// Represents the contribution of a child to the tab count and its starting offset +struct TabChildInfo { + /// Expression for the starting index of this child's tabs + offset_expr: Expression, +} + +/// Compute the offset expressions for each child and the total num-tabs expression. +/// Static tabs contribute 1, repeated tabs contribute the model length. +fn compute_tab_offsets(children: &[ElementRc]) -> (Vec, Expression) { + let mut infos = Vec::new(); + let mut cumulative: Option = None; + + for child in children { + let offset = cumulative.clone().unwrap_or(Expression::NumberLiteral(0., Unit::None)); + + let count = if let Some(repeated) = &child.borrow().repeated { + if repeated.is_conditional_element { + // if condition: contributes 0 or 1 + Expression::Condition { + condition: Box::new(repeated.model.clone()), + true_expr: Box::new(Expression::NumberLiteral(1., Unit::None)), + false_expr: Box::new(Expression::NumberLiteral(0., Unit::None)), + } + } else { + // for loop: contributes model.length() + Expression::FunctionCall { + function: crate::expression_tree::Callable::Builtin( + crate::expression_tree::BuiltinFunction::ArrayLength, + ), + arguments: vec![repeated.model.clone()], + source_location: None, + } + } + } else { + // Static tab: contributes exactly 1 + Expression::NumberLiteral(1., Unit::None) + }; + + cumulative = Some(if let Some(prev) = cumulative { + Expression::BinaryExpression { + lhs: Box::new(prev), + rhs: Box::new(count.clone()), + op: '+', + } + } else { + count.clone() + }); + + infos.push(TabChildInfo { offset_expr: offset }); + } + + let total = cumulative.unwrap_or(Expression::NumberLiteral(0., Unit::None)); + (infos, total) +} + +/// Clone an expression, replacing any RepeaterModelReference or RepeaterIndexReference +/// that points to `from_elem` with one that points to `to_elem`. +/// This is used to duplicate a binding expression from a content repeater to a tabbar repeater. +fn remap_repeater_references( + expr: &Expression, + from_elem: &ElementRc, + to_elem: &ElementRc, +) -> Expression { + match expr { + Expression::RepeaterModelReference { element } + if element.upgrade().is_some_and(|e| Rc::ptr_eq(&e, from_elem)) => + { + Expression::RepeaterModelReference { element: Rc::downgrade(to_elem) } + } + Expression::RepeaterIndexReference { element } + if element.upgrade().is_some_and(|e| Rc::ptr_eq(&e, from_elem)) => + { + Expression::RepeaterIndexReference { element: Rc::downgrade(to_elem) } + } + _ => { + let mut cloned = expr.clone(); + cloned.visit_mut(|sub| { + *sub = remap_repeater_references(sub, from_elem, to_elem); + }); + cloned + } + } +} + fn process_tabwidget( elem: &ElementRc, tabwidget_impl: ElementType, @@ -87,37 +174,68 @@ fn process_tabwidget( elem.borrow_mut().base_type = tabwidget_impl; let mut children = std::mem::take(&mut elem.borrow_mut().children); - let num_tabs = children.len(); - let mut tabs = Vec::new(); - for child in &mut children { - if child.borrow().repeated.is_some() { - diag.push_error( - "dynamic tabs ('if' or 'for') are currently not supported".into(), - &*child.borrow(), - ); - continue; - } - if child.borrow().base_type.to_string() != "Tab" { - assert!(diag.has_errors()); - continue; + + // Validate that all children are Tabs + children.retain(|child| { + let base = child.borrow().base_type.to_string(); + if base != "Tab" { + // If it has errors already, just skip + if !diag.has_errors() { + diag.push_error( + "Only Tab elements are allowed inside a TabWidget".into(), + &*child.borrow(), + ); + } + false + } else { + true } - let index = tabs.len(); + }); + + let (tab_infos, num_tabs_expr) = compute_tab_offsets(&children); + + let mut tabs = Vec::new(); + + for (child_idx, child) in children.iter_mut().enumerate() { + let info = &tab_infos[child_idx]; + let is_repeated = child.borrow().repeated.is_some(); + + // Transform the Tab into a content pane child.borrow_mut().base_type = empty_type.clone(); child .borrow_mut() .property_declarations .insert(SmolStr::new_static("title"), Type::String.into()); + set_geometry_prop(elem, child, "x", diag); set_geometry_prop(elem, child, "y", diag); set_geometry_prop(elem, child, "width", diag); set_geometry_prop(elem, child, "height", diag); + + // Set visibility: current-index == (offset + repeater_index) for for-loops, + // current-index == offset for static tabs and conditionals (which have at most 1 instance) + let is_conditional = + child.borrow().repeated.as_ref().is_some_and(|r| r.is_conditional_element); + let index_expr = if is_repeated && !is_conditional { + // For `for` repeated elements, the index within the repeater is available via + // RepeaterIndexReference. The absolute index is offset + repeater_index. + Expression::BinaryExpression { + lhs: Box::new(info.offset_expr.clone()), + rhs: Box::new(Expression::RepeaterIndexReference { element: Rc::downgrade(child) }), + op: '+', + } + } else { + // Static tabs and conditional tabs (which contribute at most 1 tab at offset) + info.offset_expr.clone() + }; + let condition = Expression::BinaryExpression { lhs: Expression::PropertyReference(NamedReference::new( elem, SmolStr::new_static("current-index"), )) .into(), - rhs: Expression::NumberLiteral(index as _, Unit::None).into(), + rhs: Box::new(index_expr.clone()), op: '=', }; let old = child @@ -130,6 +248,7 @@ fn process_tabwidget( &old.into_inner(), ); } + let role = crate::typeregister::BUILTIN .with(|e| e.enums.AccessibleRole.clone()) .try_value_from_string("tab-panel") @@ -145,6 +264,7 @@ fn process_tabwidget( &old.into_inner(), ); } + let title_ref = RefCell::new( Expression::PropertyReference(NamedReference::new(child, "title".into())).into(), ); @@ -157,42 +277,164 @@ fn process_tabwidget( ); } - let mut tab = Element { - id: format_smolstr!("{}-tab{}", elem.borrow().id, index), - base_type: tab_impl.clone(), - enclosing_component: elem.borrow().enclosing_component.clone(), - ..Default::default() - }; - tab.bindings.insert( - SmolStr::new_static("title"), - BindingExpression::new_two_way( - NamedReference::new(child, SmolStr::new_static("title")).into(), - ) - .into(), - ); - tab.bindings.insert( - SmolStr::new_static("current"), - BindingExpression::new_two_way( - NamedReference::new(elem, SmolStr::new_static("current-index")).into(), - ) - .into(), - ); - tab.bindings.insert( - SmolStr::new_static("current-focused"), - BindingExpression::new_two_way( - NamedReference::new(elem, SmolStr::new_static("current-focused")).into(), - ) - .into(), - ); - tab.bindings.insert( - SmolStr::new_static("tab-index"), - RefCell::new(Expression::NumberLiteral(index as _, Unit::None).into()), - ); - tab.bindings.insert( - SmolStr::new_static("num-tabs"), - RefCell::new(Expression::NumberLiteral(num_tabs as _, Unit::None).into()), - ); - tabs.push(Element::make_rc(tab)); + // Create the corresponding tab bar item(s) + if is_repeated && !is_conditional { + // For `for` repeated tabs, create a repeated TabImpl element with the same model + let repeated_info = child.borrow().repeated.as_ref().unwrap().clone(); + + let tab = Element { + id: format_smolstr!("{}-tab-repeated{}", elem.borrow().id, child_idx), + base_type: tab_impl.clone(), + enclosing_component: elem.borrow().enclosing_component.clone(), + repeated: Some(RepeatedElementInfo { + model: repeated_info.model.clone(), + model_data_id: repeated_info.model_data_id.clone(), + index_id: repeated_info.index_id.clone(), + is_conditional_element: false, + is_listview: None, + }), + ..Default::default() + }; + + let tab_rc = Element::make_rc(tab); + + // tab-index = offset + repeater_index + let tab_index_expr = Expression::BinaryExpression { + lhs: Box::new(info.offset_expr.clone()), + rhs: Box::new(Expression::RepeaterIndexReference { + element: Rc::downgrade(&tab_rc), + }), + op: '+', + }; + + // Clone the title binding from the content child and remap repeater + // references to point to the tabbar tab element instead. + let title_binding = + child.borrow().bindings.get("title").map(|b| b.borrow().expression.clone()); + if let Some(title_expr) = title_binding { + let remapped = remap_repeater_references(&title_expr, child, &tab_rc); + tab_rc + .borrow_mut() + .bindings + .insert(SmolStr::new_static("title"), RefCell::new(remapped.into())); + } + tab_rc.borrow_mut().bindings.insert( + SmolStr::new_static("current"), + BindingExpression::new_two_way( + NamedReference::new(elem, SmolStr::new_static("current-index")).into(), + ) + .into(), + ); + tab_rc.borrow_mut().bindings.insert( + SmolStr::new_static("current-focused"), + BindingExpression::new_two_way( + NamedReference::new(elem, SmolStr::new_static("current-focused")).into(), + ) + .into(), + ); + tab_rc + .borrow_mut() + .bindings + .insert(SmolStr::new_static("tab-index"), RefCell::new(tab_index_expr.into())); + tab_rc.borrow_mut().bindings.insert( + SmolStr::new_static("num-tabs"), + RefCell::new(num_tabs_expr.clone().into()), + ); + + tabs.push(tab_rc); + } else if is_conditional { + // For conditional (`if`) tabs, create a conditional TabImpl element + // with the same boolean model. The tab is at a fixed offset position. + let repeated_info = child.borrow().repeated.as_ref().unwrap().clone(); + + let tab = Element { + id: format_smolstr!("{}-tab-cond{}", elem.borrow().id, child_idx), + base_type: tab_impl.clone(), + enclosing_component: elem.borrow().enclosing_component.clone(), + repeated: Some(RepeatedElementInfo { + model: repeated_info.model.clone(), + model_data_id: repeated_info.model_data_id.clone(), + index_id: repeated_info.index_id.clone(), + is_conditional_element: true, + is_listview: None, + }), + ..Default::default() + }; + + let tab_rc = Element::make_rc(tab); + + // For a conditional, the title is a static expression (not model-dependent) + let title_binding = + child.borrow().bindings.get("title").map(|b| b.borrow().expression.clone()); + if let Some(title_expr) = title_binding { + tab_rc + .borrow_mut() + .bindings + .insert(SmolStr::new_static("title"), RefCell::new(title_expr.into())); + } + tab_rc.borrow_mut().bindings.insert( + SmolStr::new_static("current"), + BindingExpression::new_two_way( + NamedReference::new(elem, SmolStr::new_static("current-index")).into(), + ) + .into(), + ); + tab_rc.borrow_mut().bindings.insert( + SmolStr::new_static("current-focused"), + BindingExpression::new_two_way( + NamedReference::new(elem, SmolStr::new_static("current-focused")).into(), + ) + .into(), + ); + tab_rc.borrow_mut().bindings.insert( + SmolStr::new_static("tab-index"), + RefCell::new(info.offset_expr.clone().into()), + ); + tab_rc.borrow_mut().bindings.insert( + SmolStr::new_static("num-tabs"), + RefCell::new(num_tabs_expr.clone().into()), + ); + + tabs.push(tab_rc); + } else { + // Static tab + let mut tab = Element { + id: format_smolstr!("{}-tab{}", elem.borrow().id, child_idx), + base_type: tab_impl.clone(), + enclosing_component: elem.borrow().enclosing_component.clone(), + ..Default::default() + }; + tab.bindings.insert( + SmolStr::new_static("title"), + BindingExpression::new_two_way( + NamedReference::new(child, SmolStr::new_static("title")).into(), + ) + .into(), + ); + tab.bindings.insert( + SmolStr::new_static("current"), + BindingExpression::new_two_way( + NamedReference::new(elem, SmolStr::new_static("current-index")).into(), + ) + .into(), + ); + tab.bindings.insert( + SmolStr::new_static("current-focused"), + BindingExpression::new_two_way( + NamedReference::new(elem, SmolStr::new_static("current-focused")).into(), + ) + .into(), + ); + tab.bindings.insert( + SmolStr::new_static("tab-index"), + RefCell::new(info.offset_expr.clone().into()), + ); + tab.bindings.insert( + SmolStr::new_static("num-tabs"), + RefCell::new(num_tabs_expr.clone().into()), + ); + tabs.push(Element::make_rc(tab)); + } } let mut tabbar_impl = tabbar_horizontal_impl; @@ -222,10 +464,10 @@ fn process_tabwidget( set_tabbar_geometry_prop(elem, &tabbar, "y"); set_tabbar_geometry_prop(elem, &tabbar, "width"); set_tabbar_geometry_prop(elem, &tabbar, "height"); - tabbar.borrow_mut().bindings.insert( - SmolStr::new_static("num-tabs"), - RefCell::new(Expression::NumberLiteral(num_tabs as _, Unit::None).into()), - ); + tabbar + .borrow_mut() + .bindings + .insert(SmolStr::new_static("num-tabs"), RefCell::new(num_tabs_expr.clone().into())); tabbar.borrow_mut().bindings.insert( SmolStr::new_static("current"), BindingExpression::new_two_way( @@ -255,8 +497,12 @@ fn process_tabwidget( .into(), ); + // Only include static (non-repeated) children in content-min-width/height, + // because repeated/conditional elements become sub-components whose properties + // cannot be referenced directly from the parent component in the LLR. if let Some(expr) = children .iter() + .filter(|x| x.borrow().repeated.is_none()) .map(|x| { Expression::PropertyReference(NamedReference::new(x, SmolStr::new_static("min-width"))) }) @@ -266,6 +512,7 @@ fn process_tabwidget( }; if let Some(expr) = children .iter() + .filter(|x| x.borrow().repeated.is_none()) .map(|x| { Expression::PropertyReference(NamedReference::new(x, SmolStr::new_static("min-height"))) }) diff --git a/internal/compiler/tests/syntax/elements/tabwidget2.slint b/internal/compiler/tests/syntax/elements/tabwidget2.slint index 42f4a5301ab..dde7f574306 100644 --- a/internal/compiler/tests/syntax/elements/tabwidget2.slint +++ b/internal/compiler/tests/syntax/elements/tabwidget2.slint @@ -25,7 +25,6 @@ export Test2 := Rectangle { } if (true) : Tab { -// > +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +import { TabWidget } from "std-widgets.slint"; + +TestCase := Window { + preferred_height: 500px; + preferred_width: 500px; + + property show_optional_tab: true; + property current_tab: tw.current-index; + + VerticalLayout { + padding: 20px; + tw := TabWidget { + current-index: 0; + Tab { + title: "Always Visible"; + Rectangle { + background: yellow; + } + } + if show_optional_tab: Tab { + title: "Conditional"; + Rectangle { + background: green; + } + } + Tab { + title: "Also Always Visible"; + Rectangle { + background: blue; + } + } + } + } + + // With show_optional_tab true: 3 tabs total, current-index starts at 0 + out property test: tw.current-index == 0; +} diff --git a/tests/cases/elements/tabwidget_dynamic.slint b/tests/cases/elements/tabwidget_dynamic.slint new file mode 100644 index 00000000000..c459f1e8409 --- /dev/null +++ b/tests/cases/elements/tabwidget_dynamic.slint @@ -0,0 +1,32 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +import { TabWidget } from "std-widgets.slint"; + +TestCase := Window { + preferred_height: 500px; + preferred_width: 500px; + + property <[{name: string}]> tab_model: [ + { name: "First" }, + { name: "Second" }, + { name: "Third" }, + ]; + + property current_tab: tw.current-index; + + VerticalLayout { + padding: 20px; + tw := TabWidget { + current-index: 0; + for item[idx] in tab_model: Tab { + title: item.name; + Rectangle { + background: idx == 0 ? red : idx == 1 ? green : blue; + } + } + } + } + + out property test: tw.current-index == 0; +} diff --git a/tests/cases/elements/tabwidget_mixed.slint b/tests/cases/elements/tabwidget_mixed.slint new file mode 100644 index 00000000000..ef810d2d68d --- /dev/null +++ b/tests/cases/elements/tabwidget_mixed.slint @@ -0,0 +1,44 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +import { TabWidget } from "std-widgets.slint"; + +TestCase := Window { + preferred_height: 500px; + preferred_width: 500px; + + property <[{name: string}]> tab_model: [ + { name: "Dynamic1" }, + { name: "Dynamic2" }, + ]; + + property current_tab: tw.current-index; + + VerticalLayout { + padding: 20px; + tw := TabWidget { + current-index: 0; + Tab { + title: "Static First"; + Rectangle { + background: yellow; + } + } + for item[idx] in tab_model: Tab { + title: item.name; + Rectangle { + background: idx == 0 ? red : green; + } + } + Tab { + title: "Static Last"; + Rectangle { + background: blue; + } + } + } + } + + // 1 static + 2 dynamic + 1 static = 4 tabs total, current-index starts at 0 + out property test: tw.current-index == 0; +}