Input

For multiple addons, shared frames, and textarea toolbars, see Input Group.

Install

A single-line text input with validation, masking, prefix/suffix elements, and different visual states. Use Textarea for ordinary multi-line text and Editor for source code.

#Import

use gpui_kit::component::input::{Input, InputState};
// Installed in ui/components. That directory is on the Slint include path, so import by file name:
import { Input } from "input.slint";
use iced::widget::{Id, column, container, mouse_area, operation, row, scrollable, svg, text};
use iced::{Alignment, Border, Element, Length, Subscription, Task, Theme};

#Usage

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
let usage = column![
    Input::new("email")
        .value(app.email.as_str())
        .placeholder("you@example.com")
        .on_input(Message::Email),
    muted_line(&if app.email.is_empty() {
        "The caller owns the value and takes every edit back through `on_input`.".to_string()
    } else {
        format!("Value: {}", app.email)
    }),
]
.spacing(8.0);

#Basic

let input = cx.new(|cx| InputState::new(window, cx));

Input::new(&input)
GridLayout {
    horizontal-stretch: 1;
    spacing: 12px;
    name := Input {
        horizontal-stretch: 1;
        placeholder: "Enter your name...";
    }
    Input {
        horizontal-stretch: 1;
        text: "John Doe";
    }
}
Text {
    text: name.text == "" ? "Type to see the value." : "Hello, \{name.text}!";
    color: Theme.muted-foreground;
}
let basic = column![
    Input::new("name")
        .value(app.name.as_str())
        .placeholder("Enter your name...")
        .on_input(Message::Name),
    Input::new("greeting")
        .value(app.greeting.as_str())
        .on_input(Message::Greeting),
    muted_line(&if app.name.is_empty() {
        "Type to see the value.".to_string()
    } else {
        format!("Hello, {}!", app.name)
    }),
]
.spacing(12.0);

#Prefix, suffix and clear button

use gpui_kit::component::{Icon, IconName};

// With prefix icon
Input::new(&input)
    .prefix(Icon::new(IconName::Search).small())

// With suffix button
Input::new(&input)
    .suffix(
        Button::new("info")
            .ghost()
            .icon(IconName::Info)
            .xsmall()
    )

// With both
Input::new(&input)
    .prefix(Icon::new(IconName::Search).small())
    .suffix(Button::new("btn").ghost().icon(IconName::Info).xsmall())
GridLayout {
    horizontal-stretch: 1;
    spacing: 12px;
    Input {
        horizontal-stretch: 1;
        placeholder: "Search...";
        prefix-icon: "search";
        cleanable: true;
    }
    Input {
        horizontal-stretch: 1;
        placeholder: "Username";
        prefix-icon: "user";
        Button {
            icon: "info";
            variant: ButtonVariant.ghost;
            size: ControlSize.xsmall;
            accessible-label: "About usernames";
        }
    }
    Input {
        horizontal-stretch: 1;
        text: "1,280.00";
        prefix-text: "$";
        suffix-text: "USD";
        horizontal-alignment: TextHorizontalAlignment.right;
    }
}
let mut search = Input::new("search")
    .value(app.search.as_str())
    .placeholder("Search...")
    .prefix(glyph(SEARCH))
    .on_input(Message::Search);
// Slint draws the clear control itself behind `cleanable`; here it is the
// suffix, shown only while there is something to clear.
if !app.search.is_empty() {
    search = search.suffix(icon_button(X, Message::ClearSearch));
}

let prefix_suffix = column![
    row![
        search,
        Input::new("username")
            .value(app.username.as_str())
            .placeholder("Username")
            .prefix(glyph(USER))
            .suffix(icon_button(INFO, Message::Notice("About usernames")))
            .on_input(Message::Username),
    ]
    .spacing(12.0),
    Input::new("amount")
        .value(app.amount.as_str())
        .prefix(slot_text("$", ControlSize::Medium))
        .suffix(slot_text("USD", ControlSize::Medium))
        .align_x(Alignment::End)
        .on_input(Message::Amount),
]
.spacing(12.0);

#Password

let input = cx.new(|cx|
    InputState::new(window, cx)
        .masked(true)
        .default_value("password123")
);

Input::new(&input)
    .content_type(InputContentType::Password)
    .mask_toggle() // Shows toggle button to reveal password
GridLayout {
    horizontal-stretch: 1;
    spacing: 12px;
    Input {
        horizontal-stretch: 1;
        text: "password123";
        prefix-icon: "lock";
        masked: true;
        mask-toggle: true;
    }
    Input {
        horizontal-stretch: 1;
        placeholder: "Clears on Escape";
        text: "Press Escape";
        clean-on-escape: true;
    }
}
// iced masks with `secure`; a reveal toggle is the caller's suffix, where
// Slint's `masked` and `mask-toggle` are two properties of one control.
let password = column![
    Input::new("password")
        .value(app.password.as_str())
        .prefix(glyph(LOCK))
        .secure(!app.revealed)
        .suffix(icon_button(
            if app.revealed { EYE_OFF } else { EYE },
            Message::Reveal,
        ))
        .on_input(Message::Password),
    muted_line(if app.revealed {
        "Revealed: the toggle is a suffix button."
    } else {
        "Masked: `secure(true)`."
    }),
]
.spacing(12.0);

While the value is masked, the input keeps it out of the clipboard and out of the selection: Copy and Cut do nothing (and are disabled in the context menu), a word-wise delete takes everything before the caret, and a double click selects the whole value instead of one word. Paste and Select All keep working, and revealing the value with mask_toggle restores all of them.

#Sizes

Input::new(&input).large()
Input::new(&input) // medium (default)
Input::new(&input).small()
GridLayout {
    horizontal-stretch: 1;
    spacing: 12px;
    VerticalLayout {
        horizontal-stretch: 1;
        alignment: center;
        Input {
            placeholder: "Large";
            size: ControlSize.large;
        }
    }
    VerticalLayout {
        horizontal-stretch: 1;
        alignment: center;
        Input {
            placeholder: "Medium";
        }
    }
    VerticalLayout {
        horizontal-stretch: 1;
        alignment: center;
        Input {
            placeholder: "Small";
            size: ControlSize.small;
        }
    }
    VerticalLayout {
        horizontal-stretch: 1;
        alignment: center;
        Input {
            placeholder: "Extra small";
            size: ControlSize.xsmall;
        }
    }
}
let sizes = row![
    Input::new("size-large")
        .value(app.large.as_str())
        .placeholder("Large")
        .size(ControlSize::Large)
        .on_input(Message::Large),
    Input::new("size-medium")
        .value(app.medium.as_str())
        .placeholder("Medium")
        .size(ControlSize::Medium)
        .on_input(Message::Medium),
    Input::new("size-small")
        .value(app.small.as_str())
        .placeholder("Small")
        .size(ControlSize::Small)
        .on_input(Message::Small),
    Input::new("size-xsmall")
        .value(app.xsmall.as_str())
        .placeholder("Extra small")
        .size(ControlSize::XSmall)
        .on_input(Message::XSmall),
]
.spacing(12.0);

#States

let input = cx.new(|cx|
    InputState::new(window, cx)
        .placeholder("Enter your name...")
);

Input::new(&input)
GridLayout {
    horizontal-stretch: 1;
    spacing: 12px;
    Input {
        horizontal-stretch: 1;
        text: "Disabled";
        enabled: false;
    }
    Input {
        horizontal-stretch: 1;
        text: "Read-only, still selectable";
        read-only: true;
    }
    Input {
        horizontal-stretch: 1;
        text: "not-an-email";
        invalid: true;
    }
}
// Slint also shows a read-only field here. iced's field either accepts edits
// or is disabled, so the disabled state is the closest, and it is not
// selectable the way read-only is.
let states = row![
    Input::new("state-disabled")
        .value("Disabled")
        .disabled(true),
    Input::new("state-invalid")
        .value(app.state_invalid.as_str())
        .invalid(true)
        .on_input(Message::StateInvalid),
]
.spacing(12.0);

#With Default Value

let input = cx.new(|cx|
    InputState::new(window, cx)
        .default_value("John Doe")
);

Input::new(&input)
SlintNo Slint version of this example yet.

#Cleanable Input

Input::new(&input)
    .cleanable(true) // Show clear button when input has value
SlintNo Slint version of this example yet.

#Disabled Input

Input::new(&input).disabled(true)
VerticalLayout {
    spacing: 12px;
    Input {
        text: "readonly@example.com";
        read-only: true;
    }
    Input {
        text: "locked@example.com";
        enabled: false;
    }
}
let disabled = column![
    Input::new("disabled-value")
        .value("locked@example.com")
        .disabled(true),
    Input::new("disabled-placeholder")
        .placeholder("Empty and disabled")
        .disabled(true),
]
.spacing(12.0);

#Read-only Input

Unlike disabled, a read-only input keeps the normal appearance and still can be focused, selected and copied, it only rejects the changes made by the user.

Input::new(&input).readonly(true)
SlintNo Slint version of this example yet.

#Clean on ESC

let input = cx.new(|cx|
    InputState::new(window, cx)
        .clean_on_escape() // Clear input when ESC is pressed
);

Input::new(&input)
VerticalLayout {
    spacing: 12px;
    Input {
        text: "press escape to clear";
        cleanable: true;
        clean-on-escape: true;
    }
    Input {
        placeholder: "escape clears this too";
        cleanable: true;
        clean-on-escape: true;
    }
}
// Escape is the application's subscription above: iced's field handles no key
// of its own outside editing and captures Escape to leave the field, so
// `event::listen_with` is what hears it. The suffix is the same clear by
// pointer, which is Slint's `cleanable` beside `clean-on-escape`.
let escape = column![
    Input::new("escape")
        .value(app.escape.as_str())
        .placeholder("Press Escape to clear")
        .suffix(icon_button(X, Message::ClearEscape))
        .on_input(Message::Escape),
    muted_line("Escape clears the field — the key is the application's, the suffix does the same."),
]
.spacing(8.0);

#Input Validation

// Validate float numbers
let input = cx.new(|cx|
    InputState::new(window, cx)
        .validate(|s, _| s.parse::<f32>().is_ok())
);

// Regex pattern validation
let input = cx.new(|cx|
    InputState::new(window, cx)
        .pattern(regex::Regex::new(r"^[a-zA-Z0-9]*$").unwrap())
);
VerticalLayout {
    spacing: 12px;
    Input {
        text: "you@example.com";
        prefix-icon: "mail";
    }
    Input {
        text: "not-an-address";
        invalid: true;
        prefix-icon: "mail";
    }
}
let validation = column![
    Input::new("valid-email")
        .value(app.valid_email.as_str())
        .prefix(glyph(MAIL))
        .on_input(Message::ValidEmail),
    Input::new("invalid-email")
        .value(app.invalid_email.as_str())
        .prefix(glyph(MAIL))
        .invalid(true)
        .on_input(Message::InvalidEmail),
]
.spacing(12.0);

#Input Masking

// Phone number mask
let input = cx.new(|cx|
    InputState::new(window, cx)
        .mask_pattern("(999)-999-9999")
);

// Custom pattern: AAA-###-AAA (A=letter, #=digit, 9=digit optional)
let input = cx.new(|cx|
    InputState::new(window, cx)
        .mask_pattern("AAA-###-AAA")
);

// Number with thousands separator
use gpui_kit::component::input::MaskPattern;

let input = cx.new(|cx|
    InputState::new(window, cx)
        .mask_pattern(MaskPattern::Number {
            separator: Some(','),
            fraction: Some(3),
        })
);
SlintNo Slint version of this example yet.

#Handle Input Events

let input = cx.new(|cx| InputState::new(window, cx));

cx.subscribe_in(&input, window, |view, state, event, window, cx| {
    match event {
        InputEvent::Change => {
            let text = state.read(cx).value();
            println!("Input changed: {}", text);
        }
        InputEvent::PressEnter { secondary } => {
            println!("Enter pressed, secondary: {}", secondary);
        }
        InputEvent::Focus => println!("Input focused"),
        InputEvent::Blur => println!("Input blurred"),
    }
});
VerticalLayout {
    spacing: 8px;
    alignment: start;
    Input {
        placeholder: "Type and press enter";
        prefix-icon: "search";
        accepted(text) => {
            root.last-query = text;
        }
    }
    Text {
        text: "Last submitted: " + (root.last-query == "" ? "nothing yet" : root.last-query);
        color: Theme.muted-foreground;
        font-size: Theme.font-size-xs;
    }
}
let events = column![
    Input::new("query")
        .value(app.query.as_str())
        .placeholder("Type and press enter")
        .prefix(glyph(SEARCH))
        .on_input(Message::Query)
        .on_submit(Message::Submit(app.query.clone())),
    muted_line(&format!(
        "Last submitted: {}",
        app.submitted.as_deref().unwrap_or("nothing yet")
    )),
]
.spacing(8.0);

#Custom Appearance

// Without default styling
Input::new(&input).appearance(false)

// Use in custom container
div()
    .border_b_2()
    .px_6()
    .py_3()
    .border_color(cx.theme().border)
    .bg(cx.theme().secondary)
    .child(Input::new(&input).appearance(false))
Input {
    text: "Borderless field";
    appearance: false;
}
// `appearance(false)` drops the frame so the field can sit inside a container
// that draws its own, which is how both the Slint and GPUI examples use it.
let custom = container(
    Input::new("borderless")
        .value(app.borderless.as_str())
        .appearance(false)
        .on_input(Message::Borderless),
)
.padding(6.0)
.width(Length::Fill)
.style(move |theme: &Theme| {
    let tokens = Tokens::of(theme);
    container::Style {
        background: Some(tokens.secondary.into()),
        border: Border {
            color: tokens.border,
            width: 1.0,
            radius: theme::RADIUS_LG.into(),
        },
        ..container::Style::default()
    }
});

#Context Menu

// Turn off the right-click menu entirely, including a custom one.
let input = cx.new(|cx| InputState::new(window, cx).context_menu(false));

// Or replace the built-in menu with your own. The state's context menu must
// stay enabled, which is the default.
Input::new(&input).context_menu(|menu, window, cx| {
    // You can define your own actions and even utilize
    // built-in actions (cut, copy, paste, etc.)
    // to avoid having to re-implement that functionality.
    menu.menu("Custom Action", Box::new(CustomAction))
        .separator()
        .menu("Cut", Box::new(input::Cut))
        .menu("Copy", Box::new(input::Copy))
        .menu("Paste", Box::new(input::Paste))
})
SlintNo Slint version of this example yet.

#Touch Selection

On a touch screen, a long press selects the word under the finger and keeps following the finger while it stays down. Lifting it opens an edit menu over the selection with the commands that apply — Cut, Copy, Paste, and Select All — and puts a grab handle at each end of the selection. Dragging a handle moves that end; the other end stays put, and a multi-line input scrolls when the finger reaches its edge. A long press on whitespace or in an empty field places the caret and offers Paste and Select All.

The handles and the menu belong to the selection the gesture made. They disappear as soon as anything else moves the selection — a tap, typing, an arrow key, Escape — and the menu steps aside while the content scrolls under a finger. Tapping the selected text brings the menu back.

Cut, Copy, and Paste go through the input’s own actions, so a custom key binding or an open completion menu sees them the same way. A read-only input offers only Copy and Select All; a masked input keeps its value out of the clipboard.

#Paste Hook

on_paste intercepts the clipboard before the default text insertion, so pasted images and copied files can live in app-owned state instead of being silently dropped. It is available on Input, Textarea and Editor.

use gpui_kit::ClipboardEntry;

let view = cx.entity().downgrade();
Textarea::new(&self.composer).on_paste(move |item, _, cx| {
    let images: Vec<_> = item.entries().iter().filter_map(|entry| match entry {
        ClipboardEntry::Image(image) => Some(image.clone()),
        _ => None,
    }).collect();
    if images.is_empty() {
        return false; // fall through to the default text insertion
    }
    view.update(cx, |this, cx| {
        // Store the images beside the input, e.g. as `Attachment`s.
        this.attachments.extend(images);
        cx.notify();
    }).ok();
    true // consumed, the input inserts nothing
})
SlintNo Slint version of this example yet.

Return true when the handler took the paste: the input::Paste action stops there and the input inserts nothing. Return false to let the action reach the engine, which inserts clipboard.text() as before. Copied files arrive as ClipboardEntry::ExternalPaths through the same hook.

Known limit: on web read_from_clipboard() is None (text arrives through the platform input handler); image paste there needs async clipboard access and permission, and is out of scope.

#Search Input

let search = cx.new(|cx|
    InputState::new(window, cx)
        .placeholder("Search...")
);

Input::new(&search)
    .prefix(Icon::new(IconName::Search).small())
SlintNo Slint version of this example yet.

#Currency Input

let amount = cx.new(|cx|
    InputState::new(window, cx)
        .mask_pattern(MaskPattern::Number {
            separator: Some(','),
            fraction: Some(2),
        })
);

div()
    .child(Input::new(&amount))
    .child(format!("Value: {}", amount.read(cx).value()))
SlintNo Slint version of this example yet.

#Form with Multiple Inputs

struct FormView {
    name_input: Entity<InputState>,
    email_input: Entity<InputState>,
}

v_flex()
    .gap_3()
    .child(Input::new(&self.name_input))
    .child(Input::new(&self.email_input))
SlintNo Slint version of this example yet.

#Atomic inline tokens

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// iced's field holds one plain `String`: a reference cannot live inside the
// value as a token that selection and deletion move as a unit. A prefix or
// suffix element is an adornment beside the value, not part of it, and that is
// as close as the port comes.
let tokens = column![
    Input::new("tokens")
        .value(app.mention.as_str())
        .placeholder("Ask @alice")
        .prefix(token_chip())
        .on_input(Message::Mention),
    muted_line("The chip is a prefix element; `@alice` in the value is ordinary text."),
]
.spacing(8.0);

Use inline tokens for mentions, file references or commands that should be selected and deleted as a whole. For example, an input can display “Alice” as a token while value() and Copy return its text, @alice.

#Insert a reference

Create the input state once, then insert a token when the user picks a reference:

use gpui_kit::component::input::{InlineToken, Input, InputState};

let input = cx.new(|cx| InputState::new(window, cx));

input.update(cx, |state, cx| {
    state.replace_with_token(
        InlineToken::new("person-1", "@alice").with_label("Alice"),
        window,
        cx,
    ).expect("valid reference");
});

Input::new(&input)
SlintNo Slint version of this example yet.

replace_with_token replaces the selection, or inserts at the caret. It does not add a space. To replace a completion query such as @ali, use replace_range_with_token(range, token, window, cx). Rust ranges are half-open UTF-8 byte ranges; use byte offsets such as those returned by str::find.

The ID names the referenced resource, so two mentions of the same person carry the same ID. Use text for the value to copy or submit and with_label for its displayed name. Omit with_label to display the text itself.

Users can move the caret to either side of a token, click it to select it, or delete it with Backspace/Delete. A selection that crosses part of a token includes the whole token. Undo/Redo restores both its text and reference. Pasting inserts plain text.

#Customize appearance and opening a reference

Tokens render as an InputToken by default. The token slot supplies the element for each token; return one with an icon from it, and use on_token_click to open the reference:

use gpui_kit::component::{
    IconName,
    input::{InputToken, InlineTokenClickEvent},
};

Input::new(&input)
    .token(|token, _, _| {
        InputToken::new(token).icon(IconName::File)
    })
    .on_token_click(|event: &InlineTokenClickEvent, _, _| {
        // Look up event.token().id() and open its resource.
    });
SlintNo Slint version of this example yet.

You can also return your own single-row element. Keep it within the input’s line height; content wider than the available row is clipped. Read selection and readonly/disabled state from the renderer’s context. Do not edit the input from the renderer; event callbacks may update it. Keep hover and selection styles the same size. Tokens are measured whenever they render, so an element that grows once its data arrives reflows on the next frame.

A click selects the token and then opens it; dragging or Shift-selecting a token does not open it. Readonly inputs allow opening references; disabled inputs do not. To offer a keyboard shortcut for opening an exactly selected token, bind ActivateToken to a key of your choice; assistive technology reaches the same listener through the token’s click action. Add a menu item for it through context_menu when your application has a name for the reference, such as “Open file”.

#Show a tooltip or preview on hover

Use on_token_hover to react to pointer presence without changing the document. Hover never selects or edits; it reports entry and exit so the application can show a tooltip, preview, or status detail:

use gpui_kit::component::input::InlineTokenHoverEvent;

Input::new(&input)
    .on_token_hover(|event: &InlineTokenHoverEvent, _, _| {
        if event.is_hovered() {
            // Look up event.token().id() and show its preview.
        }
        // `false` means the pointer left the token: dismiss the preview.
    });
SlintNo Slint version of this example yet.

The event carries the token, its byte range(), the measured bounds(), and is_hovered(). Disabled tokens never report hover entry, matching click; readonly tokens do. Removing, replacing, or disabling a hovered token still sends its exit event so tooltips dismiss. Keep hover and selection styles the same size so the row does not shift under the pointer.

If your token includes a button, consume its mouse-down and click events so that it does not also open the reference. Apply token.is_disabled() to every child action, including accessibility actions, and token.is_readonly() to actions that change the content.

#Save, restore and submit

Use content() to keep the text and references together when saving a draft:

let draft = input.read(cx).content();

// Restore the saved draft later: `set_value` takes plain text or content.
input.update(cx, |state, cx| {
    state.set_value(draft, window, cx);
});
SlintNo Slint version of this example yet.

To restore data from your own storage, build an InputContent from the text and attach each token to its byte range. with_token validates the range against the text as you go, so a content value is always consistent by the time it is set:

use gpui_kit::component::input::InputContent;

let draft = InputContent::new("Ask @alice")
    .with_token(4..10, InlineToken::new("person-1", "@alice").with_label("Alice"))?;
SlintNo Slint version of this example yet.

At submission time, read a fresh content(): text() is the message, and tokens() contains the references still present in it. Use each token’s ID to look up its resource and handle missing resources before sending.

set_value clears undo history and does not emit InputEvent::Change. Passing plain text removes every token, even if the text is unchanged; passing content restores its tokens, except in modes that cannot show them. Use replace_all for an undoable plain-text replacement. Token edits, including adding a reference to existing text, emit InputEvent::Change. Programmatic setters can update readonly or disabled inputs, so check these states in application commands that should be unavailable to users.

#Validation and supported inputs

Tokens work with Input and Textarea. They are not available in Editor, NumberInput, formatted masks or password fields. A token’s ID must not be blank; its text and label must be nonempty, single-line strings without control characters. Ranges cannot overlap or split a Unicode grapheme (such as an emoji or a character with a combining accent), and each token’s text must match its range when restoring a draft.

Token operations return Result<_, InlineTokenError>. A rejected operation leaves the input unchanged. If insertion returns CompositionActive, wait until the user finishes composing with their input method before inserting the token.

#JavaScript

Create and keep an InputState in init(), then render an Input with that state. JavaScript ranges use UTF-16 string offsets, matching slice() and indexOf():

import { Input, InputState } from "gpui-component";

// In init():
this.input = InputState();
this.input.set_value({
  text: "🙂 @alice",
  tokens: [{
    range: { start: 3, end: 9 },
    token: { id: "person-1", text: "@alice", label: "Alice" },
  }],
});

// In render():
new Input(this.input)
  .on_token_click((event, cx) => {
    // Look up event.token.id and open its resource.
  })
  .on_token_hover((event, cx) => {
    // event.hovered is true on entry and false on exit; show or dismiss a preview.
  });
SlintNo Slint version of this example yet.

Use replace_with_token or replace_range_with_token to insert references, content() and set_value(content) to save and restore drafts, and tokens() to read the current references. To remove a reference, pass its current range to set_selected_range, then call replace(""). Returned snapshots are independent objects; changing one does not update the input. Make edits from initialization, event or task callbacks, not from renderers.

Token validation errors expose an error.code, such as InvalidBoundary or CompositionActive; invalid argument shapes also throw. Textarea offers the same methods on TextareaState(). If you use gpui-base, construct these states with InputState.new() or TextareaState.new() instead.