Skip to main content

libsurfer/
annotation_list.rs

1use crate::{Message, annotation::Annotatable, time::TimeFormatter, wave_data::WaveData};
2use egui::{Align, Color32, Key, Layout, Ui};
3use egui_remixicon::icons;
4use tracing::warn;
5
6#[derive(Clone, Default)]
7pub struct AnnotationList {}
8
9const DEFAULT_GROUP_NAME: &str = "Ungrouped";
10const TIME_FONT_SIZE: f32 = 11.;
11const DEFAULT_SPACE: f32 = 4.;
12const WIDTH_CONSTRAINT: f32 = 30.;
13
14impl AnnotationList {}
15
16#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
17pub struct AnnotationGroup {
18    pub name: String,
19    pub cycle_counter: usize,
20    pub annotations: Vec<egui::Id>,
21}
22
23impl AnnotationList {}
24
25impl WaveData {
26    pub fn draw_annotation_list(
27        &self,
28        ui: &mut Ui,
29        msgs: &mut Vec<Message>,
30        time_formatter: &TimeFormatter,
31        annotation_groups: &mut [AnnotationGroup],
32    ) {
33        ui.style_mut()
34            .visuals
35            .widgets
36            .noninteractive
37            .bg_stroke
38            .width = 0.5;
39
40        ui.horizontal(|ui| {
41            ui.allocate_space(egui::vec2(ui.available_width() - WIDTH_CONSTRAINT, 0.0));
42            if ui.button(icons::CLOSE_LARGE_LINE).clicked() {
43                msgs.push(Message::ToggleAnnotationlistVisibility());
44            }
45        });
46
47        ui.vertical_centered(|ui| {
48            ui.heading("Annotation List");
49            if self.annotations.is_empty() {
50                ui.label("Your annotations will be displayed here.");
51            }
52        });
53
54        ui.add_space(DEFAULT_SPACE * 2.);
55        ui.separator();
56
57        // Create Group UI (Using egui Temp Memory)
58        ui.horizontal(|ui| {
59            ui.add_space(DEFAULT_SPACE * 2.);
60            ui.label(egui::RichText::new("Manage Groups").small().strong());
61        });
62        ui.horizontal(|ui| {
63            ui.add_space(DEFAULT_SPACE * 2.);
64            let input_id = ui.make_persistent_id("group_input_buffer");
65            let mut buffer = ui.data_mut(|d| d.get_temp::<String>(input_id).unwrap_or_default());
66
67            let text_edit_res = ui.add(
68                egui::TextEdit::singleline(&mut buffer)
69                    .hint_text("Type group name...")
70                    .desired_width(ui.available_width() - 160.0),
71            );
72
73            // Handle focusing of the text area when user clicks elsewhere, enables shortcuts.
74            let focus_id = ui.make_persistent_id("group_input_focus_init");
75            let has_focused = ui.data_mut(|d| d.get_temp::<bool>(focus_id).unwrap_or(false));
76
77            if !has_focused {
78                text_edit_res.request_focus();
79                ui.data_mut(|d| d.insert_temp(focus_id, true));
80            }
81
82            ui.data_mut(|d| d.insert_temp(input_id, buffer.clone()));
83
84            // create group when user press enter
85            if text_edit_res.ctx.input(|i| i.key_pressed(Key::Enter)) && !buffer.is_empty() {
86                let flag = annotation_groups.iter().any(|group| {
87                    if group.name == buffer.trim() {
88                        return true;
89                    }
90                    false
91                });
92
93                if !flag {
94                    msgs.push(Message::CreateAnnotationGroup(buffer.trim().to_string()));
95                    ui.data_mut(|d| d.insert_temp(input_id, String::new()));
96                }
97                // Keep focus here so users can type the next group immediately
98                text_edit_res.request_focus();
99            }
100            // create group when user press plus button
101            if ui
102                .button(icons::ADD_LINE)
103                .on_hover_text("Create Group")
104                .clicked()
105                && !buffer.is_empty()
106            {
107                msgs.push(Message::CreateAnnotationGroup(buffer.trim().to_string()));
108                ui.data_mut(|d| d.insert_temp(input_id, String::new()));
109            }
110
111            // delete group when user press plus button
112            if ui
113                .button(icons::DELETE_BIN_LINE)
114                .on_hover_text("Delete Group")
115                .clicked()
116                && !buffer.is_empty()
117            {
118                msgs.push(Message::DeleteAnnotationGroup(buffer.trim().to_string()));
119                ui.data_mut(|d| d.insert_temp(input_id, String::new()));
120            }
121        });
122
123        ui.add_space(DEFAULT_SPACE);
124        ui.separator();
125
126        // Scrollable List
127        egui::ScrollArea::vertical()
128            .auto_shrink([false; 2])
129            .show(ui, |ui| {
130                // this is so ungrouped annotations are listed last
131                for group in annotation_groups.iter_mut().rev() {
132                    self.render_group_section(ui, group, msgs, time_formatter);
133                }
134            });
135    }
136
137    fn render_group_section(
138        &self,
139        ui: &mut Ui,
140        group: &mut AnnotationGroup,
141        msgs: &mut Vec<Message>,
142        time_formatter: &TimeFormatter,
143    ) {
144        // Determine if the group is "mostly visible" or "mostly hidden" to pick the icon
145        let any_visible = group.annotations.iter().any(|id| {
146            if let Some(annotation) = self.get_annotation_by_id(id) {
147                annotation.is_visible()
148            } else {
149                warn!("Got id to non existing annotatation");
150                false
151            }
152        });
153        let group_icon = if any_visible {
154            icons::EYE_LINE
155        } else {
156            icons::EYE_OFF_LINE
157        };
158
159        // Create the header manually to inject the button
160        let id = ui.make_persistent_id(&group.name);
161        let state =
162            egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, true);
163
164        state
165            .show_header(ui, |ui| {
166                ui.label(format!("{} ({})", group.name, group.annotations.len()));
167
168                let delete_tooltip;
169                let delete_message;
170                if group.annotations.is_empty() {
171                    delete_tooltip = "Delete this group";
172                    delete_message = Message::DeleteAnnotationGroup(group.name.clone());
173                } else {
174                    delete_tooltip = "Delete all annotations in this group";
175                    delete_message = Message::DeleteAllAnnotationInGroup(group.name.clone());
176                }
177                // Push everything else to the right
178                ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
179                    if group.name != DEFAULT_GROUP_NAME
180                        && ui
181                            .button(icons::DELETE_BIN_LINE)
182                            .on_hover_text(delete_tooltip)
183                            .clicked()
184                    {
185                        msgs.push(delete_message);
186                    }
187                    if ui
188                        .button(group_icon)
189                        .on_hover_text("Toggle visibility for all in this group")
190                        .clicked()
191                    {
192                        msgs.push(Message::SetGroupVisibility(group.clone(), !any_visible));
193                    }
194                    // No need to allow user to cycle unless there are more than one annotations in group
195                    if group.annotations.len() > 1
196                        && ui
197                            .button(icons::SKIP_FORWARD_LINE)
198                            .on_hover_text("Cycle through group")
199                            .clicked()
200                    {
201                        msgs.push(Message::GoToAnnotationPosition(
202                            group.annotations[group.cycle_counter],
203                            self.last_active_viewport_idx,
204                        ));
205                        group.cycle_counter += 1;
206
207                        if group.cycle_counter >= group.annotations.len() {
208                            group.cycle_counter = 0;
209                        }
210                    }
211                });
212            })
213            .body(|ui| {
214                if group.annotations.is_empty() {
215                    ui.weak("  No items");
216                }
217
218                for id in &group.annotations {
219                    if let Some(annotation) = self.get_annotation_by_id(id) {
220                        ui.horizontal(|ui| {
221                            ui.add_space(6.0);
222
223                            // Editable Name Logic
224                            let editing_id =
225                                ui.make_persistent_id(("editing_name", annotation.get_name()));
226                            let is_editing =
227                                ui.data(|d| d.get_temp::<bool>(editing_id).unwrap_or(false));
228
229                            let current_name = annotation.get_name();
230
231                            if is_editing {
232                                let mut buffer = ui.data_mut(|d| {
233                                    d.get_temp::<String>(editing_id)
234                                        .unwrap_or_else(|| current_name.clone())
235                                });
236
237                                let res = ui.add(
238                                    egui::TextEdit::singleline(&mut buffer).desired_width(120.0),
239                                );
240
241                                if res.has_focus() {
242                                    ui.data_mut(|d| d.insert_temp(editing_id, buffer.clone()));
243                                }
244
245                                // Save on Enter or if focus is lost
246                                if res.lost_focus()
247                                    || (res.has_focus() && ui.input(|i| i.key_pressed(Key::Enter)))
248                                {
249                                    msgs.push(Message::UpdateAnnotationName(
250                                        *id,
251                                        buffer.trim().to_string(),
252                                    ));
253                                    ui.data_mut(|d| d.insert_temp(editing_id, false));
254                                }
255
256                                // Request focus once when we start editing
257                                if ui.data(|d| {
258                                    d.get_temp::<bool>(
259                                        ui.make_persistent_id(("focus_req", &current_name)),
260                                    )
261                                    .unwrap_or(true)
262                                }) {
263                                    res.request_focus();
264                                    ui.data_mut(|d| {
265                                        d.insert_temp(
266                                            ui.make_persistent_id(("focus_req", current_name)),
267                                            false,
268                                        )
269                                    });
270                                }
271                            } else {
272                                // Display the name as a clickable label
273                                let response = ui.add(
274                                    egui::Label::new(egui::RichText::new(&current_name).strong())
275                                        .sense(egui::Sense::click()),
276                                );
277                                if response.clicked() {
278                                    ui.data_mut(|d| d.insert_temp(editing_id, true));
279                                    ui.data_mut(|d| {
280                                        d.insert_temp(
281                                            ui.make_persistent_id(("focus_req", current_name)),
282                                            true,
283                                        )
284                                    });
285                                }
286                                response.on_hover_text("Click to rename");
287                            }
288
289                            let show_comment_icon = if annotation.show_comments() {
290                                icons::ARROW_DOWN_S_LINE
291                            } else {
292                                icons::ARROW_RIGHT_S_LINE
293                            };
294
295                            if ui
296                                .button(show_comment_icon)
297                                .on_hover_text("Show comments")
298                                .clicked()
299                            {
300                                msgs.push(Message::ToggleAnnotationListShowComments(*id));
301                            }
302
303                            //This is only here because selectable_value needs a string, we dont want it to match any group we have.
304                            let placeholder = "ungrouped".to_string();
305
306                            ui.menu_button(icons::FOLDER_TRANSFER_LINE, |ui| {
307                                for group in self.annotation_groups.iter().rev() {
308                                    if ui
309                                        .selectable_value(
310                                            &mut Some(placeholder.clone()),
311                                            Some(group.name.clone()),
312                                            group.name.clone(),
313                                        )
314                                        .clicked()
315                                    {
316                                        msgs.push(Message::UpdateAnnotationGroup(
317                                            *id,
318                                            Some(group.name.clone()),
319                                        ));
320                                        ui.close();
321                                    }
322                                }
323                            })
324                            .response
325                            .on_hover_text("Change Group");
326
327                            // Buttons on the right
328                            ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
329                                if ui
330                                    .button(icons::DELETE_BIN_LINE)
331                                    .on_hover_text("Delete annotation")
332                                    .clicked()
333                                {
334                                    msgs.push(Message::RemoveAnnotation(*id));
335                                }
336
337                                let vis_icon = if annotation.is_visible() {
338                                    icons::EYE_LINE
339                                } else {
340                                    icons::EYE_OFF_LINE
341                                };
342                                if ui
343                                    .button(vis_icon)
344                                    .on_hover_text("Toggle visibility")
345                                    .clicked()
346                                {
347                                    msgs.push(Message::ToggleAnnotationVisiblility(*id));
348                                }
349
350                                let comment = annotation.get_comment_box();
351
352                                if annotation.is_visible() {
353                                    let chat_icon = if comment.visible {
354                                        icons::CHAT_4_LINE
355                                    } else {
356                                        icons::CHAT_OFF_LINE
357                                    };
358
359                                    if ui
360                                        .button(chat_icon)
361                                        .on_hover_text("Toggle comment visibility")
362                                        .clicked()
363                                    {
364                                        msgs.push(Message::ToggleCommentVisibility(*id));
365                                    }
366                                }
367                                if ui
368                                    .button(icons::SEARCH_LINE)
369                                    .on_hover_text("Go to annotation")
370                                    .clicked()
371                                {
372                                    msgs.push(Message::GoToAnnotationPosition(
373                                        *id,
374                                        self.last_active_viewport_idx,
375                                    ));
376                                }
377                            });
378                        });
379
380                        ui.horizontal(|ui| {
381                            ui.add_space(16.0);
382                            ui.label(
383                                egui::RichText::new(annotation.get_time_info(time_formatter))
384                                    .size(TIME_FONT_SIZE)
385                                    .color(Color32::LIGHT_GRAY),
386                            )
387                        });
388
389                        // Show comments for this annotation
390                        if annotation.show_comments() {
391                            let messages = annotation.get_messages();
392                            for c in messages {
393                                let mut line_left = ui.cursor().left_top();
394                                line_left.x += 16.;
395                                ui.painter().add(egui::Shape::line_segment(
396                                    [line_left, ui.cursor().right_top()],
397                                    egui::Stroke::new(0.5, egui::Color32::WHITE),
398                                ));
399                                ui.horizontal(|ui| {
400                                    ui.add_space(18.0); // Indent comments
401                                    ui.vertical(|ui| {
402                                        ui.add_space(DEFAULT_SPACE / 2.);
403                                        ui.set_max_width(ui.available_width() - WIDTH_CONSTRAINT);
404                                        ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
405
406                                        ui.add(egui::Label::new(c.text.as_str()).wrap());
407                                    });
408
409                                    ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
410                                        let response = ui.add_sized(
411                                            egui::Vec2::new(10.0, 10.0),
412                                            egui::Button::new(icons::DELETE_BIN_LINE),
413                                        );
414
415                                        if response.on_hover_text("Delete message").clicked() {
416                                            msgs.push(Message::RemoveCommentMessage(
417                                                annotation.get_id(),
418                                                c.id,
419                                            ));
420                                        }
421                                    });
422                                });
423                            }
424                        }
425                    }
426                }
427            });
428        ui.separator();
429        ui.add_space(DEFAULT_SPACE);
430    }
431
432    pub fn remove_annotation_from_group(&mut self, id_to_remove: egui::Id) -> Option<egui::Id> {
433        for group in &mut self.annotation_groups {
434            if let Some(idx) = group.annotations.iter().position(|&id| id == id_to_remove) {
435                group.cycle_counter = 0;
436                return Some(group.annotations.remove(idx));
437            }
438        }
439
440        None
441    }
442
443    pub fn remove_all_annotations_from_group(&mut self, name: String) {
444        for group in &mut self.annotation_groups {
445            if group.name == name {
446                self.annotations
447                    .retain(|annotation| !group.annotations.contains(&annotation.get_id()));
448                group.cycle_counter = 0;
449                group.annotations = Vec::new();
450            }
451        }
452    }
453
454    pub fn delete_group(&mut self, group_name: String) {
455        if let Some(idx) = self
456            .annotation_groups
457            .iter()
458            .position(|group| group.name == group_name)
459        {
460            self.annotation_groups.remove(idx);
461        }
462    }
463
464    pub fn add_annotation_to_group(&mut self, group_name: String, id_to_add: egui::Id) {
465        if let Some(idx) = self
466            .annotation_groups
467            .iter()
468            .position(|group| group.name == group_name)
469        {
470            self.annotation_groups[idx].annotations.push(id_to_add);
471        }
472    }
473
474    #[must_use]
475    pub fn annotation_is_in_group(&self, annotation_id: egui::Id) -> bool {
476        for group in &self.annotation_groups {
477            if group.annotations.contains(&annotation_id) {
478                return true;
479            }
480        }
481
482        false
483    }
484
485    #[must_use]
486    pub fn get_group_from_annotation(&self, annotation_id: egui::Id) -> Option<&AnnotationGroup> {
487        self.annotation_groups
488            .iter()
489            .find(|&group| group.annotations.contains(&annotation_id))
490            .map(|g| g as _)
491    }
492
493    pub fn get_group_from_name(&mut self, group_name: String) -> Option<&mut AnnotationGroup> {
494        self.annotation_groups
495            .iter_mut()
496            .find(|group| group.name == group_name)
497            .map(|g| g as _)
498    }
499}