Skip to main content

libsurfer/
comment.rs

1use egui::Pos2;
2use egui_remixicon::icons;
3use serde::{Deserialize, Serialize};
4
5const DEFAULT_SPACE: f32 = 4.;
6#[derive(Clone, Serialize, Deserialize, Debug)]
7pub struct CommentMessage {
8    pub id: egui::Id,
9    pub user: String,
10    pub text: String,
11}
12#[derive(Clone, Serialize, Deserialize, Debug)]
13pub struct Comment {
14    pub id: egui::Id,
15    pub rect: egui::Rect,
16    pub color: egui::Color32,
17    pub offset: Pos2,
18    pub anchor: Pos2,
19    pub size: Pos2,
20    pub annotation_id: egui::Id,
21    pub name: String,
22    pub visible: bool,
23    pub message_id_source: u64,
24    pub message_chain: Vec<CommentMessage>,
25    pub new_text: String,
26    pub save_text: Option<String>,
27    pub change: bool,
28}
29
30impl Comment {
31    pub(crate) fn new(id: egui::Id, annotation_id: egui::Id) -> Self {
32        Comment {
33            id,
34            annotation_id,
35            rect: egui::Rect::ZERO,
36            color: egui::Color32::WHITE,
37            offset: Pos2::ZERO,
38            anchor: Pos2::ZERO,
39            size: Pos2 { x: 100., y: 50. },
40            message_chain: Vec::new(),
41            new_text: String::new(),
42            name: String::new(),
43            visible: false,
44            message_id_source: 0,
45            save_text: None,
46            change: false,
47        }
48    }
49}
50
51impl egui::Widget for &mut Comment {
52    fn ui(self, ui: &mut egui::Ui) -> egui::Response {
53        // Setup Layout Area
54        // We create a temporary rectangle based on the stored position but with a large
55        // height to allow the content to expand downwards without clipping during layout.
56        let mut layout_rect = self.rect;
57        layout_rect.set_height(2000.0);
58
59        // Scope the UI to the specific rectangle so child elements align correctly
60        let inner = ui.scope_builder(egui::UiBuilder::new().max_rect(layout_rect), |ui| {
61            let line_start = self.rect.left_top();
62
63            // Draw a dashed line from the comment box to the target it's referencing
64            ui.painter().add(egui::Shape::dashed_line(
65                &[line_start, self.anchor],
66                egui::Stroke::new(1.0, egui::Color32::GRAY),
67                4.0,
68                2.0,
69            ));
70
71            // Background Placeholders
72            // We reserve slots in the painter order now, but fill them later after
73            // we know the final size of the content.
74            let background_fill_shape = ui.painter().add(egui::Shape::Noop);
75            let background_stroke_shape = ui.painter().add(egui::Shape::Noop);
76
77            // Collapsible State
78            // Load whether this specific comment is open or closed from egui's persistent storage
79            let mut state = egui::collapsing_header::CollapsingState::load_with_default_open(
80                ui.ctx(),
81                ui.make_persistent_id(self.id),
82                false,
83            );
84
85            // Header Logic
86            let header = ui.horizontal(|ui| {
87                // Custom toggle button using a chat icon instead of the default arrow
88                state.show_toggle_button(ui, |ui, _openness, response| {
89                    let icon = icons::QUESTION_ANSWER_FILL;
90                    ui.painter().text(
91                        response.rect.center(),
92                        egui::Align2::CENTER_CENTER,
93                        icon,
94                        egui::FontId::proportional(20.0),
95                        ui.visuals().text_color(),
96                    );
97                });
98
99                // Only show the title label if the header is expanded
100                if state.is_open() {
101                    ui.add_space(DEFAULT_SPACE);
102                    ui.label(egui::RichText::new(&self.name).strong());
103                    ui.add_space(DEFAULT_SPACE);
104                }
105            });
106
107            let mut text_response_rect = egui::Rect::ZERO;
108
109            // Body Logic (Message List & Input)
110            state.show_body_unindented(ui, |ui| {
111                ui.vertical(|ui| {
112                    ui.spacing_mut().item_spacing.y = DEFAULT_SPACE;
113
114                    // Top border line
115                    ui.painter().add(egui::Shape::line_segment(
116                        [ui.cursor().left_top(), ui.cursor().right_top()],
117                        egui::Stroke::new(0.5, egui::Color32::WHITE),
118                    ));
119
120                    // Render existing comments in the chain
121                    for comment in &self.message_chain {
122                        ui.label(egui::RichText::new(&comment.text).size(14.0));
123                        ui.painter().add(egui::Shape::line_segment(
124                            [ui.cursor().left_top(), ui.cursor().right_top()],
125                            egui::Stroke::new(0.5, egui::Color32::WHITE),
126                        ));
127                    }
128
129                    ui.add_space(DEFAULT_SPACE);
130
131                    // Text Input Field
132                    let text_response = ui.add(
133                        egui::TextEdit::multiline(&mut self.new_text)
134                            .desired_rows(1)
135                            .desired_width(self.size.x)
136                            .hint_text("Comment..."),
137                    );
138
139                    // Store rect to prevent dragging the whole widget while typing
140                    text_response_rect = text_response.rect;
141
142                    // Handle "Enter" key to submit new comment
143                    if text_response.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
144                        let clean_text = self.new_text.trim().to_string();
145                        if !clean_text.is_empty() {
146                            self.message_id_source += 1;
147                            self.save_text = Some(clean_text);
148                            self.change = true;
149                        }
150                        self.new_text = String::new(); // Clear input after submission
151                    }
152                });
153            });
154
155            // Size Sync
156            // Calculate how much space the UI actually took up
157            let final_rect = ui.min_rect();
158            let content_height = final_rect.height();
159
160            // Auto-expand the saved height if the content grows (adding messages)
161            if self.size.y < content_height {
162                self.size.y = content_height;
163            }
164
165            let background_rect = final_rect.expand(5.0);
166
167            // Delayed Background Rendering
168            // Now that we have the final background_rect size, we fill the Noop shapes from earlier
169            ui.painter().set(
170                background_fill_shape,
171                egui::Shape::rect_filled(background_rect, 4.0, egui::Color32::BLACK),
172            );
173            ui.painter().set(
174                background_stroke_shape,
175                egui::Shape::rect_stroke(
176                    background_rect,
177                    4.0,
178                    egui::Stroke::new(1.0, egui::Color32::WHITE),
179                    egui::StrokeKind::Middle,
180                ),
181            );
182
183            // TODO: Resize temporary turned off
184            // Interaction Logic (Resize & Drag)
185            // Define a small interactive handle in the bottom-right corner for resizing
186            let handle_rect = egui::Rect::from_min_max(
187                background_rect.max - egui::vec2(15.0, 15.0),
188                background_rect.max,
189            );
190
191            // Logic for resizing the widget (South-East Corner)
192            let se_res = ui.interact(handle_rect, self.id.with("se_res"), egui::Sense::drag());
193
194            if se_res.hovered() || se_res.dragged() {
195                ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe);
196            }
197
198            // Draw a small icon to indicate the move handle
199            ui.painter().text(
200                handle_rect.center(),
201                egui::Align2::CENTER_CENTER,
202                icons::DRAG_MOVE_2_FILL,
203                egui::FontId::proportional(10.0),
204                ui.visuals().text_color().linear_multiply(0.4),
205            );
206
207            // Logic for moving the entire widget
208            // We disable dragging if the user is currently resizing or interacting with the text box
209            let can_drag_body = !se_res.dragged()
210                && !text_response_rect.contains(ui.ctx().pointer_hover_pos().unwrap_or_default());
211
212            let body_res = ui.interact(
213                background_rect,
214                self.id.with("body_res"),
215                if can_drag_body {
216                    egui::Sense::drag()
217                } else {
218                    egui::Sense::hover()
219                },
220            );
221
222            if body_res.dragged() {
223                let delta = body_res.drag_delta();
224                self.offset.x += delta.x;
225                self.offset.y += delta.y;
226                self.change = true;
227            }
228
229            // Logic for Header interactions (Click to toggle, Drag to move)
230            let header_res = ui.interact(
231                header.response.rect,
232                self.id.with("head_res"),
233                egui::Sense::click_and_drag(),
234            );
235
236            if header_res.clicked() {
237                state.toggle(ui);
238                state.store(ui.ctx());
239            } else if header_res.dragged() {
240                self.offset.x += header_res.drag_delta().x;
241                self.offset.y += header_res.drag_delta().y;
242                self.change = true;
243            }
244
245            // Return a combined response so the parent UI knows if any part was touched
246            se_res | body_res | header_res
247        });
248
249        inner.inner
250    }
251}