Files
Ely-GPUI-Components/src/forms/inline.rs
T
ZacharyZhang-NY 4c3ed1bbb9 Port · Build: Zed's gpui at 1a28cff
gpui and gpui_platform now come from Zed's repo by git at 1a28cff,
the code gpui-pre 0.3.7 republishes, so the gallery can later build
for the browser. gpui takes stacker, as 0.2.2 always did, and
gpui_platform takes font-kit, without which macOS draws no text;
runtime_shaders moves to gpui_platform.

The API moved: focus, blur and Tab steps take cx, Corner is Anchor,
shaped lines paint with an alignment, defer_draw takes a content
mask, the inspector registers a factory, async updates return their
value, and wrapped lines are shared, not cloned. Updates through a
window context go through the window, so a closed window still ends
them as it did.

Where the new gpui behaves differently, Ely keeps its old intent.
Holds and reveals under a resting pointer survive typing through
gpui's input-modality-independent hover; previews the keyboard also
moves keep the default, and the calendar's leave ignores a key press.
Enter reaches a button inside the code editor, since gpui now arms a
key click on the press. The event logger skips a lone modifier. A
video and an image grid keep their shape in a flex column under
taffy 0.13, as a picture does.

AGENTS.md records the new stack and the rules this port changes; the
rest of the old gpui and taffy rules are re-tested in T44c.
2026-09-28 20:30:17 -04:00

204 lines
7.3 KiB
Rust

use std::rc::Rc;
use gpui::{
App, Context, ElementId, Entity, FocusHandle, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Subscription,
Window, div, prelude::*,
};
use super::{Input, InputEvent, TextInput};
use crate::{
primitives::{FocusRing, Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, Radius},
typography::Ellipsis,
};
pub(crate) type OnCommit = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// A text being edited in place: its field while open, who hears the kept text, and where focus goes back after Enter or Escape. An edit that hands focus back keeps its text on Enter's release, so the release does not open it again.
#[derive(Default)]
pub(crate) struct Editing {
field: Option<(Entity<TextInput>, Subscription)>,
pub on_commit: Option<OnCommit>,
back: Option<FocusHandle>,
}
impl Editing {
/// Opens a field on `value`, all of it selected and focused; Enter or leaving keeps it.
pub(crate) fn begin(state: &Entity<Editing>, value: String, window: &mut Window, cx: &mut App) {
let field = cx.new(|cx| {
let mut input = TextInput::new(window, cx);
input.set_text(value, cx);
input
});
let all = field.read(cx).text().len();
field.update(cx, |input, cx| input.select(0..all, cx));
window.focus(&field.read(cx).focus().clone(), cx);
let owner = state.clone();
let events = window.subscribe(&field, cx, move |_, event, window, cx| match event {
InputEvent::Submit => owner.update(cx, |editing, cx| {
if editing.back.is_none() {
editing.finish(false, window, cx);
}
}),
InputEvent::Blur => owner.update(cx, |editing, cx| {
editing.back = None;
editing.finish(false, window, cx);
}),
_ => {}
});
state.update(cx, |editing, cx| {
editing.field = Some((field, events));
cx.notify();
});
}
/// Focus goes back here after Enter or Escape.
pub(crate) fn returning(&mut self, back: FocusHandle) {
self.back = Some(back);
}
pub(crate) fn give_back(&mut self, window: &mut Window, cx: &mut App) {
if let Some(back) = self.back.take() {
log::info!("inline edit: focus handed back");
window.focus(&back, cx);
}
}
pub(crate) fn field(&self) -> Option<Entity<TextInput>> {
self.field.as_ref().map(|(field, _)| field.clone())
}
/// Ends editing; keeps the text unless `cancel`.
pub(crate) fn finish(&mut self, cancel: bool, window: &mut Window, cx: &mut Context<Self>) {
let Some((field, _)) = self.field.take() else {
return;
};
if cancel {
log::info!("inline edit: cancelled");
} else {
let text = SharedString::from(field.read(cx).text().to_string());
log::info!("inline edit: kept");
if let Some(commit) = self.on_commit.clone() {
commit(&text, window, cx);
}
}
cx.notify();
}
}
/// Text that becomes a field when clicked. Enter or leaving keeps; Escape reverts.
#[derive(IntoElement)]
pub struct InlineEdit {
id: ElementId,
value: SharedString,
placeholder: SharedString,
on_commit: Option<OnCommit>,
}
impl InlineEdit {
pub fn new(id: impl Into<ElementId>, value: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
value: value.into(),
placeholder: "Empty".into(),
on_commit: None,
}
}
pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
self.placeholder = text.into();
self
}
pub fn on_commit(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_commit = Some(Rc::new(handler));
self
}
}
impl RenderOnce for InlineEdit {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Editing::default());
let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
state.update(cx, |editing, _| editing.on_commit = self.on_commit.clone());
if let Some(field) = state.read(cx).field() {
let (escape, enter) = (state.clone(), state.clone());
return div()
.id(self.id)
.on_key_down(move |event, window, cx| {
if event.keystroke.key == "escape" {
cx.stop_propagation();
escape.update(cx, |editing, cx| {
editing.finish(true, window, cx);
editing.give_back(window, cx);
});
}
})
.on_key_up(move |event, window, cx| {
if event.keystroke.key == "enter" {
cx.stop_propagation();
enter.update(cx, |editing, cx| {
editing.finish(false, window, cx);
editing.give_back(window, cx);
});
}
})
.child(Input::new(&field).size(ControlSize::Sm))
.into_any_element();
}
let theme = cx.theme();
let colors = &theme.colors;
let empty = self.value.is_empty();
let (value, start, back) = (self.value.clone(), state, focus.clone());
let group = SharedString::from(format!("inline-edit-{:?}", self.id));
let label = div()
.debug_selector(|| format!("inline-edit {}", self.id))
.flex_1()
.min_w_0()
.child(Ellipsis::new(if empty {
self.placeholder
} else {
self.value
}));
div()
.id(self.id)
.group(group.clone())
.flex()
.items_center()
.gap_2()
.h(theme.control_height(ControlSize::Sm))
.px(theme.control_padding(ControlSize::Sm))
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(gpui::transparent_black())
.track_focus(&focus)
.focus_ring(cx)
.cursor_text()
.hover(|style| style.bg(colors.hover))
.text_color(if empty { colors.fg_subtle } else { colors.fg })
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| {
log::info!("inline edit: editing");
start.update(cx, |editing, _| editing.returning(back.clone()));
Editing::begin(&start, value.to_string(), window, cx);
})
.child(label)
.child(
div()
.flex_none()
.invisible()
.group_hover(group, |style| style.visible())
.child(
Icon::new(IconName::Pencil)
.size(IconSize::Xs)
.color(colors.fg_subtle),
),
)
.into_any_element()
}
}