diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index a4c9a0b..89e9f53 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -438,7 +438,8 @@ fn main() -> eframe::Result<()> { if updated { app.session.status = format!("Updated to PlotX {}.", env!("CARGO_PKG_VERSION")); // Stamp the new version so the notice shows only once. - plotx_core::settings::update(|_| {}); + app.settings.app_version = env!("CARGO_PKG_VERSION").to_owned(); + app.persist_settings(); } if let Some(notice) = crash_notice { if updated { diff --git a/crates/app/src/scale.rs b/crates/app/src/scale.rs index cbc0de9..812f9e3 100644 --- a/crates/app/src/scale.rs +++ b/crates/app/src/scale.rs @@ -103,19 +103,19 @@ impl ScaleDriver { .input(|i| i.viewport().native_pixels_per_point) .unwrap_or(1.0); let auto = auto_zoom(probe.ppi, native_ppp); - let mut scale = MonitorScale { auto, user: None }; - let save_result = settings::try_update(|settings| { - let entry = settings + let scale = { + let entry = app + .settings .appearance .ui_scale .monitors .entry(probe.key.clone()) - .or_insert(scale); + .or_insert(MonitorScale { auto, user: None }); // Refresh `auto` on every sight: same key means same hardware, but // the derivation itself may have changed between app versions. entry.auto = auto; - scale = *entry; - }); + *entry + }; let first_sight = self.current.is_none(); self.current = Some(probe.token); @@ -125,9 +125,7 @@ impl ScaleDriver { user: scale.user, ppi: probe.ppi, }); - if let Err(error) = save_result { - app.session.status = format!("Could not save display scale settings: {error}"); - } + app.persist_settings(); let zoom = scale.effective(); if (ctx.zoom_factor() - zoom).abs() > f32::EPSILON { ctx.set_zoom_factor(zoom); @@ -179,43 +177,41 @@ pub(crate) fn reset_ui_zoom(app: &mut PlotxApp, ctx: &egui::Context) { } fn set_ui_zoom(app: &mut PlotxApp, ctx: &egui::Context, user: Option) { - set_ui_zoom_with(app, ctx, user, persist_ui_zoom); + set_ui_zoom_with(app, ctx, user, PlotxApp::persist_settings); } +/// The flush is a parameter so a test can prove the zoom commands persist at +/// all, and that they do it *after* the optimistic status — `apply_ui_zoom` +/// alone would leave the shortcuts session-only with the suite still green. fn set_ui_zoom_with( app: &mut PlotxApp, ctx: &egui::Context, user: Option, - persist: impl FnOnce(&str, f32, Option) -> std::io::Result<()>, + persist: impl FnOnce(&mut PlotxApp) -> bool, ) { + apply_ui_zoom(app, ctx, user); + persist(app); +} + +fn apply_ui_zoom(app: &mut PlotxApp, ctx: &egui::Context, user: Option) { let Some(status) = app.session.monitor.as_ref() else { return; }; let key = status.key.clone(); let auto = status.auto; - let save_result = persist(&key, auto, user); + update_monitor_override(&mut app.settings, &key, auto, user); let status = app.session.monitor.as_mut().expect("status checked above"); status.user = user; let zoom = status.effective(); ctx.set_zoom_factor(zoom); - app.session.status = match (user, save_result) { - (Some(_), Ok(())) => format!("UI scale {:.0}% on this display.", zoom * 100.0), - (None, Ok(())) => { + app.session.status = match user { + Some(_) => format!("UI scale {:.0}% on this display.", zoom * 100.0), + None => { format!("UI scale automatic ({:.0}%) on this display.", zoom * 100.0) } - (_, Err(error)) => format!( - "UI scale changed to {:.0}% for this session, but could not save it: {error}", - zoom * 100.0 - ), }; } -fn persist_ui_zoom(key: &str, auto: f32, user: Option) -> std::io::Result<()> { - settings::try_update(|settings| { - update_monitor_override(settings, key, auto, user); - }) -} - fn update_monitor_override( settings: &mut settings::Settings, key: &str, @@ -390,7 +386,26 @@ mod tests { } #[test] - fn failed_override_save_is_visible_and_keeps_session_zoom() { + fn override_updates_live_settings_and_session_zoom() { + let mut app = PlotxApp::new_with_settings(settings::Settings::default()); + app.session.monitor = Some(MonitorScaleStatus { + key: "test-monitor".to_owned(), + auto: 1.0, + user: None, + ppi: None, + }); + let ctx = egui::Context::default(); + apply_ui_zoom(&mut app, &ctx, Some(1.5)); + + assert_eq!(app.session.monitor.as_ref().unwrap().user, Some(1.5)); + assert_eq!( + app.settings.appearance.ui_scale.monitors["test-monitor"].user, + Some(1.5) + ); + } + + #[test] + fn a_zoom_command_flushes_the_override_and_keeps_a_failure_visible() { let mut app = PlotxApp::new_with_settings(settings::Settings::default()); app.session.monitor = Some(MonitorScaleStatus { key: "test-monitor".to_owned(), @@ -399,16 +414,28 @@ mod tests { ppi: None, }); let ctx = egui::Context::default(); - set_ui_zoom_with(&mut app, &ctx, Some(1.5), |_, _, _| { - Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "read-only settings", - )) + let mut flushed = false; + set_ui_zoom_with(&mut app, &ctx, Some(1.5), |app| { + flushed = true; + // What the real flush does when the file cannot be written. + app.session.status = + "Couldn't save preferences — changes apply this session only (test)".to_owned(); + false }); + assert!(flushed, "a zoom command must flush the override"); + assert_eq!( + app.settings.appearance.ui_scale.monitors["test-monitor"].user, + Some(1.5) + ); + // The zoom itself still applies, and the write failure survives the + // optimistic "UI scale 150%" line rather than replacing it. assert_eq!(app.session.monitor.as_ref().unwrap().user, Some(1.5)); - assert!(app.session.status.contains("could not save it")); - assert!(app.session.status.contains("read-only settings")); + assert!( + app.session.status.contains("Couldn't save preferences"), + "{}", + app.session.status + ); } #[test] diff --git a/crates/app/src/ui/canvas/tiling.rs b/crates/app/src/ui/canvas/tiling.rs index fb5afdf..17a9a70 100644 --- a/crates/app/src/ui/canvas/tiling.rs +++ b/crates/app/src/ui/canvas/tiling.rs @@ -105,7 +105,7 @@ pub(crate) fn update_tile_drop( .clamp(0.0, 1.0), ], }); - app.session.status = if app.keep_empty_source_canvas { + app.session.status = if app.settings.general.keep_empty_source_canvas { "Hold Alt to remove the empty source canvas.".into() } else { "Hold Alt to keep the empty source canvas.".into() @@ -154,7 +154,7 @@ pub(crate) fn commit_tile_drop( preview: TileDropPreview, alt: bool, ) { - let remove_empty_source = app.keep_empty_source_canvas == alt; + let remove_empty_source = app.settings.general.keep_empty_source_canvas == alt; let source_becomes_empty = app.doc.canvases.get(drag.canvas).is_some_and(|canvas| { canvas.objects.len() == 1 && canvas.object(drag.object).is_some() diff --git a/crates/app/src/ui/canvas_size.rs b/crates/app/src/ui/canvas_size.rs index 0b9a9b9..dc7b98e 100644 --- a/crates/app/src/ui/canvas_size.rs +++ b/crates/app/src/ui/canvas_size.rs @@ -5,7 +5,7 @@ use egui::{Response, RichText, Ui}; use egui_phosphor::regular as icon; use plotx_core::actions::{Action, PageSizeState, PendingCanvasSizeEdit}; -use plotx_core::settings::{self, CanvasSizeDefaults, CustomSizePreset}; +use plotx_core::settings::{CanvasSizeDefaults, CustomSizePreset}; use plotx_core::state::{ CanvasSizeUnit, MM_TO_PT, PlotxApp, SizePreset, SizePresetGroup, content_bounds_pt, content_overflows, matching_preset, size_display_name, size_presets, wider_preset_suggestion, @@ -52,7 +52,7 @@ pub(crate) fn size_section(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { }); let filter = search.trim().to_lowercase(); - let defaults = canvas_size_defaults(ui.ctx()); + let defaults = app.settings.canvas_size.clone(); egui::ScrollArea::vertical() .max_height(170.0) .show(ui, |ui| { @@ -138,13 +138,13 @@ pub(crate) fn size_section(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { match choice { Some(SizeChoice::Preset(preset)) => apply_preset(app, ui.ctx(), ci, preset), - Some(SizeChoice::Custom(after)) => apply_size(app, ui.ctx(), ci, after, None), + Some(SizeChoice::Custom(after)) => apply_size(app, ci, after, None), Some(SizeChoice::Swap) => { let after = [size[1], size[0]]; - apply_size(app, ui.ctx(), ci, after, matched.filter(|p| p.is_fixed())); + apply_size(app, ci, after, matched.filter(|p| p.is_fixed())); } Some(SizeChoice::DeleteCustom(index)) => { - update_canvas_size_defaults(ui.ctx(), |d| { + update_canvas_size_defaults(app, |d| { if index < d.custom_presets.len() { d.custom_presets.remove(index); } @@ -191,7 +191,7 @@ pub(crate) fn size_section(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { ui.label(unit.label()); }); - let defaults = canvas_size_defaults(ui.ctx()); + let defaults = app.settings.canvas_size.clone(); let mut scale_content = defaults.scale_content; if ui .checkbox(&mut scale_content, "Scale content when applying sizes") @@ -202,7 +202,7 @@ pub(crate) fn size_section(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { ) .changed() { - update_canvas_size_defaults(ui.ctx(), |d| d.scale_content = scale_content); + update_canvas_size_defaults(app, |d| d.scale_content = scale_content); } let mut auto = auto_height; @@ -246,7 +246,7 @@ pub(crate) fn size_section(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { .on_hover_text("Keep the current page size in the preset list, across sessions.") .clicked() { - update_canvas_size_defaults(ui.ctx(), |d| { + update_canvas_size_defaults(app, |d| { let exists = d.custom_presets.iter().any(|c| { (c.width_mm - size[0]).abs() < 0.01 && (c.height_mm - size[1]).abs() < 0.01 }); @@ -285,7 +285,7 @@ fn preset_row(ui: &mut Ui, preset: &'static SizePreset, matched: Option<&SizePre /// used only for an empty page). pub(crate) fn apply_preset( app: &mut PlotxApp, - ctx: &egui::Context, + _ctx: &egui::Context, ci: usize, preset: &'static SizePreset, ) { @@ -295,7 +295,7 @@ pub(crate) fn apply_preset( let current = canvas.size_mm; let after = if preset.is_fixed() || canvas.objects.is_empty() { preset.size_mm() - } else if canvas_size_defaults(ctx).scale_content { + } else if app.settings.canvas_size.scale_content { [ preset.width_mm, (current[1] * preset.width_mm / current[0]).clamp(10.0, 1000.0), @@ -303,12 +303,11 @@ pub(crate) fn apply_preset( } else { [preset.width_mm, current[1]] }; - apply_size(app, ctx, ci, after, Some(preset)); + apply_size(app, ci, after, Some(preset)); } fn apply_size( app: &mut PlotxApp, - ctx: &egui::Context, ci: usize, after_size: [f32; 2], preset: Option<&'static SizePreset>, @@ -322,7 +321,7 @@ fn apply_size( preset_id: preset.map(|p| p.id.to_owned()), }; if before != after { - let action = if canvas_size_defaults(ctx).scale_content { + let action = if app.settings.canvas_size.scale_content { Action::set_canvas_size_scaling_content(app, ci, after) } else { Action::set_canvas_size(ci, before, after) @@ -330,7 +329,7 @@ fn apply_size( app.execute_action(action); } if let Some(preset) = preset { - update_canvas_size_defaults(ctx, |d| d.note_recent(preset.id)); + update_canvas_size_defaults(app, |d| d.note_recent(preset.id)); } } @@ -509,29 +508,9 @@ fn suggestion_dismissed(ctx: &egui::Context, resource_id: &str, preset: &SizePre .unwrap_or(false) } -/// The sticky canvas-size choices, cached in egui memory so the per-frame UI -/// never re-reads the settings file; writes go through -/// [`update_canvas_size_defaults`], which refreshes the cache and persists. -fn canvas_size_defaults(ctx: &egui::Context) -> CanvasSizeDefaults { - let id = egui::Id::new("canvas_size_defaults_cache"); - if let Some(cached) = ctx.data(|d| d.get_temp::(id)) { - return cached; - } - let loaded = settings::load().canvas_size; - ctx.data_mut(|d| d.insert_temp(id, loaded.clone())); - loaded -} - -fn update_canvas_size_defaults(ctx: &egui::Context, f: impl FnOnce(&mut CanvasSizeDefaults)) { - let mut defaults = canvas_size_defaults(ctx); - f(&mut defaults); - ctx.data_mut(|d| { - d.insert_temp( - egui::Id::new("canvas_size_defaults_cache"), - defaults.clone(), - ) - }); - settings::update(|s| s.canvas_size = defaults); +fn update_canvas_size_defaults(app: &mut PlotxApp, f: impl FnOnce(&mut CanvasSizeDefaults)) { + f(&mut app.settings.canvas_size); + app.persist_settings(); } fn handle_canvas_dimension_response( diff --git a/crates/app/src/ui/clipboard_figure.rs b/crates/app/src/ui/clipboard_figure.rs index ebd804a..050facd 100644 --- a/crates/app/src/ui/clipboard_figure.rs +++ b/crates/app/src/ui/clipboard_figure.rs @@ -93,7 +93,7 @@ fn build_payload( .canvases .get(canvas_index) .ok_or(ClipboardFigureError::NoTarget)?; - let dpi = plotx_core::settings::load().export.dpi; + let dpi = app.settings.export.dpi; let raster = rasterize_canvas(canvas, RasterOptions::new(dpi)).map_err(ClipboardFigureError::Raster)?; #[cfg(windows)] diff --git a/crates/app/src/ui/command_palette.rs b/crates/app/src/ui/command_palette.rs index 39eea51..0ed0ef5 100644 --- a/crates/app/src/ui/command_palette.rs +++ b/crates/app/src/ui/command_palette.rs @@ -12,7 +12,7 @@ use super::properties::{self, PanelRoute}; use super::*; use egui::{Align2, FontId, Key, TextEdit, vec2}; use plotx_core::properties::PropertyId; -use plotx_core::state::{ObjectId, PropertyFocus, ToolGroup}; +use plotx_core::state::{ObjectId, PropertyFocus, SettingsCategory, ToolGroup}; const PANEL_WIDTH: f32 = 540.0; const LIST_HEIGHT: f32 = 320.0; @@ -323,6 +323,17 @@ pub(super) fn reveal_property(app: &mut PlotxApp, property: PropertyId, now: f64 app.session.secondary_sidebar_visible = true; app.session.ui.requested_tool_group = Some(ToolGroup::Processing); } + PanelRoute::Preferences => { + app.open_settings(); + if let Some(dialog) = app.session.ui.settings_dialog.as_mut() + && let Some(category) = SettingsCategory::ALL + .into_iter() + .find(|category| category.section_id() == route.section) + { + dialog.category = category; + } + return; + } } // A property owned by an owner-local component needs that component opened, // or the row it names is not on screen to scroll to. The step is chosen here diff --git a/crates/app/src/ui/command_palette_tests.rs b/crates/app/src/ui/command_palette_tests.rs index 9e2b33f..f58d866 100644 --- a/crates/app/src/ui/command_palette_tests.rs +++ b/crates/app/src/ui/command_palette_tests.rs @@ -1,5 +1,5 @@ use super::*; -use plotx_core::properties::contour; +use plotx_core::properties::{contour, export_dpi}; fn indices(items: &[PaletteItem], query: &str) -> Vec { filter(items, query) @@ -84,6 +84,24 @@ fn activating_a_property_requests_its_home_route() { assert!((focus.highlight_until - 10.8).abs() < 1e-9); } +#[test] +fn activating_export_dpi_opens_the_export_preferences_category() { + let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + reveal_property(&mut app, export_dpi::DPI, 10.0); + + let dialog = app + .session + .ui + .settings_dialog + .as_ref() + .expect("Preferences opens"); + assert_eq!(dialog.category, plotx_core::state::SettingsCategory::Export); + assert!( + app.session.ui.property_focus.is_none(), + "the native Preferences row needs no sidebar focus request" + ); +} + fn property_item(items: &[PaletteItem], property: PropertyId) -> &PaletteItem { items .iter() diff --git a/crates/app/src/ui/export_dialog.rs b/crates/app/src/ui/export_dialog.rs index b064c0a..79ed52a 100644 --- a/crates/app/src/ui/export_dialog.rs +++ b/crates/app/src/ui/export_dialog.rs @@ -2,6 +2,7 @@ use super::*; use plotx_core::export::{ ComplianceStatus, ExportPreset, PrecheckReport, page_metrics, precheck_report, }; +use plotx_core::settings::{MAX_EXPORT_DPI, MIN_EXPORT_DPI}; pub(super) fn export_options_window(app: &mut PlotxApp, ctx: &egui::Context) { if app.session.ui.export_options.is_none() { @@ -80,7 +81,10 @@ pub(super) fn export_options_window(app: &mut PlotxApp, ctx: &egui::Context) { ui.add_space(8.0); ui.horizontal(|ui| { ui.label("DPI"); - ui.add(egui::DragValue::new(&mut pending.dpi).range(72..=1200)); + ui.add( + egui::DragValue::new(&mut pending.dpi) + .range(MIN_EXPORT_DPI..=MAX_EXPORT_DPI), + ); }); } @@ -125,10 +129,9 @@ pub(super) fn export_options_window(app: &mut PlotxApp, ctx: &egui::Context) { if let Some(settings) = settings { let trim = settings.trim_to_visible_content; if let Some(path) = crate::ui::file_dialogs::choose_export_path(&settings) { - plotx_core::settings::update(move |settings| { - apply_confirmed_export_default(&mut settings.export, trim, true); - }); app.export_to(settings, &path); + apply_confirmed_export_default(&mut app.settings.export, trim, true); + app.persist_settings(); } } } else if cancel || modal.should_close() { diff --git a/crates/app/src/ui/file_dialogs.rs b/crates/app/src/ui/file_dialogs.rs index 280be93..20be31c 100644 --- a/crates/app/src/ui/file_dialogs.rs +++ b/crates/app/src/ui/file_dialogs.rs @@ -302,10 +302,13 @@ pub(crate) fn commit_table_import_preview(app: &mut PlotxApp) -> bool { candidate.series_bindings, ); } + app.session.record_operation(preview.report); + // Noting the file persists preferences, and a failed write reports itself + // through the status line. Recording the import first keeps that + // diagnostic: `record_operation` replaces the status unconditionally. if let Some(path) = preview.recent_path { app.note_recent_file(&path); } - app.session.record_operation(preview.report); true } diff --git a/crates/app/src/ui/properties/discovery.rs b/crates/app/src/ui/properties/discovery.rs index fba3880..ab25fa4 100644 --- a/crates/app/src/ui/properties/discovery.rs +++ b/crates/app/src/ui/properties/discovery.rs @@ -174,6 +174,9 @@ pub(crate) fn targets_for_property(app: &PlotxApp, property: PropertyId) -> Vec< return Vec::new(); }; match definition.applicability.component { + ComponentKind::None if definition.scope_kind == ScopeKind::App => { + vec![app.app_target()] + } ComponentKind::None if definition.scope_kind == ScopeKind::Document => { vec![app.document_target()] } diff --git a/crates/app/src/ui/properties/discovery_tests.rs b/crates/app/src/ui/properties/discovery_tests.rs index f700340..75e5fb1 100644 --- a/crates/app/src/ui/properties/discovery_tests.rs +++ b/crates/app/src/ui/properties/discovery_tests.rs @@ -13,8 +13,8 @@ use crate::ui::commands::{self, CommandId}; use crate::ui::properties::{ - CONTOUR_HOME, GROUPS, LocalizedText, PRESENTATIONS, PropertyPresentation, discovery, panel, - presentation, search, + CONTOUR_HOME, GROUPS, LocalizedText, PRESENTATIONS, PanelRoute, PropertyPresentation, + discovery, panel, presentation, search, }; use plotx_core::properties::{ Applicability, ComponentKind, DefaultPolicy, EncodingKind, PropertyAccess, PropertyDefinition, @@ -152,7 +152,22 @@ fn groups_and_home_sections_correspond() { group.section ); } + let app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); for entry in PRESENTATIONS { + if entry.home_route.panel == PanelRoute::Preferences { + // Preferences-homed defaults deliberately take no Ribbon slot: the + // panel is already one global command away. That exemption is only + // sound while the command stays globally reachable, so pin the + // premise instead of exempting a whole panel on trust. + let preferences = commands::describe(&app, CommandId::Preferences); + assert!( + preferences.enabled, + "{} is reachable only through Preferences, which is itself \ + unavailable on an empty document", + entry.id + ); + continue; + } assert!( discovery::group(entry.home_route.section).is_some(), "{} lives in section '{}', which declares no group, so it is \ diff --git a/crates/app/src/ui/properties/mod.rs b/crates/app/src/ui/properties/mod.rs index f0f7e36..67cc917 100644 --- a/crates/app/src/ui/properties/mod.rs +++ b/crates/app/src/ui/properties/mod.rs @@ -18,9 +18,10 @@ pub(crate) mod fixture; pub(crate) use search::property_hits; use plotx_core::properties::{ - PropertyDefinition, PropertyId, Tier, apodization, contour, definition, line, typography, + PropertyDefinition, PropertyId, Tier, apodization, contour, definition, export_dpi, line, + typography, }; -use plotx_core::state::WorkflowTab; +use plotx_core::state::{SettingsCategory, WorkflowTab}; /// A user-facing string in the active locale. PlotX ships one locale today; the /// type marks which strings are translatable so adding another is a table edit @@ -39,8 +40,17 @@ impl LocalizedText { pub enum PanelRoute { SecondarySidebar, Processing, + Preferences, } +const PREFERENCES_SECTIONS: &[&str] = &[ + SettingsCategory::General.section_id(), + SettingsCategory::Appearance.section_id(), + SettingsCategory::Processing.section_id(), + SettingsCategory::Export.section_id(), + SettingsCategory::Recent.section_id(), +]; + impl PanelRoute { /// The section ids this panel actually renders. A home route naming /// anything else could not be navigated to, which is what the consistency @@ -53,6 +63,7 @@ impl PanelRoute { panel::TYPOGRAPHY_SECTION, ], Self::Processing => &[panel::APODIZATION_SECTION], + Self::Preferences => PREFERENCES_SECTIONS, } } @@ -60,6 +71,7 @@ impl PanelRoute { match self { Self::SecondarySidebar => "Object inspector", Self::Processing => "Processing tools", + Self::Preferences => "Preferences", } } } @@ -194,6 +206,11 @@ const APODIZATION_HOME: HomeRoute = HomeRoute { section: panel::APODIZATION_SECTION, }; +const EXPORT_PREFERENCES_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Preferences, + section: SettingsCategory::Export.section_id(), +}; + pub const PRESENTATIONS: &[PropertyPresentation] = &[ PropertyPresentation { id: contour::BASE_MAGNITUDE, @@ -295,6 +312,13 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ home_route: APODIZATION_HOME, canvas_step: false, }, + PropertyPresentation { + id: export_dpi::DPI, + localized_label: LocalizedText("Raster resolution"), + localized_aliases: &[LocalizedText("export DPI"), LocalizedText("bitmap DPI")], + home_route: EXPORT_PREFERENCES_HOME, + canvas_step: false, + }, ]; pub fn presentation(id: PropertyId) -> Option<&'static PropertyPresentation> { diff --git a/crates/app/src/ui/properties/panel.rs b/crates/app/src/ui/properties/panel.rs index 3d5fb4b..f61faa6 100644 --- a/crates/app/src/ui/properties/panel.rs +++ b/crates/app/src/ui/properties/panel.rs @@ -659,7 +659,7 @@ fn apply(app: &mut PlotxApp, targets: &[TargetRef], pending: Pending, status_nou match planned { Ok(commit) => { let skipped = commit.skipped.clone(); - let applied = app.commit_property(commit); + let applied = commit.applied.len(); // A skipped target is reported, never silently dropped: the user // asked for the whole selection and must learn what it did not do. app.session.status = if skipped.is_empty() { @@ -672,6 +672,10 @@ fn apply(app: &mut PlotxApp, targets: &[TargetRef], pending: Pending, status_nou skipped[0].message ) }; + // Persistence failures are reported by the commit after this + // optimistic status is installed, so they remain visible instead + // of being overwritten by "Updated". + app.commit_property(commit); } Err(error) => { app.session.status = format!("Could not change {}: {error}", status_noun.plural); diff --git a/crates/app/src/ui/properties/tests.rs b/crates/app/src/ui/properties/tests.rs index 879ef60..80ef450 100644 --- a/crates/app/src/ui/properties/tests.rs +++ b/crates/app/src/ui/properties/tests.rs @@ -122,7 +122,11 @@ fn no_panel_section_exceeds_its_essential_budget() { // Every route, not a hand-picked one: a section whose panel is missing here // has no build-time budget at all, which is how the processing rows grew // theirs unchecked. - for panel in [PanelRoute::SecondarySidebar, PanelRoute::Processing] { + for panel in [ + PanelRoute::SecondarySidebar, + PanelRoute::Processing, + PanelRoute::Preferences, + ] { for section in panel.sections() { let essential = essential_in(section); assert!( diff --git a/crates/app/src/ui/settings_dialog.rs b/crates/app/src/ui/settings_dialog.rs index 654cbc8..c4700e3 100644 --- a/crates/app/src/ui/settings_dialog.rs +++ b/crates/app/src/ui/settings_dialog.rs @@ -1,7 +1,9 @@ use super::*; use egui::{Align, Align2, CornerRadius, FontId, Layout, RichText, pos2, vec2}; use egui_phosphor::regular as icon; -use plotx_core::settings::{self, GraphicsPowerPreference, Settings, ThemeMode}; +use plotx_core::settings::{ + GraphicsPowerPreference, MAX_EXPORT_DPI, MIN_EXPORT_DPI, Settings, ThemeMode, +}; use plotx_core::state::{MonitorScaleStatus, SettingsCategory, SettingsDialog}; use plotx_core::update::{UpdateChannelSetting, UpdateService, UpdateStatus}; @@ -88,7 +90,7 @@ pub(super) fn settings_window(app: &mut PlotxApp, ctx: &egui::Context) { .unwrap() .draft .clone(); - app.apply_settings(&draft); + app.apply_settings(draft.clone()); apply_chrome_theme(ctx, draft.appearance.theme); // `apply_settings` has synced the current monitor's record from the // draft; the egui zoom is an app-shell concern, applied here. @@ -101,17 +103,24 @@ pub(super) fn settings_window(app: &mut PlotxApp, ctx: &egui::Context) { } let close = done || modal.should_close(); - if let Some(dialog) = app.session.ui.settings_dialog.as_mut() { - let due = dialog.flush_at.is_some_and(|t| now >= t); - if close || due { - dialog.last_error = settings::save(&dialog.draft).err().map(|e| { - format!("Couldn't save preferences — changes apply this session only ({e})") - }); + let flush = app + .session + .ui + .settings_dialog + .as_ref() + .is_some_and(|dialog| close || dialog.flush_at.is_some_and(|t| now >= t)); + if flush { + let saved = app.persist_settings(); + let error = (!saved).then(|| app.session.status.clone()); + if let Some(dialog) = app.session.ui.settings_dialog.as_mut() { + dialog.last_error = error; dialog.flush_at = None; } - if let Some(t) = dialog.flush_at { - ctx.request_repaint_after(std::time::Duration::from_secs_f64((t - now).max(0.0))); - } + } + if let Some(dialog) = app.session.ui.settings_dialog.as_ref() + && let Some(t) = dialog.flush_at + { + ctx.request_repaint_after(std::time::Duration::from_secs_f64((t - now).max(0.0))); } if close @@ -321,7 +330,7 @@ fn render_category( |ui| { ui.add( egui::DragValue::new(&mut draft.export.dpi) - .range(72..=1200) + .range(MIN_EXPORT_DPI..=MAX_EXPORT_DPI) .suffix(" dpi"), ); }, diff --git a/crates/core/src/actions/arrange.rs b/crates/core/src/actions/arrange.rs index 11e9ebd..970fb8f 100644 --- a/crates/core/src/actions/arrange.rs +++ b/crates/core/src/actions/arrange.rs @@ -275,10 +275,9 @@ impl PlotxApp { if !enabled { self.session.ui.snap_guides.clear(); } - crate::settings::update(|settings| { - settings.export.include_view_snapshots = self.doc.save_include_view_snapshots; - settings.general.snap_enabled = enabled; - }); + self.settings.export.include_view_snapshots = self.doc.save_include_view_snapshots; + self.settings.general.snap_enabled = enabled; + self.persist_settings(); } } diff --git a/crates/core/src/automation/properties.rs b/crates/core/src/automation/properties.rs index fc49b51..1907cf7 100644 --- a/crates/core/src/automation/properties.rs +++ b/crates/core/src/automation/properties.rs @@ -203,6 +203,7 @@ fn commit_and_report( ) -> Result { let applied = commit.applied.clone(); let skipped = commit.skipped.clone(); + let changes_document = commit.has_document_action(); let before = DocumentRevision(app.doc.automation_revision); if !applied.is_empty() { app.commit_property(commit); @@ -233,15 +234,17 @@ fn commit_and_report( diagnostics: Vec::new(), verification: vec![VerificationRecord { check: "revision_advanced".to_owned(), - passed: applied.is_empty() || after > before, + passed: applied.is_empty() || !changes_document || after > before, // A commit can apply to nothing because every target refused the // property *or* because every target already held the value, and // those are opposite answers to "did I address the right thing?". // The per-target reasons say which; this line must not assert one. message: if applied.is_empty() { "nothing was committed; see each target's reason".to_owned() - } else { + } else if changes_document { "atomic document commit completed".to_owned() + } else { + "app preferences committed without changing the document revision".to_owned() }, }], value: serde_json::Value::Null, @@ -424,9 +427,9 @@ fn property_error(tool_id: &str, error: PropertyError) -> AutomationError { tool_id: tool_id.to_owned(), message: error.to_string(), }, - PropertyError::UnknownTarget(_) | PropertyError::NotApplicable(_) => { - AutomationError::Execution(error.to_string()) - } + PropertyError::UnknownTarget(_) + | PropertyError::NotApplicable(_) + | PropertyError::MixedStorage { .. } => AutomationError::Execution(error.to_string()), } } diff --git a/crates/core/src/automation/resources.rs b/crates/core/src/automation/resources.rs index 1328ecc..f8aa6b4 100644 --- a/crates/core/src/automation/resources.rs +++ b/crates/core/src/automation/resources.rs @@ -7,6 +7,7 @@ use crate::state::{Dataset, PlotxApp}; use std::collections::BTreeMap; pub const KIND_DATASET: &str = "plotx.dataset"; +pub const KIND_APP: &str = "plotx.app"; pub const KIND_DOCUMENT: &str = "plotx.document"; pub const KIND_CANVAS: &str = "plotx.canvas"; pub const KIND_TABLE_ROW: &str = "plotx.table.row"; @@ -16,6 +17,7 @@ pub const KIND_CANVAS_OBJECT: &str = "plotx.canvas.object"; pub const KIND_EXTERNAL_INPUT: &str = "plotx.external_input"; /// The one document root inside a `PlotxApp` target space. pub const DOCUMENT_RESOURCE_ID: &str = "document"; +pub const APP_RESOURCE_ID: &str = "app"; pub const CAP_RENAME: &str = "resource.rename"; pub const CAP_RENDER: &str = "figure.render"; diff --git a/crates/core/src/properties/apodization_tests.rs b/crates/core/src/properties/apodization_tests.rs index 4656b0e..2d0ba6c 100644 --- a/crates/core/src/properties/apodization_tests.rs +++ b/crates/core/src/properties/apodization_tests.rs @@ -119,7 +119,7 @@ fn apodization_step_has_a_dependent_schema_and_a_typed_processing_action() { &PropertyValue::Enum(apodization::APODIZATION_EXPONENTIAL), ) .expect("an apodization kind change plans"); - let Action::Composite(actions) = &commit.action else { + let Some(Action::Composite(actions)) = &commit.document_action else { panic!("a catalog commit is composite"); }; assert!(matches!( diff --git a/crates/core/src/properties/export_dpi.rs b/crates/core/src/properties/export_dpi.rs new file mode 100644 index 0000000..37b157a --- /dev/null +++ b/crates/core/src/properties/export_dpi.rs @@ -0,0 +1,124 @@ +//! Application-owned default resolution for raster exports. + +use super::provider::PropertyProvider; +use super::target::require_app_target; +use super::{ + AggregateValue, Applicability, Availability, ComponentKind, DefaultPolicy, EditOp, + PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, PropertyId, + PropertyTransaction, PropertyValue, ResolvedProperty, ResolvedSchema, ScopeKind, Tier, + ValueCopies, ValueSchema, definition, +}; +use crate::settings::{MAX_EXPORT_DPI, MIN_EXPORT_DPI}; +use crate::state::PlotxApp; + +pub const DPI: PropertyId = PropertyId("settings.export.dpi"); + +pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[PropertyDefinition { + id: DPI, + scope_kind: ScopeKind::App, + value_schema: ValueSchema::Int { + min: MIN_EXPORT_DPI as i64, + max: MAX_EXPORT_DPI as i64, + }, + access: PropertyAccess::ReadWrite, + applicability: Applicability::component(ComponentKind::None), + default_policy: DefaultPolicy::Fixed(PropertyValue::Int( + crate::export::DEFAULT_BITMAP_DPI as i64, + )), + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Raster export resolution", + canonical_aliases: &["export DPI", "bitmap resolution", "raster resolution"], +}]; + +pub(crate) struct ExportDpiProvider; + +pub(crate) static PROVIDER: ExportDpiProvider = ExportDpiProvider; + +impl PropertyProvider for ExportDpiProvider { + fn definitions(&self) -> &'static [PropertyDefinition] { + DEFINITIONS + } + + fn read( + &self, + app: &PlotxApp, + address: &PropertyAddress, + ) -> Result { + let definition = property_definition(address.definition)?; + require_app_target(&address.target, definition)?; + Ok(ResolvedProperty { + address: address.clone(), + value: AggregateValue::Uniform(PropertyValue::Int(i64::from(app.settings.export.dpi))), + default_value: match definition.default_policy { + DefaultPolicy::Fixed(value) => Some(value), + DefaultPolicy::EncodingFactory + | DefaultPolicy::ProcessingFactory + | DefaultPolicy::None => None, + }, + availability: Availability::Editable, + schema: ResolvedSchema::Int { + min: i64::from(MIN_EXPORT_DPI), + max: i64::from(MAX_EXPORT_DPI), + }, + }) + } + + fn edit( + &self, + app: &PlotxApp, + transaction: &mut PropertyTransaction, + address: &PropertyAddress, + operation: EditOp, + ) -> Result<(), PropertyError> { + let definition = property_definition(address.definition)?; + require_app_target(&address.target, definition)?; + let value = match operation { + EditOp::Set(PropertyValue::Int(value)) => checked_dpi(definition.id, value)?, + EditOp::Set(value) => { + return Err(PropertyError::InvalidValue { + property: definition.id, + message: format!("expected an integer, got {}", value.kind()), + }); + } + EditOp::Reset => match definition.default_policy { + DefaultPolicy::Fixed(PropertyValue::Int(value)) => { + checked_dpi(definition.id, value)? + } + DefaultPolicy::Fixed(_) + | DefaultPolicy::EncodingFactory + | DefaultPolicy::ProcessingFactory + | DefaultPolicy::None => { + return Err(PropertyError::InvalidValue { + property: definition.id, + message: "the default policy has no integer value".to_owned(), + }); + } + }, + EditOp::Step(_) => { + return Err(PropertyError::InvalidValue { + property: definition.id, + message: "this setting has no step gesture".to_owned(), + }); + } + }; + transaction.app_preferences(app).export.dpi = value; + Ok(()) + } +} + +fn property_definition(id: PropertyId) -> Result<&'static PropertyDefinition, PropertyError> { + definition(id).ok_or_else(|| PropertyError::UnknownProperty(id.as_str().to_owned())) +} + +fn checked_dpi(property: PropertyId, value: i64) -> Result { + if (i64::from(MIN_EXPORT_DPI)..=i64::from(MAX_EXPORT_DPI)).contains(&value) { + return Ok(value as u16); + } + Err(PropertyError::InvalidValue { + property, + message: format!( + "raster export resolution {value} is outside {MIN_EXPORT_DPI}–{MAX_EXPORT_DPI} dpi" + ), + }) +} diff --git a/crates/core/src/properties/export_dpi_tests.rs b/crates/core/src/properties/export_dpi_tests.rs new file mode 100644 index 0000000..fc4ac40 --- /dev/null +++ b/crates/core/src/properties/export_dpi_tests.rs @@ -0,0 +1,195 @@ +use super::*; +use crate::export::ExportFormat; +use crate::settings::{MAX_EXPORT_DPI, MIN_EXPORT_DPI, Settings}; +use crate::state::{CanvasDocument, PlotxApp, SettingsDialog}; +use std::path::PathBuf; + +fn app_with_dpi(dpi: u16) -> PlotxApp { + let mut settings = Settings::default(); + settings.export.dpi = dpi; + PlotxApp::new_with_settings(settings) +} + +fn temp_settings(name: &str) -> PathBuf { + let base = std::env::var_os("CARGO_TARGET_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + base.join(format!("plotx-property-dpi-{name}-{}", std::process::id())) +} + +fn plan_dpi(app: &PlotxApp, value: i64) -> PropertyCommit { + app.plan_property_write( + export_dpi::DPI, + std::slice::from_ref(&app.app_target()), + &PropertyValue::Int(value), + ) + .expect("DPI write plans") +} + +#[test] +fn dpi_edit_is_settings_only_and_not_undoable() { + let mut app = app_with_dpi(crate::export::DEFAULT_BITMAP_DPI); + let undo_len = app.session.undo_stack.len(); + let dirty = app.doc.dirty; + let revision = app.doc.automation_revision; + + let commit = plan_dpi(&app, 450); + assert_eq!(commit.applied.len(), 1); + assert!(commit.document_action.is_none()); + assert_eq!( + commit + .app_preferences + .as_ref() + .map(|settings| settings.export.dpi), + Some(450) + ); + assert_eq!( + app.commit_property_with_settings_writer(commit, |_| Ok(())), + 1 + ); + + assert_eq!(app.settings.export.dpi, 450); + assert_eq!(app.session.undo_stack.len(), undo_len); + assert_eq!(app.doc.dirty, dirty); + assert_eq!(app.doc.automation_revision, revision); +} + +#[test] +fn dpi_same_value_write_is_an_explicit_skip() { + let app = app_with_dpi(450); + let commit = plan_dpi(&app, 450); + + assert!(commit.applied.is_empty()); + assert_eq!(commit.skipped.len(), 1); + assert_eq!(commit.skipped[0].reason, SkipReason::AlreadyAtValue); + assert!(commit.document_action.is_none()); + assert!(commit.app_preferences.is_none()); +} + +#[test] +fn dpi_out_of_range_error_names_the_value_and_actual_bounds() { + let app = app_with_dpi(crate::export::DEFAULT_BITMAP_DPI); + let value = i64::from(MAX_EXPORT_DPI) + 1; + let error = app + .plan_property_write( + export_dpi::DPI, + std::slice::from_ref(&app.app_target()), + &PropertyValue::Int(value), + ) + .expect_err("an out-of-range DPI must be refused"); + let message = error.to_string(); + + assert!(message.contains(&value.to_string()), "{message}"); + assert!( + message.contains(&format!("{MIN_EXPORT_DPI}–{MAX_EXPORT_DPI} dpi")), + "{message}" + ); + assert_eq!(app.settings.export.dpi, crate::export::DEFAULT_BITMAP_DPI); +} + +#[test] +fn dpi_edit_roundtrips_and_supplies_the_next_export_default() { + let path = temp_settings("roundtrip").with_extension("json"); + if path.exists() { + std::fs::remove_file(&path).expect("remove stale test settings"); + } + let mut app = app_with_dpi(crate::export::DEFAULT_BITMAP_DPI); + let commit = plan_dpi(&app, 600); + app.commit_property_with_settings_writer(commit, |settings| { + crate::settings::save_to_path(&path, settings) + }); + + let loaded = crate::settings::load_from_paths(&path, None); + if path.exists() { + std::fs::remove_file(&path).expect("remove test settings"); + } + assert_eq!(loaded.export.dpi, 600); + + let mut restarted = PlotxApp::new_with_settings(loaded); + // Avoid a publication preset: selecting one deliberately replaces the + // invocation DPI and would test preset precedence rather than the app + // default this slice owns. + restarted + .doc + .canvases + .push(CanvasDocument::new("page".to_owned(), [123.0, 77.0])); + restarted.session.active_canvas = Some(0); + restarted.request_export(ExportFormat::Png); + assert_eq!( + restarted + .session + .ui + .export_options + .as_ref() + .map(|dialog| dialog.dpi), + Some(600) + ); +} + +#[test] +fn failed_dpi_flush_keeps_the_live_value_and_reports_the_failure() { + let blocking_parent = temp_settings("blocked-parent"); + if blocking_parent.exists() { + std::fs::remove_file(&blocking_parent).expect("remove stale blocker"); + } + std::fs::write(&blocking_parent, b"this is a file").expect("create blocker"); + let path = blocking_parent.join("settings.json"); + let mut app = app_with_dpi(crate::export::DEFAULT_BITMAP_DPI); + let undo_len = app.session.undo_stack.len(); + let dirty = app.doc.dirty; + + let commit = plan_dpi(&app, 720); + app.commit_property_with_settings_writer(commit, |settings| { + crate::settings::save_to_path(&path, settings) + }); + std::fs::remove_file(&blocking_parent).expect("remove blocker"); + + assert_eq!(app.settings.export.dpi, 720); + assert_eq!(app.session.undo_stack.len(), undo_len); + assert_eq!(app.doc.dirty, dirty); + assert!( + app.session.status.contains("Couldn't save preferences"), + "{}", + app.session.status + ); + assert!( + app.session + .status + .contains("changes apply this session only"), + "{}", + app.session.status + ); +} + +#[test] +fn open_preferences_draft_tracks_a_catalog_dpi_edit() { + let mut app = app_with_dpi(crate::export::DEFAULT_BITMAP_DPI); + app.session.ui.settings_dialog = Some(SettingsDialog::new(app.settings.clone())); + let mut draft = app + .session + .ui + .settings_dialog + .as_ref() + .expect("dialog open") + .draft + .clone(); + draft.export.trim_to_visible_content = true; + app.apply_settings(draft); + + let commit = plan_dpi(&app, 450); + app.commit_property_with_settings_writer(commit, |_| Ok(())); + let next_flush = app + .session + .ui + .settings_dialog + .as_ref() + .expect("dialog remains open") + .draft + .clone(); + assert_eq!(next_flush.export.dpi, 450); + assert!(next_flush.export.trim_to_visible_content); + + app.apply_settings(next_flush); + assert_eq!(app.settings.export.dpi, 450); + assert!(app.settings.export.trim_to_visible_content); +} diff --git a/crates/core/src/properties/mod.rs b/crates/core/src/properties/mod.rs index 4818787..bbca0dc 100644 --- a/crates/core/src/properties/mod.rs +++ b/crates/core/src/properties/mod.rs @@ -2,14 +2,15 @@ //! persistent setting, the counterpart of the existing command catalog. //! //! The catalog owns descriptions, addressing, reading, validation and the -//! compilation of an edit into a typed [`crate::actions::Action`]. It does not -//! own values: there is no `HashMap` anywhere in this -//! module, because every value already has exactly one home in a typed domain -//! model. Presentation — localized labels and panel routing — belongs to the +//! compilation of an edit into a typed storage commit. It does not own values: +//! there is no `HashMap` anywhere in this module, +//! because every value already has exactly one home in a typed domain model. +//! Presentation — localized labels and panel routing — belongs to the //! application crate and is keyed by the same [`PropertyId`]. pub mod apodization; pub mod contour; +pub mod export_dpi; pub mod line; mod model; mod provider; @@ -38,6 +39,9 @@ pub(crate) static GROUPS: &[PropertyProviderGroup] = &[ PropertyProviderGroup { provider: &contour::PROVIDER, }, + PropertyProviderGroup { + provider: &export_dpi::PROVIDER, + }, PropertyProviderGroup { provider: &line::PROVIDER, }, @@ -160,3 +164,7 @@ mod scope_tests; #[cfg(test)] #[path = "apodization_tests.rs"] mod apodization_tests; + +#[cfg(test)] +#[path = "export_dpi_tests.rs"] +mod export_dpi_tests; diff --git a/crates/core/src/properties/model.rs b/crates/core/src/properties/model.rs index 31def8f..3617da9 100644 --- a/crates/core/src/properties/model.rs +++ b/crates/core/src/properties/model.rs @@ -6,7 +6,7 @@ //! current value and the default are derived from the document on demand, so //! there is never a second copy of a value that already lives in a typed domain //! model. There is, for the same reason, no generic value store here; writes are -//! compiled into the existing typed `Action`s. +//! compiled into typed commits for their owning persistence boundary. use crate::automation::{ComponentRef, TargetRef}; use plotx_figure::Color; @@ -673,22 +673,30 @@ pub struct ResolvedPropertySet { pub value: AggregateValue, } -/// A validated, not-yet-executed write. Every applicable target is already -/// folded into one atomic action, so a commit either lands everywhere or -/// nowhere. +/// A validated, not-yet-executed write. Providers have already selected the +/// typed storage payload; planning guarantees that at most one arm is present. #[derive(Clone)] pub struct PropertyCommit { - pub action: crate::actions::Action, + pub(crate) document_action: Option, + pub(crate) app_preferences: Option, pub applied: Vec, pub skipped: Vec, } +impl PropertyCommit { + pub(crate) fn has_document_action(&self) -> bool { + self.document_action.is_some() + } +} + impl fmt::Debug for PropertyCommit { /// `Action` is deliberately not `Debug` — it carries whole document /// snapshots — so a commit reports what it would do, not the payload. fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("PropertyCommit") + .field("document_action", &self.document_action.is_some()) + .field("app_preferences", &self.app_preferences.is_some()) .field("applied", &self.applied) .field("skipped", &self.skipped) .finish_non_exhaustive() @@ -716,4 +724,6 @@ pub enum PropertyError { property: PropertyId, message: String, }, + #[error("one property commit cannot cross multiple storages: {storages}")] + MixedStorage { storages: String }, } diff --git a/crates/core/src/properties/provider_tests.rs b/crates/core/src/properties/provider_tests.rs index 3a747ee..20d76b3 100644 --- a/crates/core/src/properties/provider_tests.rs +++ b/crates/core/src/properties/provider_tests.rs @@ -30,7 +30,7 @@ fn document_typography_is_addressable_without_a_canvas_object() { &PropertyValue::Float(9.5), ) .expect("the document property plans"); - let Action::Composite(actions) = &commit.action else { + let Some(Action::Composite(actions)) = &commit.document_action else { panic!("a catalog commit is composite"); }; assert!(matches!( @@ -61,12 +61,9 @@ fn a_same_value_write_is_reported_without_an_empty_commit() { ); assert_eq!(commit.skipped.len(), 1); assert!(commit.skipped[0].message.contains("already has that value")); - let Action::Composite(actions) = &commit.action else { - panic!("a no-op still has the catalog composite shape"); - }; assert!( - actions.is_empty(), - "the transaction contains no typed action" + commit.document_action.is_none(), + "the transaction contains no document payload" ); let revision = app.doc.automation_revision; assert_eq!(app.commit_property(commit), 0); @@ -151,7 +148,7 @@ fn line_stroke_width_reports_mixed_values_and_skips_other_encodings() { .expect("the compatible line series plan together"); assert_eq!(commit.applied.len(), 2); assert_eq!(commit.skipped.len(), 1); - let Action::Composite(actions) = &commit.action else { + let Some(Action::Composite(actions)) = &commit.document_action else { panic!("a multi-object edit is composite"); }; assert_eq!( diff --git a/crates/core/src/properties/service.rs b/crates/core/src/properties/service.rs index 16281f2..a00a854 100644 --- a/crates/core/src/properties/service.rs +++ b/crates/core/src/properties/service.rs @@ -6,7 +6,7 @@ //! planner for every property family. use super::target::{ - canvas_object, document_target, processing_step_targets, series_context_unchecked, + app_target, canvas_object, document_target, processing_step_targets, series_context_unchecked, series_targets, }; use super::{ @@ -31,6 +31,11 @@ impl PlotxApp { document_target() } + /// The singleton target for application-owned persistent preferences. + pub fn app_target(&self) -> TargetRef { + app_target() + } + /// The address of one series-owned property on one plot object. pub fn series_target( &self, @@ -257,16 +262,21 @@ impl PlotxApp { )); } } - Ok(PropertyCommit { - action: transaction.into_action(), - applied, - skipped, - }) + transaction.ensure_single_storage()?; + Ok(transaction.into_commit(applied, skipped)) } /// Execute a validated commit and report the number of targets that were /// actually changed. pub fn commit_property(&mut self, commit: PropertyCommit) -> usize { + self.commit_property_with_persistence(commit, |app| app.persist_settings()) + } + + fn commit_property_with_persistence( + &mut self, + commit: PropertyCommit, + persist: impl FnOnce(&mut PlotxApp) -> bool, + ) -> usize { let applied = commit.applied.len(); // A same-value write changes nothing, so its composite is empty and // `try_execute_action` would drop it anyway. Stopping here is about the @@ -274,11 +284,26 @@ impl PlotxApp { // of being told an update succeeded that it cannot tell apart from one // that moved a value. if applied != 0 { - self.execute_property_action(commit.action); + if let Some(action) = commit.document_action { + self.execute_property_action(action); + } + if let Some(settings) = commit.app_preferences { + self.apply_settings(settings); + persist(self); + } } applied } + #[cfg(test)] + pub(crate) fn commit_property_with_settings_writer( + &mut self, + commit: PropertyCommit, + writer: impl FnOnce(&crate::settings::Settings) -> std::io::Result<()>, + ) -> usize { + self.commit_property_with_persistence(commit, |app| app.persist_settings_with(writer)) + } + /// Open a continuous gesture on one catalog control. /// /// Between this call and [`Self::end_property_gesture`], commits are applied @@ -411,11 +436,8 @@ impl PlotxApp { Err(error) => return Err(error), } } - Ok(PropertyCommit { - action: transaction.into_action(), - applied, - skipped, - }) + transaction.ensure_single_storage()?; + Ok(transaction.into_commit(applied, skipped)) } fn definition_for( diff --git a/crates/core/src/properties/target.rs b/crates/core/src/properties/target.rs index 2cceaab..ce40baa 100644 --- a/crates/core/src/properties/target.rs +++ b/crates/core/src/properties/target.rs @@ -24,6 +24,38 @@ pub(crate) fn document_target() -> TargetRef { }) } +pub(crate) fn app_target() -> TargetRef { + TargetRef::resource(ResourceRef { + id: crate::automation::APP_RESOURCE_ID.to_owned(), + kind: crate::automation::ResourceKindId::new(crate::automation::KIND_APP), + parent_id: None, + local_id: None, + }) +} + +pub(crate) fn require_app_target( + target: &TargetRef, + definition: &'static PropertyDefinition, +) -> Result<(), PropertyError> { + let actual = ComponentKind::of(target.component.as_ref()); + if actual != ComponentKind::None { + return Err(PropertyError::ComponentKind { + property: definition.id, + expected: ComponentKind::None.as_str(), + actual: actual.as_str(), + }); + } + if target.resource.kind.0 != crate::automation::KIND_APP + || target.resource.id != crate::automation::APP_RESOURCE_ID + { + return Err(PropertyError::NotApplicable(format!( + "{} belongs to app preferences, not {}", + definition.canonical_label, target.resource.id + ))); + } + Ok(()) +} + pub(crate) fn require_document_target( target: &TargetRef, definition: &'static PropertyDefinition, diff --git a/crates/core/src/properties/tests.rs b/crates/core/src/properties/tests.rs index 899bbcb..39f543e 100644 --- a/crates/core/src/properties/tests.rs +++ b/crates/core/src/properties/tests.rs @@ -315,7 +315,7 @@ fn an_edit_compiles_into_a_typed_binding_action() { .expect("count is writable"); assert_eq!(commit.applied.len(), 1); assert!(commit.skipped.is_empty()); - let Action::Composite(actions) = &commit.action else { + let Some(Action::Composite(actions)) = &commit.document_action else { panic!("a commit is always one atomic composite"); }; assert_eq!(actions.len(), 1); @@ -360,7 +360,7 @@ fn several_series_of_one_object_fold_into_one_action() { ) .expect("count is writable"); assert_eq!(commit.applied.len(), 2); - let Action::Composite(actions) = &commit.action else { + let Some(Action::Composite(actions)) = &commit.document_action else { panic!("expected a composite"); }; assert_eq!( diff --git a/crates/core/src/properties/transaction.rs b/crates/core/src/properties/transaction.rs index 4710e4b..d58c24d 100644 --- a/crates/core/src/properties/transaction.rs +++ b/crates/core/src/properties/transaction.rs @@ -1,4 +1,4 @@ -//! The writable document snapshots a property provider selects. +//! The typed working copies a property provider selects. //! //! Providers decide which typed storage they need; the service merely executes //! the completed action. That keeps a document property, a binding property, @@ -6,15 +6,33 @@ //! planner. use crate::actions::{Action, DatasetProcessingState}; +use crate::settings::Settings; use crate::state::{DataBinding, DatasetId, ObjectId, PlotxApp}; use plotx_figure::FigureTypography; +/// Persistence boundaries a provider can select. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum StorageClass { + Document, + AppPreferences, +} + +impl StorageClass { + const fn label(self) -> &'static str { + match self { + Self::Document => "document", + Self::AppPreferences => "app preferences", + } + } +} + /// Working copies of all typed stores a catalog edit touches. #[derive(Default)] pub(crate) struct PropertyTransaction { bindings: BindingPlan, typography: Option<(FigureTypography, FigureTypography)>, processing: Vec<(DatasetId, DatasetProcessingState, DatasetProcessingState)>, + settings: Option<(Settings, Settings)>, /// The working-copy state a single provider edit started from. It is /// deliberately per target, rather than one transaction-wide dirty bit: /// two series can share a binding, and a later no-op edit must not inherit @@ -33,6 +51,10 @@ enum TargetSnapshot { dataset: DatasetId, before: DatasetProcessingState, }, + Settings { + before: Settings, + newly_selected: bool, + }, } impl PropertyTransaction { @@ -65,6 +87,10 @@ impl PropertyTransaction { .iter() .find(|(candidate, _, _)| candidate == dataset) .is_some_and(|(_, _, after)| after != before), + TargetSnapshot::Settings { before, .. } => self + .settings + .as_ref() + .is_some_and(|(_, after)| after != before), }) } @@ -103,6 +129,16 @@ impl PropertyTransaction { *after = before.clone(); } } + TargetSnapshot::Settings { + before, + newly_selected, + } => { + if *newly_selected { + self.settings = None; + } else if let Some((_, after)) = self.settings.as_mut() { + *after = before.clone(); + } + } } } } @@ -183,7 +219,63 @@ impl PropertyTransaction { Ok(&mut self.processing[index].2) } - pub(crate) fn into_action(self) -> Action { + /// Select the live application preferences for mutation. The provider + /// chooses this storage exactly as another provider chooses typography or + /// processing state; the planner does not infer it from scope. + pub(crate) fn app_preferences(&mut self, app: &PlotxApp) -> &mut Settings { + let newly_selected = self.settings.is_none(); + let settings = &mut self + .settings + .get_or_insert_with(|| (app.settings.clone(), app.settings.clone())) + .1; + if !self + .target_before + .iter() + .any(|snapshot| matches!(snapshot, TargetSnapshot::Settings { .. })) + { + self.target_before.push(TargetSnapshot::Settings { + before: settings.clone(), + newly_selected, + }); + } + settings + } + + /// The storage boundaries selected by providers in this transaction. + pub(crate) fn storage_classes(&self) -> Vec { + let mut classes = Vec::with_capacity(2); + if !self.bindings.entries.is_empty() + || self.typography.is_some() + || !self.processing.is_empty() + { + classes.push(StorageClass::Document); + } + if self.settings.is_some() { + classes.push(StorageClass::AppPreferences); + } + classes + } + + /// Refuse a cross-storage request before either half becomes executable. + pub(crate) fn ensure_single_storage(&self) -> Result<(), super::PropertyError> { + let classes = self.storage_classes(); + if classes.len() <= 1 { + return Ok(()); + } + Err(super::PropertyError::MixedStorage { + storages: classes + .iter() + .map(|class| class.label()) + .collect::>() + .join(" and "), + }) + } + + pub(crate) fn into_commit( + self, + applied: Vec, + skipped: Vec, + ) -> super::PropertyCommit { let mut actions = self.bindings.into_actions(); if let Some((before, after)) = self.typography && before != after @@ -198,7 +290,16 @@ impl PropertyTransaction { Action::update_dataset_processing(dataset, before, after) }), ); - Action::Composite(actions) + let document_action = (!actions.is_empty()).then_some(Action::Composite(actions)); + let app_preferences = self + .settings + .and_then(|(before, after)| (before != after).then_some(after)); + super::PropertyCommit { + document_action, + app_preferences, + applied, + skipped, + } } } @@ -245,3 +346,42 @@ impl BindingPlan { .collect() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mixed_storage_selection_is_refused_before_commit_creation() { + let app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + let mut transaction = PropertyTransaction::default(); + transaction.begin_target(); + transaction.figure_typography(&app); + transaction.app_preferences(&app); + + assert_eq!( + transaction.storage_classes(), + [StorageClass::Document, StorageClass::AppPreferences] + ); + let error = transaction + .ensure_single_storage() + .expect_err("a commit cannot span two persistence boundaries"); + let message = error.to_string(); + assert!(message.contains("document"), "{message}"); + assert!(message.contains("app preferences"), "{message}"); + } + + #[test] + fn rolled_back_settings_target_leaves_no_storage_selected() { + let app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + let mut transaction = PropertyTransaction::default(); + transaction.begin_target(); + transaction.app_preferences(&app).export.dpi = 450; + assert!(transaction.target_changed()); + + transaction.rollback_target(); + + assert!(!transaction.target_changed()); + assert!(transaction.storage_classes().is_empty()); + } +} diff --git a/crates/core/src/settings/io.rs b/crates/core/src/settings/io.rs index 14ad337..2ce6769 100644 --- a/crates/core/src/settings/io.rs +++ b/crates/core/src/settings/io.rs @@ -2,6 +2,9 @@ use super::{SETTINGS_SCHEMA_VERSION, Settings, migrate, paths}; use std::io; use std::path::{Path, PathBuf}; +/// Read the stored preferences. This is the boundary read taken before a +/// [`crate::state::PlotxApp`] exists; once one does, its `settings` field is +/// the live value and this function must not be used to re-read it. pub fn load() -> Settings { let Some(path) = paths::settings_file() else { return Settings::default(); @@ -71,6 +74,10 @@ fn load_from_bytes(data: &[u8], quarantine_path: Option<&Path>) -> Settings { .general .project_backup_generations .min(super::MAX_PROJECT_BACKUP_GENERATIONS); + // The bound belongs here as well as on `note`: the loaded value is now the + // live list the Preferences panel shows, so a hand-edited file would + // otherwise display more entries than the cap promises. + settings.recent.files.truncate(super::MAX_RECENT_FILES); // `app_version` deliberately keeps the value the file was written with: // it is how the shell detects "first launch after an update". Saving // stamps the current version (see `save_to_path`). diff --git a/crates/core/src/settings/mod.rs b/crates/core/src/settings/mod.rs index e281ad3..c6426ca 100644 --- a/crates/core/src/settings/mod.rs +++ b/crates/core/src/settings/mod.rs @@ -3,23 +3,17 @@ mod migrate; mod model; mod paths; -pub use io::{load, save}; +pub use io::load; +// Writing is deliberately crate-private: `PlotxApp::persist_settings` is the +// only flush path, so no caller can leave the live preferences and the file +// disagreeing. +pub(crate) use io::save; +#[cfg(test)] +pub(crate) use io::{load_from_paths, save_to_path}; pub use model::*; pub use paths::{config_dir, data_local_dir}; pub const SETTINGS_SCHEMA_VERSION: u32 = 1; -pub fn try_update(f: impl FnOnce(&mut Settings)) -> std::io::Result<()> { - let mut settings = load(); - f(&mut settings); - save(&settings) -} - -pub fn update(f: impl FnOnce(&mut Settings)) { - if let Err(error) = try_update(f) { - eprintln!("Could not save PlotX settings: {error}"); - } -} - #[cfg(test)] mod tests; diff --git a/crates/core/src/settings/model.rs b/crates/core/src/settings/model.rs index 94de884..73c9979 100644 --- a/crates/core/src/settings/model.rs +++ b/crates/core/src/settings/model.rs @@ -63,6 +63,8 @@ pub struct GeneralSettings { } pub const MAX_PROJECT_BACKUP_GENERATIONS: u8 = 5; +pub const MIN_EXPORT_DPI: u16 = 72; +pub const MAX_EXPORT_DPI: u16 = 1200; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] pub struct AppearanceSettings { diff --git a/crates/core/src/settings/tests.rs b/crates/core/src/settings/tests.rs index 514a697..5ae3417 100644 --- a/crates/core/src/settings/tests.rs +++ b/crates/core/src/settings/tests.rs @@ -89,6 +89,27 @@ fn backup_generation_count_is_bounded_on_load() { ); } +#[test] +fn recent_files_are_bounded_on_load() { + let path = temp_settings("recent-bound"); + let _ = std::fs::remove_file(&path); + let files: Vec = (0..(MAX_RECENT_FILES + 4)) + .map(|index| format!("C:/data/run-{index}.abf")) + .collect(); + std::fs::write( + &path, + serde_json::json!({ "recent": { "files": files } }).to_string(), + ) + .unwrap(); + + let settings = io::load_from_paths(&path, None); + let _ = std::fs::remove_file(&path); + + // The loaded list is what the Preferences panel shows, so the documented + // cap has to hold here and not only on `note`. + assert_eq!(settings.recent.files.len(), MAX_RECENT_FILES); +} + #[test] fn save_and_load_roundtrip() { let path = temp_settings("roundtrip"); diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index 40f2561..f3e758f 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -28,7 +28,6 @@ impl PlotxApp { pub fn new_with_settings(settings: crate::settings::Settings) -> Self { Self { - keep_empty_source_canvas: settings.general.keep_empty_source_canvas, doc: SharedDocument::new(Document { datasets: Vec::new(), canvases: Vec::new(), @@ -84,6 +83,7 @@ impl PlotxApp { present_fullscreen_on: false, monitor: None, }, + settings, } } diff --git a/crates/core/src/state/app_impl_io.rs b/crates/core/src/state/app_impl_io.rs index 57d0f17..fdac669 100644 --- a/crates/core/src/state/app_impl_io.rs +++ b/crates/core/src/state/app_impl_io.rs @@ -56,37 +56,38 @@ impl PlotxApp { /// watermark that refers to it — or every pre-load report would resurface /// in the banner after each project open. pub(crate) fn install_loaded_project(&mut self, mut loaded: PlotxApp) { + let settings = self.settings.clone(); + let save_include_view_snapshots = loaded.doc.save_include_view_snapshots; loaded.session.operation_history = std::mem::take(&mut self.session.operation_history); loaded.session.ui.dismissed_feedback_order = self.session.ui.dismissed_feedback_order; - // Like the history: the session list is the runtime truth and - // must survive the swap even when a settings save failed. - loaded.session.recent_files = std::mem::take(&mut self.session.recent_files); *self = loaded; + // Project loading constructs a fresh app, but app preferences outlive + // document swaps. In particular, a value whose disk flush failed must + // not revert merely because a project was opened. + self.apply_settings(settings); + // A project records the save profile that produced it. Preserve that + // existing load behavior; the next explicit Preferences or save choice + // can replace it and update the live default. + self.doc.save_include_view_snapshots = save_include_view_snapshots; } pub fn request_save_project(&mut self) { self.session.ui.save_project_options = true; } - /// Open the Preferences panel, seeding its draft from the on-disk settings. + /// Open the Preferences panel, seeding its draft from the live settings. /// A no-op when it is already open, so re-triggering focuses the live window. pub fn open_settings(&mut self) { if self.session.ui.settings_dialog.is_none() { - let mut settings = crate::settings::load(); - // The session list is the runtime truth for recents; seeding from - // disk would let a draft flush resurrect a stale copy whenever a - // background settings save had failed. - settings.recent.files = self.session.recent_files.clone(); - self.session.ui.settings_dialog = Some(SettingsDialog::new(settings)); + self.session.ui.settings_dialog = Some(SettingsDialog::new(self.settings.clone())); } } /// Reconcile the egui-free live state to a settings snapshot. Idempotent, so /// the instant-apply path may call it on every edit. The chrome theme is an /// egui concern and is applied separately by the app shell. - pub fn apply_settings(&mut self, settings: &crate::settings::Settings) { + pub fn apply_settings(&mut self, settings: crate::settings::Settings) { self.session.ui.snap_enabled = settings.general.snap_enabled; - self.keep_empty_source_canvas = settings.general.keep_empty_source_canvas; self.session.canvas_accent = settings.appearance.canvas_accent; if !settings.general.snap_enabled { self.session.ui.snap_guides.clear(); @@ -109,6 +110,31 @@ impl PlotxApp { monitor.auto = scale.auto; monitor.user = scale.user; } + self.settings = settings; + } + + /// Flush the authoritative live settings and keep a Preferences draft in + /// lockstep so its next debounce cannot overwrite an edit made elsewhere. + pub fn persist_settings(&mut self) -> bool { + self.persist_settings_with(crate::settings::save) + } + + pub(crate) fn persist_settings_with( + &mut self, + writer: impl FnOnce(&crate::settings::Settings) -> std::io::Result<()>, + ) -> bool { + if let Some(dialog) = self.session.ui.settings_dialog.as_mut() { + dialog.draft = self.settings.clone(); + } + match writer(&self.settings) { + Ok(()) => true, + Err(error) => { + self.session.status = format!( + "Couldn't save preferences — changes apply this session only ({error})" + ); + false + } + } } /// Record a successfully opened or saved path at the front of the recent @@ -127,17 +153,15 @@ impl PlotxApp { pub fn clear_recent_files(&mut self) { self.session.recent_files.clear(); - self.sync_recent_files_to_settings(Vec::new()); self.session.status = "Cleared the recent files list.".to_owned(); + self.sync_recent_files_to_settings(Vec::new()); } /// Persist the list and mirror it into an open Preferences draft, so a /// later draft flush cannot resurrect entries with a stale copy. fn sync_recent_files_to_settings(&mut self, files: Vec) { - if let Some(dialog) = self.session.ui.settings_dialog.as_mut() { - dialog.draft.recent.files = files.clone(); - } - crate::settings::update(move |settings| settings.recent.files = files); + self.settings.recent.files = files; + self.persist_settings(); } /// Save the project and report whether persistence completed. The return @@ -153,12 +177,10 @@ impl PlotxApp { Ok(outcome) => { self.doc.project_path = Some(path.to_owned()); self.doc.save_include_view_snapshots = include_view_snapshots; - crate::settings::update(|settings| { - settings.export.include_view_snapshots = include_view_snapshots; - settings.general.snap_enabled = self.session.ui.snap_enabled; - settings.general.project_backup_generations = - self.session.project_backup_generations; - }); + self.settings.export.include_view_snapshots = include_view_snapshots; + self.settings.general.snap_enabled = self.session.ui.snap_enabled; + self.settings.general.project_backup_generations = + self.session.project_backup_generations; self.doc.dirty = false; self.doc.project_revision = Some(outcome.revision.clone()); let mut report = OperationReport::success( @@ -188,6 +210,13 @@ impl PlotxApp { ); } self.session.record_operation(report); + // Flush the three preferences harvested above on their own, + // rather than relying on the recent-file call below to write + // the whole struct as a side effect: that dependency is + // invisible, and reordering either line would silently stop + // persisting them. It follows the success report so a failed + // write keeps its diagnostic instead of being overwritten. + self.persist_settings(); self.note_recent_file(path); true } @@ -381,8 +410,7 @@ impl PlotxApp { self.record_export_unavailable(format); return; } - let defaults = crate::settings::load().export; - let mut state = ExportDialogState::from_defaults(format, &defaults); + let mut state = ExportDialogState::from_defaults(format, &self.settings.export); let canvas = &self.doc.canvases[ci]; if let Some(preset) = crate::export::ExportPreset::matching_canvas( format, diff --git a/crates/core/src/state/app_state.rs b/crates/core/src/state/app_state.rs index dbeb743..9297a8b 100644 --- a/crates/core/src/state/app_state.rs +++ b/crates/core/src/state/app_state.rs @@ -3,9 +3,10 @@ use super::{Session, SharedDocument}; pub struct PlotxApp { pub doc: SharedDocument, pub session: Session, - /// Live, already-applied tiling preference. This deliberately does not read - /// the Preferences draft or disk during pointer movement. - pub keep_empty_source_canvas: bool, + /// Live, already-applied preferences. The file is only a flush target, and + /// the Preferences dialog owns an in-progress draft rather than another + /// source of truth. + pub settings: crate::settings::Settings, } /// Live UI-scale state of the monitor under the window: the settings key it is diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index 123c92a..b1074c0 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -111,7 +111,7 @@ impl SettingsCategory { SettingsCategory::Recent, ]; - pub fn label(self) -> &'static str { + pub const fn label(self) -> &'static str { match self { SettingsCategory::General => "General", SettingsCategory::Appearance => "Appearance", @@ -120,6 +120,16 @@ impl SettingsCategory { SettingsCategory::Recent => "Recent", } } + + pub const fn section_id(self) -> &'static str { + match self { + SettingsCategory::General => "preferences.general", + SettingsCategory::Appearance => "preferences.appearance", + SettingsCategory::Processing => "preferences.processing", + SettingsCategory::Export => "preferences.export", + SettingsCategory::Recent => "preferences.recent", + } + } } /// The Preferences window's working state: the draft [`Settings`] every widget diff --git a/docs/src/content/docs/reference/command-palette.md b/docs/src/content/docs/reference/command-palette.md index fd3f1fa..d367e09 100644 --- a/docs/src/content/docs/reference/command-palette.md +++ b/docs/src/content/docs/reference/command-palette.md @@ -37,6 +37,9 @@ Activating a setting does not change anything. It opens the panel the setting lives in, expands its section, scrolls to the row and highlights it briefly, so you can see the current value before editing it. +A setting that lives in Preferences instead opens the Preferences window at the +page that holds it; no individual row is highlighted there. + A setting that belongs to one processing step also opens the Processing panel and expands the first step that actually carries it — a step whose window has no **GB**, for instance, is passed over rather than opened onto a row that is not @@ -89,6 +92,11 @@ Settings: `apodization`, `window function`, `exponential`, `gaussian`, `LB`, `line broadening`, `GB` and `gaussian broadening` reach them. See [Processing](/guides/processing/). +- **Raster resolution**, the pixel density bitmap exports start from, on the + **Export** page of Preferences. `export DPI`, `bitmap DPI`, `bitmap + resolution` and `raster resolution` reach it. It stays available whatever is + selected, changing it cannot be undone, and it applies to every project. See + [Preferences](/reference/preferences/). Data: @@ -97,6 +105,6 @@ Data: Parameterized operations that need a target picked on the canvas — such as a specific integral or phase adjustment — are not in the palette; switch to the -corresponding tool instead. One-off inputs to an operation, such as the export -resolution of a single export, are part of that operation's dialog rather than -searchable settings. +corresponding tool instead. One-off inputs to an operation stay in that +operation's dialog: the DPI you type into a single export is not searchable, +while **Raster resolution**, the default it starts from, is. diff --git a/docs/src/content/docs/reference/preferences.md b/docs/src/content/docs/reference/preferences.md index 858e26e..c0bc06d 100644 --- a/docs/src/content/docs/reference/preferences.md +++ b/docs/src/content/docs/reference/preferences.md @@ -45,7 +45,9 @@ restores everything except your recent-files list. - **Embed view snapshots** — save each plot's on-screen view into the `.plotx` file. - **Raster resolution** — the default pixel density (72–1200 dpi) for bitmap - exports. + exports. A DPI typed into one export dialog applies to that export alone and + leaves this default unchanged; searching `export DPI` in the + [command palette](/reference/command-palette/) opens this page. ## Recent diff --git a/docs/src/content/docs/zh-cn/reference/command-palette.md b/docs/src/content/docs/zh-cn/reference/command-palette.md index 5088db5..155fb2c 100644 --- a/docs/src/content/docs/zh-cn/reference/command-palette.md +++ b/docs/src/content/docs/zh-cn/reference/command-palette.md @@ -31,6 +31,8 @@ description: 用键盘搜索命令、设置与数据。 激活一个设置不会改变任何内容。它会打开该设置所在的面板、展开其分节、滚动到该行 并短暂高亮,让你先看清当前值再编辑。 +位于首选项中的设置则会打开首选项窗口并切到承载它的那一页,不会高亮其中某一行。 + 若某个设置属于某一个处理步骤,激活它还会打开处理面板,并展开第一个真正带有该 设置的步骤——例如窗函数没有 **GB** 的步骤会被跳过,而不会展开到一行并不存在 的控件上。 @@ -79,11 +81,15 @@ description: 用键盘搜索命令、设置与数据。 `window function`、`exponential`、`gaussian`、`LB`、`line broadening`、 `GB` 与 `gaussian broadening` 都能命中它们。参见 [数据处理](/zh-cn/guides/processing/)。 +- 首选项 **Export** 页中的 **Raster resolution**,即位图导出的起始像素密度。 + `export DPI`、`bitmap DPI`、`bitmap resolution` 与 `raster resolution` 都能 + 命中它。无论当前选中什么,它都可用;修改它无法撤销,且对所有项目生效。参见 + [首选项](/zh-cn/reference/preferences/)。 数据: - 项目中的每个数据集、页面,以及页面上的每个对象。激活即打开并选中它。 需要在画布上点选目标的参数化操作——如某个具体的积分或相位调整——不在面板 -中;请改为切换到对应工具完成。某次操作的一次性输入(例如单次导出的分辨率)属于 -该操作的对话框,而不是可搜索的设置。 +中;请改为切换到对应工具完成。某次操作的一次性输入仍留在该操作的对话框里:单次 +导出时填写的 DPI 不可搜索,而它的起始默认值 **Raster resolution** 可以。 diff --git a/docs/src/content/docs/zh-cn/reference/preferences.md b/docs/src/content/docs/zh-cn/reference/preferences.md index dcedb97..0c8f669 100644 --- a/docs/src/content/docs/zh-cn/reference/preferences.md +++ b/docs/src/content/docs/zh-cn/reference/preferences.md @@ -36,7 +36,9 @@ description: 偏好设置窗口中的每一项设置,按类别列出。 ## Export(导出) - **Embed view snapshots**——把每个图的屏幕视图保存进 `.plotx` 文件。 -- **Raster resolution**——位图导出的默认像素密度(72–1200 dpi)。 +- **Raster resolution**——位图导出的默认像素密度(72–1200 dpi)。在某次导出 + 对话框里填写的 DPI 只作用于那一次导出,不会改动这里的默认值;在 + [命令面板](/zh-cn/reference/command-palette/)中搜索 `export DPI` 即可打开本页。 ## Recent(最近)