1use gpui_kit::{
4 App, AppContext as _, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
5 IntoElement, ParentElement as _, Render, SharedString, Styled as _, Subscription, Window,
6 assets::IconName,
7 component::{
8 ActiveTheme as _, Disableable as _, Sizable as _,
9 button::{Button, ButtonVariants as _},
10 h_flex,
11 input::{InputEvent, Textarea, TextareaState},
12 v_flex,
13 },
14 div,
15};
16
17#[derive(Clone, Debug, PartialEq, Eq)]
19pub enum ComposerEvent {
20 Submit(String),
22 Stop,
24}
25
26pub struct Composer {
33 textarea: Entity<TextareaState>,
34 streaming: bool,
35 disabled: bool,
36 pending_clear: bool,
37 hint: Option<SharedString>,
38 _input: Subscription,
39}
40
41impl Composer {
42 pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
44 let textarea = cx.new(|cx| {
45 TextareaState::new(window, cx)
46 .auto_grow(1, 6)
47 .placeholder("Ask your library…")
48 });
49
50 let input = cx.subscribe(&textarea, |this, _, event: &InputEvent, cx| match event {
51 InputEvent::PressEnter {
52 secondary: true, ..
53 } => this.submit(cx),
54 InputEvent::Change | InputEvent::Focus | InputEvent::Blur => cx.notify(),
55 InputEvent::PressEnter { .. } => {}
56 });
57
58 Self {
59 textarea,
60 streaming: false,
61 disabled: false,
62 pending_clear: false,
63 hint: None,
64 _input: input,
65 }
66 }
67
68 pub fn textarea(&self) -> &Entity<TextareaState> {
70 &self.textarea
71 }
72
73 pub fn value(&self, cx: &App) -> SharedString {
75 self.textarea.read(cx).value()
76 }
77
78 pub fn set_draft(
80 &mut self,
81 text: impl Into<SharedString>,
82 window: &mut Window,
83 cx: &mut Context<Self>,
84 ) {
85 let text = text.into();
86 self.textarea
87 .update(cx, |state, cx| state.set_value(text, window, cx));
88 self.textarea
89 .update(cx, |state, cx| state.focus(window, cx));
90 }
91
92 pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
94 self.textarea
95 .update(cx, |state, cx| state.clean(window, cx));
96 }
97
98 pub fn focus(&mut self, window: &mut Window, cx: &mut Context<Self>) {
100 self.textarea
101 .update(cx, |state, cx| state.focus(window, cx));
102 }
103
104 pub fn set_streaming(&mut self, streaming: bool, cx: &mut Context<Self>) {
106 if self.streaming != streaming {
107 self.streaming = streaming;
108 cx.notify();
109 }
110 }
111
112 pub fn is_streaming(&self) -> bool {
114 self.streaming
115 }
116
117 pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
119 if self.disabled != disabled {
120 self.disabled = disabled;
121 cx.notify();
122 }
123 }
124
125 pub fn set_hint(&mut self, hint: impl Into<SharedString>, cx: &mut Context<Self>) {
127 self.hint = Some(hint.into());
128 cx.notify();
129 }
130
131 pub fn submit(&mut self, cx: &mut Context<Self>) {
135 if self.streaming || self.disabled {
136 return;
137 }
138 let text = self.textarea.read(cx).value().trim().to_owned();
139 if text.is_empty() {
140 return;
141 }
142 self.pending_clear = true;
143 cx.emit(ComposerEvent::Submit(text));
144 cx.notify();
145 }
146
147 pub fn request_stop(&mut self, cx: &mut Context<Self>) {
149 if self.streaming {
150 cx.emit(ComposerEvent::Stop);
151 }
152 }
153
154 pub fn submit_or_stop(&mut self, cx: &mut Context<Self>) {
156 if self.streaming {
157 self.request_stop(cx);
158 } else {
159 self.submit(cx);
160 }
161 }
162}
163
164impl Render for Composer {
165 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
166 if std::mem::take(&mut self.pending_clear) {
167 self.textarea
168 .update(cx, |state, cx| state.clean(window, cx));
169 self.textarea
170 .update(cx, |state, cx| state.focus(window, cx));
171 }
172
173 let empty = self.textarea.read(cx).value().trim().is_empty();
174 let entity_id = cx.entity_id().as_u64();
175 let action_id = ElementId::NamedInteger("composer-action".into(), entity_id);
176
177 let action = if self.streaming {
178 Button::new(action_id)
179 .icon(IconName::Square)
180 .label("Stop")
181 .small()
182 .on_click(cx.listener(|this, _, _, cx| this.submit_or_stop(cx)))
183 } else {
184 Button::new(action_id)
185 .icon(IconName::Send)
186 .label("Send")
187 .primary()
188 .small()
189 .disabled(empty || self.disabled)
190 .on_click(cx.listener(|this, _, _, cx| this.submit_or_stop(cx)))
191 };
192
193 let hint = self.hint.clone().unwrap_or_else(|| submit_hint().into());
194
195 v_flex()
196 .p_3()
197 .gap_2()
198 .border_t_1()
199 .border_color(cx.theme().border)
200 .child(Textarea::new(&self.textarea).bordered(true))
201 .child(
202 h_flex()
203 .justify_between()
204 .gap_2()
205 .child(
206 div()
207 .text_sm()
208 .text_color(cx.theme().muted_foreground)
209 .child(hint),
210 )
211 .child(action),
212 )
213 }
214}
215
216impl EventEmitter<ComposerEvent> for Composer {}
217
218impl Focusable for Composer {
219 fn focus_handle(&self, cx: &App) -> FocusHandle {
220 self.textarea.read(cx).focus_handle(cx)
221 }
222}
223
224fn submit_hint() -> &'static str {
225 if cfg!(target_os = "macos") {
226 "⌘+Enter to send · Enter for a new line"
227 } else {
228 "Ctrl+Enter to send · Enter for a new line"
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use std::{cell::RefCell, rc::Rc};
235
236 use gpui_kit::{Render, TestAppContext, VisualTestContext};
237
238 use super::*;
239
240 struct Host {
241 composer: Entity<Composer>,
242 events: Rc<RefCell<Vec<ComposerEvent>>>,
243 }
244
245 impl Host {
246 fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
247 let composer = cx.new(|cx| Composer::new(window, cx));
248 let events = Rc::new(RefCell::new(Vec::new()));
249 let sink = events.clone();
250 cx.subscribe(&composer, move |_, _, event: &ComposerEvent, _| {
251 sink.borrow_mut().push(event.clone());
252 })
253 .detach();
254 Self { composer, events }
255 }
256 }
257
258 impl Render for Host {
259 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
260 self.composer.clone()
261 }
262 }
263
264 fn mount(cx: &mut TestAppContext) -> (Entity<Host>, &mut VisualTestContext) {
265 cx.update(crate::init);
266 cx.add_window_view(Host::new)
267 }
268
269 fn focus(host: &Entity<Host>, cx: &mut VisualTestContext) {
270 cx.update(|window, cx| {
271 host.update(cx, |host, cx| {
272 host.composer
273 .update(cx, |composer, cx| composer.focus(window, cx));
274 });
275 });
276 }
277
278 #[gpui_kit::test]
279 fn secondary_enter_emits_submit(cx: &mut TestAppContext) {
280 let (host, cx) = mount(cx);
281 focus(&host, cx);
282 cx.simulate_input("Summarise the corpus");
283
284 let shortcut = if cfg!(target_os = "macos") {
285 "cmd-enter"
286 } else {
287 "ctrl-enter"
288 };
289 cx.simulate_keystrokes(shortcut);
290
291 cx.update(|_, cx| {
292 assert_eq!(
293 host.read(cx).events.borrow().as_slice(),
294 &[ComposerEvent::Submit("Summarise the corpus".to_owned())]
295 );
296 });
297 }
298
299 #[gpui_kit::test]
300 fn enter_inserts_a_newline_without_submitting(cx: &mut TestAppContext) {
301 let (host, cx) = mount(cx);
302 focus(&host, cx);
303 cx.simulate_input("hello");
304 cx.simulate_keystrokes("enter");
305
306 cx.update(|_, cx| {
307 assert_eq!(host.read(cx).composer.read(cx).value(cx), "hello\n");
308 assert!(host.read(cx).events.borrow().is_empty());
309 });
310 }
311
312 #[gpui_kit::test]
313 fn a_streaming_composer_emits_stop(cx: &mut TestAppContext) {
314 let (host, cx) = mount(cx);
315 cx.update(|_, cx| {
316 host.update(cx, |host, cx| {
317 host.composer.update(cx, |composer, cx| {
318 composer.set_streaming(true, cx);
319 composer.submit_or_stop(cx);
320 });
321 });
322 });
323
324 cx.update(|_, cx| {
325 assert_eq!(
326 host.read(cx).events.borrow().as_slice(),
327 &[ComposerEvent::Stop]
328 );
329 });
330 }
331}