guise-ui 1.5.3

A component library for gpui, Zed's GPU-accelerated UI framework: a themed palette, sizing tokens, 130+ composable components, a reactive state layer, and an in-app Safari-style inspector.
Documentation
//! `TextInput` — a stateful single-line text field (gpui entity).
//!
//! Owns its buffer and focus; renders the shared field chrome (label, box,
//! description/error) and emits [`TextInputEvent`] on edit and submit.
//!
//! The editing surface itself lives in [`line`](super::line), which is what
//! gives the field the behaviour an `<input>` has: click and drag to select,
//! double-click a word, Tab to the next field, the clipboard, undo, IME, and
//! horizontal scrolling for values wider than the box.

use gpui::prelude::*;
use gpui::{
  div, px, App, Context, Entity, EventEmitter, FocusHandle, IntoElement, KeyDownEvent,
  SharedString, Window,
};

use super::line::{self, Line, LineEditor, LineState};
use super::{control_metrics, edit::TextEdit, Field, KeyOutcome};
use crate::devtools::ProbedAny;
use crate::reactive::Signal;
use crate::theme::{theme, ColorName, Size};

/// Emitted as the user edits or submits the field.
#[derive(Debug, Clone)]
pub enum TextInputEvent {
  /// The text changed. Carries the full new value.
  Change(String),
  /// The user pressed Enter. Carries the current value.
  Submit(String),
}

/// A single-line text field. Create with `cx.new(|cx| TextInput::new(cx))`.
pub struct TextInput {
  edit: TextEdit,
  state: LineState,
  focus: FocusHandle,
  placeholder: SharedString,
  label: Option<SharedString>,
  description: Option<SharedString>,
  error: Option<SharedString>,
  size: Size,
  radius: Option<Size>,
  disabled: bool,
  read_only: bool,
  password: bool,
  max_length: Option<usize>,
}

impl EventEmitter<TextInputEvent> for TextInput {}

impl TextInput {
  pub fn new(cx: &mut Context<Self>) -> Self {
    TextInput {
      edit: TextEdit::new(""),
      state: LineState::new(),
      // Every field is a tab stop by default, the way a form control is
      // in a browser. Ordering falls out of render order unless a host
      // sets `tab_index`.
      focus: cx.focus_handle().tab_stop(true),
      placeholder: SharedString::default(),
      label: None,
      description: None,
      error: None,
      size: Size::Sm,
      radius: None,
      disabled: false,
      read_only: false,
      password: false,
      max_length: None,
    }
  }

  pub fn value(mut self, value: &str) -> Self {
    self.edit = TextEdit::new(value);
    self
  }

  pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
    self.placeholder = placeholder.into();
    self
  }

  pub fn label(mut self, label: impl Into<SharedString>) -> Self {
    self.label = Some(label.into());
    self
  }

  pub fn description(mut self, description: impl Into<SharedString>) -> Self {
    self.description = Some(description.into());
    self
  }

  pub fn error(mut self, error: impl Into<SharedString>) -> Self {
    self.error = Some(error.into());
    self
  }

  pub fn size(mut self, size: Size) -> Self {
    self.size = size;
    self
  }

  pub fn radius(mut self, radius: Size) -> Self {
    self.radius = Some(radius);
    self
  }

  pub fn disabled(mut self, disabled: bool) -> Self {
    self.disabled = disabled;
    self
  }

  /// Selectable and copyable, but not editable — an `<input readonly>`.
  /// Unlike [`disabled`](Self::disabled) the field still takes focus.
  pub fn read_only(mut self, read_only: bool) -> Self {
    self.read_only = read_only;
    self
  }

  pub fn password(mut self, password: bool) -> Self {
    self.password = password;
    self
  }

  /// Cap the value's length in characters, like `<input maxlength>`.
  pub fn max_length(mut self, max: usize) -> Self {
    self.max_length = Some(max);
    self
  }

  /// The current text.
  pub fn text(&self) -> String {
    self.edit.text()
  }

  /// Replace the text programmatically.
  pub fn set_text(&mut self, value: &str, cx: &mut Context<Self>) {
    self.edit.set_text(value);
    cx.notify();
  }

  /// Select the whole value, as focusing a field with `<input autofocus>`
  /// and a preset value tends to.
  pub fn select_all(&mut self, cx: &mut Context<Self>) {
    self.edit.select_all();
    cx.notify();
  }

  /// Two-way bind the text to a `Signal<String>`. The signal is the source
  /// of truth: the field adopts its value now, edits write back through
  /// [`Signal::set_if_changed`], and signal writes replace the text without
  /// emitting. Equality guards on both directions prevent update loops.
  pub fn bind(entity: &Entity<TextInput>, signal: &Signal<String>, cx: &mut App) {
    let initial = signal.get(cx);
    entity.update(cx, |this, cx| this.sync_text(initial, cx));
    let sink = signal.clone();
    cx.subscribe(entity, move |_input, event: &TextInputEvent, cx| {
      if let TextInputEvent::Change(text) = event {
        sink.set_if_changed(cx, text.clone());
      }
    })
    .detach();
    let field = entity.downgrade();
    cx.observe(signal.entity(), move |observed, cx| {
      let text = observed.read(cx).clone();
      field.update(cx, |this, cx| this.sync_text(text, cx)).ok();
    })
    .detach();
  }

  /// Programmatic set: repaint without emitting an event.
  fn sync_text(&mut self, text: String, cx: &mut Context<Self>) {
    if self.edit.text() != text {
      self.edit.set_text(&text);
      cx.notify();
    }
  }

  fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
    if self.disabled {
      return;
    }
    match line::keys(self, event, window, cx) {
      KeyOutcome::Submit => {
        cx.emit(TextInputEvent::Submit(self.edit.text()));
        cx.notify();
        cx.stop_propagation();
      }
      KeyOutcome::Edited => {
        self.line_changed(cx);
        cx.stop_propagation();
      }
      // Escape (Cancel) bubbles so dialogs can close on it. Printable
      // keys pass too: the platform hands them to the input handler,
      // which is what makes IME and dead keys work.
      KeyOutcome::Cancel | KeyOutcome::Pass => {}
    }
  }
}

impl LineEditor for TextInput {
  fn edit(&self) -> &TextEdit {
    &self.edit
  }

  fn edit_mut(&mut self) -> &mut TextEdit {
    &mut self.edit
  }

  fn line(&self) -> &LineState {
    &self.state
  }

  fn line_mut(&mut self) -> &mut LineState {
    &mut self.state
  }

  fn line_focus(&self) -> &FocusHandle {
    &self.focus
  }

  fn line_masked(&self) -> bool {
    self.password
  }

  fn line_read_only(&self) -> bool {
    self.read_only || self.disabled
  }

  fn line_max_length(&self) -> Option<usize> {
    self.max_length
  }

  fn line_changed(&mut self, cx: &mut Context<Self>) {
    cx.emit(TextInputEvent::Change(self.edit.text()));
    cx.notify();
  }
}

line::line_input_handler!(TextInput);
line::line_focus_builders!(TextInput);

impl Render for TextInput {
  fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
    let t = theme(cx);
    let (height, pad_x, font) = control_metrics(self.size);
    let radius = t.radius(self.radius.unwrap_or(t.default_radius));
    let focused = self.focus.is_focused(window) && !self.disabled;
    let has_error = self.error.is_some();

    let border = if has_error {
      t.color(ColorName::Red, 6)
    } else if focused {
      t.primary()
    } else {
      t.border()
    }
    .hsla();
    let dimmed = t.dimmed().hsla();
    let surface = t.surface().hsla();

    let line = Line::new(cx.entity()).placeholder(self.placeholder.clone(), dimmed);

    let field = line::wire(div().id("guise-textinput"), &self.focus, cx)
      .on_key_down(cx.listener(Self::on_key))
      .flex()
      .items_center()
      .w_full()
      .overflow_hidden()
      .h(px(height))
      .px(px(pad_x))
      .rounded(px(radius))
      .border_1()
      .border_color(border)
      .bg(surface)
      .text_size(px(font))
      .line_height(px(font * 1.3))
      .child(div().flex_1().min_w(px(0.0)).child(line));

    let mut chrome = Field::new().child(if self.disabled {
      field.opacity(0.6)
    } else {
      field
    });
    if let Some(label) = self.label.clone() {
      chrome = chrome.label(label);
    }
    if let Some(error) = self.error.clone() {
      chrome = chrome.error(error);
    } else if let Some(description) = self.description.clone() {
      chrome = chrome.description(description);
    }
    chrome.probe_any("TextInput")
  }
}