Merge PR #321 from xc4pr: Text: the caret shows on dark slides and in empty placeholders

# Conflicts:
#	crates/engine/src/cmd/text.rs
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/textures.rs
This commit is contained in:
Brandon Thomas
2026-10-14 15:29:17 -04:00
4 changed files with 94 additions and 5 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::{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<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);
@@ -234,6 +243,22 @@ pub fn caret_line(st: &DocState, t: &TextSel) -> 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<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
@@ -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));
}
+7 -2
View File
@@ -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, &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, visible, 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, 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));
}
}
}
+16 -2
View File
@@ -21,6 +21,7 @@ struct Key {
extra: u64,
/// The pasteboard region the slide's drawing calls for (canvas only).
want: Region,
skip_text: Option<ShapeId>,
}
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<ShapeId>,
) -> 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;