Merge pull request #6215 from davidmann4/feat/ui-217-4-columns-unit

Image Size: a Columns width unit from the Column Size preference (#6214)
This commit is contained in:
David Mann authored and GitHub committed 2026-11-10 19:36:23 +01:00
commit 0d08cb7dd6
8 files changed
+314 -26

No files matched your search

+3
View File
@@ -924,7 +924,10 @@ impl TransparencyAndGamut {
pub struct UnitsAndRulers {
pub rulers: Unit,
pub type_units: TypeUnit,
/// Column Size › Width in points: Image Size and Canvas Size's Columns width unit
/// (`n` columns are `n × column_width + (n − 1) × gutter`).
pub column_width: f64,
/// Column Size › Gutter in points, the space between columns.
pub gutter: f64,
pub print_resolution: f64,
pub screen_resolution: f64,
-2
View File
@@ -40,8 +40,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[
"workspace.autoShowHiddenPanels",
"workspace.enableFloatingDocumentWindowDocking",
"performance.legacyCompositing",
"unitsAndRulers.columnWidth",
"unitsAndRulers.gutter",
"plugIns.showExtensionPanels",
"plugIns.allowScriptsToConnect",
"type.textEngine",
+130 -16
View File
@@ -13,6 +13,56 @@ use crate::theme::Tokens;
const UNITS: [(&str, &str); 7] =
[("px", "Pixels"), ("percent", "Percent"), ("in", "Inches"), ("cm", "Centimeters"), ("mm", "Millimeters"), ("pt", "Points"), ("pica", "Picas")];
/// Width-only unit of Image Size and Canvas Size (height has no Columns). Image Size measures it
/// in columns of Preferences › Units & Rulers › Column Size; Canvas Size in whole inches (see
/// [`canvas_columns_to_px`]).
const COLUMNS: (&str, &str) = ("col", "Columns");
/// Decimal places a Columns field shows.
const COLUMN_DECIMALS: u32 = 3;
/// Column Size in points, read from the dialog: (width, gutter). Photoshop's 180 pt / 12 pt when
/// missing or degenerate: a width that isn't a positive finite number, or a gutter that isn't a
/// finite number of at least zero (0 then), so the conversion never divides by zero.
fn column_size(f: &Map<String, Value>) -> (f64, f64) {
let w = f.get("__colWidth").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(180.0);
let g = f.get("__colGutter").and_then(Value::as_f64).filter(|v| v.is_finite()).map_or(12.0, |g| g.max(0.0));
(w, g)
}
/// Pixels → columns: `n` columns span `n × width + (n − 1) × gutter` points, so a width of
/// `pt` points is `(pt + gutter) / (width + gutter)` columns. With `relative` (Canvas Size's
/// deltas) the count applies to the magnitude and zero stays zero.
fn px_to_columns(px: f64, res: f64, ppi: f64, (w, g): (f64, f64), relative: bool) -> f64 {
let pt = px / res.max(1.0) * ppi;
if relative {
if pt == 0.0 {
return 0.0;
}
return pt.signum() * (pt.abs() + g) / (w + g);
}
(pt + g) / (w + g)
}
/// Columns → pixels, the inverse of [`px_to_columns`].
fn columns_to_px(n: f64, res: f64, ppi: f64, (w, g): (f64, f64), relative: bool) -> f64 {
let span = |n: f64| n * (w + g) - g;
let pt = if relative { if n == 0.0 { 0.0 } else { n.signum() * span(n.abs()).max(0.0) } } else { span(n) };
pt / ppi * res.max(1.0)
}
/// Canvas Size's Columns, measured in the reference application: one column is one inch at the
/// document resolution, whatever Column Size says, and only whole columns count (1.5 is 2; a
/// 1001 px canvas at 300 ppi shows as 3). Relative deltas the same way.
fn canvas_columns_to_px(n: f64, res: f64) -> f64 {
n.round() * res.max(1.0)
}
/// Pixels → Canvas Size columns, rounded for display; the inverse of [`canvas_columns_to_px`].
fn canvas_px_to_columns(px: f64, res: f64) -> f64 {
(px / res.max(1.0)).round()
}
/// Dialog unit for the Units & Rulers preference.
fn pref_unit(u: photocraft_engine::prefs::Unit) -> &'static str {
use photocraft_engine::prefs::Unit;
@@ -72,6 +122,8 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
let unit = pref_unit(app.session.prefs().units_and_rulers.rulers);
let mut resample = pref_resample(app.session.prefs().general.image_interpolation);
let pt_per_in = app.session.prefs().units_and_rulers.point_size.per_inch();
let column_width = app.session.prefs().units_and_rulers.column_width;
let column_gutter = app.session.prefs().units_and_rulers.gutter;
let d = &app.session.active()?.doc;
let (w, h, res) = (d.size.width as f64, d.size.height as f64, d.resolution_dpi as f64);
let label = photocraft_engine::commands::find(command).map(|c| c.label).unwrap_or("Image Size…");
@@ -84,6 +136,8 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
f.insert("__origRes".into(), json!(res));
f.insert("__unit".into(), json!(unit));
f.insert("__ptPerIn".into(), json!(pt_per_in));
f.insert("__colWidth".into(), json!(column_width));
f.insert("__colGutter".into(), json!(column_gutter));
f.insert("__bytesPerPixel".into(), json!(d.layers.first().and_then(|l| l.surface()).map_or(4, |s| s.format().bytes_per_pixel())));
f.insert("width".into(), json!(w));
f.insert("height".into(), json!(h));
@@ -175,11 +229,34 @@ fn info_row(ui: &mut egui::Ui, k: &str, v: String) {
/// Width / height field in the current unit, writing back pixels. Returns true when edited.
fn dim_field(ui: &mut egui::Ui, f: &mut Map<String, Value>, key: &str, orig_key: &str, id: &str) -> bool {
let unit = f.get("__unit").and_then(Value::as_str).unwrap_or("px").to_string();
let unit = field_unit(f, key);
let res = f.get("resolution").and_then(Value::as_f64).unwrap_or(num(f, "__origRes"));
let orig = num(f, orig_key);
let px = num(f, key);
let ppi = pt_per_in(f);
if unit == COLUMNS.0 && f.get("__command").and_then(Value::as_str) == Some("image.canvasSize") {
let mut v = canvas_px_to_columns(px, res) as f32;
let lim = (300000.0 / res.max(1.0)).ceil() as f32;
let changed = crate::widgets::value_field_prec(ui, &mut v, -lim..=lim, "", 90.0, 0).accessible_name(tl!("Width")).changed();
if changed {
f.insert(key.into(), json!(canvas_columns_to_px(v as f64, res)));
}
let _ = id;
return changed;
}
if unit == COLUMNS.0 {
let cols = column_size(f);
let relative = f.get("relative").and_then(Value::as_bool).unwrap_or(false);
let mut v = px_to_columns(px, res, ppi, cols, relative) as f32;
let lim = px_to_columns(300000.0, res, ppi, cols, false).max(30000.0) as f32;
// No typed-unit conversion: Columns aren't a unit the field parser knows.
let changed = crate::widgets::value_field_prec(ui, &mut v, -lim..=lim, "", 90.0, COLUMN_DECIMALS).accessible_name(tl!("Width")).changed();
if changed {
f.insert(key.into(), json!(columns_to_px(v as f64, res, ppi, cols, relative)));
}
let _ = id;
return changed;
}
let mut v = to_unit(px, &unit, orig, res, ppi) as f32;
// The engine's 300000 px limit in this unit (never below 30000), so a large document's size
// isn't clamped when the field is drawn: egui clamps the shown value to the range, and the
@@ -200,12 +277,41 @@ fn dim_field(ui: &mut egui::Ui, f: &mut Map<String, Value>, key: &str, orig_key:
changed
}
fn unit_dropdown(ui: &mut egui::Ui, f: &mut Map<String, Value>, id: &str) {
let mut unit = f.get("__unit").and_then(Value::as_str).unwrap_or("px").to_string();
let opts: Vec<(String, &str)> = UNITS.iter().map(|(k, l)| (k.to_string(), *l)).collect();
if crate::widgets::dropdown(ui, id, tl!("Units"), &mut unit, &opts, 110.0) {
f.insert("__unit".into(), json!(unit));
/// The unit `key`'s field shows: the dialog's shared `__unit`, except a Width set to Columns
/// (`__unitW`), which height can't follow.
fn field_unit(f: &Map<String, Value>, key: &str) -> String {
let shared = f.get("__unit").and_then(Value::as_str).unwrap_or("px");
if key == "width" && f.get("__unitW").and_then(Value::as_str) == Some(COLUMNS.0) {
return COLUMNS.0.to_string();
}
if shared == COLUMNS.0 { "px".to_string() } else { shared.to_string() }
}
/// Unit menu beside the Width (`key` "width", which also offers Columns) or Height field.
fn unit_dropdown(ui: &mut egui::Ui, f: &mut Map<String, Value>, key: &str, id: &str) {
let mut unit = field_unit(f, key);
let mut opts: Vec<(String, &str)> = UNITS.iter().map(|(k, l)| (k.to_string(), *l)).collect();
if key == "width" {
opts.push((COLUMNS.0.to_string(), COLUMNS.1));
}
if crate::widgets::dropdown(ui, id, tl!("Units"), &mut unit, &opts, 110.0) {
set_unit(f, key, &unit);
}
}
/// Picks `unit` from `key`'s menu: Columns applies to Width alone; any other unit applies to both
/// fields, and on Width it leaves Columns.
fn set_unit(f: &mut Map<String, Value>, key: &str, unit: &str) {
if unit == COLUMNS.0 {
if key == "width" {
f.insert("__unitW".into(), json!(COLUMNS.0));
}
return;
}
if key == "width" {
f.remove("__unitW");
}
f.insert("__unit".into(), json!(unit));
}
fn image_size(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
@@ -228,18 +334,20 @@ fn image_size(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
let mut constrain = !resampling || f.get("__constrain").and_then(Value::as_bool).unwrap_or(true);
// With Resample off, a size in pixels or percent can't change (Photoshop disables those
// fields); a physical size (inches, cm…) changes the resolution instead.
let unit = f.get("__unit").and_then(Value::as_str).unwrap_or("px").to_string();
let editable = resampling || !matches!(unit.as_str(), "px" | "percent");
// Width in Columns is a physical size, so it stays editable while Height (in pixels) isn't.
let editable = |f: &Map<String, Value>, key: &str| resampling || !matches!(field_unit(f, key).as_str(), "px" | "percent");
let w_row = ui.horizontal(|ui| {
label(ui, tl!("Width:"), 90.0);
let editable = editable(f, "width");
let c = ui.add_enabled_ui(editable, |ui| dim_field(ui, f, "width", "__origW", "is-w")).inner;
unit_dropdown(ui, f, "is-unit-w");
unit_dropdown(ui, f, "width", "is-unit-w");
c
});
let h_row = ui.horizontal(|ui| {
label(ui, tl!("Height:"), 90.0);
let editable = editable(f, "height");
let c = ui.add_enabled_ui(editable, |ui| dim_field(ui, f, "height", "__origH", "is-h")).inner;
unit_dropdown(ui, f, "is-unit-h");
unit_dropdown(ui, f, "height", "is-unit-h");
c
});
let (w_changed, h_changed) = (w_row.inner, h_row.inner);
@@ -298,13 +406,19 @@ fn image_size(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
// Without resampling the pixel count is fixed: physical size follows resolution.
f.insert("width".into(), json!(ow));
f.insert("height".into(), json!(oh));
} else if !matches!(unit.as_str(), "px" | "percent") {
} else {
// Resampling in a physical unit keeps the print size: the pixels follow the
// resolution (Photoshop). In pixels or percent the pixel count stays.
// resolution (Photoshop). In pixels or percent the pixel count stays. Each field
// has its own unit (Width can be Columns, a physical unit, beside Height in
// pixels); with Constrain Proportions on, one physical side scales both.
let physical = |key: &str| !matches!(field_unit(f, key).as_str(), "px" | "percent");
let (pw, ph) = (physical("width"), physical("height"));
let old = f.get("resolution").and_then(Value::as_f64).unwrap_or(num(f, "__origRes"));
if old > 0.0 && old.is_finite() {
for key in ["width", "height"] {
f.insert(key.into(), json!((num(f, key) * r / old).max(1.0)));
for (key, scale) in [("width", pw || (constrain && ph)), ("height", ph || (constrain && pw))] {
if scale {
f.insert(key.into(), json!((num(f, key) * r / old).max(1.0)));
}
}
}
}
@@ -369,12 +483,12 @@ fn canvas_size(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
ui.horizontal(|ui| {
label(ui, tl!("Width:"), 90.0);
dim_field(ui, f, "width", "__origW", "cs-w");
unit_dropdown(ui, f, "cs-unit-w");
unit_dropdown(ui, f, "width", "cs-unit-w");
});
ui.horizontal(|ui| {
label(ui, tl!("Height:"), 90.0);
dim_field(ui, f, "height", "__origH", "cs-h");
unit_dropdown(ui, f, "cs-unit-h");
unit_dropdown(ui, f, "height", "cs-unit-h");
});
ui.horizontal(|ui| {
label(ui, "", 90.0);
+165
View File
@@ -32,6 +32,142 @@ fn points_and_picas_use_the_points_per_inch() {
assert_eq!(pt_per_in(&f), 72.27);
}
/// #6214: `n` columns are `n × width + (n − 1) × gutter` points. Photoshop's default Column
/// Size (180 pt, 12 pt gutter) at 300 ppi: 1 column is 2.5 in (750 px), 2 are 372 pt
/// (1550 px), 3 are 564 pt (2350 px).
#[test]
fn columns_convert_both_ways() {
let cols = (180.0, 12.0);
for (n, px) in [(1.0, 750.0), (2.0, 1550.0), (3.0, 2350.0), (0.5, 350.0)] {
assert!((columns_to_px(n, 300.0, 72.0, cols, false) - px).abs() < 1e-9, "{n} columns");
assert!((px_to_columns(px, 300.0, 72.0, cols, false) - n).abs() < 1e-9, "{px} px");
}
// Traditional points (72.27 pt/in) and other resolutions round-trip too.
for (ppi, res, cols) in [(72.27, 72.0, (100.0, 0.0)), (72.0, 150.0, (240.0, 18.0)), (72.0, 1.0, (1.0, 10_000.0))] {
for n in [1.0, 2.0, 4.0, 12.0, 0.25] {
// A relative delta under one column can be shorter than the gutter: it's zero.
for relative in [false, true].into_iter().filter(|r| !*r || n >= 1.0) {
let px = columns_to_px(n, res, ppi, cols, relative);
assert!((px_to_columns(px, res, ppi, cols, relative) - n).abs() < 1e-9, "{n} at {res} ppi, {cols:?}, relative {relative}");
}
}
}
// Canvas Size's relative deltas: zero stays zero, and shrinking mirrors growing.
assert_eq!(columns_to_px(0.0, 300.0, 72.0, cols, true), 0.0);
assert_eq!(px_to_columns(0.0, 300.0, 72.0, cols, true), 0.0);
assert_eq!(columns_to_px(-2.0, 300.0, 72.0, cols, true), -1550.0);
}
/// #6214: a zero, negative, NaN or missing Column Size falls back instead of dividing by zero.
#[test]
fn degenerate_column_size_does_not_panic() {
let mut f = Map::new();
assert_eq!(column_size(&f), (180.0, 12.0));
for (w, g, want) in [
(json!(0.0), json!(0.0), (180.0, 0.0)),
(json!(-5.0), json!(-3.0), (180.0, 0.0)),
(json!(f64::NAN), json!(f64::INFINITY), (180.0, 12.0)),
(json!("x"), json!(null), (180.0, 12.0)),
(json!(90.0), json!(6.0), (90.0, 6.0)),
] {
f.insert("__colWidth".into(), w);
f.insert("__colGutter".into(), g);
let cols = column_size(&f);
assert_eq!(cols, want);
for px in [0.0, 1.0, 750.0, -10.0, 1e12] {
assert!(px_to_columns(px, 300.0, 72.0, cols, false).is_finite());
assert!(px_to_columns(px, 0.0, 72.0, cols, true).is_finite());
}
}
}
/// #6214: the dialogs read Column Size from the preferences; Columns is a Width-only unit and
/// picking another unit on Width leaves it.
#[test]
fn columns_unit_is_width_only_and_follows_the_preference() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", json!({"width": 200, "height": 100})).unwrap();
app.session.execute("prefs.set", json!({"path": "unitsAndRulers.columnWidth", "value": 0})).unwrap();
app.session.execute("prefs.set", json!({"path": "unitsAndRulers.gutter", "value": 6})).unwrap();
for command in ["image.imageSize", "image.canvasSize"] {
let id = open(&mut app, command).unwrap();
let f = &mut app.ui.dialog_mut(id).unwrap().fields;
assert_eq!((f.get("__colWidth"), f.get("__colGutter")), (Some(&json!(0.0)), Some(&json!(6.0))), "{command}");
assert_eq!(column_size(f), (180.0, 6.0), "{command}: a zero width falls back");
f.insert("__unit".into(), json!("in"));
set_unit(f, "width", "col");
assert_eq!((field_unit(f, "width"), field_unit(f, "height")), ("col".to_string(), "in".to_string()), "{command}");
set_unit(f, "height", "col");
assert_eq!(field_unit(f, "height"), "in", "{command}: Height has no Columns");
set_unit(f, "height", "mm");
assert_eq!((field_unit(f, "width"), field_unit(f, "height")), ("col".to_string(), "mm".to_string()), "{command}");
set_unit(f, "width", "pt");
assert_eq!((field_unit(f, "width"), field_unit(f, "height")), ("pt".to_string(), "pt".to_string()), "{command}");
// A stray shared `col` (automation) shows Height in pixels rather than as columns.
f.insert("__unit".into(), json!("col"));
assert_eq!(field_unit(f, "height"), "px", "{command}");
app.ui.close_dialog(id);
}
}
/// #6214: 2 columns typed into Image Size's Width at 300 ppi resize to 1550 px wide (the
/// default 180 pt columns with 12 pt gutters), Height following the proportions; Canvas Size
/// counts whole inches, so its 2 columns are 600 px.
#[test]
fn typed_columns_resize_the_document() {
use egui_kittest::Harness;
for command in ["image.imageSize", "image.canvasSize"] {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", json!({"width": 1000, "height": 500, "resolution": 300})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
let id = open(h.state_mut(), command).unwrap();
set_unit(&mut h.state_mut().ui.dialog_mut(id).unwrap().fields, "width", "col");
h.run_steps(3);
type_width(&mut h, "2");
let f = fields(&h);
let px = if command == "image.imageSize" { 1550.0 } else { 600.0 };
assert!((num(&f, "width") - px).abs() < 1e-6, "{command}: {f:?}");
crate::dialogs::confirm(h.state_mut(), id).unwrap();
let want = if command == "image.imageSize" { photocraft_doc::Size::new(1550, 775) } else { photocraft_doc::Size::new(600, 500) };
assert_eq!(h.state().session.active().unwrap().doc.size, want, "{command}");
}
}
/// Canvas Size's Columns as measured in the reference application: a column is one inch at the
/// document resolution whatever Column Size says, and only whole columns count. At 300 ppi with
/// Column Size 360 pt / 24 pt: a 1001 px canvas shows as 3 columns, typed 1.5 is 2 columns
/// (600 px), and Relative +1 column adds 300 px.
#[test]
fn canvas_size_columns_are_whole_inches() {
use egui_kittest::Harness;
assert_eq!(canvas_px_to_columns(1001.0, 300.0), 3.0);
assert_eq!(canvas_px_to_columns(1001.0, 72.0), 14.0);
assert_eq!(canvas_columns_to_px(1.5, 72.0), 144.0);
assert_eq!(canvas_columns_to_px(-1.0, 300.0), -300.0);
assert!(canvas_px_to_columns(1000.0, 0.0).is_finite());
for (relative, typed, want) in [(false, "1.5", 600), (true, "1", 1301)] {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", json!({"width": 1001, "height": 500, "resolution": 300})).unwrap();
app.session.execute("prefs.set", json!({"path": "unitsAndRulers.columnWidth", "value": 360})).unwrap();
app.session.execute("prefs.set", json!({"path": "unitsAndRulers.gutter", "value": 24})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
let id = open(h.state_mut(), "image.canvasSize").unwrap();
let f = &mut h.state_mut().ui.dialog_mut(id).unwrap().fields;
set_unit(f, "width", "col");
if relative {
f.insert("relative".into(), json!(true));
f.insert("width".into(), json!(0.0));
f.insert("height".into(), json!(0.0));
}
h.run_steps(3);
type_width(&mut h, typed);
crate::dialogs::confirm(h.state_mut(), id).unwrap();
assert_eq!(h.state().session.active().unwrap().doc.size, photocraft_doc::Size::new(want, 500), "relative {relative}");
}
}
#[test]
fn dialog_maps_to_engine_command() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
@@ -309,6 +445,35 @@ fn resample_on_resolution_keeps_the_physical_size() {
}
}
/// #6979 with #6214's per-field units: Width in Columns (physical) beside Height in pixels.
/// A new Resolution with Resample on keeps the print width; linked, the height follows the
/// proportions, unlinked it keeps its pixels. 372 x 300 px at 72 ppi is 2 columns (the default
/// 180 pt columns, 12 pt gutter) wide; at 300 ppi 2 columns are 1550 px.
#[test]
fn resample_on_resolution_with_width_in_columns() {
for (constrain, size) in [(true, (1550.0, 1250.0)), (false, (1550.0, 300.0))] {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", json!({"width": 372, "height": 300, "resolution": 72})).unwrap();
let mut h = egui_kittest::Harness::builder()
.with_size(egui::vec2(1400.0, 900.0))
.build_ui_state(|ui, app: &mut PhotocraftApp| crate::dialogs::show(app, ui.ctx()), app);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
let id = open(h.state_mut(), "image.imageSize").unwrap();
let d = h.state_mut().ui.dialog_mut(id).unwrap();
d.fields.insert("resample".into(), json!("bicubic"));
d.fields.insert("__unit".into(), json!("px"));
d.fields.insert("__constrain".into(), json!(constrain));
set_unit(&mut d.fields, "width", "col");
h.run_steps(3);
type_into(&mut h, 2, "300");
let f = fields(&h);
assert_eq!((num(&f, "width"), num(&f, "height"), num(&f, "resolution")), (size.0, size.1, 300.0), "constrain {constrain}: {f:?}");
crate::dialogs::confirm(h.state_mut(), id).unwrap();
let doc = &h.state().session.active().unwrap().doc;
assert_eq!(doc.size, photocraft_doc::Size::new(size.0 as u32, size.1 as u32), "constrain {constrain}");
}
}
/// #4162: a document wider than 30000 units in a physical unit (40000 px at 72 ppi is 40000 pt)
/// kept its size in the fields and on OK; the fields clamped it to 30000 on open, so OK with
/// no edit resized (Image Size) or cropped (Canvas Size) it.
+5 -1
View File
@@ -34,6 +34,8 @@ pub(super) fn humanize(key: &str) -> String {
"darkTheme" => return "Dark Theme".into(),
"lightTheme" => return "Light Theme".into(),
"automaticallyCheckUpdates" => return "Automatically Check for Updates".into(),
// Units & Rulers › Column Size (in points), as translated for New Guide Layout.
"columnWidth" => return "Column width".into(),
_ => {}
}
// These controls appear only for WebP, so reuse the existing translated labels.
@@ -536,7 +538,9 @@ fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map<String, Value>, ke
}
} else {
let mut x = n.as_f64().unwrap_or(0.0);
ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.1).max_decimals(3).custom_parser(crate::widgets::parse_num))
// Column Size is stored in points (Image Size's Columns unit, #6214).
let suffix = if matches!(path.as_str(), "unitsAndRulers.columnWidth" | "unitsAndRulers.gutter") { " pt" } else { "" };
ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.1).max_decimals(3).suffix(suffix).custom_parser(crate::widgets::parse_num))
.labelled_by(value_label.id);
obj.insert(k, json!(x));
}
+5 -2
View File
@@ -1,6 +1,6 @@
# Where PhotoCraft falls short of Photoshop
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (G14: Fujifilm compressed RAF decodes, #7377; G5: Warp mode edits Photoshop's Cylinder warp, #4112: the Warp menu lists it, five handles set its seven values as in the reference, and a smart object's stored cylinder opens in Warp mode as one; Average executes through shared filters on Smart Objects and imported PSD records; Average preserves per-pixel alpha and includes hidden RGB in its mean; RGB8 observations against Photoshop 26.6.1, see average-filter.md; G4: Both Axes on Scatter headers, #3825 partial; G4: combined Spacing checkbox and percentage header, #3825 partial; G14: Hasselblad 3FR/FFF, the X2D II 100C's uncompressed CFA, decodes; G4: Angle/Roundness fields left of the Brush Tip Shape widget, #3825 partial; G12: the eraser end of flipped pens selects the Eraser on Windows with Windows Ink on, #2285; no estimate changed; RGB and CMYK documents preview their spot inks as an overprint on the canvas and in the PSD thumbnail, FILE-215-17 narrows to PDF Separation, #10249; G2: compositor compilation moved off the first document refresh, with startup measurements pending, #9157; G11: Playback Options dialog with Step by Step and Pause For, #5212; G11: Stop step messages with Continue/Stop and nested resume, #8386; adjustment-layer Make keeps name, blend mode and clipping, #8384; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpaceLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (G9: Column Size preference and Image Size / Canvas Size Columns unit, #6214: 8 of 199 preferences do nothing; G14: Fujifilm compressed RAF decodes, #7377; G5: Warp mode edits Photoshop's Cylinder warp, #4112: the Warp menu lists it, five handles set its seven values as in the reference, and a smart object's stored cylinder opens in Warp mode as one; Average executes through shared filters on Smart Objects and imported PSD records; Average preserves per-pixel alpha and includes hidden RGB in its mean; RGB8 observations against Photoshop 26.6.1, see average-filter.md; G4: Both Axes on Scatter headers, #3825 partial; G4: combined Spacing checkbox and percentage header, #3825 partial; G14: Hasselblad 3FR/FFF, the X2D II 100C's uncompressed CFA, decodes; G4: Angle/Roundness fields left of the Brush Tip Shape widget, #3825 partial; G12: the eraser end of flipped pens selects the Eraser on Windows with Windows Ink on, #2285; no estimate changed; RGB and CMYK documents preview their spot inks as an overprint on the canvas and in the PSD thumbnail, FILE-215-17 narrows to PDF Separation, #10249; G2: compositor compilation moved off the first document refresh, with startup measurements pending, #9157; G11: Playback Options dialog with Step by Step and Pause For, #5212; G11: Stop step messages with Continue/Stop and nested resume, #8386; adjustment-layer Make keeps name, blend mode and clipping, #8384; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longeLine truncated
Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact
on the way to beta. This is the work list agents pick from: take the highest-ranked entry that
@@ -344,6 +344,8 @@ what an agent can't finish alone.
- **Progress (2026-10-21):** adjustment clipping is reachable above the Properties editor in a compact dock (#5231), with all-theme layout coverage and 8/16/32-bit scope and undo/redo tests. The normal adjustment-layer scope and remaining estimate stay unchanged.
- **Progress (2026-10-16, #3222):** UI scale presets are complete: 75–125% in 5% steps,
then 150–300%, remembered through preferences (UI-217-9).
- **Progress (2026-11-06):** Units & Rulers › Column Size (width, gutter) takes effect: Image
Size and Canvas Size offer a Columns width unit (#6214).
- **Progress (2026-10-15):** Preferences › General › Animated Zoom takes effect: a held Zoom
tool press zooms continuously about the press point, ⌥ out (#6210, `ui-egui/src/zoom_hold.rs`).
- **Progress (2026-10-14, #5556):** Character and Paragraph controls stay available without
@@ -354,7 +356,7 @@ what an agent can't finish alone.
- **Progress (2026-10-15):** the Color panel has Photoshop's panel-menu modes (Hue Cube, Brightness
Cube, Color Wheel, Grayscale/RGB/HSB/CMYK/Lab/Web Color sliders), its colour ramp and copy
commands, the same in every theme (UI-217-17, #2569, #6000; `ui-egui/src/color_panel_ui.rs`).
- **Missing:** 11 of 183 preferences do nothing (scorecard prefs audit, 2026-11-01); panel groups can't
- **Missing:** 8 of 199 preferences do nothing (scorecard prefs audit, 2026-11-08: Column Size takes effect, #6214); panel groups can't
float or tear off into OS windows (UI-217-5, #1828, #1213, #2305); more themes (UI-217-11, #2661); a single pixel layer
copies without its mask and styles (UI-217-23; fill and adjustment layers copy whole, #2344); tool-specific context menus beyond brushes, selections, Pen and
Crop
@@ -486,6 +488,7 @@ what an agent can't finish alone.
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | minor | G9: Units & Rulers › Column Size takes effect as the Columns width unit of Image Size and Canvas Size (#6214); preferences that do nothing 10 → 8 of 199 |
| 2026-10-27 | minor | G5: Warp mode edits Photoshop's Cylinder warp (#4112): the Warp menu lists it after the presets, five handles set its silhouettes, edges, ellipse depths and wrap as in the reference (Bend, H and V stay greyed), and a smart object's stored cylinder opens in Warp mode as a cylinder and commits back as one; no estimate changed |
| 2026-11-07 | minor | G14: Hasselblad 3FR/FFF, the X2D II 100C's uncompressed CFA, decodes; compressed 3FR still opens as the embedded preview |
| 2026-11-06 | minor | G12: eraser end of flipped pens on Windows (Windows Ink on) selects the Eraser (#2285); no estimate changed |
+3 -3
View File
@@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| [Performance](#performance) | 3 | 22 | 54 | 88 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 50 not run, 4 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 11 | 14 | 2 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/258, round trip ≥ 258/258 |
| [Tools](#tools) | 36 | 9 | 0 | |
| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 199 (target 0) |
| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 8 of 199 (target 0) |
| [Type](#type) | 5 | 5 | 0 | |
| [Automation](#automation) | 5 | 4 | 2 | 664 of 664 menu items live (100.0%) |
| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present |
@@ -212,9 +212,9 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu
## Workspace and UI
**Settings that do nothing: 10 of 199** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
**Settings that do nothing: 8 of 199** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.legacy_compositing`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`.
Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.legacy_compositing`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`.
| Id | Target | Status | Issue | Note |
|---|---|---|---|---|
+3 -2
View File
@@ -1,6 +1,6 @@
# UI parity: tools, handles, modifiers and feel
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Warp mode edits Photoshop's Cylinder warp with its five handles, #4112; Hue/Saturation range zones drag, #9252; a click on the image centres a filter dialog's preview window, #9648; a gradient stop dragged off the bar is removed in every stop editor, Gradient Map and Layer Style included, #10742; Image › Adjustments › Photo Filter… keeps Preserve Luminosity from its last OK and opens at Warming Filter (85), 25 %, as the reference application does, #9879; foreground/background swatches and exact CMYK picks restored across launches, #8485; Layer ▸ Smart Objects ▸ Reset Transform and the Layers panel row-menu item for a smart object layer: 100 % again in place with the Warp cleared and centred on the corners' average, #11662; each toolbar slot and tool key keeps its last tool across launches, #3569; Image › Adjustments › Selective Color keeps each colour's own values and applies all nine, #11463; History Options snapshot options, #3634; Channels panel menu: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, Channel Options…, Split Channels, Merge Channels…, #11451; a Color Picker for another dialog's colour or the Crop shield shows the picked colour there before OK, #9886; Edit › Transform › Distort side handles move their edge, Perspective side handles skew, #10085; Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; Layers panel filter by Color, #5898; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/DelLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Column Size drives the Columns width unit, #6214: 8 of 199 preferences do nothing; Warp mode edits Photoshop's Cylinder warp with its five handles, #4112; Hue/Saturation range zones drag, #9252; a click on the image centres a filter dialog's preview window, #9648; a gradient stop dragged off the bar is removed in every stop editor, Gradient Map and Layer Style included, #10742; Image › Adjustments › Photo Filter… keeps Preserve Luminosity from its last OK and opens at Warming Filter (85), 25 %, as the reference application does, #9879; foreground/background swatches and exact CMYK picks restored across launches, #8485; Layer ▸ Smart Objects ▸ Reset Transform and the Layers panel row-menu item for a smart object layer: 100 % again in place with the Warp cleared and centred on the corners' average, #11662; each toolbar slot and tool key keeps its last tool across launches, #3569; Image › Adjustments › Selective Color keeps each colour's own values and applies all nine, #11463; History Options snapshot options, #3634; Channels panel menu: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, Channel Options…, Split Channels, Merge Channels…, #11451; a Color Picker for another dialog's colour or the Crop shield shows the picked colour there before OK, #9886; Edit › Transform › Distort side handles move their edge, Perspective side handles skew, #10085; Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; Layers panel filter by Color, #5898; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms cLine truncated
How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the
handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation,
@@ -252,7 +252,7 @@ Paragraph; the rest open as in-app floating windows.
| Histogram panel: Compact (the default, of the entire image), Expanded and All Channels views (the channel kept across them); RGB/CMYK/Lab/Gray channels, Luminosity and Colors (overlaid, mixed where they overlap); Show Channels in Color and Show Statistics; Source (Entire Image, Selected Layer, Adjustment Composite) for multilayer documents; Mean, Std Dev, Median, Pixels, Level, Count, Percentile and Cache Level for the pointer level or a dragged range; the cached-data warning, Uncached Refresh (menu, button, warning click, double-click); the adjusted histogram over the original while an adjustment previews; Cache Levels 1–8 in Preferences › Performance; Color Sampler levels marked on the graph | partial | `histogram_panel.rs`, `histogram_panel_tests.rs`, `tone.rs`; differs: filter dialogs that preview on the CPU proxy aren't counted live, a selection doesn't limit the histogram yet, with Source Selected Layer an adjustment dialog's preview isn't counted, and new strings are English-only so far |
| Command palette with recents | done | UI-217-7 |
| Menu bar from the keyboard: Alt / F10 focus the bar, ← → ↓ ↩ drive it, Alt+letter opens a menu, underlined mnemonics run items | done | `menu_keys.rs`, #4988; macOS uses the native bar |
| Every preference takes effect | partial | 10 of 195 do nothing (scorecard, 2026-11-07) |
| Every preference takes effect | partial | 8 of 199 do nothing (scorecard, 2026-11-08); Column Size drives Image Size and Canvas Size's Columns width unit (#6214) |
| UI scale presets | done | UI-217-9; 75–125% in 5% steps (#2492, #3222) |
| History snapshots: camera button, New Snapshot dialog (⌥-click: Name, From), the first snapshot (the document as opened), snapshot rows (click returns and selects, rename, delete; the trash deletes the selected snapshot, and a snapshot or state dragged onto it, #5324), `history.*Snapshot` commands; History Options' snapshot options (Automatically Create First Snapshot, New Snapshot When Saving, Show New Snapshot Dialog by Default) | partial | `history_cmds.rs`, `history_options_cmds.rs`, `snapshot_rows` in `panels.rs`, `snapshot_dialog.rs`; History Brush sources to come (#2966) |
| Themes: Photoshop's four greys | partial | five themes; #2661 |
@@ -280,6 +280,7 @@ Paragraph; the rest open as in-app floating windows.
| 2026-10-27 | minor | Warp mode edits Photoshop's Cylinder warp (#4112): the Warp menu lists it, its five handles set its seven values as in the reference, and a smart object's stored cylinder opens as one. Scores unchanged. |
| 2026-11-08 | minor | Image › Adjustments › Photo Filter… keeps Preserve Luminosity from its last OK; filter, colour and density reopen at Warming Filter (85) and 25 %, measured in the reference application (#9879); no percentage changed |
| 2026-11-07 | minor | Gradient stops dragged off the bar are removed, with a live preview and drag-back cancel, in every stop editor: Gradient Map and Layer Style gained it, the Gradient Editor, Properties and shape popover share the same threshold (#10742); row added (done); no percentage changed |
| 2026-11-08 | minor | Units & Rulers › Column Size takes effect: Image Size and Canvas Size offer a Columns width unit (#6214); preferences that do nothing 10 → 8 of 199 |
| 2026-11-07 | minor | Foreground/background colours restored before the first frame (`Preferences.toolColors`, #8485), with separate pixel/mask pairs, HDR/alpha, exact CMYK ink picks and a final normal-exit flush; malformed values are ignored one by one, and first saves from older preferences merge against the default swatches so separate edits in two instances survive. No percentage changed |
| 2026-11-07 | minor | Reset Transform on a smart object also clears the **Warp**, and centres on the average of the placement's four corners rather than the point its box centre maps to: measured in the reference application 2026-11-06 (a smart object warped with Arc 50, rotated 20° and scaled to 80 % came back with no warp at all, at its contents' own size, unrotated), which corrects the row below (#11662, discussion in #11675). The corner average is the box centre for an affine placement, so only Distort and Perspective placements move; the Smart Filter mask and linked masks keep following the placement, as under Free Transform. The step also requires its re-render: a visible filter PhotoCraft doesn't implement, or contents that can't be read, refuses it whole (no history state, placement and warp untouched) rather than recording the old warped pixels at the reset placement. No percentage changed |
| 2026-11-06 | minor | Layer ▸ Smart Objects ▸ Reset Transform, and the same item in the Layers panel row menu for a smart object layer (#11662): 100 % of the current contents' own size again with rotation, skew, Distort and Perspective cleared and the object left centred where it is, in one undo step; the Warp and the smart filters are untouched, as Photoshop resets the transform and not the warp. No percentage changed |