diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 4ed6f749..fb845a8e 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -231,6 +231,12 @@ impl AppearanceItem { pub fn kind_name(&self) -> &'static str { if self.is_fill() { "fill" } else { "stroke" } } + pub fn paint(&self) -> &Paint { + match self { + AppearanceItem::Fill(f) => &f.paint, + AppearanceItem::Stroke(s) => &s.paint, + } + } pub fn visible(&self) -> bool { match self { AppearanceItem::Fill(f) => f.visible, diff --git a/crates/engine/src/tests_appearance.rs b/crates/engine/src/tests_appearance.rs index cad8e7ac..fb39cdfd 100644 --- a/crates/engine/src/tests_appearance.rs +++ b/crates/engine/src/tests_appearance.rs @@ -245,3 +245,25 @@ fn per_fill_offset_path_renders() { let px = render(&s).pixel(90, 150); assert!(px[0] > 200 && px[3] > 200, "{px:?}"); } + +#[test] +fn text_fill_items_composite_with_their_opacity() { + let mut s = session(); + let t = NodeId(run(&mut s, "text.create", json!({"x": 20, "y": 200, "text": "HIM", "size": 150}))["id"].as_u64().unwrap()); + run(&mut s, "select.set", json!({"ids": [t.0]})); + run(&mut s, "appearance.addFill", json!({})); + run(&mut s, "appearance.setItem", json!({"index": 0, "color": "#ff0000"})); + let render = |s: &Session| { + vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default()) + }; + let opaque = render(&s); + // A pixel the red fill covers fully (over the black character fill). + let (x, y) = (0..400u32).flat_map(|y| (0..400u32).map(move |x| (x, y))).find(|&(x, y)| opaque.pixel(x, y) == [255, 0, 0, 255]).expect("red text"); + run(&mut s, "appearance.setItem", json!({"index": 0, "opacity": 0.5, "blend": "multiply"})); + let px = render(&s).pixel(x, y); + // Half of red multiplied over black: black. + assert!(px[0] < 20 && px[3] == 255, "{px:?}"); + run(&mut s, "appearance.setItem", json!({"index": 0, "blend": "normal"})); + let px = render(&s).pixel(x, y); + assert!((110..=145).contains(&px[0]) && px[1] < 10, "{px:?}"); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 10cfadc6..87458127 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -798,19 +798,28 @@ impl Renderer { if !n.appearance.items.is_empty() { let mut all = g.all.clone(); all.apply_affine(t.xf); - for item in &n.appearance.items { + for item in n.appearance.items.iter().filter(|i| i.visible() && !i.paint().is_none()) { + // Each item composites with its own opacity and blend mode. + let layered = item.opacity() < 1.0 || item.blend() != vectorcraft_color::BlendMode::Normal; + if layered { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(blend_mode(item.blend())), Some(item.opacity()), None, None); + } ctx.set_transform(f.view); match item { - AppearanceItem::Fill(fl) if fl.visible && paint::set_paint(ctx, &fl.paint, tb, f.doc) => { + AppearanceItem::Fill(fl) if paint::set_paint(ctx, &fl.paint, tb, f.doc) => { ctx.set_fill_rule(peniko::Fill::NonZero); ctx.fill_path(&all); } - AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 && paint::set_paint(ctx, &st.paint, tb, f.doc) => { + AppearanceItem::Stroke(st) if st.width > 0.0 && paint::set_paint(ctx, &st.paint, tb, f.doc) => { ctx.set_stroke(kurbo::Stroke::new(st.width)); ctx.stroke_path(&all); } _ => {} } + if layered { + ctx.pop_layer(); + } } } } diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index d30b461a..106a912a 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -59,6 +59,14 @@ pub(super) fn param_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool { d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect(); for (k, v) in keys { ui.label(egui::RichText::new(humanize(&k)).color(t.text)); + if let Some(cur) = crate::widgets::blend_param(&k, &v) { + if let Some(m) = crate::widgets::blend_param_dropdown(ui, ("fx-blend", &k), cur) { + d.fields.insert(k, m); + changed = true; + } + ui.end_row(); + continue; + } match v { Value::Number(n) => { let mut x = n.as_f64().unwrap_or(0.0); diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 900e93da..afa6ac34 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -9,9 +9,9 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeId}; -use super::{first_selected, pstate, set_pstate}; +use super::{first_selected, live_run, pstate, set_pstate}; use crate::theme::Tokens; -use crate::widgets::{self, menu_item}; +use crate::widgets::{self, TransparencyEdit, menu_item}; use crate::{VectorcraftApp, icons}; const ROW: f32 = 30.0; @@ -221,11 +221,7 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { let open: bool = pstate(ui.ctx(), &format!("ap-open-{i}")); let (r, resp) = row(ui, sel == Sel::Item(i)); row_rects.push((i, r)); - let visible = it.visible(); - let (paint, width) = match it { - AppearanceItem::Fill(f) => (&f.paint, 0.0), - AppearanceItem::Stroke(s) => (&s.paint, s.width), - }; + let (visible, paint) = (it.visible(), it.paint()); let is_stroke = !it.is_fill(); if eye(ui, r, ("ap-eye", i), visible, true) { app.run("appearance.setItem", json!({"index": i, "visible": !visible})).ok(); @@ -249,10 +245,10 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { egui::Popup::menu(&cresp).show(|ui| { swatch_picker(app, ui, i); }); - if is_stroke { + if let AppearanceItem::Stroke(st) = it { let fr = Rect::from_min_size(pos2(r.left() + EYE_W + 104.0, r.center().y - 12.0), vec2(56.0, 24.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(fr).layout(egui::Layout::left_to_right(egui::Align::Center))); - if let Some(w) = widgets::num_field(&mut child, ("ap-w", i), Some(width), vectorcraft_doc::Unit::Points, 56.0) { + if let Some(w) = widgets::num_field(&mut child, ("ap-w", i), Some(st.width), vectorcraft_doc::Unit::Points, 56.0) { app.run("appearance.setItem", json!({"index": i, "weight": w})).ok(); } } @@ -269,7 +265,7 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { stopped = true; } if open { - sub_row(app, ui, ("ap-sub-op", i), &format!("Opacity: {}", opacity_text(it.opacity(), it.blend())), true, 1); + opacity_row(app, ui, Some(i), it.opacity(), it.blend()); for (k, e) in it.effects().iter().enumerate() { effect_row(app, ui, Some(i), k, e, sel == Sel::ItemEffect(i, k)); } @@ -297,14 +293,7 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { for (k, e) in n.appearance.effects.iter().enumerate() { effect_row(app, ui, None, k, e, sel == Sel::Effect(k)); } - // Object opacity row. - let (r, resp) = row(ui, false); - eye(ui, r, "ap-op-eye", true, false); - let lx = r.left() + EYE_W + 24.0; - if link(ui, pos2(lx, r.center().y), "ap-op-link", "Opacity:") || resp.double_clicked() { - app.ui.open_panel = Some("transparency".into()); - } - text(ui, pos2(lx + 56.0, r.center().y), &opacity_text(n.opacity, n.blend), false); + opacity_row(app, ui, None, n.opacity, n.blend); if let Some((from, to_row)) = drop && from != to_row { @@ -320,10 +309,24 @@ fn target_index(rows: &[(usize, Rect)], y: f32) -> usize { }) } -fn sub_row(_app: &mut VectorcraftApp, ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug + Copy, label: &str, visible: bool, depth: usize) { - let (r, _) = row(ui, false); - eye(ui, r, (id, "eye"), visible, false); - text(ui, r.left_center() + vec2(EYE_W + 24.0 + depth as f32 * 12.0, 0.0), label, false); +/// The Opacity row of the object (`item: None`) or, indented, of fill/stroke `item`. Clicking it +/// opens a popup with the shared opacity and blend controls (`transparency.set` on that item). +fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, opacity: f32, blend: BlendMode) { + let (r, resp) = row(ui, false); + eye(ui, r, ("ap-op-eye", item), true, false); + let lx = r.left() + EYE_W + 24.0 + if item.is_some() { 12.0 } else { 0.0 }; + let clicked = link(ui, pos2(lx, r.center().y), ("ap-op-link", item), "Opacity:") || resp.clicked(); + text(ui, pos2(lx + 56.0, r.center().y), &opacity_text(opacity, blend), false); + egui::Popup::menu(&resp) + .open_memory(clicked.then_some(egui::SetOpenCommand::Toggle)) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show(|ui| match widgets::opacity_blend(ui, ("ap-op", item), opacity, blend, true) { + Some(TransparencyEdit::Blend(b)) => { + app.run("transparency.set", json!({"item": item, "blend": b.label()})).ok(); + } + Some(TransparencyEdit::Opacity(o, phase)) => live_run(app, "Opacity", "transparency.set", json!({"item": item, "opacity": o}), phase), + None => {} + }); } /// An effect row: of the object (`item: None`) or, indented under it, of fill/stroke `item`. @@ -379,6 +382,12 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: ui.horizontal(|ui| { let label = humanize(key); ui.add_sized(vec2(84.0, 22.0), egui::Label::new(egui::RichText::new(label).size(11.5).color(t.text)).truncate()); + if let Some(cur) = widgets::blend_param(key, v) { + if let Some(m) = widgets::blend_param_dropdown(ui, ("fx-blend", item, k, key.as_str()), cur) { + change = Some((key.clone(), m)); + } + return; + } match v { Value::Bool(b) => { if widgets::check(ui, "", *b, true) { diff --git a/crates/ui-egui/src/panels/tests_appearance.rs b/crates/ui-egui/src/panels/tests_appearance.rs index 1ffb59d2..a49bfcad 100644 --- a/crates/ui-egui/src/panels/tests_appearance.rs +++ b/crates/ui-egui/src/panels/tests_appearance.rs @@ -126,3 +126,34 @@ fn item_effect_rows_toggle_their_eye() { fn current_stroke_effects(app: &VectorcraftApp) -> Vec { first_selected(app).unwrap().appearance.items[1].effects().clone() } + +#[test] +fn blend_separators_follow_the_groups() { + use vectorcraft_color::BlendMode; + let starts: Vec = + (0..BlendMode::ALL.len()).filter(|i| crate::widgets::blend_separator_before(*i)).map(|i| BlendMode::ALL[i]).collect(); + assert_eq!(starts, [BlendMode::Darken, BlendMode::Lighten, BlendMode::Overlay, BlendMode::Difference, BlendMode::Hue]); + assert!(starts.iter().all(|m| m.group() > 0) && !crate::widgets::blend_separator_before(BlendMode::ALL.len())); +} + +#[test] +fn item_opacity_popup_sets_the_items_opacity() { + let mut app = app_with_rect(); + let ctx = egui::Context::default(); + super::set_pstate(&ctx, "ap-open-1", true); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + // The stroke's Opacity sub-row (listed first; the object's own Opacity row is last). + let link = texts.iter().find(|(t, _)| t == "Opacity:").expect("item opacity row").1; + click(&ctx, &mut app, link.center(), appearance::show); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + assert!(texts.iter().any(|(t, _)| t == "Normal"), "the popup shows the blend mode: {texts:?}"); + // Type 50 into the popup's opacity field. + let field = text_rect(&texts, "100%"); + click(&ctx, &mut app, field.center(), appearance::show); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + frame_events(&ctx, &mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND), egui::Event::Text("50".into())], appearance::show); + frame_events(&ctx, &mut app, vec![key(egui::Key::Enter, Default::default())], appearance::show); + let n = first_selected(&app).unwrap(); + assert_eq!(n.appearance.items[1].opacity(), 0.5); + assert_eq!((n.appearance.items[0].opacity(), n.opacity), (1.0, 1.0)); +} diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index f04d9019..c0a75dca 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -8,7 +8,7 @@ use vectorcraft_color::{BlendMode, Paint}; use super::{current_paints, current_transparency, first_selected, live_run, pstate, selection_len, set_pstate}; use crate::theme::Tokens; -use crate::widgets::{self, Live, menu_item}; +use crate::widgets::{self, TransparencyEdit, menu_item}; use crate::{VectorcraftApp, icons}; pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -27,36 +27,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { Some(_) => n.as_ref().map(|n| (n.opacity, n.blend)), } .unwrap_or((1.0, BlendMode::Normal)); - ui.horizontal(|ui| { - let labels: Vec<&str> = BlendMode::ALL.iter().map(|b| b.label()).collect(); - ui.add_enabled_ui(has, |ui| { - if let Some(i) = widgets::dropdown(ui, "tr-blend", blend.label(), &labels, 104.0) { - app.run("transparency.set", json!({"blend": labels[i]})).ok(); - } - }); - widgets::dim_label(ui, "Opacity:"); - ui.spacing_mut().item_spacing.x = 0.0; - ui.add_enabled_ui(has, |ui| { - if let Some(o) = widgets::plain_field(ui, "tr-op", op as f64 * 100.0, "%", 0, 50.0) { - app.run("transparency.set", json!({"opacity": o.clamp(0.0, 100.0)})).ok(); - } - }); - let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if has { Sense::click() } else { Sense::hover() }); - ui.painter().rect_stroke(r, 2, Stroke::new(1.0, t.input_border), StrokeKind::Inside); - icons::paint(ui, "chevron-right", r.shrink2(vec2(3.0, 7.0)), if has { t.icon } else { t.text_disabled }); - egui::Popup::menu(&resp).show(|ui| { - let mut o = op * 100.0; - let r = ui.add(egui::Slider::new(&mut o, 0.0..=100.0).show_value(false)); - let phase = if r.drag_stopped() || (r.changed() && !r.dragged()) { - Live::Released - } else if r.changed() { - Live::Dragging - } else { - Live::Idle - }; - live_run(app, "Opacity", "transparency.set", json!({"opacity": o.round()}), phase); - }); - }); + match widgets::opacity_blend(ui, "tr", op, blend, has) { + Some(TransparencyEdit::Blend(b)) => { + app.run("transparency.set", json!({"blend": b.label()})).ok(); + } + Some(TransparencyEdit::Opacity(o, phase)) => live_run(app, "Opacity", "transparency.set", json!({ "opacity": o }), phase), + None => {} + } widgets::divider(ui); // Thumbnails and the opacity-mask controls. let hide_thumbs: bool = pstate(ui.ctx(), "tr-hide-thumbs"); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 968b6e9b..f51fef63 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1,7 +1,7 @@ //! Shared widgets in Illustrator's panel style. Colours come from theme tokens. use egui::{Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; -use vectorcraft_color::Paint; +use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::Unit; use crate::icons; @@ -269,19 +269,125 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, fill_active: /// A compact dropdown. Returns the chosen index. pub fn dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: &str, options: &[&str], width: f32) -> Option { - let t = Tokens::get(ui.ctx()); - let mut chosen = None; - let resp = egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.input_border)).corner_radius(CornerRadius::same(3)).show(ui, |ui| { - egui::ComboBox::from_id_salt(ui.id().with(id)).selected_text(egui::RichText::new(current).size(12.0)).width(width - 4.0).show_ui(ui, |ui| { - for (i, o) in options.iter().enumerate() { - if ui.selectable_label(*o == current, *o).clicked() { - chosen = Some(i); - } + combo(ui, id, current, width, |ui| { + let mut chosen = None; + for (i, o) in options.iter().enumerate() { + if ui.selectable_label(*o == current, *o).clicked() { + chosen = Some(i); } + } + chosen + }) +} + +/// The recessed combo box of [`dropdown`] showing `current`; `list` draws the options and returns +/// the chosen one. +fn combo( + ui: &mut Ui, + id: impl std::hash::Hash + std::fmt::Debug, + current: &str, + width: f32, + list: impl FnOnce(&mut Ui) -> Option, +) -> Option { + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, t.input_border)) + .corner_radius(CornerRadius::same(3)) + .show(ui, |ui| { + egui::ComboBox::from_id_salt(ui.id().with(id)) + .selected_text(egui::RichText::new(current).size(12.0)) + .width(width - 4.0) + .show_ui(ui, list) + .inner + .flatten() }) + .inner +} + +/// Whether the blend-mode list draws a separator above `BlendMode::ALL[i]`: where a +/// [`BlendMode::group`] starts. +pub fn blend_separator_before(i: usize) -> bool { + i > 0 && i < BlendMode::ALL.len() && BlendMode::ALL[i].group() != BlendMode::ALL[i - 1].group() +} + +/// The blend-mode dropdown, its groups separated (Normal | darken | lighten | contrast | inversion +/// | component modes). Returns the chosen mode. +pub fn blend_dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: BlendMode, width: f32) -> Option { + combo(ui, id, current.label(), width, |ui| { + let mut chosen = None; + for (i, m) in BlendMode::ALL.into_iter().enumerate() { + if blend_separator_before(i) { + ui.separator(); + } + if ui.selectable_label(m == current, m.label()).clicked() { + chosen = Some(m); + } + } + chosen + }) +} + +/// The blend mode an effect parameter names (`"mode": "multiply"`), which editors show as a +/// [`blend_param_dropdown`]. +pub fn blend_param(key: &str, value: &serde_json::Value) -> Option { + value.as_str().filter(|_| key == "mode").and_then(BlendMode::parse) +} + +/// [`blend_dropdown`] for a blend-mode parameter; returns the chosen mode's parameter value. +pub fn blend_param_dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: BlendMode) -> Option { + blend_dropdown(ui, id, current, 120.0).map(|m| serde_json::Value::String(m.label().to_ascii_lowercase())) +} + +/// An edit made with [`opacity_blend`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum TransparencyEdit { + Blend(BlendMode), + /// Opacity in percent, with the slider's drag phase (`Released` for a typed value). + Opacity(f64, Live), +} + +/// The transparency controls shared by the Transparency panel and the Appearance panel's Opacity +/// popups: the grouped blend-mode dropdown, the opacity percent field and its slider popup. +pub fn opacity_blend( + ui: &mut Ui, + id: impl std::hash::Hash + std::fmt::Debug + Copy, + opacity: f32, + blend: BlendMode, + enabled: bool, +) -> Option { + let t = Tokens::get(ui.ctx()); + let mut edit = None; + ui.horizontal(|ui| { + ui.add_enabled_ui(enabled, |ui| { + if let Some(m) = blend_dropdown(ui, (id, "blend"), blend, 104.0) { + edit = Some(TransparencyEdit::Blend(m)); + } + }); + dim_label(ui, "Opacity:"); + ui.spacing_mut().item_spacing.x = 0.0; + ui.add_enabled_ui(enabled, |ui| { + if let Some(o) = plain_field(ui, (id, "opacity"), opacity as f64 * 100.0, "%", 0, 50.0) { + edit = Some(TransparencyEdit::Opacity(o.clamp(0.0, 100.0), Live::Released)); + } + }); + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if enabled { Sense::click() } else { Sense::hover() }); + ui.painter().rect_stroke(r, 2, Stroke::new(1.0, t.input_border), StrokeKind::Inside); + icons::paint(ui, "chevron-right", r.shrink2(vec2(3.0, 7.0)), if enabled { t.icon } else { t.text_disabled }); + egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + let mut o = opacity * 100.0; + let r = ui.add(egui::Slider::new(&mut o, 0.0..=100.0).show_value(false)); + let phase = if r.drag_stopped() || (r.changed() && !r.dragged()) { + Live::Released + } else if r.changed() { + Live::Dragging + } else { + return; + }; + edit = Some(TransparencyEdit::Opacity(o.round() as f64, phase)); + }); }); - let _ = resp; - chosen + edit } /// The 3×3 reference point locator. Returns a new index when clicked.