mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
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:
@@ -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))
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user