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

commit1a14d40d2f00114863b01bc482d3511facdec87e
parent759cc63fa1
authorLucas Galante <[email protected]>
date2026-06-21 21:52
Implement reactive updates, event propagation, layout and clipping improvements

 src/backend/window_runner.rs       |  26 +++++++++-
 src/context.rs                     |  71 ++++++++++++++++++++++++-
 src/layout.rs                      | 104 +++++++++++++++++++++++++++++++++++++
 src/widget/container/container.rs  |  33 ++++++++++--
 src/widget/container/menu.rs       |  30 +++++++----
 src/widget/container/paginator.rs  |   6 ++-
 src/widget/core.rs                 |   3 ++
 src/widget/display/mod.rs          |   2 +
 src/widget/display/text_label.rs   |   7 +--
 src/widget/display/text_sizer.rs   | 102 ++++++++++++++++++++++++++++++++++++
 src/widget/input/button.rs         |   2 +-
 src/widget/input/button_strip.rs   |  50 +++++-------------
 src/widget/input/checkbox.rs       |   2 +-
 src/widget/input/color_selector.rs |   2 +-
 src/widget/mod.rs                  |  20 +++++++
 15 files changed, 398 insertions(+), 62 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 765c7ff..3e8e9a6 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1473,8 +1473,14 @@ impl<A: Application> OutputHandler for EngineState<A> {
         &mut self.output_state
     }
     
-    fn new_output(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _output: wl_output::WlOutput) {}
-    fn update_output(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _output: wl_output::WlOutput) {}
+    fn new_output(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _output: wl_output::WlOutput) {
+        let scale = crate::wayland::detect_scale_factor(&self.output_state);
+        crate::scale::set_scale_factor(scale as f32);
+    }
+    fn update_output(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _output: wl_output::WlOutput) {
+        let scale = crate::wayland::detect_scale_factor(&self.output_state);
+        crate::scale::set_scale_factor(scale as f32);
+    }
     fn output_destroyed(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _output: wl_output::WlOutput) {}
 }
 
@@ -1789,6 +1795,22 @@ impl<A: Application> KeyboardHandler for EngineState<A> {
         self.ctrl_pressed = modifiers.ctrl;
         self.shift_pressed = modifiers.shift;
     }
+
+    fn update_repeat_info(
+        &mut self,
+        _conn: &Connection,
+        _qh: &QueueHandle<Self>,
+        _keyboard: &wl_keyboard::WlKeyboard,
+        info: smithay_client_toolkit::seat::keyboard::RepeatInfo,
+    ) {
+        match info {
+            smithay_client_toolkit::seat::keyboard::RepeatInfo::Repeat { rate, delay } => {
+                // Store/expose delay/rate if required by the application
+                let _ = (rate, delay);
+            }
+            smithay_client_toolkit::seat::keyboard::RepeatInfo::Disable => {}
+        }
+    }
 }
 
 impl<A: Application> EngineState<A> {
diff --git a/src/context.rs b/src/context.rs
index da0b9c3..3236f80 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -1,5 +1,5 @@
 use std::collections::HashMap;
-use crate::widget::{Element, WidgetId, LayoutTree, Key, MouseButton, ElementState};
+use crate::widget::{Element, WidgetId, LayoutTree, Key, MouseButton, ElementState, Event};
 use crate::widget::core::hover_animation::HoverState;
 use crate::widget::core::context_menu::ContextMenuState;
 
@@ -29,6 +29,75 @@ impl UiContext {
         }
     }
 
+    pub fn propagate_event(&mut self, event: &Event, root: *mut (dyn Element + 'static)) -> bool {
+        if root.is_null() {
+            return false;
+        }
+        unsafe {
+            // For KeyInput, send directly to focused widget if it exists
+            if let Event::KeyInput(_) = event {
+                if let Some(focused) = self.focused_widget {
+                    if (*focused).handle_event(event, self) {
+                        (*focused).mark_dirty(self);
+                        return true;
+                    }
+                }
+            }
+
+            let mut handled = false;
+            let children = (*root).children(self);
+            
+            match event {
+                Event::PointerMove { .. } => {
+                    for child in children.into_iter().rev() {
+                        if self.propagate_event(event, child) {
+                            handled = true;
+                        }
+                    }
+                    if (*root).handle_event(event, self) {
+                        (*root).mark_dirty(self);
+                        handled = true;
+                    }
+                }
+                _ => {
+                    for child in children.into_iter().rev() {
+                        if self.propagate_event(event, child) {
+                            return true;
+                        }
+                    }
+                    if (*root).handle_event(event, self) {
+                        (*root).mark_dirty(self);
+                        return true;
+                    }
+                }
+            }
+            handled
+        }
+    }
+
+    pub fn is_dirty(&self) -> bool {
+        for &ptr in self.widget_registry.values() {
+            unsafe {
+                if let Some(b) = (*ptr).base() {
+                    if b.dirty {
+                        return true;
+                    }
+                }
+            }
+        }
+        false
+    }
+
+    pub fn clear_dirty(&mut self) {
+        for &ptr in self.widget_registry.values() {
+            unsafe {
+                if let Some(b) = (*ptr).base_mut() {
+                    b.dirty = false;
+                }
+            }
+        }
+    }
+
     // --- Focus management ---
     pub fn set_focused(&mut self, w: &mut dyn Element) {
         let new_ptr = unsafe {
diff --git a/src/layout.rs b/src/layout.rs
index 0b654b5..81fcce2 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -89,6 +89,7 @@ static BREADCRUMB_FONT: RwLock<String> = RwLock::new(String::new());
 
 static PAGINATOR_TAB_PADDING_X: RwLock<f32> = RwLock::new(10.0);
 static BUTTON_PADDING: RwLock<f32> = RwLock::new(14.0);
+static BUTTON_STRIP_SPACING: RwLock<f32> = RwLock::new(8.0);
 
 static PLATE_PADDING: RwLock<f32> = RwLock::new(20.0);
 static DROPDOWN_HEIGHT: RwLock<f32> = RwLock::new(44.0);
@@ -426,6 +427,15 @@ pub fn reload_config() {
                     }
                 }
             }
+            if let Some(rest) = trimmed.strip_prefix("button_strip_spacing") {
+                let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
+                let val_str = rest.trim_end_matches('"').trim();
+                if let Ok(val) = val_str.parse::<f32>() {
+                    if let Ok(mut lock) = BUTTON_STRIP_SPACING.write() {
+                        *lock = val;
+                    }
+                }
+            }
             if let Some(rest) = trimmed.strip_prefix("textbox_height") {
                 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
                 let val_str = rest.trim_end_matches('"').trim();
@@ -1446,6 +1456,34 @@ pub fn set_button_padding(padding: f32) {
     }
 }
 
+pub fn button_strip_spacing() -> f32 {
+    use std::sync::Once;
+    static INIT: Once = Once::new();
+    INIT.call_once(|| {
+        if let Some(content) = read_config() {
+            for line in content.lines() {
+                let trimmed = line.trim();
+                if let Some(rest) = trimmed.strip_prefix("button_strip_spacing") {
+                    let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
+                    let val_str = rest.trim_end_matches('"').trim();
+                    if let Ok(val) = val_str.parse::<f32>() {
+                        if let Ok(mut lock) = BUTTON_STRIP_SPACING.write() {
+                            *lock = val;
+                        }
+                    }
+                }
+            }
+        }
+    });
+    *BUTTON_STRIP_SPACING.read().unwrap()
+}
+
+pub fn set_button_strip_spacing(spacing: f32) {
+    if let Ok(mut lock) = BUTTON_STRIP_SPACING.write() {
+        *lock = spacing;
+    }
+}
+
 pub fn paginator_tab_padding_y() -> f32 {
     button_padding()
 }
@@ -1557,6 +1595,8 @@ pub trait RenderTarget {
     fn text_with_font_and_bounds(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], font: &str, _bounds: Option<[f32; 4]>) {
         self.text_with_font(content, x, y, size, color, font);
     }
+    fn push_clip_rect(&mut self, _x: f32, _y: f32, _w: f32, _h: f32) {}
+    fn pop_clip_rect(&mut self) {}
 }
 
 pub struct PopoverCollector {
@@ -2482,6 +2522,70 @@ pub trait LayoutStrategy {
     fn get_gap(&self) -> f32 { 20.0 }
 }
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum FlexDirection {
+    Row,
+    Column,
+}
+
+pub struct FlexLayout {
+    left: f32,
+    top: f32,
+    width: f32,
+    height: f32,
+    direction: FlexDirection,
+    spacing: f32,
+    current_x: f32,
+    current_y: f32,
+}
+
+impl FlexLayout {
+    pub fn new(direction: FlexDirection, spacing: f32) -> Self {
+        Self {
+            left: 0.0,
+            top: 0.0,
+            width: 0.0,
+            height: 0.0,
+            direction,
+            spacing,
+            current_x: 0.0,
+            current_y: 0.0,
+        }
+    }
+}
+
+impl LayoutStrategy for FlexLayout {
+    fn init(&mut self, left: f32, top: f32, width: f32, height: f32) {
+        self.left = left;
+        self.top = top;
+        self.width = width;
+        self.height = height;
+        self.current_x = left;
+        self.current_y = top;
+    }
+
+    fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
+        match self.direction {
+            FlexDirection::Row => {
+                let rx = self.current_x;
+                let ry = self.current_y;
+                self.current_x += ww + self.spacing;
+                (rx, ry, ww, wh)
+            }
+            FlexDirection::Column => {
+                let rx = self.current_x;
+                let ry = self.current_y;
+                self.current_y += wh + self.spacing;
+                (rx, ry, ww, wh)
+            }
+        }
+    }
+
+    fn get_gap(&self) -> f32 {
+        self.spacing
+    }
+}
+
 pub struct ColumnLayout {
     left: f32,
     top: f32,
diff --git a/src/widget/container/container.rs b/src/widget/container/container.rs
index 87b3e22..9533484 100644
--- a/src/widget/container/container.rs
+++ b/src/widget/container/container.rs
@@ -4,17 +4,18 @@ use crate::widget::*;
 pub struct Container {
     pub parent: Option<*mut (dyn Element + 'static)>,
     pub children: Vec<*mut (dyn Element + 'static)>,
+    pub base: Widget,
 }
 
 impl Container {
     pub fn new() -> Self {
-        Self { parent: None, children: Vec::new() }
+        Self { parent: None, children: Vec::new(), base: Widget::new() }
     }
 }
 
 impl Element for Container {
-    fn rect(&self) -> (f32, f32, f32, f32) { (0.0, 0.0, 0.0, 0.0) }
-    fn set_rect(&mut self, _x: f32, _y: f32, _w: f32, _h: f32) {}
+    fn base(&self) -> Option<&Widget> { Some(&self.base) }
+    fn base_mut(&mut self) -> Option<&mut Widget> { Some(&mut self.base) }
     fn color(&self) -> [f32; 4] { [0.0, 0.0, 0.0, 0.0] }
     fn as_ptr(&self) -> *mut (dyn Element + 'static) {
         self as *const Self as *mut Self as *mut (dyn Element + 'static)
@@ -23,6 +24,32 @@ impl Element for Container {
         self as *mut Self as *mut (dyn Element + 'static)
     }
 
+    fn measure(&self, constraints: LayoutConstraints, ctx: &UiContext) -> Size {
+        let mut max_w = 0.0f32;
+        let mut max_h = 0.0f32;
+        for &child in &self.children {
+            unsafe {
+                let size = (*child).measure(constraints, ctx);
+                max_w = max_w.max(size.width);
+                max_h = max_h.max(size.height);
+            }
+        }
+        Size {
+            width: max_w.clamp(constraints.min_width, constraints.max_width),
+            height: max_h.clamp(constraints.min_height, constraints.max_height),
+        }
+    }
+
+    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);
+        for &child in &self.children {
+            unsafe {
+                (*child).layout(origin, constraints, ctx);
+            }
+        }
+    }
+
     fn focus(&mut self) {
         focus::set_focused(self);
     }
diff --git a/src/widget/container/menu.rs b/src/widget/container/menu.rs
index 4dffcbf..325cfd9 100644
--- a/src/widget/container/menu.rs
+++ b/src/widget/container/menu.rs
@@ -352,8 +352,9 @@ impl Element for MenuBar {
         let parent_ptr = self as *mut MenuBar as *mut (dyn Element + 'static);
 
         let font_setting = crate::layout::menubar_font();
-        let (_, font_size_opt) = crate::layout::parse_font_string(&font_setting);
-        let font_size = font_size_opt.unwrap_or(12.0);
+        let font_info = crate::layout::parse_font_string(&font_setting);
+        let font_fam = font_info.0;
+        let font_size = font_info.1.unwrap_or(12.0);
 
         if self.vertical {
             let mut cy = 16.0;
@@ -381,6 +382,7 @@ impl Element for MenuBar {
             self.menus.set_parent(Some(parent_ptr), &mut dummy);
         } else {
             let padding = crate::layout::button_padding();
+            let spacing = crate::layout::button_strip_spacing();
             let mut cx = 8.0;
             if self.center_items {
                 let mut total_width = 8.0;
@@ -389,12 +391,15 @@ impl Element for MenuBar {
                     if !self.context_options.is_empty() {
                         display_title.push_str(" ▼");
                     }
-                    total_width += TextLabel::estimate_width(&display_title, font_size) + 24.0;
+                    total_width += crate::widget::display::measure_text_width(&display_title, &font_fam, font_size) + 24.0;
                 }
                 let mut btn_strip_w = 0.0;
-                for btn_label in &self.menus.buttons {
-                    let text_w = TextLabel::estimate_width(btn_label, font_size);
+                for (i, btn_label) in self.menus.buttons.iter().enumerate() {
+                    let text_w = crate::widget::display::measure_text_width(btn_label, &font_fam, font_size);
                     btn_strip_w += text_w + 2.0 * padding;
+                    if i > 0 {
+                        btn_strip_w += spacing;
+                    }
                 }
                 total_width += btn_strip_w;
                 if self.base.w > total_width {
@@ -406,12 +411,15 @@ impl Element for MenuBar {
                 if !self.context_options.is_empty() {
                     display_title.push_str(" ▼");
                 }
-                cx += TextLabel::estimate_width(&display_title, font_size) + 24.0;
+                cx += crate::widget::display::measure_text_width(&display_title, &font_fam, font_size) + 24.0;
             }
             let mut btn_strip_w = 0.0;
-            for btn_label in &self.menus.buttons {
-                let text_w = TextLabel::estimate_width(btn_label, font_size);
+            for (i, btn_label) in self.menus.buttons.iter().enumerate() {
+                let text_w = crate::widget::display::measure_text_width(btn_label, &font_fam, font_size);
                 btn_strip_w += text_w + 2.0 * padding;
+                if i > 0 {
+                    btn_strip_w += spacing;
+                }
             }
             let menus_x = (clamped_x + cx).clamp(clamped_x, clamped_x + clamped_w);
             let menus_w = btn_strip_w.min(clamped_x + clamped_w - menus_x);
@@ -739,7 +747,7 @@ impl Element for MenuBar {
                 let start_y = self.base.y + 16.0;
                 for (i, c) in label.chars().enumerate() {
                     let char_str = c.to_string();
-                    let char_w = TextLabel::estimate_width(&char_str, font_size);
+                    let char_w = crate::widget::display::measure_text(&char_str, font_size);
                     let x_pos = self.base.x + (self.base.w - char_w) / 2.0;
                     let y_pos = start_y + i as f32 * line_height;
                     labels.push(TextLabel {
@@ -797,7 +805,7 @@ impl Element for MenuBar {
                     start_y += label_h + 20.0;
                 }
                 let line_height = font_size * 1.2;
-                let char_w = TextLabel::estimate_width("o", font_size);
+                let char_w = crate::widget::display::measure_text("o", font_size);
                 let x_pos = self.base.x + (self.base.w - char_w) / 2.0;
                 let mut display_title_vertical = self.title.clone();
                 if !self.context_options.is_empty() {
@@ -1203,7 +1211,7 @@ impl Element for Menu {
             let label_len = title.chars().count() as f32;
             let total_h = label_len * line_height;
             let start_y = self.base.y + (self.base.h - total_h) / 2.0;
-            let char_w = TextLabel::estimate_width("o", font_size);
+            let char_w = crate::widget::display::measure_text("o", font_size);
             let x_pos = self.base.x + (self.base.w - char_w) / 2.0;
             for (i, c) in title.chars().enumerate() {
                 let char_str = c.to_string();
diff --git a/src/widget/container/paginator.rs b/src/widget/container/paginator.rs
index c6eeaf5..23c1435 100644
--- a/src/widget/container/paginator.rs
+++ b/src/widget/container/paginator.rs
@@ -139,7 +139,9 @@ impl PageSelector for Paginator {
         if self.pages.is_empty() {
             return self.sidebar_w;
         }
-        let font_size = crate::layout::menubar_font_parsed().1;
+        let font_info = crate::layout::menubar_font_parsed();
+        let font_fam = font_info.0;
+        let font_size = font_info.1;
         let padding = crate::layout::button_padding();
         
         let mut max_w = 0.0;
@@ -152,7 +154,7 @@ impl PageSelector for Paginator {
                 let (icon, _) = trimmed.split_at(space_idx);
                 let icon = icon.trim();
                 let icon_font_size = 14.0;
-                let est_icon_w = TextLabel::estimate_width(icon, icon_font_size);
+                let est_icon_w = crate::widget::display::measure_text_width(icon, &font_fam, icon_font_size);
                 est_icon_w.max(font_size)
             } else {
                 font_size
diff --git a/src/widget/core.rs b/src/widget/core.rs
index 6eba037..7b1cd27 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -671,6 +671,7 @@ pub struct Widget {
     pub row_w: f32,
     pub focused: bool,
     pub id: std::cell::Cell<Option<crate::widget::WidgetId>>,
+    pub dirty: bool,
 }
 
 impl Widget {
@@ -686,6 +687,7 @@ impl Widget {
             row_w: 0.0,
             focused: false,
             id: std::cell::Cell::new(None),
+            dirty: true,
         }
     }
 
@@ -701,6 +703,7 @@ impl Widget {
             row_w: 0.0,
             focused: false,
             id: std::cell::Cell::new(None),
+            dirty: true,
         }
     }
 
diff --git a/src/widget/display/mod.rs b/src/widget/display/mod.rs
index d1bf2be..8f344d8 100644
--- a/src/widget/display/mod.rs
+++ b/src/widget/display/mod.rs
@@ -18,6 +18,7 @@ pub mod font_preview;
 pub mod info_box;
 pub mod status_dot;
 pub mod preview;
+pub mod text_sizer;
 
 pub use self::text_label::TextLabel;
 pub(crate) use self::text_label::make_widget_text_buffer;
@@ -40,3 +41,4 @@ pub use self::font_preview::FontPreview;
 pub use self::info_box::InfoBox;
 pub use self::status_dot::{DotStatus, StatusDot};
 pub use self::preview::PreviewState;
+pub use self::text_sizer::{measure_text_width, measure_text};
diff --git a/src/widget/display/text_label.rs b/src/widget/display/text_label.rs
index 5281181..5d7e625 100644
--- a/src/widget/display/text_label.rs
+++ b/src/widget/display/text_label.rs
@@ -34,8 +34,9 @@ impl TextLabel {
         color: [u8; 3],
     ) -> Vec<TextLabel> {
         let mut labels = Vec::new();
+        let font_fam = crate::layout::menubar_font_parsed().0;
         let char_widths: Vec<f32> = text.chars().map(|c| {
-            Self::estimate_width(&c.to_string(), font_size)
+            crate::widget::display::measure_text_width(&c.to_string(), &font_fam, font_size)
         }).collect();
         let total_width: f32 = char_widths.iter().sum();
         
@@ -64,9 +65,9 @@ impl TextLabel {
         }
         labels
     }
-
+ 
     pub fn is_covered_by(&self, px: f32, py: f32, pw: f32, ph: f32) -> bool {
-        let text_w = Self::estimate_width(&self.text, self.font_size);
+        let text_w = crate::widget::display::measure_text(&self.text, self.font_size);
         let x_overlap = self.x <= px + pw && (self.x + text_w) >= px;
         let y_overlap = self.y <= py + ph && (self.y + self.font_size) >= py;
         x_overlap && y_overlap
diff --git a/src/widget/display/text_sizer.rs b/src/widget/display/text_sizer.rs
new file mode 100644
index 0000000..753a624
--- /dev/null
+++ b/src/widget/display/text_sizer.rs
@@ -0,0 +1,102 @@
+use std::collections::HashMap;
+use std::sync::RwLock;
+use std::sync::OnceLock;
+use crate::widget::display::TextLabel;
+
+#[derive(Hash, Eq, PartialEq, Clone, Debug)]
+struct TextMeasureKey {
+    text: String,
+    font_family: String,
+    font_size_bits: u32,
+    scale_bits: u32,
+}
+
+static TEXT_SIZE_CACHE: OnceLock<RwLock<HashMap<TextMeasureKey, f32>>> = OnceLock::new();
+
+pub fn measure_text_width(text: &str, font_family: &str, font_size: f32) -> f32 {
+    let scale = crate::scale::scale_factor().max(1.0);
+    
+    let key = TextMeasureKey {
+        text: text.trim().to_string(),
+        font_family: font_family.to_string(),
+        font_size_bits: font_size.to_bits(),
+        scale_bits: scale.to_bits(),
+    };
+
+    let cache = TEXT_SIZE_CACHE.get_or_init(|| RwLock::new(HashMap::new()));
+    if let Ok(lock) = cache.read() {
+        if let Some(&exact_width) = lock.get(&key) {
+            return exact_width;
+        }
+    }
+
+    let exact_width = perform_svg_measurement(&key.text, &key.font_family, font_size, scale);
+
+    if let Ok(mut lock) = cache.write() {
+        lock.insert(key, exact_width);
+    }
+
+    exact_width
+}
+
+pub fn measure_text(text: &str, font_size: f32) -> f32 {
+    let font_family = crate::layout::menubar_font_parsed().0;
+    measure_text_width(text, &font_family, font_size)
+}
+
+fn perform_svg_measurement(text: &str, font_family: &str, font_size: f32, scale: f32) -> f32 {
+    if text.is_empty() {
+        return 0.0;
+    }
+    
+    let canvas_w = 1000.0;
+    let canvas_h = font_size * 2.5;
+
+    let w_px = (canvas_w * scale) as u32;
+    let h_px = (canvas_h * scale) as u32;
+
+    let svg_data = format!(
+        r##"<svg width="{}" height="{}" viewBox="0 0 {} {}" xmlns="http://www.w3.org/2000/svg">
+  <text x="{}" y="{}" font-family="{}" font-size="{}" fill="#000000" text-anchor="middle" dominant-baseline="middle">{}</text>
+</svg>"##,
+        w_px, h_px,
+        canvas_w, canvas_h,
+        canvas_w / 2.0, canvas_h / 2.0,
+        font_family,
+        font_size,
+        text
+    );
+
+    let opt = resvg::usvg::Options::default();
+    let fontdb = crate::widget::input::get_font_db();
+    
+    if let Ok(tree) = resvg::usvg::Tree::from_data(svg_data.as_bytes(), &opt, fontdb) {
+        if let Some(mut pixmap) = resvg::tiny_skia::Pixmap::new(w_px, h_px) {
+            resvg::render(&tree, resvg::tiny_skia::Transform::default(), &mut pixmap.as_mut());
+            let pixels = pixmap.data();
+
+            let mut min_col = None;
+            let mut max_col = None;
+
+            for row in 0..h_px {
+                for col in 0..w_px {
+                    let idx = ((row * w_px + col) * 4) as usize;
+                    if idx + 3 < pixels.len() && pixels[idx + 3] > 0 {
+                        if min_col.is_none() || col < min_col.unwrap() {
+                            min_col = Some(col);
+                        }
+                        if max_col.is_none() || col > max_col.unwrap() {
+                            max_col = Some(col);
+                        }
+                    }
+                }
+            }
+
+            if let (Some(min), Some(max)) = (min_col, max_col) {
+                return (max - min + 1) as f32 / scale;
+            }
+        }
+    }
+
+    TextLabel::estimate_width(text, font_size)
+}
diff --git a/src/widget/input/button.rs b/src/widget/input/button.rs
index c795e2d..2099632 100644
--- a/src/widget/input/button.rs
+++ b/src/widget/input/button.rs
@@ -218,7 +218,7 @@ impl Element for Button {
             let est_w = if label == "📋" {
                 12.0
             } else {
-                TextLabel::estimate_width(label, font_size)
+                crate::widget::display::measure_text(label, font_size)
             };
             let color = if let Some(lc) = self.label_color {
                 [
diff --git a/src/widget/input/button_strip.rs b/src/widget/input/button_strip.rs
index e4ee090..aa00738 100644
--- a/src/widget/input/button_strip.rs
+++ b/src/widget/input/button_strip.rs
@@ -14,8 +14,6 @@ pub struct ButtonStrip {
     pub tab_text_quads: Vec<Vec<(f32, f32, f32, f32, [f32; 4])>>,
     pub tab_quads_cache: std::collections::HashMap<String, Vec<(f32, f32, f32, f32, [f32; 4])>>,
     pub last_padding: Option<f32>,
-    pub tab_exact_widths: std::collections::HashMap<String, f32>,
-    pub needs_relayout: bool,
 }
 
 impl ButtonStrip {
@@ -31,8 +29,6 @@ impl ButtonStrip {
             tab_text_quads: Vec::new(),
             tab_quads_cache: std::collections::HashMap::new(),
             last_padding: None,
-            tab_exact_widths: std::collections::HashMap::new(),
-            needs_relayout: false,
         }
     }
 
@@ -154,17 +150,13 @@ impl ButtonStrip {
                 if let Some(mut pixmap) = resvg::tiny_skia::Pixmap::new(w_px, h_px) {
                     resvg::render(&tree, resvg::tiny_skia::Transform::default(), &mut pixmap.as_mut());
                     let pixels = pixmap.data();
-                    let mut first_row = None;
-                    let mut last_row = None;
-
+                    
                     for row in 0..h_px {
-                        let mut row_has_pixel = false;
                         for col in 0..w_px {
                             let idx = ((row * w_px + col) * 4) as usize;
                             if idx + 3 < pixels.len() {
                                 let a = pixels[idx + 3] as f32 / 255.0;
                                 if a > 0.0 {
-                                    row_has_pixel = true;
                                     let r = ((pixels[idx] as f32 / 255.0) / a).min(1.0);
                                     let g = ((pixels[idx + 1] as f32 / 255.0) / a).min(1.0);
                                     let b = ((pixels[idx + 2] as f32 / 255.0) / a).min(1.0);
@@ -178,21 +170,6 @@ impl ButtonStrip {
                                 }
                             }
                         }
-                        if row_has_pixel {
-                            if first_row.is_none() {
-                                first_row = Some(row);
-                            }
-                            last_row = Some(row);
-                        }
-                    }
-
-                    if let (Some(first), Some(last)) = (first_row, last_row) {
-                        let exact_h = (last - first + 1) as f32 / scale;
-                        let cached = self.tab_exact_widths.get(label_text);
-                        if cached != Some(&exact_h) {
-                            self.tab_exact_widths.insert(label_text.to_string(), exact_h);
-                            self.needs_relayout = true;
-                        }
                     }
                 }
             }
@@ -210,7 +187,9 @@ impl ButtonStrip {
         let get_button_weight = |i: usize| -> f32 {
             let label = &self.buttons[i];
             let trimmed = label.trim();
-            let font_size = crate::layout::menubar_font_parsed().1;
+            let font_info = crate::layout::menubar_font_parsed();
+            let font_fam = font_info.0;
+            let font_size = font_info.1;
             let padding = crate::layout::button_padding();
             if self.vertical {
                 let space_idx = trimmed.find(' ');
@@ -220,15 +199,14 @@ impl ButtonStrip {
                 } else {
                     trimmed
                 };
-                let text_w = self.tab_exact_widths.get(label_text).copied()
-                    .unwrap_or_else(|| TextLabel::estimate_width(label_text, font_size));
+                let text_w = crate::widget::display::measure_text_width(label_text, &font_fam, font_size);
                 if has_icon {
                     (text_w + 12.0 + 3.0 * padding).max(1.0)
                 } else {
                     (text_w + 2.0 * padding).max(1.0)
                 }
             } else {
-                let text_w = TextLabel::estimate_width(label, font_size);
+                let text_w = crate::widget::display::measure_text_width(label, &font_fam, font_size);
                 (text_w + 2.0 * padding).max(1.0)
             }
         };
@@ -238,8 +216,8 @@ impl ButtonStrip {
             weights.push(get_button_weight(i));
         }
 
+        let spacing = crate::layout::button_strip_spacing();
         if self.vertical {
-            let spacing = 8.0;
             let mut current_y = y;
             let mut btn_h = 0.0;
             for i in 0..=idx {
@@ -255,7 +233,7 @@ impl ButtonStrip {
             for i in 0..=idx {
                 btn_w = weights[i];
                 if i < idx {
-                    current_x += btn_w;
+                    current_x += btn_w + spacing;
                 }
             }
             (current_x, y, btn_w, h)
@@ -283,10 +261,6 @@ impl Element for ButtonStrip {
             self.generate_rotated_labels();
             changed = true;
         }
-        if self.needs_relayout {
-            self.needs_relayout = false;
-            changed = true;
-        }
         changed
     }
 
@@ -395,7 +369,9 @@ impl Element for ButtonStrip {
 
     fn text_labels(&self) -> Vec<TextLabel> {
         let mut labels = Vec::new();
-        let font_size = 12.0;
+        let font_info = crate::layout::menubar_font_parsed();
+        let font_fam = font_info.0;
+        let font_size = font_info.1;
         for (i, btn_label) in self.buttons.iter().enumerate() {
             let r = self.item_rect(i);
             let color = if Some(i) == self.selected {
@@ -413,7 +389,7 @@ impl Element for ButtonStrip {
                     let icon = icon.trim();
                     if !icon.is_empty() {
                         let icon_font_size = 14.0;
-                        let est_icon_w = TextLabel::estimate_width(icon, icon_font_size);
+                        let est_icon_w = crate::widget::display::measure_text_width(icon, &font_fam, icon_font_size);
                         let padding_y = crate::layout::button_padding();
                         let icon_y = r.1 + (padding_y - 2.0).max(0.0);
                         labels.push(TextLabel {
@@ -426,7 +402,7 @@ impl Element for ButtonStrip {
                     }
                 }
             } else {
-                let est_w = TextLabel::estimate_width(btn_label, font_size);
+                let est_w = crate::widget::display::measure_text_width(btn_label, &font_fam, font_size);
                 labels.push(TextLabel {
                     text: btn_label.clone(),
                     x: r.0 + (r.2 - est_w) / 2.0,
diff --git a/src/widget/input/checkbox.rs b/src/widget/input/checkbox.rs
index 0081881..ffd9464 100644
--- a/src/widget/input/checkbox.rs
+++ b/src/widget/input/checkbox.rs
@@ -276,7 +276,7 @@ impl Element for Toggle {
         let mut labels = Vec::new();
         if let Some(ref label) = self.base.label {
             let font_size = 12.0;
-            let est_w = TextLabel::estimate_width(label, font_size);
+            let est_w = crate::widget::display::measure_text(label, font_size);
             labels.push(TextLabel {
                 text: label.clone(),
                 x: self.base.x + (self.base.w - est_w) / 2.0,
diff --git a/src/widget/input/color_selector.rs b/src/widget/input/color_selector.rs
index 4567d11..134d00b 100644
--- a/src/widget/input/color_selector.rs
+++ b/src/widget/input/color_selector.rs
@@ -357,7 +357,7 @@ impl Element for ColorSelector {
         if self.editing {
             let font_size = 12.0;
             let cursor_text: String = self.edit_buffer.chars().take(self.cursor_idx).collect();
-            let text_w = TextLabel::estimate_width(&cursor_text, font_size);
+            let text_w = crate::widget::display::measure_text(&cursor_text, font_size);
             let caret_x = self.base.x + 4.0 + text_w;
             let caret_h = font_size * 1.15;
             let caret_y = self.base.y + top + (visual_h - caret_h) / 2.0;
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 5af7410..226f9cb 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -126,6 +126,26 @@ pub trait Element {
     fn base_mut(&mut self) -> Option<&mut Widget> { None }
     fn preferred_height(&self) -> Option<f32> { None }
 
+    fn mark_dirty(&mut self, ctx: &mut UiContext) {
+        let mut parent_id = None;
+        if let Some(b) = self.base_mut() {
+            if b.dirty {
+                return;
+            }
+            b.dirty = true;
+            parent_id = b.id.get();
+        }
+        if let Some(id) = parent_id {
+            if let Some(&p_id) = ctx.layout_tree.parents.get(&id) {
+                if let Some(&parent_ptr) = ctx.widget_registry.get(&p_id) {
+                    unsafe {
+                        (*parent_ptr).mark_dirty(ctx);
+                    }
+                }
+            }
+        }
+    }
+
     fn as_any(&self) -> &dyn std::any::Any {
         struct DummyAny;
         static DUMMY: DummyAny = DummyAny;