collective

Browse / github.com/storytold/designcraft / patch

Text: the Gradient panel and Apply Gradient colour selected text; the type Control panel shows the text's Fill and Stroke

Text can't take a gradient from the Gradient panel or Apply Gradient, and the type Control panel has no Fill or Stroke **What happens:** With text selected, gradients only reach the text through a gradient swatch that already exists. - **Gradient panel:** it always edits the frame's gradient, never the text's. - **Apply Gradient (`.`):** with text selected it stops with "no gradient to apply yet". - **New documents:** they have no gradient swatch, so there is no way to give text a gradient at all. The type Control panel (Character Formatting Controls) also has no Fill or Stroke for the text, and the Character section of Properties shows the text's colour but not its stroke. **Expected:** With text selected,

Status
open
Score
0.00
Adopters
0 other operators
Author
PiMinusMax (imported from pull request #1888, open)
Base
ed8aabd1dc5a88652a076631c2d46886ca60c031
Size
+198 −39 in 8 files
Review
pending
Shared
2026-10-28
Id
56f882d747a1f1c59ec31eb9

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/engine/src/cmd/style.rs crates/engine/src/cmd/text.rs crates/ui-egui/src/chrome.rs crates/ui-egui/src/i18n/de.rs crates/ui-egui/src/panels/properties.rs crates/ui-egui/src/panels/swatches.rs crates/ui-egui/src/toolbar.rs

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -12,6 +12,10 @@  DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. +- **2026-10-26** · Text takes a gradient like an object: with text selected (or Formatting+  Affects Text), the Gradient panel and Apply Gradient edit the characters' gradient, not the+  frame's. The type Control panel shows the text's Fill and Stroke, and the Character section of+  Properties its Stroke too. No readiness percentage change. - **2026-10-25** · The application bar uses craft-window's shared title-bar code. A drag that   starts on a floating panel over the bar moves the panel, not the window, and in full screen the   bar no longer zooms or moves the window. No readiness percentage change.diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs--- a/crates/engine/src/cmd/style.rs+++ b/crates/engine/src/cmd/style.rs@@ -411,8 +411,8 @@ pub fn specs() -> Vec<CommandSpec> {             "Gradient",             [],             None,-            "{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255)|{model: rgb|cmyk|gray, …}(0..1) | swatch: a color swatch's name (as dropped on the Gradient panel: its color on screen, its inks in print), opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), target?: fill|stroke, ids?} → {swatch, kind, stops: [{location, color, swatch?, opacity, midpoint}]} — edits the fill's (or the stroke's) gradient (an unnamed gradient unless it equals a gradient swatch)",-            super::has_selection,+            "{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255)|{model: rgb|cmyk|gray, …}(0..1) | swatch: a color swatch's name (as dropped on the Gradient panel: its color on screen, its inks in print), opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), target?: fill|stroke, text?: bool, ids?} → {swatch, kind, stops: [{location, color, swatch?, opacity, midpoint}]} — edits the fill's (or the stroke's) gradient (an unnamed gradient unless it equals a gradient swatch); on selected text, or with `text` on the text of the selected frames, the characters' gradient",+            has_selection_or_text,             apply_gradient         ),         cmd!(@@ -1779,41 +1779,22 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result<Value> {         Some(t) => return Err(bad("object.gradient", format!("unknown target {t}"))),     };     let (from, to) = (super::point_param(p, "from"), super::point_param(p, "to"));+    let edit = GradientEdit { kind, stops, reverse };+    // Selected text (or a text frame with `text`): the gradient goes on the characters, as a+    // colour does with `object.color`.+    let named = p.get("ids").is_some() || p.get("id").is_some();+    let text = !named+        && (p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some() || s.doc()?.selection.cells.is_some());+    if text {+        return text_gradient(s, &edit, stroke);+    }     let ids = super::targets(s, p)?;     s.edit(|d, _| {         let mut out = Value::Null;         for id in &ids {             let Some(it) = d.item(*id) else { continue };             let cur_name = if stroke { it.stroke.swatch.clone() } else { it.fill.swatch.clone() };-            let cur = designcraft_color::swatch::resolve_gradient(&d.swatches, &cur_name).cloned();-            let mut g = cur.clone().unwrap_or_default();-            if let Some(k) = kind {-                g.kind = k;-            }-            if let Some(st) = &stops {-                g.stops = st.clone();-                g.sort();-            }-            if reverse {-                g.reverse();-            }-            // Keep the swatch while the gradient is unchanged, else reuse an equal gradient swatch-            // or add an unnamed one.-            let name = if cur.as_ref() == Some(&g) {-                cur_name-            } else if let Some(w) = d.swatches.iter().find(|w| matches!(&w.value, SwatchValue::Gradient { gradient } if *gradient == g)) {-                w.name.clone()-            } else {-                let name = Styles::unique_name(|n| d.swatch(n).is_some(), "Gradient");-                d.swatches.push(Swatch {-                    name: name.clone(),-                    value: SwatchValue::Gradient { gradient: g.clone() },-                    locked: false,-                    named: false,-                    hidden: true,-                });-                name-            };+            let (name, g) = edit.apply(d, cur_name);             let inv = d.find(*id).map(|l| d.parent_xf(&l)).zip(d.item(*id).map(|i| i.xf)).map(|(pxf, xf)| (pxf * xf).inverse());             let Some(it) = d.item_mut(*id) else { continue };             // The fill's or the stroke's paint: swatch, tint, angle, vector.@@ -1842,6 +1823,87 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result<Value> {     }) } +/// Objects selected, or text (a text selection or table cells).+fn has_selection_or_text(s: &Session) -> std::result::Result<(), String> {+    if s.active().is_some_and(|d| d.selection.text.is_some() || d.selection.cells.is_some()) { has_doc(s) } else { super::has_selection(s) }+}++/// What `object.gradient` changes in a gradient: its kind, its stops, its direction.+struct GradientEdit {+    kind: Option<designcraft_color::GradientKind>,+    stops: Option<Vec<designcraft_color::GradientStop>>,+    reverse: bool,+}++impl GradientEdit {+    /// The gradient of the paint `cur_name` (a new one when it is no gradient) with the edit made,+    /// and the swatch that holds it: `cur_name` while the gradient is unchanged, else an equal+    /// gradient swatch, else a new unnamed one.+    fn apply(&self, d: &mut designcraft_doc::Document, cur_name: String) -> (String, designcraft_color::Gradient) {+        let cur = designcraft_color::swatch::resolve_gradient(&d.swatches, &cur_name).cloned();+        let mut g = cur.clone().unwrap_or_default();+        if let Some(k) = self.kind {+            g.kind = k;+        }+        if let Some(st) = &self.stops {+            g.stops = st.clone();+            g.sort();+        }+        if self.reverse {+            g.reverse();+        }+        let name = if cur.as_ref() == Some(&g) {+            cur_name+        } else if let Some(w) = d.swatches.iter().find(|w| matches!(&w.value, SwatchValue::Gradient { gradient } if *gradient == g)) {+            w.name.clone()+        } else {+            let name = Styles::unique_name(|n| d.swatch(n).is_some(), "Gradient");+            d.swatches.push(Swatch {+                name: name.clone(),+                value: SwatchValue::Gradient { gradient: g.clone() },+                locked: false,+                named: false,+                hidden: true,+            });+            name+        };+        (name, g)+    }+}++/// `object.gradient` on the selected text: the gradient of the first selected character's fill+/// (or stroke), edited, on every selected character; with a caret, on the next typing.+fn text_gradient(s: &mut Session, edit: &GradientEdit, stroke: bool) -> Result<Value> {+    let texts = super::text::format_targets_pub(s);+    let Some(first) = texts.first() else { return Err(bad("object.gradient", "select the text to colour")) };+    let cur_name = {+        let st = s.doc()?;+        let story = st.doc.text_story(first.story, first.cell).ok_or(designcraft_doc::DocError::NoStory(first.story))?;+        let pf = story.paras.get(story.para_at(first.range.start)).ok_or_else(|| bad("object.gradient", "no paragraph at the selection"))?;+        let (_, base) = st.doc.styles.resolve_para(pf);+        let cp = st.doc.styles.resolve_char(&base, &super::text::effective_char_format(s, first)?);+        if stroke { cp.stroke } else { cp.fill }+    };+    let caret = matches!(texts.as_slice(), [t] if t.range.is_empty());+    let r = s.edit(|d, _| {+        let (name, g) = edit.apply(d, cur_name);+        if !caret {+            let a = if stroke {+                CharAttrs { stroke: Some(name.clone()), stroke_tint: Some(1.0), ..Default::default() }+            } else {+                CharAttrs { fill: Some(name.clone()), fill_tint: Some(1.0), ..Default::default() }+            };+            super::text::apply_char_attrs(d, &texts, &a, &[]);+        }+        Ok(gradient_json(&name, &g))+    })?;+    if caret && let Some(name) = r.get("swatch").cloned() {+        let attrs = if stroke { json!({"stroke": name, "strokeTint": 1.0}) } else { json!({"fill": name, "fillTint": 1.0}) };+        super::text::format_chars(s, &attrs)?;+    }+    Ok(r)+}+ fn apply_color(s: &mut Session, p: &Value) -> Result<Value> {     let color = p.get("color").and_then(parse_color).ok_or_else(|| bad("object.color", "missing or bad color"))?;     let stroke = str_param(p, "target") == Some("stroke");diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs--- a/crates/engine/src/cmd/text.rs+++ b/crates/engine/src/cmd/text.rs@@ -2947,6 +2947,40 @@ mod text_color_tests {         assert_eq!(story.format_after(0).over.stroke.as_deref(), r["swatch"].as_str());         assert_eq!(s.doc().unwrap().history.undo.len(), 3, "the frame, then one undo step per colour");     }++    /// `object.gradient` on selected text gives the selected characters a gradient fill (the frame+    /// keeps its own); a later edit starts from the text's gradient.+    #[test]+    fn a_gradient_goes_to_the_selected_text() {+        let mut s = Session::new();+        s.execute("file.new", &json!({})).unwrap();+        let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "colour me", "caret": false})).unwrap();+        let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());+        let frame_fill = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(fid)).unwrap().fill.swatch.clone();+        let before = frame_fill(&s);+        s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 6})).unwrap();+        let stops = json!([{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]);+        let r = s.execute("object.gradient", &json!({"stops": stops})).unwrap();+        let name = r["swatch"].as_str().unwrap().to_string();+        let d = &s.doc().unwrap().doc;+        assert!(designcraft_color::swatch::resolve_gradient(&d.swatches, &name).is_some(), "{name} is a gradient");+        let story = d.story(designcraft_doc::StoryId(sid)).unwrap();+        assert_eq!(story.format_after(0).over.fill.as_deref(), Some(name.as_str()), "the selected characters");+        assert_ne!(story.format_after(7).over.fill.as_deref(), Some(name.as_str()), "not the rest");+        assert_eq!(frame_fill(&s), before, "the frame keeps its fill");+        // Radial from the panel: the same stops, now radial, still on the text.+        let r = s.execute("object.gradient", &json!({"kind": "radial"})).unwrap();+        assert_eq!(r["kind"], "radial");+        assert_eq!(r["stops"].as_array().unwrap().len(), 2, "the text's stops are kept");+        let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap();+        assert_eq!(story.format_after(0).over.fill.as_deref(), r["swatch"].as_str());+        // A frame selected with `text`: the whole story.+        s.execute("selection.set", &json!({"ids": [fid]})).unwrap();+        let r = s.execute("object.gradient", &json!({"stops": stops, "target": "stroke", "text": true})).unwrap();+        let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap();+        assert_eq!(story.format_after(8).over.stroke.as_deref(), r["swatch"].as_str());+        assert_eq!(frame_fill(&s), before);+    } }  #[cfg(test)]diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs--- a/crates/ui-egui/src/chrome.rs+++ b/crates/ui-egui/src/chrome.rs@@ -670,6 +670,27 @@ fn control_character(     para: &serde_json::Value,     tr: &dyn Fn(&str) -> String, ) {+    // The text's Fill and Stroke, as the Control panel shows them for type: any swatch, a gradient+    // swatch too (its direction: the Gradient Swatch tool).+    rows(ui, |ui, row| {+        let stroke = row == 1;+        let key = if stroke { "stroke" } else { "fill" };+        let resp = panels::swatch_picker(+            app,+            ui,+            if stroke { "ctl_text_stroke" } else { "ctl_text_fill" },+            chars[key].as_str().map(str::to_string),+            |app, name| {+                let _ = app.run("type.char", json!({"attrs": {key: name}}));+            },+        );+        if let Some(resp) = resp {+            let label = tr(if stroke { "Text Stroke" } else { "Text Fill" });+            resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label));+            resp.on_hover_text(label);+        }+    });+    vsep(ui);     ui.vertical(|ui| {         let fam = chars["fontFamily"].as_str().unwrap_or("").to_string();         let sty = chars["fontStyle"].as_str().unwrap_or("").to_string();diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs--- a/crates/ui-egui/src/i18n/de.rs+++ b/crates/ui-egui/src/i18n/de.rs@@ -1709,6 +1709,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[     ("A, B, C, D...", "A, B, C, D..."),     ("Alpha Channel", "Alphakanal"),     ("Black", "Schwarz"),+    ("Text Fill", "Textfläche"),+    ("Text Stroke", "Textkontur"),+    ("Stroke:", "Kontur:"),     // Document Setup     ("Page Size:", "Seitenformat:"),     ("Inside:", "Innen:"),diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs--- a/crates/ui-egui/src/panels/properties.rs+++ b/crates/ui-egui/src/panels/properties.rs@@ -1821,6 +1821,13 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) {         });         crate::rtl::label(ui, c["fill"].as_str().unwrap_or(""));     });+    ui.horizontal(|ui| {+        caption(ui, crate::i18n::tr(&app.ui.language, "Stroke:"));+        super::swatch_picker(app, ui, "tstroke", c["stroke"].as_str().map(str::to_string), |app, n| {+            let _ = app.run("type.char", json!({"attrs": {"stroke": n}}));+        });+        crate::rtl::label(ui, c["stroke"].as_str().unwrap_or(""));+    });     ui.horizontal(|ui| {         // Bold and Italic pick the family's own styles (style linking).         let (weight, italic) = super::fonts(app).traits_of(c["fontFamily"].as_str().unwrap_or_default(), c["fontStyle"].as_str().unwrap_or_default());diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs--- a/crates/ui-egui/src/panels/swatches.rs+++ b/crates/ui-egui/src/panels/swatches.rs@@ -694,12 +694,18 @@ fn set_stop(stops: &mut [serde_json::Value], i: usize, key: &str, v: serde_json: pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) {     let t = Tokens::get(ui.ctx());     let lang = app.ui.language.clone();+    // Selected text (or Formatting Affects Text): the gradient of the text's fill or stroke.+    let text = super::colors_affect_text(app);+    let stroke_front = app.ui.stroke_front;+    let text_paint = if text { super::color_target(app).map(|(fill, _, stroke, _)| if stroke_front { stroke } else { fill }) } else { None };     let Some(st) = app.session.active() else { return };     let item = st.selection.items.first().and_then(|i| st.doc.item(*i));-    let has_item = item.is_some();+    let has_item = item.is_some() || text_paint.is_some();     // The fill's gradient, or the stroke's when the toolbox's Stroke box is in front.-    let stroke_front = app.ui.stroke_front;-    let fill = item.map(|it| if stroke_front { it.stroke.as_fill() } else { it.fill.clone() }).unwrap_or_default();+    let fill = match &text_paint {+        Some(swatch) => designcraft_doc::Fill { swatch: swatch.clone(), ..Default::default() },+        None => item.map(|it| if stroke_front { it.stroke.as_fill() } else { it.fill.clone() }).unwrap_or_default(),+    };     let cur = designcraft_color::swatch::resolve_gradient(&st.doc.swatches, &fill.swatch).cloned();     let is_gradient = cur.is_some();     let g = cur.unwrap_or_default();@@ -772,7 +778,8 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) {                     crate::widgets::caption(ui, crate::i18n::tr(&lang, "Angle:"));                     let angle =                         fill.gradient_vector.map(|[x0, y0, x1, y1]| (-(y1 - y0)).atan2(x1 - x0).to_degrees()).or(fill.gradient_angle).unwrap_or(0.0);-                    ui.add_enabled_ui(is_gradient && !radial, |ui| {+                    // Text takes its direction from the Gradient Swatch tool.+                    ui.add_enabled_ui(is_gradient && !radial && !text, |ui| {                         if let Some(v) = crate::widgets::number(ui, "grad_angle", is_gradient.then_some(angle), "°", 44.0, 1) {                             send = Some(json!({"angle": v}));                         }@@ -993,6 +1000,9 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) {     if let Some(mut p) = send {         if let Some(o) = p.as_object_mut() {             o.insert("target".to_string(), json!(if stroke_front { "stroke" } else { "fill" }));+            if text {+                o.insert("text".to_string(), json!(true));+            }         }         let _ = app.run("object.gradient", p);     }diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs--- a/crates/ui-egui/src/toolbar.rs+++ b/crates/ui-egui/src/toolbar.rs@@ -598,11 +598,10 @@ pub(crate) fn apply_gradient(app: &mut DesignApp) -> Result<Value, String> {     if let Some(sw) = known {         return crate::panels::apply_swatch(app, stroke, &sw, None);     }-    if crate::panels::colors_affect_text(app) {-        return Err("no gradient to apply yet: make one in the Gradient panel".into());-    }+    // A new white-to-black gradient, on the text when colour edits go to the text.+    let text = crate::panels::colors_affect_text(app);     let stops = json!([{"location": 0, "color": "#ffffff"}, {"location": 100, "color": "#000000"}]);-    let r = app.run("object.gradient", json!({"stops": stops, "target": if stroke { "stroke" } else { "fill" }}))?;+    let r = app.run("object.gradient", json!({"stops": stops, "target": if stroke { "stroke" } else { "fill" }, "text": text}))?;     if let Some(sw) = r["swatch"].as_str() {         app.ui.last_gradient = Some(sw.to_string());     }@@ -871,6 +870,25 @@ mod tests {         }     } +    /// Apply Gradient with text selected and no gradient yet: a new white-to-black gradient on the+    /// text, not on its frame.+    #[test]+    fn apply_gradient_with_text_selected_colours_the_text() {+        use serde_json::json;+        let mut app = app(true);+        app.run("file.new", json!({})).unwrap();+        let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "gradient", "caret": false})).unwrap();+        let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());+        app.run("text.select", json!({"story": sid, "anchor": 0, "focus": 8})).unwrap();+        let frame_before = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(fid)).unwrap().fill.swatch.clone();+        app.run("color.applyGradient", json!({})).unwrap();+        let doc = &app.session.active().unwrap().doc;+        let fill = doc.story(designcraft_doc::StoryId(sid)).unwrap().format_after(0).over.fill.clone().expect("the text's fill");+        let grad = designcraft_color::swatch::resolve_gradient(&doc.swatches, &fill).expect("a gradient on the text");+        assert_eq!((grad.stops[0].color.to_rgb(), grad.stops[1].color.to_rgb()), ([1.0; 3], [0.0; 3]));+        assert_eq!(doc.item(designcraft_doc::ItemId(fid)).unwrap().fill.swatch, frame_before, "the frame keeps its fill");+    }+     /// A new default gradient on the stroke in front is one undo step; the fill keeps its swatch and tint.     #[test]     fn apply_gradient_to_the_stroke_is_one_undo_step() {