Merge origin/main into fix/2674-stored-frame-regions

Keep face-box (#2674) and main (monitor profile / denoise) entries in the parity checklist change line.
This commit is contained in:
FredApps
2026-11-05 10:54:46 +01:00
23 changed files with 679 additions and 55 deletions
+4 -3
View File
@@ -38,9 +38,10 @@ of it.
"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
@@ -39,9 +39,10 @@ of it.
"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
@@ -3599,12 +3599,14 @@ dependencies = [
"lightcraft-sysmem",
"lightcraft-testkit",
"lightcraft-ui-egui",
"lightcraft-wincolor",
"log",
"muda",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"pollster 1.0.1",
"raw-window-handle",
"rfd",
"serde_json",
"winit",
@@ -4091,6 +4093,14 @@ dependencies = [
"web-sys",
]
[[package]]
name = "lightcraft-wincolor"
version = "0.18.0"
dependencies = [
"lightcraft-testkit",
"windows-sys 0.61.2",
]
[[package]]
name = "linebender_resource_handle"
version = "0.1.1"
+1
View File
@@ -23,6 +23,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" }
+3
View File
@@ -58,3 +58,6 @@ lightcraft-testkit = { workspace = true }
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
# the colour profile of the monitor showing the window (`win_screen_profile`)
lightcraft-wincolor = { workspace = true }
raw-window-handle = "0.6"
+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();
}
}
+4
View File
@@ -35,8 +35,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 _;
+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();
}
}
+5 -1
View File
@@ -144,7 +144,11 @@ pub(crate) fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::
// 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()
+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(),
}
}
+1
View File
@@ -265,6 +265,7 @@ impl LightcraftApp {
self.import_dropped_files(ctx);
}
self.check_external_editor(ctx);
theme::refresh_window_on_first_focus(ctx);
// Alt (Option on macOS) while a slider is dragged shows its temporary preview
self.ui.alt_held = ctx.input(|i| i.modifiers.alt);
// Recent-library checks and launch-list keys read focus/time/keys: they belong to the UI
+7 -2
View File
@@ -90,9 +90,14 @@ impl LightcraftApp {
/// While no display profile is chosen, show previews through the profile of the screen the
/// window is on (`services.screen_profile`), read about once a second so moving the window to
/// another screen is followed. Without the service (or a profile) the display is sRGB.
fn follow_screen_profile(&mut self, _ctx: &egui::Context) {
fn follow_screen_profile(&mut self, ctx: &egui::Context) {
if self.services.screen_profile.is_none() {
return;
}
// an idle window draws no frames: wake up to read again, so a profile changed in Windows is followed
ctx.request_repaint_after(std::time::Duration::from_secs(1));
let now = self.tick_now();
if self.services.screen_profile.is_none() || self.screen_profile_read.is_some_and(|t| (0.0..1.0).contains(&(now - t))) {
if self.screen_profile_read.is_some_and(|t| (0.0..1.0).contains(&(now - t))) {
return;
}
self.screen_profile_read = Some(now);
+2 -1
View File
@@ -70,7 +70,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>,
+64
View File
@@ -412,6 +412,25 @@ pub fn current(ctx: &egui::Context) -> Option<ThemeKind> {
ctx.data(|d| d.get_temp::<ThemeKind>(theme_id()))
}
/// Windows only: tell the window its theme again, the first time it has focus. A first theme that
/// reaches a focused window as it comes up changes the title's colour but not the caption's, so
/// with LightCraft dark on a light system the title is white on a light caption (it looks blank),
/// and light on dark is dark on dark, until focus next moves (#3381). The same command repaints
/// the caption once the window is up. A window that opens without focus shows the right colours
/// from the start.
pub fn refresh_window_on_first_focus(ctx: &egui::Context) {
let done = egui::Id::unique("lightcraft-window-theme-refreshed");
if !cfg!(target_os = "windows") || !ctx.input(|i| i.focused) || ctx.data(|d| d.get_temp::<bool>(done)).is_some() {
return;
}
ctx.data_mut(|d| d.insert_temp(done, true));
let theme = match ctx.theme() {
egui::Theme::Dark => egui::SystemTheme::Dark,
egui::Theme::Light => egui::SystemTheme::Light,
};
ctx.send_viewport_cmd(egui::ViewportCommand::SetTheme(theme));
}
/// Style `ctx` with the default theme.
pub fn apply(ctx: &egui::Context) {
apply_kind(ctx, ThemeKind::default());
@@ -506,6 +525,51 @@ mod tests {
}
}
/// The `SetTheme(Light)` commands one frame of `ctx` sends the window, with or without
/// [`refresh_window_on_first_focus`] in it.
#[cfg(target_os = "windows")]
fn light_theme_commands(ctx: &egui::Context, focused: bool, refresh: bool) -> usize {
let raw = egui::RawInput { focused, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
if refresh {
refresh_window_on_first_focus(ui.ctx());
}
});
out.textures_delta.clear();
let commands = out.viewport_output.remove(&egui::ViewportId::ROOT).map(|v| v.commands).unwrap_or_default();
commands.iter().filter(|c| matches!(c, egui::ViewportCommand::SetTheme(egui::SystemTheme::Light))).count()
}
/// #3381: egui sends the theme with the first frame, and the window hears it once more the
/// first time it has focus, not on every later focus.
#[cfg(target_os = "windows")]
#[test]
fn the_window_hears_its_theme_again_the_first_time_it_has_focus() {
let ctx = egui::Context::default();
apply_kind(&ctx, ThemeKind::Silver);
let sent = |focused: bool| light_theme_commands(&ctx, focused, true);
assert_eq!(sent(false), 1, "egui's own, with the first frame");
assert_eq!(sent(false), 0, "no focus yet");
assert_eq!(sent(true), 1, "the first frame with focus");
assert_eq!(sent(true), 0, "still focused");
assert_eq!(sent(false), 0, "focus lost");
assert_eq!(sent(true), 0, "focus back: Windows repaints this by itself");
}
/// #3381: a window that has focus from its very first frame hears the theme once more in that
/// frame, on top of egui's own command. (In the app the first frame never gets here: `ui`
/// returns before the fonts are ready, so egui's command always goes a frame earlier.)
#[cfg(target_os = "windows")]
#[test]
fn a_window_focused_from_its_first_frame_hears_its_theme_again_in_that_frame() {
let first_frame = |refresh: bool| {
let ctx = egui::Context::default();
apply_kind(&ctx, ThemeKind::Silver);
light_theme_commands(&ctx, true, refresh)
};
assert_eq!(first_frame(true), first_frame(false) + 1);
}
/// Every theme scrolls 100 points a wheel notch, and a zoom notch stays egui's stock ~1.22x
/// (`exp(40 / 200)`) instead of growing with the longer scroll.
#[test]
+28
View File
@@ -0,0 +1,28 @@
[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)."
# The workspace lints: they deny `unsafe_code` (deny, not forbid), which the `win32` module in
# src/lib.rs allows for its Win32 calls; every other crate root has `#![forbid(unsafe_code)]`. See
# AGENTS.md and craftrules standards/never-crash.md ("isolated helper library crate").
[lints]
workspace = true
[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"] }
+366
View File
@@ -0,0 +1,366 @@
//! 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 answer Windows' own colour management settles, `None` when the monitor's profile decides:
/// [`MonitorColor::ManagedByWindows`] while it colour-manages the desktop, and
/// [`MonitorColor::Unknown`] when its state can't be read (`managed` is `None`), since applying the
/// monitor's profile on top of Windows' conversion would convert twice.
#[cfg(any(windows, test))]
fn settled_by_windows(managed: Option<bool>) -> Option<MonitorColor> {
match managed {
Some(true) => Some(MonitorColor::ManagedByWindows),
Some(false) => None,
None => Some(MonitorColor::Unknown),
}
}
/// 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, settled_by_windows, 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 };
// (an advanced-colour state that can't be read, as on Windows before 10 1709 or with a
// driver that doesn't say, is unknown: previews then stay sRGB, as before profiles were read)
if let Some(answer) = settled_by_windows(managed_by_windows(&device)) {
return answer;
}
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 an_unreadable_advanced_colour_state_is_unknown_rather_than_a_profile() {
assert_eq!(settled_by_windows(Some(true)), Some(MonitorColor::ManagedByWindows));
assert_eq!(settled_by_windows(Some(false)), None, "an SDR desktop: the monitor's profile decides");
// Windows may be converting already: a profile on top would convert twice
assert_eq!(settled_by_windows(None), Some(MonitorColor::Unknown));
}
#[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 |
+21 -6
View File
@@ -16,17 +16,31 @@ 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. So do they when that state can't be read (Windows 10 before 1709, a driver
that doesn't report it). 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 +69,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
File diff suppressed because one or more lines are too long
+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** | **75–142 h** (the core set for beta, HDR display + monitor profile + pen pressure: 15–32 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 |
File diff suppressed because one or more lines are too long
+17
View File
@@ -12,6 +12,13 @@
hot pixels on a sensor. Stars and other points of light that are kept don't count, so a dense star field still
loses its hot pixels. Settings ▸ Performance ▸ Hot pixel limit sets the share (0.05 % to 1 %).
## 2026-11-02
### The Windows title bar is readable from the moment the window opens
- When LightCraft's appearance differs from the one set in Windows (the dark default on a light Windows, for one), the
title bar kept the system's colour with the title in LightCraft's, so the title looked blank until focus moved to
another window. It now takes LightCraft's colours as soon as the window has focus.
## 2026-11-01
### Korean and Vietnamese text in exports draws whole
@@ -775,6 +782,16 @@
## 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).
### Smart albums check File Path rules faster
- A smart album rule on File Path (contains, doesn't contain, is, isn't, starts with, ends with, is empty, isn't
empty) no longer builds new text for every photo. On a synthetic library of 312,000 photos, a File Path "contains"
+1
View File
@@ -39,6 +39,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)),