diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index b2c924a3..a55b0ed8 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use deckcraft_model::edit::{self as ed, Pos}; use deckcraft_model::resolve::{self, Ctx}; -use deckcraft_model::text::{Align, AutoFit, Hyperlink, Run, RunKind, RunProps, TextBody}; +use deckcraft_model::text::{Align, AutoFit, Hyperlink, Paragraph, Run, RunKind, RunProps, TextBody}; use deckcraft_model::{Presentation, ShapeId, ShapeKind}; use serde_json::{Value, json}; @@ -188,6 +188,15 @@ pub fn layout_for(st: &DocState, t: &TextSel) -> Option Option<(deckcraft_geom::Point, Some((m * deckcraft_geom::Point::new(cx, top), m * deckcraft_geom::Point::new(cx, bot))) } +/// The colour of text typed at the caret. The canvas draws the caret in it, so the caret shows +/// wherever the text does: light on a dark slide, dark on a light one. `None` when that text +/// has no fill to take a colour from. +pub fn caret_color(st: &DocState, t: &TextSel) -> Option { + if t.notes || t.cell.is_some() { + return None; + } + let sh = st.shape(t.shape)?; + let body = sh.text.as_ref()?; + let ctx = ctx_for(&st.doc, &st.selection)?; + let blank = Paragraph::default(); + let para = body.paragraphs.get(t.caret.0).unwrap_or(&blank); + let props = resolve::run(&ctx, sh, para, &ed::props_at(body, t.caret)); + Some(resolve::text_color(&ctx, &props)).filter(|c| c.a > 0) +} + fn pos_param(p: &Value, key: &str) -> Option { let a = p.get(key)?.as_array()?; Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize)) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 663324aa..e0aff8a8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2494,3 +2494,48 @@ fn language_marks_text_and_no_proof_and_repeats_are_reported() { assert!(s.execute("review.spelling", &json!({})).unwrap().as_array().unwrap().is_empty()); assert!(s.execute("format.language", &json!({"lang": "en US!"})).is_err()); } + +#[test] +fn an_empty_placeholder_has_a_caret() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + let st = s.doc().unwrap(); + let sel = st.selection.text.clone().unwrap(); + let layout = cmd::text::layout_for(st, &sel).unwrap(); + let (x, top, bottom) = layout.caret(deckcraft_text::Pos { para: 0, ch: 0 }).expect("a caret in the empty title"); + // A centred title: the caret stands in the middle of the text box, one line tall. + assert!((x - layout.inner.center().x).abs() < 1.0, "{x} in {:?}", layout.inner); + assert!(bottom - top > 20.0, "{top}..{bottom}"); +} + +#[test] +fn the_caret_takes_the_colour_of_the_text() { + let caret = |theme: &str| { + let mut s = session(); + s.execute("design.theme", &json!({"name": theme})).unwrap(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + let st = s.doc().unwrap(); + let empty = cmd::text::caret_color(st, &st.selection.text.clone().unwrap()).unwrap(); + s.execute("text.insert", &json!({"text": "Typed"})).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(cmd::text::caret_color(st, &st.selection.text.clone().unwrap()), Some(empty)); + empty + }; + // Dark text on the light theme, light text on the dark one: never black on a dark slide. + let (light, dark) = (caret("DeckCraft"), caret("Nocturne")); + assert!(light.r < 0x60 && light.g < 0x60 && light.b < 0x60, "{light:?}"); + assert!(dark.r > 0xC0 && dark.g > 0xC0 && dark.b > 0xC0, "{dark:?}"); + // Text set to red gets a red caret. + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.insert", &json!({"text": "Red"})).unwrap(); + s.execute("text.selectAll", &json!({})).unwrap(); + s.execute("format.color", &json!({"color": "#FF0000"})).unwrap(); + s.execute("text.move", &json!({"to": "end"})).unwrap(); + let st = s.doc().unwrap(); + let red = cmd::text::caret_color(st, &st.selection.text.clone().unwrap()).unwrap(); + assert_eq!((red.r, red.g, red.b), (0xFF, 0, 0)); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 819ac036..9a6a7163 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -137,7 +137,10 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { Some((Target::Slides, d)) => { let idx = d.selection.slide; let doc = d.doc.clone(); - app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×) + // A placeholder's prompt makes way for the caret once it is being typed into. + let typing_into = d.selection.text.as_ref().filter(|t| !t.notes && t.cell.is_none()).map(|t| t.shape); + let no_prompt = typing_into.filter(|id| d.shape(*id).and_then(|sh| sh.text.as_ref()).is_none_or(|body| body.is_empty())); + app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×, no_prompt) } Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible), Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible), @@ -622,7 +625,9 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To ui.ctx().request_repaint_after(std::time::Duration::from_millis(300)); // An empty placeholder shows the caret where typing begins too. if blink && let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) { - painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, t.caret)); + // In the colour of the text being typed: a black caret is lost on a dark slide. + let color = deckcraft_engine::cmd::text::caret_color(st, ts).map_or(t.caret, theme::to_color32); + painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, color)); } } } diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index e60aafbe..d724a193 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -21,6 +21,7 @@ struct Key { extra: u64, /// The pasteboard region the slide's drawing calls for (canvas only). want: Region, + skip_text: Option, } struct Entry { @@ -135,7 +136,8 @@ impl Textures { /// The canvas texture for slide `index`, the slide being `size` pixels, and the region it /// covers: the slide, grown over the pasteboard to show what reaches off the slide within /// `visible` (the canvas, in pixels from the slide's top-left corner). `extra` changes with the - /// GIF frames. + /// GIF frames. `skip_text` leaves out one shape's text (the prompt of a placeholder being typed + /// into). #[allow(clippy::too_many_arguments)] pub fn slide( &mut self, @@ -148,6 +150,7 @@ impl Textures { extra: u64, grayscale: bool, gif_times: &[(ShapeId, f64)], + skip_text: Option, ) -> Option<(TextureHandle, Region)> { let slide = p.slides.get(index)?.clone(); let master = master_of(p, &slide); @@ -172,6 +175,7 @@ impl Textures { edit, extra: extra ^ grayscale as u64, want, + skip_text, }; if let Some(e) = &self.canvas && e.key == key @@ -181,7 +185,16 @@ impl Textures { let t0 = crate::now_ms(); let render = |region: Region| { let opts = - RenderOpts { scale, edit, gif_times, threads: threads(), offset: region.offset(), size: Some(region.size()), ..Default::default() }; + RenderOpts { + scale, + edit, + gif_times, + skip_text, + threads: threads(), + offset: region.offset(), + size: Some(region.size()), + ..Default::default() + }; deckcraft_render::render_slide_drawn(p, index, &opts) }; let (mut img, drawn) = render(want); @@ -236,6 +249,7 @@ impl Textures { edit: false, extra: 0, want: Region::slide(width_px, h), + skip_text: None, }; self.clock += 1; let clock = self.clock;