Skip to main content

dais_ui/widgets/
text_box_canvas.rs

1//! Text box canvas widget.
2//!
3//! Renders text box overlays on a slide and handles placement, selection,
4//! move, resize, and inline editing interactions.
5
6use dais_core::commands::Command;
7use dais_core::state::TextBox;
8use dais_document::typst_renderer::TextBoxRenderCache;
9use egui::{Color32, ColorImage, Id, Pos2, Rect, Sense, Stroke, TextureHandle, Ui, vec2};
10use std::collections::HashMap;
11
12const HANDLE_RADIUS: f32 = 5.0;
13const HANDLE_COLOR: Color32 = Color32::WHITE;
14const BOX_BORDER_WIDTH: f32 = 1.0;
15const SELECTED_BORDER: Color32 = Color32::from_rgb(100, 160, 255);
16const SELECTED_BORDER_WIDTH: f32 = 2.0;
17const MIN_PLACE_SIZE: f32 = 0.04;
18
19#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
20struct TextureKey {
21    id: u64,
22    width: u32,
23    height: u32,
24    content_hash: u64,
25    font_size_bits: u32,
26    color: [u8; 4],
27    background: Option<[u8; 4]>,
28}
29
30#[derive(Default)]
31pub struct TextBoxTextureCache {
32    textures: HashMap<TextureKey, TextureHandle>,
33}
34
35impl TextBoxTextureCache {
36    pub fn get_or_load(
37        &mut self,
38        ui: &Ui,
39        tb: &TextBox,
40        rendered: &dais_document::typst_renderer::RenderedTextBox,
41        width: u32,
42        height: u32,
43        font_size: f32,
44    ) -> &TextureHandle {
45        let key = TextureKey {
46            id: tb.id,
47            width,
48            height,
49            content_hash: content_hash(&tb.content),
50            font_size_bits: font_size.to_bits(),
51            color: tb.color,
52            background: tb.background,
53        };
54        self.textures.entry(key).or_insert_with(|| {
55            let image = ColorImage::from_rgba_unmultiplied(
56                [rendered.width as usize, rendered.height as usize],
57                &rendered.data,
58            );
59            ui.ctx().load_texture(
60                format!("tb_{}_{}", tb.id, key.content_hash),
61                image,
62                egui::TextureOptions::LINEAR,
63            )
64        })
65    }
66
67    pub fn retain_for_boxes(&mut self, boxes: &[TextBox], slide_rect: Rect) {
68        self.textures.retain(|key, _| {
69            boxes.iter().any(|tb| {
70                tb.id == key.id
71                    && key.content_hash == content_hash(&tb.content)
72                    && key.color == tb.color
73                    && key.background == tb.background
74                    && key.font_size_bits == tb.font_size.clamp(8.0, 72.0).to_bits()
75                    && key.width == texture_dimension(screen_rect(slide_rect, tb.rect).width())
76                    && key.height == texture_dimension(screen_rect(slide_rect, tb.rect).height())
77            })
78        });
79    }
80}
81
82/// Draw text box overlays on a slide image area.
83///
84/// When `text_box_mode` is true, the canvas also handles:
85/// - Click-drag on empty space → [`Command::PlaceTextBox`]
86/// - Click on box → [`Command::SelectTextBox`]
87/// - Double-click on box → [`Command::BeginTextBoxEdit`]
88/// - Drag box body → [`Command::MoveTextBox`]
89/// - Drag corner handle → [`Command::ResizeTextBox`]
90///
91/// Returns a list of commands to dispatch. Non-interactive (audience) renders
92/// should pass `text_box_mode: false`, `selected_id: None`, `editing_id: None`.
93#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
94pub fn draw_text_boxes(
95    ui: &mut Ui,
96    boxes: &[TextBox],
97    selected_id: Option<u64>,
98    editing_id: Option<u64>,
99    text_box_mode: bool,
100    slide_rect: Rect,
101    tb_cache: &mut TextBoxRenderCache,
102    texture_cache: &mut TextBoxTextureCache,
103) -> Vec<Command> {
104    let mut commands = Vec::new();
105    texture_cache.retain_for_boxes(boxes, slide_rect);
106
107    // --- Drag-to-place new box ---
108    if text_box_mode {
109        let place_start_id = Id::new("tb_place_start");
110        let slide_resp =
111            ui.interact(slide_rect, Id::new("tb_slide_interact"), Sense::click_and_drag());
112
113        let place_start: Option<(f32, f32)> = ui.data(|d| d.get_temp(place_start_id));
114
115        if slide_resp.drag_started() {
116            // Only initiate place-drag if cursor was NOT inside any existing box
117            let press_pos = ui.ctx().input(|i| i.pointer.press_origin());
118            let on_box = press_pos
119                .is_some_and(|p| boxes.iter().any(|b| screen_rect(slide_rect, b.rect).contains(p)));
120            if !on_box && let Some(pos) = press_pos {
121                let norm = norm_pos(pos, slide_rect);
122                ui.data_mut(|d| d.insert_temp(place_start_id, norm));
123            }
124        }
125
126        if let Some(start) = place_start {
127            if slide_resp.drag_stopped() {
128                let end = ui
129                    .ctx()
130                    .input(|i| i.pointer.interact_pos())
131                    .map_or(start, |p| norm_pos(p, slide_rect));
132                let x = start.0.min(end.0);
133                let y = start.1.min(end.1);
134                let w = (start.0 - end.0).abs().max(MIN_PLACE_SIZE);
135                let h = (start.1 - end.1).abs().max(MIN_PLACE_SIZE);
136                commands.push(Command::PlaceTextBox { x, y, w, h });
137                ui.data_mut(|d| d.remove::<(f32, f32)>(place_start_id));
138            } else if !slide_resp.dragged() {
139                // Stale state (drag was cancelled), clear
140                ui.data_mut(|d| d.remove::<(f32, f32)>(place_start_id));
141            } else {
142                // Draw placement preview
143                let cur = ui
144                    .ctx()
145                    .input(|i| i.pointer.interact_pos())
146                    .map_or(start, |p| norm_pos(p, slide_rect));
147                let px = start.0.min(cur.0);
148                let py = start.1.min(cur.1);
149                let pw = (start.0 - cur.0).abs().max(0.01);
150                let ph = (start.1 - cur.1).abs().max(0.01);
151                let preview = screen_rect(slide_rect, (px, py, pw, ph));
152                ui.painter_at(slide_rect).rect_stroke(
153                    preview,
154                    2.0,
155                    Stroke::new(1.5, Color32::from_rgba_unmultiplied(100, 160, 255, 180)),
156                    egui::StrokeKind::Outside,
157                );
158            }
159        }
160
161        // Click on empty space with no drag → deselect
162        if slide_resp.clicked() && selected_id.is_some() {
163            let click_pos = slide_resp.interact_pointer_pos();
164            let on_box = click_pos
165                .is_some_and(|p| boxes.iter().any(|b| screen_rect(slide_rect, b.rect).contains(p)));
166            if !on_box {
167                commands.push(Command::DeselectTextBox);
168            }
169        }
170    }
171
172    // --- Render each box ---
173    for tb in boxes {
174        let box_rect = screen_rect(slide_rect, tb.rect);
175        let is_selected = selected_id == Some(tb.id);
176        let is_editing = editing_id == Some(tb.id);
177
178        // Background fill
179        if let Some(bg) = tb.background {
180            ui.painter_at(slide_rect).rect_filled(
181                box_rect,
182                2.0,
183                Color32::from_rgba_unmultiplied(bg[0], bg[1], bg[2], bg[3]),
184            );
185        }
186
187        // Always draw a subtle outline so empty boxes remain visible while testing.
188        ui.painter_at(slide_rect).rect_stroke(
189            box_rect,
190            2.0,
191            Stroke::new(BOX_BORDER_WIDTH, Color32::from_rgba_unmultiplied(255, 255, 255, 140)),
192            egui::StrokeKind::Outside,
193        );
194
195        if is_editing {
196            // Inline TextEdit overlay
197            let edit_buf_id = Id::new(("tb_edit_buf", tb.id));
198            let mut buf: String = ui
199                .data(|d| d.get_temp::<String>(edit_buf_id))
200                .unwrap_or_else(|| tb.content.clone());
201
202            let mut child = ui.new_child(egui::UiBuilder::new().max_rect(box_rect.shrink(4.0)));
203            child.visuals_mut().extreme_bg_color = Color32::TRANSPARENT;
204            child.visuals_mut().override_text_color = Some(Color32::from_rgba_unmultiplied(
205                tb.color[0],
206                tb.color[1],
207                tb.color[2],
208                tb.color[3],
209            ));
210            let font_size = tb.font_size.clamp(8.0, 72.0);
211            child.style_mut().override_font_id = Some(egui::FontId::proportional(font_size));
212
213            let edit_resp = child.add_sized(
214                box_rect.shrink(4.0).size(),
215                egui::TextEdit::multiline(&mut buf)
216                    .desired_width(f32::INFINITY)
217                    .hint_text("Type here…"),
218            );
219            if edit_resp.changed() {
220                commands.push(Command::EditTextBoxContent { id: tb.id, content: buf.clone() });
221            }
222            // Ctrl+Enter commits and exits editing
223            let commit = child.ctx().input(|i| {
224                i.events.iter().any(|e| {
225                    matches!(e, egui::Event::Key { key: egui::Key::Enter, pressed: true, modifiers, .. }
226                        if modifiers.ctrl || modifiers.command)
227                })
228            });
229            let clicked_away = child.ctx().input(|i| {
230                i.pointer.any_pressed()
231                    && i.pointer.interact_pos().is_some_and(|pos| !box_rect.contains(pos))
232            });
233            if commit || (edit_resp.lost_focus() && clicked_away) {
234                // Invalidate cache for old content so next render re-compiles
235                tb_cache.invalidate(&tb.content);
236                commands.push(Command::DeselectTextBox);
237            }
238            ui.data_mut(|d| d.insert_temp(edit_buf_id, buf));
239
240            // Border around editing box
241            ui.painter_at(slide_rect).rect_stroke(
242                box_rect,
243                2.0,
244                Stroke::new(SELECTED_BORDER_WIDTH, Color32::from_rgb(255, 200, 80)),
245                egui::StrokeKind::Outside,
246            );
247        } else {
248            // Typst-rendered texture
249            let px_w = texture_dimension(box_rect.width());
250            let px_h = texture_dimension(box_rect.height());
251            let font_size = tb.font_size.clamp(8.0, 72.0);
252            if let Some(rendered) =
253                tb_cache.get_or_render(&tb.content, px_w, px_h, font_size, tb.color, tb.background)
254            {
255                let tex = texture_cache.get_or_load(ui, tb, rendered, px_w, px_h, font_size);
256                ui.painter_at(slide_rect).image(
257                    tex.id(),
258                    box_rect,
259                    egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
260                    Color32::WHITE,
261                );
262            } else {
263                // Fallback: plain label if typst render fails
264                let text_color = Color32::from_rgba_unmultiplied(
265                    tb.color[0],
266                    tb.color[1],
267                    tb.color[2],
268                    tb.color[3],
269                );
270                let mut child = ui.new_child(egui::UiBuilder::new().max_rect(box_rect.shrink(4.0)));
271                child.visuals_mut().override_text_color = Some(text_color);
272                child.style_mut().override_font_id = Some(egui::FontId::proportional(font_size));
273                child.label(egui::RichText::new(&tb.content).size(font_size).color(text_color));
274            }
275        }
276
277        // --- Box interaction (only in text_box_mode) ---
278        if text_box_mode && !is_editing {
279            let box_resp =
280                ui.interact(box_rect, Id::new(("tb_box", tb.id)), Sense::click_and_drag());
281
282            if box_resp.double_clicked() {
283                commands.push(Command::BeginTextBoxEdit { id: tb.id });
284            } else if box_resp.clicked() {
285                commands.push(Command::SelectTextBox(tb.id));
286            }
287
288            if box_resp.dragged() && is_selected {
289                let delta = box_resp.drag_delta();
290                let dx = delta.x / slide_rect.width();
291                let dy = delta.y / slide_rect.height();
292                let (bx, by, _, _) = tb.rect;
293                commands.push(Command::MoveTextBox {
294                    id: tb.id,
295                    x: (bx + dx).max(0.0),
296                    y: (by + dy).max(0.0),
297                });
298            }
299        }
300
301        // Selected border + resize handles
302        if is_selected && !is_editing {
303            ui.painter_at(slide_rect).rect_stroke(
304                box_rect,
305                2.0,
306                Stroke::new(SELECTED_BORDER_WIDTH, SELECTED_BORDER),
307                egui::StrokeKind::Outside,
308            );
309
310            if text_box_mode {
311                // 4 corner handles — drag to resize
312                let corners = [
313                    (box_rect.left_top(), "nw"),
314                    (box_rect.right_top(), "ne"),
315                    (box_rect.left_bottom(), "sw"),
316                    (box_rect.right_bottom(), "se"),
317                ];
318                for (corner, tag) in corners {
319                    let handle_rect = Rect::from_center_size(
320                        corner,
321                        vec2(HANDLE_RADIUS * 2.0, HANDLE_RADIUS * 2.0),
322                    );
323                    let handle_resp =
324                        ui.interact(handle_rect, Id::new(("tb_handle", tb.id, tag)), Sense::drag());
325                    ui.painter_at(slide_rect).circle_filled(corner, HANDLE_RADIUS, HANDLE_COLOR);
326                    ui.painter_at(slide_rect).circle_stroke(
327                        corner,
328                        HANDLE_RADIUS,
329                        Stroke::new(1.0, Color32::from_gray(80)),
330                    );
331
332                    if handle_resp.dragged() {
333                        let delta = handle_resp.drag_delta();
334                        let dx = delta.x / slide_rect.width();
335                        let dy = delta.y / slide_rect.height();
336                        let (bx, by, bw, bh) = tb.rect;
337                        let (new_x, new_y, new_w, new_h) = match tag {
338                            "nw" => (bx + dx, by + dy, (bw - dx).max(0.02), (bh - dy).max(0.02)),
339                            "ne" => (bx, by + dy, (bw + dx).max(0.02), (bh - dy).max(0.02)),
340                            "sw" => (bx + dx, by, (bw - dx).max(0.02), (bh + dy).max(0.02)),
341                            _ => (bx, by, (bw + dx).max(0.02), (bh + dy).max(0.02)), // se
342                        };
343                        // Move if anchor changed
344                        if (new_x - bx).abs() > f32::EPSILON || (new_y - by).abs() > f32::EPSILON {
345                            commands.push(Command::MoveTextBox {
346                                id: tb.id,
347                                x: new_x.max(0.0),
348                                y: new_y.max(0.0),
349                            });
350                        }
351                        commands.push(Command::ResizeTextBox { id: tb.id, w: new_w, h: new_h });
352                    }
353                }
354            }
355        }
356    }
357
358    commands
359}
360
361/// Convert a normalized (x, y, w, h) rect to screen-space using the slide rect.
362fn screen_rect(slide_rect: Rect, (nx, ny, nw, nh): (f32, f32, f32, f32)) -> Rect {
363    Rect::from_min_size(
364        Pos2::new(
365            slide_rect.min.x + nx * slide_rect.width(),
366            slide_rect.min.y + ny * slide_rect.height(),
367        ),
368        vec2(nw * slide_rect.width(), nh * slide_rect.height()),
369    )
370}
371
372/// Convert a screen-space position to normalized 0..1 coordinates within the slide rect.
373fn norm_pos(pos: Pos2, slide_rect: Rect) -> (f32, f32) {
374    (
375        ((pos.x - slide_rect.min.x) / slide_rect.width()).clamp(0.0, 1.0),
376        ((pos.y - slide_rect.min.y) / slide_rect.height()).clamp(0.0, 1.0),
377    )
378}
379
380#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
381fn texture_dimension(size: f32) -> u32 {
382    size.max(1.0).ceil() as u32
383}
384
385fn content_hash(content: &str) -> u64 {
386    use std::collections::hash_map::DefaultHasher;
387    use std::hash::{Hash, Hasher};
388
389    let mut hasher = DefaultHasher::new();
390    content.hash(&mut hasher);
391    hasher.finish()
392}