From fafd06c9594d1592edf4a6f1e537190e180cf9cb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 22:48:57 -0800 Subject: [PATCH 01/48] Recolor Artwork: the Prominent Colors bar weighs the art's colors With art selected, the Edit tab shows a Prominent Colors bar under the wheel: a part for each row in its new color, as wide as the row's share of the art, the largest first. Dragging the edge between two parts moves width from one to the other; when the drag ends, the rows' current colors move toward the new widths and the art previews the result. OK keeps the recolor as one undo step. - recolor.weigh {map, weights?, ids?} returns each row's share of the uses of the art's colors and, with weights, the rows with their current colors moved: one color at a time between rows that have a new color, while a move brings the shares nearer the weights, the color nearest in Lab to the row it goes to first. A row keeps its first color, the tints of one global swatch move together, colors that stay keep their order, and the command changes nothing. A use counts for the row recolor.apply recolors it with, and the work is bounded at 50,000,000 color-by-row comparisons. - The New Color Group menu beside the link button has Save All Colors (the new colors in row order) and Save Prominent Colors (the bar's colors in its order); each saves a color group as its own undo step. - The dialog's weights field drives the bar from ui.dialog.set. A new Colors count drops it. --- crates/color/src/recolor.rs | 157 ++++++++ crates/engine/src/cmd/plugin_call/reviewed.rs | 1 + crates/engine/src/cmd/recolor.rs | 154 +++++++- crates/engine/src/tests/recolor.rs | 121 ++++++ crates/ui-egui/src/dialogs/recolor.rs | 357 ++++++++++++++++-- crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/bs.tsv | 3 + crates/ui-egui/src/i18n/ca.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/da.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hi.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ka.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + docs/mcp.md | 24 ++ docs/ui-parity.md | 2 + 34 files changed, 874 insertions(+), 23 deletions(-) diff --git a/crates/color/src/recolor.rs b/crates/color/src/recolor.rs index 93c0ad0f37..05c386e405 100644 --- a/crates/color/src/recolor.rs +++ b/crates/color/src/recolor.rs @@ -567,6 +567,112 @@ pub fn theme_palette(samples: &[(Color, f32)], n: usize) -> Vec { groups.into_iter().map(|g| g.2).collect() } +/// Colors that [`weigh`] moves from one row to another as one: a color they are shown by, how +/// often the art uses them and the row they are in. A fixed unit (such as the color a row is +/// shown by) stays in its row. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WeighUnit { + pub color: Color, + pub uses: f64, + pub row: usize, + pub fixed: bool, +} + +/// The unit-by-row comparisons one [`weigh`] makes, at most. +const WEIGH_WORK: usize = 50_000_000; + +/// The uses of `u` that count: a positive finite number, else 0. +fn unit_uses(u: &WeighUnit) -> f64 { + if u.uses.is_finite() && u.uses > 0.0 { u.uses } else { 0.0 } +} + +/// Each row's share of the uses of `units`, in percent (0 for every row when nothing is used). +/// Units whose row is past `rows`, or whose uses are not a positive finite number, count nothing. +pub fn row_shares(units: &[WeighUnit], rows: usize) -> Vec { + let mut sums = vec![0.0; rows]; + for u in units { + if let Some(s) = sums.get_mut(u.row) { + *s += unit_uses(u); + } + } + let total: f64 = sums.iter().sum(); + if total <= 0.0 { + return vec![0.0; rows]; + } + sums.iter().map(|s| s / total * 100.0).collect() +} + +/// For Recolor Artwork's Prominent Colors (our own rule): the row each of `units` goes to so that +/// each row's share of the uses comes as near its share of `weights` as whole units allow. One +/// unit moves at a time, while a move brings the rows' shares nearer their weights (the sum of +/// every row's distance from its weight falls); of those moves, the one taken is the unit nearest +/// in Lab (CIEDE2000) to the color of the row it goes to (`anchors`), compared with the row it +/// leaves; on a tie, the move that brings the shares nearest. Fixed units, units in a row past +/// `anchors` and units used no times stay where they are. Weights that are negative or not finite +/// count as 0; when no weight is positive, every unit stays. The moves stop after +/// [`WEIGH_WORK`] comparisons of a unit with a row, and when the movable units times the rows pass +/// it, every unit stays. +pub fn weigh(units: &[WeighUnit], anchors: &[Color], weights: &[f64]) -> Vec { + let mut row: Vec = units.iter().map(|u| u.row).collect(); + let n = anchors.len(); + let given = |r: usize| weights.get(r).copied().filter(|x| x.is_finite() && *x > 0.0).unwrap_or(0.0); + // Scaled by the largest weight first, so the sum stays finite. + let top = (0..n).map(given).fold(0.0, f64::max); + let w = |r: usize| if top > 0.0 { given(r) / top } else { 0.0 }; + let sum_w: f64 = (0..n).map(w).sum(); + let movable: Vec = (0..units.len()).filter(|&i| !units[i].fixed && units[i].row < n && unit_uses(&units[i]) > 0.0).collect(); + let per_pass = movable.len().saturating_mul(n); + if sum_w <= 0.0 || n < 2 || movable.is_empty() || per_pass > WEIGH_WORK { + return row; + } + let mut sums = vec![0.0; n]; + for u in units.iter().filter(|u| u.row < n) { + sums[u.row] += unit_uses(u); + } + let total: f64 = sums.iter().sum(); + let target: Vec = (0..n).map(|r| w(r) / sum_w * total).collect(); + let anchor_labs: Vec = anchors.iter().map(|c| c.to_lab()).collect(); + // Each movable unit's distance to each row's color. + let dist: Vec> = movable + .iter() + .map(|&i| { + let l = units[i].color.to_lab(); + anchor_labs.iter().map(|a| delta_e2000(*a, l)).collect() + }) + .collect(); + // A move must bring the shares nearer by more than rounding. + let eps = total * 1e-9; + for _ in 0..WEIGH_WORK / per_pass { + let mut best: Option<(f32, f64, usize, usize)> = None; + for (m, &i) in movable.iter().enumerate() { + let (a, x) = (row[i], unit_uses(&units[i])); + let before_a = (sums[a] - target[a]).abs(); + let after_a = (sums[a] - x - target[a]).abs(); + for b in (0..n).filter(|&b| b != a) { + let gain = before_a + (sums[b] - target[b]).abs() - after_a - (sums[b] + x - target[b]).abs(); + if gain <= eps { + continue; + } + let cost = dist[m][b] - dist[m][a]; + let better = match best { + None => true, + Some((c, g, ..)) => cost < c || (cost == c && gain > g), + }; + if better { + best = Some((cost, gain, m, b)); + } + } + } + let Some((_, _, m, b)) = best else { break }; + let i = movable[m]; + let x = unit_uses(&units[i]); + sums[row[i]] -= x; + sums[b] += x; + row[i] = b; + } + row +} + /// A set of colours to snap to (Limit to Library), with their Lab values cached. #[derive(Clone, Debug, Default)] pub struct Palette(Vec<(Color, Lab)>); @@ -911,4 +1017,55 @@ mod tests { assert!(matches!(Rng::new(3).saturation_brightness(Color::lab(50.0, 40.0, 20.0)), Color::Lab { .. })); assert_eq!(Rng::new(3).saturation_brightness(Color::gray(0.4)).to_hsb()[1], 0.0, "greys stay grey"); } + + fn unit(color: Color, uses: f64, row: usize, fixed: bool) -> WeighUnit { + WeighUnit { color, uses, row, fixed } + } + + #[test] + fn row_shares_are_percent_of_the_uses() { + let units = [unit(Color::rgb(1.0, 0.0, 0.0), 3.0, 0, true), unit(Color::rgb(0.0, 0.0, 1.0), 1.0, 1, true), unit(Color::WHITE, 5.0, 4, false)]; + assert_eq!(row_shares(&units, 2), vec![75.0, 25.0]); + assert_eq!(row_shares(&[], 2), vec![0.0, 0.0]); + assert_eq!(row_shares(&[unit(Color::WHITE, f64::NAN, 0, false)], 1), vec![0.0]); + } + + #[test] + fn weigh_moves_the_nearest_colors_toward_the_weights() { + let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)); + let units = [ + unit(red, 1.0, 0, true), + unit(Color::rgb(0.9, 0.1, 0.3), 1.0, 0, false), + unit(Color::rgb(0.5, 0.0, 0.8), 1.0, 0, false), + unit(blue, 1.0, 1, true), + ]; + // Equal weights: the reddish purple, nearer blue than the red-pink is, moves. + assert_eq!(weigh(&units, &[red, blue], &[1.0, 1.0]), vec![0, 0, 1, 1]); + // Most of the weight on blue: both movable colors go, the row's own color stays. + assert_eq!(weigh(&units, &[red, blue], &[0.0, 1.0]), vec![0, 1, 1, 1]); + // Weights already met, or none positive: nothing moves. + assert_eq!(weigh(&units, &[red, blue], &[3.0, 1.0]), vec![0, 0, 0, 1]); + assert_eq!(weigh(&units, &[red, blue], &[0.0, f64::NAN]), vec![0, 0, 0, 1]); + assert_eq!(weigh(&units, &[red, blue], &[1e308, 1e308]), vec![0, 0, 1, 1], "huge weights act as equal ones"); + assert_eq!(weigh(&units, &[red], &[1.0]), vec![0, 0, 0, 1], "a row past the anchors keeps its units"); + } + + #[test] + fn weigh_takes_whole_units_and_stops_when_no_move_helps() { + let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)); + // A heavy unit: moving it would overshoot by more than staying misses. + let units = [unit(red, 1.0, 0, true), unit(Color::rgb(0.8, 0.0, 0.2), 10.0, 0, false), unit(blue, 10.0, 1, true)]; + assert_eq!(weigh(&units, &[red, blue], &[1.0, 1.4]), vec![0, 0, 1]); + assert_eq!(weigh(&units, &[red, blue], &[1.0, 20.0]), vec![0, 1, 1]); + // Many units: the result still follows the weights. + let many: Vec = (0..400).map(|i| unit(Color::rgb(i as f32 / 400.0, 0.2, 1.0 - i as f32 / 400.0), 1.0, 0, i == 0)).collect(); + let rows = weigh(&many, &[Color::rgb(0.0, 0.2, 1.0), Color::rgb(1.0, 0.2, 0.0)], &[1.0, 3.0]); + assert_eq!(rows.iter().filter(|r| **r == 1).count(), 300); + assert!(rows[399] == 1 && rows[1] == 0, "the reddest colors go to the red row"); + // Past the work limit (8,000 units by 7,000 rows) nothing moves. + let units: Vec = (0..8_000).map(|i| unit(Color::WHITE, 1.0, 0, i == 0)).collect(); + let anchors = vec![Color::BLACK; 7_000]; + let weights = vec![1.0; 7_000]; + assert!(weigh(&units, &anchors, &weights).iter().all(|r| *r == 0)); + } } diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index c4b21b1cb4..bf46bcee94 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -478,6 +478,7 @@ pub(super) const ALLOWED: &[&str] = &[ "recolor.pickTheme", "recolor.randomize", "recolor.reduce", + "recolor.weigh", "select.add", "select.all", "select.allOnArtboard", diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 931bcce681..f36486064c 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -7,7 +7,9 @@ use std::collections::{BTreeSet, HashMap}; use serde_json::{Value, json}; -use vectorcraft_color::recolor::{ColorKey, GlobalAdjust, Method, Preserve, Rng, RowMap, cluster, same_ink}; +use vectorcraft_color::recolor::{ + ColorKey, GlobalAdjust, Method, Preserve, Rng, RowMap, WeighUnit, cluster, row_shares, same_ink, weigh as weigh_rows, +}; use vectorcraft_color::{Color, Paint, Swatch}; use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; @@ -63,6 +65,15 @@ pub fn specs() -> Vec { has_doc, pick_theme ), + cmd!( + query "recolor.weigh", + "Prominent Colors", + [], + None, + "{map: rows [{from, to, …}] or {current: new} as recolor.apply, weights?: [numbers ≥ 0, one per row; an excluded row's is ignored, and at least one other must be above 0], ids?: as recolor.colors} → {map: the rows as given, each with its current colours (from) moved as below, shares: [one per row: its percent of the uses (recolor.colors' count) of the current colours of the rows that aren't excluded; 0 for an excluded row]} for Recolor Artwork's Prominent Colors (VectorCraft's own rule): without weights the rows stay as they are. With weights, current colours move one at a time between the rows that aren't excluded and have a new colour (to), while a move brings each row's share of the uses nearer its share of the weights, first the colour nearest in Lab to the first colour of the row it goes to compared with the row it leaves; a row's first colour stays in it, the tints of one global swatch in a row move together, and a use counts for the row recolor.apply recolors it with (rows with a new colour first, an exact colour before a hex one, the first row that lists it), else for the first other row that lists it. The colours that can move are compared with the rows at most 50,000,000 times in all, and when they times the rows that aren't excluded pass that, none move. Changes nothing", + has_doc, + weigh + ), ] } @@ -1062,6 +1073,147 @@ fn colors(s: &mut Session, p: &Value) -> Result { Ok(json!({ "colors": v })) } +/// `recolor.weigh`'s rows as given, each with `from` as a list (a `{current: new}` map gives a +/// row per entry). The rows have already been read by [`parse_rows`]. +fn given_rows(p: &Value) -> Vec { + match p.get("map") { + Some(Value::Array(rows)) => rows + .iter() + .map(|r| { + let mut r = r.clone(); + if let Some(f) = r.get("from").filter(|f| !f.is_array()).cloned() { + r["from"] = json!([f]); + } + r + }) + .collect(), + Some(Value::Object(m)) => m.iter().map(|(k, v)| json!({"from": [k], "to": v})).collect(), + _ => vec![], + } +} + +fn weigh(s: &mut Session, p: &Value) -> Result { + const C: &str = "recolor.weigh"; + let rows = parse_rows(p, C)?; + let weights = match p.get("weights") { + None | Some(Value::Null) => None, + Some(Value::Array(a)) if a.len() == rows.len() => Some( + a.iter() + .map(|v| { + v.as_f64() + .filter(|x| x.is_finite() && *x >= 0.0) + .ok_or_else(|| bad(C, format!("each weight must be a number of 0 or more (got {v})"))) + }) + .collect::>>()?, + ), + Some(v) => return Err(bad(C, format!("`weights` must be a list of {} numbers, one per row (got {v})", rows.len()))), + }; + let active: Vec = (0..rows.len()).filter(|&i| !rows[i].exclude).collect(); + if let Some(w) = &weights + && !active.is_empty() + && !active.iter().any(|&i| w[i] > 0.0) + { + return Err(bad(C, "at least one row that isn't excluded needs a weight above 0")); + } + let (ids, only) = scope_ids(s, p, C)?; + let st = s.doc()?; + let mut used: Vec> = used_colors(&st.doc, &ids, only.as_ref()).into_iter().map(Some).collect(); + let mut by_key: HashMap = HashMap::new(); + let mut by_hex: HashMap> = HashMap::new(); + for (i, u) in used.iter().enumerate().filter_map(|(i, u)| Some((i, u.as_ref()?))) { + by_key.insert(ColorKey::of(&u.color), i); + by_hex.entry(u.color.to_hex()).or_default().push(i); + } + // Each current colour's uses and global swatch. A use counts where recolor.apply recolors it: + // rows with a new colour first, exact colours before hex colours, the first row that lists it; + // then the other rows, in the same way. + let mut take = |m: &Match| -> (usize, Option) { + let hits: Vec = match m { + Match::Key(k) => by_key.get(k).copied().into_iter().collect(), + Match::Hex(h) => by_hex.get(h).cloned().unwrap_or_default(), + }; + let (mut uses, mut link) = (0, None); + for u in hits.into_iter().filter_map(|i| used[i].take()) { + uses += u.count; + link = link.or(u.swatch); + } + (uses, link) + }; + let mut found: Vec)>> = rows.iter().map(|r| vec![(0, None); r.from.len()]).collect(); + for recolors in [true, false] { + for hex in [false, true] { + for (r, row) in rows.iter().enumerate().filter(|(_, r)| r.new_color().is_some() == recolors) { + for (e, m) in row.from.iter().enumerate().filter(|(_, m)| matches!(m, Match::Hex(_)) == hex) { + found[r][e] = take(m); + } + } + } + } + // The units that move: in each row that isn't excluded, the colours linked to one global + // swatch go together, each other colour on its own, and the unit of the first colour stays. + let (mut units, mut members): (Vec, Vec<(usize, Vec)>) = (vec![], vec![]); + for (n, &r) in active.iter().enumerate() { + let mut groups: Vec<(Option<&String>, Vec, usize)> = vec![]; + for (e, (uses, link)) in found[r].iter().enumerate() { + match groups.iter_mut().find(|g| link.is_some() && g.0 == link.as_ref()) { + Some(g) => { + g.1.push(e); + g.2 += uses; + } + None => groups.push((link.as_ref(), vec![e], *uses)), + } + } + for (k, (_, entries, uses)) in groups.into_iter().enumerate() { + units.push(WeighUnit { color: rows[r].from[entries[0]].color(), uses: uses as f64, row: n, fixed: k == 0 }); + members.push((r, entries)); + } + } + if let Some(w) = &weights { + // Only rows with a new colour recolor what they hold, so only they give and take colours. + let movers: Vec = (0..active.len()).filter(|&n| rows[active[n]].new_color().is_some()).collect(); + let anchors: Vec = + movers.iter().map(|&n| rows[active[n]].from.first().map(Match::color).or(rows[active[n]].to).unwrap_or_default()).collect(); + let mut mover_of = vec![None; active.len()]; + for (m, &n) in movers.iter().enumerate() { + mover_of[n] = Some(m); + } + let at: Vec> = units.iter().map(|u| mover_of[u.row]).collect(); + let sub: Vec = units.iter().zip(&at).filter_map(|(u, m)| Some(WeighUnit { row: (*m)?, ..*u })).collect(); + let to = weigh_rows(&sub, &anchors, &movers.iter().map(|&n| w[active[n]]).collect::>()); + for ((u, _), m) in units.iter_mut().zip(&at).filter(|(_, m)| m.is_some()).zip(to) { + u.row = movers[m]; + } + } + let mut out = given_rows(p); + if weights.is_some() { + // Each row's colours: those that stayed, in their order, then those that came to it. + let old: Vec> = out.iter().map(|r| r["from"].as_array().cloned().unwrap_or_default()).collect(); + let mut stayed: Vec> = active.iter().map(|_| vec![]).collect(); + let mut came: Vec> = active.iter().map(|_| vec![]).collect(); + for (u, (r, entries)) in units.iter().zip(&members) { + if active[u.row] == *r { + stayed[u.row].extend(entries); + } else { + came[u.row].extend(entries.iter().filter_map(|&e| old[*r].get(e).cloned())); + } + } + for (n, &r) in active.iter().enumerate() { + stayed[n].sort_unstable(); + let mut from: Vec = stayed[n].iter().filter_map(|&e| old[r].get(e).cloned()).collect(); + from.append(&mut came[n]); + if let Some(row) = out.get_mut(r) { + row["from"] = Value::Array(from); + } + } + } + let shares = row_shares(&units, active.len()); + let mut all = vec![0.0; rows.len()]; + for (n, &r) in active.iter().enumerate() { + all[r] = shares[n]; + } + Ok(json!({"map": out, "shares": all})) +} + /// The `method` parameter (`default` without one, or for null). A `method` that is not a string, /// such as `5`, names no method. fn method_param(p: &Value, cmd: &str, default: Method) -> Result { diff --git a/crates/engine/src/tests/recolor.rs b/crates/engine/src/tests/recolor.rs index 4707ae98fd..a065d09c42 100644 --- a/crates/engine/src/tests/recolor.rs +++ b/crates/engine/src/tests/recolor.rs @@ -1348,3 +1348,124 @@ fn theme_picker_refuses_bad_params() { assert!(e.contains(msg), "{p}: {e}"); } } + +const RED: &str = "rgb 255 0 0"; +const PURPLE: &str = "rgb 128 0 204"; +const BLUE: &str = "rgb 0 0 255"; + +/// Two red rectangles, a purple one and a blue one, selected. +fn prominent_art(s: &mut Session) -> [NodeId; 4] { + let ids = [rect(s, json!("#ff0000")), rect(s, json!("#ff0000")), rect(s, json!("#8000cc")), rect(s, json!("#0000ff"))]; + select_all(s); + ids +} + +#[test] +fn weigh_gives_each_rows_share_of_the_uses() { + let mut s = session(); + prominent_art(&mut s); + let map = json!([{"from": [RED, PURPLE], "to": "#00ff00"}, {"from": BLUE, "to": null, "tint": 50}]); + let r = run(&mut s, "recolor.weigh", json!({"map": map})); + assert_eq!(r["shares"], json!([75.0, 25.0])); + assert_eq!( + r["map"], + json!([{"from": [RED, PURPLE], "to": "#00ff00"}, {"from": [BLUE], "to": null, "tint": 50}]), + "rows as given, from as a list" + ); + // An excluded row has no share, and the others share the uses. + let excl = json!([{"from": [RED, PURPLE], "to": null}, {"from": [BLUE], "to": null, "exclude": true}]); + assert_eq!(run(&mut s, "recolor.weigh", json!({"map": excl}))["shares"], json!([100.0, 0.0])); + // A current colour that no art uses counts nothing, and a use counts for the first row only. + let twice = json!({"map": [{"from": [RED, "#123456"]}, {"from": [RED, PURPLE, BLUE]}]}); + assert_eq!(run(&mut s, "recolor.weigh", twice)["shares"], json!([50.0, 50.0])); + // As recolor.apply matches them: an exact colour before a hex one, a row with a new colour first. + let exact = json!({"map": [{"from": ["#ff0000", PURPLE], "to": "#00ff00"}, {"from": [RED, BLUE], "to": "#ffff00"}]}); + assert_eq!(run(&mut s, "recolor.weigh", exact)["shares"], json!([25.0, 75.0])); + let recolored = json!({"map": [{"from": [RED, PURPLE]}, {"from": [RED, BLUE], "to": "#ffff00"}]}); + assert_eq!(run(&mut s, "recolor.weigh", recolored)["shares"], json!([25.0, 75.0])); +} + +#[test] +fn weigh_moves_colors_to_the_rows_weights_and_changes_nothing() { + let mut s = session(); + let [_, _, purple, blue] = prominent_art(&mut s); + let map = json!([{"from": [RED, PURPLE], "to": "#00ff00"}, {"from": [BLUE], "to": "#ffff00"}]); + let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [1, 1]})); + assert_eq!(r["map"], json!([{"from": [RED], "to": "#00ff00"}, {"from": [BLUE, PURPLE], "to": "#ffff00"}])); + assert_eq!(r["shares"], json!([50.0, 50.0])); + assert_eq!(fill(&s, purple).to_hex(), "#8000cc", "a query leaves the art alone"); + // Recoloring with the weighed rows gives the moved colour its new row's colour. + select_all(&mut s); + run(&mut s, "recolor.apply", json!({"map": r["map"]})); + assert_eq!(fill(&s, purple).to_hex(), "#ffff00"); + assert_eq!(fill(&s, blue).to_hex(), "#ffff00"); + // A row's first colour stays, however small its weight. + let mut s = session(); + prominent_art(&mut s); + let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [0, 1]})); + assert_eq!(r["map"][0]["from"], json!([RED])); + assert_eq!(r["shares"], json!([50.0, 50.0])); + // Excluded rows keep their colours and take none; their weights are ignored. + let three = + json!([{"from": [RED, PURPLE], "to": "#000000"}, {"from": [BLUE], "to": "#000000", "exclude": true}, {"from": ["#00ff00"], "to": "#000000"}]); + let r = run(&mut s, "recolor.weigh", json!({"map": three, "weights": [1, 50, 1]})); + assert_eq!(r["map"][1]["from"], json!([BLUE])); + assert_eq!(r["map"][2]["from"], json!(["#00ff00", PURPLE])); + // A row without a new colour recolors nothing, so it neither gives nor takes colours. + let keep = json!([{"from": [RED, PURPLE], "to": "#000000"}, {"from": [BLUE]}, {"from": ["#00ff00"], "to": "#000000"}]); + let r = run(&mut s, "recolor.weigh", json!({"map": keep, "weights": [0, 1, 1]})); + assert_eq!(r["map"][1]["from"], json!([BLUE])); + assert_eq!(r["map"][2]["from"], json!(["#00ff00", PURPLE])); +} + +#[test] +fn weigh_moves_the_tints_of_a_global_swatch_together() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Brand", "color": {"c": 0, "m": 80, "y": 90, "k": 0}, "global": true})); + rect(&mut s, json!("#ff0000")); + let full = rect(&mut s, json!("#00ff00")); + let tint = rect(&mut s, json!("#00ff00")); + rect(&mut s, json!("#0000ff")); + run(&mut s, "paint.setFill", json!({"ids": [full.0], "swatch": "Brand"})); + run(&mut s, "paint.setFill", json!({"ids": [tint.0], "swatch": "Brand", "tint": 50})); + select_all(&mut s); + let brand: Vec = run(&mut s, "recolor.colors", json!({}))["colors"] + .as_array() + .unwrap() + .iter() + .filter(|c| c["swatch"] == "Brand") + .map(|c| c["key"].clone()) + .collect(); + assert_eq!(brand.len(), 2); + let map = json!([{"from": [RED, brand[0], brand[1]], "to": "#000000"}, {"from": [BLUE], "to": "#ffffff"}]); + // Even weights would take one tint alone, which would split the swatch: nothing moves. + let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [1, 1]})); + assert_eq!(r["map"][1]["from"], json!([BLUE])); + // The swatch's two colours move to the blue row as one. + let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [1, 3]})); + assert_eq!(r["map"][1]["from"], json!([BLUE, brand[0], brand[1]])); + assert_eq!(r["shares"], json!([25.0, 75.0])); + // Colours that stay keep their order, a swatch's colours with another between them included. + let split = json!([{"from": [brand[0], RED, brand[1]], "to": "#000000"}, {"from": [BLUE], "to": "#ffffff"}]); + let r = run(&mut s, "recolor.weigh", json!({"map": split, "weights": [3, 1]})); + assert_eq!(r["map"][0]["from"], json!([brand[0], RED, brand[1]])); +} + +#[test] +fn weigh_refuses_bad_weights() { + let mut s = session(); + prominent_art(&mut s); + let map = json!([{"from": [RED]}, {"from": [BLUE], "exclude": true}]); + for (p, msg) in [ + (json!({"map": map, "weights": [1]}), "`weights` must be a list of 2 numbers, one per row"), + (json!({"map": map, "weights": "1, 1"}), "`weights` must be a list of 2 numbers"), + (json!({"map": map, "weights": [1, -1]}), "each weight must be a number of 0 or more (got -1)"), + (json!({"map": map, "weights": [1, "2"]}), "each weight must be a number of 0 or more"), + (json!({"map": map, "weights": [0, 5]}), "at least one row that isn't excluded needs a weight above 0"), + (json!({"weights": [1, 1]}), "missing `map`"), + (json!({"map": [{"to": "#ff0000"}]}), "each row needs `from`"), + ] { + let e = s.execute("recolor.weigh", &p).unwrap_err().to_string(); + assert!(e.contains(msg), "{p}: {e}"); + } +} diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index 8c68237b0c..495c38f8d9 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -19,7 +19,13 @@ //! show the new colors on the smooth wheel, on a segmented wheel (solid patches of 24 hues by 5 //! saturations; its markers move as freely) or as color bars (one per new color, in row order: click //! one to select it for the sliders, drag one to move its row, which changes no color); the dialog -//! opens on the smooth wheel. The preset menu sets up a 1, 2 or 3 +//! opens on the smooth wheel. With art, the Prominent Colors bar under the wheel shows the new +//! colors, each part as wide as its row's share of the art's uses, the largest first; dragging the +//! edge between two parts moves width between them, and when the drag ends the rows' current +//! colors move toward those widths (`recolor.weigh`), and the art previews them. The New Color +//! Group menu beside the link button saves the new colors (Save All Colors, in row order) or the +//! bar's colors (Save Prominent Colors, in its order) as a color group right away, a step of its +//! own. The preset menu sets up a 1, 2 or 3 //! colour job or a library. Opened on a colour group (Edit or Apply Color Group), OK also rewrites //! the group with the new colours (renamed by `groupName`); without art only the group changes. //! Opened on colours alone (the Color Guide's Edit or Apply Colors without art), OK saves the new @@ -41,8 +47,9 @@ //! (`{saturation, brightness, temperature, luminosity}` in percent, sent as `recolor.apply`'s //! `globalAdjust` in every slider mode), `display` (the Edit tab's `smooth`, `segmented` or `bars`; //! unset: `smooth`), `themePicker` (the Color Theme Picker is on), `themePicks` (its picks, as -//! `recolor.pickTheme`'s `picks`; a new list recolors the rows). Changing `colors`, a preserve -//! flag or `limitTo` reduces the rows again. +//! `recolor.pickTheme`'s `picks`; a new list recolors the rows), `weights` (one per row, as +//! `recolor.weigh`'s; a new list moves the rows' current colors toward them). Changing `colors`, a +//! preserve flag or `limitTo` reduces the rows again (and drops `weights`). use egui::{Rect, Sense, Ui, pos2, vec2}; use serde_json::{Value, json}; @@ -151,7 +158,7 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { "preserveWhite": kept("preserveWhite", true), "preserveBlack": kept("preserveBlack", true), "preserveGrays": kept("preserveGrays", false), "limitTo": limit, "group": group, "groupName": if new_group { Some(NEW_GROUP) } else { group }, "tab": "assign", "rule": Harmony::Complementary.id(), "linked": true, - "preview": true, THEME_PICKER: false, THEME_PICKS: [], "__art": art, "__assign": assign, "__newGroup": new_group, + "preview": true, THEME_PICKER: false, THEME_PICKS: [], WEIGHTS: null, "__art": art, "__assign": assign, "__newGroup": new_group, }); let mut d = Dialog::new(KIND, fields); // Without art the rows are there from the start: limit them now (art's rows snap as they reduce). @@ -214,6 +221,182 @@ fn sync(app: &mut VectorcraftApp, d: &mut Dialog) { d.fields.insert(DONE.into(), sig); // New rows take the colors picked with the Color Theme Picker again. d.fields.remove(THEME_DONE); + // Weights given for the old rows don't apply to the new ones. + d.fields.remove(WEIGHTS); + d.fields.remove(WEIGHED); +} + +/// The fields of Prominent Colors: the weights the rows are to take (`recolor.weigh`'s `weights`, +/// one per row; a new list moves the rows' current colors), the weights they last took, each row's +/// share of the art's uses (`recolor.weigh`'s `shares`) and the rows' current colors those were +/// read for; a drag of a part's edge (each part's share of the bar while it lasts); and a request +/// from the New Color Group menu (`all` or `prominent`). +const WEIGHTS: &str = "weights"; +const WEIGHED: &str = "__weighed"; +const SHARES: &str = "__shares"; +const SHARES_OF: &str = "__sharesOf"; +const BAR_DRAG: &str = "__barDrag"; +const SAVE_GROUP: &str = "__saveGroup"; +/// Height of the Prominent Colors bar, and the smallest share of it a drag leaves a part. +const PROMINENT_H: f32 = 18.0; +const MIN_PART: f64 = 0.01; +/// The ids of the bar's part `n` and of the edge after part `k`, with `n` or `k`. +const PROMINENT_PART: &str = "recolor-prominent-part"; +const PROMINENT_EDGE: &str = "recolor-prominent-edge"; + +/// Cancel the preview, so commands read the art's own colors; it runs again afterwards. +fn cancel_preview(app: &mut VectorcraftApp, d: &mut Dialog) { + if d.fields.remove(super::form::PREVIEWED).is_some() { + let _ = app.session.cancel_interaction(); + } +} + +/// With art: when `weights` changed, move the rows' current colors toward them +/// (`recolor.weigh`); when the rows' current colors changed, read each row's share of the art's +/// uses again. +fn sync_weights(app: &mut VectorcraftApp, d: &mut Dialog) { + if !d.bool("__art") { + return; + } + if let Some(w) = d.fields.get(WEIGHTS).filter(|w| !w.is_null()).cloned() + && d.fields.get(WEIGHED) != Some(&w) + { + d.fields.insert(WEIGHED.into(), w.clone()); + cancel_preview(app, d); + match app.session.execute("recolor.weigh", &json!({"map": rows(d), "weights": w})) { + Ok(r) => { + if let Some(m) = r["map"].as_array() { + d.fields.insert("rows".into(), Value::Array(m.clone())); + } + } + Err(e) => app.status(e.to_string()), + } + } + let rs = rows(d); + let of = Value::Array(rs.iter().map(|r| json!([r["from"], excluded(r)])).collect()); + if d.fields.get(SHARES_OF) == Some(&of) { + return; + } + cancel_preview(app, d); + let shares = match app.session.execute("recolor.weigh", &json!({"map": rs})) { + Ok(r) => r["shares"].clone(), + Err(e) => { + app.status(e.to_string()); + json!([]) + } + }; + d.fields.insert(SHARES.into(), shares); + d.fields.insert(SHARES_OF.into(), of); +} + +/// The parts of the Prominent Colors bar: each row that isn't excluded and has a share of the +/// art's uses, with that share in percent, the largest share first (on a tie, the first row). +fn prominent_parts(d: &Dialog, rows: &[Value]) -> Vec<(usize, f64)> { + let shares = d.fields.get(SHARES).and_then(Value::as_array).cloned().unwrap_or_default(); + let mut parts: Vec<(usize, f64)> = shares + .iter() + .enumerate() + .filter_map(|(i, s)| Some((i, s.as_f64().filter(|s| *s > 0.0)?))) + .filter(|(i, _)| rows.get(*i).is_some_and(|r| !excluded(r))) + .collect(); + parts.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0))); + parts +} + +/// The Prominent Colors bar: a part per row of [`prominent_parts`] with the row's new color, as wide +/// as its share of the art's uses. A drag of the edge between two parts moves width from one to +/// the other (each keeps at least [`MIN_PART`] of the bar); when it ends, the parts' widths become +/// the rows' `weights`. +fn prominent_bar(ui: &mut Ui, d: &mut Dialog, rows: &[Value]) { + let t = Tokens::get(ui.ctx()); + let parts = prominent_parts(d, rows); + let (bar, resp) = ui.allocate_exact_size(vec2(ui.available_width(), PROMINENT_H), Sense::hover()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Image, ui.is_enabled(), tl!("Prominent Colors"))); + if parts.is_empty() { + ui.painter().rect_stroke(bar, 1.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + return; + } + let total: f64 = parts.iter().map(|p| p.1).sum(); + let mut widths: Vec = match d.fields.get(BAR_DRAG).and_then(Value::as_array) { + Some(w) if w.len() == parts.len() => w.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect(), + _ => parts.iter().map(|p| p.1 / total).collect(), + }; + let w = f64::from(bar.width()); + let edge = |k: usize, widths: &[f64]| bar.left() + (widths[..=k].iter().sum::() * w) as f32; + let mut left = bar.left(); + for (n, (i, _)) in parts.iter().enumerate() { + let right = if n + 1 == parts.len() { bar.right() } else { edge(n, &widths) }; + let r = Rect::from_min_max(pos2(left, bar.top()), pos2(right, bar.bottom())); + let c = to_color(&rows[*i]).or_else(|| from_colors(&rows[*i]).first().copied()).unwrap_or_default(); + chip(ui, r, c, false); + let share = (widths[n] * 100.0).round(); + ui.interact(r, egui::Id::unique((PROMINENT_PART, n)), Sense::hover()).on_hover_text(format!("{} {}: {share}%", tl!("Color"), i + 1)); + left = right; + } + let mut ended = false; + for k in 0..parts.len() - 1 { + let x = edge(k, &widths); + let handle = Rect::from_center_size(pos2(x, bar.center().y), vec2(8.0, PROMINENT_H)); + let h = ui.interact(handle, egui::Id::unique((PROMINENT_EDGE, k)), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + if h.dragged() && w > 0.0 { + // Each part keeps MIN_PART, or half of the two parts' width when they are narrower. + let pair = widths[k] + widths[k + 1]; + let least = MIN_PART.min(pair / 2.0); + let moved = (widths[k] + f64::from(h.drag_delta().x) / w).clamp(least, pair - least); + widths[k] = moved; + widths[k + 1] = pair - moved; + d.fields.insert(BAR_DRAG.into(), json!(widths)); + } + if h.hovered() || h.dragged() { + ui.painter().vline(x, bar.y_range(), egui::Stroke::new(2.0, t.accent)); + } + ended |= h.drag_stopped(); + } + if ended && d.fields.remove(BAR_DRAG).is_some() { + let mut weights = vec![0.0; rows.len()]; + for ((i, _), x) in parts.iter().zip(&widths) { + weights[*i] = (x * 10_000.0).round() / 100.0; + } + d.fields.insert(WEIGHTS.into(), json!(weights)); + } +} + +/// The colors a new color group takes from rows `rs`: each row's new color with the Global Adjust, +/// snapped to Limit to Library, as `recolor.apply` gives them to a group; a row without a new color +/// gives its first current color, which the art keeps. +fn group_colors<'a>(app: &VectorcraftApp, d: &Dialog, rs: impl Iterator) -> Vec { + let adjust = global_adjust(adjust_values(d)); + let limit = d.str("limitTo"); + let palette = Some(limit).filter(|l| !l.is_empty()).and_then(|l| swatchlib::limit_palette(&app.session, &l)); + rs.filter_map(|r| match to_color(r) { + None => from_colors(r).first().map(key), + Some(_) if adjust.is_identity() => Some(r["to"].clone()), + Some(c) => { + let c = adjust.apply(c); + Some(key(&palette.as_ref().map_or(c, |p| p.nearest(c)))) + } + }) + .collect() +} + +/// The New Color Group menu's request: save the new colors of the rows that aren't excluded (Save +/// All Colors, in row order; a row without one gives its first current color) or of the Prominent Colors bar (Save Prominent Colors, in its order) +/// as a new color group, a step of its own (`swatch.newGroup`). +fn save_group(app: &mut VectorcraftApp, d: &mut Dialog, which: &str) { + let rs = rows(d); + let order: Vec = match which { + "prominent" => prominent_parts(d, &rs).into_iter().map(|p| p.0).collect(), + _ => (0..rs.len()).filter(|&i| !excluded(&rs[i])).collect(), + }; + let colors = group_colors(app, d, order.iter().filter_map(|&i| rs.get(i))); + if colors.is_empty() { + return; + } + // The group is added to the document itself, not to the preview, which runs again afterwards. + cancel_preview(app, d); + if let Err(e) = app.run("swatch.newGroup", json!({"colors": colors, "keepModel": true})) { + app.status(e); + } } /// The fields of the Color Theme Picker: whether a click or drag on the canvas picks colors, and @@ -401,6 +584,7 @@ fn apply_params(d: &Dialog) -> Value { fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { sync(app, d); sync_theme(app, d); + sync_weights(app, d); let t = Tokens::get(ui.ctx()); let picking = d.bool(THEME_PICKER); ui.horizontal(|ui| { @@ -444,6 +628,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { } d.fields.insert("rows".into(), Value::Array(rs)); } + sync_weights(app, d); + if let Some(which) = d.fields.remove(SAVE_GROUP) { + save_group(app, d, which.as_str().unwrap_or_default()); + } if d.fields.get("group").is_some_and(Value::is_string) || d.bool("__newGroup") { ui.add_space(6.0); ui.horizontal(|ui| { @@ -479,25 +667,9 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let mut d = d.clone(); sync(app, &mut d); sync_theme(app, &mut d); + sync_weights(app, &mut d); if d.bool("__newGroup") { - // The new colors with the Global Adjust, snapped to Limit to Library, as `recolor.apply` - // gives them to a group. - let adjust = global_adjust(adjust_values(&d)); - let limit = d.str("limitTo"); - let palette = Some(limit).filter(|l| !l.is_empty()).and_then(|l| swatchlib::limit_palette(&app.session, &l)); - let colors: Vec = rows(&d) - .into_iter() - .filter(|r| !excluded(r)) - .map(|r| { - to_color(&r).filter(|_| !adjust.is_identity()).map_or_else( - || r["to"].clone(), - |c| { - let c = adjust.apply(c); - key(&palette.as_ref().map_or(c, |p| p.nearest(c))) - }, - ) - }) - .collect(); + let colors = group_colors(app, &d, rows(&d).iter().filter(|r| !excluded(r))); return form::commit_preview(app, "swatch.newGroup", json!({"name": d.str("groupName"), "colors": colors, "keepModel": true})); } let r = form::commit_preview(app, CMD, apply_params(&d))?; @@ -1091,6 +1263,18 @@ fn edit_tab(app: &VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [Value if widgets::icon_button(ui, if linked { "link" } else { "link-2-off" }, tip, linked, 24.0).clicked() { d.fields.insert("linked".into(), json!(!linked)); } + // New Color Group: save the wheel's colors or the Prominent Colors as a color group. + let button = widgets::icon_button(ui, "dc-folder", tl!("New Color Group"), false, 24.0); + let prominent = d.bool("__art") && !prominent_parts(d, rows).is_empty(); + let saved = egui::Popup::menu(&button).show(|ui| { + if widgets::menu_item_name(ui, tl!("Save All Colors"), rows.iter().any(|r| !excluded(r)), false) { + return Some("all"); + } + widgets::menu_item_name(ui, tl!("Save Prominent Colors"), prominent, false).then_some("prominent") + }); + if let Some(which) = saved.and_then(|r| r.inner) { + d.fields.insert(SAVE_GROUP.into(), json!(which)); + } ui.add_space(8.0); // The mode of the color sliders under the wheel: the selected new color in a color // mode, or Global Adjust for all of them. @@ -1122,6 +1306,11 @@ fn edit_tab(app: &VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [Value _ => w = widgets::harmony_wheel(ui, "recolor-wheel", WHEEL, &colors, marked), }); }); + // Prominent Colors: the art's new colors, each as wide as its share of the art. + if d.bool("__art") { + ui.add_space(6.0); + prominent_bar(ui, d, rows); + } if let Some(k) = w.pressed { d.fields.insert("__sel".into(), json!([active[k]])); } @@ -1625,6 +1814,130 @@ mod tests { assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); } + const RED: &str = "rgb 255 0 0"; + const PURPLE: &str = "rgb 128 0 204"; + const BLUE: &str = "rgb 0 0 255"; + + /// Two red squares, a purple one and a blue one, selected; Recolor Artwork open on them on the + /// Edit tab with the Exact method and the rows set: the blue to yellow, then the red and purple + /// to green (the purple's square id last). One frame drawn. + fn prominent_setup(app: &mut VectorcraftApp, ctx: &egui::Context) -> u64 { + app.run("file.new", json!({"width": 300, "height": 100})).unwrap(); + let ids = [square(app, 10.0, "#ff0000"), square(app, 40.0, "#ff0000"), square(app, 70.0, "#8000cc"), square(app, 100.0, "#0000ff")]; + app.run("select.set", json!({"ids": ids})).unwrap(); + app.run("ui.recolorDialog", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("method".into(), json!(Method::Exact.id())); + d.fields.insert("tab".into(), json!("edit")); + crate::theme::install_fonts(ctx); + frame(app, ctx, 0.0, vec![], 0); + let rows = json!([{"from": [BLUE], "to": "rgb 255 255 0"}, {"from": [RED, PURPLE], "to": "rgb 0 255 0"}]); + app.ui.dialog.as_mut().unwrap().fields.insert("rows".into(), rows); + frame(app, ctx, 0.01, vec![], 0); + ids[2] + } + + fn froms(app: &VectorcraftApp) -> Vec { + super::rows(app.ui.dialog.as_ref().unwrap()).iter().map(|r| r["from"].clone()).collect() + } + + /// The Prominent Colors bar holds each row's share of the art's uses, the largest first; new + /// `weights` move the rows' current colors toward them, the art previews the result, and only + /// OK commits it, as one undo step. + #[test] + fn weights_move_the_rows_colors_and_the_art_previews_them() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + let purple = prominent_setup(&mut app, &ctx); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.fields[SHARES], json!([25.0, 75.0])); + assert_eq!(prominent_parts(d, &super::rows(d)), [(1, 75.0), (0, 25.0)], "the largest share first"); + assert_eq!(fill_hex(&app, purple), "#00ff00", "the art previews the rows"); + let undo = app.session.doc().unwrap().history.undo.len(); + app.ui.dialog.as_mut().unwrap().fields.insert(WEIGHTS.into(), json!([50, 50])); + frame(&mut app, &ctx, 0.1, vec![], 0); + assert_eq!(froms(&app), [json!([BLUE, PURPLE]), json!([RED])]); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields[SHARES], json!([50.0, 50.0])); + assert_eq!(fill_hex(&app, purple), "#ffff00", "the moved color previews its new row's color"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "nothing is committed before OK"); + // A new count of Colors reduces the rows again and drops the weights. + app.ui.dialog.as_mut().unwrap().fields.insert("colors".into(), json!(2)); + frame(&mut app, &ctx, 0.2, vec![], 0); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get(WEIGHTS), None); + app.ui + .dialog + .as_mut() + .unwrap() + .fields + .insert("rows".into(), json!([{"from": [BLUE, PURPLE], "to": "rgb 255 255 0"}, {"from": [RED], "to": "rgb 0 255 0"}])); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(fill_hex(&app, purple), "#ffff00"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + } + + /// Dragging the edge between two parts of the bar to the left gives the right-hand part more + /// weight; when the drag ends, the rows' current colors move. + #[test] + fn dragging_a_prominent_edge_moves_colors_between_rows() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + let purple = prominent_setup(&mut app, &ctx); + let edge = |ctx: &egui::Context| ctx.read_response(egui::Id::unique((PROMINENT_EDGE, 0usize))).map(|r| r.rect); + let mut last = None; + for n in 0..10 { + frame(&mut app, &ctx, 0.02 + f64::from(n) * 0.001, vec![], 0); + let r = edge(&ctx); + if r.is_some() && r == last { + break; + } + last = r; + } + let at = edge(&ctx).expect("the edge between the two parts").center(); + let bar = ctx.read_response(egui::Id::unique((PROMINENT_PART, 0usize))).unwrap().rect; + // From 75% to 50%: a quarter of the bar to the left. + let to = at - vec2((bar.width() / 0.75) * 0.25, 0.0); + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + frame(&mut app, &ctx, 0.1, vec![egui::Event::PointerMoved(at), button(at, true)], 0); + for k in 1..=5 { + frame(&mut app, &ctx, 0.1 + f64::from(k) * 0.02, vec![egui::Event::PointerMoved(at + (to - at) * (k as f32 / 5.0))], 0); + } + assert!(app.ui.dialog.as_ref().unwrap().fields.contains_key(BAR_DRAG), "the drag is under way"); + assert_eq!(froms(&app)[0], json!([BLUE]), "the rows wait for the drag to end"); + frame(&mut app, &ctx, 0.3, vec![button(to, false)], 0); + frame(&mut app, &ctx, 0.32, vec![], 0); + let d = app.ui.dialog.as_ref().unwrap(); + let w = d.fields[WEIGHTS].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect::>(); + assert!((w[0] - 50.0).abs() < 2.0 && (w[1] - 50.0).abs() < 2.0, "{w:?}"); + assert_eq!(froms(&app), [json!([BLUE, PURPLE]), json!([RED])]); + assert_eq!(fill_hex(&app, purple), "#ffff00"); + } + + /// The New Color Group menu's Save All Colors saves the new colors in row order and Save + /// Prominent Colors the bar's colors, the largest share first, each as its own step; the + /// preview of the recolor runs on. + #[test] + fn save_all_and_prominent_colors_make_color_groups() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + let purple = prominent_setup(&mut app, &ctx); + let groups = |app: &VectorcraftApp| app.session.doc().unwrap().doc.swatch_groups.len(); + let last = |app: &VectorcraftApp| -> Vec { + let doc = &app.session.doc().unwrap().doc; + doc.swatch_groups.last().unwrap().swatches.iter().map(|w| w.paint.color().unwrap().to_hex()).collect() + }; + let (n, undo) = (groups(&app), app.session.doc().unwrap().history.undo.len()); + for (which, colors, t) in [("all", ["#ffff00", "#00ff00"], 0.1), ("prominent", ["#00ff00", "#ffff00"], 0.2)] { + app.ui.dialog.as_mut().unwrap().fields.insert(SAVE_GROUP.into(), json!(which)); + frame(&mut app, &ctx, t, vec![], 0); + frame(&mut app, &ctx, t + 0.01, vec![], 0); + assert_eq!(last(&app), colors, "{which}"); + assert_eq!(fill_hex(&app, purple), "#00ff00", "{which}: the art still previews the rows"); + } + assert_eq!(groups(&app), n + 2); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 2, "a step for each group"); + } + /// One frame with `modifiers` held. fn frame_with(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec, modifiers: egui::Modifiers) { let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 8edde76eb3..d3f8fe92e6 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2825,6 +2825,9 @@ digits Malayalam Малаялам Reset the rows to the artwork's colors Нулиране на редовете към цветовете на илюстрацията Color Theme Picker Избор на цветова тема Click or drag across the art to pick its colors; Shift adds more Щракнете или плъзнете през илюстрацията, за да вземете цветовете ѝ; Shift добавя още + Prominent Colors Преобладаващи цветове + Save All Colors Запази всички цветове + Save Prominent Colors Запази преобладаващите цветове Reset to Default Връщане към стандартните Reset to White, Black Нулиране към бяло, черно Reset with Mesh Нулиране с мрежа diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index c9c207eaa2..bf1a65904c 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -2849,6 +2849,9 @@ digits Malayalam Malajalam Reset the rows to the artwork's colors Vrati redove na boje crteža Color Theme Picker Birač teme boja Click or drag across the art to pick its colors; Shift adds more Kliknite ili prevucite preko crteža da uzmete njegove boje; Shift dodaje još + Prominent Colors Istaknute boje + Save All Colors Sačuvaj sve boje + Save Prominent Colors Sačuvaj istaknute boje Separate colors into rows Razdvoji boje u redove Unlink harmony colors Prekini vezu boja harmonije 1 Color Job Posao s 1 bojom diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 0399e6c04a..3e590eadb1 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3607,6 +3607,9 @@ digits Malayalam Malaiàlam Reset the rows to the artwork's colors Restableix les files als colors de l’obra Color Theme Picker Selector de tema de colors Click or drag across the art to pick its colors; Shift adds more Feu clic o arrossegueu sobre l’obra per agafar-ne els colors; Maj n’afegeix més + Prominent Colors Colors predominants + Save All Colors Desa tots els colors + Save Prominent Colors Desa els colors predominants Reset to Default Restableix el valor predeterminat Reset to default Restableix el valor predeterminat Reset to Defaults Restableix els valors predeterminats diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e62070329c..751d06c66a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2885,6 +2885,9 @@ digits Malayalam Malajálamské Reset the rows to the artwork's colors Obnovit řádky na barvy kresby Color Theme Picker Výběr barevného motivu Click or drag across the art to pick its colors; Shift adds more Klepnutím nebo tažením přes kresbu převezmete její barvy; Shift přidá další + Prominent Colors Převládající barvy + Save All Colors Uložit všechny barvy + Save Prominent Colors Uložit převládající barvy Separate colors into rows Rozdělit barvy do řádků Unlink harmony colors Zrušit propojení barev harmonie 1 Color Job Zakázka s 1 barvou diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 47a78ef612..df2fbc011f 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2669,6 +2669,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors Nulstil rækkerne til grafikkens farver Color Theme Picker Farvetemavælger Click or drag across the art to pick its colors; Shift adds more Klik eller træk hen over grafikken for at hente dens farver; Skift tilføjer flere + Prominent Colors Fremtrædende farver + Save All Colors Gem alle farver + Save Prominent Colors Gem fremtrædende farver Separate colors into rows Opdel farver i rækker Unlink harmony colors Fjern sammenkædning af harmonifarver 1 Color Job 1-farvejob diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index af4489a637..ed11eca5cf 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2871,6 +2871,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors Zeilen auf die Farben der Grafik zurücksetzen Color Theme Picker Farbthema-Auswahl Click or drag across the art to pick its colors; Shift adds more Klicken oder ziehen Sie über die Grafik, um ihre Farben aufzunehmen; Umschalt fügt weitere hinzu + Prominent Colors Hervorstechende Farben + Save All Colors Alle Farben speichern + Save Prominent Colors Hervorstechende Farben speichern Reset to Default Auf Standard zurücksetzen Reset to White, Black Auf Weiß, Schwarz zurücksetzen Reset with Mesh Mit Gitter zurücksetzen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6ad6ff11c0..398d51f9db 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3589,6 +3589,9 @@ digits Malayalam Μαλαγιάλαμ Reset the rows to the artwork's colors Επαναφορά γραμμών στα χρώματα του σχεδίου Color Theme Picker Επιλογέας χρωματικού θέματος Click or drag across the art to pick its colors; Shift adds more Κάντε κλικ ή σύρετε πάνω στο σχέδιο για να πάρετε τα χρώματά του· το Shift προσθέτει κι άλλα + Prominent Colors Κυρίαρχα χρώματα + Save All Colors Αποθήκευση όλων των χρωμάτων + Save Prominent Colors Αποθήκευση κυρίαρχων χρωμάτων Reset to White, Black Επαναφορά σε λευκό και μαύρο Reset with Mesh Επαναφορά με πλέγμα Reset with Mesh… Επαναφορά με πλέγμα… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 41f92e14f7..380124c709 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2904,6 +2904,9 @@ digits Malayalam Malayálam Reset the rows to the artwork's colors Restablecer las filas a los colores de la ilustración Color Theme Picker Selector de tema de color Click or drag across the art to pick its colors; Shift adds more Haga clic o arrastre sobre la ilustración para tomar sus colores; Mayús añade más + Prominent Colors Colores destacados + Save All Colors Guardar todos los colores + Save Prominent Colors Guardar colores destacados Reset to Default Restablecer valores predeterminados Reset to White, Black Restablecer a blanco, negro Reset with Mesh Restablecer con malla diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index fd38382f63..8211efc0cd 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2829,6 +2829,9 @@ digits Malayalam Malajalam Reset the rows to the artwork's colors Palauta rivit kuvituksen väreihin Color Theme Picker Väriteeman valitsin Click or drag across the art to pick its colors; Shift adds more Napsauta tai vedä kuvituksen yli poimiaksesi sen värit; Vaihto lisää useampia + Prominent Colors Hallitsevat värit + Save All Colors Tallenna kaikki värit + Save Prominent Colors Tallenna hallitsevat värit Separate colors into rows Erottele värit riveille Unlink harmony colors Poista harmoniavärien linkitys 1 Color Job 1 värin työ diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index dcbe436fc3..dc7be09b13 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2896,6 +2896,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors Réinitialiser les lignes avec les couleurs de l’illustration Color Theme Picker Sélecteur de thème de couleurs Click or drag across the art to pick its colors; Shift adds more Cliquez ou faites glisser sur l’illustration pour prélever ses couleurs ; Maj en ajoute d’autres + Prominent Colors Couleurs dominantes + Save All Colors Enregistrer toutes les couleurs + Save Prominent Colors Enregistrer les couleurs dominantes Reset to Default Rétablir les valeurs par défaut Reset to White, Black Réinitialiser à blanc, noir Reset with Mesh Réinitialiser avec un filet diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9b7a15bbad..dd0d8137e7 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3948,6 +3948,9 @@ antiAlias Sharp शार्प Reset the rows to the artwork's colors आर्टवर्क के रंग के लिए पंक्तियों को रीसेट करें Color Theme Picker रंग थीम पिकर Click or drag across the art to pick its colors; Shift adds more आर्टवर्क के रंग लेने के लिए उस पर क्लिक करें या खींचें; Shift और जोड़ता है + Prominent Colors प्रमुख रंग + Save All Colors सभी रंग सेव करें + Save Prominent Colors प्रमुख रंग सेव करें Reset to default डिफ़ॉल्ट पर रीसेट करें Reset to Default डिफ़ॉल्ट पर रीसेट करें Reset to Defaults डिफ़ॉल्ट पर रीसेट करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 6832a86707..7c1c82e97e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3454,6 +3454,9 @@ mark-style Japanese Japán Reset the rows to the artwork's colors Sorok visszaállítása a grafika színeire Color Theme Picker Színtéma-választó Click or drag across the art to pick its colors; Shift adds more Kattintson vagy húzzon a grafikán a színei felvételéhez; a Shift továbbiakat ad hozzá + Prominent Colors Domináns színek + Save All Colors Összes szín mentése + Save Prominent Colors Domináns színek mentése Reset to default Alapérték visszaállítása Reset to Default Alapérték visszaállítása Reset to Defaults Alapértékek visszaállítása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 45e05b9622..758903eb08 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2669,6 +2669,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors Setel ulang baris ke warna karya seni Color Theme Picker Pemilih tema warna Click or drag across the art to pick its colors; Shift adds more Klik atau seret melintasi karya seni untuk mengambil warnanya; Shift menambahkan lagi + Prominent Colors Warna menonjol + Save All Colors Simpan semua warna + Save Prominent Colors Simpan warna menonjol Separate colors into rows Pisahkan warna ke dalam baris Unlink harmony colors Lepaskan tautan warna harmoni 1 Color Job Pekerjaan 1 warna diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 57f141f880..579098b1de 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2947,6 +2947,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors Reimposta le righe sui colori della grafica Color Theme Picker Selettore tema colore Click or drag across the art to pick its colors; Shift adds more Fai clic o trascina sulla grafica per prelevarne i colori; Maiusc ne aggiunge altri + Prominent Colors Colori prominenti + Save All Colors Salva tutti i colori + Save Prominent Colors Salva colori prominenti Reset to Default Reimposta valori predefiniti Reset to White, Black Reimposta su bianco, nero Reset with Mesh Reimposta con trama diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 69b55c9121..6abc349cb3 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2952,6 +2952,9 @@ mark-style Japanese 日本式 Reset the rows to the artwork's colors 行をアートワークのカラーにリセット Color Theme Picker カラーテーマピッカー Click or drag across the art to pick its colors; Shift adds more アートワークをクリックまたはドラッグしてカラーを取得します。Shift キーで追加します + Prominent Colors 主要カラー + Save All Colors すべてのカラーを保存 + Save Prominent Colors 主要カラーを保存 Reset to Default 初期設定に戻す Reset to White, Black 白、黒にリセット Reset {name} {name}をリセット diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 1cbb2d1bf1..8a1605eb96 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2585,6 +2585,9 @@ digits Malayalam მალაიალამური Reset the rows to the artwork's colors სტრიქონებისთვის ნახატის ფერების დაბრუნება Color Theme Picker ფერთა თემის ამრჩევი Click or drag across the art to pick its colors; Shift adds more დააწკაპუნეთ ან გადაატარეთ ნახატზე მისი ფერების ასაღებად; Shift ამატებს კიდევ + Prominent Colors გამოკვეთილი ფერები + Save All Colors ყველა ფერის შენახვა + Save Prominent Colors გამოკვეთილი ფერების შენახვა Separate colors into rows ფერების სტრიქონებად დაყოფა Unlink harmony colors ჰარმონიის ფერების კავშირის გაწყვეტა 1 Color Job 1-ფერიანი ბეჭდვა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 6135f8d5ec..b1359dba7a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2941,6 +2941,9 @@ mark-style Japanese 일본식 Reset the rows to the artwork's colors 행을 아트웍 색상으로 재설정 Color Theme Picker 색상 테마 피커 Click or drag across the art to pick its colors; Shift adds more 아트웍을 클릭하거나 드래그하여 색상을 가져옵니다. Shift 키로 더 추가합니다 + Prominent Colors 주요 색상 + Save All Colors 모든 색상 저장 + Save Prominent Colors 주요 색상 저장 Reset to Default 기본값으로 재설정 Reset to White, Black 흰색, 검정으로 재설정 Reset {name} {name} 재설정 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 327aee902c..b4fe48049b 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2683,6 +2683,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors De rijen herstellen naar de kleuren van de artwork Color Theme Picker Kleurthemakiezer Click or drag across the art to pick its colors; Shift adds more Klik of sleep over het artwork om de kleuren ervan op te nemen; Shift voegt er meer toe + Prominent Colors Prominente kleuren + Save All Colors Alle kleuren opslaan + Save Prominent Colors Prominente kleuren opslaan Separate colors into rows Kleuren over rijen verdelen Unlink harmony colors Harmoniekleuren ontkoppelen 1 Color Job Opdracht in 1 kleur diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f277d51d65..cd618b1912 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2794,6 +2794,9 @@ digits Malayalam Malajalam Reset the rows to the artwork's colors Przywróć w wierszach kolory kompozycji Color Theme Picker Próbnik motywu kolorów Click or drag across the art to pick its colors; Shift adds more Kliknij lub przeciągnij po kompozycji, aby pobrać jej kolory; Shift dodaje kolejne + Prominent Colors Dominujące kolory + Save All Colors Zapisz wszystkie kolory + Save Prominent Colors Zapisz dominujące kolory Separate colors into rows Rozdziel kolory na wiersze Unlink harmony colors Odłącz kolory harmonii 1 Color Job Praca 1-kolorowa diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index afd10a5fbc..38eb7122f3 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2442,6 +2442,9 @@ digits Malayalam Malaiala Reset the rows to the artwork's colors Redefinir as linhas para as cores da arte Color Theme Picker Seletor de Tema de Cores Click or drag across the art to pick its colors; Shift adds more Clique ou arraste sobre a arte para pegar suas cores; Shift adiciona mais + Prominent Colors Cores Proeminentes + Save All Colors Salvar Todas as Cores + Save Prominent Colors Salvar Cores Proeminentes Reset to Default Redefinir para o Padrão Reset to White, Black Redefinir para Branco, Preto Reset with Mesh… Redefinir com Malha… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2dca232079..4d069732ef 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2934,6 +2934,9 @@ digits Malayalam Малаялам Reset the rows to the artwork's colors Сбросить строки к цветам рисунка Color Theme Picker Выбор цветовой темы Click or drag across the art to pick its colors; Shift adds more Щёлкните или проведите по рисунку, чтобы взять его цвета; Shift добавляет ещё + Prominent Colors Преобладающие цвета + Save All Colors Сохранить все цвета + Save Prominent Colors Сохранить преобладающие цвета Reset to Default Сбросить к значениям по умолчанию Reset to White, Black Сбросить к белому, чёрному Reset with Mesh Сбросить с сеткой diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index e47c4e8662..6311bca512 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2840,6 +2840,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors Återställ raderna till teckningens färger Color Theme Picker Färgtemaväljare Click or drag across the art to pick its colors; Shift adds more Klicka eller dra över teckningen för att hämta dess färger; Skift lägger till fler + Prominent Colors Framträdande färger + Save All Colors Spara alla färger + Save Prominent Colors Spara framträdande färger Reset to Default Återställ till standard Reset to White, Black Återställ till vitt, svart Reset with Mesh Återställ med nät diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5983ec4bfe..310f7e8dae 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2708,6 +2708,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors Satırları resmin renklerine göre sıfırlayın Color Theme Picker Renk Teması Seçici Click or drag across the art to pick its colors; Shift adds more Renklerini almak için resme tıklayın veya üzerinde sürükleyin; Shift daha fazlasını ekler + Prominent Colors Belirgin Renkler + Save All Colors Tüm Renkleri Kaydet + Save Prominent Colors Belirgin Renkleri Kaydet Reset to Default Varsayılana Sıfırla Reset to White, Black Beyaz, Siyah'a Sıfırla Reset with Mesh Mesh ile Sıfırla diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4c9326af72..6590c4bf76 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2939,6 +2939,9 @@ digits Malayalam Малаялам Reset the rows to the artwork's colors Скинути рядки до кольорів малюнка Color Theme Picker Вибір колірної теми Click or drag across the art to pick its colors; Shift adds more Клацніть або проведіть по малюнку, щоб узяти його кольори; Shift додає ще + Prominent Colors Переважні кольори + Save All Colors Зберегти всі кольори + Save Prominent Colors Зберегти переважні кольори Reset to Default Скинути до значень за замовчуванням Reset to White, Black Скинути до білого, чорного Reset with Mesh Скинути із сіткою diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index fef0b8ff90..f485a03f51 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3227,6 +3227,9 @@ mark-style Japanese Kiểu Nhật Reset the rows to the artwork's colors Đặt lại các hàng về màu của tác phẩm Color Theme Picker Bộ chọn bộ màu Click or drag across the art to pick its colors; Shift adds more Nhấp hoặc kéo qua tác phẩm để lấy màu của nó; Shift để thêm + Prominent Colors Màu nổi bật + Save All Colors Lưu tất cả màu + Save Prominent Colors Lưu màu nổi bật Reset to Default Đặt lại về mặc định Reset to default Đặt lại về mặc định Reset to White, Black Đặt lại về Trắng, Đen diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a0d941ada8..45e1c6d6fd 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2804,6 +2804,9 @@ digits Malayalam 马拉雅拉姆数字 Reset the rows to the artwork's colors 将各行重置为图稿的颜色 Color Theme Picker 色彩主题拾取器 Click or drag across the art to pick its colors; Shift adds more 单击或拖过图稿以拾取其颜色;按住 Shift 可添加更多 + Prominent Colors 主要颜色 + Save All Colors 存储所有颜色 + Save Prominent Colors 存储主要颜色 Reset to Default 恢复默认 Reset to White, Black 重置为白、黑 Reset with Mesh 以网格重置 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b724e57351..c6b1b68402 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3999,6 +3999,9 @@ digits Malayalam 馬來亞拉姆數字 Reset the rows to the artwork's colors 將各列重設為圖稿的顏色 Color Theme Picker 色彩主題選取器 Click or drag across the art to pick its colors; Shift adds more 按一下或拖過圖稿以選取其顏色;按住 Shift 可加入更多 + Prominent Colors 主要顏色 + Save All Colors 儲存所有顏色 + Save Prominent Colors 儲存主要顏色 Reset to Default 重設為預設值 Reset to White, Black 重設為白色、黑色 Reset with Mesh 以網格重設 diff --git a/docs/mcp.md b/docs/mcp.md index 20e37222c6..1f370735c6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3719,6 +3719,30 @@ excluded take the palette as their new colors in row order, the colors repeating {"name":"run_command","arguments":{"command":"recolor.pickTheme","params":{"picks":[{"at":[120,80]},{"rect":[200,40,320,140]}],"colors":4}}} ``` +`recolor.weigh {map, weights?, ids?}` is the dialog's Prominent Colors bar. It takes rows as `recolor.apply` does and +returns them as `map`, each with `from` as a list, and `shares`: for each row, its percent of the uses (the `count` of +`recolor.colors`) of the current colors of the rows that are not excluded, and 0 for an excluded row. A use counts for +the row that `recolor.apply` recolors it with (rows with a new color first, an exact color before a hex color, then +the first row that lists it), and otherwise for the first other row that lists it. A current color that no art uses +counts nothing. Without `weights` the +rows come back unchanged. `weights` holds one number of 0 or more per row; an excluded row's weight is ignored, and +at least one other row needs a weight above 0. As VectorCraft's own rule, current colors then move one at a time +between the rows that are not excluded and have a new color (`to`), as long as a move brings each row's share of the uses nearer its share of the +weights. Of those moves, it takes first the color nearest in Lab to the first color of the row it goes to, compared +with the row it leaves. A row keeps its first color, and the tints of one global swatch in a row move together. The +colors that can move are compared with the rows at most 50,000,000 times in all; when those colors times the rows +that are not excluded pass that, no color moves. Colors that stay in a row keep their order, and the colors that come +to it follow them. The command changes nothing; `recolor.apply` with the returned `map` recolors the art. In the Recolor Artwork dialog the +bar sits under the Edit tab's wheel: each row with a share is a part as wide as that share, in the row's new color, +the largest first. Dragging the edge between two parts moves width between them, and when the drag ends the widths +become the `weights` field, which moves the rows' current colors and previews the result. The New Color Group menu +beside the link button runs `swatch.newGroup` right away, as its own undo step: Save All Colors saves the new colors +of the rows that are not excluded, in row order, and Save Prominent Colors saves the bar's colors in the bar's order. + +```json +{"name":"run_command","arguments":{"command":"recolor.weigh","params":{"map":[{"from":["rgb 255 0 0","rgb 128 0 204"],"to":"#00ff00"},{"from":["rgb 0 0 255"],"to":"#ffff00"}],"weights":[1,1]}}} +``` + The `recolor.*` commands refuse parameters of the wrong form. A `limitTo` or `method` that is not a string gets the error for an unknown library or method. Other wrong-form parameters get an error that names them. `includeImages`, `includePatterns`, a row's `exclude`, `order`, `saturationBrightness` and the `white`, `black` and diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5f10ea4841..8485de41ac 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -486,6 +486,8 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po - [x] Recolor Artwork › Color Theme Picker: with the button beside the tabs on, a click on the canvas takes the color palette of an object or image, a drag takes the palette of the art inside its marquee, and Shift adds more picks; the rows that aren't excluded take the palette as their new colors and the art previews them ([Recolor artwork](https://helpx.adobe.com/illustrator/desktop/manage-colors/modify-colors/recolor-artwork.html)). The number and order of the picked colors are VectorCraft's own rule (`recolor.pickTheme`). Tests: `theme_picks_give_the_rows_the_palette_of_the_art_picked` and `the_theme_picker_takes_clicks_and_shift_drags_on_the_canvas` in `crates/ui-egui/src/dialogs/recolor.rs`, `theme_picker_*` in `crates/engine/src/tests/recolor.rs`. +- [x] Recolor Artwork › Prominent Colors: with art, a bar under the Edit tab's wheel shows the new colors, each as wide as its share of the art; dragging the edge between two parts raises one color's weight and lowers the other's, and the art previews the result; the New Color Group menu has Save All Colors and Save Prominent Colors ([Recolor options overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/modify-colors/recolor-options-overview.html)). How a share is measured and how a weight moves colors between rows are VectorCraft's own rules (`recolor.weigh`). Tests: `weights_move_the_rows_colors_and_the_art_previews_them`, `dragging_a_prominent_edge_moves_colors_between_rows` and `save_all_and_prominent_colors_make_color_groups` in `crates/ui-egui/src/dialogs/recolor.rs`, `weigh_*` in `crates/engine/src/tests/recolor.rs`. + - [x] Recolor Artwork Edit color chips scroll within the screen, preserving the wheel and actions (#3416); 5, 20, 200 and 400 colors, resizing and UI scaling, final-chip editing and preview/history covered by egui_kittest. - [x] Status messages and bounded readouts remain stable as values, canvas hover and background jobs change; ordinary values fit all registered languages and units, and optional groups yield in narrow windows (regressions; contributor rebuilt and confirmed the original-file status-bar scenario on 2026-10-19). Sample widths are cached per UI and refreshed only when language, document units or pixels per point changes. From 3792561e1a0c01234857ad3bad22ceaaf6c779cb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 00:51:37 -0800 Subject: [PATCH 02/48] Learn: View more opens a tutorial's practice sample, and Next Up shows the next tutorial Under a tutorial's steps, View more opens the tutorial's practice sample as a new untitled document, which becomes the active one; open documents stay as they are, and Save asks where to save the copy. Each of the 12 tutorials has its own small SVG drawing in panels/learn/samples.rs, read only when View more is pressed. Once the sample is open, Finish tutorial marks the tutorial finished. A finished tutorial shows Next Up with the next tutorial's title in the same skill, and Next Up shows that tutorial without opening its sample. After a skill's last tutorial the panel says so and shows Back to skills; Next Up never moves to another skill. Finished tutorials are kept while the app runs; the skill rings and counts do not show them yet. The help page's short video is not part of this change. - learn.openSample, learn.finish and learn.next run the three controls and are listed in the UI command catalogs; a missing, non-string or unknown tutorial id, or a step out of order, is an error and changes nothing. - New labels and messages have rows in every catalog. - Docs: docs/mcp.md, docs/target-app-parity.md, docs/ui-parity.md and the ASSETS.md line for art generated in code. --- ASSETS.md | 2 +- crates/mcp/src/ui_catalog.rs | 18 ++ crates/ui-egui/src/i18n/bg.tsv | 12 ++ crates/ui-egui/src/i18n/bs.tsv | 12 ++ crates/ui-egui/src/i18n/ca.tsv | 12 ++ crates/ui-egui/src/i18n/cs.tsv | 12 ++ crates/ui-egui/src/i18n/da.tsv | 12 ++ crates/ui-egui/src/i18n/de.tsv | 12 ++ crates/ui-egui/src/i18n/el.tsv | 12 ++ crates/ui-egui/src/i18n/es.tsv | 12 ++ crates/ui-egui/src/i18n/fi.tsv | 12 ++ crates/ui-egui/src/i18n/fr.tsv | 12 ++ crates/ui-egui/src/i18n/hi.tsv | 12 ++ crates/ui-egui/src/i18n/hu.tsv | 12 ++ crates/ui-egui/src/i18n/id.tsv | 12 ++ crates/ui-egui/src/i18n/it.tsv | 12 ++ crates/ui-egui/src/i18n/ja.tsv | 12 ++ crates/ui-egui/src/i18n/ka.tsv | 12 ++ crates/ui-egui/src/i18n/ko.tsv | 12 ++ crates/ui-egui/src/i18n/nl.tsv | 12 ++ crates/ui-egui/src/i18n/pl.tsv | 12 ++ crates/ui-egui/src/i18n/pt-br.tsv | 12 ++ crates/ui-egui/src/i18n/ru.tsv | 12 ++ crates/ui-egui/src/i18n/sv.tsv | 12 ++ crates/ui-egui/src/i18n/tr.tsv | 12 ++ crates/ui-egui/src/i18n/uk.tsv | 12 ++ crates/ui-egui/src/i18n/vi.tsv | 12 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 12 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 12 ++ crates/ui-egui/src/menus/ui_commands.rs | 18 ++ crates/ui-egui/src/panels/learn.rs | 135 ++++++++++++- crates/ui-egui/src/panels/learn/catalog.rs | 7 + crates/ui-egui/src/panels/learn/samples.rs | 103 ++++++++++ crates/ui-egui/src/panels/learn/tests.rs | 216 ++++++++++++++++++++- crates/ui-egui/src/state.rs | 5 + docs/mcp.md | 20 +- docs/target-app-parity.md | 1 + docs/ui-parity.md | 2 +- 38 files changed, 837 insertions(+), 14 deletions(-) create mode 100644 crates/ui-egui/src/panels/learn/samples.rs diff --git a/ASSETS.md b/ASSETS.md index 5658882d48..dbdd8b84d8 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -20,7 +20,7 @@ Every non-code asset in this repository (icons, images, fonts, example art, colo **Policy (mandatory):** VectorCraft contains **no Adobe iconography, images, artwork, presets, swatch/brush/symbol/pattern libraries or ICC profiles**. Every asset is either original work by VectorCraft contributors, or third-party material under an open licence (OSI open source, public domain / CC0, or Creative Commons that allows redistribution), and it is attributed below. Screenshots of Adobe software are never committed. The one exception is the ArtCraft name, wordmark and logos in `docs/brand/`: ArtCraft trademarks, not open source, usable only under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). -Generated-in-code art is original and has no file to list. This covers the default swatches, brushes, symbols, patterns, graphic styles, image-trace presets and the vector tool cursors (`crates/ui-egui/src/cursors.rs`). +Generated-in-code art is original and has no file to list. This covers the default swatches, brushes, symbols, patterns, graphic styles, image-trace presets, the vector tool cursors (`crates/ui-egui/src/cursors.rs`) and the Learn panel's practice samples (`crates/ui-egui/src/panels/learn/samples.rs`). **Font files are never committed here.** Fonts shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts). The small Latin UI/document fonts below predate that rule and stay. diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 342feb568c..bffe47bfa7 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -658,6 +658,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{tool: a tool id, as tool.select takes} mark where that tool is for 3 seconds without choosing it, as the link on a tool's name in a Learn tutorial step does: its toolbar button, a row of its collapsed group's flyout (which opens), its group's torn-off strip (which comes to the front), or, when no shown toolbar holds it, its row in the All Tools drawer (which opens); a Tools panel hidden with Window › Tools shows again; Escape or the next locate removes the mark; a missing, non-string or unknown id is an error and changes nothing; returns {tool, location: toolbar|flyout|strip|allTools|unavailable, toolbar: the name of the toolbar made with New Toolbar that holds it, or null}, with reason when Presentation Mode or Tab hides the toolbars", ), + ( + "learn.openSample", + "Open Tutorial Sample", + "", + "{tutorial: id (see learn.list)} open that tutorial's practice sample as a new untitled document, which becomes the active one, and move the Learn view to the tutorial, as its View more button does; each call opens another copy and open documents stay as they are; a missing, non-string or unknown id is an error and changes nothing; returns {tutorial, index, title} of the new document", + ), + ( + "learn.finish", + "Finish Tutorial", + "", + "{tutorial: id (see learn.list)} mark a tutorial whose sample learn.openSample opened as finished, as its Finish tutorial button does; finishing it again keeps one entry; a missing, non-string or unknown id, or a tutorial whose sample was not opened, is an error and changes nothing; returns {tutorial, finished: true, next: id of the next tutorial in its skill, or null after the skill's last one}", + ), + ( + "learn.next", + "Next Tutorial", + "", + "{tutorial: id (see learn.list)} show the Learn panel on the tutorial after a finished one in the same skill, as its Next Up button does, without opening a sample; a missing, non-string or unknown id, a tutorial not finished, or a skill's last tutorial is an error and changes nothing; returns {skill, tutorial, title} of the next tutorial", + ), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), ( "window.workspace", diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 3828d9ba2d..0cc6e106ce 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6744,6 +6744,18 @@ breakText Glyphs Глифи Show where this tool is Показване къде е този инструмент @msg missing `tutorial` (see learn.list) липсва `tutorial` (вижте learn.list) @msg unknown tutorial `{id}` (see learn.list) непознат урок `{id}` (вижте learn.list) + View more Вижте повече + Finish tutorial Завършване на урока + Next Up Следващ + You finished the last tutorial in this skill. Завършихте последния урок в това умение. + Back to skills Обратно към уменията + Open Tutorial Sample Отваряне на примерния файл на урока + Finish Tutorial Завършване на урок + Next Tutorial Следващ урок +@msg tutorial `{id}` has no practice sample урокът `{id}` няма примерен файл за упражнение +@msg open the practice sample of `{id}` first (see learn.openSample) първо отворете примерния файл за упражнение на `{id}` (вижте learn.openSample) +@msg finish `{id}` first (see learn.finish) първо завършете `{id}` (вижте learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` е последният урок в своето умение # Deferred plug-in effects Refresh Deferred Effects Обновяване на отложените ефекти diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 81fee3a079..2df8131afe 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6798,6 +6798,18 @@ shape Open Otvoren Show where this tool is Prikaži gdje je ovaj alat @msg missing `tutorial` (see learn.list) nedostaje `tutorial` (vidi learn.list) @msg unknown tutorial `{id}` (see learn.list) nepoznat tutorijal `{id}` (vidi learn.list) + View more Prikaži više + Finish tutorial Završi tutorijal + Next Up Sljedeće + You finished the last tutorial in this skill. Završili ste posljednji tutorijal u ovoj vještini. + Back to skills Nazad na vještine + Open Tutorial Sample Otvori uzorak tutorijala + Finish Tutorial Završi tutorijal + Next Tutorial Sljedeći tutorijal +@msg tutorial `{id}` has no practice sample tutorijal `{id}` nema uzorak za vježbu +@msg open the practice sample of `{id}` first (see learn.openSample) prvo otvorite uzorak za vježbu tutorijala `{id}` (vidi learn.openSample) +@msg finish `{id}` first (see learn.finish) prvo završite `{id}` (vidi learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` je posljednji tutorijal u svojoj vještini # Deferred plug-in effects Refresh Deferred Effects Osvježi odgođene efekte diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 02ae0f4a09..71959a3767 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -7321,6 +7321,18 @@ breakText Glyphs Glifs Show where this tool is Mostra on és aquesta eina @msg missing `tutorial` (see learn.list) falta `tutorial` (vegeu learn.list) @msg unknown tutorial `{id}` (see learn.list) tutorial desconegut `{id}` (vegeu learn.list) + View more Mostra’n més + Finish tutorial Acaba el tutorial + Next Up A continuació + You finished the last tutorial in this skill. Heu acabat l’últim tutorial d’aquesta habilitat. + Back to skills Torna a les habilitats + Open Tutorial Sample Obre la mostra del tutorial + Finish Tutorial Acaba el tutorial + Next Tutorial Tutorial següent +@msg tutorial `{id}` has no practice sample el tutorial `{id}` no té cap mostra de pràctica +@msg open the practice sample of `{id}` first (see learn.openSample) obriu primer la mostra de pràctica de `{id}` (vegeu learn.openSample) +@msg finish `{id}` first (see learn.finish) acabeu primer `{id}` (vegeu learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` és l’últim tutorial de la seva habilitat # Deferred plug-in effects Refresh Deferred Effects Actualitza els efectes ajornats diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7e3794e8a1..ca6c1cb875 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -7340,6 +7340,18 @@ breakText Glyphs Glyfy Show where this tool is Zobrazit, kde je tento nástroj @msg missing `tutorial` (see learn.list) chybí `tutorial` (viz learn.list) @msg unknown tutorial `{id}` (see learn.list) neznámý výukový kurz `{id}` (viz learn.list) + View more Zobrazit více + Finish tutorial Dokončit výukový kurz + Next Up Další + You finished the last tutorial in this skill. Dokončili jste poslední výukový kurz této dovednosti. + Back to skills Zpět na dovednosti + Open Tutorial Sample Otevřít ukázku výukového kurzu + Finish Tutorial Dokončit výukový kurz + Next Tutorial Další výukový kurz +@msg tutorial `{id}` has no practice sample výukový kurz `{id}` nemá cvičnou ukázku +@msg open the practice sample of `{id}` first (see learn.openSample) nejprve otevřete cvičnou ukázku kurzu `{id}` (viz learn.openSample) +@msg finish `{id}` first (see learn.finish) nejprve dokončete `{id}` (viz learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` je poslední výukový kurz své dovednosti # Deferred plug-in effects Refresh Deferred Effects Obnovit odložené efekty diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index f7011e23bf..70411cbd00 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6760,6 +6760,18 @@ breakText Glyphs Glyffer Show where this tool is Vis, hvor dette værktøj er @msg missing `tutorial` (see learn.list) `tutorial` mangler (se learn.list) @msg unknown tutorial `{id}` (see learn.list) ukendt vejledning `{id}` (se learn.list) + View more Vis mere + Finish tutorial Afslut vejledning + Next Up Næste + You finished the last tutorial in this skill. Du har afsluttet den sidste vejledning i denne færdighed. + Back to skills Tilbage til færdigheder + Open Tutorial Sample Åbn eksempel til vejledning + Finish Tutorial Afslut vejledning + Next Tutorial Næste vejledning +@msg tutorial `{id}` has no practice sample vejledningen `{id}` har intet øvelseseksempel +@msg open the practice sample of `{id}` first (see learn.openSample) åbn først øvelseseksemplet til `{id}` (se learn.openSample) +@msg finish `{id}` first (see learn.finish) afslut først `{id}` (se learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` er den sidste vejledning i sin færdighed # Deferred plug-in effects Refresh Deferred Effects Opdatér udskudte effekter diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4174537f87..eceefc5fb0 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6935,6 +6935,18 @@ breakText Glyphs Glyphen Show where this tool is Zeigen, wo sich dieses Werkzeug befindet @msg missing `tutorial` (see learn.list) `tutorial` fehlt (siehe learn.list) @msg unknown tutorial `{id}` (see learn.list) unbekanntes Lernprogramm `{id}` (siehe learn.list) + View more Mehr anzeigen + Finish tutorial Lernprogramm abschließen + Next Up Als Nächstes + You finished the last tutorial in this skill. Sie haben das letzte Lernprogramm dieser Fertigkeit abgeschlossen. + Back to skills Zurück zu den Fertigkeiten + Open Tutorial Sample Übungsdatei des Lernprogramms öffnen + Finish Tutorial Lernprogramm abschließen + Next Tutorial Nächstes Lernprogramm +@msg tutorial `{id}` has no practice sample das Lernprogramm `{id}` hat keine Übungsdatei +@msg open the practice sample of `{id}` first (see learn.openSample) öffnen Sie zuerst die Übungsdatei von `{id}` (siehe learn.openSample) +@msg finish `{id}` first (see learn.finish) schließen Sie zuerst `{id}` ab (siehe learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` ist das letzte Lernprogramm seiner Fertigkeit # Deferred plug-in effects Refresh Deferred Effects Zurückgestellte Effekte aktualisieren diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index bde2fbee78..79e834ebee 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -7292,6 +7292,18 @@ breakText Glyphs Γλύφοι Show where this tool is Εμφάνιση της θέσης αυτού του εργαλείου @msg missing `tutorial` (see learn.list) λείπει το `tutorial` (δείτε learn.list) @msg unknown tutorial `{id}` (see learn.list) άγνωστος οδηγός εκμάθησης `{id}` (δείτε learn.list) + View more Προβολή περισσότερων + Finish tutorial Ολοκλήρωση οδηγού + Next Up Επόμενο + You finished the last tutorial in this skill. Ολοκληρώσατε τον τελευταίο οδηγό εκμάθησης αυτής της δεξιότητας. + Back to skills Πίσω στις δεξιότητες + Open Tutorial Sample Άνοιγμα δείγματος οδηγού εκμάθησης + Finish Tutorial Ολοκλήρωση οδηγού εκμάθησης + Next Tutorial Επόμενος οδηγός εκμάθησης +@msg tutorial `{id}` has no practice sample ο οδηγός εκμάθησης `{id}` δεν έχει δείγμα εξάσκησης +@msg open the practice sample of `{id}` first (see learn.openSample) ανοίξτε πρώτα το δείγμα εξάσκησης του `{id}` (δείτε learn.openSample) +@msg finish `{id}` first (see learn.finish) ολοκληρώστε πρώτα το `{id}` (δείτε learn.finish) +@msg `{id}` is the last tutorial in its skill το `{id}` είναι ο τελευταίος οδηγός εκμάθησης της δεξιότητάς του # Deferred plug-in effects Refresh Deferred Effects Ανανέωση εφέ σε αναμονή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ff28d9dfc5..905221dc68 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -7283,6 +7283,18 @@ breakText Glyphs Glifos Show where this tool is Mostrar dónde está esta herramienta @msg missing `tutorial` (see learn.list) falta `tutorial` (consulta learn.list) @msg unknown tutorial `{id}` (see learn.list) tutorial desconocido `{id}` (consulta learn.list) + View more Ver más + Finish tutorial Terminar tutorial + Next Up A continuación + You finished the last tutorial in this skill. Has terminado el último tutorial de esta habilidad. + Back to skills Volver a las habilidades + Open Tutorial Sample Abrir muestra del tutorial + Finish Tutorial Terminar tutorial + Next Tutorial Tutorial siguiente +@msg tutorial `{id}` has no practice sample el tutorial `{id}` no tiene muestra de práctica +@msg open the practice sample of `{id}` first (see learn.openSample) abre primero la muestra de práctica de `{id}` (consulta learn.openSample) +@msg finish `{id}` first (see learn.finish) termina primero `{id}` (consulta learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` es el último tutorial de su habilidad # Deferred plug-in effects Refresh Deferred Effects Actualizar efectos aplazados diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index bb74188ed8..2227a31c33 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -7375,6 +7375,18 @@ breakText Glyphs Merkit Show where this tool is Näytä, missä tämä työkalu on @msg missing `tutorial` (see learn.list) `tutorial` puuttuu (ks. learn.list) @msg unknown tutorial `{id}` (see learn.list) tuntematon opetusohjelma `{id}` (ks. learn.list) + View more Näytä lisää + Finish tutorial Viimeistele opetusohjelma + Next Up Seuraavaksi + You finished the last tutorial in this skill. Viimeistelit tämän taidon viimeisen opetusohjelman. + Back to skills Takaisin taitoihin + Open Tutorial Sample Avaa opetusohjelman harjoitustiedosto + Finish Tutorial Viimeistele opetusohjelma + Next Tutorial Seuraava opetusohjelma +@msg tutorial `{id}` has no practice sample opetusohjelmalla `{id}` ei ole harjoitustiedostoa +@msg open the practice sample of `{id}` first (see learn.openSample) avaa ensin opetusohjelman `{id}` harjoitustiedosto (ks. learn.openSample) +@msg finish `{id}` first (see learn.finish) viimeistele ensin `{id}` (ks. learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` on taitonsa viimeinen opetusohjelma # Deferred plug-in effects Refresh Deferred Effects Päivitä lykätyt tehosteet diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 09c9c35b0d..22d5e3d376 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -7335,6 +7335,18 @@ breakText Glyphs Glyphes Show where this tool is Afficher l'emplacement de cet outil @msg missing `tutorial` (see learn.list) `tutorial` manquant (voir learn.list) @msg unknown tutorial `{id}` (see learn.list) tutoriel inconnu `{id}` (voir learn.list) + View more Voir plus + Finish tutorial Terminer le tutoriel + Next Up À suivre + You finished the last tutorial in this skill. Vous avez terminé le dernier tutoriel de cette compétence. + Back to skills Retour aux compétences + Open Tutorial Sample Ouvrir le fichier d’exercice du tutoriel + Finish Tutorial Terminer le tutoriel + Next Tutorial Tutoriel suivant +@msg tutorial `{id}` has no practice sample le tutoriel `{id}` n’a pas de fichier d’exercice +@msg open the practice sample of `{id}` first (see learn.openSample) ouvrez d’abord le fichier d’exercice de `{id}` (voir learn.openSample) +@msg finish `{id}` first (see learn.finish) terminez d’abord `{id}` (voir learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` est le dernier tutoriel de sa compétence # Deferred plug-in effects Refresh Deferred Effects Actualiser les effets différés diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9aa4bd7b99..996e534ef0 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8733,6 +8733,18 @@ theme Appearance अपीयरेंस Show where this tool is दिखाएँ कि यह टूल कहाँ है @msg missing `tutorial` (see learn.list) लापता `tutorial` (learn.list देखें) @msg unknown tutorial `{id}` (see learn.list) अज्ञात ट्यूटोरियल `{id}` (learn.list देखें) + View more और देखें + Finish tutorial ट्यूटोरियल पूरा करें + Next Up आगे + You finished the last tutorial in this skill. आपने इस कौशल का अंतिम ट्यूटोरियल पूरा कर लिया है। + Back to skills कौशल पर वापस जाएँ + Open Tutorial Sample ट्यूटोरियल का नमूना खोलें + Finish Tutorial ट्यूटोरियल पूरा करें + Next Tutorial अगला ट्यूटोरियल +@msg tutorial `{id}` has no practice sample ट्यूटोरियल `{id}` का कोई अभ्यास नमूना नहीं है +@msg open the practice sample of `{id}` first (see learn.openSample) पहले `{id}` का अभ्यास नमूना खोलें (learn.openSample देखें) +@msg finish `{id}` first (see learn.finish) पहले `{id}` पूरा करें (learn.finish देखें) +@msg `{id}` is the last tutorial in its skill `{id}` अपने कौशल का अंतिम ट्यूटोरियल है # Deferred plug-in effects Refresh Deferred Effects स्थगित इफ़ेक्ट्स रिफ्रेश करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index af6f586880..5c38da2fb4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -7324,6 +7324,18 @@ breakText Glyphs Glifák Show where this tool is Megmutatja, hol van ez az eszköz @msg missing `tutorial` (see learn.list) hiányzik: `tutorial` (lásd: learn.list) @msg unknown tutorial `{id}` (see learn.list) ismeretlen oktatóanyag: `{id}` (lásd: learn.list) + View more Továbbiak + Finish tutorial Oktatóanyag befejezése + Next Up Következő + You finished the last tutorial in this skill. Befejezte a készség utolsó oktatóanyagát. + Back to skills Vissza a készségekhez + Open Tutorial Sample Oktatóanyag mintafájljának megnyitása + Finish Tutorial Oktatóanyag befejezése + Next Tutorial Következő oktatóanyag +@msg tutorial `{id}` has no practice sample nincs gyakorló mintafájl ehhez az oktatóanyaghoz: `{id}` +@msg open the practice sample of `{id}` first (see learn.openSample) előbb nyissa meg a gyakorló mintafájlt ehhez: `{id}` (lásd: learn.openSample) +@msg finish `{id}` first (see learn.finish) előbb fejezze be ezt: `{id}` (lásd: learn.finish) +@msg `{id}` is the last tutorial in its skill a készsége utolsó oktatóanyaga: `{id}` # Deferred plug-in effects Refresh Deferred Effects Elhalasztott effektusok frissítése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e0ba31ecd5..75b03775f3 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6760,6 +6760,18 @@ breakText Glyphs Glif Show where this tool is Tampilkan letak alat ini @msg missing `tutorial` (see learn.list) `tutorial` tidak ada (lihat learn.list) @msg unknown tutorial `{id}` (see learn.list) tutorial `{id}` tidak dikenal (lihat learn.list) + View more Lihat selengkapnya + Finish tutorial Selesaikan tutorial + Next Up Berikutnya + You finished the last tutorial in this skill. Anda telah menyelesaikan tutorial terakhir dalam keterampilan ini. + Back to skills Kembali ke keterampilan + Open Tutorial Sample Buka contoh tutorial + Finish Tutorial Selesaikan tutorial + Next Tutorial Tutorial berikutnya +@msg tutorial `{id}` has no practice sample tutorial `{id}` tidak memiliki contoh latihan +@msg open the practice sample of `{id}` first (see learn.openSample) buka contoh latihan `{id}` terlebih dahulu (lihat learn.openSample) +@msg finish `{id}` first (see learn.finish) selesaikan `{id}` terlebih dahulu (lihat learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` adalah tutorial terakhir dalam keterampilannya # Deferred plug-in effects Refresh Deferred Effects Segarkan efek yang ditunda diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ce0bc58ce5..1304d8cf61 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6969,6 +6969,18 @@ breakText Glyphs Glifi Show where this tool is Mostra dove si trova questo strumento @msg missing `tutorial` (see learn.list) manca `tutorial` (vedi learn.list) @msg unknown tutorial `{id}` (see learn.list) tutorial sconosciuto `{id}` (vedi learn.list) + View more Mostra altro + Finish tutorial Termina il tutorial + Next Up Prossimo + You finished the last tutorial in this skill. Hai terminato l’ultimo tutorial di questa competenza. + Back to skills Torna alle competenze + Open Tutorial Sample Apri l’esempio del tutorial + Finish Tutorial Termina il tutorial + Next Tutorial Tutorial successivo +@msg tutorial `{id}` has no practice sample il tutorial `{id}` non ha un esempio di esercitazione +@msg open the practice sample of `{id}` first (see learn.openSample) apri prima l’esempio di esercitazione di `{id}` (vedi learn.openSample) +@msg finish `{id}` first (see learn.finish) termina prima `{id}` (vedi learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` è l’ultimo tutorial della sua competenza # Deferred plug-in effects Refresh Deferred Effects Aggiorna effetti rimandati diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1ecce4618a..1d5d882a82 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -7034,6 +7034,18 @@ breakText Glyphs 字形 Show where this tool is このツールの場所を表示 @msg missing `tutorial` (see learn.list) `tutorial` が指定されていません (learn.list を参照) @msg unknown tutorial `{id}` (see learn.list) `{id}` は不明なチュートリアルです (learn.list を参照) + View more 詳細を見る + Finish tutorial チュートリアルを完了 + Next Up 次へ + You finished the last tutorial in this skill. このスキルの最後のチュートリアルを完了しました。 + Back to skills スキルに戻る + Open Tutorial Sample チュートリアルのサンプルを開く + Finish Tutorial チュートリアルを完了 + Next Tutorial 次のチュートリアル +@msg tutorial `{id}` has no practice sample チュートリアル `{id}` には練習用サンプルがありません +@msg open the practice sample of `{id}` first (see learn.openSample) 先に `{id}` の練習用サンプルを開いてください (learn.openSample を参照) +@msg finish `{id}` first (see learn.finish) 先に `{id}` を完了してください (learn.finish を参照) +@msg `{id}` is the last tutorial in its skill `{id}` はこのスキルの最後のチュートリアルです # Deferred plug-in effects Refresh Deferred Effects 保留中の効果を更新 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 3d6425c373..8e4de4c62d 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -7454,6 +7454,18 @@ font-script Japanese იაპონური Show where this tool is ამ ხელსაწყოს მდებარეობის ჩვენება @msg missing `tutorial` (see learn.list) აკლია `tutorial` (იხ. learn.list) @msg unknown tutorial `{id}` (see learn.list) უცნობი გაკვეთილი `{id}` (იხ. learn.list) + View more მეტის ნახვა + Finish tutorial გაკვეთილის დასრულება + Next Up შემდეგი + You finished the last tutorial in this skill. თქვენ დაასრულეთ ამ უნარის ბოლო გაკვეთილი. + Back to skills უნარებზე დაბრუნება + Open Tutorial Sample გაკვეთილის ნიმუშის გახსნა + Finish Tutorial გაკვეთილის დასრულება + Next Tutorial შემდეგი გაკვეთილი +@msg tutorial `{id}` has no practice sample გაკვეთილს `{id}` არ აქვს სავარჯიშო ნიმუში +@msg open the practice sample of `{id}` first (see learn.openSample) ჯერ გახსენით `{id}` გაკვეთილის სავარჯიშო ნიმუში (იხ. learn.openSample) +@msg finish `{id}` first (see learn.finish) ჯერ დაასრულეთ `{id}` (იხ. learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` თავისი უნარის ბოლო გაკვეთილია # Deferred plug-in effects Refresh Deferred Effects გადადებული ეფექტების განახლება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index fb87488d64..7d8f77477b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6930,6 +6930,18 @@ breakText Glyphs 글리프 Show where this tool is 이 도구의 위치 표시 @msg missing `tutorial` (see learn.list) `tutorial`이 없습니다(learn.list 참조) @msg unknown tutorial `{id}` (see learn.list) 알 수 없는 튜토리얼 `{id}`(learn.list 참조) + View more 자세히 보기 + Finish tutorial 튜토리얼 완료 + Next Up 다음 + You finished the last tutorial in this skill. 이 기술의 마지막 튜토리얼을 완료했습니다. + Back to skills 기술로 돌아가기 + Open Tutorial Sample 튜토리얼 샘플 열기 + Finish Tutorial 튜토리얼 완료 + Next Tutorial 다음 튜토리얼 +@msg tutorial `{id}` has no practice sample 튜토리얼 `{id}`에 연습용 샘플이 없습니다 +@msg open the practice sample of `{id}` first (see learn.openSample) 먼저 `{id}`의 연습용 샘플을 여세요(learn.openSample 참조) +@msg finish `{id}` first (see learn.finish) 먼저 `{id}`을(를) 완료하세요(learn.finish 참조) +@msg `{id}` is the last tutorial in its skill `{id}`은(는) 해당 기술의 마지막 튜토리얼입니다 # Deferred plug-in effects Refresh Deferred Effects 보류된 효과 새로 고침 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b3388fa82a..db793697a6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6811,6 +6811,18 @@ breakText Glyphs Glyphs Show where this tool is Tonen waar dit gereedschap zich bevindt @msg missing `tutorial` (see learn.list) `tutorial` ontbreekt (zie learn.list) @msg unknown tutorial `{id}` (see learn.list) onbekende tutorial `{id}` (zie learn.list) + View more Meer weergeven + Finish tutorial Tutorial voltooien + Next Up Hierna + You finished the last tutorial in this skill. Je hebt de laatste tutorial van deze vaardigheid voltooid. + Back to skills Terug naar vaardigheden + Open Tutorial Sample Oefenbestand van tutorial openen + Finish Tutorial Tutorial voltooien + Next Tutorial Volgende tutorial +@msg tutorial `{id}` has no practice sample tutorial `{id}` heeft geen oefenbestand +@msg open the practice sample of `{id}` first (see learn.openSample) open eerst het oefenbestand van `{id}` (zie learn.openSample) +@msg finish `{id}` first (see learn.finish) voltooi eerst `{id}` (zie learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` is de laatste tutorial van zijn vaardigheid # Deferred plug-in effects Refresh Deferred Effects Uitgestelde effecten vernieuwen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 0729e35c79..372697fdef 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -7196,6 +7196,18 @@ breakText Glyphs Glify Show where this tool is Pokaż, gdzie jest to narzędzie @msg missing `tutorial` (see learn.list) brak `tutorial` (patrz learn.list) @msg unknown tutorial `{id}` (see learn.list) nieznany samouczek `{id}` (patrz learn.list) + View more Pokaż więcej + Finish tutorial Zakończ samouczek + Next Up Następny + You finished the last tutorial in this skill. Ukończono ostatni samouczek tej umiejętności. + Back to skills Powrót do umiejętności + Open Tutorial Sample Otwórz przykład samouczka + Finish Tutorial Zakończ samouczek + Next Tutorial Następny samouczek +@msg tutorial `{id}` has no practice sample samouczek `{id}` nie ma przykładu do ćwiczeń +@msg open the practice sample of `{id}` first (see learn.openSample) najpierw otwórz przykład do ćwiczeń samouczka `{id}` (patrz learn.openSample) +@msg finish `{id}` first (see learn.finish) najpierw zakończ `{id}` (patrz learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` to ostatni samouczek swojej umiejętności # Deferred plug-in effects Refresh Deferred Effects Odśwież odroczone efekty diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 36f778dbd5..728f11dafa 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -7413,6 +7413,18 @@ swatch Yellow Green Verde-amarelado Show where this tool is Mostrar onde está esta ferramenta @msg missing `tutorial` (see learn.list) falta `tutorial` (veja learn.list) @msg unknown tutorial `{id}` (see learn.list) tutorial desconhecido `{id}` (veja learn.list) + View more Ver mais + Finish tutorial Concluir tutorial + Next Up A seguir + You finished the last tutorial in this skill. Você concluiu o último tutorial desta habilidade. + Back to skills Voltar às habilidades + Open Tutorial Sample Abrir Amostra do Tutorial + Finish Tutorial Concluir Tutorial + Next Tutorial Próximo Tutorial +@msg tutorial `{id}` has no practice sample o tutorial `{id}` não tem amostra de prática +@msg open the practice sample of `{id}` first (see learn.openSample) abra primeiro a amostra de prática de `{id}` (veja learn.openSample) +@msg finish `{id}` first (see learn.finish) conclua primeiro `{id}` (veja learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` é o último tutorial da sua habilidade # Deferred plug-in effects Refresh Deferred Effects Atualizar Efeitos Adiados diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 442a9612d4..2f5708c436 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -7429,6 +7429,18 @@ shape Open Открытый Show where this tool is Показать, где находится этот инструмент @msg missing `tutorial` (see learn.list) отсутствует `tutorial` (см. learn.list) @msg unknown tutorial `{id}` (see learn.list) неизвестный урок `{id}` (см. learn.list) + View more Подробнее + Finish tutorial Завершить урок + Next Up Далее + You finished the last tutorial in this skill. Вы завершили последний урок этого навыка. + Back to skills Назад к навыкам + Open Tutorial Sample Открыть пример урока + Finish Tutorial Завершить урок + Next Tutorial Следующий урок +@msg tutorial `{id}` has no practice sample у урока `{id}` нет учебного примера +@msg open the practice sample of `{id}` first (see learn.openSample) сначала откройте учебный пример урока `{id}` (см. learn.openSample) +@msg finish `{id}` first (see learn.finish) сначала завершите `{id}` (см. learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` является последним уроком своего навыка # Deferred plug-in effects Refresh Deferred Effects Обновить отложенные эффекты diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index cc871ad8a3..a05aa652d6 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8779,6 +8779,18 @@ digits Hindi Hindi Show where this tool is Visa var det här verktyget finns @msg missing `tutorial` (see learn.list) `tutorial` saknas (se learn.list) @msg unknown tutorial `{id}` (see learn.list) okänd självstudie `{id}` (se learn.list) + View more Visa mer + Finish tutorial Slutför självstudien + Next Up Nästa + You finished the last tutorial in this skill. Du har slutfört den sista självstudien i den här färdigheten. + Back to skills Tillbaka till färdigheter + Open Tutorial Sample Öppna övningsexempel för självstudie + Finish Tutorial Slutför självstudie + Next Tutorial Nästa självstudie +@msg tutorial `{id}` has no practice sample självstudien `{id}` har inget övningsexempel +@msg open the practice sample of `{id}` first (see learn.openSample) öppna först övningsexemplet för `{id}` (se learn.openSample) +@msg finish `{id}` first (see learn.finish) slutför först `{id}` (se learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` är den sista självstudien i sin färdighet # Deferred plug-in effects Refresh Deferred Effects Uppdatera uppskjutna effekter diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ef8773c31b..1c310c7f6e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -7296,6 +7296,18 @@ breakText Glyphs Glifler Show where this tool is Bu aracın nerede olduğunu göster @msg missing `tutorial` (see learn.list) `tutorial` eksik (bkz. learn.list) @msg unknown tutorial `{id}` (see learn.list) bilinmeyen öğretici `{id}` (bkz. learn.list) + View more Daha fazlasını gör + Finish tutorial Öğreticiyi bitir + Next Up Sıradaki + You finished the last tutorial in this skill. Bu becerinin son öğreticisini bitirdiniz. + Back to skills Becerilere dön + Open Tutorial Sample Öğretici Örneğini Aç + Finish Tutorial Öğreticiyi Bitir + Next Tutorial Sonraki Öğretici +@msg tutorial `{id}` has no practice sample `{id}` öğreticisinin alıştırma örneği yok +@msg open the practice sample of `{id}` first (see learn.openSample) önce `{id}` öğreticisinin alıştırma örneğini açın (bkz. learn.openSample) +@msg finish `{id}` first (see learn.finish) önce `{id}` öğreticisini bitirin (bkz. learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}`, becerisinin son öğreticisidir # Deferred plug-in effects Refresh Deferred Effects Ertelenen Efektleri Yenile diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0bccbd8cb6..5f7de5c52f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6949,6 +6949,18 @@ breakText Glyphs Гліфи Show where this tool is Показати, де розташований цей інструмент @msg missing `tutorial` (see learn.list) відсутній `tutorial` (див. learn.list) @msg unknown tutorial `{id}` (see learn.list) невідомий урок `{id}` (див. learn.list) + View more Докладніше + Finish tutorial Завершити урок + Next Up Далі + You finished the last tutorial in this skill. Ви завершили останній урок цієї навички. + Back to skills Назад до навичок + Open Tutorial Sample Відкрити приклад уроку + Finish Tutorial Завершити урок + Next Tutorial Наступний урок +@msg tutorial `{id}` has no practice sample урок `{id}` не має навчального прикладу +@msg open the practice sample of `{id}` first (see learn.openSample) спочатку відкрийте навчальний приклад уроку `{id}` (див. learn.openSample) +@msg finish `{id}` first (see learn.finish) спочатку завершіть `{id}` (див. learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` є останнім уроком своєї навички # Deferred plug-in effects Refresh Deferred Effects Оновити відкладені ефекти diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4f084029db..5fad3e2493 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6950,6 +6950,18 @@ breakText Glyphs Glyph Show where this tool is Hiện vị trí của công cụ này @msg missing `tutorial` (see learn.list) thiếu `tutorial` (xem learn.list) @msg unknown tutorial `{id}` (see learn.list) hướng dẫn không xác định `{id}` (xem learn.list) + View more Xem thêm + Finish tutorial Hoàn tất hướng dẫn + Next Up Tiếp theo + You finished the last tutorial in this skill. Bạn đã hoàn tất hướng dẫn cuối cùng của kỹ năng này. + Back to skills Quay lại kỹ năng + Open Tutorial Sample Mở tệp mẫu của hướng dẫn + Finish Tutorial Hoàn tất hướng dẫn + Next Tutorial Hướng dẫn tiếp theo +@msg tutorial `{id}` has no practice sample hướng dẫn `{id}` không có tệp mẫu thực hành +@msg open the practice sample of `{id}` first (see learn.openSample) hãy mở tệp mẫu thực hành của `{id}` trước (xem learn.openSample) +@msg finish `{id}` first (see learn.finish) hãy hoàn tất `{id}` trước (xem learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` là hướng dẫn cuối cùng trong kỹ năng của nó # Deferred plug-in effects Refresh Deferred Effects Làm mới hiệu ứng bị hoãn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b04e65a65b..f6e69e53c5 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -7178,6 +7178,18 @@ breakText Glyphs 字形 Show where this tool is 显示此工具的位置 @msg missing `tutorial` (see learn.list) 缺少 `tutorial` (请参阅 learn.list) @msg unknown tutorial `{id}` (see learn.list) 未知教程 `{id}` (请参阅 learn.list) + View more 查看更多 + Finish tutorial 完成教程 + Next Up 下一个 + You finished the last tutorial in this skill. 你已完成此技能的最后一个教程。 + Back to skills 返回技能 + Open Tutorial Sample 打开教程示例 + Finish Tutorial 完成教程 + Next Tutorial 下一个教程 +@msg tutorial `{id}` has no practice sample 教程 `{id}` 没有练习示例 +@msg open the practice sample of `{id}` first (see learn.openSample) 请先打开 `{id}` 的练习示例 (请参阅 learn.openSample) +@msg finish `{id}` first (see learn.finish) 请先完成 `{id}` (请参阅 learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` 是其技能的最后一个教程 # Deferred plug-in effects Refresh Deferred Effects 刷新延迟的效果 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index be62fde8a7..c9d6631fb3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6984,6 +6984,18 @@ breakText Glyphs 字符 Show where this tool is 顯示此工具的位置 @msg missing `tutorial` (see learn.list) 未提供 `tutorial` (請參閱 learn.list) @msg unknown tutorial `{id}` (see learn.list) 未知的教學課程 `{id}` (請參閱 learn.list) + View more 檢視更多 + Finish tutorial 完成教學課程 + Next Up 接下來 + You finished the last tutorial in this skill. 你已完成此技能的最後一個教學課程。 + Back to skills 返回技能 + Open Tutorial Sample 開啟教學課程範例 + Finish Tutorial 完成教學課程 + Next Tutorial 下一個教學課程 +@msg tutorial `{id}` has no practice sample 教學課程 `{id}` 沒有練習範例 +@msg open the practice sample of `{id}` first (see learn.openSample) 請先開啟 `{id}` 的練習範例 (請參閱 learn.openSample) +@msg finish `{id}` first (see learn.finish) 請先完成 `{id}` (請參閱 learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` 是其技能的最後一個教學課程 # Deferred plug-in effects Refresh Deferred Effects 重新整理延後的效果 diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 20a2c58314..8e4aaab55e 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -659,6 +659,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{tool: a tool id, as tool.select takes} mark where that tool is for 3 seconds without choosing it, as the link on a tool's name in a Learn tutorial step does: its toolbar button, a row of its collapsed group's flyout (which opens), its group's torn-off strip (which comes to the front), or, when no shown toolbar holds it, its row in the All Tools drawer (which opens); a Tools panel hidden with Window › Tools shows again; Escape or the next locate removes the mark; a missing, non-string or unknown id is an error and changes nothing; returns {tool, location: toolbar|flyout|strip|allTools|unavailable, toolbar: the name of the toolbar made with New Toolbar that holds it, or null}, with reason when Presentation Mode or Tab hides the toolbars", ), + ( + "learn.openSample", + "Open Tutorial Sample", + "", + "{tutorial: id (see learn.list)} open that tutorial's practice sample as a new untitled document, which becomes the active one, and move the Learn view to the tutorial, as its View more button does; each call opens another copy and open documents stay as they are; a missing, non-string or unknown id is an error and changes nothing; returns {tutorial, index, title} of the new document", + ), + ( + "learn.finish", + "Finish Tutorial", + "", + "{tutorial: id (see learn.list)} mark a tutorial whose sample learn.openSample opened as finished, as its Finish tutorial button does; finishing it again keeps one entry; a missing, non-string or unknown id, or a tutorial whose sample was not opened, is an error and changes nothing; returns {tutorial, finished: true, next: id of the next tutorial in its skill, or null after the skill's last one}", + ), + ( + "learn.next", + "Next Tutorial", + "", + "{tutorial: id (see learn.list)} show the Learn panel on the tutorial after a finished one in the same skill, as its Next Up button does, without opening a sample; a missing, non-string or unknown id, a tutorial not finished, or a skill's last tutorial is an error and changes nothing; returns {skill, tutorial, title} of the next tutorial", + ), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), ( "window.workspace", diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs index cce131c7a3..8941626a2d 100644 --- a/crates/ui-egui/src/panels/learn.rs +++ b/crates/ui-egui/src/panels/learn.rs @@ -3,11 +3,15 @@ //! section with the Quick skill guide card, which opens the Skills tab. The Skills tab lists each //! skill with an empty progress ring and its count of tutorials; a skill lists its tutorials, and a //! tutorial previews its numbered steps. A tool's name in a step is a link that shows where the tool -//! is ([`locate`]). Back goes up one level. `learn.list` lists the tutorials, `learn.show` opens one, -//! `learn.locateTool` shows where a tool is and `window.learn` shows or hides the panel. +//! is ([`locate`]). Back goes up one level. Under the steps, View more opens the tutorial's practice +//! sample as a new untitled document; Finish tutorial then marks it finished, and Next Up shows the +//! next tutorial in the same skill. `learn.list` lists the tutorials, `learn.show` opens one, +//! `learn.locateTool` shows where a tool is, `learn.openSample`, `learn.finish` and `learn.next` run +//! the three practice controls, and `window.learn` shows or hides the panel. pub mod catalog; pub mod locate; +pub mod samples; use egui::{Sense, Stroke, Ui, vec2}; use serde_json::{Value, json}; @@ -35,6 +39,14 @@ pub struct View { pub tutorial: Option<&'static str>, } +/// The tutorials whose samples View more opened, and the tutorials finished, in the order they +/// were finished. Each id is listed once. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Practice { + pub opened: Vec<&'static str>, + pub finished: Vec<&'static str>, +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.horizontal(|ui| { for (tab, label) in [(Tab::ForYou, tl!("For you")), (Tab::Skills, tl!("Skills"))] { @@ -89,6 +101,7 @@ fn skills(app: &mut VectorcraftApp, ui: &mut Ui) { app.ui.learn.tutorial = None; } steps(app, ui, tutorial); + practice(app, ui, tutorial); return; } if let Some(skill) = app.ui.learn.skill.and_then(catalog::skill) { @@ -163,6 +176,49 @@ fn tool_link(ui: &mut Ui, t: &Tokens, name: &str) -> egui::Response { resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tl!("Show where this tool is")) } +/// The controls under a tutorial's steps: View more; Finish tutorial once its sample is open; and +/// once it is finished, Next Up with the next tutorial's title, or for the skill's last tutorial a +/// line saying so and Back to skills. +fn practice(app: &mut VectorcraftApp, ui: &mut Ui, tutorial: &'static catalog::Tutorial) { + let t = Tokens::get(ui.ctx()); + let id = tutorial.id; + let mut run = None; + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + if ui.button(tl!("View more")).clicked() { + run = Some("learn.openSample"); + } + let done = &app.ui.learn_practice; + if done.opened.contains(&id) && !done.finished.contains(&id) && ui.button(tl!("Finish tutorial")).clicked() { + run = Some("learn.finish"); + } + }); + if app.ui.learn_practice.finished.contains(&id) { + ui.add_space(6.0); + match catalog::next(id) { + Some(next) => { + ui.horizontal_wrapped(|ui| { + if ui.button(tl!("Next Up")).clicked() { + run = Some("learn.next"); + } + ui.add(egui::Label::new(egui::RichText::new(next.title).color(t.text)).wrap()); + }); + } + None => { + ui.add(egui::Label::new(egui::RichText::new(tl!("You finished the last tutorial in this skill.")).color(t.text_dim)).wrap()); + if ui.button(tl!("Back to skills")).clicked() { + app.ui.learn = View { tab: Tab::Skills, skill: None, tutorial: None }; + } + } + } + } + if let Some(cmd) = run + && let Err(e) = app.run(cmd, json!({ "tutorial": id })) + { + app.status(e); + } +} + /// A clickable row: `title`, and for a skill (`tutorials`) a progress ring before it and the /// count of tutorials done of all after it. No progress is kept yet, so the ring is empty. fn row(ui: &mut Ui, title: &str, tutorials: Option) -> egui::Response { @@ -191,13 +247,17 @@ fn row(ui: &mut Ui, title: &str, tutorials: Option) -> egui::Response { resp.on_hover_cursor(egui::CursorIcon::PointingHand) } -/// The Learn panel's UI commands: `window.learn`, `learn.list`, `learn.show` and `learn.locateTool`. +/// The Learn panel's UI commands: `window.learn`, `learn.list`, `learn.show`, `learn.locateTool`, +/// `learn.openSample`, `learn.finish` and `learn.next`. pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { Some(match id { "window.learn" => toggle(app, p), "learn.list" => Ok(list()), "learn.show" => open(app, p), "learn.locateTool" => locate::locate_tool(app, p), + "learn.openSample" => open_sample(app, p), + "learn.finish" => finish(app, p), + "learn.next" => next(app, p), _ => return None, }) } @@ -234,18 +294,73 @@ fn list() -> Value { /// `learn.show {tutorial}`: show the panel on the Skills tab with that tutorial's steps → the /// tutorial. A missing or unknown id is an error and changes nothing. fn open(app: &mut VectorcraftApp, p: &Value) -> Result { - let key = "tutorial"; - let id = match p.get(key) { - None | Some(Value::Null) => return Err("missing `tutorial` (see learn.list)".into()), - Some(Value::String(id)) => id.as_str(), - Some(_) => return Err(format!("`{key}` must be a string")), - }; - let (skill, tutorial) = catalog::tutorial(id).ok_or_else(|| format!("unknown tutorial `{id}` (see learn.list)"))?; + let (skill, tutorial) = tutorial_param(p)?; crate::panel_docking::reveal_panel(app, ID)?; app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(tutorial.id) }; let steps: Vec = tutorial.steps.iter().map(catalog::Step::plain).collect(); Ok(json!({"skill": skill.id, "tutorial": tutorial.id, "title": tutorial.title, "steps": steps})) } +/// The tutorial `p`'s `tutorial` names, with its skill. A missing, non-string or unknown id is an +/// error. +fn tutorial_param(p: &Value) -> Result<(&'static catalog::Skill, &'static catalog::Tutorial), String> { + let key = "tutorial"; + let id = match p.get(key) { + None | Some(Value::Null) => return Err("missing `tutorial` (see learn.list)".into()), + Some(Value::String(id)) => id.as_str(), + Some(_) => return Err(format!("`{key}` must be a string")), + }; + catalog::tutorial(id).ok_or_else(|| format!("unknown tutorial `{id}` (see learn.list)")) +} + +/// `learn.openSample {tutorial}`: View more. Open that tutorial's practice sample as a new untitled +/// document, which becomes the active one, and move the Learn view to the tutorial → `{tutorial, +/// index, title}` of the new document. Each call opens another copy; open documents stay as they +/// are. An id that is not valid, or a sample that does not read, is an error and changes nothing. +fn open_sample(app: &mut VectorcraftApp, p: &Value) -> Result { + let (skill, tutorial) = tutorial_param(p)?; + let id = tutorial.id; + let svg = samples::sample(id).ok_or_else(|| format!("tutorial `{id}` has no practice sample"))?; + let name = format!("{id}.svg"); + let opened = vectorcraft_engine::cmd::fileio::open_template(&mut app.session, &name, svg.as_bytes(), None).map_err(|e| e.to_string())?; + app.sync_views(); + if !app.ui.learn_practice.opened.contains(&id) { + app.ui.learn_practice.opened.push(id); + } + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(id) }; + Ok(json!({"tutorial": id, "index": opened["index"], "title": opened["title"]})) +} + +/// `learn.finish {tutorial}`: Finish tutorial. Mark a tutorial whose sample was opened finished → +/// `{tutorial, finished: true, next}`, where `next` is the id of the next tutorial in its skill, or +/// null after the skill's last one. Finishing it again keeps one entry. The Learn view stays as it is. +fn finish(app: &mut VectorcraftApp, p: &Value) -> Result { + let (_, tutorial) = tutorial_param(p)?; + let id = tutorial.id; + let practice = &mut app.ui.learn_practice; + if !practice.opened.contains(&id) { + return Err(format!("open the practice sample of `{id}` first (see learn.openSample)")); + } + if !practice.finished.contains(&id) { + practice.finished.push(id); + } + Ok(json!({"tutorial": id, "finished": true, "next": catalog::next(id).map(|t| t.id)})) +} + +/// `learn.next {tutorial}`: Next Up. Show the panel on the tutorial after a finished one in the +/// same skill → `{skill, tutorial, title}` of that tutorial. It opens no sample. An unfinished +/// tutorial, or a skill's last tutorial, is an error and changes nothing. +fn next(app: &mut VectorcraftApp, p: &Value) -> Result { + let (skill, tutorial) = tutorial_param(p)?; + let id = tutorial.id; + if !app.ui.learn_practice.finished.contains(&id) { + return Err(format!("finish `{id}` first (see learn.finish)")); + } + let after = catalog::next(id).ok_or_else(|| format!("`{id}` is the last tutorial in its skill"))?; + crate::panel_docking::reveal_panel(app, ID)?; + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(after.id) }; + Ok(json!({"skill": skill.id, "tutorial": after.id, "title": after.title})) +} + #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/panels/learn/catalog.rs b/crates/ui-egui/src/panels/learn/catalog.rs index 98f0d6f355..57a8f30e31 100644 --- a/crates/ui-egui/src/panels/learn/catalog.rs +++ b/crates/ui-egui/src/panels/learn/catalog.rs @@ -201,3 +201,10 @@ pub fn skill(id: &str) -> Option<&'static Skill> { pub fn tutorial(id: &str) -> Option<(&'static Skill, &'static Tutorial)> { SKILLS.iter().find_map(|s| s.tutorials.iter().find(|t| t.id == id).map(|t| (s, t))) } + +/// The tutorial after the one whose id is `id` in the same skill; none for a skill's last tutorial. +pub fn next(id: &str) -> Option<&'static Tutorial> { + let (skill, _) = tutorial(id)?; + let at = skill.tutorials.iter().position(|t| t.id == id)?; + skill.tutorials.get(at + 1) +} diff --git a/crates/ui-egui/src/panels/learn/samples.rs b/crates/ui-egui/src/panels/learn/samples.rs new file mode 100644 index 0000000000..d5e2758f59 --- /dev/null +++ b/crates/ui-egui/src/panels/learn/samples.rs @@ -0,0 +1,103 @@ +//! The practice samples that a tutorial's View more opens: one small original SVG drawing for each +//! tutorial, with only the art its steps work on. Each one opens as a new untitled document. + +/// The largest sample, in bytes: the catalog test keeps every sample under it. +pub const MAX_SAMPLE_BYTES: usize = 16 * 1024; + +/// Every tutorial's sample, by tutorial id, in the catalog's order. +pub const SAMPLES: &[(&str, &str)] = &[ + ( + "draw-and-color", + r##" + + +"##, + ), + ( + "combine-shapes", + r##" + + + +"##, + ), + ( + "add-headline", + r##" + + +"##, + ), + ( + "pen-paths", + r##" + + + + + +"##, + ), + ( + "smooth-curves", + r##" + + +"##, + ), + ( + "pencil-sketch", + r##" + + +"##, + ), + ( + "fill-and-stroke", + r##" + + +"##, + ), + ( + "gradient", + r##" + + +"##, + ), + ( + "copy-colors", + r##" + + + +"##, + ), + ( + "area-type", + r##" + + +"##, + ), + ( + "type-on-path", + r##" + + +"##, + ), + ( + "outline-type", + r##" + Outline + +"##, + ), +]; + +/// The sample of the tutorial whose id is `id`. +pub fn sample(id: &str) -> Option<&'static str> { + SAMPLES.iter().find(|(tutorial, _)| *tutorial == id).map(|(_, svg)| *svg) +} diff --git a/crates/ui-egui/src/panels/learn/tests.rs b/crates/ui-egui/src/panels/learn/tests.rs index 7014785eda..f302d8f436 100644 --- a/crates/ui-egui/src/panels/learn/tests.rs +++ b/crates/ui-egui/src/panels/learn/tests.rs @@ -4,7 +4,7 @@ use egui::accesskit::Node; use serde_json::json; use vectorcraft_engine::Session; -use super::{ID, Tab, View, catalog, show}; +use super::{ID, Practice, Tab, View, catalog, samples, show}; use crate::VectorcraftApp; use crate::tests_labels::painted_text; @@ -228,6 +228,220 @@ fn learn_show_opens_a_tutorial_and_learn_list_lists_them() { assert_eq!(skills[2]["tutorials"][1], json!({"id": "gradient", "title": "Add a gradient", "steps": 3})); } +/// Every tutorial has one sample of its own, in the catalog's order, each one small and read +/// without warnings into art of its own. +#[test] +fn every_tutorial_has_its_own_readable_sample() { + let ids: Vec<&str> = catalog::SKILLS.iter().flat_map(|s| s.tutorials.iter().map(|t| t.id)).collect(); + let sampled: Vec<&str> = samples::SAMPLES.iter().map(|(id, _)| *id).collect(); + assert_eq!(sampled, ids, "one sample for each tutorial, in the catalog's order"); + let mut texts = HashSet::new(); + for (id, svg) in samples::SAMPLES { + assert!(svg.len() <= samples::MAX_SAMPLE_BYTES, "{id}: {} bytes", svg.len()); + assert!(texts.insert(*svg), "{id} reuses another tutorial's sample"); + let loaded = vectorcraft_engine::cmd::fileio::load(&format!("{id}.svg"), svg.as_bytes()).unwrap_or_else(|e| panic!("{id}: {e}")); + assert!(loaded.warnings.is_empty(), "{id}: {:?}", loaded.warnings); + let mut art = 0; + loaded.doc.walk(|n| art += usize::from(!n.is_layer())); + assert!(art > 0, "{id} has no art"); + } +} + +/// Next Up stays in the tutorial's skill: a skill's last tutorial, the catalog's last one +/// included, has no next tutorial. +#[test] +fn the_next_tutorial_is_the_next_one_in_the_same_skill() { + for skill in catalog::SKILLS { + for pair in skill.tutorials.windows(2) { + assert_eq!(catalog::next(pair[0].id).map(|t| t.id), Some(pair[1].id)); + } + let last = skill.tutorials.last().unwrap(); + assert!(catalog::next(last.id).is_none(), "{} is the last of {}", last.id, skill.id); + } + assert!(catalog::next("nope").is_none()); +} + +/// View more opens the tutorial's sample as a new untitled document beside a modified one, which +/// stays as it was; another tutorial opens another sample, and View more again opens another copy. +#[test] +fn view_more_opens_the_tutorial_s_sample_as_a_new_untitled_document() { + let mut app = app(); + let ctx = context(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + let work = app.session.documents()[0].doc.node_count(); + let (skill, tutorial) = catalog::tutorial("pen-paths").unwrap(); + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(tutorial.id) }; + click(&mut app, &ctx, "View more", show); + let docs = app.session.documents(); + assert_eq!(docs.len(), 2, "{}", app.ui.status); + assert_eq!(app.session.active_index(), Some(1)); + assert_eq!(docs[0].doc.node_count(), work, "the open document stays as it was"); + assert!(docs[1].path.is_none() && docs[1].title().starts_with("Untitled-"), "{}", docs[1].title()); + let pen = docs[1].doc.node_count(); + assert_eq!(app.ui.learn_practice, Practice { opened: vec!["pen-paths"], finished: vec![] }); + + let r = app.run("learn.openSample", json!({"tutorial": "gradient"})).unwrap(); + assert_eq!(r["tutorial"], "gradient"); + assert_eq!(r["index"], 2); + assert_eq!(r["title"], json!(app.session.documents()[2].title())); + assert_ne!(app.session.documents()[2].doc.node_count(), pen, "another tutorial, another sample"); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("color"), tutorial: Some("gradient") }); + app.run("learn.openSample", json!({"tutorial": "gradient"})).unwrap(); + assert_eq!(app.session.documents().len(), 4, "View more again opens another copy"); + assert_eq!(app.ui.learn_practice.opened, ["pen-paths", "gradient"]); +} + +/// Finish tutorial shows once the sample is open; finishing shows Next Up with the next +/// tutorial's title, and Next Up shows that tutorial without opening its sample. +#[test] +fn finishing_a_tutorial_offers_next_up_in_its_skill() { + let mut app = app(); + let ctx = context(); + let (skill, tutorial) = catalog::tutorial("pen-paths").unwrap(); + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(tutorial.id) }; + let text = painted_text(&mut app, show); + assert!(text.contains("View more") && !text.contains("Finish tutorial") && !text.contains("Next Up"), "{text}"); + click(&mut app, &ctx, "View more", show); + let text = painted_text(&mut app, show); + assert!(text.contains("Finish tutorial") && !text.contains("Next Up"), "{text}"); + click(&mut app, &ctx, "Finish tutorial", show); + assert_eq!(app.ui.learn_practice.finished, ["pen-paths"]); + let text = painted_text(&mut app, show); + assert!(text.contains("Next Up") && text.contains("Draw smooth curves") && !text.contains("Finish tutorial"), "{text}"); + let docs = app.session.documents().len(); + click(&mut app, &ctx, "Next Up", show); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("draw"), tutorial: Some("smooth-curves") }); + assert_eq!(app.session.documents().len(), docs, "Next Up opens no sample"); + let text = painted_text(&mut app, show); + assert!(text.contains("View more") && !text.contains("Finish tutorial") && !text.contains("Next Up"), "{text}"); +} + +/// One press and release of `key` in what `draw` draws. +fn key(app: &mut VectorcraftApp, ctx: &egui::Context, key: egui::Key, draw: fn(&mut VectorcraftApp, &mut egui::Ui)) { + let event = |pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + frame(app, ctx, vec![event(true)], draw); + frame(app, ctx, vec![event(false)], draw); +} + +/// Press Tab until the control labeled `name` has the keyboard focus. +fn tab_to(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, draw: fn(&mut VectorcraftApp, &mut egui::Ui)) { + for _ in 0..32 { + key(app, ctx, egui::Key::Tab, draw); + let at = center(&frame(app, ctx, vec![], draw), name).unwrap_or_else(|| panic!("{name} is drawn")); + if ctx.memory(|m| m.focused()).and_then(|id| ctx.read_response(id)).is_some_and(|r| r.rect.contains(at)) { + return; + } + } + panic!("Tab did not reach {name}"); +} + +/// View more, Finish tutorial and Next Up are reached with Tab and pressed with Enter. +#[test] +fn the_practice_controls_work_from_the_keyboard() { + let mut app = app(); + let ctx = context(); + let (skill, tutorial) = catalog::tutorial("pen-paths").unwrap(); + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(tutorial.id) }; + frame(&mut app, &ctx, vec![], show); + tab_to(&mut app, &ctx, "View more", show); + key(&mut app, &ctx, egui::Key::Enter, show); + assert_eq!(app.session.documents().len(), 1, "{}", app.ui.status); + assert_eq!(app.ui.learn_practice.opened, ["pen-paths"]); + tab_to(&mut app, &ctx, "Finish tutorial", show); + key(&mut app, &ctx, egui::Key::Enter, show); + assert_eq!(app.ui.learn_practice.finished, ["pen-paths"]); + tab_to(&mut app, &ctx, "Next Up", show); + key(&mut app, &ctx, egui::Key::Enter, show); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("draw"), tutorial: Some("smooth-curves") }); + assert_eq!(app.session.documents().len(), 1, "Next Up opens no sample"); +} + +/// The panel 150 points wide. +fn narrow(app: &mut VectorcraftApp, ui: &mut egui::Ui) { + ui.allocate_ui(egui::vec2(150.0, 800.0), |ui| { + ui.set_max_width(150.0); + show(app, ui); + }); +} + +/// In a narrow panel the practice controls wrap inside it. +#[test] +fn the_practice_controls_fit_a_narrow_panel() { + let mut app = app(); + let ctx = context(); + app.run("learn.openSample", json!({"tutorial": "pen-paths"})).unwrap(); + frame(&mut app, &ctx, vec![], narrow); + let nodes = frame(&mut app, &ctx, vec![], narrow); + for name in ["View more", "Finish tutorial"] { + let node = nodes.iter().find(|n| n.label() == Some(name)).unwrap_or_else(|| panic!("{name} is drawn")); + let b = node.bounds().unwrap(); + assert!(b.x1 <= 151.0, "{name} ends at {}", b.x1); + } +} + +/// A skill's last tutorial, finished, says so and offers Back to skills instead of Next Up, even +/// when another skill follows; `learn.next` there is an error. +#[test] +fn the_last_tutorial_of_a_skill_offers_back_to_skills() { + let mut app = app(); + let ctx = context(); + for id in ["add-headline", "outline-type"] { + app.run("learn.openSample", json!({"tutorial": id})).unwrap(); + assert_eq!(app.run("learn.finish", json!({"tutorial": id})).unwrap(), json!({"tutorial": id, "finished": true, "next": null})); + let text = painted_text(&mut app, show); + assert!(text.contains("You finished the last tutorial in this skill.") && !text.contains("Next Up"), "{text}"); + let before = app.ui.learn.clone(); + assert_eq!(app.run("learn.next", json!({"tutorial": id})).unwrap_err(), format!("`{id}` is the last tutorial in its skill")); + assert_eq!(app.ui.learn, before); + click(&mut app, &ctx, "Back to skills", show); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: None, tutorial: None }); + } +} + +/// `learn.finish` needs the sample opened, and finishing again keeps one entry; `learn.next` needs +/// the tutorial finished and answers the next tutorial. +#[test] +fn learn_finish_and_learn_next_follow_the_practice() { + let mut app = app(); + let open = "open the practice sample of `gradient` first (see learn.openSample)"; + assert_eq!(app.run("learn.finish", json!({"tutorial": "gradient"})).unwrap_err(), open); + assert_eq!(app.run("learn.next", json!({"tutorial": "gradient"})).unwrap_err(), "finish `gradient` first (see learn.finish)"); + assert_eq!(app.ui.learn_practice, Practice::default()); + app.run("learn.openSample", json!({"tutorial": "gradient"})).unwrap(); + assert_eq!(app.run("learn.next", json!({"tutorial": "gradient"})).unwrap_err(), "finish `gradient` first (see learn.finish)"); + for _ in 0..2 { + let r = app.run("learn.finish", json!({"tutorial": "gradient"})).unwrap(); + assert_eq!(r, json!({"tutorial": "gradient", "finished": true, "next": "copy-colors"})); + } + assert_eq!(app.ui.learn_practice.finished, ["gradient"], "finishing again keeps one entry"); + let r = app.run("learn.next", json!({"tutorial": "gradient"})).unwrap(); + assert_eq!(r, json!({"skill": "color", "tutorial": "copy-colors", "title": "Copy colors from other art"})); + assert!(shown(&app)); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("color"), tutorial: Some("copy-colors") }); +} + +/// The practice commands reject a missing, mistyped or unknown tutorial id and change nothing. +#[test] +fn the_practice_commands_reject_a_missing_or_unknown_tutorial() { + let mut app = app(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + for cmd in ["learn.openSample", "learn.finish", "learn.next"] { + for (params, error) in [ + (json!({}), "missing `tutorial` (see learn.list)"), + (json!({"tutorial": null}), "missing `tutorial` (see learn.list)"), + (json!({"tutorial": 3}), "`tutorial` must be a string"), + (json!({"tutorial": "nope"}), "unknown tutorial `nope` (see learn.list)"), + ] { + assert_eq!(app.run(cmd, params).unwrap_err(), error, "{cmd}"); + assert_eq!(app.ui.learn, View::default()); + assert_eq!(app.ui.learn_practice, Practice::default()); + assert_eq!(app.session.documents().len(), 1); + assert!(!shown(&app)); + } + } +} + /// A missing, mistyped or unknown tutorial id is an error and leaves the panel as it was. #[test] fn learn_show_rejects_a_missing_or_unknown_tutorial() { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f84b8f53b0..e7951f6d4f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -424,6 +424,10 @@ pub struct UiState { /// is on screen. Not saved. #[serde(skip)] pub learn_locate: Option, + /// The tutorials whose practice samples were opened and the tutorials finished, kept while the + /// app runs. + #[serde(skip)] + pub learn_practice: crate::panels::learn::Practice, pub control_bar: bool, /// The Control bar is docked to the bottom of the window instead of the top /// (`window.control.dock`). @@ -1089,6 +1093,7 @@ impl Default for UiState { find_font_in: String::new(), learn: Default::default(), learn_locate: None, + learn_practice: Default::default(), control_bar: false, control_bar_bottom: false, control_bar_floating: false, diff --git a/docs/mcp.md b/docs/mcp.md index 1232ffb131..8e158a81a9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2685,11 +2685,29 @@ dock; in a full screen mode it makes Learn the front panel, which shows when the other value of `show` is an error and changes nothing. `learn.list {}` → `{skills: [{id, title, tutorials: [{id, title, steps}]}]}`, in the order the Skills tab lists them, where `steps` is the number of steps. `learn.show {tutorial}` shows the panel on its Skills tab with that tutorial's steps → `{skill, tutorial, title, steps: [text]}`. A missing, non-string or unknown `tutorial` is an error -and changes nothing. Behavior reference: +and changes nothing. + +Under a tutorial's steps, View more opens the tutorial's practice sample, a small drawing of VectorCraft's own with the +art its steps work on, as a new untitled document that becomes the active one; Save asks where to save it. Once the +sample is open, Finish tutorial marks the tutorial finished. A finished tutorial shows Next Up with the title of the +next tutorial in the same skill, which Next Up shows without opening its sample. A skill's last tutorial, finished, +says so and shows Back to skills instead; Next Up never moves to another skill. Which tutorials are finished is kept +while the app runs, and the skill rings and counts do not show it yet. These are VectorCraft's own rules; the help +page also plays a short video, which VectorCraft does not have. `learn.openSample {tutorial}` runs View more → +`{tutorial, index, title}` of the new document, and moves the Learn view to that tutorial; each call opens another +copy and leaves the open documents as they are. `learn.finish {tutorial}` runs Finish tutorial → +`{tutorial, finished: true, next}`, where `next` is the next tutorial's id in the skill, or null after the skill's +last one; the tutorial's sample must have been opened, and finishing it again keeps one entry. `learn.next {tutorial}` +runs Next Up on a finished tutorial and shows the panel on the next one → `{skill, tutorial, title}`; on a tutorial +not finished, or a skill's last tutorial, it is an error. For all three, a missing, non-string or unknown `tutorial` +is an error, and an error changes nothing. Behavior reference: [Quick skill guide](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html). ```json {"name":"invoke_menu","arguments":{"command":"learn.show","params":{"tutorial":"pen-paths"}}} +{"name":"invoke_menu","arguments":{"command":"learn.openSample","params":{"tutorial":"pen-paths"}}} +{"name":"invoke_menu","arguments":{"command":"learn.finish","params":{"tutorial":"pen-paths"}}} +{"name":"invoke_menu","arguments":{"command":"learn.next","params":{"tutorial":"pen-paths"}}} ``` A step that names a tool shows the tool's name as a blue link. A click on it, or Enter or Space while it has the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 086d4cfc0d..4ac027eb78 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -864,6 +864,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their tutorial counts and empty progress rings; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list` and `learn.show {tutorial}` script it ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`). - A tool's name in a Learn tutorial step is a blue link that shows where the tool is without choosing it (`learn.locateTool {tool}`): its toolbar button is marked, a collapsed group's flyout opens with the tool's row marked, a torn-off strip comes to the front, and a tool no shown toolbar holds is marked in the All Tools drawer. The mark, its 3 seconds and the hidden-toolbar cases are VectorCraft's own rules ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`, the `a_learn_link_*` tests in `toolbar::tests`). + - Under a tutorial's steps, View more opens the tutorial's own practice sample as a new untitled document, and once the tutorial is finished, Next Up moves to the next tutorial in the same skill, as the help page describes ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html)). The samples are VectorCraft's own drawings. A Finish tutorial button that shows once the sample is open, the untitled copy, Back to skills in place of Next Up after a skill's last tutorial, and Next Up showing the next tutorial without opening its sample are VectorCraft's own rules. `learn.openSample`, `learn.finish` and `learn.next` script them. Not done: the help page's short video, and progress kept between sessions and shown in the skill rings. Regression tests: `view_more_opens_the_tutorial_s_sample_as_a_new_untitled_document`, `finishing_a_tutorial_offers_next_up_in_its_skill`, `the_practice_controls_work_from_the_keyboard`, `the_last_tutorial_of_a_skill_offers_back_to_skills`, `learn_finish_and_learn_next_follow_the_practice`, `the_practice_commands_reject_a_missing_or_unknown_tutorial`, `every_tutorial_has_its_own_readable_sample` and `the_next_tutorial_is_the_next_one_in_the_same_skill` in `crates/ui-egui/src/panels/learn/tests.rs`. - The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`). - The errors of the 3D effects (Extrude & Bevel, Revolve, Inflate, Rotate and their Classic forms) show in the status bar in the interface language: the art a 3D effect cannot build a solid from, invalid light, graphics and surface settings, and the notes for an object that draws no 3D faces or more wireframe edges than the limit. Each has a row in every catalog that translates all status and error messages, and the effect's name and the values in the message are translated or kept as written (`three_d_refusals_are_translated_in_every_complete_catalog`, `three_d_refusals_translate_with_their_values`). - The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b2dd7d23a1..86f05d0ad8 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -380,7 +380,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po |---|---|---| | Effect > Warp Options: style previews, orientation choices and percent controls | done | #1760; focused input, mixed-style and history checks passed; original captures reviewed. No geometry or optional Properties changes. | | Command search: Up and Down move through the results, Enter runs the highlighted one | done | #1798 (#1757) | -| Learn panel: Window › Learn and Explore tutorials on the app bar open it; For you holds a Quick skill guide card, Skills lists VectorCraft's own skills with tutorial counts and empty progress rings, a tutorial shows its numbered steps, Back goes up a level; a tool's name in a step is a link that marks the tool where the toolbars show it (its button, its group's flyout, a torn-off strip, a toolbar made with New Toolbar or the All Tools drawer) without choosing it | done | [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `learn.list`, `learn.show`, `learn.locateTool`; the mark's look and 3-second lifetime are VectorCraft's own rules; `panels::learn::tests`, `toolbar::tests` | +| Learn panel: Window › Learn and Explore tutorials on the app bar open it; For you holds a Quick skill guide card, Skills lists VectorCraft's own skills with tutorial counts and empty progress rings, a tutorial shows its numbered steps, Back goes up a level; a tool's name in a step is a link that marks the tool where the toolbars show it (its button, its group's flyout, a torn-off strip, a toolbar made with New Toolbar or the All Tools drawer) without choosing it; View more opens the tutorial's practice sample as a new untitled document, Finish tutorial then marks it finished, and Next Up shows the next tutorial in the same skill (no video) | done | [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `learn.list`, `learn.show`, `learn.locateTool`, `learn.openSample`, `learn.finish`, `learn.next`; the mark's look and 3-second lifetime are VectorCraft's own rules; `panels::learn::tests`, `toolbar::tests` | | Pathfinder panel: a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled | done | #1642 (#1635) | | Selected Grid Repeat shows row, column and corner handles and spacing sliders; bounding-box handles take priority where they meet | done | #2691, `tools/src/repeat_grid.rs`; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html); drags preview and commit `object.repeat.options` as one undo step, Escape cancels; Radial Repeat has no handles yet | | Grid Repeat Options (Object › Repeat › Options…) has a Preview checkbox: the repeat on the canvas follows the fields while the dialog is open, Cancel puts the original back without a history step, OK keeps the previewed values as one undo step | done | #2651; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html) lists the Preview option for the grid options only, so Radial and Mirror Repeat Options have none; `dialogs/command.rs` tests | From 6e2fe9fddf714b71f276a65f10db2445e6e65c35 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 01:06:53 -0800 Subject: [PATCH 03/48] SVG: right-to-left text with text-anchor start or end opens at its directional edge SVG 2 defines text-anchor start for right-to-left text as placing the right side of the text at its position, and end as placing the left side there (https://www.w3.org/TR/SVG2/text.html#TextAnchoringProperties). Import placed start at the left edge and end at the right edge whatever the direction. - Point type and type on a path swap start and end when the effective direction is rtl. On a path, right-to-left text anchored by start now ends at startOffset and text anchored by end begins there. Vertical type keeps its anchoring. - Each line and each absolutely placed chunk is anchored by the text-anchor and direction of the element holding its first character, a tspan's own included. A later chunk no longer takes the first chunk's anchor. - Each line of point type starts at its own x, also when it has the same anchor as the line before it or another line holds absolutely placed characters. Such lines were drawn at the first line's x. - A test covers letter-spaced right-to-left lines: the layout keeps the tracking of every letter in such a line, so the whole advance sits on its anchor. --- crates/svg/src/import/text.rs | 78 +++++++++------ crates/svg/tests/it/import_text.rs | 152 +++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 200 insertions(+), 31 deletions(-) diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 51bcffa41e..f0d50a747d 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -1236,12 +1236,6 @@ impl Ctx { .collect(); let under = stroke_under(&mut cells, label, warnings); - let anchor_of = |i: usize| match cs.chars.get(i).and_then(|c| self.prop(c.1, "text-anchor")).as_deref() { - Some("middle") => 0.5, - Some("end") => 1.0, - _ => 0.0, - }; - let anchor = anchor_of(0); let path = cs.path.and_then(|tp| match self.text_path(xml, tp) { Some(p) => Some((tp, p)), None => { @@ -1250,6 +1244,22 @@ impl Ctx { } }); let vertical = self.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical")); + // Where the chunk starting at character `i` sits on its anchor: 0 its left (or top) edge, + // 0.5 its middle, 1 its right (or bottom) edge; on a path, its edge toward the path's + // start, its middle or its edge toward the path's end. In horizontal type `start` and + // `end` follow the `direction` of the element holding that character: right-to-left text + // starts at its right edge, and on a path at the edge toward the path's end (type on a + // path lays its glyphs out in visual order from its start). + let anchor_of = |i: usize| { + let Some(&(_, el)) = cs.chars.get(i) else { return 0.0 }; + let a = match self.prop(el, "text-anchor").as_deref() { + Some("middle") => 0.5, + Some("end") => 1.0, + _ => 0.0, + }; + if !vertical && self.prop(el, "direction").is_some_and(|d| d.trim() == "rtl") { 1.0 - a } else { a } + }; + let anchor = anchor_of(0); self.fit_lengths(&cs.spans, &mut cells, vertical, label, warnings); let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default()); let mut breaks: Vec = vec![]; @@ -1268,6 +1278,8 @@ impl Ctx { let em = self.font_size(tp); let offset = tp.attribute("startOffset").and_then(|v| self.length(v, em, len)).unwrap_or(0.0) + dx[0]; let width = if anchor > 0.0 { advance(&point_text(&cells)) } else { 0.0 }; + // Text that would start before the path's beginning starts at it, with all its glyphs + // on the path (SVG does not draw the glyphs that fall off it). let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| s.clamp(0.0, 1.0)); obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start, end: None }; obj.xf = Affine::IDENTITY; @@ -1293,14 +1305,14 @@ impl Ctx { obj.xf = Affine::IDENTITY; } else { let origin = Point::new(x[0].unwrap_or(0.0) + dx[0], y[0].unwrap_or(0.0) + dy[0]); - let (justify, left, starts) = lines(&mut cells, &x, &y, &dx, &dy, anchor, origin.x, &mut breaks); + let (justify, left, starts) = lines(&mut cells, &x, &y, &dx, &dy, &anchor_of, origin.x, &mut breaks); obj.para.justify = justify; obj.xf = Affine::translate((finite(left), finite(origin.y))); - // Lines anchored differently (paragraphs aligned differently): each line's paragraph - // takes its alignment, and indents that put it where it was. - if let Some(starts) = starts.filter(|st| st.iter().any(|(i, _)| anchor_of(*i) != anchor)) { - for (k, &(i, ax)) in starts.iter().enumerate() { - let a = anchor_of(i); + // Lines anchored differently or at different x (paragraphs aligned or indented + // differently): each line's paragraph takes its alignment, and indents that put it + // where it was. + if starts.iter().any(|&(_, a, ax)| a != starts[0].1 || ax != starts[0].2) { + for (k, &(_, a, ax)) in starts.iter().enumerate() { let d = finite(ax - left); let mut pa = ParaStyle { justify: justify_of(a), ..ParaStyle::default() }; match pa.justify { @@ -1412,7 +1424,7 @@ fn finite(v: f64) -> f64 { if v.is_finite() { v } else { 0.0 } } -/// The alignment of an SVG text chunk anchored at `anchor` (0 start, 0.5 middle, 1 end). +/// The alignment of an SVG text chunk anchored at `anchor` (0 left edge, 0.5 middle, 1 right edge). fn justify_of(anchor: f64) -> Justify { match anchor { a if a >= 1.0 => Justify::Right, @@ -1428,8 +1440,10 @@ fn leading_tolerance(spacing: f64) -> f64 { } /// Point type: line breaks, baseline shifts and kerning from the positioning lists. Returns the -/// justification, the x of the first line's left (or anchor) edge and, when no character is -/// placed on its own, each line's first character and anchor x. +/// justification, the x of the first line's left (or anchor) edge and each line's first +/// character, alignment (as for [`justify_of`]) and anchor x. `anchor_of` gives where the chunk +/// starting at a character sits on its anchor. When characters are placed on their own, every +/// line is left-aligned and its anchor x is its left edge. #[allow(clippy::too_many_arguments)] fn lines( cells: &mut [Cell], @@ -1437,10 +1451,10 @@ fn lines( y: &[Option], dx: &[f64], dy: &[f64], - anchor: f64, + anchor_of: &dyn Fn(usize) -> f64, x0: f64, breaks: &mut Vec, -) -> (Justify, f64, Option>) { +) -> (Justify, f64, Vec<(usize, f64, f64)>) { let n = cells.len(); let y0 = y[0].unwrap_or(0.0) + dy[0]; let (mut base, mut cur) = (y0, y0); @@ -1482,15 +1496,16 @@ fn lines( } } if targets.is_empty() { - return (justify_of(anchor), x0, Some(lines)); + let starts = lines.iter().map(|&(s, lx)| (s, anchor_of(s), lx)).collect(); + return (justify_of(anchor_of(0)), x0, starts); } // Absolutely placed characters: lay the text out left-aligned and kern each one onto its x. - // SVG anchors every absolutely placed chunk on its own, so with a middle/end anchor each - // chunk's target moves back by its share of the chunk's width. + // SVG anchors every absolutely placed chunk on its own, so a chunk not anchored at its left + // edge moves back by its share of its own width. let line_of = |i: usize| lines.partition_point(|(s, _)| *s <= i) - 1; - let mut left = x0; + let mut line_left: Vec = lines.iter().map(|l| l.1).collect(); let mut wants: Vec<(usize, f64)> = targets.clone(); - if anchor > 0.0 { + if lines.iter().chain(&targets).any(|c| anchor_of(c.0) > 0.0) { let (runs, bytes) = assemble(cells, breaks); let lay = measure(&with_runs(runs.0)); let (pens, ends) = pens(&lay, &bytes); @@ -1506,16 +1521,14 @@ fn lines( _ => 0.0, } }; - let line_left: Vec = lines.iter().map(|&(s, lx)| lx - anchor * width(s)).collect(); - left = line_left[0]; + line_left = lines.iter().map(|&(s, lx)| lx - anchor_of(s) * width(s)).collect(); for w in &mut wants { - w.1 -= anchor * width(w.0) + line_left[line_of(w.0)]; - } - } else { - for w in &mut wants { - w.1 -= lines[line_of(w.0)].1; + w.1 -= anchor_of(w.0) * width(w.0); } } + for w in &mut wants { + w.1 -= line_left[line_of(w.0)]; + } // Kerning changes shaping (pair kerning is off for manually kerned characters): repeat until // every character sits on its x. for _ in 0..4 { @@ -1538,7 +1551,8 @@ fn lines( break; } } - (Justify::Left, left, None) + let starts = lines.iter().zip(&line_left).map(|(&(s, _), &lx)| (s, 0.0, lx)).collect(); + (Justify::Left, line_left[0], starts) } /// Centred and right-aligned lines of point type `obj` where a reader draws them. `text-anchor` @@ -1546,7 +1560,9 @@ fn lines( /// export anchors lines so); VectorCraft aligns a line without that tracking /// ([`vectorcraft_text::LineInfo::trailing`]). Each such line moves back by its share of it: the /// type moves when every line moves alike, otherwise each paragraph's right indent takes the -/// tracking (a line of imported point type is a paragraph). Browsers add that letter spacing +/// tracking (a line of imported point type is a paragraph). A right-to-left paragraph's lines +/// keep all their tracking in the layout (`trailing` is 0), so they stay where they are and their +/// whole advance sits on the anchor. Browsers add that letter spacing /// today; CSS WG issue 1518 and Firefox bugs 1907305 and 1913554 may drop it at a line's end. fn keep_reader_anchors(obj: &mut TextObject) { let share = |j: Justify| match j { diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index b25dc23678..01f541d8d6 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -360,6 +360,158 @@ fn anchored_letter_spaced_lines_stay_where_a_reader_draws_them() { assert!(close(s[0], 200.0 - a[0] / 2.0, 1e-6) && close(s[1], 200.0 - a[1], 1e-6), "{s:?} {a:?}"); } +/// Document-space left and right edges of the advance of each laid-out line. +fn line_edges(t: &TextObject) -> Vec<(f64, f64)> { + let lay = layout(FontDb::global(), t); + lay.lines + .iter() + .map(|l| { + let g = &lay.glyphs[l.glyph_start..l.glyph_end]; + let left = g.iter().map(|g| (t.xf * g.origin).x).fold(f64::INFINITY, f64::min); + let right = g.iter().map(|g| (t.xf * g.origin).x + g.advance).fold(f64::NEG_INFINITY, f64::max); + (left, right) + }) + .collect() +} + +/// SVG 2 §11.10.1.1 (https://www.w3.org/TR/SVG2/text.html#TextAnchoringProperties): with +/// `direction: rtl`, `start` puts the right side of the text at its position and `end` puts the +/// left side there; `middle` centers it either way. The direction is the effective one of the element that +/// holds the chunk's first character. +#[test] +fn rtl_point_text_start_and_end_use_directional_edges() { + // (markup, share of the advance left of x = 200) + for (body, share) in [ + (r#"Wave"#, 1.0), + (r#"Wave"#, 1.0), + (r#"Wave"#, 0.5), + (r#"Wave"#, 0.0), + (r#"Wave"#, 1.0), + (r#"Wave"#, 0.0), + (r#"Wave"#, 0.0), + (r#"Wave"#, 0.0), + (r#"Wave"#, 1.0), + ] { + let d = open(body); + let (left, right) = line_edges(only_text(&d))[0]; + let want = 200.0 - share * (right - left); + assert!(close(left, want, 1e-6), "{body}: the advance spans {left}..{right}, so it does not start at {want}"); + } +} + +/// On a path, right-to-left text anchored by `start` ends at `startOffset` and one anchored by +/// `end` begins there (its glyphs run from the path's start in visual order); `middle` centers it. +#[test] +fn rtl_type_on_a_path_starts_at_its_directional_edge() { + // (anchor, direction, share of the advance before the offset) + for (anchor, direction, share) in [("start", "rtl", 1.0), ("middle", "rtl", 0.5), ("end", "rtl", 0.0), ("start", "ltr", 0.0), ("end", "ltr", 1.0)] + { + let d = open(&format!( + r##" + Wave"## + )); + let t = only_text(&d); + assert!(matches!(t.kind, TextKind::OnPath { .. }), "{:?}", t.kind); + let lay = layout(FontDb::global(), t); + let left = lay.glyphs.iter().map(|g| (t.xf * g.origin).x).fold(f64::INFINITY, f64::min); + let width: f64 = lay.glyphs.iter().map(|g| g.advance).sum(); + let want = 200.0 - share * width; + assert!(close(left, want, 0.05), "{anchor} {direction}: the text starts at {left}, not {want}"); + } +} + +/// Letter-spaced right-to-left lines: a line keeps the tracking after each of its characters, and +/// its whole advance sits on its anchor as `start`, `middle` and `end` place it. +#[test] +fn rtl_letter_spaced_lines_stay_on_their_anchor() { + for (anchor, share) in [("start", 1.0), ("middle", 0.5), ("end", 0.0)] { + let d = open(&format!(r#"WAVE"#)); + let t = only_text(&d); + let (left, right) = line_edges(t)[0]; + let want = 200.0 - share * (right - left); + assert!(close(left, want, 1e-6), "{anchor}: the advance spans {left}..{right}, so it does not start at {want}"); + let natural = { + let d = open(&format!(r#"WAVE"#)); + let (l, r) = line_edges(only_text(&d))[0]; + r - l + }; + assert!(close(right - left, natural + 4.0 * 8.0, 1e-6), "{anchor}: the advance {} holds the tracking of all 4 letters", right - left); + } + // A right-to-left line anchored by `start` beside a left-to-right `middle` line: each keeps its own anchor. + let d = open( + r#"WaveLine"#, + ); + let t = only_text(&d); + let e = line_edges(t); + assert_eq!(e.len(), 2, "{e:?}"); + assert!(close(e[0].1, 200.0, 1e-6), "the right-to-left line ends at 200: {e:?}"); + let lay = layout(FontDb::global(), t); + let a: f64 = lay.glyphs[lay.lines[1].glyph_start..lay.lines[1].glyph_end].iter().map(|g| g.advance).sum(); + assert!(close(e[1].0, 200.0 - a / 2.0, 1e-6), "the middle line is centered on 200 with its tracking: {e:?}"); +} + +/// Each line is a chunk anchored by the `text-anchor` and `direction` of its first character. +#[test] +fn rtl_lines_use_each_chunk_first_character_direction_and_anchor() { + let d = open( + r#"WaveLineLastMid"#, + ); + let e = line_edges(only_text(&d)); + assert_eq!(e.len(), 4, "{e:?}"); + assert!(close(e[0].1, 200.0, 1e-6), "a right-to-left start line ends at 200: {e:?}"); + assert!(close(e[1].0, 200.0, 1e-6), "a left-to-right start line starts at 200: {e:?}"); + assert!(close(e[2].0, 200.0, 1e-6), "a right-to-left end line starts at 200: {e:?}"); + assert!(close((e[3].0 + e[3].1) / 2.0, 200.0, 1e-6), "a middle line is centered on 200: {e:?}"); +} + +/// A line starts at its own x, also when it is anchored as the line before it is and when another +/// line holds absolutely placed characters. +#[test] +fn each_line_starts_at_its_own_x() { + let d = open(r#"WaveLine"#); + let e = line_edges(only_text(&d)); + assert!(close(e[0].0, 50.0, 1e-6) && close(e[1].0, 120.0, 1e-6), "{e:?}"); + let d = open(r#"WaveLine"#); + let e = line_edges(only_text(&d)); + assert!(close(e[0].1, 150.0, 1e-6) && close(e[1].1, 100.0, 1e-6), "{e:?}"); + let d = open(r#"abLine"#); + let t = only_text(&d); + let e = line_edges(t); + assert_eq!(e.len(), 2, "{e:?}"); + assert!(close(e[0].0, 50.0, 0.05) && close(e[1].1, 200.0, 0.05), "{e:?}"); + let b = (t.xf * layout(FontDb::global(), t).glyphs[1].origin).x; + assert!(close(b, 100.0, 0.05), "b starts at {b}"); +} + +/// Absolutely placed chunks are anchored on their own, each by the `text-anchor` and `direction` +/// of its first character, whatever the first chunk's anchor. +#[test] +fn absolutely_placed_chunks_use_their_own_rtl_anchor() { + // (markup, share of each chunk's advance left of its x) + for (body, shares) in [ + (r#"ab"#, [0.0, 1.0]), + (r#"ab"#, [0.0, 0.0]), + (r#"ab"#, [0.0, 1.0]), + (r#"ab"#, [0.5, 0.0]), + (r#"ab"#, [0.5, 1.0]), + (r#"ab"#, [0.5, 0.5]), + (r#"ab"#, [1.0, 1.0]), + ] { + let d = open(body); + let t = only_text(&d); + let lay = layout(FontDb::global(), t); + assert_eq!(lay.glyphs.len(), 2, "{body}"); + // Natural advance: the kerning that moves `b` onto its x is added after `a`. + let style = |i: usize| t.runs.iter().flat_map(|r| r.text.chars().map(move |_| &r.style)).nth(i).unwrap(); + let natural = |i: usize| lay.glyphs[i].advance - style(i).kerning.unwrap_or(0.0) / 1000.0 * style(i).size; + for (i, (x, share)) in [80.0, 200.0].into_iter().zip(shares).enumerate() { + let left = (t.xf * lay.glyphs[i].origin).x; + let want = x - share * natural(i); + assert!(close(left, want, 0.05), "{body}: chunk {i} starts at {left}, not {want}"); + } + } +} + #[test] fn a_small_line_gap_in_tiny_type_keeps_its_leading() { // 0.0085 against an auto leading of 0.006 differs by less than 0.01 but is not a whole multiple of it. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 086d4cfc0d..a202baa403 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1526,6 +1526,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A function-based PDF shading (type 1) whose colour changes along one axis only, as a browser writes a repeating linear gradient, comes in as a linear gradient over its domain, each hard colour change two stops at one offset; one whose colour changes in two directions comes in as its average colour, with a warning. Either paints only over the shading's domain. Both came in as a flat grey that wasn't in the file (#3621). Each such shading is read once per import, and all of them within one budget of 200,000 function evaluations, so a file filling thousands of paths with long functions still opens in about a second (it took minutes); past the budget they come in as their average colour, then grey, with a warning. A gap between glyphs of two sizes is measured against the smaller one, so a word set beside a much larger one with no space glyph between them (a browser's margin between two sizes, as in `≤ 4` and a small `hours`) keeps its word space (#3623). SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `