mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-19 21:54:05 -04:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d921c7859 | ||
|
|
3d955b6dc7 | ||
|
|
7de0dae648 | ||
|
|
5ea62e5c7a | ||
|
|
0273f92abe | ||
|
|
6420a4d5f9 | ||
|
|
103783a2ab | ||
|
|
9c4f810d14 | ||
|
|
a3c34e8221 | ||
|
|
0475d60258 | ||
|
|
e6795f9d70 | ||
|
|
f8fb5d4595 | ||
|
|
4560d12ac2 | ||
|
|
48adaa7edf | ||
|
|
51a5da427e | ||
|
|
7eb04a1f64 | ||
|
|
e4bf997806 | ||
|
|
067cdd1f68 | ||
|
|
72468368e3 | ||
|
|
8e6f7fffe4 | ||
|
|
caea32cc51 | ||
|
|
cee1d4ee49 | ||
|
|
a7da0ad8f0 | ||
|
|
cd9127b3b5 | ||
|
|
6fe7b0e3ba | ||
|
|
5b2f34c6b8 | ||
|
|
7cdb227a9f | ||
|
|
e9cc4826c4 | ||
|
|
62172a0cd9 | ||
|
|
89aab1d218 | ||
|
|
a614222b63 | ||
|
|
6512c938ad | ||
|
|
21318c065f | ||
|
|
7f0de92453 | ||
|
|
e14c0cf9ce | ||
|
|
02461a2307 | ||
|
|
76c9d65ba8 |
No files matched your search
@@ -9,3 +9,4 @@ target/
|
||||
/temp
|
||||
/book/output
|
||||
/craft-fonts
|
||||
*.log
|
||||
@@ -341,6 +341,12 @@ pub struct Sentinel {
|
||||
/// The sentinel, shared with eframe's adapter hook and the app's started hook.
|
||||
pub type SharedSentinel = Arc<Mutex<Option<Sentinel>>>;
|
||||
|
||||
/// Only renderer errors should affect the next launch's graphics backend. Windowing errors
|
||||
/// (such as a missing Linux display) and app setup errors never indicate a driver failure.
|
||||
pub fn keep_marker_after_run(result: &eframe::Result) -> bool {
|
||||
matches!(result, Err(eframe::Error::Wgpu(_)))
|
||||
}
|
||||
|
||||
impl Sentinel {
|
||||
/// Open (creating) the marker in `dir` and lock it. Returns what a previous start left and,
|
||||
/// unless another instance holds the lock or the directory isn't writable, this start's
|
||||
@@ -599,6 +605,33 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returned_non_graphics_errors_do_not_trigger_backend_recovery() {
|
||||
let cases = [
|
||||
("success", Ok(()), false),
|
||||
("app-error", Err(eframe::Error::AppCreation(Box::new(std::io::Error::other("app setup failed")))), false),
|
||||
("gpu-error", Err(eframe::Error::Wgpu(egui_wgpu::WgpuError::CustomNativeAdapterSelectionError("no adapter".into()))), true),
|
||||
];
|
||||
for (name, result, keep) in cases {
|
||||
let dir = temp_dir(name);
|
||||
let (_, s) = Sentinel::begin(&dir);
|
||||
let mut s = s.expect("sentinel");
|
||||
s.write(Marker { backend: "vulkan".into(), ..Default::default() }).unwrap();
|
||||
if keep_marker_after_run(&result) {
|
||||
drop(s);
|
||||
} else {
|
||||
s.finish();
|
||||
}
|
||||
let (previous, s) = Sentinel::begin(&dir);
|
||||
assert_eq!(previous.crashed().is_some(), keep, "{name}: {result:?}");
|
||||
if !keep {
|
||||
assert_eq!(plan(Vulkan, previous.crashed(), None, false, Os::Other).backend, Vulkan);
|
||||
}
|
||||
s.expect("sentinel").finish();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwritable_config_dir_is_not_fatal() {
|
||||
let dir = temp_dir("file");
|
||||
|
||||
@@ -390,12 +390,22 @@ fn main() -> eframe::Result {
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
);
|
||||
// Closed before the first frames rendered: not a driver crash. (A start that failed to
|
||||
// create its device keeps the marker, so the next one tries a safer backend.)
|
||||
if result.is_ok()
|
||||
&& let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()
|
||||
// Closed or failed outside graphics initialization: not a driver crash. A renderer
|
||||
// error keeps the marker, so the next start tries a safer backend.
|
||||
if !gpu_startup::keep_marker_after_run(&result)
|
||||
&& let Some(mut s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()
|
||||
{
|
||||
s.finish();
|
||||
if result.is_err()
|
||||
&& let Some(marker) = previous.crashed()
|
||||
{
|
||||
// This failure supplies no new graphics-crash evidence: retain the previous
|
||||
// marker, rather than recording this attempt's fallback backend.
|
||||
if let Err(error) = s.write(marker.clone()) {
|
||||
log::warn!("couldn't restore previous GPU startup marker: {error}");
|
||||
}
|
||||
} else {
|
||||
s.finish();
|
||||
}
|
||||
}
|
||||
// Retry in a fresh process: winit event loops cannot be recreated reliably in-process.
|
||||
// Only renderer initialization failures qualify; never restart after editing has begun.
|
||||
|
||||
@@ -32,13 +32,17 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
|
||||
("OpenEXR", &["exr"]),
|
||||
];
|
||||
|
||||
/// [`SAVE_FILTERS`] with the one for `suggested`'s extension first.
|
||||
fn save_filters(suggested: &str) -> Vec<(&'static str, &'static [&'static str])> {
|
||||
/// Lists the save dialog's file types with the `suggested` type first (added if unlisted), so the dialog keeps that extension instead of .psd.
|
||||
fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
|
||||
let ext = Path::new(suggested).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
let mut v = SAVE_FILTERS.to_vec();
|
||||
if let Some(i) = v.iter().position(|(_, exts)| exts.contains(&ext.as_str())) {
|
||||
let f = v.remove(i);
|
||||
v.insert(0, f);
|
||||
let mut v: Vec<(String, Vec<String>)> = SAVE_FILTERS.iter().map(|(name, exts)| (name.to_string(), exts.iter().map(|e| e.to_string()).collect())).collect();
|
||||
match v.iter().position(|(_, exts)| exts.contains(&ext)) {
|
||||
Some(i) => {
|
||||
let f = v.remove(i);
|
||||
v.insert(0, f);
|
||||
}
|
||||
None if !ext.is_empty() => v.insert(0, (ext.to_ascii_uppercase(), vec![ext])),
|
||||
None => {}
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -180,7 +184,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
let p = std::path::Path::new(suggested);
|
||||
let mut d = rfd::FileDialog::new();
|
||||
for (name, exts) in save_filters(suggested) {
|
||||
d = d.add_filter(name, exts);
|
||||
d = d.add_filter(name, &exts);
|
||||
}
|
||||
if let Some(name) = p.file_name() {
|
||||
d = d.set_file_name(name.to_string_lossy());
|
||||
@@ -310,14 +314,75 @@ mod tests {
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_format::list_recovery;
|
||||
use photocraft_ui_egui::{PhotocraftApp, prefs_ui};
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// "Export As" formats must lead with their own filter, or the save panel appends the first one's extension (`photo.webp.psd`).
|
||||
/// Tests every native save dialog and records the suggested file name. Each name's file type must come first
|
||||
/// in the save panel, or the panel appends the first type's extension (`photo.webp.psd`, `photo.gif.psd`).
|
||||
#[test]
|
||||
fn save_filters_lead_with_every_export_format() {
|
||||
for ext in ["png", "jpg", "webp", "tif", "tga"] {
|
||||
assert!(save_filters(&format!("photo.{ext}"))[0].1.contains(&ext), "{ext}");
|
||||
fn every_save_dialog_leads_with_its_own_extension() {
|
||||
let asked: Rc<RefCell<Vec<String>>> = Rc::default();
|
||||
let log = asked.clone();
|
||||
let services = Services {
|
||||
pick_save: Some(Box::new(move |s: &str| {
|
||||
log.borrow_mut().push(s.to_string());
|
||||
None
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
let ctx = egui::Context::default();
|
||||
let invoke = |app: &mut PhotocraftApp, id: &str| photocraft_ui_egui::menus::invoke(app, &ctx, id, json!({}));
|
||||
let dialog = |app: &mut PhotocraftApp, id: &str, fields: Value| {
|
||||
let d = invoke(app, id).unwrap()["dialog"].as_u64().unwrap();
|
||||
for (k, v) in fields.as_object().unwrap() {
|
||||
app.ui.dialog_mut(d).unwrap().fields.insert(k.clone(), v.clone());
|
||||
}
|
||||
let _ = photocraft_ui_egui::dialogs::confirm(app, d);
|
||||
};
|
||||
new_doc(&mut app, "#ff0000");
|
||||
// Save As, Save a Copy.
|
||||
// Files in a format Save As can't write, like .dng, should suggest .psd instead.
|
||||
let _ = invoke(&mut app, "file.saveAs");
|
||||
for name in ["cat.pcraft", "cat.jpeg", "cat.gif", "cat.bmp", "cat.dng"] {
|
||||
app.session.active_mut().unwrap().path = Some(name.into());
|
||||
let _ = invoke(&mut app, "file.saveAs");
|
||||
}
|
||||
let _ = invoke(&mut app, "file.saveACopy");
|
||||
// Export As, Quick Export, Save for Web (with and without slices).
|
||||
for format in ["png", "jpg", "webp", "tif", "tga"] {
|
||||
dialog(&mut app, "file.export.exportAs", json!({"format": format}));
|
||||
}
|
||||
for format in ["png", "jpg", "gif", "webp"] {
|
||||
app.run("prefs.set", json!({"path": "export.quickExportFormat", "value": format})).unwrap();
|
||||
let _ = invoke(&mut app, "file.export.quickExportAsPng");
|
||||
}
|
||||
for format in ["gif", "png8", "png24", "jpeg", "wbmp"] {
|
||||
dialog(&mut app, "file.export.saveForWebLegacy", json!({"format": format}));
|
||||
}
|
||||
app.run("slice.new", json!({"x": 0, "y": 0, "width": 2, "height": 2})).unwrap();
|
||||
dialog(&mut app, "file.export.saveForWebLegacy", json!({"html": true}));
|
||||
// Measurement Log, Export/Import Presets.
|
||||
app.run("image.analysis.recordMeasurements", json!({})).unwrap();
|
||||
let _ = invoke(&mut app, "measurementLog.export");
|
||||
dialog(&mut app, "edit.presets.exportImportPresets", json!({"action": "export"}));
|
||||
let asked = asked.borrow();
|
||||
let exts: Vec<&str> = asked.iter().filter_map(|s| s.rsplit_once('.').map(|(_, e)| e)).collect();
|
||||
// The extension each save above should suggest, in order.
|
||||
let want = [
|
||||
"psd", // Save As untitled
|
||||
"pcraft jpeg gif bmp psd", // Save As opened .pcraft .jpeg .gif .bmp .dng
|
||||
"psd", // Save a Copy
|
||||
"png jpg webp tif tga", // Export As
|
||||
"png jpg gif webp", // Quick Export
|
||||
"gif png png jpg wbmp html", // Save for Web: gif png8 png24 jpeg wbmp, then with slices
|
||||
"csv pcpresets", // Measurement Log, Export Presets
|
||||
]
|
||||
.join(" ");
|
||||
assert_eq!(exts, want.split(' ').collect::<Vec<_>>());
|
||||
for (name, ext) in asked.iter().zip(exts) {
|
||||
assert!(save_filters(name)[0].1.iter().any(|e| e == ext), "{name}: {:?}", save_filters(name)[0]);
|
||||
}
|
||||
assert_eq!(save_filters("Untitled")[0].0, "Photoshop", "no extension keeps the default");
|
||||
}
|
||||
|
||||
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! A failed Linux desktop launch must not poison the next launch's graphics policy.
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
#[test]
|
||||
fn missing_display_does_not_leave_a_gpu_crash_marker() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-no-display-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let prefs = br#"{"performance":{"gpuBackend":"vulkan","useGpu":true}}"#;
|
||||
std::fs::write(dir.join("preferences.json"), prefs).unwrap();
|
||||
|
||||
let error = launch_without_display(&dir);
|
||||
assert!(!dir.join("gpu-starting.json").exists(), "a display error is not a GPU crash: {error}");
|
||||
assert_eq!(std::fs::read(dir.join("preferences.json")).unwrap(), prefs);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_display_preserves_existing_gpu_crash_evidence() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-no-display-prior-crash-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let marker = serde_json::json!({
|
||||
"backend": "vulkan", "adapter": "Test GPU", "adapterBackend": "vulkan",
|
||||
"driver": "test", "version": "test"
|
||||
});
|
||||
std::fs::write(dir.join("gpu-starting.json"), serde_json::to_vec(&marker).unwrap()).unwrap();
|
||||
launch_without_display(&dir);
|
||||
let restored: serde_json::Value = serde_json::from_slice(&std::fs::read(dir.join("gpu-starting.json")).unwrap()).unwrap();
|
||||
assert_eq!(restored, marker, "a windowing error must not erase or advance prior GPU crash evidence");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
fn launch_without_display(dir: &std::path::Path) -> String {
|
||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft"))
|
||||
.env("PHOTOCRAFT_CONFIG_DIR", dir)
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.env_remove("WAYLAND_SOCKET")
|
||||
.env_remove("WGPU_BACKEND")
|
||||
.env_remove("PHOTOCRAFT_GPU_STARTUP_FAILURE")
|
||||
.env_remove("PHOTOCRAFT_CONTROL_PORT")
|
||||
.output()
|
||||
.unwrap();
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(!output.status.success(), "a desktop cannot open without a display");
|
||||
assert!(error.contains("WAYLAND_DISPLAY") && error.contains("DISPLAY"), "expected a display error, got: {error}");
|
||||
error.into_owned()
|
||||
}
|
||||
@@ -351,9 +351,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// The most recent filter command in the session journal.
|
||||
/// The most recent filter command in the session journal, retargeted at the active layer: dialogs
|
||||
/// such as the Filter Gallery record the layer they ran on, and Last Filter applies to the
|
||||
/// current layer, as in Photoshop (#1270).
|
||||
fn last_filter(s: &Session) -> Option<(String, Value)> {
|
||||
s.journal.iter().rev().find(|(id, _)| params_for(id, &Value::Null).is_some() && id.starts_with("filter.")).cloned()
|
||||
let (id, mut params) = s.journal.iter().rev().find(|(id, _)| params_for(id, &Value::Null).is_some() && id.starts_with("filter.")).cloned()?;
|
||||
if let Value::Object(m) = &mut params {
|
||||
m.remove("layer");
|
||||
}
|
||||
Some((id, params))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -528,6 +534,24 @@ mod tests {
|
||||
assert_eq!(active_pixels(&s2), twice);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_filter_applies_to_the_active_layer() {
|
||||
let mut s = session();
|
||||
paint_pattern(&mut s);
|
||||
let first = s.active().unwrap().active_layer.unwrap();
|
||||
// Dialogs (Filter Gallery, Camera Raw…) record the layer they ran on.
|
||||
s.execute("filter.other.offset", json!({"horizontal": 3, "layer": first.0})).unwrap();
|
||||
let first_once = active_pixels(&s);
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
paint_pattern(&mut s);
|
||||
let before = active_pixels(&s);
|
||||
s.execute("filter.lastFilter", json!({})).unwrap();
|
||||
assert_ne!(active_pixels(&s), before, "the active layer is filtered");
|
||||
let d = s.active().unwrap();
|
||||
let l = d.doc.layer(first).unwrap();
|
||||
assert_eq!(l.surface().unwrap().read_region(photocraft_geom::Rect::new(0, 0, 48, 32)), first_once, "the first layer is left alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_map_to_algorithm_units() {
|
||||
assert_eq!(params_for("filter.blur.gaussianBlur", &json!({"radius": 4.5})), Some(FilterParams::GaussianBlur { radius: 4.5 }));
|
||||
|
||||
@@ -222,8 +222,25 @@ pub fn apply_set(layer: &Layer, f: &Fill, canvas: Rect, p: &Value, fg: [f32; 4],
|
||||
}
|
||||
if from.is_some() || to.is_some() || (style_changed && !explicit_geometry) {
|
||||
let cur = if from.is_some() || to.is_some() { gf::handles(*style, *angle, *scale, *offset, frame) } else { old_handles };
|
||||
let (a, s, o) = gf::from_handles(*style, from.unwrap_or(cur.0), to.unwrap_or(cur.1), frame, *angle);
|
||||
let (f0, t0) = (from.unwrap_or(cur.0), to.unwrap_or(cur.1));
|
||||
let (a, s, o) = gf::from_handles(*style, f0, t0, frame, *angle);
|
||||
(*angle, *scale, *offset) = (a, clamp_scale(s), o);
|
||||
// Past the scale range the dragged handle stops; the other one stays where it is (#1243).
|
||||
// (Linear centres on the midpoint, so a clamped scale would otherwise move both ends.)
|
||||
let anchor = match (from, to) {
|
||||
(Some(_), None) => Some((t0, false)),
|
||||
(None, Some(_)) => Some((f0, true)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((pin, is_start)) = anchor
|
||||
&& *scale != s
|
||||
{
|
||||
let (f1, t1) = gf::handles(*style, *angle, *scale, *offset, frame);
|
||||
let at = if is_start { f1 } else { t1 };
|
||||
let (dx, dy) = (pin[0] - at[0], pin[1] - at[1]);
|
||||
let (w, h) = (frame.width().max(1) as f32, frame.height().max(1) as f32);
|
||||
*offset = (offset.0 + dx / w, offset.1 + dy / h);
|
||||
}
|
||||
}
|
||||
if let Some(v) = p.get("stops") {
|
||||
let arr = v.as_array().ok_or_else(|| bad(CMD, "`stops` is [[location, colour], …]"))?;
|
||||
|
||||
@@ -297,3 +297,29 @@ fn commands_fail_gracefully() {
|
||||
let g = s.execute(SET, json!({"scale": 1})).unwrap();
|
||||
assert!((g["scale"].as_f64().unwrap() - 10.0).abs() < 1e-3, "{g}");
|
||||
}
|
||||
|
||||
/// Dragging one handle past the scale range stops it; the other handle stays put (#1243).
|
||||
#[test]
|
||||
fn a_clamped_handle_drag_leaves_the_other_handle_alone() {
|
||||
let pt = |g: &Value, k: &str| [g[k][0].as_f64().unwrap(), g[k][1].as_f64().unwrap()];
|
||||
let close = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-2 && (a[1] - b[1]).abs() < 1e-2;
|
||||
for style in STYLES {
|
||||
for (from, to, short, short_start) in [([14.0, 16.0], [34.0, 16.0], [15.0, 16.0], [33.0, 16.0]), ([24.0, 6.0], [24.0, 26.0], [24.0, 7.0], [24.0, 25.0])]
|
||||
{
|
||||
let mut s = session(8, "white");
|
||||
s.execute(CREATE, json!({"from": from, "to": to, "style": style})).unwrap();
|
||||
// End dragged almost onto the start: far below 10 %.
|
||||
let g = s.execute(SET, json!({"to": short})).unwrap();
|
||||
assert!((g["scale"].as_f64().unwrap() - 10.0).abs() < 1e-3, "{style}: {g}");
|
||||
assert!(close(pt(&g, "from"), from), "{style}: start moved to {:?}", pt(&g, "from"));
|
||||
// The end stops on the line towards the pointer.
|
||||
let e = pt(&g, "to");
|
||||
assert!((e[0] - from[0]) * (short[0] - from[0]) >= 0.0 && (e[1] - from[1]) * (short[1] - from[1]) >= 0.0, "{style}: {e:?}");
|
||||
// And the same for the start handle.
|
||||
let mut s = session(8, "white");
|
||||
s.execute(CREATE, json!({"from": from, "to": to, "style": style})).unwrap();
|
||||
let g = s.execute(SET, json!({"from": short_start})).unwrap();
|
||||
assert!(close(pt(&g, "to"), to), "{style}: end moved: {g}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -364,6 +364,9 @@ pub struct Export {
|
||||
pub quick_export_format: QuickExportFormat,
|
||||
pub quick_export_location: ExportLocation,
|
||||
pub jpeg_quality: u32,
|
||||
/// Keep the existing lossless Quick Export default until the user opts into lossy WebP.
|
||||
pub webp_lossless: bool,
|
||||
pub webp_quality: u32,
|
||||
pub metadata: ExportMetadata,
|
||||
pub convert_to_srgb: bool,
|
||||
}
|
||||
@@ -374,6 +377,8 @@ impl Default for Export {
|
||||
quick_export_format: QuickExportFormat::Png,
|
||||
quick_export_location: ExportLocation::Ask,
|
||||
jpeg_quality: 85,
|
||||
webp_lossless: true,
|
||||
webp_quality: 85,
|
||||
metadata: ExportMetadata::Copyright,
|
||||
convert_to_srgb: true,
|
||||
}
|
||||
@@ -905,7 +910,7 @@ pub fn range(path: &str) -> Option<(f64, f64)> {
|
||||
Some(match path {
|
||||
"fileHandling.autosaveMinutes" => (1.0, 240.0),
|
||||
"fileHandling.recentFileCount" => (0.0, 100.0),
|
||||
"export.jpegQuality" => (1.0, 100.0),
|
||||
"export.jpegQuality" | "export.webpQuality" => (1.0, 100.0),
|
||||
"performance.memoryUsageMb" => (256.0, 1_048_576.0),
|
||||
"performance.historyStates" => (1.0, 1000.0),
|
||||
"performance.cacheLevels" => (1.0, 8.0),
|
||||
|
||||
@@ -840,6 +840,8 @@ fn export_preferences(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
("quickExportFormat", "export.quickExportFormat"),
|
||||
("quickExportLocation", "export.quickExportLocation"),
|
||||
("jpegQuality", "export.jpegQuality"),
|
||||
("webpLossless", "export.webpLossless"),
|
||||
("webpQuality", "export.webpQuality"),
|
||||
("metadata", "export.metadata"),
|
||||
("convertToSrgb", "export.convertToSrgb"),
|
||||
] {
|
||||
@@ -897,9 +899,12 @@ fn quick_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o.insert("format".into(), json!("gif"));
|
||||
}
|
||||
"webp" => {
|
||||
// WebP has no legacy Save for Web optimiser: write it through the regular exporter.
|
||||
// WebP uses the regular exporter. Its optional quality is in the Photoshop-style
|
||||
// 0–12 save scale: invert that mapping so the codec receives the chosen 1–100.
|
||||
// Keeping quality absent preserves the historical lossless Quick Export behavior.
|
||||
let (wdoc, _, _) = web_document(&doc, &json!({}), &WebSettings { convert_to_srgb: prefs.convert_to_srgb, ..Default::default() })?;
|
||||
let warnings = crate::file_cmds::save_doc(&wdoc, &path, None)?;
|
||||
let quality = if prefs.webp_lossless { None } else { Some((f64::from(prefs.webp_quality.clamp(1, 100)) - 1.0) * 12.0 / 99.0) };
|
||||
let warnings = crate::file_cmds::save_doc(&wdoc, &path, quality)?;
|
||||
crate::automate_cmds::fire_event(s, "export");
|
||||
return Ok(json!({"path": path, "format": fmt, "warnings": warnings}));
|
||||
}
|
||||
@@ -1211,7 +1216,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Preferences…",
|
||||
&["File", "Export"],
|
||||
None,
|
||||
r##"{"quickExportFormat":"png|jpg|gif|webp"?,"quickExportLocation":"ask|sameFolder"?,"jpegQuality":1..100?,"metadata":"none|copyright|all"?,"convertToSrgb":bool?} → {values}"##,
|
||||
r##"{"quickExportFormat":"png|jpg|gif|webp"?,"quickExportLocation":"ask|sameFolder"?,"jpegQuality":1..100?,"webpLossless":bool?,"webpQuality":1..100?,"metadata":"none|copyright|all"?,"convertToSrgb":bool?} → {values}"##,
|
||||
|_| Ok(()),
|
||||
export_preferences
|
||||
),
|
||||
|
||||
@@ -153,6 +153,54 @@ fn export_preferences_drive_quick_export() {
|
||||
s.execute("prefs.reset", json!({"path": "export"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_export_preferences_select_lossless_or_lossy_output() {
|
||||
let dir = tmp("quick_webp_quality");
|
||||
let mut s = session(8);
|
||||
let defaults = s.prefs().export.clone();
|
||||
assert!(defaults.webp_lossless, "existing Quick Export defaults to lossless WebP");
|
||||
assert_eq!(defaults.webp_quality, 85);
|
||||
|
||||
// Simulate an older stored preference section without the newly added WebP keys.
|
||||
let mut old = s.prefs().to_json();
|
||||
old["export"].as_object_mut().unwrap().remove("webpLossless");
|
||||
old["export"].as_object_mut().unwrap().remove("webpQuality");
|
||||
let restored: crate::prefs::Preferences = serde_json::from_value(old).unwrap();
|
||||
assert!(restored.export.webp_lossless);
|
||||
assert_eq!(restored.export.webp_quality, 85);
|
||||
|
||||
let r = s.execute("file.export.exportPreferences", json!({"quickExportFormat": "webp", "webpLossless": true, "webpQuality": 40})).unwrap();
|
||||
assert_eq!(r["values"]["webpLossless"], true);
|
||||
assert_eq!(r["values"]["webpQuality"], 40);
|
||||
let lossless_path = format!("{dir}/lossless.webp");
|
||||
s.execute("file.export.quickExport", json!({"path": lossless_path})).unwrap();
|
||||
let lossless = std::fs::read(&lossless_path).unwrap();
|
||||
assert!(lossless.starts_with(b"RIFF") && &lossless[8..12] == b"WEBP");
|
||||
assert!(lossless.windows(4).any(|w| w == b"VP8L"), "lossless WebP should use VP8L");
|
||||
assert_eq!(decode(&lossless_path).dimensions(), (64, 48));
|
||||
|
||||
s.execute("file.export.exportPreferences", json!({"webpLossless": false})).unwrap();
|
||||
let low_path = format!("{dir}/lossy40.webp");
|
||||
s.execute("file.export.quickExport", json!({"path": low_path})).unwrap();
|
||||
let low = std::fs::read(&low_path).unwrap();
|
||||
assert!(low.windows(4).any(|w| w == b"VP8 "), "lossy WebP should contain a VP8 frame");
|
||||
assert_eq!(decode(&low_path).dimensions(), (64, 48));
|
||||
|
||||
s.execute("file.export.exportPreferences", json!({"webpQuality": 90})).unwrap();
|
||||
let high_path = format!("{dir}/lossy90.webp");
|
||||
s.execute("file.export.quickExport", json!({"path": high_path})).unwrap();
|
||||
let high = std::fs::read(&high_path).unwrap();
|
||||
assert!(high.windows(4).any(|w| w == b"VP8 "));
|
||||
assert_eq!(decode(&high_path).dimensions(), (64, 48));
|
||||
assert_ne!(low, high, "changing WebP quality should change the encoded image");
|
||||
|
||||
let before = s.prefs().export.webp_quality;
|
||||
for quality in [0, 101] {
|
||||
assert!(s.execute("file.export.exportPreferences", json!({"webpQuality": quality})).is_err());
|
||||
assert_eq!(s.prefs().export.webp_quality, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generator_naming_grammar() {
|
||||
let a = parse_asset_name("200% [email protected], 48x48 icons/bar.png8 + photo.jpg8", 72.0);
|
||||
|
||||
@@ -244,7 +244,11 @@ impl Ex {
|
||||
|
||||
fn encode(&self, id: i16, plane: &[u8], w: usize, h: usize) -> ChannelData {
|
||||
let depth = psd_depth(self.fmt.sample);
|
||||
ChannelData::encode(id, self.compression(), plane, w, h, depth, self.version)
|
||||
// Photoshop writes empty layer/mask channels as just the raw compression code.
|
||||
// A ZIP stream for a zero-sized channel makes readers try to decompress an
|
||||
// invalid image extent (notably psd-tools for 32-bit adjustment layers).
|
||||
let compression = if w == 0 || h == 0 { Compression::Raw } else { self.compression() };
|
||||
ChannelData::encode(id, compression, plane, w, h, depth, self.version)
|
||||
.or_else(|_| ChannelData::encode(id, Compression::Raw, plane, w, h, depth, self.version))
|
||||
.unwrap_or(ChannelData { id, compression: Some(Compression::Raw), data: plane.to_vec() })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
mod common;
|
||||
use common::Features;
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_io::{ExportOptions, export};
|
||||
use photocraft_psd::{Compression, PsdFile};
|
||||
|
||||
#[test]
|
||||
fn empty_layer_and_mask_channels_use_raw_without_a_payload() {
|
||||
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
for ext in ["out.psd", "out.psb"] {
|
||||
let mut doc = common::gen_doc(mode, depth, Features::ALL);
|
||||
let mut masked = common::raster("Uniform mask", doc.pixel_format(), photocraft_geom::Rect::new(0, 0, 3, 3), 1, true);
|
||||
masked.mask = Some(photocraft_doc::LayerMask {
|
||||
surface: photocraft_raster::Surface::new(photocraft_color::PixelFormat::new(ColorMode::Grayscale, depth, false)),
|
||||
enabled: true,
|
||||
linked: true,
|
||||
density: 1.0,
|
||||
feather: 0.0,
|
||||
});
|
||||
doc.layers.push(masked);
|
||||
let bytes = export(&doc, ext, &ExportOptions::default()).unwrap().bytes;
|
||||
let file = PsdFile::from_bytes(&bytes).unwrap();
|
||||
let mut empty = 0;
|
||||
let mut empty_masks = 0;
|
||||
for layer in file.layers() {
|
||||
for channel in &layer.channels {
|
||||
let rect = layer.channel_rect(channel.id);
|
||||
if rect.width() == 0 || rect.height() == 0 {
|
||||
empty += 1;
|
||||
if channel.id == -2 {
|
||||
empty_masks += 1;
|
||||
}
|
||||
assert_eq!(channel.compression, Some(Compression::Raw), "{mode:?} {depth:?} {ext}: {} channel {}", layer.name(), channel.id);
|
||||
assert!(channel.data.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(empty > 0);
|
||||
assert!(empty_masks > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -594,6 +594,8 @@ pub(crate) fn style_sheet_data(s: &CharStyle, font: usize, k: f32) -> E {
|
||||
("Strikethrough".into(), E::Bool(s.strikethrough)),
|
||||
("Ligatures".into(), E::Bool(s.ligatures)),
|
||||
("DLigatures".into(), E::Bool(s.discretionary_ligatures)),
|
||||
// Photopea lays out edited text without painting it unless its fill is enabled.
|
||||
("FillFlag".into(), E::Bool(true)),
|
||||
("FillColor".into(), E::Dict(vec![("Type".into(), E::Int(color_type)), ("Values".into(), E::Array(values))])),
|
||||
];
|
||||
dict.append(&mut opentype);
|
||||
|
||||
@@ -545,6 +545,75 @@ fn txt2_carries_optical_kerning() {
|
||||
assert!(l.char_runs().iter().all(|r| r.style.kerning == Kerning::Optical));
|
||||
}
|
||||
|
||||
/// Photopea needs an enabled fill, not just FillColor, to paint text after an edit. Inspect the
|
||||
/// serialized TySh rather than our renderer, which does not consume the PSD fill flag.
|
||||
#[test]
|
||||
fn psd_text_fill_is_enabled_in_runs_and_new_default_styles() {
|
||||
let mut t = point("AB", 100.0);
|
||||
t.runs = vec![
|
||||
TextRun { len: 1, style: CharStyle { color: Color::rgb(0.2, 0.4, 0.6), ..Default::default() } },
|
||||
TextRun { len: 1, style: CharStyle { faux_bold: true, size_pt: 100.0, ..Default::default() } },
|
||||
];
|
||||
for shape in [TextShape::Point, TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 200.0 }] {
|
||||
t.shape = shape;
|
||||
let tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&t, 72.0, None)).unwrap();
|
||||
let data = crate::psd::engine_data(&tysh.text).unwrap();
|
||||
let runs = data.path(&["EngineDict", "StyleRun", "RunArray"]).unwrap().as_array().unwrap();
|
||||
assert_eq!(runs.len(), 2);
|
||||
for run in runs {
|
||||
assert_eq!(run.path(&["StyleSheet", "StyleSheetData", "FillFlag"]).and_then(crate::engine_data::Value::as_bool), Some(true));
|
||||
}
|
||||
for key in ["ResourceDict", "DocumentResources"] {
|
||||
let sheets = data.path(&[key, "StyleSheetSet"]).unwrap().as_array().unwrap();
|
||||
assert_eq!(sheets[0].path(&["StyleSheetData", "FillFlag"]).and_then(crate::engine_data::Value::as_bool), Some(true));
|
||||
}
|
||||
let back = crate::psd::text_layer_from_tysh(&crate::psd::write_tysh(&tysh), 72.0).unwrap();
|
||||
assert_eq!(back.text, t.text);
|
||||
assert_eq!(back.runs.len(), t.runs.len());
|
||||
for (actual, expected) in back.runs.iter().zip(&t.runs) {
|
||||
assert_eq!(actual.style.color, expected.style.color);
|
||||
assert_eq!(actual.style.size_pt, expected.style.size_pt);
|
||||
assert_eq!(actual.style.faux_bold, expected.style.faux_bold);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_text_fill_is_enabled_after_editing_a_legacy_export() {
|
||||
use crate::engine_data::Value as E;
|
||||
// A legacy PhotoCraft export has a normal style and character runs without FillFlag.
|
||||
let mut t = point("Before", 40.0);
|
||||
let mut legacy = crate::psd::parse_tysh(&crate::psd::build_tysh(&t, 72.0, None)).unwrap();
|
||||
fn remove_fill_flags(value: &mut E) {
|
||||
match value {
|
||||
E::Dict(items) => {
|
||||
items.retain(|(key, _)| key != "FillFlag");
|
||||
for (_, value) in items {
|
||||
remove_fill_flags(value);
|
||||
}
|
||||
}
|
||||
E::Array(items) => items.iter_mut().for_each(remove_fill_flags),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut data = crate::psd::engine_data(&legacy.text).unwrap();
|
||||
remove_fill_flags(&mut data);
|
||||
for (key, value) in &mut legacy.text.items {
|
||||
if key.as_bytes() == b"EngineData" {
|
||||
*value = photocraft_psd::descriptor::Value::RawData(crate::engine_data::write(&data));
|
||||
}
|
||||
}
|
||||
t.psd_raw = Some(crate::psd::write_tysh(&legacy).into());
|
||||
t.text = "After".into();
|
||||
let tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&t, 72.0, None)).unwrap();
|
||||
let data = crate::psd::engine_data(&tysh.text).unwrap();
|
||||
let runs = data.path(&["EngineDict", "StyleRun", "RunArray"]).unwrap().as_array().unwrap();
|
||||
assert!(!runs.is_empty());
|
||||
for run in runs {
|
||||
assert_eq!(run.path(&["StyleSheet", "StyleSheetData", "FillFlag"]).and_then(E::as_bool), Some(true));
|
||||
}
|
||||
}
|
||||
|
||||
/// EngineData pair fields: manual kerning and "no automatic kerning" round-trip exactly, in
|
||||
/// Photoshop's form (see `psd::pair_runs`).
|
||||
#[test]
|
||||
|
||||
@@ -324,7 +324,11 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result<Value, String> {
|
||||
app.filter_preview = None;
|
||||
let cmd = d.fields.get("__command").and_then(|v| v.as_str().map(str::to_string)).ok_or("dialog has no command")?;
|
||||
let (cmd, params) = crate::smart_ui::confirm_command(&d.fields, cmd, crate::filter_dialog::params_of(&d.fields));
|
||||
app.run(&cmd, params)
|
||||
let result = app.run(&cmd, params.clone());
|
||||
if result.is_ok() && cmd == "view.newGuideLayout" {
|
||||
app.ui.view.guide_layout = params;
|
||||
}
|
||||
result
|
||||
}
|
||||
DialogKind::LayerStyle => crate::layer_style::confirm(app, &d.fields),
|
||||
DialogKind::About | DialogKind::Error => Ok(Value::Null),
|
||||
|
||||
@@ -19,8 +19,8 @@ pub fn open(app: &mut PhotocraftApp) -> Result<u64, String> {
|
||||
f.insert("__export".into(), json!(true));
|
||||
f.insert("__label".into(), json!("Export As"));
|
||||
f.insert("format".into(), json!("png"));
|
||||
f.insert("quality".into(), json!(85));
|
||||
f.insert("lossless".into(), json!(false));
|
||||
f.insert("quality".into(), json!(app.session.prefs().export.jpeg_quality));
|
||||
f.insert("lossless".into(), json!(app.session.prefs().export.webp_lossless));
|
||||
f.insert("transparency".into(), json!(true));
|
||||
f.insert("scale".into(), json!(100));
|
||||
f.insert("metadata".into(), json!("none"));
|
||||
@@ -92,6 +92,22 @@ fn export_document(doc: &Document, f: &Map<String, Value>, max_side: Option<u32>
|
||||
s.active().map(|d| (*d.doc).clone()).ok_or_else(|| "export failed".into())
|
||||
}
|
||||
|
||||
/// Export As already supports lossy WebP; reuse the Quick Export defaults when
|
||||
/// choosing that format so both flows expose the same quality and lossless options.
|
||||
fn set_format_defaults(f: &mut Map<String, Value>, fmt: &str, prefs: &photocraft_engine::prefs::Export) {
|
||||
f.insert("format".into(), json!(fmt));
|
||||
match fmt {
|
||||
"jpg" => {
|
||||
f.insert("quality".into(), json!(prefs.jpeg_quality));
|
||||
}
|
||||
"webp" => {
|
||||
f.insert("quality".into(), json!(prefs.webp_quality));
|
||||
f.insert("lossless".into(), json!(prefs.webp_lossless));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn s_fmt(f: &Map<String, Value>) -> String {
|
||||
let v = s(f, "format");
|
||||
if v.is_empty() { "png".into() } else { v }
|
||||
@@ -137,7 +153,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
|
||||
let mut fmt = s_fmt(f);
|
||||
let opts: Vec<(String, &str)> = FORMATS.iter().map(|(k, l)| (k.to_string(), *l)).collect();
|
||||
if crate::widgets::dropdown(ui, "export-format", &mut fmt, &opts, 130.0) {
|
||||
f.insert("format".into(), json!(fmt));
|
||||
set_format_defaults(f, &fmt, &app.session.prefs().export);
|
||||
}
|
||||
});
|
||||
let fmt = s_fmt(f);
|
||||
@@ -287,6 +303,28 @@ mod tests {
|
||||
assert_eq!(settings(&f).jpeg_quality, Some(85));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_export_formats_uses_independent_quality_preferences() {
|
||||
let prefs = photocraft_engine::prefs::Export { jpeg_quality: 43, webp_quality: 72, webp_lossless: false, ..Default::default() };
|
||||
let mut fields = Map::new();
|
||||
set_format_defaults(&mut fields, "webp", &prefs);
|
||||
assert_eq!(fields["quality"], 72);
|
||||
assert_eq!(fields["lossless"], false);
|
||||
let s = settings(&fields);
|
||||
assert!(!s.webp_lossless);
|
||||
assert_eq!(s.webp_quality, Some(72));
|
||||
assert_eq!(s.jpeg_quality, None);
|
||||
|
||||
set_format_defaults(&mut fields, "jpg", &prefs);
|
||||
assert_eq!(fields["quality"], 43);
|
||||
assert_eq!(settings(&fields).jpeg_quality, Some(43));
|
||||
assert_eq!(settings(&fields).webp_quality, None);
|
||||
|
||||
set_format_defaults(&mut fields, "webp", &photocraft_engine::prefs::Export::default());
|
||||
assert!(settings(&fields).webp_lossless, "default WebP Quick Export mode is lossless");
|
||||
assert_eq!(settings(&fields).webp_quality, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_settings_follow_the_lossless_switch() {
|
||||
let mut f = Map::new();
|
||||
|
||||
@@ -835,8 +835,15 @@ impl PhotocraftApp {
|
||||
return Ok(("smart object".into(), Vec::new()));
|
||||
}
|
||||
let st = self.session.active().ok_or("no document")?;
|
||||
// Documents are named after their file ("cat.png"): suggest "cat.psd", not "cat.png.psd".
|
||||
let suggested = st.path.clone().unwrap_or_else(|| format!("{}.psd", st.doc.name.rsplit_once('.').map_or(st.doc.name.as_str(), |(stem, _)| stem)));
|
||||
// Suggest the file's own name if Save As can write its format, otherwise switch to .psd.
|
||||
let ext = st.path.as_deref().and_then(|p| std::path::Path::new(p).extension()).map(|e| e.to_string_lossy().to_ascii_lowercase());
|
||||
let writable = ext.is_some_and(|e| {
|
||||
matches!(e.as_str(), photocraft_format::EXTENSION | "psd" | "psb") || photocraft_codecs::from_extension(&e).is_some_and(|f| f.caps().write)
|
||||
});
|
||||
let suggested = match &st.path {
|
||||
Some(p) if writable => p.clone(),
|
||||
p => std::path::Path::new(p.as_deref().unwrap_or(&st.doc.name)).with_extension("psd").to_string_lossy().into_owned(),
|
||||
};
|
||||
let path = match path {
|
||||
Some(p) => p,
|
||||
None => self.services.pick_save.as_mut().and_then(|f| f(&suggested)).ok_or("cancelled")?,
|
||||
|
||||
@@ -614,6 +614,17 @@ fn checked(app: &PhotocraftApp, id: &str) -> Option<bool> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Translate the fixed command label and substitute the currently configured export format.
|
||||
/// Reuse the existing translated PNG sentence, so dynamic formats work in every UI language.
|
||||
fn translated_menu_label(lang: crate::i18n::Lang, item: &MenuItem) -> String {
|
||||
if item.id == "file.export.quickExportAsPng"
|
||||
&& let Some(fmt) = item.label.strip_prefix("Quick Export as ")
|
||||
{
|
||||
return crate::i18n::tr_id(lang, &item.id, "Quick Export as PNG").replace("PNG", fmt);
|
||||
}
|
||||
crate::i18n::tr_id(lang, &item.id, &item.label).to_string()
|
||||
}
|
||||
|
||||
/// Menu tree entry for rendering and for `ui.inspect`.
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
pub struct MenuItem {
|
||||
@@ -701,6 +712,23 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec<MenuItem> {
|
||||
}
|
||||
}
|
||||
crate::plugin_ui::insert_menu_items(app, &mut items);
|
||||
// The File quick-export command uses the format selected in Export Preferences.
|
||||
// On the web it intentionally downloads PNG until the quick-export service supports
|
||||
// the other formats; the Layer quick-export command also always produces PNG.
|
||||
if let Some(item) = items.iter_mut().find(|i| i.id == "file.export.quickExportAsPng") {
|
||||
use photocraft_engine::prefs::QuickExportFormat;
|
||||
let format = if cfg!(target_arch = "wasm32") {
|
||||
"PNG"
|
||||
} else {
|
||||
match app.session.prefs().export.quick_export_format {
|
||||
QuickExportFormat::Png => "PNG",
|
||||
QuickExportFormat::Jpg => "JPG",
|
||||
QuickExportFormat::Gif => "GIF",
|
||||
QuickExportFormat::Webp => "WebP",
|
||||
}
|
||||
};
|
||||
item.label = format!("Quick Export as {format}");
|
||||
}
|
||||
// File › Open Recent: a dynamic submenu of recently opened files (inserted after "Open As…").
|
||||
if let Some(after) = items.iter().position(|i| i.id == "file.openAs") {
|
||||
let rp: Vec<String> = vec!["File".into(), "Open Recent".into()];
|
||||
@@ -938,7 +966,7 @@ pub fn search_items<'a>(items: &'a [MenuItem], query: &str, lang: crate::i18n::L
|
||||
let rank = |it: &MenuItem| {
|
||||
let english = search_rank(&q, &it.label, &it.path);
|
||||
let path: Vec<String> = it.path.iter().map(|p| crate::i18n::tr(lang, p).to_string()).collect();
|
||||
let local = search_rank(&q, crate::i18n::tr_id(lang, &it.id, &it.label), &path);
|
||||
let local = search_rank(&q, &translated_menu_label(lang, it), &path);
|
||||
english.into_iter().chain(local).min()
|
||||
};
|
||||
let mut hits: Vec<(u8, usize, &MenuItem)> =
|
||||
@@ -978,8 +1006,8 @@ fn help_search(ui: &mut egui::Ui, items: &[MenuItem], clicked: &mut Option<Strin
|
||||
ui.weak(crate::i18n::tr(lang, "No matching commands"));
|
||||
}
|
||||
for it in results {
|
||||
let mut trail: Vec<&str> = it.path.iter().map(|p| crate::i18n::tr(lang, p)).collect();
|
||||
trail.push(crate::i18n::tr_id(lang, &it.id, &it.label));
|
||||
let mut trail: Vec<String> = it.path.iter().map(|p| crate::i18n::tr(lang, p).to_string()).collect();
|
||||
trail.push(translated_menu_label(lang, it));
|
||||
let mut b = egui::Button::new(trail.join(" › "));
|
||||
if let Some(sc) = &it.shortcut {
|
||||
b = b.shortcut_text(crate::shortcuts::pretty(sc));
|
||||
@@ -1038,7 +1066,7 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut text = crate::i18n::tr_id(lang, &it.id, &it.label).to_string();
|
||||
let mut text = translated_menu_label(lang, it);
|
||||
if let Some(c) = it.checked {
|
||||
text = format!("{} {}", if c { "✔" } else { " " }, text);
|
||||
}
|
||||
@@ -1461,3 +1489,26 @@ mod open_recent_tests {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod quick_export_label_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_quick_export_menu_reflects_selected_format_without_renaming_layer_export() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
for (value, expected) in [("png", "PNG"), ("jpg", "JPG"), ("gif", "GIF"), ("webp", "WebP")] {
|
||||
app.run("prefs.set", json!({"values": {"export.quickExportFormat": value}})).unwrap();
|
||||
let items = menu_items(&app);
|
||||
let quick: Vec<_> = items.iter().filter(|i| i.id == "file.export.quickExportAsPng").collect();
|
||||
assert_eq!(quick.len(), 1, "the catalogue and UI command must not create duplicate items");
|
||||
let shown_format = if cfg!(target_arch = "wasm32") { "PNG" } else { expected };
|
||||
assert_eq!(quick[0].label, format!("Quick Export as {shown_format}"));
|
||||
let id = crate::i18n::Lang::from_code("id").unwrap();
|
||||
let localized = translated_menu_label(id, quick[0]);
|
||||
assert_eq!(localized, format!("Ekspor Cepat ke {shown_format}"));
|
||||
let layer = items.iter().find(|i| i.id == "layer.quickExportAsPng").unwrap();
|
||||
assert_eq!(layer.label, "Quick Export as PNG", "layer export always creates PNG");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,9 +161,13 @@ fn display_scale(pref: prefs::UiScale, native: Option<f32>, monitor_px: Option<e
|
||||
let native = native.filter(|v| v.is_finite() && *v > 0.0).unwrap_or(1.0);
|
||||
match pref {
|
||||
prefs::UiScale::Auto => {
|
||||
// A 4K display needs at least 200%; preserve larger system scales.
|
||||
// Follow the system's scale, fractional ones included (125%, 150%, 175%: #1072).
|
||||
// Only when the system reports no scaling at all (100%) does a 4K display fall back
|
||||
// to 200%, since unscaled 4K controls are unreadably small (#225). Overriding a scale
|
||||
// the user picked in their desktop settings left only "too small" or "too big".
|
||||
let unscaled = (native - 1.0).abs() < 0.01;
|
||||
let is_4k = monitor_px.is_some_and(|s| s.x.is_finite() && s.y.is_finite() && s.x.min(s.y) >= 2160.0 && s.x.max(s.y) >= 3840.0);
|
||||
if is_4k { native.max(2.0) } else { native }
|
||||
if unscaled && is_4k { 2.0 } else { native }
|
||||
}
|
||||
fixed => fixed.name().parse::<f32>().map_or(1.0, |pct| pct / 100.0),
|
||||
}
|
||||
@@ -756,6 +760,13 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
|
||||
}
|
||||
|
||||
fn humanize(key: &str) -> String {
|
||||
// These controls appear only for WebP, so reuse the existing translated labels.
|
||||
if key == "webpLossless" {
|
||||
return "Lossless".into();
|
||||
}
|
||||
if key == "webpQuality" {
|
||||
return "Quality".into();
|
||||
}
|
||||
let mut s = String::new();
|
||||
for (i, ch) in key.chars().enumerate() {
|
||||
if i == 0 {
|
||||
@@ -940,6 +951,17 @@ fn has_visible_fields(values: &Value, section: &str) -> bool {
|
||||
values.get(section).and_then(Value::as_object).is_some_and(|o| o.keys().any(|k| !prefs::is_hidden(&format!("{section}.{k}"))))
|
||||
}
|
||||
|
||||
/// JPEG and WebP have independent settings; unrelated format controls stay out of view.
|
||||
fn export_field_visible(obj: &Map<String, Value>, key: &str) -> bool {
|
||||
let format = obj.get("quickExportFormat").and_then(Value::as_str).unwrap_or("png");
|
||||
match key {
|
||||
"jpegQuality" => format == "jpg",
|
||||
"webpLossless" => format == "webp",
|
||||
"webpQuality" => format == "webp" && obj.get("webpLossless").and_then(Value::as_bool) != Some(true),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generic editor for a section's fields: checkboxes, dropdowns for choices, colour swatches,
|
||||
/// number fields with the preference's range, text fields.
|
||||
fn section_fields(ui: &mut egui::Ui, section: &str, obj: &mut Map<String, Value>, order: &[String], lang: crate::i18n::Lang) {
|
||||
@@ -954,7 +976,10 @@ fn section_fields(ui: &mut egui::Ui, section: &str, obj: &mut Map<String, Value>
|
||||
let path = format!("{section}.{k}");
|
||||
// Settings nothing reads yet stay out of the dialog (issue #204); their stored values
|
||||
// pass through untouched.
|
||||
if prefs::is_hidden(&path) || (section == "performance" && matches!(k.as_str(), "useGpu" | "gpuBackend" | "renderingMode")) {
|
||||
if prefs::is_hidden(&path)
|
||||
|| (section == "performance" && matches!(k.as_str(), "useGpu" | "gpuBackend" | "renderingMode"))
|
||||
|| (section == "export" && !export_field_visible(obj, &k))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let v = obj.get(&k).cloned().unwrap_or(Value::Null);
|
||||
@@ -1431,6 +1456,29 @@ mod tests {
|
||||
assert_eq!(*h.state(), [0x80, 0x80, 0x80]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_export_controls_match_selected_format() {
|
||||
let mut obj = serde_json::to_value(photocraft_engine::prefs::Export::default()).unwrap().as_object().unwrap().clone();
|
||||
assert!(!export_field_visible(&obj, "jpegQuality"));
|
||||
assert!(!export_field_visible(&obj, "webpLossless"));
|
||||
assert!(!export_field_visible(&obj, "webpQuality"));
|
||||
|
||||
obj.insert("quickExportFormat".into(), json!("jpg"));
|
||||
assert!(export_field_visible(&obj, "jpegQuality"));
|
||||
assert!(!export_field_visible(&obj, "webpLossless"));
|
||||
assert!(!export_field_visible(&obj, "webpQuality"));
|
||||
|
||||
obj.insert("quickExportFormat".into(), json!("webp"));
|
||||
assert!(!export_field_visible(&obj, "jpegQuality"));
|
||||
assert!(export_field_visible(&obj, "webpLossless"));
|
||||
assert!(!export_field_visible(&obj, "webpQuality"), "lossless WebP does not have a quality setting");
|
||||
|
||||
obj.insert("webpLossless".into(), json!(false));
|
||||
assert!(export_field_visible(&obj, "webpQuality"));
|
||||
assert_eq!(humanize("webpLossless"), "Lossless");
|
||||
assert_eq!(humanize("webpQuality"), "Quality");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_mode_display_respects_explicit_mode_and_legacy_disable() {
|
||||
let legacy = serde_json::json!({"useGpu": false, "gpuBackend": "auto"});
|
||||
@@ -1597,12 +1645,20 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_scale_detects_4k_and_preserves_larger_system_dpi() {
|
||||
fn auto_scale_detects_4k_and_preserves_system_scale() {
|
||||
use prefs::UiScale::Auto;
|
||||
for size in [vec2(3840.0, 2160.0), vec2(4096.0, 2160.0), vec2(2160.0, 3840.0)] {
|
||||
assert_eq!(display_scale(Auto, Some(1.0), Some(size)), 2.0);
|
||||
for dpi in [1.25, 1.5, 2.0] {
|
||||
assert_eq!(display_scale(Auto, Some(dpi), Some(size)), 2.0);
|
||||
// A fractional system scale is the user's choice and is kept as is (#1072).
|
||||
for dpi in [1.25, 1.5, 1.75, 2.0, 2.5] {
|
||||
assert_eq!(display_scale(Auto, Some(dpi), Some(size)), dpi);
|
||||
}
|
||||
}
|
||||
// Below 100% is a system choice too, even on 4K.
|
||||
assert_eq!(display_scale(Auto, Some(0.75), Some(vec2(3840.0, 2160.0))), 0.75);
|
||||
for size in [vec2(1920.0, 1080.0), vec2(2560.0, 1440.0)] {
|
||||
for dpi in [1.25, 1.5, 1.75] {
|
||||
assert_eq!(display_scale(Auto, Some(dpi), Some(size)), dpi);
|
||||
}
|
||||
}
|
||||
assert_eq!(display_scale(Auto, Some(3.0), Some(vec2(3840.0, 2160.0))), 3.0);
|
||||
@@ -1636,7 +1692,10 @@ mod tests {
|
||||
step(vec2(3840.0, 2160.0), 1.0, 2.0);
|
||||
}
|
||||
step(vec2(1920.0, 1080.0), 1.0, 1.0);
|
||||
step(vec2(3840.0, 2160.0), 1.5, 2.0);
|
||||
step(vec2(3840.0, 2160.0), 1.5, 1.5);
|
||||
step(vec2(3840.0, 2160.0), 1.25, 1.25);
|
||||
step(vec2(2560.0, 1440.0), 1.75, 1.75);
|
||||
step(vec2(3840.0, 2160.0), 1.0, 2.0);
|
||||
}
|
||||
for (pref, expected) in [("200", 2.0), ("125", 1.25), ("150", 1.5), ("100", 1.0), ("auto", 1.5)] {
|
||||
app.run("prefs.set", json!({"values": {"interface.uiScale": pref}})).unwrap();
|
||||
|
||||
@@ -124,7 +124,9 @@ pub fn draw_guides(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform,
|
||||
|
||||
/// Existing guide under a document point (within 4 screen px).
|
||||
pub fn guide_at(app: &PhotocraftApp, x: f64, y: f64) -> Option<(bool, usize)> {
|
||||
if !app.ui.extras.guides || app.ui.extras.lock_guides {
|
||||
// Hidden canvas guides are not interactive: the Move tool must reach the layer below.
|
||||
// Keep hit testing consistent with the visibility gate in canvas::draw.
|
||||
if !app.ui.extras.guides || !app.ui.view.shows(app.ui.view.show.canvas_guides) || app.ui.extras.lock_guides {
|
||||
return None;
|
||||
}
|
||||
let doc = &app.session.active()?.doc;
|
||||
@@ -269,6 +271,45 @@ mod tests {
|
||||
assert_eq!(tick_step(0.25), 500.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_guides_are_not_hit_tested() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
app.sync_views();
|
||||
app.session.execute("view.newGuide", json!({"orientation": "vertical", "position": 50})).unwrap();
|
||||
app.session.execute("view.newGuide", json!({"orientation": "horizontal", "position": 25})).unwrap();
|
||||
let positions = app.session.active().unwrap().doc.guides.clone();
|
||||
|
||||
// Visible, unlocked guides remain interactive in either orientation.
|
||||
assert_eq!(guide_at(&app, 50.0, 10.0), Some((true, 0)));
|
||||
assert_eq!(guide_at(&app, 10.0, 25.0), Some((false, 0)));
|
||||
|
||||
// All ways to hide the canvas guides must also disable hit testing.
|
||||
app.ui.extras.guides = false;
|
||||
assert_eq!(guide_at(&app, 50.0, 10.0), None);
|
||||
assert_eq!(guide_at(&app, 10.0, 25.0), None);
|
||||
app.ui.extras.guides = true;
|
||||
|
||||
app.ui.view.show.canvas_guides = false;
|
||||
assert_eq!(guide_at(&app, 50.0, 10.0), None);
|
||||
assert_eq!(guide_at(&app, 10.0, 25.0), None);
|
||||
app.ui.view.show.canvas_guides = true;
|
||||
|
||||
app.ui.view.extras = false;
|
||||
assert_eq!(guide_at(&app, 50.0, 10.0), None);
|
||||
assert_eq!(guide_at(&app, 10.0, 25.0), None);
|
||||
app.ui.view.extras = true;
|
||||
|
||||
// Hiding/showing must not change the stored guide positions.
|
||||
assert_eq!(app.session.active().unwrap().doc.guides, positions);
|
||||
assert_eq!(guide_at(&app, 50.0, 10.0), Some((true, 0)));
|
||||
assert_eq!(guide_at(&app, 10.0, 25.0), Some((false, 0)));
|
||||
|
||||
app.ui.extras.lock_guides = true;
|
||||
assert_eq!(guide_at(&app, 50.0, 10.0), None);
|
||||
assert_eq!(guide_at(&app, 10.0, 25.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guide_drags_create_move_and_delete() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
|
||||
@@ -94,6 +94,12 @@ pub struct ViewOptions {
|
||||
/// and the Middle Eastern & South Asian composer.
|
||||
pub language_features: String,
|
||||
pub middle_eastern_composer: bool,
|
||||
/// Parameters from the last successfully applied New Guide Layout dialog.
|
||||
pub guide_layout: Value,
|
||||
}
|
||||
|
||||
fn default_guide_layout() -> Value {
|
||||
json!({"columns": 8, "gutter": 20, "rows": 0, "rowGutter": 0, "margin": 0, "centerColumns": false, "clearExisting": false})
|
||||
}
|
||||
|
||||
impl Default for ViewOptions {
|
||||
@@ -112,6 +118,7 @@ impl Default for ViewOptions {
|
||||
font_preview_size: "medium".into(),
|
||||
language_features: "defaultFeatures".into(),
|
||||
middle_eastern_composer: false,
|
||||
guide_layout: default_guide_layout(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -846,7 +853,8 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option<Result<Val
|
||||
json!({"from": ["any", "rgb", "grayscale", "cmyk", "lab", "indexed", "bitmap", "duotone", "multichannel"], "to": ["rgb", "grayscale", "cmyk", "lab"]}),
|
||||
),
|
||||
"view.newGuideLayout" => {
|
||||
dialog(app, json!({"columns": 8, "gutter": 20, "rows": 0, "rowGutter": 0, "margin": 0, "centerColumns": false, "clearExisting": false}), json!({}))
|
||||
let fields = app.ui.view.guide_layout.clone();
|
||||
dialog(app, fields, json!({}))
|
||||
}
|
||||
"type.warpText" => {
|
||||
let styles: Vec<&str> = std::iter::once("none").chain(photocraft_text::warp::STYLES.iter().map(|(_, s)| *s)).collect();
|
||||
|
||||
@@ -172,3 +172,43 @@ fn engine_commands_open_their_dialogs() {
|
||||
crate::menus::invoke_unguarded(&mut app, &ctx, "file.closeAll", json!({})).unwrap();
|
||||
assert!(app.ui.views.is_empty() && app.session.documents().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_guide_layout_remembers_successful_values_not_cancelled_edits() {
|
||||
let (mut app, ctx) = app_with(1);
|
||||
let first = menu(&mut app, &ctx, "view.newGuideLayout", json!({})).unwrap()["dialog"].as_u64().unwrap();
|
||||
assert_eq!(app.ui.dialog_mut(first).unwrap().fields["columns"], 8);
|
||||
assert_eq!(app.ui.dialog_mut(first).unwrap().fields["gutter"], 20);
|
||||
for (key, value) in [("columns", json!(2)), ("gutter", json!(0)), ("rows", json!(2)), ("rowGutter", json!(0))] {
|
||||
app.ui.dialog_mut(first).unwrap().fields.insert(key.into(), value);
|
||||
}
|
||||
crate::dialogs::confirm(&mut app, first).unwrap();
|
||||
|
||||
let second = menu(&mut app, &ctx, "view.newGuideLayout", json!({})).unwrap()["dialog"].as_u64().unwrap();
|
||||
let fields = &app.ui.dialog_mut(second).unwrap().fields;
|
||||
assert_eq!(fields["columns"], 2);
|
||||
assert_eq!(fields["gutter"], 0);
|
||||
assert_eq!(fields["rows"], 2);
|
||||
assert_eq!(fields["rowGutter"], 0);
|
||||
assert_eq!(fields["clearExisting"], false);
|
||||
// A cancelled edit must not replace the last successfully used values.
|
||||
app.ui.dialog_mut(second).unwrap().fields.insert("columns".into(), json!(9));
|
||||
app.ui.close_dialog(second);
|
||||
let third = menu(&mut app, &ctx, "view.newGuideLayout", json!({})).unwrap()["dialog"].as_u64().unwrap();
|
||||
assert_eq!(app.ui.dialog_mut(third).unwrap().fields["columns"], 2);
|
||||
|
||||
// The remembered state is part of the serializable UI state.
|
||||
let json_state = serde_json::to_value(&app.ui).unwrap();
|
||||
assert_eq!(json_state["view"]["guide_layout"]["columns"], 2);
|
||||
let restored: crate::state::UiState = serde_json::from_value(json_state).unwrap();
|
||||
assert_eq!(restored.view.guide_layout["rows"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_guide_layout_does_not_remember_a_failed_command() {
|
||||
let (mut app, _) = app_with(0);
|
||||
let id = front(&mut app, "view.newGuideLayout", &json!({})).unwrap().unwrap()["dialog"].as_u64().unwrap();
|
||||
app.ui.dialog_mut(id).unwrap().fields.insert("columns".into(), json!(3));
|
||||
assert!(crate::dialogs::confirm(&mut app, id).is_err());
|
||||
assert_eq!(app.ui.view.guide_layout["columns"], 8);
|
||||
}
|
||||
@@ -44,6 +44,11 @@ CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo test --workspace # runs the Japa
|
||||
|
||||
On DX12 the shader compiler is FXC (`d3dcompiler_47.dll`, part of Windows), or a `dxcompiler.dll` placed beside `photocraft.exe`, loaded by its full path. wgpu's default looks `dxcompiler.dll` up by name, which reaches the current directory and `PATH` and loaded other programs' incompatible builds (#712).
|
||||
|
||||
Handled windowing and app initialization errors clear this launch's startup marker, restoring any
|
||||
prior crash evidence. For example, a Linux launch without `DISPLAY` or `WAYLAND_DISPLAY` does not
|
||||
change the next launch's graphics backend. Renderer initialization errors and driver crashes still
|
||||
keep the marker for recovery.
|
||||
|
||||
If the device is lost while running (#243), every GPU entry point checks the device's health flag first, the canvas switches to the CPU compositor for the rest of the session and a notice says "GPU device was lost; using the CPU renderer." `ui.gpu.simulateLoss` triggers this path from the control channel.
|
||||
|
||||
## Environment variables
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
||||
| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 3 | 9 | 15 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
|
||||
| [Tools](#tools) | 6 | 15 | 9 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 57 of 135 (target 0) |
|
||||
| [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 57 of 137 (target 0) |
|
||||
| [Type](#type) | 0 | 3 | 6 | |
|
||||
| [Automation](#automation) | 3 | 2 | 6 | 627 of 627 menu items live (100.0%) |
|
||||
| [Reliability](#reliability) | 4 | 1 | 2 | never-crash lints on 27 of 27 crates; panic_hunt present |
|
||||
@@ -154,7 +154,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
||||
|
||||
## Workspace and UI
|
||||
|
||||
**Settings that do nothing: 57 of 135** (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: 57 of 137** (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`, `general.beep_when_done`, `general.export_clipboard`, `general.resize_image_during_place`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `interface.show_channels_in_color`, `interface.dynamic_color_sliders`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.open_documents_as_tabs`, `workspace.enable_floating_document_window_docking`, `workspace.large_tabs`, `workspace.enable_narrow_options_bar`, `tools.zoom_clicked_point_to_center`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `tools.double_click_layer_mask_launches_select_and_mask`, `file_handling.image_previews`, `file_handling.lowercase_extension`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `units_and_rulers.screen_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.smart_quotes`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.use_esc_to_commit`, `type_.text_engine`, `type_.font_preview`, `type_.fill_new_type_layers_with_placeholder`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `enhanced_controls.rotate_view_with_trackpad`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
|
||||
|
||||
|
||||
+3
-2
@@ -76,8 +76,9 @@ draft open for correction. Settings marked for the next launch still require a r
|
||||
## High DPI and 4K displays
|
||||
|
||||
Edit → Preferences → Interface → UI Scale applies immediately. Auto follows the operating
|
||||
system's display scale (including fractional scales). For a 4K or larger monitor,
|
||||
Auto uses at least 200% so text and controls remain readable. Detection uses the current monitor,
|
||||
system's display scale, including fractional scales such as 125%, 150% and 175% (on Linux,
|
||||
Wayland's fractional scale or X11's `Xft.dpi`). Only when the system reports no scaling (100%) on
|
||||
a 4K or larger monitor does Auto use 200%, so text and controls remain readable. Detection uses the current monitor,
|
||||
including portrait displays, and updates when the window moves between monitors. If monitor
|
||||
size is unavailable, Auto follows system DPI. The fixed choices (75% to 300%) set an absolute UI
|
||||
scale, allowing large 4K displays to use smaller controls when desired. Canvas zoom shortcuts
|
||||
|
||||
Whitespace-only changes.
@@ -1,5 +0,0 @@
|
||||
|
||||
running 7 tests
|
||||
.......
|
||||
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.53s
|
||||
|
||||
Reference in new issue
Block a user