mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-13 09:20:07 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 _;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ L5 depends on egui, eframe, winit or rfd, and that each crate only uses lower la
|
||||
|
||||
| Layer | Crates (lines) | Role |
|
||||
|---|---|---|
|
||||
| L0 | `geom` (1.4k), `color` (0.9k), `raster` (0.8k), `tiff` (2.0k), `fetch` (1.4k), `sysmem` (0.1k) | Matrices and warps, colour spaces and transfer functions, float image buffers, TIFF/IFD read and write, pinned HTTPS model downloads, the one `unsafe` FFI call (`malloc_zone_pressure_relief`) |
|
||||
| L0 | `geom` (1.4k), `color` (0.9k), `raster` (0.8k), `tiff` (2.0k), `fetch` (1.4k), `sysmem` (0.1k), `wincolor` (0.3k) | Matrices and warps, colour spaces and transfer functions, float image buffers, TIFF/IFD read and write, pinned HTTPS model downloads, and the two crates with `unsafe` FFI: `malloc_zone_pressure_relief` (macOS) and the Win32 calls that read the window's monitor profile (Windows) |
|
||||
| L1 | `raw` (22.3k), `codecs` (7.3k), `meta` (4.3k), `develop` (2.5k), `denoise-core` (2.2k), `denoise` (2.7k), `faces` (5.3k), `scenes` (1.0k), `heif` (2.1k, optional) | Raw decoders and demosaic, DNG colour model; standard codecs and encoders; EXIF/XMP/IPTC; the non-destructive edit model and control specs; AI denoise; face detection and recognition; procedural demo photos; HEIC |
|
||||
| L2 | `pipeline` (9.8k) | The develop pipeline: scene-referred float, resolution independent; the CPU reference (oracle) for everything else |
|
||||
| L3 | `catalog` (11.0k), `gpu` (5.1k), `preview` (1.3k), `merge` (3.8k), `segment` (2.3k) | Library model and persistence; wgpu compute twin of the pipeline; thumbnail/preview cache and job pool; HDR and panorama merge; SAM 3 segmentation |
|
||||
|
||||
@@ -16,17 +16,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
File diff suppressed because one or more lines are too long
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user