moly_kit/widgets/
model_selector_list.rs

1use super::model_selector_item::{ModelSelectorItemAction, ModelSelectorItemWidgetRefExt};
2use crate::{
3    aitk::{controllers::chat::ChatController, protocol::*},
4    utils::makepad::load_image_from_resource,
5    widgets::model_selector::{BotGroup, default_grouping},
6};
7use makepad_widgets::*;
8use std::collections::HashMap;
9use std::sync::{Arc, Mutex};
10
11// We need a type alias, so Makepad's `#[rust(...)]` macro attribute works.
12type ErasedGroupingClosure = Box<dyn Fn(&Bot) -> BotGroup>;
13
14/// Trait for filtering which bots to show in the model selector.
15pub trait BotFilter {
16    /// Returns whether the given bot should be shown in the list.
17    fn should_show(&self, bot: &Bot) -> bool;
18}
19
20script_mod! {
21    use mod.prelude.widgets.*
22
23    mod.widgets.ModelSelectorList = #(ModelSelectorList::register_widget(vm)) {
24        width: Fill, height: Fit
25        flow: Down
26
27        item_template := mod.widgets.ModelSelectorItem {}
28
29        section_label_template := View {
30            width: Fill, height: Fit
31            padding: Inset { left: 14, top: 6, bottom: 4 }
32            align: Align { x: 0.0, y: 0.5 }
33            spacing: 4
34
35            icon_view := View {
36                width: Fit, height: Fit
37                visible: false
38                icon_image := Image {
39                    width: 25.0, height: 25.0
40                }
41            }
42
43            icon_fallback_view := RoundedView {
44                width: 25.0, height: 25.0
45                visible: false
46                show_bg: true
47                draw_bg +: {
48                    color: #344054
49                    border_radius: 6.0
50                }
51                align: Align { x: 0.5, y: 0.5 }
52
53                icon_fallback_label := Label {
54                    draw_text +: {
55                        text_style: theme.font_bold { font_size: 13.0 }
56                        color: #fff
57                    }
58                }
59            }
60
61            label := Label {
62                draw_text +: {
63                    text_style: theme.font_bold { font_size: 10.0 }
64                    color: #989898
65                }
66            }
67        }
68    }
69}
70
71#[derive(Script, Widget)]
72pub struct ModelSelectorList {
73    #[uid]
74    uid: WidgetUid,
75
76    #[redraw]
77    #[rust]
78    area: Area,
79
80    #[walk]
81    walk: Walk,
82
83    #[layout]
84    layout: Layout,
85
86    // Templates collected from DSL in on_after_apply
87    #[rust]
88    templates: HashMap<LiveId, ScriptObjectRef>,
89
90    #[rust]
91    pub items: ComponentMap<LiveId, WidgetRef>,
92
93    #[rust]
94    pub search_filter: String,
95
96    #[rust]
97    pub total_height: Option<f64>,
98
99    #[rust]
100    pub chat_controller: Option<Arc<Mutex<ChatController>>>,
101
102    #[rust(Box::new(default_grouping) as ErasedGroupingClosure)]
103    pub grouping: ErasedGroupingClosure,
104
105    #[rust]
106    pub filter: Option<Box<dyn BotFilter>>,
107}
108
109impl ScriptHook for ModelSelectorList {
110    fn on_before_apply(
111        &mut self,
112        _vm: &mut ScriptVm,
113        apply: &Apply,
114        _scope: &mut Scope,
115        _value: ScriptValue,
116    ) {
117        if apply.is_reload() {
118            self.templates.clear();
119        }
120    }
121
122    fn on_after_apply(
123        &mut self,
124        vm: &mut ScriptVm,
125        apply: &Apply,
126        _scope: &mut Scope,
127        value: ScriptValue,
128    ) {
129        if !apply.is_eval() {
130            if let Some(obj) = value.as_object() {
131                vm.vec_with(obj, |vm, vec| {
132                    for kv in vec {
133                        if let Some(id) = kv.key.as_id() {
134                            if let Some(template_obj) = kv.value.as_object() {
135                                self.templates
136                                    .insert(id, vm.bx.heap.new_object_ref(template_obj));
137                            }
138                        }
139                    }
140                });
141            }
142        }
143    }
144}
145
146impl Widget for ModelSelectorList {
147    fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
148        // Forward events to child items
149        for (_, item) in self.items.iter_mut() {
150            item.handle_event(cx, event, scope);
151        }
152
153        self.widget_match_event(cx, event, scope);
154    }
155
156    fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep {
157        cx.begin_turtle(walk, self.layout);
158
159        // Get bots and selected_bot_id from chat controller
160        let (bots, selected_bot_id) = if let Some(chat_controller) = &self.chat_controller {
161            {
162                let chat_controller = chat_controller.lock().unwrap();
163                let state = chat_controller.state();
164                (state.bots.clone(), state.bot_id.clone())
165            }
166        } else {
167            (Vec::new(), None)
168        };
169
170        self.draw_items(cx, &bots, selected_bot_id.as_ref());
171
172        cx.end_turtle_with_area(&mut self.area);
173        DrawStep::done()
174    }
175}
176
177impl WidgetMatchEvent for ModelSelectorList {
178    fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) {
179        // Catch widget actions from child items and re-fire with our widget_uid.
180        // This bubbles the action up to the parent ModelSelector.
181        for action in actions {
182            let Some(widget_action) = action.as_widget_action() else {
183                continue;
184            };
185
186            if let ModelSelectorItemAction::BotSelected(bot_id) = widget_action.cast() {
187                cx.widget_action(
188                    self.widget_uid(),
189                    ModelSelectorItemAction::BotSelected(bot_id),
190                );
191            }
192        }
193    }
194}
195
196impl ModelSelectorList {
197    fn create_widget_from_template(
198        templates: &HashMap<LiveId, ScriptObjectRef>,
199        cx: &mut Cx,
200        template_name: LiveId,
201    ) -> WidgetRef {
202        let template_ref = templates.get(&template_name).expect("template not found");
203        let template_value: ScriptValue = template_ref.as_object().into();
204        cx.with_vm(|vm| WidgetRef::script_from_value(vm, template_value))
205    }
206
207    fn draw_items(&mut self, cx: &mut Cx2d, bots: &[Bot], selected_bot_id: Option<&BotId>) {
208        let mut total_height = 0.0;
209
210        // Filter bots based on search
211        let terms = self
212            .search_filter
213            .split_whitespace()
214            .map(|s| s.to_ascii_lowercase())
215            .collect::<Vec<_>>();
216
217        let filtered_bots: Vec<&Bot> = bots
218            .iter()
219            .filter(|bot| {
220                // Filter by search terms
221                let matches_search = if terms.is_empty() {
222                    true
223                } else {
224                    let name = bot.name.to_ascii_lowercase();
225                    let id = bot.id.as_str().to_ascii_lowercase();
226                    terms.iter().all(|t| name.contains(t) || id.contains(t))
227                };
228
229                // Filter by custom filter function (if provided)
230                let passes_filter = self.filter.as_ref().map_or(true, |f| f.should_show(bot));
231
232                matches_search && passes_filter
233            })
234            .collect();
235
236        // Group bots by their group ID
237        let mut groups: HashMap<String, ((String, Option<EntityAvatar>), Vec<&Bot>)> =
238            HashMap::new();
239        for bot in filtered_bots {
240            let group = (self.grouping)(bot);
241            groups
242                .entry(group.id)
243                .or_insert_with(|| ((group.label, group.icon), Vec::new()))
244                .1
245                .push(bot);
246        }
247
248        // Sort groups alphabetically by group ID
249        let mut group_list: Vec<_> = groups.into_iter().collect();
250        group_list.sort_by(|(a_id, _), (b_id, _)| a_id.cmp(b_id));
251
252        for (group_id, ((group_label, group_icon), mut group_bots)) in group_list {
253            // Render section header
254            let section_id = LiveId::from_str(&format!("section_{}", group_id));
255            let templates = &self.templates;
256            let section_label = self.items.get_or_insert(cx, section_id, |cx| {
257                Self::create_widget_from_template(templates, cx, id!(section_label_template))
258            });
259
260            section_label
261                .label(cx, ids!(label))
262                .set_text(cx, &group_label);
263
264            match group_icon
265                .or_else(|| EntityAvatar::from_first_grapheme(&group_label.to_uppercase()))
266                .unwrap_or_else(|| EntityAvatar::Text("?".into()))
267            {
268                EntityAvatar::Image(image) => {
269                    section_label
270                        .view(cx, ids!(icon_fallback_view))
271                        .set_visible(cx, false);
272                    section_label
273                        .view(cx, ids!(icon_view))
274                        .set_visible(cx, true);
275                    let img = section_label.image(cx, ids!(icon_image));
276                    let _ = load_image_from_resource(&img, cx, &image)
277                        .or_else(|_| img.load_image_file_by_path(cx, image.as_ref()));
278                }
279                EntityAvatar::Text(text) => {
280                    section_label
281                        .view(cx, ids!(icon_view))
282                        .set_visible(cx, false);
283                    section_label
284                        .view(cx, ids!(icon_fallback_view))
285                        .set_visible(cx, true);
286                    section_label
287                        .label(cx, ids!(icon_fallback_label))
288                        .set_text(cx, &text);
289                }
290            }
291
292            let _ = section_label.draw_all(cx, &mut Scope::empty());
293            total_height += section_label.area().rect(cx).size.y;
294
295            // Sort bots within group by name
296            group_bots.sort_by(|a, b| a.name.cmp(&b.name));
297
298            // Render bot items in this group
299            for bot in group_bots {
300                let item_id = LiveId::from_str(bot.id.as_str());
301
302                let templates = &self.templates;
303                let item_widget = self.items.get_or_insert(cx, item_id, |cx| {
304                    Self::create_widget_from_template(templates, cx, id!(item_template))
305                });
306
307                let mut item = item_widget.as_model_selector_item();
308                item.set_bot(bot.clone());
309
310                let is_selected = selected_bot_id == Some(&bot.id);
311                item.set_selected(is_selected);
312
313                let _ = item_widget.draw_all(cx, &mut Scope::empty());
314                total_height += item_widget.area().rect(cx).size.y;
315            }
316        }
317
318        self.total_height = Some(total_height);
319    }
320}
321
322impl ModelSelectorListRef {
323    /// Returns the total computed height of all items.
324    pub fn get_height(&self) -> f64 {
325        if let Some(inner) = self.borrow() {
326            inner.total_height.unwrap_or(0.0)
327        } else {
328            0.0
329        }
330    }
331
332    /// Sets the search filter and resets the item list.
333    pub fn set_search_filter(&mut self, cx: &mut Cx, filter: &str) {
334        if let Some(mut inner) = self.borrow_mut() {
335            inner.search_filter = filter.to_string();
336            inner.items.clear();
337            inner.total_height = None;
338            inner.redraw(cx);
339        }
340    }
341
342    /// Clears the search filter.
343    pub fn clear_search_filter(&mut self, cx: &mut Cx) {
344        self.set_search_filter(cx, "");
345    }
346
347    /// Sets the chat controller used to retrieve bot data.
348    pub fn set_chat_controller(&mut self, controller: Option<Arc<Mutex<ChatController>>>) {
349        if let Some(mut inner) = self.borrow_mut() {
350            inner.chat_controller = controller;
351        }
352    }
353
354    /// Sets a custom grouping function for organizing bots.
355    pub fn set_grouping<F>(&mut self, grouping: F)
356    where
357        F: Fn(&Bot) -> BotGroup + 'static,
358    {
359        if let Some(mut inner) = self.borrow_mut() {
360            inner.grouping = Box::new(grouping);
361        }
362    }
363}