From 6f30dd5218b0dda64628f42c8abb0e5fcc3a2aaa Mon Sep 17 00:00:00 2001 From: Jason Stiebs Date: Wed, 14 Oct 2026 18:01:31 -0500 Subject: [PATCH] Expose search-first dialog presentation options --- .../test/gpui/template/components_test.exs | 35 +++++++++++++++++++ .../gpui/components/schema_declarations.ex | 2 ++ apps/gpui_components/lib/gpui/ui/overlay.ex | 17 +++++++-- .../native/src/generated/schema.rs | 4 +++ .../src/element/component/overlay/dialog.rs | 20 ++++++++++- .../native/src/element/component_registry.rs | 2 ++ .../gpui_native/native/src/generated/atoms.rs | 20 +++++------ .../native/src/generated/schema.rs | 4 +++ .../test/gpui/test/native/navigation_test.exs | 19 ++++++++++ .../gpui/codegen/native/component/macros.ex | 4 +++ 10 files changed, 114 insertions(+), 13 deletions(-) diff --git a/apps/gpui/test/gpui/template/components_test.exs b/apps/gpui/test/gpui/template/components_test.exs index fcedeae7..b9291dc0 100644 --- a/apps/gpui/test/gpui/template/components_test.exs +++ b/apps/gpui/test/gpui/template/components_test.exs @@ -139,6 +139,41 @@ defmodule GPUI.Template.ComponentsTest do end end + test "search-first dialogs hide their heading and retain a controlled top offset" do + dialog = + ~GPUI""" + + <:content>Search results + + """ + + refute dialog.attrs[:show_title] + assert dialog.attrs[:title] == "Search" + assert dialog.attrs[:margin_top] == 112 + + default_dialog = + ~GPUI""" + + <:content>Settings content + + """ + + assert default_dialog.attrs[:show_title] + assert default_dialog.attrs[:margin_top] == nil + + for margin <- [-1, 4097, "80", false] do + assigns = %{margin: margin} + + assert_raise ArgumentError, ~r/margin_top/, fn -> + ~GPUI""" + + <:content>Search results + + """ + end + end + end + test "dropdown menus compile repeated textual item slots" do assert %GPUI.Element{ type: :ui_dropdown_menu, diff --git a/apps/gpui_components/lib/gpui/components/schema_declarations.ex b/apps/gpui_components/lib/gpui/components/schema_declarations.ex index 69db4a4c..a125ea57 100644 --- a/apps/gpui_components/lib/gpui/components/schema_declarations.ex +++ b/apps/gpui_components/lib/gpui/components/schema_declarations.ex @@ -207,7 +207,9 @@ defmodule GPUI.Components.Schema.Declarations do id: :string, open: :boolean, title: :required_string, + show_title: {:default, :boolean, true}, width: {:default, :number, 448.0}, + margin_top: :number, overlay: {:default, :boolean, true}, closable: {:default, :boolean, true}, keyboard: {:default, :boolean, true}, diff --git a/apps/gpui_components/lib/gpui/ui/overlay.ex b/apps/gpui_components/lib/gpui/ui/overlay.ex index c9fcca5e..e60460d0 100644 --- a/apps/gpui_components/lib/gpui/ui/overlay.ex +++ b/apps/gpui_components/lib/gpui/ui/overlay.ex @@ -81,6 +81,11 @@ defmodule GPUI.UI.Overlay do focus while open and restores the previous focus when it closes. Escape and overlay clicks request closure when enabled. + Set `show_title: false` for search-first dialogs without a visible heading. + `title` still names the dialog contract and optional trigger. `margin_top` + controls the dialog's distance from the top of the window; omitting it retains + the native viewport-relative placement. + #{ComponentsSchema.component_options_doc(:ui_dialog)} """ @spec dialog(dialog_options()) :: Element.t() @@ -266,16 +271,24 @@ defmodule GPUI.UI.Overlay do raise ArgumentError, "dialog content must use :trigger and :content named slots" end - boolean_attrs = Enum.map([:open, :overlay, :closable, :keyboard, :close_button], &assigns[&1]) + boolean_attrs = + Enum.map([:open, :show_title, :overlay, :closable, :keyboard, :close_button], &assigns[&1]) unless Enum.all?(boolean_attrs, &is_boolean/1) do raise ArgumentError, - "dialog open, overlay, closable, keyboard, and close_button must be booleans" + "dialog open, show_title, overlay, closable, keyboard, and close_button must be booleans" end unless is_number(assigns.width) and assigns.width > 0 and assigns.width <= 4096 do raise ArgumentError, "dialog width must be greater than 0 and at most 4096" end + + margin_top = assigns[:margin_top] + + unless is_nil(margin_top) or + (is_number(margin_top) and margin_top >= 0 and margin_top <= 4096) do + raise ArgumentError, "dialog margin_top must be between 0 and 4096" + end end defp tooltip_text!(children), do: textual_slot!(children, "tooltip :content") diff --git a/apps/gpui_components/native/src/generated/schema.rs b/apps/gpui_components/native/src/generated/schema.rs index bea7448a..70271641 100644 --- a/apps/gpui_components/native/src/generated/schema.rs +++ b/apps/gpui_components/native/src/generated/schema.rs @@ -2576,7 +2576,9 @@ pub(crate) struct DialogComponentNode { pub(crate) id: String, pub(crate) open: bool, pub(crate) title: String, + pub(crate) show_title: bool, pub(crate) width: f64, + pub(crate) margin_top: Option, pub(crate) overlay: bool, pub(crate) closable: bool, pub(crate) keyboard: bool, @@ -2595,7 +2597,9 @@ pub(crate) fn decode_generated_dialog_component<'a>( id: component_id(term)?, open: component_bool_attr(term, atoms::open())?.unwrap_or(false), title: component_required_string_attr(term, atoms::title())?, + show_title: component_bool_attr(term, atoms::show_title())?.unwrap_or(true), width: component_number_attr(term, atoms::width())?.unwrap_or(448.0), + margin_top: component_number_attr(term, atoms::margin_top())?, overlay: component_bool_attr(term, atoms::overlay())?.unwrap_or(true), closable: component_bool_attr(term, atoms::closable())?.unwrap_or(true), keyboard: component_bool_attr(term, atoms::keyboard())?.unwrap_or(true), diff --git a/apps/gpui_native/native/src/element/component/overlay/dialog.rs b/apps/gpui_native/native/src/element/component/overlay/dialog.rs index e7a137a9..c5813d55 100644 --- a/apps/gpui_native/native/src/element/component/overlay/dialog.rs +++ b/apps/gpui_native/native/src/element/component/overlay/dialog.rs @@ -18,6 +18,7 @@ pub(crate) fn render_dialog( ComponentDialog, ComponentOverlayState, DialogConfig, }; use crate::element::controlled::ControlledBinding; + use gpui::prelude::FluentBuilder as _; use gpui::{ AppContext, InteractiveElement, IntoElement, MouseButton, ParentElement, Role, StatefulInteractiveElement, @@ -44,7 +45,11 @@ pub(crate) fn render_dialog( let content_focus = context.cx.focus_handle(); let config = Arc::new(Mutex::new(DialogConfig { title: node.title.clone(), + show_title: node.show_title, width: node.width.clamp(1.0, 4096.0) as f32, + margin_top: node + .margin_top + .map(|margin| margin.clamp(0.0, 4096.0) as f32), overlay: node.overlay, closable: node.closable, keyboard: node.keyboard, @@ -137,7 +142,11 @@ pub(crate) fn render_dialog( if let Ok(mut config) = dialog.config.lock() { *config = DialogConfig { title: node.title.clone(), + show_title: node.show_title, width: node.width.clamp(1.0, 4096.0) as f32, + margin_top: node + .margin_top + .map(|margin| margin.clamp(0.0, 4096.0) as f32), overlay: node.overlay, closable: node.closable, keyboard: node.keyboard, @@ -201,7 +210,9 @@ pub(crate) fn render_dialog( .map(|config| config.clone()) .unwrap_or(DialogConfig { title: "Dialog".to_string(), + show_title: true, width: 448.0, + margin_top: None, overlay: true, closable: true, keyboard: true, @@ -210,6 +221,9 @@ pub(crate) fn render_dialog( }); let dialog = super::super::apply_component_styles(dialog, config.style) .width(gpui::px(config.width)) + .when_some(config.margin_top, |dialog, margin| { + dialog.margin_top(gpui::px(margin)) + }) .overlay(config.overlay) .overlay_closable(config.closable) .keyboard(config.keyboard && config.closable) @@ -242,7 +256,11 @@ pub(crate) fn render_dialog( } }) .child(content_view.clone()); - dialog.title(config.title) + if config.show_title { + dialog.title(config.title) + } else { + dialog + } }); window.defer(cx, move |window, cx| content_focus.focus(window, cx)); }); diff --git a/apps/gpui_native/native/src/element/component_registry.rs b/apps/gpui_native/native/src/element/component_registry.rs index 6f1109f8..b5b677a0 100644 --- a/apps/gpui_native/native/src/element/component_registry.rs +++ b/apps/gpui_native/native/src/element/component_registry.rs @@ -182,7 +182,9 @@ pub(crate) struct ComponentCombobox { #[derive(Clone)] pub(crate) struct DialogConfig { pub(crate) title: String, + pub(crate) show_title: bool, pub(crate) width: f32, + pub(crate) margin_top: Option, pub(crate) overlay: bool, pub(crate) closable: bool, pub(crate) keyboard: bool, diff --git a/apps/gpui_native/native/src/generated/atoms.rs b/apps/gpui_native/native/src/generated/atoms.rs index 566aca13..833b00fb 100644 --- a/apps/gpui_native/native/src/generated/atoms.rs +++ b/apps/gpui_native/native/src/generated/atoms.rs @@ -87,16 +87,16 @@ mod atoms { search_placeholder = "search_placeholder", select = "select", selectable = "selectable", selected = "selected", selected_column = "selected_column", selected_index = "selected_index", selection = "selection", set_size = - "set_size", show_line_numbers = "show_line_numbers", show_whitespaces = - "show_whitespaces", side = "side", size = "size", sizes = "sizes", soft_wrap = - "soft_wrap", sort = "sort", sort_column = "sort_column", sort_direction = - "sort_direction", sortable = "sortable", stale_revision = "stale_revision", - status = "status", step = "step", stride = "stride", style = "style", style_runs - = "style_runs", submit = "submit", submit_policy = "submit_policy", tab_size = - "tab_size", tab_width = "tab_width", text = "text", text_align = "text_align", - text_buffer_lock_failed = "text_buffer_lock_failed", text_overflow = - "text_overflow", title = "title", toggle = "toggle", top = "top", total_count = - "total_count", transaction = "transaction", transaction_conflict = + "set_size", show_line_numbers = "show_line_numbers", show_title = "show_title", + show_whitespaces = "show_whitespaces", side = "side", size = "size", sizes = + "sizes", soft_wrap = "soft_wrap", sort = "sort", sort_column = "sort_column", + sort_direction = "sort_direction", sortable = "sortable", stale_revision = + "stale_revision", status = "status", step = "step", stride = "stride", style = + "style", style_runs = "style_runs", submit = "submit", submit_policy = + "submit_policy", tab_size = "tab_size", tab_width = "tab_width", text = "text", + text_align = "text_align", text_buffer_lock_failed = "text_buffer_lock_failed", + text_overflow = "text_overflow", title = "title", toggle = "toggle", top = "top", + total_count = "total_count", transaction = "transaction", transaction_conflict = "transaction_conflict", truncate = "truncate", type_atom = "type", value = "value", vanilla = "vanilla", variant = "variant", viewport = "viewport", white_space = "white_space", width = "width", window_blur = "window_blur", diff --git a/apps/gpui_native/native/src/generated/schema.rs b/apps/gpui_native/native/src/generated/schema.rs index bea7448a..70271641 100644 --- a/apps/gpui_native/native/src/generated/schema.rs +++ b/apps/gpui_native/native/src/generated/schema.rs @@ -2576,7 +2576,9 @@ pub(crate) struct DialogComponentNode { pub(crate) id: String, pub(crate) open: bool, pub(crate) title: String, + pub(crate) show_title: bool, pub(crate) width: f64, + pub(crate) margin_top: Option, pub(crate) overlay: bool, pub(crate) closable: bool, pub(crate) keyboard: bool, @@ -2595,7 +2597,9 @@ pub(crate) fn decode_generated_dialog_component<'a>( id: component_id(term)?, open: component_bool_attr(term, atoms::open())?.unwrap_or(false), title: component_required_string_attr(term, atoms::title())?, + show_title: component_bool_attr(term, atoms::show_title())?.unwrap_or(true), width: component_number_attr(term, atoms::width())?.unwrap_or(448.0), + margin_top: component_number_attr(term, atoms::margin_top())?, overlay: component_bool_attr(term, atoms::overlay())?.unwrap_or(true), closable: component_bool_attr(term, atoms::closable())?.unwrap_or(true), keyboard: component_bool_attr(term, atoms::keyboard())?.unwrap_or(true), diff --git a/apps/gpui_native/test/test/gpui/test/native/navigation_test.exs b/apps/gpui_native/test/test/gpui/test/native/navigation_test.exs index 4d2260ba..43d9f41c 100644 --- a/apps/gpui_native/test/test/gpui/test/native/navigation_test.exs +++ b/apps/gpui_native/test/test/gpui/test/native/navigation_test.exs @@ -31,7 +31,9 @@ defmodule GPUI.Test.Native.NavigationTest do id="settings-dialog" open={assigns.open} title="Settings" + show_title={Map.get(assigns, :show_title, true)} width={280} + margin_top={Map.get(assigns, :margin_top)} phx-change="dialog_changed" > <:trigger> @@ -115,6 +117,23 @@ defmodule GPUI.Test.Native.NavigationTest do assert_receive {:gpui, ^ui, {:event, %{type: :click, event: "dialog_action"}}} end + test "search-first dialogs omit the heading and retain content activation and Escape", %{ui: ui} do + render(ui, DialogView, open: true, margin_top: 24) + settle(ui) + titled_y = bounds(ui, "dialog-action").y + + render(ui, DialogView, open: false, margin_top: 24) + settle(ui) + render(ui, DialogView, open: true, show_title: false, margin_top: 24) + settle(ui) + assert bounds(ui, "dialog-action").y < titled_y + + click(ui, "dialog-action") + assert_receive {:gpui, ^ui, {:event, %{type: :click, event: "dialog_action"}}} + press(ui, :escape) + assert_receive {:gpui, ^ui, {:event, %{type: :change, event: "dialog_changed", value: false}}} + end + test "dialog routes Escape closure and restores its trigger focus", %{ui: ui} do render(ui, DialogView, open: false) focus(ui, "settings-dialog") diff --git a/codegen/gpui/codegen/native/component/macros.ex b/codegen/gpui/codegen/native/component/macros.ex index e5f5eea4..598deb4d 100644 --- a/codegen/gpui/codegen/native/component/macros.ex +++ b/codegen/gpui/codegen/native/component/macros.ex @@ -93,6 +93,7 @@ defmodule GPUI.Codegen.Native.Component.Macros do when type in [:number, :positive_number, :unit_number, :edge_fade_size], do: quote(do: R.f64()) + defp component_field_type(_name, :number), do: quote(do: R.option(R.f64())) defp component_field_type(_name, :non_negative_integer), do: quote(do: R.option(R.u64())) defp component_field_type(_name, {:default, type, _value}) @@ -160,6 +161,9 @@ defmodule GPUI.Codegen.Native.Component.Macros do defp component_decoder_expr(name, :non_negative_integer), do: quote(do: unwrap!(component_non_negative_integer_attr(term, unquote(atom_call(name))))) + defp component_decoder_expr(name, :number), + do: quote(do: unwrap!(component_number_attr(term, unquote(atom_call(name))))) + defp component_decoder_expr(name, {:default, type, default}) when type in [:non_negative_integer, :positive_integer] do helper =