git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit35ac22848c33fd424de471fcebec926530bae2d0
parentd98544b64d
authorLucas Galante <[email protected]>
date2026-06-16 21:55
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,