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 <noreply@anthropic.com>
This commit is contained in:
parent
b899290f4b
commit
56f31158c7
|
|
@ -167,26 +167,15 @@ fn background(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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<slidecraft_model::style::GradientStop> = 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<Value> {
|
|||
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");
|
||||
|
|
|
|||
|
|
@ -217,7 +217,10 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
})
|
||||
}
|
||||
|
||||
fn gradient_param(v: &Value) -> Option<Gradient> {
|
||||
/// `{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<Gradient> {
|
||||
let stops: Vec<GradientStop> = v
|
||||
.get("stops")?
|
||||
.as_array()?
|
||||
|
|
@ -230,11 +233,27 @@ fn gradient_param(v: &Value) -> Option<Gradient> {
|
|||
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<Value> {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<Value> = 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<Gradient> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut out: Option<Gradient> = 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<Gradient> = 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::<f32>(key));
|
||||
out = Some(ng);
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(sel_id, sel));
|
||||
out
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Value> = 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}));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
<!-- SUMMARY -->
|
||||
**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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue