Skip to main content

gpui_kit/controls/
inline_edit.rs

1//! Text that becomes a field when it is asked to.
2//!
3//! Whether the field is open, and what it holds when it opens, are the
4//! caller's. `InlineEdit` reports that an edit was requested, committed, or
5//! abandoned, and writes nothing.
6//!
7//! # A failed save keeps what was typed
8//!
9//! Losing someone's typing because the host refused the save is the worst
10//! thing this component could do. So the field is built once when an editing
11//! session opens and is left alone for as long as the session stays open: a
12//! caller that answers a commit with [`InlineEdit::failure`] and keeps
13//! `editing` true gets the typed text still there, under the reason it did not
14//! save.
15
16use std::cell::RefCell;
17use std::collections::HashMap;
18use std::rc::Rc;
19
20use gpui::{
21    App, AppContext, Entity, Focusable, Global, InteractiveElement, IntoElement, ParentElement,
22    RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
23    prelude::FluentBuilder, px,
24};
25use gpui_kit_semantics::{NodeSpec, Role, Semantic};
26use gpui_kit_theme::{ActiveTheme, ControlSize, Radius, Space, TypeScale};
27
28use crate::controls::input::{self, TextInput};
29use crate::controls::textarea::{self, TextArea};
30use crate::foundation::{
31    Disableable, FocusRing, Ident, Pressable, Sizable, StyledExt, text as foundation_text,
32};
33use crate::strings::{ActiveStrings, StringKey};
34
35type EditHandler = Rc<dyn Fn(&mut Window, &mut App)>;
36type CommitHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
37
38/// The field an open editing session holds.
39#[derive(Clone)]
40enum Editor {
41    Line(Entity<TextInput>),
42    Block(Entity<TextArea>),
43}
44
45impl Editor {
46    fn value(&self, cx: &App) -> SharedString {
47        match self {
48            Self::Line(field) => field.read(cx).value().clone(),
49            Self::Block(field) => field.read(cx).value().clone(),
50        }
51    }
52
53    fn focus(&self, window: &mut Window, cx: &mut App) {
54        let handle = match self {
55            Self::Line(field) => field.read(cx).focus_handle(cx),
56            Self::Block(field) => field.read(cx).focus_handle(cx),
57        };
58        window.focus(&handle, cx);
59    }
60
61    fn is_block(&self) -> bool {
62        matches!(self, Self::Block(_))
63    }
64}
65
66/// What one identity remembers between two frames.
67///
68/// A `RenderOnce` builder cannot carry anything across frames, so the open
69/// field lives in an application global keyed by the component's identity —
70/// the same arrangement [`crate::layout::measure`] and [`crate::data::DataGrid`]
71/// use.
72#[derive(Default)]
73struct Memory {
74    editor: RefCell<Option<Editor>>,
75}
76
77#[derive(Default)]
78struct Memories(RefCell<HashMap<SharedString, Rc<Memory>>>);
79
80impl Global for Memories {}
81
82fn memory(id: &SharedString, cx: &mut App) -> Rc<Memory> {
83    if !cx.has_global::<Memories>() {
84        cx.set_global(Memories::default());
85    }
86    let mut memories = cx.global::<Memories>().0.borrow_mut();
87    Rc::clone(memories.entry(id.clone()).or_default())
88}
89
90/// Text that a click or enter turns into a field.
91#[derive(IntoElement)]
92pub struct InlineEdit {
93    ident: Ident,
94    value: SharedString,
95    placeholder: Option<SharedString>,
96    editing: bool,
97    multiline: bool,
98    rows: usize,
99    failure: Option<SharedString>,
100    size: ControlSize,
101    disabled: bool,
102    on_edit: Option<EditHandler>,
103    on_commit: Option<CommitHandler>,
104    on_cancel: Option<EditHandler>,
105}
106
107impl std::fmt::Debug for InlineEdit {
108    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        formatter
110            .debug_struct("InlineEdit")
111            .field("ident", &self.ident)
112            .field("editing", &self.editing)
113            .field("multiline", &self.multiline)
114            .field("failed", &self.failure.is_some())
115            .field("disabled", &self.disabled)
116            .finish()
117    }
118}
119
120impl InlineEdit {
121    pub fn new(ident: impl Into<Ident>, value: impl Into<SharedString>) -> Self {
122        Self {
123            ident: ident.into(),
124            value: value.into(),
125            placeholder: None,
126            editing: false,
127            multiline: false,
128            rows: 3,
129            failure: None,
130            size: ControlSize::Md,
131            disabled: false,
132            on_edit: None,
133            on_commit: None,
134            on_cancel: None,
135        }
136    }
137
138    /// What to show where an empty value would be, so a blank line is still
139    /// something to aim at.
140    /// The placeholder the host gave, or the built-in default word for a
141    /// value that is not there.
142    fn resolved_placeholder(&self, cx: &App) -> SharedString {
143        self.placeholder
144            .clone()
145            .unwrap_or_else(|| cx.strings().text(StringKey::InlineEditPlaceholder))
146    }
147
148    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
149        self.placeholder = Some(placeholder.into());
150        self
151    }
152
153    /// Whether the caller has opened the field. The component never opens it
154    /// on its own; a click reports the request.
155    pub fn editing(mut self, editing: bool) -> Self {
156        self.editing = editing;
157        self
158    }
159
160    /// Edits over a [`TextArea`] rather than a [`TextInput`], so enter inserts
161    /// a line and the platform modifier plus enter commits.
162    pub fn multiline(mut self, multiline: bool) -> Self {
163        self.multiline = multiline;
164        self
165    }
166
167    /// How many rows a multi-line field opens at.
168    pub fn rows(mut self, rows: usize) -> Self {
169        self.rows = rows.max(1);
170        self
171    }
172
173    /// Why the last save did not take, in the host's own words. The typed text
174    /// stays exactly as it was.
175    pub fn failure(mut self, failure: impl Into<SharedString>) -> Self {
176        self.failure = Some(failure.into());
177        self
178    }
179
180    /// Reports that the typist asked to edit.
181    pub fn on_edit(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
182        self.on_edit = Some(Rc::new(handler));
183        self
184    }
185
186    /// Reports the text the field held when the edit ended.
187    pub fn on_commit(
188        mut self,
189        handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
190    ) -> Self {
191        self.on_commit = Some(Rc::new(handler));
192        self
193    }
194
195    /// Reports that the edit was abandoned. Nothing was typed as far as the
196    /// host is concerned.
197    pub fn on_cancel(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
198        self.on_cancel = Some(Rc::new(handler));
199        self
200    }
201
202    /// The field for the open session, built once when the session opens.
203    fn editor(&self, state: &Rc<Memory>, window: &mut Window, cx: &mut App) -> Editor {
204        let existing = state
205            .editor
206            .borrow()
207            .clone()
208            .filter(|editor| editor.is_block() == self.multiline);
209        if let Some(editor) = existing {
210            return editor;
211        }
212
213        let ident = self.ident.child("field");
214        let editor = if self.multiline {
215            let rows = self.rows;
216            let value = self.value.clone();
217            let placeholder = self.resolved_placeholder(cx);
218            Editor::Block(cx.new(|cx| {
219                TextArea::new(ident, window, cx)
220                    .text(value)
221                    .placeholder(placeholder)
222                    .rows(rows)
223            }))
224        } else {
225            let value = self.value.clone();
226            let placeholder = self.resolved_placeholder(cx);
227            Editor::Line(cx.new(|cx| {
228                TextInput::new(ident, window, cx)
229                    .text(value)
230                    .placeholder(placeholder)
231                    .bare(true)
232            }))
233        };
234        *state.editor.borrow_mut() = Some(editor.clone());
235        editor.focus(window, cx);
236        editor
237    }
238}
239
240impl Disableable for InlineEdit {
241    /// Refuses editing entirely: no click, no key, and no field, whatever the
242    /// caller says about `editing`.
243    fn disabled(mut self, disabled: bool) -> Self {
244        self.disabled = disabled;
245        self
246    }
247}
248
249impl Sizable for InlineEdit {
250    fn control_size(mut self, size: ControlSize) -> Self {
251        self.size = size;
252        self
253    }
254}
255
256impl RenderOnce for InlineEdit {
257    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
258        let theme = cx.theme().clone();
259        let metrics = theme.control.get(self.size);
260        let state = memory(&self.ident.semantic_id(), cx);
261        let editing = self.editing && !self.disabled;
262
263        if !editing {
264            // The session is over; the next one starts from the caller's value
265            // rather than from whatever the last one left behind.
266            state.editor.borrow_mut().take();
267        }
268
269        let failure = self.failure.clone().map(|reason| {
270            foundation_text(&theme, TypeScale::Body, reason.clone())
271                .text_color(theme.colors.danger)
272                .semantic_in(
273                    cx,
274                    NodeSpec::new(self.ident.child("failure").semantic_id(), Role::Status)
275                        .parent(self.ident.semantic_id())
276                        .invalid(true)
277                        .text(reason),
278                )
279        });
280
281        if !editing {
282            let actionable = !self.disabled && self.on_edit.is_some();
283            let empty = self.value.is_empty();
284            let mut reading = div()
285                .id(self.ident.element_id())
286                .row()
287                .min_h(px(metrics.height))
288                .px(px(theme.space(Space::Xs)))
289                .radius(&theme, Radius::Control)
290                .child(
291                    foundation_text(
292                        &theme,
293                        TypeScale::Label,
294                        if empty {
295                            self.resolved_placeholder(cx)
296                        } else {
297                            self.value.clone()
298                        },
299                    )
300                    .text_size(px(metrics.font_size))
301                    .text_color(if self.disabled || empty {
302                        theme.colors.text_faint
303                    } else {
304                        theme.colors.text
305                    }),
306                )
307                .when(actionable, |element| {
308                    element
309                        .cursor_pointer()
310                        .tab_index(0)
311                        .pressable(cx)
312                        .hover(|style| style.bg(theme.colors.hover))
313                        .focus_ring(&theme)
314                });
315
316            if let (true, Some(handler)) = (actionable, self.on_edit.clone()) {
317                let key = Rc::clone(&handler);
318                reading = reading
319                    .on_click(move |_, window, cx| handler(window, cx))
320                    .on_key_down(move |event, window, cx| {
321                        if matches!(event.keystroke.key.as_str(), "enter" | "space") {
322                            key(window, cx);
323                            cx.stop_propagation();
324                        }
325                    });
326            }
327
328            let mut spec = NodeSpec::new(
329                self.ident.semantic_id(),
330                if actionable { Role::Button } else { Role::Text },
331            )
332            .disabled(!actionable)
333            .invalid(self.failure.is_some())
334            .value(if empty { "empty" } else { "reading" });
335            if empty {
336                spec = spec.placeholder(self.resolved_placeholder(cx));
337            } else {
338                spec = spec.text(self.value.clone());
339            }
340
341            return div()
342                .column()
343                .w_full()
344                .gap(px(theme.space(Space::Xs)))
345                .child(reading)
346                .children(failure)
347                .semantic_in(cx, spec)
348                .into_any_element();
349        }
350
351        let editor = self.editor(&state, window, cx);
352        let reading = editor.clone();
353        let commit = self.on_commit.clone().map(|handler| {
354            let editor = reading.clone();
355            move |window: &mut Window, cx: &mut App| {
356                handler(editor.value(cx), window, cx);
357            }
358        });
359        let cancel = self.on_cancel.clone();
360
361        let mut frame = div()
362            .id(self.ident.element_id())
363            .column()
364            .w_full()
365            .gap(px(theme.space(Space::Xs)));
366
367        if let Some(commit) = commit.clone() {
368            // A press anywhere else ends the edit the way leaving a field
369            // does, which is the other half of "enter or blur commits".
370            frame = frame.on_mouse_down_out(move |_, window, cx| commit(window, cx));
371        }
372
373        // The field's own bindings dispatch before an ancestor's key listener,
374        // so the two chords are taken as actions in the capture phase rather
375        // than as keystrokes that never arrive.
376        if let Some(commit) = commit {
377            let line = commit.clone();
378            frame = frame
379                .capture_action::<input::Submit>(move |_, window, cx| {
380                    line(window, cx);
381                    cx.stop_propagation();
382                })
383                .capture_action::<textarea::Submit>(move |_, window, cx| {
384                    commit(window, cx);
385                    cx.stop_propagation();
386                });
387        }
388
389        if let Some(cancel) = cancel {
390            let line = Rc::clone(&cancel);
391            frame = frame
392                .capture_action::<input::Cancel>(move |_, window, cx| {
393                    line(window, cx);
394                    cx.stop_propagation();
395                })
396                .capture_action::<textarea::Cancel>(move |_, window, cx| {
397                    cancel(window, cx);
398                    cx.stop_propagation();
399                });
400        }
401
402        let field = match editor {
403            Editor::Line(field) => div()
404                .w_full()
405                .min_h(px(metrics.height))
406                .px(px(theme.space(Space::Xs)))
407                .radius(&theme, Radius::Control)
408                .well(&theme)
409                .when(self.failure.is_some(), |element| {
410                    element.border_color(theme.colors.danger)
411                })
412                .shadow(theme.focus_ring())
413                .text_size(px(metrics.font_size))
414                .child(field),
415            Editor::Block(field) => div().w_full().child(field),
416        };
417
418        frame
419            .child(field)
420            .children(failure)
421            .semantic_in(
422                cx,
423                NodeSpec::new(self.ident.semantic_id(), Role::Group)
424                    .text(self.value.clone())
425                    .invalid(self.failure.is_some())
426                    .value(if self.failure.is_some() {
427                        "failed"
428                    } else {
429                        "editing"
430                    }),
431            )
432            .into_any_element()
433    }
434}