mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-11 02:49:56 -06:00
LR-PREF-DISPLAY: Windows previews follow the monitor's colour profile
While no monitor profile is chosen in Settings > Display, Windows now shows previews through the profile Windows Color System assigns to the monitor showing the window (per-user or system), as macOS and Linux already do. It is read about once a second, so moving the window to another monitor and changing the profile in Windows are followed without a restart. While Windows colour-manages the desktop itself (HDR, Auto Color Management), previews stay sRGB. The Win32 calls live in a new L0 helper crate, crates/wincolor, the second crate allowed `unsafe` after crates/sysmem (SAFETY comments on every block, safe API, Unknown on any failure); CLAUDE.md and AGENTS.md name it. GDI's GetICMProfileW keeps the profile a process first saw, so WCS is read directly and GetICMProfileW is only a fallback. The profile-file loader moves from the Linux-only colord.rs to a shared profile_file.rs (unchanged), with a cache that re-reads a file only when it changes. Verified on Windows 11 by setting and clearing a Display P3 profile and toggling HDR while the app ran. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K93HTyCiwYvbRGaAkjPvXn
This commit is contained in:
@@ -40,9 +40,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
"unsupported" error or is disabled. Sole exception: a provably infallible literal, as `#[allow(clippy::expect_used)]`
|
||||
+ `.expect("why it can't fail")`.
|
||||
- **`unsafe` lives only in `crates/sysmem`** (one FFI call, `malloc_zone_pressure_relief`, that returns freed
|
||||
allocator pages to macOS after raw decodes). Every other production crate root has `#![forbid(unsafe_code)]`. A new
|
||||
unsafe need goes in an isolated, well-tested helper crate like it: `// SAFETY:` on every block, a safe API, a safe
|
||||
fallback where possible, and a line here naming it.
|
||||
allocator pages to macOS after raw decodes) **and `crates/wincolor`** (Win32 calls that read the colour profile
|
||||
Windows assigns to the monitor showing the window, and whether Windows colour-manages that monitor itself). Every
|
||||
other production crate root has `#![forbid(unsafe_code)]`. A new unsafe need goes in an isolated, well-tested helper
|
||||
crate like them: `// SAFETY:` on every block, a safe API, a safe fallback where possible, and a line here naming it.
|
||||
- **Input-derived numbers are hostile:** `get()` instead of `[i]`/`[a..b]` for offsets from files, users, agents or
|
||||
arithmetic on them; checked/saturating math for lengths, offsets and counts; no division by zero, NaN/inf or negative
|
||||
casts to `usize`; cap allocations sized by input; slice strings only at char boundaries.
|
||||
|
||||
@@ -41,9 +41,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
"unsupported" error or is disabled. Sole exception: a provably infallible literal, as `#[allow(clippy::expect_used)]`
|
||||
+ `.expect("why it can't fail")`.
|
||||
- **`unsafe` lives only in `crates/sysmem`** (one FFI call, `malloc_zone_pressure_relief`, that returns freed
|
||||
allocator pages to macOS after raw decodes). Every other production crate root has `#![forbid(unsafe_code)]`. A new
|
||||
unsafe need goes in an isolated, well-tested helper crate like it: `// SAFETY:` on every block, a safe API, a safe
|
||||
fallback where possible, and a line here naming it.
|
||||
allocator pages to macOS after raw decodes) **and `crates/wincolor`** (Win32 calls that read the colour profile
|
||||
Windows assigns to the monitor showing the window, and whether Windows colour-manages that monitor itself). Every
|
||||
other production crate root has `#![forbid(unsafe_code)]`. A new unsafe need goes in an isolated, well-tested helper
|
||||
crate like them: `// SAFETY:` on every block, a safe API, a safe fallback where possible, and a line here naming it.
|
||||
- **Input-derived numbers are hostile:** `get()` instead of `[i]`/`[a..b]` for offsets from files, users, agents or
|
||||
arithmetic on them; checked/saturating math for lengths, offsets and counts; no division by zero, NaN/inf or negative
|
||||
casts to `usize`; cap allocations sized by input; slice strings only at char boundaries.
|
||||
|
||||
Generated
+10
@@ -3088,12 +3088,14 @@ dependencies = [
|
||||
"lightcraft-sysmem",
|
||||
"lightcraft-testkit",
|
||||
"lightcraft-ui-egui",
|
||||
"lightcraft-wincolor",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"pollster 1.0.1",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"winit",
|
||||
@@ -3553,6 +3555,14 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-wincolor"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"lightcraft-testkit",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linebender_resource_handle"
|
||||
version = "0.1.1"
|
||||
|
||||
@@ -18,6 +18,7 @@ lightcraft-denoise = { path = "crates/denoise", default-features = false }
|
||||
lightcraft-geom = { path = "crates/geom" }
|
||||
lightcraft-faces = { path = "crates/faces" }
|
||||
lightcraft-sysmem = { path = "crates/sysmem" }
|
||||
lightcraft-wincolor = { path = "crates/wincolor" }
|
||||
lightcraft-color = { path = "crates/color" }
|
||||
lightcraft-raster = { path = "crates/raster" }
|
||||
lightcraft-tiff = { path = "crates/tiff" }
|
||||
|
||||
@@ -43,6 +43,11 @@ rfd = { workspace = true }
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus = { workspace = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# the colour profile of the monitor showing the window (`win_screen_profile`)
|
||||
lightcraft-wincolor = { workspace = true }
|
||||
raw-window-handle = "0.6"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = { workspace = true }
|
||||
objc2 = { workspace = true }
|
||||
|
||||
@@ -18,8 +18,6 @@ use std::time::Duration;
|
||||
const POLL: Duration = Duration::from_secs(3);
|
||||
/// A wait before trying again when colord isn't there (not installed, no system bus).
|
||||
const RETRY: Duration = Duration::from_secs(30);
|
||||
/// The largest profile file read (real display profiles are a few KiB to a few MiB).
|
||||
const MAX_PROFILE: u64 = 16 << 20;
|
||||
|
||||
const SERVICE: &str = "org.freedesktop.ColorManager";
|
||||
const DEVICE: &str = "org.freedesktop.ColorManager.Device";
|
||||
@@ -72,7 +70,7 @@ fn read(conn: &zbus::blocking::Connection) -> Option<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
let file = pick(&files)?;
|
||||
load(std::path::Path::new(file))
|
||||
crate::profile_file::load(std::path::Path::new(file))
|
||||
}
|
||||
|
||||
/// The profile to use for these displays' profile files: the one they share, `None` with none or
|
||||
@@ -83,15 +81,6 @@ fn pick(files: &[String]) -> Option<&str> {
|
||||
named.all(|f| f == first).then_some(first)
|
||||
}
|
||||
|
||||
/// The bytes of profile file `path`, `None` when it can't be read or is implausibly large.
|
||||
fn load(path: &std::path::Path) -> Option<Vec<u8>> {
|
||||
use std::io::Read as _;
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_PROFILE + 1).read_to_end(&mut bytes).ok()?;
|
||||
(!bytes.is_empty() && bytes.len() as u64 <= MAX_PROFILE).then_some(bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -105,21 +94,4 @@ mod tests {
|
||||
assert_eq!(pick(&s(&["/a.icc", "/b.icc"])), None, "which one the window is on isn't known");
|
||||
assert_eq!(pick(&s(&[""])), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_files_are_read_with_a_size_cap() {
|
||||
let dir = std::env::temp_dir().join(format!("colord-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let small = dir.join("small.icc");
|
||||
std::fs::write(&small, b"acsp").unwrap();
|
||||
assert_eq!(load(&small).as_deref(), Some(&b"acsp"[..]));
|
||||
let empty = dir.join("empty.icc");
|
||||
std::fs::write(&empty, b"").unwrap();
|
||||
assert_eq!(load(&empty), None);
|
||||
assert_eq!(load(&dir.join("missing.icc")), None);
|
||||
let big = dir.join("big.icc");
|
||||
std::fs::File::create(&big).unwrap().set_len(MAX_PROFILE + 1).unwrap();
|
||||
assert_eq!(load(&big), None);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,12 @@ mod hdr_display;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod profile_file;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod screen_profile;
|
||||
#[cfg(windows)]
|
||||
mod win_screen_profile;
|
||||
mod window_gpu;
|
||||
|
||||
use dialog_filter::FileDialogExt as _;
|
||||
@@ -633,7 +637,11 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
|
||||
// Linux: the profile the desktop assigns in colord, read off the UI thread
|
||||
#[cfg(target_os = "linux")]
|
||||
screen_profile: Some(colord::service()),
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
// Windows: the profile it uses for the window's monitor, unless it colour-manages the
|
||||
// desktop itself (HDR, Auto Color Management)
|
||||
#[cfg(windows)]
|
||||
screen_profile: win_screen_profile::service(cc),
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||
screen_profile: None,
|
||||
pick_display_profile: Some(Box::new(|| {
|
||||
let mut d = file_dialog()
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Reading a display's ICC profile file for the screen-profile services (`colord` on Linux,
|
||||
//! `win_screen_profile` on Windows).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// The largest profile file read (real display profiles are a few KiB to a few MiB).
|
||||
pub const MAX_PROFILE: u64 = 16 << 20;
|
||||
|
||||
/// The bytes of profile file `path`, `None` when it can't be read or is implausibly large.
|
||||
pub fn load(path: &Path) -> Option<Vec<u8>> {
|
||||
use std::io::Read as _;
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_PROFILE + 1).read_to_end(&mut bytes).ok()?;
|
||||
(!bytes.is_empty() && bytes.len() as u64 <= MAX_PROFILE).then_some(bytes)
|
||||
}
|
||||
|
||||
/// A profile file read again only when it changes: the services are asked about once a second, and
|
||||
/// calibration software rewrites its profile under the same name.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
#[derive(Default)]
|
||||
pub struct Cached {
|
||||
/// The file last read: its path, length and modification time.
|
||||
stamp: Option<(PathBuf, u64, Option<SystemTime>)>,
|
||||
bytes: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
impl Cached {
|
||||
/// The bytes of profile file `path`, read again when it isn't the file read last time or has
|
||||
/// changed since (see [`load`]).
|
||||
pub fn read(&mut self, path: &Path) -> Option<Vec<u8>> {
|
||||
let stamp = std::fs::metadata(path).ok().map(|m| (path.to_path_buf(), m.len(), m.modified().ok()));
|
||||
if stamp.is_none() || stamp != self.stamp {
|
||||
self.bytes = stamp.as_ref().and_then(|_| load(path));
|
||||
self.stamp = stamp;
|
||||
}
|
||||
self.bytes.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn profile_files_are_read_with_a_size_cap() {
|
||||
let dir = std::env::temp_dir().join(format!("profile-file-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let small = dir.join("small.icc");
|
||||
std::fs::write(&small, b"acsp").unwrap();
|
||||
assert_eq!(load(&small).as_deref(), Some(&b"acsp"[..]));
|
||||
let empty = dir.join("empty.icc");
|
||||
std::fs::write(&empty, b"").unwrap();
|
||||
assert_eq!(load(&empty), None);
|
||||
assert_eq!(load(&dir.join("missing.icc")), None);
|
||||
let big = dir.join("big.icc");
|
||||
std::fs::File::create(&big).unwrap().set_len(MAX_PROFILE + 1).unwrap();
|
||||
assert_eq!(load(&big), None);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cached_profile_is_read_again_when_the_file_changes() {
|
||||
let dir = std::env::temp_dir().join(format!("profile-cache-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (a, b) = (dir.join("a.icc"), dir.join("b.icc"));
|
||||
std::fs::write(&a, b"first").unwrap();
|
||||
std::fs::write(&b, b"other").unwrap();
|
||||
let mut cache = Cached::default();
|
||||
assert_eq!(cache.read(&a).as_deref(), Some(&b"first"[..]));
|
||||
assert_eq!(cache.read(&a).as_deref(), Some(&b"first"[..]));
|
||||
// another monitor's profile
|
||||
assert_eq!(cache.read(&b).as_deref(), Some(&b"other"[..]));
|
||||
// rewritten under the same name (a different length, so the change shows whatever the
|
||||
// file system's time resolution)
|
||||
std::fs::write(&b, b"calibrated").unwrap();
|
||||
assert_eq!(cache.read(&b).as_deref(), Some(&b"calibrated"[..]));
|
||||
// gone: nothing, then read again once it is back
|
||||
std::fs::remove_file(&b).unwrap();
|
||||
assert_eq!(cache.read(&b), None);
|
||||
std::fs::write(&b, b"back").unwrap();
|
||||
assert_eq!(cache.read(&b).as_deref(), Some(&b"back"[..]));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! The ICC profile Windows assigns to the monitor showing LightCraft's window.
|
||||
//!
|
||||
//! On an SDR desktop Windows passes what an app draws to the monitor unchanged, so sRGB previews
|
||||
//! look oversaturated on a wide-gamut monitor and ignore a calibration. While no monitor profile is
|
||||
//! chosen in Settings ▸ Display, previews are shown through the profile Windows uses for the window's
|
||||
//! monitor (`Services::screen_profile`, see `lightcraft_wincolor`), followed when the window moves to
|
||||
//! another monitor or the profile changes in Windows' Color Management. When Windows colour-manages
|
||||
//! the desktop itself (HDR, Auto Color Management) previews stay sRGB, as Windows expects then.
|
||||
|
||||
use lightcraft_wincolor::MonitorColor;
|
||||
|
||||
/// The service the UI polls, for the window `cc` belongs to; `None` when its handle isn't a Win32 one.
|
||||
pub fn service(cc: &eframe::CreationContext<'_>) -> Option<lightcraft_ui_egui::ScreenProfileFn> {
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
let RawWindowHandle::Win32(handle) = cc.window_handle().ok()?.as_raw() else { return None };
|
||||
let hwnd = handle.hwnd.get();
|
||||
let mut file = crate::profile_file::Cached::default();
|
||||
let mut last: Option<MonitorColor> = None;
|
||||
Some(Box::new(move || {
|
||||
let answer = lightcraft_wincolor::monitor_color(hwnd);
|
||||
if last.as_ref() != Some(&answer) {
|
||||
log::info!("screen profile: {}", describe(&answer));
|
||||
last = Some(answer.clone());
|
||||
}
|
||||
match answer {
|
||||
MonitorColor::Profile(path) => file.read(&path),
|
||||
MonitorColor::ManagedByWindows | MonitorColor::NoProfile | MonitorColor::Unknown => None,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// For the log: what the window's monitor answered.
|
||||
fn describe(answer: &MonitorColor) -> String {
|
||||
match answer {
|
||||
MonitorColor::Profile(path) => format!("the monitor's profile, {}", path.display()),
|
||||
MonitorColor::ManagedByWindows => "Windows colour-manages this monitor (HDR or Auto Color Management): previews are sRGB".into(),
|
||||
MonitorColor::NoProfile => "no profile is assigned to the monitor: previews are sRGB".into(),
|
||||
MonitorColor::Unknown => "not known for this monitor: previews are sRGB".into(),
|
||||
}
|
||||
}
|
||||
@@ -248,7 +248,8 @@ pub struct Services {
|
||||
/// Open dialog for a monitor ICC profile (`.icc` / `.icm`; Settings ▸ Display; desktop only).
|
||||
pub pick_display_profile: Option<PickFiles>,
|
||||
/// The profile of the screen showing the window, used while none is chosen in Settings ▸
|
||||
/// Display (macOS, whose windows aren't colour-managed: issue #787).
|
||||
/// Display: macOS's screen profile (its windows aren't colour-managed: issue #787), colord's on
|
||||
/// Linux, and on Windows the monitor's (none while Windows colour-manages the desktop itself).
|
||||
pub screen_profile: Option<ScreenProfileFn>,
|
||||
/// Save dialog for an exported `.lcpreset` file.
|
||||
pub save_preset_file: Option<SaveFile>,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "lightcraft-wincolor"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "LightCraft wincolor (L0): the colour profile Windows assigns to the monitor showing a window. One of the two crates allowed `unsafe` (Win32 calls)."
|
||||
|
||||
# Opts out of the workspace lints only to keep `unsafe_code = "deny"` overridable for the Win32
|
||||
# module below, as `crates/sysmem` does; every other crate root has `#![forbid(unsafe_code)]`. See
|
||||
# AGENTS.md and craftrules standards/never-crash.md ("isolated helper library crate").
|
||||
[lints.rust]
|
||||
unsafe_code = "deny"
|
||||
|
||||
[lints.clippy]
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_UI_ColorSystem",
|
||||
"Win32_Devices_Display",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
# tests record why they skip (`cargo xtask ci` lists the skips)
|
||||
lightcraft-testkit = { workspace = true }
|
||||
|
||||
# the desktop window, for a test
|
||||
[target.'cfg(windows)'.dev-dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_UI_WindowsAndMessaging"] }
|
||||
@@ -0,0 +1,345 @@
|
||||
//! The colour profile Windows assigns to the monitor showing a window.
|
||||
//!
|
||||
//! On an SDR desktop Windows doesn't colour-manage ordinary windows: the values an app draws go to
|
||||
//! the monitor as they are, so on a wide-gamut monitor sRGB previews look oversaturated, and on a
|
||||
//! calibrated one the calibration is ignored. Photo apps convert to the monitor's profile themselves.
|
||||
//! LightCraft reads it here and shows previews through it while no profile is chosen in Settings ▸
|
||||
//! Display, as it does with the screen's profile on macOS and colord's on Linux.
|
||||
//!
|
||||
//! When Windows colour-manages the desktop itself (HDR, or Auto Color Management on Windows 11), it
|
||||
//! treats what an ordinary app draws as sRGB and converts it for the monitor; converting in the app
|
||||
//! too would do it twice, so the answer is then [`MonitorColor::ManagedByWindows`].
|
||||
//!
|
||||
//! The profile comes from Windows Color System, which holds what Settings ▸ Display ▸ Color profile
|
||||
//! and Color Management assign, per user or for the system: it is read afresh on every call, so a
|
||||
//! change shows without restarting. (GDI's `GetICMProfileW` answers what the profile was when the
|
||||
//! process first asked, and serves only as a fallback when the monitor's device can't be found.)
|
||||
//!
|
||||
//! The Win32 calls (`MonitorFromWindow`, `GetMonitorInfoW`, `EnumDisplayDevicesW`, the `Wcs…`
|
||||
//! profile functions, `QueryDisplayConfig` + `DisplayConfigGetDeviceInfo`) are in the `win32` module,
|
||||
//! the only place besides `crates/sysmem` where LightCraft allows `unsafe`. The API is safe, and any
|
||||
//! failure answers [`MonitorColor::Unknown`].
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// What Windows says about colour on the monitor showing a window.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum MonitorColor {
|
||||
/// Windows colour-manages this monitor's desktop (HDR or Auto Color Management): an app draws
|
||||
/// sRGB and converts nothing.
|
||||
ManagedByWindows,
|
||||
/// The ICC profile assigned to the monitor (the user's own when they chose per-user profiles,
|
||||
/// else the system's).
|
||||
Profile(PathBuf),
|
||||
/// No profile is assigned: Windows treats the monitor as sRGB.
|
||||
NoProfile,
|
||||
/// Not known: not Windows, no such window, or a call failed.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// What Windows says about colour on the monitor the window `hwnd` is mostly on (the nearest one
|
||||
/// when it is off screen or minimised). `hwnd` is the window's `HWND` as an integer; anything that
|
||||
/// isn't a window answers [`MonitorColor::Unknown`].
|
||||
pub fn monitor_color(hwnd: isize) -> MonitorColor {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
win32::monitor_color(hwnd)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = hwnd;
|
||||
MonitorColor::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` when Windows colour-manages the desktop on a display with these advanced-colour flags
|
||||
/// (`DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO`'s bit field): advanced colour enabled (bit 1: HDR, or
|
||||
/// Auto Color Management) or wide colour enforced (bit 2).
|
||||
#[cfg(any(windows, test))]
|
||||
fn managed(flags: u32) -> bool {
|
||||
flags & 0b110 != 0
|
||||
}
|
||||
|
||||
/// The text of a NUL-terminated UTF-16 buffer (all of it without a NUL).
|
||||
#[cfg(any(windows, test))]
|
||||
fn until_nul(units: &[u16]) -> &[u16] {
|
||||
units.iter().position(|&u| u == 0).and_then(|n| units.get(..n)).unwrap_or(units)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[allow(unsafe_code)]
|
||||
mod win32 {
|
||||
use super::{MonitorColor, managed, until_nul};
|
||||
use std::ffi::OsString;
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
use std::path::PathBuf;
|
||||
use windows_sys::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_sys::Win32::Foundation::{ERROR_SUCCESS, HWND};
|
||||
use windows_sys::Win32::Graphics::Gdi::{
|
||||
CreateDCW, DISPLAY_DEVICE_ACTIVE, DISPLAY_DEVICEW, DeleteDC, EnumDisplayDevicesW, GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO,
|
||||
MONITORINFOEXW, MonitorFromWindow,
|
||||
};
|
||||
use windows_sys::Win32::UI::ColorSystem::{
|
||||
CPST_NONE, CPT_ICC, GetColorDirectoryW, GetICMProfileW, WCS_PROFILE_MANAGEMENT_SCOPE_CURRENT_USER, WCS_PROFILE_MANAGEMENT_SCOPE_SYSTEM_WIDE,
|
||||
WcsGetDefaultColorProfile, WcsGetDefaultColorProfileSize, WcsGetUsePerUserProfiles,
|
||||
};
|
||||
|
||||
/// The most display paths read (a machine has a handful).
|
||||
const MAX_PATHS: u32 = 64;
|
||||
/// The most monitors looked at on one display output (several in clone mode).
|
||||
const MAX_MONITORS: u32 = 16;
|
||||
/// The device class of monitors in Windows Color System (`'mntr'`, as in an ICC header).
|
||||
const CLASS_MONITOR: u32 = u32::from_be_bytes(*b"mntr");
|
||||
/// The longest profile name read, in bytes (names are file names in the colour directory).
|
||||
const MAX_NAME_BYTES: u32 = 64 << 10;
|
||||
|
||||
pub fn monitor_color(hwnd: isize) -> MonitorColor {
|
||||
let Some(device) = monitor_device(hwnd) else { return MonitorColor::Unknown };
|
||||
if managed_by_windows(&device) == Some(true) {
|
||||
return MonitorColor::ManagedByWindows;
|
||||
}
|
||||
// (an unknown advanced-colour state, as on Windows before 10 1709 or with a driver that
|
||||
// doesn't say, counts as not managed)
|
||||
match monitor_id(&device) {
|
||||
Some(id) => wcs_profile(&id),
|
||||
None => icm_profile(&device).map_or(MonitorColor::Unknown, MonitorColor::Profile),
|
||||
}
|
||||
}
|
||||
|
||||
/// The GDI device name (`\\.\DISPLAY1`) of the monitor showing `hwnd`, NUL-terminated.
|
||||
fn monitor_device(hwnd: isize) -> Option<[u16; 32]> {
|
||||
if hwnd == 0 {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: `MonitorFromWindow` takes any handle value: window handles are looked up in the
|
||||
// system's table, not dereferenced, and one that isn't a window gives a null monitor (or
|
||||
// the nearest monitor). It writes nothing.
|
||||
let monitor = unsafe { MonitorFromWindow(hwnd as HWND, MONITOR_DEFAULTTONEAREST) };
|
||||
if monitor.is_null() {
|
||||
return None;
|
||||
}
|
||||
let mut info = MONITORINFOEXW::default();
|
||||
info.monitorInfo.cbSize = size_of::<MONITORINFOEXW>() as u32;
|
||||
// SAFETY: `info` is a `MONITORINFOEXW` whose `cbSize` says so, and the pointer is taken from
|
||||
// the whole struct, so `GetMonitorInfoW` may write all of it (it writes at most `cbSize`
|
||||
// bytes). `monitor` came from `MonitorFromWindow` just above.
|
||||
let ok = unsafe { GetMonitorInfoW(monitor, (&raw mut info).cast::<MONITORINFO>()) };
|
||||
(ok != 0 && !until_nul(&info.szDevice).is_empty() && info.szDevice.contains(&0)).then_some(info.szDevice)
|
||||
}
|
||||
|
||||
/// The device id (`MONITOR\GSM7750\{…}\0002`) of the monitor on display output `device`, as
|
||||
/// Windows Color System names it, NUL-terminated: the first active monitor there.
|
||||
fn monitor_id(device: &[u16; 32]) -> Option<[u16; 128]> {
|
||||
if !device.contains(&0) {
|
||||
return None;
|
||||
}
|
||||
let mut first = None;
|
||||
for i in 0..MAX_MONITORS {
|
||||
let mut dd = DISPLAY_DEVICEW { cb: size_of::<DISPLAY_DEVICEW>() as u32, ..Default::default() };
|
||||
// SAFETY: `device` is NUL-terminated (checked above) and `dd` is a `DISPLAY_DEVICEW` whose
|
||||
// `cb` says so; `EnumDisplayDevicesW` writes at most that struct and fails past the last
|
||||
// monitor.
|
||||
if unsafe { EnumDisplayDevicesW(device.as_ptr(), i, &mut dd, 0) } == 0 {
|
||||
break;
|
||||
}
|
||||
if until_nul(&dd.DeviceID).is_empty() || !dd.DeviceID.contains(&0) {
|
||||
continue;
|
||||
}
|
||||
if dd.StateFlags & DISPLAY_DEVICE_ACTIVE != 0 {
|
||||
return Some(dd.DeviceID);
|
||||
}
|
||||
first.get_or_insert(dd.DeviceID);
|
||||
}
|
||||
first
|
||||
}
|
||||
|
||||
/// The profile Windows Color System assigns to monitor `id`: in the user's scope when they chose
|
||||
/// per-user profiles for it, else the system's.
|
||||
fn wcs_profile(id: &[u16; 128]) -> MonitorColor {
|
||||
let mut per_user = 0;
|
||||
// SAFETY: `id` is NUL-terminated (from `monitor_id`); one `BOOL` to write.
|
||||
let asked = unsafe { WcsGetUsePerUserProfiles(id.as_ptr(), CLASS_MONITOR, &mut per_user) };
|
||||
let scope = if asked != 0 && per_user != 0 { WCS_PROFILE_MANAGEMENT_SCOPE_CURRENT_USER } else { WCS_PROFILE_MANAGEMENT_SCOPE_SYSTEM_WIDE };
|
||||
let mut bytes = 0u32;
|
||||
// SAFETY: as above; one `u32` to write (the name's size in bytes, its NUL included). It fails
|
||||
// when no profile is assigned in that scope.
|
||||
if unsafe { WcsGetDefaultColorProfileSize(scope, id.as_ptr(), CPT_ICC, CPST_NONE, 0, &mut bytes) } == 0 {
|
||||
return MonitorColor::NoProfile;
|
||||
}
|
||||
if !(4..=MAX_NAME_BYTES).contains(&bytes) {
|
||||
return MonitorColor::Unknown;
|
||||
}
|
||||
let mut name = vec![0u16; bytes.div_ceil(2) as usize];
|
||||
// SAFETY: `name` holds at least `bytes` bytes, the size given; Windows writes at most that.
|
||||
if unsafe { WcsGetDefaultColorProfile(scope, id.as_ptr(), CPT_ICC, CPST_NONE, 0, bytes, name.as_mut_ptr()) } == 0 {
|
||||
return MonitorColor::Unknown;
|
||||
}
|
||||
let name = PathBuf::from(OsString::from_wide(until_nul(&name)));
|
||||
if name.as_os_str().is_empty() {
|
||||
return MonitorColor::NoProfile;
|
||||
}
|
||||
// a name is a file in the colour directory (a full path is kept as it is)
|
||||
if name.is_absolute() {
|
||||
return MonitorColor::Profile(name);
|
||||
}
|
||||
color_directory().map_or(MonitorColor::Unknown, |dir| MonitorColor::Profile(dir.join(name)))
|
||||
}
|
||||
|
||||
/// The system's colour directory (`…\spool\drivers\color`), where profiles are installed.
|
||||
fn color_directory() -> Option<PathBuf> {
|
||||
let mut dir = [0u16; 1024];
|
||||
let mut bytes = size_of_val(&dir) as u32;
|
||||
// SAFETY: a null machine name means this one; `dir` holds `bytes` bytes, and Windows writes at
|
||||
// most that many (failing when the path doesn't fit), updating `bytes`.
|
||||
let ok = unsafe { GetColorDirectoryW(std::ptr::null(), dir.as_mut_ptr(), &mut bytes) };
|
||||
let dir = until_nul(&dir);
|
||||
(ok != 0 && !dir.is_empty()).then(|| PathBuf::from(OsString::from_wide(dir)))
|
||||
}
|
||||
|
||||
/// GDI's profile for the display `device`: what it was when this process first asked (see the
|
||||
/// crate notes), for when the monitor's device isn't found.
|
||||
fn icm_profile(device: &[u16; 32]) -> Option<PathBuf> {
|
||||
if !device.contains(&0) {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: `device` is NUL-terminated (checked above); the driver, port and mode may be null,
|
||||
// which makes a DC for that display. A null result is handled below.
|
||||
let dc = unsafe { CreateDCW(std::ptr::null(), device.as_ptr(), std::ptr::null(), std::ptr::null()) };
|
||||
if dc.is_null() {
|
||||
return None;
|
||||
}
|
||||
// (profile paths are under the colour directory, well within this)
|
||||
let mut name = [0u16; 1024];
|
||||
let mut len = name.len() as u32;
|
||||
// SAFETY: `dc` is the valid DC created above; `name` holds `len` UTF-16 units and
|
||||
// `GetICMProfileW` writes at most that many (failing when the name doesn't fit), updating `len`.
|
||||
let ok = unsafe { GetICMProfileW(dc, &mut len, name.as_mut_ptr()) };
|
||||
// SAFETY: `dc` came from `CreateDCW` and is deleted exactly once, after its last use.
|
||||
unsafe { DeleteDC(dc) };
|
||||
let path = until_nul(&name);
|
||||
(ok != 0 && !path.is_empty()).then(|| PathBuf::from(OsString::from_wide(path)))
|
||||
}
|
||||
|
||||
/// Whether Windows colour-manages the desktop on the display `device`; `None` when it can't be
|
||||
/// told.
|
||||
fn managed_by_windows(device: &[u16; 32]) -> Option<bool> {
|
||||
let (mut paths, mut modes) = (0u32, 0u32);
|
||||
// SAFETY: two counts to write; nothing else is passed.
|
||||
if unsafe { GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut paths, &mut modes) } != ERROR_SUCCESS {
|
||||
return None;
|
||||
}
|
||||
if paths == 0 || paths > MAX_PATHS || modes > 2 * MAX_PATHS {
|
||||
return None;
|
||||
}
|
||||
let mut path = vec![DISPLAYCONFIG_PATH_INFO::default(); paths as usize];
|
||||
let mut mode = vec![DISPLAYCONFIG_MODE_INFO::default(); modes as usize];
|
||||
// SAFETY: the arrays hold `paths` and `modes` elements as the counts say; Windows writes at
|
||||
// most that many and lowers the counts to what it wrote. The topology id must be null with
|
||||
// `QDC_ONLY_ACTIVE_PATHS`.
|
||||
let r =
|
||||
unsafe { QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, &mut paths, path.as_mut_ptr(), &mut modes, mode.as_mut_ptr(), std::ptr::null_mut()) };
|
||||
if r != ERROR_SUCCESS {
|
||||
return None;
|
||||
}
|
||||
path.truncate(paths as usize);
|
||||
let wanted = until_nul(device);
|
||||
for p in &path {
|
||||
let mut source = DISPLAYCONFIG_SOURCE_DEVICE_NAME {
|
||||
header: DISPLAYCONFIG_DEVICE_INFO_HEADER {
|
||||
r#type: DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME,
|
||||
size: size_of::<DISPLAYCONFIG_SOURCE_DEVICE_NAME>() as u32,
|
||||
adapterId: p.sourceInfo.adapterId,
|
||||
id: p.sourceInfo.id,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: the header's type and size describe `source`, and the pointer is taken from the
|
||||
// whole struct, which `DisplayConfigGetDeviceInfo` fills (at most `size` bytes).
|
||||
if unsafe { DisplayConfigGetDeviceInfo((&raw mut source).cast::<DISPLAYCONFIG_DEVICE_INFO_HEADER>()) } != 0 {
|
||||
continue;
|
||||
}
|
||||
if until_nul(&source.viewGdiDeviceName) != wanted {
|
||||
continue;
|
||||
}
|
||||
let mut color = DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO {
|
||||
header: DISPLAYCONFIG_DEVICE_INFO_HEADER {
|
||||
r#type: DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO,
|
||||
size: size_of::<DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO>() as u32,
|
||||
adapterId: p.targetInfo.adapterId,
|
||||
id: p.targetInfo.id,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: as above, for `color`.
|
||||
if unsafe { DisplayConfigGetDeviceInfo((&raw mut color).cast::<DISPLAYCONFIG_DEVICE_INFO_HEADER>()) } != 0 {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: the union's two views are a bit field and the same 32 bits as a `u32`; every
|
||||
// bit pattern is a valid `u32`.
|
||||
let flags = unsafe { color.Anonymous.value };
|
||||
return Some(managed(flags));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn desktop_window() -> isize {
|
||||
// SAFETY: `GetDesktopWindow` takes nothing and returns the desktop's handle.
|
||||
unsafe { windows_sys::Win32::UI::WindowsAndMessaging::GetDesktopWindow() as isize }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_window_is_unknown() {
|
||||
assert_eq!(monitor_color(0), MonitorColor::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_handles_never_crash() {
|
||||
// not windows: unknown, or (Windows answers the nearest monitor) a profile or managed
|
||||
for h in [1isize, -1, 0x7fff_ffff, isize::MIN, isize::MAX] {
|
||||
let _ = monitor_color(h);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_colour_flags_decide_who_manages_colour() {
|
||||
// supported only (an SDR desktop on an HDR-capable monitor): the app converts
|
||||
assert!(!managed(0b0001));
|
||||
assert!(!managed(0));
|
||||
// HDR on / Auto Color Management (advanced colour enabled), or wide colour enforced
|
||||
assert!(managed(0b0011));
|
||||
assert!(managed(0b0101));
|
||||
// forced off (bit 3) alone
|
||||
assert!(!managed(0b1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_text_stops_at_its_nul() {
|
||||
assert_eq!(until_nul(&[65, 66, 0, 67]), &[65, 66]);
|
||||
assert_eq!(until_nul(&[65, 66]), &[65, 66]);
|
||||
assert_eq!(until_nul(&[0, 65]), &[] as &[u16]);
|
||||
}
|
||||
|
||||
/// The desktop's monitor has an answer, and a profile it names is a file that exists.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn the_desktop_monitor_names_a_profile_that_exists_or_windows_manages_colour() {
|
||||
let answer = monitor_color(win32::desktop_window());
|
||||
eprintln!("the desktop's monitor: {answer:?}");
|
||||
match answer {
|
||||
MonitorColor::Profile(p) => assert!(p.is_file(), "{}", p.display()),
|
||||
MonitorColor::ManagedByWindows | MonitorColor::NoProfile => {}
|
||||
MonitorColor::Unknown => lightcraft_testkit::skip!(System, "no monitor answers for the desktop window (a session without a display)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ L5 depends on egui, eframe, winit or rfd, and that each crate only uses lower la
|
||||
|
||||
| Layer | Crates (lines) | Role |
|
||||
|---|---|---|
|
||||
| L0 | `geom` (1.4k), `color` (0.9k), `raster` (0.8k), `tiff` (2.0k), `fetch` (1.4k), `sysmem` (0.1k) | Matrices and warps, colour spaces and transfer functions, float image buffers, TIFF/IFD read and write, pinned HTTPS model downloads, the one `unsafe` FFI call (`malloc_zone_pressure_relief`) |
|
||||
| L0 | `geom` (1.4k), `color` (0.9k), `raster` (0.8k), `tiff` (2.0k), `fetch` (1.4k), `sysmem` (0.1k), `wincolor` (0.3k) | Matrices and warps, colour spaces and transfer functions, float image buffers, TIFF/IFD read and write, pinned HTTPS model downloads, and the two crates with `unsafe` FFI: `malloc_zone_pressure_relief` (macOS) and the Win32 calls that read the window's monitor profile (Windows) |
|
||||
| L1 | `raw` (22.3k), `codecs` (7.3k), `meta` (4.3k), `develop` (2.5k), `denoise-core` (2.2k), `denoise` (2.7k), `faces` (5.3k), `scenes` (1.0k), `heif` (2.1k, optional) | Raw decoders and demosaic, DNG colour model; standard codecs and encoders; EXIF/XMP/IPTC; the non-destructive edit model and control specs; AI denoise; face detection and recognition; procedural demo photos; HEIC |
|
||||
| L2 | `pipeline` (9.8k) | The develop pipeline: scene-referred float, resolution independent; the CPU reference (oracle) for everything else |
|
||||
| L3 | `catalog` (11.0k), `gpu` (5.1k), `preview` (1.3k), `merge` (3.8k), `segment` (2.3k) | Library model and persistence; wgpu compute twin of the pipeline; thumbnail/preview cache and job pool; HDR and panorama merge; SAM 3 segmentation |
|
||||
|
||||
@@ -16,17 +16,30 @@ have one share it; otherwise, and without colord, previews stay sRGB (choose a p
|
||||
that colour-manages windows itself with the same profile (KWin with an ICC profile set in Display Configuration)
|
||||
would correct twice; KWin keeps that profile in its own settings, not colord, so this only happens when both are set.
|
||||
|
||||
**Windows uses the profile it assigns to the monitor showing the window** (Settings ▸ System ▸ Display ▸ Color
|
||||
profile, Color Management, or a calibrator) while none is chosen. On an SDR desktop Windows passes what an app draws to
|
||||
the monitor unchanged, so without it a wide-gamut monitor shows sRGB previews oversaturated and a calibration is
|
||||
ignored. `crates/wincolor` finds the monitor the window is mostly on and asks Windows Color System for its default
|
||||
profile, in the user's scope when they chose per-user profiles for that monitor, else the system's. It is asked about
|
||||
once a second, so moving the window to another monitor and changing the profile in Windows are both followed without
|
||||
a restart. (GDI's `GetICMProfileW` keeps answering the profile a process first saw, so it is only the fallback when the
|
||||
monitor's device can't be found.) With no profile assigned, previews are sRGB. **While Windows colour-manages the
|
||||
desktop itself** (HDR on, or Auto Color Management on Windows 11: its advanced-colour state, from
|
||||
`DisplayConfigGetDeviceInfo`), it treats an ordinary app's output as sRGB and converts it for the monitor, so previews
|
||||
stay sRGB rather than be converted twice. The log says which applies (`screen profile: …`) whenever it changes.
|
||||
|
||||
## Using it
|
||||
|
||||
Settings ▸ Display ▸ **Choose Profile…** picks an `.icc` / `.icm` file (the dialog opens where the system keeps
|
||||
them: `~/.local/share/icc` on Linux, ColorSync's folders on macOS, `…\spool\drivers\color` on Windows). **Use sRGB**
|
||||
(on macOS and Linux **Use Screen Profile**) goes back to no profile. The choice is an app setting (`ui.json`, `displayProfile`)
|
||||
them: `~/.local/share/icc` on Linux, ColorSync's folders on macOS, `…\spool\drivers\color` on Windows). **Use Screen
|
||||
Profile** (in hosts without one, **Use sRGB**) goes back to no profile. The choice is an app setting (`ui.json`, `displayProfile`)
|
||||
and applies at once; a file that can't be used is reported there and previews stay sRGB.
|
||||
|
||||
Agents and scripts: `app.displayProfile {"path": "/path/to/monitor.icc"}` (`""` or `null`: none; no `path`: report
|
||||
only) returns `{path, description, kind: "matrix" | "lut", primaries: {red, green, blue: [x, y]}}`; the screen's
|
||||
profile (macOS or Linux, none chosen) reports `path: null, screen: true`. Hosts supply it as `Services::screen_profile`
|
||||
(`apps/lightcraft/src/screen_profile.rs`, `apps/lightcraft/src/colord.rs`); headless sessions have none and stay sRGB.
|
||||
profile (none chosen) reports `path: null, screen: true`. Hosts supply it as `Services::screen_profile`
|
||||
(`apps/lightcraft/src/screen_profile.rs`, `apps/lightcraft/src/colord.rs`, `apps/lightcraft/src/win_screen_profile.rs`);
|
||||
headless sessions have none and stay sRGB.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -55,5 +68,6 @@ LUT-based profile's correction about 65 ms. Drafts during slider drags skip the
|
||||
|
||||
## Not yet
|
||||
|
||||
- Picking up the profile Windows assigns (WCS: needs FFI, so an isolated `unsafe` helper crate) and X11's
|
||||
`_ICC_PROFILE`; following the window across monitors on Linux and Windows (macOS does both, above).
|
||||
- X11's `_ICC_PROFILE`, and following the window across monitors on Linux (macOS and Windows do both, above).
|
||||
- Windows' advanced-colour profiles (what a monitor uses while Auto Color Management is on): LightCraft leaves colour
|
||||
to Windows then.
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Gaps: where LightCraft falls short of Lightroom
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (gap 15: Print Page Options — page numbers, page info, crop marks)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-20 · **Change:** minor (gap 22: Windows previews follow the monitor's profile)
|
||||
|
||||
Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and
|
||||
how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is
|
||||
@@ -37,7 +37,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
|
||||
| 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, ERF, KDC, DCR, MEF, GPR, old FinePix | raw-parity.md | small user share each | 45–85 | [raw-parity](raw-parity.md) |
|
||||
| 21 | **Localization**: 9 of 15 shipped languages partial (de, es, fr, ja, uk now 97–99% of catalog messages, awaiting native review; pt-BR, ru, zh-hans, zh-hant 74–92%), lower-layer errors English; Hindi, Arabic, Indonesian, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 40–70 (shipped to full); 145–255 (five new) | [localization-parity](localization-parity.md) |
|
||||
| 23 | **Accessibility**: AccessKit tree audited by a test; canvas handles, spots and mask pins by keyboard; left: brush and red-eye by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 2–4 | [ui-parity](ui-parity.md) |
|
||||
| 22 | **Pen pressure on macOS / Linux tablets** (brush size and flow follow pressure where winit reports a pen force), OS monitor profiles on Windows (Linux reads colord's, macOS the screen's), follow-window colour on Linux / Windows | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) |
|
||||
| 22 | **Pen pressure on macOS / Linux tablets** (brush size and flow follow pressure where winit reports a pen force), follow-window colour on Linux (Windows reads the monitor's profile from Windows Color System and follows the window, macOS the screen's, Linux colord's) | hardware-parity.md | brush users with tablets; multi-monitor setups | 9–18 | [hardware-parity](hardware-parity.md) |
|
||||
| 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) |
|
||||
| 25 | **File writes**: AVIF import; Render to DNG as a catalog command (lossy DNG and the Render to DNG export format landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–34 | [file-format-parity](file-format-parity.md) |
|
||||
| 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution; at 100k photos stack and album lookups are indexed (an "In a Stack" or "Album" smart album counts in ~14 ms, was 0.6–2.7 s) | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) |
|
||||
@@ -77,6 +77,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-20 | minor | Gap 22 shrinks (11–22 → 9–18 h): on Windows, previews follow the profile Windows Color System assigns to the window's monitor (sRGB while Windows colour-manages the desktop: HDR, Auto Color Management) |
|
||||
| 2026-10-18 | patch | Gap 1: six X-Trans CMOS 4 Fujifilm bodies (X-T30 …) take the X-T3's spectral matrices instead of the neutral matrix |
|
||||
| 2026-10-15 | minor | Gap 15 shrinks: the Page panel's Page Options (Page Numbers, Page Info, Crop Marks) print on Single Image and Contact Sheet pages; identity plate, watermark, photo info, Cut Guides, direct printing and JPEG output remain |
|
||||
| 2026-10-14 | minor | Gap 26: at 100k photos stack and album lookups are indexed, so an "In a Stack" or "Album" smart album counts in ~14 ms (was 0.6–2.7 s); open time unchanged |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hardware parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (pen pressure for brush masks; monitor profile from colord on Linux); previously major (created from the code, gpu-pipeline.md and the parity checklist) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (monitor profile from Windows Color System on Windows); previously major (created from the code, gpu-pipeline.md and the parity checklist) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
|
||||
What Lightroom does with the machine's hardware, per platform, and what LightCraft does. Sources:
|
||||
[gpu-pipeline.md](gpu-pipeline.md), [display-profiles.md](display-profiles.md), [denoise.md](denoise.md),
|
||||
@@ -12,7 +12,7 @@ What Lightroom does with the machine's hardware, per platform, and what LightCra
|
||||
| GPU export | ✅ | ✅ (6000 × 4000 in ~0.3 s vs ~1.2 s CPU) | ✅ | ✅ | — | ✅ (black-export fix on an Intel iGPU, #78) |
|
||||
| AI on GPU / neural engine | Metal / DirectML / Core ML, model per feature | AI denoise on GPU or CPU (setting); SAM 3 on Metal via candle; faces on CPU | CPU / GPU denoise | CPU / GPU denoise | — | 🟡 no Core ML / Neural Engine / DirectML |
|
||||
| HDR (EDR) display | ✅ (macOS EDR, Windows HDR) | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ (LR-VIEW-HDR-DISPLAY) |
|
||||
| Monitor profile | automatic per display, follows the window | ✅ the screen's profile, followed across screens | 🟡 picked by hand (WCS needs FFI) | 🟡 colord's profile (one display, or all sharing one); no follow across monitors | — | 🟡 Windows OS profile not read; Linux per-monitor follow missing |
|
||||
| Monitor profile | automatic per display, follows the window | ✅ the screen's profile, followed across screens | ✅ the profile Windows Color System assigns to the window's monitor, followed across monitors and profile changes; sRGB while Windows colour-manages the desktop (HDR, Auto Color Management) | 🟡 colord's profile (one display, or all sharing one); no follow across monitors | — | 🟡 Linux per-monitor follow missing |
|
||||
| Second window / second monitor | ✅ (Classic) | ✅ (⌘F11) | ✅ | ✅ | — | ✅ (Classic secondary-window keys missing) |
|
||||
| Pen pressure / tilt (brush masks) | ✅ pressure for size / flow | ⬜ winit reports no tablet pressure on macOS | 🟡 pen force from winit touch events (untested on a tablet) | ⬜ winit has no X11 / Wayland tablet input | 🟡 pointer-event force where winit passes it | 🟡 brush size and flow follow pen pressure wherever egui gets a touch force (Brush ▸ Pen Pressure); no tilt; macOS / Linux tablets wait on winit |
|
||||
| Trackpad gestures | pinch zoom, pan | ✅ pinch, two-finger pan | 🟡 (winit) | 🟡 | 🟡 | ✅ on macOS |
|
||||
@@ -27,7 +27,7 @@ What Lightroom does with the machine's hardware, per platform, and what LightCra
|
||||
|
||||
**~40% ready** (estimated): the GPU editing path is real and fast (measured: 24 MP exposure drag
|
||||
3.9 ms on GPU vs 22–42 ms CPU; NR drag 18 ms vs 301–471 ms), but HDR display, pen pressure,
|
||||
tethering, printing, automatic monitor profiles and neural-engine inference are missing. GPU
|
||||
tethering, printing, per-monitor profiles on Linux and neural-engine inference are missing. GPU
|
||||
correctness is proven on Apple GPUs and on one AMD GPU on Linux (Radeon 8060S, Vulkan / RADV: the
|
||||
equivalence suite passes with the same max 1–2 LSB as on Metal, [gpu-pipeline.md](gpu-pipeline.md#correctness-cpu-oracle-and-equivalence-tests)).
|
||||
NVIDIA, Intel and Windows DX12 still rely on user reports.
|
||||
@@ -37,18 +37,19 @@ NVIDIA, Intel and Windows DX12 still rely on user reports.
|
||||
| Work | Hours | Needs |
|
||||
|---|---:|---|
|
||||
| HDR display (EDR on macOS, Windows HDR, Linux where possible) | 10–20 | an HDR display to verify (human) |
|
||||
| OS monitor profile: Windows WCS (an FFI helper crate, like `crates/sysmem`), follow the window across displays on Linux | 4–8 | multi-monitor setup |
|
||||
| OS monitor profile: follow the window across displays on Linux | 2–4 | multi-monitor setup |
|
||||
| Pen pressure for brush masks: done where winit reports a touch force; macOS and Linux tablets need winit tablet events (or a pure-Rust tablet layer) | 3–8 | a tablet |
|
||||
| Remaining stages on GPU (defringe, spot removal, masks) | 10–20 | |
|
||||
| Tethered capture (PTP over USB in pure Rust, Canon / Nikon / Sony first) | 25–45 | cameras (human) |
|
||||
| Printing with colour management | 15–25 | printers (human) |
|
||||
| Neural-engine / DirectML inference for AI features | 10–20 | depends on the AI model decision |
|
||||
| **Total** | **80–150 h** (the core set for beta, HDR display + monitor profile + pen pressure: 20–40 h) | |
|
||||
| **Total** | **78–146 h** (the core set for beta, HDR display + monitor profile + pen pressure: 18–36 h) | |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-20 | minor | Monitor profile: Windows reads the window's monitor profile from Windows Color System (`crates/wincolor`), sRGB while Windows colour-manages the desktop; verified on Windows 11 by setting and clearing a Display P3 profile while the app ran. Remaining monitor-profile work 4–8 h → 2–4 h |
|
||||
| 2026-10-12 | minor | Monitor profile: Linux reads the display profile from colord; macOS row reflects #787 |
|
||||
| 2026-10-12 | minor | CPU ↔ GPU equivalence suite run on an AMD Radeon 8060S (Vulkan, RADV, Linux): 16 tests / 135 cases pass, max 2 LSB, mean ≤ 0.0003; first GPU verified outside Apple |
|
||||
| 2026-10-12 | minor | Pen pressure: strokes record per-point pressure from egui touch force, scaling dab size and flow; per-platform status |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Lightroom parity checklist
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (LRC-PRINT-PAGE: Print Page Options) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (LR-PREF-DISPLAY: Windows previews follow the monitor's profile) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
|
||||
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
|
||||
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
|
||||
@@ -433,7 +433,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-PREF-LOCALSTORAGE | Storage & cache | P1 | ✅ | `cmd:library.preferences`, `cmd:library.clearPreviews`, `cmd:library.compact` | thumbnail cache size + clear in Settings → Performance; library location + Open Library… in General |
|
||||
| LR-PREF-ACCOUNT | Account | OOS | 🚫 | | |
|
||||
| LR-PREF-INTERFACE | Interface options | P1 | ✅ | `cmd:app.settings`, `cmd:view.appearance`, `cmd:view.theme` | Appearance Mode (Sync with System / Dark / Light; the top bar button cycles Auto, Light, Dark) with separate dark (Charcoal, Midnight) and light (Silver, Paper) themes previewed in cards; hover stars persist favorite themes, the appearance button context menu opens Interface settings and lists light favorites before dark favorites; filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay |
|
||||
| LR-PREF-DISPLAY | Monitor (display) colour management | P1 | 🟡 | `cmd:app.displayProfile`, `crates/codecs/src/display.rs`, `crates/engine/src/display.rs`, `crates/ui-egui/src/panels/settings.rs`, `apps/lightcraft/src/screen_profile.rs`, `apps/lightcraft/src/colord.rs`, `docs/display-profiles.md` | Settings ▸ Display: a monitor ICC profile (matrix/TRC exactly, LUT-based through the CMS) chosen by hand, or on macOS the profile of the screen showing the window (followed across screens, #787), on Linux the profile the desktop assigns in colord (read off the UI thread every 3 s; used with one display, or several sharing one profile); the loupe, Before, Compare, hover previews and the second window render into the display's primaries (its full gamut, CPU and GPU), thumbnails and stand-ins are converted from sRGB; histogram, preview caches and exports stay sRGB; soft proofing's display gamut warning uses the display's gamut. Missing: Windows' WCS profile (needs FFI, so `unsafe`), X11 `_ICC_PROFILE`, per-monitor profiles on Linux (eframe doesn't say which monitor the window is on) |
|
||||
| LR-PREF-DISPLAY | Monitor (display) colour management | P1 | 🟡 | `cmd:app.displayProfile`, `crates/codecs/src/display.rs`, `crates/engine/src/display.rs`, `crates/ui-egui/src/panels/settings.rs`, `apps/lightcraft/src/screen_profile.rs`, `apps/lightcraft/src/colord.rs`, `apps/lightcraft/src/win_screen_profile.rs`, `crates/wincolor/src/lib.rs`, `docs/display-profiles.md` | Settings ▸ Display: a monitor ICC profile (matrix/TRC exactly, LUT-based through the CMS) chosen by hand, or on macOS the profile of the screen showing the window (followed across screens, #787), on Linux the profile the desktop assigns in colord (read off the UI thread every 3 s; used with one display, or several sharing one profile), on Windows the profile Windows Color System assigns to the monitor showing the window (per-user or system, followed across monitors and profile changes; sRGB while Windows colour-manages the desktop itself: HDR or Auto Color Management; verified on Windows 11 with a Display P3 profile set and unset while the app ran); the loupe, Before, Compare, hover previews and the second window render into the display's primaries (its full gamut, CPU and GPU), thumbnails and stand-ins are converted from sRGB; histogram, preview caches and exports stay sRGB; soft proofing's display gamut warning uses the display's gamut. Missing: X11 `_ICC_PROFILE`, per-monitor profiles on Linux (eframe doesn't say which monitor the window is on) |
|
||||
| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off (a crash while the GPU device or its kernels are built, issue #250, turns it off for the next launch), preview size (Automatic = the whole-frame loupe render at canvas size, up to 2560 px; or a 1600–5120 px size for it; zoomed views add a window at up to 100 %, issue #323), memory budget (a quarter of it is the idle per-view stage caches' share; the open photo's views are counted and reported, and cut only past four times that, the before side's and the zoom window's first — a Before/After at 1:1 on a 2800 px canvas holds about 1 GB of GPU stages, plus the one 288 MB copy of the original, which `ui.inspect` reports as `sharedSourceBytes`), thumbnail cache size in Settings |
|
||||
| LR-PREF-PEOPLE | Face recognition | P2 | 🟡 | `cmd:faces.enable`, `cmd:faces.models.list`, `cmd:faces.models.inspect`, `cmd:faces.models.install`, `cmd:faces.models.download`, `cmd:faces.models.downloads`, `cmd:faces.models.downloadCancel`, `cmd:faces.models.remove`, `cmd:faces.models.select`, `cmd:dialog.faceModel`, `crates/faces/src/lib.rs`, `crates/ui-egui/src/panels/faces.rs` | Settings ▸ Faces: an off-by-default toggle and the face model list. Models are opt-in and never bundled, the 232 KB YuNet detector included. Press Download (terms first; then a pinned address, size and SHA-256 checked, fetched over pure-Rust https with `lightcraft-fetch`), or add any `.onnx` by picking or dropping it: it is recognised by hash or described from its shape, shows its licence terms and training-data note, runs a self-test, and installs only after an "I accept" box; an installed model is chosen and recognition switched on at once (the newest wins, each model keeps its own embedding cache). Non-commercial models are add-by-file only, never offered for download. Suggestions of names run on the shared pure-Rust CPU interpreter (`crates/faces/src/net.rs`, `crates/faces/src/runtime.rs`; bounded im2col/GEMM, checked ONNX shapes/operators; no external inference runtime; 40 public crops agree within 2.1e-6, one-thread SFace 34–36 ms / AuraFace 233–252 ms, see docs/faces.md) in a background worker and are never applied automatically. Thresholds are starting points calibrated on busts, not yet on live faces; the Recognise faces switch is off until a recognition model is chosen and installed (`faces.enable {enabled: true}` refuses otherwise; removing the last recogniser switches it off), issue #678 |
|
||||
| LR-PREF-WATERMARK | Watermark settings | P1 | ✅ | `cmd:export.savePreset`, `crates/ui-egui/src/panels/dialogs.rs` | set in the Export dialog; kept with Export with Previous and in saved export presets |
|
||||
@@ -797,6 +797,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-20 | minor | LR-PREF-DISPLAY: on Windows, previews follow the profile Windows Color System assigns to the window's monitor (sRGB while Windows colour-manages the desktop: HDR, Auto Color Management); still 🟡 (X11 `_ICC_PROFILE`, Linux per-monitor) |
|
||||
| 2026-10-14 | patch | LR-EXP-TYPE: opaque 8-bit PNG/TIFF/WebP use RGB; transparent exports retain alpha (#1670). Statuses and feature counts unchanged. |
|
||||
| 2026-10-15 | minor | LRC-PRINT-PAGE: Page Options (Page Numbers, Page Info, Crop Marks) on Single Image and Contact Sheet pages, in the Print dialog, `print.set` and templates; still 🟡 (no identity plate, watermark, photo info or Cut Guides) |
|
||||
| 2026-10-15 | minor | LR-IMP-FORMATS: Hasselblad 3FR and FFF show their uncompressed RGB preview (no JPEG in these files) in the grid and loupe until developed, and as the stand-in when a file fails to decode; status unchanged (🟡) |
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# What's new in LightCraft
|
||||
|
||||
## 2026-10-20
|
||||
|
||||
### Windows: photos follow the monitor's colour profile
|
||||
- On Windows, photos are now shown through the colour profile Windows assigns to the monitor the window is on
|
||||
(Settings ▸ System ▸ Display ▸ Color profile, Color Management, or a calibrator's profile), while no monitor profile
|
||||
is chosen in Settings ▸ Display. Before, they were shown as sRGB, so a wide-gamut monitor showed them oversaturated
|
||||
and a calibration was ignored unless you picked the profile by hand. Moving the window to another monitor, or
|
||||
changing the profile in Windows, is followed within about a second.
|
||||
- While Windows manages colour itself (HDR on, or Auto Color Management on Windows 11), photos stay sRGB: Windows
|
||||
converts them for the monitor then. Settings ▸ Display ▸ **Use Screen Profile** goes back to this after choosing a
|
||||
profile by hand. See [display profiles](display-profiles.md).
|
||||
|
||||
## 2026-10-14
|
||||
|
||||
### Model downloads time out on a name that doesn't resolve
|
||||
|
||||
@@ -38,6 +38,7 @@ pub const TABLE: &[(&str, Class)] = &[
|
||||
("raster", Class::Layer(0)),
|
||||
("tiff", Class::Layer(0)),
|
||||
("sysmem", Class::Layer(0)),
|
||||
("wincolor", Class::Layer(0)),
|
||||
("fetch", Class::Layer(0)),
|
||||
("denoise-core", Class::Layer(1)),
|
||||
("denoise", Class::Layer(1)),
|
||||
|
||||
Reference in New Issue
Block a user