From bdf66d7749371f70f0a619001d1e3c1428a4f619 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 20:02:45 +0200 Subject: [PATCH] feat(ui): Gradient panel in InDesign's layout; swatches drop onto its stops LYH Studio episode 6 (1:36-2:05) builds its gradient in the Gradient panel: it drags C=0 M=100 Y=0 K=0 from the Swatches panel onto the left stop, drops C=100 M=0 Y=0 K=0 on the ramp (a new stop at 68.636 %), then drags the black stop off. DesignCraft's Gradient panel took no swatches, showed only a hint until the fill was a gradient, and laid out its fields differently. - Gradient panel: InDesign's layout (thumbnail; Type; Location % and Angle; Reverse; the ramp with midpoint diamonds above and stops below). Until the fill is a gradient it shows the default White -> Black ramp; clicking the thumbnail or using the ramp starts one. Midpoints drag and take a Location. A stop's colour picker moved to its right-click menu, so selecting a stop no longer grows the panel. - Swatches panel rows are drag sources (the chip follows the pointer): dropped on a stop a swatch colours it, elsewhere on the ramp it adds a stop there. - Stops keep their colour model through panel edits (CMYK used to turn into hex RGB whenever a stop moved). - engine: `object.gradient` stops accept `swatch` (its colour; gradient swatches and [None] are refused) and colours accept `{gray}`. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/object.rs | 32 ++ crates/engine/src/cmd/style.rs | 16 +- crates/ui-egui/src/panels/swatches.rs | 567 ++++++++++++++++++-------- docs/target-app-parity.md | 2 +- 5 files changed, 450 insertions(+), 168 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..d5a4d02d8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,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-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index aa5a2cc01..9ccc19067 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2668,6 +2668,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 c4d20f099..178d49deb 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -144,7 +144,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 ), @@ -757,6 +757,10 @@ pub fn parse_color(v: &Value) -> Option { } Value::Object(o) => { 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)) @@ -902,9 +906,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 7544347a9..2ea37baae 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -126,7 +126,19 @@ fn swatch_row( text: bool, ) { 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.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() { @@ -387,186 +399,411 @@ 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 eb7d7e5c1..3daad6a58 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -421,7 +421,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; `object.gradient`; the vector round-trips through IDML. +- 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; `object.gradient`; the vector round-trips through IDML. - Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup. - 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. - Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands.