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

commit795d8ff3f09ec2e4e700050914ab3be4df65ed3b
parent2416904568
authorLucas Galante <[email protected]>
date2026-08-25 12:15
parameters: textpick rows — a text box with a completion picker

The Houdini-style attribute/group chooser: a "textpick:a,b,c" row type
(the spec-string convention) builds the text row's TextBox PLUS a slim
menu-button Dropdown at its right edge; the picker's options ride the type
string, a pick fills the box (both the direct dispatch and the open-popover
priority path drain into it — the box IS the row value), and an option-less
textpick degrades to a plain text row. All text-row sites route through
is_text_row; the popover machinery (registration, hit reach, paint) comes
free from the choices vector the picker shares with dropdown rows.

 src/widget/container/parameters_bg.rs | 130 +++++++++++++++++++++++++++++++---
 1 file changed, 119 insertions(+), 11 deletions(-)

diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 2b6f23a..f14efc6 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -31,6 +31,17 @@ use crate::widget::{
     MouseScrollDelta, NamedKey, Paint, ParamController, TextEditorState, UiContext,
 };
 
+/// A text row, in either variant: plain (`"text"`), or with a completion
+/// picker (`"textpick:a,b,c"` — the Houdini-style attribute/group chooser: a
+/// TextBox plus a slim menu-button Dropdown at its right edge whose pick
+/// fills the box; the host supplies the candidates in the type string).
+fn is_text_row(t: &str) -> bool {
+    t == "text" || t.starts_with("textpick")
+}
+
+/// Width of a textpick row's picker trigger, carved off the TextBox's right.
+const PICK_W: f32 = 26.0;
+
 pub struct ParametersBg {
     rect: Rect,
     display_params: Vec<(String, String, String)>,
@@ -187,7 +198,7 @@ impl ParametersBg {
             108.0
         } else if p.2.starts_with("slider") {
             38.0
-        } else if p.2 == "text" || p.2.starts_with("spinbox") || p.2.starts_with("choice") {
+        } else if is_text_row(&p.2) || p.2.starts_with("spinbox") || p.2.starts_with("choice") {
             42.0
         } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
             40.0
@@ -588,13 +599,22 @@ impl ParametersBg {
         for (i, d_opt) in self.choices.iter_mut().enumerate() {
             if let Some(d) = d_opt {
                 let r = rects[i];
-                d.set_rect(r.0, r.1, r.2, r.3);
+                if self.display_params[i].2.starts_with("textpick") {
+                    // The picker trigger: a sliver at the text box's right.
+                    d.set_rect(r.0 + r.2 - PICK_W, r.1, PICK_W, r.3);
+                } else {
+                    d.set_rect(r.0, r.1, r.2, r.3);
+                }
             }
         }
         for (i, tb_opt) in self.texts.iter_mut().enumerate() {
             if let Some(tb) = tb_opt {
                 let r = rects[i];
-                tb.set_rect(r.0, r.1, r.2, r.3);
+                if self.display_params[i].2.starts_with("textpick") && self.choices[i].is_some() {
+                    tb.set_rect(r.0, r.1, r.2 - PICK_W - 4.0, r.3);
+                } else {
+                    tb.set_rect(r.0, r.1, r.2, r.3);
+                }
             }
         }
         for (i, cb_opt) in self.toggles.iter_mut().enumerate() {
@@ -697,10 +717,13 @@ impl ParametersBg {
                 if let Some(sb) = &self.spinboxes[i] {
                     labels.extend(sb.own_text_labels());
                 }
-            } else if ptype == "text" {
+            } else if is_text_row(ptype) {
                 if let Some(tb) = &self.texts[i] {
                     labels.extend(tb.own_text_labels());
                 }
+                if let Some(d) = &self.choices[i] {
+                    labels.extend(d.own_text_labels());
+                }
             } else if ptype.starts_with("choice") {
                 if let Some(d) = &self.choices[i] {
                     labels.extend(d.own_text_labels());
@@ -801,11 +824,14 @@ impl ParametersBg {
                         let val2 = min + f.values[2] * (max - min);
                         p.1 = format!("{:.2}:{:.2}:{:.2}", val0, val1, val2);
                     }
-                } else if p.2 == "text" {
+                } else if is_text_row(&p.2) {
                     if let Some(tb) = &mut self.texts[idx] {
                         tb.unfocus();
                         p.1 = tb.text.clone();
                     }
+                    if let Some(d) = &mut self.choices[idx] {
+                        d.unfocus();
+                    }
                 } else if p.2.starts_with("choice") {
                     if let Some(d) = &mut self.choices[idx] {
                         d.unfocus();
@@ -909,10 +935,13 @@ impl ParametersBg {
                         }
                     }
                 }
-            } else if p.2 == "text" {
+            } else if is_text_row(&p.2) {
                 if let Some(tb) = &self.texts[i] {
                     param_quads.extend(tb.extra_quads());
                 }
+                if let Some(d) = &self.choices[i] {
+                    param_quads.extend(d.extra_quads());
+                }
             } else if p.2.starts_with("choice") {
                 if let Some(d) = &self.choices[i] {
                     param_quads.extend(d.extra_quads());
@@ -970,10 +999,13 @@ impl ParametersBg {
             if hidden[i] {
                 continue;
             }
-            if p.2 == "text" {
+            if is_text_row(&p.2) {
                 if let Some(tb) = &self.texts[i] {
                     out.extend(tb.all_rounded_quads(ctx));
                 }
+                if let Some(d) = &self.choices[i] {
+                    out.extend(d.all_rounded_quads(ctx));
+                }
             } else if p.2.starts_with("slider") {
                 // Track (square style only — the recessed style has no track
                 // background), value fill, and readout box; the thumb knob is a
@@ -1106,7 +1138,7 @@ impl ParametersBg {
                 continue;
             }
             // (control, its configured corner radius, raised vs recessed)
-            let ctl: Option<(&dyn WidgetHost, f32, bool)> = if p.2 == "text" {
+            let ctl: Option<(&dyn WidgetHost, f32, bool)> = if is_text_row(&p.2) {
                 self.texts[i].as_ref().map(|w| (w as &dyn WidgetHost, crate::layout::textbox_corner_radius(), false))
             } else if p.2.starts_with("choice") {
                 self.choices[i].as_ref().map(|w| (w as &dyn WidgetHost, crate::layout::dropdown_corner_radius(), true))
@@ -1821,6 +1853,14 @@ impl Input for ParametersBg {
                             if consumed {
                                 if d.take_change() {
                                     if let Some(val) = d.get_value_string() {
+                                        // A textpick row's pick fills its
+                                        // TextBox — the box IS the value.
+                                        if self.display_params[i].2.starts_with("textpick") {
+                                            if let Some(tb) = &mut self.texts[i] {
+                                                tb.text = val.clone();
+                                                tb.edit_buffer = val.clone();
+                                            }
+                                        }
                                         self.display_params[i].1 = val;
                                     }
                                 }
@@ -1875,7 +1915,21 @@ impl Input for ParametersBg {
                                 return true;
                             }
                         }
-                    } else if p.2 == "text" {
+                    } else if is_text_row(&p.2) {
+                        if let Some(d) = &mut self.choices[i] {
+                            if d.mouse_input(button, state, px, py, ui) {
+                                if d.take_change() {
+                                    if let Some(val) = d.get_value_string() {
+                                        if let Some(tb) = &mut self.texts[i] {
+                                            tb.text = val.clone();
+                                            tb.edit_buffer = val.clone();
+                                        }
+                                        p.1 = val;
+                                    }
+                                }
+                                return true;
+                            }
+                        }
                         if let Some(tb) = &mut self.texts[i] {
                             if tb.mouse_input(button, state, px, py, ui) {
                                 if tb.editing {
@@ -2138,7 +2192,21 @@ impl Input for ParametersBg {
                                     return true;
                                 }
                             }
-                        } else if p.2 == "text" {
+                        } else if is_text_row(&p.2) {
+                            if let Some(d) = &mut self.choices[idx] {
+                                if d.open && d.keyboard_input(event, ui) {
+                                    if d.take_change() {
+                                        if let Some(val) = d.get_value_string() {
+                                            if let Some(tb) = &mut self.texts[idx] {
+                                                tb.text = val.clone();
+                                                tb.edit_buffer = val.clone();
+                                            }
+                                            p.1 = val;
+                                        }
+                                    }
+                                    return true;
+                                }
+                            }
                             if let Some(tb) = &mut self.texts[idx] {
                                 if tb.keyboard_input(event, ui) {
                                     if !tb.editing {
@@ -2523,12 +2591,25 @@ impl ParamController for ParametersBg {
                     let options: Vec<String> = options_str.split(',').map(|s| s.to_string()).collect();
                     let selected = options.iter().position(|o| o == &p.1).unwrap_or(0);
                     Some(Dropdown::new(options, selected).with_label(&p.0))
+                } else if p.2.starts_with("textpick:") {
+                    // The text row's completion picker: a menu-button Dropdown
+                    // (fixed glyph, re-fires on repeat picks) beside the box.
+                    let options: Vec<String> = p.2.strip_prefix("textpick:").unwrap_or("")
+                        .split(',')
+                        .filter(|s| !s.is_empty())
+                        .map(|s| s.to_string())
+                        .collect();
+                    if options.is_empty() {
+                        None
+                    } else {
+                        Some(Dropdown::new(options, 0).with_custom_display_text("\u{25be}"))
+                    }
                 } else {
                     None
                 }
             }).collect();
             self.texts = self.display_params.iter().map(|p| {
-                if p.2 == "text" {
+                if is_text_row(&p.2) {
                     Some(TextBox::new(p.1.clone()).with_label(&p.0))
                 } else {
                     None
@@ -2717,6 +2798,33 @@ mod tests {
         p
     }
 
+    /// The textpick text-row variant: a TextBox AND a menu-button Dropdown
+    /// share the row — the picker takes a right-edge sliver, its options come
+    /// from the type string, and a plain text row builds no picker.
+    #[test]
+    fn textpick_rows_carry_a_picker() {
+        let p = panel_with(&[
+            ("Attribute Name", "mass", "textpick:Norm,UV,Pos,Col"),
+            ("Plain", "x", "text"),
+            ("Empty", "y", "textpick:"),
+        ]);
+        assert!(p.texts[0].is_some(), "textpick keeps its TextBox");
+        let d = p.choices[0].as_ref().expect("textpick builds the picker");
+        assert_eq!(d.options, ["Norm", "UV", "Pos", "Col"]);
+        assert!(d.custom_display_text.is_some(), "menu-button mode");
+        assert!(p.choices[1].is_none(), "plain text has no picker");
+        assert!(p.texts[2].is_some() && p.choices[2].is_none(), "no options, no picker");
+
+        // Layout: the picker is a sliver at the box's right edge.
+        let (tx, _, tw, _) = p.texts[0].as_ref().unwrap().rect();
+        let (dx, _, dw, _) = p.choices[0].as_ref().unwrap().rect();
+        assert_eq!(dw, PICK_W);
+        assert!(dx >= tx + tw, "picker sits right of the text box");
+
+        // Both text variants lay out at the same row height.
+        assert_eq!(p.inner().row_height(0), p.inner().row_height(1));
+    }
+
     #[test]
     fn param_controller_roundtrip_and_row_widgets() {
         let p = panel_with(&[