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