diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c..02437c8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-12** · The Eyedropper samples a placed image: clicking the photo with text (or objects) selected fills them with the image's colour under the pointer, as an unnamed colour (`color.sample`), as in InDesign. Images on locked layers can be sampled. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 4bf69ec..65052d5 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -184,6 +184,15 @@ pub fn specs() -> Vec { super::has_selection, apply_color ), + cmd!( + "color.sample", + "Sample Color (Eyedropper)", + [], + None, + "{at: [x,y] (spread coords), spread?, target?: fill|stroke, ids?} → {color: \"#rrggbb\", swatch, from: image id} — the Eyedropper on a placed image: the image's colour at the point (an unnamed colour) fills the selected text, else the selected objects", + has_text_or_selection, + sample_color + ), cmd!( "swatch.addToSwatches", "Add to Swatches", @@ -957,41 +966,149 @@ fn apply_color(s: &mut Session, p: &Value) -> Result { let stroke = str_param(p, "target") == Some("stroke"); let ids = super::targets(s, p)?; s.edit(|d, _| { - // Reuse a swatch holding exactly this colour, else add an unnamed one. - let existing = d - .swatches - .iter() - .find(|w| matches!(&w.value, SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } if *c == color)) - .map(|w| w.name.clone()); - let name = match existing { - Some(n) => n, - None => { - let name = Styles::unique_name(|n| d.swatch(n).is_some(), &value_name(color)); - d.swatches.push(Swatch { - name: name.clone(), - value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process }, - locked: false, - named: false, - hidden: true, - }); - name - } - }; - for id in &ids { - if let Some(it) = d.item_mut(*id) { - if stroke { - it.stroke.swatch = name.clone(); - it.stroke.tint = 1.0; - } else { - it.fill.swatch = name.clone(); - it.fill.tint = 1.0; - } - } - } + let name = color_swatch(d, color); + color_items(d, &ids, &name, stroke); Ok(json!({"swatch": name})) }) } +/// A process swatch holding exactly `color`, else a new unnamed one (hidden from the Swatches +/// panel until Add to Swatches). +fn color_swatch(d: &mut designcraft_doc::Document, color: designcraft_color::Color) -> String { + let existing = d + .swatches + .iter() + .find(|w| matches!(&w.value, SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } if *c == color)) + .map(|w| w.name.clone()); + if let Some(n) = existing { + return n; + } + let name = Styles::unique_name(|n| d.swatch(n).is_some(), &value_name(color)); + d.swatches.push(Swatch { + name: name.clone(), + value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process }, + locked: false, + named: false, + hidden: true, + }); + name +} + +fn color_items(d: &mut designcraft_doc::Document, ids: &[designcraft_doc::ItemId], name: &str, stroke: bool) { + for id in ids { + if let Some(it) = d.item_mut(*id) { + if stroke { + it.stroke.swatch = name.to_string(); + it.stroke.tint = 1.0; + } else { + it.fill.swatch = name.to_string(); + it.fill.tint = 1.0; + } + } + } +} + +fn has_text_or_selection(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.active().is_some_and(|d| d.selection.text.is_some() || d.selection.cells.is_some()) { Ok(()) } else { super::has_selection(s) } +} + +/// The Eyedropper on a placed image (InDesign samples the pixel under the pointer): that colour, +/// as an unnamed colour, fills the selected text (characters), or the selected objects. +fn sample_color(s: &mut Session, p: &Value) -> Result { + const ID: &str = "color.sample"; + let at = super::point_param(p, "at").ok_or_else(|| bad(ID, "missing at"))?; + let sr = super::spread_param(p, "spread"); + let stroke = str_param(p, "target") == Some("stroke"); + let named = p.get("ids").and_then(Value::as_array).is_some_and(|a| !a.is_empty()) || p.get("id").is_some(); + let st = s.doc()?; + let (from, color) = image_color_at(&st.doc, sr, at).ok_or_else(|| bad(ID, "no placed image under the point"))?; + let hex = color.to_hex(); + if named || (st.selection.text.is_none() && st.selection.cells.is_none()) { + let ids = super::targets(s, p)?; + return s.edit(|d, _| { + let name = color_swatch(d, color); + color_items(d, &ids, &name, stroke); + Ok(json!({"color": hex, "swatch": name, "from": from.0})) + }); + } + // A caret colours nothing (as with a swatch click): say so instead of an empty undo step. + let targets: Vec<_> = super::text::format_targets(s).into_iter().filter(|t| !t.range.is_empty()).collect(); + if targets.is_empty() { + return Err(bad(ID, "select the text to colour")); + } + s.edit(|d, _| { + let name = color_swatch(d, color); + for t in &targets { + let Some(story) = d.text_story_mut(t.story, t.cell) else { continue }; + story.format_chars(t.range.clone(), |f| { + if stroke { + f.over.stroke = Some(name.clone()); + f.over.stroke_tint = Some(1.0); + } else { + f.over.fill = Some(name.clone()); + f.over.fill_tint = Some(1.0); + } + }); + } + Ok(json!({"color": hex, "swatch": name, "from": from.0})) + }) +} + +/// The colour of the placed raster image at `at` on spread `sr`: the frontmost visible image whose +/// frame shows that point (locked ones too, as sampling changes nothing). (image frame, colour); +/// None over no image, a transparent pixel, or a graphic that is not a raster (PDF, EPS). +pub(crate) fn image_color_at( + d: &designcraft_doc::Document, + sr: designcraft_doc::SpreadRef, + at: designcraft_geom::Point, +) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> { + let sp = d.spread(sr)?; + let mut items: Vec<&designcraft_doc::Item> = sp.items.iter().map(|i| &**i).collect(); + items.sort_by_key(|i| d.layer_rank(i.layer)); + items + .into_iter() + .rev() + .filter(|it| d.layer(it.layer).is_none_or(|l| l.visible)) + .find_map(|it| sample_item(d, it, designcraft_geom::Affine::IDENTITY, at, 0)) +} + +fn sample_item( + d: &designcraft_doc::Document, + it: &designcraft_doc::Item, + outer: designcraft_geom::Affine, + at: designcraft_geom::Point, + depth: usize, +) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> { + use designcraft_doc::Content; + if it.hidden || depth > 64 { + return None; + } + let xf = outer * it.xf; + match &it.content { + Content::Group { items } => items.iter().rev().find_map(|c| sample_item(d, c, xf, at, depth + 1)), + Content::Graphic(g) => { + let inner = xf.inverse() * at; + if !designcraft_geom::hit::fill_contains(&it.path.to_bezpath(), designcraft_geom::FillRule::NonZero, inner) { + return None; + } + let gp = g.xf.inverse() * inner; + let (w, h) = g.size; + // Written so that NaN (a degenerate transform) is outside too. + if !(w > 0.0 && h > 0.0 && gp.x >= 0.0 && gp.y >= 0.0 && gp.x < w && gp.y < h) { + return None; + } + let img = designcraft_images::decode_rgba(&d.assets.get(&g.asset)?.data)?; + let (pw, ph) = img.dimensions(); + let px = ((gp.x / w * f64::from(pw)) as u32).min(pw.checked_sub(1)?); + let py = ((gp.y / h * f64::from(ph)) as u32).min(ph.checked_sub(1)?); + let [r, gr, b, a] = img.get_pixel_checked(px, py)?.0; + (a > 0).then(|| (it.id, designcraft_color::Color::rgb8(r, gr, b))) + } + _ => None, + } +} + fn add_to_swatches(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let name = match str_param(p, "swatch") { @@ -1493,3 +1610,85 @@ mod cjk_tests { assert!(s.execute("style.compositeFont.set", &definition(-1.0)).is_err()); } } + +#[cfg(test)] +mod sample_color_tests { + use designcraft_color::{Color, SwatchValue}; + use designcraft_doc::ItemId; + use serde_json::json; + + use crate::Session; + + /// A 2×1 image, red then blue, placed 144 pt wide at (72, 72): each pixel is 72 pt. + fn session_with_image() -> (Session, u64) { + let png = designcraft_render::Rendered { width: 2, height: 1, pixels: vec![255, 0, 0, 255, 0, 0, 255, 255] }.to_png(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let b64 = super::super::file::base64_encode(&png); + let r = s.execute("file.place", &json!({"base64": b64, "name": "rb.png", "x": 72, "y": 72, "width": 144})).unwrap(); + (s, r["id"].as_u64().unwrap()) + } + + fn swatch_color(s: &Session, name: &str) -> Color { + match &s.doc().unwrap().doc.swatch(name).unwrap().value { + SwatchValue::Color { color, .. } => *color, + other => panic!("{other:?}"), + } + } + + #[test] + fn the_eyedropper_colours_selected_text_with_the_image_under_it() { + let (mut s, image) = session_with_image(); + let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Elevate."})).unwrap(); + let fid = ItemId(f["id"].as_u64().unwrap()); + let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 7})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("color.sample", &json!({"at": [180, 100]})).unwrap(); + assert_eq!((r["color"].as_str(), r["from"].as_u64()), (Some("#0000ff"), Some(image)), "{r}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let name = r["swatch"].as_str().unwrap().to_string(); + assert_eq!(swatch_color(&s, &name), Color::rgb8(0, 0, 255)); + assert!(s.doc().unwrap().doc.swatch(&name).unwrap().hidden, "an unnamed colour"); + let st = s.doc().unwrap().doc.story(story).unwrap().clone(); + assert_eq!(st.format_after(0).over.fill.as_deref(), Some(name.as_str())); + assert_eq!(st.format_after(7).over.fill, None, "the full stop keeps its colour"); + // The frame itself is not coloured. + assert!(s.doc().unwrap().doc.item(fid).unwrap().fill.is_none()); + } + + #[test] + fn the_eyedropper_fills_selected_objects_and_samples_locked_images() { + let (mut s, _) = session_with_image(); + let lid = s.doc().unwrap().doc.layers[0].id.0; + s.execute("layer.set", &json!({"id": lid, "locked": true})).unwrap(); + s.execute("layer.new", &json!({"name": "TEXT"})).unwrap(); + let rect = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); + let r = s.execute("color.sample", &json!({"at": [100, 100], "target": "stroke"})).unwrap(); + assert_eq!(r["color"], "#ff0000", "{r}"); + let it = s.doc().unwrap().doc.item(ItemId(rect)).unwrap().clone(); + assert_eq!(swatch_color(&s, &it.stroke.swatch), Color::rgb8(255, 0, 0)); + // Sampling the same colour again reuses its swatch. + let again = s.execute("color.sample", &json!({"at": [90, 90]})).unwrap(); + assert_eq!(again["swatch"], r["swatch"]); + } + + #[test] + fn nothing_to_sample_or_nothing_to_colour_is_refused_without_an_edit() { + let (mut s, _) = session_with_image(); + let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Hi"})).unwrap(); + let fid = ItemId(f["id"].as_u64().unwrap()); + let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": story.0, "anchor": 1, "focus": 1})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let caret = s.execute("color.sample", &json!({"at": [100, 100]})).unwrap_err().to_string(); + assert!(caret.contains("select the text"), "{caret}"); + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap(); + let none = s.execute("color.sample", &json!({"at": [600, 600]})).unwrap_err().to_string(); + assert!(none.contains("no placed image"), "{none}"); + // Just past the image's right edge. + assert!(s.execute("color.sample", &json!({"at": [216.5, 100]})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } +} diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6c75b0d..0080630 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -705,3 +705,44 @@ fn pen_click_snaps_to_a_guide() { let x = p["anchors"][0]["p"][0].as_f64().unwrap(); assert!((x - 100.0).abs() < 1e-6, "anchor x {x}"); } + +#[test] +fn eyedropper_samples_a_placed_image_and_matches_other_objects() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let photo = ItemId(d.alloc()); + let mut it = Item::new(photo, lid, Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0))); + it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + asset: designcraft_doc::AssetId(1), + size: (100.0, 100.0), + xf: designcraft_geom::Affine::translate((0.0, 0.0)), + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + }); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + // An unfilled text frame over part of the photo lets it show through. + d.add_text_frame(SpreadRef::Doc(0), Rect::new(150.0, 150.0, 250.0, 250.0), lid, "x", ParaFormat::default()).unwrap(); + let swatch = add_rect(&mut d, Rect::new(300.0, 100.0, 350.0, 150.0)); + d.item_mut(swatch).unwrap().fill.swatch = "[Black]".into(); + let target = add_rect(&mut d, Rect::new(400.0, 100.0, 450.0, 150.0)); + // The photo's layer is locked: sampling it still works. + d.layers.iter_mut().for_each(|l| l.locked = true); + let s = Selection { items: vec![target], ..Default::default() }; + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let cx = ctx(&d, &s, &c, &l); + let mut t = create("eyedropper"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); + let sample = |x: f64, y: f64| Action::Exec("color.sample".into(), serde_json::json!({"at": [x, y], "spread": {"kind": "doc", "index": 0}})); + assert_eq!(a, vec![sample(120.0, 130.0)]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 180.0 + off.x, 180.0 + off.y)); + assert_eq!(a, vec![sample(180.0, 180.0)], "through the text frame"); + // A filled object is matched as before (here on a locked layer: not reachable, so nothing). + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y)).is_empty()); + let mut d2 = d.clone(); + d2.layers.iter_mut().for_each(|l| l.locked = false); + let cx = ctx(&d2, &s, &c, &l); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y)); + assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": swatch.0}))]); +} diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index fbb1df9..4ca55ff 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -1,6 +1,7 @@ //! Rotate (R), Scale (S) and Shear (O) tools: drag around the selection centre (Alt-click sets //! the reference point elsewhere in a later iteration); Eyedropper (I): click an object to apply -//! its attributes to the selection. +//! its attributes to the selection, or a placed image to colour the selection (text or objects) +//! with the image's colour under the pointer. use designcraft_doc::SpreadRef; use designcraft_geom::{Point, Rect}; @@ -247,6 +248,37 @@ fn positive_pad(v: f64) -> f64 { if v.is_finite() && v > 0.0 { v } else { 0.0 } } +/// The spread and spread point under `p` when a placed graphic shows there: the frontmost visible +/// filled or graphic item at the point (locked included) is a graphic frame. +fn image_under(cx: &ToolContext, p: Point) -> Option<(SpreadRef, Point)> { + use designcraft_doc::{Content, Item}; + fn shows(it: &Item, outer: designcraft_geom::Affine, at: Point, depth: usize) -> Option { + if it.hidden || depth > 64 { + return None; + } + let xf = outer * it.xf; + if let Content::Group { items } = &it.content { + return items.iter().rev().find_map(|c| shows(c, xf, at, depth + 1)); + } + let graphic = matches!(it.content, Content::Graphic(_)); + // Unfilled frames (a text frame over the photo) let the image show through. + if !graphic && it.fill.is_none() { + return None; + } + designcraft_doc::edit_hit(it, outer.inverse() * at, 0.0).then_some(graphic) + } + let (sr, at) = cx.layout.spread_at(p)?; + let sp = cx.doc.spread(sr)?; + let mut items: Vec<&Item> = sp.items.iter().map(|i| &**i).collect(); + items.sort_by_key(|i| cx.doc.layer_rank(i.layer)); + let image = items + .into_iter() + .rev() + .filter(|it| cx.doc.layer(it.layer).is_none_or(|l| l.visible)) + .find_map(|it| shows(it, designcraft_geom::Affine::IDENTITY, at, 0))?; + image.then_some((sr, at)) +} + #[derive(Default)] pub struct EyedropperTool; @@ -258,8 +290,13 @@ impl Tool for EyedropperTool { if ev.kind != PointerKind::Down { return vec![]; } + let selected = !cx.selection.items.is_empty() || cx.selection.text.is_some() || cx.selection.cells.is_some(); + // A placed image under the pointer is sampled (its layer may be locked: sampling changes nothing). + if selected && let Some((sr, at)) = image_under(cx, ev.pos) { + return vec![Action::Exec("color.sample".into(), json!({"at": [at.x, at.y], "spread": sr}))]; + } let Some((_, id)) = cx.hit(ev.pos) else { return vec![] }; - if cx.selection.items.is_empty() && cx.selection.text.is_none() { + if !selected { return vec![Action::Exec("selection.set".into(), json!({"ids": [id.0]}))]; } vec![Action::Exec("object.matchAttributes".into(), json!({"from": id.0}))] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5976d7d..29e5eca 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -739,6 +739,10 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("Apply Cell Style", ["Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "应用单元格样式"]), ("Apply Color", ["Apply Color", "Apply Color", "Apply Color", "Apply Color", "应用颜色"]), + ( + "Sample Color (Eyedropper)", + ["Farbe aufnehmen (Pipette)", "Prélever la couleur (Pipette)", "Tomar color (Cuentagotas)", "カラーを抽出 (スポイト)", "取样颜色 (吸管)"], + ), ("Apply Condition", ["Apply Condition", "Apply Condition", "Apply Condition", "Apply Condition", "应用条件"]), ( "Apply Parent to Pages…", diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index c337348..fb3b773 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1041,6 +1041,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Formatting affects container (J)", "تطبيق التنسيق على الحاوية (J)"), ("Formatting affects text (J)", "تطبيق التنسيق على النص (J)"), ("Apply Color", "تطبيق اللون"), + ("Sample Color (Eyedropper)", "أخذ عينة من اللون (القطارة)"), ("Apply None", "إزالة اللون"), ("Height Only", "الارتفاع فقط"), ("Width Only", "العرض فقط"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 34766cf..95fbcb4 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -412,6 +412,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "Applica attributi (Contagocce)"), ("Apply Cell Style", "Applica stile di cella"), ("Apply Color", "Applica colore"), + ("Sample Color (Eyedropper)", "Campiona colore (Contagocce)"), ("Apply Condition", "Applica condizione"), ("Apply Parent to Pages…", "Applica pagina mastro alle pagine…"), ("ArtCraft App Page…", "Pagina dell'app ArtCraft…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index de57db5..9351743 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -414,6 +414,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "属性を適用 (スポイト)"), ("Apply Cell Style", "セルスタイルを適用"), ("Apply Color", "カラーを適用"), + ("Sample Color (Eyedropper)", "カラーを抽出 (スポイト)"), ("Apply Condition", "条件を適用"), ("Apply Parent to Pages…", "ページに親ページを適用…"), ("ArtCraft App Page…", "ArtCraft アプリページ…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index a4d06bb..6c383cc 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1045,6 +1045,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Formatting affects container (J)", "Formatação afeta o contêiner (J)"), ("Formatting affects text (J)", "Formatação afeta o texto (J)"), ("Apply Color", "Aplicar cor"), + ("Sample Color (Eyedropper)", "Amostrar cor (Conta-gotas)"), ("Apply None", "Aplicar nenhum"), ("Height Only", "Somente altura"), ("Width Only", "Somente largura"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9b70204..6ea43f3 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -415,6 +415,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "Застосувати атрибути (піпетка)"), ("Apply Cell Style", "Застосувати стиль клітинки"), ("Apply Color", "Застосувати колір"), + ("Sample Color (Eyedropper)", "Узяти колір (піпетка)"), ("Apply Condition", "Застосувати умову"), ("Apply Parent to Pages…", "Застосувати шаблон до сторінок…"), ("ArtCraft App Page…", "Сторінка програми ArtCraft…"),