From b97af97a58c513f15e6211b361cd814d5ce11388 Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:39:17 +0200 Subject: [PATCH] Text: the caret shows on dark slides and in empty placeholders The caret was always stroked black. On a dark slide (the Nocturne theme, or any dark background with light text) it could not be seen. It is now drawn in the colour that text typed at the caret takes, and falls back to the theme token when that text has no fill. An empty placeholder showed no caret at all: its body holds no paragraph until something is typed, so the edit layout had no line for the caret to stand on. The edit layout now treats such a body as one empty paragraph, and the canvas leaves the placeholder's prompt out while it is being typed into, so the caret does not sit inside "Click to add title". Fixes #318. --- crates/engine/src/cmd/text.rs | 27 +++++++++++++++++++- crates/engine/src/tests.rs | 45 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 9 +++++-- crates/ui-egui/src/textures.rs | 7 +++++- 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 48be3a51..2a430d93 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::{AutoFit, Hyperlink, Run, RunKind, RunProps, TextBody}; +use deckcraft_model::text::{AutoFit, Hyperlink, Paragraph, Run, RunKind, RunProps, TextBody}; use deckcraft_model::{Presentation, ShapeId, ShapeKind}; use serde_json::{Value, json}; @@ -133,6 +133,15 @@ pub fn layout_for(st: &DocState, t: &TextSel) -> Option Option 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 9aefd0b7..20122e6e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -543,3 +543,48 @@ fn inserted_hyperlinks_look_like_links() { s.execute("format.underline", &json!({})).unwrap(); assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false); } + +#[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 d267f76c..509cd5fc 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -112,7 +112,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, 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, 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), Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px), @@ -427,7 +430,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)); if blink && let Some((cx, top, bot)) = l.caret(deckcraft_text::Pos { para: ts.caret.0, ch: ts.caret.1 }) { let (p0, p1) = (xf.to_screen(m * Point::new(cx, top)), xf.to_screen(m * Point::new(cx, bot))); - painter.line_segment([p0, p1], 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([p0, p1], Stroke::new(1.5, color)); } } } diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index 84cf2755..d93a5f2a 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -15,6 +15,7 @@ struct Key { size: (u32, u32), edit: bool, extra: u64, + skip_text: Option, } struct Entry { @@ -61,6 +62,7 @@ fn threads() -> u16 { impl Textures { /// The canvas texture for slide `index` at `size` pixels; `extra` changes with the GIF frames. + /// `skip_text` leaves out one shape's text (the prompt of a placeholder being typed into). pub fn slide( &mut self, ctx: &egui::Context, @@ -71,6 +73,7 @@ impl Textures { extra: u64, grayscale: bool, gif_times: &[(ShapeId, f64)], + skip_text: Option, ) -> Option { let slide = p.slides.get(index)?.clone(); let master = master_of(p, &slide); @@ -80,6 +83,7 @@ impl Textures { size, edit, extra: extra ^ grayscale as u64, + skip_text, }; if let Some(e) = &self.canvas && e.key == key @@ -88,7 +92,7 @@ impl Textures { } let t0 = crate::now_ms(); let scale = size.0 as f64 / p.slide_size.width.max(1.0); - let opts = RenderOpts { scale, edit, gif_times, threads: threads(), size: Some(size), ..Default::default() }; + let opts = RenderOpts { scale, edit, gif_times, skip_text, threads: threads(), size: Some(size), ..Default::default() }; let img = deckcraft_render::render_slide(p, index, &opts); self.last_render_ms = crate::now_ms() - t0; let ci = to_color_image(&img, grayscale); @@ -121,6 +125,7 @@ impl Textures { size: (width_px, h), edit: false, extra: 0, + skip_text: None, }; self.clock += 1; let clock = self.clock;