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.
This commit is contained in:
xc4pr
2026-10-13 00:39:17 +02:00
parent 880b974cff
commit b97af97a58
4 changed files with 84 additions and 4 deletions
+26 -1
View File
@@ -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<deckcraft_text::TextLayo
}
let sh = st.shape(t.shape)?;
let body = sh.text.as_ref()?;
// A body nobody has typed into holds no paragraph. Lay it out as one empty paragraph, so
// the caret has a line to stand on.
let blank;
let body = if body.paragraphs.is_empty() {
blank = TextBody { paragraphs: vec![Paragraph::default()], ..body.clone() };
&blank
} else {
body
};
let ctx = ctx_for(&st.doc, &st.selection)?;
let x = xfrm_of(&st.doc, &st.selection, sh);
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
@@ -144,6 +153,22 @@ pub fn layout_for(st: &DocState, t: &TextSel) -> Option<deckcraft_text::TextLayo
))
}
/// 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<deckcraft_color::Rgba> {
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<Pos> {
let a = p.get(key)?.as_array()?;
Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))
+45
View File
@@ -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));
}
+7 -2
View File
@@ -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, &times)
// 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, &times, 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));
}
}
}
+6 -1
View File
@@ -15,6 +15,7 @@ struct Key {
size: (u32, u32),
edit: bool,
extra: u64,
skip_text: Option<ShapeId>,
}
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<ShapeId>,
) -> Option<TextureHandle> {
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;