diff --git a/apps/gpui_components/lib/gpui/components/schema_declarations.ex b/apps/gpui_components/lib/gpui/components/schema_declarations.ex index 69db4a4..258361b 100644 --- a/apps/gpui_components/lib/gpui/components/schema_declarations.ex +++ b/apps/gpui_components/lib/gpui/components/schema_declarations.ex @@ -345,6 +345,9 @@ defmodule GPUI.Components.Schema.Declarations do value: {:default, :string}, focus_request: {:default, :non_negative_integer, 0}, placeholder: :string, + appearance: {:default, :boolean, true}, + multiline: :boolean, + max_rows: {:default, :positive_integer, 16}, size: {:enum, ["xs", "sm", "md", "lg"]}, disabled: :boolean, cleanable: :boolean, diff --git a/apps/gpui_components/lib/gpui/ui.ex b/apps/gpui_components/lib/gpui/ui.ex index 606f2db..baff331 100644 --- a/apps/gpui_components/lib/gpui/ui.ex +++ b/apps/gpui_components/lib/gpui/ui.ex @@ -256,10 +256,24 @@ defmodule GPUI.UI do Increment `focus_request` to request native focus after validation or another application-owned transition. + `appearance: false` removes the input's border, background and shadow. + `multiline: true` creates a wrapping, auto-growing input bounded by `max_rows` + (1 through 64). Enter submits; Shift+Enter inserts a newline without submitting. + Multiline mode and its row limit are construction options: use a new `id` when + changing them so native editing state is recreated. + #{ComponentsSchema.component_options_doc(:ui_input)} """ @spec input(input_options()) :: Element.t() - def input(assigns), do: component(:ui_input, assigns) + def input(assigns) do + assigns = ComponentsSchema.apply_defaults(assigns, :ui_input) + + unless is_integer(assigns.max_rows) and assigns.max_rows in 1..64 do + raise ArgumentError, "ui_input max_rows must be an integer from 1 through 64" + end + + component(:ui_input, assigns) + end @doc """ Builds a persistent native GPUI Component select. diff --git a/apps/gpui_components/native/src/generated/schema.rs b/apps/gpui_components/native/src/generated/schema.rs index bea7448..5e81748 100644 --- a/apps/gpui_components/native/src/generated/schema.rs +++ b/apps/gpui_components/native/src/generated/schema.rs @@ -2764,6 +2764,9 @@ pub(crate) struct InputComponentNode { pub(crate) value: String, pub(crate) focus_request: u64, pub(crate) placeholder: Option, + pub(crate) appearance: bool, + pub(crate) multiline: bool, + pub(crate) max_rows: u64, pub(crate) size: Option, pub(crate) disabled: bool, pub(crate) cleanable: bool, @@ -2788,6 +2791,10 @@ pub(crate) fn decode_generated_input_component<'a>( focus_request: component_non_negative_integer_attr(term, atoms::focus_request())? .unwrap_or(0), placeholder: component_string_attr(term, atoms::placeholder())?, + appearance: component_bool_attr(term, atoms::appearance())?.unwrap_or(true), + multiline: component_bool_attr(term, atoms::multiline())?.unwrap_or(false), + max_rows: component_positive_integer_attr(term, atoms::max_rows())? + .unwrap_or(16), size: component_enum_attr(term, atoms::size(), &vec!["xs", "sm", "md", "lg"])?, disabled: component_bool_attr(term, atoms::disabled())?.unwrap_or(false), cleanable: component_bool_attr(term, atoms::cleanable())?.unwrap_or(false), diff --git a/apps/gpui_components/test/test/gpui/ui/components_test.exs b/apps/gpui_components/test/test/gpui/ui/components_test.exs index 4010c89..2a1b5e4 100644 --- a/apps/gpui_components/test/test/gpui/ui/components_test.exs +++ b/apps/gpui_components/test/test/gpui/ui/components_test.exs @@ -4,6 +4,24 @@ defmodule GPUI.UI.ComponentsTest do alias GPUI.Element alias GPUI.UI + test "builds borderless multiline inputs without changing ordinary input defaults" do + defaults = %{:"phx-change" => "name", id: "name", label: "Name"} + ordinary = UI.input(defaults) + assert ordinary.attrs[:appearance] + refute ordinary.attrs[:multiline] + + inline = UI.input(Map.merge(defaults, %{appearance: false, multiline: true, max_rows: 8})) + refute inline.attrs[:appearance] + assert inline.attrs[:multiline] + assert inline.attrs[:max_rows] == 8 + + for rows <- [0, 65, 1.5] do + assert_raise ArgumentError, ~r/max_rows/, fn -> + UI.input(Map.put(defaults, :max_rows, rows)) + end + end + end + test "builds progress, file-read, and clipboard controls" do assert %Element{type: :ui_progress, attrs: progress} = UI.progress(%{id: "upload", label: "Uploading", value: 25, max: 50}) diff --git a/apps/gpui_native/native/src/element/component/form.rs b/apps/gpui_native/native/src/element/component/form.rs index 43c4ce0..257443c 100644 --- a/apps/gpui_native/native/src/element/component/form.rs +++ b/apps/gpui_native/native/src/element/component/form.rs @@ -34,11 +34,14 @@ fn input_accessibility( value: String, placeholder: Option, masked: bool, + multiline: bool, ) -> InputAccessibility { InputAccessibility { label, role: if masked { gpui::Role::PasswordInput + } else if multiline { + gpui::Role::MultilineTextInput } else { gpui::Role::TextInput }, @@ -101,6 +104,7 @@ pub(crate) fn render_input_component( node.value.clone(), node.placeholder.clone(), node.masked, + node.multiline, ); if context.components.input_mut(&node.id).is_none() { @@ -108,6 +112,11 @@ pub(crate) fn render_input_component( let mut state = InputState::new(context.window, cx) .default_value(node.value.clone()) .placeholder(node.placeholder.clone().unwrap_or_default()); + if node.multiline { + state = state + .auto_grow(1, node.max_rows.clamp(1, 64) as usize) + .submit_on_enter(true); + } if node.masked { state = state.masked(true); } @@ -123,14 +132,17 @@ pub(crate) fn render_input_component( let window_id = context.window_id; let event_binding = binding.clone(); let event_submit = submit_event.clone(); + let multiline = node.multiline; let subscription = context.cx.subscribe_in( &state, context.window, move |_root, state, event: &InputEvent, _window, cx| { let value = state.read(cx).value().to_string(); - match event { - InputEvent::Change => { + // Shift+Enter inserts a newline silently and emits PressEnter, not Change. + match (event, multiline) { + (InputEvent::Change, _) + | (InputEvent::PressEnter { shift: true, .. }, true) => { let event = event_binding.lock().ok().and_then(|mut binding| { binding.event.clone().inspect(|_event| { binding.push_pending(value.clone()); @@ -154,7 +166,7 @@ pub(crate) fn render_input_component( } } } - InputEvent::PressEnter { .. } => { + (InputEvent::PressEnter { .. }, _) => { let event = event_submit.lock().ok().and_then(|event| event.clone()); if let Some(event) = event { let _ = push_event( @@ -168,7 +180,7 @@ pub(crate) fn render_input_component( ); } } - InputEvent::Focus | InputEvent::Blur => {} + (InputEvent::Focus | InputEvent::Blur, _) => {} } }, ); @@ -274,6 +286,7 @@ pub(crate) fn render_input_component( } let mut element = Input::new(&input.state) .role(gpui::Role::GenericContainer) + .appearance(node.appearance) .disabled(node.disabled) .cleanable(node.cleanable); element = match node.size.as_deref() { @@ -297,8 +310,12 @@ pub(crate) fn render_input_component( } }) .flex() - .w_full() - .h(gpui::px(component_height)); + .w_full(); + if node.multiline { + accessible_element = accessible_element.h_auto(); + } else { + accessible_element = accessible_element.h(gpui::px(component_height)); + } if let Some(value) = accessibility_value { accessible_element = accessible_element.aria_value(value); } @@ -754,6 +771,7 @@ mod tests { "Ada".to_string(), Some("Name".to_string()), false, + false, ), InputAccessibility { label: "Display name".to_string(), @@ -764,7 +782,13 @@ mod tests { ); assert_eq!( - input_accessibility("Password".to_string(), "secret".to_string(), None, true), + input_accessibility( + "Password".to_string(), + "secret".to_string(), + None, + true, + true + ), InputAccessibility { label: "Password".to_string(), role: crate::gpui::Role::PasswordInput, @@ -774,6 +798,19 @@ mod tests { ); } + #[test] + fn multiline_input_accessibility_exposes_its_role_and_value() { + let accessibility = input_accessibility( + "Message".to_string(), + "First\nSecond".to_string(), + None, + false, + true, + ); + assert_eq!(accessibility.role, crate::gpui::Role::MultilineTextInput); + assert_eq!(accessibility.value.as_deref(), Some("First\nSecond")); + } + #[test] fn choice_accessibility_uses_the_selected_visible_label() { let options = vec![SelectOptionNode { diff --git a/apps/gpui_native/native/src/generated/atoms.rs b/apps/gpui_native/native/src/generated/atoms.rs index 566aca1..4079b37 100644 --- a/apps/gpui_native/native/src/generated/atoms.rs +++ b/apps/gpui_native/native/src/generated/atoms.rs @@ -45,26 +45,26 @@ mod atoms { margin_bottom = "margin_bottom", margin_left = "margin_left", margin_right = "margin_right", margin_top = "margin_top", margin_x = "margin_x", margin_y = "margin_y", masked = "masked", max = "max", max_columns = "max_columns", - max_height = "max_height", max_lines = "max_lines", max_sizes = "max_sizes", - max_width = "max_width", menu = "menu", min = "min", min_height = "min_height", - min_lines = "min_lines", min_sizes = "min_sizes", min_width = "min_width", - missing_resource = "missing_resource", mode = "mode", motion_delay = - "motion_delay", motion_duration = "motion_duration", motion_easing = - "motion_easing", motion_from_opacity = "motion_from_opacity", motion_from_x = + max_height = "max_height", max_lines = "max_lines", max_rows = "max_rows", + max_sizes = "max_sizes", max_width = "max_width", menu = "menu", min = "min", + min_height = "min_height", min_lines = "min_lines", min_sizes = "min_sizes", + min_width = "min_width", missing_resource = "missing_resource", mode = "mode", + motion_delay = "motion_delay", motion_duration = "motion_duration", motion_easing + = "motion_easing", motion_from_opacity = "motion_from_opacity", motion_from_x = "motion_from_x", motion_from_y = "motion_from_y", motion_policy = - "motion_policy", motion_request = "motion_request", multiple = "multiple", name = - "name", nil = "nil", nothing_to_redo = "nothing_to_redo", nothing_to_undo = - "nothing_to_undo", number = "number", offset = "offset", offset_x = "offset_x", - offset_y = "offset_y", ok = "ok", opacity = "opacity", open = "open", - operation_id = "operation_id", options = "options", orientation = "orientation", - outline = "outline", overdraw = "overdraw", overflow = "overflow", - overlapping_edits = "overlapping_edits", overlay = "overlay", overscan = - "overscan", padding = "padding", padding_bottom = "padding_bottom", padding_left - = "padding_left", padding_right = "padding_right", padding_top = "padding_top", - padding_x = "padding_x", padding_y = "padding_y", parent_id = "parent_id", - phx_blur = "phx-blur", phx_bounds_change = "phx-bounds-change", phx_cell_change = - "phx-cell-change", phx_change = "phx-change", phx_click = "phx-click", - phx_clipboard_read = "phx-clipboard-read", phx_clipboard_write = + "motion_policy", motion_request = "motion_request", multiline = "multiline", + multiple = "multiple", name = "name", nil = "nil", nothing_to_redo = + "nothing_to_redo", nothing_to_undo = "nothing_to_undo", number = "number", offset + = "offset", offset_x = "offset_x", offset_y = "offset_y", ok = "ok", opacity = + "opacity", open = "open", operation_id = "operation_id", options = "options", + orientation = "orientation", outline = "outline", overdraw = "overdraw", overflow + = "overflow", overlapping_edits = "overlapping_edits", overlay = "overlay", + overscan = "overscan", padding = "padding", padding_bottom = "padding_bottom", + padding_left = "padding_left", padding_right = "padding_right", padding_top = + "padding_top", padding_x = "padding_x", padding_y = "padding_y", parent_id = + "parent_id", phx_blur = "phx-blur", phx_bounds_change = "phx-bounds-change", + phx_cell_change = "phx-cell-change", phx_change = "phx-change", phx_click = + "phx-click", phx_clipboard_read = "phx-clipboard-read", phx_clipboard_write = "phx-clipboard-write", phx_copy = "phx-copy", phx_drag_enter = "phx-drag-enter", phx_drag_leave = "phx-drag-leave", phx_drag_move = "phx-drag-move", phx_drop = "phx-drop", phx_file_read = "phx-file-read", phx_focus = "phx-focus", diff --git a/apps/gpui_native/native/src/generated/schema.rs b/apps/gpui_native/native/src/generated/schema.rs index bea7448..5e81748 100644 --- a/apps/gpui_native/native/src/generated/schema.rs +++ b/apps/gpui_native/native/src/generated/schema.rs @@ -2764,6 +2764,9 @@ pub(crate) struct InputComponentNode { pub(crate) value: String, pub(crate) focus_request: u64, pub(crate) placeholder: Option, + pub(crate) appearance: bool, + pub(crate) multiline: bool, + pub(crate) max_rows: u64, pub(crate) size: Option, pub(crate) disabled: bool, pub(crate) cleanable: bool, @@ -2788,6 +2791,10 @@ pub(crate) fn decode_generated_input_component<'a>( focus_request: component_non_negative_integer_attr(term, atoms::focus_request())? .unwrap_or(0), placeholder: component_string_attr(term, atoms::placeholder())?, + appearance: component_bool_attr(term, atoms::appearance())?.unwrap_or(true), + multiline: component_bool_attr(term, atoms::multiline())?.unwrap_or(false), + max_rows: component_positive_integer_attr(term, atoms::max_rows())? + .unwrap_or(16), size: component_enum_attr(term, atoms::size(), &vec!["xs", "sm", "md", "lg"])?, disabled: component_bool_attr(term, atoms::disabled())?.unwrap_or(false), cleanable: component_bool_attr(term, atoms::cleanable())?.unwrap_or(false), diff --git a/apps/gpui_native/test/test/gpui/test/native/controls_test.exs b/apps/gpui_native/test/test/gpui/test/native/controls_test.exs index 533930f..5110cf0 100644 --- a/apps/gpui_native/test/test/gpui/test/native/controls_test.exs +++ b/apps/gpui_native/test/test/gpui/test/native/controls_test.exs @@ -34,18 +34,23 @@ defmodule GPUI.Test.Native.ControlsTest do @impl GPUI.View def render(assigns) do ~GPUI""" - +
+ +
""" end end @@ -61,6 +66,9 @@ defmodule GPUI.Test.Native.ControlsTest do disabled: false, loading: false, masked: false, + appearance: true, + multiline: false, + max_rows: 16, focus_request: 1 ], opts @@ -104,6 +112,62 @@ defmodule GPUI.Test.Native.ControlsTest do refute_receive {:gpui, ^ui, {:event, %{event: "name_changed"}}} end + test "inline inputs grow and preserve Shift+Enter without submitting", %{ui: ui} do + render_input(ui, value: "", appearance: false, multiline: true) + settle(ui) + initial_height = bounds(ui, "input-host").height + type(ui, "First") + assert_receive {:gpui, ^ui, {:event, %{type: :change, value: "First"}}} + render_input(ui, value: "First", appearance: false, multiline: true) + + press(ui, "shift-enter") + assert_receive {:gpui, ^ui, {:event, %{type: :change, value: "First\n"}}} + refute_receive {:gpui, ^ui, {:event, %{type: :submit}}} + + render_input(ui, value: "First\n", appearance: false, multiline: true) + settle(ui) + assert bounds(ui, "input-host").height > initial_height + type(ui, "Second") + assert_receive {:gpui, ^ui, {:event, %{type: :change, value: "First\nSecond"}}} + press(ui, :enter) + + assert_receive {:gpui, ^ui, + {:event, %{type: :submit, event: "name_submitted", value: "First\nSecond"}}} + end + + test "multiline inputs wrap to the available width", %{ui: ui} do + render_input(ui, value: "", appearance: false, multiline: true) + settle(ui) + assert %{width: 280.0, height: initial_height} = bounds(ui, "input-host") + + text = String.duplicate("word ", 40) + type(ui, text) + assert_receive {:gpui, ^ui, {:event, %{type: :change, value: ^text}}} + settle(ui) + assert bounds(ui, "input-host").height > initial_height + end + + test "multiline inputs stop growing at their row limit", %{ui: ui} do + render_input(ui, value: "First", appearance: false, multiline: true, max_rows: 2) + settle(ui) + initial_height = bounds(ui, "input-host").height + + render_input(ui, value: "First\nSecond", appearance: false, multiline: true, max_rows: 2) + settle(ui) + bounded_height = bounds(ui, "input-host").height + assert bounded_height > initial_height + + render_input(ui, + value: "First\nSecond\nThird", + appearance: false, + multiline: true, + max_rows: 2 + ) + + settle(ui) + assert bounds(ui, "input-host").height == bounded_height + end + defmodule MotionButtonView do use GPUI.View