ui prompt: Adjust UI and focus visibility (#39106)

Danilo Leal created

Closes https://github.com/zed-industries/zed/issues/38643

This PR adds some UI improvements to the Zed replacement of the system
dialog/prompt, including better visibility of which button is currently
focused.

One little design note, though: because of a current (and somewhat
annoying) constraint of button component, where we're only drawing a
border when its style is outlined, if I kept them horizontally stacked,
there'd be a little layout shift now that I'm toggling styles for better
focus visibility. So, for this reason, I changed them to be vertically
stacked, which matches the macOS design and avoids this problem. Maybe
in the future, we'll revert it back to being `flex_row` because that
ultimately consumes less space.


https://github.com/user-attachments/assets/500c840b-6b56-4c0c-b56a-535939398a7b

Release Notes:

- Improve focus visibility of the actions within Zed's UI system prompt.

Change summary

crates/ui_prompt/src/ui_prompt.rs | 155 ++++++++++++++------------------
1 file changed, 68 insertions(+), 87 deletions(-)

Detailed changes

crates/ui_prompt/src/ui_prompt.rs 🔗

@@ -1,16 +1,12 @@
 use gpui::{
-    App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, FontWeight,
-    InteractiveElement, IntoElement, ParentElement, PromptButton, PromptHandle, PromptLevel,
-    PromptResponse, Refineable, Render, RenderablePromptHandle, SharedString, Styled,
-    TextStyleRefinement, Window, div,
+    App, Entity, EventEmitter, FocusHandle, Focusable, PromptButton, PromptHandle, PromptLevel,
+    PromptResponse, RenderablePromptHandle, SharedString, TextStyleRefinement, Window, div,
+    prelude::*,
 };
 use markdown::{Markdown, MarkdownElement, MarkdownStyle};
 use settings::{Settings, SettingsStore};
 use theme::ThemeSettings;
-use ui::{
-    ActiveTheme, ButtonCommon, ButtonStyle, Clickable, ElevationIndex, FluentBuilder, LabelSize,
-    StyledExt, TintColor, h_flex, v_flex,
-};
+use ui::{FluentBuilder, TintColor, prelude::*};
 use workspace::WorkspaceSettings;
 
 pub fn init(cx: &mut App) {
@@ -92,11 +88,7 @@ impl ZedPromptRenderer {
     }
 
     fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
-        if self.active_action_id > 0 {
-            self.active_action_id -= 1;
-        } else {
-            self.active_action_id = self.actions.len().saturating_sub(1);
-        }
+        self.active_action_id = (self.active_action_id + 1) % self.actions.len();
         cx.notify();
     }
 
@@ -106,7 +98,11 @@ impl ZedPromptRenderer {
         _window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        self.active_action_id = (self.active_action_id + 1) % self.actions.len();
+        if self.active_action_id > 0 {
+            self.active_action_id -= 1;
+        } else {
+            self.active_action_id = self.actions.len().saturating_sub(1);
+        }
         cx.notify();
     }
 }
@@ -114,8 +110,8 @@ impl ZedPromptRenderer {
 impl Render for ZedPromptRenderer {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let settings = ThemeSettings::get_global(cx);
-        let font_size = settings.ui_font_size(cx).into();
-        let prompt = v_flex()
+
+        let dialog = v_flex()
             .key_context("Prompt")
             .cursor_default()
             .track_focus(&self.focus)
@@ -125,96 +121,81 @@ impl Render for ZedPromptRenderer {
             .on_action(cx.listener(Self::select_previous))
             .on_action(cx.listener(Self::select_first))
             .on_action(cx.listener(Self::select_last))
-            .elevation_3(cx)
-            .w_72()
-            .overflow_hidden()
+            .w_80()
             .p_4()
             .gap_4()
+            .elevation_3(cx)
+            .overflow_hidden()
             .font_family(settings.ui_font.family.clone())
-            .child(
-                div()
-                    .w_full()
-                    .child(MarkdownElement::new(self.message.clone(), {
-                        let mut base_text_style = window.text_style();
-                        base_text_style.refine(&TextStyleRefinement {
-                            font_family: Some(settings.ui_font.family.clone()),
-                            font_size: Some(font_size),
-                            font_weight: Some(FontWeight::BOLD),
-                            color: Some(ui::Color::Default.color(cx)),
-                            ..Default::default()
-                        });
-                        MarkdownStyle {
-                            base_text_style,
-                            selection_background_color: cx
-                                .theme()
-                                .colors()
-                                .element_selection_background,
-                            ..Default::default()
-                        }
-                    })),
-            )
+            .child(div().w_full().child(MarkdownElement::new(
+                self.message.clone(),
+                markdown_style(true, window, cx),
+            )))
             .children(self.detail.clone().map(|detail| {
-                div()
-                    .w_full()
-                    .text_xs()
-                    .child(MarkdownElement::new(detail, {
-                        let mut base_text_style = window.text_style();
-                        base_text_style.refine(&TextStyleRefinement {
-                            font_family: Some(settings.ui_font.family.clone()),
-                            font_size: Some(font_size),
-                            color: Some(ui::Color::Muted.color(cx)),
-                            ..Default::default()
-                        });
-                        MarkdownStyle {
-                            base_text_style,
-                            selection_background_color: cx
-                                .theme()
-                                .colors()
-                                .element_selection_background,
-                            ..Default::default()
-                        }
-                    }))
+                div().w_full().text_xs().child(MarkdownElement::new(
+                    detail,
+                    markdown_style(false, window, cx),
+                ))
             }))
-            .child(h_flex().justify_end().gap_2().children(
-                self.actions.iter().enumerate().rev().map(|(ix, action)| {
-                    ui::Button::new(ix, action.clone())
-                        .label_size(LabelSize::Large)
-                        .style(ButtonStyle::Filled)
-                        .when(ix == self.active_action_id, |el| {
-                            el.style(ButtonStyle::Tinted(TintColor::Accent))
-                        })
-                        .layer(ElevationIndex::ModalSurface)
-                        .on_click(cx.listener(move |_, _, _window, cx| {
-                            cx.emit(PromptResponse(ix));
-                        }))
-                }),
-            ));
+            .child(
+                v_flex()
+                    .gap_1()
+                    .children(self.actions.iter().enumerate().map(|(ix, action)| {
+                        Button::new(ix, action.clone())
+                            .full_width()
+                            .style(ButtonStyle::Outlined)
+                            .when(ix == self.active_action_id, |s| {
+                                s.style(ButtonStyle::Tinted(TintColor::Accent))
+                            })
+                            .tab_index(ix as isize)
+                            .on_click(cx.listener(move |_, _, _window, cx| {
+                                cx.emit(PromptResponse(ix));
+                            }))
+                    })),
+            );
 
         div()
             .size_full()
             .occlude()
             .bg(gpui::black().opacity(0.2))
             .child(
-                div()
+                v_flex()
                     .size_full()
                     .absolute()
                     .top_0()
                     .left_0()
-                    .flex()
-                    .flex_col()
-                    .justify_around()
-                    .child(
-                        div()
-                            .w_full()
-                            .flex()
-                            .flex_row()
-                            .justify_around()
-                            .child(prompt),
-                    ),
+                    .items_center()
+                    .justify_center()
+                    .child(dialog),
             )
     }
 }
 
+fn markdown_style(main_message: bool, window: &Window, cx: &App) -> MarkdownStyle {
+    let mut base_text_style = window.text_style();
+    let settings = ThemeSettings::get_global(cx);
+    let font_size = settings.ui_font_size(cx).into();
+
+    let color = if main_message {
+        Color::Default.color(cx)
+    } else {
+        Color::Muted.color(cx)
+    };
+
+    base_text_style.refine(&TextStyleRefinement {
+        font_family: Some(settings.ui_font.family.clone()),
+        font_size: Some(font_size),
+        color: Some(color),
+        ..Default::default()
+    });
+
+    MarkdownStyle {
+        base_text_style,
+        selection_background_color: cx.theme().colors().element_selection_background,
+        ..Default::default()
+    }
+}
+
 impl EventEmitter<PromptResponse> for ZedPromptRenderer {}
 
 impl Focusable for ZedPromptRenderer {