moly_kit/widgets/
prompt_input.rs

1use makepad_widgets::defer_with_redraw::DeferWithRedraw;
2use makepad_widgets::*;
3use std::cell::{Ref, RefMut};
4
5#[allow(unused)]
6use crate::{
7    aitk::protocol::*,
8    utils::makepad::events::EventExt,
9    widgets::attachment_list::{AttachmentListRef, AttachmentListWidgetExt},
10};
11
12script_mod! {
13    use mod.prelude.widgets.*
14
15    let SubmitButton = Button {
16        width: 28,
17        height: 28,
18        padding: 0
19        margin: Inset { bottom: 2 },
20        align: Align { x: 0.5, y: 0.5 }
21
22        draw_text +: {
23            text_style: theme.font_icons { font_size: 11 }
24            color: #fff
25            color_hover: #fff
26            color_focus: #fff
27            color_down: #fff
28        }
29
30        draw_bg +: {
31            get_color: fn() {
32                if self.disabled == 1.0 {
33                    return #xD0D5DD;
34                }
35                return #000;
36            }
37
38            pixel: fn() {
39                let sdf = Sdf2d.viewport(self.pos * self.rect_size)
40                let center = self.rect_size * 0.5
41                let radius = min(self.rect_size.x self.rect_size.y) * 0.5
42
43                sdf.circle(center.x center.y radius)
44                sdf.fill_keep(self.get_color())
45
46                return sdf.result
47            }
48        }
49    }
50
51    let SendButton = SubmitButton {
52        text: "\u{f062}" // fa-arrow-up
53    }
54
55    let StopButton = SubmitButton {
56        visible: false
57        text: "\u{f04d}" // fa-stop
58        draw_text +: {
59            text_style +: { font_size: 8.5 }
60        }
61    }
62
63    let AttachButton = Button {
64        visible: false
65        text: "\u{f0c6}" // fa-paperclip
66        width: Fit,
67        height: Fit,
68        padding: Inset { left: 8, right: 8, top: 6, bottom: 6 }
69        draw_text +: {
70            text_style: theme.font_icons { font_size: 13. }
71            color: #333,
72            color_hover: #111,
73            color_focus: #111
74            color_down: #000
75        }
76        draw_bg +: {
77            color_down: #0000
78            border_radius: 7.
79            border_size: 0.
80            color_hover: #xf2
81        }
82    }
83
84    let AudioButton = Button {
85        visible: false
86        width: 28, height: 28
87        text: "\u{f590}" // fa-headset
88        draw_text +: {
89            text_style: theme.font_icons { font_size: 13. }
90            color: #333,
91            color_hover: #111,
92            color_focus: #111
93            color_down: #000
94        }
95        draw_bg +: {
96            color_down: #0000
97            border_radius: 7.
98            border_size: 0.
99        }
100    }
101
102    let SttButton = Button {
103        visible: false
104        width: 28, height: 28
105        text: "\u{f130}" // fa-microphone
106        draw_text +: {
107            text_style: theme.font_icons { font_size: 13. }
108            color: #333,
109            color_hover: #111,
110            color_focus: #111
111            color_down: #000
112        }
113        draw_bg +: {
114            color_down: #0000
115            border_radius: 7.
116            border_size: 0.
117        }
118    }
119
120    let SendControls = View {
121        width: Fit, height: Fit
122        align: Align { x: 0.5, y: 0.5 }
123        spacing: 10
124        stt := SttButton {}
125        audio := AudioButton {}
126        send := SendButton {}
127        stop := StopButton {}
128    }
129
130    mod.widgets.PromptInputBase = #(PromptInput::register_widget(vm))
131    mod.widgets.PromptInput = set_type_default() do mod.widgets.PromptInputBase {
132        height: Fit { max: FitBound.Abs(350) }
133        flow: Down
134
135        persistent := RoundedView {
136            height: Fit
137            flow: Down
138            padding: Inset { top: 10, bottom: 10, left: 10, right: 10 }
139            draw_bg +: {
140                color: #fff,
141                border_radius: 10.0,
142                border_color: #xD0D5DD,
143                border_size: 1.0,
144            }
145            top := View {
146                height: Fit
147                attachments := mod.widgets.DenseAttachmentList {}
148            }
149            center := View {
150                height: Fit
151                text_input := TextInput {
152                    height: Fit {
153                        min: FitBound.Abs(35)
154                        max: FitBound.Abs(180)
155                    }
156                    width: Fill
157                    empty_text: "Start typing...",
158                    draw_bg +: {
159                        pixel: fn() {
160                            return vec4(0.)
161                        }
162                    }
163                    draw_text +: {
164                        color: #000
165                        color_hover: #000
166                        color_focus: #000
167                        color_empty: #x98A2B3
168                        color_empty_focus: #x98A2B3
169                        color_empty_hover: #x98A2B3
170                        text_style +: { font_size: 11 }
171                    }
172                    draw_selection +: {
173                        color: #xd9e7e999
174                        color_hover: #xd9e7e999
175                        color_focus: #xd9e7e999
176                    }
177                    draw_cursor +: {
178                        color: #000
179                    }
180                }
181                right := View {
182                    width: Fit, height: Fit
183                }
184            }
185            bottom := View {
186                height: Fit
187                left := View {
188                    width: Fit, height: Fit
189                    align: Align { x: 0.0, y: 0.5 }
190                    attach := AttachButton {}
191                    model_selector := mod.widgets.ModelSelector {}
192                }
193                width: Fill, height: Fit
194                separator := View { width: Fill, height: 1 }
195                SendControls {}
196            }
197        }
198    }
199}
200
201#[derive(Default, Copy, Clone, PartialEq)]
202pub enum Task {
203    #[default]
204    Send,
205    Stop,
206}
207
208#[derive(Default, Copy, Clone, PartialEq)]
209pub enum Interactivity {
210    #[default]
211    Enabled,
212    Disabled,
213}
214
215/// A prepared text input for conversation with bots.
216///
217/// This is mostly a dummy widget. Prefer using and adapting [crate::widgets::chat::Chat] instead.
218#[derive(Script, Widget)]
219pub struct PromptInput {
220    #[deref]
221    pub deref: View,
222
223    /// Placeholder text shown when the input is empty.
224    #[live(String::from("Start typing..."))]
225    pub empty_text: String,
226
227    /// Placeholder text shown when a realtime model is selected.
228    #[live(String::from("For realtime models, use the audio feature ->"))]
229    pub realtime_empty_text: String,
230
231    /// If this widget should provoke sending a message or stopping the current response.
232    #[rust]
233    pub task: Task,
234
235    /// If this widget should be interactive or not.
236    #[rust]
237    pub interactivity: Interactivity,
238
239    /// Capabilities of the currently selected bot
240    #[rust]
241    pub bot_capabilities: Option<BotCapabilities>,
242}
243
244impl ScriptHook for PromptInput {
245    fn on_after_new(&mut self, _vm: &mut ScriptVm) {
246        // Cannot call update_button_visibility here because we don't have cx.
247        // It will be called later when bot capabilities are set.
248    }
249}
250
251impl Widget for PromptInput {
252    fn set_text(&mut self, cx: &mut Cx, v: &str) {
253        self.text_input(cx, ids!(text_input)).set_text(cx, v);
254    }
255
256    fn text(&self) -> String {
257        self.child_by_path(&[id!(text_input)])
258            .borrow::<TextInput>()
259            .map(|ti| ti.text())
260            .unwrap_or_else(|| {
261                error!("PromptInput::text(): text_input child not found or wrong type");
262                String::new()
263            })
264    }
265
266    fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
267        self.deref.handle_event(cx, event, scope);
268        self.ui_runner().handle(cx, event, scope, self);
269
270        if self.button(cx, ids!(attach)).clicked(event.actions()) {
271            let ui = self.ui_runner();
272            Attachment::pick_multiple(move |result| match result {
273                Ok(attachments) => {
274                    ui.defer_with_redraw(move |me: &mut PromptInput, cx, _| {
275                        let mut list = me.attachment_list_ref(cx);
276                        list.write().attachments.extend(attachments);
277                        list.write().on_tap(move |list, index| {
278                            list.attachments.remove(index);
279                        });
280                    });
281                }
282                Err(_) => {}
283            });
284        }
285    }
286
287    fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
288        let supports_realtime = self
289            .bot_capabilities
290            .as_ref()
291            .map(|caps| caps.has_capability(&BotCapability::AudioCall))
292            .unwrap_or(false);
293
294        let supports_attachments = self
295            .bot_capabilities
296            .as_ref()
297            .map(|caps| caps.has_capability(&BotCapability::AttachmentInput))
298            .unwrap_or(false);
299
300        // Attach button: only on supported platforms and if bot supports it
301        #[cfg(any(
302            target_os = "windows",
303            target_os = "macos",
304            target_os = "linux",
305            target_arch = "wasm32"
306        ))]
307        self.button(cx, ids!(attach))
308            .set_visible(cx, supports_attachments);
309
310        #[cfg(not(any(
311            target_os = "windows",
312            target_os = "macos",
313            target_os = "linux",
314            target_arch = "wasm32"
315        )))]
316        self.button(cx, ids!(attach)).set_visible(cx, false);
317
318        // Audio button: only on non-wasm and if bot supports realtime
319        #[cfg(not(target_arch = "wasm32"))]
320        self.button(cx, ids!(audio))
321            .set_visible(cx, supports_realtime);
322
323        // Send/stop buttons: hidden for realtime, toggled by task otherwise
324        let mut send = self.button(cx, ids!(send));
325        let mut stop = self.button(cx, ids!(stop));
326
327        if supports_realtime {
328            send.set_visible(cx, false);
329            stop.set_visible(cx, false);
330        } else {
331            match self.task {
332                Task::Send => {
333                    send.set_visible(cx, true);
334                    stop.set_visible(cx, false);
335                }
336                Task::Stop => {
337                    send.set_visible(cx, false);
338                    stop.set_visible(cx, true);
339                }
340            }
341        }
342
343        // Interactivity and text input state
344        let input = self.text_input(cx, ids!(text_input));
345        if supports_realtime {
346            self.interactivity = Interactivity::Disabled;
347            input.set_is_read_only(cx, true);
348            input.set_empty_text(cx, self.realtime_empty_text.clone());
349        } else {
350            input.set_is_read_only(cx, false);
351            input.set_empty_text(cx, self.empty_text.clone());
352        }
353
354        let enabled = self.interactivity == Interactivity::Enabled;
355        send.set_enabled(cx, enabled);
356        stop.set_enabled(cx, enabled);
357
358        self.deref.draw_walk(cx, scope, walk)
359    }
360}
361
362impl PromptInput {
363    /// Reset this prompt input erasing text, removing attachments, etc.
364    pub fn reset(&mut self, cx: &mut Cx) {
365        self.text_input_ref(cx).set_text(cx, "");
366        self.attachment_list_ref(cx).write().attachments.clear();
367    }
368
369    /// Returns a reference to the inner `TextInput` widget.
370    pub fn text_input_ref(&self, cx: &Cx) -> TextInputRef {
371        self.text_input(cx, ids!(text_input))
372    }
373
374    /// Check if the submit button or the return key was pressed.
375    ///
376    /// Note: To know what the button submission means, check [Self::task] or
377    /// the utility methods.
378    pub fn submitted(&self, cx: &Cx, actions: &Actions) -> bool {
379        let send = self.button(cx, ids!(send));
380        let stop = self.button(cx, ids!(stop));
381        let input = self.text_input_ref(cx);
382        (send.clicked(actions) || stop.clicked(actions) || input.returned(actions).is_some())
383            && self.interactivity == Interactivity::Enabled
384    }
385
386    pub fn call_pressed(&self, cx: &Cx, actions: &Actions) -> bool {
387        self.button(cx, ids!(audio)).clicked(actions)
388    }
389
390    pub fn stt_pressed(&self, cx: &Cx, actions: &Actions) -> bool {
391        self.button(cx, ids!(stt)).clicked(actions)
392    }
393
394    /// Shorthand to check if [Self::task] is set to [Task::Send].
395    pub fn has_send_task(&self) -> bool {
396        self.task == Task::Send
397    }
398
399    /// Shorthand to check if [Self::task] is set to [Task::Stop].
400    pub fn has_stop_task(&self) -> bool {
401        self.task == Task::Stop
402    }
403
404    /// Allows submission.
405    pub fn enable(&mut self) {
406        self.interactivity = Interactivity::Enabled;
407    }
408
409    /// Disallows submission.
410    pub fn disable(&mut self) {
411        self.interactivity = Interactivity::Disabled;
412    }
413
414    /// Shorthand to set [Self::task] to [Task::Send].
415    pub fn set_send(&mut self) {
416        self.task = Task::Send;
417    }
418
419    /// Shorthand to set [Self::task] to [Task::Stop].
420    pub fn set_stop(&mut self) {
421        self.task = Task::Stop;
422    }
423
424    pub(crate) fn attachment_list_ref(&self, cx: &Cx) -> AttachmentListRef {
425        self.attachment_list(cx, ids!(attachments))
426    }
427
428    /// Set the chat controller for the model selector
429    pub fn set_chat_controller(
430        &mut self,
431        cx: &Cx,
432        controller: Option<
433            std::sync::Arc<std::sync::Mutex<crate::aitk::controllers::chat::ChatController>>,
434        >,
435    ) {
436        if let Some(mut inner) = self
437            .widget(cx, ids!(model_selector))
438            .borrow_mut::<crate::widgets::model_selector::ModelSelector>()
439        {
440            inner.chat_controller = controller;
441        }
442    }
443
444    /// Set the capabilities of the currently selected bot
445    pub fn set_bot_capabilities(&mut self, cx: &mut Cx, capabilities: Option<BotCapabilities>) {
446        let was_realtime = self
447            .bot_capabilities
448            .as_ref()
449            .map(|caps| caps.has_capability(&BotCapability::AudioCall))
450            .unwrap_or(false);
451
452        let is_realtime = capabilities
453            .as_ref()
454            .map(|caps| caps.has_capability(&BotCapability::AudioCall))
455            .unwrap_or(false);
456
457        self.bot_capabilities = capabilities;
458
459        // Reset text input state when switching away from realtime
460        if was_realtime && !is_realtime {
461            self.interactivity = Interactivity::Enabled;
462            self.text_input_ref(cx).set_is_read_only(cx, false);
463            self.text_input_ref(cx).set_text(cx, "");
464        }
465
466        self.redraw(cx);
467    }
468
469    /// Set whether the speech-to-text button is visible.
470    pub fn set_stt_visible(&mut self, cx: &mut Cx, visible: bool) {
471        self.button(cx, ids!(stt)).set_visible(cx, visible);
472    }
473}
474
475impl PromptInputRef {
476    /// Immutable access to the underlying [[PromptInput]].
477    ///
478    /// Panics if the widget reference is empty or if it's already borrowed.
479    pub fn read(&self) -> Ref<'_, PromptInput> {
480        self.borrow().unwrap()
481    }
482
483    /// Mutable access to the underlying [[PromptInput]].
484    ///
485    /// Panics if the widget reference is empty or if it's already borrowed.
486    pub fn write(&mut self) -> RefMut<'_, PromptInput> {
487        self.borrow_mut().unwrap()
488    }
489
490    /// Immutable reader to the underlying [[PromptInput]].
491    ///
492    /// Panics if the widget reference is empty or if it's already borrowed.
493    pub fn read_with<R>(&self, f: impl FnOnce(&PromptInput) -> R) -> R {
494        f(&*self.read())
495    }
496
497    /// Mutable writer to the underlying [[PromptInput]].
498    ///
499    /// Panics if the widget reference is empty or if it's already borrowed.
500    pub fn write_with<R>(&mut self, f: impl FnOnce(&mut PromptInput) -> R) -> R {
501        f(&mut *self.write())
502    }
503}