mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 22:30:20 -06:00
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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, ".", §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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(§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<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();
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|---|---|---|---|---|
|
||||
|
||||
Reference in New Issue
Block a user