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

commitf34ee3cfefdaf1b6d4c77eed94ed798bf8c2135a
parent02a783e66a
authorLucas Galante <[email protected]>
date2026-07-06 21:55
feat: optionally integrate search box at bottom of List element

 src/color.rs                           |  38 ++++++
 src/config.rs                          |   4 +-
 src/widget/container/scrolling_list.rs | 228 +++++++++++++++++++++++++--------
 src/widget/container/treelist.rs       |   2 +-
 src/widget/mod.rs                      |  42 ++++++
 5 files changed, 261 insertions(+), 53 deletions(-)

diff --git a/src/color.rs b/src/color.rs
index 708a8cb..7315ed4 100644
--- a/src/color.rs
+++ b/src/color.rs
@@ -103,6 +103,8 @@ static TREE_BORDER_COLOR: RwLock<[f32; 4]> = RwLock::new([0.18, 0.18, 0.24, 1.0]
 static TREE_BORDER_HOVER_COLOR: RwLock<[f32; 4]> = RwLock::new([0.25, 0.25, 0.35, 1.0]);
 static TREE_BORDER_FOCUS_COLOR: RwLock<[f32; 4]> = RwLock::new([0.30, 0.50, 0.32, 1.0]);
 static TREE_OPEN_SEARCH_KEY: RwLock<String> = RwLock::new(String::new());
+static LIST_OPEN_SEARCH_KEY: RwLock<String> = RwLock::new(String::new());
+static LIST_CLOSE_SEARCH_KEY: RwLock<String> = RwLock::new(String::new());
 
 static TREE_SECTION_BG_COLOR: RwLock<[f32; 4]> = RwLock::new([0.07, 0.07, 0.09, 1.0]);
 static TREE_SECTION_BG_HOVER_COLOR: RwLock<[f32; 4]> = RwLock::new([0.10, 0.12, 0.18, 1.0]);
@@ -602,6 +604,12 @@ fn parse_and_set_colors(content: &str) {
     if let Some(k) = val.pointer("/style/data/tree/open_search").and_then(|v| v.as_str()) {
         if let Ok(mut lock) = TREE_OPEN_SEARCH_KEY.write() { *lock = k.to_string(); }
     }
+    if let Some(k) = val.pointer("/style/data/list/open_search").and_then(|v| v.as_str()) {
+        if let Ok(mut lock) = LIST_OPEN_SEARCH_KEY.write() { *lock = k.to_string(); }
+    }
+    if let Some(k) = val.pointer("/style/data/list/close_search").and_then(|v| v.as_str()) {
+        if let Ok(mut lock) = LIST_CLOSE_SEARCH_KEY.write() { *lock = k.to_string(); }
+    }
     if let Some(c) = get_color("/style/control/scrollbar/track_color") {
         if let Ok(mut lock) = SCROLLBAR_TRACK_COLOR.write() { *lock = c; }
     }
@@ -1502,6 +1510,36 @@ pub fn set_tree_open_search_key(k: String) {
     }
 }
 
+pub fn list_open_search_key() -> String {
+    load_colors_once();
+    let val = LIST_OPEN_SEARCH_KEY.read().unwrap().clone();
+    if val.is_empty() {
+        "ctrl+f".to_string()
+    } else {
+        val
+    }
+}
+pub fn set_list_open_search_key(k: String) {
+    if let Ok(mut lock) = LIST_OPEN_SEARCH_KEY.write() {
+        *lock = k;
+    }
+}
+
+pub fn list_close_search_key() -> String {
+    load_colors_once();
+    let val = LIST_CLOSE_SEARCH_KEY.read().unwrap().clone();
+    if val.is_empty() {
+        "escape".to_string()
+    } else {
+        val
+    }
+}
+pub fn set_list_close_search_key(k: String) {
+    if let Ok(mut lock) = LIST_CLOSE_SEARCH_KEY.write() {
+        *lock = k;
+    }
+}
+
 pub fn backplate_menubar_color() -> [f32; 4] {
     load_colors_once();
     *BACKPLATE_MENUBAR_COLOR.read().unwrap()
diff --git a/src/config.rs b/src/config.rs
index d203527..38924df 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -884,7 +884,7 @@ pub fn value_to_kdl_with_annotations(
                                     let s_clean = s.trim_start_matches('#');
                                     let ty = if s_clean.len() == 8 { "rgba" } else { "rgb" };
                                     (format!("\"{}\"", s), Some(ty.to_string()))
-                                } else if prop_name == "key" || prop_name == "keybind" || prop_name == "shortcut" || prop_name == "open_search" || prop_name == "delete" || prop_name.ends_with("_key") || prop_name.ends_with(".key") || prop_name.ends_with(".keybind") || prop_name == "brightness_up" || prop_name == "brightness_down" || prop_name.ends_with(".brightness_up") || prop_name.ends_with(".brightness_down") {
+                                } else if prop_name == "key" || prop_name == "keybind" || prop_name == "shortcut" || prop_name == "open_search" || prop_name == "close_search" || prop_name == "delete" || prop_name.ends_with("_key") || prop_name.ends_with(".key") || prop_name.ends_with(".keybind") || prop_name.ends_with(".open_search") || prop_name.ends_with(".close_search") || prop_name == "brightness_up" || prop_name == "brightness_down" || prop_name.ends_with(".brightness_up") || prop_name.ends_with(".brightness_down") {
                                     (format!("\"{}\"", s), Some("keybind".to_string()))
                                 } else {
                                     (format!("\"{}\"", s), None)
@@ -944,7 +944,7 @@ pub fn value_to_kdl_with_annotations(
                         let s_clean = s.trim_start_matches('#');
                         let ty = if s_clean.len() == 8 { "rgba" } else { "rgb" };
                         (format!("\"{}\"", s), Some(ty.to_string()))
-                    } else if key == "key" || key == "keybind" || key == "shortcut" || key == "open_search" || key == "delete" || key.ends_with("_key") || key.ends_with(".key") || key.ends_with(".keybind") || key == "brightness_up" || key == "brightness_down" || key.ends_with(".brightness_up") || key.ends_with(".brightness_down") {
+                    } else if key == "key" || key == "keybind" || key == "shortcut" || key == "open_search" || key == "close_search" || key == "delete" || key.ends_with("_key") || key.ends_with(".key") || key.ends_with(".keybind") || key.ends_with(".open_search") || key.ends_with(".close_search") || key == "brightness_up" || key == "brightness_down" || key.ends_with(".brightness_up") || key.ends_with(".brightness_down") {
                         (format!("\"{}\"", s), Some("keybind".to_string()))
                     } else {
                         (format!("\"{}\"", s), None)
diff --git a/src/widget/container/scrolling_list.rs b/src/widget/container/scrolling_list.rs
index e3794ab..a796c7f 100644
--- a/src/widget/container/scrolling_list.rs
+++ b/src/widget/container/scrolling_list.rs
@@ -38,6 +38,9 @@ pub struct List {
     pub clicked_row: Option<usize>,
     pub double_clicked_row: Option<usize>,
     pub last_click_time: Option<std::time::Instant>,
+    pub search_enabled: bool,
+    pub search_visible: bool,
+    pub search_box: TextBox,
 }
 
 impl List {
@@ -56,9 +59,17 @@ impl List {
             clicked_row: None,
             double_clicked_row: None,
             last_click_time: None,
+            search_enabled: false,
+            search_visible: false,
+            search_box: TextBox::new(String::new()).with_placeholder("Search...").with_update_on_type(true),
         }
     }
 
+    pub fn with_search(mut self, enabled: bool) -> Self {
+        self.search_enabled = enabled;
+        self
+    }
+
     pub fn with_columns(mut self, columns: Vec<ListColumn>) -> Self {
         self.columns = Some(columns);
         self
@@ -170,6 +181,59 @@ impl List {
     pub fn set_scroll_y(&mut self, val: f32) {
         self.scroll_box.scroll_y = val;
     }
+
+    pub fn handle_list_navigation(&mut self, event: &KeyEvent, ctx: &mut UiContext) -> bool {
+        if self.columns.is_none() {
+            return false;
+        }
+        if event.state == ElementState::Pressed {
+            let mut current_selected = None;
+            for (idx, r) in self.rows.iter().enumerate() {
+                if r.selected {
+                    current_selected = Some(idx);
+                    break;
+                }
+            }
+
+            let mut next_selected = None;
+            if event.logical_key == Key::Named(NamedKey::ArrowDown) {
+                if let Some(curr) = current_selected {
+                    if curr + 1 < self.rows.len() {
+                        next_selected = Some(curr + 1);
+                    }
+                } else if !self.rows.is_empty() {
+                    next_selected = Some(0);
+                }
+            } else if event.logical_key == Key::Named(NamedKey::ArrowUp) {
+                if let Some(curr) = current_selected {
+                    if curr > 0 {
+                        next_selected = Some(curr - 1);
+                    }
+                }
+            }
+
+            if let Some(next) = next_selected {
+                for (idx, r) in self.rows.iter_mut().enumerate() {
+                    r.selected = idx == next;
+                }
+                self.clicked_row = Some(next);
+
+                let item_height_full = self.item_height + self.item_gap;
+                let item_y = next as f32 * item_height_full + 2.0;
+                let viewport_h = self.scroll_box.viewport_h;
+
+                if item_y < self.scroll_box.scroll_y {
+                    self.scroll_box.scroll_y = item_y;
+                } else if item_y + self.item_height > self.scroll_box.scroll_y + viewport_h {
+                    self.scroll_box.scroll_y = item_y + self.item_height - viewport_h;
+                }
+
+                self.mark_dirty(ctx);
+                return true;
+            }
+        }
+        false
+    }
 }
 
 impl Default for List {
@@ -191,7 +255,16 @@ impl Element for List {
         self.base.y = y;
         self.base.w = w;
         self.base.h = h;
-        self.scroll_box.set_rect(x, y, w, h);
+        if self.search_enabled && self.search_visible {
+            let search_margin_y = 6.0;
+            let search_h = 26.0;
+            let offset_y = search_h + 2.0 * search_margin_y;
+            self.scroll_box.set_rect(x, y, w, h - offset_y);
+            self.search_box.set_rect(x + 8.0, y + h - offset_y + search_margin_y, w - 16.0, search_h);
+        } else {
+            self.scroll_box.set_rect(x, y, w, h);
+            self.search_box.set_rect(x, y + h, w, 0.0);
+        }
         if self.columns.is_some() && !self.rows.is_empty() {
             self.update_bounds_from_rows();
         }
@@ -249,9 +322,15 @@ impl Element for List {
     }
 
     fn on_cursor_moved(&mut self, px: f32, py: f32, ctx: &mut UiContext) -> bool {
+        let mut handled = false;
+        if self.search_enabled && self.search_visible {
+            if self.search_box.on_cursor_moved(px, py, ctx) {
+                handled = true;
+            }
+        }
         self.scroll_box.cursor_moved(px, py, ctx);
         if self.columns.is_none() {
-            return false;
+            return handled;
         }
         let (x, _, w, _) = self.rect();
         let item_height_full = self.item_height + self.item_gap;
@@ -269,7 +348,7 @@ impl Element for List {
         
         let changed = self.hovered_row != new_hovered;
         self.hovered_row = new_hovered;
-        changed
+        changed || handled
     }
 
     fn mouse_wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32, ctx: &mut UiContext) -> bool {
@@ -277,6 +356,12 @@ impl Element for List {
     }
 
     fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
+        if self.search_enabled && self.search_visible {
+            if self.search_box.mouse_input(button, state, px, py, ctx) {
+                ctx.set_focused(&mut self.search_box);
+                return true;
+            }
+        }
         let (x, _, w, _) = self.rect();
         let was_scroll = self.scroll_box.mouse_input(button, state, px, py, ctx);
         
@@ -430,6 +515,9 @@ impl Element for List {
                     item.2 = Some(sb_bounds);
                 }
             }
+            if self.search_enabled && self.search_visible {
+                labels.extend(self.search_box.text_labels_with_font_and_bounds(ctx));
+            }
             return labels;
         }
         let (x, _, w, _) = self.rect();
@@ -526,62 +614,63 @@ impl Element for List {
                 }
             }
         }
+        if self.search_enabled && self.search_visible {
+            result.extend(self.search_box.text_labels_with_font_and_bounds(ctx));
+        }
         result
     }
 
     fn keyboard_input(&mut self, event: &KeyEvent, ctx: &mut UiContext) -> bool {
-        if self.scroll_box.keyboard_input(event, ctx) {
-            return true;
-        }
-        if self.columns.is_none() {
-            return false;
-        }
-        if event.state == ElementState::Pressed {
-            let mut current_selected = None;
-            for (idx, r) in self.rows.iter().enumerate() {
-                if r.selected {
-                    current_selected = Some(idx);
-                    break;
-                }
-            }
-
-            let mut next_selected = None;
-            if event.logical_key == Key::Named(NamedKey::ArrowDown) {
-                if let Some(curr) = current_selected {
-                    if curr + 1 < self.rows.len() {
-                        next_selected = Some(curr + 1);
-                    }
-                } else if !self.rows.is_empty() {
-                    next_selected = Some(0);
+        if self.search_enabled {
+            let open_key = crate::color::list_open_search_key();
+            let close_key = crate::color::list_close_search_key();
+            
+            if event.state == ElementState::Pressed {
+                if self.search_visible && match_key_shortcut(event, &close_key) {
+                    self.search_visible = false;
+                    self.search_box.text.clear();
+                    self.search_box.edit_buffer.clear();
+                    self.search_box.just_changed = true;
+                    self.search_box.unfocus();
+                    ctx.clear_focus();
+                    
+                    let (lx, ly, lw, lh) = (self.base.x, self.base.y, self.base.w, self.base.h);
+                    self.set_rect(lx, ly, lw, lh);
+                    self.mark_dirty(ctx);
+                    return true;
                 }
-            } else if event.logical_key == Key::Named(NamedKey::ArrowUp) {
-                if let Some(curr) = current_selected {
-                    if curr > 0 {
-                        next_selected = Some(curr - 1);
-                    }
+                
+                if !self.search_visible && match_key_shortcut(event, &open_key) {
+                    self.search_visible = true;
+                    let (lx, ly, lw, lh) = (self.base.x, self.base.y, self.base.w, self.base.h);
+                    self.set_rect(lx, ly, lw, lh);
+                    ctx.set_focused(&mut self.search_box);
+                    self.search_box.focus();
+                    self.mark_dirty(ctx);
+                    return true;
                 }
             }
-
-            if let Some(next) = next_selected {
-                for (idx, r) in self.rows.iter_mut().enumerate() {
-                    r.selected = idx == next;
+            
+            let is_arrow = event.logical_key == Key::Named(NamedKey::ArrowDown) || event.logical_key == Key::Named(NamedKey::ArrowUp);
+            if is_arrow {
+                if self.handle_list_navigation(event, ctx) {
+                    return true;
                 }
-                self.clicked_row = Some(next);
-
-                let item_height_full = self.item_height + self.item_gap;
-                let item_y = next as f32 * item_height_full + 2.0;
-                let viewport_h = self.scroll_box.viewport_h;
-
-                if item_y < self.scroll_box.scroll_y {
-                    self.scroll_box.scroll_y = item_y;
-                } else if item_y + self.item_height > self.scroll_box.scroll_y + viewport_h {
-                    self.scroll_box.scroll_y = item_y + self.item_height - viewport_h;
+            }
+            
+            if self.search_visible {
+                if self.search_box.keyboard_input(event, ctx) {
+                    return true;
                 }
-
-                self.mark_dirty(ctx);
-                return true;
             }
         }
+        
+        if self.scroll_box.keyboard_input(event, ctx) {
+            return true;
+        }
+        if self.handle_list_navigation(event, ctx) {
+            return true;
+        }
         false
     }
 
@@ -595,10 +684,49 @@ impl Element for List {
     }
 
     fn parent(&self, _ctx: &UiContext) -> Option<*mut (dyn Element + 'static)> { self.scroll_box.parent(_ctx) }
-    fn set_parent(&mut self, parent: Option<*mut (dyn Element + 'static)>, ctx: &mut UiContext) { self.scroll_box.set_parent(parent, ctx); }
-    fn children(&self, ctx: &UiContext) -> Vec<*mut (dyn Element + 'static)> { self.scroll_box.children(ctx) }
+    fn set_parent(&mut self, parent: Option<*mut (dyn Element + 'static)>, ctx: &mut UiContext) {
+        self.scroll_box.set_parent(parent, ctx);
+        if parent.is_some() {
+            let self_ptr = self as *mut Self;
+            let self_id = self.base.id();
+            unsafe {
+                let sb_ptr = &mut (*self_ptr).search_box as *mut TextBox as *mut (dyn Element + 'static);
+                let sb_id = (*self_ptr).search_box.base().unwrap().id();
+                ctx.register_widget(sb_id, sb_ptr);
+                ctx.link_ids(self_id, sb_id);
+                (*sb_ptr).set_parent(Some(self_ptr), ctx);
+            }
+        }
+    }
+    fn children(&self, ctx: &UiContext) -> Vec<*mut (dyn Element + 'static)> {
+        let mut list = self.scroll_box.children(ctx);
+        if self.search_enabled && self.search_visible {
+            let self_ptr = self as *const Self as *mut Self;
+            unsafe {
+                list.push(&mut (*self_ptr).search_box as *mut TextBox as *mut (dyn Element + 'static));
+            }
+        }
+        list
+    }
     fn add_child(&mut self, child: *mut (dyn Element + 'static), ctx: &mut UiContext) { self.scroll_box.add_child(child, ctx); }
     fn clear_children(&mut self, ctx: &mut UiContext) { self.scroll_box.clear_children(ctx); }
+    fn prepare_text(&mut self, fs: &mut glyphon::FontSystem) {
+        if self.search_enabled && self.search_visible {
+            self.search_box.prepare_text(fs);
+        }
+    }
+    fn tick(&mut self, dt: f32, ctx: &mut UiContext) -> bool {
+        let mut changed = false;
+        if self.scroll_box.tick(dt, ctx) {
+            changed = true;
+        }
+        if self.search_enabled && self.search_visible {
+            if self.search_box.tick(dt, ctx) {
+                changed = true;
+            }
+        }
+        changed
+    }
 }
 
 unsafe impl Send for List {}
diff --git a/src/widget/container/treelist.rs b/src/widget/container/treelist.rs
index 2476344..7adfdb4 100644
--- a/src/widget/container/treelist.rs
+++ b/src/widget/container/treelist.rs
@@ -1016,7 +1016,7 @@ impl Element for TreeList {
                                 } else {
                                     Some("rgb")
                                 }
-                            } else if name == "key" || name == "keybind" || name == "shortcut" || name == "open_search" || name == "delete" || name.ends_with("_key") || name.ends_with(".key") || name.ends_with(".keybind") || name.ends_with(".shortcut") || name.ends_with("_delete") || name.ends_with(".delete") {
+                            } else if name == "key" || name == "keybind" || name == "shortcut" || name == "open_search" || name == "close_search" || name == "delete" || name.ends_with("_key") || name.ends_with(".key") || name.ends_with(".keybind") || name.ends_with(".shortcut") || name.ends_with(".open_search") || name.ends_with(".close_search") || name.ends_with("_delete") || name.ends_with(".delete") {
                                 Some("keybind")
                             } else if name == "font" || name.ends_with("_font") || name.ends_with(".font") {
                                 Some("font")
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 4774277..edd9e57 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -879,3 +879,45 @@ impl CornerRadii {
         Self::new(radius, radius, radius, radius)
     }
 }
+
+pub fn match_key_shortcut(event: &KeyEvent, shortcut_str: &str) -> bool {
+    let shortcut_lower = shortcut_str.to_lowercase();
+    let parts: Vec<&str> = shortcut_lower.split('+').collect();
+    
+    let mut req_ctrl = false;
+    let mut req_shift = false;
+    let mut req_key = "";
+    
+    for part in parts {
+        match part {
+            "ctrl" | "control" => req_ctrl = true,
+            "shift" => req_shift = true,
+            "super" | "win" | "logo" | "alt" | "meta" => {}
+            k => req_key = k,
+        }
+    }
+    
+    if event.ctrl != req_ctrl { return false; }
+    if event.shift != req_shift { return false; }
+    
+    if let Key::Character(ref ch) = event.logical_key {
+        let ch_lower = ch.to_lowercase();
+        if req_key.len() == 1 {
+            return ch_lower == req_key;
+        } else {
+            let mapped_key = match req_key {
+                "slash" => "/",
+                "enter" => "enter",
+                "escape" => "escape",
+                "space" => " ",
+                k => k,
+            };
+            return ch_lower == mapped_key;
+        }
+    } else if let Key::Named(nk) = event.logical_key {
+        let nk_str = format!("{:?}", nk).to_lowercase();
+        return nk_str == req_key;
+    }
+    false
+}
+