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:
Claude
2026-10-20 22:24:20 -07:00
parent 5fe74557ec
commit 423d209d16
19 changed files with 586 additions and 53 deletions
+4 -3
View File
@@ -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.
+4 -3
View File
@@ -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
View File
@@ -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"
+1
View File
@@ -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" }
+5
View File
@@ -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 }
+1 -29
View File
@@ -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();
}
}
+9 -1
View File
@@ -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()
+87
View File
@@ -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();
}
}
+40
View File
@@ -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(),
}
}
+2 -1
View File
@@ -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>,
+32
View File
@@ -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"] }
+345
View File
@@ -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)"),
}
}
}
+1 -1
View File
@@ -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 |
+20 -6
View File
@@ -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
View File
@@ -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 |
+6 -5
View File
@@ -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 |
+3 -2
View File
@@ -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 (🟡) |
+12
View File
@@ -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
+1
View File
@@ -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)),