Color: Sample Color from Screen picks a color anywhere on the screen

The Color Picker gets an eyedropper button beside the New and Original
chips, and `ui.sampleScreenColor {stroke?, picker?}` shows the system's
color sampler. The command answers at once. When the person clicks a
color anywhere on the screen, the color becomes the fill or the stroke
through paint.setFill / paint.setStroke as one undo step, or with
`picker` it becomes the open Color Picker's color, which previews on the
selection as any pick does. Escape cancels the sampler. The sampled sRGB
color goes through Cms::srgb_to_rgb, so in a Display P3 working space a
color inside sRGB shows as it did on the screen. `ui.inspect` reports
`screenSampler` while the sampler is open.

- macOS uses NSColorSampler (apps/vectorcraft/src/mac_screen_color.rs).
- The web app uses the browser's EyeDropper API where the browser has
  one.
- Without a sampler (Windows, Linux, other browsers) the button is gray
  and the command returns an error. Keyboard Shortcuts lists the
  command, so it can be given a key.

Docs: docs/control-protocol.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-16 02:25:53 +07:00
committed by bflatastic
parent a14abc3177
commit 25df070576
25 changed files with 687 additions and 3 deletions
+38
View File
@@ -406,6 +406,7 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
let open_inbox = inbox.clone();
let picked = place_inbox.clone();
let place_ctx = ctx.clone();
let screen_color = eye_dropper(ctx.clone());
Services {
// File → Place…: the picked files go to the Place dialog.
place_async: Some(Box::new(move || {
@@ -443,6 +444,8 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
ctx.request_repaint();
});
})),
// Sample Color from Screen: the browser's EyeDropper, where it has one.
screen_color,
// A download that couldn't be started fails the save or export it is for (#1693).
download: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes).inspect_err(|e| log::error!("download of {name} failed: {e}")))),
inbox: Some(inbox),
@@ -453,6 +456,41 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
}
}
/// Sample Color from Screen in the browser: the EyeDropper API
/// (<https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper>), which only some browsers have,
/// on secure (HTTPS) pages; `None` without it, and the command is then gray. `open()` needs a
/// transient user activation (otherwise it fails with a `NotAllowedError`) and resolves with the
/// picked color's `sRGBHex`; Escape rejects it with an `AbortError`.
fn eye_dropper(ctx: egui::Context) -> Option<vectorcraft_ui_egui::screen_color::StartSampler> {
use js_sys::{Function, Promise, Reflect};
use wasm_bindgen::JsValue;
let window = web_sys::window()?;
let constructor = Reflect::get(&window, &JsValue::from_str("EyeDropper")).ok()?.dyn_into::<Function>().ok()?;
Some(Box::new(move || {
let eye = Reflect::construct(&constructor, &js_sys::Array::new()).map_err(js_err)?;
let open = Reflect::get(&eye, &JsValue::from_str("open")).map_err(js_err)?.dyn_into::<Function>().map_err(js_err)?;
let promise = open.call0(&eye).map_err(js_err)?.dyn_into::<Promise>().map_err(js_err)?;
let (tx, rx) = std::sync::mpsc::channel();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let answer = match wasm_bindgen_futures::JsFuture::from(promise).await {
Ok(result) => Reflect::get(&result, &JsValue::from_str("sRGBHex"))
.ok()
.and_then(|hex| hex.as_string())
.and_then(|hex| vectorcraft_ui_egui::screen_color::srgb_from_hex(&hex))
.map(Some)
.ok_or_else(|| "the browser's EyeDropper answered no color".to_string()),
Err(e) if Reflect::get(&e, &JsValue::from_str("name")).ok().and_then(|n| n.as_string()).as_deref() == Some("AbortError") => Ok(None),
Err(e) => Err(js_err(e)),
};
// The UI may be gone (the page closing): nothing waits for the color then.
let _ = tx.send(answer);
ctx.request_repaint();
});
Ok(rx)
}))
}
/// Data Recovery's store on the web: the browser's local storage (kept across visits and shared by
/// the site's tabs), each entry as base64 under [`RECOVERY_PREFIX`]`<area>/<name>`. It has no
/// locks: each tab holds its area with a heartbeat, judged by the browser's clock, and with a Web
+1 -1
View File
@@ -55,7 +55,7 @@ windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
# with default features off). Pinned exactly: review the diff before bumping.
muda = { version = "=0.21.1", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
# Files opened from Finder and the Dock (src/open_documents.rs) and pasted bitmaps (src/clipboard.rs); both already come with winit, muda and rfd.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSString", "NSURL"] }
block2 = "0.6"
+69
View File
@@ -0,0 +1,69 @@
//! macOS: Sample Color from Screen (`vectorcraft_ui_egui::screen_color`) through the system's color
//! sampler, `NSColorSampler` (macOS 10.15 and later), which "displays the system’s color-sampling
//! interface and returns the selected color to your app"
//! (<https://developer.apple.com/documentation/appkit/nscolorsampler>). AppKit calls the handler on
//! the main thread with the color the person clicked, or with nil when they pressed Escape. The
//! color is converted to sRGB before it is sent to the UI; AppKit clips a color outside sRGB to it
//! (Display P3 red arrives as sRGB red).
//!
//! `showSamplerWithSelectionHandler:` has no safe binding; this module allows `unsafe_code`.
#![allow(unsafe_code)]
use std::sync::mpsc::channel;
use block2::RcBlock;
use objc2::MainThreadMarker;
use objc2_app_kit::{NSColor, NSColorSampler, NSColorSpace};
use vectorcraft_ui_egui::screen_color::{Answer, StartSampler};
/// The system's color sampler; `ui` is woken when it answers.
pub fn sampler(ui: egui::Context) -> StartSampler {
Box::new(move || {
MainThreadMarker::new().ok_or("the color sampler can only be shown from the main thread")?;
let (tx, rx) = channel();
let ui = ui.clone();
let handler = RcBlock::new(move |color: *mut NSColor| {
// SAFETY: AppKit passes the selected color, or nil when the person canceled, and keeps
// it alive while the handler runs.
let answer: Answer = match unsafe { color.as_ref() } {
None => Ok(None),
Some(color) => srgb(color).map(Some).ok_or_else(|| "the sampled color has no sRGB value".into()),
};
// The UI may be gone (the app quitting): nothing waits for the color then.
let _ = tx.send(answer);
ui.request_repaint();
});
let sampler = NSColorSampler::new();
// SAFETY: on the main thread (checked above); the handler has the signature AppKit calls it
// with, and AppKit keeps the block and the sampler until the session ends.
unsafe { sampler.showSamplerWithSelectionHandler(&handler) };
Ok(rx)
})
}
/// The components of `color` in sRGB, or `None` when it has no RGB form (a pattern color).
fn srgb(color: &NSColor) -> Option<[f32; 3]> {
let c = color.colorUsingColorSpace(&NSColorSpace::sRGBColorSpace())?;
Some([c.redComponent() as f32, c.greenComponent() as f32, c.blueComponent() as f32])
}
#[cfg(test)]
mod tests {
use super::*;
/// A color in another space arrives as its sRGB components.
#[test]
fn sampled_colors_convert_to_srgb() {
let red = NSColor::colorWithSRGBRed_green_blue_alpha(1.0, 0.0, 0.0, 1.0);
assert_eq!(srgb(&red), Some([1.0, 0.0, 0.0]));
// Display P3 (0.6, 0.4, 0.3) lies inside sRGB with other numbers: more red, less green and blue.
let p3 = NSColor::colorWithDisplayP3Red_green_blue_alpha(0.6, 0.4, 0.3, 1.0);
let [r, g, b] = srgb(&p3).unwrap();
assert!(r > 0.61 && g < 0.39 && b < 0.29, "converted, not read as sRGB numbers: {r} {g} {b}");
// A color outside sRGB is clipped to it.
assert_eq!(srgb(&NSColor::colorWithDisplayP3Red_green_blue_alpha(1.0, 0.0, 0.0, 1.0)), Some([1.0, 0.0, 0.0]));
let gray = NSColor::colorWithWhite_alpha(0.5, 1.0);
let [r, g, b] = srgb(&gray).unwrap();
assert!((r - g).abs() < 1e-4 && (g - b).abs() < 1e-4, "{r} {g} {b}");
}
}
+7
View File
@@ -35,6 +35,8 @@ mod mac_fonts;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
mod mac_screen_color;
#[cfg(target_os = "macos")]
mod mac_window;
#[cfg(target_os = "macos")]
mod open_documents;
@@ -504,6 +506,8 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
let wayland = parent.as_ref().and_then(|w| w.window_handle().ok()).is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Wayland(_)));
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
#[cfg(target_os = "macos")]
let sampled_ui = ui.clone();
Services {
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))),
@@ -535,6 +539,9 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
// Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders.
open_file: Some(Box::new(open_file)),
pick_folder: Some(Box::new(move || pick_now(PickRequest::Folder, &p4).into_iter().next())),
// Sample Color from Screen: the system's color sampler.
#[cfg(target_os = "macos")]
screen_color: Some(mac_screen_color::sampler(sampled_ui)),
// File → Print: the system's printers and print queue.
print: Some(Box::new(printing::SystemPrint)),
..Default::default()
+3
View File
@@ -162,6 +162,9 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
"background": app.background.jobs.iter().map(|j| j.label.as_str()).collect::<Vec<_>>(),
// Documents still being read off the UI thread (File › Open, Open Recent): their paths.
"opening": app.opening.pending.iter().map(|p| p.path.as_str()).collect::<Vec<_>>(),
// The screen color sampler is shown, waiting for the person at the computer
// (`ui.sampleScreenColor`).
"screenSampler": app.screen_sampler.is_some(),
// A native file dialog open off the UI thread (`open`, `place`, `save`, `folder`), waiting
// for the person at the computer: what asked for it runs again when it answers.
"fileDialog": app.picks.waiting_kind(),
+10 -2
View File
@@ -1,6 +1,7 @@
//! The Color Picker (double-click a Fill/Stroke proxy, or `ui.colorPicker`): a colour field with a
//! channel slider for H, S, B, R, G or B; HSB, RGB, Lab and CMYK fields and hex; Only Web Colors;
//! the out-of-gamut and out-of-web warnings (click to correct); New/Original chips; and a Color
//! the out-of-gamut and out-of-web warnings (click to correct); New/Original chips; an eyedropper
//! button that samples a color anywhere on the screen ([`crate::screen_color`]); and a Color
//! Swatches list. With art selected the colour shows on it live as it is picked; OK keeps it as one
//! undo step through `paint.setFill` / `paint.setStroke`, Cancel puts the art back.
//!
@@ -183,6 +184,7 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
json!({"stroke": stroke, "original": color_json(&color), "channel": Channel::default().id(), "webOnly": false, "swatches": false}),
);
store(&mut d, &Picked::of(color));
d.fields.insert(crate::screen_color::PICKER_ID.into(), json!(crate::screen_color::picker_id()));
app.ui.dialog = Some(d);
Ok(Value::Null)
}
@@ -197,6 +199,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let mut pick: Option<Picked> = None;
let mut new_channel = None;
let mut toggle_swatches = false;
let mut sample = false;
ui.horizontal_top(|ui| {
if swatches {
pick = swatch_list(app, ui, &p.color).map(Picked::of);
@@ -218,7 +221,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
if new_original_chips(ui, &p.color, &original) {
pick = Some(Picked::of(original));
}
ui.add_space(8.0);
ui.add_space(4.0);
sample = crate::screen_color::button(app, ui);
ui.add_space(4.0);
ui.vertical(|ui| {
if let Some(fix) = gamut
&& warning_chip(ui, GAMUT_WARNING, &fix)
@@ -250,6 +255,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
if toggle_swatches {
d.fields.insert("swatches".into(), json!(!swatches));
}
if sample && let Err(e) = app.run("ui.sampleScreenColor", json!({"picker": true})) {
app.status(e);
}
store(d, &pick.unwrap_or(p).snapped(d.bool("webOnly")));
preview(app, d);
false
+6
View File
@@ -2732,6 +2732,9 @@ effect Vertical Svislý
Clear Appearance Vymazat vzhled
Duplicate Item Duplikovat položku
Eyedropper Kapátko
Sample Color from Screen Nabrat barvu z obrazovky
This system has no screen color sampler Tento systém nemá nástroj pro nabírání barvy z obrazovky
The active document changed while the screen color sampler was open Aktivní dokument se změnil, zatímco byl otevřený nástroj pro nabírání barvy z obrazovky
Move Appearance Přesunout vzhled
Reduce to Basic Appearance Zredukovat na základní vzhled
Remove Item Odebrat položku
@@ -3943,6 +3946,9 @@ imageTrace Outline Obrys
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` osa musí být "vertical", "horizontal" nebo úhel ve stupních, ne `{v}`
@msg background {other}: transparent, white, black or a colour such as "#ff8800" pozadí {other}: transparent, white, black nebo barva jako "#ff8800"
@msg bad color {v} chybná barva {v}
@msg the Color Picker is not open Výběr barvy není otevřený
@msg the screen color sampler is already open nástroj pro nabírání barvy z obrazovky už je otevřený
@msg this system has no screen color sampler tento systém nemá nástroj pro nabírání barvy z obrazovky
@msg bad colour `{hex}` (#rrggbb) chybná barva `{hex}` (#rrggbb)
@msg bad page tree chybný strom stránek
@msg bad shortcut chybná klávesová zkratka
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -989,6 +989,9 @@
Export… Exportieren…
Extrude & Bevel… Extrudieren und abgeflachte Kante…
Eyedropper Pipette
Sample Color from Screen Farbe vom Bildschirm aufnehmen
This system has no screen color sampler Dieses System hat keine Farbaufnahme vom Bildschirm
The active document changed while the screen color sampler was open Das aktive Dokument hat sich geändert, während die Farbaufnahme vom Bildschirm geöffnet war
Eyedropper Applies: Pipette wendet an:
Eyedropper Options Pipettenoptionen
Eyedropper Picks Up: Pipette nimmt auf:
@@ -3549,6 +3552,9 @@ theme Appearance Erscheinungsbild
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis muss "vertical", "horizontal" oder ein Winkel in Grad sein, nicht `{v}`
@msg background {other}: transparent, white, black or a colour such as "#ff8800" background {other}: transparent, white, black oder eine Farbe wie "#ff8800"
@msg bad color {v} ungültige Farbe {v}
@msg the Color Picker is not open der Farbwähler ist nicht geöffnet
@msg the screen color sampler is already open die Farbaufnahme vom Bildschirm ist bereits geöffnet
@msg this system has no screen color sampler dieses System hat keine Farbaufnahme vom Bildschirm
@msg bad colour `{hex}` (#rrggbb) ungültige Farbe `{hex}` (#rrggbb)
@msg bad page tree fehlerhafter Seitenbaum
@msg bad shortcut ungültiger Tastaturbefehl
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -1005,6 +1005,9 @@
Export… Exportar…
Extrude & Bevel… Extrusión y biselado…
Eyedropper Cuentagotas
Sample Color from Screen Tomar color de la pantalla
This system has no screen color sampler Este sistema no tiene muestreador de color de la pantalla
The active document changed while the screen color sampler was open El documento activo cambió mientras el muestreador de color de la pantalla estaba abierto
Eyedropper Applies: El cuentagotas aplica:
Eyedropper Options Opciones de cuentagotas
Eyedropper Picks Up: El cuentagotas toma:
@@ -3647,6 +3650,9 @@ axis Both Ambos
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis debe ser "vertical", "horizontal" o un ángulo en grados, no `{v}`
@msg background {other}: transparent, white, black or a colour such as "#ff8800" fondo {other}: transparent, white, black o un color como "#ff8800"
@msg bad color {v} color no válido {v}
@msg the Color Picker is not open el Selector de color no está abierto
@msg the screen color sampler is already open el muestreador de color de la pantalla ya está abierto
@msg this system has no screen color sampler este sistema no tiene muestreador de color de la pantalla
@msg bad colour `{hex}` (#rrggbb) color no válido `{hex}` (#rrggbb)
@msg bad page tree árbol de páginas no válido
@msg bad shortcut atajo no válido
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -1005,6 +1005,9 @@
Export… Exporter…
Extrude & Bevel… Extrusion et biseautage…
Eyedropper Pipette
Sample Color from Screen Prélever une couleur à l’écran
This system has no screen color sampler Ce système n’a pas d’outil de prélèvement de couleur à l’écran
The active document changed while the screen color sampler was open Le document actif a changé pendant que l’outil de prélèvement de couleur à l’écran était ouvert
Eyedropper Applies: La pipette applique :
Eyedropper Options Options de la pipette
Eyedropper Picks Up: La pipette prélève :
@@ -3644,6 +3647,9 @@ axis Both Les deux
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis doit être "vertical", "horizontal" ou un angle en degrés, pas `{v}`
@msg background {other}: transparent, white, black or a colour such as "#ff8800" arrière-plan {other} : transparent, white, black ou une couleur comme "#ff8800"
@msg bad color {v} couleur non valide {v}
@msg the Color Picker is not open le Sélecteur de couleurs n’est pas ouvert
@msg the screen color sampler is already open l’outil de prélèvement de couleur à l’écran est déjà ouvert
@msg this system has no screen color sampler ce système n’a pas d’outil de prélèvement de couleur à l’écran
@msg bad colour `{hex}` (#rrggbb) couleur non valide `{hex}` (#rrggbb)
@msg bad page tree arborescence des pages non valide
@msg bad shortcut raccourci non valide
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -1290,6 +1290,9 @@
Export… Exportálás…
Extrude & Bevel… Kihúzás és élletörés…
Eyedropper Pipetta
Sample Color from Screen Szín mintavétele a képernyőről
This system has no screen color sampler Ezen a rendszeren nincs képernyős színmintavevő
The active document changed while the screen color sampler was open Az aktív dokumentum megváltozott, amíg a képernyős színmintavevő nyitva volt
Eyedropper Applies: A pipetta alkalmazza:
Eyedropper Options Pipetta beállításai
Eyedropper Picks Up: A pipetta felveszi:
@@ -4160,6 +4163,9 @@ imageTrace Outline Körvonal
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` az axis értéke "vertical", "horizontal" vagy fokban megadott szög lehet, ez nem az: `{v}`
@msg background {other}: transparent, white, black or a colour such as "#ff8800" background {other}: transparent, white, black vagy egy szín, például "#ff8800"
@msg bad color {v} hibás szín: {v}
@msg the Color Picker is not open a Színválasztó nincs megnyitva
@msg the screen color sampler is already open a képernyős színmintavevő már nyitva van
@msg this system has no screen color sampler ezen a rendszeren nincs képernyős színmintavevő
@msg bad colour `{hex}` (#rrggbb) hibás szín: `{hex}` (#rrggbb)
@msg bad page tree hibás oldalfa
@msg bad shortcut hibás billentyűparancs
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -1005,6 +1005,9 @@
Export… Esporta…
Extrude & Bevel… Estrusione e smusso…
Eyedropper Contagocce
Sample Color from Screen Campiona colore dallo schermo
This system has no screen color sampler Questo sistema non ha un campionatore di colore dello schermo
The active document changed while the screen color sampler was open Il documento attivo è cambiato mentre il campionatore di colore dello schermo era aperto
Eyedropper Applies: Il contagocce applica:
Eyedropper Options Opzioni contagocce
Eyedropper Picks Up: Il contagocce preleva:
@@ -3649,6 +3652,9 @@ axis Both Entrambi
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis deve essere "vertical", "horizontal" o un angolo in gradi, non `{v}`
@msg background {other}: transparent, white, black or a colour such as "#ff8800" sfondo {other}: transparent, white, black o un colore come "#ff8800"
@msg bad color {v} colore non valido {v}
@msg the Color Picker is not open il Selettore colore non è aperto
@msg the screen color sampler is already open il campionatore di colore dello schermo è già aperto
@msg this system has no screen color sampler questo sistema non ha un campionatore di colore dello schermo
@msg bad colour `{hex}` (#rrggbb) colore non valido `{hex}` (#rrggbb)
@msg bad page tree albero delle pagine non valido
@msg bad shortcut scelta rapida non valida
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -1381,6 +1381,9 @@
Export: 書き出し:
Export… 書き出し…
Eyedropper スポイト
Sample Color from Screen 画面からカラーをサンプル
This system has no screen color sampler このシステムには画面のカラーサンプラーがありません
The active document changed while the screen color sampler was open 画面のカラーサンプラーが開いている間にアクティブなドキュメントが変わりました
Eyedropper Applies: スポイトの適用:
Eyedropper Options スポイトオプション
Eyedropper Picks Up: スポイトの抽出:
@@ -3906,6 +3909,9 @@ theme Appearance 外観
@msg {label}… {label}…
@msg no blend options ブレンドオプションがありません
@msg bad color {v} カラー {v} が正しくありません
@msg the Color Picker is not open カラーピッカーが開いていません
@msg the screen color sampler is already open 画面のカラーサンプラーはすでに開いています
@msg this system has no screen color sampler このシステムには画面のカラーサンプラーがありません
@msg Corners edits the corners of a path 「コーナー」で編集できるのはパスの角だけです
@msg give corners of the path: anchors where its sides meet at an angle パスの角 (辺が角度をなして交わるアンカーポイント) を指定してください
@msg select one path, or give its `id` パスを 1 つ選択するか、その `id` を指定してください
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -1376,6 +1376,9 @@
Export: 내보내기:
Export… 내보내기…
Eyedropper 스포이드
Sample Color from Screen 화면에서 색상 추출
This system has no screen color sampler 이 시스템에는 화면 색상 추출기가 없습니다
The active document changed while the screen color sampler was open 화면 색상 추출기가 열려 있는 동안 활성 문서가 바뀌었습니다
Eyedropper Applies: 스포이드 적용 대상:
Eyedropper Options 스포이드 옵션
Eyedropper Picks Up: 스포이드 추출 대상:
@@ -3884,6 +3887,9 @@ theme Appearance 외관
@msg {label}… {label}…
@msg no blend options 혼합 옵션이 없습니다
@msg bad color {v} 잘못된 색상 {v}
@msg the Color Picker is not open 색상 피커가 열려 있지 않습니다
@msg the screen color sampler is already open 화면 색상 추출기가 이미 열려 있습니다
@msg this system has no screen color sampler 이 시스템에는 화면 색상 추출기가 없습니다
@msg Corners edits the corners of a path 모퉁이는 패스의 모퉁이를 편집합니다
@msg give corners of the path: anchors where its sides meet at an angle 패스의 모퉁이, 즉 변이 각을 이루며 만나는 앵커를 지정하세요
@msg select one path, or give its `id` 패스를 하나 선택하거나 `id`를 지정하세요
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -2602,6 +2602,9 @@ effect Vertical Pionowo
Clear Appearance Wyczyść wygląd
Duplicate Item Powiel element
Eyedropper Kroplomierz
Sample Color from Screen Pobierz kolor z ekranu
This system has no screen color sampler Ten system nie ma narzędzia do pobierania koloru z ekranu
The active document changed while the screen color sampler was open Aktywny dokument zmienił się, gdy narzędzie do pobierania koloru z ekranu było otwarte
Move Appearance Przesuń wygląd
Reduce to Basic Appearance Ogranicz do wyglądu podstawowego
Remove Item Usuń element
@@ -3757,6 +3760,9 @@ imageTrace Outline Kontur
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` oś musi mieć wartość "vertical", "horizontal" lub kąt w stopniach, nie `{v}`
@msg background {other}: transparent, white, black or a colour such as "#ff8800" tło {other}: transparent, white, black lub kolor, np. "#ff8800"
@msg bad color {v} nieprawidłowy kolor {v}
@msg the Color Picker is not open Próbnik kolorów nie jest otwarty
@msg the screen color sampler is already open narzędzie do pobierania koloru z ekranu jest już otwarte
@msg this system has no screen color sampler ten system nie ma narzędzia do pobierania koloru z ekranu
@msg bad colour `{hex}` (#rrggbb) nieprawidłowy kolor `{hex}` (#rrggbb)
@msg bad page tree nieprawidłowe drzewo stron
@msg bad shortcut nieprawidłowy skrót
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -1003,6 +1003,9 @@
Export… Экспорт…
Extrude & Bevel… Выдавливание и фаска…
Eyedropper Пипетка
Sample Color from Screen Взять цвет с экрана
This system has no screen color sampler В этой системе нет средства выбора цвета с экрана
The active document changed while the screen color sampler was open Активный документ сменился, пока было открыто средство выбора цвета с экрана
Eyedropper Applies: Пипетка применяет:
Eyedropper Options Параметры пипетки
Eyedropper Picks Up: Пипетка берёт:
@@ -3645,6 +3648,9 @@ axis Both Оба
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis должно быть "vertical", "horizontal" или углом в градусах, а не `{v}`
@msg background {other}: transparent, white, black or a colour such as "#ff8800" фон {other}: transparent, white, black или цвет, например "#ff8800"
@msg bad color {v} недопустимый цвет {v}
@msg the Color Picker is not open палитра цветов не открыта
@msg the screen color sampler is already open средство выбора цвета с экрана уже открыто
@msg this system has no screen color sampler в этой системе нет средства выбора цвета с экрана
@msg bad colour `{hex}` (#rrggbb) недопустимый цвет `{hex}` (#rrggbb)
@msg bad page tree недопустимое дерево страниц
@msg bad shortcut недопустимое сочетание клавиш
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -999,6 +999,9 @@
Export… Експорт…
Extrude & Bevel… Видавлювання та фаска…
Eyedropper Піпетка
Sample Color from Screen Взяти колір з екрана
This system has no screen color sampler У цій системі немає засобу вибору кольору з екрана
The active document changed while the screen color sampler was open Активний документ змінився, поки був відкритий засіб вибору кольору з екрана
Eyedropper Applies: Піпетка застосовує:
Eyedropper Options Параметри піпетки
Eyedropper Picks Up: Піпетка бере:
@@ -3633,6 +3636,9 @@ axis Both Обидві
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis має бути "vertical", "horizontal" або кутом у градусах, а не `{v}`
@msg background {other}: transparent, white, black or a colour such as "#ff8800" фон {other}: transparent, white, black або колір, наприклад "#ff8800"
@msg bad color {v} неприпустимий колір {v}
@msg the Color Picker is not open палітра кольорів не відкрита
@msg the screen color sampler is already open засіб вибору кольору з екрана вже відкритий
@msg this system has no screen color sampler у цій системі немає засобу вибору кольору з екрана
@msg bad colour `{hex}` (#rrggbb) неприпустимий колір `{hex}` (#rrggbb)
@msg bad page tree неприпустиме дерево сторінок
@msg bad shortcut неприпустиме поєднання клавіш
Can't render this file because it contains an unexpected character in line 713 and column 40.
+6
View File
@@ -1168,6 +1168,9 @@
Export… Xuất…
Extrude & Bevel… Kéo dài & vát…
Eyedropper Ống hút màu
Sample Color from Screen Lấy mẫu màu từ màn hình
This system has no screen color sampler Hệ thống này không có công cụ lấy mẫu màu trên màn hình
The active document changed while the screen color sampler was open Tài liệu đang hoạt động đã thay đổi trong khi công cụ lấy mẫu màu trên màn hình đang mở
Eyedropper Applies: Ống hút màu áp dụng:
Eyedropper Options Tùy chọn Ống hút màu
Eyedropper Picks Up: Ống hút màu lấy mẫu:
@@ -3790,6 +3793,9 @@ theme Appearance Giao diện
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis phải là "vertical", "horizontal" hoặc góc theo độ, không phải `{v}`
@msg background {other}: transparent, white, black or a colour such as "#ff8800" background {other}: transparent, white, black hoặc màu như "#ff8800"
@msg bad color {v} màu không hợp lệ {v}
@msg the Color Picker is not open Bộ chọn màu chưa được mở
@msg the screen color sampler is already open công cụ lấy mẫu màu trên màn hình đã được mở
@msg this system has no screen color sampler hệ thống này không có công cụ lấy mẫu màu trên màn hình
@msg bad colour `{hex}` (#rrggbb) màu không hợp lệ `{hex}` (#rrggbb)
@msg bad page tree cây trang bị lỗi
@msg bad shortcut phím tắt không hợp lệ
Can't render this file because it contains an unexpected character in line 3698 and column 10.
+3
View File
@@ -958,6 +958,9 @@
Export… 导出…
Extrude & Bevel… 突出与斜角…
Eyedropper 吸管
Sample Color from Screen 从屏幕取样颜色
This system has no screen color sampler 此系统没有屏幕取色工具
The active document changed while the screen color sampler was open 屏幕取色工具打开期间,当前文档已更改
Eyedropper Applies: 吸管应用:
Eyedropper Options 吸管选项
Eyedropper Picks Up: 吸管拾取:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -980,6 +980,9 @@
Export… 匯出…
Extrude & Bevel… 突出與斜角…
Eyedropper 滴管
Sample Color from Screen 從螢幕取樣顏色
This system has no screen color sampler 此系統沒有螢幕取色工具
The active document changed while the screen color sampler was open 螢幕取色工具開啟期間,作用中的文件已變更
Eyedropper Applies: 滴管套用:
Eyedropper Options 滴管選項
Eyedropper Picks Up: 滴管擷取:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+9
View File
@@ -46,6 +46,7 @@ pub mod prefs_dialog;
pub mod print;
pub mod recovery;
pub mod render_worker;
pub mod screen_color;
mod scrub;
pub mod shortcut_editor;
pub mod shortcuts;
@@ -247,6 +248,10 @@ pub struct Services {
pub open_file: Option<RevealFn>,
/// Show a folder picker; returns its path (desktop: Relink to Folder, Package).
pub pick_folder: Option<Box<dyn FnMut() -> Option<String>>>,
/// Show the system's color sampler, which picks a color anywhere on the screen (macOS, and
/// browsers with an EyeDropper): Sample Color from Screen ([`screen_color`]). Without it that
/// command answers with an error and the Color Picker's eyedropper button is gray.
pub screen_color: Option<screen_color::StartSampler>,
/// Where Data Recovery keeps its copies when not in a folder (the web's browser storage;
/// tests): installed into the session ([`vectorcraft_engine::cmd::recovery`]).
pub recovery_store: Option<std::sync::Arc<dyn vectorcraft_engine::cmd::recovery::RecoveryStore>>,
@@ -407,6 +412,8 @@ pub struct VectorcraftApp {
clipboard_probe: Option<clipboard_probe::Probe>,
/// File dialogs shown off the UI thread and what runs again with their answers.
pub(crate) picks: picks::Picks,
/// The screen color sampler shown and not answered yet, and where its color goes.
pub(crate) screen_sampler: Option<screen_color::Waiting>,
/// The look for fonts installed or removed while the app was in the background, running
/// ([`Self::refresh_installed_fonts`]): whether they were.
font_check: Option<std::sync::mpsc::Receiver<bool>>,
@@ -513,6 +520,7 @@ impl VectorcraftApp {
system_paste_at: f64::NEG_INFINITY,
clipboard_probe: None,
picks: picks::Picks::default(),
screen_sampler: None,
font_check: None,
paste_chord: Default::default(),
background: Default::default(),
@@ -1093,6 +1101,7 @@ impl VectorcraftApp {
self.poll_font_check(ctx);
picks::poll(self, ctx);
background::poll(self);
screen_color::poll(self, ctx);
opening::poll(self);
#[cfg(not(target_arch = "wasm32"))]
self.tick_link_updates(ctx, now);
+8
View File
@@ -409,6 +409,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{stroke?: bool (default: the active proxy), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's colour)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke",
),
(
"ui.sampleScreenColor",
"Sample Color from Screen",
"",
"{} or {stroke?: bool (default: the active proxy), picker?: bool (the open Color Picker instead)} show the system's color sampler (macOS; browsers with an EyeDropper) and answer at once; the color the person picks anywhere on the screen then runs paint.setFill / paint.setStroke (one undo step) or becomes the Color Picker's color (field `color`), converted from display sRGB into the working RGB space; Escape cancels it and the colors stay as they were. ui.inspect reports `screenSampler: true` until it answers. An error without a sampler, while one is open, or with `picker` and no Color Picker open",
),
(
"ui.brushOptions",
"Brush Options…",
@@ -1328,6 +1334,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
crate::dialogs::new_color_group::open(app, names.unwrap_or_default())
}
"ui.colorPicker" => crate::dialogs::open_color_picker(app, p),
"ui.sampleScreenColor" => crate::screen_color::start(app, p),
"ui.graphicStyleOptions" => crate::dialogs::graphic_style_options::open(app, s("name").as_deref()),
"ui.symbolOptions" => crate::dialogs::symbol_options::open(app),
"ui.brushOptions" => crate::dialogs::brush_options::open(app, s("name").as_deref()),
@@ -2017,6 +2024,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
"ui.findFontsInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(),
"ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(),
"ui.graphicStyleOptions" => app.session.active().is_some(),
"ui.sampleScreenColor" => crate::screen_color::available(app),
"ui.brushOptions" => crate::dialogs::brush_options::available(app),
"ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
"ui.saveSwatchLibrary" => app.session.active().is_some(),
+461
View File
@@ -0,0 +1,461 @@
//! Sample Color from Screen (`ui.sampleScreenColor`): the system's color sampler picks a color
//! anywhere on the screen ([`crate::Services::screen_color`]: `NSColorSampler` on macOS, the
//! browser's EyeDropper where the browser has one). The command shows the sampler and answers at
//! once. The color arrives in a later frame ([`poll`]) and becomes the fill or the stroke through
//! `paint.setFill` / `paint.setStroke` (one undo step), or the open Color Picker's color. When the
//! person cancels the sampler (Escape), the colors stay as they were.
//!
//! The sampler answers in sRGB (macOS clips a color outside sRGB to it). [`sampled`] converts that
//! into the working RGB space, so when the working space is not sRGB, a color inside sRGB looks as
//! it did on the screen.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, TryRecvError};
use serde_json::{Value, json};
use vectorcraft_color::Color;
use vectorcraft_color::cms::{self, Cms};
use crate::menus::opt_bool;
use crate::{VectorcraftApp, widgets};
/// What the sampler answers: the picked color's display sRGB components (0 to 1), `None` when the
/// person canceled it, or an error.
pub type Answer = Result<Option<[f32; 3]>, String>;
/// Shows the system's color sampler. The answer arrives on the receiver when the person picks a
/// color or cancels, and the host then asks the UI for a frame. An error when the sampler can't be
/// shown.
pub type StartSampler = Box<dyn FnMut() -> Result<Receiver<Answer>, String>>;
/// Where the sampled color goes.
#[derive(Clone, Debug, PartialEq)]
enum Target {
Fill,
Stroke,
/// The Color Picker that was open, known by its [`PICKER_ID`].
Picker(u64),
}
/// The Color Picker's hidden field that holds a number unique to each Color Picker opened
/// ([`picker_id`]). The sampled color goes only to the Color Picker the sampler was shown for.
pub(crate) const PICKER_ID: &str = "__pickerId";
/// A new number for a Color Picker's [`PICKER_ID`].
pub(crate) fn picker_id() -> u64 {
static NEXT: AtomicU64 = AtomicU64::new(0);
NEXT.fetch_add(1, Ordering::Relaxed)
}
/// A sampler that is shown and has not answered yet.
pub struct Waiting {
answer: Receiver<Answer>,
target: Target,
/// The document active when it was shown ([`vectorcraft_engine::DocState::uid`]).
doc: Option<u64>,
}
/// The host can show a color sampler.
pub fn available(app: &VectorcraftApp) -> bool {
app.services.screen_color.is_some()
}
/// The open Color Picker dialog, as [`Target::Picker`] names it.
fn open_picker(app: &VectorcraftApp) -> Option<Target> {
let d = app.ui.dialog.as_ref().filter(|d| crate::dialogs::DialogKind::of(&d.kind) == Some(crate::dialogs::DialogKind::ColorPicker))?;
d.fields.get(PICKER_ID).and_then(Value::as_u64).map(Target::Picker)
}
/// `ui.sampleScreenColor {stroke?, picker?}`: show the sampler for the fill, the stroke (default:
/// the proxy in front) or, with `picker`, the open Color Picker.
pub fn start(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let stroke = opt_bool(p, "stroke")?.unwrap_or(!app.session.fill_active);
let target = if opt_bool(p, "picker")?.unwrap_or(false) {
open_picker(app).ok_or("the Color Picker is not open")?
} else if stroke {
Target::Stroke
} else {
Target::Fill
};
if app.screen_sampler.is_some() {
return Err("the screen color sampler is already open".into());
}
let show = app.services.screen_color.as_mut().ok_or("this system has no screen color sampler")?;
let answer = show()?;
let doc = app.session.active().map(|d| d.uid);
app.screen_sampler = Some(Waiting { answer, target, doc });
Ok(Value::Null)
}
/// The color a display sRGB value stands for in `cms`'s working RGB space: it shows as that sRGB
/// value. Components outside 0 to 1 are clamped, and a component that is not a number reads as 0.
pub fn sampled(cms: &Cms, srgb: [f32; 3]) -> Color {
let [r, g, b] = cms.srgb_to_rgb(srgb.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }));
Color::rgb(r, g, b)
}
/// The sRGB components of a sampler's hex answer (`#rrggbb` as the browser's EyeDropper gives
/// `sRGBHex`; the Color panel's hex rules apply), or `None` when it is not hex.
pub fn srgb_from_hex(hex: &str) -> Option<[f32; 3]> {
match crate::panels::color::parse_hex(hex)? {
Color::Rgb { r, g, b } => Some([r, g, b]),
_ => None,
}
}
/// Each frame: take the sampler's answer when it has arrived.
pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some(w) = &app.screen_sampler else { return };
let answer = match w.answer.try_recv() {
Ok(answer) => answer,
Err(TryRecvError::Empty) => {
// The host wakes the window when the sampler answers; this is in case it can't.
ctx.request_repaint_after(std::time::Duration::from_secs(1));
return;
}
// The sampler is gone without an answer: as canceled.
Err(TryRecvError::Disconnected) => Ok(None),
};
let Some(Waiting { target, doc, .. }) = app.screen_sampler.take() else { return };
let srgb = match answer {
Ok(Some(srgb)) => srgb,
Ok(None) => return,
Err(e) => return app.status(e),
};
if app.session.active().map(|d| d.uid) != doc {
return app.status(tl!("The active document changed while the screen color sampler was open"));
}
let color = crate::panels::color_json(&sampled(&cms::active(), srgb));
let cmd = match target {
Target::Fill => "paint.setFill",
Target::Stroke => "paint.setStroke",
// The Color Picker previews it on the selection, as any pick. The color is dropped when the
// Color Picker the sampler was shown for is no longer open (it was closed meanwhile, or
// another Color Picker replaced it).
picker @ Target::Picker(_) => {
if open_picker(app) == Some(picker)
&& let Some(d) = app.ui.dialog.as_mut()
{
d.fields.insert("color".into(), color);
}
return;
}
};
if let Err(e) = app.run(cmd, json!({ "color": color })) {
app.status(e);
}
}
/// The eyedropper button that starts the sampler (gray without one). Returns true when clicked.
pub(crate) fn button(app: &VectorcraftApp, ui: &mut egui::Ui) -> bool {
let on = available(app);
let tip = if on { tl!("Sample Color from Screen") } else { tl!("This system has no screen color sampler") };
widgets::icon_button_enabled(ui, "pipette", tip, app.screen_sampler.is_some(), on, 24.0).clicked()
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::mpsc::{Sender, channel};
use vectorcraft_color::cms::{ColorSettings, DISPLAY_P3};
use vectorcraft_engine::Session;
use super::*;
/// The senders of the samplers a stand-in host showed, newest last.
type Shown = Rc<RefCell<Vec<Sender<Answer>>>>;
/// An app with a document and a selected rectangle, whose host has a stand-in sampler.
fn app() -> (VectorcraftApp, Shown) {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
let shown = Shown::default();
let host = shown.clone();
app.services.screen_color = Some(Box::new(move || {
let (tx, rx) = channel();
host.borrow_mut().push(tx);
Ok(rx)
}));
(app, shown)
}
/// The person picks `hex` (or cancels, `None`) in the last sampler shown; the next frame takes it.
fn pick(app: &mut VectorcraftApp, shown: &Shown, hex: Option<&str>) {
let srgb = hex.map(|h| Color::from_hex(h).unwrap().to_rgb());
shown.borrow().last().unwrap().send(Ok(srgb)).unwrap();
poll(app, &egui::Context::default());
}
fn paints(app: &VectorcraftApp) -> (String, String) {
let (f, s) = crate::panels::current_paints(app);
(f.color().map_or("none".into(), |c| c.to_hex()), s.color().map_or("none".into(), |c| c.to_hex()))
}
fn undo_len(app: &VectorcraftApp) -> usize {
app.session.doc().unwrap().history.undo.len()
}
/// The color picked on the screen becomes the fill, as one undo step.
#[test]
fn a_sampled_color_fills_the_selection() {
let (mut app, shown) = app();
let (_, stroke) = paints(&app);
let before = undo_len(&app);
assert_eq!(app.run("ui.sampleScreenColor", json!({})).unwrap(), Value::Null, "answers at once");
assert_eq!(undo_len(&app), before, "nothing changes until the person picks");
pick(&mut app, &shown, Some("#123456"));
assert_eq!(paints(&app), ("#123456".into(), stroke));
assert_eq!(undo_len(&app), before + 1, "one step");
app.run("edit.undo", json!({})).unwrap();
assert_ne!(paints(&app).0, "#123456");
}
/// `stroke: true`, or the stroke proxy in front, sets the stroke instead.
#[test]
fn stroke_takes_the_stroke() {
let (mut app, shown) = app();
let (fill, _) = paints(&app);
app.run("ui.sampleScreenColor", json!({"stroke": true})).unwrap();
pick(&mut app, &shown, Some("#336699"));
assert_eq!(paints(&app), (fill.clone(), "#336699".into()));
app.session.fill_active = false;
app.run("ui.sampleScreenColor", json!({})).unwrap();
pick(&mut app, &shown, Some("#996633"));
assert_eq!(paints(&app), (fill, "#996633".into()));
app.run("ui.sampleScreenColor", json!({"stroke": false})).unwrap();
pick(&mut app, &shown, Some("#669933"));
assert_eq!(paints(&app).0, "#669933");
}
/// With `picker`, the color goes to the open Color Picker, which shows it on the selection as any
/// pick; Cancel puts the art back.
#[test]
fn the_color_picker_takes_the_sampled_color() {
let (mut app, shown) = app();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let frame = |app: &mut VectorcraftApp| {
let mut out = ctx.run_ui(Default::default(), |ui| crate::dialogs::show(app, ui.ctx()));
out.textures_delta.clear();
};
let before = paints(&app);
assert!(app.run("ui.sampleScreenColor", json!({"picker": true})).is_err(), "no Color Picker open");
assert!(app.screen_sampler.is_none());
app.run("ui.colorPicker", json!({})).unwrap();
frame(&mut app);
app.run("ui.sampleScreenColor", json!({"picker": true})).unwrap();
pick(&mut app, &shown, Some("#123456"));
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(d.fields.get("color").and_then(vectorcraft_engine::cmd::color_value).map(|c| c.to_hex()).as_deref(), Some("#123456"));
frame(&mut app);
assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "123456");
assert_eq!(paints(&app).0, "#123456", "previewed on the selection");
crate::dialogs::cancel(&mut app);
assert_eq!(paints(&app), before);
// A Color Picker closed before the answer arrives takes nothing, and the art stays as it is.
app.run("ui.colorPicker", json!({})).unwrap();
app.run("ui.sampleScreenColor", json!({"picker": true})).unwrap();
crate::dialogs::cancel(&mut app);
pick(&mut app, &shown, Some("#654321"));
assert_eq!(paints(&app), before);
assert!(app.ui.dialog.is_none());
// Nor does a new Color Picker for the same proxy and color that replaced it.
app.run("ui.colorPicker", json!({})).unwrap();
app.run("ui.sampleScreenColor", json!({"picker": true})).unwrap();
crate::dialogs::cancel(&mut app);
app.run("ui.colorPicker", json!({})).unwrap();
pick(&mut app, &shown, Some("#654321"));
frame(&mut app);
assert_eq!(paints(&app), before);
assert_ne!(app.ui.dialog.as_ref().unwrap().str("hex"), "654321");
crate::dialogs::cancel(&mut app);
// Nor does a Color Picker for the other proxy that replaced it.
app.run("ui.colorPicker", json!({})).unwrap();
app.run("ui.sampleScreenColor", json!({"picker": true})).unwrap();
crate::dialogs::cancel(&mut app);
app.run("ui.colorPicker", json!({"stroke": true})).unwrap();
pick(&mut app, &shown, Some("#654321"));
frame(&mut app);
assert_eq!(paints(&app), before);
assert_ne!(app.ui.dialog.as_ref().unwrap().str("hex"), "654321");
crate::dialogs::cancel(&mut app);
// Nor one in another document: the status bar says why.
app.run("ui.colorPicker", json!({})).unwrap();
app.run("ui.sampleScreenColor", json!({"picker": true})).unwrap();
crate::dialogs::cancel(&mut app);
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
app.run("ui.colorPicker", json!({})).unwrap();
pick(&mut app, &shown, Some("#654321"));
assert_ne!(app.ui.dialog.as_ref().unwrap().fields.get("color"), Some(&crate::panels::color_json(&Color::from_hex("#654321").unwrap())));
assert_eq!(app.ui.status, "The active document changed while the screen color sampler was open");
}
/// The Color Picker's eyedropper button shows the sampler for the Color Picker, and the color
/// the frame takes goes there.
#[test]
fn the_color_pickers_eyedropper_samples_into_it() {
let (mut app, shown) = app();
app.run("ui.colorPicker", json!({})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 900.0));
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| {
app.logic(ui.ctx());
crate::dialogs::show(app, ui.ctx());
});
out.textures_delta.clear();
};
// The dialog's one square clickable widget of the button's size, once the window has settled.
let buttons = || -> Vec<egui::Rect> {
ctx.viewport(|v| {
let widgets = v.this_pass.widgets.layers().flat_map(|(_, w)| w.iter());
widgets.filter(|w| w.sense.senses_click() && w.rect.size() == egui::Vec2::splat(24.0)).map(|w| w.rect).collect()
})
};
let mut last = vec![];
for _ in 0..10 {
frame(&mut app, vec![]);
if !last.is_empty() && buttons() == last {
break;
}
last = buttons();
}
let [button] = last[..] else { panic!("{last:?}") };
let pos = button.center();
let press = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, vec![egui::Event::PointerMoved(pos), press(true), press(false)]);
assert_eq!(shown.borrow().len(), 1, "the sampler is shown");
assert!(matches!(app.screen_sampler.as_ref().map(|w| &w.target), Some(Target::Picker(_))));
assert_eq!(app.screen_sampler.as_ref().map(|w| &w.target), open_picker(&app).as_ref());
shown.borrow().last().unwrap().send(Ok(Some([0x12, 0x34, 0x56].map(|v| v as f32 / 255.0)))).unwrap();
frame(&mut app, vec![]);
assert!(app.screen_sampler.is_none());
assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "123456");
}
/// `ui.inspect` reports `screenSampler` while the sampler waits; one at a time.
#[test]
fn inspect_reports_the_open_sampler() {
let (mut app, shown) = app();
let ctx = egui::Context::default();
let open = |app: &VectorcraftApp| crate::control::inspect(app, &ctx)["screenSampler"].clone();
assert_eq!(open(&app), json!(false));
app.run("ui.sampleScreenColor", json!({})).unwrap();
assert_eq!(open(&app), json!(true));
poll(&mut app, &ctx);
assert_eq!(open(&app), json!(true), "still waiting");
let e = app.run("ui.sampleScreenColor", json!({})).unwrap_err();
assert!(e.contains("already open"), "{e}");
assert_eq!(shown.borrow().len(), 1);
pick(&mut app, &shown, None);
assert_eq!(open(&app), json!(false));
}
/// Escape (no color) changes nothing; an error from the host reaches the status bar; a sampler
/// that is gone without answering counts as canceled.
#[test]
fn cancel_and_errors_leave_the_art_alone() {
let (mut app, shown) = app();
let before = (paints(&app), undo_len(&app));
app.run("ui.sampleScreenColor", json!({})).unwrap();
pick(&mut app, &shown, None);
assert_eq!((paints(&app), undo_len(&app)), before);
app.run("ui.sampleScreenColor", json!({})).unwrap();
shown.borrow().last().unwrap().send(Err("the sampler failed".into())).unwrap();
poll(&mut app, &egui::Context::default());
assert_eq!(app.ui.status, "the sampler failed");
app.run("ui.sampleScreenColor", json!({})).unwrap();
shown.borrow_mut().clear();
poll(&mut app, &egui::Context::default());
assert!(app.screen_sampler.is_none());
assert_eq!((paints(&app), undo_len(&app)), before);
// A sampler that can't be shown answers with its error and waits for nothing.
app.services.screen_color = Some(Box::new(|| Err("no screen".into())));
assert_eq!(app.run("ui.sampleScreenColor", json!({})).unwrap_err(), "no screen");
assert!(app.screen_sampler.is_none());
}
/// A color that arrives after another document became active paints nothing.
#[test]
fn a_new_active_document_takes_no_color() {
let (mut app, shown) = app();
app.run("ui.sampleScreenColor", json!({})).unwrap();
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
let before = paints(&app);
pick(&mut app, &shown, Some("#123456"));
assert_eq!(paints(&app), before);
assert_eq!(app.ui.status, "The active document changed while the screen color sampler was open");
}
/// The sampler answers display sRGB: with Display P3 as the working space, the color it becomes
/// still shows as the sampled #123456.
#[test]
fn a_wide_working_space_shows_the_sampled_color() {
let p3 = Cms::new(&ColorSettings { rgb: DISPLAY_P3.into(), ..Default::default() }).unwrap();
let srgb = [0x12, 0x34, 0x56].map(|v| v as f32 / 255.0);
let Color::Rgb { r, g, b } = sampled(&p3, srgb) else { panic!("not RGB") };
let shown = p3.rgb_to_srgb([r, g, b]);
for (s, v) in shown.iter().zip(srgb) {
assert!((s - v).abs() < 0.5 / 255.0, "{shown:?} shows as {srgb:?}");
}
assert!([r, g, b].iter().zip(srgb).any(|(w, v)| (w - v).abs() > 1.0 / 255.0), "Display P3 numbers differ from sRGB's");
// sRGB keeps the numbers; values out of range or not numbers are clamped.
let srgb_cms = Cms::new(&ColorSettings::default()).unwrap();
assert_eq!(sampled(&srgb_cms, srgb), Color::rgb(srgb[0], srgb[1], srgb[2]));
assert_eq!(sampled(&srgb_cms, [f32::NAN, 2.0, -1.0]), Color::rgb(0.0, 1.0, 0.0));
}
/// The browser's EyeDropper answers `sRGBHex`; anything else isn't a color.
#[test]
fn hex_answers_read_as_srgb() {
assert_eq!(srgb_from_hex("#123456"), Some([0x12, 0x34, 0x56].map(|v| v as f32 / 255.0)));
assert_eq!(srgb_from_hex("#FFFFFF"), Some([1.0; 3]));
for junk in ["", "#", "#12345", "#1234567", "#12345g", "+12345", "rgb(1, 2, 3)", "red"] {
assert_eq!(srgb_from_hex(junk), None, "{junk}");
}
}
/// Without a sampler (Windows, Linux, a browser without an EyeDropper) the command answers an
/// error and is disabled, and the Color Picker draws its button gray.
#[test]
fn without_a_sampler_the_command_is_unavailable() {
let (mut app, _) = app();
assert!(crate::menus::enabled(&app, "ui.sampleScreenColor"));
app.services.screen_color = None;
assert!(!crate::menus::enabled(&app, "ui.sampleScreenColor"));
assert_eq!(app.run("ui.sampleScreenColor", json!({})).unwrap_err(), "this system has no screen color sampler");
assert!(app.screen_sampler.is_none());
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut clicked = true;
let mut out = ctx.run_ui(Default::default(), |ui| clicked = button(&app, ui));
out.textures_delta.clear();
assert!(!clicked);
// Keyboard Shortcuts lists the command, so it can be given a key.
assert!(crate::shortcut_editor::entries().iter().any(|e| e.key == "ui.sampleScreenColor"));
}
/// The command answers every junk parameter without a panic.
#[test]
fn junk_params_never_panic() {
let (mut app, shown) = app();
let doc = crate::menus::UI_COMMANDS.iter().find(|c| c.0 == "ui.sampleScreenColor").unwrap().3;
let mut params = vectorcraft_testkit::strategies::junk_params(doc, "/nonexistent");
params.extend(vectorcraft_testkit::strategies::junk_values());
assert!(params.len() > 40);
for p in params {
let _ = app.run("ui.sampleScreenColor", p);
if !shown.borrow().is_empty() {
pick(&mut app, &shown, Some("#808080"));
shown.borrow_mut().clear();
}
}
assert!(app.screen_sampler.is_none());
}
}
+2
View File
@@ -139,6 +139,8 @@ fill or stroke (fields as in `swatchOptions` plus `group`; a gradient or pattern
The Color Picker is a dialog too: `engine.execute {command: "ui.colorPicker", params: {stroke?, color?}}` opens it for the fill (or stroke) proxy; `ui.dialog.set {field: "hex", value: "00FF00"}` (or `color`, `channel`, `webOnly`, `swatches`) then `ui.dialog.confirm` applies the colour through `paint.setFill` / `paint.setStroke`.
Sample Color from Screen: `ui.sampleScreenColor {stroke?, picker?}` shows the system's color sampler (macOS, and browsers with an EyeDropper) and returns `null` at once. When the person at the computer clicks a color anywhere on the screen, it runs `paint.setFill` (or `paint.setStroke`) with that color as one undo step, or with `picker: true` sets the open Color Picker's `color`; Escape cancels it and the colors stay as they were. The sampled display sRGB color is converted into the working RGB space. `ui.inspect` reports `screenSampler: true` until the sampler answers. Without a sampler (Windows, Linux, other browsers) the call returns an error. To read a color from VectorCraft's own window without a person, call `ui.screenshot`.
Graphic styles: `ui.graphicStyleOptions {name}` opens the `graphicStyleOptions` dialog (field `name`) for a style and
`ui.dialog.confirm` renames it with `graphicStyle.rename` (a name another style has returns an error and keeps the
dialog open); without `name` it names a new style made from the selection (`graphicStyle.new`).
+1
View File
@@ -503,6 +503,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
- **Eyedropper sources (#1251):** ordinary and Shift clicks, Shift+Alt appearance append, raster pixels and gradient-stop sampling can read visible locked/template art and sources outside isolation. Sampling leaves the source and selection intact; Alt reverse still uses editable hits, and hidden art stays excluded. Engine/tool/hit regressions cover the editing boundary, color models and one-step Undo/Redo. The general object/image workflow is described in [public HelpX](https://helpx.adobe.com/illustrator/using/tool-techniques/eyedropper-tool.html); the lock/isolation expectations come from the user report and these regressions, without a new full-parity measurement.
- **Sample Color from Screen:** an eyedropper button in the Color Picker and `ui.sampleScreenColor` (which can be given a key in Keyboard Shortcuts) pick a color anywhere on the screen with the system's sampler: `NSColorSampler` on macOS, the EyeDropper API in browsers that have it. The color becomes the fill or the stroke as one undo step, or the Color Picker's color, converted from display sRGB into the working RGB space. Windows, Linux and other browsers have no sampler yet; the button is gray there.
- **Stroke visibility (#1253):** adding paint to a previously unpainted zero-width stroke restores the existing 1 pt default (`PaintDefaults::default`, `Appearance::set_stroke`), for the selected appearance item, the new-art defaults and Swap Fill/Stroke. Positive widths are kept; recoloring a painted zero-width stroke keeps its explicit zero. A real Eyedropper-to-new-shape sequence reproduces the zero-width inheritance. Five engine regressions cover creation, defaults, targeted items, preserved widths and one-step Undo/Redo (`crates/engine/tests/stroke_visibility.rs`).
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.