Preferences: wire tooltips, Home screen, recent files; hide what nothing reads (#245) (#245)

This commit is contained in:
Brandon Thomas
2026-10-05 11:16:27 -04:00
committed by GitHub
parent 82270711de
commit 34de1a5dfc
9 changed files with 484 additions and 24 deletions
+78
View File
@@ -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 {
+169
View File
@@ -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 `.<snake_key>` in a non-test source file that also names the section
//! (`<snake_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.<snake_key>` 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<PathBuf>) {
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<String> {
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::<Vec<_>>()
.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<String> = 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 <field>: <Type>,` 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, ".", &section_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, "", &section_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");
}
+60 -2
View File
@@ -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<String> = 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;
+22 -9
View File
@@ -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]
+31 -3
View File
@@ -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
+8 -2
View File
@@ -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).
+109 -4
View File
@@ -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<String, Value>) {
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<String, Value>) {
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<String, Value>) {
egui::ScrollArea::vertical().max_height(390.0).id_salt("prefs-scroll").show(ui, |ui| {
let order: Vec<String> =
f.get("__order").and_then(|o| o.get(&section)).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
if let Some(obj) = values.get_mut(&section).and_then(Value::as_object_mut) {
if !has_visible_fields(&values, &section) {
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(&section).and_then(Value::as_object_mut) {
section_fields(ui, &section, 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, &section)
&& crate::widgets::secondary_button(ui, "Reset Section", 110.0).clicked()
&& let Some(def) = prefs::Preferences::default().get(&section)
{
values[&section] = def;
@@ -636,6 +661,11 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
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<String, Value>, order: &[String]) {
@@ -645,6 +675,11 @@ fn section_fields(ui: &mut egui::Ui, section: &str, obj: &mut Map<String, Value>
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();
+4 -1
View File
@@ -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;
+3 -3
View File
@@ -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 |
|---|---|---|---|---|