GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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
+}
+