1use 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#[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#[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#[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 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 pub fn editing(mut self, editing: bool) -> Self {
156 self.editing = editing;
157 self
158 }
159
160 pub fn multiline(mut self, multiline: bool) -> Self {
163 self.multiline = multiline;
164 self
165 }
166
167 pub fn rows(mut self, rows: usize) -> Self {
169 self.rows = rows.max(1);
170 self
171 }
172
173 pub fn failure(mut self, failure: impl Into<SharedString>) -> Self {
176 self.failure = Some(failure.into());
177 self
178 }
179
180 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 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 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 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 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 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 frame = frame.on_mouse_down_out(move |_, window, cx| commit(window, cx));
371 }
372
373 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}