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