1use 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#[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 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 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 ui.data_mut(|d| d.remove::<(f32, f32)>(place_start_id));
148 } else {
149 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 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 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 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 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 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 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 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 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 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 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 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 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)), };
360 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
389fn 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
400fn 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}