From 4b50fdba1ce2caa2f34729a5ca0189f707a4baef Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 07:27:30 -0800 Subject: [PATCH] View: the canvas is drawn through the monitor profile on Windows and X11 Preferences > Performance > Use Monitor Profile for Canvas (on by default, `monitorProfileDisplay`) converts the canvas's art, artboard paper and transparency grid from sRGB into the ICC profile of the display that shows most of the main window, after any proof. Arranged panes and the render worker's frames are converted too; the worker keeps its frame in sRGB. - Windows: the display's default profile for its standard color mode (ColorProfileGetDisplayDefault, GetICMProfileW where it is missing). A display with Advanced Color on is left to Windows. - X11: colord's profile for the RandR output, then the output's `_ICC_PROFILE`, then the screen's when only one output is active. - A Wayland session stays sRGB; macOS converts the window itself. - A profile that is missing, larger than 1 MiB, not an RGB display profile or unreadable leaves the canvas sRGB, with the reason in `ui.inspect`'s `displayProfile`. The conversion is sampled into a 33x33x33 table when a profile is read, on a background thread. Exports, `ui.render` and the document are unchanged. --- apps/vectorcraft/Cargo.toml | 8 +- apps/vectorcraft/src/app.rs | 3 + apps/vectorcraft/src/display_profile.rs | 629 +++++++++++++++++++++ apps/vectorcraft/src/main.rs | 4 + crates/color/src/cms.rs | 4 +- crates/color/src/cms/display.rs | 327 +++++++++++ crates/engine/src/cmd/prefscmds.rs | 1 + crates/engine/src/prefs.rs | 5 + crates/engine/src/tests/prefs.rs | 28 + crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/caches.rs | 3 + crates/ui-egui/src/canvas.rs | 22 +- crates/ui-egui/src/canvas/fill.rs | 2 + crates/ui-egui/src/canvas/grid.rs | 17 +- crates/ui-egui/src/canvas/panes.rs | 32 +- crates/ui-egui/src/canvas/raster.rs | 4 +- crates/ui-egui/src/canvas/tests.rs | 48 ++ crates/ui-egui/src/control.rs | 3 + crates/ui-egui/src/dialogs/save_for_web.rs | 2 +- crates/ui-egui/src/display_profile.rs | 311 ++++++++++ crates/ui-egui/src/i18n/bg.tsv | 5 + crates/ui-egui/src/i18n/bs.tsv | 5 + crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/da.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/id.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/nl.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 5 + crates/ui-egui/src/i18n/tests.rs | 16 + crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/prefs_dialog.rs | 10 + crates/ui-egui/src/render_worker.rs | 3 + docs/hardware-parity.md | 2 +- docs/mcp.md | 15 + docs/target-app-parity.md | 1 + 54 files changed, 1617 insertions(+), 23 deletions(-) create mode 100644 apps/vectorcraft/src/display_profile.rs create mode 100644 crates/color/src/cms/display.rs create mode 100644 crates/ui-egui/src/display_profile.rs diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 55b9e344e7..7bdfece60c 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -58,7 +58,10 @@ pollster = "1" # cores the process may run on. Win32_UI_Input_KeyboardAndMouse has `GetKeyState`, which # src/caps_lock.rs uses to read whether Caps Lock is on. Win32_System_Registry has `RegGetValueW`, # which src/host_services.rs uses to read the path PhotoCraft registers under App Paths. -windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } +# Win32_Devices_Display has `QueryDisplayConfig`, Win32_UI_ColorSystem the display's ICC profile and +# Win32_System_LibraryLoader `GetProcAddress`, which src/display_profile.rs uses to read the +# display's color profile for the canvas. +windows = { version = "0.62", features = ["Win32_Devices_Display", "Win32_Foundation", "Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_ColorSystem", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } # The renderer's worker count follows those cores (src/windows_cores.rs). vectorcraft-render = { workspace = true } @@ -93,7 +96,8 @@ winit = { version = "0.30", default-features = false, features = ["wayland-csd-a # A drawing tablet's pen pressure under X11 and XWayland (src/tablet.rs): XInput2 raw events on a # second X connection, x11rb's pure-Rust `RustConnection`. MIT OR Apache-2.0, already in the build # through winit with the `xinput` feature. XKB reads Caps Lock's locked modifier state (src/caps_lock.rs). -x11rb = { version = "0.13", default-features = false, features = ["xinput", "xkb"] } +# RandR reads a display's `_ICC_PROFILE` for the canvas (src/display_profile.rs). +x11rb = { version = "0.13", default-features = false, features = ["randr", "xinput", "xkb"] } # Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs), # so Automatic draws on that one. Both are already in the build through winit. diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 0391b7534b..b753427d8b 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -192,6 +192,9 @@ impl App { self.app.services.dock_monitors = window::dock_monitors(w, zoom); self.monitors_listed = Some((now, zoom)); } + if let Some(w) = &self.winit_window { + self.display_profile.tick(w, now, self.app.session.prefs.monitor_profile_display, &self.app.display_profile, ctx); + } self.app.tick(ctx); if self.pending_maximize { if let Some(w) = &self.winit_window { diff --git a/apps/vectorcraft/src/display_profile.rs b/apps/vectorcraft/src/display_profile.rs new file mode 100644 index 0000000000..2be6e27aa5 --- /dev/null +++ b/apps/vectorcraft/src/display_profile.rs @@ -0,0 +1,629 @@ +//! Reads the ICC profile of the display that shows most of the main window, for Preferences › +//! Performance › Use Monitor Profile for Canvas, and hands it to [`DisplayProfile::report`]. +//! +//! - Windows: the display's default profile for its standard color mode. A display with Advanced +//! Color on (HDR or Automatic Color Management) is converted by Windows, and nothing is read. +//! - X11: colord's profile for the RandR output, then the output's `_ICC_PROFILE` property, then +//! the screen's `_ICC_PROFILE` when only one output is active. +//! - Wayland, XWayland included: the compositor gives applications no display profile, and the +//! canvas stays sRGB. +//! - macOS: the window's layer is tagged sRGB and macOS converts it (`mac_color_space`). +//! +//! While the preference is on, a background thread reads the display every [`EVERY`] seconds, +//! and at once when the window moves to another display. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use vectorcraft_ui_egui::display_profile::{DisplayProfile, Found, Reading}; + +/// Seconds between two readings of the same display. +const EVERY: f64 = 10.0; +/// Seconds between two checks of which display shows the window. +const LOOK_EVERY: f64 = 1.0; + +/// Starts the readings from the main window's frames. +#[derive(Default)] +pub(crate) struct Reader { + /// When the last reading started, and the display it was for. + last: Option<(f64, Option)>, + /// When the window's display was last looked up. + looked: Option, + /// A reading is running. + busy: Arc, +} + +/// Clears [`Reader::busy`] when the reading thread ends, also by a panic. +struct Done(Arc); + +impl Drop for Done { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +impl Reader { + /// Starts a reading when one is due and none is running. `now` is the frame's time in seconds. + pub(crate) fn tick(&mut self, window: &winit::window::Window, now: f64, enabled: bool, profile: &DisplayProfile, ctx: &egui::Context) { + if !enabled || self.busy.load(Ordering::Acquire) || self.looked.is_some_and(|at| (at..at + LOOK_EVERY).contains(&now)) { + return; + } + self.looked = Some(now); + let display = window.current_monitor().and_then(|m| m.name()); + if !due(self.last.as_ref(), now, display.as_deref()) { + return; + } + self.last = Some((now, display.clone())); + profile.pending(); + self.busy.store(true, Ordering::Release); + let done = Done(self.busy.clone()); + let (profile, ctx) = (profile.clone(), ctx.clone()); + let spawned = std::thread::Builder::new().name("display profile".into()).spawn(move || { + let _done = done; + if profile.report(read(display)) { + ctx.request_repaint(); + } + }); + if let Err(e) = spawned { + log::warn!("couldn't start reading the display profile: {e}"); + } + } +} + +/// Whether a reading of `display` is due at `now`, after the last one (`last`): none yet, another +/// display, [`EVERY`] seconds later, or a clock that went back. +fn due(last: Option<&(f64, Option)>, now: f64, display: Option<&str>) -> bool { + last.is_none_or(|(at, shown)| shown.as_deref() != display || !(*at..at + EVERY).contains(&now)) +} + +#[cfg(windows)] +fn read(display: Option) -> Reading { + let found = win::read(display.as_deref()); + Reading { display, found } +} + +#[cfg(target_os = "linux")] +fn read(display: Option) -> Reading { + let found = if wayland_session(|k| std::env::var_os(k)) { + Found::NoProfile("a Wayland session doesn't give applications the display's profile".into()) + } else { + x11::read(display.as_deref()) + }; + Reading { display, found } +} + +#[cfg(all(target_os = "macos", feature = "wgpu"))] +fn read(display: Option) -> Reading { + Reading { display, found: Found::System("macOS converts the window to the display's profile".into()) } +} + +#[cfg(not(any(windows, target_os = "linux", all(target_os = "macos", feature = "wgpu"))))] +fn read(display: Option) -> Reading { + Reading { display, found: Found::NoProfile("this build doesn't read display profiles on this system".into()) } +} + +/// Whether the app runs in a Wayland session, read from the environment `var` gives. XWayland +/// windows count: the variables stay set for them. +#[cfg(any(target_os = "linux", test))] +fn wayland_session(var: impl Fn(&str) -> Option) -> bool { + ["WAYLAND_DISPLAY", "WAYLAND_SOCKET"].iter().any(|k| var(k).is_some_and(|v| !v.is_empty())) +} + +/// The X11 reading from colord's answer `colord` (the profile file it names, read) and the X11 +/// properties `properties`: colord's profile when its file can be read, otherwise the +/// properties' profile. When neither has one, the reason colord gave is kept. +#[cfg(any(target_os = "linux", test))] +fn x11_choice(colord: Option, String>>, properties: impl FnOnce() -> Result, String>) -> Found { + let colord_reason = match colord { + Some(Ok(Some(found @ Found::Profile { .. }))) => return found, + Some(Ok(Some(Found::NoProfile(e) | Found::System(e)))) | Some(Err(e)) => Some(e), + Some(Ok(None)) | None => None, + }; + match properties() { + Ok(Some(found)) => found, + Ok(None) => Found::NoProfile(match colord_reason { + Some(e) => format!("no profile is set for this display ({e})"), + None => "no profile is set for this display".into(), + }), + Err(e) => Found::NoProfile(e), + } +} + +/// `found` when it is not a profile or the profile can be used, otherwise why it can't, so the +/// next source is tried. +#[cfg(any(target_os = "linux", test))] +fn usable(found: Found) -> Found { + match found { + Found::Profile { icc, from } => match vectorcraft_ui_egui::display_profile::check_profile(&icc) { + Ok(()) => Found::Profile { icc, from }, + Err(e) => Found::NoProfile(format!("{from}: {e}")), + }, + other => other, + } +} + +/// The ICC file at `path`, refused before it is read when it is larger than [`MAX_PROFILE_BYTES`]. +/// +/// [`MAX_PROFILE_BYTES`]: vectorcraft_ui_egui::display_profile::MAX_PROFILE_BYTES +#[cfg(any(windows, target_os = "linux", test))] +fn read_icc(path: &std::path::Path) -> Result, String> { + use std::io::Read as _; + let max = vectorcraft_ui_egui::display_profile::MAX_PROFILE_BYTES; + let too_large = || format!("{} is larger than {} MiB", path.display(), max >> 20); + let file = std::fs::File::open(path).map_err(|e| format!("couldn't open {}: {e}", path.display()))?; + let len = file.metadata().map_err(|e| format!("couldn't read {}: {e}", path.display()))?.len(); + if len > max as u64 { + return Err(too_large()); + } + let mut bytes = Vec::with_capacity(len as usize); + file.take(max as u64 + 1).read_to_end(&mut bytes).map_err(|e| format!("couldn't read {}: {e}", path.display()))?; + if bytes.len() > max { Err(too_large()) } else { Ok(bytes) } +} + +/// The file a profile `name` stands for: `name` itself when it is a path, otherwise the file of +/// that name in the system's color folder `dir`. +#[cfg(any(windows, test))] +fn profile_path(dir: Option<&std::path::Path>, name: &str) -> std::path::PathBuf { + match dir { + Some(dir) if !name.contains(['\\', '/']) => dir.join(name), + _ => std::path::PathBuf::from(name), + } +} + +/// `path`'s profile, or why there is none. +#[cfg(any(windows, target_os = "linux"))] +fn profile_file(path: &std::path::Path) -> Found { + let from = path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()); + match read_icc(path) { + Ok(icc) => Found::Profile { icc, from }, + Err(e) => Found::NoProfile(e), + } +} + +#[cfg(windows)] +#[allow(unsafe_code)] +mod win { + use std::path::PathBuf; + use std::sync::OnceLock; + + use vectorcraft_ui_egui::display_profile::Found; + use windows::Win32::Devices::Display::{ + DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_HEADER, + DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_MODE_INFO, DISPLAYCONFIG_PATH_INFO, DISPLAYCONFIG_SOURCE_DEVICE_NAME, + DisplayConfigGetDeviceInfo, GetDisplayConfigBufferSizes, QDC_ONLY_ACTIVE_PATHS, QueryDisplayConfig, + }; + use windows::Win32::Foundation::{ERROR_INSUFFICIENT_BUFFER, ERROR_SUCCESS, HLOCAL, LUID, LocalFree}; + use windows::Win32::Graphics::Gdi::{CreateDCW, DeleteDC}; + use windows::Win32::System::LibraryLoader::{GetProcAddress, LOAD_LIBRARY_SEARCH_SYSTEM32, LoadLibraryExW}; + use windows::Win32::UI::ColorSystem::{ + CPST_STANDARD_DISPLAY_COLOR_MODE, CPT_ICC, GetColorDirectoryW, GetICMProfileW, WCS_PROFILE_MANAGEMENT_SCOPE_CURRENT_USER, + }; + use windows::core::{HRESULT, PCWSTR, PWSTR, s, w}; + + /// `ColorProfileGetDisplayDefault` and `ColorProfileGetDisplayUserScope` (mscms.dll), which + /// older Windows versions don't have. + type GetDefault = unsafe extern "system" fn(i32, LUID, u32, i32, i32, *mut PWSTR) -> HRESULT; + type GetScope = unsafe extern "system" fn(LUID, u32, *mut i32) -> HRESULT; + + /// The display path that shows a GDI device. + struct Path { + source: u32, + target_adapter: LUID, + target: u32, + } + + pub(super) fn read(display: Option<&str>) -> Found { + let Some(device) = display else { return Found::NoProfile("Windows didn't name the window's display".into()) }; + let path = match find_path(device) { + Ok(path) => path, + Err(e) => return Found::NoProfile(e), + }; + if path.as_ref().is_some_and(advanced_color) { + return Found::System("Windows manages this display's colors (Advanced Color)".into()); + } + let name = match path.as_ref().and_then(default_profile) { + Some(Ok(name)) => name, + Some(Err(e)) => return Found::NoProfile(e), + // Older Windows, or a display without an active path: GDI's profile for the device. + None => match icm_profile(device) { + Ok(name) => name, + Err(e) => return Found::NoProfile(e), + }, + }; + match name { + Some(name) => super::profile_file(&super::profile_path(color_directory().as_deref(), &name)), + None => Found::NoProfile("no color profile is set for this display".into()), + } + } + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain([0]).collect() + } + + fn until_nul(w: &[u16]) -> String { + String::from_utf16_lossy(&w[..w.iter().position(|&c| c == 0).unwrap_or(w.len())]) + } + + /// The active display paths. + fn paths() -> Result, String> { + for _ in 0..4 { + let (mut paths_len, mut modes_len) = (0u32, 0u32); + // SAFETY: both pointers are to live `u32`s. + let e = unsafe { GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut paths_len, &mut modes_len) }; + if e != ERROR_SUCCESS { + return Err(format!("Windows couldn't list the displays (error {})", e.0)); + } + let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); paths_len as usize]; + let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); modes_len as usize]; + // SAFETY: the arrays hold as many elements as the lengths passed with them. + let e = + unsafe { QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, &mut paths_len, paths.as_mut_ptr(), &mut modes_len, modes.as_mut_ptr(), None) }; + if e == ERROR_INSUFFICIENT_BUFFER { + // The displays changed between the two calls. + continue; + } + if e != ERROR_SUCCESS { + return Err(format!("Windows couldn't list the displays (error {})", e.0)); + } + paths.truncate(paths_len as usize); + return Ok(paths); + } + Err("the displays kept changing while Windows listed them".into()) + } + + /// The path whose source is the GDI device `device` (`\\.\DISPLAY1`), if one is active. + fn find_path(device: &str) -> Result, String> { + for p in paths()? { + let mut name = DISPLAYCONFIG_SOURCE_DEVICE_NAME { + header: DISPLAYCONFIG_DEVICE_INFO_HEADER { + r#type: DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, + size: size_of::() as u32, + adapterId: p.sourceInfo.adapterId, + id: p.sourceInfo.id, + }, + ..Default::default() + }; + // SAFETY: the header starts a `DISPLAYCONFIG_SOURCE_DEVICE_NAME` of the size it states. + if unsafe { DisplayConfigGetDeviceInfo(&mut name.header) } == 0 && until_nul(&name.viewGdiDeviceName).eq_ignore_ascii_case(device) { + return Ok(Some(Path { source: p.sourceInfo.id, target_adapter: p.targetInfo.adapterId, target: p.targetInfo.id })); + } + } + Ok(None) + } + + /// Whether Windows color-manages the path's display itself: Advanced Color is on (HDR), or + /// wide color is enforced (Automatic Color Management on an SDR display). + fn advanced_color(path: &Path) -> bool { + let mut info = DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO { + header: DISPLAYCONFIG_DEVICE_INFO_HEADER { + r#type: DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO, + size: size_of::() as u32, + adapterId: path.target_adapter, + id: path.target, + }, + ..Default::default() + }; + // SAFETY: the header starts a `DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO` of the size it states. + if unsafe { DisplayConfigGetDeviceInfo(&mut info.header) } != 0 { + return false; + } + // SAFETY: every bit pattern is a valid `u32`. + let bits = unsafe { info.Anonymous.value }; + // advancedColorEnabled is bit 1 and wideColorEnforced bit 2. + bits & 0b110 != 0 + } + + fn api() -> Option<(GetDefault, GetScope)> { + static API: OnceLock> = OnceLock::new(); + *API.get_or_init(|| { + // SAFETY: mscms.dll is loaded from System32 only, and stays loaded for the process. + let module = unsafe { LoadLibraryExW(w!("mscms.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32) }.ok()?; + // SAFETY: the names are NUL-terminated, and the module is loaded. + let (default, scope) = unsafe { + (GetProcAddress(module, s!("ColorProfileGetDisplayDefault"))?, GetProcAddress(module, s!("ColorProfileGetDisplayUserScope"))?) + }; + // SAFETY: both exports have these signatures (icm.h). + unsafe { + Some(( + std::mem::transmute:: isize, GetDefault>(default), + std::mem::transmute:: isize, GetScope>(scope), + )) + } + }) + } + + /// The display's default profile name for its standard color mode, `None` without the API. + fn default_profile(path: &Path) -> Option, String>> { + let (get_default, get_scope) = api()?; + let mut scope = WCS_PROFILE_MANAGEMENT_SCOPE_CURRENT_USER.0; + // SAFETY: `scope` is a live `i32`. + if unsafe { get_scope(path.target_adapter, path.source, &mut scope) }.is_err() { + scope = WCS_PROFILE_MANAGEMENT_SCOPE_CURRENT_USER.0; + } + let mut name = PWSTR::null(); + // SAFETY: `name` is a live pointer the function fills with a string it allocates. + let hr = unsafe { get_default(scope, path.target_adapter, path.source, CPT_ICC.0, CPST_STANDARD_DISPLAY_COLOR_MODE.0, &mut name) }; + if hr.is_err() { + return Some(Err(format!("Windows couldn't give the display's profile (error {:#010x})", hr.0))); + } + if name.is_null() { + return Some(Ok(None)); + } + // SAFETY: `name` is a NUL-terminated string from the call above, freed once with LocalFree. + let text = unsafe { name.to_string() }; + // SAFETY: as above. + let _ = unsafe { LocalFree(Some(HLOCAL(name.0.cast()))) }; + Some(text.map(|t| Some(t).filter(|t| !t.is_empty())).map_err(|e| format!("the display's profile name isn't readable: {e}"))) + } + + /// GDI's profile file for the display device `device`. + fn icm_profile(device: &str) -> Result, String> { + let device = wide(device); + // SAFETY: both strings are NUL-terminated and outlive the call. + let dc = unsafe { CreateDCW(w!("DISPLAY"), PCWSTR(device.as_ptr()), PCWSTR::null(), None) }; + if dc.is_invalid() { + return Err("Windows couldn't open the display".into()); + } + let mut len = 0u32; + // SAFETY: a null buffer asks for the length only. + let _ = unsafe { GetICMProfileW(dc, &mut len, None) }; + let mut buf = vec![0u16; len.clamp(1, 32 * 1024) as usize]; + len = buf.len() as u32; + // SAFETY: `buf` holds `len` characters. + let ok = unsafe { GetICMProfileW(dc, &mut len, Some(PWSTR(buf.as_mut_ptr()))) }.as_bool(); + // SAFETY: `dc` came from CreateDCW above. + let _ = unsafe { DeleteDC(dc) }; + Ok(ok.then(|| until_nul(&buf)).filter(|n| !n.is_empty())) + } + + /// The system's color folder (`…\spool\drivers\color`). + fn color_directory() -> Option { + let mut bytes = 0u32; + // SAFETY: a null buffer asks for the size only, in bytes. + let _ = unsafe { GetColorDirectoryW(PCWSTR::null(), None, &mut bytes) }; + let mut buf = vec![0u16; (bytes as usize / 2).clamp(1, 32 * 1024)]; + bytes = (buf.len() * 2) as u32; + // SAFETY: `buf` holds `bytes` bytes. + unsafe { GetColorDirectoryW(PCWSTR::null(), Some(PWSTR(buf.as_mut_ptr())), &mut bytes) }.as_bool().then(|| PathBuf::from(until_nul(&buf))) + } + + #[cfg(test)] + mod tests { + use windows::Win32::Graphics::Gdi::{DISPLAY_DEVICE_ATTACHED_TO_DESKTOP, DISPLAY_DEVICEW, EnumDisplayDevicesW}; + + use super::*; + + /// The GDI names (`\\.\DISPLAY1`) of the displays attached to the desktop. + fn attached_displays() -> Vec { + let mut names = Vec::new(); + for i in 0.. { + let mut device = DISPLAY_DEVICEW { cb: size_of::() as u32, ..Default::default() }; + // SAFETY: `device` is a live `DISPLAY_DEVICEW` with `cb` set to its size. + if !unsafe { EnumDisplayDevicesW(PCWSTR::null(), i, &mut device, 0) }.as_bool() { + break; + } + if device.StateFlags.contains(DISPLAY_DEVICE_ATTACHED_TO_DESKTOP) { + names.push(until_nul(&device.DeviceName)); + } + } + names + } + + #[test] + fn windows_reads_each_attached_display_and_refuses_an_unknown_one() { + assert_eq!(read(None), Found::NoProfile("Windows didn't name the window's display".into())); + assert!(color_directory().is_none_or(|dir| dir.is_absolute())); + match find_path(r"\\.\DISPLAY999") { + Ok(path) => { + assert!(path.is_none()); + assert_eq!(read(Some(r"\\.\DISPLAY999")), Found::NoProfile("Windows couldn't open the display".into())); + } + // A session without displays (a service): the reading gives Windows' error. + Err(e) => { + eprintln!("the displays couldn't be listed: {e}"); + assert_eq!(read(Some(r"\\.\DISPLAY999")), Found::NoProfile(e)); + return; + } + } + for name in attached_displays() { + // A display without an active path is read through GDI (`icm_profile`). + let path = find_path(&name).unwrap(); + // winit names the display in GDI's spelling; the match ignores letter case. + let lower = find_path(&name.to_lowercase()).unwrap(); + assert_eq!(lower.map(|p| (p.source, p.target)), path.as_ref().map(|p| (p.source, p.target)), "{name} in lowercase"); + match read(Some(&name)) { + Found::Profile { icc, from } => { + eprintln!("{name}: profile {from}, {} bytes", icc.len()); + assert!(!icc.is_empty() && !from.is_empty()); + assert!(!path.as_ref().is_some_and(advanced_color)); + } + Found::System(e) => { + eprintln!("{name}: system, {e}"); + assert!(path.as_ref().is_some_and(advanced_color)); + } + Found::NoProfile(e) => { + eprintln!("{name}: no profile, {e}"); + assert!(!e.is_empty()); + } + } + } + } + } +} + +#[cfg(target_os = "linux")] +mod x11 { + use std::collections::HashMap; + use std::path::Path; + use std::time::Duration; + + use vectorcraft_ui_egui::display_profile::{Found, MAX_PROFILE_BYTES}; + use x11rb::connection::Connection as _; + use x11rb::protocol::randr; + use x11rb::protocol::xproto::{self, AtomEnum}; + use zbus::blocking::{connection::Builder, proxy::Proxy}; + use zbus::zvariant::OwnedObjectPath; + + const COLORD: &str = "org.freedesktop.ColorManager"; + const COLORD_ROOT: &str = "/org/freedesktop/ColorManager"; + const COLORD_MANAGER: &str = "org.freedesktop.ColorManager"; + const COLORD_DEVICE: &str = "org.freedesktop.ColorManager.Device"; + const COLORD_PROFILE: &str = "org.freedesktop.ColorManager.Profile"; + + pub(super) fn read(output: Option<&str>) -> Found { + let colord = output.map(|o| colord_profile(o).map(|path| path.map(|path| super::usable(super::profile_file(Path::new(&path)))))); + super::x11_choice(colord, || properties(output)) + } + + /// The file colord assigns the RandR output `output`. + fn colord_profile(output: &str) -> Result, String> { + let connection = Builder::system() + .map_err(|e| format!("couldn't connect to the system bus: {e}"))? + .method_timeout(Duration::from_secs(2)) + .build() + .map_err(|e| format!("couldn't connect to the system bus: {e}"))?; + let manager = Proxy::new(&connection, COLORD, COLORD_ROOT, COLORD_MANAGER).map_err(|e| format!("couldn't reach colord: {e}"))?; + let devices: Vec = + manager.call("GetDevicesByKind", &("display",)).map_err(|e| format!("colord couldn't list the displays: {e}"))?; + for path in devices { + let Ok(device) = Proxy::new(&connection, COLORD, &path, COLORD_DEVICE) else { continue }; + let Ok(metadata) = device.get_property::>("Metadata") else { continue }; + if metadata.get("XRANDR_name").map(String::as_str) != Some(output) { + continue; + } + // `*` asks for the device's default profile. + let Ok(profile_path) = device.call::<_, _, OwnedObjectPath>("GetProfileForQualifiers", &(vec!["*"],)) else { return Ok(None) }; + let profile = + Proxy::new(&connection, COLORD, &profile_path, COLORD_PROFILE).map_err(|e| format!("couldn't reach colord's profile: {e}"))?; + return Ok(profile.get_property::("Filename").ok().filter(|f| !f.is_empty())); + } + Ok(None) + } + + /// The `_ICC_PROFILE` property of the RandR output `output`, or of the screen when only one + /// output is active. + fn properties(output: Option<&str>) -> Result, String> { + let (conn, screen) = x11rb::connect(None).map_err(|e| format!("couldn't connect to the X11 display: {e}"))?; + let root = conn.setup().roots.get(screen).ok_or_else(|| "the X11 display has no screen".to_string())?.root; + let atom = xproto::intern_atom(&conn, true, b"_ICC_PROFILE") + .map_err(|e| format!("couldn't ask the X11 display for profiles: {e}"))? + .reply() + .map_err(|e| format!("couldn't ask the X11 display for profiles: {e}"))? + .atom; + if atom == 0 { + return Ok(None); + } + let resources = randr::get_screen_resources_current(&conn, root) + .map_err(|e| format!("couldn't list the X11 outputs: {e}"))? + .reply() + .map_err(|e| format!("couldn't list the X11 outputs: {e}"))?; + let (mut active, mut shown) = (0usize, None); + for o in resources.outputs { + let Ok(Ok(info)) = randr::get_output_info(&conn, o, resources.config_timestamp).map(|c| c.reply()) else { continue }; + if info.connection != randr::Connection::CONNECTED || info.crtc == 0 { + continue; + } + active += 1; + if output.is_some_and(|name| info.name == name.as_bytes()) { + shown = Some(o); + } + } + let longs = (MAX_PROFILE_BYTES / 4) as u32; + let too_large = || Found::NoProfile(format!("the display's profile is larger than {} MiB", MAX_PROFILE_BYTES >> 20)); + if let Some(o) = shown + && let Ok(Ok(p)) = randr::get_output_property(&conn, o, atom, AtomEnum::ANY, 0, longs, false, false).map(|c| c.reply()) + && !p.data.is_empty() + { + if p.bytes_after > 0 { + return Ok(Some(too_large())); + } + return Ok(Some(Found::Profile { icc: p.data, from: "the X11 output's _ICC_PROFILE".into() })); + } + if active != 1 { + return Ok(None); + } + let Ok(Ok(p)) = xproto::get_property(&conn, false, root, atom, AtomEnum::ANY, 0, longs).map(|c| c.reply()) else { return Ok(None) }; + if p.value.is_empty() { + return Ok(None); + } + if p.bytes_after > 0 { + return Ok(Some(too_large())); + } + Ok(Some(Found::Profile { icc: p.value, from: "the X11 screen's _ICC_PROFILE".into() })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_reading_is_due_first_after_ten_seconds_and_when_the_window_changes_display() { + assert!(due(None, 0.0, None)); + let last = (100.0, Some("DP-1".to_string())); + assert!(!due(Some(&last), 100.0, Some("DP-1"))); + assert!(!due(Some(&last), 109.9, Some("DP-1"))); + assert!(due(Some(&last), 110.0, Some("DP-1"))); + assert!(due(Some(&last), 101.0, Some("HDMI-1"))); + assert!(due(Some(&last), 101.0, None)); + // A clock that went back. + assert!(due(Some(&last), 50.0, Some("DP-1"))); + } + + #[test] + fn wayland_sessions_are_told_apart_from_x11_by_the_environment() { + let env = |pairs: &'static [(&'static str, &'static str)]| { + move |k: &str| pairs.iter().find(|(n, _)| *n == k).map(|(_, v)| std::ffi::OsString::from(v)) + }; + assert!(wayland_session(env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]))); + assert!(wayland_session(env(&[("WAYLAND_SOCKET", "5")]))); + assert!(!wayland_session(env(&[("DISPLAY", ":0")]))); + assert!(!wayland_session(env(&[("WAYLAND_DISPLAY", ""), ("DISPLAY", ":0")]))); + } + + #[test] + fn profile_files_larger_than_the_limit_are_refused_before_they_are_read() { + let dir = tempfile::tempdir().unwrap(); + let small = dir.path().join("small.icc"); + std::fs::write(&small, [1u8, 2, 3]).unwrap(); + assert_eq!(read_icc(&small).unwrap(), [1, 2, 3]); + let big = dir.path().join("big.icc"); + let f = std::fs::File::create(&big).unwrap(); + f.set_len(vectorcraft_ui_egui::display_profile::MAX_PROFILE_BYTES as u64 + 1).unwrap(); + assert!(read_icc(&big).unwrap_err().contains("larger than 1 MiB")); + assert!(read_icc(&dir.path().join("missing.icc")).unwrap_err().starts_with("couldn't open")); + } + + #[test] + fn x11_takes_the_output_property_when_colord_s_file_cannot_be_read() { + let icc = |b: u8| Found::Profile { icc: vec![b], from: "test".into() }; + let property = || Ok(Some(icc(2))); + // colord's profile, read: the property is not asked for. + assert_eq!(x11_choice(Some(Ok(Some(icc(1)))), || panic!("not asked")), icc(1)); + // colord names a file that can't be read, or has no profile, or isn't running. + assert_eq!(x11_choice(Some(Ok(Some(Found::NoProfile("couldn't open a.icc".into())))), property), icc(2)); + assert_eq!(x11_choice(Some(Ok(None)), property), icc(2)); + assert_eq!(x11_choice(Some(Err("no colord".into())), property), icc(2)); + assert_eq!(x11_choice(None, property), icc(2)); + // Neither has a profile: colord's reason is kept. + assert_eq!( + x11_choice(Some(Ok(Some(Found::NoProfile("couldn't open a.icc".into())))), || Ok(None)), + Found::NoProfile("no profile is set for this display (couldn't open a.icc)".into()) + ); + assert_eq!(x11_choice(None, || Ok(None)), Found::NoProfile("no profile is set for this display".into())); + assert_eq!(x11_choice(None, || Err("no X11".into())), Found::NoProfile("no X11".into())); + // colord's file holds no usable profile: the property is used. + let broken = usable(Found::Profile { icc: vec![1, 2, 3], from: "a.icc".into() }); + assert_eq!(broken, Found::NoProfile("a.icc: the file is not an ICC profile".into())); + assert_eq!(x11_choice(Some(Ok(Some(broken))), property), icc(2)); + assert_eq!(usable(Found::NoProfile("none".into())), Found::NoProfile("none".into())); + } + + #[test] + fn a_profile_name_is_found_in_the_color_folder_and_a_path_is_kept() { + let dir = std::path::Path::new("C:/Windows/System32/spool/drivers/color"); + assert_eq!(profile_path(Some(dir), "Display.icm"), dir.join("Display.icm")); + assert_eq!(profile_path(Some(dir), "D:\\profiles\\Display.icm"), std::path::PathBuf::from("D:\\profiles\\Display.icm")); + assert_eq!(profile_path(None, "Display.icm"), std::path::PathBuf::from("Display.icm")); + } +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index c2b40e37e6..c10b72da44 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -28,6 +28,7 @@ mod clipboard; mod compositor_gpu; mod control_server; mod cursor_theme; +mod display_profile; #[cfg(feature = "wgpu")] mod gpu; mod hang_watch; @@ -126,6 +127,8 @@ struct App { held_files: Vec, /// The time and UI zoom of the last monitor list ([`window::monitors_due`]). monitors_listed: Option<(f64, f32)>, + /// Reads the profile of the display that shows the main window, for the canvas. + display_profile: display_profile::Reader, /// When this frame's input hook started, for the hang report's frame times (`hang_watch`). frame_start: Option, } @@ -629,6 +632,7 @@ fn main() -> std::process::ExitCode { maximize_tries: 8, held_files, monitors_listed: None, + display_profile: Default::default(), frame_start: None, })) }), diff --git a/crates/color/src/cms.rs b/crates/color/src/cms.rs index 46dcdb52ba..74ac2d50d5 100644 --- a/crates/color/src/cms.rs +++ b/crates/color/src/cms.rs @@ -2,7 +2,8 @@ //! gamut checks and soft-proof transforms. //! //! * **RGB working spaces** are ICC matrix/shaper profiles via [`moxcms`] (sRGB, Wide Gamut RGB -//! (1998 primaries), Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB. +//! (1998 primaries), Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB; +//! the desktop canvas converts it into the display's own profile last ([`display`]). //! * **CMYK spaces:** [`GENERIC_CMYK`] is our own documented parametric press model //! ([`generic`]); [`DEVICE_CMYK`] is the old profile-free formula (kept for exact legacy numbers); //! any CMYK `.icc` the user loads goes through `moxcms` (LUT-based profiles). @@ -10,6 +11,7 @@ //! the profiles [`use_document_profiles`] last put in place of the working spaces; //! [`crate::Color::to_rgb`] uses them. Code that needs deterministic numbers builds its own [`Cms`] with [`Cms::new`]. +pub mod display; pub mod generic; pub mod icc; pub mod lab; diff --git a/crates/color/src/cms/display.rs b/crates/color/src/cms/display.rs new file mode 100644 index 0000000000..03fe00220c --- /dev/null +++ b/crates/color/src/cms/display.rs @@ -0,0 +1,327 @@ +//! The canvas's last step before the screen: the sRGB the canvas draws, converted into a display's +//! ICC profile (Preferences › Performance › Use Monitor Profile for Canvas). +//! +//! The display profile is a destination only. Document colors, proofs, exports and the web build +//! keep their sRGB output; a soft proof is applied first, then this conversion. +//! +//! [`DisplayTransform::from_icc`] accepts an RGB display-class (`mntr`) profile of at most +//! [`MAX_PROFILE_BYTES`] whose declared length fits the data and from which a transform can be built. +//! The conversion is relative colorimetric, sampled once into a [`LUT_N`]³ table, so drawing a frame +//! costs a table lookup per pixel and never builds a transform. + +use std::hash::{Hash, Hasher}; + +use super::{Intent, ProfileKind, icc::IccProfile}; + +/// The largest display profile read: VectorCraft's own limit. Display profiles are a few kilobytes +/// (matrix/shaper) to a few hundred (table-based). +pub const MAX_PROFILE_BYTES: usize = 1 << 20; + +/// Grid points per channel of the conversion table. +const LUT_N: usize = 33; + +/// Why a display profile can't be used. The canvas then stays sRGB. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ProfileError { + /// More bytes than [`MAX_PROFILE_BYTES`]. + TooLarge(usize), + /// No ICC header (`acsp` signature) or a header cut short. + NotIcc, + /// The header's length is shorter than a header or longer than the data. + BadLength { declared: usize, len: usize }, + /// The profile class isn't a display's (`mntr`): an input, output or other profile. + NotDisplay(String), + /// The profile's data color space isn't RGB. + NotRgb(String), + /// The profile doesn't parse. + Unreadable(String), + /// No conversion from sRGB into the profile can be built. + NoTransform, +} + +impl std::fmt::Display for ProfileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::TooLarge(n) => write!(f, "the profile is {n} bytes, more than the {MAX_PROFILE_BYTES} a display profile may have"), + Self::NotIcc => f.write_str("the file is not an ICC profile"), + Self::BadLength { declared, len } => write!(f, "the profile's header gives a length of {declared} bytes for {len} bytes of data"), + Self::NotDisplay(class) => write!(f, "the profile's class is `{class}`, not a display profile (`mntr`)"), + Self::NotRgb(space) => write!(f, "the profile's color space is `{space}`, not RGB"), + Self::Unreadable(e) => write!(f, "the profile can't be read: {e}"), + Self::NoTransform => f.write_str("no conversion into the profile can be built"), + } + } +} + +/// Four header bytes as text, for an error message. +fn signature(b: &[u8]) -> String { + String::from_utf8_lossy(b).trim_end().to_string() +} + +/// sRGB → a display profile, as a table. +pub struct DisplayTransform { + name: String, + fingerprint: u64, + /// `LUT_N`³ display values, red slowest; `None` when the profile shows sRGB unchanged (every + /// grid point within half an 8-bit step of itself). + table: Option>, +} + +impl std::fmt::Debug for DisplayTransform { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DisplayTransform").field("name", &self.name).field("identity", &self.is_identity()).finish() + } +} + +impl DisplayTransform { + /// Checks `bytes` as a display profile, as [`Self::from_icc`] does, without building the + /// conversion table. + pub fn check(bytes: &[u8]) -> Result<(), ProfileError> { + Self::parse(bytes).map(|_| ()) + } + + /// Checks `bytes` as a display profile and builds the conversion into it. + pub fn from_icc(bytes: &[u8]) -> Result { + let (profile, bytes) = Self::parse(bytes)?; + let to_display = + |rgb: [f32; 3]| profile.from_srgb(rgb, Intent::RelativeColorimetric).and_then(|v| Some([*v.first()?, *v.get(1)?, *v.get(2)?])); + let s = (LUT_N - 1) as f32; + let mut table = Vec::with_capacity(LUT_N.pow(3)); + for r in 0..LUT_N { + for g in 0..LUT_N { + for b in 0..LUT_N { + let rgb = [r as f32 / s, g as f32 / s, b as f32 / s]; + table.push(to_display(rgb).ok_or(ProfileError::NoTransform)?); + } + } + } + let grid = |i: usize| [i / (LUT_N * LUT_N), i / LUT_N % LUT_N, i % LUT_N].map(|v| v as f32 / s); + let identity = table.iter().enumerate().all(|(i, v)| v.iter().zip(grid(i)).all(|(a, b)| (a - b).abs() <= 0.5 / 255.0)); + let mut h = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut h); + Ok(Self { name: profile.name.clone(), fingerprint: h.finish(), table: (!identity).then_some(table) }) + } + + /// The parsed profile and its bytes up to its declared length, when `bytes` is an RGB display + /// profile from which a conversion can be built. + fn parse(bytes: &[u8]) -> Result<(IccProfile, &[u8]), ProfileError> { + if bytes.len() > MAX_PROFILE_BYTES { + return Err(ProfileError::TooLarge(bytes.len())); + } + if bytes.len() < 132 || bytes.get(36..40) != Some(b"acsp") { + return Err(ProfileError::NotIcc); + } + let declared = bytes.get(0..4).map_or(0, |b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize); + if declared < 132 || declared > bytes.len() { + return Err(ProfileError::BadLength { declared, len: bytes.len() }); + } + let bytes = &bytes[..declared]; + if &bytes[12..16] != b"mntr" { + return Err(ProfileError::NotDisplay(signature(&bytes[12..16]))); + } + if &bytes[16..20] != b"RGB " { + return Err(ProfileError::NotRgb(signature(&bytes[16..20]))); + } + let profile = IccProfile::from_bytes(None, bytes).map_err(|e| ProfileError::Unreadable(e.to_string()))?; + if profile.kind != ProfileKind::Rgb { + return Err(ProfileError::NotRgb(format!("{:?}", profile.kind))); + } + let to_display = + |rgb: [f32; 3]| profile.from_srgb(rgb, Intent::RelativeColorimetric).and_then(|v| Some([*v.first()?, *v.get(1)?, *v.get(2)?])); + if to_display([0.5; 3]).is_none() { + return Err(ProfileError::NoTransform); + } + Ok((profile, bytes)) + } + + /// The profile's description. + pub fn name(&self) -> &str { + &self.name + } + + /// A hash of the profile's bytes, which tells two profiles with the same name apart. + pub fn fingerprint(&self) -> u64 { + self.fingerprint + } + + /// The profile shows sRGB unchanged, so nothing is converted. + pub fn is_identity(&self) -> bool { + self.table.is_none() + } + + /// One sRGB color (0..1) in the display profile. + pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] { + let Some(table) = &self.table else { return rgb.map(|v| v.clamp(0.0, 1.0)) }; + let n = LUT_N; + let s = (n - 1) as f32; + let p = rgb.map(|v| if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) * s }); + let i = p.map(|v| (v.floor() as usize).min(n - 2)); + let t = [p[0] - i[0] as f32, p[1] - i[1] as f32, p[2] - i[2] as f32]; + let mut out = [0.0f32; 3]; + for (dr, wr) in [(0, 1.0 - t[0]), (1, t[0])] { + for (dg, wg) in [(0, 1.0 - t[1]), (1, t[1])] { + for (db, wb) in [(0, 1.0 - t[2]), (1, t[2])] { + let w = wr * wg * wb; + if w == 0.0 { + continue; + } + let v = table[((i[0] + dr) * n + i[1] + dg) * n + i[2] + db]; + for c in 0..3 { + out[c] += w * v[c]; + } + } + } + } + out.map(|v| v.clamp(0.0, 1.0)) + } + + /// One 8-bit sRGB color in the display profile. + pub fn apply8(&self, rgb: [u8; 3]) -> [u8; 3] { + if self.table.is_none() { + return rgb; + } + self.apply(rgb.map(|v| f32::from(v) / 255.0)).map(|v| (v * 255.0).round() as u8) + } + + /// Premultiplied RGBA8 pixels, converted in place. Alpha is kept, and fully transparent + /// pixels are left as they are. + pub fn apply_premultiplied(&self, pixels: &mut [u8]) { + if self.table.is_none() { + return; + } + let mut last_in = [0u8; 4]; + let mut last_out = [0u8; 4]; + for px in pixels.as_chunks_mut::<4>().0 { + let a = px[3]; + if a == 0 { + continue; + } + if *px == last_in { + *px = last_out; + continue; + } + last_in = *px; + let un = |c: u8| if a == 255 { c } else { ((u32::from(c) * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8 }; + let out = self.apply8([un(px[0]), un(px[1]), un(px[2])]); + for (c, o) in px.iter_mut().zip(out) { + *c = if a == 255 { o } else { ((u32::from(o) * u32::from(a) + 127) / 255) as u8 }; + } + last_out = *px; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cms::{DISPLAY_P3, GENERIC_CMYK, SRGB, icc_bytes}; + + /// `name`'s ICC file, marked as a display profile. + fn display(name: &str) -> Vec { + let mut b = icc_bytes(name).unwrap().to_vec(); + b[12..16].copy_from_slice(b"mntr"); + b + } + + #[test] + fn a_wide_gamut_display_shows_srgb_red_less_saturated_and_srgb_is_left_alone() { + let p3 = DisplayTransform::from_icc(&display(DISPLAY_P3)).unwrap(); + assert!(!p3.is_identity()); + assert_eq!(p3.name(), DISPLAY_P3); + // sRGB red is inside Display P3: its display values have green and blue above 0 and less red. + let [r, g, b] = p3.apply8([255, 0, 0]); + assert!(r < 245 && g > 40 && b > 10, "{:?}", [r, g, b]); + // White and black stay where they are (relative colorimetric). + assert_eq!(p3.apply8([255; 3]), [255; 3]); + assert_eq!(p3.apply8([0; 3]), [0; 3]); + let srgb = DisplayTransform::from_icc(&display(SRGB)).unwrap(); + assert!(srgb.is_identity()); + assert_eq!(srgb.apply8([255, 0, 0]), [255, 0, 0]); + assert_ne!(srgb.fingerprint(), p3.fingerprint()); + } + + #[test] + fn the_table_matches_the_profile_between_its_grid_points() { + let bytes = display(DISPLAY_P3); + let p3 = DisplayTransform::from_icc(&bytes).unwrap(); + let profile = IccProfile::from_bytes(None, &bytes).unwrap(); + for rgb in [[0.1, 0.7, 0.33], [0.9, 0.05, 0.6], [0.47, 0.47, 0.47], [0.0, 0.2, 1.0]] { + let want = profile.from_srgb(rgb, Intent::RelativeColorimetric).unwrap(); + let got = p3.apply(rgb); + for c in 0..3 { + assert!((got[c] - want[c]).abs() < 1.0 / 255.0, "{rgb:?}: {got:?} vs {want:?}"); + } + } + } + + #[test] + fn premultiplied_pixels_keep_their_alpha_and_transparent_ones_stay_empty() { + let p3 = DisplayTransform::from_icc(&display(DISPLAY_P3)).unwrap(); + let mut px = vec![255, 0, 0, 255, 128, 0, 0, 128, 0, 0, 0, 0, 255, 0, 0, 255]; + p3.apply_premultiplied(&mut px); + let opaque = p3.apply8([255, 0, 0]); + assert_eq!(&px[0..4], &[opaque[0], opaque[1], opaque[2], 255]); + assert_eq!(&px[12..16], &px[0..4], "a repeated pixel converts the same"); + assert_eq!(px[7], 128); + // Half-covered red: the same color at half coverage, within rounding. + for c in 0..3 { + assert!((i32::from(px[4 + c]) - i32::from(opaque[c]) / 2).abs() <= 1, "{:?}", &px[4..8]); + } + assert_eq!(&px[8..12], &[0, 0, 0, 0]); + } + + #[test] + fn profiles_that_are_not_rgb_display_profiles_are_refused() { + let cmyk = icc_bytes(GENERIC_CMYK).unwrap().to_vec(); + assert!(matches!(DisplayTransform::from_icc(&cmyk), Err(ProfileError::NotDisplay(_)))); + let mut cmyk_display = cmyk.clone(); + cmyk_display[12..16].copy_from_slice(b"mntr"); + assert!(matches!(DisplayTransform::from_icc(&cmyk_display), Err(ProfileError::NotRgb(s)) if s == "CMYK")); + let mut input = display(DISPLAY_P3); + input[12..16].copy_from_slice(b"scnr"); + assert_eq!(DisplayTransform::from_icc(&input).unwrap_err(), ProfileError::NotDisplay("scnr".into())); + } + + #[test] + fn malformed_short_and_oversized_data_is_refused() { + assert_eq!(DisplayTransform::from_icc(&[]).unwrap_err(), ProfileError::NotIcc); + assert_eq!(DisplayTransform::from_icc(&[0; 132]).unwrap_err(), ProfileError::NotIcc); + let good = display(DISPLAY_P3); + assert_eq!(DisplayTransform::from_icc(&good[..100]).unwrap_err(), ProfileError::NotIcc); + let mut long = good.clone(); + long[0..4].copy_from_slice(&(good.len() as u32 + 1).to_be_bytes()); + assert_eq!(DisplayTransform::from_icc(&long).unwrap_err(), ProfileError::BadLength { declared: good.len() + 1, len: good.len() }); + let mut short = good.clone(); + short[0..4].copy_from_slice(&64u32.to_be_bytes()); + assert!(matches!(DisplayTransform::from_icc(&short), Err(ProfileError::BadLength { declared: 64, .. }))); + // Trailing bytes past the declared length are not part of the profile. + let mut padded = good.clone(); + padded.extend([0xAB; 16]); + let a = DisplayTransform::from_icc(&padded).unwrap(); + assert_eq!(a.fingerprint(), DisplayTransform::from_icc(&good).unwrap().fingerprint()); + let mut garbage_tags = good.clone(); + for b in &mut garbage_tags[128..] { + *b = 0xFF; + } + assert!(matches!(DisplayTransform::from_icc(&garbage_tags), Err(ProfileError::Unreadable(_) | ProfileError::NoTransform))); + let huge = vec![0u8; MAX_PROFILE_BYTES + 1]; + assert_eq!(DisplayTransform::from_icc(&huge).unwrap_err(), ProfileError::TooLarge(MAX_PROFILE_BYTES + 1)); + } + + #[test] + fn check_accepts_and_refuses_what_from_icc_does() { + assert_eq!(DisplayTransform::check(&display(DISPLAY_P3)), Ok(())); + assert_eq!(DisplayTransform::check(&display(SRGB)), Ok(())); + let cmyk = icc_bytes(GENERIC_CMYK).unwrap().to_vec(); + assert_eq!(DisplayTransform::check(&cmyk), Err(DisplayTransform::from_icc(&cmyk).unwrap_err())); + assert_eq!(DisplayTransform::check(&[1, 2, 3]), Err(ProfileError::NotIcc)); + } + + #[test] + fn nan_and_out_of_range_values_stay_in_range() { + let p3 = DisplayTransform::from_icc(&display(DISPLAY_P3)).unwrap(); + for v in p3.apply([f32::NAN, -1.0, 9.0]) { + assert!((0.0..=1.0).contains(&v)); + } + } +} diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index a73485f4fa..7920df223e 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -368,6 +368,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("realTimeDrawing", "Performance", "", "Real-time Drawing and Editing", bool), p!("renderThreads", "Performance", "", "Render Threads (-1 = Automatic)", int(-1, 64)), p!("linuxDisplayServer", "Performance", "", "Linux Display Server", choice(LINUX_DISPLAY_SERVERS)), + p!("monitorProfileDisplay", "Performance", "", "Use Monitor Profile for Canvas", bool), // File Handling p!("backgroundSave", "File Handling", "", "Enable Background Save", bool), p!("backgroundExport", "File Handling", "", "Enable Background Export", bool), diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 05ef5e84f5..2433923309 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -220,6 +220,10 @@ pub struct Prefs { /// Wayland session, where dropped files arrive (#1151); `x11WithPen` opens it through XWayland /// only when a pen is attached (#2028). Read when the app starts. pub linux_display_server: String, + /// The desktop canvas converts its sRGB into the ICC profile of the display that shows the + /// window, where the system doesn't (Windows without Advanced Color, X11). Display only: it + /// changes no document, proof or export. + pub monitor_profile_display: bool, // File Handling pub background_save: bool, pub background_export: bool, @@ -496,6 +500,7 @@ impl Default for Prefs { real_time_drawing: true, render_threads: -1, linux_display_server: s("automatic"), + monitor_profile_display: true, background_save: true, background_export: true, autosave_recovery: true, diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 14669fd357..e4d00cda5d 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -464,6 +464,34 @@ fn linux_display_server_defaults_to_automatic_and_validates() { assert_eq!(s.prefs.linux_display_server, "automatic"); } +/// Performance › Use Monitor Profile for Canvas: on by default and for preference files written +/// before it, a boolean only, saved, listed under Performance, and set without changing the +/// document. +#[test] +fn monitor_profile_display_is_on_by_default_and_changes_no_document() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + assert!(s.prefs.monitor_profile_display); + let old: Prefs = serde_json::from_value(json!({"gpuPerformance": true})).unwrap(); + assert!(old.monitor_profile_display); + let (revision, doc) = (s.active().unwrap().revision, s.active().unwrap().doc.clone()); + s.execute("prefs.set", &json!({"key": "monitorProfileDisplay", "value": false})).unwrap(); + assert!(!s.prefs.monitor_profile_display); + assert_eq!(s.prefs.to_json()["monitorProfileDisplay"], false); + for bad in [json!("off"), json!(0), json!(null)] { + assert!(s.execute("prefs.set", &json!({"key": "monitorProfileDisplay", "value": bad})).is_err(), "{bad}"); + assert!(!s.prefs.monitor_profile_display); + } + assert_eq!(s.active().unwrap().revision, revision); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &doc), "the document is not edited"); + let l = s.execute("prefs.list", &json!({})).unwrap(); + let row = l.as_array().unwrap().iter().find(|e| e["key"] == "monitorProfileDisplay").unwrap().clone(); + assert_eq!((row["category"].as_str(), row["label"].as_str()), (Some("Performance"), Some("Use Monitor Profile for Canvas"))); + s.execute("prefs.reset", &json!({"category": "Performance"})).unwrap(); + assert!(s.prefs.monitor_profile_display); +} + /// Linux Display Server's pen choice (#2028): set by value or by label, saved, and off by default. #[test] fn linux_display_server_has_a_choice_for_an_attached_pen() { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 67e67ac444..5b4b924f0f 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -98,6 +98,7 @@ impl VectorcraftApp { panes: Default::default(), windows: crate::doc_windows::Windows::new(known), }, + display_profile: Default::default(), perf: Perf::default(), dock_rt: Default::default(), titlebar_inset: 0.0, diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index 7f0d73511d..286b4d4d02 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -161,6 +161,9 @@ pub struct CacheKey { /// The Image Trace objects drawn with their source image /// ([`vectorcraft_render::RenderOptions::trace_source`]). pub trace_source: Option>>, + /// The display profile the frame is converted into ([`crate::display_profile::DisplayProfile::key`]), + /// 0 for none. + pub display: u64, } #[derive(Clone, Copy, Debug, Default)] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d75eae537f..96e08db309 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -619,6 +619,8 @@ fn cache_key( dragging: app.session.in_interaction() && ui.data(|d| d.get_temp::(drag_id())) == Some(Drag::Tool), // The Image Trace panel's eye button, while it is held. trace_source: app.ui.trace_hold_source.clone(), + // Preferences › Performance › Use Monitor Profile for Canvas. + display: app.display_profile.key(app.session.prefs.monitor_profile_display), } } @@ -870,6 +872,7 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { // A new view can copy from the worker's last frame. The frame shown asked for again renders // whole. The worker keeps either one for the next pan to copy from. let may_copy = app.canvas.key.as_ref() != Some(key); + let display = app.display_profile.transform(app.session.prefs.monitor_profile_display); let mut landed = None; match (&mut app.canvas.worker, heavy, changed) { // Only the document changed since the frame shown, in a part of it: that part is @@ -882,7 +885,9 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { } if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) { let renderer = &mut app.canvas.renderer; - let part = effects.view(|| renderer.render(doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts)); + let mut part = + effects.view(|| renderer.render(doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts)); + crate::display_profile::convert(display.as_deref(), &mut part.pixels); let color = color_image(&part); let options = if pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR }; tex.set_partial([x0 as usize, y0 as usize], color, options); @@ -907,6 +912,7 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { effects: effects.clone(), may_copy, may_keep: true, + display, }); // A light document's first frame is ready within the grace and shows in this frame, // with no frame of bare paper. Waited for once, when the frame is queued. @@ -917,8 +923,9 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { _ => { let t0 = now_ms(); let renderer = &mut app.canvas.renderer; - let img = effects.view(|| renderer.render(doc, w, h, view, &opts)); + let mut img = effects.view(|| renderer.render(doc, w, h, view, &opts)); effects.first_view_done(); + crate::display_profile::convert(display.as_deref(), &mut img.pixels); upload(app, ui.ctx(), img, pixel.is_some()); app.canvas.key = Some(key.clone()); app.canvas.shown = Some((doc.clone(), opts.clone())); @@ -967,13 +974,18 @@ fn paint_paper( // on, the canvas multiplies it with the paper color: each sRGB channel is artboard × paper / 255, // rounded. The transparency grid, while it shows, covers it as it covers the others. painter.rect_filled(rect, 0.0, t.pasteboard); + // The paper and the transparency grid are document colors: they go through the display + // profile as the art does. The pasteboard and the shadows are the interface's. + let display = app.display_profile.transform(app.session.prefs.monitor_profile_display); + let on_display = |c: Color32| crate::display_profile::color32(display.as_deref(), c); if !shows_artboards(app) { // View › Hide Artboards: no edges and no pasteboard, the whole canvas paper (#4038). let setup = &doc.setup; - painter.rect_filled(rect, 0.0, if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE }); + painter.rect_filled(rect, 0.0, on_display(if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE })); } else { let setup = &doc.setup; - let grid = (st.transparency_grid && setup.background == vectorcraft_doc::Background::Transparent).then(|| checker_texture(ui.ctx(), setup)); + let grid = (st.transparency_grid && setup.background == vectorcraft_doc::Background::Transparent) + .then(|| checker_texture(ui.ctx(), setup, display.as_deref())); let paper = if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE }; for ab in &doc.artboards { let q = xf.quad(ab.rect); @@ -985,7 +997,7 @@ fn paint_paper( Some((tex, cell)) => checker(painter, &q, ab.rect, xf.zoom, tex.id(), *cell), None => { let fill = ab.color.map_or(paper, |c| crate::panels::c32(&c) * paper); - painter.add(Shape::convex_polygon(q, fill, Stroke::NONE)); + painter.add(Shape::convex_polygon(q, on_display(fill), Stroke::NONE)); } } } diff --git a/crates/ui-egui/src/canvas/fill.rs b/crates/ui-egui/src/canvas/fill.rs index 8eda1db219..9e9c099cec 100644 --- a/crates/ui-egui/src/canvas/fill.rs +++ b/crates/ui-egui/src/canvas/fill.rs @@ -96,6 +96,7 @@ pub(super) fn fill_uncovered( app.canvas.fill = Some(Fill { key: done.key, view, size: (fw, fh), tex }); } let effects = app.session.documents().iter().find(|d| d.uid as usize == key.doc).map(|d| d.plugin_effects.clone()).unwrap_or_default(); + let display = app.display_profile.transform(app.session.prefs.monitor_profile_display); let Some(worker) = app.canvas.fill_worker.as_mut().filter(|_| !gaps.is_empty()) else { return }; if !app.canvas.fill.as_ref().is_some_and(|f| covers(&f.key)) && !worker.pending.as_ref().is_some_and(covers) { let (w, h, view) = fill_view(key); @@ -110,6 +111,7 @@ pub(super) fn fill_uncovered( effects, may_copy: false, may_keep: false, + display, }); } let Some(f) = app.canvas.fill.as_ref().filter(|f| usable(&f.key)) else { return }; diff --git a/crates/ui-egui/src/canvas/grid.rs b/crates/ui-egui/src/canvas/grid.rs index 2c99c618db..84adb0d861 100644 --- a/crates/ui-egui/src/canvas/grid.rs +++ b/crates/ui-egui/src/canvas/grid.rs @@ -3,11 +3,18 @@ use super::{ Color32, Point, PointerEvent, PointerKind, Pos2, RULER, Rect, Sense, Shape, Stroke, Tokens, Ui, Unit, VectorcraftApp, Xf, json, pos2, vec2, }; -/// The transparency grid's 2 × 2-cell tile in the document's colours (Document Setup), cached per -/// look, and its cell size in screen points. -pub(crate) fn checker_texture(ctx: &egui::Context, setup: &vectorcraft_doc::DocSetup) -> (egui::TextureHandle, f32) { - let colors = setup.grid_colors.map(|c| crate::panels::c32(&c)); - let id = egui::Id::unique("transparency-grid-tile"); +/// The transparency grid's 2 × 2-cell tile in the document's colours (Document Setup), converted +/// into the display profile `display` when there is one, cached per look, and its cell size in +/// screen points. +pub(crate) fn checker_texture( + ctx: &egui::Context, + setup: &vectorcraft_doc::DocSetup, + display: Option<&vectorcraft_color::cms::display::DisplayTransform>, +) -> (egui::TextureHandle, f32) { + let colors = setup.grid_colors.map(|c| crate::display_profile::color32(display, crate::panels::c32(&c))); + // A tile of its own with a display profile, so a preview drawn in sRGB beside the canvas + // doesn't make the canvas's tile again each frame. + let id = egui::Id::unique("transparency-grid-tile").with(display.is_some()); let cached = ctx.data(|d| d.get_temp::<([Color32; 2], egui::TextureHandle)>(id)).filter(|(c, _)| *c == colors); let tex = cached.map(|(_, t)| t).unwrap_or_else(|| { let [a, b] = colors; diff --git a/crates/ui-egui/src/canvas/panes.rs b/crates/ui-egui/src/canvas/panes.rs index fc440be673..76fb599fda 100644 --- a/crates/ui-egui/src/canvas/panes.rs +++ b/crates/ui-egui/src/canvas/panes.rs @@ -70,9 +70,17 @@ impl Panes { } /// Bring `slot`'s render up to `key` (`size` pixels through `view`), on the worker when there - /// is one, then draw the slot's latest render through `xf`, turned and scaled into the view it - /// was rendered for. - fn draw(&mut self, ctx: &egui::Context, painter: &egui::Painter, xf: &Xf, slot: Slot, frame: PaneFrame) { + /// is one, converted into the display profile `display`, then draw the slot's latest render + /// through `xf`, turned and scaled into the view it was rendered for. + fn draw( + &mut self, + ctx: &egui::Context, + painter: &egui::Painter, + xf: &Xf, + slot: Slot, + frame: PaneFrame, + display: Option>, + ) { let (key, doc, ((w, h), view), opts) = frame; if !self.worker_started { self.worker_started = true; @@ -101,8 +109,16 @@ impl Panes { if !current { let mut queued = false; if let Some(worker) = &self.worker { - let (job_doc, job_opts) = (doc.clone(), opts.clone()); - queued = worker.submit(slot, (slot, key.clone()), Box::new(move |r| Some(r.render(&job_doc, w, h, view, &job_opts)))); + let (job_doc, job_opts, job_display) = (doc.clone(), opts.clone(), display.clone()); + queued = worker.submit( + slot, + (slot, key.clone()), + Box::new(move |r| { + let mut img = r.render(&job_doc, w, h, view, &job_opts); + crate::display_profile::convert(job_display.as_deref(), &mut img.pixels); + Some(img) + }), + ); if queued { self.pending.insert(slot, key.clone()); } else { @@ -110,7 +126,8 @@ impl Panes { } } if !queued { - let img = self.renderer.get_or_insert_with(vectorcraft_render::Renderer::new).render(&doc, w, h, view, &opts); + let mut img = self.renderer.get_or_insert_with(vectorcraft_render::Renderer::new).render(&doc, w, h, view, &opts); + crate::display_profile::convert(display.as_deref(), &mut img.pixels); self.upload(ctx, slot, key, &img); } } @@ -314,7 +331,8 @@ pub(crate) fn pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, cell: egui::Rect) let opts = frame_options(app, &key, st.shown_mask(), smooth_images); let (fw, fh, view) = frame_view(pixel, (pw, ph), &drawn, ppp); let doc = st.doc.clone(); - app.canvas.panes.draw(ui.ctx(), &painter, &xf, slot, (key, doc, ((fw, fh), view), opts)); + let display = app.display_profile.transform(app.session.prefs.monitor_profile_display); + app.canvas.panes.draw(ui.ctx(), &painter, &xf, slot, (key, doc, ((fw, fh), view), opts), display); let Some(st) = app.session.documents().get(w.0) else { return false }; if let Some(look) = grid_look.filter(|_| !grids_in_back) { grid(&painter, &xf, st.doc.grid.spacing, st.doc.grid.subdivisions, look); diff --git a/crates/ui-egui/src/canvas/raster.rs b/crates/ui-egui/src/canvas/raster.rs index 2722bc79fe..f57a15a795 100644 --- a/crates/ui-egui/src/canvas/raster.rs +++ b/crates/ui-egui/src/canvas/raster.rs @@ -216,7 +216,7 @@ pub(crate) fn render_job(r: &mut vectorcraft_render::Renderer, kept: &mut Option composed_frame(r, moved_pixels(k.pixels.as_ref()?, job.w, job.h, pan), parts, job) }); let reused = copied.is_some(); - let (img, moved) = copied.unwrap_or_else(|| { + let (mut img, moved) = copied.unwrap_or_else(|| { let img = r.render(&job.doc, job.w, job.h, job.view, &job.opts); let moved = r.stats.moved; (img, moved) @@ -225,6 +225,8 @@ pub(crate) fn render_job(r: &mut vectorcraft_render::Renderer, kept: &mut Option job.may_keep && moved == 0 && r.stats.view_sized == 0 && img.pixels.len() <= MAX_KEPT_BYTES && (img.width, img.height) == (job.w, job.h); *kept = keeps.then(|| KeptFrame { key: job.key.clone(), opts: job.opts.clone(), view: job.view, pixels: holds_pixels.then(|| img.pixels.clone()) }); + // The kept frame stays sRGB, so the next frame's copied pixels are converted only once. + crate::display_profile::convert(job.display.as_deref(), &mut img.pixels); WorkerFrame { img, moved, reused } } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 8597607717..802b42319c 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2387,6 +2387,53 @@ fn pixel_preview_shows_the_document_pixels() { assert!(w > 600 && !hard, "zoomed out: the screen's pixels"); } +/// Preferences › Performance › Use Monitor Profile for Canvas: with a display profile read, the +/// art is drawn converted into it, and drawn again when the profile or the preference changes. +#[test] +fn the_canvas_is_drawn_through_the_display_profile_while_the_preference_is_on() { + use crate::display_profile::{Found, Reading}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap(); + app.session.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + app.canvas.worker_started = true; // render on this thread + assert!(app.session.prefs.monitor_profile_display, "on by default"); + let ctx = egui::Context::default(); + // How many pixels of the art's raster one frame uploads are `c`, None when it was not re-rendered. + let count = |app: &mut VectorcraftApp, c: egui::Color32| -> Option { + let (_, mut delta) = frame_output(app, &ctx); + let art = app.canvas.texture.as_ref().unwrap().id(); + let n = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| { + let egui::ImageData::Color(img) = &d.image; + img.pixels.iter().filter(|p| **p == c).count() + }); + delta.clear(); + n + }; + let red = egui::Color32::RED; + let srgb = count(&mut app, red).expect("the first frame"); + assert!(srgb > 1000, "the rectangle in sRGB: {srgb}"); + let p3 = crate::display_profile::tests::p3(); + let t = vectorcraft_color::cms::display::DisplayTransform::from_icc(&p3).unwrap(); + let [r, g, b] = t.apply8([255, 0, 0]); + let shown = egui::Color32::from_rgb(r, g, b); + assert_ne!(shown, red, "sRGB red is less saturated on a wide-gamut display"); + let reading = |found| Reading { display: Some("DP-1".into()), found }; + assert!(app.display_profile.report(reading(Found::Profile { icc: p3.clone(), from: "test".into() }))); + assert_eq!(count(&mut app, shown), Some(srgb), "drawn again, converted"); + // The same profile again: nothing is drawn again. + assert!(!app.display_profile.report(reading(Found::Profile { icc: p3, from: "test".into() }))); + assert_eq!(count(&mut app, shown), None); + // Off: drawn again in sRGB. + app.session.prefs.monitor_profile_display = false; + assert_eq!(count(&mut app, red), Some(srgb)); + // On again, then the system takes over: sRGB. + app.session.prefs.monitor_profile_display = true; + assert_eq!(count(&mut app, shown), Some(srgb)); + app.display_profile.report(reading(Found::System("test".into()))); + assert_eq!(count(&mut app, red), Some(srgb)); +} + /// View › Hide Template (#2000): a template layer's art leaves the canvas, and Show Template /// draws it again, dimmed as before; each re-renders at once. #[test] @@ -11512,6 +11559,7 @@ fn pan_job(doc: &std::sync::Arc, key: &CacheKey, (dx, effects: Default::default(), may_copy: true, may_keep: true, + display: None, } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 89ab7e37b4..9c8a438b02 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -270,6 +270,9 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { "freeTransformWidget": crate::free_transform::rect(ctx).map(|r| json!([r.left(), r.top(), r.width(), r.height()])), "view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()), "canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])), + // The display profile the canvas is drawn through, or why none (Preferences › + // Performance › Use Monitor Profile for Canvas). + "displayProfile": app.display_profile.status(app.session.prefs.monitor_profile_display), // Window › Arrange › Tile (not saved, so not in `ui`) and the panes the last frame drew: // each one's document index, view number (Window › New Window) and rect. Canvas // coordinates map to the current window's pane, inside its rulers and scroll bars diff --git a/crates/ui-egui/src/dialogs/save_for_web.rs b/crates/ui-egui/src/dialogs/save_for_web.rs index 64dc2fae8c..d5ed966f94 100644 --- a/crates/ui-egui/src/dialogs/save_for_web.rs +++ b/crates/ui-egui/src/dialogs/save_for_web.rs @@ -536,7 +536,7 @@ fn preview_area(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, set let (original, shots) = previews(app, ui.ctx(), s, &panes); let zoom = d.str("__zoom").parse::().ok().filter(|z| z.is_finite() && *z > 0.0).map(|z| z / 100.0); let kbps = d.f64("__kbps", 56.6); - let Some(checker) = app.session.active().map(|st| crate::canvas::checker_texture(ui.ctx(), &st.doc.setup)) else { return false }; + let Some(checker) = app.session.active().map(|st| crate::canvas::checker_texture(ui.ctx(), &st.doc.setup, None)) else { return false }; let cells: Vec<(egui::Rect, Pane)> = match view { 0 => vec![(area, Pane::Original)], 1 => vec![(area, Pane::Optimized(0))], diff --git a/crates/ui-egui/src/display_profile.rs b/crates/ui-egui/src/display_profile.rs new file mode 100644 index 0000000000..486489e6fe --- /dev/null +++ b/crates/ui-egui/src/display_profile.rs @@ -0,0 +1,311 @@ +//! Preferences › Performance › Use Monitor Profile for Canvas: the canvas's art, its artboards' +//! paper and the transparency grid are converted from sRGB into the ICC profile of the display +//! that shows most of the main window, as the last step before they are drawn. +//! +//! The desktop app reads the display's profile on a background thread and hands the reading to +//! [`DisplayProfile::report`], which checks the profile and builds its conversion table there. +//! Where the system converts the window to the display itself (macOS, Windows with Advanced +//! Color, a Wayland session), or the display has no usable profile, nothing is converted and the +//! canvas stays sRGB. Exports, `ui.render`, proofs and the document are not affected: a proof is +//! drawn first and then converted like any other frame. + +use std::sync::{Arc, RwLock}; + +use serde_json::{Value, json}; +use vectorcraft_color::cms::display::DisplayTransform; +pub use vectorcraft_color::cms::display::MAX_PROFILE_BYTES; + +/// What the desktop app read for the display showing the main window. +#[derive(Clone, Debug, PartialEq)] +pub struct Reading { + /// The system's name for the display (Windows: `\\.\DISPLAY1`; X11: the output's name). + pub display: Option, + pub found: Found, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Found { + /// The display's ICC profile, and where it was read from. + Profile { icc: Vec, from: String }, + /// The system converts the window to the display: VectorCraft converts nothing. The reason. + System(String), + /// The display has no profile VectorCraft can read: the canvas stays sRGB. The reason. + NoProfile(String), +} + +/// What the canvas does with the reading. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + /// Nothing has been read: the web build, tests, or a platform without a reader. + Unsupported, + /// The first reading has not finished. + Pending, + /// The profile is converted into. + Applied, + /// The profile shows sRGB unchanged. + Identity, + /// The system converts the window. + System, + /// No profile, or one that can't be used. + NoProfile, +} + +impl Kind { + fn id(self) -> &'static str { + match self { + Self::Unsupported => "unsupported", + Self::Pending => "pending", + Self::Applied => "profile", + Self::Identity => "srgbProfile", + Self::System => "system", + Self::NoProfile => "none", + } + } +} + +#[derive(Debug)] +struct State { + /// Changes whenever the conversion changes, so frames rendered for the old one are rendered again. + generation: u64, + kind: Kind, + display: Option, + /// The profile's description and where it was read from. + profile: Option<(String, String)>, + reason: Option, + transform: Option>, + /// The bytes last reported, so a reading of the same profile builds nothing. + reported: Option<(Option, Vec)>, +} + +impl Default for State { + fn default() -> Self { + Self { + generation: 0, + kind: Kind::Unsupported, + display: None, + profile: None, + reason: Some("this build doesn't read display profiles".into()), + transform: None, + reported: None, + } + } +} + +/// The display profile of the main window, shared with the thread that reads it. +#[derive(Clone, Debug, Default)] +pub struct DisplayProfile(Arc>); + +impl DisplayProfile { + fn read(&self) -> std::sync::RwLockReadGuard<'_, State> { + self.0.read().unwrap_or_else(|e| e.into_inner()) + } + + fn write(&self) -> std::sync::RwLockWriteGuard<'_, State> { + self.0.write().unwrap_or_else(|e| e.into_inner()) + } + + /// The desktop app has started reading the display's profile. + pub fn pending(&self) { + let mut s = self.write(); + if s.kind == Kind::Unsupported { + s.kind = Kind::Pending; + s.reason = Some("the display profile hasn't been read yet".into()); + } + } + + /// Takes `reading` in. A profile is checked, and its conversion built, on the calling thread; + /// a profile that can't be used leaves the canvas sRGB, with the reason in [`Self::status`]. + /// The same profile reported again for the same display changes nothing. Returns whether the + /// canvas's conversion changed. + pub fn report(&self, reading: Reading) -> bool { + if let Found::Profile { icc, .. } = &reading.found + && self.read().reported.as_ref().is_some_and(|(d, b)| *d == reading.display && b == icc) + { + return false; + } + let (kind, profile, reason, transform, reported) = match reading.found { + Found::Profile { icc, from } => { + let reported = Some((reading.display.clone(), icc.clone())); + match DisplayTransform::from_icc(&icc) { + Ok(t) if t.is_identity() => (Kind::Identity, Some((t.name().to_string(), from)), None, None, reported), + Ok(t) => (Kind::Applied, Some((t.name().to_string(), from)), None, Some(Arc::new(t)), reported), + Err(e) => (Kind::NoProfile, None, Some(format!("{from}: {e}")), None, reported), + } + } + Found::System(r) => (Kind::System, None, Some(r), None, None), + Found::NoProfile(r) => (Kind::NoProfile, None, Some(r), None, None), + }; + let mut s = self.write(); + let changed = s.kind != kind || s.display != reading.display || s.profile != profile || s.reason != reason; + let converts_differently = match (&s.transform, &transform) { + (None, None) => false, + (Some(a), Some(b)) => a.fingerprint() != b.fingerprint(), + _ => true, + }; + if converts_differently { + s.generation += 1; + } + (s.kind, s.display, s.profile, s.reason, s.transform, s.reported) = (kind, reading.display, profile, reason, transform, reported); + changed || converts_differently + } + + /// The conversion the canvas draws through: `None` when `enabled` (the preference) is off, or + /// when nothing is to be converted. + pub fn transform(&self, enabled: bool) -> Option> { + if !enabled { + return None; + } + self.read().transform.clone() + } + + /// A number that changes whenever [`Self::transform`] does, 0 while it is `None`: the canvas's + /// frames depend on it ([`crate::CacheKey::display`]). + pub fn key(&self, enabled: bool) -> u64 { + let s = self.read(); + if enabled && s.transform.is_some() { s.generation } else { 0 } + } + + /// The status `ui.inspect` reports as `displayProfile`: the display, what was found + /// (`profile`, `srgbProfile`, `system`, `none`, `pending` or `unsupported`), the profile's + /// description and where it was read from, whether the canvas is converted, why not, and + /// whether the system converts the window. + pub fn status(&self, enabled: bool) -> Value { + let s = self.read(); + json!({ + "enabled": enabled, + "display": s.display, + "source": s.kind.id(), + "profile": s.profile.as_ref().map(|p| &p.0), + "from": s.profile.as_ref().map(|p| &p.1), + "applied": enabled && s.transform.is_some(), + "reason": s.reason, + "osManaged": s.kind == Kind::System, + }) + } + + /// The line the Preferences dialog shows under the checkbox, translated. + pub(crate) fn note(&self, enabled: bool) -> String { + let s = self.read(); + match (enabled, s.kind, &s.profile) { + (false, ..) => tl!("The canvas is drawn in sRGB.").to_string(), + (true, Kind::Applied | Kind::Identity, Some((name, _))) => format!("{} {name}", tl!("Canvas display profile:")), + (true, Kind::System, _) => tl!("The system matches the window to the display's colors.").to_string(), + _ => tl!("No usable display profile was found: the canvas is drawn in sRGB.").to_string(), + } + } +} + +/// Why `icc` can't be used as a display profile, or `Ok` when [`DisplayProfile::report`] would +/// convert through it (or find that it shows sRGB unchanged). Builds no conversion table. +pub fn check_profile(icc: &[u8]) -> Result<(), String> { + DisplayTransform::check(icc).map_err(|e| e.to_string()) +} + +/// `pixels` (premultiplied RGBA8) through `t`, using every core for a large frame. +pub(crate) fn convert(t: Option<&DisplayTransform>, pixels: &mut [u8]) { + let Some(t) = t else { return }; + #[cfg(not(target_arch = "wasm32"))] + { + const PIECE: usize = 256 * 1024 * 4; + let threads = std::thread::available_parallelism().map_or(1, usize::from).min(16); + if threads > 1 && pixels.len() > PIECE { + let per = pixels.len().div_ceil(threads).div_ceil(4) * 4; + std::thread::scope(|scope| { + for piece in pixels.chunks_mut(per) { + scope.spawn(move || t.apply_premultiplied(piece)); + } + }); + return; + } + } + t.apply_premultiplied(pixels); +} + +/// The opaque or translucent color `c` through `t`. +pub(crate) fn color32(t: Option<&DisplayTransform>, c: egui::Color32) -> egui::Color32 { + let Some(t) = t else { return c }; + let mut px = c.to_array(); + t.apply_premultiplied(&mut px); + egui::Color32::from_rgba_premultiplied(px[0], px[1], px[2], px[3]) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + /// Display P3's ICC file as a display profile. + pub(crate) fn p3() -> Vec { + let mut b = vectorcraft_color::cms::icc_bytes(vectorcraft_color::cms::DISPLAY_P3).unwrap().to_vec(); + b[12..16].copy_from_slice(b"mntr"); + b + } + + fn reading(found: Found) -> Reading { + Reading { display: Some("DP-1".into()), found } + } + + #[test] + fn a_display_profile_is_converted_into_while_the_preference_is_on() { + let d = DisplayProfile::default(); + assert_eq!(d.status(true)["source"], "unsupported"); + assert!(d.transform(true).is_none() && d.key(true) == 0); + d.pending(); + assert_eq!(d.status(true)["source"], "pending"); + assert!(d.report(reading(Found::Profile { icc: p3(), from: "colord".into() }))); + let st = d.status(true); + assert_eq!((st["source"].as_str(), st["applied"].as_bool(), st["profile"].as_str()), (Some("profile"), Some(true), Some("Display P3"))); + assert_eq!((st["display"].as_str(), st["from"].as_str(), st["osManaged"].as_bool()), (Some("DP-1"), Some("colord"), Some(false))); + assert!(d.transform(true).is_some()); + let key = d.key(true); + assert_ne!(key, 0); + // Off: nothing is converted, and frames don't depend on the profile. + assert!(d.transform(false).is_none()); + assert_eq!((d.key(false), d.status(false)["applied"].as_bool()), (0, Some(false))); + // The same profile again changes nothing. + assert!(!d.report(reading(Found::Profile { icc: p3(), from: "colord".into() }))); + assert_eq!(d.key(true), key); + // The system takes over: nothing is converted. + assert!(d.report(reading(Found::System("Windows manages this display's colors".into())))); + let st = d.status(true); + assert_eq!((st["source"].as_str(), st["applied"].as_bool(), st["osManaged"].as_bool()), (Some("system"), Some(false), Some(true))); + assert_eq!(d.key(true), 0); + // The profile back: a new key, so frames drawn before are drawn again. + d.report(reading(Found::Profile { icc: p3(), from: "colord".into() })); + assert!(d.key(true) > key); + } + + #[test] + fn a_profile_that_can_t_be_used_leaves_the_canvas_srgb_and_says_why() { + let d = DisplayProfile::default(); + let cmyk = vectorcraft_color::cms::icc_bytes(vectorcraft_color::cms::GENERIC_CMYK).unwrap().to_vec(); + assert!(d.report(reading(Found::Profile { icc: cmyk, from: "printer.icc".into() }))); + let st = d.status(true); + assert_eq!((st["source"].as_str(), st["applied"].as_bool()), (Some("none"), Some(false))); + assert!(st["reason"].as_str().is_some_and(|r| r.starts_with("printer.icc: ") && r.contains("not a display profile")), "{st}"); + assert!(d.transform(true).is_none()); + d.report(reading(Found::Profile { icc: vec![1, 2, 3], from: "broken.icc".into() })); + assert!(d.status(true)["reason"].as_str().is_some_and(|r| r.contains("not an ICC profile"))); + // An sRGB display profile converts nothing. + let mut srgb = vectorcraft_color::cms::icc_bytes(vectorcraft_color::cms::SRGB).unwrap().to_vec(); + srgb[12..16].copy_from_slice(b"mntr"); + d.report(reading(Found::Profile { icc: srgb, from: "sRGB.icc".into() })); + assert_eq!((d.status(true)["source"].as_str(), d.status(true)["applied"].as_bool()), (Some("srgbProfile"), Some(false))); + assert!(d.transform(true).is_none()); + } + + #[test] + fn large_frames_convert_the_same_on_several_threads() { + let t = DisplayTransform::from_icc(&p3()).unwrap(); + let mut big: Vec = (0..(600 * 1024 * 4)).map(|i| (i * 7 % 251) as u8).collect(); + for px in big.as_chunks_mut::<4>().0 { + px[3] = 255; + } + let mut one = big.clone(); + t.apply_premultiplied(&mut one); + convert(Some(&t), &mut big); + assert_eq!(big, one); + assert_eq!(color32(None, egui::Color32::RED), egui::Color32::RED); + let [r, g, b] = t.apply8([255, 0, 0]); + assert_eq!(color32(Some(&t), egui::Color32::RED), egui::Color32::from_rgb(r, g, b)); + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1fb96d9c7d..6aa3de6752 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2621,6 +2621,11 @@ digits Malayalam Малаялам Rating: Оценка: Real World: Реален свят: Real-time Drawing and Editing Чертане и редактиране в реално време + Use Monitor Profile for Canvas Използване на профила на монитора за платното + The canvas is drawn in sRGB. Платното се изобразява в sRGB. + Canvas display profile: Профил на дисплея за платното: + The system matches the window to the display's colors. Системата съгласува цветовете на прозореца с дисплея. + No usable display profile was found: the canvas is drawn in sRGB. Не е намерен използваем профил на дисплея: платното се изобразява в sRGB. Rearrange All Artboards Пренареждане на всички работни области Rearrange Пренареждане Select All Artboards Избиране на всички работни области diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index f04e9535e5..126081f29d 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -3824,6 +3824,11 @@ theme Appearance Izgled Primary Primarno Printing / Exporting Štampanje / izvoz Real-time Drawing and Editing Crtanje i uređivanje u stvarnom vremenu + Use Monitor Profile for Canvas Koristi profil monitora za platno + The canvas is drawn in sRGB. Platno se iscrtava u sRGB-u. + Canvas display profile: Profil ekrana za platno: + The system matches the window to the display's colors. Sistem prilagođava boje prozora ekranu. + No usable display profile was found: the canvas is drawn in sRGB. Nije pronađen upotrebljiv profil ekrana: platno se iscrtava u sRGB-u. Render Threads (-1 = Automatic) Niti iscrtavanja (-1 = automatski) Reset Preferences Vrati postavke na početne Export Preferences… Izvezi postavke… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index e6b6f398f2..f4c8ad9b2c 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3395,6 +3395,11 @@ digits Malayalam Malaiàlam Real name Nom real Real World: Món real: Real-time Drawing and Editing Dibuix i edició en temps real + Use Monitor Profile for Canvas Utilitza el perfil del monitor per al llenç + The canvas is drawn in sRGB. El llenç es dibuixa en sRGB. + Canvas display profile: Perfil de pantalla del llenç: + The system matches the window to the display's colors. El sistema ajusta la finestra als colors de la pantalla. + No usable display profile was found: the canvas is drawn in sRGB. No s’ha trobat cap perfil de pantalla utilitzable: el llenç es dibuixa en sRGB. Rearrange Reorganitza Select All Artboards Selecciona tots els taulers Switch Orientation Canvia l’orientació diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index a44e3b9979..0670f6b2af 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3880,6 +3880,11 @@ theme Appearance Motiv Primary Primární Printing / Exporting Tisk / export Real-time Drawing and Editing Kreslení a úpravy v reálném čase + Use Monitor Profile for Canvas Použít profil monitoru pro plátno + The canvas is drawn in sRGB. Plátno se vykresluje v sRGB. + Canvas display profile: Profil displeje pro plátno: + The system matches the window to the display's colors. Systém přizpůsobuje okno barvám displeje. + No usable display profile was found: the canvas is drawn in sRGB. Nebyl nalezen použitelný profil displeje: plátno se vykresluje v sRGB. Render Threads (-1 = Automatic) Vlákna vykreslování (-1 = automaticky) Reset Preferences Obnovit předvolby Export Preferences… Exportovat předvolby… diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index bce3bd12da..865113ba2f 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3599,6 +3599,11 @@ theme Appearance Udseende Primary Primær Printing / Exporting Udskrivning / eksport Real-time Drawing and Editing Tegning og redigering i realtid + Use Monitor Profile for Canvas Brug skærmprofil til canvas + The canvas is drawn in sRGB. Canvasset tegnes i sRGB. + Canvas display profile: Canvassets skærmprofil: + The system matches the window to the display's colors. Systemet tilpasser vinduet til skærmens farver. + No usable display profile was found: the canvas is drawn in sRGB. Der blev ikke fundet nogen brugbar skærmprofil: canvasset tegnes i sRGB. Render Threads (-1 = Automatic) Renderingstråde (-1 = automatisk) Reset Preferences Nulstil indstillinger Export Preferences… Eksportér præferencer… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0d995ddc2e..661d318afe 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2665,6 +2665,11 @@ digits Malayalam Malayalam Rating: Bewertung: Real World: Reale Welt: Real-time Drawing and Editing Zeichnen und Bearbeiten in Echtzeit + Use Monitor Profile for Canvas Monitorprofil für Arbeitsfläche verwenden + The canvas is drawn in sRGB. Die Arbeitsfläche wird in sRGB dargestellt. + Canvas display profile: Bildschirmprofil der Arbeitsfläche: + The system matches the window to the display's colors. Das System passt das Fenster an die Farben des Bildschirms an. + No usable display profile was found: the canvas is drawn in sRGB. Kein verwendbares Bildschirmprofil gefunden: Die Arbeitsfläche wird in sRGB dargestellt. Rearrange All Artboards Alle Zeichenflächen neu anordnen Rearrange Neu anordnen Select All Artboards Alle Zeichenflächen auswählen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index cb409e9274..0c81df1fec 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3429,6 +3429,11 @@ digits Malayalam Μαλαγιάλαμ Rating: Αξιολόγηση: Real World: Πραγματικός κόσμος: Real-time Drawing and Editing Σχεδίαση και επεξεργασία σε πραγματικό χρόνο + Use Monitor Profile for Canvas Χρήση προφίλ οθόνης για τον καμβά + The canvas is drawn in sRGB. Ο καμβάς σχεδιάζεται σε sRGB. + Canvas display profile: Προφίλ οθόνης καμβά: + The system matches the window to the display's colors. Το σύστημα προσαρμόζει το παράθυρο στα χρώματα της οθόνης. + No usable display profile was found: the canvas is drawn in sRGB. Δεν βρέθηκε κατάλληλο προφίλ οθόνης: ο καμβάς σχεδιάζεται σε sRGB. Rearrange All Artboards Αναδιάταξη όλων των πινάκων σχεδίασης Rearrange Αναδιάταξη Select All Artboards Επιλογή όλων των πινάκων σχεδίασης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index de9512cddb..17ab676451 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2699,6 +2699,11 @@ digits Malayalam Malayálam Rating: Clasificación: Real World: Mundo real: Real-time Drawing and Editing Dibujo y edición en tiempo real + Use Monitor Profile for Canvas Usar perfil del monitor para el lienzo + The canvas is drawn in sRGB. El lienzo se dibuja en sRGB. + Canvas display profile: Perfil de pantalla del lienzo: + The system matches the window to the display's colors. El sistema ajusta la ventana a los colores de la pantalla. + No usable display profile was found: the canvas is drawn in sRGB. No se encontró ningún perfil de pantalla utilizable: el lienzo se dibuja en sRGB. Rearrange All Artboards Reorganizar todas las mesas de trabajo Rearrange Reorganizar Select All Artboards Seleccionar todas las mesas de trabajo diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1c3327fb1e..63ddc7cf4e 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3804,6 +3804,11 @@ theme Appearance Ulkoasu Primary Ensisijainen Printing / Exporting Tulostus / vienti Real-time Drawing and Editing Reaaliaikainen piirto ja muokkaus + Use Monitor Profile for Canvas Käytä näytön profiilia kankaalle + The canvas is drawn in sRGB. Kangas piirretään sRGB-tilassa. + Canvas display profile: Kankaan näyttöprofiili: + The system matches the window to the display's colors. Järjestelmä sovittaa ikkunan näytön väreihin. + No usable display profile was found: the canvas is drawn in sRGB. Käyttökelpoista näyttöprofiilia ei löytynyt: kangas piirretään sRGB-tilassa. Render Threads (-1 = Automatic) Renderöintisäikeet (-1 = automaattinen) Reset Preferences Palauta asetukset Export Preferences… Vie asetukset… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3b45c4d386..9d0c9ece99 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2691,6 +2691,11 @@ digits Malayalam Malayalam Rating: Classement : Real World: Monde réel : Real-time Drawing and Editing Dessin et édition en temps réel + Use Monitor Profile for Canvas Utiliser le profil du moniteur pour la zone de travail + The canvas is drawn in sRGB. La zone de travail est affichée en sRGB. + Canvas display profile: Profil d’affichage de la zone de travail : + The system matches the window to the display's colors. Le système adapte la fenêtre aux couleurs de l’écran. + No usable display profile was found: the canvas is drawn in sRGB. Aucun profil d’affichage utilisable n’a été trouvé : la zone de travail est affichée en sRGB. Rearrange All Artboards Réorganiser tous les plans de travail Rearrange Réorganiser Select All Artboards Sélectionner tous les plans de travail diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 0c6089e40a..946d9729c8 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3713,6 +3713,11 @@ antiAlias Sharp शार्प Real name वास्तविक नाम Real World: रियल वर्ल्ड: Real-time Drawing and Editing रियल टाइम ड्राइंग और संपादन + Use Monitor Profile for Canvas कैनवास के लिए मॉनिटर प्रोफ़ाइल का उपयोग करें + The canvas is drawn in sRGB. कैनवास sRGB में बनाया जाता है। + Canvas display profile: कैनवास की डिस्प्ले प्रोफ़ाइल: + The system matches the window to the display's colors. सिस्टम विंडो को डिस्प्ले के रंगों से मिलाता है। + No usable display profile was found: the canvas is drawn in sRGB. कोई उपयोगी डिस्प्ले प्रोफ़ाइल नहीं मिली: कैनवास sRGB में बनाया जाता है। Rearrange फिर से व्यवस्थित करें Select All Artboards सभी आर्टबोर्ड सिलेक्ट करें Switch Orientation ओरिएंटेशन स्विच करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 06122f94ee..7563432878 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3255,6 +3255,11 @@ mark-style Japanese Japán Real name Valódi név Real World: Valós méret: Real-time Drawing and Editing Valós idejű rajzolás és szerkesztés + Use Monitor Profile for Canvas Monitorprofil használata a vászonhoz + The canvas is drawn in sRGB. A vászon sRGB-ben jelenik meg. + Canvas display profile: A vászon kijelzőprofilja: + The system matches the window to the display's colors. A rendszer a kijelző színeihez igazítja az ablakot. + No usable display profile was found: the canvas is drawn in sRGB. Nem található használható kijelzőprofil: a vászon sRGB-ben jelenik meg. Rearrange Átrendezés Select All Artboards Összes rajztábla kijelölése Switch Orientation Tájolás váltása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 00d2b09600..13346dc437 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3599,6 +3599,11 @@ theme Appearance Tampilan Primary Utama Printing / Exporting Pencetakan / Ekspor Real-time Drawing and Editing Penggambaran dan pengeditan waktu nyata + Use Monitor Profile for Canvas Gunakan profil monitor untuk kanvas + The canvas is drawn in sRGB. Kanvas digambar dalam sRGB. + Canvas display profile: Profil layar kanvas: + The system matches the window to the display's colors. Sistem menyesuaikan jendela dengan warna layar. + No usable display profile was found: the canvas is drawn in sRGB. Tidak ditemukan profil layar yang dapat digunakan: kanvas digambar dalam sRGB. Render Threads (-1 = Automatic) Utas render (-1 = Otomatis) Reset Preferences Atur ulang preferensi Export Preferences… Ekspor preferensi… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 869fe2e4a0..107679a970 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2741,6 +2741,11 @@ digits Malayalam Malayalam Rating: Valutazione: Real World: Mondo reale: Real-time Drawing and Editing Disegno e modifica in tempo reale + Use Monitor Profile for Canvas Usa il profilo del monitor per l'area di disegno + The canvas is drawn in sRGB. L'area di disegno è visualizzata in sRGB. + Canvas display profile: Profilo del display per l'area di disegno: + The system matches the window to the display's colors. Il sistema adatta la finestra ai colori del display. + No usable display profile was found: the canvas is drawn in sRGB. Nessun profilo del display utilizzabile trovato: l'area di disegno è visualizzata in sRGB. Rearrange All Artboards Ridisponi tutte le tavole da disegno Rearrange Ridisponi Select All Artboards Seleziona tutte le tavole da disegno diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a0ce9c93b9..ff0d3c4bae 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2789,6 +2789,11 @@ mark-style Japanese 日本式 Rating: 評価: Real World: 実寸: Real-time Drawing and Editing リアルタイム描画および編集 + Use Monitor Profile for Canvas キャンバスにモニタープロファイルを使用 + The canvas is drawn in sRGB. キャンバスは sRGB で描画されます。 + Canvas display profile: キャンバスのディスプレイプロファイル: + The system matches the window to the display's colors. システムがウィンドウをディスプレイのカラーに合わせます。 + No usable display profile was found: the canvas is drawn in sRGB. 使用できるディスプレイプロファイルが見つかりません。キャンバスは sRGB で描画されます。 Rearrange All Artboards すべてのアートボードを再配置 Rearrange 再配置 Select All Artboards すべてのアートボードを選択 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index b19ed4009a..bf6d6ba36c 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3443,6 +3443,11 @@ theme Appearance იერსახე Primary ძირითადი Printing / Exporting ბეჭდვა / ექსპორტირება Real-time Drawing and Editing რეალურ დროში ხატვა და რედაქტირება + Use Monitor Profile for Canvas მონიტორის პროფილის გამოყენება ტილოსთვის + The canvas is drawn in sRGB. ტილო sRGB-ში იხატება. + Canvas display profile: ტილოს ეკრანის პროფილი: + The system matches the window to the display's colors. სისტემა ფანჯარას ეკრანის ფერებს უსადაგებს. + No usable display profile was found: the canvas is drawn in sRGB. გამოსაყენებელი ეკრანის პროფილი ვერ მოიძებნა: ტილო sRGB-ში იხატება. Render Threads (-1 = Automatic) ნაკადები (-1 = ავტომატური) Reset Preferences პარამეტრების ჩამოყრა Export Preferences… პარამეტრების ექსპორტირება… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3f518f00c4..95f70cfe62 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2779,6 +2779,11 @@ mark-style Japanese 일본식 Rating: 평점: Real World: 실제 크기: Real-time Drawing and Editing 실시간 그리기 및 편집 + Use Monitor Profile for Canvas 캔버스에 모니터 프로필 사용 + The canvas is drawn in sRGB. 캔버스는 sRGB로 그려집니다. + Canvas display profile: 캔버스 디스플레이 프로필: + The system matches the window to the display's colors. 시스템이 창을 디스플레이 색상에 맞춥니다. + No usable display profile was found: the canvas is drawn in sRGB. 사용할 수 있는 디스플레이 프로필이 없습니다. 캔버스는 sRGB로 그려집니다. Rearrange All Artboards 모든 아트보드 재정렬 Rearrange 재정렬 Select All Artboards 모든 아트보드 선택 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 40facfda42..929a950f4c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4114,6 +4114,11 @@ theme Appearance Vormgeving Primary Primair Printing / Exporting Afdrukken / exporteren Real-time Drawing and Editing Tekenen en bewerken in realtime + Use Monitor Profile for Canvas Monitorprofiel gebruiken voor canvas + The canvas is drawn in sRGB. Het canvas wordt in sRGB getekend. + Canvas display profile: Weergaveprofiel van het canvas: + The system matches the window to the display's colors. Het systeem stemt het venster af op de kleuren van het scherm. + No usable display profile was found: the canvas is drawn in sRGB. Geen bruikbaar weergaveprofiel gevonden: het canvas wordt in sRGB getekend. Render Threads (-1 = Automatic) Renderthreads (-1 = automatisch) Reset Preferences Voorkeuren herstellen Export Preferences… Voorkeuren exporteren… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 770622cc1e..162dc2c65e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3743,6 +3743,11 @@ theme Appearance Wygląd Primary Główny Printing / Exporting Drukowanie / eksportowanie Real-time Drawing and Editing Rysowanie i edycja w czasie rzeczywistym + Use Monitor Profile for Canvas Użyj profilu monitora dla płótna + The canvas is drawn in sRGB. Płótno jest rysowane w sRGB. + Canvas display profile: Profil wyświetlacza płótna: + The system matches the window to the display's colors. System dopasowuje okno do kolorów wyświetlacza. + No usable display profile was found: the canvas is drawn in sRGB. Nie znaleziono użytecznego profilu wyświetlacza: płótno jest rysowane w sRGB. Render Threads (-1 = Automatic) Wątki renderowania (-1 = automatycznie) Reset Preferences Resetuj preferencje Export Preferences… Eksportuj preferencje… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 77fce38c53..d419046e02 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2256,6 +2256,11 @@ digits Malayalam Malaiala Rating: Avaliação: Real World: Mundo Real: Real-time Drawing and Editing Desenho e Edição em Tempo Real + Use Monitor Profile for Canvas Usar Perfil do Monitor para a Tela + The canvas is drawn in sRGB. A tela é desenhada em sRGB. + Canvas display profile: Perfil do monitor para a tela: + The system matches the window to the display's colors. O sistema ajusta a janela às cores do monitor. + No usable display profile was found: the canvas is drawn in sRGB. Nenhum perfil de monitor utilizável foi encontrado: a tela é desenhada em sRGB. Rearrange All Artboards Reorganizar Todas as Mesas de Trabalho Rearrange Reorganizar Select All Artboards Selecionar Todas as Mesas de Trabalho diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b546e87258..53fc1703f5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2728,6 +2728,11 @@ digits Malayalam Малаялам Rating: Рейтинг: Real World: Реальный мир: Real-time Drawing and Editing Рисование и редактирование в реальном времени + Use Monitor Profile for Canvas Использовать профиль монитора для холста + The canvas is drawn in sRGB. Холст отображается в sRGB. + Canvas display profile: Профиль дисплея для холста: + The system matches the window to the display's colors. Система согласует цвета окна с дисплеем. + No usable display profile was found: the canvas is drawn in sRGB. Подходящий профиль дисплея не найден: холст отображается в sRGB. Rearrange All Artboards Переставить все монтажные области Rearrange Переставить Select All Artboards Выделить все монтажные области diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 9271ac4bac..4aa386976e 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2636,6 +2636,11 @@ digits Malayalam Malayalam Rating: Betyg: Real World: Verkligt mått: Real-time Drawing and Editing Ritning och redigering i realtid + Use Monitor Profile for Canvas Använd skärmprofil för arbetsytan + The canvas is drawn in sRGB. Arbetsytan ritas i sRGB. + Canvas display profile: Arbetsytans skärmprofil: + The system matches the window to the display's colors. Systemet anpassar fönstret till skärmens färger. + No usable display profile was found: the canvas is drawn in sRGB. Ingen användbar skärmprofil hittades: arbetsytan ritas i sRGB. Rearrange All Artboards Ordna om alla ritytor Rearrange Ordna om Select All Artboards Markera alla ritytor diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 605ff0382b..16c8213204 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -208,6 +208,22 @@ fn marquee_selection_labels_are_in_complete_catalogs() { } } +/// Preferences › Performance › Use Monitor Profile for Canvas and the note under it. +#[test] +fn monitor_profile_labels_are_in_complete_catalogs() { + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + for label in [ + "Use Monitor Profile for Canvas", + "The canvas is drawn in sRGB.", + "Canvas display profile:", + "The system matches the window to the display's colors.", + "No usable display profile was found: the canvas is drawn in sRGB.", + ] { + assert!(lang.catalog().plain(label).is_some(), "{} is missing {label}", lang.code); + } + } +} + /// The Control bar panel menu's items, its groups of options and the edges, and the label of the /// command that turns a group on or off. #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 107f30f3ea..6482f0cb65 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2507,6 +2507,11 @@ digits Malayalam Malayalam Rating: Değerlendirme: Real World: Gerçek Dünya: Real-time Drawing and Editing Gerçek Zamanlı Çizim ve Düzenleme + Use Monitor Profile for Canvas Tuval için Monitör Profilini Kullan + The canvas is drawn in sRGB. Tuval sRGB olarak çizilir. + Canvas display profile: Tuvalin ekran profili: + The system matches the window to the display's colors. Sistem pencereyi ekranın renklerine uyarlar. + No usable display profile was found: the canvas is drawn in sRGB. Kullanılabilir ekran profili bulunamadı: tuval sRGB olarak çizilir. Rearrange All Artboards Tüm Çalışma Yüzeylerini Yeniden Düzenleme Rearrange Yeniden Düzenle Select All Artboards Tüm Çalışma Yüzeylerini Seç diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 68aabf0253..d0873a63fe 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2733,6 +2733,11 @@ digits Malayalam Малаялам Rating: Рейтинг: Real World: Реальний світ: Real-time Drawing and Editing Малювання та редагування у реальному часі + Use Monitor Profile for Canvas Використовувати профіль монітора для полотна + The canvas is drawn in sRGB. Полотно відображається в sRGB. + Canvas display profile: Профіль дисплея для полотна: + The system matches the window to the display's colors. Система узгоджує кольори вікна з дисплеєм. + No usable display profile was found: the canvas is drawn in sRGB. Придатний профіль дисплея не знайдено: полотно відображається в sRGB. Rearrange All Artboards Переставити всі монтажні області Rearrange Переставити Select All Artboards Виділити всі монтажні області diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 36793e6c56..71896fee27 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3043,6 +3043,11 @@ mark-style Japanese Kiểu Nhật Real name Tên thật Real World: Thực tế: Real-time Drawing and Editing Vẽ và chỉnh sửa theo thời gian thực + Use Monitor Profile for Canvas Dùng hồ sơ màn hình cho vùng vẽ + The canvas is drawn in sRGB. Vùng vẽ được hiển thị ở sRGB. + Canvas display profile: Hồ sơ màn hình của vùng vẽ: + The system matches the window to the display's colors. Hệ thống điều chỉnh cửa sổ theo màu của màn hình. + No usable display profile was found: the canvas is drawn in sRGB. Không tìm thấy hồ sơ màn hình dùng được: vùng vẽ được hiển thị ở sRGB. Rearrange Sắp xếp lại Select All Artboards Chọn tất cả các bảng vẽ Switch Orientation Chuyển hướng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 27cad80b4a..02e23831ff 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2597,6 +2597,11 @@ digits Malayalam 马拉雅拉姆数字 Rating: 等级: Real World: 真实世界: Real-time Drawing and Editing 实时绘图和编辑 + Use Monitor Profile for Canvas 画布使用显示器配置文件 + The canvas is drawn in sRGB. 画布以 sRGB 绘制。 + Canvas display profile: 画布显示配置文件: + The system matches the window to the display's colors. 系统会使窗口匹配显示器的颜色。 + No usable display profile was found: the canvas is drawn in sRGB. 未找到可用的显示器配置文件:画布以 sRGB 绘制。 Rearrange All Artboards 重新排列所有画板 Rearrange 重新排列 Select All Artboards 选择所有画板 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ab1fd359cd..42caecb668 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3796,6 +3796,11 @@ digits Malayalam 馬來亞拉姆數字 Rating: 評等: Real World: 真實世界: Real-time Drawing and Editing 即時繪圖與編輯 + Use Monitor Profile for Canvas 畫布使用顯示器描述檔 + The canvas is drawn in sRGB. 畫布以 sRGB 繪製。 + Canvas display profile: 畫布顯示描述檔: + The system matches the window to the display's colors. 系統會讓視窗符合顯示器的色彩。 + No usable display profile was found: the canvas is drawn in sRGB. 找不到可用的顯示器描述檔:畫布以 sRGB 繪製。 Rearrange All Artboards 重新排列所有工作區域 Rearrange 重新排列 Select All Artboards 選取所有工作區域 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c4c6ded189..2bc505ee2d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -34,6 +34,7 @@ pub mod credits; pub mod cursors; mod custom_toolbars; pub mod dialogs; +pub mod display_profile; pub mod doc_windows; pub mod dock; mod edit_toolbar; @@ -144,6 +145,9 @@ pub struct VectorcraftApp { clipboard_probe: Option, pub services: Services, pub canvas: CanvasCache, + /// The main window's display profile, which the canvas is drawn through + /// ([`display_profile`]); the desktop app reports what it reads into it. + pub display_profile: display_profile::DisplayProfile, pub perf: Perf, /// Docking state that lives for the session only. pub(crate) dock_rt: panel_docking::Runtime, diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index a6d1828ac1..fbea47770b 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -66,6 +66,7 @@ fn settings() -> impl Iterator { "systemTitleBar" => SYSTEM_TITLE_BAR, "appTitleBar" => APP_TITLE_BAR_SHOWN, "linuxDisplayServer" => DISPLAY_SERVER_CHOICE, + "monitorProfileDisplay" => !cfg!(target_arch = "wasm32"), _ => true, }; let prefs = @@ -557,6 +558,15 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, ui.label(egui::RichText::new(note).color(t.text_dim).size(11.0)); } } + // The desktop app only: the web build has no display profile to read. A note says + // which profile the canvas is drawn through, or why none. + PrefKind::Bool if sp.key == "monitorProfileDisplay" => { + if !cfg!(target_arch = "wasm32") { + bool_row(ui, d, sp.key, sp.label); + let note = app.display_profile.note(d.bool(sp.key)); + ui.label(egui::RichText::new(note).color(t.text_dim).size(11.0)); + } + } PrefKind::Bool => bool_row(ui, d, sp.key, sp.label), PrefKind::Num { min, max, unit } => { labeled(ui, sp.label, |ui, label_id| { diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index 40478e36ed..50d5bc087c 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -33,6 +33,9 @@ pub struct Job { /// The worker may keep the frame for its next frame to copy from /// ([`crate::canvas::render_job`]). Off: the worker holds no frame after this one. pub may_keep: bool, + /// The display profile the finished frame is converted into ([`crate::display_profile`]). + /// The frame the worker keeps stays sRGB. + pub display: Option>, } pub struct Done { diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 38adb78090..37eae066c5 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -28,7 +28,7 @@ It reads the pen's tilt and barrel rotation on macOS and on Linux under X11 and | Trackpad (smooth scroll, pinch) | yes | yes | precision touchpads | yes | yes | done | | HiDPI / Retina, per-monitor scale | yes | yes | yes | yes | yes | done; a window dragged between monitors of different scale can still grow on Windows (#1459, open); the custom title bar's window on Windows stays truly borderless so the pointer lines up with what is drawn (#1791, #1758; awaiting the reporter's confirmation) ; macOS cursors use aligned 1×/2× native image representations (#2292), covered by headless construction tests; mixed-display transitions and physical cursor sizing remain unverified, no additional hardware validated | | Multiple monitors: panels and document windows on another screen | yes | no (one OS window) | no | no | — | missing: 6–10 h (with multiple windows, gap G7) | -| Display colour profile (proof and display through the monitor's ICC) | yes | no | no | no | — | missing: 3–5 h | +| Display colour profile (proof and display through the monitor's ICC) | yes | yes: the window is tagged sRGB and macOS converts it | yes: the display's default ICC profile, Windows' own conversion with Advanced Color on | X11: colord or `_ICC_PROFILE`; Wayland: sRGB | — | done on macOS, Windows and X11; Wayland and floating document windows remain | | 3D GPU rendering (Materials, ray tracing) | yes | — | — | — | — | blocked on 3D (gap G3) | ## Revision history diff --git a/docs/mcp.md b/docs/mcp.md index 41e28969f4..c9a5ed70b6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5550,6 +5550,21 @@ desktop app): in View › Pixel Preview, on softens placed and embedded bitmaps; `disableAutoAddDelete` (off by default): on, the Pen no longer adds or deletes an anchor when a click lands on a segment or an anchor of a selected path. +`monitorProfileDisplay` (Preferences › Performance › Use Monitor Profile for Canvas, on by default; desktop app) +converts the canvas from sRGB into the ICC profile of the display that shows most of the main window, as the last +step before it is drawn: the art, the artboards' paper and the transparency grid. On Windows the profile is the +display's default profile for its standard color mode; on X11 it is colord's profile for the RandR output, then the +output's `_ICC_PROFILE` property when colord has no usable profile, then the screen's when only one output is active. A display with Windows Advanced +Color on (HDR or Automatic Color Management) and macOS windows are converted by the system, and nothing is converted +again. In a Wayland session, and where no profile is set or the profile is not an RGB display profile of at most 1 MiB, +the canvas stays sRGB. The app reads the display again every 10 seconds and when the window moves to another display. +Exports, `ui.render`, the document, panels and thumbnails are not converted; with Proof Colors on, the proof is drawn +first and then converted. The art is converted before it is drawn over the converted paper, so the blend of a +translucent object or an antialiased edge with the paper can differ slightly from a conversion of the blended color. +`ui.inspect` returns `displayProfile`: `{enabled, display, source, profile, from, applied, reason, +osManaged}`, where `source` is `profile`, `srgbProfile` (a profile that shows sRGB unchanged), `system`, `none`, +`pending` or `unsupported`, `applied` says whether the canvas is converted, and `reason` says why not. + `ctrlWheelScrollsSideways` (on by default): on, Cmd/Ctrl with a mouse wheel's whole notches scrolls the canvas sideways instead of zooming while `zoomWithMouseWheel` is off; off, Cmd/Ctrl-wheel zooms about the pointer. Alt + Cmd/Ctrl-wheel and a pinch on a Mac trackpad, on a touch screen or in the web build still zoom about the pointer. Under either diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d9783b0216..9c6e7946c8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -704,6 +704,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. - **Document Setup › Color:** a Color tab shows the document color mode (RGB Color or CMYK Color), a Convert to CMYK or Convert to RGB box for a mode change (cleared: the colors keep their numbers), and the RGB and CMYK profiles assigned to the document, with a working-space entry for none. OK runs `edit.assignProfile` and then `file.documentColorMode` with the dialog's other changes in one `command.batch`, and the document's changes are one undo step. An unchanged Color tab adds no color commands, and Cancel, or OK with nothing changed, records no undo step. A Color tab field with a value of the wrong kind is an error, and the dialog stays open. The tab is VectorCraft's own layout; File › Document Color Mode and Edit › Assign Profile… stay as they are. Tests in `dialogs/document_setup.rs`. - **Assign Profile and the working spaces:** Edit › Assign Profile… tags the document and leaves the working spaces of Edit › Color Settings… as they are, in Color Settings, the Separations panel and the settings saved for the next start. Colors show and convert through the profiles assigned; a change in Color Settings keeps the profiles the document in front is assigned in use, and removing an assigned profile in use puts the working space back in its place. View › Proof Setup › Working CMYK simulates the working CMYK space of Color Settings in Proof Colors and Separations Preview, also in a document assigned another CMYK profile. Public references: [Changing the color profile for a document and About color working spaces](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=150) and [Soft-proof presets](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=134). Tests in `engine/tests/color_settings.rs`. +- **Canvas through the display's profile on Windows and X11:** Preferences › Performance › Use Monitor Profile for Canvas (on by default) converts the canvas's art, artboard paper and transparency grid from sRGB into the ICC profile of the display that shows most of the main window, after any proof, as the help page describes the monitor's profile translating the document's colors for the monitor ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=151)). Windows gives the display's default profile, and a display with Advanced Color on is left to Windows ([Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/wcs/advanced-color-icc-profiles)); X11 gives colord's profile for the output or the `_ICC_PROFILE` property. macOS converts the window itself, and a Wayland session stays sRGB. `ui.inspect` reports `displayProfile`. Tests in `color/src/cms/display.rs`, `ui-egui/src/display_profile.rs` and `canvas::tests::the_canvas_is_drawn_through_the_display_profile_while_the_preference_is_on`. - **New Document's More Settings and Save Preset:** More Settings opens a pop-up over New Document with only Number of Artboards, the artboard layout, Change to Right-to-Left Layout, Spacing and Columns; Done, Escape or a click outside closes it and keeps the values. Save Preset turns the top of Preset Details into a Save Document Preset heading with the preset's name, Cancel and Save, and hides the document's name, Close and Create until the preset is saved or canceled; nothing in that mode creates a document. Save Preset's default name is the first "Preset N" no saved preset has. VectorCraft's own layout. Harness tests in `dialogs/new_document.rs` cover both. - **New Document profiles:** each built-in category's presets start documents from that category's profile, a native document holding the starting swatches, graphic styles, brushes, symbols and other assets, the ruler and Document Setup options and the print settings ([Create a custom new document profile](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/create-a-custom-new-document-profile.html)). New Document's Manage Profiles pop-up opens a profile to edit and resets one to the shipped profile (`file.newProfiles`, `file.newProfiles.edit`, `file.newProfiles.reset`; `file.new {profile}`). The profiles live in VectorCraft's own `New Document Profiles` folder next to the preferences; a profile that cannot be read or is larger than 64 MiB leaves the defaults and is reported. Tests: `tests::newdoc::an_edited_profile_starts_the_documents_of_its_category`, `the_profile_follows_the_preset_access_and_reset`, `a_damaged_or_oversized_profile_leaves_the_defaults_and_is_reported`, `profile_names_are_checked_and_editing_needs_the_folder`, `dialogs::new_document::tests::manage_profiles_edits_and_resets_the_profiles`. - **Control bar with the Artboard tool:** while the Artboard tool is in use, the Control bar shows the active artboard's controls in place of the object controls: Preset, Portrait and Landscape, New Artboard, Delete Artboard, the Name field, Artboard Options, Rearrange All Artboards, the Transform link, Move and Scale Artwork with Artboard and, while the bar has room, the reference point with X and Y, and W and H with the Constrain Width and Height Proportions link. Each runs the command its Properties or Transform panel counterpart runs (`artboard.setProps`, `artboard.new`, `artboard.delete`, `tool.options {tool: "artboard"}`), and the reference point is the one the Transform panel uses. In an 800-point window the controls up to the Transform link fit, Move and Scale Artwork with Artboard show as their boxes alone with their names in tooltips, and the link opens the Transform panel with the artboard's position and size. Pointer and key tests in `panels/artboards.rs` cover the controls, W about the reference point, the orientation and the name. Public reference: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy).