diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index b6bdf9719c..6993bc0506 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -752,6 +752,84 @@ pub const SECTIONS: [(&str, &str); 18] = [ ("integrations", "Integrations"), ]; +/// Preferences that nothing reads yet, so Edit › Preferences doesn't show them: a control that +/// does nothing is worse than a missing one (https://github.com/storytold/photocraft/issues/204). +/// They still load, save and round-trip through `prefs.get` / `prefs.set` unchanged. +/// +/// When you implement one, remove it here; the `prefs_usage` test fails while a listed +/// preference is read anywhere outside `prefs.rs`, and while an unlisted one is read nowhere. +pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ + "general.colorPicker", + "general.beepWhenDone", + "general.exportClipboard", + "general.resizeImageDuringPlace", + "general.alwaysCreateSmartObjectsWhenPlacing", + "general.animatedZoom", + "general.zoomResizesWindows", + "general.useLegacyFreeTransform", + "interface.uiFontSize", + "interface.showChannelsInColor", + "interface.dynamicColorSliders", + "workspace.autoCollapseIconPanels", + "workspace.autoShowHiddenPanels", + "workspace.openDocumentsAsTabs", + "workspace.enableFloatingDocumentWindowDocking", + "workspace.largeTabs", + "workspace.enableNarrowOptionsBar", + "tools.zoomClickedPointToCenter", + "tools.enableFlickPanning", + "tools.varyRoundBrushHardnessOnHud", + "tools.showTransformationValues", + "tools.overscroll", + "tools.doubleClickLayerMaskLaunchesSelectAndMask", + "fileHandling.imagePreviews", + "fileHandling.lowercaseExtension", + "fileHandling.saveInBackground", + "fileHandling.ignoreExifProfileTag", + "fileHandling.askBeforeSavingLayeredTiff", + "fileHandling.maximizePsdCompatibility", + "performance.cacheLevels", + "performance.effectCacheMb", + "performance.legacyCompositing", + "scratchDisks.disks", + "cursors.brushPreviewColor", + "unitsAndRulers.typeUnits", + "unitsAndRulers.columnWidth", + "unitsAndRulers.gutter", + "unitsAndRulers.printResolution", + "unitsAndRulers.screenResolution", + "plugIns.showExtensionPanels", + "plugIns.allowScriptsToConnect", + "plugIns.generatorEnabled", + "type.smartQuotes", + "type.missingGlyphProtection", + "type.showFontNamesInEnglish", + "type.useEscToCommit", + "type.textEngine", + "type.fontPreview", + "type.fillNewTypeLayersWithPlaceholder", + "type.recentFonts", + "enhancedControls.scrubbySliderAcceleration", + "enhancedControls.touchGestures", + "enhancedControls.zoomWithTrackpadPinch", + "enhancedControls.rotateViewWithTrackpad", + "rawDefaults.colorSpace", + "rawDefaults.bitDepth", + "rawDefaults.resolution", + "rawDefaults.sharpenFor", + "rawDefaults.openAsSmartObject", + "rawDefaults.applyAutoTone", + // Agent access is governed by the launch flags (`--control`, the automation roots), not + // by these yet. + "integrations.allowAgentControl", + "integrations.controlPort", +]; + +/// Is the preference at `path` (`"section.key"`) hidden from the Preferences dialog? +pub fn is_hidden(path: &str) -> bool { + HIDDEN_UNTIL_IMPLEMENTED.contains(&path) +} + /// Choices of an enumerated preference (dotted path, e.g. `"cursors.painting"`). pub fn choices(path: &str) -> Option<&'static [&'static str]> { Some(match path { diff --git a/crates/engine/tests/prefs_usage.rs b/crates/engine/tests/prefs_usage.rs new file mode 100644 index 0000000000..f7dd35b281 --- /dev/null +++ b/crates/engine/tests/prefs_usage.rs @@ -0,0 +1,169 @@ +//! Every preference does something, or the Preferences dialog hides it (issue #204). +//! +//! Walks every key of `Preferences::default()` serialised to JSON (`section.key`) and checks +//! the workspace sources: each one is either read outside `prefs.rs` or listed in +//! `prefs::HIDDEN_UNTIL_IMPLEMENTED`, never both. A setting that appears in the dialog but that +//! nothing reads is a control that silently does nothing. +//! +//! "Read" is a source scan (no type information), so it is deliberately simple: +//! - a member access `.` in a non-test source file that also names the section +//! (``), e.g. `p.file_handling.recent_files` or `let fh = &p.file_handling; +//! … fh.autosave`; +//! - or the JSON path as a string literal (`"fileHandling.autosave"`), for code that reads +//! preferences by path; +//! - or `self.` in an inherent `impl` of the section's struct in `prefs.rs` (helpers +//! such as `TransparencyAndGamut::colors`). + +use photocraft_engine::prefs::{HIDDEN_UNTIL_IMPLEMENTED, Preferences}; +use std::path::{Path, PathBuf}; + +fn snake(camel: &str) -> String { + let mut out = String::new(); + for c in camel.chars() { + if c.is_ascii_uppercase() { + out.push('_'); + out.push(c.to_ascii_lowercase()); + } else { + out.push(c); + } + } + out +} + +fn is_ident(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' +} + +/// `needle` occurs in `text` as a whole identifier (after `prefix`, e.g. "." or "self."). +fn has_ident(text: &str, prefix: &str, ident: &str) -> bool { + let needle = format!("{prefix}{ident}"); + text.match_indices(&needle).any(|(i, _)| { + let before = text[..i].chars().next_back(); + let after = text[i + needle.len()..].chars().next(); + (!prefix.is_empty() || before.is_none_or(|c| !is_ident(c))) && after.is_none_or(|c| !is_ident(c)) + }) +} + +/// Production source of a file: everything before its unit-test module. +fn production(text: &str) -> &str { + let cut = text.find("#[cfg(test)]\nmod ").or_else(|| text.find("#[cfg(test)]\npub mod ")).unwrap_or(text.len()); + &text[..cut] +} + +fn rust_sources(dir: &Path, out: &mut Vec) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for e in rd.flatten() { + let p = e.path(); + let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + if p.is_dir() { + if !matches!(name.as_str(), "tests" | "examples" | "benches" | "target") { + rust_sources(&p, out); + } + } else if name.ends_with(".rs") && name != "tests.rs" && !name.ends_with("_tests.rs") { + out.push(p); + } + } +} + +/// `pub struct Name { … }` field names, and the bodies of `impl Name { … }` blocks, in prefs.rs. +fn struct_fields(src: &str, name: &str) -> Vec { + let Some(start) = src.find(&format!("pub struct {name} {{")) else { return Vec::new() }; + let body = &src[start..]; + let end = body.find("\n}").unwrap_or(body.len()); + body[..end].lines().filter_map(|l| l.trim().strip_prefix("pub ")?.split_once(':').map(|(f, _)| f.trim().to_string())).collect() +} + +fn inherent_impls(src: &str, name: &str) -> String { + let marker = format!("\nimpl {name} {{"); + src.match_indices(&marker) + .map(|(i, _)| { + let body = &src[i + 1..]; + body[..body.find("\n}").unwrap_or(body.len())].to_string() + }) + .collect::>() + .join("\n") +} + +#[test] +fn every_preference_is_read_or_hidden() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let prefs_rs = std::fs::read_to_string(root.join("crates/engine/src/prefs.rs")).unwrap(); + let mut files = Vec::new(); + rust_sources(&root.join("crates"), &mut files); + rust_sources(&root.join("apps"), &mut files); + let sources: Vec = files + .iter() + .filter(|p| !p.ends_with("engine/src/prefs.rs")) + .filter_map(|p| std::fs::read_to_string(p).ok()) + .map(|t| production(&t).to_string()) + .collect(); + assert!(sources.len() > 100, "found only {} source files under {}", sources.len(), root.display()); + + // Section key → struct name, from `pub : ,` in `struct Preferences`. + let section_types: Vec<(String, String)> = { + let start = prefs_rs.find("pub struct Preferences {").unwrap(); + let body = &prefs_rs[start..]; + body[..body.find("\n}").unwrap()] + .lines() + .filter_map(|l| { + let (f, ty) = l.trim().strip_prefix("pub ")?.split_once(':')?; + Some((f.trim().to_string(), ty.trim().trim_end_matches(',').to_string())) + }) + .collect() + }; + + let json = serde_json::to_value(Preferences::default()).unwrap(); + let mut paths = Vec::new(); + for (section, v) in json.as_object().unwrap() { + match v.as_object() { + Some(o) if !o.is_empty() => paths.extend(o.keys().map(|k| (section.clone(), Some(k.clone())))), + _ => paths.push((section.clone(), None)), + } + } + + let mut unread = Vec::new(); + let mut stale = Vec::new(); + for (section, key) in &paths { + let section_rs = if section == "type" { "type_".to_string() } else { snake(section) }; + let (path, read) = match key { + None => (section.clone(), sources.iter().any(|t| has_ident(t, ".", §ion_rs))), + Some(k) => { + let field = snake(k); + let path = format!("{section}.{k}"); + let ty = section_types.iter().find(|(f, _)| *f == section_rs).map(|(_, t)| t.clone()).unwrap_or_default(); + let in_helpers = + !ty.is_empty() && struct_fields(&prefs_rs, &ty).contains(&field) && has_ident(&inherent_impls(&prefs_rs, &ty), "self.", &field); + let read = + in_helpers || sources.iter().any(|t| (has_ident(t, ".", &field) && has_ident(t, "", §ion_rs)) || t.contains(&format!("\"{path}\""))); + (path, read) + } + }; + let hidden = HIDDEN_UNTIL_IMPLEMENTED.contains(&path.as_str()); + if !read && !hidden { + unread.push(path); + } else if read && hidden { + stale.push(path); + } + } + for h in HIDDEN_UNTIL_IMPLEMENTED { + assert!( + paths.iter().any(|(s, k)| k.as_ref().map(|k| format!("{s}.{k}")).as_deref() == Some(*h)), + "HIDDEN_UNTIL_IMPLEMENTED lists `{h}`, which is not a preference" + ); + } + assert!( + unread.is_empty(), + "these preferences are shown in Edit › Preferences but nothing reads them: wire them up or add them to prefs::HIDDEN_UNTIL_IMPLEMENTED: {unread:#?}" + ); + assert!(stale.is_empty(), "these preferences are read now: remove them from prefs::HIDDEN_UNTIL_IMPLEMENTED so the dialog shows them: {stale:#?}"); +} + +#[test] +fn the_scanner_finds_reads() { + assert!(has_ident("p.file_handling.recent_files.clone()", ".", "recent_files")); + assert!(!has_ident("p.file_handling.recent_files_x", ".", "recent_files")); + assert!(has_ident("let fh = &p.file_handling;", "", "file_handling")); + assert!(!has_ident("my_file_handling", "", "file_handling")); + assert_eq!(snake("recentFileCount"), "recent_file_count"); + assert_eq!(production("fn a() {}\n#[cfg(test)]\nmod tests {\n p.x.y\n}"), "fn a() {}\n"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a4b18ffece..7cd5f66423 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -670,10 +670,16 @@ pub(crate) fn retain_gpu_documents(app: &mut PhotocraftApp) { /// Tabs + canvas for the active document, or the start screen. pub fn document_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) { retain_gpu_documents(app); - if app.ui.chrome.shows_home(app.session.documents().len()) { + let n = app.session.documents().len(); + if app.ui.chrome.shows_home(n, app.session.prefs().general.auto_show_home_screen) { start_screen(app, ui); return; } + if n == 0 { + // Auto show the Home Screen is off: an empty workspace, like Photoshop. + paint_dots(ui, ui.available_rect_before_wrap()); + return; + } if !app.ui.view.hides_tabs() { tabs(app, ui); } @@ -862,7 +868,10 @@ fn start_screen(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(ui.ctx()); let area = ui.available_rect_before_wrap(); paint_dots(ui, area); - let card = Rect::from_center_size(area.center(), egui::vec2(460.0, 330.0)); + // File › Open Recent, newest first (on the web there are no paths to reopen). + let recent: Vec = if cfg!(target_arch = "wasm32") { Vec::new() } else { app.ui.recent_files.iter().take(HOME_RECENT).cloned().collect() }; + let recent_h = if recent.is_empty() { 0.0 } else { 34.0 + recent.len() as f32 * HOME_RECENT_ROW }; + let card = Rect::from_center_size(area.center(), egui::vec2(460.0, 330.0 + recent_h)); ui.scope_builder(egui::UiBuilder::new().max_rect(card), |ui| { ui.vertical_centered(|ui| { ui.horizontal(|ui| { @@ -897,6 +906,10 @@ fn start_screen(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::icons::paint(ui, r, "image", 15.0, t.text_faint); ui.label(egui::RichText::new(msg).color(t.text_faint)); }); + if !recent.is_empty() { + ui.add_space(22.0); + home_recent(app, ui, &recent); + } ui.add_space(26.0); crate::links::discord_button(app, ui, 190.0); ui.add_space(10.0); @@ -905,6 +918,51 @@ fn start_screen(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); } +/// Recent files listed on the Home screen. +const HOME_RECENT: usize = 6; +/// Height of one Home-screen recent-file row. +const HOME_RECENT_ROW: f32 = 30.0; + +/// The Home screen's "Recent" list: file name, its folder on the right, click to open. +fn home_recent(app: &mut PhotocraftApp, ui: &mut egui::Ui, recent: &[String]) { + let t = crate::theme::Tokens::get(ui.ctx()); + let width = 380.0; + ui.horizontal(|ui| { + // Line the heading up with the file icons. + ui.add_space(((ui.available_width() - width) / 2.0).max(0.0) + 8.0); + ui.label(egui::RichText::new("Recent").font(crate::theme::semibold(12.5)).color(t.text_dim)); + }); + ui.add_space(4.0); + let mut open = None; + for path in recent { + let name = crate::file_open::display_name(path); + let folder = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default(); + let (row, resp) = ui.allocate_exact_size(egui::vec2(width, HOME_RECENT_ROW), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(row, t.radius_sm, t.hover); + } + let icon = Rect::from_center_size(egui::pos2(row.left() + 16.0, row.center().y), egui::vec2(16.0, 16.0)); + crate::icons::paint(ui, icon, "file", 14.0, t.text_dim); + let x = row.left() + 32.0; + let name_g = crate::tab_strip::elided(ui, &name, egui::FontId::proportional(12.5), t.text, 170.0); + let name_w = name_g.size().x; + ui.painter().galley(egui::pos2(x, row.center().y - name_g.size().y / 2.0), name_g, t.text); + let folder_max = (row.right() - 10.0 - (x + name_w + 14.0)).max(0.0); + if folder_max > 20.0 && !folder.is_empty() { + let fg = crate::tab_strip::elided(ui, &folder, egui::FontId::proportional(11.5), t.text_faint, folder_max); + ui.painter().galley(egui::pos2(row.right() - 10.0 - fg.size().x, row.center().y - fg.size().y / 2.0), fg, t.text_faint); + } + if resp.on_hover_text(path).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { + open = Some(path.clone()); + } + } + if let Some(path) = open + && let Err(e) = app.open_path(&path) + { + app.open_failed(&crate::file_open::display_name(&path), &e); + } +} + /// Lattice size of the canvas display LUT (colour management, Proof Colors, Gamut Warning). const DISPLAY_LUT: usize = 33; diff --git a/crates/ui-egui/src/chrome_ui.rs b/crates/ui-egui/src/chrome_ui.rs index 9894dcf3ab..1049f143be 100644 --- a/crates/ui-egui/src/chrome_ui.rs +++ b/crates/ui-egui/src/chrome_ui.rs @@ -20,9 +20,10 @@ pub struct ChromeState { } impl ChromeState { - /// Is the Home screen up, given the current number of open documents? - pub fn shows_home(&self, documents: usize) -> bool { - documents == 0 || self.home == Some(documents) + /// Is the Home screen up, given the current number of open documents? With no documents it + /// shows by itself when `auto_show` (Preferences › General › Auto show the Home Screen). + pub fn shows_home(&self, documents: usize, auto_show: bool) -> bool { + (documents == 0 && auto_show) || self.home == Some(documents) } } @@ -146,8 +147,11 @@ pub fn status_bar_pro(app: &mut PhotocraftApp, ui: &mut egui::Ui) { /// screen over the open documents, which stay open. pub fn home_button(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let n = app.session.documents().len(); - let on = app.ui.chrome.shows_home(n); - if icons::button(ui, "house", 26.0, on && n > 0, "Home").clicked() && n > 0 { + let auto = app.session.prefs().general.auto_show_home_screen; + let on = app.ui.chrome.shows_home(n, auto); + // With no documents and auto-show on, Home can't be dismissed (there's nothing behind it). + let can_toggle = n > 0 || !auto; + if icons::button(ui, "house", 26.0, on && can_toggle, "Home").clicked() && can_toggle { app.ui.chrome.home = if on { None } else { Some(n) }; } } @@ -224,11 +228,20 @@ mod tests { #[test] fn home_screen_closes_when_a_document_opens() { let mut c = ChromeState::default(); - assert!(c.shows_home(0)); - assert!(!c.shows_home(2)); + assert!(c.shows_home(0, true)); + assert!(!c.shows_home(2, true)); c.home = Some(2); - assert!(c.shows_home(2)); - assert!(!c.shows_home(3)); + assert!(c.shows_home(2, true)); + assert!(!c.shows_home(3, true)); + } + + #[test] + fn auto_show_home_screen_off_leaves_an_empty_workspace() { + let mut c = ChromeState::default(); + assert!(!c.shows_home(0, false), "no Home by itself"); + c.home = Some(0); + assert!(c.shows_home(0, false), "the Home button still opens it"); + assert!(!c.shows_home(1, false), "opening a document leaves it"); } #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5558589562..660247f5c6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -465,12 +465,40 @@ impl PhotocraftApp { } /// Record `path` as the most-recently-opened file (File › Open Recent): de-duplicated, newest - /// first, capped at 10. + /// first, capped at Preferences › File Handling › Recent File List Contains. The list lives in + /// the preferences (`fileHandling.recentFiles`), so it survives a restart; `ui.recent_files` + /// mirrors it for the menus and the Home screen. pub fn push_recent(&mut self, path: &str) { - let r = &mut self.ui.recent_files; + let cap = self.recent_cap(); + let mut r = self.session.prefs().file_handling.recent_files.clone(); r.retain(|p| p != path); r.insert(0, path.to_string()); - r.truncate(10); + r.truncate(cap); + self.ui.recent_files = r.clone(); + self.session.prefs.edit(|p| p.file_handling.recent_files = r); + } + + /// File › Open Recent › Clear Recent File List. + pub fn clear_recent(&mut self) { + self.ui.recent_files.clear(); + self.session.prefs.edit(|p| p.file_handling.recent_files.clear()); + } + + /// How many recent files to remember ("Recent File List Contains", 0–100). + pub fn recent_cap(&self) -> usize { + self.session.prefs().file_handling.recent_file_count.min(100) as usize + } + + /// Mirror the stored recent-file list into the UI state (after loading the preferences, or + /// when the Preferences dialog or an agent changes the list or its length). Cheap enough to + /// run every frame: a slice comparison of at most 100 paths, no allocation unless it changed. + pub fn sync_recent(&mut self) { + let cap = self.recent_cap(); + let stored = &self.session.prefs().file_handling.recent_files; + let want = stored.get(..cap.min(stored.len())).unwrap_or_default(); + if self.ui.recent_files.as_slice() != want { + self.ui.recent_files = want.to_vec(); + } } /// Open a file's bytes as a new document named `name`; returns the import warnings (also diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a1d0611353..53fd2d0968 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -230,7 +230,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: Ok(Value::Null) } "file.clearRecent" => { - app.ui.recent_files.clear(); + app.clear_recent(); Ok(Value::Null) } id if id.starts_with("file.openRecent.") => { @@ -1095,7 +1095,12 @@ mod open_recent_tests { for i in 0..15 { app.push_recent(&format!("/tmp/f{i}.png")); } - assert_eq!(app.ui.recent_files.len(), 10, "capped at 10"); + assert_eq!(app.ui.recent_files.len(), 17, "all kept below the default cap of 20"); + for i in 15..30 { + app.push_recent(&format!("/tmp/f{i}.png")); + } + assert_eq!(app.ui.recent_files.len(), 20, "capped at Recent File List Contains (20)"); + assert_eq!(app.session.prefs().file_handling.recent_files, app.ui.recent_files, "stored in the preferences"); // Menu lists them under File › Open Recent, with basenames, plus Clear Recent Files. app.ui.recent_files = vec!["/tmp/a.png".into(), "/dir/b.psd".into()]; @@ -1109,6 +1114,7 @@ mod open_recent_tests { let ctx = egui::Context::default(); invoke(&mut app, &ctx, "file.clearRecent", json!({})).unwrap(); assert!(app.ui.recent_files.is_empty()); + assert!(app.session.prefs().file_handling.recent_files.is_empty(), "cleared in the preferences too"); // No recent items in the menu once cleared (only the disabled "Clear Recent Files"). assert!(!menu_items(&app).iter().any(|i| i.id.starts_with("file.openRecent."))); // A bad recent index errors gracefully (no panic). diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index a598b83e00..9f33e447d9 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -102,6 +102,7 @@ pub fn load(app: &mut PhotocraftApp) { app.ui.status = format!("Preferences were reset: {e}"); } crate::dock::restore(app); + app.sync_recent(); app.prefs_rt.saved_rev = app.session.prefs.rev(); if app.session.prefs().file_handling.recover_on_launch && let Some(recover) = app.services.recover.as_mut() @@ -169,6 +170,17 @@ fn sync_display_scale(app: &PhotocraftApp, ctx: &egui::Context) { ctx.set_zoom_factor(scale / native_scale); } +/// Interface › Show Tooltips and Tools › Show Tooltips (either one off hides them): egui never +/// shows a tooltip whose delay is infinite. Re-checked every frame because a theme change +/// rebuilds the style; that's one style read, and a write only when it differs. +fn sync_tooltips(app: &PhotocraftApp, ctx: &egui::Context) { + let p = app.session.prefs(); + let delay = if p.interface.show_tooltips && p.tools.show_tooltips { crate::theme::TOOLTIP_DELAY } else { f32::INFINITY }; + if ctx.global_style().interaction.tooltip_delay != delay { + ctx.global_style_mut(|s| s.interaction.tooltip_delay = delay); + } +} + /// Per-frame upkeep: theme sync, persistence, autosave and the history log. pub fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) { if !app.prefs_rt.loaded { @@ -189,6 +201,8 @@ pub fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) { app.prefs_rt.theme_pref = Some(t); } presets_store(app); + sync_tooltips(app, ctx); + app.sync_recent(); if app.session.prefs.rev() != app.prefs_rt.saved_rev { app.prefs_rt.saved_rev = app.session.prefs.rev(); let text = app.session.prefs_to_json(); @@ -593,6 +607,7 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map) { ui.set_width(170.0); for (id, title) in SECTIONS { let sel = section == id; + let empty = !has_visible_fields(&values, id); let (rect, resp) = ui.allocate_exact_size(vec2(170.0, 22.0), Sense::click()); if sel { ui.painter().rect_filled(rect, t.radius_sm, t.row_selected); @@ -604,7 +619,13 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map) { egui::Align2::LEFT_CENTER, title, crate::theme::medium(12.5), - if sel { t.text } else { t.text_dim }, + if sel { + t.text + } else if empty { + t.text_faint + } else { + t.text_dim + }, ); if resp.clicked() { section = id.to_string(); @@ -620,11 +641,15 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map) { egui::ScrollArea::vertical().max_height(390.0).id_salt("prefs-scroll").show(ui, |ui| { let order: Vec = f.get("__order").and_then(|o| o.get(§ion)).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); - if let Some(obj) = values.get_mut(§ion).and_then(Value::as_object_mut) { + if !has_visible_fields(&values, §ion) { + ui.add_space(4.0); + ui.label(RichText::new("These settings aren't available in PhotoCraft yet.").color(t.text_faint)); + } else if let Some(obj) = values.get_mut(§ion).and_then(Value::as_object_mut) { section_fields(ui, §ion, obj, &order); + ui.add_space(8.0); } - ui.add_space(8.0); - if crate::widgets::secondary_button(ui, "Reset Section", 110.0).clicked() + if has_visible_fields(&values, §ion) + && crate::widgets::secondary_button(ui, "Reset Section", 110.0).clicked() && let Some(def) = prefs::Preferences::default().get(§ion) { values[§ion] = def; @@ -636,6 +661,11 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map) { f.insert("values".into(), values); } +/// Does `section` have any setting the dialog shows (see [`prefs::HIDDEN_UNTIL_IMPLEMENTED`])? +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}")))) +} + /// 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, order: &[String]) { @@ -645,6 +675,11 @@ fn section_fields(ui: &mut egui::Ui, section: &str, obj: &mut Map egui::Grid::new(("prefs-grid", section)).num_columns(2).spacing([14.0, 7.0]).show(ui, |ui| { for k in keys { 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) { + continue; + } let v = obj.get(&k).cloned().unwrap_or(Value::Null); let label = humanize(&k); match &v { @@ -1133,6 +1168,76 @@ mod tests { } } + #[test] + fn recent_files_survive_a_restart_and_honour_the_count() { + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + tick(&mut app, &ctx); + app.push_recent("/work/a.psd"); + app.push_recent("/work/b.png"); + tick(&mut app, &ctx); + let saved = store.lock().unwrap().clone().unwrap(); + // A new app instance (a restart) gets the list back, newest first. + let (mut app2, _) = app_with_saved(Some(saved)); + tick(&mut app2, &ctx); + assert_eq!(app2.ui.recent_files, vec!["/work/b.png".to_string(), "/work/a.psd".to_string()]); + // Lowering "Recent File List Contains" shortens the menu at once; 0 turns it off. + app2.run("prefs.set", json!({"values": {"fileHandling.recentFileCount": 1}})).unwrap(); + tick(&mut app2, &ctx); + assert_eq!(app2.ui.recent_files, vec!["/work/b.png".to_string()]); + app2.push_recent("/work/c.tif"); + assert_eq!(app2.session.prefs().file_handling.recent_files, vec!["/work/c.tif".to_string()]); + app2.run("prefs.set", json!({"values": {"fileHandling.recentFileCount": 0}})).unwrap(); + tick(&mut app2, &ctx); + app2.push_recent("/work/d.tif"); + assert!(app2.ui.recent_files.is_empty()); + // Clearing the list in the Preferences dialog (or by an agent) reaches the menu. + app2.run("prefs.set", json!({"values": {"fileHandling.recentFileCount": 20, "fileHandling.recentFiles": ["/x.psd"]}})).unwrap(); + tick(&mut app2, &ctx); + assert_eq!(app2.ui.recent_files, vec!["/x.psd".to_string()]); + // A hostile count from a hand-edited preferences file is capped, not trusted. + app2.run("prefs.set", json!({"values": {"fileHandling.recentFileCount": 100}})).unwrap(); + app2.session.prefs.edit(|p| p.file_handling.recent_file_count = u32::MAX); + assert_eq!(app2.recent_cap(), 100); + } + + #[test] + fn show_tooltips_preferences_turn_tooltips_off() { + let (mut app, _) = app_with_store(); + let ctx = egui::Context::default(); + tick(&mut app, &ctx); + assert_eq!(ctx.global_style().interaction.tooltip_delay, crate::theme::TOOLTIP_DELAY); + for path in ["interface.showTooltips", "tools.showTooltips"] { + app.run("prefs.set", json!({"values": {path: false}})).unwrap(); + tick(&mut app, &ctx); + assert!(ctx.global_style().interaction.tooltip_delay.is_infinite(), "{path} off hides tooltips"); + app.run("prefs.set", json!({"values": {path: true}})).unwrap(); + tick(&mut app, &ctx); + assert_eq!(ctx.global_style().interaction.tooltip_delay, crate::theme::TOOLTIP_DELAY); + } + } + + #[test] + fn unimplemented_preferences_are_hidden_from_the_dialog() { + let values = prefs::Preferences::default().to_json(); + assert!(has_visible_fields(&values, "general")); + assert!(has_visible_fields(&values, "fileHandling")); + // Every setting of these sections is still unimplemented. + for section in ["type", "enhancedControls", "rawDefaults", "integrations", "scratchDisks"] { + assert!(!has_visible_fields(&values, section), "{section}"); + } + assert!(prefs::is_hidden("rawDefaults.applyAutoTone")); + assert!(!prefs::is_hidden("general.autoShowHomeScreen")); + assert!(!prefs::is_hidden("interface.uiScale")); + // Hidden values still round-trip through the dialog untouched. + let (mut app, _) = app_with_store(); + app.run("prefs.set", json!({"values": {"type.smartQuotes": false}})).unwrap(); + let id = open_preferences(&mut app, "type"); + let fields = app.ui.dialogs.iter().find(|d| d.id == id).map(|d| d.fields.clone()).unwrap(); + confirm(&mut app, &fields).unwrap(); + assert!(!app.session.prefs().type_.smart_quotes); + } + #[test] fn preferences_persist_through_services_and_theme_follows() { let (mut app, store) = app_with_store(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 08aa4506c7..de5fa792aa 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -368,13 +368,16 @@ pub fn apply(ctx: &egui::Context, kind: ThemeKind) { s.spacing.window_margin = egui::Margin::same(16); s.spacing.icon_width = 14.0; s.visuals.indent_has_left_vline = false; - s.interaction.tooltip_delay = 0.35; + s.interaction.tooltip_delay = TOOLTIP_DELAY; // Thin overlay scrollbars that appear on hover (Photoshop/macOS style). s.spacing.scroll = if t.bevel { egui::style::ScrollStyle::solid() } else { egui::style::ScrollStyle::thin() }; s.spacing.tooltip_width = 280.0; }); } +/// Seconds the pointer rests on a control before its tooltip shows. +pub const TOOLTIP_DELAY: f32 = 0.35; + /// Vertical gap between stacked control rows in panels (Properties fields, the Layers panel's /// Opacity and Fill rows); docks zero egui's item spacing, so rows add this themselves. pub const ROW_GAP: f32 = 4.0; diff --git a/docs/scorecard.md b/docs/scorecard.md index 16fcee1422..6349db024c 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 10 | 44 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 0 not run, 10 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 0 | 6 | 21 | io corpus: oracle ≥ 145/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 228/309, round trip ≥ 307/309 | | [Tools](#tools) | 0 | 16 | 14 | | -| [Workspace and UI](#workspace-and-ui) | 0 | 3 | 20 | settings that do nothing: 66 of 129 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 0 | 3 | 20 | settings that do nothing: 61 of 129 (target 0) | | [Type](#type) | 0 | 3 | 6 | | | [Automation](#automation) | 1 | 3 | 7 | 626 of 626 menu items live (100.0%) | | [Reliability](#reliability) | 2 | 1 | 4 | never-crash lints on 25 of 25 crates; panic_hunt present | @@ -150,9 +150,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 66 of 129** (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: 61 of 129** (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`, `general.use_legacy_free_transform`, `general.auto_show_home_screen`, `interface.ui_font_size`, `interface.show_channels_in_color`, `interface.dynamic_color_sliders`, `interface.show_tooltips`, `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.show_tooltips`, `tools.zoom_clicked_point_to_center`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `tools.overscroll`, `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.ask_before_saving_layered_tiff`, `file_handling.maximize_psd_compatibility`, `file_handling.recent_file_count`, `file_handling.recent_files`, `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`. +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`, `general.use_legacy_free_transform`, `interface.ui_font_size`, `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.overscroll`, `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.ask_before_saving_layered_tiff`, `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`. | Id | Target | Status | Issue | Note | |---|---|---|---|---|