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