Derive autocomplete menu's width from the width of its largest item

Max Brunsfeld and Nathan Sobo created

Co-Authored-By: Nathan Sobo <nathan@zed.dev>

Change summary

crates/editor/src/editor.rs              | 16 +++
crates/editor/src/element.rs             |  2 
crates/gpui/src/elements/uniform_list.rs | 99 +++++++++++++++++--------
crates/theme/src/theme.rs                |  9 ++
crates/zed/assets/themes/_base.toml      |  7 +
5 files changed, 96 insertions(+), 37 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -1535,9 +1535,10 @@ impl Editor {
         self.completion_state.is_some()
     }
 
-    pub fn render_completions(&self) -> Option<ElementBox> {
+    pub fn render_completions(&self, cx: &AppContext) -> Option<ElementBox> {
         self.completion_state.as_ref().map(|state| {
             let build_settings = self.build_settings.clone();
+            let settings = build_settings(cx);
             let completions = state.completions.clone();
             UniformList::new(
                 state.list.clone(),
@@ -1547,11 +1548,23 @@ impl Editor {
                     for completion in &completions[range] {
                         items.push(
                             Label::new(completion.label().to_string(), settings.style.text.clone())
+                                .contained()
+                                .with_style(settings.style.autocomplete.item)
                                 .boxed(),
                         );
                     }
                 },
             )
+            .with_width_from_item(
+                state
+                    .completions
+                    .iter()
+                    .enumerate()
+                    .max_by_key(|(_, completion)| completion.label().chars().count())
+                    .map(|(ix, _)| ix),
+            )
+            .contained()
+            .with_style(settings.style.autocomplete.container)
             .boxed()
         })
     }
@@ -4056,6 +4069,7 @@ impl EditorSettings {
                     invalid_information_diagnostic: default_diagnostic_style.clone(),
                     hint_diagnostic: default_diagnostic_style.clone(),
                     invalid_hint_diagnostic: default_diagnostic_style.clone(),
+                    autocomplete: Default::default(),
                 }
             },
         }

crates/editor/src/element.rs 🔗

@@ -886,7 +886,7 @@ impl Element for EditorElement {
                     .to_display_point(&snapshot);
 
                 if (start_row..end_row).contains(&newest_selection_head.row()) {
-                    let list = view.render_completions().unwrap();
+                    let list = view.render_completions(cx).unwrap();
                     completions = Some((newest_selection_head, list));
                 }
             }

crates/gpui/src/elements/uniform_list.rs 🔗

@@ -51,6 +51,7 @@ where
     append_items: F,
     padding_top: f32,
     padding_bottom: f32,
+    get_width_from_item: Option<usize>,
 }
 
 impl<F> UniformList<F>
@@ -64,9 +65,15 @@ where
             append_items,
             padding_top: 0.,
             padding_bottom: 0.,
+            get_width_from_item: None,
         }
     }
 
+    pub fn with_width_from_item(mut self, item_ix: Option<usize>) -> Self {
+        self.get_width_from_item = item_ix;
+        self
+    }
+
     pub fn with_padding_top(mut self, padding: f32) -> Self {
         self.padding_top = padding;
         self
@@ -155,46 +162,70 @@ where
                 "UniformList does not support being rendered with an unconstrained height"
             );
         }
-        let mut size = constraint.max;
-        let mut item_constraint =
-            SizeConstraint::new(vec2f(size.x(), 0.0), vec2f(size.x(), f32::INFINITY));
-        let mut item_height = 0.;
-        let mut scroll_max = 0.;
-
         let mut items = Vec::new();
-        (self.append_items)(0..1, &mut items, cx);
-        if let Some(first_item) = items.first_mut() {
-            let mut item_size = first_item.layout(item_constraint, cx);
+
+        if self.item_count == 0 {
+            return (
+                constraint.min,
+                LayoutState {
+                    item_height: 0.,
+                    scroll_max: 0.,
+                    items,
+                },
+            );
+        }
+
+        let mut size = constraint.max;
+        let mut item_size;
+        if let Some(sample_item_ix) = self.get_width_from_item {
+            (self.append_items)(sample_item_ix..sample_item_ix + 1, &mut items, cx);
+            let sample_item = items.get_mut(0).unwrap();
+            item_size = sample_item.layout(constraint, cx);
+            size.set_x(item_size.x());
+        } else {
+            (self.append_items)(0..1, &mut items, cx);
+            let first_item = items.first_mut().unwrap();
+            item_size = first_item.layout(
+                SizeConstraint::new(
+                    vec2f(constraint.max.x(), 0.0),
+                    vec2f(constraint.max.x(), f32::INFINITY),
+                ),
+                cx,
+            );
             item_size.set_x(size.x());
-            item_constraint.min = item_size;
-            item_constraint.max = item_size;
-            item_height = item_size.y();
+        }
 
-            let scroll_height = self.item_count as f32 * item_height;
-            if scroll_height < size.y() {
-                size.set_y(size.y().min(scroll_height).max(constraint.min.y()));
-            }
+        let item_constraint = SizeConstraint {
+            min: item_size,
+            max: vec2f(constraint.max.x(), item_size.y()),
+        };
+        let item_height = item_size.y();
 
-            let scroll_height =
-                item_height * self.item_count as f32 + self.padding_top + self.padding_bottom;
-            scroll_max = (scroll_height - size.y()).max(0.);
-            self.autoscroll(scroll_max, size.y(), item_height);
+        let scroll_height = self.item_count as f32 * item_height;
+        if scroll_height < size.y() {
+            size.set_y(size.y().min(scroll_height).max(constraint.min.y()));
+        }
 
-            items.clear();
-            let start = cmp::min(
-                ((self.scroll_top() - self.padding_top) / item_height) as usize,
-                self.item_count,
-            );
-            let end = cmp::min(
-                self.item_count,
-                start + (size.y() / item_height).ceil() as usize + 1,
-            );
-            (self.append_items)(start..end, &mut items, cx);
-            for item in &mut items {
-                item.layout(item_constraint, cx);
+        let scroll_height =
+            item_height * self.item_count as f32 + self.padding_top + self.padding_bottom;
+        let scroll_max = (scroll_height - size.y()).max(0.);
+        self.autoscroll(scroll_max, size.y(), item_height);
+
+        let start = cmp::min(
+            ((self.scroll_top() - self.padding_top) / item_height) as usize,
+            self.item_count,
+        );
+        let end = cmp::min(
+            self.item_count,
+            start + (size.y() / item_height).ceil() as usize + 1,
+        );
+        items.clear();
+        (self.append_items)(start..end, &mut items, cx);
+        for item in &mut items {
+            let item_size = item.layout(item_constraint, cx);
+            if item_size.x() > size.x() {
+                size.set_x(item_size.x());
             }
-        } else {
-            size = constraint.min;
         }
 
         (

crates/theme/src/theme.rs 🔗

@@ -292,6 +292,7 @@ pub struct EditorStyle {
     pub invalid_information_diagnostic: DiagnosticStyle,
     pub hint_diagnostic: DiagnosticStyle,
     pub invalid_hint_diagnostic: DiagnosticStyle,
+    pub autocomplete: AutocompleteStyle,
 }
 
 #[derive(Clone, Deserialize, Default)]
@@ -321,6 +322,13 @@ pub struct DiagnosticStyle {
     pub text_scale_factor: f32,
 }
 
+#[derive(Clone, Deserialize, Default)]
+pub struct AutocompleteStyle {
+    #[serde(flatten)]
+    pub container: ContainerStyle,
+    pub item: ContainerStyle,
+}
+
 #[derive(Clone, Copy, Default, Deserialize)]
 pub struct SelectionStyle {
     pub cursor: Color,
@@ -408,6 +416,7 @@ impl InputEditorStyle {
             invalid_information_diagnostic: default_diagnostic_style.clone(),
             hint_diagnostic: default_diagnostic_style.clone(),
             invalid_hint_diagnostic: default_diagnostic_style.clone(),
+            autocomplete: Default::default(),
         }
     }
 }

crates/zed/assets/themes/_base.toml 🔗

@@ -188,7 +188,7 @@ corner_radius = 6
 
 [project_panel]
 extends = "$panel"
-padding.top = 6 # ($workspace.tab.height - $project_panel.entry.height) / 2
+padding.top = 6    # ($workspace.tab.height - $project_panel.entry.height) / 2
 
 [project_panel.entry]
 text = "$text.1"
@@ -314,6 +314,11 @@ extends = "$editor.hint_diagnostic"
 message.text.color = "$text.3.color"
 message.highlight_text.color = "$text.3.color"
 
+[editor.autocomplete]
+background = "$surface.2"
+border = { width = 1, color = "$border.1" }
+item.padding = 2
+
 [project_diagnostics]
 background = "$surface.1"
 empty_message = { extends = "$text.0", size = 18 }