From 56f31158c7fa3434648f63b6787b229c6f50274b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 6 Oct 2026 01:34:50 -0400 Subject: [PATCH] Gradient editor (type, direction, angle, stops with colour/position/transparency) for shape fill and Format Background; pattern backgrounds; painted dropdown chevrons Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/design.rs | 29 +-- crates/engine/src/cmd/shape.rs | 27 ++- crates/render/src/paint.rs | 6 +- crates/ui-egui/src/fillui.rs | 321 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panes.rs | 165 ++++++++++------ crates/ui-egui/src/ribbon.rs | 18 +- crates/ui-egui/src/widgets.rs | 9 + docs/parity.md | 14 +- 9 files changed, 499 insertions(+), 91 deletions(-) create mode 100644 crates/ui-egui/src/fillui.rs diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 5906dc9..6cf6b03 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -167,26 +167,15 @@ fn background(s: &mut Session, p: &Value) -> Result { color: slidecraft_model::ColorRef::scheme(slots.get(i % 4).copied().unwrap_or(slidecraft_color::SchemeSlot::Bg1)), }) } else if let Some(g) = p.get("gradient") { - let stops: Vec = g - .get("stops") - .and_then(Value::as_array) - .map(|a| { - a.iter() - .filter_map(|s| Some(slidecraft_model::style::GradientStop { pos: s.get(0)?.as_f64()?, color: color_value(s.get(1)?)? })) - .collect() - }) - .unwrap_or_default(); - if stops.len() < 2 { - return Err(bad("design.background", "gradient needs two or more `stops`")); - } + let mut g = super::shape::gradient_param(g).ok_or_else(|| bad("design.background", "gradient needs two or more `stops`"))?; + g.rotate_with_shape = false; + Some(Background::Fill { fill: Fill::Gradient(g) }) + } else if let Some(pt) = p.get("pattern") { Some(Background::Fill { - fill: Fill::Gradient(slidecraft_model::style::Gradient { - stops, - shape: slidecraft_model::style::GradientShape::Linear { - angle: g.get("angle").and_then(Value::as_f64).unwrap_or(90.0), - scaled: false, - }, - rotate_with_shape: false, + fill: Fill::Pattern(slidecraft_model::style::PatternFill { + preset: pt.get("preset").and_then(Value::as_str).unwrap_or("pct50").to_string(), + fg: pt.get("fg").and_then(color_value).unwrap_or(slidecraft_model::ColorRef::scheme(slidecraft_color::SchemeSlot::Accent1)), + bg: pt.get("bg").and_then(color_value).unwrap_or(slidecraft_model::ColorRef::scheme(slidecraft_color::SchemeSlot::Bg1)), }), }) } else if p.get("picture").is_some() { @@ -195,7 +184,7 @@ fn background(s: &mut Session, p: &Value) -> Result { let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?; Some(Background::Fill { fill: Fill::Picture(slidecraft_model::style::PictureFill { media, ..Default::default() }) }) } else { - return Err(bad("design.background", "give `color`, `gradient`, `picture`, `style` or `reset`")); + return Err(bad("design.background", "give `color`, `gradient`, `pattern`, `picture`, `style` or `reset`")); }; let all = bool_or(p, "all", false); let index = usize_param(p, "index"); diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index 877e6a2..10ac46f 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -217,7 +217,10 @@ fn change(s: &mut Session, p: &Value) -> Result { }) } -fn gradient_param(v: &Value) -> Option { +/// `{stops: [[pos 0–1, color], …], kind?: linear|radial|rectangular|path, angle?: deg (linear), +/// focus?: [l, t, r, b] fractions (radial/rectangular/path: where the first stop sits), +/// rotateWithShape?}`. +pub(crate) fn gradient_param(v: &Value) -> Option { let stops: Vec = v .get("stops")? .as_array()? @@ -230,11 +233,27 @@ fn gradient_param(v: &Value) -> Option { if stops.len() < 2 { return None; } + let focus = v + .get("focus") + .and_then(Value::as_array) + .and_then(|a| { + let f = |i: usize| a.get(i).and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0)); + Some([f(0)?, f(1)?, f(2)?, f(3)?]) + }) + .unwrap_or([0.5, 0.5, 0.5, 0.5]); + let mut stops = stops; + stops.sort_by(|a, b| a.pos.total_cmp(&b.pos)); + let path = |p: &str| GradientShape::Path { path: p.into(), focus }; let shape = match v.get("kind").and_then(Value::as_str) { - Some("radial" | "path") => GradientShape::Path { path: "circle".into(), focus: [0.5, 0.5, 0.5, 0.5] }, - _ => GradientShape::Linear { angle: v.get("angle").and_then(Value::as_f64).unwrap_or(90.0), scaled: false }, + Some("radial" | "circle") => path("circle"), + Some("rectangular" | "rect") => path("rect"), + Some("path" | "shape") => path("shape"), + _ => GradientShape::Linear { + angle: v.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()).unwrap_or(90.0).rem_euclid(360.0), + scaled: false, + }, }; - Some(Gradient { stops, shape, rotate_with_shape: true }) + Some(Gradient { stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) }) } fn fill(s: &mut Session, p: &Value) -> Result { diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 1009aca..3c79462 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -52,7 +52,11 @@ pub fn fill_path( GradientShape::Path { focus, .. } => { let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0; let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0; - let r = ((bounds.width() / 2.0).powi(2) + (bounds.height() / 2.0).powi(2)).sqrt(); + // Reach the farthest corner, so a gradient from a corner covers the whole box. + let r = [(bounds.x0, bounds.y0), (bounds.x1, bounds.y0), (bounds.x0, bounds.y1), (bounds.x1, bounds.y1)] + .iter() + .map(|(x, y)| ((x - fx).powi(2) + (y - fy).powi(2)).sqrt()) + .fold(1e-6, f64::max); peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice()) } }; diff --git a/crates/ui-egui/src/fillui.rs b/crates/ui-egui/src/fillui.rs new file mode 100644 index 0000000..6e4bd38 --- /dev/null +++ b/crates/ui-egui/src/fillui.rs @@ -0,0 +1,321 @@ +//! The gradient editor of the Format pane (shape fill and slide background): preset variations, +//! type, direction, angle and a stop bar (add, drag, recolour, set transparency, remove stops). + +use egui::{Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; +use serde_json::{Value, json}; +use slidecraft_color::{ColorScheme, ColorTransform, SchemeSlot}; +use slidecraft_model::ColorRef; +use slidecraft_model::style::{Gradient, GradientShape, GradientStop}; + +use crate::ribbon::cref_param; +use crate::theme::{self, Tokens}; +use crate::widgets; + +/// A default two-stop gradient on `base`: the colour and a darker shade. +pub fn default_gradient(base: ColorRef, angle: f64) -> Gradient { + let mut dark = base.clone(); + dark.mods.push(ColorTransform::LumMod(60_000)); + Gradient { + stops: vec![GradientStop { pos: 0.0, color: base }, GradientStop { pos: 1.0, color: dark }], + shape: GradientShape::Linear { angle, scaled: false }, + rotate_with_shape: true, + } +} + +/// The command parameter for `g` (`shape.fill {gradient}` / `design.background {gradient}`). +pub fn gradient_json(g: &Gradient) -> Value { + let stops: Vec = g.stops.iter().map(|s| json!([s.pos, cref_param(&s.color)])).collect(); + match &g.shape { + GradientShape::Linear { angle, .. } => json!({"stops": stops, "kind": "linear", "angle": angle, "rotateWithShape": g.rotate_with_shape}), + GradientShape::Path { path, focus } => { + let kind = match path.as_str() { + "rect" => "rectangular", + "shape" => "path", + _ => "radial", + }; + json!({"stops": stops, "kind": kind, "focus": focus, "rotateWithShape": g.rotate_with_shape}) + } + } +} + +fn alpha_of(c: &ColorRef) -> f64 { + c.mods.iter().find_map(|m| if let ColorTransform::Alpha(a) = m { Some(*a as f64 / 100_000.0) } else { None }).unwrap_or(1.0) +} + +fn with_alpha(mut c: ColorRef, a: f64) -> ColorRef { + c.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_))); + if a < 0.999 { + c.mods.push(ColorTransform::Alpha((a.clamp(0.0, 1.0) * 100_000.0).round() as i32)); + } + c +} + +fn c32(c: &ColorRef, sc: &ColorScheme) -> Color32 { + theme::to_color32(c.resolve(sc, None)) +} + +/// Paint `g` left→right into `r` (the stop bar and preset swatches). +fn paint_bar(ui: &Ui, r: Rect, g: &Gradient, sc: &ColorScheme) { + // Checkerboard under transparent stops. + let p = ui.painter(); + let n = (r.width() / 6.0).ceil() as i32; + for i in 0..n { + for j in 0..((r.height() / 6.0).ceil() as i32) { + if (i + j) % 2 == 0 { + let cell = Rect::from_min_size(pos2(r.min.x + i as f32 * 6.0, r.min.y + j as f32 * 6.0), vec2(6.0, 6.0)).intersect(r); + p.rect_filled(cell, CornerRadius::ZERO, Color32::from_gray(220)); + } + } + } + let mut mesh = egui::Mesh::default(); + let stops: Vec<(f32, Color32)> = g.stops.iter().map(|s| (s.pos as f32, c32(&s.color, sc))).collect(); + let mut pts: Vec<(f32, Color32)> = vec![]; + if let Some(first) = stops.first() { + pts.push((0.0, first.1)); + } + pts.extend(stops.iter().copied()); + if let Some(last) = stops.last() { + pts.push((1.0, last.1)); + } + for (k, (t, c)) in pts.iter().enumerate() { + let x = r.min.x + t.clamp(0.0, 1.0) * r.width(); + mesh.colored_vertex(pos2(x, r.min.y), *c); + mesh.colored_vertex(pos2(x, r.max.y), *c); + if k > 0 { + let i = (k as u32) * 2; + mesh.add_triangle(i - 2, i - 1, i); + mesh.add_triangle(i - 1, i, i + 1); + } + } + p.add(egui::Shape::mesh(mesh)); + p.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, Tokens::get(ui.ctx()).border), egui::StrokeKind::Inside); +} + +const DIRECTIONS_LINEAR: [(&str, f64); 8] = + [("→", 0.0), ("↘", 45.0), ("↓", 90.0), ("↙", 135.0), ("←", 180.0), ("↖", 225.0), ("↑", 270.0), ("↗", 315.0)]; +const DIRECTIONS_PATH: [(&str, [f64; 4]); 5] = [ + ("From center", [0.5, 0.5, 0.5, 0.5]), + ("From top left", [0.0, 0.0, 1.0, 1.0]), + ("From top right", [1.0, 0.0, 0.0, 1.0]), + ("From bottom left", [0.0, 1.0, 1.0, 0.0]), + ("From bottom right", [1.0, 1.0, 0.0, 0.0]), +]; + +/// The editor for `g`; returns the new gradient when the user changed something. +pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> Option { + let t = Tokens::get(ui.ctx()); + let mut out: Option = None; + let sel_id = ui.id().with((id, "stop")); + let mut sel: usize = ui.data_mut(|d| d.get_temp(sel_id).unwrap_or(0)).min(g.stops.len().saturating_sub(1)); + + // Preset variations of the first stop's colour. + ui.label("Preset gradients"); + ui.horizontal_wrapped(|ui| { + let base = g.stops.first().map(|s| s.color.clone()).unwrap_or(ColorRef::scheme(SchemeSlot::Accent1)); + let mut light = base.clone(); + light.mods.push(ColorTransform::LumMod(40_000)); + light.mods.push(ColorTransform::LumOff(60_000)); + let mut dark = base.clone(); + dark.mods.push(ColorTransform::LumMod(50_000)); + let variants: Vec = vec![ + default_gradient(base.clone(), 90.0), + Gradient { + stops: vec![GradientStop { pos: 0.0, color: light.clone() }, GradientStop { pos: 1.0, color: base.clone() }], + ..default_gradient(base.clone(), 90.0) + }, + Gradient { + stops: vec![GradientStop { pos: 0.0, color: base.clone() }, GradientStop { pos: 1.0, color: dark.clone() }], + ..default_gradient(base.clone(), 0.0) + }, + Gradient { + stops: vec![ + GradientStop { pos: 0.0, color: light.clone() }, + GradientStop { pos: 0.5, color: base.clone() }, + GradientStop { pos: 1.0, color: dark.clone() }, + ], + ..default_gradient(base.clone(), 45.0) + }, + Gradient { + stops: vec![GradientStop { pos: 0.0, color: light }, GradientStop { pos: 1.0, color: dark }], + shape: GradientShape::Path { path: "circle".into(), focus: [0.5, 0.5, 0.5, 0.5] }, + rotate_with_shape: true, + }, + Gradient { + stops: vec![GradientStop { pos: 0.0, color: base.clone() }, GradientStop { pos: 1.0, color: with_alpha(base, 0.0) }], + ..default_gradient(ColorRef::scheme(SchemeSlot::Accent1), 90.0) + }, + ]; + for v in variants { + let (r, resp) = ui.allocate_exact_size(vec2(34.0, 22.0), Sense::click()); + paint_bar(ui, r.shrink(2.0), &v, sc); + if resp.hovered() { + ui.painter().rect_stroke(r, CornerRadius::same(2), Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + } + if resp.clicked() { + out = Some(v); + } + } + }); + + // Type and direction. + let kind = match &g.shape { + GradientShape::Linear { .. } => "Linear", + GradientShape::Path { path, .. } if path == "rect" => "Rectangular", + GradientShape::Path { path, .. } if path == "shape" => "Path", + GradientShape::Path { .. } => "Radial", + }; + ui.horizontal(|ui| { + ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Type")); + egui::ComboBox::from_id_salt((id, "kind")).selected_text(kind).width(120.0).show_ui(ui, |ui| { + for k in ["Linear", "Radial", "Rectangular", "Path"] { + if ui.selectable_label(kind == k, k).clicked() && kind != k { + let focus = match &g.shape { + GradientShape::Path { focus, .. } => *focus, + _ => [0.5, 0.5, 0.5, 0.5], + }; + let shape = match k { + "Linear" => GradientShape::Linear { angle: 90.0, scaled: false }, + "Rectangular" => GradientShape::Path { path: "rect".into(), focus }, + "Path" => GradientShape::Path { path: "shape".into(), focus }, + _ => GradientShape::Path { path: "circle".into(), focus }, + }; + out = Some(Gradient { shape, ..g.clone() }); + } + } + }); + }); + ui.horizontal_wrapped(|ui| { + ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Direction")); + match &g.shape { + GradientShape::Linear { angle, .. } => { + for (l, a) in DIRECTIONS_LINEAR { + let on = (angle - a).abs() < 0.5; + if ui.selectable_label(on, l).on_hover_text(format!("{a}°")).clicked() { + out = Some(Gradient { shape: GradientShape::Linear { angle: a, scaled: false }, ..g.clone() }); + } + } + } + GradientShape::Path { path, focus } => { + for (l, f) in DIRECTIONS_PATH { + let on = focus.iter().zip(f).all(|(a, b)| (a - b).abs() < 1e-3); + if ui.selectable_label(on, l.trim_start_matches("From ")).on_hover_text(l).clicked() { + out = Some(Gradient { shape: GradientShape::Path { path: path.clone(), focus: f }, ..g.clone() }); + } + } + } + } + }); + if let GradientShape::Linear { angle, .. } = &g.shape { + ui.horizontal(|ui| { + ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Angle")); + if let Some(a) = widgets::spinner(ui, &format!("{id}-angle"), *angle, 15.0, "°", 70.0) { + out = Some(Gradient { shape: GradientShape::Linear { angle: a.rem_euclid(360.0), scaled: false }, ..g.clone() }); + } + }); + } + + // Stop bar: click to add, drag a stop to move it. + ui.add_space(4.0); + ui.label("Gradient stops"); + let (bar, bar_resp) = ui.allocate_exact_size(vec2(ui.available_width().min(260.0), 18.0), Sense::click()); + paint_bar(ui, bar, g, sc); + let (marks, _) = ui.allocate_exact_size(vec2(bar.width(), 14.0), Sense::hover()); + let drag_id = ui.id().with((id, "drag")); + let mut dragging: Option<(usize, f64)> = ui.data_mut(|d| d.get_temp(drag_id)); + for (i, s) in g.stops.iter().enumerate() { + let pos = dragging.filter(|d| d.0 == i).map_or(s.pos, |d| d.1); + let x = bar.min.x + pos as f32 * bar.width(); + let r = Rect::from_center_size(pos2(x, marks.center().y), vec2(10.0, 12.0)); + let resp = ui.interact(r, ui.id().with((id, "mark", i)), Sense::click_and_drag()); + let fill = c32(&s.color, sc); + let stroke = if i == sel { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.handle_stroke) }; + let tri = vec![pos2(x, r.min.y), pos2(r.max.x, r.min.y + 4.0), pos2(r.max.x, r.max.y), pos2(r.min.x, r.max.y), pos2(r.min.x, r.min.y + 4.0)]; + ui.painter().add(egui::Shape::convex_polygon(tri, fill, stroke)); + if resp.clicked() || resp.drag_started() { + sel = i; + } + if resp.dragged() + && let Some(p) = resp.interact_pointer_pos() + { + dragging = Some((i, ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0) as f64)); + } + if resp.drag_stopped() + && let Some((di, dp)) = dragging.take() + { + let mut ng = g.clone(); + if let Some(st) = ng.stops.get_mut(di) { + st.pos = (dp * 1000.0).round() / 1000.0; + } + out = Some(ng); + } + } + ui.data_mut(|d| match dragging { + Some(v) => { + d.insert_temp(drag_id, v); + } + None => d.remove::<(usize, f64)>(drag_id), + }); + if bar_resp.clicked() + && let Some(p) = bar_resp.interact_pointer_pos() + && g.stops.len() < 10 + { + // A new stop takes the colour of the nearest stop. + let pos = (((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0) as f64 * 1000.0).round() / 1000.0; + let near = g.stops.iter().min_by(|a, b| (a.pos - pos).abs().total_cmp(&(b.pos - pos).abs())).map(|s| s.color.clone()); + let mut ng = g.clone(); + ng.stops.push(GradientStop { pos, color: near.unwrap_or(ColorRef::scheme(SchemeSlot::Accent1)) }); + ng.stops.sort_by(|a, b| a.pos.total_cmp(&b.pos)); + sel = ng.stops.iter().position(|s| s.pos == pos).unwrap_or(0); + out = Some(ng); + } + + // The selected stop. + if let Some(s) = g.stops.get(sel) { + ui.horizontal(|ui| { + ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Color")); + let (sw, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); + ui.painter().rect(sw, CornerRadius::same(3), c32(&s.color, sc), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + let r = widgets::drop_button(ui, "", vec2(24.0, 20.0), true); + egui::Popup::menu(&r).show(|ui| { + if let Some(Some(c)) = widgets::color_grid(ui, sc, None) { + let mut ng = g.clone(); + ng.stops[sel].color = with_alpha(c, alpha_of(&s.color)); + out = Some(ng); + } + }); + let can_remove = g.stops.len() > 2; + if ui.add_enabled(can_remove, egui::Button::new("Remove stop")).clicked() { + let mut ng = g.clone(); + ng.stops.remove(sel); + sel = sel.saturating_sub(1); + out = Some(ng); + } + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Position")); + if let Some(v) = widgets::spinner(ui, &format!("{id}-pos-{sel}"), (s.pos * 100.0).round(), 5.0, "%", 70.0) { + let mut ng = g.clone(); + ng.stops[sel].pos = (v / 100.0).clamp(0.0, 1.0); + ng.stops.sort_by(|a, b| a.pos.total_cmp(&b.pos)); + out = Some(ng); + } + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Transparency")); + let key = ui.id().with((id, "tr", sel)); + let mut tr: f32 = ui.data_mut(|d| d.get_temp(key).unwrap_or(((1.0 - alpha_of(&s.color)) * 100.0) as f32)); + let r = ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).suffix("%")); + if r.changed() { + ui.data_mut(|d| d.insert_temp(key, tr)); + } + if r.drag_stopped() || (r.changed() && !r.dragged()) { + let mut ng = g.clone(); + ng.stops[sel].color = with_alpha(s.color.clone(), 1.0 - tr as f64 / 100.0); + ui.data_mut(|d| d.remove::(key)); + out = Some(ng); + } + }); + } + ui.data_mut(|d| d.insert_temp(sel_id, sel)); + out +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 31fcb70..d4431fc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -9,6 +9,7 @@ pub mod canvas; pub mod control; pub mod dialogs; +pub mod fillui; pub mod icons; pub mod menus; pub mod panes; diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index dc7c2fc..f030ce2 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -74,7 +74,7 @@ fn color_button( ui.horizontal(|ui| { ui.label(label); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let r = ui.add(egui::Button::new("Color ▾").min_size(vec2(70.0, 22.0))); + let r = widgets::drop_button(ui, "Color", vec2(70.0, 22.0), true); egui::Popup::menu(&r).show(|ui| { if let Some(c) = widgets::color_grid(ui, &sc, none_label) { let (id, p) = on_pick(c); @@ -293,19 +293,16 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { let _ = app.run("shape.fill", json!({"transparency": v as f64 / 100.0})); } }); - if kind == "gradient" { - ui.horizontal(|ui| { - ui.label("Angle"); - for a in [0, 45, 90, 135, 180, 270] { - if ui.small_button(format!("{a}°")).clicked() { - run( - app, - "shape.fill", - json!({"gradient": {"stops": [[0, "accent1"], [1, {"scheme": "accent1", "lumMod": 50000}]], "angle": a}}), - ); - } - } - }); + if let Some(Fill::Gradient(g)) = &sh.fill { + let sc = scheme(app); + if let Some(ng) = crate::fillui::gradient_editor(ui, "shape-grad", g, &sc) { + run(app, "shape.fill", json!({"gradient": crate::fillui::gradient_json(&ng)})); + } + let mut rot = g.rotate_with_shape; + if ui.checkbox(&mut rot, "Rotate with shape").changed() { + let ng = slidecraft_model::style::Gradient { rotate_with_shape: rot, ..g.clone() }; + run(app, "shape.fill", json!({"gradient": crate::fillui::gradient_json(&ng)})); + } } ui.add_space(8.0); ui.label(egui::RichText::new("Line").font(theme::bold(13.0))); @@ -343,46 +340,104 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { } fn background(app: &mut SlideApp, ui: &mut Ui) { - ui.label(egui::RichText::new("Fill").font(theme::bold(13.0))); - if ui.button("Solid fill: theme background").clicked() { - run(app, "design.background", json!({"color": "bg1"})); - } let sc = scheme(app); - ui.horizontal(|ui| { - ui.label("Color"); - let r = ui.button("Color ▾"); - egui::Popup::menu(&r).show(|ui| { - if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { - run(app, "design.background", json!({"color": cref_param(&c)})); - } - }); - }); - ui.label("Gradient presets"); - ui.horizontal_wrapped(|ui| { - for (a, b) in [("accent1", "accent4"), ("accent2", "accent6"), ("dk2", "accent1"), ("lt2", "bg1"), ("accent3", "accent5")] { - let (r, resp) = ui.allocate_exact_size(vec2(44.0, 26.0), Sense::click()); - let ca = theme::to_color32(sc.get(slidecraft_color::SchemeSlot::from_xml(a).unwrap_or(slidecraft_color::SchemeSlot::Accent1))); - let cb = theme::to_color32(sc.get(slidecraft_color::SchemeSlot::from_xml(b).unwrap_or(slidecraft_color::SchemeSlot::Accent1))); - let mut mesh = egui::Mesh::default(); - mesh.colored_vertex(r.left_top(), ca); - mesh.colored_vertex(r.right_top(), cb); - mesh.colored_vertex(r.right_bottom(), cb); - mesh.colored_vertex(r.left_bottom(), ca); - mesh.add_triangle(0, 1, 2); - mesh.add_triangle(0, 2, 3); - ui.painter().add(egui::Shape::mesh(mesh)); - if resp.clicked() { - run(app, "design.background", json!({"gradient": {"stops": [[0, a], [1, b]], "angle": 0}})); + let bg = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.background.clone())); + let fill = match &bg { + Some(slidecraft_model::Background::Fill { fill }) => Some(fill.clone()), + _ => None, + }; + let kind = match &fill { + Some(Fill::Solid { .. }) => "solid", + Some(Fill::Gradient(_)) => "gradient", + Some(Fill::Picture(_)) => "picture", + Some(Fill::Pattern(_)) => "pattern", + _ => "auto", + }; + // What "Apply to All" re-applies: the current slide's background as command params. + let mut current: Option = None; + ui.label(egui::RichText::new("Fill").font(theme::bold(13.0))); + for (l, k) in [ + ("Solid fill", "solid"), + ("Gradient fill", "gradient"), + ("Picture or texture fill", "picture"), + ("Pattern fill", "pattern"), + ("Follow the layout", "auto"), + ] { + if ui.radio(kind == k, l).clicked() && kind != k { + match k { + "solid" => run(app, "design.background", json!({"color": "bg1"})), + "gradient" => { + let mut g = crate::fillui::default_gradient(slidecraft_model::ColorRef::scheme(slidecraft_color::SchemeSlot::Bg2), 90.0); + g.rotate_with_shape = false; + run(app, "design.background", json!({"gradient": crate::fillui::gradient_json(&g)})); + } + "picture" => { + if let Some(pick) = app.services.pick_open.as_mut() + && let Some(path) = pick("picture") + && let Some(Ok(bytes)) = app.services.read.as_ref().map(|r| r(&path)) + { + let _ = app.run("design.background", json!({"picture": slidecraft_engine::cmd::base64_encode(&bytes)})); + } + } + "pattern" => run(app, "design.background", json!({"pattern": {"preset": "pct20", "fg": "accent1", "bg": "bg1"}})), + _ => run(app, "design.background", json!({"reset": true})), } } - }); - if ui.button("Picture fill…").clicked() - && let Some(pick) = app.services.pick_open.as_mut() - && let Some(path) = pick("picture") - && let Some(Ok(bytes)) = app.services.read.as_ref().map(|r| r(&path)) - { - let _ = app.run("design.background", json!({"picture": slidecraft_engine::cmd::base64_encode(&bytes)})); } + ui.add_space(4.0); + match &fill { + Some(Fill::Solid { color }) => { + current = Some(json!({"color": cref_param(color)})); + ui.horizontal(|ui| { + ui.label("Color"); + let r = widgets::drop_button(ui, "Color", vec2(70.0, 20.0), true); + egui::Popup::menu(&r).show(|ui| { + if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { + run(app, "design.background", json!({"color": cref_param(&c)})); + } + }); + }); + } + Some(Fill::Gradient(g)) => { + current = Some(json!({"gradient": crate::fillui::gradient_json(g)})); + if let Some(ng) = crate::fillui::gradient_editor(ui, "bg-grad", g, &sc) { + run(app, "design.background", json!({"gradient": crate::fillui::gradient_json(&ng)})); + } + } + Some(Fill::Pattern(pt)) => { + current = Some(json!({"pattern": {"preset": pt.preset, "fg": cref_param(&pt.fg), "bg": cref_param(&pt.bg)}})); + ui.horizontal_wrapped(|ui| { + for preset in ["pct5", "pct20", "pct50", "ltHorz", "ltVert", "smGrid", "dkDnDiag", "wave", "dotGrid", "weave"] { + if ui.selectable_label(pt.preset == preset, preset).clicked() { + run(app, "design.background", json!({"pattern": {"preset": preset, "fg": cref_param(&pt.fg), "bg": cref_param(&pt.bg)}})); + } + } + }); + for (label, fg) in [("Foreground", true), ("Background", false)] { + ui.horizontal(|ui| { + ui.label(label); + let r = widgets::drop_button(ui, "Color", vec2(70.0, 20.0), true); + egui::Popup::menu(&r).show(|ui| { + if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { + let (f, b) = if fg { (c, pt.bg.clone()) } else { (pt.fg.clone(), c) }; + run(app, "design.background", json!({"pattern": {"preset": pt.preset, "fg": cref_param(&f), "bg": cref_param(&b)}})); + } + }); + }); + } + } + Some(Fill::Picture(_)) => { + if ui.button("Insert picture from file…").clicked() + && let Some(pick) = app.services.pick_open.as_mut() + && let Some(path) = pick("picture") + && let Some(Ok(bytes)) = app.services.read.as_ref().map(|r| r(&path)) + { + let _ = app.run("design.background", json!({"picture": slidecraft_engine::cmd::base64_encode(&bytes)})); + } + } + _ => {} + } + ui.add_space(4.0); let hide = app.session.active().and_then(|d| d.current_slide().map(|s| !s.show_master_shapes)).unwrap_or(false); let mut h = hide; if ui.checkbox(&mut h, "Hide background graphics").changed() { @@ -390,11 +445,11 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { } ui.add_space(8.0); ui.horizontal(|ui| { - if ui.button("Apply to All").clicked() { - let bg = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.background.clone())); - if let Some(slidecraft_model::Background::Fill { fill: Fill::Solid { color } }) = bg { - run(app, "design.background", json!({"color": cref_param(&color), "all": true})); - } + if ui.add_enabled(current.is_some(), egui::Button::new("Apply to All")).clicked() + && let Some(Value::Object(mut o)) = current.clone() + { + o.insert("all".into(), json!(true)); + run(app, "design.background", Value::Object(o)); } if ui.button("Reset Background").clicked() { run(app, "design.background", json!({"reset": true})); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 13e43f7..aa01ed3 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -505,7 +505,17 @@ pub fn cref_param(c: &slidecraft_model::ColorRef) -> Value { } Value::Object(o) } - slidecraft_model::ColorBase::Rgb { rgb } => json!(format!("#{}", rgb.hex())), + slidecraft_model::ColorBase::Rgb { rgb } if c.mods.is_empty() => json!(format!("#{}", rgb.hex())), + slidecraft_model::ColorBase::Rgb { rgb } => { + let mut o = serde_json::Map::new(); + o.insert("rgb".into(), json!(format!("#{}", rgb.hex()))); + for m in &c.mods { + if let Some(v) = m.value() { + o.insert(m.xml_name().into(), json!(v)); + } + } + Value::Object(o) + } _ => json!("#000000"), } } @@ -1329,7 +1339,7 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) { { run(app, "transition.set", json!({"kind": x.0})); } - let more = ui.add(egui::Button::new("▾").min_size(vec2(14.0, 52.0))); + let more = widgets::drop_button(ui, "", vec2(14.0, 52.0), true); egui::Popup::menu(&more).width(480.0).show(|ui| { for cat in ["Subtle", "Exciting", "Dynamic Content"] { ui.label(egui::RichText::new(cat).font(theme::bold(12.0))); @@ -1542,7 +1552,7 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { { run(app, "animation.set", json!({"effect": a.0, "class": a.2.xml()})); } - let more = ui.add_enabled(has_sel, egui::Button::new("▾").min_size(vec2(14.0, 52.0))); + let more = widgets::drop_button(ui, "", vec2(14.0, 52.0), has_sel); egui::Popup::menu(&more).width(420.0).show(|ui| { for (cls, label) in [(AnimClass::Entrance, "Entrance"), (AnimClass::Emphasis, "Emphasis"), (AnimClass::Exit, "Exit"), (AnimClass::Path, "Motion Paths")] @@ -2153,7 +2163,7 @@ fn table_design(app: &mut SlideApp, ui: &mut Ui) { { run(app, "table.style", json!({"style": id})); } - let more = ui.add(egui::Button::new("▾").min_size(vec2(14.0, 40.0))); + let more = widgets::drop_button(ui, "", vec2(14.0, 40.0), true); egui::Popup::menu(&more).width(360.0).show(|ui| { ui.horizontal_wrapped(|ui| { for (id, label) in &styles { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 12698ba..e40a8a7 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -431,3 +431,12 @@ pub fn section(ui: &mut Ui, title: &str, open: bool) -> bool { p.text(pos2(rect.min.x + 18.0, rect.center().y), Align2::LEFT_CENTER, title, theme::bold(13.0), t.text); if resp.clicked() { !open } else { open } } + +/// A button with an optional label and a painted dropdown chevron (the UI font has no ▾). +pub fn drop_button(ui: &mut Ui, label: &str, size: egui::Vec2, enabled: bool) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let r = ui.add_enabled(enabled, egui::Button::new(if label.is_empty() { String::new() } else { format!("{label} ") }).min_size(size)); + let col = if enabled { t.text_dim } else { t.text_faint }; + chevron(ui.painter(), egui::pos2(r.rect.max.x - 8.0, r.rect.center().y), 3.0, col); + r +} diff --git a/docs/parity.md b/docs/parity.md index b78d340..cae1d6a 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -11,11 +11,11 @@ Each row is scored **D** (done), **P** (partial: works but lacks options, UI or Live Presentations), add-ins and VBA macros. -**Weighted breadth parity: 77%** over 187 features. +**Weighted breadth parity: 78%** over 187 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| -| P0 | 72 | 59 | 12 | 1 | 90% | +| P0 | 72 | 61 | 10 | 1 | 92% | | P1 | 65 | 35 | 23 | 7 | 72% | | P2 | 50 | 11 | 11 | 28 | 33% | @@ -25,9 +25,9 @@ Live Presentations), add-ins and VBA macros. | Files | 71% | | Slides | 95% | | Views | 90% | -| Masters & themes | 80% | +| Masters & themes | 85% | | Shapes | 86% | -| Format | 75% | +| Format | 81% | | Arrange | 100% | | Text | 83% | | Tables | 89% | @@ -44,7 +44,7 @@ Live Presentations), add-ins and VBA macros. | Automation (✱) | 100% | | Platforms (✱) | 25% | -Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format background (solid, gradient, picture, pattern), Gradient fill (linear, radial, rectangular, path), Format Shape pane, Edit chart data, Crop (and crop to shape, aspect ratio), Insert audio (any common format), Audio/video playback in the show, Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds. +Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format Shape pane, Edit chart data, Crop (and crop to shape, aspect ratio), Insert audio (any common format), Audio/video playback in the show, Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds. ## Rows @@ -104,7 +104,7 @@ Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Sh | Masters & themes | Colour schemes (built-in and custom) | P0 | D | | Masters & themes | Font schemes (built-in and custom) | P0 | D | | Masters & themes | Effect schemes / background styles | P2 | P | -| Masters & themes | Format background (solid, gradient, picture, pattern) | P0 | P | +| Masters & themes | Format background (solid, gradient, picture, pattern) | P0 | D | | Masters & themes | Hide background graphics | P1 | D | | Masters & themes | Slide size (16:9, 4:3, presets, custom) | P0 | D | | Masters & themes | Header & footer (date, slide number, footer) | P1 | D | @@ -125,7 +125,7 @@ Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Sh | Shapes | Shape quick styles | P1 | D | | Shapes | Set as default shape | P2 | D | | Format | Solid fill and transparency | P0 | D | -| Format | Gradient fill (linear, radial, rectangular, path) | P0 | P | +| Format | Gradient fill (linear, radial, rectangular, path) | P0 | D | | Format | Picture and texture fill | P1 | P | | Format | Pattern fill | P2 | P | | Format | Line colour, width, dash, compound, cap, join | P0 | D |