Browse / github.com/storytold/deckcraft / patch
Format Shape: text fill, outline width and dash, text direction and Do not rotate text in Text Options
Format Shape: text fill, outline width and dash, text direction and Do not rotate text in Text Options Format Shape ▸ Text Options gets the rest of its text controls. - **Text Fill**: No fill, Solid fill or Gradient fill (with its angle). Colour and Transparency show only for the fills they change. - **Text Outline**: No line or Solid line, with colour, width and dash type. - **Text Box**: a Text direction drop-down (Horizontal, Rotate all text 90°, Rotate all text 270°, Stacked) and a Do not rotate text check box. Engine: `format.textFill {mode: none|solid|gradient, angle?}` and `format.upright {on?}` are new. `format.textOutline` now takes `dash` and keeps the parts it is not given, so chan
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Harrison-Booth (imported from pull request #1448, open)
- Base
ee875abffa1a31cb760c843bb29e79391a90a766- Size
- +489 −16 in 17 files
- Review
- pending
- Shared
- 2026-10-28
- Id
c6bdc4e4e2f1f0c27e97ecea
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/engine/src/cmd/format.rs crates/engine/src/cmd/format/font.rs crates/engine/src/cmd/format/paragraph.rs crates/engine/src/cmd/format/text_effects.rs crates/engine/src/tests.rs crates/pptx/tests/it/roundtrip.rs crates/render/src/morph_text.rs crates/render/src/tests.rs crates/render/src/text_draw.rs crates/render/src/warp.rs crates/text/src/layout.rs crates/text/src/run.rs crates/text/src/style.rs crates/text/src/tests.rs crates/ui-egui/src/panels.rs crates/ui-egui/src/panels/format_shape.rs
@@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: Format Shape Text Options depth; previous: trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)@@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---|+| 2026-10-26 | Format Shape ▸ Text Options: Text Fill (No fill, Solid, Gradient with its angle), Text Outline (No line, Solid line, colour, width, dash type), Text direction and Do not rotate text; `format.textFill`, `format.upright`, and `format.textOutline` takes `dash` and keeps the parts it is not given. Dashed text outlines draw, and upright text counters the shape's rotation as PowerPoint draws it | | 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI `<Icon>`, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once | | 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off | | 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |@@ -294,6 +295,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---|+| 2026-10-26 | trivial | Progress log: Format Shape ▸ Text Options depth. Scores and milestone names unchanged | | 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: measurement units in centimetres (#1326). Scores unchanged |@@ -25,13 +25,14 @@ mod text_effects; pub use font::SIZES; use font::{ bold, caps, clear, color, font, font_dialog, grow, highlight, italic, language, language_all, shrink, size, spacing, strike, subscript,- superscript, text_transparency, underline,+ superscript, text_fill, text_transparency, underline, }; pub use painter::Painted; pub(crate) use painter::apply_painted; use painter::{font_add, font_rescan, painter_apply, painter_pick, state}; use paragraph::{- align, align_to, anchor, autofit, bullets, columns, direction, level, line_spacing, margins, numbering, paragraph, paragraph_dialog, rtl, wrap,+ align, align_to, anchor, autofit, bullets, columns, direction, level, line_spacing, margins, numbering, paragraph, paragraph_dialog, rtl,+ upright, wrap, }; use ruler::{ruler, tabs}; use text_effects::{case, text_outline, text_shadow, text_warp};@@ -76,6 +77,15 @@ pub fn specs() -> Vec<CommandSpec> { has_text_or_shapes, text_transparency ),+ cmd!(+ "format.textFill",+ "Text Fill",+ ["Shape Format", "WordArt Styles"],+ None,+ "{mode: none|solid|gradient, angle?: deg} — solid and gradient start from the colour the text shows now; gradient fades into accent 1",+ has_text_or_shapes,+ text_fill+ ), cmd!( "format.language", "Language",@@ -130,7 +140,7 @@ pub fn specs() -> Vec<CommandSpec> { "Text Outline", ["Shape Format", "WordArt Styles"], None,- "{color?, width?: pt} (no color = none)",+ "{color?, width?: pt (0–1584), dash?: solid|dot|dash|…|sysDashDotDot, none?: bool} — given parts replace those of the outline the text has (new: black, 0.75 pt, solid); no color, width or dash (or none: true) removes it", has_text_or_shapes, text_outline ),@@ -243,6 +253,15 @@ pub fn specs() -> Vec<CommandSpec> { ), cmd!("format.autofit", "Autofit", ["Format Shape", "Text Box"], None, "{mode: none|shrink|resize}", has_text_or_shapes, autofit), cmd!("format.wrap", "Wrap Text in Shape", ["Format Shape", "Text Box"], None, "{on: bool}", has_text_or_shapes, wrap),+ cmd!(+ "format.upright",+ "Do Not Rotate Text",+ ["Format Shape", "Text Box"],+ None,+ "{on?: bool (default true)} — the text stays upright when the shape is rotated",+ has_text_or_shapes,+ upright+ ), cmd!( "format.margins", "Text Box Margins",@@ -145,6 +145,63 @@ pub(super) fn text_transparency(s: &mut Session, p: &Value) -> Result<Value> { }) } +/// The colour a run shows for a fill that keeps it (solid, or a gradient's start); `fallback`, the+/// colour the selection shows now, for a run that has none of its own.+fn kept_color(r: &RunProps, fallback: &ColorRef) -> ColorRef {+ match &r.fill {+ Some(Fill::Solid { color }) => color.clone(),+ Some(Fill::Gradient(g)) => g.stops.first().map_or_else(|| fallback.clone(), |st| st.color.clone()),+ _ => fallback.clone(),+ }+}++/// Text Fill: No fill, Solid fill (the colour each run shows now) or Gradient fill (that colour+/// fading into the theme's accent 1, or accent 2 when it already is that, at `angle` degrees+/// clockwise from left to right). A tint of the colour would not do: white or black text hardly+/// changes. A run that already has a gradient keeps its stops and takes only a given `angle`.+pub(super) fn text_fill(s: &mut Session, p: &Value) -> Result<Value> {+ use deckcraft_model::style::{Gradient, GradientShape, GradientStop};+ const CMD: &str = "format.textFill";+ let shown = match current_run(s).fill {+ Some(Fill::Solid { color }) => color,+ Some(Fill::Gradient(g)) => g.stops.first().map_or_else(|| ColorRef::scheme(SchemeSlot::Tx1), |st| st.color.clone()),+ _ => ColorRef::scheme(SchemeSlot::Tx1),+ };+ let mode = match str_param(p, "mode") {+ Some(m @ ("none" | "solid" | "gradient")) => m,+ _ => return Err(bad(CMD, "`mode` is none, solid or gradient")),+ };+ let angle = match p.get("angle") {+ None | Some(Value::Null) => None,+ Some(v) => Some(v.as_f64().filter(|a| a.is_finite()).ok_or_else(|| bad(CMD, "`angle` must be a number of degrees"))?.rem_euclid(360.0)),+ };+ format_run(s, &move |r| {+ r.fill = Some(match mode {+ "none" => Fill::None,+ "solid" => Fill::solid(kept_color(r, &shown)),+ _ => {+ if let Some(Fill::Gradient(g)) = &r.fill {+ let mut g = g.clone();+ if let (Some(a), GradientShape::Linear { angle, .. }) = (angle, &mut g.shape) {+ *angle = a;+ } else if let Some(a) = angle {+ g.shape = GradientShape::Linear { angle: a, scaled: false };+ }+ return r.fill = Some(Fill::Gradient(g));+ }+ let base = kept_color(r, &shown);+ let accent = ColorRef::scheme(if base == ColorRef::scheme(SchemeSlot::Accent1) { SchemeSlot::Accent2 } else { SchemeSlot::Accent1 });+ Fill::Gradient(Gradient {+ stops: vec![GradientStop { pos: 0.0, color: base }, GradientStop { pos: 1.0, color: accent }],+ shape: GradientShape::Linear { angle: angle.unwrap_or(0.0), scaled: false },+ rotate_with_shape: true,+ tile: [0.0; 4],+ })+ }+ });+ })+}+ /// The `lang` and `noProof` params of the language commands, checked: a run-props setter. fn language_edit(id: &str, p: &Value) -> Result<impl Fn(&mut RunProps) + Send + Sync + 'static> { let lang = str_param(p, "lang").map(str::to_string);@@ -394,6 +394,11 @@ pub(super) fn wrap(s: &mut Session, p: &Value) -> Result<Value> { format_body(s, &move |b| b.wrap = Some(on)) } +pub(super) fn upright(s: &mut Session, p: &Value) -> Result<Value> {+ let on = bool_or(p, "on", true);+ format_body(s, &move |b| b.upright = Some(on))+}+ pub(super) fn margins(s: &mut Session, p: &Value) -> Result<Value> { let (l, t, r, b) = (f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")); format_body(s, &move |bp| {@@ -18,10 +18,54 @@ pub(super) fn text_shadow(s: &mut Session, p: &Value) -> Result<Value> { format_run(s, &move |r| r.shadow = if v { Some(sh.clone()) } else { None }) } +/// Text Outline. With `none`, or with neither `color`, `width` nor `dash`, the outline goes; otherwise+/// the given parts replace those of the outline the run shows (a new one is the text colour's black, 0.75 pt, solid). pub(super) fn text_outline(s: &mut Session, p: &Value) -> Result<Value> {+ const CMD: &str = "format.textOutline"; let c = color_param(p, "color");- let w = f64_or(p, "width", 0.75);- format_run(s, &move |r| r.outline = c.clone().map(|c| Line::solid(c, w)))+ let w = match p.get("width") {+ None => None,+ Some(v) => Some(+ v.as_f64()+ .filter(|w| w.is_finite() && (0.0..=1584.0).contains(w))+ .ok_or_else(|| bad(CMD, "`width` is a number of points from 0 to 1584"))?,+ ),+ };+ // A part that is given but not understood is an error, not a request to remove the outline.+ if p.get("color").is_some_and(|v| !v.is_null()) && c.is_none() {+ return Err(bad(CMD, "`color` is not a colour"));+ }+ if p.get("dash").is_some_and(|v| !v.is_null() && !v.is_string()) {+ return Err(bad(CMD, "`dash` is a dash name"));+ }+ let dash = match str_param(p, "dash") {+ None => None,+ Some("solid") => Some(None),+ Some(d) if deckcraft_model::style::Dash::PRESETS.contains(&d) => Some(Some(deckcraft_model::style::Dash::Preset { name: d.to_string() })),+ Some(d) => return Err(bad(CMD, format!("unknown dash `{d}`"))),+ };+ if bool_or(p, "none", false) || (c.is_none() && w.is_none() && dash.is_none()) {+ // An outline the text only inherits is switched off with an explicit no-line of its own.+ let inherited = current_run(s).outline.is_some();+ return format_run(s, &move |r| r.outline = inherited.then(Line::none));+ }+ let inherited = current_run(s).outline;+ format_run(s, &move |r| {+ let mut l = r.outline.take().or_else(|| inherited.clone()).unwrap_or_else(|| Line::solid(ColorRef::rgb(deckcraft_color::Rgba::BLACK), 0.75));+ l.width.get_or_insert(0.75);+ if let Some(c) = &c {+ l.fill = Some(Fill::solid(c.clone()));+ } else if matches!(l.fill, None | Some(Fill::None)) {+ l.fill = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::BLACK)));+ }+ if let Some(w) = w {+ l.width = Some(w);+ }+ if let Some(d) = &dash {+ l.dash.clone_from(d);+ }+ r.outline = Some(l);+ }) } pub(super) fn text_warp(s: &mut Session, p: &Value) -> Result<Value> {@@ -5646,3 +5646,78 @@ fn invalid_ids_array_never_falls_back_to_the_selection() { assert!(s.doc().unwrap().shape(deckcraft_model::ShapeId(a as u32)).is_some(), "shape deleted for {ids}"); } }++/// Text Options: Text Fill none / solid / gradient, Text Outline width and dash, and+/// Do Not Rotate Text, each one undo step.+#[test]+fn text_fill_outline_and_upright_commands() {+ use deckcraft_model::style::{Dash, GradientShape};+ use deckcraft_model::{ColorBase, Fill, Rgba};+ let mut s = session();+ let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Hi"})).unwrap();+ let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);+ let run = |s: &Session| s.doc().unwrap().shape(id).unwrap().text.as_ref().unwrap().paragraphs[0].runs[0].props.clone();+ let body = |s: &Session| s.doc().unwrap().shape(id).unwrap().text.as_ref().unwrap().body.clone();++ s.execute("format.color", &json!({"color": "#C00000"})).unwrap();+ s.execute("format.textFill", &json!({"mode": "none"})).unwrap();+ assert_eq!(run(&s).fill, Some(Fill::None));+ // Back to solid: the colour before the gradient or none is gone, so it falls back to the text colour.+ s.execute("format.color", &json!({"color": "#C00000"})).unwrap();+ s.execute("format.textFill", &json!({"mode": "gradient", "angle": 90})).unwrap();+ let Some(Fill::Gradient(g)) = run(&s).fill else { panic!("gradient expected") };+ assert_eq!(g.stops.len(), 2);+ assert_eq!(g.stops[1].color, deckcraft_model::ColorRef::scheme(deckcraft_model::SchemeSlot::Accent1));+ assert_eq!(g.stops[0].color.base, ColorBase::Rgb { rgb: Rgba::rgb(0xC0, 0, 0) });+ assert_eq!(g.shape, GradientShape::Linear { angle: 90.0, scaled: false });+ // A new angle keeps the stops, with their transparency.+ s.execute("format.textTransparency", &json!({"value": 0.5})).unwrap();+ s.execute("format.textFill", &json!({"mode": "gradient", "angle": 180})).unwrap();+ let Some(Fill::Gradient(g2)) = run(&s).fill else { panic!("gradient expected") };+ assert_eq!(g2.shape, GradientShape::Linear { angle: 180.0, scaled: false });+ assert!(g2.stops.iter().all(|st| (st.color.alpha() - 0.5).abs() < 1e-9), "{:?}", g2.stops);+ s.execute("format.textFill", &json!({"mode": "solid"})).unwrap();+ let Some(Fill::Solid { color }) = run(&s).fill else { panic!("solid expected") };+ assert_eq!(color.base, ColorBase::Rgb { rgb: Rgba::rgb(0xC0, 0, 0) });+ assert!(s.execute("format.textFill", &json!({"mode": "rainbow"})).is_err());+ assert!(s.execute("format.textFill", &json!({})).is_err());++ // Outline: parts replace parts, a bare call removes it.+ s.execute("format.textOutline", &json!({"width": 2.5})).unwrap();+ let l = run(&s).outline.unwrap();+ assert_eq!((l.width, l.dash.clone()), (Some(2.5), None));+ s.execute("format.textOutline", &json!({"dash": "lgDash"})).unwrap();+ s.execute("format.textOutline", &json!({"color": "#336699"})).unwrap();+ let l = run(&s).outline.unwrap();+ assert_eq!(l.width, Some(2.5));+ assert_eq!(l.dash, Some(Dash::Preset { name: "lgDash".into() }));+ let Some(Fill::Solid { color }) = l.fill else { panic!() };+ assert_eq!(color.base, ColorBase::Rgb { rgb: Rgba::rgb(0x33, 0x66, 0x99) });+ s.execute("format.textOutline", &json!({"dash": "solid"})).unwrap();+ assert_eq!(run(&s).outline.unwrap().dash, None);+ for bad in [+ json!({"dash": "wavy"}),+ json!({"dash": 123}),+ json!({"color": "not-a-color"}),+ json!({"width": -1}),+ json!({"width": 1e9}),+ json!({"width": "wide"}),+ ] {+ assert!(s.execute("format.textOutline", &bad).is_err(), "{bad}");+ }+ s.execute("format.textOutline", &json!({})).unwrap();+ assert_eq!(run(&s).outline, Some(deckcraft_model::Line::none()));+ s.execute("edit.undo", &json!({})).unwrap();+ assert_eq!(run(&s).outline.unwrap().width, Some(2.5));++ // Upright and direction.+ assert_eq!(body(&s).upright, None);+ s.execute("format.upright", &json!({"on": true})).unwrap();+ assert_eq!(body(&s).upright, Some(true));+ s.execute("format.direction", &json!({"dir": "rotate270"})).unwrap();+ assert_eq!(body(&s).vert, Some(deckcraft_model::text::TextDir::Vertical270));+ s.execute("format.upright", &json!({"on": false})).unwrap();+ assert_eq!(body(&s).upright, Some(false));+ s.execute("edit.undo", &json!({})).unwrap();+ assert_eq!(body(&s).upright, Some(true));+}@@ -1925,3 +1925,27 @@ fn zoom_inserts_linked_slide_pictures() { assert!(s.execute("insert.zoom", &json!({"slides": [42]})).is_err()); assert!(s.execute("insert.zoom", &json!({"kind": "slide"})).is_err()); }++/// Text Options (text fill, outline width and dash, direction, Do not rotate text) survive a save.+#[test]+fn text_options_round_trip() {+ use deckcraft_engine::Session;+ use deckcraft_model::style::Dash;+ let mut s = Session::with_new();+ s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Hi"})).unwrap();+ s.execute("format.textFill", &serde_json::json!({"mode": "gradient", "angle": 45})).unwrap();+ s.execute("format.textOutline", &serde_json::json!({"color": "#336699", "width": 2.5, "dash": "sysDot"})).unwrap();+ s.execute("format.direction", &serde_json::json!({"dir": "rotate90"})).unwrap();+ s.execute("format.upright", &serde_json::json!({"on": true})).unwrap();+ let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc.clone()).unwrap();+ let back = deckcraft_pptx::import(&bytes).unwrap();+ let sh = back.slides[0].shapes.iter().find(|sh| sh.text.as_ref().is_some_and(|t| t.text().contains("Hi"))).unwrap();+ let t = sh.text.as_ref().unwrap();+ let props = &t.paragraphs[0].runs[0].props;+ assert!(matches!(props.fill, Some(deckcraft_model::Fill::Gradient(_))), "{:?}", props.fill);+ let line = props.outline.as_ref().unwrap();+ assert_eq!(line.width, Some(2.5));+ assert_eq!(line.dash, Some(Dash::Preset { name: "sysDot".into() }));+ assert_eq!(t.body.vert, Some(deckcraft_model::text::TextDir::Vertical));+ assert_eq!(t.body.upright, Some(true));+}@@ -101,7 +101,12 @@ fn glyph_at(l: &TextLayout, (ri, gi): (usize, usize)) -> Option<(&GlyphRun, u32, } fn same_look(a: &GlyphRun, b: &GlyphRun) -> bool {- a.face.id() == b.face.id() && a.size == b.size && a.color == b.color && a.alpha == b.alpha && a.outline == b.outline+ a.face.id() == b.face.id()+ && a.size == b.size+ && a.color == b.color+ && a.alpha == b.alpha+ && a.outline == b.outline+ && a.outline_dash == b.outline_dash } fn lerp(a: f64, b: f64, t: f64) -> f64 {@@ -2951,3 +2951,29 @@ fn pie_data_labels_take_the_chart_text_colour_when_it_has_one() { assert!(count(&coloured, &red) > 30, "the labels take the chart's red: {} red pixels", count(&coloured, &red)); assert!(count(&plain, &white) > count(&coloured, &white), "the labels were white before"); }++/// A text outline draws its dash: a dashed outline leaves gaps a solid one covers.+#[test]+fn dashed_text_outline_leaves_gaps() {+ use deckcraft_model::style::{Dash, Fill, Line};+ use deckcraft_model::text::{Run, RunProps, TextBody};+ let ink = |dash: Option<Dash>| {+ let props = RunProps {+ size: Some(120.0),+ bold: Some(true),+ fill: Some(Fill::None),+ outline: Some(Line { dash, ..Line::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)), 3.0) }),+ ..Default::default()+ };+ let mut body = TextBody::from_text("I");+ body.paragraphs[0].runs = vec![Run::with("I", props)];+ let sh = Shape { id: ShapeId(790), xfrm: Some(Xfrm::new(100.0, 50.0, 400.0, 300.0)), text: Some(body), ..Default::default() };+ let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });+ (0..img.height).flat_map(|y| (0..img.width).map(move |x| (x, y))).filter(|(x, y)| img.pixel(*x, *y)[0] < 128).count()+ };+ let solid = ink(None);+ let dashed = ink(Some(Dash::Preset { name: "dash".into() }));+ assert!(solid > 500, "a solid outline draws ink: {solid}");+ assert!(dashed < solid * 3 / 4, "dashes leave gaps: {dashed} of {solid}");+ assert!(dashed > 100, "and still draw: {dashed}");+}@@ -68,6 +68,17 @@ pub(crate) fn preview_empty_bullets(l: &mut deckcraft_text::TextLayout) { } } +/// The stroke of a text outline `width` wide (in the units of the path it is drawn on) with the+/// dash pattern `dash` (multiples of the width; empty = solid).+pub(crate) fn outline_stroke(width: f64, dash: &[f64]) -> kurbo::Stroke {+ let mut sk = kurbo::Stroke::new(width.max(0.0)).with_join(kurbo::Join::Round);+ let pat: Vec<f64> = dash.iter().map(|v| v * width).collect();+ if !pat.is_empty() && pat.iter().all(|v| v.is_finite() && *v > 0.0) {+ sk = sk.with_dashes(0.0, pat);+ }+ sk+}+ /// Where text laid out in text rect `tr` is drawn, for a shape drawn with `m` (shape-local → /// output): `m`, without mirroring, then the layout's `rotation` about the rect's centre. ///@@ -312,7 +323,7 @@ fn draw_glyph_with( } if let Some((oc, ow)) = run.outline { ctx.set_paint(color(oc, opacity));- ctx.set_stroke(kurbo::Stroke::new(ow / k.max(1e-9)).with_join(kurbo::Join::Round));+ ctx.set_stroke(outline_stroke(ow / k.max(1e-9), &run.outline_dash)); ctx.stroke_path(&path); } }@@ -188,7 +188,7 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine, } if let Some((oc, ow)) = run.outline { ctx.set_paint(color(oc, ps.opacity));- ctx.set_stroke(kurbo::Stroke::new(ow.max(0.0)).with_join(kurbo::Join::Round));+ ctx.set_stroke(crate::text_draw::outline_stroke(ow, &run.outline_dash)); ctx.stroke_path(&path); } }@@ -104,7 +104,10 @@ pub(crate) fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &Body let col_gap = bp.col_spacing.unwrap_or(0.0).max(0.0); let col_w = ((inner.width() - col_gap * (ncols as f64 - 1.0)) / ncols as f64).max(1.0); - let mut out = TextLayout { inner, font_scale: scale, line_reduction: reduce, rotation, ..Default::default() };+ // "Do not rotate text": turn the text back by the shape's own turn about its rect's centre.+ // shortcut: a rotated group around the shape is not counted, when it matters read the parents' turns too.+ let held = if bp.upright == Some(true) { resolve::xfrm(ctx, shape).rot } else { 0.0 };+ let mut out = TextLayout { inner, font_scale: scale, line_reduction: reduce, rotation: rotation - held, ..Default::default() }; let mut y = 0.0; let mut numbering: Vec<u32> = vec![0; 9]; let mut all_lines: Vec<(LineInfo, LinePlace, f64, Option<(String, f64, f64)>)> = vec![];@@ -435,6 +438,7 @@ pub(crate) fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &Body fake_bold: false, fake_italic: false, outline: None,+ outline_dash: vec![], para: line.para, chars: (0, 0), link: false,@@ -476,6 +480,7 @@ pub(crate) fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &Body fake_bold: st.fake_bold, fake_italic: st.fake_italic, outline: st.outline,+ outline_dash: st.outline_dash.clone(), para: line.para, chars: (ci, ci + 1), link: st.link,@@ -16,6 +16,8 @@ pub struct GlyphRun { /// Slant because the family has no italic face. pub fake_italic: bool, pub outline: Option<(Rgba, f64)>,+ /// The outline's dash and gap lengths in multiples of its width; empty = solid.+ pub outline_dash: Vec<f64>, /// Paragraph and character range this run covers (for hyperlinks, selection colours). pub para: usize, pub chars: (usize, usize),@@ -45,6 +45,7 @@ pub(crate) struct Style { pub(crate) strike: Option<Strike>, pub(crate) highlight: Option<Rgba>, pub(crate) outline: Option<(Rgba, f64)>,+ pub(crate) outline_dash: Vec<f64>, pub(crate) link: bool, pub(crate) gradient: Option<deckcraft_model::style::Gradient>, pub(crate) effects: Option<RunEffects>,@@ -158,6 +159,7 @@ pub(crate) fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgb Some(Fill::Solid { color: c }) => Some((ctx.color(c, None), l.width.unwrap_or(0.75))), _ => None, });+ let outline_dash = r.outline.as_ref().and_then(|l| l.dash.as_ref()).map(|d| d.pattern()).unwrap_or_default(); Style { face, size,@@ -172,6 +174,7 @@ pub(crate) fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgb strike: r.strike.filter(|s| *s != Strike::None), highlight: r.highlight.as_ref().map(|c| ctx.color(c, None)), outline,+ outline_dash, link: r.link.is_some(), gradient, effects: run_effects(ctx, r, scale),@@ -1845,3 +1845,15 @@ fn an_empty_paragraph_keeps_its_paragraph_mark_height() { let h = l.lines[1].bottom - l.lines[1].top; assert!((h - h10).abs() < 0.5, "a 10pt empty paragraph should give {h10}, got {h}"); }++#[test]+fn do_not_rotate_text_turns_the_text_back_by_the_shapes_rotation() {+ let (p, mut sh) = setup("Upright", 300.0, 100.0);+ sh.xfrm = Some(Xfrm { rot: 30.0, ..Xfrm::new(0.0, 0.0, 300.0, 100.0) });+ assert_eq!(lay(&p, &sh, 300.0, 100.0).rotation, 0.0);+ sh.text.as_mut().unwrap().body.upright = Some(true);+ assert_eq!(lay(&p, &sh, 300.0, 100.0).rotation, -30.0);+ // Vertical text stays a quarter turn from the page: the shape's turn comes off that too.+ sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::Vertical);+ assert_eq!(lay(&p, &sh, 300.0, 100.0).rotation, 60.0);+}@@ -217,6 +217,66 @@ mod tests { assert!(changed.iter().any(|v| (*v - 50.0).abs() < 0.5), "the changed slider shows 50%, got {changed:?}"); } + /// Format Shape ▸ Text Options: Text Fill none and gradient, Text Outline width and dash, text+ /// direction and Do not rotate text act on the selected shape's text.+ #[test]+ fn text_options_set_fill_outline_direction_and_upright() {+ use deckcraft_model::style::{Dash, GradientShape};+ use deckcraft_model::text::TextDir;+ use egui::accesskit::Role;+ let mut s = Session::with_new();+ s.execute("slide.new", &json!({"layout": "blank"})).unwrap();+ let id =+ s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 240.0, 80.0], "text": "Hi"})).unwrap()["id"].as_u64().unwrap();+ s.execute("edit.select", &json!({"ids": [id]})).unwrap();+ let mut app = SlideApp::new(s, Services::default());+ app.ui.pane = Some("format".into());+ app.ui.format_tab = "text".into();+ let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(+ |ui, app: &mut SlideApp| {+ app.logic(ui.ctx());+ app.ui(ui);+ },+ app,+ );+ h.run_steps(3);+ let text = |h: &Harness<'_, SlideApp>| h.state().session.active().unwrap().shape(ShapeId(id as u32)).unwrap().text.clone().unwrap();+ let run_props = |h: &Harness<'_, SlideApp>| text(h).paragraphs[0].runs[0].props.clone();+ let click = |h: &mut Harness<'_, SlideApp>, role: Role, label: &str| {+ h.get_by_role_and_label(role, label).click();+ h.run_steps(3);+ };++ // The outline controls wait for a solid line.+ assert!(h.query_by_role_and_label(Role::SpinButton, "Width").is_none());+ click(&mut h, Role::Button, "Gradient fill");+ let Some(Fill::Gradient(g)) = run_props(&h).fill else { panic!("gradient fill expected") };+ assert_eq!(g.shape, GradientShape::Linear { angle: 0.0, scaled: false });+ click(&mut h, Role::Button, "No fill");+ assert_eq!(run_props(&h).fill, Some(Fill::None));+ // No fill: nothing left to make transparent.+ assert!(h.query_by_role_and_label(Role::Slider, "Transparency").is_none());++ click(&mut h, Role::Button, "Solid line");+ assert_eq!(run_props(&h).outline.as_ref().and_then(|l| l.width), Some(0.75));+ h.get_by_role_and_label(Role::ComboBox, "Dash type").click();+ h.run_steps(3);+ click(&mut h, Role::Button, "Long Dash");+ assert_eq!(run_props(&h).outline.unwrap().dash, Some(Dash::Preset { name: "lgDash".into() }));++ h.get_by_role_and_label(Role::ComboBox, "Text direction").click();+ h.run_steps(3);+ click(&mut h, Role::Button, "Stacked");+ assert_eq!(text(&h).body.vert, Some(TextDir::Stacked));++ click(&mut h, Role::CheckBox, "Do not rotate text");+ assert_eq!(text(&h).body.upright, Some(true));+ click(&mut h, Role::CheckBox, "Do not rotate text");+ assert_eq!(text(&h).body.upright, Some(false));+ click(&mut h, Role::Button, "No line");+ assert_eq!(run_props(&h).outline, Some(deckcraft_model::Line::none()));+ }+ /// A blank slide with one selected rectangle, its fill set by `fill` (none for an automatic fill), /// in Format Shape ▸ Fill. fn fill_pane(fill: Option<serde_json::Value>) -> Harness<'static, SlideApp> {@@ -1,4 +1,5 @@ use super::*;+use deckcraft_model::text::TextDir; /// Percent transparency (0 = opaque) of a solid fill, the same number `shape.fill`'s /// `transparency` parameter writes as an alpha transform. Anything else (no fill, a@@ -187,14 +188,26 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) { ui.data_mut(|d| d.insert_temp(id, (sh.descr.clone(), alt))); } "text" => {- ui.label(egui::RichText::new("Text Fill").font(theme::bold(13.0)));- color_button(app, ui, "Color", None, |c| match c {- Some(c) => ("format.color".into(), json!({"color": cref_param(&c)})),- None => ("format.color".into(), json!({"color": "tx1"})),- });- text_transparency_slider(app, ui, &sh);+ text_fill_options(app, ui, &sh);+ ui.add_space(6.0);+ text_outline_options(app, ui, &sh); ui.add_space(6.0); ui.label(egui::RichText::new("Text Box").font(theme::bold(13.0)));+ let dir = match sh.text.as_ref().and_then(|t| t.body.vert) {+ None | Some(TextDir::Horizontal) => "horizontal",+ Some(TextDir::Vertical270) => "rotate270",+ Some(TextDir::Stacked | TextDir::StackedRtl) => "stacked",+ Some(_) => "rotate90",+ };+ text_choice(+ app,+ ui,+ "Text direction",+ "format.direction",+ "dir",+ dir,+ &[("Horizontal", "horizontal"), ("Rotate all text 90°", "rotate90"), ("Rotate all text 270°", "rotate270"), ("Stacked", "stacked")],+ ); ui.horizontal(|ui| { ui.label("Vertical alignment"); for (l, a) in [("Top", "top"), ("Middle", "middle"), ("Bottom", "bottom")] {@@ -210,6 +223,10 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) { } } });+ let mut upright = sh.text.as_ref().is_some_and(|t| t.body.upright == Some(true));+ if ui.checkbox(&mut upright, "Do not rotate text").changed() {+ run(app, "format.upright", json!({"on": upright}));+ } let wrap = sh.text.as_ref().and_then(|t| t.body.wrap).unwrap_or(true); let mut w = wrap; if ui.checkbox(&mut w, "Wrap text in shape").changed() {@@ -390,6 +407,112 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) { } } +/// A labelled drop-down on the Text Options tab: picks one of `items` (label, value) and runs+/// `cmd` with `{key: value}`.+fn text_choice(app: &mut SlideApp, ui: &mut Ui, label: &str, cmd: &str, key: &str, current: &str, items: &[(&str, &str)]) {+ ui.horizontal(|ui| {+ let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));+ let shown = items.iter().find(|(_, v)| *v == current).map_or(current, |(l, _)| *l);+ let combo = egui::ComboBox::from_id_salt(("fmt-text", cmd, key)).selected_text(shown).width(150.0).show_ui(ui, |ui| {+ for (l, v) in items {+ if ui.selectable_label(*v == current, *l).clicked() {+ run(app, cmd, json!({key: v}));+ }+ }+ });+ let _ = combo.response.labelled_by(field.id);+ });+}++/// The first run's own props of a shape's text: what the Text Options tab reads.+fn first_run_props(sh: &deckcraft_model::Shape) -> Option<&deckcraft_model::text::RunProps> {+ sh.text.as_ref().and_then(|t| t.paragraphs.iter().flat_map(|p| p.runs.iter()).next()).map(|r| &r.props)+}++/// Text Fill: No fill, Solid fill or Gradient fill (with its angle), the colour and the transparency.+fn text_fill_options(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) {+ use deckcraft_model::style::GradientShape;+ ui.label(egui::RichText::new("Text Fill").font(theme::bold(13.0)));+ let fill = first_run_props(sh).and_then(|r| r.fill.clone());+ let (kind, angle) = match &fill {+ Some(Fill::None) => ("none", 0.0),+ Some(Fill::Gradient(g)) => ("gradient", if let GradientShape::Linear { angle, .. } = g.shape { angle } else { 0.0 }),+ _ => ("solid", 0.0),+ };+ ui.horizontal(|ui| {+ for (l, m) in [("No fill", "none"), ("Solid fill", "solid"), ("Gradient fill", "gradient")] {+ if ui.selectable_label(kind == m, l).clicked() && kind != m {+ run(app, "format.textFill", json!({"mode": m}));+ }+ }+ });+ if kind == "solid" {+ color_button(app, ui, "Color", None, |c| match c {+ Some(c) => ("format.color".into(), json!({"color": cref_param(&c)})),+ None => ("format.color".into(), json!({"color": "tx1"})),+ });+ }+ if kind == "gradient" {+ ui.horizontal(|ui| {+ ui.add_sized(vec2(90.0, 20.0), egui::Label::new("Angle"));+ if let Some(nv) = widgets::spinner(ui, "text-fill-angle", "Angle", angle, 15.0, "°", 70.0) {+ run(app, "format.textFill", json!({"mode": "gradient", "angle": nv}));+ }+ });+ }+ if kind != "none" {+ text_transparency_slider(app, ui, sh);+ }+}++/// Text Outline: No line or Solid line, with its colour, width and dash.+fn text_outline_options(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) {+ ui.label(egui::RichText::new("Text Outline").font(theme::bold(13.0)));+ let line = first_run_props(sh).and_then(|r| r.outline.clone()).filter(|l| !matches!(l.fill, Some(Fill::None)));+ ui.horizontal(|ui| {+ if ui.selectable_label(line.is_none(), "No line").clicked() && line.is_some() {+ run(app, "format.textOutline", json!({"none": true}));+ }+ if ui.selectable_label(line.is_some(), "Solid line").clicked() && line.is_none() {+ run(app, "format.textOutline", json!({"width": 0.75}));+ }+ });+ let Some(line) = line else { return };+ color_button(app, ui, "Color", None, |c| match c {+ Some(c) => ("format.textOutline".into(), json!({"color": cref_param(&c)})),+ None => ("format.textOutline".into(), json!({"none": true})),+ });+ ui.horizontal(|ui| {+ ui.add_sized(vec2(90.0, 20.0), egui::Label::new("Width"));+ if let Some(nv) = widgets::spinner(ui, "text-outline-width", "Width", line.width.unwrap_or(0.75), 0.25, " pt", 70.0) {+ run(app, "format.textOutline", json!({"width": nv.clamp(0.0, 1584.0)}));+ }+ });+ let dash = match &line.dash {+ None => "solid",+ Some(deckcraft_model::style::Dash::Preset { name }) => name.as_str(),+ Some(_) => "custom",+ };+ text_choice(+ app,+ ui,+ "Dash type",+ "format.textOutline",+ "dash",+ dash,+ &[+ ("Solid", "solid"),+ ("Round Dot", "sysDot"),+ ("Square Dot", "sysDash"),+ ("Dash", "dash"),+ ("Dash Dot", "dashDot"),+ ("Long Dash", "lgDash"),+ ("Long Dash Dot", "lgDashDot"),+ ("Long Dash Dot Dot", "lgDashDotDot"),+ ],+ );+}+ /// A labelled drop-down on the Format Shape pane: picks one of `items` (label, value) and runs /// `shape.line` with `{key: value}`. fn line_choice(app: &mut SlideApp, ui: &mut Ui, label: &str, key: &str, current: &str, items: &[(&str, &str)]) {