Skip to main content

Module ch4_text_input

Module ch4_text_input 

Source
Expand description

§4. Text input

The list still contains only starter data. Add a text field above it, then bind Enter to an action that appends its text to the list.

Three GPUI mechanisms meet here: an entity owns editing state, focus routes keystrokes to that state, and an action gives Enter a name that can be bound in the surrounding view.

§Add the text field

Text editing lives in a companion crate, gpui_ce_elements, so add it to Cargo.toml next to GPUI:

gpui_ce_elements = { git = "https://github.com/gpui-ce/gpui-ce" }

A text field has state of its own: the text, the cursor, the selection, the undo history. That state lives in an Entity, an EditableTextState, which your view creates and keeps. Remove the starter todos, and create the field’s state instead:

use gpui_ce_elements::editable_text::{EditableTextState, StringStorage};

struct Todos {
    todos: Vec<Todo>,
    input: Entity<EditableTextState>,
}

impl Todos {
    fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
        let input = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
        window.focus(&input.focus_handle(cx), cx);
        Self { todos: Vec::new(), input }
    }
}

cx.new works as it did in chapter 1: the app owns the field state and returns an Entity handle. Todos keeps that handle so the action can read and clear the field. Focusing its handle routes keyboard input there when the window opens. At most one element in a window has focus at a time. Keystrokes go to that element, and clicking another field moves focus there.

new now needs the window, so create the view with cx.new(|cx| Todos::new(window, cx)) in main.

Then show the field in render, right after the title. The text_input element draws the state you pass it:

text_input("new-todo")
    .state(input.downgrade())
    .placeholder("What needs doing?")
    .px_3()
    .py_2()
    .rounded_lg()
    .border_1()
    .border_color(colors.border)

§Bind Enter to an action

Editing keys like Backspace and the arrows need bindings too. The text field comes with a standard set; install them once, in main, before opening the window:

use gpui_ce_elements::editable_text::actions::{DEFAULT_INPUT_CONTEXT, default_bindings};

cx.bind_keys(default_bindings().as_keybindings(Some(DEFAULT_INPUT_CONTEXT)));

Enter should add the todo, but the view should not inspect raw key events. GPUI separates the operation from the key that invokes it. You declare the operation as an action: a type that names a command. Then you bind a key to it, and handle it in the view:

use gpui::{App, KeyBinding, actions};

actions!(todos, [AddTodo]);

fn bind_keys(cx: &mut App) {
    cx.bind_keys([KeyBinding::new("enter", AddTodo, Some("Todos"))]);
}

The third argument to KeyBinding::new is a key context. The binding applies when the focused element is inside an element that declared that context. Declare it on the view’s root element, alongside the handler:

impl Todos {
    fn add(&mut self, _: &AddTodo, _window: &mut Window, cx: &mut Context<Self>) {
        let title = self.input.read(cx).as_str().trim().to_string();
        if title.is_empty() {
            return;
        }
        self.todos.push(Todo { title: title.into(), done: false });
        self.input.update(cx, |input, cx| input.emplace("", cx));
        cx.notify();
    }
}

div()
    .key_context("Todos")
    .on_action(cx.listener(Self::add))
    // ...the rest of the view, as before

When Enter is pressed, GPUI searches the contexts around the focused element for a binding whose key matches. The text field is inside Todos, so enter → AddTodo matches. GPUI dispatches AddTodo from the focused element up through its ancestors until a handler takes it: your add method.

add reads the field’s text through its entity, adds a todo, and clears the field with emplace. Reading another entity with read and changing it with update is how views work with each other’s state.

Actions may look like a long way round compared to listening for the Enter key directly. They pay off as an app grows: the same action can be bound to different keys, triggered from a menu or a button, rebound by the user, and listed with its shortcut in a command palette. Input and dispatch explains how GPUI routes keys and actions.

§The program so far

src/main.rs
use gpui::{
    App, Context, Entity, Focusable, FontWeight, KeyBinding, QuitMode, SharedString, Window,
    WindowOptions, actions, colors::Colors, div, prelude::*, px,
};
use gpui_ce_elements::editable_text::{
    EditableTextState, StringStorage,
    actions::{DEFAULT_INPUT_CONTEXT, default_bindings},
    text_input,
};

actions!(todos, [AddTodo]);

struct Todo {
    title: SharedString,
    done: bool,
}

struct Todos {
    todos: Vec<Todo>,
    input: Entity<EditableTextState>,
}

impl Todos {
    fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
        let input = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
        window.focus(&input.focus_handle(cx), cx);
        Self { todos: Vec::new(), input }
    }

    fn add(&mut self, _: &AddTodo, _window: &mut Window, cx: &mut Context<Self>) {
        let title = self.input.read(cx).as_str().trim().to_string();
        if title.is_empty() {
            return;
        }
        self.todos.push(Todo { title: title.into(), done: false });
        self.input.update(cx, |input, cx| input.emplace("", cx));
        cx.notify();
    }

    fn row(&self, index: usize, todo: &Todo, colors: &Colors, cx: &mut Context<Self>) -> impl IntoElement + use<> {
        div()
            .id(index)
            .flex()
            .items_center()
            .gap_3()
            .px_2()
            .py_2()
            .rounded_md()
            .cursor_pointer()
            .hover(|style| style.bg(colors.container))
            .child(
                div()
                    .size_5()
                    .rounded_full()
                    .border_1()
                    .border_color(colors.border)
                    .when(todo.done, |checkbox| checkbox.bg(colors.selected)),
            )
            .child(
                div()
                    .flex_1()
                    .when(todo.done, |title| title.line_through().text_color(colors.disabled))
                    .child(todo.title.clone()),
            )
            .on_click(cx.listener(move |todos, _event, _window, cx| {
                let todo = &mut todos.todos[index];
                todo.done = !todo.done;
                cx.notify();
            }))
    }
}

impl Render for Todos {
    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let colors = Colors::for_appearance(window);
        let left = self.todos.iter().filter(|todo| !todo.done).count();

        div()
            .key_context("Todos")
            .on_action(cx.listener(Self::add))
            .size_full()
            .flex()
            .justify_center()
            .p_8()
            .bg(colors.background)
            .text_color(colors.text)
            .child(
                div()
                    .w_full()
                    .max_w(px(480.))
                    .flex()
                    .flex_col()
                    .gap_4()
                    .child(div().text_3xl().font_weight(FontWeight::SEMIBOLD).child("Todos"))
                    .child(
                        text_input("new-todo")
                            .state(self.input.downgrade())
                            .placeholder("What needs doing?")
                            .px_3()
                            .py_2()
                            .rounded_lg()
                            .border_1()
                            .border_color(colors.border),
                    )
                    .child(
                        div().flex().flex_col().children(
                            self.todos
                                .iter()
                                .enumerate()
                                .map(|(index, todo)| self.row(index, todo, &colors, cx)),
                        ),
                    )
                    .child(div().text_sm().text_color(colors.disabled).child(format!("{left} left"))),
            )
    }
}

fn main() {
    gpui_platform::application()
        .with_quit_mode(QuitMode::LastWindowClosed)
        .run(|cx: &mut App| {
            cx.bind_keys(default_bindings().as_keybindings(Some(DEFAULT_INPUT_CONTEXT)));
            cx.bind_keys([KeyBinding::new("enter", AddTodo, Some("Todos"))]);

            cx.open_window(WindowOptions::default(), |window, cx| {
                cx.new(|cx| Todos::new(window, cx))
            })
            .unwrap();
            cx.activate(true);
        });
}

The field now supplies the text that Chapter 2 stored and Chapter 3 laid out. Chapter 5 will reuse the same event boundary when rows gain independent controls. In the next chapter, deleting and filtering make stable ids necessary, and the row becomes a reusable component.