moly_kit/widgets/
model_selector.rs

1use makepad_widgets::*;
2use std::sync::{Arc, Mutex};
3
4use crate::{
5    aitk::{
6        controllers::chat::{ChatController, ChatStateMutation},
7        protocol::*,
8    },
9    utils::makepad::events::EventExt,
10    widgets::{
11        model_selector_item::ModelSelectorItemAction, model_selector_list::ModelSelectorList,
12        moly_modal::MolyModalWidgetExt,
13    },
14};
15
16script_mod! {
17    use mod.prelude.widgets.*
18    use mod.widgets.*
19
20    let ModelSelectorButton = Button {
21        width: Fit
22        height: Fit
23        padding: Inset { left: 8, right: 8, top: 6, bottom: 6 }
24
25        draw_bg +: {
26            color_down: #0000
27            border_radius: 7.
28            border_size: 0.
29            color_hover: #xf2
30        }
31
32        draw_text +: {
33            text_style: theme.font_regular {
34                font_size: 11.
35            }
36            color: #222
37            color_hover: #111
38            color_focus: #111
39            color_down: #000
40        }
41    }
42
43    let ModelSelectorOptions = RoundedShadowView {
44        width: Fill, height: Fit
45        padding: 8
46        flow: Down
47        spacing: 8
48
49        show_bg: true
50        draw_bg +: {
51            color: #xf9
52            border_radius: 6.0
53            shadow_color: instance(#0002)
54            shadow_radius: 9.0
55            shadow_offset: vec2(0.0, -2.0)
56        }
57
58        search_container := RoundedView {
59            width: Fill, height: Fit
60            show_bg: true
61            padding: Inset { top: 4, bottom: 4, left: 8, right: 8 }
62            spacing: 8
63            align: Align { x: 0.0, y: 0.5 }
64            draw_bg +: {
65                border_radius: 6.0
66                border_color: #xD0D5DD
67                border_size: 1.0
68                color: #fff
69            }
70
71            search_input := TextInput {
72                width: Fill, height: Fit
73                draw_bg +: {
74                    pixel: fn() -> vec4 {
75                        return vec4(0.);
76                    }
77                }
78                draw_text +: {
79                    text_style: theme.font_regular { font_size: 11 }
80                    color: #000
81                    color_hover: #x98A2B3
82                    color_focus: #000
83                    color_empty: #x98A2B3
84                    color_empty_focus: #x98A2B3
85                    color_empty_hover: #x98A2B3
86                }
87                draw_cursor +: {
88                    color: #000
89                }
90                empty_text: "Search models"
91            }
92        }
93
94        list_container := ScrollYView {
95            width: Fill
96            height: 200
97            scroll_bars +: {
98                scroll_bar_y +: {
99                    drag_scrolling: true
100                    draw_bg +: {
101                        color: #xD9
102                        color_hover: #888
103                        color_drag: #777
104                    }
105                }
106            }
107
108            list := ModelSelectorList {}
109        }
110    }
111
112    mod.widgets.ModelSelector = #(ModelSelector::register_widget(vm)) {
113        width: Fit, height: Fit
114        flow: Overlay
115
116        button := ModelSelectorButton {
117            text: "Loading model..."
118        }
119
120        modal := MolyModal {
121            dismiss_on_focus_lost: true
122            bg_view +: {
123                visible: false
124            }
125            align: Align { x: 0.0, y: 0.0 }
126
127            content +: {
128                width: 400
129                height: Fit
130                padding: Inset { top: 20, left: 10, right: 10, bottom: 20 }
131                options := ModelSelectorOptions {}
132            }
133        }
134    }
135}
136
137#[derive(Script, ScriptHook, Widget)]
138pub struct ModelSelector {
139    #[deref]
140    view: View,
141
142    #[rust]
143    pub chat_controller: Option<Arc<Mutex<ChatController>>>,
144
145    #[rust]
146    pub open: bool,
147}
148
149impl Widget for ModelSelector {
150    fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
151        self.view.handle_event(cx, event, scope);
152        self.widget_match_event(cx, event, scope);
153
154        // Handle button click to open/close modal
155        if self.button(cx, ids!(button)).clicked(event.actions()) {
156            if !self.open {
157                self.open_modal(cx);
158            } else {
159                self.close_modal(cx);
160            }
161        }
162
163        // Handle modal dismissal
164        if self.moly_modal(cx, ids!(modal)).dismissed(event.actions()) {
165            self.close_modal(cx);
166            self.clear_search(cx);
167            self.button(cx, ids!(button)).reset_hover(cx);
168        }
169
170        // On mobile, handle clicks on background view to dismiss modal
171        if self.open && !cx.display_context.is_desktop() {
172            if let Hit::FingerUp(fe) = event.hits(cx, self.view(cx, ids!(modal.bg_view)).area()) {
173                if fe.was_tap() {
174                    self.close_modal(cx);
175                    self.clear_search(cx);
176                    self.button(cx, ids!(button)).reset_hover(cx);
177                }
178            }
179        }
180    }
181
182    fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
183        // Read state from controller
184        let (bots, selected_bot_id) = if let Some(chat_controller) = &self.chat_controller {
185            let state = chat_controller.lock().unwrap().state().clone();
186            (state.bots, state.bot_id)
187        } else {
188            (Vec::new(), None)
189        };
190
191        // Handle empty bots case - disable button
192        if bots.is_empty() {
193            self.button(cx, ids!(button))
194                .set_text(cx, "No models available");
195            self.button(cx, ids!(button)).set_enabled(cx, false);
196        } else {
197            self.button(cx, ids!(button)).set_enabled(cx, true);
198
199            // Update button text based on selected bot
200            if let Some(bot_id) = &selected_bot_id {
201                if let Some(bot) = bots.iter().find(|b| &b.id == bot_id) {
202                    self.button(cx, ids!(button)).set_text(cx, &bot.name);
203                } else {
204                    self.button(cx, ids!(button))
205                        .set_text(cx, "Choose an AI assistant");
206                }
207            } else {
208                self.button(cx, ids!(button))
209                    .set_text(cx, "Choose an AI assistant");
210            }
211        }
212
213        // Set the chat controller on the list before drawing
214        if let Some(controller) = &self.chat_controller
215            && let Some(mut list) = self
216                .widget(cx, ids!(options.list_container.list))
217                .borrow_mut::<ModelSelectorList>()
218            && Arc::as_ptr(controller)
219                != list
220                    .chat_controller
221                    .as_ref()
222                    .map(Arc::as_ptr)
223                    .unwrap_or(std::ptr::null())
224        {
225            {
226                list.chat_controller = Some(controller.clone());
227            }
228        }
229
230        self.view.draw_walk(cx, scope, walk)
231    }
232}
233
234impl WidgetMatchEvent for ModelSelector {
235    fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) {
236        // Handle search input changes
237        if let Some(text) = self
238            .text_input(cx, ids!(options.search_container.search_input))
239            .changed(actions)
240        {
241            if let Some(mut list) = self
242                .widget(cx, ids!(options.list_container.list))
243                .borrow_mut::<ModelSelectorList>()
244            {
245                list.search_filter = text;
246                list.items.clear();
247                list.total_height = None;
248            }
249        }
250
251        // Handle bot selection from list items.
252        // Only process actions from our own list widget to avoid
253        // handling global actions.
254        let list_widget = self.widget(cx, ids!(options.list_container.list));
255        for action in actions {
256            let Some(action) = action.as_widget_action() else {
257                continue;
258            };
259
260            if action.widget_uid != list_widget.widget_uid() {
261                continue;
262            }
263
264            match action.cast() {
265                ModelSelectorItemAction::BotSelected(bot_id) => {
266                    if let Some(controller) = &self.chat_controller {
267                        controller
268                            .lock()
269                            .unwrap()
270                            .dispatch_mutation(ChatStateMutation::SetBotId(Some(bot_id)));
271                    }
272
273                    self.button(cx, ids!(button)).reset_hover(cx);
274                    self.close_modal(cx);
275                    self.clear_search(cx);
276                    self.redraw(cx);
277                }
278                _ => {}
279            }
280        }
281    }
282}
283
284impl ModelSelector {
285    fn open_modal(&mut self, cx: &mut Cx) {
286        self.open = true;
287
288        let button_rect = self.button(cx, ids!(button)).area().rect(cx);
289        let is_desktop = cx.display_context.is_desktop();
290
291        if is_desktop {
292            let padding = Inset {
293                top: 20.0,
294                left: 10.0,
295                right: 10.0,
296                bottom: 10.0,
297            };
298            let mut content = self.view(cx, ids!(modal.content));
299            script_apply_eval!(cx, content, {
300                width: 400
301                padding: #(padding)
302            });
303
304            let anchor = DVec2 {
305                x: button_rect.pos.x,
306                y: button_rect.pos.y,
307            };
308            self.moly_modal(cx, ids!(modal))
309                .open_as_popup_above(cx, anchor, 5.0);
310        } else {
311            let fill = Size::fill();
312            let mut content = self.view(cx, ids!(modal.content));
313            script_apply_eval!(cx, content, {
314                width: #(fill)
315                padding: 0
316            });
317
318            self.moly_modal(cx, ids!(modal)).open_as_bottom_sheet(cx);
319        }
320    }
321
322    fn close_modal(&mut self, cx: &mut Cx) {
323        self.open = false;
324        self.moly_modal(cx, ids!(modal)).close(cx);
325    }
326
327    fn clear_search(&mut self, cx: &mut Cx) {
328        if let Some(mut list) = self
329            .widget(cx, ids!(options.list_container.list))
330            .borrow_mut::<ModelSelectorList>()
331        {
332            list.search_filter.clear();
333            list.items.clear();
334            list.total_height = None;
335        }
336        self.text_input(cx, ids!(options.search_container.search_input))
337            .set_text(cx, "");
338        self.redraw(cx);
339    }
340}
341
342impl ModelSelectorRef {
343    /// Sets the chat controller for the model selector.
344    pub fn set_chat_controller(&mut self, controller: Option<Arc<Mutex<ChatController>>>) {
345        if let Some(mut inner) = self.borrow_mut() {
346            inner.chat_controller = controller;
347        }
348    }
349
350    /// Set a custom grouping function for organizing bots in the list.
351    ///
352    /// By default, bots are grouped by their provider (extracted from
353    /// BotId). Applications can provide a custom grouping function to
354    /// add provider icons, custom display names, or different grouping
355    /// logic.
356    pub fn set_grouping<F>(&mut self, cx: &Cx, grouping: F)
357    where
358        F: Fn(&Bot) -> BotGroup + 'static,
359    {
360        if let Some(inner) = self.borrow_mut() {
361            if let Some(mut list) = inner
362                .widget(cx, ids!(options.list_container.list))
363                .borrow_mut::<ModelSelectorList>()
364            {
365                list.grouping = Box::new(grouping);
366            }
367        }
368    }
369}
370
371/// Default grouping: groups all bots under "All" category.
372pub fn default_grouping(bot: &Bot) -> BotGroup {
373    BotGroup {
374        id: "all".to_string(),
375        label: "All".to_string(),
376        icon: Some(bot.avatar.clone()),
377    }
378}
379
380/// Defines how a bot should be grouped in the model selector.
381///
382/// This struct is returned by the grouping function to specify:
383/// - A unique group identifier for deduplication and sorting
384/// - A display label shown in the group header
385/// - An optional icon displayed next to the group label
386#[derive(Clone, Debug)]
387pub struct BotGroup {
388    /// Unique identifier for the group (used for deduplication and sorting)
389    pub id: String,
390    /// Display name shown in the group header
391    pub label: String,
392    /// Optional icon displayed next to the group label
393    pub icon: Option<EntityAvatar>,
394}