plugin_interfaces/pluginui/
ui.rs

1//! Main UI builder implementation
2
3use crate::{send_to_frontend, PluginHandler};
4
5use super::components::{Response, UiComponent, UiComponentType};
6use std::{
7    collections::{HashMap, HashSet},
8    sync::{Arc, Mutex},
9};
10use uuid::Uuid;
11
12/// Main UI builder - provides immediate mode UI building
13pub struct Ui {
14    /// Components built in this frame
15    pub(crate) components: Vec<UiComponent>,
16    /// Plugin ID for this UI
17    pub(crate) plugin_id: String,
18    /// Current layout context
19    pub(crate) layout_stack: Vec<LayoutContext>,
20    /// Components that were clicked in this frame
21    pub(crate) clicked_components: HashSet<String>,
22    /// Components that were changed in this frame
23    pub(crate) changed_components: HashSet<String>,
24    /// UI event data from frontend (component_id -> value)
25    pub(crate) ui_event_data: HashMap<String, String>,
26}
27
28#[derive(Debug, Clone)]
29pub(crate) enum LayoutContext {
30    Root,
31    Horizontal,
32    Vertical,
33}
34
35impl Ui {
36    pub fn new(plugin_id: String) -> Arc<Mutex<Self>> {
37        Arc::new(Mutex::new(Self {
38            components: Vec::new(),
39            plugin_id,
40            layout_stack: vec![LayoutContext::Root],
41            clicked_components: HashSet::new(),
42            changed_components: HashSet::new(),
43            ui_event_data: HashMap::new(),
44        }))
45    }
46
47    /// Get plugin Id
48    pub fn plugin_id(&self) -> &str {
49        &self.plugin_id
50    }
51
52    /// Add a text label
53    pub fn label(&mut self, text: &str) {
54        let component = UiComponent {
55            id: format!("label_{}", Uuid::new_v4()),
56            component: UiComponentType::Label {
57                text: text.to_string(),
58            },
59        };
60        self.add_component(component);
61    }
62
63    /// Add a clickable button
64    pub fn button(&mut self, text: &str) -> Response {
65        // Use a hash of the text and current component count for stable ID
66        let id = format!(
67            "button_{}_{}",
68            self.components.len(),
69            text.replace(" ", "_")
70        );
71        let component = UiComponent {
72            id: id.clone(),
73            component: UiComponentType::Button {
74                text: text.to_string(),
75                enabled: true,
76            },
77        };
78        self.add_component(component);
79
80        // Check if this component was clicked in this frame
81        let was_clicked = self.clicked_components.contains(&id);
82
83        // Return a response with click state
84        Response::new_with_component_and_state(id, was_clicked, false)
85    }
86
87    /// Add a single-line text editor
88    pub fn text_edit_singleline(&mut self, value: &mut String) -> Response {
89        // Use a stable ID based on component count for consistent identification
90        let id = format!("textedit_{}", self.components.len());
91
92        // Check if this component was changed and update the value from frontend data
93        let was_changed = self.changed_components.contains(&id);
94        if was_changed {
95            if let Some(new_value) = self.ui_event_data.get(&id) {
96                *value = new_value.clone();
97            }
98        }
99
100        let component = UiComponent {
101            id: id.clone(),
102            component: UiComponentType::TextEdit {
103                value: value.clone(),
104                hint: String::new(),
105            },
106        };
107        self.add_component(component);
108
109        Response::new_with_component_and_state(id, false, was_changed)
110    }
111
112    /// Add a combo box (dropdown) widget
113    pub fn combo_box<T>(
114        &mut self,
115        options: Vec<T>,
116        selected: &mut Option<T>,
117        placeholder: &str,
118    ) -> Response
119    where
120        T: Clone + PartialEq + ToString,
121    {
122        // Use a stable ID based on component count and placeholder
123        let id = format!(
124            "combo_{}_{}",
125            self.components.len(),
126            placeholder.replace(" ", "_")
127        );
128
129        // Check if this component was clicked or changed and update the selection from frontend data
130        let was_clicked = self.clicked_components.contains(&id);
131        let was_changed = self.changed_components.contains(&id);
132        if was_changed {
133            if let Some(new_value) = self.ui_event_data.get(&id) {
134                if let Ok(selection_index) = new_value.parse::<usize>() {
135                    if selection_index < options.len() {
136                        *selected = Some(options[selection_index].clone());
137                    } else {
138                        *selected = None;
139                    }
140                }
141            }
142        }
143
144        // Validate that selected value exists in options, otherwise set to None
145        let selected_index = if let Some(ref selected_value) = selected {
146            options.iter().position(|opt| opt == selected_value)
147        } else {
148            None
149        };
150
151        // If selected value doesn't exist in options, set selected to None
152        if selected.is_some() && selected_index.is_none() {
153            *selected = None;
154        }
155
156        let component = UiComponent {
157            id: id.clone(),
158            component: UiComponentType::ComboBox {
159                options: options.iter().map(|opt| opt.to_string()).collect(),
160                selected: selected_index,
161                placeholder: placeholder.to_string(),
162            },
163        };
164        self.add_component(component);
165
166        // Return a response with event states
167        Response::new_with_component_and_state(id, was_clicked, was_changed)
168    }
169
170    /// Add a toggle switch
171    pub fn toggle(&mut self, value: &mut bool) -> Response {
172        // Use a stable ID based on component count
173        let id = format!("toggle_{}", self.components.len());
174
175        // Check if this component was clicked and update the value from frontend data
176        let was_clicked = self.clicked_components.contains(&id);
177        let was_changed = self.changed_components.contains(&id);
178        if was_changed {
179            if let Some(new_value) = self.ui_event_data.get(&id) {
180                if let Ok(toggle_value) = new_value.parse::<bool>() {
181                    *value = toggle_value;
182                }
183            }
184        }
185
186        let component = UiComponent {
187            id: id.clone(),
188            component: UiComponentType::Toggle {
189                value: *value,
190            },
191        };
192        self.add_component(component);
193
194        // Return a response with event states
195        Response::new_with_component_and_state(id, was_clicked, was_changed)
196    }
197
198    /// Create a horizontal layout
199    pub fn horizontal<R>(&mut self, add_contents: impl FnOnce(&mut Self) -> R) -> R {
200        self.layout_stack.push(LayoutContext::Horizontal);
201        let start_index = self.components.len();
202
203        let result = add_contents(self);
204
205        // Collect components added in this horizontal context
206        let children = self.components.split_off(start_index);
207
208        if !children.is_empty() {
209            let horizontal_component = UiComponent {
210                id: format!("horizontal_{}", Uuid::new_v4()),
211                component: UiComponentType::Horizontal { children },
212            };
213            self.components.push(horizontal_component);
214        }
215
216        self.layout_stack.pop();
217        result
218    }
219
220    /// Create a vertical layout
221    pub fn vertical<R>(&mut self, add_contents: impl FnOnce(&mut Self) -> R) -> R {
222        self.layout_stack.push(LayoutContext::Vertical);
223        let start_index = self.components.len();
224
225        let result = add_contents(self);
226
227        // Collect components added in this vertical context
228        let children = self.components.split_off(start_index);
229
230        if !children.is_empty() {
231            let vertical_component = UiComponent {
232                id: format!("vertical_{}", Uuid::new_v4()),
233                component: UiComponentType::Vertical { children },
234            };
235            self.components.push(vertical_component);
236        }
237
238        self.layout_stack.pop();
239        result
240    }
241
242    /// Internal method to add a component
243    fn add_component(&mut self, component: UiComponent) {
244        self.components.push(component);
245    }
246
247    /// Get all components for serialization
248    pub fn get_components(&self) -> &[UiComponent] {
249        &self.components
250    }
251
252    /// Clear all components (called at start of each frame)
253    pub fn clear(&mut self) {
254        self.components.clear();
255        // Clear event tracking - events should only be active for one frame
256        self.clicked_components.clear();
257        self.changed_components.clear();
258        self.ui_event_data.clear();
259    }
260
261    /// Clear only components, keep event tracking for current frame
262    pub fn clear_components_only(&mut self) {
263        self.components.clear();
264        // Keep event tracking for this update_ui call
265    }
266
267    /// Clear event tracking after update_ui is complete
268    pub fn clear_events(&mut self) {
269        self.clicked_components.clear();
270        self.changed_components.clear();
271        self.ui_event_data.clear();
272    }
273
274    /// Handle UI events (called when frontend sends UI events)
275    pub fn handle_ui_event(&mut self, component_id: &str, value: &str) -> bool {
276        // Store the event data for use in the event loop
277        self.ui_event_data
278            .insert(component_id.to_string(), value.to_string());
279
280        // Track the event based on component type for the event loop pattern
281        if component_id.starts_with("combo_") {
282            self.clicked_components.insert(component_id.to_string());
283            self.changed_components.insert(component_id.to_string());
284            true
285        } else if component_id.starts_with("button_") {
286            self.clicked_components.insert(component_id.to_string());
287            true
288        } else if component_id.starts_with("textedit_") {
289            self.changed_components.insert(component_id.to_string());
290            true
291        } else if component_id.starts_with("toggle_") {
292            self.clicked_components.insert(component_id.to_string());
293            self.changed_components.insert(component_id.to_string());
294            true
295        } else {
296            false
297        }
298    }
299}
300
301pub trait PluginUiOption {
302    fn refresh_ui(&self) -> bool;
303}
304
305impl<T: PluginHandler> PluginUiOption for T {
306    fn refresh_ui(&self) -> bool {
307        let plugin_id = self.get_metadata().id;
308        let payload = serde_json::json!({
309            "plugin": plugin_id
310        }).to_string();
311        send_to_frontend("plugin-ui-refreshed", &payload.as_str())
312    }
313}