GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
Implement MultiControl widget, Popover menus, and standardize vertical text alignment across cce-ui
src/layout.rs | 5 +
src/widget/container/menu.rs | 2 +-
src/widget/display/graph.rs | 2 +-
src/widget/display/label.rs | 2 +-
src/widget/display/layout_preview.rs | 3 +-
src/widget/display/list_item.rs | 4 +-
src/widget/display/node.rs | 2 +-
src/widget/input/button.rs | 2 +-
src/widget/input/button_strip.rs | 2 +-
src/widget/input/checkbox.rs | 31 +-
src/widget/input/color_selector.rs | 2 +-
src/widget/input/dropdown.rs | 6 +-
src/widget/input/font_selector.rs | 4 +-
src/widget/input/mod.rs | 2 +
src/widget/input/multi_control.rs | 863 +++++++++++++++++++++++++++++++++++
src/widget/input/slider.rs | 2 +-
src/widget/input/spinbox.rs | 8 +-
src/widget/input/text_box.rs | 4 +-
src/widget/mod.rs | 2 +-
19 files changed, 922 insertions(+), 26 deletions(-)
diff --git a/src/layout.rs b/src/layout.rs
index 3a10b2d..f012f02 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -71,6 +71,11 @@ pub fn center_text_y(y: f32, container_h: f32, font_size: f32) -> f32 {
y + (container_h - line_height(font_size)) / 2.0
}
+/// Standardized vertical text alignment calculation based on Spinbox widget alignment.
+pub fn align_text_y(y: f32, height: f32, font_size: f32, top_offset: f32) -> f32 {
+ y + top_offset + (height - top_offset - font_size) / 2.0 - 2.0
+}
+
pub fn reload_config() {
if let Some(content) = read_config() {
let mut menubar_font_changed = false;
diff --git a/src/widget/container/menu.rs b/src/widget/container/menu.rs
index b22c436..eeb606e 100644
--- a/src/widget/container/menu.rs
+++ b/src/widget/container/menu.rs
@@ -829,7 +829,7 @@ impl Element for MenuBar {
}
}
if !self.title.is_empty() {
- let text_y = self.base.y + (self.base.h - font_size) / 2.0;
+ let text_y = crate::layout::align_text_y(self.base.y, self.base.h, font_size, 0.0);
labels.push(TextLabel {
text: display_title,
x: self.base.x + start_x,
diff --git a/src/widget/display/graph.rs b/src/widget/display/graph.rs
index c6dfa4a..e2dd97d 100644
--- a/src/widget/display/graph.rs
+++ b/src/widget/display/graph.rs
@@ -277,7 +277,7 @@ impl Element for Graph {
let scale_f = nw / 80.0;
let font_size = (14.0 * scale_f).clamp(6.0, 48.0);
let lx = nx + nw + 8.0 * scale_f;
- let ly = ny + (nh - font_size) / 2.0;
+ let ly = crate::layout::align_text_y(ny, nh, font_size, 0.0);
if lx >= self.x && lx < self.x + self.w && ly >= self.y && ly < self.y + self.h {
labels.push(TextLabel {
text: node.name.clone(),
diff --git a/src/widget/display/label.rs b/src/widget/display/label.rs
index a83fe17..40a0e57 100644
--- a/src/widget/display/label.rs
+++ b/src/widget/display/label.rs
@@ -47,7 +47,7 @@ impl Element for Label {
vec![TextLabel {
text: self.base.label.clone().unwrap_or_default(),
x: self.base.x,
- y: self.base.y + (self.base.h - self.font_size) / 2.0,
+ y: crate::layout::align_text_y(self.base.y, self.base.h, self.font_size, 0.0),
font_size: self.font_size,
color: self.color,
}]
diff --git a/src/widget/display/layout_preview.rs b/src/widget/display/layout_preview.rs
index 10cf1b9..6c4f43b 100644
--- a/src/widget/display/layout_preview.rs
+++ b/src/widget/display/layout_preview.rs
@@ -260,12 +260,11 @@ impl Element for LayoutPreview {
let text_w = node.label.len() as f32 * 6.0;
let text_color = if self.is_active { [242, 242, 255] } else { [178, 178, 191] };
let tx_offset = ((node.w - text_w) / 2.0).max(1.0);
- let ty_offset = ((node.h - text_sz) / 2.0).max(1.0);
labels.push(TextLabel {
text: node.label,
x: rect_x + tx_offset,
- y: rect_y + ty_offset,
+ y: crate::layout::align_text_y(rect_y, node.h, text_sz, 0.0),
font_size: text_sz,
color: text_color,
});
diff --git a/src/widget/display/list_item.rs b/src/widget/display/list_item.rs
index 31ec337..82da5f2 100644
--- a/src/widget/display/list_item.rs
+++ b/src/widget/display/list_item.rs
@@ -160,9 +160,9 @@ impl Element for InteractiveListItem {
let mut labels = Vec::new();
let title_y = if self.subtitle.is_some() {
- y + (h - 22.0) / 2.0
+ crate::layout::align_text_y(y, h, 22.0, 0.0)
} else {
- y + (h - 12.0) / 2.0
+ crate::layout::align_text_y(y, h, 12.0, 0.0)
};
labels.push(TextLabel {
diff --git a/src/widget/display/node.rs b/src/widget/display/node.rs
index 4d541be..07492de 100644
--- a/src/widget/display/node.rs
+++ b/src/widget/display/node.rs
@@ -77,7 +77,7 @@ impl Element for Node {
vec![TextLabel {
text: self.name.clone(),
x: self.x + self.w + 8.0,
- y: self.y + (self.h - 14.0) / 2.0,
+ y: crate::layout::align_text_y(self.y, self.h, 14.0, 0.0),
font_size: 14.0,
color: [0xcc, 0xcc, 0xd4],
}]
diff --git a/src/widget/input/button.rs b/src/widget/input/button.rs
index 468ecc8..502b5e0 100644
--- a/src/widget/input/button.rs
+++ b/src/widget/input/button.rs
@@ -243,7 +243,7 @@ impl Element for Button {
labels.push(TextLabel {
text: label.clone(),
x,
- y: self.base.y + (self.base.h - font_size) / 2.0 - 1.0,
+ y: crate::layout::align_text_y(self.base.y, self.base.h, font_size, 0.0),
font_size,
color,
});
diff --git a/src/widget/input/button_strip.rs b/src/widget/input/button_strip.rs
index 5445ce8..63a7eb5 100644
--- a/src/widget/input/button_strip.rs
+++ b/src/widget/input/button_strip.rs
@@ -385,7 +385,7 @@ impl Element for ButtonStrip {
labels.push(TextLabel {
text: btn_label.clone(),
x: r.0 + (r.2 - est_w) / 2.0,
- y: r.1 + (r.3 - font_size) / 2.0 - 1.0,
+ y: crate::layout::align_text_y(r.1, r.3, font_size, 0.0),
font_size,
color,
});
diff --git a/src/widget/input/checkbox.rs b/src/widget/input/checkbox.rs
index 6746d7a..179738c 100644
--- a/src/widget/input/checkbox.rs
+++ b/src/widget/input/checkbox.rs
@@ -151,7 +151,7 @@ impl Element for Checkbox {
let mut labels = Vec::new();
if let Some(ref label) = self.base.label {
let font_size = 12.0;
- let y = self.base.y + (self.base.h - font_size) / 2.0 - 1.0;
+ let y = crate::layout::align_text_y(self.base.y, self.base.h, font_size, 0.0);
labels.push(TextLabel {
text: label.clone(),
x: self.base.x + 8.0,
@@ -206,6 +206,33 @@ impl Toggle {
impl Element for Toggle {
crate::impl_widget_base!(Toggle);
+ fn get_value_string(&self) -> Option<String> {
+ Some(self.toggled.to_string())
+ }
+
+ fn set_value_string(&mut self, val: &str) -> bool {
+ let val_trimmed = val.trim().to_lowercase();
+ let new_toggled = if val_trimmed == "true" || val_trimmed == "1" || val_trimmed == "yes" || val_trimmed == "on" {
+ true
+ } else if val_trimmed == "false" || val_trimmed == "0" || val_trimmed == "no" || val_trimmed == "off" {
+ false
+ } else {
+ return false;
+ };
+ if self.toggled != new_toggled {
+ self.toggled = new_toggled;
+ self.just_toggled = true;
+ return true;
+ }
+ false
+ }
+
+ fn take_change(&mut self) -> bool {
+ let ret = self.just_toggled;
+ self.just_toggled = false;
+ ret
+ }
+
fn color(&self) -> [f32; 4] {
if self.toggled {
colors::TOGGLE_ON
@@ -243,7 +270,7 @@ impl Element for Toggle {
labels.push(TextLabel {
text: label.clone(),
x: self.base.x + (self.base.w - est_w) / 2.0,
- y: self.base.y + (self.base.h - font_size) / 2.0 - 1.0,
+ y: crate::layout::align_text_y(self.base.y, self.base.h, font_size, 0.0),
font_size,
color: [0xcc, 0xcc, 0xd4],
});
diff --git a/src/widget/input/color_selector.rs b/src/widget/input/color_selector.rs
index fc4084b..42c545d 100644
--- a/src/widget/input/color_selector.rs
+++ b/src/widget/input/color_selector.rs
@@ -489,7 +489,7 @@ impl Element for ColorSelector {
labels.push(TextLabel {
text: hex,
x: self.base.x + 4.0,
- y: self.base.y + top + (visual_h - 12.0) / 2.0,
+ y: crate::layout::align_text_y(self.base.y, self.base.h, 12.0, top),
font_size: 12.0,
color: [0xcc, 0xcc, 0xd4],
});
diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index b031710..153b4cb 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -75,7 +75,7 @@ impl Dropdown {
}
for (idx, opt) in self.options.iter().enumerate() {
- let iy = dy + idx as f32 * 24.0 + (24.0 - 12.0) / 2.0;
+ let iy = crate::layout::align_text_y(dy + idx as f32 * 24.0, 24.0, 12.0, 0.0);
let text_color = if self.hovered_item == Some(idx) {
[0xff, 0xff, 0xff]
} else if self.selected == idx {
@@ -345,7 +345,7 @@ impl Element for Dropdown {
labels.push(TextLabel {
text: selected_text,
x: self.base.x + 8.0,
- y: self.base.y + top + (visual_h - 12.0) / 2.0,
+ y: crate::layout::align_text_y(self.base.y, self.base.h, 12.0, top),
font_size: 12.0,
color: [0xdd, 0xdd, 0xe2],
});
@@ -353,7 +353,7 @@ impl Element for Dropdown {
labels.push(TextLabel {
text: "▼".to_string(),
x: self.base.x + self.base.w - 18.0,
- y: self.base.y + top + (visual_h - 10.0) / 2.0,
+ y: crate::layout::align_text_y(self.base.y, self.base.h, 10.0, top),
font_size: 10.0,
color: [0x83, 0x83, 0x8a],
});
diff --git a/src/widget/input/font_selector.rs b/src/widget/input/font_selector.rs
index 4216de1..765ca2d 100644
--- a/src/widget/input/font_selector.rs
+++ b/src/widget/input/font_selector.rs
@@ -114,7 +114,7 @@ impl Element for FontSelector {
labels.push(TextLabel {
text: self.font_family.clone(),
x: self.base.x + 8.0,
- y: self.base.y + top + (visual_h - 12.0) / 2.0,
+ y: crate::layout::align_text_y(self.base.y, self.base.h, 12.0, top),
font_size: 12.0,
color: [0xdd, 0xdd, 0xe2],
});
@@ -122,7 +122,7 @@ impl Element for FontSelector {
labels.push(TextLabel {
text: "🔤".to_string(),
x: self.base.x + self.base.w - 20.0,
- y: self.base.y + top + (visual_h - 11.0) / 2.0,
+ y: crate::layout::align_text_y(self.base.y, self.base.h, 11.0, top),
font_size: 11.0,
color: [0x83, 0x83, 0x8a],
});
diff --git a/src/widget/input/mod.rs b/src/widget/input/mod.rs
index 0a71b78..b0d2bd8 100644
--- a/src/widget/input/mod.rs
+++ b/src/widget/input/mod.rs
@@ -9,6 +9,7 @@ pub mod text_box;
pub mod trackpad;
pub mod font_selector;
pub mod button_strip;
+pub mod multi_control;
pub use canvas::Canvas;
pub use button::{Button, ButtonKind, PageButton};
@@ -21,6 +22,7 @@ pub use text_box::{TextBox, get_font_db};
pub use trackpad::{Trackpad, Finger};
pub use font_selector::FontSelector;
pub use button_strip::ButtonStrip;
+pub use multi_control::{MultiControl, InstancedControl, InstancedWidget, MultiControlRow};
pub const BREADCRUMB_PADDING: f32 = 8.0;
pub const SEGMENT_GAP: f32 = 4.0;
diff --git a/src/widget/input/multi_control.rs b/src/widget/input/multi_control.rs
new file mode 100644
index 0000000..f47c600
--- /dev/null
+++ b/src/widget/input/multi_control.rs
@@ -0,0 +1,863 @@
+use crate::colors;
+use crate::widget::*;
+use crate::widget::focus;
+use crate::widget::input::{TextBox, Spinbox, Dropdown, Button, Toggle, Slider};
+use crate::widget::TextLabel;
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
+pub struct InstancedControl {
+ pub key: String,
+ pub control_type: String,
+ pub value: String,
+}
+
+#[derive(Clone, Debug)]
+pub enum InstancedWidget {
+ TextBox(TextBox),
+ Spinbox(Spinbox),
+ Toggle(Toggle),
+ Slider(Slider),
+}
+
+impl InstancedWidget {
+ pub fn get_value_string(&self) -> Option<String> {
+ match self {
+ InstancedWidget::TextBox(w) => w.get_value_string(),
+ InstancedWidget::Spinbox(w) => w.get_value_string(),
+ InstancedWidget::Toggle(w) => w.get_value_string(),
+ InstancedWidget::Slider(w) => w.get_value_string(),
+ }
+ }
+
+ pub fn set_value_string(&mut self, val: &str) -> bool {
+ match self {
+ InstancedWidget::TextBox(w) => w.set_value_string(val),
+ InstancedWidget::Spinbox(w) => w.set_value_string(val),
+ InstancedWidget::Toggle(w) => w.set_value_string(val),
+ InstancedWidget::Slider(w) => w.set_value_string(val),
+ }
+ }
+
+ pub fn take_change(&mut self) -> bool {
+ match self {
+ InstancedWidget::TextBox(w) => w.take_change(),
+ InstancedWidget::Spinbox(w) => w.take_change(),
+ InstancedWidget::Toggle(w) => w.take_change(),
+ InstancedWidget::Slider(w) => w.take_change(),
+ }
+ }
+
+ pub fn layout(&mut self, origin: Point, constraints: LayoutConstraints, ctx: &mut UiContext) {
+ match self {
+ InstancedWidget::TextBox(w) => w.layout(origin, constraints, ctx),
+ InstancedWidget::Spinbox(w) => w.layout(origin, constraints, ctx),
+ InstancedWidget::Toggle(w) => w.layout(origin, constraints, ctx),
+ InstancedWidget::Slider(w) => w.layout(origin, constraints, ctx),
+ }
+ }
+
+ pub fn rect(&self) -> (f32, f32, f32, f32) {
+ match self {
+ InstancedWidget::TextBox(w) => w.rect(),
+ InstancedWidget::Spinbox(w) => w.rect(),
+ InstancedWidget::Toggle(w) => w.rect(),
+ InstancedWidget::Slider(w) => w.rect(),
+ }
+ }
+
+ pub fn all_quads(&self, ctx: &UiContext) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+ match self {
+ InstancedWidget::TextBox(w) => w.all_quads(ctx),
+ InstancedWidget::Spinbox(w) => w.all_quads(ctx),
+ InstancedWidget::Toggle(w) => w.all_quads(ctx),
+ InstancedWidget::Slider(w) => w.all_quads(ctx),
+ }
+ }
+
+ pub fn text_labels_with_bounds(&self, ctx: &UiContext) -> Vec<(TextLabel, Option<[f32; 4]>)> {
+ match self {
+ InstancedWidget::TextBox(w) => w.text_labels_with_bounds(ctx),
+ InstancedWidget::Spinbox(w) => w.text_labels_with_bounds(ctx),
+ InstancedWidget::Toggle(w) => w.text_labels_with_bounds(ctx),
+ InstancedWidget::Slider(w) => w.text_labels_with_bounds(ctx),
+ }
+ }
+
+ pub fn text_labels_with_font_and_bounds(&self, ctx: &UiContext) -> Vec<(TextLabel, Option<String>, Option<[f32; 4]>)> {
+ match self {
+ InstancedWidget::TextBox(w) => w.text_labels_with_font_and_bounds(ctx),
+ InstancedWidget::Spinbox(w) => w.text_labels_with_font_and_bounds(ctx),
+ InstancedWidget::Toggle(w) => w.text_labels_with_font_and_bounds(ctx),
+ InstancedWidget::Slider(w) => w.text_labels_with_font_and_bounds(ctx),
+ }
+ }
+
+ pub fn handle_event(&mut self, event: &Event, ctx: &mut UiContext) -> bool {
+ match self {
+ InstancedWidget::TextBox(w) => w.handle_event(event, ctx),
+ InstancedWidget::Spinbox(w) => w.handle_event(event, ctx),
+ InstancedWidget::Toggle(w) => w.handle_event(event, ctx),
+ InstancedWidget::Slider(w) => w.handle_event(event, ctx),
+ }
+ }
+
+ pub fn unfocus(&mut self) {
+ match self {
+ InstancedWidget::TextBox(w) => w.unfocus(),
+ InstancedWidget::Spinbox(w) => w.unfocus(),
+ InstancedWidget::Toggle(w) => w.unfocus(),
+ InstancedWidget::Slider(w) => w.unfocus(),
+ }
+ }
+
+ pub fn hit_test(&self, px: f32, py: f32, ctx: &UiContext) -> bool {
+ match self {
+ InstancedWidget::TextBox(w) => w.hit_test(px, py, ctx),
+ InstancedWidget::Spinbox(w) => w.hit_test(px, py, ctx),
+ InstancedWidget::Toggle(w) => w.hit_test(px, py, ctx),
+ InstancedWidget::Slider(w) => w.hit_test(px, py, ctx),
+ }
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct MultiControlRow {
+ pub key_input: TextBox,
+ pub type_dropdown: Dropdown,
+ pub value_widget: InstancedWidget,
+ pub remove_button: Button,
+}
+
+impl MultiControlRow {
+ pub fn new(key: String, control_type: String, value: String) -> Self {
+ let key_input = TextBox::new(key);
+ let dropdown_options = vec![
+ "TextBox".to_string(),
+ "Spinbox".to_string(),
+ "Toggle".to_string(),
+ "Slider".to_string(),
+ ];
+ let type_idx = match control_type.as_str() {
+ "Spinbox" => 1,
+ "Toggle" => 2,
+ "Slider" => 3,
+ _ => 0,
+ };
+ let type_dropdown = Dropdown::new(dropdown_options, type_idx);
+
+ let value_widget = match type_idx {
+ 1 => {
+ let initial_val = value.parse::<i32>().unwrap_or(0);
+ InstancedWidget::Spinbox(Spinbox::new(initial_val, 0, 1000000, 1))
+ }
+ 2 => {
+ let initial_val = value.trim().to_lowercase();
+ let toggled = initial_val == "true" || initial_val == "1" || initial_val == "yes" || initial_val == "on";
+ let mut tg = Toggle::new();
+ tg.set_toggled(toggled);
+ InstancedWidget::Toggle(tg)
+ }
+ 3 => {
+ let mut sl = Slider::new();
+ sl.set_value_string(&value);
+ InstancedWidget::Slider(sl)
+ }
+ _ => {
+ InstancedWidget::TextBox(TextBox::new(value))
+ }
+ };
+
+ let remove_button = Button::new(0.0, 0.0, 0.0, 0.0).with_label("Remove");
+
+ Self {
+ key_input,
+ type_dropdown,
+ value_widget,
+ remove_button,
+ }
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct MultiControl {
+ pub base: Widget,
+ pub name: String,
+ pub rows: Vec<MultiControlRow>,
+ pub add_button: Button,
+ pub just_changed: bool,
+ pub add_popover_open: bool,
+ pub add_popover_hovered_idx: Option<usize>,
+}
+
+impl MultiControl {
+ pub fn new(name: String) -> Self {
+ let mut mc = Self {
+ base: Widget::new(),
+ name,
+ rows: Vec::new(),
+ add_button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Add Widget +"),
+ just_changed: false,
+ add_popover_open: false,
+ add_popover_hovered_idx: None,
+ };
+ mc.load_from_config();
+ mc
+ }
+
+ pub fn with_label(mut self, label: &str) -> Self {
+ self.base.label = Some(label.to_string());
+ self.load_from_config();
+ self
+ }
+
+ pub fn load_from_config(&mut self) {
+ let name = if self.base.label.is_some() {
+ self.base.label.as_ref().unwrap()
+ } else {
+ &self.name
+ };
+ let loaded = load_config(name);
+ self.rows = loaded.into_iter().map(|c| {
+ MultiControlRow::new(c.key, c.control_type, c.value)
+ }).collect();
+ }
+
+ pub fn save_to_config(&self) {
+ let name = if self.base.label.is_some() {
+ self.base.label.as_ref().unwrap()
+ } else {
+ &self.name
+ };
+ let controls: Vec<InstancedControl> = self.rows.iter().map(|row| {
+ let key = row.key_input.get_value_string().unwrap_or_default();
+ let control_type = match row.type_dropdown.selected {
+ 1 => "Spinbox".to_string(),
+ 2 => "Toggle".to_string(),
+ 3 => "Slider".to_string(),
+ _ => "TextBox".to_string(),
+ };
+ let value = row.value_widget.get_value_string().unwrap_or_default();
+ InstancedControl { key, control_type, value }
+ }).collect();
+ save_config(name, &controls);
+ }
+}
+
+fn link_child(parent_ptr: *mut (dyn Element + 'static), parent_id: WidgetId, child: &mut dyn Element, ctx: &mut UiContext) {
+ let c_ptr = child.as_ptr();
+ if let Some(c_base) = child.base() {
+ let c_id = c_base.id();
+ ctx.register_widget(parent_id, parent_ptr);
+ ctx.register_widget(c_id, c_ptr);
+ ctx.layout_tree.parents.insert(c_id, parent_id);
+ let children = ctx.layout_tree.children.entry(parent_id).or_default();
+ if !children.contains(&c_id) {
+ children.push(c_id);
+ }
+ }
+ child.set_parent(Some(parent_ptr), ctx);
+}
+
+impl Element for MultiControl {
+ crate::impl_widget_base!(MultiControl);
+
+ fn preferred_height(&self) -> Option<f32> {
+ let line_h = 44.0;
+ let gap_between_lines = 4.0;
+ let gap_between_rows = 12.0;
+ let row_h = 2.0 * line_h + gap_between_lines;
+ let add_btn_h = 36.0;
+ let total_h = if self.rows.is_empty() {
+ add_btn_h + 16.0
+ } else {
+ self.rows.len() as f32 * row_h + (self.rows.len() - 1) as f32 * gap_between_rows + add_btn_h + 24.0
+ };
+ Some(total_h)
+ }
+
+ fn color(&self) -> [f32; 4] {
+ [0.0, 0.0, 0.0, 0.0]
+ }
+
+ fn layout(&mut self, origin: Point, constraints: LayoutConstraints, ctx: &mut UiContext) {
+ let size = self.measure(constraints, ctx);
+ self.set_rect(origin.x, origin.y, size.width, size.height);
+
+ let pad_x = 8.0;
+ let pad_y = 8.0;
+ let line_h = 44.0;
+ let gap_between_lines = 4.0;
+ let gap_between_rows = 12.0;
+
+ let usable_w = (size.width - 2.0 * pad_x).max(1.0);
+ let gap_x = 6.0;
+ let top_usable_w = usable_w - 2.0 * gap_x;
+
+ let key_w = top_usable_w * 0.50;
+ let type_w = top_usable_w * 0.32;
+ let remove_w = top_usable_w * 0.18;
+
+ let mut curr_y = origin.y + pad_y;
+ let self_ptr = self.as_ptr();
+ let self_id = self.base.id();
+
+ for row in &mut self.rows {
+ // Line 1: Label, Type, Remove
+ let key_x = origin.x + pad_x;
+ row.key_input.layout(Point { x: key_x, y: curr_y }, LayoutConstraints::new(key_w, key_w, line_h, line_h), ctx);
+
+ let type_x = key_x + key_w + gap_x;
+ row.type_dropdown.layout(Point { x: type_x, y: curr_y }, LayoutConstraints::new(type_w, type_w, line_h, line_h), ctx);
+
+ let remove_x = type_x + type_w + gap_x;
+ row.remove_button.layout(Point { x: remove_x, y: curr_y }, LayoutConstraints::new(remove_w, remove_w, line_h, line_h), ctx);
+
+ // Line 2: Value Control Widget
+ let value_y = curr_y + line_h + gap_between_lines;
+ let value_x = origin.x + pad_x;
+ row.value_widget.layout(Point { x: value_x, y: value_y }, LayoutConstraints::new(usable_w, usable_w, line_h, line_h), ctx);
+
+ link_child(self_ptr, self_id, &mut row.key_input, ctx);
+ link_child(self_ptr, self_id, &mut row.type_dropdown, ctx);
+ match &mut row.value_widget {
+ InstancedWidget::TextBox(tb) => link_child(self_ptr, self_id, tb, ctx),
+ InstancedWidget::Spinbox(sb) => link_child(self_ptr, self_id, sb, ctx),
+ InstancedWidget::Toggle(tg) => link_child(self_ptr, self_id, tg, ctx),
+ InstancedWidget::Slider(sl) => link_child(self_ptr, self_id, sl, ctx),
+ }
+ link_child(self_ptr, self_id, &mut row.remove_button, ctx);
+
+ curr_y += 2.0 * line_h + gap_between_lines + gap_between_rows;
+ }
+
+ // Lay out add button
+ let add_btn_w = 120.0f32.min(usable_w);
+ let add_btn_h = 36.0;
+ let add_x = origin.x + pad_x;
+ self.add_button.layout(Point { x: add_x, y: curr_y }, LayoutConstraints::new(add_btn_w, add_btn_w, add_btn_h, add_btn_h), ctx);
+ link_child(self_ptr, self_id, &mut self.add_button, ctx);
+ }
+
+ fn all_quads(&self, ctx: &UiContext) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+ let mut quads = self.extra_quads();
+ if let Some(hq) = self.highlight_quad(ctx) {
+ if hq.4 != colors::HIGHLIGHT_SECONDARY {
+ quads.push(hq);
+ }
+ }
+
+ for row in &self.rows {
+ quads.extend(row.key_input.all_quads(ctx));
+ quads.extend(row.type_dropdown.all_quads(ctx));
+ quads.extend(row.value_widget.all_quads(ctx));
+ quads.extend(row.remove_button.all_quads(ctx));
+ }
+
+ quads.extend(self.add_button.all_quads(ctx));
+ quads
+ }
+
+ fn text_labels(&self) -> Vec<TextLabel> {
+ Vec::new()
+ }
+
+ fn text_labels_with_bounds(&self, ctx: &UiContext) -> Vec<(TextLabel, Option<[f32; 4]>)> {
+ let mut labels = Vec::new();
+ for row in &self.rows {
+ labels.extend(row.key_input.text_labels_with_bounds(ctx));
+ labels.extend(row.type_dropdown.text_labels_with_bounds(ctx));
+ labels.extend(row.value_widget.text_labels_with_bounds(ctx));
+ labels.extend(row.remove_button.text_labels_with_bounds(ctx));
+ }
+ labels.extend(self.add_button.text_labels_with_bounds(ctx));
+ labels
+ }
+
+ fn text_labels_with_font_and_bounds(&self, ctx: &UiContext) -> Vec<(TextLabel, Option<String>, Option<[f32; 4]>)> {
+ let mut labels = Vec::new();
+ let font = self.widget_font();
+ for l in self.text_labels() {
+ labels.push((l, font.clone(), None));
+ }
+
+ for row in &self.rows {
+ labels.extend(row.key_input.text_labels_with_font_and_bounds(ctx));
+ labels.extend(row.type_dropdown.text_labels_with_font_and_bounds(ctx));
+ labels.extend(row.value_widget.text_labels_with_font_and_bounds(ctx));
+ labels.extend(row.remove_button.text_labels_with_font_and_bounds(ctx));
+ }
+
+ labels.extend(self.add_button.text_labels_with_font_and_bounds(ctx));
+ labels
+ }
+
+ fn handle_event(&mut self, event: &Event, ctx: &mut UiContext) -> bool {
+ // 1. Intercept events if add popover is open
+ if self.add_popover_open {
+ let (bx, by, bw, bh) = self.add_button.rect();
+ let dy = by + bh;
+ let dh = 4.0 * 24.0;
+ match event {
+ Event::PointerMove { x, y } => {
+ if *x >= bx && *x <= bx + bw && *y >= dy && *y <= dy + dh {
+ let idx = ((*y - dy) / 24.0).floor() as usize;
+ if idx < 4 {
+ self.add_popover_hovered_idx = Some(idx);
+ return true;
+ }
+ }
+ self.add_popover_hovered_idx = None;
+ }
+ Event::MouseButton { button, state, x, y } => {
+ if *button == MouseButton::Left && *state == ElementState::Pressed {
+ if *x >= bx && *x <= bx + bw && *y >= dy && *y <= dy + dh {
+ let idx = ((*y - dy) / 24.0).floor() as usize;
+ if idx < 4 {
+ let new_type = match idx {
+ 1 => "Spinbox".to_string(),
+ 2 => "Toggle".to_string(),
+ 3 => "Slider".to_string(),
+ _ => "TextBox".to_string(),
+ };
+ let default_val = match idx {
+ 1 => "0".to_string(),
+ 2 => "false".to_string(),
+ 3 => "0.5".to_string(),
+ _ => "".to_string(),
+ };
+ let new_row = MultiControlRow::new("new_widget".to_string(), new_type, default_val);
+ self.rows.push(new_row);
+ self.save_to_config();
+ self.just_changed = true;
+ }
+ self.add_popover_open = false;
+ self.add_popover_hovered_idx = None;
+ return true;
+ } else {
+ // Clicked outside add popover, close it
+ self.add_popover_open = false;
+ self.add_popover_hovered_idx = None;
+ return true;
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+
+ let mut handled = false;
+
+ // 2. Intercept row dropdown open popovers first
+ for row in &mut self.rows {
+ if row.type_dropdown.open {
+ if row.type_dropdown.handle_event(event, ctx) {
+ handled = true;
+ }
+ }
+ }
+
+ if !handled {
+ if self.add_button.handle_event(event, ctx) {
+ handled = true;
+ }
+
+ let mut row_to_remove = None;
+ for (i, row) in self.rows.iter_mut().enumerate() {
+ if row.key_input.handle_event(event, ctx) {
+ handled = true;
+ }
+ if row.type_dropdown.handle_event(event, ctx) {
+ handled = true;
+ }
+ if row.value_widget.handle_event(event, ctx) {
+ handled = true;
+ }
+ if row.remove_button.handle_event(event, ctx) {
+ handled = true;
+ }
+ if row.remove_button.take_click() {
+ row_to_remove = Some(i);
+ handled = true;
+ }
+ }
+
+ if let Some(idx) = row_to_remove {
+ self.rows.remove(idx);
+ self.save_to_config();
+ self.just_changed = true;
+ }
+ }
+
+ if self.add_button.take_click() {
+ self.add_popover_open = !self.add_popover_open;
+ self.add_popover_hovered_idx = None;
+ handled = true;
+ }
+
+ let mut type_changed = false;
+ let mut value_changed = false;
+ let mut key_changed = false;
+
+ for row in &mut self.rows {
+ if row.key_input.take_change() {
+ key_changed = true;
+ }
+ if row.type_dropdown.take_change() {
+ type_changed = true;
+ let new_type_idx = row.type_dropdown.selected;
+ let old_val = row.value_widget.get_value_string().unwrap_or_default();
+ row.value_widget = match new_type_idx {
+ 1 => {
+ let val_i = old_val.parse::<i32>().unwrap_or(0);
+ InstancedWidget::Spinbox(Spinbox::new(val_i, 0, 1000000, 1))
+ }
+ 2 => {
+ let toggled = old_val.trim().to_lowercase() == "true" || old_val == "1";
+ let mut tg = Toggle::new();
+ tg.set_toggled(toggled);
+ InstancedWidget::Toggle(tg)
+ }
+ 3 => {
+ let mut sl = Slider::new();
+ sl.set_value_string(&old_val);
+ InstancedWidget::Slider(sl)
+ }
+ _ => {
+ InstancedWidget::TextBox(TextBox::new(old_val))
+ }
+ };
+ }
+ if row.value_widget.take_change() {
+ value_changed = true;
+ }
+ }
+
+ if key_changed || type_changed || value_changed {
+ self.save_to_config();
+ self.just_changed = true;
+ }
+
+ if !handled {
+ match event {
+ Event::PointerMove { x, y } => {
+ let is_hit = self.hit_test(*x, *y, ctx);
+ let was = self.hovered();
+ self.set_hovered(is_hit);
+ if was != is_hit {
+ handled = true;
+ }
+ }
+ _ => {}
+ }
+ }
+
+ handled
+ }
+
+ fn take_change(&mut self) -> bool {
+ let ret = self.just_changed;
+ self.just_changed = false;
+ ret
+ }
+
+ fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
+ self.handle_event(&Event::MouseButton { button, state, x: px, y: py }, ctx)
+ }
+
+ fn keyboard_input(&mut self, event: &KeyEvent, ctx: &mut UiContext) -> bool {
+ self.handle_event(&Event::KeyInput(event.clone()), ctx)
+ }
+
+ fn cursor_moved(&mut self, px: f32, py: f32, ctx: &mut UiContext) -> bool {
+ self.handle_event(&Event::PointerMove { x: px, y: py }, ctx)
+ }
+
+ fn unfocus(&mut self) {
+ self.add_popover_open = false;
+ self.add_popover_hovered_idx = None;
+ for row in &mut self.rows {
+ row.key_input.unfocus();
+ row.type_dropdown.open = false;
+ row.value_widget.unfocus();
+ }
+ }
+
+ fn hit_test(&self, px: f32, py: f32, ctx: &UiContext) -> bool {
+ if let Some((x, y, w, h)) = self.popover_rect() {
+ if px >= x && px <= x + w && py >= y && py <= y + h {
+ return true;
+ }
+ }
+ let (x, y, w, h) = self.rect();
+ px >= x && px <= x + w && py >= y && py <= y + h
+ }
+
+ fn popover_rect(&self) -> Option<(f32, f32, f32, f32)> {
+ for row in &self.rows {
+ if let Some(r) = row.type_dropdown.popover_rect() {
+ return Some(r);
+ }
+ }
+ if self.add_popover_open {
+ let (bx, by, bw, bh) = self.add_button.rect();
+ Some((bx, by + bh, bw, 4.0 * 24.0))
+ } else {
+ None
+ }
+ }
+
+ fn render_popover(&self, pc: &mut dyn crate::layout::RenderTarget) {
+ for row in &self.rows {
+ if row.type_dropdown.popover_rect().is_some() {
+ row.type_dropdown.render_popover(pc);
+ return;
+ }
+ }
+ if !self.add_popover_open { return; }
+
+ let (bx, by, bw, bh) = self.add_button.rect();
+ let dy = by + bh;
+ let dh = 4.0 * 24.0;
+ let options = vec!["TextBox", "Spinbox", "Toggle", "Slider"];
+
+ // Soft layered drop shadows
+ pc.rect([0.02, 0.02, 0.05, 0.15], bx + 1.0, dy + 1.0, bw, dh);
+ pc.rect([0.02, 0.02, 0.05, 0.08], bx + 3.0, dy + 3.0, bw, dh);
+ pc.rect([0.02, 0.02, 0.05, 0.04], bx + 5.0, dy + 5.0, bw, dh);
+
+ let theme = colors::active_theme();
+ pc.rect(theme.surface_border, bx, dy, bw, dh);
+ pc.rect(theme.surface_bg, bx + 1.0, dy + 1.0, bw - 2.0, dh - 2.0);
+
+ if let Some(h_idx) = self.add_popover_hovered_idx {
+ let iy = dy + h_idx as f32 * 24.0;
+ pc.rect(theme.primary_accent, bx + 2.0, iy + 2.0, bw - 4.0, 20.0);
+ }
+
+ for (idx, opt) in options.iter().enumerate() {
+ let iy = crate::layout::align_text_y(dy + idx as f32 * 24.0, 24.0, 12.0, 0.0);
+ let text_color = if self.add_popover_hovered_idx == Some(idx) {
+ [0xff, 0xff, 0xff]
+ } else {
+ [0xcc, 0xcc, 0xd4]
+ };
+ let color_f32 = [
+ text_color[0] as f32 / 255.0,
+ text_color[1] as f32 / 255.0,
+ text_color[2] as f32 / 255.0,
+ 1.0,
+ ];
+ pc.text(opt, bx + 8.0, iy, 12.0, color_f32);
+ }
+ }
+}
+
+impl Control for MultiControl {}
+
+fn get_application_config_path() -> std::path::PathBuf {
+ let home = std::env::var("HOME").unwrap_or_else(|_| "/home/lsgalante".to_string());
+ let base_dir = std::path::PathBuf::from(home).join(".config").join("cce");
+
+ let app_name = std::env::current_exe()
+ .ok()
+ .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
+ .unwrap_or_else(|| "this-application".to_string());
+
+ base_dir.join(app_name).join("this-application.toml")
+}
+
+fn save_config(name: &str, controls: &[InstancedControl]) {
+ let path = get_application_config_path();
+ if let Some(parent) = path.parent() {
+ let _ = std::fs::create_dir_all(parent);
+ }
+
+ let json_val = serde_json::to_string(controls).unwrap_or_else(|_| "[]".to_string());
+ let escaped_json = json_val.replace("'", "''");
+ let new_line = format!("{} = '{}'", name, escaped_json);
+
+ let content = std::fs::read_to_string(&path).unwrap_or_default();
+ let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
+
+ let mut multicontrol_sec_idx = None;
+ let mut next_section_idx = None;
+ let mut key_idx = None;
+
+ for (i, line) in lines.iter().enumerate() {
+ let trimmed = line.trim();
+ if trimmed == "[multicontrol]" {
+ multicontrol_sec_idx = Some(i);
+ } else if trimmed.starts_with('[') && trimmed.ends_with(']') {
+ if multicontrol_sec_idx.is_some() && next_section_idx.is_none() {
+ next_section_idx = Some(i);
+ }
+ } else if let Some(_) = multicontrol_sec_idx {
+ if next_section_idx.is_none() {
+ if trimmed.starts_with(name) {
+ if let Some(eq_idx) = trimmed.find('=') {
+ if trimmed[..eq_idx].trim() == name {
+ key_idx = Some(i);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if let Some(k_idx) = key_idx {
+ lines[k_idx] = new_line;
+ } else if let Some(m_idx) = multicontrol_sec_idx {
+ let insert_idx = next_section_idx.unwrap_or(lines.len());
+ lines.insert(insert_idx, new_line);
+ } else {
+ if !lines.is_empty() && !lines.last().unwrap().is_empty() {
+ lines.push(String::new());
+ }
+ lines.push("[multicontrol]".to_string());
+ lines.push(new_line);
+ }
+
+ let _ = std::fs::write(&path, lines.join("\n"));
+}
+
+fn load_config(name: &str) -> Vec<InstancedControl> {
+ let path = get_application_config_path();
+ let content = match std::fs::read_to_string(&path) {
+ Ok(c) => c,
+ Err(_) => return Vec::new(),
+ };
+
+ let mut multicontrol_sec = false;
+ for line in content.lines() {
+ let trimmed = line.trim();
+ if trimmed == "[multicontrol]" {
+ multicontrol_sec = true;
+ } else if trimmed.starts_with('[') && trimmed.ends_with(']') {
+ multicontrol_sec = false;
+ } else if multicontrol_sec {
+ if trimmed.starts_with(name) {
+ if let Some(eq_idx) = trimmed.find('=') {
+ if trimmed[..eq_idx].trim() == name {
+ let value_part = trimmed[eq_idx + 1..].trim();
+ let json_str = if value_part.starts_with('\'') && value_part.ends_with('\'') {
+ &value_part[1..value_part.len() - 1]
+ } else if value_part.starts_with('"') && value_part.ends_with('"') {
+ &value_part[1..value_part.len() - 1]
+ } else {
+ value_part
+ };
+ let json_str = json_str.replace("''", "'");
+ if let Ok(controls) = serde_json::from_str::<Vec<InstancedControl>>(&json_str) {
+ return controls;
+ }
+ }
+ }
+ }
+ }
+ }
+ Vec::new()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_instanced_control_serialization() {
+ let controls = vec![
+ InstancedControl {
+ key: "width".to_string(),
+ control_type: "Spinbox".to_string(),
+ value: "42".to_string(),
+ },
+ InstancedControl {
+ key: "label".to_string(),
+ control_type: "TextBox".to_string(),
+ value: "hello".to_string(),
+ },
+ ];
+
+ let serialized = serde_json::to_string(&controls).unwrap();
+ let deserialized: Vec<InstancedControl> = serde_json::from_str(&serialized).unwrap();
+ assert_eq!(controls, deserialized);
+ }
+
+ static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
+ #[test]
+ fn test_save_load_config_files() {
+ let _guard = ENV_MUTEX.lock().unwrap();
+ let temp_dir = std::env::temp_dir().join("cce_test_home");
+ let _ = std::fs::create_dir_all(&temp_dir);
+ let old_home = std::env::var("HOME").ok();
+ std::env::set_var("HOME", temp_dir.to_str().unwrap());
+
+ let controls = vec![
+ InstancedControl {
+ key: "x_offset".to_string(),
+ control_type: "TextBox".to_string(),
+ value: "10".to_string(),
+ },
+ ];
+
+ let name = "my_multicontrol";
+ save_config(name, &controls);
+
+ let loaded = load_config(name);
+ assert_eq!(controls, loaded);
+
+ let path = get_application_config_path();
+ assert!(path.exists());
+
+ let _ = std::fs::remove_dir_all(&temp_dir);
+ if let Some(h) = old_home {
+ std::env::set_var("HOME", h);
+ }
+ }
+
+ #[test]
+ fn test_popover_interaction() {
+ let _guard = ENV_MUTEX.lock().unwrap();
+ let mut dummy = crate::context::UiContext::new();
+ let temp_dir = std::env::temp_dir().join("cce_test_home_popover");
+ let _ = std::fs::create_dir_all(&temp_dir);
+ let old_home = std::env::var("HOME").ok();
+ std::env::set_var("HOME", temp_dir.to_str().unwrap());
+
+ let mut mc = MultiControl::new("test_mc".to_string());
+ mc.set_rect(10.0, 10.0, 300.0, 200.0);
+ mc.layout(Point { x: 10.0, y: 10.0 }, LayoutConstraints::new(300.0, 300.0, 200.0, 200.0), &mut dummy);
+
+ assert!(!mc.add_popover_open);
+
+ let (bx, by, _bw, bh) = mc.add_button.rect();
+ let clicked = mc.mouse_input(MouseButton::Left, ElementState::Pressed, bx + 5.0, by + 5.0, &mut dummy);
+ assert!(clicked);
+ let released = mc.mouse_input(MouseButton::Left, ElementState::Released, bx + 5.0, by + 5.0, &mut dummy);
+ assert!(released);
+ assert!(mc.add_popover_open);
+
+ let dy = by + bh;
+ let hover_y = dy + 60.0;
+ let moved = mc.cursor_moved(bx + 5.0, hover_y, &mut dummy);
+ assert!(moved);
+ assert_eq!(mc.add_popover_hovered_idx, Some(2));
+
+ let clicked_item = mc.mouse_input(MouseButton::Left, ElementState::Pressed, bx + 5.0, hover_y, &mut dummy);
+ assert!(clicked_item);
+ assert!(!mc.add_popover_open);
+ assert_eq!(mc.rows.len(), 1);
+
+ let row = &mc.rows[0];
+ assert_eq!(row.key_input.get_value_string().unwrap(), "new_widget");
+ match &row.value_widget {
+ InstancedWidget::Toggle(_) => {},
+ _ => panic!("Expected Toggle widget"),
+ }
+
+ let _ = std::fs::remove_dir_all(&temp_dir);
+ if let Some(h) = old_home {
+ std::env::set_var("HOME", h);
+ }
+ }
+}
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index 5b88ae6..7e93d06 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -406,7 +406,7 @@ impl Element for Slider {
if self.show_readout {
let readout_w = 60.0;
let rx = self.base.x + self.base.w - readout_w;
- let ry = self.base.y + top + (visual_h - 12.0) / 2.0;
+ let ry = crate::layout::align_text_y(self.base.y, self.base.h, 12.0, top);
let text = if self.editing {
self.edit_buffer.clone()
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index 0781157..f3bfba6 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -354,7 +354,7 @@ impl Element for Spinbox {
labels.push(TextLabel {
text: value_text,
x: split_left + 4.0,
- y: self.base.y + top + (visual_h - 14.0) / 2.0 - 2.0,
+ y: crate::layout::align_text_y(self.base.y, self.base.h, 14.0, top),
font_size: 14.0,
color: [0xcc, 0xcc, 0xd4],
});
@@ -362,7 +362,7 @@ impl Element for Spinbox {
labels.push(TextLabel {
text: unit.clone(),
x: split_left + 4.0 + 36.0,
- y: self.base.y + top + (visual_h - 11.0) / 2.0 - 2.0,
+ y: crate::layout::align_text_y(self.base.y, self.base.h, 11.0, top),
font_size: 11.0,
color: [0x73, 0x73, 0x7a],
});
@@ -371,14 +371,14 @@ impl Element for Spinbox {
labels.push(TextLabel {
text: "-".to_string(),
x: self.base.x + self.base.w * 0.1125 - 4.0,
- y: self.base.y + top + (visual_h - 12.0) / 2.0 - 2.0,
+ y: crate::layout::align_text_y(self.base.y, self.base.h, 12.0, top),
font_size: 12.0,
color: [0xcc, 0xcc, 0xd4],
});
labels.push(TextLabel {
text: "+".to_string(),
x: self.base.x + self.base.w * 0.8875 - 4.0,
- y: self.base.y + top + (visual_h - 12.0) / 2.0 - 2.0,
+ y: crate::layout::align_text_y(self.base.y, self.base.h, 12.0, top),
font_size: 12.0,
color: [0xcc, 0xcc, 0xd4],
});
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index eb328ac..201622b 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -823,7 +823,7 @@ impl Element for TextBox {
let highlight_w = ((end - start) as f32 * char_width).min(max_x - highlight_x).max(0.0);
quads.push((
highlight_x,
- crate::layout::center_text_y(self.base.y + top, visual_h, self.font_size),
+ crate::layout::align_text_y(self.base.y, self.base.h, self.font_size, top),
highlight_w,
crate::layout::line_height(self.font_size),
highlight_color,
@@ -919,7 +919,7 @@ impl Element for TextBox {
labels.push(TextLabel {
text: display_text,
x: self.base.x + 8.0,
- y: crate::layout::center_text_y(self.base.y + top, visual_h, self.font_size),
+ y: crate::layout::align_text_y(self.base.y, self.base.h, self.font_size, top),
font_size: self.font_size,
color: label_color,
});
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index e1ddd06..b3797f6 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -530,7 +530,7 @@ pub use self::core::{Widget, focus, hover_animation, popovers, clipboard, contex
pub use self::input::{
Button, TextBox, Spinbox, Dropdown, Checkbox, Toggle, Slider, RangeSlider,
ColorSelector, Finger, Trackpad, Canvas, get_font_db, ActiveThumb, FontSelector,
- ButtonStrip
+ ButtonStrip, MultiControl, InstancedControl, InstancedWidget, MultiControlRow
};
pub use self::container::{
Container, Header, ContentBg, ViewportBg, ParametersBg, ScrollingList,