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

commit1c742a3fe715614a1b23897daacf66f36ddb2431
parented4422d4c0
authorLucas Galante <[email protected]>
date2026-07-01 12:02
Implement hierarchical keybindings and dynamic TextBox text measurement

 src/config.rs                | 90 ++++++++++++++++++++++++++++++++++++--------
 src/widget/input/text_box.rs | 61 +++++++++++++++++-------------
 2 files changed, 110 insertions(+), 41 deletions(-)

diff --git a/src/config.rs b/src/config.rs
index cbfc5d8..8bac96a 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -32,7 +32,42 @@ fn kdl_to_json(doc: &kdl::KdlDocument) -> serde_json::Value {
             }
         }
 
-        let val = if let Some(children) = node.children() {
+        let val = if name == "key_bindings" {
+            if let Some(children) = node.children() {
+                let mut binds = Vec::new();
+                for child in children.nodes() {
+                    let mut child_map = serde_json::Map::new();
+                    for entry in child.entries() {
+                        if let Some(prop_name) = entry.name() {
+                            let j_val = match entry.value() {
+                                kdl::KdlValue::Bool(b) => serde_json::Value::Bool(*b),
+                                kdl::KdlValue::Base2(i) |
+                                kdl::KdlValue::Base8(i) |
+                                kdl::KdlValue::Base10(i) |
+                                kdl::KdlValue::Base16(i) => serde_json::Value::Number(serde_json::Number::from(*i)),
+                                kdl::KdlValue::Base10Float(f) => {
+                                    if let Some(num) = serde_json::Number::from_f64(*f) {
+                                        serde_json::Value::Number(num)
+                                    } else {
+                                        serde_json::Value::Null
+                                    }
+                                }
+                                kdl::KdlValue::String(s) |
+                                kdl::KdlValue::RawString(s) => serde_json::Value::String(s.clone()),
+                                kdl::KdlValue::Null => serde_json::Value::Null,
+                            };
+                            child_map.insert(prop_name.value().to_string(), j_val);
+                        }
+                    }
+                    binds.push(serde_json::Value::Object(child_map));
+                }
+                serde_json::Value::Array(binds)
+            } else if has_props {
+                serde_json::Value::Object(node_map)
+            } else {
+                serde_json::Value::Null
+            }
+        } else if let Some(children) = node.children() {
             kdl_to_json(children)
         } else if has_props {
             serde_json::Value::Object(node_map)
@@ -61,17 +96,40 @@ fn kdl_to_json(doc: &kdl::KdlDocument) -> serde_json::Value {
         if let Some(existing) = map.remove(&name) {
             match existing {
                 serde_json::Value::Array(mut arr) => {
-                    arr.push(val);
+                    match val {
+                        serde_json::Value::Array(new_arr) => {
+                            arr.extend(new_arr);
+                        }
+                        _ => {
+                            arr.push(val);
+                        }
+                    }
                     map.insert(name, serde_json::Value::Array(arr));
                 }
                 other => {
-                    map.insert(name, serde_json::Value::Array(vec![other, val]));
+                    match val {
+                        serde_json::Value::Array(new_arr) => {
+                            let mut combined = vec![other];
+                            combined.extend(new_arr);
+                            map.insert(name, serde_json::Value::Array(combined));
+                        }
+                        _ => {
+                            map.insert(name, serde_json::Value::Array(vec![other, val]));
+                        }
+                    }
                 }
             }
         } else {
             let list_names = ["key_bindings", "pointer_bind", "gesture_bind", "mode_rule", "tag_layout", "startup", "device"];
             if list_names.contains(&name.as_str()) {
-                map.insert(name, serde_json::Value::Array(vec![val]));
+                match val {
+                    serde_json::Value::Array(_) => {
+                        map.insert(name, val);
+                    }
+                    _ => {
+                        map.insert(name, serde_json::Value::Array(vec![val]));
+                    }
+                }
             } else {
                 map.insert(name, val);
             }
@@ -336,7 +394,8 @@ pub fn write_keybindings_to_kdl(path: &str, keybinds: &[serde_json::Value]) -> b
     // Remove all existing key_bindings nodes
     doc.nodes_mut().retain(|n| n.name().value() != "key_bindings");
 
-    // Add new key_bindings nodes
+    // Construct nested key_bindings block
+    let mut block_str = "key_bindings {\n".to_string();
     for v in keybinds {
         if let Some(obj) = v.as_object() {
             let mods = obj.get("mods").and_then(|m| m.as_str()).unwrap_or("");
@@ -344,26 +403,27 @@ pub fn write_keybindings_to_kdl(path: &str, keybinds: &[serde_json::Value]) -> b
             let action = obj.get("action").and_then(|a| a.as_str()).unwrap_or("");
             let command = obj.get("command").and_then(|c| c.as_str()).unwrap_or("");
 
-            let mut node_str = "key_bindings".to_string();
+            block_str.push_str("    bind");
             if !action.is_empty() {
-                node_str.push_str(&format!(" action={:?}", action));
+                block_str.push_str(&format!(" action={:?}", action));
             }
             if !command.is_empty() {
-                node_str.push_str(&format!(" command={:?}", command));
+                block_str.push_str(&format!(" command={:?}", command));
             }
             if !key.is_empty() {
-                node_str.push_str(&format!(" key={:?}", key));
+                block_str.push_str(&format!(" key={:?}", key));
             }
             if !mods.is_empty() {
-                node_str.push_str(&format!(" mods={:?}", mods));
-            }
-            node_str.push('\n');
-
-            if let Ok(node) = node_str.parse::<kdl::KdlNode>() {
-                doc.nodes_mut().push(node);
+                block_str.push_str(&format!(" mods={:?}", mods));
             }
+            block_str.push('\n');
         }
     }
+    block_str.push_str("}\n");
+
+    if let Ok(node) = block_str.parse::<kdl::KdlNode>() {
+        doc.nodes_mut().push(node);
+    }
 
     let updated_str = doc.to_string();
     safe_write(path, &updated_str)
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index d0e1970..d61a77e 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -92,7 +92,6 @@ impl TextBox {
         self.text_color = color;
         self
     }
-
     pub fn with_font_size(mut self, size: f32) -> Self {
         self.font_size = size;
         self
@@ -103,6 +102,14 @@ impl TextBox {
         self
     }
 
+    pub fn char_width(&self) -> f32 {
+        crate::widget::display::measure_text_width("M", &self.font_family, self.font_size)
+    }
+
+    pub fn line_height(&self) -> f32 {
+        self.font_size * 1.333
+    }
+
     pub fn wrap_text(&self, max_chars_per_line: usize) -> (Vec<String>, Vec<(usize, usize)>) {
         let text_src = if self.editing { &self.edit_buffer } else { &self.text };
         let chars: Vec<char> = text_src.chars().collect();
@@ -331,8 +338,8 @@ impl TextBox {
             self.scroll_y = 0.0;
             return;
         }
-        let char_width = self.font_size * 0.6;
-        let line_height = self.font_size * 1.333;
+        let char_width = self.char_width();
+        let line_height = self.line_height();
         let max_chars = (((self.base.w - 16.0) / char_width).floor() as usize).max(1);
         let (lines, _) = self.wrap_text(max_chars);
         let content_h = lines.len() as f32 * line_height;
@@ -342,8 +349,8 @@ impl TextBox {
 
     pub fn scroll_to_cursor(&mut self) {
         if !self.multiline { return; }
-        let char_width = self.font_size * 0.6;
-        let line_height = self.font_size * 1.333;
+        let char_width = self.char_width();
+        let line_height = self.line_height();
         let max_chars = (((self.base.w - 16.0) / char_width).floor() as usize).max(1);
         let (_, index_map) = self.wrap_text(max_chars);
         if index_map.is_empty() { return; }
@@ -493,9 +500,9 @@ impl Element for TextBox {
         }
         let mut changed = false;
         if self.dragging && self.editing {
-            let char_width = self.font_size * 0.6;
+            let char_width = self.char_width();
             let drag_idx = if self.multiline {
-                let line_height = self.font_size * 1.333;
+                let line_height = self.line_height();
                 let max_chars = (((self.base.w - 16.0) / char_width).floor() as usize).max(1);
                 let (lines, index_map) = self.wrap_text(max_chars);
                 let top = self.base.label_offset();
@@ -536,10 +543,10 @@ impl Element for TextBox {
 
     fn drag_update(&mut self, px: f32, py: f32) -> bool {
         if self.disabled || !self.editing { return false; }
-        let char_width = self.font_size * 0.6;
+        let char_width = self.char_width();
         let top = self.base.label_offset();
         let drag_idx = if self.multiline {
-            let line_height = self.font_size * 1.333;
+            let line_height = self.line_height();
             let max_chars = (((self.base.w - 16.0) / char_width).floor() as usize).max(1);
             let (lines, index_map) = self.wrap_text(max_chars);
             let click_line = (((py - (self.base.y + top + 8.0) + self.scroll_y) / line_height).floor() as isize).max(0) as usize;
@@ -586,10 +593,10 @@ impl Element for TextBox {
                 if !self.editing {
                     self.focus();
                 } else {
-                    let char_width = self.font_size * 0.6;
+                    let char_width = self.char_width();
                     let top = self.base.label_offset();
                     let idx = if self.multiline {
-                        let line_height = self.font_size * 1.333;
+                        let line_height = self.line_height();
                         let max_chars = (((self.base.w - 16.0) / char_width).floor() as usize).max(1);
                         let (lines, index_map) = self.wrap_text(max_chars);
                         let click_line = (((py - (self.base.y + top + 8.0) + self.scroll_y) / line_height).floor() as isize).max(0) as usize;
@@ -681,7 +688,7 @@ impl Element for TextBox {
                     state.clear_selection();
                 }
                 if self.multiline {
-                    let char_width = self.font_size * 0.6;
+                    let char_width = self.char_width();
                     let max_chars = (((self.base.w - 16.0) / char_width).floor() as usize).max(1);
                     let (lines, index_map) = self.wrap_text(max_chars);
                     let (cursor_l, cursor_c) = index_map[state.cursor_idx.min(index_map.len() - 1)];
@@ -706,7 +713,7 @@ impl Element for TextBox {
                     state.clear_selection();
                 }
                 if self.multiline {
-                    let char_width = self.font_size * 0.6;
+                    let char_width = self.char_width();
                     let max_chars = (((self.base.w - 16.0) / char_width).floor() as usize).max(1);
                     let (lines, index_map) = self.wrap_text(max_chars);
                     let (cursor_l, cursor_c) = index_map[state.cursor_idx.min(index_map.len() - 1)];
@@ -834,8 +841,8 @@ impl Element for TextBox {
         }
 
         if self.editing || self.select_anchor.is_some() {
-            let char_width = self.font_size * 0.6;
-            let line_height = self.font_size * 1.333;
+            let char_width = self.char_width();
+            let line_height = self.line_height();
             
             let highlight_color = [0.20, 0.50, 0.85, 0.3];
             let cursor_color = if self.draw_bg_border {
@@ -967,8 +974,8 @@ impl Element for TextBox {
         }
 
         if self.editing || self.select_anchor.is_some() {
-            let char_width = self.font_size * 0.6;
-            let line_height = self.font_size * 1.333;
+            let char_width = self.char_width();
+            let line_height = self.line_height();
             
             let highlight_color = [0.20, 0.50, 0.85, 0.3];
             let cursor_color = if self.draw_bg_border {
@@ -1103,8 +1110,8 @@ impl Element for TextBox {
         };
 
         if self.multiline {
-            let char_width = self.font_size * 0.6;
-            let line_height = self.font_size * 1.333;
+            let char_width = self.char_width();
+            let line_height = self.line_height();
             let max_chars = (((self.base.w - 16.0) / char_width).floor() as usize).max(1);
             let (lines, _) = self.wrap_text(max_chars);
             let lines_to_draw = if is_placeholder {
@@ -1164,8 +1171,8 @@ impl Element for TextBox {
 
     fn mouse_wheel(&mut self, delta: &MouseScrollDelta, _px: f32, _py: f32, ctx: &mut UiContext) -> bool {
         if self.disabled || !self.multiline { return false; }
-        let char_width = self.font_size * 0.6;
-        let line_height = self.font_size * 1.333;
+        let char_width = self.char_width();
+        let line_height = self.line_height();
         let max_chars = (((self.base.w - 16.0) / char_width).floor() as usize).max(1);
         let (lines, _) = self.wrap_text(max_chars);
         let content_h = lines.len() as f32 * line_height;
@@ -1264,16 +1271,18 @@ mod tests {
         assert_eq!(tb.cursor_idx, 11);
         assert_eq!(tb.select_anchor, Some(0));
 
-        // 2. Click inside placed caret at index 5 (x = 10 + 8 + 5 * 7.2 = 54)
-        let pressed_inside = tb.mouse_input(MouseButton::Left, ElementState::Pressed, 54.0, 20.0, &mut dummy);
+        // 2. Click inside placed caret at index 5
+        let click_x5 = 10.0 + 8.0 + 5.0 * tb.char_width();
+        let pressed_inside = tb.mouse_input(MouseButton::Left, ElementState::Pressed, click_x5, 20.0, &mut dummy);
         assert!(pressed_inside);
         assert_eq!(tb.cursor_idx, 5);
         assert_eq!(tb.select_anchor, Some(5));
         assert!(!tb.all_selected);
 
-        // 3. Drag to index 11 (x = 10 + 8 + 11 * 7.2 = 97.2)
-        tb.drag_begin(54.0, 20.0);
-        let updated = tb.drag_update(97.2, 20.0);
+        // 3. Drag to index 11
+        let drag_x11 = 10.0 + 8.0 + 11.0 * tb.char_width();
+        tb.drag_begin(click_x5, 20.0);
+        let updated = tb.drag_update(drag_x11, 20.0);
         assert!(updated);
         assert_eq!(tb.cursor_idx, 11);
         assert_eq!(tb.select_anchor, Some(5));