Merge pull request #2796 from dotCooCoo/fix/appearance-inline-3d-and-channel-dropdowns

Appearance: the inline effect editor shows 3D, Warp, Rasterize and channel choices as dropdowns
This commit is contained in:
BFlat
2026-10-18 13:20:53 +07:00
committed by GitHub
13 changed files with 248 additions and 38 deletions
+19 -5
View File
@@ -48,15 +48,29 @@ enum Channel {
impl Channel {
fn of(p: &Value) -> Self {
match text(p, "channel", "rgb").to_ascii_lowercase().as_str() {
"red" | "r" => Channel::One(0),
"green" | "g" => Channel::One(1),
"blue" | "b" => Channel::One(2),
_ => Channel::All,
Self::parse(text(p, "channel", "rgb")).unwrap_or(Channel::All)
}
fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"rgb" => Some(Channel::All),
"red" | "r" => Some(Channel::One(0)),
"green" | "g" => Some(Channel::One(1)),
"blue" | "b" => Some(Channel::One(2)),
_ => None,
}
}
}
/// The channels Curves and Levels apply to (`channel`), as their parameters list them.
pub const CHANNELS: [&str; 4] = ["rgb", "red", "green", "blue"];
/// Do Curves and Levels read `s` as their `channel`: one of [`CHANNELS`], or r, g or b, in any
/// case? They draw any other value on all three channels, as RGB.
pub fn is_channel(s: &str) -> bool {
Channel::parse(s).is_some()
}
/// Entries of a sampled tone curve.
const LUT: usize = 256;
+4 -1
View File
@@ -59,7 +59,10 @@ use serde_json::{Map, Value, json};
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
pub use adjust::{ADJUSTMENTS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment};
pub use adjust::{
ADJUSTMENTS, CHANNELS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment,
is_channel,
};
pub use bake::{StrokeArt, bake_appearance, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake};
pub use clip::clip_outline;
pub use group::{
+2
View File
@@ -139,6 +139,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "`params` must be an object"));
}
super::params::check_effect_color(C, id, &params)?;
super::params::check_effect_channel(C, id, &params)?;
// Crop Marks take their style from the preferences when applied.
if id == effects::CROP_MARKS && params.get("style").is_none() {
params["style"] = json!(s.crop_mark_style().id());
@@ -317,6 +318,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
let edited = set_params_effects(s, p);
for effect in &edited {
super::params::check_effect_color(C, effect, &params)?;
super::params::check_effect_channel(C, effect, &params)?;
}
if edited.iter().any(|e| e == GLASS) {
load_texture(&mut params, C)?;
+23 -6
View File
@@ -14,8 +14,9 @@
//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than its
//! default, with a parameter error ([`check_effect_params`]). [`Session::execute`] itself takes
//! what it is given, so the journal of an older version still replays, except for an effect's
//! `color` that the effect can't read, which `effect.apply` and `effect.setParams` refuse
//! themselves ([`check_effect_color`]).
//! `color` that the effect can't read and a Levels or Curves `channel` that the effect doesn't
//! have, which `effect.apply` and `effect.setParams` refuse themselves ([`check_effect_color`],
//! [`check_effect_channel`]).
use serde_json::Value;
@@ -214,9 +215,10 @@ fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError {
/// `params` of built-in effect `effect`, as command `id` was given them (#1592): only keys the
/// effect takes (those of its dialog defaults and of its params description), each of its
/// default's type, and a `color` the effect can draw ([`check_effect_color`]). Numbers out of
/// range pass: the effect clamps them when it draws. An effect plug-in resolves its own against
/// its manifest, and an unknown effect is the command's to report.
/// default's type, a `color` the effect can draw ([`check_effect_color`]) and a Levels or Curves
/// `channel` the effect has ([`check_effect_channel`]). Numbers out of range pass: the effect
/// clamps them when it draws. An effect plug-in resolves its own against its manifest, and an
/// unknown effect is the command's to report.
pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> {
let Value::Object(given) = params else { return Ok(()) };
if effect.starts_with(vectorcraft_plugins::effect::PREFIX) {
@@ -243,7 +245,8 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()>
_ => {}
}
}
check_effect_color(id, effect, params)
check_effect_color(id, effect, params)?;
check_effect_channel(id, effect, params)
}
/// Can an effect read `v` as its `color`: a string [`Color::from_hex`] reads (`#rrggbb`, `#rgb`,
@@ -280,6 +283,20 @@ pub(crate) fn check_effect_color(id: &str, effect: &str, params: &Value) -> Resu
Ok(())
}
/// The `channel` in `params` of Levels or Curves (`effect`), as command `id` was given it: a
/// parameter error naming the channels unless the effect reads it
/// ([`vectorcraft_render::effects::is_channel`]); the effect draws any other value as RGB.
/// `effect.apply` and `effect.setParams` run this themselves before they edit, as they run
/// [`check_effect_color`].
pub(crate) fn check_effect_channel(id: &str, effect: &str, params: &Value) -> Result<()> {
let key = "channel";
let Some(v) = params.get(key) else { return Ok(()) };
if !matches!(effect, "adjust.levels" | "adjust.curves") || v.as_str().is_some_and(vectorcraft_render::effects::is_channel) {
return Ok(());
}
Err(bad(id, format!("`{key}` must be one of {}", vectorcraft_render::effects::CHANNELS.join(", "))))
}
/// Commands whose params aren't checked yet (#1442): they read keys in ways the audit of the
/// descriptions couldn't follow (a list of keys in a loop, a whole object read as settings), so
/// their descriptions aren't proven to name every key they take. Sorted; take a command out once
+42
View File
@@ -96,6 +96,48 @@ fn an_effect_color_it_cannot_draw_is_refused() {
assert_eq!(back.layers[0].children().unwrap()[0].appearance.effects[0].params["color"], json!("not-a-color"));
}
/// A `channel` that Levels or Curves doesn't have (which they drew as RGB) is refused by
/// `effect.apply`, `appearance.addEffect` and `effect.setParams` themselves, not only by the check,
/// and the document and its undo history stay as they were. The channels they read pass. A
/// document saved with another channel still opens with it.
#[test]
fn a_tone_curve_channel_it_does_not_have_is_refused() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap();
s.execute("effect.apply", &json!({"effect": "adjust.levels", "params": {"channel": "red"}})).unwrap();
let state = |s: &Session| {
let st = s.active().unwrap();
(st.doc.clone(), st.history.undo.len())
};
let before = state(&s);
let why = "`channel` must be one of rgb, red, green, blue";
for bad in [json!("alpha"), json!("cyan"), json!(""), json!("reds"), json!(null)] {
for (cmd, params) in [
("effect.setParams", json!({"index": 0, "params": {"channel": bad}})),
("effect.apply", json!({"effect": "adjust.curves", "params": {"channel": bad}})),
("appearance.addEffect", json!({"effect": "adjust.levels", "params": {"channel": bad}})),
] {
let e = s.execute(cmd, &params).unwrap_err().to_string();
assert!(e.contains(why), "{cmd} {params}: {e}");
let checked = s.check_params(cmd, &params).unwrap_err().to_string();
assert!(checked.contains(why), "{cmd} {params}: {checked}");
assert!(state(&s) == before, "{cmd} {params} changed the document or its undo history");
}
}
let stored = |s: &Session| s.active().unwrap().doc.layers[0].children().unwrap()[0].appearance.effects[0].params["channel"].clone();
for good in [json!("rgb"), json!("green"), json!("blue"), json!("Red"), json!("RGB"), json!("r"), json!("G"), json!("b")] {
let params = json!({"index": 0, "params": {"channel": good}});
s.check_params("effect.setParams", &params).unwrap_or_else(|e| panic!("{good}: {e}"));
s.execute("effect.setParams", &params).unwrap_or_else(|e| panic!("{good}: {e}"));
assert_eq!(stored(&s), good);
}
let mut doc = (*s.active().unwrap().doc).clone();
doc.node_mut(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params["channel"] = json!("alpha");
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(back.layers[0].children().unwrap()[0].appearance.effects[0].params["channel"], json!("alpha"));
}
/// What a command adds to its own journal entry passes the check, so a recorded journal can be
/// sent back through the control channel or MCP.
#[test]
+65 -10
View File
@@ -290,17 +290,51 @@ fn choices(effect: &str, key: &str) -> Option<form::Choices> {
}
}
/// The parameters whose dialogs list their values in their own bodies, as (label, value), each
/// label as that dialog passes it to its dropdown or radio buttons, which translate it into the UI
/// language: Revolve's edge, Extrude & Bevel's bevel (Rounded translated in the "3d" context
/// first), every Warp style's orientation, Rasterize's Color Model (its `document` value labeled
/// CMYK when `cmyk`, for a document in CMYK, and RGB otherwise), Background and Anti-aliasing, and
/// the channel of Levels and Curves.
pub(super) fn own_choices(effect: &str, key: &str, cmyk: bool) -> Option<Vec<(&'static str, &'static str)>> {
let c3 = |s: &'static str| crate::i18n::tr_ctx(crate::i18n::current(), "3d", s);
Some(match (effect, key) {
(vectorcraft_effects::REVOLVE, "edge") => vec![(tl!("Left"), "left"), (tl!("Right"), "right")],
(vectorcraft_effects::EXTRUDE, "bevel") => vec![
(tl!("None"), "none"),
(tl!("Classic"), "classic"),
(c3("Rounded"), "rounded"),
(tl!("Cove"), "cove"),
(tl!("Step"), "step"),
(tl!("Ogee"), "ogee"),
],
(_, "orientation") if super::warp_effect::is_warp(effect) => vec![(tl!("Horizontal"), "horizontal"), (tl!("Vertical"), "vertical")],
(vectorcraft_effects::RASTERIZE, "colorModel") => {
vec![(if cmyk { "CMYK" } else { "RGB" }, "document"), (tl!("Grayscale"), "grayscale"), (tl!("Bitmap"), "bitmap")]
}
(vectorcraft_effects::RASTERIZE, "background") => vec![(tl!("White"), "white"), (tl!("Transparent"), "transparent")],
(vectorcraft_effects::RASTERIZE, "antiAlias") => vectorcraft_render::AntiAlias::ALL.iter().map(|a| (a.label(), a.id())).collect(),
("adjust.levels" | "adjust.curves", "channel") => vec![("RGB", "rgb"), (tl!("Red"), "red"), (tl!("Green"), "green"), (tl!("Blue"), "blue")],
_ => return None,
})
}
/// The values that parameter `key` of applied effect `effect` picks from, as its dialog lists
/// them, as (label, value): our own effects' labels in the UI language, a plug-in's choices as the
/// plug-in names them. Glass's Texture menu ends with Loaded Texture when the effect's values
/// `params` hold a loaded texture. `None` for a parameter that is not one of these lists.
pub(crate) fn shown_choices(effect: &str, key: &str, params: &Map<String, Value>) -> Option<Vec<(String, String)>> {
/// `params` hold a loaded texture, and the lists of [`own_choices`] are translated as their
/// dialogs translate them (`cmyk`: the document is in CMYK). `None` for a parameter that is not one
/// of these lists.
pub(crate) fn shown_choices(effect: &str, key: &str, params: &Map<String, Value>, cmyk: bool) -> Option<Vec<(String, String)>> {
if let Some(plugin) = vectorcraft_plugins::effect::installed(effect) {
return plugin.manifest().params.iter().find(|(k, _)| k == key).and_then(|(_, spec)| match spec {
vectorcraft_plugins::ParamSpec::Choice { options, .. } => Some(options.iter().map(|o| (o.clone(), o.clone())).collect()),
_ => None,
});
}
if let Some(own) = own_choices(effect, key, cmyk) {
return Some(own.into_iter().map(|(label, value)| (tl!(label).to_string(), value.to_string())).collect());
}
let options = if effect == GLASS && key == "texture" {
use vectorcraft_effects::pixel::{TEXTURE_IMAGE, texture};
glass_textures(params.get(TEXTURE_IMAGE).and_then(Value::as_str).and_then(texture).is_some())
@@ -373,14 +407,9 @@ fn adjust_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::slider_w(ui, d, (key, label, LABEL_W), *min..=*max, "", rail);
}
if d.fields.contains_key("channel") {
form::choice(
ui,
d,
"channel",
tl!("Channel"),
(LABEL_W, 120.0),
&[("rgb", "RGB"), ("red", tl!("Red")), ("green", tl!("Green")), ("blue", tl!("Blue"))],
);
let channels: Vec<(&str, &str)> =
own_choices(&id, "channel", false).unwrap_or_default().into_iter().map(|(label, value)| (value, label)).collect();
form::choice(ui, d, "channel", tl!("Channel"), (LABEL_W, 120.0), &channels);
}
if d.fields.contains_key("points") {
ui.horizontal(|ui| {
@@ -670,6 +699,32 @@ mod tests {
}
}
/// The lists that dialogs draw in their own bodies hold the values the effect's parameter
/// documentation lists, in its order; Rasterize's Color Model names the document's mode first.
#[test]
fn lists_drawn_in_a_dialogs_body_are_the_documented_values() {
for (effect, key) in [
(vectorcraft_effects::REVOLVE, "edge"),
(vectorcraft_effects::EXTRUDE, "bevel"),
("warp.arc", "orientation"),
("warp.twist", "orientation"),
(vectorcraft_effects::RASTERIZE, "background"),
(vectorcraft_effects::RASTERIZE, "antiAlias"),
("adjust.levels", "channel"),
("adjust.curves", "channel"),
] {
let doc = vectorcraft_effects::effect_info(effect).unwrap().params;
let listed = doc.split(&format!("{key}: ")).nth(1).unwrap().split([',', '}', ' ']).next().unwrap().replace('"', "");
let values: Vec<&str> = own_choices(effect, key, false).unwrap().iter().map(|(_, v)| *v).collect();
assert_eq!(values.join("|"), listed, "{effect} {key}");
}
for (cmyk, mode) in [(false, "RGB"), (true, "CMYK")] {
let models = own_choices(vectorcraft_effects::RASTERIZE, "colorModel", cmyk).unwrap();
assert_eq!(models, [(mode, "document"), ("Grayscale", "grayscale"), ("Bitmap", "bitmap")]);
}
assert!(own_choices("distort.twist", "orientation", false).is_none() && own_choices("adjust.hueSaturation", "channel", false).is_none());
}
/// Glass's Load Texture… reads the picked image into the dialog, the Texture menu shows Loaded
/// Texture and the texture's size instead of its stored text, and OK applies it.
#[test]
+7 -3
View File
@@ -6,9 +6,11 @@
//! Effect › Rasterize… with the same widgets.
use serde_json::{Value, json};
use vectorcraft_effects::RASTERIZE;
use super::DialogSpec;
use super::document_setup::{LABEL, check, choice};
use super::effect::own_choices;
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
@@ -99,8 +101,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
/// Model, Resolution (a preset, Use Document Raster Effects Resolution or Other) and Background,
/// then an Anti-aliasing menu, Create Clipping Mask and Add … Around Object.
pub(super) fn effect_fields(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let own = if app.session.active().is_some_and(|s| s.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk) { "CMYK" } else { "RGB" };
choice(ui, d, "colorModel", tl!("Color Model:"), &[("document", own), ("grayscale", tl!("Grayscale")), ("bitmap", tl!("Bitmap"))]);
let cmyk = app.session.active().is_some_and(|s| s.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk);
let models: Vec<(&str, &str)> =
own_choices(RASTERIZE, "colorModel", cmyk).unwrap_or_default().into_iter().map(|(label, value)| (value, label)).collect();
choice(ui, d, "colorModel", tl!("Color Model:"), &models);
ui.add_space(4.0);
let (ppi, document) = (d.f64("resolution", 72.0), d.bool("useDocumentResolution"));
let preset = RESOLUTIONS.iter().position(|(p, _)| (*p - ppi).abs() < 1e-9);
@@ -128,7 +132,7 @@ pub(super) fn effect_fields(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dia
ui.add_space(6.0);
widgets::label_row(ui, tl!("Background:"), LABEL, |ui| {
let cur = d.str("background");
for (id, label) in [("white", tl!("White")), ("transparent", tl!("Transparent"))] {
for (label, id) in own_choices(RASTERIZE, "background", false).unwrap_or_default() {
if widgets::radio(ui, label, cur == id, true) {
d.fields.insert("background".into(), json!(id));
}
+7 -11
View File
@@ -82,10 +82,12 @@ fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.spacing_mut().item_spacing.x = 6.0;
label(ui, tl!("Axis side"));
let edge = d.str("edge");
let current = if edge == "right" { tl!("Right") } else { tl!("Left") };
let sides = super::effect::own_choices(REVOLVE, "edge", false).unwrap_or_default();
let current = sides.iter().find(|(_, v)| *v == edge).or(sides.first()).map_or("", |(l, _)| *l);
let labels: Vec<&str> = sides.iter().map(|(l, _)| *l).collect();
let width = VALUE_W + rail_width(ui) + 6.0;
if let Some(k) = widgets::dropdown(ui, "revolve-edge", current, &[tl!("Left"), tl!("Right")], width) {
d.fields.insert("edge".into(), json!(if k == 0 { "left" } else { "right" }));
if let Some((_, v)) = widgets::dropdown(ui, "revolve-edge", current, &labels, width).and_then(|k| sides.get(k)) {
d.fields.insert("edge".into(), json!(v));
}
if reset_dot(ui, "edge", edge != "left", tl!("Left")) {
d.fields.insert("edge".into(), json!("left"));
@@ -189,14 +191,8 @@ fn extrude_controls(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
choice(ui, d, "cap", c3("Cap"), &[(json!(true), c3("Solid")), (json!(false), c3("Hollow"))]);
ui.add_space(10.0);
heading(ui, c3("Bevel"));
let shapes = [
(json!("none"), tl!("None")),
(json!("classic"), tl!("Classic")),
(json!("rounded"), c3("Rounded")),
(json!("cove"), tl!("Cove")),
(json!("step"), tl!("Step")),
(json!("ogee"), tl!("Ogee")),
];
let shapes: Vec<(Value, &str)> =
super::effect::own_choices(EXTRUDE, "bevel", false).unwrap_or_default().into_iter().map(|(label, value)| (json!(value), label)).collect();
choice(ui, d, "bevel", tl!("Bevel Shape"), &shapes);
ui.add_enabled_ui(d.str("bevel") != "none", |ui| {
number(ui, d, "bevelWidth", tl!("Width"), (0.0, 1000.0), "", 1, Some(unit));
+1 -1
View File
@@ -73,7 +73,7 @@ pub(super) fn body(ui: &mut egui::Ui, d: &mut Dialog) {
});
let orientation = d.str("orientation");
widgets::label_row(ui, "", LABEL_W, |ui| {
for (value, label) in [("horizontal", tl!("Horizontal")), ("vertical", tl!("Vertical"))] {
for (label, value) in super::effect::own_choices(&id, "orientation", false).unwrap_or_default() {
if widgets::radio(ui, label, orientation == value, true) {
d.fields.insert("orientation".into(), json!(value));
}
+14
View File
@@ -1650,6 +1650,20 @@ fn effect_color_param_error_is_translated() {
}
}
/// The error for a Levels or Curves `channel` the effect doesn't have, whose text the scan above
/// doesn't read, is translated whole in each language of [`COMPLETE_MESSAGES`] and in Bulgarian.
#[test]
fn effect_channel_param_error_is_translated() {
use serde_json::json;
let p = json!({"effect": "adjust.levels", "params": {"channel": "alpha"}});
let e = vectorcraft_engine::check_params("effect.apply", &p).unwrap_err();
let vectorcraft_engine::EngineError::BadParams { msg, .. } = &e else { panic!("{e}") };
for code in COMPLETE_MESSAGES.iter().chain(&["bg"]) {
let shown = message(Lang::from_code(code).unwrap(), &e.to_string());
assert!(!shown.contains(msg.as_str()) && shown.contains("`channel`") && shown.contains("effect.apply"), "{code}: {shown}");
}
}
/// The notes of an EPS or `.ai` whose layers weren't read, which the importer pushes as warnings
/// (the scan above doesn't read them), are translated whole, reason included, as the status bar
/// shows them after opening (#2035).
+54 -1
View File
@@ -735,6 +735,7 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k:
let mut edit = false;
let built_in = built_in_effect(&e.id);
let (unit, relative) = (app.session.general_unit(), params.get("relative").and_then(Value::as_bool).unwrap_or(false));
let cmyk = app.session.active().is_some_and(|s| s.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk);
egui::Frame::NONE.fill(t.panel_darker).inner_margin(egui::Margin { left: (EYE_W + 12.0) as i8, right: 6, top: 4, bottom: 4 }).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 3.0;
if params.is_empty() {
@@ -753,7 +754,7 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k:
}
return;
}
if let Some(options) = crate::dialogs::shown_choices(&e.id, key, &params) {
if let Some(options) = crate::dialogs::shown_choices(&e.id, key, &params, cmyk) {
let cur = v.as_str().unwrap_or_default();
let shown = options.iter().find(|(_, value)| value == cur).map_or(cur, |(label, _)| label.as_str());
let labels: Vec<&str> = options.iter().map(|(label, _)| label.as_str()).collect();
@@ -1176,6 +1177,58 @@ mod tests {
assert_eq!(st.doc.node(id).unwrap().appearance.effects[0].params["texture"], json!("frosted"));
}
/// The choices that their dialogs list in their own bodies are dropdowns with those dialogs'
/// labels too: Revolve's edge, Extrude & Bevel's bevel, Warp's orientation, Rasterize's
/// options and the channel of Levels and Curves. A pick sends the dialog's value as one undo
/// step.
#[test]
fn the_effect_editor_picks_choices_their_own_dialogs_list() {
for (effect, key, shown, pick, value) in [
("threeD.revolve", "edge", "Left", "Right", "right"),
("threeD.extrude", "bevel", "None", "Rounded", "rounded"),
("warp.arc", "orientation", "Horizontal", "Vertical", "vertical"),
("rasterize", "colorModel", "RGB", "Grayscale", "grayscale"),
("rasterize", "background", "Transparent", "White", "white"),
("rasterize", "antiAlias", "Art Optimized", "Type Optimized", "type"),
("adjust.levels", "channel", "RGB", "Red", "red"),
("adjust.curves", "channel", "RGB", "Blue", "blue"),
] {
let (mut app, ctx, id) = editing(effect);
let texts = shown_texts(&mut app, &ctx);
assert!(texts.iter().any(|s| s == shown), "{effect} {key}: {texts:?}");
let undo = app.session.doc().unwrap().history.undo.len();
click_text(&mut app, &ctx, shown);
click_text(&mut app, &ctx, pick);
let st = app.session.doc().unwrap();
assert_eq!(st.doc.node(id).unwrap().appearance.effects[0].params[key], json!(value), "{effect} {key}");
assert_eq!(st.history.undo.len(), undo + 1, "{effect} {key}");
assert!(shown_texts(&mut app, &ctx).iter().any(|s| s == pick), "{effect} {key}");
}
}
/// Those choices read as their dialogs show them in the UI language: in French, the channel's
/// RGB is RVB, and in Spanish, Extrude & Bevel's Rounded is the "3d" row's Redondeado, not the
/// plain row's Redondeada. Rasterize names the document's color mode.
#[test]
fn the_effect_editor_shows_those_choices_as_their_dialogs_do() {
crate::i18n::set_current(crate::i18n::Lang::from_code("fr").unwrap());
let (mut app, ctx, _) = editing("adjust.levels");
let texts = shown_texts(&mut app, &ctx);
assert!(texts.iter().any(|s| s == "RVB"), "{texts:?}");
let es = crate::i18n::Lang::from_code("es").unwrap();
crate::i18n::set_current(es);
let (mut app, ctx, _) = editing("threeD.extrude");
app.run("effect.setParams", json!({"item": null, "index": 0, "params": {"bevel": "rounded"}})).unwrap();
let rounded = crate::i18n::tr_ctx(es, "3d", "Rounded");
assert_ne!(rounded, crate::i18n::tr(es, "Rounded"));
assert!(shown_texts(&mut app, &ctx).iter().any(|s| s == rounded), "{rounded}");
crate::i18n::set_current(crate::i18n::Lang::EN);
let (mut app, ctx, _) = editing("rasterize");
app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap();
let texts = shown_texts(&mut app, &ctx);
assert!(texts.iter().any(|s| s == "CMYK") && !texts.iter().any(|s| s == "document"), "{texts:?}");
}
#[test]
fn the_effect_editor_lists_earlier_parameters_in_their_current_form() {
// A Scribble saved with one `variation` lists it as its Path Overlap and Spacing variations.
+9
View File
@@ -842,6 +842,15 @@ or a boolean is refused as not a string), so the app's effect dialogs and Appear
A document saved with such a color opens as before, and the effect draws its default color (an array of three or more
entries reads an entry that isn't a number as 0).
The `channel` of Levels and Curves must be `rgb`, `red`, `green` or `blue` (`r`, `g`, `b` and any letter case are read
too). `effect.apply` and `effect.setParams` refuse any other value themselves in the same way, and a document saved with
one opens as before and draws it as `rgb`:
```json
{"id": "effect.setParams", "params": {"index": 0, "params": {"channel": "alpha"}}}
→ invalid parameters for `effect.setParams`: `channel` must be one of rgb, red, green, blue
```
A number outside the range the effect documents is still stored as given and clamped when the effect draws. A
plug-in effect's parameters are resolved against its manifest instead, and `effect.setParams` as a step of
`command.batch` isn't checked before the batch runs (the effect it edits may only exist once the steps before it ran);
+1
View File
@@ -660,6 +660,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Fill and stroke paint (Fill, Stroke, None, Apply Last Color and Apply Last Gradient, also on characters selected with the Type tool), the Stroke panel's options, gradient and freeform point edits, Swap Fill and Stroke, Default Fill and Stroke, the Eyedropper, and the Appearance panel's item edits, Clear Appearance, Reduce to Basic Appearance, Remove Item, item reordering and Move Appearance record no undo step when they change nothing.
A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own.
The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values.
Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case).
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document view state saved in native files (#1968); a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. SVG export writes a knockout group of opaque art plainly, without masks, so shapes meeting edge to edge leave no seam; translucent, blended, masked or brushed art knocks out through masks. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel.