- Point and paragraph text, edited right on the canvas, with full Character and Paragraph controls: font, weight, size, leading, tracking, alignment and colour.
+ Point and paragraph text, edited right on the canvas, with full Character and Paragraph controls: font, weight, size, leading, tracking, alignment and colour. The full Type Color Picker also supports screen sampling with a pixel loupe.
Type layers stay editable, take layer styles, and round-trip through PSD.
diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml
index e633c753b7..441cbf9a81 100644
--- a/apps/photocraft-web/Cargo.toml
+++ b/apps/photocraft-web/Cargo.toml
@@ -34,4 +34,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
-web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent"] }
+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException"] }
diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs
index 3c794a38a0..90c8f64d15 100644
--- a/apps/photocraft-web/src/web.rs
+++ b/apps/photocraft-web/src/web.rs
@@ -161,6 +161,7 @@ impl eframe::App for WebShell {
fn services(inbox: Inbox) -> Services {
Services {
+ screen_pick: screen_color_service(),
import: Some(Box::new(|name: &str, bytes: &[u8]| photocraft_io::import(name, bytes).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()))),
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
let mut opts = photocraft_io::ExportOptions::default();
@@ -248,3 +249,72 @@ fn mime_for(name: &str) -> &'static str {
_ => "application/octet-stream",
}
}
+
+/// Rust bindings to the browser's user-activated EyeDropper API; unsupported browsers keep
+/// the document picker and its normal canvas zoom. The browser owns its screen magnifier.
+fn screen_color_service() -> Option {
+ use js_sys::{Function, Reflect};
+ use photocraft_ui_egui::screen_picker::{Capture, Pending};
+ use wasm_bindgen::{JsValue, closure::Closure};
+ let window = web_sys::window()?;
+ let constructor = Reflect::get(&window, &JsValue::from_str("EyeDropper")).ok()?.dyn_into::().ok()?;
+ Some(Box::new(move |ctx| {
+ let (tx, rx) = std::sync::mpsc::channel();
+ let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
+ let started = (|| -> Result<_, String> {
+ let eye = Reflect::construct(&constructor, &js_sys::Array::new()).map_err(|e| format!("Could not start browser eyedropper: {e:?}"))?;
+ let controller = web_sys::AbortController::new().map_err(|e| format!("Could not create eyedropper cancellation: {e:?}"))?;
+ let options = js_sys::Object::new();
+ Reflect::set(&options, &JsValue::from_str("signal"), &controller.signal()).map_err(|e| format!("Could not set eyedropper options: {e:?}"))?;
+ let open = Reflect::get(&eye, &JsValue::from_str("open"))
+ .map_err(|e| format!("Browser eyedropper has no open method: {e:?}"))?
+ .dyn_into::()
+ .map_err(|_| "Invalid browser eyedropper method")?;
+ let promise = open
+ .call1(&eye, &options)
+ .map_err(|e| format!("Could not open browser eyedropper: {e:?}"))?
+ .dyn_into::()
+ .map_err(|_| "Browser eyedropper returned no promise")?;
+ let stop = cancelled.clone();
+ let abort = controller.clone();
+ let timer = Closure::::new(move || {
+ if stop.load(std::sync::atomic::Ordering::Relaxed) {
+ abort.abort();
+ }
+ });
+ let interval = window.set_interval_with_callback_and_timeout_and_arguments_0(timer.as_ref().unchecked_ref(), 50).map_err(|e| {
+ controller.abort();
+ format!("Could not watch eyedropper cancellation: {e:?}")
+ })?;
+ Ok((promise, timer, interval))
+ })();
+ match started {
+ Err(e) => {
+ let _ = tx.send(Err(e));
+ }
+ Ok((promise, timer, interval)) => {
+ let wake = ctx.clone();
+ let window = window.clone();
+ wasm_bindgen_futures::spawn_local(async move {
+ let result = match wasm_bindgen_futures::JsFuture::from(promise).await {
+ Ok(value) => Reflect::get(&value, &JsValue::from_str("sRGBHex"))
+ .ok()
+ .and_then(|v| v.as_string())
+ .and_then(|h| photocraft_ui_egui::color_picker_ui::parse_hex(&h))
+ .map(|rgb| Capture::Color(Some(rgb)))
+ .ok_or_else(|| "Browser eyedropper returned an invalid color".into()),
+ Err(e) if Reflect::get(&e, &JsValue::from_str("name")).ok().and_then(|v| v.as_string()).as_deref() == Some("AbortError") => {
+ Ok(Capture::Color(None))
+ }
+ Err(e) => Err(format!("Browser screen color selection failed: {e:?}")),
+ };
+ window.clear_interval_with_handle(interval);
+ drop(timer);
+ let _ = tx.send(result);
+ wake.request_repaint();
+ });
+ }
+ }
+ Pending { receiver: rx, cancelled }
+ }))
+}
diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml
index 73d4fe3258..a79bfe9683 100644
--- a/apps/photocraft/Cargo.toml
+++ b/apps/photocraft/Cargo.toml
@@ -42,6 +42,8 @@ open = "5"
ron = "0.12"
[target.'cfg(target_os = "macos")'.dependencies]
+# Safe CoreGraphics bindings, compatible with the existing macOS 11 deployment target.
+core-graphics2 = { version = "=0.6.1", default-features = false, features = ["display", "window", "link"] }
# Finder double-click / Open With / Dock drops arrive as Apple events; this small audited crate
# registers the handler (Objective-C interop needs unsafe, which our workspace forbids) behind a
# safe API. Pinned exactly: review the diff before bumping.
@@ -58,7 +60,12 @@ muda = { version = "=0.21.1", default-features = false }
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
[target.'cfg(target_os = "linux")'.dependencies]
-x11rb = { version = "=0.13.2", default-features = false }
+x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr"] }
+# Wayland desktop color selection through the existing XDG portal transport.
+zbus = "=5.19.0"
+# Already used by the portal stack; bounded waits let cancelling close the OS request.
+futures-lite = "=2.6.1"
+async-io = "=2.6.0"
# Open the window through Xwayland for a pen on Wayland (`tablet.rs`, #639): eframe doesn't
# re-export winit's `EventLoopBuilderExtX11`. Already in Cargo.lock (eframe); pinned exactly.
winit = { version = "=0.30.13", default-features = false, features = ["x11"] }
@@ -66,6 +73,8 @@ winit = { version = "=0.30.13", default-features = false, features = ["x11"] }
# Drop position on Windows (`cursor.rs`): safe Win32 wrappers (GetCursorPos), no dependencies of
# its own. Pinned exactly: review the diff before bumping.
[target.'cfg(target_os = "windows")'.dependencies]
+# Safe Windows screen-capture bindings; Linux uses the pure-Rust X11 client/portal.
+xcap = "=0.9.8"
winsafe = { version = "=0.0.29", default-features = false, features = ["user"] }
# tests/mac_menu_appkit.rs: reads AppKit's menu bar back and sends it real key events.
diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs
index e67a819f59..1f98f60114 100644
--- a/apps/photocraft/src/main.rs
+++ b/apps/photocraft/src/main.rs
@@ -37,6 +37,7 @@ mod mac_window;
mod linux_libs;
mod logging;
mod monitor_profile;
+mod screen_color;
mod services;
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
#[cfg(any(target_os = "macos", target_os = "linux", test))]
@@ -427,6 +428,7 @@ fn main() -> eframe::Result {
let _ = in_window_menus;
// Where file drags and drops are (winit 0.30 doesn't say).
app.services.cursor_pos = cursor::service(cc);
+ app.services.screen_pick = screen_color::service(cc, app.services.is_wayland);
// Tablet pressure/tilt/eraser (winit drops them): the macOS monitor and the X11 reader
// write into the stylus feed. The monitor goes in here, not before the event loop:
// AppKit's shared application only exists once winit created it (#759).
diff --git a/apps/photocraft/src/screen_color.rs b/apps/photocraft/src/screen_color.rs
new file mode 100644
index 0000000000..59fb6ae6d7
--- /dev/null
+++ b/apps/photocraft/src/screen_color.rs
@@ -0,0 +1,413 @@
+//! Desktop colour capture starts only on an explicit UI request. No snapshots are written to
+//! disk, transmitted or retained after a pick. Wayland delegates to its permission portal.
+use photocraft_ui_egui::screen_picker::{Capture, MAX_PIXELS, Pending, ScreenImage, Service};
+
+pub fn service(cc: &eframe::CreationContext<'_>, wayland: bool) -> Option {
+ if !cfg!(any(target_os = "linux", target_os = "macos", target_os = "windows")) {
+ return None;
+ }
+ let window = cc.winit_window()?.clone();
+ Some(Box::new(move |ctx| {
+ let (tx, rx) = std::sync::mpsc::channel();
+ let fallback = tx.clone();
+ let placements: Vec = window
+ .available_monitors()
+ .enumerate()
+ .map(|(index, m)| {
+ let p = m.position();
+ let size = m.size();
+ Placement { index, x: p.x, y: p.y, width: size.width, height: size.height }
+ })
+ .collect();
+ let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
+ let stop = cancelled.clone();
+ let scale = ctx.native_pixels_per_point().unwrap_or(1.0);
+ let wake = ctx.clone();
+ if let Err(e) = std::thread::Builder::new().name("screen-color-picker".into()).spawn(move || {
+ let result = capture(wayland, scale, &stop, &placements);
+ let _ = tx.send(result);
+ wake.request_repaint();
+ }) {
+ let _ = fallback.send(Err(format!("Could not start screen color picker: {e}")));
+ }
+ Pending { receiver: rx, cancelled }
+ }))
+}
+#[derive(Clone, Copy)]
+struct Placement {
+ index: usize,
+ x: i32,
+ y: i32,
+ width: u32,
+ height: u32,
+}
+fn find_monitor(placements: &[Placement], x: i32, y: i32, w: u32, h: u32) -> Option {
+ placements.iter().find(|p| p.x == x && p.y == y && p.width == w && p.height == h).map(|p| p.index)
+}
+fn count(width: usize, height: usize) -> Result {
+ width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_PIXELS).ok_or_else(|| "Screen capture exceeds the 64 MP memory budget".into())
+}
+#[cfg(target_os = "linux")]
+fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, placements: &[Placement]) -> Result {
+ if wayland {
+ return portal(stop);
+ }
+ use x11rb::{
+ connection::Connection,
+ image::{Image, PixelLayout},
+ protocol::{
+ randr::ConnectionExt as _,
+ xproto::{AtomEnum, ConnectionExt, VisualClass},
+ },
+ };
+ let (conn, index) = x11rb::connect(None).map_err(|e| e.to_string())?;
+ let screen = conn.setup().roots.get(index).ok_or("X11 screen is missing")?;
+ let (w, h) = (screen.width_in_pixels, screen.height_in_pixels);
+ count(usize::from(w), usize::from(h))?;
+ let mut monitors = conn.randr_get_monitors(screen.root, true).ok().and_then(|c| c.reply().ok()).map(|r| r.monitors).unwrap_or_default();
+ monitors.sort_by_key(|m| !m.primary);
+ if !monitors.iter().any(|m| m.primary)
+ && let Some(first) = monitors.first_mut()
+ {
+ first.primary = true;
+ }
+ let regions: Vec<(i16, i16, u16, u16, bool)> =
+ if monitors.is_empty() { vec![(0, 0, w, h, true)] } else { monitors.iter().map(|m| (m.x, m.y, m.width, m.height, m.primary)).collect() };
+ if regions.len() > 16 {
+ return Err("Too many screens for the color picker".into());
+ }
+ let mut total = 0usize;
+ for &(x, y, mw, mh, _) in ®ions {
+ if x < 0 || y < 0 || u32::from(x as u16) + u32::from(mw) > u32::from(w) || u32::from(y as u16) + u32::from(mh) > u32::from(h) {
+ return Err("Invalid X11 display geometry".into());
+ }
+ total = total
+ .checked_add(count(usize::from(mw), usize::from(mh))?)
+ .filter(|&n| n <= MAX_PIXELS)
+ .ok_or("Combined displays exceed the screen picker memory budget")?;
+ }
+ let (image, visual_id) = Image::get(&conn, screen.root, 0, 0, w, h).map_err(|e| format!("Screen capture failed: {e}"))?;
+ let visual = screen.allowed_depths.iter().flat_map(|d| &d.visuals).find(|v| v.visual_id == visual_id).ok_or("Unsupported X11 screen visual")?;
+ if visual.class != VisualClass::TRUE_COLOR {
+ return Err("Screen picking requires an X11 TrueColor visual".into());
+ }
+ let layout = PixelLayout::from_visual_type(*visual).map_err(|e| e.to_string())?;
+ let atom = conn.intern_atom(true, b"_ICC_PROFILE").map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?.atom;
+ let primary_profile = if atom == 0 {
+ None
+ } else {
+ let reply = conn.get_property(false, screen.root, atom, AtomEnum::ANY, 0, 256 * 1024).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
+ (reply.bytes_after == 0 && reply.format == 8 && !reply.value.is_empty()).then_some(reply.value)
+ };
+ let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
+ let byte = |v: u16| ((u32::from(v) + 128) / 257) as u8;
+ let mut images = Vec::new();
+ for (x, y, mw, mh, primary) in regions {
+ if stop.load(std::sync::atomic::Ordering::Relaxed) {
+ return Ok(Capture::Color(None));
+ }
+ let mut rgba = Vec::with_capacity(count(usize::from(mw), usize::from(mh))?.saturating_mul(4));
+ for row in 0..mh {
+ for col in 0..mw {
+ let (r, g, b) = layout.decode(image.get_pixel((x as u16) + col, (y as u16) + row));
+ rgba.extend_from_slice(&[byte(r), byte(g), byte(b), 255]);
+ }
+ }
+ images.push(ScreenImage {
+ monitor: find_monitor(placements, i32::from(x), i32::from(y), u32::from(mw), u32::from(mh)),
+ position: egui::pos2(f32::from(x), f32::from(y)) / scale,
+ size: egui::vec2(f32::from(mw), f32::from(mh)) / scale,
+ width: usize::from(mw),
+ height: usize::from(mh),
+ rgba,
+ profile: if primary { primary_profile.clone() } else { None },
+ });
+ }
+ Ok(Capture::Images(images))
+}
+#[cfg(target_os = "linux")]
+fn portal(stop: &std::sync::atomic::AtomicBool) -> Result {
+ use std::collections::HashMap;
+ use zbus::{
+ blocking::Proxy,
+ zvariant::{OwnedObjectPath, OwnedValue, Value},
+ };
+ let conn = zbus::blocking::connection::Builder::session()
+ .map_err(|e| e.to_string())?
+ .method_timeout(std::time::Duration::from_secs(10))
+ .build()
+ .map_err(|e| e.to_string())?;
+ let unique = conn.unique_name().ok_or("Portal connection has no unique name")?.as_str().trim_start_matches(':').replace('.', "_");
+ let token = format!("photocraft_{}", std::process::id());
+ let path = format!("/org/freedesktop/portal/desktop/request/{unique}/{token}");
+ let request = Proxy::new(&conn, "org.freedesktop.portal.Desktop", path.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
+ use futures_lite::StreamExt;
+ let mut replies = futures_lite::future::block_on(request.inner().receive_signal("Response")).map_err(|e| e.to_string())?;
+ let screenshot = Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Screenshot")
+ .map_err(|e| e.to_string())?;
+ let options: HashMap<&str, Value<'_>> = HashMap::from([("handle_token", Value::from(token.as_str()))]);
+ let _handle: OwnedObjectPath =
+ screenshot.call("PickColor", &("", options)).map_err(|e| format!("The desktop portal does not support color picking: {e}"))?;
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
+ let reply = loop {
+ if stop.load(std::sync::atomic::Ordering::Relaxed) || std::time::Instant::now() >= deadline {
+ let _: Result<(), _> = request.call("Close", &());
+ return Ok(Capture::Color(None));
+ }
+ let next = futures_lite::future::block_on(futures_lite::future::race(async { Some(replies.next().await) }, async {
+ async_io::Timer::after(std::time::Duration::from_millis(50)).await;
+ None
+ }));
+ if let Some(message) = next {
+ break message.ok_or("The desktop color picker closed without a result")?;
+ }
+ };
+ let (code, values): (u32, HashMap) = reply.body().deserialize().map_err(|e| e.to_string())?;
+ if code == 1 {
+ return Ok(Capture::Color(None));
+ }
+ if code != 0 {
+ return Err("The desktop color picker could not sample this screen".into());
+ }
+ let value = values.get("color").ok_or("Desktop picker returned no color")?;
+ let (r, g, b): (f64, f64, f64) = value.try_clone().map_err(|e| e.to_string())?.try_into().map_err(|e: zbus::zvariant::Error| e.to_string())?;
+ if [r, g, b].iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v)) {
+ return Err("Desktop picker returned an invalid color".into());
+ }
+ Ok(Capture::Color(Some([r as f32, g as f32, b as f32])))
+}
+#[cfg(target_os = "windows")]
+fn capture(_wayland: bool, _scale: f32, stop: &std::sync::atomic::AtomicBool, placements: &[Placement]) -> Result {
+ let monitors = xcap::Monitor::all().map_err(|e| e.to_string())?;
+ if monitors.is_empty() || monitors.len() > 16 {
+ return Err("No supported screen is available".into());
+ }
+ let mut budget = 0usize;
+ for m in &monitors {
+ budget = budget
+ .checked_add(count(m.width().map_err(|e| e.to_string())? as usize, m.height().map_err(|e| e.to_string())? as usize)?)
+ .filter(|&n| n <= MAX_PIXELS)
+ .ok_or("Combined displays exceed the screen picker memory budget")?;
+ }
+ let mut total = 0usize;
+ let mut images = Vec::new();
+ for m in monitors {
+ if stop.load(std::sync::atomic::Ordering::Relaxed) {
+ return Ok(Capture::Color(None));
+ }
+ let (w, h) = (m.width().map_err(|e| e.to_string())?, m.height().map_err(|e| e.to_string())?);
+ let scale = m.scale_factor().map_err(|e| e.to_string())?;
+ if !scale.is_finite() || scale <= 0.0 || scale > 16.0 {
+ return Err("Invalid display scale".into());
+ }
+ let captured = m.capture_image().map_err(|e| format!("Screen capture failed: {e}"))?;
+ let (width, height) = (captured.width() as usize, captured.height() as usize);
+ total = total.checked_add(count(width, height)?).filter(|&n| n <= MAX_PIXELS).ok_or("Combined displays exceed the screen picker memory budget")?;
+ let (x, y) = (m.x().map_err(|e| e.to_string())?, m.y().map_err(|e| e.to_string())?);
+ images.push(ScreenImage {
+ monitor: find_monitor(placements, x, y, w, h),
+ position: egui::pos2(x as f32, y as f32) / scale,
+ size: egui::vec2(w as f32, h as f32) / scale,
+ width,
+ height,
+ rgba: captured.into_raw(),
+ profile: None,
+ });
+ }
+ Ok(Capture::Images(images))
+}
+#[cfg(target_os = "macos")]
+fn capture(_wayland: bool, _scale: f32, stop: &std::sync::atomic::AtomicBool, placements: &[Placement]) -> Result {
+ use core_graphics2::{display::get_active_display_list, image::CGImageAlphaInfo as Alpha, window};
+ if !window::preflight_screen_capture_access() && !window::request_screen_capture_access() {
+ return Err("Allow PhotoCraft in System Settings > Privacy & Security > Screen Recording, then reopen PhotoCraft".into());
+ }
+ let displays = get_active_display_list(17).ok_or("Could not enumerate displays")?;
+ if displays.is_empty() || displays.len() > 16 {
+ return Err("No supported screen is available".into());
+ }
+ let mut budget = 0usize;
+ for display in &displays {
+ let mode = display.copy_display_mode().ok_or("Display mode is unavailable")?;
+ budget = budget
+ .checked_add(count(mode.pixel_width(), mode.pixel_height())?)
+ .filter(|&n| n <= MAX_PIXELS)
+ .ok_or("Combined displays exceed the screen picker memory budget")?;
+ }
+ let mut images = Vec::new();
+ let mut total = 0usize;
+ for display in displays {
+ if stop.load(std::sync::atomic::Ordering::Relaxed) {
+ return Ok(Capture::Color(None));
+ }
+ // Capture a composed monitor at native resolution through safe bindings. Keeping the
+ // CoreGraphics seam preserves PhotoCraft's existing macOS 11 deployment target.
+ let bounds = display.bounds();
+ let image = window::new_image(
+ bounds,
+ window::CGWindowListOption::OnScreenOnly,
+ window::kCGNullWindowID,
+ window::CGWindowImageOption::BestResolution | window::CGWindowImageOption::ShouldBeOpaque,
+ )
+ .ok_or("Screen capture failed; check Screen Recording permission")?;
+ let (width, height) = (image.width(), image.height());
+ total = total.checked_add(count(width, height)?).filter(|&n| n <= MAX_PIXELS).ok_or("Combined displays exceed the screen picker memory budget")?;
+ let profile = image.color_space().and_then(|space| space.copy_icc_data());
+ if profile.as_ref().is_some_and(|p| p.len() > 1024 * 1024) {
+ return Err("Screen profile exceeds the memory budget".into());
+ }
+ let info = image.bitmap_info().bits();
+ if image.bits_per_component() != 8 || image.bits_per_pixel() != 32 || info & (1 << 8) != 0 {
+ return Err("Unsupported screen capture pixel format".into());
+ }
+ let (first, premultiplied, opaque) = match image.alpha_info() {
+ Alpha::AlphaPremultipliedFirst => (true, true, false),
+ Alpha::AlphaPremultipliedLast => (false, true, false),
+ Alpha::AlphaFirst => (true, false, false),
+ Alpha::AlphaLast => (false, false, false),
+ Alpha::AlphaNoneSkipFirst => (true, false, true),
+ Alpha::AlphaNoneSkipLast => (false, false, true),
+ _ => return Err("Unsupported screen capture alpha layout".into()),
+ };
+ let little = match info & 0x7000 {
+ 0 => cfg!(target_endian = "little"),
+ 0x2000 => true,
+ 0x4000 => false,
+ _ => return Err("Unsupported screen capture byte order".into()),
+ };
+ let order = match (first, little) {
+ (true, true) => [2, 1, 0, 3],
+ (true, false) => [1, 2, 3, 0],
+ (false, true) => [3, 2, 1, 0],
+ (false, false) => [0, 1, 2, 3],
+ };
+ let stride = image.bytes_per_row();
+ checked_stride(width, height, stride)?;
+ let bytes = image.data_provider().and_then(|p| p.copy_data()).ok_or("Screen image has no pixels")?;
+ let rgba = unpack_rgba(bytes.bytes(), width, height, stride, order, premultiplied, opaque)?;
+ let (w, h) = (bounds.size.width as f32, bounds.size.height as f32);
+ let (x, y) = (bounds.origin.x as f32, bounds.origin.y as f32);
+ if ![w, h, x, y].iter().all(|v| v.is_finite()) || w <= 0.0 || h <= 0.0 {
+ return Err("Invalid display geometry".into());
+ }
+ let scale = width as f32 / w;
+ images.push(ScreenImage {
+ monitor: find_monitor(placements, (x * scale).round() as i32, (y * scale).round() as i32, width as u32, height as u32),
+ position: egui::pos2(x, y),
+ size: egui::vec2(w, h),
+ width,
+ height,
+ rgba,
+ profile: profile.map(|p| p.bytes().to_vec()),
+ });
+ }
+ Ok(Capture::Images(images))
+}
+// CoreGraphics rows can be padded and their byte order/alpha vary. Kept platform independent
+// so Linux CI also tests the macOS decoding path against synthetic pixels.
+#[cfg(any(target_os = "macos", test))]
+fn checked_stride(width: usize, height: usize, stride: usize) -> Result {
+ count(width, height)?;
+ let row = width.checked_mul(4).ok_or("Invalid screen row")?;
+ if stride < row || stride > row.saturating_add(4096) {
+ return Err("Invalid screen row stride".into());
+ }
+ stride.checked_mul(height).filter(|&n| n <= MAX_PIXELS.saturating_mul(4)).ok_or_else(|| "Screen buffer exceeds the memory budget".into())
+}
+#[cfg(any(target_os = "macos", test))]
+fn unpack_rgba(bytes: &[u8], width: usize, height: usize, stride: usize, order: [usize; 4], premultiplied: bool, opaque: bool) -> Result, String> {
+ let length = checked_stride(width, height, stride)?;
+ if bytes.len() < length || order.iter().any(|&i| i > 3) {
+ return Err("Invalid screen pixel buffer".into());
+ }
+ let mut out = Vec::with_capacity(count(width, height)?.saturating_mul(4));
+ for row in bytes.chunks_exact(stride).take(height) {
+ for p in row[..width * 4].as_chunks::<4>().0 {
+ let a = if opaque { 255 } else { p[order[3]] };
+ for &i in &order[..3] {
+ let v = u32::from(p[i]);
+ out.push(if premultiplied && a != 255 {
+ if a == 0 { 0 } else { ((v * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8 }
+ } else {
+ v as u8
+ });
+ }
+ out.push(255);
+ }
+ }
+ Ok(out)
+}
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn screen_capture_decodes_padded_bgra_and_premultiplied_argb() {
+ assert_eq!(
+ unpack_rgba(&[10, 20, 30, 0, 99, 99, 99, 99, 40, 50, 60, 0, 99, 99, 99, 99], 1, 2, 8, [2, 1, 0, 3], false, true).unwrap(),
+ [30, 20, 10, 255, 60, 50, 40, 255]
+ );
+ assert_eq!(unpack_rgba(&[128, 64, 32, 16], 1, 1, 4, [1, 2, 3, 0], true, false).unwrap(), [128, 64, 32, 255]);
+ assert_eq!(unpack_rgba(&[0, 0, 0, 0], 1, 1, 4, [0, 1, 2, 3], true, false).unwrap(), [0, 0, 0, 255]);
+ }
+ #[test]
+ fn screen_capture_rejects_unbounded_or_truncated_buffers() {
+ assert!(count(usize::MAX, 2).is_err());
+ assert!(checked_stride(1, 1, usize::MAX).is_err());
+ assert!(checked_stride(1, MAX_PIXELS, 4096).is_err());
+ assert!(unpack_rgba(&[0; 3], 1, 1, 4, [0, 1, 2, 3], false, true).is_err());
+ assert!(unpack_rgba(&[0; 4], 1, 1, 4, [4, 1, 2, 3], false, true).is_err());
+ }
+ #[cfg(target_os = "linux")]
+ #[test]
+ #[ignore = "requires a live X11 display; draws only a tiny synthetic fixture"]
+ fn screen_capture_reads_a_known_x11_fixture_pixel() {
+ use x11rb::{
+ COPY_DEPTH_FROM_PARENT,
+ connection::Connection,
+ protocol::xproto::{ConfigureWindowAux, ConnectionExt, CreateWindowAux, StackMode, WindowClass},
+ };
+ let (conn, index) = x11rb::connect(None).unwrap();
+ let screen = &conn.setup().roots[index];
+ let window = conn.generate_id().unwrap();
+ conn.create_window(
+ COPY_DEPTH_FROM_PARENT,
+ window,
+ screen.root,
+ 200,
+ 200,
+ 8,
+ 8,
+ 0,
+ WindowClass::INPUT_OUTPUT,
+ 0,
+ &CreateWindowAux::new().override_redirect(1).background_pixel(0xff8040),
+ )
+ .unwrap()
+ .check()
+ .unwrap();
+ conn.map_window(window).unwrap().check().unwrap();
+ conn.configure_window(window, &ConfigureWindowAux::new().stack_mode(StackMode::ABOVE)).unwrap().check().unwrap();
+ conn.flush().unwrap();
+ // X11 request completion precedes the compositor presenting the mapped fixture.
+ std::thread::sleep(std::time::Duration::from_millis(150));
+ let images = capture(false, 1.0, &std::sync::atomic::AtomicBool::new(false), &[]);
+ conn.destroy_window(window).unwrap().check().unwrap();
+ conn.flush().unwrap();
+ let Capture::Images(images) = images.unwrap() else { panic!("not an image capture") };
+ let first = images.iter().find(|m| m.position.x <= 200.0 && m.position.y <= 200.0).unwrap();
+ let x = (200.0 - first.position.x) as usize;
+ let y = (200.0 - first.position.y) as usize;
+ let offset = (y * first.width + x) * 4;
+ assert_eq!(&first.rgba[offset..offset + 4], &[255, 128, 64, 255]);
+ }
+ #[test]
+ fn screen_capture_matches_negative_and_hidpi_monitor_geometry() {
+ let m = [Placement { index: 2, x: -3840, y: 0, width: 3840, height: 2160 }];
+ assert_eq!(find_monitor(&m, -3840, 0, 3840, 2160), Some(2));
+ assert_eq!(find_monitor(&m, 0, 0, 1920, 1080), None);
+ }
+}
+#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
+fn capture(_wayland: bool, _scale: f32, _stop: &std::sync::atomic::AtomicBool, _placements: &[Placement]) -> Result {
+ Err("Screen color picking is unavailable on this platform".into())
+}
diff --git a/crates/ui-egui/examples/color_picker_demo.rs b/crates/ui-egui/examples/color_picker_demo.rs
new file mode 100644
index 0000000000..45daa45c12
--- /dev/null
+++ b/crates/ui-egui/examples/color_picker_demo.rs
@@ -0,0 +1,80 @@
+//! Render synthetic color-picker evidence without capturing the user's desktop.
+//! cargo run -p photocraft-ui-egui --example color_picker_demo -- /tmp/color-picker
+use egui_kittest::{Harness, kittest::Queryable};
+use photocraft_ui_egui::{
+ PhotocraftApp, Services,
+ screen_picker::{self, Capture, Pending, ScreenImage},
+};
+use serde_json::json;
+
+fn main() {
+ let output = std::env::args().nth(1).unwrap_or_else(|| "/tmp/photocraft-color-picker".into());
+ std::fs::create_dir_all(&output).expect("output directory");
+ let mut session = photocraft_engine::Session::new();
+ session.execute("file.new", json!({"width":800,"height":500,"background":"white"})).unwrap();
+ session.execute("type.create", json!({"text":"Color from anywhere","color":"#2070c0","size":48,"x":90,"y":200})).unwrap();
+ let mut text = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(move |cc| {
+ PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
+ let services = Services {
+ screen_pick: Some(Box::new(|_| {
+ let (tx, receiver) = std::sync::mpsc::channel();
+ tx.send(Ok(Capture::Color(Some([1.0, 136.0 / 255.0, 0.0])))).unwrap();
+ Pending { receiver, cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)) }
+ })),
+ ..Default::default()
+ };
+ let mut app = PhotocraftApp::new(session, services);
+ app.ui.tool = photocraft_ui_egui::state::Tool::Type;
+ app
+ });
+ text.run_steps(8);
+ text.get_by_label("Set the text color").click();
+ text.run_steps(6);
+ text.render().unwrap().save(format!("{output}/text-color-picker.png")).unwrap();
+ text.get_by_label("Pick screen color").click();
+ text.run_steps(6);
+ text.render().unwrap().save(format!("{output}/text-color-picked.png")).unwrap();
+
+ let services = Services {
+ screen_pick: Some(Box::new(|_| {
+ let mut rgba = Vec::with_capacity(800 * 600 * 4);
+ for y in 0..600 {
+ for x in 0..800 {
+ rgba.extend_from_slice(&[(x * 255 / 799) as u8, (y * 255 / 599) as u8, 128, 255]);
+ }
+ }
+ let (tx, receiver) = std::sync::mpsc::channel();
+ tx.send(Ok(Capture::Images(vec![ScreenImage {
+ monitor: None,
+ position: egui::Pos2::ZERO,
+ size: egui::vec2(800.0, 600.0),
+ width: 800,
+ height: 600,
+ rgba,
+ profile: None,
+ }])))
+ .unwrap();
+ Pending { receiver, cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)) }
+ })),
+ ..Default::default()
+ };
+ let mut loupe = Harness::builder().with_size(egui::vec2(800.0, 600.0)).with_pixels_per_point(1.0).wgpu().build_ui_state(
+ |ui, app: &mut PhotocraftApp| {
+ screen_picker::tick(app, ui.ctx());
+ if screen_picker::showing(ui.ctx()) {
+ screen_picker::show(ui.ctx());
+ } else {
+ screen_picker::button(ui, egui::Id::new("demo"));
+ }
+ },
+ PhotocraftApp::new(photocraft_engine::Session::new(), services),
+ );
+ loupe.run_steps(2);
+ loupe.get_by_label("Pick screen color").click();
+ loupe.run_steps(3);
+ loupe.input_mut().events.push(egui::Event::PointerMoved(egui::pos2(360.0, 220.0)));
+ loupe.run_steps(2);
+ loupe.render().unwrap().save(format!("{output}/screen-color-loupe.png")).unwrap();
+ screen_picker::cancel(&loupe.ctx);
+ loupe.run_steps(1);
+}
diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs
index d83783a1bb..02694a0783 100644
--- a/crates/ui-egui/src/adjust_editors.rs
+++ b/crates/ui-egui/src/adjust_editors.rs
@@ -169,7 +169,7 @@ fn color32(c: [f32; 3]) -> Color32 {
/// A colour swatch button (egui's picker); returns the edit.
fn color_button(ui: &mut egui::Ui, c: &mut [f32; 3]) -> Edit {
let mut srgb = [c[0], c[1], c[2]].map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8);
- let r = ui.color_edit_button_srgb(&mut srgb);
+ let r = crate::widgets::color_edit_button_srgb(ui, &mut srgb);
if r.changed() {
*c = srgb.map(|x| f32::from(x) / 255.0);
}
diff --git a/crates/ui-egui/src/analysis_ui.rs b/crates/ui-egui/src/analysis_ui.rs
index bd2a981ee2..cd8b2d4c77 100644
--- a/crates/ui-egui/src/analysis_ui.rs
+++ b/crates/ui-egui/src/analysis_ui.rs
@@ -457,7 +457,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
let _ = app.run("count.setGroup", json!({"group": gi, "visible": !g.visible}));
}
let mut rgb = g.color.to_rgb();
- if ui.color_edit_button_rgb(&mut rgb).changed() {
+ if crate::widgets::color_edit_button_rgb(ui, &mut rgb).changed() {
let _ = app.run("count.setGroup", json!({"group": gi, "color": [rgb[0], rgb[1], rgb[2]], "coalesce": "count-color"}));
}
if crate::icons::button(ui, "trash", 22.0, false, tl!("Delete count group")).clicked() {
@@ -488,7 +488,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
ui.label(RichText::new(tl!("Author:")).color(t.text_dim).size(11.0));
ui.add(egui::TextEdit::singleline(&mut app.ui.analysis.note_author).desired_width(120.0));
ui.label(RichText::new(tl!("Color:")).color(t.text_dim).size(11.0));
- ui.color_edit_button_rgb(&mut app.ui.analysis.note_color);
+ crate::widgets::color_edit_button_rgb(ui, &mut app.ui.analysis.note_color);
if ui.add_enabled_ui(!doc.notes.is_empty(), |ui| crate::widgets::secondary_button(ui, tl!("Clear All"), 0.0)).inner.clicked() {
let _ = app.run("notes.delete", json!({"all": true}));
app.ui.analysis.note_selected = None;
diff --git a/crates/ui-egui/src/artboard_ui.rs b/crates/ui-egui/src/artboard_ui.rs
index 1d7ff388cd..7fd8351656 100644
--- a/crates/ui-egui/src/artboard_ui.rs
+++ b/crates/ui-egui/src/artboard_ui.rs
@@ -134,7 +134,7 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
if let ArtboardBackground::Custom(c) = a.background {
let [r8, g8, b8, _] = c.to_rgba8();
let mut rgb = [r8, g8, b8];
- if ui.color_edit_button_srgb(&mut rgb).changed() {
+ if crate::widgets::color_edit_button_srgb(ui, &mut rgb).changed() {
edit = Some(json!({"layer": layer.id.0, "background": "custom", "color": format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]), "coalesce": key("color")}));
}
}
diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs
index adb0ff3a38..e6e6b2d19a 100644
--- a/crates/ui-egui/src/color_picker_ui.rs
+++ b/crates/ui-egui/src/color_picker_ui.rs
@@ -255,6 +255,12 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) -> Option {
f.insert("__webOnly".into(), json!(web));
set_rgb(f, current(f).rgb, Keep::Nothing);
}
+ // Under the colour field, not in the action column: the dialog must still fit a
+ // 760 x 480 window.
+ ui.add_space(6.0);
+ if let Some(rgb) = crate::screen_picker::button(ui, ui.id().with("screen-color")) {
+ set_rgb(f, rgb, Keep::Nothing);
+ }
});
ui.vertical(|ui| {
ui.horizontal_top(|ui| {
@@ -718,3 +724,48 @@ mod tests {
assert_eq!(dialog_color(&app, id), "#00ff00");
}
}
+
+#[cfg(test)]
+mod screen_integration_tests {
+ use super::*;
+ use crate::screen_picker::{Capture, Pending};
+ use egui_kittest::{Harness, kittest::Queryable};
+ #[test]
+ fn text_swatch_opens_full_dialog_and_screen_pick_waits_for_ok() {
+ let mut session = photocraft_engine::Session::new();
+ session.execute("file.new", json!({"width":160,"height":120})).unwrap();
+ let id = session.execute("type.create", json!({"text":"Hello world","color":"#000000","size":24,"x":10,"y":60})).unwrap()["layer"].as_u64().unwrap();
+ let services = crate::Services {
+ screen_pick: Some(Box::new(|_| {
+ let (tx, receiver) = std::sync::mpsc::channel();
+ tx.send(Ok(Capture::Color(Some([1.0, 136.0 / 255.0, 0.0])))).unwrap();
+ Pending { receiver, cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)) }
+ })),
+ ..Default::default()
+ };
+ let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| {
+ PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
+ let mut app = PhotocraftApp::new(session, services);
+ app.ui.tool = crate::state::Tool::Type;
+ app
+ });
+ h.run_steps(8);
+ let before = h.state().session.active().unwrap().history.entries().len();
+ let fg = h.state().session.tools.foreground;
+ h.get_by_label("Set the text color").click();
+ h.run_steps(6);
+ assert_eq!(h.state().ui.dialogs.last().unwrap().fields["__label"], "Color Picker (Text Color)");
+ h.get_by_label("Pick screen color").click();
+ h.run_steps(6);
+ assert_eq!(h.state().ui.dialogs.last().unwrap().fields["color"], "#ff8800");
+ assert_eq!(h.state().session.active().unwrap().history.entries().len(), before, "sampling is only a dialog field change");
+ assert_eq!(h.state().session.tools.foreground, fg);
+ h.get_by_label("OK").click();
+ h.run_steps(4);
+ let doc = &h.state().session.active().unwrap().doc;
+ let layer = doc.layer(photocraft_doc::LayerId(id)).unwrap();
+ let photocraft_doc::LayerContent::Text(text) = &layer.content else { panic!("not text") };
+ assert_eq!(text.color.to_rgba8(), [255, 136, 0, 255]);
+ assert_eq!(h.state().session.active().unwrap().history.entries().len(), before + 1);
+ }
+}
diff --git a/crates/ui-egui/src/fill_ui.rs b/crates/ui-egui/src/fill_ui.rs
index d72055b2a2..59c7cd5138 100644
--- a/crates/ui-egui/src/fill_ui.rs
+++ b/crates/ui-egui/src/fill_ui.rs
@@ -146,7 +146,7 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map)
ui.horizontal(|ui| {
label(ui, tl!("Color:"));
let mut rgb = parse_hex(&get_str(f, "color", "#000000"));
- if egui::color_picker::color_edit_button_srgb(ui, &mut rgb).changed() {
+ if crate::widgets::color_edit_button_srgb(ui, &mut rgb).changed() {
f.insert("color".into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
}
});
diff --git a/crates/ui-egui/src/gallery_ui.rs b/crates/ui-egui/src/gallery_ui.rs
index 490771b99e..d29b196f18 100644
--- a/crates/ui-egui/src/gallery_ui.rs
+++ b/crates/ui-egui/src/gallery_ui.rs
@@ -513,7 +513,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
let mut rgb = c.map_or([neon[0], neon[1], neon[2]], |v| [v[0], v[1], v[2]]);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Glow Color")).color(t.text_dim));
- ui.color_edit_button_rgb(&mut rgb);
+ crate::widgets::color_edit_button_rgb(ui, &mut rgb);
});
e.params.insert("glowColor".into(), json!([rgb[0], rgb[1], rgb[2], 1.0]));
}
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
index 6c5d8477d0..0a73df96bd 100644
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -2167,6 +2167,14 @@ cameraRaw Color Barvy
Channels: Kanály:
Knockout Vyřazení
Apply At Canvas Bounds Použít efekt na hranicích plátna
+
+# Shared screen eyedropper and text Color Picker (#1128).
+ Color Picker (Text Color) Výběr barvy (Barva textu)
+ Pick screen color Vybrat barvu obrazovky
+ Sample a pixel anywhere on the screen Vzorkovat pixel kdekoli na obrazovce
+ Click to pick · Esc to cancel Kliknutí vybere · Esc zruší
+ Pick a screen pixel or press Esc to cancel Vyberte pixel obrazovky nebo stiskněte Esc pro zrušení
+ Scroll to zoom Rolováním přibližujte
Show/Hide All Panels Zobrazit/skrýt všechny panely
Show/Hide Panels Zobrazit/skrýt panely
Could not open Nelze otevřít
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
index 8aa8d9cc25..9c85d211bd 100644
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -2207,6 +2207,14 @@
Channels: Kanäle:
Knockout Aussparung
Apply At Canvas Bounds Effekt an Arbeitsflächenbegrenzungen anwenden
+
+# Shared screen eyedropper and text Color Picker (#1128).
+ Color Picker (Text Color) Farbwähler (Textfarbe)
+ Pick screen color Bildschirmfarbe auswählen
+ Sample a pixel anywhere on the screen Ein Pixel an beliebiger Stelle auf dem Bildschirm auswählen
+ Click to pick · Esc to cancel Klicken zum Auswählen · Esc zum Abbrechen
+ Pick a screen pixel or press Esc to cancel Ein Bildschirmpixel auswählen oder mit Esc abbrechen
+ Scroll to zoom Zum Zoomen scrollen
Show/Hide All Panels Alle Bedienfelder ein-/ausblenden
Show/Hide Panels Bedienfelder ein-/ausblenden
Could not open Öffnen nicht möglich
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
index 7d3064a286..22c290532c 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -2238,6 +2238,14 @@ cameraRaw Amount Cantidad
Channels: Canales:
Knockout Cobertura
Apply At Canvas Bounds Aplicar efecto en los límites del lienzo
+
+# Shared screen eyedropper and text Color Picker (#1128).
+ Color Picker (Text Color) Selector de color (Color del texto)
+ Pick screen color Elegir color de pantalla
+ Sample a pixel anywhere on the screen Tomar un píxel en cualquier lugar de la pantalla
+ Click to pick · Esc to cancel Clic para elegir · Esc para cancelar
+ Pick a screen pixel or press Esc to cancel Elige un píxel de pantalla o pulsa Esc para cancelar
+ Scroll to zoom Desplaza para ampliar
Show/Hide All Panels Mostrar/ocultar todos los paneles
Show/Hide Panels Mostrar/ocultar paneles
Could not open No se pudo abrir
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
index 13943838d4..bf1e0fdfeb 100644
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -2598,6 +2598,14 @@ cameraRaw Blending Fusion
Channels: Couches :
Knockout Masquage
Apply At Canvas Bounds Appliquer l’effet aux limites de la zone de travail
+
+# Shared screen eyedropper and text Color Picker (#1128).
+ Color Picker (Text Color) Sélecteur de couleurs (Couleur du texte)
+ Pick screen color Choisir une couleur à l’écran
+ Sample a pixel anywhere on the screen Prélever un pixel n’importe où sur l’écran
+ Click to pick · Esc to cancel Cliquer pour choisir · Échap pour annuler
+ Pick a screen pixel or press Esc to cancel Choisissez un pixel à l’écran ou appuyez sur Échap pour annuler
+ Scroll to zoom Défiler pour zoomer
Show/Hide All Panels Afficher/masquer tous les panneaux
Show/Hide Panels Afficher/masquer les panneaux
Could not open Impossible d’ouvrir
diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv
index 7c23602997..33f50b8a3f 100644
--- a/crates/ui-egui/src/i18n/id.tsv
+++ b/crates/ui-egui/src/i18n/id.tsv
@@ -2585,6 +2585,14 @@ cameraRaw Blending Pembauran
Channels: Channel:
Knockout Knockout
Apply At Canvas Bounds Terapkan efek di batas kanvas
+
+# Shared screen eyedropper and text Color Picker (#1128).
+ Color Picker (Text Color) Pemilih Warna (Warna Teks)
+ Pick screen color Pilih warna layar
+ Sample a pixel anywhere on the screen Ambil sampel piksel di mana saja pada layar
+ Click to pick · Esc to cancel Klik untuk memilih · Esc untuk membatalkan
+ Pick a screen pixel or press Esc to cancel Pilih piksel layar atau tekan Esc untuk membatalkan
+ Scroll to zoom Gulir untuk memperbesar
Show/Hide All Panels Tampilkan/Sembunyikan Semua Panel
Show/Hide Panels Tampilkan/Sembunyikan Panel
Could not open Tidak dapat membuka
diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv
index 0f2e2fd593..83575a2f49 100644
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -2241,6 +2241,14 @@ cameraRaw Amount Quantità
Add to Swatches Aggiungi ai campioni
Convert to Layers Converti in livelli
Apply At Canvas Bounds Applica effetto ai limiti dell’area di lavoro
+
+# Shared screen eyedropper and text Color Picker (#1128).
+ Color Picker (Text Color) Selettore colore (Colore del testo)
+ Pick screen color Scegli colore dallo schermo
+ Sample a pixel anywhere on the screen Campiona un pixel in qualsiasi punto dello schermo
+ Click to pick · Esc to cancel Clic per scegliere · Esc per annullare
+ Pick a screen pixel or press Esc to cancel Scegli un pixel dello schermo o premi Esc per annullare
+ Scroll to zoom Scorri per ingrandire
Show/Hide All Panels Mostra/nascondi tutti i pannelli
Show/Hide Panels Mostra/nascondi pannelli
Could not open Impossibile aprire
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
index bdd0c3de77..e083260ecb 100644
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -2150,6 +2150,14 @@ cameraRaw Color カラー
Channels: チャンネル:
Knockout ノックアウト
Apply At Canvas Bounds カンバスの境界に効果を適用
+
+# Shared screen eyedropper and text Color Picker (#1128).
+ Color Picker (Text Color) カラーピッカー(文字色)
+ Pick screen color 画面の色を選択
+ Sample a pixel anywhere on the screen 画面上の任意のピクセルから色を取得
+ Click to pick · Esc to cancel クリックで選択 · Escでキャンセル
+ Pick a screen pixel or press Esc to cancel 画面のピクセルを選択するか、Escでキャンセル
+ Scroll to zoom スクロールしてズーム
Show/Hide All Panels すべてのパネルを表示/非表示
Show/Hide Panels パネルを表示/非表示
Could not open 開けませんでした
diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv
index 242b3e4b34..bd35f2fa80 100644
--- a/crates/ui-egui/src/i18n/ko.tsv
+++ b/crates/ui-egui/src/i18n/ko.tsv
@@ -2753,6 +2753,14 @@ cameraRaw Color 색상
Channels: 채널:
Knockout 녹아웃
Apply At Canvas Bounds 캔버스 경계에 효과 적용
+
+# Shared screen eyedropper and text Color Picker (#1128).
+ Color Picker (Text Color) 색상 선택기 (텍스트 색상)
+ Pick screen color 화면 색상 선택
+ Sample a pixel anywhere on the screen 화면의 아무 픽셀에서 색상 추출
+ Click to pick · Esc to cancel 클릭하여 선택 · Esc로 취소
+ Pick a screen pixel or press Esc to cancel 화면 픽셀을 선택하거나 Esc를 눌러 취소
+ Scroll to zoom 스크롤하여 확대/축소
Show/Hide All Panels 모든 패널 표시/숨기기
Show/Hide Panels 패널 표시/숨기기
Could not open 열 수 없음
diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv
index 401fa245f6..72f8064b91 100644
--- a/crates/ui-egui/src/i18n/pl.tsv
+++ b/crates/ui-egui/src/i18n/pl.tsv
@@ -3189,6 +3189,14 @@ cameraRaw Roughness Chropowatość
% of all commits % wszystkich commitów
Lines +/− Wiersze +/−
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR: {prs}, commity: {commits}, wiersze +{added} / −{deleted} (Δ {delta}), pliki binarne +{binAdded} / −{binDeleted}, {first} – {last}
+
+# Text Color Picker and screen sampling
+ Color Picker (Text Color) Próbnik kolorów (Kolor tekstu)
+ Pick screen color Wybierz kolor z ekranu
+ Sample a pixel anywhere on the screen Pobierz kolor piksela w dowolnym miejscu ekranu
+ Click to pick · Esc to cancel Kliknij, aby wybrać · Esc, aby anulować
+ Pick a screen pixel or press Esc to cancel Wybierz piksel ekranu lub naciśnij Esc, aby anulować
+ Scroll to zoom Przewijaj, aby zmieniać powiększenie
Open in camera raw Otwórz w Camera Raw
System title bar Systemowy pasek tytułu
More documents Więcej dokumentów
diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv
index 9848a2d364..33b4faf893 100644
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -2605,6 +2605,14 @@ cameraRaw Whites Brancos
Channels: Canais:
Knockout Vazado
Apply At Canvas Bounds Aplicar efeito nos limites da tela
+
+# Shared screen eyedropper and text Color Picker (#1128).
+ Color Picker (Text Color) Seletor de cores (Cor do texto)
+ Pick screen color Escolher cor da tela
+ Sample a pixel anywhere on the screen Amostrar um pixel em qualquer lugar da tela
+ Click to pick · Esc to cancel Clique para escolher · Esc para cancelar
+ Pick a screen pixel or press Esc to cancel Escolha um pixel da tela ou pressione Esc para cancelar
+ Scroll to zoom Role para ampliar
Show/Hide All Panels Mostrar/ocultar todos os painéis
Show/Hide Panels Mostrar/ocultar painéis
Could not open Não foi possível abrir
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
index 7713eb62f4..adfcb19490 100644
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -2444,6 +2444,14 @@ cameraRaw Roughness Неровность
Channels: Каналы:
Knockout Просвечивание
Apply At Canvas Bounds Применить эффект на границах холста
+
+# Shared screen eyedropper and text Color Picker (#1128).
+ Color Picker (Text Color) Палитра цветов (Цвет текста)
+ Pick screen color Выбрать цвет на экране
+ Sample a pixel anywhere on the screen Выберите пиксель в любом месте экрана
+ Click to pick · Esc to cancel Щелчок — выбрать · Esc — отменить
+ Pick a screen pixel or press Esc to cancel Выберите пиксель на экране или нажмите Esc для отмены
+ Scroll to zoom Колесо — изменить масштаб
Show/Hide All Panels Показать/скрыть все панели
Show/Hide Panels Показать/скрыть панели
Could not open Не удалось открыть
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
index aa19a3fbbf..d7a90b1442 100644
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -2175,6 +2175,14 @@ cameraRaw Blending 混合
Channels: 通道:
Knockout 挖空
Apply At Canvas Bounds 应用画布边界的效果
+
+# Shared screen eyedropper and text Color Picker (#1128).
+ Color Picker (Text Color) 拾色器(文字颜色)
+ Pick screen color 选取屏幕颜色
+ Sample a pixel anywhere on the screen 在屏幕任意位置采样像素
+ Click to pick · Esc to cancel 单击选取 · Esc 取消
+ Pick a screen pixel or press Esc to cancel 选取屏幕像素或按 Esc 取消
+ Scroll to zoom 滚动以缩放
Show/Hide All Panels 显示/隐藏所有面板
Show/Hide Panels 显示/隐藏面板
Could not open 无法打开
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
index 14ce30e309..11d7a68434 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -2144,6 +2144,14 @@ cameraRaw Blending 混合
Channels: 色版:
Knockout 去底色
Apply At Canvas Bounds 套用效果至版面邊界
+
+# Shared screen eyedropper and text Color Picker (#1128).
+ Color Picker (Text Color) 檢色器(文字顏色)
+ Pick screen color 選取螢幕顏色
+ Sample a pixel anywhere on the screen 在螢幕任意位置取樣像素
+ Click to pick · Esc to cancel 按一下選取 · Esc 取消
+ Pick a screen pixel or press Esc to cancel 選取螢幕像素或按 Esc 取消
+ Scroll to zoom 捲動以縮放
Show/Hide All Panels 顯示/隱藏所有面板
Show/Hide Panels 顯示/隱藏面板
Could not open 無法開啟
diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs
index acba9a3eb4..67b41f2847 100644
--- a/crates/ui-egui/src/layer_style.rs
+++ b/crates/ui-egui/src/layer_style.rs
@@ -912,7 +912,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map Option>;
/// I/O dependencies.
#[derive(Default)]
pub struct Services {
+ /// User-initiated desktop/browser pixel sampling.
+ pub screen_pick: Option,
/// Decode a file's bytes into a document (PSD, PNG, JPEG, …).
pub import: Option,
/// Encode a document for a file name (format chosen by extension).
@@ -1052,8 +1055,10 @@ impl eframe::App for PhotocraftApp {
discard_ui::guard_window_close(self, ctx);
// Background jobs: apply finished ones, keep frames coming, Esc cancels (before the
// shortcuts see Esc).
- jobs_ui::tick(self, ctx);
- shortcuts::handle(self, ctx);
+ if !screen_picker::tick(self, ctx) {
+ jobs_ui::tick(self, ctx);
+ shortcuts::handle(self, ctx);
+ }
let arrived: Vec<(String, Vec)> =
self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default();
for (name, bytes) in arrived {
@@ -1106,6 +1111,22 @@ impl eframe::App for PhotocraftApp {
}
let t0 = gpu_canvas::now_ms();
// View › Screen Mode › Full Screen Mode: only the image, on black (F or Esc returns).
+ screen_picker::show(&ctx);
+ if screen_picker::busy(&ctx) && screen_picker::showing(&ctx) {
+ egui::Modal::new(egui::Id::new("screen-color-wait")).show(&ctx, |ui| {
+ ui.spinner();
+ ui.label(tl!("Pick a screen pixel or press Esc to cancel"));
+ if ui.button(tl!("Cancel")).clicked() {
+ screen_picker::cancel(&ctx);
+ }
+ });
+ self.automation_input = false;
+ return;
+ }
+ if screen_picker::busy(&ctx) {
+ ui.disable();
+ ui.set_opacity(1.0);
+ }
let chrome = !self.ui.view.hides_chrome();
if !chrome && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
let _ = menus::invoke(self, &ctx, "view.screenMode.standard", serde_json::json!({}));
@@ -1155,6 +1176,11 @@ impl eframe::App for PhotocraftApp {
gpu_status::check(self, &ctx);
self.automation_input = false;
native_menu::sync(self, &ctx);
+ if screen_picker::busy(&ctx) {
+ // Block input without a backdrop: the worker may still be capturing the screen.
+ egui::Modal::new(egui::Id::new("screen-color-wait")).frame(egui::Frame::NONE).backdrop_color(egui::Color32::TRANSPARENT).show(&ctx, |_| {});
+ ctx.set_cursor_icon(egui::CursorIcon::Wait);
+ }
self.perf.frame(gpu_canvas::now_ms() - t0);
// Synthetic input is injected one press/release step per frame: keep frames coming until
// the queue is empty, then release control replies waiting on it.
diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs
index 4c2d8a245d..df3551b573 100644
--- a/crates/ui-egui/src/prefs_ui.rs
+++ b/crates/ui-egui/src/prefs_ui.rs
@@ -1014,7 +1014,7 @@ fn section_fields(ui: &mut egui::Ui, section: &str, obj: &mut Map
let c = prefs::parse_hex(s).unwrap_or([128, 128, 128]);
let mut rgb = c;
ui.horizontal(|ui| {
- ui.color_edit_button_srgb(&mut rgb);
+ crate::widgets::color_edit_button_srgb(ui, &mut rgb);
hex_field(ui, &path, &mut rgb);
});
obj.insert(k, json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
diff --git a/crates/ui-egui/src/screen_picker.rs b/crates/ui-egui/src/screen_picker.rs
new file mode 100644
index 0000000000..2915d53ff6
--- /dev/null
+++ b/crates/ui-egui/src/screen_picker.rs
@@ -0,0 +1,466 @@
+//! Explicit, one-shot desktop sampling. Snapshots remain in memory until selection/cancellation.
+use crate::PhotocraftApp;
+use egui::{Color32, Context, Id, Pos2, Rect, Sense, Stroke, StrokeKind, TextureOptions, vec2};
+use std::sync::{
+ Arc, Mutex,
+ mpsc::{Receiver, TryRecvError},
+};
+
+/// Display-encoded RGBA8 pixels, native window points (before UI zoom), and optional source ICC.
+#[derive(Clone)]
+pub struct ScreenImage {
+ /// Native monitor index (winit order); places the picker on that exact output.
+ pub monitor: Option,
+ pub position: Pos2,
+ pub size: egui::Vec2,
+ pub width: usize,
+ pub height: usize,
+ pub rgba: Vec,
+ pub profile: Option>,
+}
+pub enum Capture {
+ Images(Vec),
+ /// Native portal/browser pickers return sRGB; None means cancellation.
+ Color(Option<[f32; 3]>),
+}
+/// Dropping a pending request also cancels portal/browser work.
+pub struct Pending {
+ pub receiver: Receiver>,
+ pub cancelled: Arc,
+}
+impl Drop for Pending {
+ fn drop(&mut self) {
+ self.cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
+ }
+}
+pub type Service = Box Pending>;
+pub const MAX_PIXELS: usize = 64 * 1024 * 1024;
+impl ScreenImage {
+ pub fn validate(&self) -> Result<(), String> {
+ let count = self.width.checked_mul(self.height).filter(|&n| n > 0 && n <= MAX_PIXELS).ok_or("Screen image is too large or empty")?;
+ if count.checked_mul(4) != Some(self.rgba.len())
+ || !self.position.is_finite()
+ || !self.size.is_finite()
+ || self.size.min_elem() <= 0.0
+ || self.size.max_elem() > 65535.0
+ || self.position.to_vec2().abs().max_elem() > 1_000_000.0
+ || self.profile.as_ref().is_some_and(|p| p.len() > 1024 * 1024)
+ {
+ return Err("Invalid screen image geometry".into());
+ }
+ Ok(())
+ }
+ fn pixel(&self, x: usize, y: usize) -> Option {
+ if x >= self.width || y >= self.height {
+ return None;
+ }
+ let start = y.checked_mul(self.width)?.checked_add(x)?.checked_mul(4)?;
+ let p = self.rgba.get(start..start.checked_add(4)?)?;
+ Some(Color32::from_rgb(*p.first()?, *p.get(1)?, *p.get(2)?))
+ }
+ fn at(&self, rect: Rect, pos: Pos2) -> Option<(usize, usize)> {
+ if !rect.contains(pos) || !pos.is_finite() || rect.size().min_elem() <= 0.0 {
+ return None;
+ }
+ let p = (pos - rect.min) / rect.size();
+ Some((((p.x * self.width as f32) as usize).min(self.width.saturating_sub(1)), ((p.y * self.height as f32) as usize).min(self.height.saturating_sub(1))))
+ }
+ fn conversion(&self) -> Result