Browse / github.com/storytold/designcraft / patch
Text: the Gradient Swatch tool positions a gradient on the selected text
Gradient Swatch tool sets the frame's gradient instead of the selected text's ### What happens A gradient can be applied to text or to part of a text (the text's fill set to a gradient swatch). But when the Gradient Swatch tool (G) is then dragged across that text, the drag sets the gradient on the **frame's fill**, not on the text. The text gradient can't be positioned at all. ### Steps 1. Draw a text frame, type a word and select it with the Type tool. 2. Apply a gradient swatch to the selected text. 3. Choose the Gradient Swatch tool and drag across the text. ### Expected With text selected (or a text frame selected with Formatting Affects Text on), the drag set
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- PiMinusMax (imported from pull request #1876, open)
- Base
ed8aabd1dc5a88652a076631c2d46886ca60c031- Size
- +266 −15 in 18 files
- Review
- pending
- Shared
- 2026-10-28
- Id
288964fac53be4539deceb56
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/compose/src/lib.rs crates/compose/src/shape.rs crates/doc/src/attrs.rs crates/engine/src/cmd/text.rs crates/engine/src/cmd/text_color_tests.rs crates/engine/src/tooling.rs crates/idml/src/export.rs crates/idml/src/import.rs crates/idml/src/tests.rs crates/pdf/src/text.rs crates/render/src/text.rs crates/tools/src/gradient.rs crates/tools/src/lib.rs crates/tools/src/snap.rs crates/tools/src/tests.rs crates/ui-egui/src/canvas.rs crates/ui-egui/src/lib.rs
@@ -380,6 +380,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-26** · The Gradient Swatch tool on selected text sets where the text's gradient starts and ends, for the selected characters (`type.gradient {from, to}`, the character attribute `fillGradient`); it set the frame's fill gradient before, and a gradient on text always spanned the frame's box. The screen, PDF export and IDML (`GradientFillStart`, `GradientFillLength`, `GradientFillAngle` on character ranges) use it (#1530). No readiness percentage change. - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. - **2026-10-19** · Import InDesign Set… runs on craft-keymap's importer: a set's Window menu panel items (Layers F7, Pages F12, Swatches F5) and Tools items set the panel and tool shortcuts, all of an item's Default keys apply, and the report adds numeric keypad keys read as main keys and likely commands for unmatched items (`window.importShortcuts`). On InDesign's Windows defaults it matches 366 of 1,629 items (was 233) and changes 23 commands. No readiness percentage change. - **2026-10-19** · Keyboard shortcuts run on the shared craft-keymap crate. Panels take shortcuts, with InDesign's defaults where the key is free (Swatches F5, Color F6, Layers F7, Info F8, Stroke F10, Paragraph Styles F11, Character Styles Shift+F11, Pages F12, Align Shift+F7); tool keys are editable in Edit › Keyboard Shortcuts and `window.setShortcut`, and tools sharing a key take turns (B). Saved shortcuts load unchanged. No readiness percentage change.@@ -60,6 +60,8 @@ pub use crate::table::{BorderHit, PlacedCell, StrokeSeg, TableFrag, cell_caret, pub struct RunStyle { pub fill: String, pub fill_tint: f32,+ /// A gradient fill's start and end in the frame's text space (see `CharProps::fill_gradient`).+ pub fill_gradient: Option<[f64; 4]>, pub stroke: String, pub stroke_tint: f32, pub stroke_weight: f64,@@ -144,6 +144,7 @@ fn shaping_key(p: &CharProps) -> CharProps { skew: d.skew, fill: d.fill, fill_tint: d.fill_tint,+ fill_gradient: d.fill_gradient, stroke: d.stroke, stroke_tint: d.stroke_tint, stroke_weight: d.stroke_weight,@@ -257,6 +258,7 @@ impl StyleTable<'_> { let rs = RunStyle { fill: p.fill.clone(), fill_tint: p.fill_tint,+ fill_gradient: p.fill_gradient, stroke: p.stroke.clone(), stroke_tint: p.stroke_tint, stroke_weight: p.stroke_weight,@@ -1061,6 +1061,9 @@ attr_set! { skew: f64 = 0.0, fill: String = "[Black]".into(), fill_tint: f32 = 1.0,+ /// The Gradient Swatch tool's vector for a gradient fill, start and end in the frame's text+ /// space; `None` lays the gradient across the frame's box.+ fill_gradient: Option<[f64; 4]> = None => "deserialize_explicit_value", stroke: String = "[None]".into(), stroke_tint: f32 = 1.0, stroke_weight: f64 = 1.0,@@ -178,6 +178,15 @@ pub fn specs() -> Vec<CommandSpec> { Ok(v) }), cmd!("type.fillWithPlaceholder", "Fill with Placeholder Text", ["Type"], None, "{frame?, story?}", has_text_or_frames, fill_placeholder),+ cmd!(+ "type.gradient",+ "Gradient",+ [],+ None,+ "{from: [x,y], to: [x,y] (spread coords: the Gradient Swatch tool's drag), reset?: bool} — where the selected text's gradient fill starts and ends (reset: across the frame again); sets the character attribute fillGradient. With text frames selected instead, their whole text",+ super::has_text_or_frames,+ text_gradient+ ), cmd!( "type.char", "Character Formatting",@@ -1682,6 +1691,51 @@ fn empty_para_mark(story: &designcraft_doc::Story, pos: usize) -> bool { ranges.get(story.para_at(pos)).is_some_and(|r| r.start == pos && r.end == pos) && story.text.as_bytes().get(pos) == Some(&b'\n') } +/// The Gradient Swatch tool on selected text: its drag (spread space) as the characters' gradient+/// vector in the text space of the frame the text is in.+fn text_gradient(s: &mut Session, p: &Value) -> Result<Value> {+ const ID: &str = "type.gradient";+ if p.get("reset").and_then(Value::as_bool) == Some(true) {+ // An explicit "no vector" on the selected characters, so a vector their character style+ // sets doesn't come back; at a caret, the next typing drops its own.+ let targets = format_targets(s);+ if targets.iter().any(|t| !t.range.is_empty()) {+ let a = CharAttrs { fill_gradient: Some(None), ..Default::default() };+ return s.edit(|d, _| {+ apply_char_attrs(d, &targets, &a, &[]);+ ok()+ });+ }+ return format_chars(s, &json!({"fillGradient": null}));+ }+ let (Some(from), Some(to)) = (super::point_param(p, "from"), super::point_param(p, "to")) else {+ return Err(bad(ID, "give `from` and `to`, or `reset`"));+ };+ let st = s.doc()?;+ let d = &st.doc;+ // The selected text's frame, else the first selected text frame (its whole story).+ let frame = match st.selection.text {+ Some(t) => {+ t.frame.or_else(|| d.story(t.story).and_then(|story| story.frames.first().copied())).ok_or_else(|| bad(ID, "the text has no frame"))?+ }+ None => {+ st.selection.items.iter().copied().find(|i| d.item(*i).is_some_and(|it| it.is_text_frame())).ok_or_else(|| bad(ID, "no text selected"))?+ }+ };+ let loc = d.find(frame).ok_or_else(|| bad(ID, "no such frame"))?;+ let it = d.item(frame).ok_or_else(|| bad(ID, "no such frame"))?;+ let to_text = d.parent_xf(&loc) * d.text_xf(it);+ if to_text.determinant().abs() < 1e-12 {+ return Err(bad(ID, "the frame is flattened"));+ }+ let inv = to_text.inverse();+ let (a, b) = (inv * from, inv * to);+ if ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite()) || (b - a).hypot() < 1e-6 {+ return Err(bad(ID, "the gradient needs two different points"));+ }+ format_chars(s, &json!({"fillGradient": [a.x, a.y, b.x, b.y]}))+}+ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> { let mut a = CharAttrs::default(); if let Some(o) = attrs.as_object() {@@ -179,3 +179,71 @@ fn integration_color_swap_validates_new_fill_before_any_edit() { assert_eq!(s.doc().unwrap().history.undo.len(), before.history.undo.len()); assert_eq!(s.journal, journal); }++/// The Gradient Swatch tool on selected text sets the characters' gradient vector, in the text+/// space of their frame, and leaves the frame's fill alone; reset lays it across the frame again.+#[test]+fn a_gradient_dragged_across_selected_text_sets_the_texts_vector() {+ let (mut s, sid) = setup("hello world");+ let frame = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().frames[0];+ // The frame moved: the vector is in its text space, not the spread's.+ s.execute("transform.move", &json!({"ids": [frame.0], "dx": 100, "dy": 50})).unwrap();+ s.execute("text.select", &json!({"story":sid,"anchor":0,"focus":5})).unwrap();+ let frame_fill = s.doc().unwrap().doc.item(frame).unwrap().fill.clone();+ s.execute("type.gradient", &json!({"from": [146, 100], "to": [246, 100]})).unwrap();+ let a = attrs(&mut s);+ let v: Vec<f64> = a["fillGradient"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();+ let d = &s.doc().unwrap().doc;+ let it = d.item(frame).unwrap();+ let to_text = (d.parent_xf(&d.find(frame).unwrap()) * d.text_xf(it)).inverse();+ let (a0, b0) = (to_text * designcraft_geom::Point::new(146.0, 100.0), to_text * designcraft_geom::Point::new(246.0, 100.0));+ assert!((v[0] - a0.x).abs() < 1e-6 && (v[2] - b0.x).abs() < 1e-6 && (v[1] - a0.y).abs() < 1e-6, "{v:?}");+ assert_eq!(d.item(frame).unwrap().fill, frame_fill, "the frame's fill is untouched");+ // Only the selected characters.+ s.execute("text.select", &json!({"story":sid,"anchor":7,"focus":9})).unwrap();+ assert!(attrs(&mut s)["fillGradient"].is_null());+ // Reset, and refusals.+ s.execute("text.select", &json!({"story":sid,"anchor":0,"focus":5})).unwrap();+ s.execute("type.gradient", &json!({"reset": true})).unwrap();+ assert!(attrs(&mut s)["fillGradient"].is_null());+ assert!(s.execute("type.gradient", &json!({"from": [1, 1], "to": [1, 1]})).is_err());+ assert!(s.execute("type.gradient", &json!({})).is_err());+}++/// Switching from the Type tool to the Gradient Swatch tool keeps the text selected (any other+/// tool selects its frame instead); with the frame selected, `type.gradient` sets the vector on its+/// whole text.+#[test]+fn the_gradient_swatch_tool_keeps_the_text_and_takes_a_whole_frame() {+ let (mut s, sid) = setup("hello world");+ let frame = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().frames[0];+ s.execute("tool.select", &json!({"tool": "type"})).unwrap();+ s.execute("text.select", &json!({"story":sid,"anchor":0,"focus":5})).unwrap();+ s.execute("tool.select", &json!({"tool": "gradientSwatch"})).unwrap();+ assert!(s.doc().unwrap().selection.text.is_some(), "the text stays selected");+ s.execute("tool.select", &json!({"tool": "selection"})).unwrap();+ assert!(s.doc().unwrap().selection.text.is_none());+ assert_eq!(s.doc().unwrap().selection.items, vec![frame], "the frame instead");+ // The frame selected: the whole story.+ s.execute("type.gradient", &json!({"from": [0, 0], "to": [100, 0]})).unwrap();+ s.execute("text.select", &json!({"story":sid,"anchor":6,"focus":11})).unwrap();+ assert!(attrs(&mut s)["fillGradient"].is_array(), "the last word too");+}++/// Reset lays the gradient across the frame again even when the character style sets a vector:+/// the characters get an explicit "no vector", which also survives saving.+#[test]+fn reset_overrides_a_character_styles_gradient_vector() {+ let (mut s, sid) = setup("hello world");+ s.execute("style.character.create", &json!({"name": "Swept", "chars": {"fillGradient": [0, 0, 50, 0]}})).unwrap();+ s.execute("text.select", &json!({"story":sid,"anchor":0,"focus":5})).unwrap();+ s.execute("style.character.apply", &json!({"name": "Swept"})).unwrap();+ assert!(attrs(&mut s)["fillGradient"].is_array(), "the style's vector");+ s.execute("type.gradient", &json!({"reset": true})).unwrap();+ assert!(attrs(&mut s)["fillGradient"].is_null(), "reset wins over the style");+ let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap();+ let over = story.format_after(0).over.clone();+ assert_eq!(over.fill_gradient, Some(None));+ let back: designcraft_doc::CharAttrs = serde_json::from_value(serde_json::to_value(&over).unwrap()).unwrap();+ assert_eq!(back.fill_gradient, Some(None), "kept through saving");+}@@ -39,11 +39,13 @@ pub struct ViewInfo { pub unit: Unit, /// The toolbox's Stroke box is in front (X): colour tools apply to strokes. pub stroke_in_front: bool,+ /// Formatting Affects Text (J): colour tools apply to the text of selected text frames.+ pub affects_text: bool, } impl ViewInfo { pub const fn at_zoom(zoom: f64) -> Self {- Self { zoom, snap: SnapView::FACTORY, unit: Unit::Points, stroke_in_front: false }+ Self { zoom, snap: SnapView::FACTORY, unit: Unit::Points, stroke_in_front: false, affects_text: false } } } @@ -75,8 +77,9 @@ impl Session { if id != "placeGun" { self.loaded = None; }- // Leaving the Type tool keeps the text frame selected.- if id != "type"+ // Leaving the Type tool keeps the text frame selected; the Gradient Swatch tool keeps the+ // text itself, as its drag positions the text's gradient.+ if !matches!(id, "type" | "gradientSwatch") && let Some(st) = self.active_mut() && let Some(t) = st.selection.text.take() && let Some(f) = t.frame.or_else(|| st.doc.story(t.story).and_then(|s| s.frames.first().copied()))@@ -110,6 +113,7 @@ impl Session { snap: view.snap, unit: view.unit, stroke_in_front: view.stroke_in_front,+ affects_text: view.affects_text, }; Some(f(self, &cx)) }@@ -1187,6 +1187,11 @@ impl<'a> Ex<'a> { if let Some(v) = a.fill_tint { el.set("FillTint", pct(v as f64)); }+ if let Some(Some([x0, y0, x1, y1])) = a.fill_gradient {+ el.set("GradientFillStart", format!("{} {}", num(x0), num(y0)));+ el.set("GradientFillLength", num((x1 - x0).hypot(y1 - y0)));+ el.set("GradientFillAngle", num((-(y1 - y0)).atan2(x1 - x0).to_degrees()));+ } if let Some(v) = &a.stroke { el.set("StrokeColor", self.sw(v)); }@@ -1656,6 +1656,17 @@ impl<'r> Importer<'r> { a.fill = Some(self.swatch_ref(r.trim())); } a.fill_tint = tint(e.num("FillTint"));+ // The Gradient Swatch tool on text: start, length and angle (text space).+ let start: Option<Vec<f64>> = e.prop("GradientFillStart").map(|s| nums(&s));+ if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientFillLength"))+ && len > 0.0+ && x.is_finite()+ && y.is_finite()+ && len.is_finite()+ {+ let angle = e.num("GradientFillAngle").filter(|v| v.is_finite()).unwrap_or(0.0).to_radians();+ a.fill_gradient = Some(Some([x, y, x + len * angle.cos(), y - len * angle.sin()]));+ } if let Some(r) = e.prop("StrokeColor") { a.stroke = Some(self.swatch_ref(r.trim())); }@@ -1649,6 +1649,31 @@ fn text_stroke_alignment_imports_as_stroke_behind_fill_and_round_trips() { assert_eq!(flags(&back), want); } +/// A gradient positioned on text with the Gradient Swatch tool (start, length, angle on the+/// character range) imports as the characters' gradient vector and round-trips.+#[test]+fn a_text_gradient_vector_imports_and_round_trips() {+ let story = r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1">+ <ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/[No paragraph style]">+ <CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]" GradientFillStart="10 20" GradientFillLength="100" GradientFillAngle="0"><Content>Ramp</Content></CharacterStyleRange>+ <CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>Plain</Content></CharacterStyleRange>+ </ParagraphStyleRange></Story></idPkg:Story>"#;+ let vectors = |d: &Document| -> Vec<(String, Option<Option<[f64; 4]>>)> {+ let st = d.stories.values().find(|s| s.text.contains("Ramp")).unwrap();+ st.runs().map(|(r, f)| (st.text[r].to_string(), f.over.fill_gradient)).filter(|(t, _)| !t.trim().is_empty()).collect()+ };+ let doc = import_idml(&fixture_with_story(story)).unwrap();+ let got = vectors(&doc);+ let v = got[0].1.flatten().unwrap();+ assert!((v[0] - 10.0).abs() < 1e-9 && (v[1] - 20.0).abs() < 1e-9 && (v[2] - 110.0).abs() < 1e-9 && (v[3] - 20.0).abs() < 1e-9, "{v:?}");+ assert_eq!(got[1].1, None);+ let back = import_idml(&export_idml(&doc)).unwrap();+ let again = vectors(&back);+ let w = again[0].1.flatten().unwrap();+ assert!(v.iter().zip(w).all(|(a, b)| (a - b).abs() < 1e-6), "{v:?} → {w:?}");+ assert_eq!(again[1].1, None);+}+ #[test] fn warichu_imports_from_idml_and_round_trips() { let story = r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1">@@ -33,10 +33,11 @@ use crate::export::{Exporter, solid_fill, tf, to_path}; impl Exporter<'_> { /// A text run's paint: solid, or a gradient spanning the frame's box (`area`, text space), /// for glyphs drawn under `origin` (text space ← run space). `overprint`: the run's- /// overprint attribute for this paint.- fn text_paint(&mut self, swatch: &str, tint: f32, overprint: bool, origin: Affine, area: Rect) -> Option<Paint> {+ /// overprint attribute for this paint. `vector`: the Gradient Swatch tool's start and end+ /// (text space), else the gradient spans `area`.+ fn text_paint(&mut self, swatch: &str, tint: f32, overprint: bool, origin: Affine, area: Rect, vector: Option<[f64; 4]>) -> Option<Paint> { if designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch).is_some() && origin.determinant().abs() > 1e-12 {- let fill = designcraft_doc::Fill { swatch: swatch.into(), tint, overprint, ..designcraft_doc::Fill::none() };+ let fill = designcraft_doc::Fill { swatch: swatch.into(), tint, overprint, gradient_vector: vector, ..designcraft_doc::Fill::none() }; return self.fill_paint_in(&fill, self.text_area.unwrap_or(area), origin.inverse()); } self.swatch_color(swatch, tint).map(Into::into)@@ -491,9 +492,9 @@ impl Exporter<'_> { // Outside a frame (no text area), a gradient spans the run. let end = glyphs.last().map_or(g0.x, |g| g.x + g.adv); let run_area = Rect::new(g0.x, baseline - size, end.max(g0.x + 1.0), baseline + size * 0.25);- let fill = self.text_paint(&st.fill, st.fill_tint, st.overprint_fill, origin, run_area);+ let fill = self.text_paint(&st.fill, st.fill_tint, st.overprint_fill, origin, run_area, st.fill_gradient); let stroke = if st.stroke != designcraft_color::swatch::NONE && st.stroke_weight > 0.0 {- self.text_paint(&st.stroke, st.stroke_tint, st.overprint_stroke, origin, run_area)+ self.text_paint(&st.stroke, st.stroke_tint, st.overprint_stroke, origin, run_area, None) } else { None };@@ -28,9 +28,15 @@ enum TextPaint { } impl TextPaint {- fn of(doc: &designcraft_doc::Document, swatch: &str, tint: f32) -> Option<Self> {+ /// `vector`: where a gradient starts and ends (text space), else across the frame's box.+ fn of(doc: &designcraft_doc::Document, swatch: &str, tint: f32, vector: Option<[f64; 4]>) -> Option<Self> { if designcraft_color::swatch::resolve_gradient(&doc.swatches, swatch).is_some() {- return Some(TextPaint::Gradient(designcraft_doc::Fill { swatch: swatch.into(), tint, ..designcraft_doc::Fill::none() }));+ return Some(TextPaint::Gradient(designcraft_doc::Fill {+ swatch: swatch.into(),+ tint,+ gradient_vector: vector,+ ..designcraft_doc::Fill::none()+ })); } doc.resolve_color(swatch, tint).map(|c| TextPaint::Solid(color_of(&c, 1.0))) }@@ -414,7 +420,7 @@ impl Renderer { fills[si as usize] .get_or_insert_with(|| { let st = &cs.styles[si as usize];- TextPaint::of(doc, &st.fill, st.fill_tint)+ TextPaint::of(doc, &st.fill, st.fill_tint, st.fill_gradient) }) .clone() };@@ -527,7 +533,7 @@ fn stroke_of(doc: &designcraft_doc::Document, st: &designcraft_compose::RunStyle if st.stroke == designcraft_color::swatch::NONE { return None; }- TextPaint::of(doc, &st.stroke, st.stroke_tint)+ TextPaint::of(doc, &st.stroke, st.stroke_tint, None) } /// Run `paint` in a Multiply layer when `overprint` is set.@@ -634,7 +640,7 @@ impl Renderer { let area = path.bounding_box(); for (si, bp) in designcraft_compose::path_glyphs(cs, ft, path, pt) { let st = &cs.styles[si as usize];- let fill = TextPaint::of(doc, &st.fill, st.fill_tint).map(|c| (c, false));+ let fill = TextPaint::of(doc, &st.fill, st.fill_tint, st.fill_gradient).map(|c| (c, false)); paint_glyphs(ctx, doc, area, &bp, fill, stroke_of(doc, st).map(|c| (c, false)), st.stroke_weight, st.stroke_behind_fill); self.stats.glyphs += 1; }@@ -11,7 +11,8 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool pub struct GradientTool { /// Gradient Feather tool (Shift+G): the drag sets the fade instead of the fill gradient. pub feather: bool,- /// Canvas start point, spread-space start point and the target ids, and the spread.+ /// Canvas start point, spread-space start point and the target ids (none: the selected+ /// text), and the spread. drag: Option<(Point, Point, Vec<u64>, SpreadRef)>, end: Option<Point>, }@@ -46,7 +47,15 @@ impl Tool for GradientTool { cx.selection.items.iter().map(|i| i.0).collect() }; let Some((spread, sp)) = cx.layout.spread_at(ev.pos) else { return vec![] };- if ids.is_empty() {+ // Selected text, or with Formatting Affects Text the text of the selected text+ // frames: its own gradient (a fill gradient; feathering is the frame's).+ let text = !self.feather+ && (cx.selection.text.is_some()+ || (cx.affects_text+ && !cx.selection.items.is_empty()+ && cx.selection.items.iter().all(|i| cx.doc.item(*i).is_some_and(|it| it.is_text_frame()))));+ let ids = if text { Vec::new() } else { ids };+ if ids.is_empty() && !text { return vec![]; } self.drag = Some((ev.pos, sp, ids, spread));@@ -61,6 +70,9 @@ impl Tool for GradientTool { return vec![]; } let to = *sp + cx.layout.delta_to_spread(*spread, end - *start);+ if ids.is_empty() {+ return vec![Action::Preview("type.gradient".into(), json!({"from": [sp.x, sp.y], "to": [to.x, to.y]}))];+ } let cmd = if self.feather { "object.gradientFeather" } else { "object.gradient" }; vec![Action::Preview(cmd.into(), json!({"ids": ids, "from": [sp.x, sp.y], "to": [to.x, to.y]}))] }@@ -232,6 +232,8 @@ pub struct ToolContext<'a> { pub unit: Unit, /// The toolbox's Stroke box is in front: sampled colours go to strokes (the Eyedropper). pub stroke_in_front: bool,+ /// Formatting Affects Text (J): colour tools act on the text of selected text frames.+ pub affects_text: bool, } impl ToolContext<'_> {@@ -1435,6 +1435,7 @@ mod tests { snap: SnapView::FACTORY, unit: Unit::Points, stroke_in_front: false,+ affects_text: false, } } @@ -19,6 +19,7 @@ fn ctx<'a>(d: &'a Document, s: &'a Selection, c: &'a Cache, l: &'a CanvasLayout) snap: SnapView::OFF, unit: Unit::Points, stroke_in_front: false,+ affects_text: false, } } @@ -194,6 +195,54 @@ fn gradient_tool_drag_sets_the_vector() { assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 220.0 + off.x, 153.0 + off.y)), vec![Action::Commit]); } +/// With text selected, the Gradient Swatch tool sets where the text's gradient runs+/// (`type.gradient`), not the frame's fill.+#[test]+fn gradient_tool_on_selected_text_sets_the_texts_gradient() {+ let mut d = Document::new(&NewDocument::default());+ let lid = d.default_layer();+ let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 300.0, 200.0), lid, "Hello", ParaFormat::default()).unwrap();+ let s = Selection {+ items: vec![fid],+ text: Some(designcraft_doc::TextSel { story: sid, anchor: 0, focus: 5, frame: Some(fid), cell: None }),+ ..Default::default()+ };+ let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));+ let cx = ctx(&d, &s, &c, &l);+ let off = l.xf(SpreadRef::Doc(0)).translation();+ let mut t = create("gradientSwatch");+ assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 150.0 + off.y)), vec![Action::Begin("Gradient".into())]);+ match &t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 220.0 + off.x, 150.0 + off.y))[0] {+ Action::Preview(cmd, p) => {+ assert_eq!(cmd, "type.gradient");+ assert_eq!((p["from"].clone(), p["to"].clone()), (serde_json::json!([120.0, 150.0]), serde_json::json!([220.0, 150.0])));+ assert!(p.get("ids").is_none());+ }+ other => panic!("{other:?}"),+ }+}++/// With Formatting Affects Text on and a text frame selected, the Gradient Swatch tool positions+/// the text's gradient; off, the frame's.+#[test]+fn gradient_tool_with_formatting_affects_text_sets_the_texts_gradient() {+ let mut d = Document::new(&NewDocument::default());+ let lid = d.default_layer();+ let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 300.0, 200.0), lid, "Hello", ParaFormat::default()).unwrap();+ let s = Selection { items: vec![fid], ..Default::default() };+ let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));+ let off = l.xf(SpreadRef::Doc(0)).translation();+ for (affects_text, want) in [(true, "type.gradient"), (false, "object.gradient")] {+ let cx = ToolContext { affects_text, ..ctx(&d, &s, &c, &l) };+ let mut t = create("gradientSwatch");+ t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 150.0 + off.y));+ match &t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 220.0 + off.x, 150.0 + off.y))[0] {+ Action::Preview(cmd, _) => assert_eq!(cmd, want),+ other => panic!("{other:?}"),+ }+ }+}+ #[test] fn gradient_tool_drag_follows_a_rotated_spread() { let mut d = Document::new(&NewDocument::default());@@ -3659,6 +3659,7 @@ fn guide_spot(app: &DesignApp, xf: &Xf, g: &GuideDrag) -> Option<GuideSpot> { snap: app.ui.snap_view(), unit: app.ui.units, stroke_in_front: false,+ affects_text: false, }; let s = designcraft_tools::snap::snap( &cx,@@ -3923,6 +3924,7 @@ fn over_item(app: &DesignApp, xf: &Xf, p: Pos2) -> bool { snap: app.ui.snap_view(), unit: app.ui.units, stroke_in_front: false,+ affects_text: false, }; cx.hit(xf.to_canvas(p)).is_some() }@@ -4031,6 +4033,7 @@ fn snap_gutter(app: &DesignApp, g: &ColumnDrag, want: f64, shift: bool, zoom: f6 snap: designcraft_tools::SnapView { smart_dimensions: false, smart_spacing: false, ..app.ui.snap_view() }, unit: app.ui.units, stroke_in_front: false,+ affects_text: false, }; let rect = DRect::new(want, m.y0, want + width, m.y1); let s = designcraft_tools::snap::snap(@@ -5940,6 +5943,7 @@ mod tests { snap: SnapView { snap_to_document_grid: true, zone_px: 20.0, ..SnapView::OFF }, unit: Unit::Points, stroke_in_front: false,+ affects_text: false, }; let (snapped, _) = designcraft_tools::snap::snap_point(&cx, sr, Point::new(page.x0 + 31.3, page.y0 + 47.9)); assert_eq!((snapped.x - page.x0, snapped.y - page.y0), (27.0, 45.0));@@ -1005,6 +1005,7 @@ impl DesignApp { // as its Control and Properties fields do. unit: self.session.active().map_or(self.ui.units, |st| st.doc.settings.horizontal_units), stroke_in_front: self.ui.stroke_front,+ affects_text: self.ui.formatting_affects_text, } }