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

commitb4449d25c6c28dc2338546272697c4d715d00463
parent4232d2170d
authorLucas Galante <[email protected]>
date2026-06-17 12:42
Fix context menu dangling pointer crash, resolve mutability UB, and migrate application config from TOML to JSON

 src/main.rs                        |  23 ++----
 src/widget/container/container.rs  |   2 +-
 src/widget/container/menu.rs       |   4 +-
 src/widget/container/scroll_box.rs |   2 +-
 src/widget/core.rs                 |  22 +++++
 src/widget/display/float3.rs       |   2 +-
 src/widget/display/graph.rs        |   2 +-
 src/widget/display/node.rs         |   2 +-
 src/widget/input/checkbox.rs       |   8 +-
 src/widget/input/color_selector.rs |   4 +-
 src/widget/input/dropdown.rs       |   4 +-
 src/widget/input/font_selector.rs  |   2 +-
 src/widget/input/multi_control.rs  | 159 +++++++++++++++++++++----------------
 src/widget/input/slider.rs         |   4 +-
 src/widget/input/spinbox.rs        |   4 +-
 src/widget/input/text_box.rs       |   4 +-
 src/widget/mod.rs                  |   9 ++-
 17 files changed, 149 insertions(+), 108 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index e557b41..b86443a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -686,21 +686,8 @@ impl State {
         } else {
             for (i, w) in self.widgets.iter().enumerate() {
                 for (label, font, bounds) in w.text_labels_with_font_and_bounds(ui_context) {
-                    let mut covered = false;
-                    for (pi, pw) in self.widgets.iter().enumerate() {
-                        if pi != i {
-                            if let Some((px, py, pw_val, ph)) = pw.popover_rect() {
-                                if label.is_covered_by(px, py, pw_val, ph) {
-                                    covered = true;
-                                    break;
-                                }
-                            }
-                        }
-                    }
-                    if !covered {
-                        widget_buffers.push(make_text_buffer_with_font(font_system, &label.text, label.font_size, font.as_deref()));
-                        widget_labels.push((label, bounds));
-                    }
+                    widget_buffers.push(make_text_buffer_with_font(font_system, &label.text, label.font_size, font.as_deref()));
+                    widget_labels.push((label, bounds));
                 }
             }
 
@@ -1426,11 +1413,11 @@ impl AppState {
             shift: self.shift_pressed,
         };
 
-        if crate::widget::context_menu::is_visible() {
-            if custom_event.state == crate::widget::ElementState::Pressed
+        if cce_ui::widget::context_menu::is_visible() {
+            if custom_event.state == cce_ui::widget::ElementState::Pressed
                 && custom_event.logical_key == Key::Named(NamedKey::Escape)
             {
-                crate::widget::context_menu::hide();
+                cce_ui::widget::context_menu::hide();
                 if let Some(st) = &mut self.state {
                     st.upload_vertices();
                 }
diff --git a/src/widget/container/container.rs b/src/widget/container/container.rs
index 0e96a0d..c3efa69 100644
--- a/src/widget/container/container.rs
+++ b/src/widget/container/container.rs
@@ -31,6 +31,6 @@ impl Element for Container {
 
 impl Drop for Container {
     fn drop(&mut self) {
-        focus::clear_if_matches(self);
+        clear_widget_references(self);
     }
 }
diff --git a/src/widget/container/menu.rs b/src/widget/container/menu.rs
index eeb606e..20b7c6d 100644
--- a/src/widget/container/menu.rs
+++ b/src/widget/container/menu.rs
@@ -923,7 +923,7 @@ impl Element for MenuBar {
 }
 impl Drop for MenuBar {
     fn drop(&mut self) {
-        focus::clear_if_matches(self);
+        clear_widget_references(self);
     }
 }
 
@@ -1334,7 +1334,7 @@ unsafe impl Sync for Menu {}
 
 impl Drop for Menu {
     fn drop(&mut self) {
-        focus::clear_if_matches(self);
+        clear_widget_references(self);
     }
 }
 
diff --git a/src/widget/container/scroll_box.rs b/src/widget/container/scroll_box.rs
index 59e5bd7..aa83ba3 100644
--- a/src/widget/container/scroll_box.rs
+++ b/src/widget/container/scroll_box.rs
@@ -197,7 +197,7 @@ impl Element for ScrollBox {
 
 impl Drop for ScrollBox {
     fn drop(&mut self) {
-        focus::clear_if_matches(self);
+        clear_widget_references(self);
     }
 }
 
diff --git a/src/widget/core.rs b/src/widget/core.rs
index aade366..2f71627 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -612,6 +612,20 @@ pub mod context_menu {
         CONTEXT_MENU.with(|m| m.borrow_mut().hide());
     }
 
+    pub fn clear_if_matches(w: &dyn Element) {
+        CONTEXT_MENU.with(|m| {
+            let mut menu = m.borrow_mut();
+            if let Some(ptr) = menu.target {
+                let current_data = ptr as *const () as usize;
+                let query_data = w as *const dyn Element as *const () as usize;
+                if current_data == query_data {
+                    menu.target = None;
+                    menu.visible = false;
+                }
+            }
+        });
+    }
+
     pub fn x() -> f32 { CONTEXT_MENU.with(|m| m.borrow().x) }
     pub fn y() -> f32 { CONTEXT_MENU.with(|m| m.borrow().y) }
     pub fn w() -> f32 { CONTEXT_MENU.with(|m| m.borrow().w) }
@@ -707,6 +721,11 @@ impl Widget {
 }
 
 
+pub fn clear_widget_references(w: &dyn Element) {
+    focus::clear_if_matches(w);
+    context_menu::clear_if_matches(w);
+}
+
 #[macro_export]
 macro_rules! impl_widget_base {
     ($name:ident) => {
@@ -717,5 +736,8 @@ macro_rules! impl_widget_base {
         fn as_ptr(&self) -> *mut (dyn $crate::widget::Element + 'static) {
             self as *const Self as *mut Self as *mut (dyn $crate::widget::Element + 'static)
         }
+        fn as_ptr_mut(&mut self) -> *mut (dyn $crate::widget::Element + 'static) {
+            self as *mut Self as *mut (dyn $crate::widget::Element + 'static)
+        }
     };
 }
diff --git a/src/widget/display/float3.rs b/src/widget/display/float3.rs
index 6c2a3a0..4119e91 100644
--- a/src/widget/display/float3.rs
+++ b/src/widget/display/float3.rs
@@ -310,7 +310,7 @@ impl Element for Float3 {
 
 impl Drop for Float3 {
     fn drop(&mut self) {
-        focus::clear_if_matches(self);
+        clear_widget_references(self);
     }
 }
 
diff --git a/src/widget/display/graph.rs b/src/widget/display/graph.rs
index e2dd97d..ad58b85 100644
--- a/src/widget/display/graph.rs
+++ b/src/widget/display/graph.rs
@@ -827,7 +827,7 @@ impl Element for Graph {
 
 impl Drop for Graph {
     fn drop(&mut self) {
-        focus::clear_if_matches(self);
+        clear_widget_references(self);
     }
 }
 
diff --git a/src/widget/display/node.rs b/src/widget/display/node.rs
index 07492de..6ba2e09 100644
--- a/src/widget/display/node.rs
+++ b/src/widget/display/node.rs
@@ -194,6 +194,6 @@ impl GeomController for Node {
 
 impl Drop for Node {
     fn drop(&mut self) {
-        focus::clear_if_matches(self);
+        clear_widget_references(self);
     }
 }
diff --git a/src/widget/input/checkbox.rs b/src/widget/input/checkbox.rs
index a0aba44..2811093 100644
--- a/src/widget/input/checkbox.rs
+++ b/src/widget/input/checkbox.rs
@@ -82,7 +82,7 @@ impl Element for Checkbox {
     fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
         if button == MouseButton::Right && state == ElementState::Pressed {
             if self.hit_test(px, py, ctx) {
-                ctx.handle_right_click(self.as_ptr(), px, py);
+                ctx.handle_right_click(self.as_ptr_mut(), px, py);
                 return true;
             }
         }
@@ -169,6 +169,12 @@ impl Element for Checkbox {
     fn value(&self) -> i32 { if self.checked { 1 } else { 0 } }
 }
 
+impl Drop for Checkbox {
+    fn drop(&mut self) {
+        clear_widget_references(self);
+    }
+}
+
 #[derive(Debug, Clone)]
 pub struct Toggle {
     base: Widget,
diff --git a/src/widget/input/color_selector.rs b/src/widget/input/color_selector.rs
index 42c545d..85e4c4c 100644
--- a/src/widget/input/color_selector.rs
+++ b/src/widget/input/color_selector.rs
@@ -176,7 +176,7 @@ impl Element for ColorSelector {
     fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
         if button == MouseButton::Right && state == ElementState::Pressed {
             if self.hit_test(px, py, ctx) {
-                ctx.handle_right_click(self.as_ptr(), px, py);
+                ctx.handle_right_click(self.as_ptr_mut(), px, py);
                 return true;
             }
         }
@@ -499,7 +499,7 @@ impl Element for ColorSelector {
 
 impl Drop for ColorSelector {
     fn drop(&mut self) {
-        focus::clear_if_matches(self);
+        clear_widget_references(self);
     }
 }
 
diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index 789aa3c..48f2e37 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -226,7 +226,7 @@ impl Element for Dropdown {
     fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
         if button == MouseButton::Right && state == ElementState::Pressed {
             if self.hit_test(px, py, ctx) {
-                ctx.handle_right_click(self.as_ptr(), px, py);
+                ctx.handle_right_click(self.as_ptr_mut(), px, py);
                 return true;
             }
         }
@@ -390,7 +390,7 @@ impl Element for Dropdown {
 
 impl Drop for Dropdown {
     fn drop(&mut self) {
-        focus::clear_if_matches(self);
+        clear_widget_references(self);
     }
 }
 
diff --git a/src/widget/input/font_selector.rs b/src/widget/input/font_selector.rs
index 6f12848..4003bc3 100644
--- a/src/widget/input/font_selector.rs
+++ b/src/widget/input/font_selector.rs
@@ -146,7 +146,7 @@ impl Element for FontSelector {
 
 impl Drop for FontSelector {
     fn drop(&mut self) {
-        focus::clear_if_matches(self);
+        clear_widget_references(self);
     }
 }
 
diff --git a/src/widget/input/multi_control.rs b/src/widget/input/multi_control.rs
index bb2978a..6af7b1a 100644
--- a/src/widget/input/multi_control.rs
+++ b/src/widget/input/multi_control.rs
@@ -684,7 +684,7 @@ fn get_application_config_path() -> std::path::PathBuf {
         .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")
+    base_dir.join(app_name).join("this-application.json")
 }
 
 fn save_config(name: &str, controls: &[InstancedControl]) {
@@ -693,89 +693,75 @@ fn save_config(name: &str, controls: &[InstancedControl]) {
         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;
+    let mut map = if path.exists() {
+        let content = std::fs::read_to_string(&path).unwrap_or_default();
+        serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&content)
+            .unwrap_or_default()
+    } else {
+        serde_json::Map::new()
+    };
 
-    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 Ok(json_val) = serde_json::to_value(controls) {
+        map.insert(name.to_string(), json_val);
     }
 
-    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);
+    if let Ok(updated_content) = serde_json::to_string_pretty(&map) {
+        let _ = std::fs::write(&path, updated_content);
     }
-
-    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;
+    
+    if path.exists() {
+        if let Ok(content) = std::fs::read_to_string(&path) {
+            if let Ok(serde_json::Value::Object(map)) = serde_json::from_str(&content) {
+                if let Some(val) = map.get(name) {
+                    if let Ok(controls) = serde_json::from_value::<Vec<InstancedControl>>(val.clone()) {
+                        return controls;
+                    }
+                }
+            }
+        }
+        return Vec::new();
+    }
+
+    let toml_path = path.with_extension("toml");
+    if toml_path.exists() {
+        if let Ok(content) = std::fs::read_to_string(&toml_path) {
+            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) {
+                                    save_config(name, &controls);
+                                    let _ = std::fs::remove_file(&toml_path);
+                                    return controls;
+                                }
+                            }
                         }
                     }
                 }
             }
         }
     }
+
     Vec::new()
 }
 
@@ -836,6 +822,39 @@ mod tests {
         }
     }
 
+    #[test]
+    fn test_config_toml_to_json_migration() {
+        let _guard = ENV_MUTEX.lock().unwrap();
+        let temp_dir = std::env::temp_dir().join("cce_test_home_migration");
+        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 path = get_application_config_path();
+        let toml_path = path.with_extension("toml");
+        if let Some(parent) = toml_path.parent() {
+            let _ = std::fs::create_dir_all(parent);
+        }
+        let toml_content = "[multicontrol]\ntest_param = '[{\"key\":\"x\",\"control_type\":\"Spinbox\",\"value\":\"5\"}]'\n";
+        std::fs::write(&toml_path, toml_content).unwrap();
+
+        let loaded = load_config("test_param");
+        assert_eq!(loaded.len(), 1);
+        assert_eq!(loaded[0].key, "x");
+        assert_eq!(loaded[0].value, "5");
+
+        assert!(!toml_path.exists());
+        assert!(path.exists());
+
+        let json_content = std::fs::read_to_string(&path).unwrap();
+        assert!(json_content.contains("\"test_param\""));
+
+        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();
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index 7e93d06..721e479 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -234,7 +234,7 @@ impl Element for Slider {
     fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
         if button == MouseButton::Right && state == ElementState::Pressed {
             if self.hit_test(px, py, ctx) {
-                ctx.handle_right_click(self.as_ptr(), px, py);
+                ctx.handle_right_click(self.as_ptr_mut(), px, py);
                 return true;
             }
         }
@@ -432,7 +432,7 @@ impl Element for Slider {
 
 impl Drop for Slider {
     fn drop(&mut self) {
-        focus::clear_if_matches(self);
+        clear_widget_references(self);
     }
 }
 
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index f3bfba6..f7ba592 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -149,7 +149,7 @@ impl Element for Spinbox {
     fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
         if button == MouseButton::Right && state == ElementState::Pressed {
             if self.hit_test(px, py, ctx) {
-                ctx.handle_right_click(self.as_ptr(), px, py);
+                ctx.handle_right_click(self.as_ptr_mut(), px, py);
                 return true;
             }
         }
@@ -388,7 +388,7 @@ impl Element for Spinbox {
 
 impl Drop for Spinbox {
     fn drop(&mut self) {
-        focus::clear_if_matches(self);
+        clear_widget_references(self);
     }
 }
 
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index 201622b..36d6b25 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -506,7 +506,7 @@ impl Element for TextBox {
         if self.disabled { return false; }
         if button == MouseButton::Right && state == ElementState::Pressed {
             if self.hit_test(px, py, ctx) {
-                ctx.handle_right_click(self.as_ptr(), px, py);
+                ctx.handle_right_click(self.as_ptr_mut(), px, py);
                 return true;
             }
         }
@@ -941,7 +941,7 @@ impl Element for TextBox {
 
 impl Drop for TextBox {
     fn drop(&mut self) {
-        focus::clear_if_matches(self);
+        clear_widget_references(self);
     }
 }
 
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index b3797f6..1c0367c 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -145,6 +145,13 @@ pub trait Element {
         }
         std::ptr::null_mut::<DummyElement>() as *mut (dyn Element + 'static)
     }
+    fn as_ptr_mut(&mut self) -> *mut (dyn Element + 'static) {
+        struct DummyElement;
+        impl Element for DummyElement {
+            fn color(&self) -> [f32; 4] { [0.0, 0.0, 0.0, 0.0] }
+        }
+        std::ptr::null_mut::<DummyElement>() as *mut (dyn Element + 'static)
+    }
 
     fn handle_event(&mut self, event: &Event, ctx: &mut UiContext) -> bool {
         match event {
@@ -526,7 +533,7 @@ pub mod editor;
 
 // Re-exports
 pub use self::editor::TextEditorState;
-pub use self::core::{Widget, focus, hover_animation, popovers, clipboard, context_menu};
+pub use self::core::{Widget, focus, hover_animation, popovers, clipboard, context_menu, clear_widget_references};
 pub use self::input::{
     Button, TextBox, Spinbox, Dropdown, Checkbox, Toggle, Slider, RangeSlider,
     ColorSelector, Finger, Trackpad, Canvas, get_font_db, ActiveThumb, FontSelector,