diff --git a/ROADMAP.md b/ROADMAP.md index a826701c1..379039368 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · The Gradient panel follows InDesign's layout (thumbnail, Type, Location, Angle, Reverse, ramp with midpoint diamonds) and takes swatches dragged from the Swatches panel: dropped on a stop they colour it, elsewhere they add a stop. Stops keep their CMYK through panel edits; `object.gradient` stops accept a `swatch`. No readiness percentage change. - **2026-10-13** · New Swatch works as InDesign's: a gradient made in the Gradient panel becomes "New Gradient Swatch" (still applied), an unnamed colour a listed swatch, a named swatch a "copy" (`swatch.new`); `swatch.create {gradient}` makes a gradient swatch from colour swatches. No readiness percentage change. - **2026-10-13** · Radial gradients stretch with the object's box, as InDesign draws them: by default an ellipse reaching the frame's sides (it was a circle reaching only the longer side), and a Gradient Swatch tool drag keeps the box's proportions; on screen and in PDF. No readiness percentage change. - **2026-10-13** · `.indd` and `.indt` documents open through File › Open, drop, the web inbox, `file.open`, `file.openBytes`, `file.openIdml` and the CLI: [storytold/indd-utils](https://github.com/storytold/indd-utils) converts them to IDML in memory and the IDML importer reads the result, on desktop and web. Converter warnings come first in the open result; an unreadable file is an open error (#21, #114). No readiness percentage change until a real-file corpus measures it. diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 41b285300..5864fe19f 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -3521,6 +3521,38 @@ mod gradient_tests { assert_eq!(fill(&s).gradient_vector, None); assert_eq!(fill(&s).gradient_angle, Some(90.0)); } + + #[test] + fn swatches_dropped_on_stops_keep_their_cmyk() { + use designcraft_color::Color; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [100, 100, 300, 200]})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + // LYH Studio episode 6: magenta on the left stop, cyan dropped at 68.636 %, a grey third. + let r = s + .execute( + "object.gradient", + &json!({"stops": [ + {"location": 0, "swatch": "C=0 M=100 Y=0 K=0"}, + {"location": 68.636, "swatch": "C=100 M=0 Y=0 K=0"}, + {"location": 100, "color": {"gray": 25}} + ]}), + ) + .unwrap(); + let d = &s.doc().unwrap().doc; + let g = designcraft_color::swatch::resolve_gradient(&d.swatches, r["swatch"].as_str().unwrap()).unwrap(); + assert_eq!(d.item(id).unwrap().fill.swatch, r["swatch"].as_str().unwrap()); + assert_eq!(g.stops[0].color, Color::cmyk(0.0, 1.0, 0.0, 0.0)); + assert_eq!(g.stops[1].color, Color::cmyk(1.0, 0.0, 0.0, 0.0)); + assert!((g.stops[1].offset - 0.68636).abs() < 1e-6); + assert_eq!(g.stops[2].color, Color::gray(0.25)); + // Not a colour: refused, nothing changes. + for bad in ["[None]", "Missing", r["swatch"].as_str().unwrap()] { + let p = json!({"stops": [{"location": 0, "swatch": bad}, {"location": 100, "color": "#000000"}]}); + assert!(s.execute("object.gradient", &p).is_err(), "{bad}"); + } + } } #[cfg(test)] diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 81ccd63bf..52d5a028c 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -188,7 +188,7 @@ pub fn specs() -> Vec { "Gradient", [], None, - "{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}|[r,g,b], opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), ids?} → {swatch, kind, stops} — edits the fill's gradient (an unnamed gradient unless it equals a gradient swatch)", + "{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}|{gray}|[r,g,b] | swatch: name (its colour, as dropped on the Gradient panel), opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), ids?} → {swatch, kind, stops} — edits the fill's gradient (an unnamed gradient unless it equals a gradient swatch)", super::has_selection, apply_gradient ), @@ -1006,6 +1006,10 @@ pub fn parse_color(v: &Value) -> Option { return Some(Color::gray(if gray > 1.0 { gray / 100.0 } else { gray } as f32)); } let g = |k: &str| o.get(k).and_then(Value::as_f64); + // {gray: 0–100} is a grey with that much black. + if let Some(k) = g("gray") { + return Some(Color::gray((k.clamp(0.0, 100.0) / 100.0) as f32)); + } let v = [g("c")?, g("m")?, g("y")?, g("k")?]; let scale = if v.iter().any(|&x| x > 1.0) { 100.0 } else { 1.0 }; Some(Color::cmyk((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32, (v[3] / scale) as f32)) @@ -1270,9 +1274,17 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result { }; let stops = match p.get("stops").and_then(Value::as_array) { Some(a) => { + let doc = &s.doc()?.doc; let mut v = Vec::new(); for st in a { - let color = st.get("color").and_then(parse_color).ok_or_else(|| bad("object.gradient", "a stop needs a color"))?; + // A swatch dropped on a stop gives it the swatch's colour (a tint's mixed in). + let color = match st.get("swatch").and_then(Value::as_str) { + Some(name) => doc + .resolve_color(name, 1.0) + .filter(|_| designcraft_color::swatch::resolve_gradient(&doc.swatches, name).is_none()) + .ok_or_else(|| bad("object.gradient", format!("`{name}` isn't a colour swatch")))?, + None => st.get("color").and_then(parse_color).ok_or_else(|| bad("object.gradient", "a stop needs a color or a swatch"))?, + }; let pct = |k: &str, d: f64| st.get(k).and_then(Value::as_f64).unwrap_or(d).clamp(0.0, 100.0) as f32 / 100.0; v.push(GradientStop { offset: pct("location", 0.0), diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 0b12c6c1c..0e73e15fe 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -145,8 +145,20 @@ fn swatch_row( fill_cur: Option<&str>, ) { let t = Tokens::get(ui.ctx()); - let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + // Rows can be dragged onto the Gradient panel's ramp. + let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &sw.name)); + resp.dnd_set_drag_payload(SwatchDrag(sw.name.clone())); + if resp.dragged() + && let Some(p) = ui.ctx().pointer_interact_pos() + { + // The swatch's chip follows the pointer. + let (c, g) = crate::widgets::swatch_colors(doc, &sw.name, 1.0); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("swatch_drag"))); + let chip = egui::Rect::from_min_size(p + vec2(8.0, 8.0), vec2(16.0, 16.0)); + crate::widgets::paint_chip(&painter, chip, c, g); + painter.rect_stroke(chip, 0.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside); + } if fill_cur == Some(sw.name.as_str()) { ui.painter().rect_filled(row, 0.0, t.row_selected); } else if resp.hovered() { @@ -446,187 +458,412 @@ fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { } } +/// A swatch dragged out of the Swatches panel (the Gradient panel's ramp takes it as a stop). +#[derive(Clone, Debug)] +pub struct SwatchDrag(pub String); + +/// A colour in `object.gradient` form, keeping its model (CMYK stays CMYK). +fn color_json(c: &designcraft_color::Color) -> serde_json::Value { + use designcraft_color::Color; + match *c { + // Fractions: every component is within 0–1, so they're read back as fractions. + Color::Cmyk { c, m, y, k } => json!({"c": c, "m": m, "y": y, "k": k}), + Color::Gray { k } => json!({"gray": k as f64 * 100.0}), + Color::Rgb { .. } => json!(c.to_hex()), + } +} + /// Stops in `object.gradient` form. fn stops_json(g: &designcraft_color::Gradient) -> Vec { g.stops .iter() - .map(|s| json!({"location": s.offset as f64 * 100.0, "color": s.color.to_hex(), "opacity": s.opacity as f64 * 100.0, "midpoint": s.midpoint as f64 * 100.0})) + .map(|s| json!({"location": s.offset as f64 * 100.0, "color": color_json(&s.color), "opacity": s.opacity as f64 * 100.0, "midpoint": s.midpoint as f64 * 100.0})) .collect() } -/// Gradient panel: type, angle, reverse, and the ramp with its stops (drag to move, click below -/// the ramp to add, drag off to remove; the selected stop's location and colour are editable). +fn color32(c: &designcraft_color::Color, alpha: f32) -> egui::Color32 { + let [r, g, b] = c.to_rgb(); + egui::Color32::from_rgba_unmultiplied((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, (alpha * 255.0) as u8) +} + +/// The panel's thumbnail: the gradient in a square, linear across or radial from the centre. +fn thumbnail(p: &egui::Painter, r: egui::Rect, g: &designcraft_color::Gradient, border: egui::Color32) { + let n = 32; + if g.kind == designcraft_color::GradientKind::Radial { + // Rings from the outside in. + for i in (0..n).rev() { + let t = (i as f32 + 0.5) / n as f32; + let (c, a) = g.sample(t); + let rad = r.width() / 2.0 * std::f32::consts::SQRT_2 * (i + 1) as f32 / n as f32; + p.with_clip_rect(r).circle_filled(r.center(), rad, color32(&c, a)); + } + } else { + for i in 0..n { + let (a, b) = (i as f32 / n as f32, (i + 1) as f32 / n as f32); + let (c, al) = g.sample((a + b) / 2.0); + let x0 = r.left() + r.width() * a; + p.rect_filled(egui::Rect::from_x_y_ranges(x0..=r.left() + r.width() * b + 0.5, r.y_range()), 0.0, color32(&c, al)); + } + } + p.rect_stroke(r, 0.0, egui::Stroke::new(1.0, border), egui::StrokeKind::Outside); +} + +/// What's selected on the ramp: a stop, or the midpoint after a stop. +#[derive(Clone, Copy, PartialEq, Debug)] +enum RampSel { + Stop(usize), + Mid(usize), +} + +/// Gradient panel, laid out as InDesign's: thumbnail, Type, Location and Angle, Reverse, and the +/// ramp with midpoint diamonds above it and stops below it. Drag a stop or a diamond to move it, +/// click below the ramp to add a stop, drag a stop off to remove it, and drop a swatch from the +/// Swatches panel on a stop to colour it or elsewhere on the ramp to add a stop (or right-click a +/// stop for its colour). Until the fill is +/// a gradient the ramp shows the default White → Black, and using it gives the fill a gradient. pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); let Some(st) = app.session.active() else { return }; - let Some(it) = st.selection.items.first().and_then(|i| st.doc.item(*i)) else { - ui.label(crate::rtl::widget( - ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, "Select an object to give its fill a gradient.")).color(t.text_dim), - )); - return; - }; - let fill = it.fill.clone(); - let Some(g) = designcraft_color::swatch::resolve_gradient(&st.doc.swatches, &fill.swatch).cloned() else { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "The fill isn't a gradient.")).color(t.text_dim)); - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply Gradient"))).clicked() { - let _ = app.run("object.gradient", json!({})); - } - return; - }; + let item = st.selection.items.first().and_then(|i| st.doc.item(*i)); + let has_item = item.is_some(); + let fill = item.map(|it| 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(); let radial = g.kind == designcraft_color::GradientKind::Radial; - ui.horizontal(|ui| { - crate::widgets::caption(ui, crate::i18n::tr(&app.ui.language, "Type:")); - egui::ComboBox::from_id_salt("grad_type") - .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, g.kind.label()))) - .width(100.0) - .show_ui(ui, |ui| { - for (k, v) in [("Linear", "linear"), ("Radial", "radial")] { - if ui.selectable_label(g.kind.label() == k, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, k))).clicked() { - let _ = app.run("object.gradient", json!({"kind": v})); - } - } - }); - if ui - .button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reverse"))) - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Reverse the gradient")); - }) - .clicked() - { - let _ = app.run("object.gradient", json!({"reverse": true})); - } - }); let sel_id = egui::Id::new("grad_sel"); let drag_id = egui::Id::new("grad_drag"); - let mut sel: usize = ui.data(|d| d.get_temp(sel_id)).unwrap_or(0).min(g.stops.len().saturating_sub(1)); - ui.horizontal(|ui| { - crate::widgets::caption(ui, crate::i18n::tr(&app.ui.language, "Location:")); - let loc = g.stops.get(sel).map(|s| s.offset as f64 * 100.0); - if let Some(v) = crate::widgets::number(ui, "grad_loc", loc, " %", 54.0, 1) { - let mut stops = stops_json(&g); - stops[sel]["location"] = json!(v.clamp(0.0, 100.0)); - let _ = app.run("object.gradient", json!({"stops": stops})); - } - if !radial { - crate::widgets::caption(ui, crate::i18n::tr(&app.ui.language, "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); - if let Some(v) = crate::widgets::number(ui, "grad_angle", Some(angle), "°", 54.0, 1) { - let _ = app.run("object.gradient", json!({"angle": v})); - } - } - }); - ui.add_space(4.0); - // The ramp, with stop markers below it. - let w = ui.available_width().min(240.0); - let (area, resp) = ui.allocate_exact_size(vec2(w, 34.0), Sense::click_and_drag()); - let ramp = egui::Rect::from_min_size(area.min + vec2(6.0, 0.0), vec2(w - 12.0, 18.0)); - let painter = ui.painter(); - let n = 64; - for i in 0..n { - let (a, b) = (i as f32 / n as f32, (i + 1) as f32 / n as f32); - let (c, alpha) = g.sample((a + b) / 2.0); - let [r, gg, bb] = c.to_rgb(); - let col = egui::Color32::from_rgba_unmultiplied((r * 255.0) as u8, (gg * 255.0) as u8, (bb * 255.0) as u8, (alpha * 255.0) as u8); - let x0 = ramp.left() + ramp.width() * a; - let x1 = ramp.left() + ramp.width() * b + 0.5; - painter.rect_filled(egui::Rect::from_x_y_ranges(x0..=x1, ramp.y_range()), 0.0, col); - } - painter.rect_stroke(ramp, 0.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside); - // A stop being dragged: (index, location 0–1, off the ramp). - let mut dragging: Option<(usize, f32, bool)> = ui.data(|d| d.get_temp(drag_id)); - let x_of = |o: f32| ramp.left() + ramp.width() * o; - let o_of = |x: f32| ((x - ramp.left()) / ramp.width()).clamp(0.0, 1.0); - let near = |x: f32| { - g.stops.iter().enumerate().map(|(i, s)| (i, (x_of(s.offset) - x).abs())).filter(|(_, d)| *d < 7.0).min_by(|a, b| a.1.total_cmp(&b.1)) + let mut sel: RampSel = ui.data(|d| d.get_temp(sel_id)).unwrap_or(RampSel::Stop(0)); + let last = g.stops.len().saturating_sub(1); + sel = match sel { + RampSel::Stop(i) => RampSel::Stop(i.min(last)), + RampSel::Mid(i) if i < last => RampSel::Mid(i), + RampSel::Mid(_) => RampSel::Stop(0), }; - if resp.drag_started() - && let Some(p) = resp.interact_pointer_pos() - && let Some((i, _)) = near(p.x) - { - sel = i; - dragging = Some((i, g.stops[i].offset, false)); - } - if resp.dragged() - && let (Some((i, _, _)), Some(p)) = (dragging, resp.interact_pointer_pos()) - { - dragging = Some((i, o_of(p.x), p.y > area.bottom() + 16.0 && g.stops.len() > 2)); - } - if resp.drag_stopped() - && let Some((i, o, off)) = dragging.take() - { - let mut stops = stops_json(&g); - if off { - stops.remove(i); - sel = 0; - } else { - stops[i]["location"] = json!(o as f64 * 100.0); - } - let _ = app.run("object.gradient", json!({"stops": stops})); - } - if resp.clicked() - && let Some(p) = resp.interact_pointer_pos() - { - match near(p.x) { - Some((i, _)) => sel = i, - // Below the ramp, away from the stops: a new stop with the colour there. - None if p.y > ramp.bottom() => { - let o = o_of(p.x); - let (c, a) = g.sample(o); - let mut stops = stops_json(&g); - stops.push(json!({"location": o as f64 * 100.0, "color": c.to_hex(), "opacity": a as f64 * 100.0, "midpoint": 50.0})); - let _ = app.run("object.gradient", json!({"stops": stops})); - sel = g.stops.iter().filter(|s| s.offset <= o).count(); - } - None => {} - } - } - for (i, s) in g.stops.iter().enumerate() { - let (o, off) = match dragging { - Some((j, o, off)) if j == i => (o, off), - _ => (s.offset, false), - }; - if off { - continue; - } - let x = x_of(o); - let top = ramp.bottom() + 2.0; - let [r, gg, bb] = s.color.to_rgb(); - let col = egui::Color32::from_rgb((r * 255.0) as u8, (gg * 255.0) as u8, (bb * 255.0) as u8); - let edge = if i == sel { t.accent } else { t.text_dim }; - painter.add(egui::Shape::convex_polygon( - vec![ - egui::pos2(x, top), - egui::pos2(x + 6.0, top + 7.0), - egui::pos2(x + 6.0, top + 13.0), - egui::pos2(x - 6.0, top + 13.0), - egui::pos2(x - 6.0, top + 7.0), - ], - col, - egui::Stroke::new(if i == sel { 2.0 } else { 1.0 }, edge), - )); - } - ui.data_mut(|d| { - d.insert_temp(sel_id, sel); - match dragging { - Some(v) => { - d.insert_temp(drag_id, v); - } - None => d.remove::<(usize, f32, bool)>(drag_id), - } - }); - // The selected stop's colour. - if let Some(s) = g.stops.get(sel) { + let mut send: Option = None; + // The ramp spans the panel, however wide the rows above are. + let full = ui.available_width().max(60.0); + ui.add_enabled_ui(has_item, |ui| { ui.horizontal(|ui| { - crate::widgets::caption(ui, crate::i18n::tr(&app.ui.language, "Stop Color:")); - let [r, gg, bb] = s.color.to_rgb(); - let mut rgb = [(r * 255.0) as u8, (gg * 255.0) as u8, (bb * 255.0) as u8]; - if egui::color_picker::color_edit_button_srgb(ui, &mut rgb).changed() { - let mut stops = stops_json(&g); - stops[sel]["color"] = json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])); - let _ = app.run("object.gradient", json!({"stops": stops})); + let (r, resp) = ui.allocate_exact_size(vec2(34.0, 34.0), Sense::click()); + thumbnail(ui.painter(), r.shrink(1.0), &g, t.field_border); + // Clicking the thumbnail applies the gradient shown (InDesign's way to start one). + if resp.clicked() && !is_gradient { + send = Some(json!({})); + } + ui.add_space(4.0); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + ui.horizontal(|ui| { + crate::widgets::caption_w(ui, crate::i18n::tr(&lang, "Type:"), 52.0); + let shown = if is_gradient { crate::i18n::tr(&lang, g.kind.label()) } else { "" }; + egui::ComboBox::from_id_salt("grad_type").selected_text(crate::rtl::widget(ui, shown)).width(104.0).show_ui(ui, |ui| { + for (k, v) in [("Linear", "linear"), ("Radial", "radial")] { + if ui.selectable_label(is_gradient && g.kind.label() == k, crate::rtl::widget(ui, crate::i18n::tr(&lang, k))).clicked() { + send = Some(json!({"kind": v})); + } + } + }); + }); + ui.horizontal(|ui| { + crate::widgets::caption_w(ui, crate::i18n::tr(&lang, "Location:"), 52.0); + let loc = match sel { + RampSel::Stop(i) => g.stops.get(i).map(|s| s.offset as f64 * 100.0), + RampSel::Mid(i) => g.stops.get(i).map(|s| s.midpoint as f64 * 100.0), + }; + if let Some(v) = crate::widgets::number(ui, "grad_loc", loc.filter(|_| is_gradient), "", 50.0, 3) { + let mut stops = stops_json(&g); + match sel { + RampSel::Stop(i) => stops[i]["location"] = json!(v.clamp(0.0, 100.0)), + RampSel::Mid(i) => stops[i]["midpoint"] = json!(v.clamp(13.0, 87.0)), + } + send = Some(json!({"stops": stops})); + } + crate::widgets::caption(ui, "%"); + ui.add_space(4.0); + 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| { + if let Some(v) = crate::widgets::number(ui, "grad_angle", is_gradient.then_some(angle), "°", 44.0, 1) { + send = Some(json!({"angle": v})); + } + }); + }); + ui.horizontal(|ui| { + crate::widgets::caption_w(ui, crate::i18n::tr(&lang, "Reverse"), 52.0); + if icons::button(ui, "swap", 20.0, false, crate::i18n::tr(&lang, "Reverse the gradient")).clicked() && is_gradient { + send = Some(json!({"reverse": true})); + } + }); + }); + }); + ui.add_space(6.0); + // The ramp: midpoint diamonds above, stops below. + let w = full; + let (area, resp) = ui.allocate_exact_size(vec2(w, 40.0), Sense::click_and_drag()); + let ramp = egui::Rect::from_min_size(area.min + vec2(6.0, 10.0), vec2(w - 12.0, 14.0)); + let painter = ui.painter(); + let n = 96; + for i in 0..n { + let (a, b) = (i as f32 / n as f32, (i + 1) as f32 / n as f32); + let (c, alpha) = g.sample((a + b) / 2.0); + let x0 = ramp.left() + ramp.width() * a; + let x1 = ramp.left() + ramp.width() * b + 0.5; + painter.rect_filled(egui::Rect::from_x_y_ranges(x0..=x1, ramp.y_range()), 0.0, color32(&c, alpha)); + } + painter.rect_stroke(ramp, 0.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside); + let x_of = |o: f32| ramp.left() + ramp.width() * o; + let o_of = |x: f32| ((x - ramp.left()) / ramp.width()).clamp(0.0, 1.0); + let mid_x = |i: usize| match (g.stops.get(i), g.stops.get(i + 1)) { + (Some(a), Some(b)) => Some(x_of(a.offset + (b.offset - a.offset) * a.midpoint)), + _ => None, + }; + // The marker under the pointer: a stop below the ramp, a diamond above it. + let hit = |p: egui::Pos2| -> Option { + if p.y < ramp.top() { + (0..last) + .filter_map(|i| mid_x(i).map(|x| (i, (x - p.x).abs()))) + .filter(|(_, d)| *d < 6.0) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| RampSel::Mid(i)) + } else { + g.stops + .iter() + .enumerate() + .map(|(i, s)| (i, (x_of(s.offset) - p.x).abs())) + .filter(|(_, d)| *d < 7.0) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| RampSel::Stop(i)) + } + }; + // A marker being dragged: what, where (0–1), off the panel (stops only). + let mut dragging: Option<(RampSel, f32, bool)> = ui.data(|d| d.get_temp(drag_id)); + if resp.drag_started() + && let Some(p) = resp.interact_pointer_pos() + && let Some(h) = hit(p) + { + sel = h; + let o = match h { + RampSel::Stop(i) => g.stops[i].offset, + RampSel::Mid(i) => g.stops[i].midpoint, + }; + dragging = Some((h, o, false)); + } + if resp.dragged() + && let (Some((h, _, _)), Some(p)) = (dragging, resp.interact_pointer_pos()) + { + dragging = Some(match h { + RampSel::Stop(_) => (h, o_of(p.x), p.y > area.bottom() + 16.0 && g.stops.len() > 2), + RampSel::Mid(i) => { + let (a, b) = (g.stops[i].offset, g.stops[i + 1].offset); + let m = if b - a > 1e-6 { ((o_of(p.x) - a) / (b - a)).clamp(0.13, 0.87) } else { 0.5 }; + (h, m, false) + } + }); + } + if resp.drag_stopped() + && let Some((h, o, off)) = dragging.take() + { + let mut stops = stops_json(&g); + match h { + RampSel::Stop(i) if off => { + stops.remove(i); + sel = RampSel::Stop(0); + } + RampSel::Stop(i) => stops[i]["location"] = json!(o as f64 * 100.0), + RampSel::Mid(i) => stops[i]["midpoint"] = json!(o as f64 * 100.0), + } + send = Some(json!({"stops": stops})); + } + if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + { + match hit(p) { + Some(h) => sel = h, + // Below the ramp, away from the stops: a new stop with the colour there. + None if p.y > ramp.bottom() => { + let o = o_of(p.x); + let (c, a) = g.sample(o); + let mut stops = stops_json(&g); + stops.push(json!({"location": o as f64 * 100.0, "color": color_json(&c), "opacity": a as f64 * 100.0, "midpoint": 50.0})); + send = Some(json!({"stops": stops})); + sel = RampSel::Stop(g.stops.iter().filter(|s| s.offset <= o).count()); + } + None => {} + } + } + // Right-click a stop: its colour (InDesign takes it from the Swatches or Color panel). + if resp.secondary_clicked() + && let Some(RampSel::Stop(i)) = resp.interact_pointer_pos().and_then(hit) + { + sel = RampSel::Stop(i); + } + if let (true, RampSel::Stop(i)) = (is_gradient, sel) + && let Some(s) = g.stops.get(i) + { + resp.context_menu(|ui| { + ui.horizontal(|ui| { + crate::widgets::caption(ui, crate::i18n::tr(&lang, "Stop Color:")); + let [r, gg, bb] = s.color.to_rgb(); + let mut rgb = [(r * 255.0) as u8, (gg * 255.0) as u8, (bb * 255.0) as u8]; + if egui::color_picker::color_edit_button_srgb(ui, &mut rgb).changed() { + let mut stops = stops_json(&g); + stops[i]["color"] = json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])); + send = Some(json!({"stops": stops})); + } + }); + }); + } + // A swatch dropped from the Swatches panel: on a stop it colours that stop, elsewhere on + // the ramp it's a new stop there. + let drop_at = |p: egui::Pos2| match hit(egui::pos2(p.x, ramp.bottom() + 4.0)) { + Some(RampSel::Stop(i)) => (Some(i), g.stops[i].offset), + _ => (None, o_of(p.x)), + }; + if resp.dnd_hover_payload::().is_some() + && let Some(p) = ui.ctx().pointer_hover_pos() + { + let x = match drop_at(p) { + (Some(i), _) => x_of(g.stops[i].offset), + (None, o) => x_of(o), + }; + painter.line_segment([egui::pos2(x, ramp.top() - 3.0), egui::pos2(x, ramp.bottom() + 14.0)], egui::Stroke::new(2.0, t.accent)); + } + if let Some(sw) = resp.dnd_release_payload::() + && let Some(p) = ui.ctx().pointer_interact_pos() + { + let mut stops = stops_json(&g); + match drop_at(p) { + (Some(i), _) => { + stops[i]["swatch"] = json!(sw.0); + if let Some(o) = stops[i].as_object_mut() { + o.remove("color"); + } + sel = RampSel::Stop(i); + } + (None, o) => { + stops.push(json!({"location": o as f64 * 100.0, "swatch": sw.0, "midpoint": 50.0})); + sel = RampSel::Stop(g.stops.iter().filter(|s| s.offset <= o).count()); + } + } + send = Some(json!({"stops": stops})); + } + // Diamonds. + for i in 0..last { + let x = match dragging { + Some((RampSel::Mid(j), m, _)) if j == i => x_of(g.stops[i].offset + (g.stops[i + 1].offset - g.stops[i].offset) * m), + _ => mid_x(i).unwrap_or(ramp.left()), + }; + let (cy, r) = (ramp.top() - 6.0, 4.0); + let pts = vec![egui::pos2(x, cy - r), egui::pos2(x + r, cy), egui::pos2(x, cy + r), egui::pos2(x - r, cy)]; + let on = sel == RampSel::Mid(i); + painter.add(egui::Shape::convex_polygon(pts, if on { t.text } else { egui::Color32::TRANSPARENT }, egui::Stroke::new(1.0, t.text_dim))); + } + // Stops: a colour square under a pointer. + for (i, s) in g.stops.iter().enumerate() { + let (o, off) = match dragging { + Some((RampSel::Stop(j), o, off)) if j == i => (o, off), + _ => (s.offset, false), + }; + if off { + continue; + } + let x = x_of(o); + let top = ramp.bottom() + 2.0; + let on = sel == RampSel::Stop(i); + let edge = if on { t.text_strong } else { t.text_dim }; + painter.add(egui::Shape::convex_polygon( + vec![egui::pos2(x, top), egui::pos2(x + 5.0, top + 5.0), egui::pos2(x - 5.0, top + 5.0)], + if on { t.text_strong } else { t.panel }, + egui::Stroke::new(1.0, edge), + )); + let sq = egui::Rect::from_min_size(egui::pos2(x - 5.0, top + 5.0), vec2(10.0, 9.0)); + painter.rect_filled(sq, 0.0, color32(&s.color, 1.0)); + painter.rect_stroke(sq, 0.0, egui::Stroke::new(if on { 1.5 } else { 1.0 }, edge), egui::StrokeKind::Inside); + } + ui.data_mut(|d| { + d.insert_temp(sel_id, sel); + match dragging { + Some(v) => { + d.insert_temp(drag_id, v); + } + None => d.remove::<(RampSel, f32, bool)>(drag_id), } }); + }); + if let Some(p) = send { + let _ = app.run("object.gradient", p); + } +} + +#[cfg(test)] +mod gradient_panel_tests { + use designcraft_color::Color; + use egui_kittest::{Harness, kittest::Queryable}; + use serde_json::json; + + use super::*; + + fn app_with_frame() -> DesignApp { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [100, 100, 300, 300], "content": "unassigned"})).unwrap(); + app + } + + fn gradient(app: &DesignApp) -> Option { + let d = &app.session.doc().unwrap().doc; + let id = app.session.doc().unwrap().selection.items[0]; + designcraft_color::swatch::resolve_gradient(&d.swatches, &d.item(id).unwrap().fill.swatch).cloned() + } + + #[test] + fn panel_edits_keep_cmyk_and_grey_stops() { + let mut app = app_with_frame(); + app.run("object.gradient", json!({"stops": [{"location": 0, "swatch": "C=0 M=100 Y=0 K=0"}, {"location": 100, "color": {"gray": 60}}]})) + .unwrap(); + let g = gradient(&app).unwrap(); + // What the panel sends when a stop moves: the colours come back exactly. + let mut stops = stops_json(&g); + stops[1]["location"] = json!(80.0); + app.run("object.gradient", json!({"stops": stops})).unwrap(); + let g = gradient(&app).unwrap(); + assert_eq!(g.stops[0].color, Color::cmyk(0.0, 1.0, 0.0, 0.0)); + assert_eq!(g.stops[1].color, Color::gray(0.6)); + assert!((g.stops[1].offset - 0.8).abs() < 1e-6); + } + + #[test] + fn a_swatch_dropped_on_the_ramp_is_a_new_stop() { + let app = app_with_frame(); + let mut h = Harness::builder().with_size(vec2(280.0, 200.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + gradient_panel(app, ui); + }, + app, + ); + h.run_steps(3); + assert!(gradient(h.state()).is_none(), "no gradient yet"); + // The ramp sits under the Reverse row: drop cyan at its middle. + let row = h.get_by_label("Reverse the gradient").rect(); + let at = egui::pos2(h.ctx.content_rect().center().x, row.bottom() + 6.0 + 17.0); + egui::DragAndDrop::set_payload(&h.ctx, SwatchDrag("C=100 M=0 Y=0 K=0".into())); + h.event(egui::Event::PointerMoved(at)); + h.run_steps(2); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + let g = gradient(h.state()).expect("the drop gives the fill a gradient"); + assert_eq!(g.stops.len(), 3, "White, cyan, Black"); + assert_eq!(g.stops[1].color, Color::cmyk(1.0, 0.0, 0.0, 0.0)); + assert!((g.stops[1].offset - 0.5).abs() < 0.05, "{}", g.stops[1].offset); } } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f48793a5b..174a91927 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -433,7 +433,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). - OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. - Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`. -- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches until the Swatches panel's New Swatch names one "New Gradient Swatch" (`swatch.new`, which also duplicates a named swatch); `swatch.create {gradient}` builds one from colour swatches; `object.gradient`; the vector round-trips through IDML. A radial gradient stretches with the object's box as InDesign's does (an ellipse reaching the sides of a non-square frame, on screen and in PDF). +- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel (InDesign's layout: thumbnail, Type, Location, Angle, Reverse) sets type, angle, reverse and edits stops and midpoints on a ramp (drag, add, drag off to remove, location; drop a swatch from the Swatches panel on a stop or the ramp; stops keep their CMYK). Edited gradients are unnamed swatches until the Swatches panel's New Swatch names one "New Gradient Swatch" (`swatch.new`, which also duplicates a named swatch); `swatch.create {gradient}` builds one from colour swatches; `object.gradient`; the vector round-trips through IDML. A radial gradient stretches with the object's box as InDesign's does (an ellipse reaching the sides of a non-square frame, on screen and in PDF). - New Document (⌘N) as in InDesign: Recent, Saved, Print, Web and Mobile tabs of page cards (double-click creates) beside the preset's details: document name, size with arrows, units, orientation, pages, start page #, facing pages, primary text frame, columns, margins, bleed and slug per edge (linked or not); Save Document Preset (`file.savePreset`, `file.deletePreset`; saved presets are kept with the preferences, listed by `file.presets` and made by `file.new {preset: name}`); `file.new` takes `intent`, `units`, `startPage` and per-edge `bleed`/`slug`. Preview shows a document with the window's settings in the workspace while it is open; Create keeps it, Close removes it. - Document Setup (⌥⌘P), laid out like InDesign's: intent, number of pages, start page # (an even start begins with a left page), facing pages, binding, Page Size (presets keeping the orientation, width and height with arrows, orientation), margins for every page and parent page, bleed and slug per edge (each group linked or not), and Adjust Layout (objects follow the new page size and margins), all in one undo step; measures are read in the document's units, and a value that can't be read keeps the dialog open; `layout.documentSetup {}` reports the setup, margins included. - Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots.