Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 15 additions & 1 deletion apps/gpui_components/lib/gpui/ui.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions apps/gpui_components/native/src/generated/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2764,6 +2764,9 @@ pub(crate) struct InputComponentNode {
pub(crate) value: String,
pub(crate) focus_request: u64,
pub(crate) placeholder: Option<String>,
pub(crate) appearance: bool,
pub(crate) multiline: bool,
pub(crate) max_rows: u64,
pub(crate) size: Option<String>,
pub(crate) disabled: bool,
pub(crate) cleanable: bool,
Expand All @@ -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),
Expand Down
18 changes: 18 additions & 0 deletions apps/gpui_components/test/test/gpui/ui/components_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
51 changes: 44 additions & 7 deletions apps/gpui_native/native/src/element/component/form.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,14 @@ fn input_accessibility(
value: String,
placeholder: Option<String>,
masked: bool,
multiline: bool,
) -> InputAccessibility {
InputAccessibility {
label,
role: if masked {
gpui::Role::PasswordInput
} else if multiline {
gpui::Role::MultilineTextInput
} else {
gpui::Role::TextInput
},
Expand Down Expand Up @@ -101,13 +104,19 @@ 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() {
let state = context.cx.new(|cx| {
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);
}
Expand All @@ -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());
Expand All @@ -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(
Expand All @@ -168,7 +180,7 @@ pub(crate) fn render_input_component(
);
}
}
InputEvent::Focus | InputEvent::Blur => {}
(InputEvent::Focus | InputEvent::Blur, _) => {}
}
},
);
Expand Down Expand Up @@ -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() {
Expand All @@ -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);
}
Expand Down Expand Up @@ -754,6 +771,7 @@ mod tests {
"Ada".to_string(),
Some("Name".to_string()),
false,
false,
),
InputAccessibility {
label: "Display name".to_string(),
Expand All @@ -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,
Expand All @@ -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 {
Expand Down
38 changes: 19 additions & 19 deletions apps/gpui_native/native/src/generated/atoms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions apps/gpui_native/native/src/generated/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2764,6 +2764,9 @@ pub(crate) struct InputComponentNode {
pub(crate) value: String,
pub(crate) focus_request: u64,
pub(crate) placeholder: Option<String>,
pub(crate) appearance: bool,
pub(crate) multiline: bool,
pub(crate) max_rows: u64,
pub(crate) size: Option<String>,
pub(crate) disabled: bool,
pub(crate) cleanable: bool,
Expand All @@ -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),
Expand Down
88 changes: 76 additions & 12 deletions apps/gpui_native/test/test/gpui/test/native/controls_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,23 @@ defmodule GPUI.Test.Native.ControlsTest do
@impl GPUI.View
def render(assigns) do
~GPUI"""
<GPUI.UI.input
id="name"
label="Name"
value={assigns.value}
placeholder={assigns.placeholder}
disabled={assigns.disabled}
loading={assigns.loading}
masked={assigns.masked}
focus_request={assigns.focus_request}
phx-change="name_changed"
phx-submit="name_submitted"
/>
<div id="input-host" class="flex flex-col w-[280px]">
<GPUI.UI.input
id="name"
label="Name"
value={assigns.value}
placeholder={assigns.placeholder}
disabled={assigns.disabled}
loading={assigns.loading}
masked={assigns.masked}
appearance={assigns.appearance}
multiline={assigns.multiline}
max_rows={assigns.max_rows}
focus_request={assigns.focus_request}
phx-change="name_changed"
phx-submit="name_submitted"
/>
</div>
"""
end
end
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading