diff --git a/crates/app/src/ui/canvas/geometry.rs b/crates/app/src/ui/canvas/geometry.rs index 173a77a4..6af1bd04 100644 --- a/crates/app/src/ui/canvas/geometry.rs +++ b/crates/app/src/ui/canvas/geometry.rs @@ -221,10 +221,13 @@ pub(crate) fn plot_under_cursor( let Some(plot_object) = canvas.object(id).and_then(|object| object.plot()) else { continue; }; - let layout = - plotx_render::axis_layout(&plot_object.figure, outer.width / zoom, outer.height / zoom); + let layout = plotx_render::axis_layout( + plot_object.figure(), + outer.width / zoom, + outer.height / zoom, + ); let plot = - plotx_render::Projector::new(&plot_object.figure, outer, &layout.margins.scaled(zoom)) + plotx_render::Projector::new(plot_object.figure(), outer, &layout.margins.scaled(zoom)) .plot; return Some((id, outer_rect, plot)); } @@ -241,10 +244,14 @@ pub(crate) fn plot_inner_rect( let outer = object_screen_rect(app.session.board, canvas, object_id, screen)?; let plot_object = canvas.object(object_id).and_then(|object| object.plot())?; let zoom = app.session.board.zoom; - let layout = - plotx_render::axis_layout(&plot_object.figure, outer.width / zoom, outer.height / zoom); + let layout = plotx_render::axis_layout( + plot_object.figure(), + outer.width / zoom, + outer.height / zoom, + ); Some( - plotx_render::Projector::new(&plot_object.figure, outer, &layout.margins.scaled(zoom)).plot, + plotx_render::Projector::new(plot_object.figure(), outer, &layout.margins.scaled(zoom)) + .plot, ) } diff --git a/crates/app/src/ui/canvas/integrals.rs b/crates/app/src/ui/canvas/integrals.rs index 11098fd1..3413cf23 100644 --- a/crates/app/src/ui/canvas/integrals.rs +++ b/crates/app/src/ui/canvas/integrals.rs @@ -85,7 +85,7 @@ pub(crate) fn handle_integral_drag( .object(object_id) .and_then(|object| object.plot()) .unwrap() - .figure; + .figure(); (fig.x.min, fig.x.span(), fig.x.reversed) }; diff --git a/crates/app/src/ui/canvas/integrals2d.rs b/crates/app/src/ui/canvas/integrals2d.rs index 32424fe1..1c06989d 100644 --- a/crates/app/src/ui/canvas/integrals2d.rs +++ b/crates/app/src/ui/canvas/integrals2d.rs @@ -149,7 +149,7 @@ pub(crate) fn handle_integral_2d_drag( .object(object_id) .and_then(|o| o.plot()) .unwrap() - .figure; + .figure(); ( AxisMap { min: figure.x.min, @@ -473,7 +473,7 @@ pub(crate) fn paint_integrals_2d( let Some(figure) = app.doc.canvases[ci] .object(object_id) .and_then(|o| o.plot()) - .map(|p| &p.figure) + .map(|p| p.figure()) else { return; }; diff --git a/crates/app/src/ui/canvas/interactions.rs b/crates/app/src/ui/canvas/interactions.rs index 65ddb945..4a127d86 100644 --- a/crates/app/src/ui/canvas/interactions.rs +++ b/crates/app/src/ui/canvas/interactions.rs @@ -97,7 +97,7 @@ pub(crate) fn finish_selection_drag( let object = app.doc.canvases[ci].object(object_id).unwrap(); let plot_object = object.plot().unwrap(); - let fig = &plot_object.figure; + let fig = plot_object.figure(); let x = AxisRange::new( screen_to_x(a.x, plot, fig.x.min, fig.x.span(), fig.x.reversed), screen_to_x(b.x, plot, fig.x.min, fig.x.span(), fig.x.reversed), @@ -184,7 +184,7 @@ pub(crate) fn finish_zoom_drag( let object = app.doc.canvases[ci].object(object_id).unwrap(); let plot_object = object.plot().unwrap(); - let fig = &plot_object.figure; + let fig = plot_object.figure(); let before = plot_object.viewport.clone(); let x = if width >= SELECT_MIN_PX { Some(AxisRange::new( @@ -557,7 +557,7 @@ pub(crate) fn arrange_context_menu(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { if ui.checkbox(&mut show_grid, "Show layout grid").clicked() { app.set_show_grid(ci, show_grid); } - let mut snap = app.session.ui.snap_enabled; + let mut snap = app.settings.general.snap_enabled; if ui.checkbox(&mut snap, "Snap to grid & objects").clicked() { app.set_snap_enabled(snap); } diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index 1512ad36..92873c3b 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -116,7 +116,7 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { let avail = ui.available_rect_before_wrap(); let (resp, painter) = ui.allocate_painter(avail.size(), Sense::click_and_drag()); let rect = resp.rect; - let chrome = ChromeStyle::from_visuals(ui.visuals(), app.session.canvas_accent); + let chrome = ChromeStyle::from_visuals(ui.visuals(), app.settings.appearance.canvas_accent); ensure_board_view(app, rect); drive_board_fit(app, ui, rect); @@ -265,7 +265,7 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { .object(object_id) .and_then(|object| object.plot()) .unwrap() - .figure; + .figure(); let zoom = app.session.board.zoom; let layout = plotx_render::axis_layout(fig, outer.width / zoom, outer.height / zoom); let proj = plotx_render::Projector::new(fig, outer, &layout.margins.scaled(zoom)); @@ -285,7 +285,7 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { .object(object_id) .and_then(|object| object.plot()) .unwrap() - .figure; + .figure(); match axis.orient() { PhaseOrient::Vertical => { let (mn, sp, rv) = (fig.x.min, fig.x.span(), fig.x.reversed); @@ -593,20 +593,21 @@ mod tests { locked: false, visible: true, group: None, - kind: CanvasObjectKind::Plot(Box::new(PlotObject { - next_series_id: plotx_core::state::SeriesId::new(1), - binding: plotx_core::state::DataBinding { series: Vec::new() }, - chart: plotx_core::state::ChartSpec::default(), - stack: plotx_core::state::StackSpec::default(), - projections: plotx_core::state::AxisProjections::default(), - axis_overrides: plotx_core::state::AxisOverrides::default(), - figure: Figure::new("plot", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)), - viewport: CanvasViewport::from_figure(&Figure::new( - "plot", - Axis::new("x", 0.0, 1.0), - Axis::new("y", 0.0, 1.0), - )), - panel: PanelMeta::new("title".to_owned(), 50.0), + kind: CanvasObjectKind::Plot(Box::new({ + let figure = + Figure::new("plot", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)); + let viewport = CanvasViewport::from_figure(&figure); + PlotObject::new( + plotx_core::state::SeriesId::new(1), + plotx_core::state::DataBinding { series: Vec::new() }, + plotx_core::state::ChartSpec::default(), + plotx_core::state::StackSpec::default(), + plotx_core::state::AxisProjections::default(), + plotx_core::state::AxisOverrides::default(), + figure, + viewport, + PanelMeta::new("title".to_owned(), 50.0), + ) })), }); @@ -626,20 +627,21 @@ mod tests { locked: false, visible: true, group: None, - kind: CanvasObjectKind::Plot(Box::new(PlotObject { - next_series_id: plotx_core::state::SeriesId::new(1), - binding: plotx_core::state::DataBinding { series: Vec::new() }, - chart: plotx_core::state::ChartSpec::default(), - stack: plotx_core::state::StackSpec::default(), - projections: plotx_core::state::AxisProjections::default(), - axis_overrides: plotx_core::state::AxisOverrides::default(), - figure: Figure::new("plot", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)), - viewport: CanvasViewport::from_figure(&Figure::new( - "plot", - Axis::new("x", 0.0, 1.0), - Axis::new("y", 0.0, 1.0), - )), - panel: PanelMeta::new("title".to_owned(), 50.0), + kind: CanvasObjectKind::Plot(Box::new({ + let figure = + Figure::new("plot", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)); + let viewport = CanvasViewport::from_figure(&figure); + PlotObject::new( + plotx_core::state::SeriesId::new(1), + plotx_core::state::DataBinding { series: Vec::new() }, + plotx_core::state::ChartSpec::default(), + plotx_core::state::StackSpec::default(), + plotx_core::state::AxisProjections::default(), + plotx_core::state::AxisOverrides::default(), + figure, + viewport, + PanelMeta::new("title".to_owned(), 50.0), + ) })), }); app.doc.canvases.push(canvas); diff --git a/crates/app/src/ui/canvas/navigation.rs b/crates/app/src/ui/canvas/navigation.rs index 4a0a55a6..84ea79c2 100644 --- a/crates/app/src/ui/canvas/navigation.rs +++ b/crates/app/src/ui/canvas/navigation.rs @@ -224,7 +224,7 @@ pub(crate) fn apply_plot_pan( else { return; }; - let fig = &plot_object.figure; + let fig = plot_object.figure(); let x_sign = if fig.x.reversed { 1.0 } else { -1.0 }; let y_sign = if fig.y.reversed { -1.0 } else { 1.0 }; let dx = x_sign * f64::from(delta.x) / f64::from(plot.width.max(1.0)) * fig.x.span(); @@ -240,8 +240,7 @@ pub(crate) fn apply_plot_pan( ) .clamp_to(plot_object.viewport.full_y); plot_object.viewport.auto_y = false; - let viewport = plot_object.viewport.clone(); - viewport.apply_to(&mut plot_object.figure); + plot_object.apply_viewport(); app.doc.dirty = true; } @@ -275,7 +274,7 @@ pub(crate) fn finish_axis_zoom( else { return; }; - let fig = &plot_object.figure; + let fig = plot_object.figure(); let before = plot_object.viewport.clone(); let (x, y) = match drag.axis { ZoomAxis::X => { @@ -323,8 +322,8 @@ pub(crate) fn reset_plot_viewport( let before = plot_object.viewport.clone(); let mut after = before.clone(); match hit_zone(p, outer_rect, plot) { - HitZone::XAxis => after.reset_x(&plot_object.figure), - HitZone::YAxis => after.reset_y(&plot_object.figure), + HitZone::XAxis => after.reset_x(plot_object.figure()), + HitZone::YAxis => after.reset_y(plot_object.figure()), HitZone::Plot => after.reset_all(), HitZone::None => return, } @@ -386,17 +385,16 @@ pub(crate) fn zoom_plot_viewport( let object = app.doc.canvases[ci].object_mut(object_id).unwrap(); let plot_object = object.plot_mut().unwrap(); - let fig = &plot_object.figure; + let fig = plot_object.figure().clone(); if zoom_x { let anchor = screen_to_x(p.x, plot, fig.x.min, fig.x.span(), fig.x.reversed); - plot_object.viewport.zoom_x(fig, anchor, scale); + plot_object.viewport.zoom_x(&fig, anchor, scale); } if zoom_y { let anchor = screen_to_y(p.y, plot, fig.y.min, fig.y.span(), fig.y.reversed); plot_object.viewport.zoom_y(anchor, scale); } - let viewport = plot_object.viewport.clone(); - viewport.apply_to(&mut plot_object.figure); + plot_object.apply_viewport(); app.doc.dirty = true; ui.ctx() .request_repaint_after(std::time::Duration::from_millis(200)); diff --git a/crates/app/src/ui/canvas/painting.rs b/crates/app/src/ui/canvas/painting.rs index f0fac9f0..30c0c895 100644 --- a/crates/app/src/ui/canvas/painting.rs +++ b/crates/app/src/ui/canvas/painting.rs @@ -93,7 +93,7 @@ pub(crate) fn paint_analysis_selection( let Some(plot_object) = object.plot() else { return; }; - let fig = &plot_object.figure; + let fig = plot_object.figure(); let x0 = x_to_screen( selection.x_range.min, plot, @@ -139,7 +139,7 @@ pub(crate) fn paint_regions( let Some(fig) = app.doc.canvases[ci] .object(object_id) .and_then(|object| object.plot()) - .map(|plot| &plot.figure) + .map(|plot| plot.figure()) else { return; }; @@ -236,7 +236,7 @@ pub(crate) fn paint_integrals( let Some(fig) = app.doc.canvases[ci] .object(object_id) .and_then(|object| object.plot()) - .map(|plot| &plot.figure) + .map(|plot| plot.figure()) else { return; }; @@ -363,7 +363,7 @@ pub(crate) fn paint_peaks( let Some(fig) = app.doc.canvases[ci] .object(object_id) .and_then(|object| object.plot()) - .map(|plot| &plot.figure) + .map(|plot| plot.figure()) else { return; }; diff --git a/crates/app/src/ui/canvas/peaks.rs b/crates/app/src/ui/canvas/peaks.rs index 19c36f8c..768659dd 100644 --- a/crates/app/src/ui/canvas/peaks.rs +++ b/crates/app/src/ui/canvas/peaks.rs @@ -81,7 +81,7 @@ pub(crate) fn handle_peaks( .object(object_id) .and_then(|object| object.plot()) .unwrap() - .figure + .figure() .clone(); let sc = Screen { plot, diff --git a/crates/app/src/ui/canvas/phase.rs b/crates/app/src/ui/canvas/phase.rs index f1d205f1..84b1110f 100644 --- a/crates/app/src/ui/canvas/phase.rs +++ b/crates/app/src/ui/canvas/phase.rs @@ -67,7 +67,7 @@ pub(crate) fn handle_phase_before_paint( let Some(figure) = app.doc.canvases[ci] .object(object_id) .and_then(|object| object.plot()) - .map(|plot| &plot.figure) + .map(|plot| plot.figure()) else { return; }; diff --git a/crates/app/src/ui/canvas/regions.rs b/crates/app/src/ui/canvas/regions.rs index 790b2a1c..a49ee994 100644 --- a/crates/app/src/ui/canvas/regions.rs +++ b/crates/app/src/ui/canvas/regions.rs @@ -78,7 +78,7 @@ pub(crate) fn handle_region_drag( .object(object_id) .and_then(|object| object.plot()) .unwrap() - .figure; + .figure(); (fig.x.min, fig.x.span(), fig.x.reversed) }; diff --git a/crates/app/src/ui/canvas/slices.rs b/crates/app/src/ui/canvas/slices.rs index 95060593..577ec3b4 100644 --- a/crates/app/src/ui/canvas/slices.rs +++ b/crates/app/src/ui/canvas/slices.rs @@ -46,7 +46,7 @@ pub(crate) fn handle_slice( let Some(fig) = app.doc.canvases[ci] .object(object_id) .and_then(|o| o.plot()) - .map(|pl| &pl.figure) + .map(|pl| pl.figure()) else { return; }; @@ -101,7 +101,7 @@ pub(crate) fn paint_slice( let Some(fig) = app.doc.canvases[ci] .object(object_id) .and_then(|o| o.plot()) - .map(|pl| &pl.figure) + .map(|pl| pl.figure()) else { return; }; diff --git a/crates/app/src/ui/canvas/snap.rs b/crates/app/src/ui/canvas/snap.rs index bcd97f10..9b307606 100644 --- a/crates/app/src/ui/canvas/snap.rs +++ b/crates/app/src/ui/canvas/snap.rs @@ -102,7 +102,7 @@ pub(crate) fn snap_object_frame( ui: &Ui, ) -> (ObjectFrame, Vec) { let alt = ui.input(|i| i.modifiers.alt); - if !app.session.ui.snap_enabled || alt { + if !app.settings.general.snap_enabled || alt { return (candidate, Vec::new()); } let canvas = &app.doc.canvases[ci]; diff --git a/crates/app/src/ui/canvas/tiling.rs b/crates/app/src/ui/canvas/tiling.rs index 17a9a706..8eff2041 100644 --- a/crates/app/src/ui/canvas/tiling.rs +++ b/crates/app/src/ui/canvas/tiling.rs @@ -142,7 +142,7 @@ fn layout_item(canvas: &CanvasDocument, id: ObjectId) -> Option PageSizeState { - let preset_id = before - .preset_id - .clone() - .filter(|id| plotx_core::state::preset_by_id(id).is_some_and(|p| p.matches(after_size))); - PageSizeState { - size_mm: after_size, - preset_id, +fn set_scale_content_default(app: &mut PlotxApp, scale_content: bool) { + let target = app.app_target(); + match app.plan_property_write( + plotx_core::properties::app_preferences::SCALE_CONTENT, + std::slice::from_ref(&target), + &plotx_core::properties::PropertyValue::Bool(scale_content), + ) { + Ok(commit) => { + app.commit_property(commit); + } + Err(error) => { + app.session.status = format!("Could not save the scale-content preference: {error}"); + } } } diff --git a/crates/app/src/ui/command_exec.rs b/crates/app/src/ui/command_exec.rs index 97ccb4a9..8b3e6924 100644 --- a/crates/app/src/ui/command_exec.rs +++ b/crates/app/src/ui/command_exec.rs @@ -77,7 +77,7 @@ pub fn execute( app.set_show_grid(canvas, !app.doc.canvases[canvas].layout.show_grid); } } - CommandId::ToggleSnap => app.set_snap_enabled(!app.session.ui.snap_enabled), + CommandId::ToggleSnap => app.set_snap_enabled(!app.settings.general.snap_enabled), CommandId::Preferences => app.open_settings(), CommandId::CommandPalette => { app.session.ui.command_palette = match app.session.ui.command_palette.take() { @@ -266,3 +266,63 @@ fn reveal_group(app: &mut PlotxApp, group: ToolGroup) { app.session.secondary_sidebar_visible = true; app.session.ui.requested_tool_group = Some(group); } + +#[cfg(test)] +mod tests { + use super::*; + use plotx_core::properties::{AggregateValue, PropertyAddress, PropertyValue, app_preferences}; + use plotx_core::settings::Settings; + + fn catalog_snap(app: &mut PlotxApp, enabled: bool) { + let commit = app + .plan_property_write( + app_preferences::SNAP_ENABLED, + std::slice::from_ref(&app.app_target()), + &PropertyValue::Bool(enabled), + ) + .expect("the Preferences catalog row plans"); + app.commit_property(commit); + } + + fn resolved_snap(app: &PlotxApp) -> AggregateValue { + app.resolve_property(&PropertyAddress::new( + app.app_target(), + app_preferences::SNAP_ENABLED, + )) + .expect("snap resolves through the catalog") + .value + } + + #[test] + fn settings_toolbar_and_toggle_command_share_the_snap_catalog_value() { + let mut app = PlotxApp::new_with_settings(Settings::default()); + + // Preferences rows submit this same catalog write. + catalog_snap(&mut app, false); + assert_eq!( + resolved_snap(&app), + AggregateValue::Uniform(PropertyValue::Bool(false)) + ); + + // The canvas toolbar keeps its existing setter surface, whose + // implementation now plans and commits the catalog property. + app.set_snap_enabled(true); + assert_eq!( + resolved_snap(&app), + AggregateValue::Uniform(PropertyValue::Bool(true)) + ); + + let mut clipboard = ClipboardTablePaste::default(); + execute( + CommandId::ToggleSnap, + &mut app, + &mut clipboard, + &egui::Context::default(), + ); + assert_eq!( + resolved_snap(&app), + AggregateValue::Uniform(PropertyValue::Bool(false)) + ); + assert!(!app.settings.general.snap_enabled); + } +} diff --git a/crates/app/src/ui/command_palette.rs b/crates/app/src/ui/command_palette.rs index 0ed0ef5f..96076c0f 100644 --- a/crates/app/src/ui/command_palette.rs +++ b/crates/app/src/ui/command_palette.rs @@ -323,16 +323,20 @@ 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::CanvasSettings => { + app.session.ui.canvas_settings = app.session.active_canvas; + } 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) + && let Some(category) = SettingsCategory::ALL.into_iter().find(|category| { + category.section_id() == route.section + || (route.section == properties::panel::PREFERENCES_UPDATES_SECTION + && *category == SettingsCategory::General) + }) { dialog.category = category; } - return; } } // A property owned by an owner-local component needs that component opened, diff --git a/crates/app/src/ui/command_palette_tests.rs b/crates/app/src/ui/command_palette_tests.rs index f58d8663..68ed9665 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, export_dpi}; +use plotx_core::properties::{canvas, contour, export_dpi}; fn indices(items: &[PaletteItem], query: &str) -> Vec { filter(items, query) @@ -97,11 +97,134 @@ fn activating_export_dpi_opens_the_export_preferences_category() { .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" + app.session + .ui + .property_focus + .is_some_and(|focus| focus.property == export_dpi::DPI && focus.pending), + "the Preferences row receives the same scroll-and-highlight request as other homes" ); } +#[test] +fn activating_a_canvas_property_opens_the_active_canvas_settings() { + let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + app.doc + .canvases + .push(plotx_core::state::CanvasDocument::new( + "Page".to_owned(), + [120.0, 80.0], + )); + app.session.active_canvas = Some(0); + + reveal_property(&mut app, canvas::WIDTH_MM, 10.0); + + assert_eq!(app.session.ui.canvas_settings, Some(0)); + assert_eq!( + app.session + .ui + .property_focus + .expect("the catalog row is focused") + .property, + canvas::WIDTH_MM + ); +} + +#[test] +fn object_home_route_reveals_expands_scrolls_and_highlights_the_row() { + let (mut app, ids) = properties::fixture::contour_page(1); + reveal_property(&mut app, contour::COUNT, 10.0); + assert!(app.session.secondary_sidebar_visible); + assert!( + app.session + .ui + .property_focus + .is_some_and(|focus| focus.property == contour::COUNT && focus.pending) + ); + + let ctx = egui::Context::default(); + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + properties::panel::contour_section(&mut app, 0, &ids, ui); + }); + + let focus = app + .session + .ui + .property_focus + .expect("the Advanced row remains highlighted after it is revealed"); + assert_eq!(focus.property, contour::COUNT); + assert!( + !focus.pending, + "rendering the Advanced row consumes its one-shot scroll request" + ); + assert!(focus.highlight_until > 10.0); +} + +#[test] +fn canvas_home_route_opens_scrolls_and_highlights_the_row() { + let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + app.doc + .canvases + .push(plotx_core::state::CanvasDocument::new( + "Page".to_owned(), + [120.0, 80.0], + )); + app.session.active_canvas = Some(0); + reveal_property(&mut app, canvas::WIDTH_MM, 10.0); + assert_eq!(app.session.ui.canvas_settings, Some(0)); + + let target = app.canvas_target(app.doc.canvases[0].resource_id); + let ctx = egui::Context::default(); + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + properties::panel::canvas_size_section(&mut app, &target, ui); + }); + + let focus = app + .session + .ui + .property_focus + .expect("the canvas row remains highlighted after it is revealed"); + assert_eq!(focus.property, canvas::WIDTH_MM); + assert!( + !focus.pending, + "rendering the canvas row consumes its one-shot scroll request" + ); + assert!(focus.highlight_until > 10.0); +} + +#[test] +fn app_home_route_opens_preferences_scrolls_and_highlights_the_row() { + use plotx_core::properties::app_preferences; + + let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + reveal_property(&mut app, app_preferences::SNAP_ENABLED, 10.0); + assert_eq!( + app.session + .ui + .settings_dialog + .as_ref() + .expect("Preferences opens") + .category, + plotx_core::state::SettingsCategory::General + ); + + let ctx = egui::Context::default(); + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + crate::ui::settings_dialog::settings_window(&mut app, ui.ctx()); + }); + + let focus = app + .session + .ui + .property_focus + .expect("the preference row remains highlighted after it is revealed"); + assert_eq!(focus.property, app_preferences::SNAP_ENABLED); + assert!( + !focus.pending, + "rendering the preference row consumes its one-shot scroll request" + ); + assert!(focus.highlight_until > 10.0); +} + fn property_item(items: &[PaletteItem], property: PropertyId) -> &PaletteItem { items .iter() diff --git a/crates/app/src/ui/commands.rs b/crates/app/src/ui/commands.rs index 5407e09e..6cdef7b2 100644 --- a/crates/app/src/ui/commands.rs +++ b/crates/app/src/ui/commands.rs @@ -599,7 +599,8 @@ fn ribbon_placement(id: CommandId) -> Option { CommandId::TogglePrimarySidebar | CommandId::ToggleSecondarySidebar | CommandId::ToggleGrid - | CommandId::Present => (View, "Display", 1, Always), + | CommandId::Present + | CommandId::Preferences => (View, "Display", 1, Always), CommandId::OpenFile | CommandId::ImportTable | CommandId::OpenFolder diff --git a/crates/app/src/ui/commands/identity.rs b/crates/app/src/ui/commands/identity.rs index be7324b9..ea683e18 100644 --- a/crates/app/src/ui/commands/identity.rs +++ b/crates/app/src/ui/commands/identity.rs @@ -118,7 +118,7 @@ pub(super) fn command_identity( CommandId::ToggleSnap => ( "Toggle Snapping".into(), Some(icon::MAGNET), - Some(app.session.ui.snap_enabled), + Some(app.settings.general.snap_enabled), ), CommandId::Preferences => plain("Preferences…", Some(icon::GEAR_SIX)), CommandId::CommandPalette => plain("Command Palette…", Some(icon::MAGNIFYING_GLASS)), diff --git a/crates/app/src/ui/export_dialog.rs b/crates/app/src/ui/export_dialog.rs index 79ed52a1..b06df6ef 100644 --- a/crates/app/src/ui/export_dialog.rs +++ b/crates/app/src/ui/export_dialog.rs @@ -130,8 +130,7 @@ pub(super) fn export_options_window(app: &mut PlotxApp, ctx: &egui::Context) { let trim = settings.trim_to_visible_content; if let Some(path) = crate::ui::file_dialogs::choose_export_path(&settings) { app.export_to(settings, &path); - apply_confirmed_export_default(&mut app.settings.export, trim, true); - app.persist_settings(); + set_confirmed_trim_default(app, trim); } } } else if cancel || modal.should_close() { @@ -139,13 +138,19 @@ pub(super) fn export_options_window(app: &mut PlotxApp, ctx: &egui::Context) { } } -fn apply_confirmed_export_default( - defaults: &mut plotx_core::settings::ExportDefaults, - trim_to_visible_content: bool, - path_confirmed: bool, -) { - if path_confirmed { - defaults.trim_to_visible_content = trim_to_visible_content; +fn set_confirmed_trim_default(app: &mut PlotxApp, trim_to_visible_content: bool) { + let target = app.app_target(); + match app.plan_property_write( + plotx_core::properties::app_preferences::TRIM_TO_VISIBLE_CONTENT, + std::slice::from_ref(&target), + &plotx_core::properties::PropertyValue::Bool(trim_to_visible_content), + ) { + Ok(commit) => { + app.commit_property(commit); + } + Err(error) => { + app.session.status = format!("Could not save the export trim preference: {error}"); + } } } @@ -206,17 +211,23 @@ mod tests { use super::*; #[test] - fn only_confirmed_path_updates_trim_and_never_dpi() { - let mut defaults = plotx_core::settings::ExportDefaults { - dpi: 600, - ..Default::default() - }; - apply_confirmed_export_default(&mut defaults, true, false); - assert!(!defaults.trim_to_visible_content); - assert_eq!(defaults.dpi, 600); - - apply_confirmed_export_default(&mut defaults, true, true); - assert!(defaults.trim_to_visible_content); - assert_eq!(defaults.dpi, 600); + fn confirmed_export_updates_trim_through_the_catalog_and_never_dpi() { + let mut settings = plotx_core::settings::Settings::default(); + settings.export.dpi = 600; + let mut app = PlotxApp::new_with_settings(settings); + + set_confirmed_trim_default(&mut app, true); + assert!(app.settings.export.trim_to_visible_content); + assert_eq!(app.settings.export.dpi, 600); + let resolved = app + .resolve_property(&plotx_core::properties::PropertyAddress::new( + app.app_target(), + plotx_core::properties::app_preferences::TRIM_TO_VISIBLE_CONTENT, + )) + .expect("the catalog reads the confirmed default"); + assert_eq!( + resolved.value.uniform(), + Some(&plotx_core::properties::PropertyValue::Bool(true)) + ); } } diff --git a/crates/app/src/ui/figure_typography.rs b/crates/app/src/ui/figure_typography.rs index 5af29dc4..b93c01b4 100644 --- a/crates/app/src/ui/figure_typography.rs +++ b/crates/app/src/ui/figure_typography.rs @@ -4,7 +4,6 @@ //! same contract as the canvas-size fields. use super::*; -use plotx_core::properties::FloatBounds; use plotx_figure::FigureTypography; pub(super) fn figure_typography_window(app: &mut PlotxApp, ctx: &egui::Context) { @@ -26,29 +25,7 @@ pub(super) fn figure_typography_window(app: &mut PlotxApp, ctx: &egui::Context) .color(ui.visuals().weak_text_color()), ); ui.add_space(6.0); - egui::Grid::new("figure_typography_grid") - .num_columns(2) - .spacing([12.0, 6.0]) - .show(ui, |ui| { - // The tick size is a catalog property, so its range comes - // from the definition the catalog control and the write path - // are both built from. A literal here would be a second copy - // of the rule, and it was: this window clamped to 24 pt while - // the catalog admitted 72, so any interaction here silently - // pulled a 40 pt figure back down. - size_row(app, ui, "Tick labels", tick_bounds(), |t| &mut t.tick_pt); - ui.end_row(); - // The other two are not catalog properties yet and keep the - // range this window has always applied to them. - size_row(app, ui, "Axis titles", UNREGISTERED_BOUNDS, |t| { - &mut t.label_pt - }); - ui.end_row(); - size_row(app, ui, "Figure title", UNREGISTERED_BOUNDS, |t| { - &mut t.title_pt - }); - ui.end_row(); - }); + crate::ui::properties::panel::typography_section(app, ui); ui.add_space(8.0); if ui.button("Reset to defaults").clicked() { let before = app.doc.style_library.figure_typography; @@ -63,83 +40,3 @@ pub(super) fn figure_typography_window(app: &mut PlotxApp, ctx: &egui::Context) app.session.ui.figure_typography_before = None; } } - -/// One labelled pt-size drag. Live-applies while dragging and commits a single -/// undoable action per gesture (or per typed edit), mirroring -/// `handle_canvas_dimension_response`. -/// The range this window applies to the two sizes that have no catalog entry. -const UNREGISTERED_BOUNDS: FloatBounds = FloatBounds::inclusive(4.0, 24.0); - -/// The declared range of the tick-label size, read from its definition. -fn tick_bounds() -> FloatBounds { - plotx_core::properties::definition(plotx_core::properties::typography::TICK_PT) - .and_then(|definition| definition.value_schema.float_bounds()) - .unwrap_or(UNREGISTERED_BOUNDS) -} - -fn size_row( - app: &mut PlotxApp, - ui: &mut Ui, - label: &str, - bounds: FloatBounds, - field: impl Fn(&mut FigureTypography) -> &mut f32, -) { - ui.label(label); - let frame_before = app.doc.style_library.figure_typography; - let mut value = { - let mut current = frame_before; - *field(&mut current) - }; - let resp = ui.add( - egui::DragValue::new(&mut value) - .speed(0.25) - .range(bounds.lowest()..=bounds.max) - .max_decimals(1) - .suffix(" pt"), - ); - if resp.drag_started() { - app.session.ui.figure_typography_before = Some(frame_before); - } - if resp.changed() { - let mut after = frame_before; - *field(&mut after) = value; - app.set_figure_typography_value(after); - app.doc.dirty = true; - } - if resp.drag_stopped() { - let before = app - .session - .ui - .figure_typography_before - .take() - .unwrap_or(frame_before); - let after = app.doc.style_library.figure_typography; - app.execute_action(Action::set_figure_typography(before, after)); - } else if resp.changed() && !resp.dragged() { - let after = app.doc.style_library.figure_typography; - app.execute_action(Action::set_figure_typography(frame_before, after)); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// One definition of the range. The window used to stop at 24 pt while the - /// catalog admitted 72, so a size set through the catalog was silently - /// clamped the next time this window was touched. - #[test] - fn the_tick_row_takes_its_range_from_the_catalog() { - let declared = - plotx_core::properties::definition(plotx_core::properties::typography::TICK_PT) - .and_then(|definition| definition.value_schema.float_bounds()) - .expect("the tick-label size is a registered float property"); - let row = tick_bounds(); - assert_eq!(row.max, declared.max); - assert_eq!(row.lowest(), declared.lowest()); - assert!( - row.admits(40.0), - "a size the catalog accepts must survive a visit to this window" - ); - } -} diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index 493c9bb0..52447c55 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -38,13 +38,12 @@ use data_sheet::*; use diagnostics::*; use egui::{Color32, Pos2, Response, Sense, Stroke, Ui, Vec2}; use export_dialog::*; -use plotx_core::actions::{Action, PendingPageLayoutEdit}; +use plotx_core::actions::Action; use plotx_core::export::{ExportPageScope, ExportScopeKind, ExportSettings}; -use plotx_core::layout::PageLayout; use plotx_core::operation::OperationOutcome; -use plotx_core::state::{CanvasSizeUnit, Interaction, PanelLabelStyle, PlotxApp, Selection}; +use plotx_core::state::{Interaction, PlotxApp, Selection}; pub(crate) use settings_dialog::apply_chrome_theme; -use settings_dialog::settings_window; +use settings_dialog::{settings_window, sync_chrome_theme}; use shortcuts::*; use windows::*; @@ -56,6 +55,7 @@ pub fn render( input_blocked: bool, ) { let ctx = ui.ctx().clone(); + sync_chrome_theme(&ctx, app.settings.appearance.theme); clipboard_table_paste.begin_frame(app, &ctx); if let Some(payload) = app.poll_data_export() { copy_table_export(&ctx, payload); diff --git a/crates/app/src/ui/object_inspector.rs b/crates/app/src/ui/object_inspector.rs index 082c0e76..1cdf229a 100644 --- a/crates/app/src/ui/object_inspector.rs +++ b/crates/app/src/ui/object_inspector.rs @@ -3,17 +3,21 @@ mod axes; mod chart_gallery; -mod panel_note; +mod data; +mod edits; +mod geometry; use axes::{axes_section, commit_if_target_changed}; use chart_gallery::chart_gallery; +use data::data_section; +use edits::{flush_inspector_edit, format_once_section, kind_targets, selection_label}; use egui::{DragValue, Ui}; use egui_phosphor::regular as icon; -use panel_note::{commit_panel_note_edit, panel_note_section}; +use geometry::geometry_section; use plotx_core::actions::{Action, PendingInspectorEdit}; use plotx_core::state::{ CanvasObject, DataBinding, Dataset, MM_TO_PT, OVERLAY_PALETTE, ObjectFrame, ObjectId, PlotxApp, - SeriesBinding, ShapeKind, StackKind, StackMode, StackSpec, TextAlign, + SeriesBinding, StackSpec, }; use plotx_figure::Color; @@ -39,8 +43,7 @@ pub(crate) fn render(app: &mut PlotxApp, ui: &mut Ui) { return; } if ids.is_empty() { - commit_panel_note_edit(app); - property_sections(app, ci, ui); + property_sections(app, ci, true, ui); return; } @@ -54,8 +57,17 @@ pub(crate) fn render(app: &mut PlotxApp, ui: &mut Ui) { ui.add_space(4.0); geometry_section(app, ci, &ids, ui); + let property_objects: Vec<_> = ids + .iter() + .copied() + .filter(|&id| { + app.doc.canvases[ci] + .object(id) + .is_some_and(|object| !object.locked) + }) + .collect(); + crate::ui::properties::panel::axis_section(app, ci, &property_objects, ui); - let mut note_focused = false; let mut axes_focused = false; if ids.len() == 1 && app.doc.canvases[ci] @@ -65,23 +77,19 @@ pub(crate) fn render(app: &mut PlotxApp, ui: &mut Ui) { { ui.separator(); axes_focused = axes_section(app, ci, ids[0], ui); - ui.separator(); - note_focused = panel_note_section(app, ci, ids[0], ui); + crate::ui::properties::panel::panel_section(app, ci, &property_objects, ui); data_section(app, ci, ids[0], ui); } - property_sections(app, ci, ui); + property_sections(app, ci, false, ui); let text_ids = kind_targets(app, ci, &ids, |o| o.text().is_some()); let shape_ids = kind_targets(app, ci, &ids, |o| o.shape().is_some()); - let mut text_focused = false; if !text_ids.is_empty() { - ui.separator(); - text_focused = text_section(app, ci, &text_ids, ui); + crate::ui::properties::panel::text_section(app, ci, &text_ids, ui); } if !shape_ids.is_empty() { - ui.separator(); - shape_section(app, ci, &shape_ids, ui); + crate::ui::properties::panel::shape_section(app, ci, &shape_ids, ui); } let primary = ids[0]; @@ -94,7 +102,7 @@ pub(crate) fn render(app: &mut PlotxApp, ui: &mut Ui) { format_once_section(app, ci, primary, ui); } - flush_inspector_edit(app, ui, text_focused || note_focused || axes_focused); + flush_inspector_edit(app, ui, axes_focused); ui.separator(); ui.add_space(2.0); } @@ -113,685 +121,19 @@ pub(crate) fn render(app: &mut PlotxApp, ui: &mut Ui) { /// property, so it applies whenever a document is open, whatever is selected. /// Gating it on the selection would hide an always-applicable control for a /// transient reason, which the crate's hide-vs-disable rule forbids. -fn property_sections(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { +fn property_sections(app: &mut PlotxApp, ci: usize, include_axes: bool, ui: &mut Ui) { // The write side of the shared selection: these sections carry controls, so // they take the editable subset. The lock lives in one place rather than // being re-derived here. let objects = crate::ui::properties::discovery::editable_objects(app); + if include_axes { + crate::ui::properties::panel::axis_section(app, ci, &objects, ui); + } crate::ui::properties::panel::contour_section(app, ci, &objects, ui); crate::ui::properties::panel::line_section(app, ci, &objects, ui); crate::ui::properties::panel::typography_section(app, ui); } -fn geometry_section(app: &mut PlotxApp, ci: usize, ids: &[ObjectId], ui: &mut Ui) { - let primary = ids[0]; - let Some(o) = app.doc.canvases[ci].object(primary) else { - return; - }; - let enabled = !o.locked; - let frame = o.frame; - let mut x = frame.x / MM_TO_PT; - let mut y = frame.y / MM_TO_PT; - let mut w = frame.width / MM_TO_PT; - let mut h = frame.height / MM_TO_PT; - - egui::Grid::new("object_geometry") - .num_columns(4) - .spacing([6.0, 4.0]) - .show(ui, |ui| { - ui.label("X"); - let rx = ui.add_enabled(enabled, mm_drag(&mut x)); - ui.label("Y"); - let ry = ui.add_enabled(enabled, mm_drag(&mut y)); - ui.end_row(); - ui.label("W"); - let rw = ui.add_enabled(enabled, mm_drag(&mut w)); - ui.label("H"); - let rh = ui.add_enabled(enabled, mm_drag(&mut h)); - ui.end_row(); - - if rx.changed() || ry.changed() || rw.changed() || rh.changed() { - note_inspector_edit(app, ci, ids); - let new = ObjectFrame::new(x * MM_TO_PT, y * MM_TO_PT, w * MM_TO_PT, h * MM_TO_PT); - app.set_object_frame(ci, primary, new); - } - }); - - if !enabled { - ui.weak("Locked — unlock to edit geometry."); - } else if ids.len() > 1 { - ui.weak("Geometry edits the primary selection."); - } -} - -/// Binding edits rebuild through `SetDataBinding`; stack-layout edits through -/// `SetStackSpec`. -fn data_section(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: &mut Ui) { - let Some((binding, stack)) = app.doc.canvases[ci] - .object(object) - .and_then(|o| o.plot()) - .map(|p| (p.binding.clone(), p.stack)) - else { - return; - }; - - ui.separator(); - ui.strong("Data"); - - let is_stack = binding.series.len() > 1 && app.series_stackable(&binding); - let count = binding.series.len(); - let mut next_binding: Option = None; - let mut next_stack: Option = None; - for (i, sb) in binding.series.iter().enumerate() { - ui.horizontal(|ui| { - if is_stack { - let mut visible = sb.visible; - if ui - .checkbox(&mut visible, "") - .on_hover_text("Visible") - .changed() - { - let mut b = binding.clone(); - b.series[i].visible = visible; - next_binding = Some(b); - } - } - let color = sb - .primary_color() - .unwrap_or(OVERLAY_PALETTE[i % OVERLAY_PALETTE.len()]); - swatch(ui, color); - let name = app - .doc - .dataset_index(sb.source.resource) - .and_then(|index| app.doc.datasets.get(index)) - .map(Dataset::display_name) - .unwrap_or_default(); - let label = if i == 0 { - format!("{name} (primary)") - } else { - name - }; - if is_stack { - if ui - .selectable_label(stack.active == Some(i), label) - .on_hover_text("Highlight this trace") - .clicked() - { - next_stack = Some(StackSpec { - active: Some(i), - ..stack - }); - } - } else { - ui.label(label); - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if count > 1 && ui.small_button(icon::X).on_hover_text("Remove").clicked() { - let mut b = binding.clone(); - b.series.remove(i); - next_binding = Some(b); - } - if is_stack { - if ui - .add_enabled(i + 1 < count, egui::Button::new(icon::CARET_DOWN).small()) - .on_hover_text("Move down") - .clicked() - { - let mut b = binding.clone(); - b.series.swap(i, i + 1); - next_binding = Some(b); - } - if ui - .add_enabled(i > 0, egui::Button::new(icon::CARET_UP).small()) - .on_hover_text("Move up") - .clicked() - { - let mut b = binding.clone(); - b.series.swap(i, i - 1); - next_binding = Some(b); - } - if matches!(sb.encoding, plotx_figure::SeriesEncoding::Line(_)) { - let mut scale = sb.line_scale(); - if ui - .add(DragValue::new(&mut scale).speed(0.02).range(0.01..=100.0)) - .on_hover_text("Scale") - .changed() - { - let mut b = binding.clone(); - if let plotx_figure::SeriesEncoding::Line(line) = - &mut b.series[i].encoding - { - line.scale = scale; - } - next_binding = Some(b); - } - } - } else if i != 0 && ui.small_button("Primary").clicked() { - let mut b = binding.clone(); - b.series.swap(0, i); - next_binding = Some(b); - } - }); - }); - } - - let candidates = app.stack_candidates(&binding); - if binding - .primary_dataset() - .and_then(|id| app.doc.dataset_by_id(id)) - .map(Dataset::domain) - .is_some_and(|d| d.stack_kind().is_some()) - { - if candidates.is_empty() { - ui.weak("No other datasets to stack."); - } else { - egui::ComboBox::from_id_salt("object_add_series") - .selected_text("Add series…") - .show_ui(ui, |ui| { - for di in &candidates { - let label = app.doc.datasets[*di].display_name(); - if ui.selectable_label(false, label).clicked() { - let mut b = binding.clone(); - let Some(series_id) = app - .doc - .canvases - .get_mut(ci) - .and_then(|canvas| canvas.object_mut(object)) - .and_then(|object| object.plot_mut()) - .map(|plot| plot.allocate_series_id()) - else { - continue; - }; - if let Some(mut series) = - SeriesBinding::from_dataset(&app.doc.datasets[*di]) - { - series.id = series_id; - b.series.push(series); - next_binding = Some(b); - } - } - } - }); - } - } else { - ui.weak("Stacking is available for line-series plots."); - } - - if is_stack { - let kind = binding - .primary_dataset() - .and_then(|id| app.doc.dataset_by_id(id)) - .and_then(|d| d.domain().stack_kind()); - if let Some(kind) = kind { - stack_controls(kind, &stack, &mut next_stack, ui); - } - } - - if let Some(after) = next_binding - && after != binding - { - app.execute_action(Action::set_data_binding(ci, object, binding, after)); - app.session.status = "Updated plot data.".to_owned(); - } else if let Some(after) = next_stack - && after != stack - { - app.execute_action(Action::set_stack_spec(ci, object, stack, after)); - app.session.status = "Updated stack layout.".to_owned(); - } - - chart_gallery(app, ci, object, ui); -} - -fn stack_controls(kind: StackKind, stack: &StackSpec, next: &mut Option, ui: &mut Ui) { - ui.separator(); - if kind == StackKind::Field { - ui.horizontal(|ui| { - ui.label("Mode"); - ui.label("Color overlay"); - }); - return; - } - ui.horizontal(|ui| { - ui.label("Mode"); - if ui - .selectable_label(stack.mode == StackMode::Superimposed, "Superimposed") - .clicked() - { - *next = Some(StackSpec { - mode: StackMode::Superimposed, - ..*stack - }); - } - if ui - .selectable_label(stack.mode == StackMode::Offset, "Offset") - .clicked() - { - *next = Some(StackSpec { - mode: StackMode::Offset, - ..*stack - }); - } - }); - - if stack.mode != StackMode::Offset { - return; - } - ui.horizontal(|ui| { - ui.label("Vertical spacing"); - let mut v = stack.spacing_y; - if ui.add(egui::Slider::new(&mut v, 0.0..=1.0)).changed() { - *next = Some(StackSpec { - spacing_y: v, - ..*stack - }); - } - }); - ui.horizontal(|ui| { - ui.label("3D shear"); - // Signed: positive leans traces up-and-right (bottom-left → top-right), - // negative up-and-left (bottom-right → top-left). Zero = pure vertical. - let mut v = stack.shear_x; - if ui - .add(egui::Slider::new(&mut v, -0.5..=0.5)) - .on_hover_text("Drag right to lean up-and-right, left to lean up-and-left") - .changed() - { - *next = Some(StackSpec { - shear_x: v, - ..*stack - }); - } - }); - let mut normalize = stack.normalize; - if ui.checkbox(&mut normalize, "Normalize").changed() { - *next = Some(StackSpec { - normalize, - ..*stack - }); - } -} - -fn swatch(ui: &mut Ui, color: Color) { - let (rect, _) = ui.allocate_exact_size(egui::vec2(14.0, 10.0), egui::Sense::hover()); - ui.painter().rect_filled( - rect, - 2.0, - egui::Color32::from_rgb(color.r, color.g, color.b), - ); -} - -fn mm_drag(value: &mut f32) -> DragValue<'_> { - DragValue::new(value) - .speed(0.5) - .max_decimals(1) - .suffix(" mm") -} - -fn text_section(app: &mut PlotxApp, ci: usize, ids: &[ObjectId], ui: &mut Ui) -> bool { - let rep_id = ids[0]; - let Some(rep) = app.doc.canvases[ci] - .object(rep_id) - .and_then(|o| o.text()) - .cloned() - else { - return false; - }; - ui.strong("Text"); - - let mut focused = false; - if ids.len() == 1 { - let mut buf = rep.text.clone(); - let resp = ui.add( - egui::TextEdit::multiline(&mut buf) - .desired_rows(2) - .desired_width(f32::INFINITY), - ); - if resp.changed() { - note_inspector_edit(app, ci, ids); - if let Some(t) = app.doc.canvases[ci] - .object_mut(rep_id) - .and_then(|o| o.text_mut()) - { - t.text = buf; - } - } - focused = resp.has_focus(); - } - - let mut font = rep.font_size; - ui.horizontal(|ui| { - ui.label("Size"); - if ui - .add(DragValue::new(&mut font).speed(0.5).range(4.0..=200.0)) - .changed() - { - note_inspector_edit(app, ci, ids); - for &id in ids { - if let Some(t) = app.doc.canvases[ci] - .object_mut(id) - .and_then(|o| o.text_mut()) - { - t.font_size = font; - } - } - } - }); - - let mut bold = rep.bold; - if ui.checkbox(&mut bold, "Bold").changed() { - note_inspector_edit(app, ci, ids); - for &id in ids { - if let Some(t) = app.doc.canvases[ci] - .object_mut(id) - .and_then(|o| o.text_mut()) - { - t.bold = bold; - } - } - } - - ui.horizontal(|ui| { - ui.label("Align"); - for (align, label) in [ - (TextAlign::Left, "Left"), - (TextAlign::Center, "Center"), - (TextAlign::Right, "Right"), - ] { - if ui.selectable_label(rep.align == align, label).clicked() { - note_inspector_edit(app, ci, ids); - for &id in ids { - if let Some(t) = app.doc.canvases[ci] - .object_mut(id) - .and_then(|o| o.text_mut()) - { - t.align = align; - } - } - } - } - }); - - ui.horizontal(|ui| { - ui.label("Colour"); - let mut rgb = rgb_of(rep.color); - if ui.color_edit_button_srgb(&mut rgb).changed() { - note_inspector_edit(app, ci, ids); - let color = color_of(rgb); - for &id in ids { - if let Some(t) = app.doc.canvases[ci] - .object_mut(id) - .and_then(|o| o.text_mut()) - { - t.color = color; - } - } - } - }); - - focused -} - -fn shape_section(app: &mut PlotxApp, ci: usize, ids: &[ObjectId], ui: &mut Ui) { - let rep_id = ids[0]; - let Some(rep) = app.doc.canvases[ci] - .object(rep_id) - .and_then(|o| o.shape()) - .cloned() - else { - return; - }; - ui.strong("Shape"); - - ui.horizontal(|ui| { - ui.label("Kind"); - for (kind, label) in [ - (ShapeKind::Rect, "Rect"), - (ShapeKind::Ellipse, "Ellipse"), - (ShapeKind::Line, "Line"), - (ShapeKind::Arrow, "Arrow"), - ] { - if ui.selectable_label(rep.shape == kind, label).clicked() { - note_inspector_edit(app, ci, ids); - for &id in ids { - if let Some(s) = app.doc.canvases[ci] - .object_mut(id) - .and_then(|o| o.shape_mut()) - { - s.shape = kind; - } - } - } - } - }); - - ui.horizontal(|ui| { - ui.label("Stroke"); - let mut rgb = rgb_of(rep.stroke); - if ui.color_edit_button_srgb(&mut rgb).changed() { - note_inspector_edit(app, ci, ids); - let color = color_of(rgb); - for &id in ids { - if let Some(s) = app.doc.canvases[ci] - .object_mut(id) - .and_then(|o| o.shape_mut()) - { - s.stroke = color; - } - } - } - let mut width = rep.stroke_width; - if ui - .add(DragValue::new(&mut width).speed(0.1).range(0.1..=40.0)) - .changed() - { - note_inspector_edit(app, ci, ids); - for &id in ids { - if let Some(s) = app.doc.canvases[ci] - .object_mut(id) - .and_then(|o| o.shape_mut()) - { - s.stroke_width = width; - } - } - } - }); - - ui.horizontal(|ui| { - let mut fill_on = rep.fill.is_some(); - if ui.checkbox(&mut fill_on, "Fill").changed() { - note_inspector_edit(app, ci, ids); - let fill = fill_on.then(|| rep.fill.unwrap_or(Color::rgb(200, 200, 200))); - for &id in ids { - if let Some(s) = app.doc.canvases[ci] - .object_mut(id) - .and_then(|o| o.shape_mut()) - { - s.fill = fill; - } - } - } - if let Some(current) = rep.fill { - let mut rgb = rgb_of(current); - if ui.color_edit_button_srgb(&mut rgb).changed() { - note_inspector_edit(app, ci, ids); - let color = color_of(rgb); - for &id in ids { - if let Some(s) = app.doc.canvases[ci] - .object_mut(id) - .and_then(|o| o.shape_mut()) - { - s.fill = Some(color); - } - } - } - } - }); -} - -fn format_once_section(app: &mut PlotxApp, ci: usize, primary: ObjectId, ui: &mut Ui) { - let noun = app.doc.canvases[ci] - .object(primary) - .map(kind_noun) - .unwrap_or("objects"); - ui.horizontal_wrapped(|ui| { - if ui.button(format!("Apply to all {noun}")).clicked() { - app.apply_style_to_kind(ci, primary); - } - if ui.button(format!("Set as default {noun}")).clicked() { - app.set_style_default(ci, primary); - } - }); -} - -fn kind_noun(o: &CanvasObject) -> &'static str { - if o.is_panel_label() { - "panel labels" - } else if o.text().is_some() { - "text" - } else if o.shape().is_some() { - "shapes" - } else { - "objects" - } -} - -fn kind_targets( - app: &PlotxApp, - ci: usize, - ids: &[ObjectId], - pred: impl Fn(&CanvasObject) -> bool, -) -> Vec { - ids.iter() - .copied() - .filter(|&id| { - app.doc.canvases[ci] - .object(id) - .map(|o| !o.locked && pred(o)) - .unwrap_or(false) - }) - .collect() -} - -fn selection_label(app: &PlotxApp, ci: usize, ids: &[ObjectId]) -> String { - if ids.len() > 1 { - format!("{} selected", ids.len()) - } else { - app.doc.canvases[ci] - .object(ids[0]) - .map(|o| o.name.clone()) - .unwrap_or_default() - } -} - -/// Snapshot the touched objects' pre-edit frames and styles once per -/// interaction; later widget frames in the same drag see it already set and -/// leave the earliest snapshot in place. -fn note_inspector_edit(app: &mut PlotxApp, ci: usize, ids: &[ObjectId]) { - if app.session.ui.inspector_edit.is_some() { - return; - } - let Some(c) = app.doc.canvases.get(ci) else { - return; - }; - let frames = ids - .iter() - .filter_map(|&id| c.object(id).map(|o| (id, o.frame))) - .collect(); - let styles = ids - .iter() - .filter_map(|&id| c.object(id).and_then(|o| o.style().map(|s| (id, s)))) - .collect(); - app.session.ui.inspector_edit = Some(PendingInspectorEdit { - canvas: ci, - frames, - styles, - }); -} - -/// Commit the coalesced interaction once it ends (pointer released and no text -/// field focused), emitting at most one frame action and one style action for -/// whichever properties actually changed. -fn flush_inspector_edit(app: &mut PlotxApp, ui: &Ui, text_focused: bool) { - if app.session.ui.inspector_edit.is_none() { - return; - } - if text_focused || ui.input(|i| i.pointer.any_down()) { - return; - } - let Some(edit) = app.session.ui.inspector_edit.take() else { - return; - }; - let ci = edit.canvas; - let (fb, fa, sb, sa) = { - let Some(c) = app.doc.canvases.get(ci) else { - return; - }; - let mut fb = Vec::new(); - let mut fa = Vec::new(); - for &(id, before) in &edit.frames { - if let Some(o) = c.object(id) - && o.frame != before - { - fb.push((id, before)); - fa.push((id, o.frame)); - } - } - let mut sb = Vec::new(); - let mut sa = Vec::new(); - for (id, before) in &edit.styles { - if let Some(cur) = c.object(*id).and_then(|o| o.style()) - && cur != *before - { - sb.push((*id, before.clone())); - sa.push((*id, cur)); - } - } - (fb, fa, sb, sa) - }; - - if !fb.is_empty() { - app.execute_action(Action::set_object_frames(ci, fb, fa)); - } - if !sb.is_empty() { - app.execute_action(Action::set_object_style(ci, sb, sa)); - } -} - -fn rgb_of(c: Color) -> [u8; 3] { - [c.r, c.g, c.b] -} - -fn color_of(rgb: [u8; 3]) -> Color { - Color::rgb(rgb[0], rgb[1], rgb[2]) -} - #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn renders_safely_during_active_canvas_transition() { - let mut app = PlotxApp::new(); - app.session.active_canvas = Some(0); - assert!(app.doc.canvases.is_empty()); - assert!(app.session.ui.selection.objects().is_empty()); - - let ctx = egui::Context::default(); - let _ = ctx.run_ui(egui::RawInput::default(), |ui| render(&mut app, ui)); - } - - #[test] - fn property_target_filter_excludes_locked_plot_objects() { - let (mut app, ids) = crate::ui::properties::fixture::contour_page(1); - let object = ids[0]; - app.doc.canvases[0] - .object_mut(object) - .expect("the fixture plot exists") - .locked = true; - let targets = kind_targets(&app, 0, &ids, |candidate| candidate.plot().is_some()); - assert!( - targets.is_empty(), - "a locked plot must be excluded before catalog targets are built" - ); - } -} +#[path = "object_inspector_tests.rs"] +mod tests; diff --git a/crates/app/src/ui/object_inspector/axes.rs b/crates/app/src/ui/object_inspector/axes.rs index 838fcad6..281326c6 100644 --- a/crates/app/src/ui/object_inspector/axes.rs +++ b/crates/app/src/ui/object_inspector/axes.rs @@ -1,5 +1,6 @@ -use egui::{DragValue, Response, TextEdit, Ui}; +use egui::{DragValue, Response, Ui}; use plotx_core::actions::Action; +use plotx_core::properties::axis; use plotx_core::state::{AxisOverrides, AxisRange, ObjectId, PlotxApp}; use plotx_figure::AxisFrame; @@ -27,55 +28,21 @@ pub(super) fn axes_section( object: ObjectId, ui: &mut Ui, ) -> bool { - let Some((x_auto, y_auto, hidden, x_categorical, y_categorical)) = app.doc.canvases[canvas] + let Some((hidden, x_categorical, y_categorical)) = app.doc.canvases[canvas] .object(object) .and_then(|object| object.plot()) .map(|plot| { ( - plot.figure.x.label.clone(), - plot.figure.y.label.clone(), - plot.figure.axis_frame == AxisFrame::Hidden, - plot.figure.x.categories.is_some(), - plot.figure.y.categories.is_some(), + plot.figure().axis_frame == AxisFrame::Hidden, + plot.figure().x.categories.is_some(), + plot.figure().y.categories.is_some(), ) }) else { return false; }; - ui.strong("Axes"); let hidden_reason = "Choose a chart with visible axes to edit axis settings."; - let mut focused = false; - egui::Grid::new("object_axis_labels") - .num_columns(2) - .spacing([8.0, 4.0]) - .show(ui, |ui| { - focused |= label_row( - app, - canvas, - object, - AxisKind::X, - "X title", - &x_auto, - !hidden, - hidden_reason, - ui, - ); - ui.end_row(); - focused |= label_row( - app, - canvas, - object, - AxisKind::Y, - "Y title", - &y_auto, - !hidden, - hidden_reason, - ui, - ); - ui.end_row(); - }); - let x_reason = if hidden { hidden_reason } else { @@ -106,122 +73,30 @@ pub(super) fn axes_section( y_reason, ui, ); - visibility_row(app, canvas, object, AxisKind::X, "X text", ui); - visibility_row(app, canvas, object, AxisKind::Y, "Y text", ui); - - focused -} - -fn visibility_row( - app: &mut PlotxApp, - canvas: usize, - object: ObjectId, - axis: AxisKind, - label: &str, - ui: &mut Ui, -) { - let Some((mut ticks, mut title)) = app.doc.canvases[canvas] - .object(object) - .and_then(|object| object.plot()) - .map(|plot| match axis { - AxisKind::X => (plot.figure.x.show_tick_labels, plot.figure.x.show_label), - AxisKind::Y => (plot.figure.y.show_tick_labels, plot.figure.y.show_label), - }) - else { - return; - }; - ui.horizontal(|ui| { - ui.label(label); - let tick_changed = ui.checkbox(&mut ticks, "Tick labels").changed(); - let title_changed = ui.checkbox(&mut title, "Title").changed(); - if tick_changed || title_changed { - let before = current_overrides(app, canvas, object); - let mut after = before.clone(); - match axis { - AxisKind::X => { - if tick_changed { - after.x_show_tick_labels = Some(ticks); - } - if title_changed { - after.x_show_label = Some(title); - } - } - AxisKind::Y => { - if tick_changed { - after.y_show_tick_labels = Some(ticks); - } - if title_changed { - after.y_show_label = Some(title); - } - } + if ui + .button("Automatic") + .on_hover_text("Clear all axis visibility overrides") + .clicked() + && let Some(target) = app.object_target(canvas, object) + { + match app.plan_property_resets( + &[ + axis::X_SHOW_TICK_LABELS, + axis::X_SHOW_LABEL, + axis::Y_SHOW_TICK_LABELS, + axis::Y_SHOW_LABEL, + ], + std::slice::from_ref(&target), + ) { + Ok(commit) => { + app.commit_property(commit); } - app.execute_action(Action::set_axis_overrides(canvas, object, before, after)); - } - if ui - .button("Automatic") - .on_hover_text("Clear visibility overrides") - .clicked() - { - let before = current_overrides(app, canvas, object); - let mut after = before.clone(); - match axis { - AxisKind::X => { - after.x_show_tick_labels = None; - after.x_show_label = None; - } - AxisKind::Y => { - after.y_show_tick_labels = None; - after.y_show_label = None; - } + Err(error) => { + app.session.status = format!("Could not reset axis visibility: {error}"); } - app.execute_action(Action::set_axis_overrides(canvas, object, before, after)); } - }); -} - -#[allow(clippy::too_many_arguments)] -fn label_row( - app: &mut PlotxApp, - canvas: usize, - object: ObjectId, - axis: AxisKind, - label: &str, - automatic: &str, - enabled: bool, - disabled_reason: &str, - ui: &mut Ui, -) -> bool { - let current = current_overrides(app, canvas, object); - let mut text = match axis { - AxisKind::X => current.x_label.clone(), - AxisKind::Y => current.y_label.clone(), - } - .unwrap_or_default(); - ui.label(label); - let response = ui - .add_enabled( - enabled, - TextEdit::singleline(&mut text) - .hint_text(automatic) - .desired_width(132.0), - ) - .on_disabled_hover_text(disabled_reason); - if response.gained_focus() { - begin_edit(app, canvas, object); - } - if response.changed() { - let value = (!text.trim().is_empty()).then_some(text); - let mut after = current_overrides(app, canvas, object); - match axis { - AxisKind::X => after.x_label = value, - AxisKind::Y => after.y_label = value, - } - apply_live(app, canvas, object, after); - } - if response.lost_focus() { - commit_edit(app); } - response.has_focus() + false } #[allow(clippy::too_many_arguments)] diff --git a/crates/app/src/ui/object_inspector/chart_gallery.rs b/crates/app/src/ui/object_inspector/chart_gallery.rs index 2f337706..c23765fb 100644 --- a/crates/app/src/ui/object_inspector/chart_gallery.rs +++ b/crates/app/src/ui/object_inspector/chart_gallery.rs @@ -1,12 +1,11 @@ //! The Chart type gallery: chart selection chips plus per-chart options //! (bins, stacking, colormap, 3D view), committing through undoable actions. -use egui::{DragValue, Ui}; +use egui::Ui; use plotx_core::actions::Action; use plotx_core::state::{ ChartSpec, Dataset, ObjectId, PlotxApp, PresentationProfile, RequestedChart, chart_type, - chart_types_for_capabilities, default_chart_type, default_encoding, encoding_descriptors_for, - field_peak_magnitude, + default_chart_type, default_encoding, encoding_descriptors_for, field_peak_magnitude, }; pub(super) fn chart_gallery(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: &mut Ui) { @@ -30,7 +29,6 @@ pub(super) fn chart_gallery(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: else { return; }; - let types = chart_types_for_capabilities(&field.capabilities, domain); let current_id = if chart_type(¤t.type_id) .is_some_and(|chart| chart.is_applicable_to(&field.capabilities)) { @@ -39,24 +37,7 @@ pub(super) fn chart_gallery(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: default_chart_type(domain).id.to_owned() }; - ui.separator(); - ui.strong("Chart type"); - let mut next: Option = None; - if types.len() > 1 { - ui.horizontal_wrapped(|ui| { - for ct in &types { - if ui.selectable_label(current_id == ct.id, ct.name).clicked() { - next = Some(ChartSpec { - type_id: ct.id.to_owned(), - ..current.clone() - }); - } - } - }); - } else if let Some(ct) = types.first() { - ui.weak(ct.name); - } let needs_column = chart_type(¤t_id) .map(|c| c.needs_column) @@ -104,7 +85,6 @@ pub(super) fn chart_gallery(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: let selected = columns.get(sel).map(|(id, _)| *id); if selected != current.column { next = Some(ChartSpec { - type_id: current_id.clone(), column: selected, ..current.clone() }); @@ -112,8 +92,6 @@ pub(super) fn chart_gallery(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: } } - chart_options(¤t, ¤t_id, &mut next, ui); - if let Some(after) = next && after != current { @@ -173,113 +151,6 @@ fn visual_encodings( encoding_descriptors_for(capabilities) } -/// Per-chart-type options below the gallery. Drag edits only commit on -/// release/defocus so a slider gesture is one undo step, not dozens. -fn chart_options(current: &ChartSpec, current_id: &str, next: &mut Option, ui: &mut Ui) { - match current_id { - "table_histogram" => { - ui.horizontal(|ui| { - let mut auto = current.bins.is_none(); - if ui.checkbox(&mut auto, "Auto bins").changed() { - *next = Some(ChartSpec { - bins: if auto { None } else { Some(20) }, - ..current.clone() - }); - } - if let Some(bins) = current.bins - && let Some(value) = deferred_drag(ui, "chart_bins", bins, |ui, value| { - ui.add(DragValue::new(value).range(1..=512)) - }) - && value != bins - { - *next = Some(ChartSpec { - bins: Some(value), - ..current.clone() - }); - } - }); - } - "table_bar_grouped" => { - let mut stacked = current.stacked; - if ui.checkbox(&mut stacked, "Stacked").changed() { - *next = Some(ChartSpec { - stacked, - ..current.clone() - }); - } - } - "table_heatmap" | "table_surface" => { - ui.horizontal(|ui| { - ui.label("Colormap"); - egui::ComboBox::from_id_salt(("chart_colormap", current_id)) - .selected_text(current.colormap.name()) - .show_ui(ui, |ui| { - for cm in plotx_figure::ColormapId::ALL { - if ui - .selectable_label(current.colormap == cm, cm.name()) - .clicked() - { - *next = Some(ChartSpec { - colormap: cm, - ..current.clone() - }); - } - } - }); - }); - if current_id == "table_surface" { - ui.horizontal(|ui| { - ui.label("View"); - if let Some(angles) = - deferred_drag(ui, "chart_view", current.view_angles, |ui, angles| { - let azimuth = ui.add( - DragValue::new(&mut angles[0]) - .range(-180.0..=180.0) - .suffix("°"), - ); - let elevation = ui - .add(DragValue::new(&mut angles[1]).range(5.0..=90.0).suffix("°")); - azimuth | elevation - }) - && angles != current.view_angles - { - *next = Some(ChartSpec { - view_angles: angles, - ..current.clone() - }); - } - }); - } - } - _ => {} - } -} - -/// Drive drag-value widgets against a scratch copy held in egui temp memory, -/// so the committed model can stay untouched during the gesture (a live drag -/// would otherwise be reset by the unchanged model every frame). Returns the -/// edited value once the gesture ends (drag release / focus loss). -fn deferred_drag( - ui: &mut Ui, - key: &'static str, - committed: T, - add_widgets: impl FnOnce(&mut Ui, &mut T) -> egui::Response, -) -> Option { - let id = ui.id().with(key); - let mut value = ui - .data_mut(|d| d.get_temp::(id)) - .unwrap_or_else(|| committed.clone()); - let response = add_widgets(ui, &mut value); - if response.drag_stopped() || response.lost_focus() { - ui.data_mut(|d| d.remove_temp::(id)); - return Some(value); - } - if response.dragged() || response.has_focus() || response.changed() { - ui.data_mut(|d| d.insert_temp(id, value)); - } - None -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/app/src/ui/object_inspector/data.rs b/crates/app/src/ui/object_inspector/data.rs new file mode 100644 index 00000000..1b6b5262 --- /dev/null +++ b/crates/app/src/ui/object_inspector/data.rs @@ -0,0 +1,186 @@ +//! Data binding, series, and stack controls for plot objects. + +use super::*; + +/// Binding edits rebuild through `SetDataBinding`; stack-layout edits through +/// `SetStackSpec`. +pub(super) fn data_section(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: &mut Ui) { + let Some((binding, stack)) = app.doc.canvases[ci] + .object(object) + .and_then(|o| o.plot()) + .map(|p| (p.binding.clone(), p.stack)) + else { + return; + }; + + ui.separator(); + ui.strong("Data"); + + let is_stack = binding.series.len() > 1 && app.series_stackable(&binding); + let count = binding.series.len(); + let mut next_binding: Option = None; + let mut next_stack: Option = None; + for (i, sb) in binding.series.iter().enumerate() { + ui.horizontal(|ui| { + if is_stack { + let mut visible = sb.visible; + if ui + .checkbox(&mut visible, "") + .on_hover_text("Visible") + .changed() + && let Some(target) = app.series_target(ci, object, sb.id) + && let Ok(commit) = app.plan_property_write( + plotx_core::properties::object::SERIES_VISIBLE, + std::slice::from_ref(&target), + &plotx_core::properties::PropertyValue::Bool(visible), + ) + { + app.commit_property(commit); + } + } + let color = sb + .primary_color() + .unwrap_or(OVERLAY_PALETTE[i % OVERLAY_PALETTE.len()]); + swatch(ui, color); + let name = app + .doc + .dataset_index(sb.source.resource) + .and_then(|index| app.doc.datasets.get(index)) + .map(Dataset::display_name) + .unwrap_or_default(); + let label = if i == 0 { + format!("{name} (primary)") + } else { + name + }; + if is_stack { + if ui + .selectable_label(stack.active == Some(i), label) + .on_hover_text("Highlight this trace") + .clicked() + { + next_stack = Some(StackSpec { + active: Some(i), + ..stack + }); + } + } else { + ui.label(label); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if count > 1 && ui.small_button(icon::X).on_hover_text("Remove").clicked() { + let mut b = binding.clone(); + b.series.remove(i); + next_binding = Some(b); + } + if is_stack { + if ui + .add_enabled(i + 1 < count, egui::Button::new(icon::CARET_DOWN).small()) + .on_hover_text("Move down") + .clicked() + { + let mut b = binding.clone(); + b.series.swap(i, i + 1); + next_binding = Some(b); + } + if ui + .add_enabled(i > 0, egui::Button::new(icon::CARET_UP).small()) + .on_hover_text("Move up") + .clicked() + { + let mut b = binding.clone(); + b.series.swap(i, i - 1); + next_binding = Some(b); + } + if matches!(sb.encoding, plotx_figure::SeriesEncoding::Line(_)) { + let mut scale = sb.line_scale(); + if ui + .add(DragValue::new(&mut scale).speed(0.02).range(0.01..=100.0)) + .on_hover_text("Scale") + .changed() + { + let mut b = binding.clone(); + if let plotx_figure::SeriesEncoding::Line(line) = + &mut b.series[i].encoding + { + line.scale = scale; + } + next_binding = Some(b); + } + } + } else if i != 0 && ui.small_button("Primary").clicked() { + let mut b = binding.clone(); + b.series.swap(0, i); + next_binding = Some(b); + } + }); + }); + } + + let candidates = app.stack_candidates(&binding); + if binding + .primary_dataset() + .and_then(|id| app.doc.dataset_by_id(id)) + .map(Dataset::domain) + .is_some_and(|d| d.stack_kind().is_some()) + { + if candidates.is_empty() { + ui.weak("No other datasets to stack."); + } else { + egui::ComboBox::from_id_salt("object_add_series") + .selected_text("Add series…") + .show_ui(ui, |ui| { + for di in &candidates { + let label = app.doc.datasets[*di].display_name(); + if ui.selectable_label(false, label).clicked() { + let mut b = binding.clone(); + let Some(series_id) = app + .doc + .canvases + .get_mut(ci) + .and_then(|canvas| canvas.object_mut(object)) + .and_then(|object| object.plot_mut()) + .map(|plot| plot.allocate_series_id()) + else { + continue; + }; + if let Some(mut series) = + SeriesBinding::from_dataset(&app.doc.datasets[*di]) + { + series.id = series_id; + b.series.push(series); + next_binding = Some(b); + } + } + } + }); + } + } else { + ui.weak("Stacking is available for line-series plots."); + } + + if let Some(after) = next_binding + && after != binding + { + app.execute_action(Action::set_data_binding(ci, object, binding, after)); + app.session.status = "Updated plot data.".to_owned(); + } else if let Some(after) = next_stack + && after != stack + { + app.execute_action(Action::set_stack_spec(ci, object, stack, after)); + app.session.status = "Updated stack layout.".to_owned(); + } + + crate::ui::properties::panel::stack_section(app, ci, &[object], ui); + crate::ui::properties::panel::chart_section(app, ci, &[object], ui); + chart_gallery(app, ci, object, ui); +} + +fn swatch(ui: &mut Ui, color: Color) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(14.0, 10.0), egui::Sense::hover()); + ui.painter().rect_filled( + rect, + 2.0, + egui::Color32::from_rgb(color.r, color.g, color.b), + ); +} diff --git a/crates/app/src/ui/object_inspector/edits.rs b/crates/app/src/ui/object_inspector/edits.rs new file mode 100644 index 00000000..18f0ed7b --- /dev/null +++ b/crates/app/src/ui/object_inspector/edits.rs @@ -0,0 +1,111 @@ +//! Shared inspector edit coalescing, selection, and style helpers. + +use super::*; + +pub(super) fn format_once_section(app: &mut PlotxApp, ci: usize, primary: ObjectId, ui: &mut Ui) { + let noun = app.doc.canvases[ci] + .object(primary) + .map(kind_noun) + .unwrap_or("objects"); + ui.horizontal_wrapped(|ui| { + if ui.button(format!("Apply to all {noun}")).clicked() { + app.apply_style_to_kind(ci, primary); + } + if ui.button(format!("Set as default {noun}")).clicked() { + app.set_style_default(ci, primary); + } + }); +} + +fn kind_noun(o: &CanvasObject) -> &'static str { + if o.is_panel_label() { + "panel labels" + } else if o.text().is_some() { + "text" + } else if o.shape().is_some() { + "shapes" + } else { + "objects" + } +} + +pub(super) fn kind_targets( + app: &PlotxApp, + ci: usize, + ids: &[ObjectId], + pred: impl Fn(&CanvasObject) -> bool, +) -> Vec { + ids.iter() + .copied() + .filter(|&id| { + app.doc.canvases[ci] + .object(id) + .map(|o| !o.locked && pred(o)) + .unwrap_or(false) + }) + .collect() +} + +pub(super) fn selection_label(app: &PlotxApp, ci: usize, ids: &[ObjectId]) -> String { + if ids.len() > 1 { + format!("{} selected", ids.len()) + } else { + app.doc.canvases[ci] + .object(ids[0]) + .map(|o| o.name.clone()) + .unwrap_or_default() + } +} + +/// Snapshot the touched objects' pre-edit frames once per interaction; later +/// widget frames in the same drag see it already set and +/// leave the earliest snapshot in place. +pub(super) fn note_inspector_edit(app: &mut PlotxApp, ci: usize, ids: &[ObjectId]) { + if app.session.ui.inspector_edit.is_some() { + return; + } + let Some(c) = app.doc.canvases.get(ci) else { + return; + }; + let frames = ids + .iter() + .filter_map(|&id| c.object(id).map(|o| (id, o.frame))) + .collect(); + app.session.ui.inspector_edit = Some(PendingInspectorEdit { canvas: ci, frames }); +} + +/// Commit the coalesced interaction once it ends (pointer released and no text +/// field focused), emitting at most one frame action. Style fields use the +/// property catalog's independent gesture coalescing. +pub(super) fn flush_inspector_edit(app: &mut PlotxApp, ui: &Ui, text_focused: bool) { + if app.session.ui.inspector_edit.is_none() { + return; + } + if text_focused || ui.input(|i| i.pointer.any_down()) { + return; + } + let Some(edit) = app.session.ui.inspector_edit.take() else { + return; + }; + let ci = edit.canvas; + let (fb, fa) = { + let Some(c) = app.doc.canvases.get(ci) else { + return; + }; + let mut fb = Vec::new(); + let mut fa = Vec::new(); + for &(id, before) in &edit.frames { + if let Some(o) = c.object(id) + && o.frame != before + { + fb.push((id, before)); + fa.push((id, o.frame)); + } + } + (fb, fa) + }; + + if !fb.is_empty() { + app.execute_action(Action::set_object_frames(ci, fb, fa)); + } +} diff --git a/crates/app/src/ui/object_inspector/geometry.rs b/crates/app/src/ui/object_inspector/geometry.rs new file mode 100644 index 00000000..f8065222 --- /dev/null +++ b/crates/app/src/ui/object_inspector/geometry.rs @@ -0,0 +1,52 @@ +//! Geometry controls for selected canvas objects. + +use super::edits::note_inspector_edit; +use super::*; + +pub(super) fn geometry_section(app: &mut PlotxApp, ci: usize, ids: &[ObjectId], ui: &mut Ui) { + let primary = ids[0]; + let Some(o) = app.doc.canvases[ci].object(primary) else { + return; + }; + let enabled = !o.locked; + let frame = o.frame; + let mut x = frame.x / MM_TO_PT; + let mut y = frame.y / MM_TO_PT; + let mut w = frame.width / MM_TO_PT; + let mut h = frame.height / MM_TO_PT; + + egui::Grid::new("object_geometry") + .num_columns(4) + .spacing([6.0, 4.0]) + .show(ui, |ui| { + ui.label("X"); + let rx = ui.add_enabled(enabled, mm_drag(&mut x)); + ui.label("Y"); + let ry = ui.add_enabled(enabled, mm_drag(&mut y)); + ui.end_row(); + ui.label("W"); + let rw = ui.add_enabled(enabled, mm_drag(&mut w)); + ui.label("H"); + let rh = ui.add_enabled(enabled, mm_drag(&mut h)); + ui.end_row(); + + if rx.changed() || ry.changed() || rw.changed() || rh.changed() { + note_inspector_edit(app, ci, ids); + let new = ObjectFrame::new(x * MM_TO_PT, y * MM_TO_PT, w * MM_TO_PT, h * MM_TO_PT); + app.set_object_frame(ci, primary, new); + } + }); + + if !enabled { + ui.weak("Locked — unlock to edit geometry."); + } else if ids.len() > 1 { + ui.weak("Geometry edits the primary selection."); + } +} + +fn mm_drag(value: &mut f32) -> DragValue<'_> { + DragValue::new(value) + .speed(0.5) + .max_decimals(1) + .suffix(" mm") +} diff --git a/crates/app/src/ui/object_inspector/panel_note.rs b/crates/app/src/ui/object_inspector/panel_note.rs deleted file mode 100644 index 8792647a..00000000 --- a/crates/app/src/ui/object_inspector/panel_note.rs +++ /dev/null @@ -1,98 +0,0 @@ -use egui::Ui; -use egui_phosphor::regular as icon; -use plotx_core::actions::Action; -use plotx_core::state::{ObjectId, PlotxApp}; - -pub(super) fn panel_note_section( - app: &mut PlotxApp, - ci: usize, - object: ObjectId, - ui: &mut Ui, -) -> bool { - let Some((letter, panel)) = app.doc.canvases[ci] - .object(object) - .and_then(|o| o.plot()) - .map(|p| { - ( - app.doc.canvases[ci] - .panel_letter(object) - .unwrap_or_else(|| "?".to_owned()), - p.panel.clone(), - ) - }) - else { - return false; - }; - - ui.horizontal(|ui| { - ui.strong("Panel note"); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.weak(letter); - }); - }); - - let mut buffer = panel.user_note.clone(); - let resp = ui.add( - egui::TextEdit::multiline(&mut buffer) - .desired_width(f32::INFINITY) - .desired_rows(3) - .hint_text("Shown in the notes below the page"), - ); - if resp.gained_focus() { - commit_panel_note_edit(app); - app.session.ui.note_edit_before = Some((ci, object, panel.clone())); - } - if resp.changed() - && let Some(plot) = app.doc.canvases[ci] - .object_mut(object) - .and_then(|o| o.plot_mut()) - { - plot.panel.user_note = buffer; - app.doc.dirty = true; - } - if resp.lost_focus() { - commit_panel_note_edit(app); - } - - ui.horizontal(|ui| { - if ui - .small_button(icon::PENCIL_SIMPLE) - .on_hover_text("Edit in dialog") - .clicked() - { - crate::ui::canvas::open_panel_note_editor(app, ci, object); - } - if ui - .add_enabled( - !panel.user_note.trim().is_empty(), - egui::Button::new(icon::X).small(), - ) - .on_hover_text("Clear note") - .clicked() - { - let mut after = panel.clone(); - after.user_note.clear(); - app.execute_action(Action::set_panel_meta(ci, object, panel, after)); - app.session.status = "Panel note cleared.".to_owned(); - } - }); - - resp.has_focus() -} - -pub(super) fn commit_panel_note_edit(app: &mut PlotxApp) { - let Some((ci, id, before)) = app.session.ui.note_edit_before.take() else { - return; - }; - let Some(after) = app - .doc - .canvases - .get(ci) - .and_then(|c| c.object(id)) - .and_then(|o| o.plot()) - .map(|p| p.panel.clone()) - else { - return; - }; - app.execute_action(Action::set_panel_meta(ci, id, before, after)); -} diff --git a/crates/app/src/ui/object_inspector_tests.rs b/crates/app/src/ui/object_inspector_tests.rs new file mode 100644 index 00000000..945f8b90 --- /dev/null +++ b/crates/app/src/ui/object_inspector_tests.rs @@ -0,0 +1,100 @@ +//! Object inspector rendering and target-filter tests. + +use super::*; + +#[test] +fn renders_safely_during_active_canvas_transition() { + let mut app = PlotxApp::new(); + app.session.active_canvas = Some(0); + assert!(app.doc.canvases.is_empty()); + assert!(app.session.ui.selection.objects().is_empty()); + + let ctx = egui::Context::default(); + let _ = ctx.run_ui(egui::RawInput::default(), |ui| render(&mut app, ui)); +} + +#[test] +fn property_target_filter_excludes_locked_plot_objects() { + let (mut app, ids) = crate::ui::properties::fixture::contour_page(1); + let object = ids[0]; + app.doc.canvases[0] + .object_mut(object) + .expect("the fixture plot exists") + .locked = true; + let targets = kind_targets(&app, 0, &ids, |candidate| candidate.plot().is_some()); + assert!( + targets.is_empty(), + "a locked plot must be excluded before catalog targets are built" + ); +} + +#[test] +fn frame_drag_with_mid_gesture_catalog_style_write_keeps_two_independent_undo_records() { + let mut app = PlotxApp::new(); + let mut canvas = plotx_core::state::CanvasDocument::new("objects".to_owned(), [120.0, 80.0]); + let id = canvas.allocate_object_id(); + canvas.objects.push(plotx_core::state::CanvasObject { + id, + name: "Shape".to_owned(), + frame: ObjectFrame::new(1.0, 2.0, 30.0, 20.0), + locked: false, + visible: true, + group: None, + kind: plotx_core::state::CanvasObjectKind::Shape(plotx_core::state::ShapeObject::new( + plotx_core::state::ShapeKind::Rect, + )), + }); + app.doc.canvases.push(canvas); + app.session.active_canvas = Some(0); + let frame_before = app.doc.canvases[0].object(id).unwrap().frame; + let width_before = app.doc.canvases[0] + .object(id) + .unwrap() + .shape() + .unwrap() + .stroke_width; + + edits::note_inspector_edit(&mut app, 0, &[id]); + let frame_after = ObjectFrame::new(8.0, 9.0, 40.0, 25.0); + app.set_object_frame(0, id, frame_after); + + let target = app.object_target(0, id).unwrap(); + let commit = app + .plan_property_write( + plotx_core::properties::object::SHAPE_STROKE_WIDTH, + std::slice::from_ref(&target), + &plotx_core::properties::PropertyValue::Float(6.0), + ) + .unwrap(); + app.commit_property(commit); + assert_eq!(app.session.undo_stack.len(), 1); + + let ctx = egui::Context::default(); + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + flush_inspector_edit(&mut app, ui, false); + }); + assert_eq!(app.session.undo_stack.len(), 2); + + app.undo(); + assert_eq!(app.doc.canvases[0].object(id).unwrap().frame, frame_before); + assert_eq!( + app.doc.canvases[0] + .object(id) + .unwrap() + .shape() + .unwrap() + .stroke_width, + 6.0, + "undoing the frame record must not touch the independent style record" + ); + app.undo(); + assert_eq!( + app.doc.canvases[0] + .object(id) + .unwrap() + .shape() + .unwrap() + .stroke_width, + width_before + ); +} diff --git a/crates/app/src/ui/primary_sidebar.rs b/crates/app/src/ui/primary_sidebar.rs index 5072f7ca..caf9a46c 100644 --- a/crates/app/src/ui/primary_sidebar.rs +++ b/crates/app/src/ui/primary_sidebar.rs @@ -253,17 +253,14 @@ fn object_list(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { .checkbox(&mut locked, "") .on_hover_text("Locked") .changed() + && let Some(target) = app.object_target(ci, object_id) + && let Ok(commit) = app.plan_property_write( + plotx_core::properties::object::LOCKED, + std::slice::from_ref(&target), + &plotx_core::properties::PropertyValue::Bool(locked), + ) { - let before = ( - app.doc.canvases[ci].objects[oi].visible, - app.doc.canvases[ci].objects[oi].locked, - ); - app.execute_action(Action::set_object_flags( - ci, - object_id, - before, - (before.0, locked), - )); + app.commit_property(commit); } if ui .add_enabled( diff --git a/crates/app/src/ui/properties/control.rs b/crates/app/src/ui/properties/control.rs new file mode 100644 index 00000000..938970b7 --- /dev/null +++ b/crates/app/src/ui/properties/control.rs @@ -0,0 +1,799 @@ +//! Property row controls, value descriptions, and gesture edges. + +use super::*; +use plotx_core::state::CanvasSizeUnit; +use std::borrow::Cow; + +pub(super) struct RowEdits<'a> { + pub pending: &'a mut Option, + pub gesture: &'a mut Option<(PropertyId, GestureEdge)>, + pub text_edits: &'a mut Vec, +} + +pub(super) fn property_row( + row: &Row, + focus: Option, + now: f64, + edits: &mut RowEdits<'_>, + targets: &[TargetRef], + length_unit: CanvasSizeUnit, + ui: &mut Ui, +) { + let highlighted = focus + .is_some_and(|focus| focus.property == row.presentation.id && now < focus.highlight_until); + let response = ui + .scope(|ui| { + ui.horizontal(|ui| { + ui.label(row.presentation.localized_label.get()) + .on_hover_text(row.definition.canonical_label); + match row.representative.availability { + plotx_core::properties::Availability::Editable => { + control( + row, + edits.pending, + edits.gesture, + targets, + edits.text_edits, + length_unit, + ui, + ); + if row.modified() { + modified_marker(row, edits.pending, ui); + } + } + plotx_core::properties::Availability::Disabled(reason) => { + ui.add_enabled_ui(false, |ui| { + control( + row, + edits.pending, + edits.gesture, + targets, + edits.text_edits, + length_unit, + ui, + ); + if row.modified() { + modified_marker(row, edits.pending, ui); + } + }) + .response + .on_disabled_hover_text(reason); + } + plotx_core::properties::Availability::ReadOnly => { + ui.weak(describe( + row, + row.value().unwrap_or_else(|| { + row.editing_value() + .expect("a resolved read-only row has a value") + }), + )); + } + } + }); + }) + .response; + if highlighted { + ui.painter().rect_stroke( + response.rect.expand(2.0), + 4.0, + egui::Stroke::new(1.5_f32, ui.visuals().selection.bg_fill), + egui::StrokeKind::Outside, + ); + ui.ctx().request_repaint(); + } + if focus.is_some_and(|focus| focus.property == row.presentation.id && focus.pending) { + response.scroll_to_me(None); + } +} + +/// Compact form of the same row for a list item that already supplies the +/// property's label through its surrounding context. +pub(super) fn property_row_inline( + row: &Row, + focus: Option, + now: f64, + edits: &mut RowEdits<'_>, + targets: &[TargetRef], + length_unit: CanvasSizeUnit, + ui: &mut Ui, +) { + let highlighted = focus + .is_some_and(|focus| focus.property == row.presentation.id && now < focus.highlight_until); + let response = ui + .horizontal(|ui| { + control( + row, + edits.pending, + edits.gesture, + targets, + edits.text_edits, + length_unit, + ui, + ); + if row.modified() { + modified_marker(row, edits.pending, ui); + } + }) + .response + .on_hover_text(row.definition.canonical_label); + if highlighted { + ui.painter().rect_stroke( + response.rect.expand(2.0), + 4.0, + egui::Stroke::new(1.5_f32, ui.visuals().selection.bg_fill), + egui::StrokeKind::Outside, + ); + ui.ctx().request_repaint(); + } +} + +/// The "modified" affordance: a marker whose tooltip names the default, and a +/// one-click reset back to it. +fn modified_marker(row: &Row, pending: &mut Option, ui: &mut Ui) { + let default = row + .representative + .default_value + .as_ref() + .map(|value| describe(row, value)) + .unwrap_or(Cow::Borrowed("no default")); + let hint = if row.mixed() { + format!( + "{} Default: {default}", + no_single_value_hint(row.set.applicable_targets.len(), row.definition.copies) + ) + } else { + format!("Changed from the default: {default}") + }; + ui.label(icon::DOT_OUTLINE).on_hover_text(&hint); + if ui + .small_button(icon::ARROW_COUNTER_CLOCKWISE) + .on_hover_text(format!("Reset to {default}")) + .clicked() + { + *pending = Some(Pending::Reset(row.presentation.id)); + } +} + +/// Draw the control for one row. +/// +/// When the row has no single value the widget still edits — one gesture must +/// be enough to make the whole selection agree — but it displays nothing that +/// could be read as the current setting: an em dash instead of a number or a +/// choice, and no checkbox state at all. +fn control( + row: &Row, + pending: &mut Option, + gesture: &mut Option<(PropertyId, GestureEdge)>, + targets: &[TargetRef], + text_edits: &mut Vec, + length_unit: CanvasSizeUnit, + ui: &mut Ui, +) { + let mixed = row.mixed(); + let Some(value) = row.editing_value() else { + ui.weak("unavailable"); + return; + }; + match (&row.representative.schema, value) { + (ResolvedSchema::Bool, PropertyValue::Bool(current)) => { + if mixed { + // A checkbox has no third state, and an unticked box would be a + // claim about the selection. Two unselected choices are not. + for (label, next) in [("On", true), ("Off", false)] { + if ui.selectable_label(false, label).clicked() { + *pending = Some(Pending::Write( + row.presentation.id, + PropertyValue::Bool(next), + )); + } + } + } else { + let mut current = *current; + if ui.checkbox(&mut current, "").changed() { + *pending = Some(Pending::Write( + row.presentation.id, + PropertyValue::Bool(current), + )); + } + } + } + (ResolvedSchema::Text, PropertyValue::Text(current)) => { + text_control(row, current, mixed, targets, text_edits, pending, ui); + } + (ResolvedSchema::Int { min, max, unit }, PropertyValue::Int(current)) => { + let mut current = *current; + let drag = DragValue::new(&mut current).speed(0.25).range(*min..=*max); + let response = ui.add(hide_value(drag, mixed)); + note_gesture(row, &response, gesture); + if response.changed() { + *pending = Some(Pending::Write( + row.presentation.id, + PropertyValue::Int(current), + )); + } + draw_unit(ui, unit); + } + ( + ResolvedSchema::IntWithDrag { + min, + max, + drag_step, + unit, + }, + PropertyValue::Int(current), + ) => { + let mut current = *current; + let drag = DragValue::new(&mut current) + .speed(*drag_step) + .range(*min..=*max); + let response = ui.add(hide_value(drag, mixed)); + note_gesture(row, &response, gesture); + if response.changed() { + *pending = Some(Pending::Write( + row.presentation.id, + PropertyValue::Int(current), + )); + } + draw_unit(ui, unit); + } + ( + ResolvedSchema::SteppedInt { + min, + max, + step, + drag_step, + unit, + }, + PropertyValue::Int(current), + ) => { + let mut current = *current; + let drag = DragValue::new(&mut current) + .speed(*drag_step) + .range(*min..=*max); + let response = ui.add(hide_value(drag, mixed)); + note_gesture(row, &response, gesture); + if response.changed() { + current = snapped_stepped_int(current, *min, *max, *step); + *pending = Some(Pending::Write( + row.presentation.id, + PropertyValue::Int(current), + )); + } + draw_unit(ui, unit); + } + (ResolvedSchema::Float { bounds, display }, PropertyValue::Float(current)) => { + float_control( + row, + FloatControlInput { + bounds: *bounds, + display: *display, + current: *current, + mixed, + length_unit, + }, + pending, + gesture, + ui, + ); + } + (ResolvedSchema::Enum { variants }, PropertyValue::Enum(current)) => { + // Nothing is current when the sources disagree, so no variant is + // marked selected and the box names none of them. + let current = (!mixed).then_some(*current); + let selected = current + .map(|current| { + variants + .iter() + .find(|variant| variant.id == current) + .map(|variant| variant.canonical_label) + .unwrap_or(current) + }) + .unwrap_or(NO_SINGLE_VALUE); + egui::ComboBox::from_id_salt(row.presentation.id.as_str()) + .selected_text(selected) + .show_ui(ui, |ui| { + for variant in variants { + if ui + .selectable_label(current == Some(variant.id), variant.canonical_label) + .clicked() + { + *pending = Some(Pending::Write( + row.presentation.id, + PropertyValue::Enum(variant.id), + )); + } + } + }); + if let Some(PropertyReadout::ZeroFillTarget(readout)) = row.readout { + ui.weak(format!("{} {} points", icon::ARROW_RIGHT, readout.points)); + } + } + (ResolvedSchema::Color, PropertyValue::Color(current)) => { + let mut rgb = [current.r, current.g, current.b]; + if ui.color_edit_button_srgb(&mut rgb).changed() { + *pending = Some(Pending::Write( + row.presentation.id, + PropertyValue::Color(plotx_figure::Color::rgb(rgb[0], rgb[1], rgb[2])), + )); + } + } + // A control and a value of different shapes means the schema and the + // domain model disagree; say so rather than drawing a wrong widget. + _ => { + ui.weak("unavailable"); + } + } + if mixed { + ui.weak("mixed").on_hover_text(no_single_value_hint( + row.set.applicable_targets.len(), + row.definition.copies, + )); + } +} + +fn text_control( + row: &Row, + current: &str, + mixed: bool, + targets: &[TargetRef], + text_edits: &mut Vec, + pending: &mut Option, + ui: &mut Ui, +) { + text_edits + .retain(|edit| edit.property != row.presentation.id || edit.targets.as_slice() == targets); + let index = text_edits + .iter() + .position(|edit| edit.property == row.presentation.id && edit.targets.as_slice() == targets) + .unwrap_or_else(|| { + text_edits.push(PropertyTextEditState { + property: row.presentation.id, + targets: targets.to_vec(), + text: if mixed { + String::new() + } else { + current.to_owned() + }, + editing: false, + }); + text_edits.len() - 1 + }); + let edit = &mut text_edits[index]; + if !edit.editing { + let shown = if mixed { "" } else { current }; + if edit.text != shown { + edit.text.clear(); + edit.text.push_str(shown); + } + } + let hint = if mixed { + NO_SINGLE_VALUE + } else { + row.representative + .default_value + .as_ref() + .and_then(PropertyValue::as_text) + .unwrap_or("") + }; + let response = ui.add( + egui::TextEdit::singleline(&mut edit.text) + .hint_text(hint) + .desired_width(132.0), + ); + if response.gained_focus() { + edit.editing = true; + } + let enter = response.has_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter)); + if enter { + ui.memory_mut(|memory| memory.surrender_focus(response.id)); + } + if should_submit_text_edit( + &mut edit.editing, + response.changed(), + response.lost_focus(), + enter, + ) { + *pending = Some(Pending::Write( + row.presentation.id, + PropertyValue::Text(edit.text.clone()), + )); + } +} + +fn should_submit_text_edit( + editing: &mut bool, + _changed: bool, + lost_focus: bool, + enter: bool, +) -> bool { + let submit = *editing && (lost_focus || enter); + if submit { + *editing = false; + } + submit +} + +/// The drag notch this row's definition declares, if it declares one. +fn declared_drag_step(row: &Row) -> Option { + match row.definition.value_schema { + ValueSchema::Float { drag_step, .. } => drag_step, + _ => None, + } +} + +fn float_control( + row: &Row, + input: FloatControlInput, + pending: &mut Option, + gesture: &mut Option<(PropertyId, GestureEdge)>, + ui: &mut Ui, +) { + let FloatControlInput { + bounds, + display, + current, + mixed, + length_unit, + } = input; + let projection = FloatControlProjection::new( + row.presentation.uses_canvas_length_unit, + bounds, + display, + current, + length_unit, + declared_drag_step(row), + ); + let mut displayed = projection.displayed; + // Every declared step is in display space. This keeps a degree control at + // half a degree and a logarithmic control at a tenth of a decade while the + // values sent to the property service remain radians and λ respectively. + let mut drag = DragValue::new(&mut displayed) + .speed(projection.speed) + .range(projection.range.clone()); + if let Some(decimals) = projection.decimals { + drag = drag.max_decimals(decimals); + } + let response = ui.add(hide_value(drag, mixed)); + note_gesture(row, &response, gesture); + if response.changed() { + let proposed = projection.to_domain(displayed); + let next = admitted_float_from_control( + bounds, + current, + proposed, + display, + projection.domain_step(), + ); + *pending = Some(Pending::Write( + row.presentation.id, + PropertyValue::Float(next), + )); + } + draw_unit(ui, &projection.caption); + if let Some(PropertyReadout::ContourBase(readout)) = &row.readout + && let Some(suffix) = super::super::readout::resolution_suffix(readout) + { + ui.weak(suffix) + .on_hover_text(super::super::readout::explanation(readout)); + } + if let Some(PropertyReadout::PhasePivotPpm { ppm }) = row.readout { + ui.weak(format!( + "{} {ppm:.3} ppm", + egui_phosphor::regular::ARROW_RIGHT + )); + } +} + +struct FloatControlInput { + bounds: plotx_core::properties::FloatBounds, + display: plotx_core::properties::FloatDisplay, + current: f64, + mixed: bool, + length_unit: CanvasSizeUnit, +} + +struct FloatControlProjection { + displayed: f64, + range: std::ops::RangeInclusive, + speed: f64, + decimals: Option, + caption: std::borrow::Cow<'static, str>, + length_unit: Option, + display: plotx_core::properties::FloatDisplay, +} + +impl FloatControlProjection { + fn new( + uses_canvas_length_unit: bool, + bounds: plotx_core::properties::FloatBounds, + display: plotx_core::properties::FloatDisplay, + current: f64, + length_unit: CanvasSizeUnit, + declared_step: Option, + ) -> Self { + if uses_canvas_length_unit { + debug_assert_eq!(display, plotx_core::properties::FloatDisplay::Linear("mm")); + return Self { + displayed: length_from_mm(length_unit, current), + range: length_from_mm(length_unit, bounds.lowest()) + ..=length_from_mm(length_unit, bounds.max), + speed: length_unit.drag_speed(), + decimals: Some(length_unit.decimals()), + caption: std::borrow::Cow::Borrowed(length_unit.label()), + length_unit: Some(length_unit), + display, + }; + } + let speed = declared_step.unwrap_or_else(|| match display { + plotx_core::properties::FloatDisplay::Log10(_) => 0.1, + _ => ((display.to_display(bounds.max) - display.to_display(bounds.lowest())) / 200.0) + .abs() + .max(1.0e-3), + }); + Self { + displayed: display.to_display(current), + range: display.to_display(bounds.lowest())..=display.to_display(bounds.max), + speed, + decimals: None, + caption: display.caption(), + length_unit: None, + display, + } + } + + fn to_domain(&self, displayed: f64) -> f64 { + self.length_unit + .map(|unit| length_to_mm(unit, displayed)) + .unwrap_or_else(|| self.display.to_domain(displayed)) + } + + fn domain_step(&self) -> f64 { + self.length_unit + .map(|unit| length_to_mm(unit, self.speed)) + .unwrap_or(self.speed) + } +} + +fn length_from_mm(unit: CanvasSizeUnit, value_mm: f64) -> f64 { + f64::from(unit.from_mm(value_mm as f32)) +} + +fn length_to_mm(unit: CanvasSizeUnit, value: f64) -> f64 { + f64::from(unit.to_mm(value as f32)) +} + +fn draw_unit(ui: &mut Ui, unit: &str) { + if !unit.is_empty() { + ui.weak(unit); + } +} + +fn snapped_stepped_int(value: i64, min: i64, max: i64, step: i64) -> i64 { + debug_assert!(step > 0); + let offset = value.saturating_sub(min); + let lower = min.saturating_add(offset.div_euclid(step).saturating_mul(step)); + let upper = lower.saturating_add(step); + let snapped = if value.saturating_sub(lower) < upper.saturating_sub(value) { + lower + } else { + upper + }; + snapped.clamp(min, max - (max - min).rem_euclid(step)) +} + +fn admitted_float_from_control( + bounds: plotx_core::properties::FloatBounds, + current: f64, + proposed: f64, + display: plotx_core::properties::FloatDisplay, + display_step: f64, +) -> f64 { + if bounds.admits(proposed) { + return proposed; + } + if let Some(threshold) = bounds.excluded_magnitude + && proposed.abs() <= threshold + { + let domain_step = (display.to_domain(display.to_display(current) + display_step) - current) + .abs() + .max(threshold.next_up()); + let sign = if proposed < current { -1.0 } else { 1.0 }; + let candidate = sign * domain_step; + if bounds.admits(candidate) { + return candidate; + } + } + current +} + +/// Report a continuous control's drag edges to the section that owns the +/// gesture. Only the edges: what happens in between is an ordinary write. +fn note_gesture( + row: &Row, + response: &egui::Response, + gesture: &mut Option<(PropertyId, GestureEdge)>, +) { + if response.drag_started() { + *gesture = Some((row.presentation.id, GestureEdge::Started)); + } else if response.drag_stopped() { + *gesture = Some((row.presentation.id, GestureEdge::Stopped)); + } +} + +/// Blank a drag control's readout while leaving it draggable and typable. The +/// number it still carries only decides where a drag starts; it is never shown. +fn hide_value(drag: DragValue<'_>, hidden: bool) -> DragValue<'_> { + if hidden { + drag.custom_formatter(|_, _| NO_SINGLE_VALUE.to_owned()) + } else { + drag + } +} + +fn describe<'a>(row: &Row, value: &'a PropertyValue) -> Cow<'a, str> { + match value { + PropertyValue::Bool(value) => Cow::Borrowed(if *value { "on" } else { "off" }), + PropertyValue::Text(value) => Cow::Borrowed(value), + PropertyValue::Int(value) => Cow::Owned(value.to_string()), + PropertyValue::Float(value) => Cow::Owned(format!("{value:.4}")), + // Read the label from the full static variant list, not the ones this + // field permits: a default may name a choice the user cannot switch + // back to by hand, and it still has to be nameable in the tooltip. + PropertyValue::Enum(value) => match row.definition.value_schema { + ValueSchema::Enum { variants } => variants + .iter() + .find(|variant| variant.id == *value) + .map(|variant| Cow::Borrowed(variant.canonical_label)) + .unwrap_or_else(|| Cow::Borrowed(value)), + _ => Cow::Borrowed(value), + }, + PropertyValue::Color(color) => { + Cow::Owned(format!("#{:02x}{:02x}{:02x}", color.r, color.g, color.b)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use plotx_core::properties::{FloatBounds, FloatDisplay, axis}; + + #[test] + fn stepped_drag_candidates_snap_to_the_schema_lattice() { + for candidate in 3..=15 { + let snapped = snapped_stepped_int(candidate, 3, 15, 2); + assert!((3..=15).contains(&snapped)); + assert_eq!((snapped - 3) % 2, 0, "candidate {candidate}"); + } + assert_eq!(snapped_stepped_int(10, 3, 15, 2), 11); + } + + #[test] + fn continuous_text_input_commits_one_undo_record() { + let (mut app, ids) = crate::ui::properties::fixture::contour_page(1); + let target = app.object_target(0, ids[0]).expect("plot target"); + let undo_before = app.session.undo_stack.len(); + let mut editing = true; + let mut buffer = String::new(); + let mut submissions = 0; + + for character in ["A", "x", "i", "s"] { + buffer.push_str(character); + if should_submit_text_edit(&mut editing, true, false, false) { + submissions += 1; + } + } + if should_submit_text_edit(&mut editing, false, true, false) { + submissions += 1; + let commit = app + .plan_property_write( + axis::X_LABEL, + std::slice::from_ref(&target), + &PropertyValue::Text(buffer), + ) + .expect("text edit plans"); + app.commit_property(commit); + } + + assert_eq!(submissions, 1); + assert_eq!(app.session.undo_stack.len(), undo_before + 1); + assert_eq!( + app.doc.canvases[0].objects[0] + .plot() + .expect("plot") + .axis_overrides + .x_label + .as_deref(), + Some("Axis") + ); + app.undo(); + assert_eq!( + app.doc.canvases[0].objects[0] + .plot() + .expect("plot") + .axis_overrides + .x_label, + None + ); + } + + #[test] + fn continuous_text_box_input_commits_one_undo_record() { + use plotx_core::state::{ + CanvasDocument, CanvasObject, CanvasObjectKind, ObjectFrame, TextBox, + }; + let mut app = PlotxApp::new(); + let mut canvas = CanvasDocument::new("text".to_owned(), [120.0, 80.0]); + let id = canvas.allocate_object_id(); + canvas.objects.push(CanvasObject { + id, + name: "Caption".to_owned(), + frame: ObjectFrame::new(0.0, 0.0, 40.0, 20.0), + locked: false, + visible: true, + group: None, + kind: CanvasObjectKind::Text(TextBox::label(String::new())), + }); + app.doc.canvases.push(canvas); + let target = app.object_target(0, id).unwrap(); + let undo_before = app.session.undo_stack.len(); + let mut editing = true; + let mut buffer = String::new(); + let mut submissions = 0; + for character in ["P", "l", "o", "t", "X"] { + buffer.push_str(character); + if should_submit_text_edit(&mut editing, true, false, false) { + submissions += 1; + } + } + if should_submit_text_edit(&mut editing, false, true, false) { + submissions += 1; + let commit = app + .plan_property_write( + plotx_core::properties::object::TEXT, + std::slice::from_ref(&target), + &PropertyValue::Text(buffer), + ) + .unwrap(); + app.commit_property(commit); + } + assert_eq!(submissions, 1); + assert_eq!(app.session.undo_stack.len(), undo_before + 1); + assert_eq!( + app.doc.canvases[0].object(id).unwrap().text().unwrap().text, + "PlotX" + ); + app.undo(); + let text = app.doc.canvases[0].object(id).unwrap().text().unwrap(); + assert!(text.text.is_empty()); + } + + #[test] + fn a_drag_across_zero_never_emits_a_kernel_rejected_divisor() { + let bounds = FloatBounds::excluding_magnitude(-f64::MAX, f64::MAX, f64::MIN_POSITIVE); + let next = admitted_float_from_control(bounds, 1.0, 0.0, FloatDisplay::Linear(""), 0.1); + assert!(next < 0.0, "a downward drag crosses to the negative side"); + assert!(bounds.admits(next)); + assert!(next.abs() > f64::MIN_POSITIVE); + } + + #[test] + fn canvas_length_projection_changes_value_caption_and_write_space_together() { + let projection = FloatControlProjection::new( + true, + FloatBounds::inclusive(0.0, 100.0), + FloatDisplay::Linear("mm"), + 25.4, + CanvasSizeUnit::Inch, + Some(1.0), + ); + assert!((projection.displayed - 1.0).abs() < 1.0e-6); + assert_eq!(projection.caption, "in"); + assert_eq!(projection.decimals, Some(3)); + assert_eq!(projection.speed, CanvasSizeUnit::Inch.drag_speed()); + assert!( + (projection.to_domain(2.0) - 50.8).abs() < 1.0e-5, + "the catalog always receives millimetres" + ); + } +} diff --git a/crates/app/src/ui/properties/discovery.rs b/crates/app/src/ui/properties/discovery.rs index ab25fa4b..63723191 100644 --- a/crates/app/src/ui/properties/discovery.rs +++ b/crates/app/src/ui/properties/discovery.rs @@ -17,7 +17,7 @@ use super::{PRESENTATIONS, PropertyGroup, PropertyPresentation}; use egui::Ui; use plotx_core::automation::{ResourceRef, TargetRef}; use plotx_core::properties::{ - ComponentKind, PropertyId, PropertyStep, ScopeKind, Tier, definition, + ComponentKind, PropertyId, PropertyStep, ScopeKind, Tier, definition, object, }; use plotx_core::state::{ObjectId, PlotxApp}; @@ -148,12 +148,40 @@ pub(crate) fn editable_targets_for_property( app: &PlotxApp, property: PropertyId, ) -> Vec { - match definition(property).map(|definition| definition.applicability.component) { - Some(ComponentKind::Series) => editable_targets(app), + match definition(property) { + Some(definition) + if definition.scope_kind == ScopeKind::Object + && definition.applicability.component == ComponentKind::None + && property != object::LOCKED => + { + selected_object_targets(app, true) + } + Some(definition) if definition.applicability.component == ComponentKind::Series => { + editable_targets(app) + } _ => targets_for_property(app, property), } } +fn selected_object_targets(app: &PlotxApp, unlocked_only: bool) -> Vec { + let Some(canvas) = app.session.active_canvas else { + return Vec::new(); + }; + app.session + .ui + .selection + .objects() + .iter() + .copied() + .filter(|&object| { + app.doc.canvases[canvas] + .object(object) + .is_some_and(|candidate| !unlocked_only || !candidate.locked) + }) + .filter_map(|object| app.object_target(canvas, object)) + .collect() +} + fn targets_of(app: &PlotxApp, objects: Vec) -> Vec { let Some(canvas) = app.session.active_canvas else { return Vec::new(); @@ -180,6 +208,15 @@ pub(crate) fn targets_for_property(app: &PlotxApp, property: PropertyId) -> Vec< ComponentKind::None if definition.scope_kind == ScopeKind::Document => { vec![app.document_target()] } + ComponentKind::None if definition.scope_kind == ScopeKind::Canvas => app + .session + .active_canvas + .and_then(|index| app.doc.canvases.get(index)) + .map(|canvas| vec![app.canvas_target(canvas.resource_id)]) + .unwrap_or_default(), + ComponentKind::None if definition.scope_kind == ScopeKind::Object => { + selected_object_targets(app, false) + } ComponentKind::None => Vec::new(), ComponentKind::Series => selection_targets(app), ComponentKind::ProcessingStep => { diff --git a/crates/app/src/ui/properties/discovery_tests.rs b/crates/app/src/ui/properties/discovery_tests.rs index 75e5fb18..ceb446c6 100644 --- a/crates/app/src/ui/properties/discovery_tests.rs +++ b/crates/app/src/ui/properties/discovery_tests.rs @@ -44,6 +44,7 @@ const NEWCOMER_PRESENTATION: PropertyPresentation = PropertyPresentation { localized_aliases: &[LocalizedText("brand new")], home_route: CONTOUR_HOME, canvas_step: true, + uses_canvas_length_unit: false, }; fn with_newcomer() -> Vec { @@ -91,7 +92,7 @@ fn one_registration_joins_its_group_without_a_second_entry() { "membership is derived, so it grows with the table and nothing else" ); // The group table itself is untouched: the newcomer contributed no entry. - assert_eq!(GROUPS.len(), 4); + assert_eq!(GROUPS.len(), 24); } /// Channel 3: the gesture picks up whichever property declared itself @@ -141,10 +142,11 @@ fn every_group_lands_on_a_property_with_a_home() { } /// A group with no section behind it could never be navigated to, and a -/// section with no group would silently lose channels 2 and 4. Both directions -/// are checked so the omission fails the build instead of the interface. +/// section with no group would silently lose channels 2 and 4. Preferences are +/// the explicit exception: they stay out of the selection-owned canvas menu +/// and share the global Preferences command in the palette and Ribbon. #[test] -fn groups_and_home_sections_correspond() { +fn every_home_section_has_a_group_or_the_explicit_preferences_entry() { for group in GROUPS { assert!( !discovery::members_of(group.section, PRESENTATIONS).is_empty(), @@ -153,28 +155,42 @@ fn groups_and_home_sections_correspond() { ); } 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; + let command_catalog = commands::catalog(&app); + let preferences = commands::describe(&app, CommandId::Preferences); + for panel_route in [ + PanelRoute::SecondarySidebar, + PanelRoute::Processing, + PanelRoute::CanvasSettings, + PanelRoute::Preferences, + ] { + for section in panel_route.sections() { + if panel_route == PanelRoute::Preferences { + assert!( + discovery::group(section).is_none(), + "Preferences section '{section}' must not enter the \ + selection-owned canvas context menu" + ); + assert!( + preferences.enabled && preferences.ribbon.is_some(), + "Preferences section '{section}' is exempt only while the \ + global Preferences command is enabled in the Ribbon" + ); + assert!( + command_catalog + .iter() + .any(|command| command.id == CommandId::Preferences), + "Preferences section '{section}' is exempt only while the \ + Preferences command remains searchable" + ); + } else { + assert!( + discovery::group(section).is_some(), + "section '{section}' in {} declares no group, so it is \ + unreachable from the Ribbon and the context menu", + panel_route.title() + ); + } } - assert!( - discovery::group(entry.home_route.section).is_some(), - "{} lives in section '{}', which declares no group, so it is \ - unreachable from the Ribbon and the context menu", - entry.id, - entry.home_route.section - ); } } diff --git a/crates/app/src/ui/properties/groups.rs b/crates/app/src/ui/properties/groups.rs new file mode 100644 index 00000000..fe57d75b --- /dev/null +++ b/crates/app/src/ui/properties/groups.rs @@ -0,0 +1,268 @@ +use super::*; + +pub(crate) const GROUPS: &[PropertyGroup] = &[ + PropertyGroup { + section: panel::AXIS_SECTION, + label: LocalizedText("Axes"), + icon: egui_phosphor::regular::CHART_POLAR, + ribbon: RibbonSpot { + tab: WorkflowTab::Figure, + group: "Style", + priority: 2, + }, + unavailable_reason: "Select a plot before changing its axis text.", + }, + PropertyGroup { + section: panel::STACK_SECTION, + label: LocalizedText("Stack"), + icon: egui_phosphor::regular::STACK, + ribbon: RibbonSpot { + tab: WorkflowTab::Figure, + group: "Data", + priority: 2, + }, + unavailable_reason: "Select a stackable multi-series plot.", + }, + PropertyGroup { + section: panel::CHART_SECTION, + label: LocalizedText("Chart"), + icon: egui_phosphor::regular::CHART_BAR, + ribbon: RibbonSpot { + tab: WorkflowTab::Figure, + group: "Data", + priority: 2, + }, + unavailable_reason: "Select a plot before changing its chart options.", + }, + PropertyGroup { + section: panel::TEXT_SECTION, + label: LocalizedText("Text"), + icon: egui_phosphor::regular::TEXT_T, + ribbon: RibbonSpot { + tab: WorkflowTab::Figure, + group: "Style", + priority: 2, + }, + unavailable_reason: "Select an unlocked text object.", + }, + PropertyGroup { + section: panel::SHAPE_SECTION, + label: LocalizedText("Shape"), + icon: egui_phosphor::regular::SQUARE, + ribbon: RibbonSpot { + tab: WorkflowTab::Figure, + group: "Style", + priority: 2, + }, + unavailable_reason: "Select an unlocked shape object.", + }, + PropertyGroup { + section: panel::PANEL_SECTION, + label: LocalizedText("Panel"), + icon: egui_phosphor::regular::NOTE, + ribbon: RibbonSpot { + tab: WorkflowTab::Figure, + group: "Style", + priority: 2, + }, + unavailable_reason: "Select a plot before changing its panel note.", + }, + PropertyGroup { + section: panel::OBJECT_SECTION, + label: LocalizedText("Object"), + icon: egui_phosphor::regular::LOCK, + ribbon: RibbonSpot { + tab: WorkflowTab::Arrange, + group: "Object", + priority: 2, + }, + unavailable_reason: "Select an object before changing its flags.", + }, + PropertyGroup { + section: panel::CONTOUR_SECTION, + label: LocalizedText("Contour"), + icon: egui_phosphor::regular::CHART_POLAR, + ribbon: RibbonSpot { + tab: WorkflowTab::Figure, + group: "Style", + priority: 2, + }, + unavailable_reason: "Select a plot whose series draws contours before changing contour levels.", + }, + PropertyGroup { + section: panel::LINE_SECTION, + label: LocalizedText("Line"), + icon: egui_phosphor::regular::LINE_SEGMENT, + ribbon: RibbonSpot { + tab: WorkflowTab::Figure, + group: "Style", + priority: 3, + }, + unavailable_reason: "Select a plot whose series draws lines before changing line style.", + }, + PropertyGroup { + section: panel::TYPOGRAPHY_SECTION, + label: LocalizedText("Figure typography"), + icon: egui_phosphor::regular::TEXT_T, + ribbon: RibbonSpot { + tab: WorkflowTab::Figure, + group: "Style", + priority: 3, + }, + unavailable_reason: "Open a PlotX document before changing figure typography.", + }, + PropertyGroup { + section: panel::CANVAS_MARGINS_SECTION, + label: LocalizedText("Margins and spacing"), + icon: egui_phosphor::regular::ARROWS_OUT, + ribbon: RibbonSpot { + tab: WorkflowTab::Arrange, + group: "Canvas", + priority: 2, + }, + unavailable_reason: "Open a canvas before changing its margins and spacing.", + }, + PropertyGroup { + section: panel::CANVAS_GRID_SECTION, + label: LocalizedText("Layout grid"), + icon: egui_phosphor::regular::DOTS_SIX, + ribbon: RibbonSpot { + tab: WorkflowTab::Arrange, + group: "Canvas", + priority: 2, + }, + unavailable_reason: "Open a canvas before changing its layout grid.", + }, + PropertyGroup { + section: panel::CANVAS_SIZE_SECTION, + label: LocalizedText("Page size"), + icon: egui_phosphor::regular::FRAME_CORNERS, + ribbon: RibbonSpot { + tab: WorkflowTab::Arrange, + group: "Canvas", + priority: 1, + }, + unavailable_reason: "Open a canvas before changing its page size.", + }, + PropertyGroup { + section: panel::CANVAS_CAPTION_SECTION, + label: LocalizedText("Caption and labels"), + icon: egui_phosphor::regular::TEXT_T, + ribbon: RibbonSpot { + tab: WorkflowTab::Figure, + group: "Canvas", + priority: 3, + }, + unavailable_reason: "Open a canvas before changing its caption and panel labels.", + }, + PropertyGroup { + section: panel::APODIZATION_SECTION, + label: LocalizedText("Apodization"), + icon: egui_phosphor::regular::WAVEFORM, + ribbon: RibbonSpot { + tab: WorkflowTab::Process, + group: "Processing", + priority: 1, + }, + unavailable_reason: "Select a dataset with an apodization processing step.", + }, + PropertyGroup { + section: panel::ZERO_FILL_SECTION, + label: LocalizedText("Zero fill"), + icon: egui_phosphor::regular::DOTS_SIX, + ribbon: RibbonSpot { + tab: WorkflowTab::Process, + group: "Processing", + priority: 1, + }, + unavailable_reason: "Select a dataset with a zero-fill processing step.", + }, + PropertyGroup { + section: panel::PHASE_SECTION, + label: LocalizedText("Phase"), + icon: egui_phosphor::regular::WAVE_SINE, + ribbon: RibbonSpot { + tab: WorkflowTab::Process, + group: "Processing", + priority: 1, + }, + unavailable_reason: "Select a dataset with a phase processing step.", + }, + PropertyGroup { + section: panel::BASELINE_SECTION, + label: LocalizedText("Baseline"), + icon: egui_phosphor::regular::LINE_SEGMENT, + ribbon: RibbonSpot { + tab: WorkflowTab::Process, + group: "Processing", + priority: 2, + }, + unavailable_reason: "Select a dataset with a baseline processing step.", + }, + PropertyGroup { + section: panel::REFERENCE_SECTION, + label: LocalizedText("Reference"), + icon: egui_phosphor::regular::TAG, + ribbon: RibbonSpot { + tab: WorkflowTab::Process, + group: "Processing", + priority: 2, + }, + unavailable_reason: "Select a dataset with a reference processing step.", + }, + PropertyGroup { + section: panel::SMOOTH_SECTION, + label: LocalizedText("Smoothing"), + icon: egui_phosphor::regular::WAVE_TRIANGLE, + ribbon: RibbonSpot { + tab: WorkflowTab::Process, + group: "Processing", + priority: 3, + }, + unavailable_reason: "Select a dataset with a smoothing processing step.", + }, + PropertyGroup { + section: panel::NORMALIZE_SECTION, + label: LocalizedText("Normalize"), + icon: egui_phosphor::regular::DIVIDE, + ribbon: RibbonSpot { + tab: WorkflowTab::Process, + group: "Processing", + priority: 3, + }, + unavailable_reason: "Select a dataset with a normalization processing step.", + }, + PropertyGroup { + section: panel::BIN_SECTION, + label: LocalizedText("Binning"), + icon: egui_phosphor::regular::CHART_BAR, + ribbon: RibbonSpot { + tab: WorkflowTab::Process, + group: "Processing", + priority: 3, + }, + unavailable_reason: "Select a dataset with a binning processing step.", + }, + PropertyGroup { + section: panel::PROCESSING_STEP_SECTION, + label: LocalizedText("Processing step"), + icon: egui_phosphor::regular::TOGGLE_LEFT, + ribbon: RibbonSpot { + tab: WorkflowTab::Process, + group: "Processing", + priority: 3, + }, + unavailable_reason: "Select a dataset with a processing step.", + }, + PropertyGroup { + section: panel::PROCESSING_ADVANCED_SECTION, + label: LocalizedText("Advanced processing"), + icon: egui_phosphor::regular::SLIDERS_HORIZONTAL, + ribbon: RibbonSpot { + tab: WorkflowTab::Process, + group: "Processing", + priority: 3, + }, + unavailable_reason: "Select an NMR dataset before changing advanced processing.", + }, +]; diff --git a/crates/app/src/ui/properties/mod.rs b/crates/app/src/ui/properties/mod.rs index 8890be33..27cbd2a6 100644 --- a/crates/app/src/ui/properties/mod.rs +++ b/crates/app/src/ui/properties/mod.rs @@ -8,202 +8,157 @@ //! so a property cannot be Essential in the panel and Advanced in the catalog. pub(crate) mod discovery; +#[path = "groups.rs"] +mod groups; pub(crate) mod panel; pub(crate) mod readout; mod search; +mod types; #[cfg(test)] pub(crate) mod fixture; +pub(crate) use groups::GROUPS; pub(crate) use search::property_hits; +pub use types::*; +#[cfg(test)] +use plotx_core::properties::definition; use plotx_core::properties::{ - PropertyDefinition, PropertyId, Tier, apodization, contour, definition, export_dpi, ilt, line, - typography, + PropertyId, Tier, apodization, app_preferences, axis, baseline, bin, canvas, contour, + export_dpi, group_delay, ilt, line, normalize, object, phase, reference, smooth, step_enabled, + typography, zero_fill, }; 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 -/// rather than a rework of the search index. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct LocalizedText(pub &'static str); +const CONTOUR_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::SecondarySidebar, + section: panel::CONTOUR_SECTION, +}; -impl LocalizedText { - pub const fn get(self) -> &'static str { - self.0 +const LINE_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::SecondarySidebar, + section: panel::LINE_SECTION, +}; + +const AXIS_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::SecondarySidebar, + section: panel::AXIS_SECTION, +}; + +const STACK_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::SecondarySidebar, + section: panel::STACK_SECTION, +}; +const CHART_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::SecondarySidebar, + section: panel::CHART_SECTION, +}; +const TEXT_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::SecondarySidebar, + section: panel::TEXT_SECTION, +}; +const SHAPE_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::SecondarySidebar, + section: panel::SHAPE_SECTION, +}; +const PANEL_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::SecondarySidebar, + section: panel::PANEL_SECTION, +}; +const OBJECT_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::SecondarySidebar, + section: panel::OBJECT_SECTION, +}; + +const fn object_entry( + id: PropertyId, + label: &'static str, + home_route: HomeRoute, +) -> PropertyPresentation { + PropertyPresentation { + id, + localized_label: LocalizedText(label), + localized_aliases: &[], + home_route, + canvas_step: false, + uses_canvas_length_unit: false, } } -/// Which panel owns a property's canonical home. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum PanelRoute { - SecondarySidebar, - Processing, - Preferences, -} +const TYPOGRAPHY_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::SecondarySidebar, + section: panel::TYPOGRAPHY_SECTION, +}; -const PREFERENCES_SECTIONS: &[&str] = &[ - SettingsCategory::General.section_id(), - SettingsCategory::Appearance.section_id(), - SettingsCategory::Processing.section_id(), - SettingsCategory::Export.section_id(), - SettingsCategory::Recent.section_id(), -]; +const CANVAS_MARGINS_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::CanvasSettings, + section: panel::CANVAS_MARGINS_SECTION, +}; -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 - /// test checks. - pub const fn sections(self) -> &'static [&'static str] { - match self { - Self::SecondarySidebar => &[ - panel::CONTOUR_SECTION, - panel::LINE_SECTION, - panel::TYPOGRAPHY_SECTION, - ], - Self::Processing => &[panel::APODIZATION_SECTION], - Self::Preferences => PREFERENCES_SECTIONS, - } - } +const CANVAS_GRID_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::CanvasSettings, + section: panel::CANVAS_GRID_SECTION, +}; - pub const fn title(self) -> &'static str { - match self { - Self::SecondarySidebar => "Object inspector", - Self::Processing => "Processing tools", - Self::Preferences => "Preferences", - } - } -} +const CANVAS_SIZE_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::CanvasSettings, + section: panel::CANVAS_SIZE_SECTION, +}; -/// Where a property is edited. This is data, not code: navigation opens the -/// panel, expands the section and scrolls to the row named by the property id. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct HomeRoute { - pub panel: PanelRoute, - pub section: &'static str, -} +const CANVAS_CAPTION_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::CanvasSettings, + section: panel::CANVAS_CAPTION_SECTION, +}; -/// The interface half of one catalog entry. -#[derive(Clone, Copy, Debug)] -pub struct PropertyPresentation { - pub id: PropertyId, - pub localized_label: LocalizedText, - pub localized_aliases: &'static [LocalizedText], - pub home_route: HomeRoute, - /// Whether the canvas `+` / `-` gesture drives this property (§8.5 - /// channel 3). Declared here, on the property's single registration, so the - /// gesture is derived rather than listed in a table of its own. Most - /// properties have no natural direction, which is why this is an opt-in and - /// not an inference. - pub canvas_step: bool, -} +const APODIZATION_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Processing, + section: panel::APODIZATION_SECTION, +}; -/// Where a group of properties appears in the Ribbon. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct RibbonSpot { - pub tab: WorkflowTab, - pub group: &'static str, - /// Lower values survive longer as the Ribbon's width budget tightens. - pub priority: u8, -} +const ZERO_FILL_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Processing, + section: panel::ZERO_FILL_SECTION, +}; -/// One group of properties with a shared home (§8.5 channel 2 and 4). -/// -/// The Ribbon and the context menu address groups, never single parameters: -/// they are entry maps that jump to the panel section where the controls -/// already live. Membership is not listed here — it is read off the members' -/// home routes — so adding a property to an existing group requires no edit to -/// this table. -#[derive(Clone, Copy, Debug)] -pub struct PropertyGroup { - /// The home-route section its members share. - pub section: &'static str, - pub label: LocalizedText, - pub icon: &'static str, - pub ribbon: RibbonSpot, - /// Why the entry is disabled when nothing in the selection has a member of - /// this group. Starts with a verb and says how to unblock it. - pub unavailable_reason: &'static str, -} +const PHASE_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Processing, + section: panel::PHASE_SECTION, +}; -pub const GROUPS: &[PropertyGroup] = &[ - PropertyGroup { - section: panel::CONTOUR_SECTION, - label: LocalizedText("Contour"), - icon: egui_phosphor::regular::CHART_POLAR, - ribbon: RibbonSpot { - tab: WorkflowTab::Figure, - group: "Style", - priority: 2, - }, - unavailable_reason: "Select a plot whose series draws contours before changing contour levels.", - }, - PropertyGroup { - section: panel::LINE_SECTION, - label: LocalizedText("Line"), - icon: egui_phosphor::regular::LINE_SEGMENT, - ribbon: RibbonSpot { - tab: WorkflowTab::Figure, - group: "Style", - priority: 3, - }, - unavailable_reason: "Select a plot whose series draws lines before changing line style.", - }, - PropertyGroup { - section: panel::TYPOGRAPHY_SECTION, - label: LocalizedText("Figure typography"), - icon: egui_phosphor::regular::TEXT_T, - ribbon: RibbonSpot { - tab: WorkflowTab::Figure, - group: "Style", - priority: 3, - }, - unavailable_reason: "Open a PlotX document before changing figure typography.", - }, - PropertyGroup { - section: panel::APODIZATION_SECTION, - label: LocalizedText("Apodization"), - icon: egui_phosphor::regular::WAVEFORM, - ribbon: RibbonSpot { - tab: WorkflowTab::Process, - group: "Processing", - priority: 1, - }, - unavailable_reason: "Select a dataset with an apodization processing step.", - }, -]; +const BASELINE_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Processing, + section: panel::BASELINE_SECTION, +}; -impl PropertyPresentation { - /// The tier lives on the definition; presentation reads it so the panel - /// budget and the catalog can never disagree. - pub fn tier(&self) -> Option { - definition(self.id).map(|definition| definition.tier) - } +const REFERENCE_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Processing, + section: panel::REFERENCE_SECTION, +}; - pub fn definition(&self) -> Option<&'static PropertyDefinition> { - definition(self.id) - } -} +const SMOOTH_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Processing, + section: panel::SMOOTH_SECTION, +}; -const CONTOUR_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::SecondarySidebar, - section: panel::CONTOUR_SECTION, +const NORMALIZE_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Processing, + section: panel::NORMALIZE_SECTION, }; -const LINE_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::SecondarySidebar, - section: panel::LINE_SECTION, +const BIN_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Processing, + section: panel::BIN_SECTION, }; -const TYPOGRAPHY_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::SecondarySidebar, - section: panel::TYPOGRAPHY_SECTION, +const PROCESSING_STEP_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Processing, + section: panel::PROCESSING_STEP_SECTION, }; -const APODIZATION_HOME: HomeRoute = HomeRoute { +const PROCESSING_ADVANCED_HOME: HomeRoute = HomeRoute { panel: PanelRoute::Processing, - section: panel::APODIZATION_SECTION, + section: panel::PROCESSING_ADVANCED_SECTION, }; const EXPORT_PREFERENCES_HOME: HomeRoute = HomeRoute { @@ -211,12 +166,100 @@ const EXPORT_PREFERENCES_HOME: HomeRoute = HomeRoute { section: SettingsCategory::Export.section_id(), }; +const GENERAL_PREFERENCES_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Preferences, + section: SettingsCategory::General.section_id(), +}; + +const APPEARANCE_PREFERENCES_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Preferences, + section: SettingsCategory::Appearance.section_id(), +}; + +const UPDATES_PREFERENCES_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Preferences, + section: panel::PREFERENCES_UPDATES_SECTION, +}; + const PROCESSING_PREFERENCES_HOME: HomeRoute = HomeRoute { panel: PanelRoute::Preferences, section: SettingsCategory::Processing.section_id(), }; pub const PRESENTATIONS: &[PropertyPresentation] = &[ + object_entry(object::STACK_MODE, "Mode", STACK_HOME), + object_entry(object::STACK_SPACING_Y, "Vertical spacing", STACK_HOME), + object_entry(object::STACK_SHEAR_X, "3D shear", STACK_HOME), + object_entry(object::STACK_NORMALIZE, "Normalize", STACK_HOME), + object_entry(object::SERIES_VISIBLE, "Visible", STACK_HOME), + object_entry(object::CHART_TYPE_ID, "Type", CHART_HOME), + object_entry(object::CHART_BINS_AUTO, "Auto bins", CHART_HOME), + object_entry(object::CHART_BINS_COUNT, "Bins", CHART_HOME), + object_entry(object::CHART_STACKED, "Stacked", CHART_HOME), + object_entry(object::CHART_COLORMAP, "Colormap", CHART_HOME), + object_entry(object::CHART_VIEW_AZIMUTH, "Azimuth", CHART_HOME), + object_entry(object::CHART_VIEW_ELEVATION, "Elevation", CHART_HOME), + object_entry(object::PANEL_USER_NOTE, "Note", PANEL_HOME), + object_entry(object::PANEL_VISIBLE, "Show letter", PANEL_HOME), + object_entry(object::TEXT, "Text", TEXT_HOME), + object_entry(object::TEXT_FONT_SIZE, "Size", TEXT_HOME), + object_entry(object::TEXT_BOLD, "Bold", TEXT_HOME), + object_entry(object::TEXT_ALIGN, "Align", TEXT_HOME), + object_entry(object::TEXT_COLOR, "Color", TEXT_HOME), + object_entry(object::SHAPE_KIND, "Kind", SHAPE_HOME), + object_entry(object::SHAPE_STROKE, "Stroke", SHAPE_HOME), + object_entry(object::SHAPE_STROKE_WIDTH, "Stroke width", SHAPE_HOME), + object_entry(object::SHAPE_FILL_ENABLED, "Fill", SHAPE_HOME), + object_entry(object::SHAPE_FILL_COLOR, "Fill color", SHAPE_HOME), + object_entry(object::LOCKED, "Locked", OBJECT_HOME), + PropertyPresentation { + id: axis::X_LABEL, + localized_label: LocalizedText("X title"), + localized_aliases: &[LocalizedText("x-axis label")], + home_route: AXIS_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: axis::Y_LABEL, + localized_label: LocalizedText("Y title"), + localized_aliases: &[LocalizedText("y-axis label")], + home_route: AXIS_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: axis::X_SHOW_TICK_LABELS, + localized_label: LocalizedText("X tick labels"), + localized_aliases: &[LocalizedText("show x ticks")], + home_route: AXIS_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: axis::X_SHOW_LABEL, + localized_label: LocalizedText("Show X title"), + localized_aliases: &[LocalizedText("x title visibility")], + home_route: AXIS_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: axis::Y_SHOW_TICK_LABELS, + localized_label: LocalizedText("Y tick labels"), + localized_aliases: &[LocalizedText("show y ticks")], + home_route: AXIS_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: axis::Y_SHOW_LABEL, + localized_label: LocalizedText("Show Y title"), + localized_aliases: &[LocalizedText("y title visibility")], + home_route: AXIS_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, PropertyPresentation { id: contour::BASE_MAGNITUDE, localized_label: LocalizedText("Lowest level"), @@ -229,6 +272,7 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ // The one contour setting worth reaching without leaving the plot: // §1 principle 4(c) — the best parameter is the one you never look for. canvas_step: true, + uses_canvas_length_unit: false, }, PropertyPresentation { id: contour::BASE_POLICY, @@ -236,6 +280,7 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ localized_aliases: &[LocalizedText("level anchor"), LocalizedText("base policy")], home_route: CONTOUR_HOME, canvas_step: false, + uses_canvas_length_unit: false, }, PropertyPresentation { id: contour::COUNT, @@ -246,6 +291,7 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ ], home_route: CONTOUR_HOME, canvas_step: false, + uses_canvas_length_unit: false, }, PropertyPresentation { id: contour::RATIO, @@ -253,6 +299,7 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ localized_aliases: &[LocalizedText("contour spacing")], home_route: CONTOUR_HOME, canvas_step: false, + uses_canvas_length_unit: false, }, PropertyPresentation { id: contour::NEGATIVE_ENABLED, @@ -260,6 +307,7 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ localized_aliases: &[LocalizedText("negative peaks")], home_route: CONTOUR_HOME, canvas_step: false, + uses_canvas_length_unit: false, }, PropertyPresentation { id: contour::POSITIVE_COLOR, @@ -267,6 +315,7 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ localized_aliases: &[LocalizedText("contour colour")], home_route: CONTOUR_HOME, canvas_step: false, + uses_canvas_length_unit: false, }, PropertyPresentation { id: contour::NEGATIVE_COLOR, @@ -274,6 +323,7 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ localized_aliases: &[], home_route: CONTOUR_HOME, canvas_step: false, + uses_canvas_length_unit: false, }, PropertyPresentation { id: contour::LINE_WIDTH, @@ -281,6 +331,7 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ localized_aliases: &[LocalizedText("contour width")], home_route: CONTOUR_HOME, canvas_step: false, + uses_canvas_length_unit: false, }, PropertyPresentation { id: line::STROKE_WIDTH, @@ -288,6 +339,7 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ localized_aliases: &[LocalizedText("line thickness")], home_route: LINE_HOME, canvas_step: false, + uses_canvas_length_unit: false, }, PropertyPresentation { id: typography::TICK_PT, @@ -295,6 +347,135 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ localized_aliases: &[LocalizedText("figure font size")], home_route: TYPOGRAPHY_HOME, canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: typography::LABEL_PT, + localized_label: LocalizedText("Axis titles"), + localized_aliases: &[LocalizedText("axis label size")], + home_route: TYPOGRAPHY_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: typography::TITLE_PT, + localized_label: LocalizedText("Figure title"), + localized_aliases: &[LocalizedText("figure title size")], + home_route: TYPOGRAPHY_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: canvas::MARGIN_TOP_MM, + localized_label: LocalizedText("Top margin"), + localized_aliases: &[], + home_route: CANVAS_MARGINS_HOME, + canvas_step: false, + uses_canvas_length_unit: true, + }, + PropertyPresentation { + id: canvas::MARGIN_RIGHT_MM, + localized_label: LocalizedText("Right margin"), + localized_aliases: &[], + home_route: CANVAS_MARGINS_HOME, + canvas_step: false, + uses_canvas_length_unit: true, + }, + PropertyPresentation { + id: canvas::MARGIN_BOTTOM_MM, + localized_label: LocalizedText("Bottom margin"), + localized_aliases: &[], + home_route: CANVAS_MARGINS_HOME, + canvas_step: false, + uses_canvas_length_unit: true, + }, + PropertyPresentation { + id: canvas::MARGIN_LEFT_MM, + localized_label: LocalizedText("Left margin"), + localized_aliases: &[], + home_route: CANVAS_MARGINS_HOME, + canvas_step: false, + uses_canvas_length_unit: true, + }, + PropertyPresentation { + id: canvas::GUTTER_MM, + localized_label: LocalizedText("Minimum spacing"), + localized_aliases: &[LocalizedText("gutter")], + home_route: CANVAS_MARGINS_HOME, + canvas_step: false, + uses_canvas_length_unit: true, + }, + PropertyPresentation { + id: canvas::ROWS, + localized_label: LocalizedText("Rows"), + localized_aliases: &[LocalizedText("grid rows")], + home_route: CANVAS_GRID_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: canvas::COLS, + localized_label: LocalizedText("Columns"), + localized_aliases: &[LocalizedText("grid columns")], + home_route: CANVAS_GRID_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: canvas::SHOW_GRID, + localized_label: LocalizedText("Show layout grid"), + localized_aliases: &[LocalizedText("grid overlay")], + home_route: CANVAS_GRID_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: canvas::SPACING_MODE, + localized_label: LocalizedText("Spacing basis"), + localized_aliases: &[LocalizedText("visual spacing")], + home_route: CANVAS_GRID_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: canvas::WIDTH_MM, + localized_label: LocalizedText("Width"), + localized_aliases: &[LocalizedText("page width")], + home_route: CANVAS_SIZE_HOME, + canvas_step: false, + uses_canvas_length_unit: true, + }, + PropertyPresentation { + id: canvas::HEIGHT_MM, + localized_label: LocalizedText("Height"), + localized_aliases: &[LocalizedText("page height")], + home_route: CANVAS_SIZE_HOME, + canvas_step: false, + uses_canvas_length_unit: true, + }, + PropertyPresentation { + id: canvas::AUTO_HEIGHT, + localized_label: LocalizedText("Auto height"), + localized_aliases: &[LocalizedText("content height")], + home_route: CANVAS_SIZE_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: canvas::CAPTION_VISIBLE, + localized_label: LocalizedText("Show caption below page"), + localized_aliases: &[LocalizedText("caption visibility")], + home_route: CANVAS_CAPTION_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: canvas::PANEL_LABEL_STYLE, + localized_label: LocalizedText("Panel label style"), + localized_aliases: &[LocalizedText("panel letters")], + home_route: CANVAS_CAPTION_HOME, + canvas_step: false, + uses_canvas_length_unit: false, }, PropertyPresentation { id: apodization::KIND, @@ -302,6 +483,7 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ localized_aliases: &[LocalizedText("apodization window")], home_route: APODIZATION_HOME, canvas_step: false, + uses_canvas_length_unit: false, }, PropertyPresentation { id: apodization::LB_HZ, @@ -309,6 +491,7 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ localized_aliases: &[LocalizedText("line broadening")], home_route: APODIZATION_HOME, canvas_step: false, + uses_canvas_length_unit: false, }, PropertyPresentation { id: apodization::GB_HZ, @@ -316,13 +499,257 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ localized_aliases: &[LocalizedText("gaussian broadening")], home_route: APODIZATION_HOME, canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: zero_fill::MODE, + localized_label: LocalizedText("Zero fill"), + localized_aliases: &[LocalizedText("FFT size")], + home_route: ZERO_FILL_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: zero_fill::POINTS, + localized_label: LocalizedText("Points"), + localized_aliases: &[LocalizedText("custom FFT points")], + home_route: ZERO_FILL_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: phase::MODE, + localized_label: LocalizedText("Mode"), + localized_aliases: &[LocalizedText("phase method")], + home_route: PHASE_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: phase::PHASE0, + localized_label: LocalizedText("φ0"), + localized_aliases: &[LocalizedText("zero-order phase")], + home_route: PHASE_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: phase::PHASE1, + localized_label: LocalizedText("φ1"), + localized_aliases: &[LocalizedText("first-order phase")], + home_route: PHASE_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: phase::PIVOT, + localized_label: LocalizedText("Pivot"), + localized_aliases: &[LocalizedText("phase pivot fraction")], + home_route: PHASE_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: baseline::METHOD, + localized_label: LocalizedText("Method"), + localized_aliases: &[LocalizedText("baseline correction")], + home_route: BASELINE_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: baseline::POLYNOMIAL_ORDER, + localized_label: LocalizedText("Order"), + localized_aliases: &[LocalizedText("polynomial order")], + home_route: BASELINE_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: baseline::SMOOTHNESS, + localized_label: LocalizedText("Smoothness"), + localized_aliases: &[LocalizedText("AsLS lambda")], + home_route: BASELINE_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: baseline::ASYMMETRY, + localized_label: LocalizedText("Peak weight"), + localized_aliases: &[LocalizedText("AsLS asymmetry")], + home_route: BASELINE_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: baseline::ITERATIONS, + localized_label: LocalizedText("Iterations"), + localized_aliases: &[LocalizedText("AsLS passes")], + home_route: BASELINE_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: reference::AT_PPM, + localized_label: LocalizedText("At"), + localized_aliases: &[LocalizedText("reference source")], + home_route: REFERENCE_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: reference::TARGET_PPM, + localized_label: LocalizedText("Target"), + localized_aliases: &[LocalizedText("reference destination")], + home_route: REFERENCE_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: smooth::METHOD, + localized_label: LocalizedText("Method"), + localized_aliases: &[LocalizedText("smoothing method")], + home_route: SMOOTH_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: smooth::WINDOW, + localized_label: LocalizedText("Window"), + localized_aliases: &[LocalizedText("window points")], + home_route: SMOOTH_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: smooth::POLYNOMIAL_ORDER, + localized_label: LocalizedText("Polynomial order"), + localized_aliases: &[LocalizedText("Savitzky-Golay order")], + home_route: SMOOTH_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: normalize::METHOD, + localized_label: LocalizedText("Method"), + localized_aliases: &[LocalizedText("normalization method")], + home_route: NORMALIZE_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: normalize::DIVISOR, + localized_label: LocalizedText("Divisor"), + localized_aliases: &[LocalizedText("divide by constant")], + home_route: NORMALIZE_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: bin::WIDTH, + localized_label: LocalizedText("Bin width"), + localized_aliases: &[LocalizedText("bucket width")], + home_route: BIN_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: bin::METHOD, + localized_label: LocalizedText("Aggregate"), + localized_aliases: &[LocalizedText("bin aggregation")], + home_route: BIN_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: step_enabled::ENABLED, + localized_label: LocalizedText("Enabled"), + localized_aliases: &[LocalizedText("enable processing step")], + home_route: PROCESSING_STEP_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: group_delay::CORRECT, + localized_label: LocalizedText("Group-delay correction"), + localized_aliases: &[LocalizedText("digital filter correction")], + home_route: PROCESSING_ADVANCED_HOME, + canvas_step: false, + uses_canvas_length_unit: false, }, + preference_entry( + app_preferences::SNAP_ENABLED, + "Object snapping", + &[LocalizedText("snap to guides")], + GENERAL_PREFERENCES_HOME, + ), + preference_entry( + app_preferences::KEEP_EMPTY_SOURCE_CANVAS, + "Keep empty source canvas", + &[LocalizedText("keep source canvas when tiling")], + GENERAL_PREFERENCES_HOME, + ), + preference_entry( + app_preferences::PROJECT_BACKUP_GENERATIONS, + "Project backup copies", + &[LocalizedText("backup generations")], + GENERAL_PREFERENCES_HOME, + ), + preference_entry( + app_preferences::THEME, + "Chrome theme", + &[LocalizedText("appearance theme")], + APPEARANCE_PREFERENCES_HOME, + ), + preference_entry( + app_preferences::GRAPHICS_POWER, + "Graphics processor", + &[LocalizedText("GPU preference")], + APPEARANCE_PREFERENCES_HOME, + ), + preference_entry( + app_preferences::ACCENT_COLOR, + "Canvas accent", + &[LocalizedText("selection colour")], + APPEARANCE_PREFERENCES_HOME, + ), + preference_entry( + app_preferences::INCLUDE_VIEW_SNAPSHOTS, + "Embed view snapshots", + &[LocalizedText("save view snapshots")], + EXPORT_PREFERENCES_HOME, + ), + preference_entry( + app_preferences::TRIM_TO_VISIBLE_CONTENT, + "Trim to visible content", + &[LocalizedText("remove page whitespace")], + EXPORT_PREFERENCES_HOME, + ), + preference_entry( + app_preferences::SCALE_CONTENT, + "Scale content with page size", + &[LocalizedText("resize canvas content")], + EXPORT_PREFERENCES_HOME, + ), + preference_entry( + app_preferences::AUTO_CHECK_UPDATES, + "Automatic updates", + &[LocalizedText("check for updates")], + UPDATES_PREFERENCES_HOME, + ), + preference_entry( + app_preferences::UPDATE_CHANNEL, + "Update channel", + &[LocalizedText("release channel")], + UPDATES_PREFERENCES_HOME, + ), 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, + uses_canvas_length_unit: false, }, PropertyPresentation { id: ilt::DEFAULT_LAMBDA, @@ -333,6 +760,7 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ ], home_route: PROCESSING_PREFERENCES_HOME, canvas_step: false, + uses_canvas_length_unit: false, }, ]; diff --git a/crates/app/src/ui/properties/panel.rs b/crates/app/src/ui/properties/panel.rs index f61faa61..205770ed 100644 --- a/crates/app/src/ui/properties/panel.rs +++ b/crates/app/src/ui/properties/panel.rs @@ -6,26 +6,54 @@ //! rows are rendered by default; everything else is folded away, which is what //! keeps the panel from growing a row per feature. +#[path = "control.rs"] +mod control; +#[path = "sections.rs"] +mod sections; + use super::{PRESENTATIONS, PropertyPresentation}; use egui::{DragValue, Ui}; use egui_phosphor::regular as icon; use plotx_core::automation::TargetRef; use plotx_core::properties::{ - AggregateValue, ContourBaseReadout, EncodingKind, PropertyDefinition, PropertyId, - PropertyReadout, PropertyValue, ResolvedProperty, ResolvedPropertySet, ResolvedSchema, - ValueCopies, ValueSchema, contour, + AggregateValue, EncodingKind, PropertyDefinition, PropertyId, PropertyReadout, PropertyValue, + ResolvedProperty, ResolvedPropertySet, ResolvedSchema, ValueCopies, ValueSchema, contour, + phase, zero_fill, }; -use plotx_core::state::{ObjectId, PlotxApp, PropertyFocus}; +use plotx_core::state::{ObjectId, PlotxApp, PropertyFocus, PropertyTextEditState}; /// The home-route section id of the contour rows. The route table and the /// collapsing header below must agree on it, so both read this constant. pub(crate) const CONTOUR_SECTION: &str = "object.contour"; /// The home section for line-encoding rows on selected plot objects. pub(crate) const LINE_SECTION: &str = "object.line"; +pub(crate) const AXIS_SECTION: &str = "object.axes"; +pub(crate) const STACK_SECTION: &str = "object.stack"; +pub(crate) const CHART_SECTION: &str = "object.chart"; +pub(crate) const TEXT_SECTION: &str = "object.text"; +pub(crate) const SHAPE_SECTION: &str = "object.shape"; +pub(crate) const PANEL_SECTION: &str = "object.panel"; +pub(crate) const OBJECT_SECTION: &str = "object.general"; /// The document root's figure typography rows. pub(crate) const TYPOGRAPHY_SECTION: &str = "document.figure_typography"; +pub(crate) const CANVAS_MARGINS_SECTION: &str = "canvas.margins"; +pub(crate) const CANVAS_GRID_SECTION: &str = "canvas.grid"; +pub(crate) const CANVAS_SIZE_SECTION: &str = "canvas.size"; +pub(crate) const CANVAS_CAPTION_SECTION: &str = "canvas.caption"; /// The processing editor's per-step apodization rows. pub(crate) const APODIZATION_SECTION: &str = "dataset.apodization"; +pub(crate) const ZERO_FILL_SECTION: &str = "dataset.zero_fill"; +pub(crate) const PHASE_SECTION: &str = "dataset.phase"; +pub(crate) const BASELINE_SECTION: &str = "dataset.baseline"; +pub(crate) const REFERENCE_SECTION: &str = "dataset.reference"; +pub(crate) const SMOOTH_SECTION: &str = "dataset.smooth"; +pub(crate) const NORMALIZE_SECTION: &str = "dataset.normalize"; +pub(crate) const BIN_SECTION: &str = "dataset.bin"; +pub(crate) const PROCESSING_STEP_SECTION: &str = "dataset.processing_step"; +pub(crate) const PROCESSING_ADVANCED_SECTION: &str = "dataset.processing_advanced"; +/// Updates remain in the General Preferences rail page, but have their own +/// density budget because they are a distinct settings sub-struct. +pub(crate) const PREFERENCES_UPDATES_SECTION: &str = "preferences.updates"; /// What a control shows in place of a number or a choice when there is none: /// the sources behind the row do not agree, so no value may be presented as the @@ -92,14 +120,14 @@ struct Row { /// the anchored-level row, only when the row has a single value to explain, /// and only from what the derived caches already hold — reading it never /// starts a measurement. - readout: Option, + readout: Option, } impl Row { /// The current value, or `None` when the sources behind the row disagree — /// several selected series, or the two halves of one contour ladder. - fn value(&self) -> Option { - self.set.value.uniform().copied() + fn value(&self) -> Option<&PropertyValue> { + self.set.value.uniform() } /// The value a control edits *from* when there is none to show. It is the @@ -107,8 +135,8 @@ impl Row { /// controls hide it outright, and the colour swatch — which cannot render /// blank — then shows a colour that belongs to nobody in the selection /// rather than passing one target's off as the answer. - fn editing_value(&self) -> Option { - self.value().or(self.representative.default_value) + fn editing_value(&self) -> Option<&PropertyValue> { + self.value().or(self.representative.default_value.as_ref()) } fn mixed(&self) -> bool { @@ -139,195 +167,14 @@ enum GestureEdge { Stopped, } -/// Render the contour section for the current selection. Returns `false` when -/// nothing in the selection draws a contour, in which case the caller draws no -/// heading either. -pub(crate) fn contour_section( - app: &mut PlotxApp, - canvas: usize, - objects: &[ObjectId], - ui: &mut Ui, -) -> bool { - let targets: Vec = objects - .iter() - .flat_map(|&object| app.series_targets(canvas, object)) - .collect(); - render_section( - app, - CONTOUR_SECTION, - "Contour", - SectionNoun::new("contour series", "contour series"), - &targets, - Some(EncodingKind::Contour), - ui, - ) -} - -/// Render line properties over the current plot selection. -pub(crate) fn line_section( - app: &mut PlotxApp, - canvas: usize, - objects: &[ObjectId], - ui: &mut Ui, -) -> bool { - let targets: Vec = objects - .iter() - .flat_map(|&object| app.series_targets(canvas, object)) - .collect(); - render_section( - app, - LINE_SECTION, - "Line", - SectionNoun::new("line series", "line series"), - &targets, - None, - ui, - ) -} - -/// Render document-owned typography without requiring any canvas object. -pub(crate) fn typography_section(app: &mut PlotxApp, ui: &mut Ui) -> bool { - let target = app.document_target(); - render_section( - app, - TYPOGRAPHY_SECTION, - "Figure typography", - SectionNoun::new("document", "documents"), - std::slice::from_ref(&target), - None, - ui, - ) -} - -/// Render the catalog rows for one expanded apodization step in the existing -/// processing editor. The step list supplies the stable component target; this -/// panel supplies the same schema, reset and action path as every other scope. -pub(crate) fn apodization_section(app: &mut PlotxApp, target: &TargetRef, ui: &mut Ui) -> bool { - render_section( - app, - APODIZATION_SECTION, - "Apodization", - SectionNoun::new("processing step", "processing steps"), - std::slice::from_ref(target), - None, - ui, - ) -} - -#[allow(clippy::too_many_arguments)] -fn render_section( - app: &mut PlotxApp, - section: &'static str, - title: &'static str, - status_noun: SectionNoun, - targets: &[TargetRef], - reset_encoding: Option, - ui: &mut Ui, -) -> bool { - if targets.is_empty() { - return false; - } - let rows = resolve_rows_for(app, targets, section); - if rows.is_empty() { - return false; - } - - let now = ui.input(|input| input.time); - let focus = app.session.ui.property_focus; - let focused_here = - focus.is_some_and(|focus| rows.iter().any(|row| row.presentation.id == focus.property)); - - // Every target in the selection is not what this section acts on: the - // heading counts the ones that actually supply one of its rows, so a page - // holding one contour plot and one line plot does not report two of each. - let applicable = applicable_targets(&rows); - - ui.separator(); - ui.horizontal(|ui| { - ui.strong(title); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.weak(status_noun.counted(applicable.len())); - }); - }); - - // The rows rendered without expanding anything are exactly the list the - // budget check counts, so the check cannot pass while the panel shows more. - let essential: Vec = super::essential_in(section) - .into_iter() - .map(|entry| entry.id) - .collect(); - let mut pending: Option = None; - let mut gesture: Option<(PropertyId, GestureEdge)> = None; - for row in rows - .iter() - .filter(|row| essential.contains(&row.presentation.id)) - { - property_row(row, focus, now, &mut pending, &mut gesture, ui); - } - - let advanced: Vec<&Row> = rows - .iter() - .filter(|row| !essential.contains(&row.presentation.id)) - .collect(); - if !advanced.is_empty() { - let id = ui.make_persistent_id(("property_section", section)); - let mut state = - egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, false); - if focused_here - && focus.is_some_and(|focus| { - advanced - .iter() - .any(|row| row.presentation.id == focus.property) - }) - { - state.set_open(true); - state.store(ui.ctx()); - } - egui::CollapsingHeader::new("Advanced") - .id_salt(("property_section", section)) - .show(ui, |ui| { - for row in advanced { - property_row(row, focus, now, &mut pending, &mut gesture, ui); - } - }); - } - - if let Some(encoding) = reset_encoding - && ui - .small_button("Reset contour") - .on_hover_text("Rebuild this series' encoding from its defaults") - .clicked() - { - pending = Some(Pending::ResetEncoding(encoding)); - } - - // The reveal is one-shot: once the section has been drawn with the row in - // it, only the fading highlight remains. - if let Some(focus) = app.session.ui.property_focus.as_mut() - && focused_here - { - focus.pending = false; - } - if focus.is_some_and(|focus| now >= focus.highlight_until) { - app.session.ui.property_focus = None; - } - - // Opened before the write and closed after it, so the frame that starts a - // drag is already inside the gesture and the frame that ends one is the last - // it records. - if let Some((property, GestureEdge::Started)) = gesture { - app.begin_property_gesture(property); - } - if let Some(pending) = pending { - // Still the whole selection: a target this section cannot supply is - // reported as a skip rather than quietly left out of the write. - apply(app, targets, pending, status_noun); - } - if let Some((_, GestureEdge::Stopped)) = gesture { - app.end_property_gesture(); - } - true -} +pub(crate) use self::sections::{ + apodization_section, axis_section, baseline_section, bin_section, canvas_caption_section, + canvas_grid_section, canvas_margins_section, canvas_size_section, chart_section, + contour_section, line_section, normalize_section, panel_inline_section, panel_section, + phase_section, preferences_section, processing_advanced_section, processing_step_section, + reference_section, shape_section, smooth_section, stack_section, text_section, + typography_section, zero_fill_section, +}; #[cfg(test)] fn resolve_rows(app: &PlotxApp, targets: &[TargetRef]) -> Vec { @@ -359,9 +206,18 @@ fn resolve_rows_for(app: &PlotxApp, targets: &[TargetRef], section: &str) -> Vec let readout = if presentation.id == contour::BASE_MAGNITUDE && set.value.uniform().is_some() { match app.property_readout(first) { - Ok(PropertyReadout::ContourBase(readout)) => Some(readout), - Ok(PropertyReadout::Value(_)) | Err(_) => None, + Ok(readout @ PropertyReadout::ContourBase(_)) => Some(readout), + Ok( + PropertyReadout::Value(_) + | PropertyReadout::ZeroFillTarget(_) + | PropertyReadout::PhasePivotPpm { .. }, + ) + | Err(_) => None, } + } else if [zero_fill::MODE, phase::PIVOT].contains(&presentation.id) + && set.value.uniform().is_some() + { + app.property_readout(first).ok() } else { None }; @@ -376,263 +232,6 @@ fn resolve_rows_for(app: &PlotxApp, targets: &[TargetRef], section: &str) -> Vec rows } -fn property_row( - row: &Row, - focus: Option, - now: f64, - pending: &mut Option, - gesture: &mut Option<(PropertyId, GestureEdge)>, - ui: &mut Ui, -) { - let highlighted = focus - .is_some_and(|focus| focus.property == row.presentation.id && now < focus.highlight_until); - let response = ui - .scope(|ui| { - ui.horizontal(|ui| { - ui.label(row.presentation.localized_label.get()) - .on_hover_text(row.definition.canonical_label); - control(row, pending, gesture, ui); - if row.modified() { - modified_marker(row, pending, ui); - } - }); - }) - .response; - if highlighted { - ui.painter().rect_stroke( - response.rect.expand(2.0), - 4.0, - egui::Stroke::new(1.5_f32, ui.visuals().selection.bg_fill), - egui::StrokeKind::Outside, - ); - ui.ctx().request_repaint(); - } - if focus.is_some_and(|focus| focus.property == row.presentation.id && focus.pending) { - response.scroll_to_me(None); - } -} - -/// The "modified" affordance: a marker whose tooltip names the default, and a -/// one-click reset back to it. -fn modified_marker(row: &Row, pending: &mut Option, ui: &mut Ui) { - let default = row - .representative - .default_value - .map(|value| describe(row, value)) - .unwrap_or_else(|| "no default".to_owned()); - let hint = if row.mixed() { - format!( - "{} Default: {default}", - no_single_value_hint(row.set.applicable_targets.len(), row.definition.copies) - ) - } else { - format!("Changed from the default: {default}") - }; - ui.label(icon::DOT_OUTLINE).on_hover_text(&hint); - if ui - .small_button(icon::ARROW_COUNTER_CLOCKWISE) - .on_hover_text(format!("Reset to {default}")) - .clicked() - { - *pending = Some(Pending::Reset(row.presentation.id)); - } -} - -/// Draw the control for one row. -/// -/// When the row has no single value the widget still edits — one gesture must -/// be enough to make the whole selection agree — but it displays nothing that -/// could be read as the current setting: an em dash instead of a number or a -/// choice, and no checkbox state at all. -fn control( - row: &Row, - pending: &mut Option, - gesture: &mut Option<(PropertyId, GestureEdge)>, - ui: &mut Ui, -) { - let mixed = row.mixed(); - let Some(value) = row.editing_value() else { - ui.weak("unavailable"); - return; - }; - match (&row.representative.schema, value) { - (ResolvedSchema::Bool, PropertyValue::Bool(current)) => { - if mixed { - // A checkbox has no third state, and an unticked box would be a - // claim about the selection. Two unselected choices are not. - for (label, next) in [("On", true), ("Off", false)] { - if ui.selectable_label(false, label).clicked() { - *pending = Some(Pending::Write( - row.presentation.id, - PropertyValue::Bool(next), - )); - } - } - } else { - let mut current = current; - if ui.checkbox(&mut current, "").changed() { - *pending = Some(Pending::Write( - row.presentation.id, - PropertyValue::Bool(current), - )); - } - } - } - (ResolvedSchema::Int { min, max }, PropertyValue::Int(current)) => { - let mut current = current; - let drag = DragValue::new(&mut current).speed(0.25).range(*min..=*max); - let response = ui.add(hide_value(drag, mixed)); - note_gesture(row, &response, gesture); - if response.changed() { - *pending = Some(Pending::Write( - row.presentation.id, - PropertyValue::Int(current), - )); - } - } - (ResolvedSchema::Float { bounds, log, unit }, PropertyValue::Float(current)) => { - let mut next = current; - // The definition's own notch wins. Deriving one from the range is - // the last resort, because a range states what is admissible, not - // what is usual: line broadening is legal out to +-10 kHz and is set - // in tenths of a hertz. - let speed = match declared_drag_step(row) { - Some(step) => step, - // A logarithmic quantity spans many decades, so the drag step - // has to follow the value rather than the (unbounded) range. - None if *log => (current.abs() * 0.02).max(f64::MIN_POSITIVE), - None => ((bounds.max - bounds.min) / 200.0).max(1.0e-3), - }; - // An egui range is inclusive, so an open bound is entered here by - // asking the schema for the smallest value it admits rather than by - // nudging the literal — the nudge would be a second copy of the rule. - let drag = DragValue::new(&mut next) - .speed(speed) - .range(bounds.lowest()..=bounds.max); - let response = ui.add(hide_value(drag, mixed)); - note_gesture(row, &response, gesture); - if response.changed() { - *pending = Some(Pending::Write( - row.presentation.id, - PropertyValue::Float(next), - )); - } - if !unit.is_empty() { - ui.weak(*unit); - } - // §4.3: the multiple alone does not say whether a cross peak - // survives; the resolved level does. - if let Some(readout) = &row.readout - && let Some(suffix) = super::readout::resolution_suffix(readout) - { - ui.weak(suffix) - .on_hover_text(super::readout::explanation(readout)); - } - } - (ResolvedSchema::Enum { variants }, PropertyValue::Enum(current)) => { - // Nothing is current when the sources disagree, so no variant is - // marked selected and the box names none of them. - let current = (!mixed).then_some(current); - let selected = current - .map(|current| { - variants - .iter() - .find(|variant| variant.id == current) - .map(|variant| variant.canonical_label) - .unwrap_or(current) - }) - .unwrap_or(NO_SINGLE_VALUE); - egui::ComboBox::from_id_salt(row.presentation.id.as_str()) - .selected_text(selected) - .show_ui(ui, |ui| { - for variant in variants { - if ui - .selectable_label(current == Some(variant.id), variant.canonical_label) - .clicked() - { - *pending = Some(Pending::Write( - row.presentation.id, - PropertyValue::Enum(variant.id), - )); - } - } - }); - } - (ResolvedSchema::Color, PropertyValue::Color(current)) => { - let mut rgb = [current.r, current.g, current.b]; - if ui.color_edit_button_srgb(&mut rgb).changed() { - *pending = Some(Pending::Write( - row.presentation.id, - PropertyValue::Color(plotx_figure::Color::rgb(rgb[0], rgb[1], rgb[2])), - )); - } - } - // A control and a value of different shapes means the schema and the - // domain model disagree; say so rather than drawing a wrong widget. - _ => { - ui.weak("unavailable"); - } - } - if mixed { - ui.weak("mixed").on_hover_text(no_single_value_hint( - row.set.applicable_targets.len(), - row.definition.copies, - )); - } -} - -/// The drag notch this row's definition declares, if it declares one. -fn declared_drag_step(row: &Row) -> Option { - match row.definition.value_schema { - ValueSchema::Float { drag_step, .. } => drag_step, - _ => None, - } -} - -/// Report a continuous control's drag edges to the section that owns the -/// gesture. Only the edges: what happens in between is an ordinary write. -fn note_gesture( - row: &Row, - response: &egui::Response, - gesture: &mut Option<(PropertyId, GestureEdge)>, -) { - if response.drag_started() { - *gesture = Some((row.presentation.id, GestureEdge::Started)); - } else if response.drag_stopped() { - *gesture = Some((row.presentation.id, GestureEdge::Stopped)); - } -} - -/// Blank a drag control's readout while leaving it draggable and typable. The -/// number it still carries only decides where a drag starts; it is never shown. -fn hide_value(drag: DragValue<'_>, hidden: bool) -> DragValue<'_> { - if hidden { - drag.custom_formatter(|_, _| NO_SINGLE_VALUE.to_owned()) - } else { - drag - } -} - -fn describe(row: &Row, value: PropertyValue) -> String { - match value { - PropertyValue::Bool(value) => if value { "on" } else { "off" }.to_owned(), - PropertyValue::Int(value) => value.to_string(), - PropertyValue::Float(value) => format!("{value:.4}"), - // Read the label from the full static variant list, not the ones this - // field permits: a default may name a choice the user cannot switch - // back to by hand, and it still has to be nameable in the tooltip. - PropertyValue::Enum(value) => match row.definition.value_schema { - ValueSchema::Enum { variants } => variants - .iter() - .find(|variant| variant.id == value) - .map(|variant| variant.canonical_label.to_owned()) - .unwrap_or_else(|| value.to_owned()), - _ => value.to_owned(), - }, - PropertyValue::Color(color) => format!("#{:02x}{:02x}{:02x}", color.r, color.g, color.b), - } -} - /// The union of the targets this section's rows apply to, in selection order. fn applicable_targets(rows: &[Row]) -> Vec { let mut targets: Vec = Vec::new(); diff --git a/crates/app/src/ui/properties/panel_tests.rs b/crates/app/src/ui/properties/panel_tests.rs index 889aaf1b..c28fbefb 100644 --- a/crates/app/src/ui/properties/panel_tests.rs +++ b/crates/app/src/ui/properties/panel_tests.rs @@ -139,6 +139,9 @@ fn a_uniform_row_still_carries_its_resolved_level() { .readout .as_ref() .expect("a single agreeing series states what its multiple means"); + let plotx_core::properties::PropertyReadout::ContourBase(readout) = readout else { + panic!("the lowest-level row carries a contour readout"); + }; assert_eq!(readout.magnitude, 5.0); } diff --git a/crates/app/src/ui/properties/readout.rs b/crates/app/src/ui/properties/readout.rs index c7e47a41..f280d8aa 100644 --- a/crates/app/src/ui/properties/readout.rs +++ b/crates/app/src/ui/properties/readout.rs @@ -105,7 +105,9 @@ pub(crate) fn aggregate_property_summary(readouts: &[PropertyReadout]) -> Option .iter() .filter_map(|readout| match readout { PropertyReadout::ContourBase(readout) => Some(*readout), - PropertyReadout::Value(_) => None, + PropertyReadout::Value(_) + | PropertyReadout::ZeroFillTarget(_) + | PropertyReadout::PhasePivotPpm { .. } => None, }) .collect(); if contours.len() != readouts.len() { @@ -118,7 +120,7 @@ pub(crate) fn aggregate_property_summary(readouts: &[PropertyReadout]) -> Option } PropertyReadout::Value(value) => { if readouts.iter().all(|readout| readout == first) { - Some(value_summary(*value)) + Some(value_summary(value)) } else { Some(format!( "{} series — no single property value", @@ -126,15 +128,33 @@ pub(crate) fn aggregate_property_summary(readouts: &[PropertyReadout]) -> Option )) } } + PropertyReadout::ZeroFillTarget(readout) => { + if readouts.iter().all(|candidate| candidate == first) { + Some(format!("{} points", readout.points)) + } else { + Some(format!( + "{} steps — no single zero-fill target", + readouts.len() + )) + } + } + PropertyReadout::PhasePivotPpm { ppm } => { + if readouts.iter().all(|candidate| candidate == first) { + Some(format!("{ppm:.3} ppm")) + } else { + Some(format!("{} steps — no single phase pivot", readouts.len())) + } + } } } -fn value_summary(value: PropertyValue) -> String { +fn value_summary(value: &PropertyValue) -> String { match value { PropertyValue::Bool(value) => value.to_string(), + PropertyValue::Text(value) => value.clone(), PropertyValue::Int(value) => value.to_string(), - PropertyValue::Float(value) => number(value), - PropertyValue::Enum(value) => value.to_owned(), + PropertyValue::Float(value) => number(*value), + PropertyValue::Enum(value) => (*value).to_owned(), PropertyValue::Color(color) => format!("#{:02x}{:02x}{:02x}", color.r, color.g, color.b), } } diff --git a/crates/app/src/ui/properties/sections.rs b/crates/app/src/ui/properties/sections.rs new file mode 100644 index 00000000..57e47603 --- /dev/null +++ b/crates/app/src/ui/properties/sections.rs @@ -0,0 +1,515 @@ +//! Catalog-driven property sections and section rendering. + +use super::control::{RowEdits, property_row, property_row_inline}; +use super::*; +use plotx_core::properties::{PropertyAddress, app_preferences, baseline}; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum SectionLayout { + Standard, + Inline, + Menu, +} + +/// Render the contour section for the current selection. Returns `false` when +/// nothing in the selection draws a contour, in which case the caller draws no +/// heading either. +pub(crate) fn contour_section( + app: &mut PlotxApp, + canvas: usize, + objects: &[ObjectId], + ui: &mut Ui, +) -> bool { + let targets: Vec = objects + .iter() + .flat_map(|&object| app.series_targets(canvas, object)) + .collect(); + render_section( + app, + CONTOUR_SECTION, + "Contour", + SectionNoun::new("contour series", "contour series"), + &targets, + Some(EncodingKind::Contour), + SectionLayout::Standard, + ui, + ) +} + +/// Render line properties over the current plot selection. +pub(crate) fn line_section( + app: &mut PlotxApp, + canvas: usize, + objects: &[ObjectId], + ui: &mut Ui, +) -> bool { + let targets: Vec = objects + .iter() + .flat_map(|&object| app.series_targets(canvas, object)) + .collect(); + render_section( + app, + LINE_SECTION, + "Line", + SectionNoun::new("line series", "line series"), + &targets, + None, + SectionLayout::Standard, + ui, + ) +} + +pub(crate) fn axis_section( + app: &mut PlotxApp, + canvas: usize, + objects: &[ObjectId], + ui: &mut Ui, +) -> bool { + let targets: Vec = objects + .iter() + .filter_map(|&object| app.object_target(canvas, object)) + .collect(); + render_section( + app, + AXIS_SECTION, + "Axes", + SectionNoun::new("plot", "plots"), + &targets, + None, + SectionLayout::Standard, + ui, + ) +} + +pub(crate) fn stack_section( + app: &mut PlotxApp, + canvas: usize, + objects: &[ObjectId], + ui: &mut Ui, +) -> bool { + object_section(app, canvas, objects, STACK_SECTION, "Stack", ui) +} + +pub(crate) fn chart_section( + app: &mut PlotxApp, + canvas: usize, + objects: &[ObjectId], + ui: &mut Ui, +) -> bool { + object_section(app, canvas, objects, CHART_SECTION, "Chart", ui) +} + +pub(crate) fn text_section( + app: &mut PlotxApp, + canvas: usize, + objects: &[ObjectId], + ui: &mut Ui, +) -> bool { + object_section(app, canvas, objects, TEXT_SECTION, "Text", ui) +} + +pub(crate) fn shape_section( + app: &mut PlotxApp, + canvas: usize, + objects: &[ObjectId], + ui: &mut Ui, +) -> bool { + object_section(app, canvas, objects, SHAPE_SECTION, "Shape", ui) +} + +pub(crate) fn panel_section( + app: &mut PlotxApp, + canvas: usize, + objects: &[ObjectId], + ui: &mut Ui, +) -> bool { + object_section(app, canvas, objects, PANEL_SECTION, "Panel", ui) +} + +pub(crate) fn panel_inline_section( + app: &mut PlotxApp, + canvas: usize, + object: ObjectId, + ui: &mut Ui, +) -> bool { + let Some(target) = app.object_target(canvas, object) else { + return false; + }; + render_section( + app, + PANEL_SECTION, + "Panel", + SectionNoun::new("panel", "panels"), + std::slice::from_ref(&target), + None, + SectionLayout::Inline, + ui, + ) +} + +fn object_section( + app: &mut PlotxApp, + canvas: usize, + objects: &[ObjectId], + section: &'static str, + title: &'static str, + ui: &mut Ui, +) -> bool { + let targets: Vec = objects + .iter() + .filter_map(|&object| app.object_target(canvas, object)) + .collect(); + render_section( + app, + section, + title, + SectionNoun::new("object", "objects"), + &targets, + None, + SectionLayout::Standard, + ui, + ) +} + +/// Render document-owned typography without requiring any canvas object. +pub(crate) fn typography_section(app: &mut PlotxApp, ui: &mut Ui) -> bool { + let target = app.document_target(); + render_section( + app, + TYPOGRAPHY_SECTION, + "Figure typography", + SectionNoun::new("document", "documents"), + std::slice::from_ref(&target), + None, + SectionLayout::Standard, + ui, + ) +} + +/// Render one settings sub-struct against the singleton application target. +/// Preferences use menu layout because the rail page already supplies the +/// surrounding title and scroll container. +pub(crate) fn preferences_section(app: &mut PlotxApp, section: &'static str, ui: &mut Ui) -> bool { + let target = app.app_target(); + render_section( + app, + section, + "Preferences", + SectionNoun::new("preference", "preferences"), + std::slice::from_ref(&target), + None, + SectionLayout::Menu, + ui, + ) +} + +pub(crate) fn canvas_margins_section(app: &mut PlotxApp, target: &TargetRef, ui: &mut Ui) -> bool { + canvas_section( + app, + CANVAS_MARGINS_SECTION, + "Margins and spacing", + target, + ui, + ) +} + +pub(crate) fn canvas_grid_section(app: &mut PlotxApp, target: &TargetRef, ui: &mut Ui) -> bool { + canvas_section(app, CANVAS_GRID_SECTION, "Layout grid", target, ui) +} + +pub(crate) fn canvas_size_section(app: &mut PlotxApp, target: &TargetRef, ui: &mut Ui) -> bool { + canvas_section(app, CANVAS_SIZE_SECTION, "Page size", target, ui) +} + +pub(crate) fn canvas_caption_section(app: &mut PlotxApp, target: &TargetRef, ui: &mut Ui) -> bool { + canvas_section( + app, + CANVAS_CAPTION_SECTION, + "Caption and labels", + target, + ui, + ) +} + +fn canvas_section( + app: &mut PlotxApp, + section: &'static str, + title: &'static str, + target: &TargetRef, + ui: &mut Ui, +) -> bool { + render_section( + app, + section, + title, + SectionNoun::new("canvas", "canvases"), + std::slice::from_ref(target), + None, + SectionLayout::Standard, + ui, + ) +} + +/// Render the catalog rows for one expanded apodization step in the existing +/// processing editor. The step list supplies the stable component target; this +/// panel supplies the same schema, reset and action path as every other scope. +pub(crate) fn apodization_section(app: &mut PlotxApp, target: &TargetRef, ui: &mut Ui) -> bool { + render_section( + app, + APODIZATION_SECTION, + "Apodization", + SectionNoun::new("processing step", "processing steps"), + std::slice::from_ref(target), + None, + SectionLayout::Standard, + ui, + ) +} + +pub(crate) fn zero_fill_section(app: &mut PlotxApp, target: &TargetRef, ui: &mut Ui) -> bool { + processing_parameter_section(app, ZERO_FILL_SECTION, "Zero fill", target, ui) +} + +pub(crate) fn phase_section(app: &mut PlotxApp, target: &TargetRef, ui: &mut Ui) -> bool { + let rendered = processing_parameter_section(app, PHASE_SECTION, "Phase", target, ui); + if rendered { + ui.weak(format!( + "{} drag the spectrum to adjust", + egui_phosphor::regular::HAND_POINTING + )); + } + rendered +} + +pub(crate) fn baseline_section(app: &mut PlotxApp, target: &TargetRef, ui: &mut Ui) -> bool { + let rendered = processing_parameter_section(app, BASELINE_SECTION, "Baseline", target, ui); + if rendered + && app + .resolve_property(&PropertyAddress::new(target.clone(), baseline::METHOD)) + .ok() + .is_some_and(|resolved| { + resolved.value.uniform() + == Some(&PropertyValue::Enum(baseline::ASYMMETRIC_LEAST_SQUARES)) + }) + { + ui.small("AsLS estimates a smooth baseline while down-weighting positive peaks."); + } + rendered +} + +pub(crate) fn reference_section(app: &mut PlotxApp, target: &TargetRef, ui: &mut Ui) -> bool { + processing_parameter_section(app, REFERENCE_SECTION, "Reference", target, ui) +} + +pub(crate) fn smooth_section(app: &mut PlotxApp, target: &TargetRef, ui: &mut Ui) -> bool { + processing_parameter_section(app, SMOOTH_SECTION, "Smoothing", target, ui) +} + +pub(crate) fn normalize_section(app: &mut PlotxApp, target: &TargetRef, ui: &mut Ui) -> bool { + processing_parameter_section(app, NORMALIZE_SECTION, "Normalize", target, ui) +} + +pub(crate) fn bin_section(app: &mut PlotxApp, target: &TargetRef, ui: &mut Ui) -> bool { + processing_parameter_section(app, BIN_SECTION, "Binning", target, ui) +} + +fn processing_parameter_section( + app: &mut PlotxApp, + section: &'static str, + title: &'static str, + target: &TargetRef, + ui: &mut Ui, +) -> bool { + render_section( + app, + section, + title, + SectionNoun::new("processing step", "processing steps"), + std::slice::from_ref(target), + None, + SectionLayout::Standard, + ui, + ) +} + +/// Render the cross-step enabled property inside the step-list row. The same +/// Essential set used by standard sections and by the budget test selects the +/// row; only its chrome is compact. +pub(crate) fn processing_step_section(app: &mut PlotxApp, target: &TargetRef, ui: &mut Ui) -> bool { + render_section( + app, + PROCESSING_STEP_SECTION, + "Processing step", + SectionNoun::new("processing step", "processing steps"), + std::slice::from_ref(target), + None, + SectionLayout::Inline, + ui, + ) +} + +/// The processing menu is already titled Advanced, so its catalog section +/// renders all of its non-Essential rows directly instead of nesting another +/// Advanced disclosure. +pub(crate) fn processing_advanced_section( + app: &mut PlotxApp, + target: &TargetRef, + ui: &mut Ui, +) -> bool { + render_section( + app, + PROCESSING_ADVANCED_SECTION, + "Advanced processing", + SectionNoun::new("dataset", "datasets"), + std::slice::from_ref(target), + None, + SectionLayout::Menu, + ui, + ) +} + +#[allow(clippy::too_many_arguments)] +fn render_section( + app: &mut PlotxApp, + section: &'static str, + title: &'static str, + status_noun: SectionNoun, + targets: &[TargetRef], + reset_encoding: Option, + layout: SectionLayout, + ui: &mut Ui, +) -> bool { + if targets.is_empty() { + return false; + } + let mut rows = resolve_rows_for(app, targets, section); + if rows.is_empty() { + return false; + } + if app.settings.appearance.canvas_accent.is_none() + && let Some(row) = rows + .iter_mut() + .find(|row| row.presentation.id == app_preferences::ACCENT_COLOR) + { + let theme = ui.visuals().selection.bg_fill; + let color = PropertyValue::Color(plotx_figure::Color::rgb(theme.r(), theme.g(), theme.b())); + row.set.value = AggregateValue::Uniform(color.clone()); + row.representative.value = AggregateValue::Uniform(color.clone()); + row.representative.default_value = Some(color); + row.representative.modified = Some(false); + } + + let now = ui.input(|input| input.time); + let focus = app.session.ui.property_focus; + let focused_here = + focus.is_some_and(|focus| rows.iter().any(|row| row.presentation.id == focus.property)); + + // Every target in the selection is not what this section acts on: the + // heading counts the ones that actually supply one of its rows, so a page + // holding one contour plot and one line plot does not report two of each. + let applicable = applicable_targets(&rows); + + if layout == SectionLayout::Standard { + ui.separator(); + ui.horizontal(|ui| { + ui.strong(title); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.weak(status_noun.counted(applicable.len())); + }); + }); + } + + // The rows rendered without expanding anything are exactly the list the + // budget check counts, so the check cannot pass while the panel shows more. + let essential: Vec = super::super::essential_in(section) + .into_iter() + .map(|entry| entry.id) + .collect(); + let mut pending: Option = None; + let mut gesture: Option<(PropertyId, GestureEdge)> = None; + let mut text_edits = std::mem::take(&mut app.session.ui.property_text_edits); + let length_unit = app.session.ui.canvas_size_unit; + let mut edits = RowEdits { + pending: &mut pending, + gesture: &mut gesture, + text_edits: &mut text_edits, + }; + for row in rows + .iter() + .filter(|row| essential.contains(&row.presentation.id)) + { + if layout == SectionLayout::Inline { + property_row_inline(row, focus, now, &mut edits, targets, length_unit, ui); + } else { + property_row(row, focus, now, &mut edits, targets, length_unit, ui); + } + } + + let advanced: Vec<&Row> = rows + .iter() + .filter(|row| !essential.contains(&row.presentation.id)) + .collect(); + if layout == SectionLayout::Menu { + for row in advanced { + property_row(row, focus, now, &mut edits, targets, length_unit, ui); + } + } else if !advanced.is_empty() { + let id = ui.make_persistent_id(("property_section", section)); + let mut state = + egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, false); + if focused_here + && focus.is_some_and(|focus| { + advanced + .iter() + .any(|row| row.presentation.id == focus.property) + }) + { + state.set_open(true); + state.store(ui.ctx()); + } + egui::CollapsingHeader::new("Advanced") + .id_salt(("property_section", section)) + .show(ui, |ui| { + for row in advanced { + property_row(row, focus, now, &mut edits, targets, length_unit, ui); + } + }); + } + + if let Some(encoding) = reset_encoding + && ui + .small_button("Reset contour") + .on_hover_text("Rebuild this series' encoding from its defaults") + .clicked() + { + pending = Some(Pending::ResetEncoding(encoding)); + } + + // The reveal is one-shot: once the section has been drawn with the row in + // it, only the fading highlight remains. + if let Some(focus) = app.session.ui.property_focus.as_mut() + && focused_here + { + focus.pending = false; + } + if focus.is_some_and(|focus| now >= focus.highlight_until) { + app.session.ui.property_focus = None; + } + app.session.ui.property_text_edits = text_edits; + + // Opened before the write and closed after it, so the frame that starts a + // drag is already inside the gesture and the frame that ends one is the last + // it records. + if let Some((property, GestureEdge::Started)) = gesture { + app.begin_property_gesture(property); + } + if let Some(pending) = pending { + // Still the whole selection: a target this section cannot supply is + // reported as a skip rather than quietly left out of the write. + apply(app, targets, pending, status_noun); + } + if let Some((_, GestureEdge::Stopped)) = gesture { + app.end_property_gesture(); + } + true +} diff --git a/crates/app/src/ui/properties/tests.rs b/crates/app/src/ui/properties/tests.rs index 80ef4508..9f276daf 100644 --- a/crates/app/src/ui/properties/tests.rs +++ b/crates/app/src/ui/properties/tests.rs @@ -1,11 +1,70 @@ use super::*; -use plotx_core::properties::{PropertyAccess, catalog}; +use plotx_core::properties::{PropertyAccess, ValueSchema, catalog, step_enabled}; /// Rows a single panel section renders without being expanded. Beyond this the /// section is no longer a panel but a settings dump, and the fix is to re-tier /// properties rather than to raise the number. const MAX_ESSENTIAL_PER_SECTION: usize = 6; +#[derive(Debug)] +struct EssentialVisibilityProfile { + discriminator: &'static str, + visible: Vec, +} + +fn essential_visibility_profiles(section: &str) -> Vec { + let declared: Vec = essential_in(section).iter().map(|entry| entry.id).collect(); + if section != panel::CHART_SECTION { + if section == panel::BASELINE_SECTION { + return [ + baseline::OFFSET, + baseline::POLYNOMIAL, + baseline::ASYMMETRIC_LEAST_SQUARES, + ] + .into_iter() + .map(|method| EssentialVisibilityProfile { + discriminator: method, + visible: declared + .iter() + .copied() + .filter(|property| baseline::property_applies_to_method(*property, method)) + .collect(), + }) + .collect(); + } + return vec![EssentialVisibilityProfile { + discriminator: "all declared Essential rows applicable", + visible: declared, + }]; + } + + let chart_type = definition(object::CHART_TYPE_ID).expect("Chart Type is registered"); + let ValueSchema::Enum { variants } = &chart_type.value_schema else { + panic!("Chart Type must remain an enum"); + }; + variants + .iter() + .map(|variant| EssentialVisibilityProfile { + discriminator: variant.id, + visible: declared + .iter() + .copied() + .filter(|property| object::chart_property_applies_to_type(*property, variant.id)) + .collect(), + }) + .collect() +} + +fn maximum_visible_essential(section: &str) -> EssentialVisibilityProfile { + essential_visibility_profiles(section) + .into_iter() + .max_by_key(|profile| profile.visible.len()) + .unwrap_or(EssentialVisibilityProfile { + discriminator: "empty section", + visible: Vec::new(), + }) +} + /// §8.1: every user-visible property must have a presentation. A definition /// without one is addressable by automation yet invisible and unsearchable in /// the interface — exactly the asymmetry the catalog exists to remove. @@ -115,7 +174,7 @@ fn aliases_are_indexed_by_the_unified_search() { } } -/// §8.6: the panel budget. Exceeding it means the section has stopped being a +/// §8.7: the panel budget. Exceeding it means the section has stopped being a /// panel, and the remedy is to move rows to `Advanced`, not to raise the limit. #[test] fn no_panel_section_exceeds_its_essential_budget() { @@ -125,26 +184,213 @@ fn no_panel_section_exceeds_its_essential_budget() { for panel in [ PanelRoute::SecondarySidebar, PanelRoute::Processing, + PanelRoute::CanvasSettings, PanelRoute::Preferences, ] { for section in panel.sections() { - let essential = essential_in(section); + let profile = maximum_visible_essential(section); assert!( - essential.len() <= MAX_ESSENTIAL_PER_SECTION, - "section '{section}' of the {} renders {} Essential properties, \ + profile.visible.len() <= MAX_ESSENTIAL_PER_SECTION, + "section '{section}' of the {} renders {} Essential properties \ + together for '{}', \ over the budget of {MAX_ESSENTIAL_PER_SECTION}. Re-tier some of \ {:?} to Advanced or Expert instead of raising the budget.", panel.title(), - essential.len(), - essential + profile.visible.len(), + profile.discriminator, + profile + .visible .iter() - .map(|entry| entry.id.as_str()) + .map(|property| property.as_str()) .collect::>() ); } } } +#[test] +fn migrated_object_sections_keep_their_existing_density() { + for (section, declared, visible, combination) in [ + ( + panel::STACK_SECTION, + 5, + 5, + "an offset line stack, including series visibility", + ), + ( + panel::CHART_SECTION, + 7, + 4, + "table_surface: type, colormap, azimuth, elevation", + ), + (panel::TEXT_SECTION, 5, 5, "a text object"), + (panel::SHAPE_SECTION, 5, 5, "a filled shape object"), + (panel::PANEL_SECTION, 2, 2, "a plot with a panel label"), + (panel::OBJECT_SECTION, 1, 1, "any selected object"), + ] { + assert_eq!(essential_in(section).len(), declared, "{section}"); + let profile = maximum_visible_essential(section); + assert_eq!( + profile.visible.len(), + visible, + "section '{section}' must retain its calibrated density from {combination}" + ); + if section == panel::CHART_SECTION { + assert_eq!(profile.discriminator, "table_surface"); + } + } +} + +#[test] +fn migrated_processing_controls_keep_their_pre_migration_visibility() { + for property in [ + baseline::POLYNOMIAL_ORDER, + baseline::SMOOTHNESS, + baseline::ASYMMETRY, + baseline::ITERATIONS, + smooth::POLYNOMIAL_ORDER, + bin::METHOD, + phase::PIVOT, + ] { + assert_eq!( + definition(property) + .expect("the processing property is registered") + .tier, + Tier::Essential, + "{property} must remain directly visible" + ); + } + let baseline_profile = maximum_visible_essential(panel::BASELINE_SECTION); + assert_eq!( + baseline_profile.visible.len(), + 4, + "AsLS shows method, smoothness, asymmetry, and iterations together" + ); + assert_eq!( + baseline_profile.discriminator, + baseline::ASYMMETRIC_LEAST_SQUARES + ); +} + +#[test] +fn migrated_canvas_and_typography_controls_keep_their_visibility_and_section_density() { + for (section, expected, combination) in [ + ( + panel::CANVAS_MARGINS_SECTION, + 5, + "a canvas with all four margins and its gutter", + ), + ( + panel::CANVAS_GRID_SECTION, + 4, + "a canvas with rows, columns, grid visibility, and spacing mode", + ), + ( + panel::CANVAS_SIZE_SECTION, + 3, + "a canvas with width, height, and automatic height", + ), + ( + panel::CANVAS_CAPTION_SECTION, + 2, + "a canvas with caption and panel-label controls", + ), + ( + panel::TYPOGRAPHY_SECTION, + 3, + "a document with all typography controls", + ), + ] { + assert_eq!( + maximum_visible_essential(section).visible.len(), + expected, + "section '{section}' must reflect the controls visible for {combination}" + ); + } +} + +#[test] +fn migrated_preferences_keep_their_real_section_density() { + for (section, expected, combination) in [ + ( + SettingsCategory::General.section_id(), + 3, + "all catalog-backed General preferences", + ), + ( + SettingsCategory::Appearance.section_id(), + 3, + "theme, GPU, and the accent override row", + ), + ( + SettingsCategory::Processing.section_id(), + 1, + "the scale-content processing preference", + ), + ( + SettingsCategory::Export.section_id(), + 4, + "all catalog-backed Export preferences", + ), + ( + panel::PREFERENCES_UPDATES_SECTION, + 2, + "automatic checks and update channel", + ), + ( + SettingsCategory::Recent.section_id(), + 0, + "no catalog-backed Essential rows", + ), + ] { + assert_eq!( + maximum_visible_essential(section).visible.len(), + expected, + "section '{section}' must retain the controls visible for {combination}" + ); + } +} + +#[test] +fn migrated_axis_controls_remain_six_essential_rows() { + let essential: Vec = essential_in(panel::AXIS_SECTION) + .iter() + .map(|entry| entry.id) + .collect(); + assert_eq!( + essential, + [ + axis::X_LABEL, + axis::Y_LABEL, + axis::X_SHOW_TICK_LABELS, + axis::X_SHOW_LABEL, + axis::Y_SHOW_TICK_LABELS, + axis::Y_SHOW_LABEL, + ] + ); +} + +#[test] +fn only_physical_canvas_lengths_follow_the_users_canvas_unit() { + let marked: Vec = PRESENTATIONS + .iter() + .filter(|entry| entry.uses_canvas_length_unit) + .map(|entry| entry.id) + .collect(); + assert_eq!( + marked, + [ + canvas::MARGIN_TOP_MM, + canvas::MARGIN_RIGHT_MM, + canvas::MARGIN_BOTTOM_MM, + canvas::MARGIN_LEFT_MM, + canvas::GUTTER_MM, + canvas::WIDTH_MM, + canvas::HEIGHT_MM, + ] + ); +} + /// §12: only the lowest level is Essential on a contour; the ladder shape and /// the negative-half colour are for users who went looking for them. #[test] @@ -155,3 +401,15 @@ fn only_the_lowest_contour_level_is_essential() { .collect(); assert_eq!(essential, [contour::BASE_MAGNITUDE.as_str()]); } + +/// The row checkbox is compact chrome, not a second presentation channel. Its +/// renderer selects from this same Essential set before drawing inline, so the +/// budget and the visible row cannot drift. +#[test] +fn the_inline_step_toggle_is_the_processing_step_sections_essential_set() { + let essential: Vec = essential_in(panel::PROCESSING_STEP_SECTION) + .iter() + .map(|entry| entry.id) + .collect(); + assert_eq!(essential, [step_enabled::ENABLED]); +} diff --git a/crates/app/src/ui/properties/types.rs b/crates/app/src/ui/properties/types.rs new file mode 100644 index 00000000..54505bea --- /dev/null +++ b/crates/app/src/ui/properties/types.rs @@ -0,0 +1,134 @@ +use super::panel; +use plotx_core::properties::{PropertyDefinition, PropertyId, Tier, definition}; +use plotx_core::state::{SettingsCategory, WorkflowTab}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LocalizedText(pub &'static str); + +impl LocalizedText { + pub const fn get(self) -> &'static str { + self.0 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PanelRoute { + SecondarySidebar, + Processing, + CanvasSettings, + Preferences, +} + +const PREFERENCES_SECTIONS: &[&str] = &[ + SettingsCategory::General.section_id(), + SettingsCategory::Appearance.section_id(), + SettingsCategory::Processing.section_id(), + SettingsCategory::Export.section_id(), + panel::PREFERENCES_UPDATES_SECTION, + SettingsCategory::Recent.section_id(), +]; + +impl PanelRoute { + pub const fn sections(self) -> &'static [&'static str] { + match self { + Self::SecondarySidebar => &[ + panel::CONTOUR_SECTION, + panel::LINE_SECTION, + panel::AXIS_SECTION, + panel::STACK_SECTION, + panel::CHART_SECTION, + panel::TEXT_SECTION, + panel::SHAPE_SECTION, + panel::PANEL_SECTION, + panel::OBJECT_SECTION, + panel::TYPOGRAPHY_SECTION, + ], + Self::Processing => &[ + panel::APODIZATION_SECTION, + panel::ZERO_FILL_SECTION, + panel::PHASE_SECTION, + panel::BASELINE_SECTION, + panel::REFERENCE_SECTION, + panel::SMOOTH_SECTION, + panel::NORMALIZE_SECTION, + panel::BIN_SECTION, + panel::PROCESSING_STEP_SECTION, + panel::PROCESSING_ADVANCED_SECTION, + ], + Self::CanvasSettings => &[ + panel::CANVAS_MARGINS_SECTION, + panel::CANVAS_GRID_SECTION, + panel::CANVAS_SIZE_SECTION, + panel::CANVAS_CAPTION_SECTION, + ], + Self::Preferences => PREFERENCES_SECTIONS, + } + } + + pub const fn title(self) -> &'static str { + match self { + Self::SecondarySidebar => "Object inspector", + Self::Processing => "Processing tools", + Self::CanvasSettings => "Canvas settings", + Self::Preferences => "Preferences", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HomeRoute { + pub panel: PanelRoute, + pub section: &'static str, +} + +#[derive(Clone, Copy, Debug)] +pub struct PropertyPresentation { + pub id: PropertyId, + pub localized_label: LocalizedText, + pub localized_aliases: &'static [LocalizedText], + pub home_route: HomeRoute, + pub canvas_step: bool, + pub uses_canvas_length_unit: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RibbonSpot { + pub tab: WorkflowTab, + pub group: &'static str, + pub priority: u8, +} + +#[derive(Clone, Copy, Debug)] +pub struct PropertyGroup { + pub section: &'static str, + pub label: LocalizedText, + pub icon: &'static str, + pub ribbon: RibbonSpot, + pub unavailable_reason: &'static str, +} + +pub(crate) const fn preference_entry( + id: PropertyId, + label: &'static str, + aliases: &'static [LocalizedText], + home_route: HomeRoute, +) -> PropertyPresentation { + PropertyPresentation { + id, + localized_label: LocalizedText(label), + localized_aliases: aliases, + home_route, + canvas_step: false, + uses_canvas_length_unit: false, + } +} + +impl PropertyPresentation { + pub fn tier(&self) -> Option { + definition(self.id).map(|definition| definition.tier) + } + + pub fn definition(&self) -> Option<&'static PropertyDefinition> { + definition(self.id) + } +} diff --git a/crates/app/src/ui/settings_dialog.rs b/crates/app/src/ui/settings_dialog.rs index 7999afbb..0352d055 100644 --- a/crates/app/src/ui/settings_dialog.rs +++ b/crates/app/src/ui/settings_dialog.rs @@ -1,12 +1,13 @@ +//! Settings dialog layout and category rendering. + +mod chrome; +mod controls; + use super::*; -use egui::{Align, Align2, CornerRadius, FontId, Layout, RichText, pos2, vec2}; -use egui_phosphor::regular as icon; -use plotx_core::settings::{ - GraphicsPowerPreference, MAX_EXPORT_DPI, MAX_ILT_LAMBDA, MIN_EXPORT_DPI, MIN_ILT_LAMBDA, - Settings, ThemeMode, -}; -use plotx_core::state::{MonitorScaleStatus, SettingsCategory, SettingsDialog}; -use plotx_core::update::{UpdateChannelSetting, UpdateService, UpdateStatus}; +use egui::vec2; +use plotx_core::properties::{PropertyAccess, ScopeKind, catalog}; +use plotx_core::settings::Settings; +use plotx_core::state::{MonitorScaleStatus, SettingsCategory}; const RAIL_WIDTH: f32 = 172.0; const CONTROL_COL: f32 = 200.0; @@ -17,24 +18,9 @@ const MIN_W: f32 = 468.0; const MIN_H: f32 = 300.0; const FLUSH_DELAY: f64 = 0.6; -pub(crate) fn apply_chrome_theme(ctx: &egui::Context, mode: ThemeMode) { - let pref = match mode { - ThemeMode::System => egui::ThemePreference::System, - ThemeMode::Light => egui::ThemePreference::Light, - ThemeMode::Dark => egui::ThemePreference::Dark, - }; - ctx.set_theme(pref); - for theme in [egui::Theme::Light, egui::Theme::Dark] { - ctx.style_mut_of(theme, |style| { - // Disabled widgets keep the normal button fill and fade only via - // `disabled_alpha`. Stock egui swaps in the near-panel - // `noninteractive` fill, which makes light-theme buttons *brighten* - // when a modal disables the chrome behind it. - style.visuals.widgets.noninteractive.weak_bg_fill = - style.visuals.widgets.inactive.weak_bg_fill; - }); - } -} +pub(crate) use chrome::{apply_chrome_theme, sync_chrome_theme}; +use chrome::{footer, rail_row}; +use controls::{render_recent, ui_scale_row, update_status_row}; pub(super) fn settings_window(app: &mut PlotxApp, ctx: &egui::Context) { if app.session.ui.settings_dialog.is_none() { @@ -42,7 +28,8 @@ pub(super) fn settings_window(app: &mut PlotxApp, ctx: &egui::Context) { } let now = ctx.input(|i| i.time); let mut done = false; - let mut changed = false; + let mut reset = false; + let settings_before = app.settings.clone(); let available = ctx.content_rect().size() - vec2(48.0, 48.0); let size = vec2(WINDOW_W, WINDOW_H) @@ -53,36 +40,22 @@ pub(super) fn settings_window(app: &mut PlotxApp, ctx: &egui::Context) { ui.set_min_size(size); ui.heading("Preferences"); ui.separator(); - let session = &mut app.session; - let dialog = session.ui.settings_dialog.as_mut().unwrap(); - let before = dialog.draft.clone(); - let (d, reset) = window_body(ui, dialog, &mut session.updates, monitor.as_ref()); + let (d, r) = window_body(ui, app, monitor.as_ref()); done = d; - if reset { - // Reset restores preferences, not history: the recent-files list - // is user data and survives. - let recent = dialog.draft.recent.clone(); - dialog.draft = Settings::default(); - dialog.draft.recent = recent; - // The probed automatic scale of the current display is a fact, not - // a preference; reseed it so reset only drops the manual override. - if let Some(monitor) = &monitor { - dialog.draft.appearance.ui_scale.monitors.insert( - monitor.key.clone(), - plotx_core::settings::MonitorScale { - auto: monitor.auto, - user: None, - }, - ); - } - dialog.last_error = None; - } - if dialog.draft != before { - changed = true; - } + reset = r; }); - if changed { + if reset { + reset_preferences(app, monitor.as_ref()); + } + + let draft_changed = app + .session + .ui + .settings_dialog + .as_ref() + .is_some_and(|dialog| dialog.draft != app.settings); + if draft_changed { let draft = app .session .ui @@ -92,16 +65,19 @@ pub(super) fn settings_window(app: &mut PlotxApp, ctx: &egui::Context) { .draft .clone(); 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. - if let Some(monitor) = &app.session.monitor { - ctx.set_zoom_factor(monitor.effective()); - } if let Some(dialog) = app.session.ui.settings_dialog.as_mut() { dialog.flush_at = Some(now + FLUSH_DELAY); } } + if app.settings.appearance.theme != settings_before.appearance.theme { + apply_chrome_theme(ctx, app.settings.appearance.theme); + } + if app.settings.appearance.ui_scale != settings_before.appearance.ui_scale + && let Some(monitor) = &app.session.monitor + { + // The current monitor's resolved zoom belongs to the app shell. + ctx.set_zoom_factor(monitor.effective()); + } let close = done || modal.should_close(); let flush = app @@ -138,8 +114,7 @@ pub(super) fn settings_window(app: &mut PlotxApp, ctx: &egui::Context) { fn window_body( ui: &mut Ui, - dialog: &mut SettingsDialog, - updates: &mut UpdateService, + app: &mut PlotxApp, monitor: Option<&MonitorScaleStatus>, ) -> (bool, bool) { let mut done = false; @@ -148,6 +123,7 @@ fn window_body( egui::Panel::bottom("settings_footer") .frame(egui::Frame::side_top_panel(ui.style()).inner_margin(egui::Margin::symmetric(8, 8))) .show_inside(ui, |ui| { + let dialog = app.session.ui.settings_dialog.as_ref().unwrap(); let (d, r) = footer(ui, dialog); done = d; reset = r; @@ -159,506 +135,114 @@ fn window_body( .show_inside(ui, |ui| { ui.add_space(6.0); for cat in SettingsCategory::ALL { + let dialog = app.session.ui.settings_dialog.as_mut().unwrap(); if rail_row(ui, cat, dialog.category == cat).clicked() { dialog.category = cat; } } }); + let category = app.session.ui.settings_dialog.as_ref().unwrap().category; egui::CentralPanel::default() .frame(egui::Frame::central_panel(ui.style()).inner_margin(egui::Margin::symmetric(18, 12))) .show_inside(ui, |ui| { egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { - render_category(ui, dialog.category, &mut dialog.draft, updates, monitor); + render_category(ui, category, app, monitor); }); }); (done, reset) } -fn rail_row(ui: &mut Ui, cat: SettingsCategory, selected: bool) -> Response { - let width = ui.available_width(); - let (rect, resp) = ui.allocate_exact_size(vec2(width, 30.0), Sense::click()); - let visuals = ui.visuals(); - let color = if selected || resp.hovered() { - visuals.strong_text_color() - } else { - visuals.text_color() - }; - if selected { - ui.painter() - .rect_filled(rect, CornerRadius::same(6), visuals.selection.bg_fill); - } else if resp.hovered() { - ui.painter() - .rect_filled(rect, CornerRadius::same(6), visuals.widgets.hovered.bg_fill); - } - let cy = rect.center().y; - let painter = ui.painter(); - painter.text( - pos2(rect.left() + 14.0, cy), - Align2::LEFT_CENTER, - rail_icon(cat), - FontId::proportional(15.0), - color, - ); - painter.text( - pos2(rect.left() + 38.0, cy), - Align2::LEFT_CENTER, - cat.label(), - FontId::proportional(14.0), - color, - ); - resp -} - -fn rail_icon(cat: SettingsCategory) -> &'static str { - match cat { - SettingsCategory::General => icon::GEAR_SIX, - SettingsCategory::Appearance => icon::PALETTE, - SettingsCategory::Processing => icon::WAVEFORM, - SettingsCategory::Export => icon::EXPORT, - SettingsCategory::Recent => icon::CLOCK_COUNTER_CLOCKWISE, - } -} - fn render_category( ui: &mut Ui, cat: SettingsCategory, - draft: &mut Settings, - updates: &mut UpdateService, + app: &mut PlotxApp, monitor: Option<&MonitorScaleStatus>, ) { ui.add_space(2.0); match cat { SettingsCategory::General => { - setting_row( + properties::panel::preferences_section(app, SettingsCategory::General.section_id(), ui); + properties::panel::preferences_section( + app, + properties::panel::PREFERENCES_UPDATES_SECTION, ui, - "Object snapping", - Some("Snap plots and shapes to guides while dragging."), - |ui| { - toggle(ui, &mut draft.general.snap_enabled); - }, ); - setting_row( - ui, - "Keep source canvas when tiling its last object", - Some("Alt temporarily reverses this choice for a single drop."), - |ui| { - toggle(ui, &mut draft.general.keep_empty_source_canvas); - }, - ); - setting_row( - ui, - "Project backup copies", - Some( - "Keep this many complete previous saves as hidden files beside the project. \ - Each copy can be as large as the project; choose Off to disable them.", - ), - |ui| backup_count_combo(ui, &mut draft.general.project_backup_generations), - ); - setting_row( - ui, - "Automatic updates", - Some("Check for new versions in the background."), - |ui| { - toggle(ui, &mut draft.updates.auto_check); - }, - ); - setting_row( - ui, - "Update channel", - Some("Which release train to follow. Each channel only offers its own builds."), - |ui| channel_combo(ui, &mut draft.updates.channel), - ); - update_status_row(ui, updates); + update_status_row(ui, &mut app.session.updates); } SettingsCategory::Appearance => { - setting_row( - ui, - "Chrome theme", - Some("Light, dark, or follow the system appearance."), - |ui| theme_combo(ui, &mut draft.appearance.theme), - ); - setting_row( + properties::panel::preferences_section( + app, + SettingsCategory::Appearance.section_id(), ui, - "Canvas accent", - Some("Editor guides and selections only; figure and export colours are unchanged."), - |ui| { - let theme = ui.visuals().selection.bg_fill; - let mut rgb = - draft - .appearance - .canvas_accent - .unwrap_or([theme.r(), theme.g(), theme.b()]); - if ui.color_edit_button_srgb(&mut rgb).changed() { - draft.appearance.canvas_accent = Some(rgb); - } - if ui - .button("Follow theme") - .on_hover_text("Reset the canvas accent to the active theme") - .clicked() - { - draft.appearance.canvas_accent = None; - } - }, ); + let draft = &mut app.session.ui.settings_dialog.as_mut().unwrap().draft; ui_scale_row(ui, draft, monitor); - setting_row( - ui, - "Graphics processor", - Some("Choose the GPU class PlotX requests at startup. Restart required."), - |ui| graphics_power_combo(ui, &mut draft.appearance.graphics_power), - ); } SettingsCategory::Processing => { - setting_row( + properties::panel::preferences_section( + app, + SettingsCategory::Processing.section_id(), ui, - "Default ILT regularization (λ)", - Some( - "Regularization used to build an ILT DOSY map for a dataset that has no earlier ILT result.", - ), - |ui| { - ui.add( - egui::DragValue::new(&mut draft.processing.ilt_lambda) - .speed(0.001) - .range(MIN_ILT_LAMBDA..=MAX_ILT_LAMBDA), - ); - }, ); } SettingsCategory::Export => { - setting_row( - ui, - "Embed view snapshots", - Some("Save each plot's on-screen view into the .plotx file."), - |ui| { - toggle(ui, &mut draft.export.include_view_snapshots); - }, - ); - setting_row( - ui, - "Raster resolution", - Some("Pixel density for bitmap (PNG) exports."), - |ui| { - ui.add( - egui::DragValue::new(&mut draft.export.dpi) - .range(MIN_EXPORT_DPI..=MAX_EXPORT_DPI) - .suffix(" dpi"), - ); - }, - ); + properties::panel::preferences_section(app, SettingsCategory::Export.section_id(), ui); + } + SettingsCategory::Recent => { + let draft = &mut app.session.ui.settings_dialog.as_mut().unwrap().draft; + render_recent(ui, draft); } - SettingsCategory::Recent => render_recent(ui, draft), - } -} - -fn graphics_power_combo(ui: &mut Ui, value: &mut GraphicsPowerPreference) { - egui::ComboBox::from_id_salt("settings_graphics_power") - .selected_text(value.label()) - .show_ui(ui, |ui| { - for choice in GraphicsPowerPreference::ALL { - ui.selectable_value(value, choice, choice.label()); - } - }); -} - -fn render_recent(ui: &mut Ui, draft: &mut Settings) { - if draft.recent.files.is_empty() { - empty_state( - ui, - "No recent files yet. Open data or a project to fill this list.", - ); - return; - } - ui.label( - RichText::new("Reopen entries from the File menu (Open Recent) or the welcome screen.") - .small() - .color(ui.visuals().weak_text_color()), - ); - ui.add_space(ROW_GAP); - let weak = ui.visuals().weak_text_color(); - let strong = ui.visuals().strong_text_color(); - for path in &draft.recent.files { - let name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or_else(|| path.to_str().unwrap_or("")); - ui.horizontal(|ui| { - ui.label(RichText::new(icon::FILE).color(weak)); - ui.label(RichText::new(name).color(strong)) - .on_hover_text(path.display().to_string()); - }); - ui.add_space(4.0); - } - ui.add_space(ROW_GAP); - if ui.button("Clear recent files").clicked() { - draft.recent.files.clear(); } } -fn setting_row(ui: &mut Ui, label: &str, desc: Option<&str>, control: impl FnOnce(&mut Ui)) { - let spacing = ui.spacing().item_spacing.x; - let full = ui.available_width(); - let control_w = CONTROL_COL.min(full * 0.45); - let label_w = (full - control_w - spacing).max(1.0); - let strong = ui.visuals().strong_text_color(); - let weak = ui.visuals().weak_text_color(); - - ui.horizontal_top(|ui| { - ui.allocate_ui_with_layout(vec2(label_w, 0.0), Layout::top_down(Align::Min), |ui| { - ui.set_width(label_w); - ui.label(RichText::new(label).strong().color(strong)); - if let Some(desc) = desc { - ui.label(RichText::new(desc).small().color(weak)); - } - }); - ui.allocate_ui_with_layout( - vec2(control_w, 0.0), - Layout::right_to_left(Align::Center), - |ui| { - ui.set_width(control_w); - control(ui); - }, - ); - }); - ui.add_space(ROW_GAP); -} - -fn empty_state(ui: &mut Ui, text: &str) { - let weak = ui.visuals().weak_text_color(); - ui.vertical_centered(|ui| { - ui.add_space(48.0); - ui.label(RichText::new(text).color(weak)); - }); -} - -fn channel_combo(ui: &mut Ui, channel: &mut UpdateChannelSetting) { - egui::ComboBox::from_id_salt("settings_update_channel") - .selected_text(channel.label()) - .width(150.0) - .show_ui(ui, |ui| { - for candidate in UpdateChannelSetting::ALL { - ui.selectable_value(channel, candidate, candidate.label()); - } - }); -} - -fn update_status_row(ui: &mut Ui, updates: &mut UpdateService) { - setting_row( - ui, - "Check for updates", - Some(&format!("Installed version {}.", env!("CARGO_PKG_VERSION"))), - |ui| { - if ui - .add_enabled(!updates.is_busy(), egui::Button::new("Check now")) - .clicked() - { - updates.check_now(); - } - }, - ); - let status = updates.status().clone(); - let label = status.label(); - if !label.is_empty() { - let color = match status { - UpdateStatus::Failed { .. } => ui.visuals().error_fg_color, - UpdateStatus::Ready { .. } | UpdateStatus::Installed { .. } => { - ui.visuals().strong_text_color() - } - _ => ui.visuals().weak_text_color(), - }; - ui.horizontal(|ui| { - ui.label(RichText::new(label).small().color(color)); - if let UpdateStatus::Installed { .. } = status - && ui.button("Restart now").clicked() - { - crate::request_relaunch(); - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); - } - }); - ui.add_space(ROW_GAP); +fn reset_preferences(app: &mut PlotxApp, monitor: Option<&MonitorScaleStatus>) { + let target = app.app_target(); + let properties = catalog() + .iter() + .filter(|definition| { + definition.scope_kind == ScopeKind::App + && definition.access == PropertyAccess::ReadWrite + }) + .map(|definition| definition.id) + .collect::>(); + match app.plan_property_resets(&properties, std::slice::from_ref(&target)) { + Ok(commit) => { + app.commit_property(commit); + } + Err(error) => { + app.session.status = format!("Could not reset preferences: {error}"); + return; + } } -} - -/// Manual percentages offered beside Automatic; Ctrl+= / Ctrl+- reach the 5% -/// steps in between. -const UI_SCALE_CHOICES: [f32; 8] = [1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0]; -fn ui_scale_row(ui: &mut Ui, draft: &mut Settings, monitor: Option<&MonitorScaleStatus>) { - let Some(monitor) = monitor else { - setting_row( - ui, - "UI scale", - Some("Size of all interface text and controls."), - |ui| { - ui.label( - RichText::new("Waiting for the display probe…") - .small() - .color(ui.visuals().weak_text_color()), - ); - }, - ); - return; - }; - let detail = match monitor.ppi { - Some(ppi) => format!( - "This display reports {ppi:.0} pixels per inch; automatic picks a physically \ - legible size ({:.0}%). Applies to this display only.", - monitor.auto * 100.0 - ), - None => format!( - "This display did not report its physical size, so automatic keeps the system \ - scale ({:.0}%). Applies to this display only.", - monitor.auto * 100.0 - ), - }; - setting_row(ui, "UI scale", Some(&detail), |ui| { - let entry = draft - .appearance - .ui_scale - .monitors - .entry(monitor.key.clone()) - .or_insert(plotx_core::settings::MonitorScale { + let defaults = Settings::default(); + let mut next = app.settings.clone(); + next.schema_version = defaults.schema_version; + next.app_version = defaults.app_version; + next.appearance.ui_scale = defaults.appearance.ui_scale; + next.canvas_size.recent_presets = defaults.canvas_size.recent_presets; + next.canvas_size.custom_presets = defaults.canvas_size.custom_presets; + next.window = defaults.window; + if let Some(monitor) = monitor { + next.appearance.ui_scale.monitors.insert( + monitor.key.clone(), + plotx_core::settings::MonitorScale { auto: monitor.auto, user: None, - }); - let selected = match entry.user { - Some(user) => format!("{:.0}%", user * 100.0), - None => format!("Automatic ({:.0}%)", entry.auto * 100.0), - }; - egui::ComboBox::from_id_salt("settings_ui_scale") - .selected_text(selected) - .width(150.0) - .show_ui(ui, |ui| { - ui.selectable_value( - &mut entry.user, - None, - format!("Automatic ({:.0}%)", entry.auto * 100.0), - ); - for choice in UI_SCALE_CHOICES { - ui.selectable_value( - &mut entry.user, - Some(choice), - format!("{:.0}%", choice * 100.0), - ); - } - }); - }); -} - -fn theme_combo(ui: &mut Ui, mode: &mut ThemeMode) { - egui::ComboBox::from_id_salt("settings_theme") - .selected_text(mode.label()) - .width(150.0) - .show_ui(ui, |ui| { - for candidate in ThemeMode::ALL { - ui.selectable_value(mode, candidate, candidate.label()); - } - }); -} - -fn footer(ui: &mut Ui, dialog: &SettingsDialog) -> (bool, bool) { - let mut done = false; - let mut reset = false; - ui.add_space(4.0); - ui.horizontal(|ui| { - if ui.button("Reset to Defaults").clicked() { - reset = true; - } - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui.button("Done").clicked() { - done = true; - } - if let Some(err) = &dialog.last_error { - ui.add_space(10.0); - let color = ui.visuals().error_fg_color; - ui.label(RichText::new(err).small().color(color)); - } - }); - }); - (done, reset) -} - -fn toggle(ui: &mut Ui, on: &mut bool) -> Response { - let (rect, mut resp) = ui.allocate_exact_size(vec2(38.0, 20.0), Sense::click()); - if resp.clicked() { - *on = !*on; - resp.mark_changed(); - } - let enabled = ui.is_enabled(); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, enabled, *on, "")); - if ui.is_rect_visible(rect) { - let how = ui.ctx().animate_bool(resp.id, *on); - let visuals = ui.style().interact_selectable(&resp, *on); - let rect = rect.expand(visuals.expansion); - let radius = 0.5 * rect.height(); - ui.painter().rect( - rect, - radius, - visuals.bg_fill, - visuals.bg_stroke, - egui::StrokeKind::Inside, - ); - let cx = egui::lerp((rect.left() + radius)..=(rect.right() - radius), how); - ui.painter().circle( - pos2(cx, rect.center().y), - 0.75 * radius, - visuals.bg_fill, - visuals.fg_stroke, + }, ); } - resp -} - -fn backup_count_combo(ui: &mut Ui, count: &mut u8) { - let selected = match *count { - 0 => "Off".to_owned(), - 1 => "1 copy".to_owned(), - value => format!("{value} copies"), - }; - egui::ComboBox::from_id_salt("project_backup_generations") - .selected_text(selected) - .width(120.0) - .show_ui(ui, |ui| { - ui.selectable_value(count, 0, "Off"); - for value in 1..=plotx_core::settings::MAX_PROJECT_BACKUP_GENERATIONS { - let label = if value == 1 { - "1 copy".to_owned() - } else { - format!("{value} copies") - }; - ui.selectable_value(count, value, label); - } - }); + app.apply_settings(next); + app.persist_settings(); + if let Some(dialog) = app.session.ui.settings_dialog.as_mut() { + dialog.last_error = None; + } } #[cfg(test)] -mod tests { - use super::*; - use egui::{Pos2, RawInput, Rect, vec2}; - - fn run_all_categories(app: &mut PlotxApp, size: egui::Vec2) { - let ctx = egui::Context::default(); - for cat in SettingsCategory::ALL { - app.session.ui.settings_dialog.as_mut().unwrap().category = cat; - let input = RawInput { - screen_rect: Some(Rect::from_min_size(Pos2::ZERO, size)), - ..Default::default() - }; - let _ = ctx.run_ui(input, |ui| settings_window(app, ui.ctx())); - } - } - - #[test] - fn renders_every_category_at_any_size_without_panic() { - let mut app = PlotxApp::new(); - app.open_settings(); - for _ in 0..3 { - run_all_categories(&mut app, vec2(480.0, 360.0)); - run_all_categories(&mut app, vec2(1600.0, 1000.0)); - } - assert!(app.session.ui.settings_dialog.is_some()); - } -} +#[path = "settings_dialog_tests.rs"] +mod tests; diff --git a/crates/app/src/ui/settings_dialog/chrome.rs b/crates/app/src/ui/settings_dialog/chrome.rs new file mode 100644 index 00000000..ac97a8d4 --- /dev/null +++ b/crates/app/src/ui/settings_dialog/chrome.rs @@ -0,0 +1,105 @@ +//! Settings dialog chrome, navigation rail, theme, and footer. + +use egui::{ + Align, Align2, CornerRadius, FontId, Layout, Response, RichText, Sense, Ui, pos2, vec2, +}; +use egui_phosphor::regular as icon; +use plotx_core::settings::ThemeMode; +use plotx_core::state::{SettingsCategory, SettingsDialog}; + +pub(crate) fn apply_chrome_theme(ctx: &egui::Context, mode: ThemeMode) { + let pref = match mode { + ThemeMode::System => egui::ThemePreference::System, + ThemeMode::Light => egui::ThemePreference::Light, + ThemeMode::Dark => egui::ThemePreference::Dark, + }; + ctx.set_theme(pref); + for theme in [egui::Theme::Light, egui::Theme::Dark] { + ctx.style_mut_of(theme, |style| { + // Disabled widgets keep the normal button fill and fade only via + // `disabled_alpha`. Stock egui swaps in the near-panel + // `noninteractive` fill, which makes light-theme buttons *brighten* + // when a modal disables the chrome behind it. + style.visuals.widgets.noninteractive.weak_bg_fill = + style.visuals.widgets.inactive.weak_bg_fill; + }); + } + ctx.data_mut(|data| { + data.insert_temp(egui::Id::new("plotx_applied_chrome_theme"), mode); + }); +} + +pub(crate) fn sync_chrome_theme(ctx: &egui::Context, mode: ThemeMode) { + let applied = + ctx.data(|data| data.get_temp::(egui::Id::new("plotx_applied_chrome_theme"))); + if applied != Some(mode) { + apply_chrome_theme(ctx, mode); + } +} + +pub(super) fn rail_row(ui: &mut Ui, cat: SettingsCategory, selected: bool) -> Response { + let width = ui.available_width(); + let (rect, resp) = ui.allocate_exact_size(vec2(width, 30.0), Sense::click()); + let visuals = ui.visuals(); + let color = if selected || resp.hovered() { + visuals.strong_text_color() + } else { + visuals.text_color() + }; + if selected { + ui.painter() + .rect_filled(rect, CornerRadius::same(6), visuals.selection.bg_fill); + } else if resp.hovered() { + ui.painter() + .rect_filled(rect, CornerRadius::same(6), visuals.widgets.hovered.bg_fill); + } + let cy = rect.center().y; + let painter = ui.painter(); + painter.text( + pos2(rect.left() + 14.0, cy), + Align2::LEFT_CENTER, + rail_icon(cat), + FontId::proportional(15.0), + color, + ); + painter.text( + pos2(rect.left() + 38.0, cy), + Align2::LEFT_CENTER, + cat.label(), + FontId::proportional(14.0), + color, + ); + resp +} + +fn rail_icon(cat: SettingsCategory) -> &'static str { + match cat { + SettingsCategory::General => icon::GEAR_SIX, + SettingsCategory::Appearance => icon::PALETTE, + SettingsCategory::Processing => icon::WAVEFORM, + SettingsCategory::Export => icon::EXPORT, + SettingsCategory::Recent => icon::CLOCK_COUNTER_CLOCKWISE, + } +} + +pub(super) fn footer(ui: &mut Ui, dialog: &SettingsDialog) -> (bool, bool) { + let mut done = false; + let mut reset = false; + ui.add_space(4.0); + ui.horizontal(|ui| { + if ui.button("Reset to Defaults").clicked() { + reset = true; + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.button("Done").clicked() { + done = true; + } + if let Some(err) = &dialog.last_error { + ui.add_space(10.0); + let color = ui.visuals().error_fg_color; + ui.label(RichText::new(err).small().color(color)); + } + }); + }); + (done, reset) +} diff --git a/crates/app/src/ui/settings_dialog/controls.rs b/crates/app/src/ui/settings_dialog/controls.rs new file mode 100644 index 00000000..0e3f2fb1 --- /dev/null +++ b/crates/app/src/ui/settings_dialog/controls.rs @@ -0,0 +1,190 @@ +//! Settings category controls and compound setting rows. + +use super::{CONTROL_COL, ROW_GAP}; +use egui::{Align, Layout, RichText, Ui, vec2}; +use egui_phosphor::regular as icon; +use plotx_core::settings::Settings; +use plotx_core::state::MonitorScaleStatus; +use plotx_core::update::{UpdateService, UpdateStatus}; + +pub(super) fn render_recent(ui: &mut Ui, draft: &mut Settings) { + if draft.recent.files.is_empty() { + empty_state( + ui, + "No recent files yet. Open data or a project to fill this list.", + ); + return; + } + ui.label( + RichText::new("Reopen entries from the File menu (Open Recent) or the welcome screen.") + .small() + .color(ui.visuals().weak_text_color()), + ); + ui.add_space(ROW_GAP); + let weak = ui.visuals().weak_text_color(); + let strong = ui.visuals().strong_text_color(); + for path in &draft.recent.files { + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_else(|| path.to_str().unwrap_or("")); + ui.horizontal(|ui| { + ui.label(RichText::new(icon::FILE).color(weak)); + ui.label(RichText::new(name).color(strong)) + .on_hover_text(path.display().to_string()); + }); + ui.add_space(4.0); + } + ui.add_space(ROW_GAP); + if ui.button("Clear recent files").clicked() { + draft.recent.files.clear(); + } +} + +pub(super) fn setting_row( + ui: &mut Ui, + label: &str, + desc: Option<&str>, + control: impl FnOnce(&mut Ui), +) { + let spacing = ui.spacing().item_spacing.x; + let full = ui.available_width(); + let control_w = CONTROL_COL.min(full * 0.45); + let label_w = (full - control_w - spacing).max(1.0); + let strong = ui.visuals().strong_text_color(); + let weak = ui.visuals().weak_text_color(); + + ui.horizontal_top(|ui| { + ui.allocate_ui_with_layout(vec2(label_w, 0.0), Layout::top_down(Align::Min), |ui| { + ui.set_width(label_w); + ui.label(RichText::new(label).strong().color(strong)); + if let Some(desc) = desc { + ui.label(RichText::new(desc).small().color(weak)); + } + }); + ui.allocate_ui_with_layout( + vec2(control_w, 0.0), + Layout::right_to_left(Align::Center), + |ui| { + ui.set_width(control_w); + control(ui); + }, + ); + }); + ui.add_space(ROW_GAP); +} + +fn empty_state(ui: &mut Ui, text: &str) { + let weak = ui.visuals().weak_text_color(); + ui.vertical_centered(|ui| { + ui.add_space(48.0); + ui.label(RichText::new(text).color(weak)); + }); +} + +pub(super) fn update_status_row(ui: &mut Ui, updates: &mut UpdateService) { + setting_row( + ui, + "Check for updates", + Some(&format!("Installed version {}.", env!("CARGO_PKG_VERSION"))), + |ui| { + if ui + .add_enabled(!updates.is_busy(), egui::Button::new("Check now")) + .clicked() + { + updates.check_now(); + } + }, + ); + let status = updates.status().clone(); + let label = status.label(); + if !label.is_empty() { + let color = match status { + UpdateStatus::Failed { .. } => ui.visuals().error_fg_color, + UpdateStatus::Ready { .. } | UpdateStatus::Installed { .. } => { + ui.visuals().strong_text_color() + } + _ => ui.visuals().weak_text_color(), + }; + ui.horizontal(|ui| { + ui.label(RichText::new(label).small().color(color)); + if let UpdateStatus::Installed { .. } = status + && ui.button("Restart now").clicked() + { + crate::request_relaunch(); + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + } + }); + ui.add_space(ROW_GAP); + } +} + +/// Manual percentages offered beside Automatic; Ctrl+= / Ctrl+- reach the 5% +/// steps in between. +const UI_SCALE_CHOICES: [f32; 8] = [1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0]; + +pub(super) fn ui_scale_row( + ui: &mut Ui, + draft: &mut Settings, + monitor: Option<&MonitorScaleStatus>, +) { + let Some(monitor) = monitor else { + setting_row( + ui, + "UI scale", + Some("Size of all interface text and controls."), + |ui| { + ui.label( + RichText::new("Waiting for the display probe…") + .small() + .color(ui.visuals().weak_text_color()), + ); + }, + ); + return; + }; + let detail = match monitor.ppi { + Some(ppi) => format!( + "This display reports {ppi:.0} pixels per inch; automatic picks a physically \ + legible size ({:.0}%). Applies to this display only.", + monitor.auto * 100.0 + ), + None => format!( + "This display did not report its physical size, so automatic keeps the system \ + scale ({:.0}%). Applies to this display only.", + monitor.auto * 100.0 + ), + }; + setting_row(ui, "UI scale", Some(&detail), |ui| { + let entry = draft + .appearance + .ui_scale + .monitors + .entry(monitor.key.clone()) + .or_insert(plotx_core::settings::MonitorScale { + auto: monitor.auto, + user: None, + }); + let selected = match entry.user { + Some(user) => format!("{:.0}%", user * 100.0), + None => format!("Automatic ({:.0}%)", entry.auto * 100.0), + }; + egui::ComboBox::from_id_salt("settings_ui_scale") + .selected_text(selected) + .width(150.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut entry.user, + None, + format!("Automatic ({:.0}%)", entry.auto * 100.0), + ); + for choice in UI_SCALE_CHOICES { + ui.selectable_value( + &mut entry.user, + Some(choice), + format!("{:.0}%", choice * 100.0), + ); + } + }); + }); +} diff --git a/crates/app/src/ui/settings_dialog_tests.rs b/crates/app/src/ui/settings_dialog_tests.rs new file mode 100644 index 00000000..d5a7a3a8 --- /dev/null +++ b/crates/app/src/ui/settings_dialog_tests.rs @@ -0,0 +1,27 @@ +//! Settings dialog rendering tests. + +use super::*; +use egui::{Pos2, RawInput, Rect, vec2}; + +fn run_all_categories(app: &mut PlotxApp, size: egui::Vec2) { + let ctx = egui::Context::default(); + for cat in SettingsCategory::ALL { + app.session.ui.settings_dialog.as_mut().unwrap().category = cat; + let input = RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, size)), + ..Default::default() + }; + let _ = ctx.run_ui(input, |ui| settings_window(app, ui.ctx())); + } +} + +#[test] +fn renders_every_category_at_any_size_without_panic() { + let mut app = PlotxApp::new(); + app.open_settings(); + for _ in 0..3 { + run_all_categories(&mut app, vec2(480.0, 360.0)); + run_all_categories(&mut app, vec2(1600.0, 1000.0)); + } + assert!(app.session.ui.settings_dialog.is_some()); +} diff --git a/crates/app/src/ui/tools/mod.rs b/crates/app/src/ui/tools/mod.rs index a8912b00..2244aa3c 100644 --- a/crates/app/src/ui/tools/mod.rs +++ b/crates/app/src/ui/tools/mod.rs @@ -13,10 +13,9 @@ mod statistics_config; mod task_card; use curve_fit::curve_fit_group; -use egui::{Button, DragValue, Id, Response, Ui}; +use egui::{Button, DragValue, Id, Ui}; use egui_phosphor::regular as icon; use line_fit::line_fit_group; -use plotx_core::actions::{DatasetProcessingState, PendingProcessingEdit}; use plotx_core::state::{Dataset, PlotxApp, Tool, ToolGroup}; use pseudo::experiment_group; use region_analysis::region_analysis_group; @@ -502,43 +501,3 @@ fn integrate_2d_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) { }); } } - -pub(super) fn begin_processing_widget( - app: &mut PlotxApp, - di: usize, - resp: &Response, - before: DatasetProcessingState, -) { - if resp.drag_started() { - app.session.ui.processing_edit = Some(PendingProcessingEdit { - dataset: app.doc.datasets[di].resource_id(), - before, - }); - } -} - -/// Commit a DragValue interaction as one undo step, routed through the pause -/// gate so a paused edit defers its recompute. A drag coalesces via the -/// pending edit's `before`; a plain click commits with `fallback_before`. -pub(super) fn commit_processing_widget( - app: &mut PlotxApp, - di: usize, - resp: &Response, - fallback_before: DatasetProcessingState, -) { - if resp.drag_stopped() { - let before = app - .session - .ui - .processing_edit - .take() - .filter(|edit| edit.dataset == app.doc.datasets[di].resource_id()) - .map(|edit| edit.before) - .unwrap_or(fallback_before); - let after = DatasetProcessingState::from_dataset(&app.doc.datasets[di]); - app.commit_processing_edit(di, before, after); - } else if resp.changed() && !resp.dragged() { - let after = DatasetProcessingState::from_dataset(&app.doc.datasets[di]); - app.commit_processing_edit(di, fallback_before, after); - } -} diff --git a/crates/app/src/ui/tools/processing/cleanup_editors.rs b/crates/app/src/ui/tools/processing/cleanup_editors.rs deleted file mode 100644 index ab889f51..00000000 --- a/crates/app/src/ui/tools/processing/cleanup_editors.rs +++ /dev/null @@ -1,248 +0,0 @@ -//! Inline editors for the cleanup steps: smoothing, normalize, binning. - -use super::commit_kind; -use super::editors::param_drag; -use egui::Ui; -use plotx_core::state::{PhaseAxis, PlotxApp}; -use plotx_processing::{BinMethod, BinParams, NormalizeMethod, SmoothMethod, StepId, StepKind}; - -pub(super) fn smooth_editor( - app: &mut PlotxApp, - di: usize, - axis: PhaseAxis, - id: StepId, - cur: SmoothMethod, - ui: &mut Ui, -) { - let current = SmoothVariant::of(cur); - let mut selected = current; - ui.horizontal(|ui| { - ui.label("Method"); - egui::ComboBox::from_id_salt((di, id, "smooth")) - .selected_text(selected.label()) - .show_ui(ui, |ui| { - for v in SmoothVariant::ALL { - ui.selectable_value(&mut selected, v, v.label()); - } - }); - }); - if selected != current { - let window = match cur { - SmoothMethod::MovingAverage { window } => window, - SmoothMethod::SavitzkyGolay { window, .. } => window, - }; - let next = match selected { - SmoothVariant::MovingAverage => SmoothMethod::MovingAverage { window }, - SmoothVariant::SavitzkyGolay => SmoothMethod::SavitzkyGolay { - window, - poly_order: 3, - }, - }; - commit_kind(app, di, axis, id, StepKind::Smooth(next)); - return; - } - - let window = match cur { - SmoothMethod::MovingAverage { window } => window, - SmoothMethod::SavitzkyGolay { window, .. } => window, - }; - param_drag( - app, - di, - axis, - id, - ui, - "Window (points)", - window as f64, - 0.2, - true, - false, - |k, v| { - let w = ((v.round() as u16).clamp(3, 201)) | 1; - match k { - StepKind::Smooth(SmoothMethod::MovingAverage { window }) => *window = w, - StepKind::Smooth(SmoothMethod::SavitzkyGolay { window, poly_order }) => { - *window = w; - *poly_order = (*poly_order).min((w - 1) as u8); - } - _ => {} - } - }, - ); - if let SmoothMethod::SavitzkyGolay { window, poly_order } = cur { - param_drag( - app, - di, - axis, - id, - ui, - "Polynomial order", - poly_order as f64, - 0.1, - true, - false, - move |k, v| { - if let StepKind::Smooth(SmoothMethod::SavitzkyGolay { poly_order, .. }) = k { - *poly_order = (v.round() as u8).clamp(1, 8).min((window - 1) as u8); - } - }, - ); - } -} - -pub(super) fn normalize_editor( - app: &mut PlotxApp, - di: usize, - axis: PhaseAxis, - id: StepId, - cur: NormalizeMethod, - ui: &mut Ui, -) { - let current = NormVariant::of(cur); - let mut selected = current; - ui.horizontal(|ui| { - ui.label("Method"); - egui::ComboBox::from_id_salt((di, id, "norm")) - .selected_text(selected.label()) - .show_ui(ui, |ui| { - for v in NormVariant::ALL { - ui.selectable_value(&mut selected, v, v.label()); - } - }); - }); - if selected != current { - let next = match selected { - NormVariant::MaxPeak => NormalizeMethod::MaxPeak, - NormVariant::TotalArea => NormalizeMethod::TotalArea, - NormVariant::Constant => NormalizeMethod::Constant { divisor: 1.0 }, - }; - commit_kind(app, di, axis, id, StepKind::Normalize(next)); - return; - } - - if let NormalizeMethod::Constant { divisor } = cur { - param_drag( - app, - di, - axis, - id, - ui, - "Divisor", - divisor, - 0.1, - true, - false, - |k, v| { - if let StepKind::Normalize(NormalizeMethod::Constant { divisor }) = k { - *divisor = v; - } - }, - ); - } -} - -pub(super) fn bin_editor( - app: &mut PlotxApp, - di: usize, - axis: PhaseAxis, - id: StepId, - cur: BinParams, - ui: &mut Ui, -) { - let mut method = cur.method; - ui.horizontal(|ui| { - ui.label("Aggregate"); - egui::ComboBox::from_id_salt((di, id, "bin")) - .selected_text(bin_method_label(method)) - .show_ui(ui, |ui| { - for m in [BinMethod::Sum, BinMethod::Mean] { - ui.selectable_value(&mut method, m, bin_method_label(m)); - } - }); - }); - if method != cur.method { - commit_kind( - app, - di, - axis, - id, - StepKind::Bin(BinParams { method, ..cur }), - ); - return; - } - param_drag( - app, - di, - axis, - id, - ui, - "Bin width (ppm)", - cur.width, - 0.005, - true, - false, - |k, v| { - if let StepKind::Bin(p) = k { - p.width = v.max(0.0001); - } - }, - ); -} - -pub(super) fn bin_method_label(m: BinMethod) -> &'static str { - match m { - BinMethod::Sum => "Sum", - BinMethod::Mean => "Mean", - } -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum SmoothVariant { - MovingAverage, - SavitzkyGolay, -} - -impl SmoothVariant { - const ALL: [Self; 2] = [Self::MovingAverage, Self::SavitzkyGolay]; - - fn of(method: SmoothMethod) -> Self { - match method { - SmoothMethod::MovingAverage { .. } => Self::MovingAverage, - SmoothMethod::SavitzkyGolay { .. } => Self::SavitzkyGolay, - } - } - - fn label(self) -> &'static str { - match self { - Self::MovingAverage => "Moving average", - Self::SavitzkyGolay => "Polynomial (Savitzky-Golay)", - } - } -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum NormVariant { - MaxPeak, - TotalArea, - Constant, -} - -impl NormVariant { - const ALL: [Self; 3] = [Self::MaxPeak, Self::TotalArea, Self::Constant]; - - fn of(method: NormalizeMethod) -> Self { - match method { - NormalizeMethod::MaxPeak => Self::MaxPeak, - NormalizeMethod::TotalArea => Self::TotalArea, - NormalizeMethod::Constant { .. } => Self::Constant, - } - } - - fn label(self) -> &'static str { - match self { - Self::MaxPeak => "Largest peak = 1", - Self::TotalArea => "Total area = 1", - Self::Constant => "Divide by constant", - } - } -} diff --git a/crates/app/src/ui/tools/processing/editors.rs b/crates/app/src/ui/tools/processing/editors.rs index 234232f5..d12f3ea2 100644 --- a/crates/app/src/ui/tools/processing/editors.rs +++ b/crates/app/src/ui/tools/processing/editors.rs @@ -1,68 +1,58 @@ -//! Inline per-step parameter editors and the DragValue helpers behind them. +//! Catalog-backed per-step editors and compact step summaries. -use super::{commit_kind, edit_step, set_phase_method}; -use egui::{DragValue, Ui}; +use egui::Ui; use egui_phosphor::regular as icon; -use plotx_core::actions::DatasetProcessingState; use plotx_core::automation::{ComponentRef, ResourceRef, TargetRef}; -use plotx_core::state::{Dataset, PhaseAxis, PlotxApp}; +use plotx_core::state::{PhaseAxis, PlotxApp}; use plotx_processing::{ - Apodization, AutoPhaseMethod, BaselineMethod, NormalizeMethod, PhaseParams, ProcessingStep, - ReferenceParams, SmoothMethod, StepId, StepKind, ZeroFill, + Apodization, BaselineMethod, BinMethod, NormalizeMethod, ProcessingStep, SmoothMethod, + StepKind, ZeroFill, }; -const AUTO_METHODS: [AutoPhaseMethod; 5] = [ - AutoPhaseMethod::RobustConsensus, - AutoPhaseMethod::AbsorptivePeak, - AutoPhaseMethod::Entropy, - AutoPhaseMethod::NegativeMinimization, - AutoPhaseMethod::PeakRegression, -]; - -fn auto_label(m: AutoPhaseMethod) -> &'static str { - match m { - AutoPhaseMethod::RobustConsensus => "Auto: Robust consensus", - AutoPhaseMethod::AbsorptivePeak => "Auto: Absorptive peak", - AutoPhaseMethod::Entropy => "Auto: Entropy (ACME)", - AutoPhaseMethod::NegativeMinimization => "Auto: Min. negative area", - AutoPhaseMethod::PeakRegression => "Auto: Peak regression", - } -} - pub(super) fn editor( app: &mut PlotxApp, di: usize, - axis: PhaseAxis, + _axis: PhaseAxis, step: &ProcessingStep, ui: &mut Ui, ) { + let Some(dataset) = app.doc.datasets.get(di) else { + return; + }; + let target = TargetRef { + resource: ResourceRef::from(dataset.resource_id()), + component: Some(ComponentRef::ProcessingStep(step.id)), + }; match &step.kind { StepKind::Apodize(_) => { - let Some(dataset) = app.doc.datasets.get(di) else { - return; - }; - let target = TargetRef { - resource: ResourceRef::from(dataset.resource_id()), - component: Some(ComponentRef::ProcessingStep(step.id)), - }; crate::ui::properties::panel::apodization_section(app, &target, ui); } - StepKind::ZeroFill(z) => zero_fill_editor(app, di, axis, step.id, *z, ui), - StepKind::Phase(p) => phase_editor(app, di, axis, step.id, *p, ui), - StepKind::Baseline(m) => baseline_editor(app, di, axis, step.id, *m, ui), - StepKind::Reference(r) => reference_editor(app, di, axis, step.id, *r, ui), + StepKind::ZeroFill(_) => { + crate::ui::properties::panel::zero_fill_section(app, &target, ui); + } + StepKind::Phase(_) => { + crate::ui::properties::panel::phase_section(app, &target, ui); + } + StepKind::Baseline(_) => { + crate::ui::properties::panel::baseline_section(app, &target, ui); + } + StepKind::Reference(_) => { + crate::ui::properties::panel::reference_section(app, &target, ui); + } StepKind::Magnitude => { ui.small( "Reduces the spectrum to its magnitude; phase no longer applies after this step.", ); } - StepKind::Smooth(m) => { - super::cleanup_editors::smooth_editor(app, di, axis, step.id, *m, ui) + StepKind::Smooth(_) => { + crate::ui::properties::panel::smooth_section(app, &target, ui); } - StepKind::Normalize(m) => { - super::cleanup_editors::normalize_editor(app, di, axis, step.id, *m, ui) + StepKind::Normalize(_) => { + crate::ui::properties::panel::normalize_section(app, &target, ui); + } + StepKind::Bin(_) => { + crate::ui::properties::panel::bin_section(app, &target, ui); } - StepKind::Bin(p) => super::cleanup_editors::bin_editor(app, di, axis, step.id, *p, ui), StepKind::Reverse => { ui.small("Mirrors the intensities along the axis."); } @@ -73,355 +63,6 @@ pub(super) fn editor( } } -fn zero_fill_editor( - app: &mut PlotxApp, - di: usize, - axis: PhaseAxis, - id: StepId, - cur: ZeroFill, - ui: &mut Ui, -) { - let raw = raw_point_count(&app.doc.datasets[di]); - let mut choice = zf_choice(cur); - ui.horizontal(|ui| { - ui.label("Zero fill"); - egui::ComboBox::from_id_salt((di, id, "zf")) - .selected_text(choice.label()) - .show_ui(ui, |ui| { - for c in ZfChoice::ALL { - ui.selectable_value(&mut choice, c, c.label()); - } - }); - }); - if choice != zf_choice(cur) { - commit_kind( - app, - di, - axis, - id, - StepKind::ZeroFill(choice.zero_fill(cur, raw)), - ); - return; - } - - if let ZeroFill::Size(size) = cur { - param_drag( - app, - di, - axis, - id, - ui, - "Points", - size as f64, - 256.0, - true, - true, - |k, v| { - if let StepKind::ZeroFill(ZeroFill::Size(s)) = k { - *s = (v.round() as usize).max(1); - } - }, - ); - } - ui.small(format!("{} {} points", icon::ARROW_RIGHT, cur.target(raw))); -} - -fn phase_editor( - app: &mut PlotxApp, - di: usize, - axis: PhaseAxis, - id: StepId, - cur: PhaseParams, - ui: &mut Ui, -) { - let selected = cur.auto; - let mut choice = selected; - ui.horizontal(|ui| { - ui.label("Mode"); - egui::ComboBox::from_id_salt((di, id, "phmode")) - .selected_text(choice.map_or("Manual", auto_label)) - .show_ui(ui, |ui| { - ui.selectable_value(&mut choice, None, "Manual"); - for m in AUTO_METHODS { - ui.selectable_value(&mut choice, Some(m), auto_label(m)); - } - }); - }); - if choice != selected { - set_phase_method(app, di, axis, id, choice); - return; - } - - let enabled = selected.is_none(); - param_drag( - app, - di, - axis, - id, - ui, - "φ0 (°)", - cur.phase0.to_degrees(), - 0.5, - enabled, - false, - |k, v| { - if let StepKind::Phase(p) = k { - p.phase0 = v.to_radians(); - } - }, - ); - param_drag( - app, - di, - axis, - id, - ui, - "φ1 (°)", - cur.phase1.to_degrees(), - 0.5, - enabled, - false, - |k, v| { - if let StepKind::Phase(p) = k { - p.phase1 = v.to_radians(); - } - }, - ); - pivot_drag(app, di, axis, ui, enabled); - - ui.horizontal(|ui| { - ui.weak(format!( - "{} drag the spectrum to adjust", - icon::HAND_POINTING - )); - }); -} - -fn baseline_editor( - app: &mut PlotxApp, - di: usize, - axis: PhaseAxis, - id: StepId, - cur: BaselineMethod, - ui: &mut Ui, -) { - let current_variant = BaselineVariant::of(cur); - let mut selected_variant = current_variant; - ui.horizontal(|ui| { - ui.label("Method"); - egui::ComboBox::from_id_salt((di, id, "bl")) - .selected_text(selected_variant.label()) - .show_ui(ui, |ui| { - for variant in BaselineVariant::ALL { - ui.selectable_value(&mut selected_variant, variant, variant.label()); - } - }); - }); - if selected_variant != current_variant { - let next = match selected_variant { - BaselineVariant::Automatic => BaselineMethod::AUTO, - BaselineVariant::Offset => BaselineMethod::Offset, - BaselineVariant::Polynomial => BaselineMethod::Polynomial { order: 2 }, - }; - commit_kind(app, di, axis, id, StepKind::Baseline(next)); - return; - } - - if let BaselineMethod::Polynomial { order } = cur { - let mut val = order as f64; - ui.horizontal(|ui| { - ui.label("Order"); - let before = DatasetProcessingState::from_dataset(&app.doc.datasets[di]); - let resp = ui.add(DragValue::new(&mut val).speed(0.1).range(1.0..=8.0)); - if resp.changed() { - let mut after = before.clone(); - edit_step(&mut after, axis, id, |k| { - if let StepKind::Baseline(BaselineMethod::Polynomial { order }) = k { - *order = (val.round() as u8).clamp(1, 8); - } - }); - app.commit_processing_edit(di, before, after); - } - }); - } - if let BaselineMethod::AsymmetricLeastSquares { - smoothness, - asymmetry, - iterations, - } = cur - { - ui.small("AsLS estimates a smooth baseline while down-weighting positive peaks."); - let mut log_smoothness = smoothness.max(1.0).log10(); - let mut asymmetric_weight = asymmetry; - let mut iteration_count = iterations as i32; - let before = DatasetProcessingState::from_dataset(&app.doc.datasets[di]); - let mut changed = false; - ui.horizontal(|ui| { - ui.label("Smoothness (log10 λ)"); - changed |= ui - .add( - DragValue::new(&mut log_smoothness) - .speed(0.1) - .range(0.0..=12.0), - ) - .changed(); - }); - ui.horizontal(|ui| { - ui.label("Peak weight"); - changed |= ui - .add( - DragValue::new(&mut asymmetric_weight) - .speed(0.0005) - .range(0.000001..=0.5), - ) - .changed(); - }); - ui.horizontal(|ui| { - ui.label("Iterations"); - changed |= ui - .add( - DragValue::new(&mut iteration_count) - .speed(0.2) - .range(1..=100), - ) - .changed(); - }); - if changed { - let mut after = before.clone(); - edit_step(&mut after, axis, id, |kind| { - if let StepKind::Baseline(BaselineMethod::AsymmetricLeastSquares { - smoothness, - asymmetry, - iterations, - }) = kind - { - *smoothness = 10.0_f64.powf(log_smoothness.clamp(0.0, 12.0)); - *asymmetry = asymmetric_weight.clamp(0.000001, 0.5); - *iterations = iteration_count.clamp(1, 100) as u16; - } - }); - app.commit_processing_edit(di, before, after); - } - } -} - -fn reference_editor( - app: &mut PlotxApp, - di: usize, - axis: PhaseAxis, - id: StepId, - cur: ReferenceParams, - ui: &mut Ui, -) { - param_drag( - app, - di, - axis, - id, - ui, - "At (ppm)", - cur.at_ppm, - 0.01, - true, - false, - |k, v| { - if let StepKind::Reference(r) = k { - r.at_ppm = v; - } - }, - ); - param_drag( - app, - di, - axis, - id, - ui, - "Target (ppm)", - cur.target_ppm, - 0.01, - true, - false, - |k, v| { - if let StepKind::Reference(r) = k { - r.target_ppm = v; - } - }, - ); -} - -/// A DragValue bound to a step parameter: live feedback plus one-undo-per-drag, -/// routed through the pause gate. `time_domain` selects the recompute cost. -#[allow(clippy::too_many_arguments)] -pub(super) fn param_drag( - app: &mut PlotxApp, - di: usize, - axis: PhaseAxis, - id: StepId, - ui: &mut Ui, - label: &str, - mut value: f64, - speed: f64, - enabled: bool, - time_domain: bool, - write: impl Fn(&mut StepKind, f64), -) { - ui.horizontal(|ui| { - ui.label(label); - let before = DatasetProcessingState::from_dataset(&app.doc.datasets[di]); - let resp = ui.add_enabled(enabled, DragValue::new(&mut value).speed(speed)); - super::super::begin_processing_widget(app, di, &resp, before.clone()); - if resp.changed() { - if let Some(pipe) = app.doc.datasets[di].axis_pipeline_mut(axis) - && let Some(s) = pipe.steps.iter_mut().find(|s| s.id == id) - { - write(&mut s.kind, value); - } - if !app.session.ui.proc_paused { - if time_domain { - app.apply_dataset_retransform(di); - } else { - app.apply_dataset_edit(di); - } - } - } - super::super::commit_processing_widget(app, di, &resp, before); - }); -} - -/// Writes the first enabled Phase step's ppm pivot live. -fn pivot_drag(app: &mut PlotxApp, di: usize, axis: PhaseAxis, ui: &mut Ui, enabled: bool) { - let mut pivot = app.doc.datasets[di].pivot_ppm(axis).unwrap_or(0.0); - ui.horizontal(|ui| { - ui.label("Pivot (ppm)"); - let before = DatasetProcessingState::from_dataset(&app.doc.datasets[di]); - let resp = ui.add_enabled(enabled, DragValue::new(&mut pivot).speed(0.01)); - super::super::begin_processing_widget(app, di, &resp, before.clone()); - if resp.changed() { - app.doc.datasets[di].set_pivot_ppm(axis, pivot); - if !app.session.ui.proc_paused { - app.apply_dataset_edit(di); - } - } - super::super::commit_processing_widget(app, di, &resp, before); - }); -} - -fn raw_point_count(dataset: &Dataset) -> usize { - match dataset { - Dataset::Nmr(n) => n.data.len(), - Dataset::Nmr2D(n) => n.data.cols, - Dataset::Table(_) => 0, - Dataset::Electrophysiology(d) => d - .data - .sweeps - .first() - .and_then(|s| s.channels.first()) - .map(Vec::len) - .unwrap_or(0), - Dataset::Afm(_) => 0, - } -} - pub(super) fn kind_icon(kind: &StepKind) -> &'static str { match kind { StepKind::Apodize(_) => icon::WAVEFORM, @@ -464,117 +105,82 @@ pub(super) fn kind_summary(kind: &StepKind) -> String { Apodization::Exponential { lb_hz } => format!("Exponential {lb_hz:.1} Hz"), Apodization::Gaussian { lb_hz, gb_hz } => format!("Gaussian {lb_hz:.1}/{gb_hz:.1} Hz"), }, - StepKind::ZeroFill(z) => zf_choice(*z).label().into(), + StepKind::ZeroFill(value) => zero_fill_label(*value).into(), StepKind::Fft => String::new(), - StepKind::Phase(p) => match p.auto { + StepKind::Phase(params) => match params.auto { Some(_) => "Auto".into(), None => format!( "φ0 {:.0}° φ1 {:.0}°", - p.phase0.to_degrees(), - p.phase1.to_degrees() + params.phase0.to_degrees(), + params.phase1.to_degrees() ), }, - StepKind::Baseline(m) => match m { + StepKind::Baseline(method) => match method { BaselineMethod::Offset => "Offset".into(), BaselineMethod::Polynomial { order } => format!("Polynomial · order {order}"), BaselineMethod::AsymmetricLeastSquares { .. } => "Auto · AsLS".into(), }, - StepKind::Reference(r) => { - format!( - "{:.2} {} {:.2} ppm", - r.at_ppm, - icon::ARROW_RIGHT, - r.target_ppm - ) - } + StepKind::Reference(params) => format!( + "{:.2} {} {:.2} ppm", + params.at_ppm, + icon::ARROW_RIGHT, + params.target_ppm + ), StepKind::Magnitude => "|c|".into(), - StepKind::Smooth(m) => match m { + StepKind::Smooth(method) => match method { SmoothMethod::MovingAverage { window } => format!("Moving avg · {window} pt"), SmoothMethod::SavitzkyGolay { window, poly_order } => { format!("Polynomial {window} pt · order {poly_order}") } }, - StepKind::Normalize(m) => match m { + StepKind::Normalize(method) => match method { NormalizeMethod::MaxPeak => "Max peak".into(), NormalizeMethod::TotalArea => "Total area".into(), NormalizeMethod::Constant { divisor } => format!("÷ {divisor:.3}"), }, - StepKind::Bin(p) => format!( + StepKind::Bin(params) => format!( "{:.3} ppm · {}", - p.width, - super::cleanup_editors::bin_method_label(p.method).to_lowercase() + params.width, + bin_method_label(params.method).to_lowercase() ), StepKind::Reverse => "mirror".into(), StepKind::Invert => "× −1".into(), } } -#[derive(Clone, Copy, PartialEq, Eq)] -enum BaselineVariant { - Automatic, - Offset, - Polynomial, -} - -impl BaselineVariant { - const ALL: [Self; 3] = [Self::Automatic, Self::Offset, Self::Polynomial]; - - fn of(method: BaselineMethod) -> Self { - match method { - BaselineMethod::AsymmetricLeastSquares { .. } => Self::Automatic, - BaselineMethod::Offset => Self::Offset, - BaselineMethod::Polynomial { .. } => Self::Polynomial, - } +fn zero_fill_label(value: ZeroFill) -> &'static str { + match value { + ZeroFill::None => "None", + ZeroFill::Factor(2) => "×2", + ZeroFill::Factor(3) => "×4", + ZeroFill::Factor(4) => "×8", + ZeroFill::Factor(_) | ZeroFill::Size(_) => "Custom", } - - fn label(self) -> &'static str { - match self { - Self::Automatic => "Automatic (AsLS)", - Self::Offset => "Offset", - Self::Polynomial => "Polynomial", - } - } -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum ZfChoice { - None, - X2, - X4, - X8, - Custom, } -impl ZfChoice { - const ALL: [Self; 5] = [Self::None, Self::X2, Self::X4, Self::X8, Self::Custom]; - - fn label(self) -> &'static str { - match self { - Self::None => "None", - Self::X2 => "×2", - Self::X4 => "×4", - Self::X8 => "×8", - Self::Custom => "Custom", - } - } - - fn zero_fill(self, cur: ZeroFill, raw: usize) -> ZeroFill { - match self { - Self::None => ZeroFill::None, - Self::X2 => ZeroFill::Factor(2), - Self::X4 => ZeroFill::Factor(3), - Self::X8 => ZeroFill::Factor(4), - Self::Custom => ZeroFill::Size(cur.target(raw).max(raw)), - } +fn bin_method_label(method: BinMethod) -> &'static str { + match method { + BinMethod::Sum => "Sum", + BinMethod::Mean => "Mean", } } -fn zf_choice(z: ZeroFill) -> ZfChoice { - match z { - ZeroFill::None => ZfChoice::None, - ZeroFill::Factor(0..=2) => ZfChoice::X2, - ZeroFill::Factor(3) => ZfChoice::X4, - ZeroFill::Factor(_) => ZfChoice::X8, - ZeroFill::Size(_) => ZfChoice::Custom, +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manual_phase_summary_uses_the_same_degree_space_as_the_editor() { + let params = plotx_processing::PhaseParams { + phase0: 45.0_f64.to_radians(), + phase1: -90.0_f64.to_radians(), + pivot_frac: 0.5, + auto: None, + }; + assert_eq!(kind_summary(&StepKind::Phase(params)), "φ0 45° φ1 -90°"); + let display = plotx_core::properties::FloatDisplay::Degrees; + assert_eq!(display.to_display(params.phase0), 45.0); + assert_eq!(display.to_display(params.phase1), -90.0); + assert_eq!(display.unit(), "°"); } } diff --git a/crates/app/src/ui/tools/processing/mod.rs b/crates/app/src/ui/tools/processing/mod.rs index 211c8250..9ab9c7e4 100644 --- a/crates/app/src/ui/tools/processing/mod.rs +++ b/crates/app/src/ui/tools/processing/mod.rs @@ -1,17 +1,17 @@ //! The ordered processing step-list panel: one editable pipeline per axis, with //! an FFT anchor separating time- and frequency-domain steps. -mod cleanup_editors; mod editors; use egui::{Button, Ui}; use egui_phosphor::regular as icon; use plotx_core::actions::DatasetProcessingState; +use plotx_core::automation::{ResourceRef, TargetRef}; use plotx_core::state::{Dataset, DatasetId, PhaseAxis, PlotxApp}; use plotx_processing::{ - Apodization, AutoPhaseMethod, AxisPipeline, BaselineMethod, BinParams, NormalizeMethod, - PhaseParams, ProcessingStep, ReferenceParams, SmoothMethod, StepDomain, StepId, StepKind, - StepSource, ZeroFill, + Apodization, AxisPipeline, BaselineMethod, BinParams, NormalizeMethod, PhaseParams, + ProcessingStep, ReferenceParams, SmoothMethod, StepDomain, StepId, StepKind, StepSource, + ZeroFill, }; /// A structural change to a step, deferred until after the row loop so the list @@ -63,10 +63,11 @@ fn badge(dataset: &Dataset) -> (String, bool) { fn panel_menu(app: &mut PlotxApp, di: usize, ui: &mut Ui) { ui.label("Advanced"); - let mut gd = group_delay(&app.doc.datasets[di]); - if ui.checkbox(&mut gd, "Group-delay correction").changed() { - set_group_delay(app, di, gd); - } + let target = TargetRef { + resource: ResourceRef::from(app.doc.datasets[di].resource_id()), + component: None, + }; + crate::ui::properties::panel::processing_advanced_section(app, &target, ui); let mut paused = app.session.ui.proc_paused; if ui.checkbox(&mut paused, "Pause auto-recompute").changed() { app.session.ui.proc_paused = paused; @@ -154,10 +155,11 @@ fn row( let expanded = app.session.ui.proc_expanded_step == Some((owner, id)); ui.horizontal(|ui| { ui.weak(icon::DOTS_SIX_VERTICAL); - let mut enabled = step.enabled; - if ui.checkbox(&mut enabled, "").changed() { - set_enabled(app, di, axis, id, enabled); - } + let target = TargetRef { + resource: ResourceRef::from(owner), + component: Some(plotx_core::automation::ComponentRef::ProcessingStep(id)), + }; + crate::ui::properties::panel::processing_step_section(app, &target, ui); ui.label(editors::kind_icon(&step.kind)); if ui .selectable_label(expanded, editors::kind_label(&step.kind)) @@ -278,7 +280,8 @@ fn add_step_menu(app: &mut PlotxApp, di: usize, axis: PhaseAxis, ui: &mut Ui) { ui.close(); } if ui.button("Binning").clicked() { - add_step(app, di, axis, StepKind::Bin(BinParams::DEFAULT)); + let params = default_bin_params(app, di); + add_step(app, di, axis, StepKind::Bin(params)); ui.close(); } if ui.button("Reverse").clicked() { @@ -293,6 +296,17 @@ fn add_step_menu(app: &mut PlotxApp, di: usize, axis: PhaseAxis, ui: &mut Ui) { }); } +fn default_bin_params(app: &PlotxApp, dataset: usize) -> BinParams { + let Some(Dataset::Nmr(dataset)) = app.doc.datasets.get(dataset) else { + return BinParams::DEFAULT; + }; + let effective_minimum = 1.5 * plotx_processing::cleanup::axis_step(&dataset.spectrum.ppm); + BinParams { + width: BinParams::DEFAULT.width.max(effective_minimum.next_up()), + ..BinParams::DEFAULT + } +} + fn action_bar(app: &mut PlotxApp, di: usize, ui: &mut Ui) { ui.separator(); if app.session.ui.proc_paused && app.has_pending_processing() { @@ -354,73 +368,6 @@ fn state_pipe(state: &mut DatasetProcessingState, axis: PhaseAxis) -> Option<&mu } } -fn edit_step( - state: &mut DatasetProcessingState, - axis: PhaseAxis, - id: StepId, - mutate: impl FnOnce(&mut StepKind), -) { - if let Some(pipe) = state_pipe(state, axis) - && let Some(s) = pipe.steps.iter_mut().find(|s| s.id == id) - { - mutate(&mut s.kind); - } -} - -fn commit_kind(app: &mut PlotxApp, di: usize, axis: PhaseAxis, id: StepId, kind: StepKind) { - let before = DatasetProcessingState::from_dataset(&app.doc.datasets[di]); - let mut after = before.clone(); - edit_step(&mut after, axis, id, |k| *k = kind); - app.commit_processing_edit(di, before, after); -} - -fn set_enabled(app: &mut PlotxApp, di: usize, axis: PhaseAxis, id: StepId, enabled: bool) { - let before = DatasetProcessingState::from_dataset(&app.doc.datasets[di]); - let mut after = before.clone(); - if let Some(pipe) = state_pipe(&mut after, axis) - && let Some(s) = pipe.steps.iter_mut().find(|s| s.id == id) - { - s.enabled = enabled; - } - app.commit_processing_edit(di, before, after); -} - -/// Switch a Phase step between manual and one of the auto methods. Switching to -/// manual seeds the exact automatic terms so the trace does not jump. -fn set_phase_method( - app: &mut PlotxApp, - di: usize, - axis: PhaseAxis, - id: StepId, - method: Option, -) { - let before = DatasetProcessingState::from_dataset(&app.doc.datasets[di]); - let mut after = before.clone(); - match method { - Some(m) => { - edit_step(&mut after, axis, id, |k| { - if let StepKind::Phase(p) = k { - p.auto = Some(m); - } - }); - } - None => { - let seed = app.doc.datasets[di].automatic_phase_params(axis); - edit_step(&mut after, axis, id, |k| { - if let StepKind::Phase(p) = k { - if let Some((p0, p1, piv)) = seed { - p.phase0 = p0; - p.phase1 = p1; - p.pivot_frac = piv; - } - p.auto = None; - } - }); - } - } - app.commit_processing_edit(di, before, after); -} - fn apply_row_op(app: &mut PlotxApp, di: usize, axis: PhaseAxis, id: StepId, op: RowOp) { let Some(dataset) = app.doc.datasets.get(di) else { return; @@ -521,43 +468,6 @@ fn reset_to_default(app: &mut PlotxApp, di: usize) { )); } -fn set_group_delay(app: &mut PlotxApp, di: usize, on: bool) { - match &app.doc.datasets[di] { - Dataset::Nmr(_) => { - let before = DatasetProcessingState::from_dataset(&app.doc.datasets[di]); - let mut after = before.clone(); - if let DatasetProcessingState::Nmr { - group_delay_correct, - .. - } = &mut after - { - *group_delay_correct = on; - } - app.commit_processing_edit(di, before, after); - } - Dataset::Nmr2D(_) => { - if let Some(n) = app.doc.datasets[di].as_nmr2d_mut() { - n.group_delay_correct = on; - } - app.apply_dataset_retransform(di); - app.recompute_integrals_2d_after_processing(di); - } - Dataset::Table(_) => {} - Dataset::Electrophysiology(_) => {} - Dataset::Afm(_) => {} - } -} - -fn group_delay(dataset: &Dataset) -> bool { - match dataset { - Dataset::Nmr(n) => n.group_delay_correct, - Dataset::Nmr2D(n) => n.group_delay_correct, - Dataset::Table(_) => true, - Dataset::Electrophysiology(_) => true, - Dataset::Afm(_) => true, - } -} - fn is_default_processing(dataset: &Dataset) -> bool { let Some(def) = plotx_core::project::reset_processing(dataset) else { return true; @@ -575,9 +485,17 @@ fn is_default_processing(dataset: &Dataset) -> bool { }, ) => ga == gb && pipe_eq(a, b), ( - DatasetProcessingState::Nmr2D { params: a, .. }, - DatasetProcessingState::Nmr2D { params: b, .. }, - ) => a.layout == b.layout && pipe_eq(&a.f2, &b.f2) && pipe_eq(&a.f1, &b.f1), + DatasetProcessingState::Nmr2D { + params: a, + group_delay_correct: ga, + .. + }, + DatasetProcessingState::Nmr2D { + params: b, + group_delay_correct: gb, + .. + }, + ) => ga == gb && a.layout == b.layout && pipe_eq(&a.f2, &b.f2) && pipe_eq(&a.f1, &b.f1), _ => false, } } @@ -590,3 +508,41 @@ fn pipe_eq(a: &AxisPipeline, b: &AxisPipeline) -> bool { .zip(&b.steps) .all(|(x, y)| x.kind == y.kind && x.enabled == y.enabled) } + +#[cfg(test)] +mod tests { + use super::*; + use num_complex::Complex64; + use plotx_core::state::Nmr2DDataset; + use plotx_io::{Dim, Domain, NmrData2D, QuadMode}; + + #[test] + fn two_dimensional_group_delay_participates_in_the_default_badge() { + let dim = |nucleus: &str| Dim { + spectral_width_hz: 2_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 4.7, + nucleus: nucleus.to_owned(), + group_delay: 2.0, + }; + let data = NmrData2D { + data: vec![Complex64::new(1.0, 0.2); 32], + rows: 4, + cols: 8, + domain: Domain::Time, + direct: dim("1H"), + indirect: dim("13C"), + quad: QuadMode::Complex, + indirect_conjugate: false, + experiment: Some("hsqc".to_owned()), + pseudo_axis: None, + diffusion: None, + nus: None, + source: "default badge".to_owned(), + }; + let mut dataset = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data))); + assert!(is_default_processing(&dataset)); + dataset.as_nmr2d_mut().unwrap().group_delay_correct = false; + assert!(!is_default_processing(&dataset)); + } +} diff --git a/crates/app/src/ui/tools/pseudo.rs b/crates/app/src/ui/tools/pseudo.rs index f5745c78..0d5d945a 100644 --- a/crates/app/src/ui/tools/pseudo.rs +++ b/crates/app/src/ui/tools/pseudo.rs @@ -17,7 +17,7 @@ pub(super) fn experiment_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) -> bo if chosen != n.preset { let before = DatasetProcessingState::from_dataset(&app.doc.datasets[di]); let mut after = before.clone(); - if let DatasetProcessingState::Nmr2D { params, preset } = &mut after { + if let DatasetProcessingState::Nmr2D { params, preset, .. } = &mut after { *preset = chosen; params.layout = chosen.layout(); } diff --git a/crates/app/src/ui/windows.rs b/crates/app/src/ui/windows.rs index 9a469404..f7094d99 100644 --- a/crates/app/src/ui/windows.rs +++ b/crates/app/src/ui/windows.rs @@ -1,673 +1,13 @@ -use super::*; - -pub(super) fn save_project_window(app: &mut PlotxApp, ctx: &egui::Context) { - if !app.session.ui.save_project_options { - return; - } - - let mut save = false; - let mut save_as = false; - let mut cancel = false; - let modal = super::modal(ctx, "save_project_modal", ModalKind::Dialog).show(ctx, |ui| { - ui.set_width(390.0); - ui.heading("Save project"); - ui.separator(); - if app.session.status.starts_with("Save failed:") { - ui.colored_label(ui.visuals().error_fg_color, &app.session.status); - if ui.link("Open diagnostic details").clicked() { - app.session.ui.diagnostics_open = true; - } - ui.add_space(8.0); - } - ui.checkbox( - &mut app.doc.save_include_view_snapshots, - "Include rendered canvas snapshots", - ) - .on_hover_text( - "Stores materialized view data for faster and more stable reopening. \ - This can make .plotx files much larger.", - ); - ui.add_space(8.0); - ui.horizontal(|ui| { - if ui.button("Save").clicked() { - save = true; - } - if ui.button("Save As…").clicked() { - save_as = true; - } - if ui.button("Cancel").clicked() { - cancel = true; - } - }); - }); - - if save { - if let Some(path) = app.doc.project_path.clone() { - app.session.ui.save_project_options = - !app.save_project_to(&path, app.doc.save_include_view_snapshots); - } else { - crate::ui::file_dialogs::save_project_as(app, app.doc.save_include_view_snapshots); - app.session.ui.save_project_options = app.doc.dirty; - } - } else if save_as { - crate::ui::file_dialogs::save_project_as(app, app.doc.save_include_view_snapshots); - app.session.ui.save_project_options = app.doc.dirty; - } else if cancel || modal.should_close() { - app.session.ui.save_project_options = false; - } -} - -/// Intercept a window-close request when the project has unsaved changes: veto the -/// close and raise the confirm dialog. Once the user confirms (Save or Discard), -/// `allow_close` lets the re-issued request through. -pub(super) fn handle_close_request(app: &mut PlotxApp, ctx: &egui::Context) { - if !ctx.input(|i| i.viewport().close_requested()) { - return; - } - if app.doc.dirty && !app.session.allow_close { - ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); - app.session.ui.quit_confirm = true; - } -} - -/// Save / Discard / Cancel dialog shown when a close was intercepted on a dirty -/// project. Save routes through the normal save flow (opening Save As… if the -/// project has no path yet) and only closes once the save actually succeeds. -pub(super) fn quit_confirm_window(app: &mut PlotxApp, ctx: &egui::Context) { - if !app.session.ui.quit_confirm { - return; - } - let mut save = false; - let mut discard = false; - let mut cancel = false; - let modal = super::modal(ctx, "quit_confirm_modal", ModalKind::Dialog).show(ctx, |ui| { - ui.set_width(420.0); - ui.heading("Unsaved changes"); - ui.separator(); - ui.label("This project has unsaved changes. Save before closing?"); - if app.session.status.starts_with("Save failed:") { - ui.add_space(8.0); - egui::Frame::new() - .fill(ui.visuals().error_fg_color.linear_multiply(0.12)) - .corner_radius(6) - .inner_margin(8) - .show(ui, |ui| { - ui.colored_label(ui.visuals().error_fg_color, &app.session.status); - if ui.link("Open diagnostic details").clicked() { - app.session.ui.diagnostics_open = true; - } - }); - } - ui.add_space(8.0); - ui.horizontal(|ui| { - if ui.button("Save").clicked() { - save = true; - } - if ui.button("Discard").clicked() { - discard = true; - } - if ui.button("Cancel").clicked() { - cancel = true; - } - }); - }); - - if save { - let saved = if let Some(path) = app.doc.project_path.clone() { - app.save_project_to(&path, app.doc.save_include_view_snapshots) - } else { - crate::ui::file_dialogs::save_project_as(app, app.doc.save_include_view_snapshots); - !app.doc.dirty - }; - if saved { - app.session.ui.quit_confirm = false; - app.session.allow_close = true; - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } else { - crate::cancel_relaunch(); - } - } else if discard { - app.session.ui.quit_confirm = false; - app.session.allow_close = true; - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } else if cancel || modal.should_close() { - app.session.ui.quit_confirm = false; - crate::cancel_relaunch(); - } -} -/// Stable layer of the Canvas settings window, shared with the size chip so a -/// chip click can raise an already-open window above the chip's own layer. -pub(super) fn canvas_settings_layer() -> egui::LayerId { - egui::LayerId::new(egui::Order::Middle, egui::Id::new("canvas_settings_window")) -} - -pub(super) fn canvas_settings_window(app: &mut PlotxApp, ctx: &egui::Context) { - let Some(ci) = app.session.ui.canvas_settings else { - return; - }; - if ci >= app.doc.canvases.len() { - app.session.ui.canvas_settings = None; - return; - } - let mut open = true; - let title = format!("Canvas settings — {}", app.doc.canvases[ci].name); - egui::Window::new(title) - .id(canvas_settings_layer().id) - .collapsible(false) - .resizable(false) - .open(&mut open) - .show(ctx, |ui| { - super::canvas_size::size_section(app, ci, ui); - - ui.add_space(12.0); - ui.separator(); - ui.strong("Layout"); - ui.add_space(6.0); - - let unit = app.session.ui.canvas_size_unit; - ui.horizontal(|ui| { - ui.label("Margins"); - margin_drag(app, ci, ui, unit, 0, "T"); - margin_drag(app, ci, ui, unit, 3, "L"); - margin_drag(app, ci, ui, unit, 2, "B"); - margin_drag(app, ci, ui, unit, 1, "R"); - ui.label(unit.label()); - }); - - ui.horizontal(|ui| { - ui.label("Minimum spacing"); - gutter_drag(app, ci, ui, unit); - ui.label(unit.label()); - }); - ui.weak("Visual spacing is a minimum request; axis furniture may make it larger."); - - ui.horizontal(|ui| { - ui.label("Spacing basis"); - for (label, mode) in [ - ("Frame", plotx_core::layout::SpacingMode::Frame), - ("Visual", plotx_core::layout::SpacingMode::Visual), - ] { - let selected = app.doc.canvases[ci].layout.spacing_mode == mode; - if ui.selectable_label(selected, label).clicked() { - app.set_spacing_mode(mode); - } - } - }); - ui.horizontal(|ui| { - ui.label("Presets"); - for preset in plotx_core::layout::GutterPreset::ALL { - let selected = (app.doc.canvases[ci].layout.gutter_mm - preset.millimetres()) - .abs() - < 0.001; - if ui.selectable_label(selected, preset.label()).clicked() { - app.set_gutter_preset(preset); - } - } - }); - - ui.horizontal(|ui| { - ui.label("Grid"); - grid_count_drag(app, ci, ui, true); - ui.label("rows ×"); - grid_count_drag(app, ci, ui, false); - ui.label("cols"); - let (rows, cols) = { - let l = app.doc.canvases[ci].layout; - (l.rows, l.cols) - }; - let simplify_id = egui::Id::new(("apply_grid_simplify", ci)); - let mut simplify = ui - .data_mut(|data| data.get_temp::(simplify_id)) - .unwrap_or(false); - if ui.checkbox(&mut simplify, "Simplify inner axes").changed() { - ui.data_mut(|data| data.insert_temp(simplify_id, simplify)); - } - if ui - .button("Apply grid") - .on_hover_text("Reposition all plots into these cells") - .clicked() - { - app.arrange_active_canvas_grid_with_simplify(rows, cols, simplify); - } - }); - - let mut show_grid = app.doc.canvases[ci].layout.show_grid; - if ui.checkbox(&mut show_grid, "Show layout grid").changed() { - app.set_show_grid(ci, show_grid); - } - - ui.add_space(12.0); - ui.separator(); - ui.strong("Caption"); - ui.add_space(6.0); - ui.weak("Shown below the page on the board only — not exported or presented."); - - let mut visible = app.doc.canvases[ci].caption_visible; - if ui - .checkbox(&mut visible, "Show caption below page") - .changed() - { - let before = (app.doc.canvases[ci].caption.clone(), !visible); - app.execute_action(Action::set_canvas_caption( - ci, - before, - (app.doc.canvases[ci].caption.clone(), visible), - )); - } +//! Modal and floating application windows grouped by responsibility. - let resp = ui.add( - egui::TextEdit::multiline(&mut app.doc.canvases[ci].caption) - .desired_width(340.0) - .desired_rows(3) - .hint_text("e.g. Figure 1. Concentration vs. time…"), - ); - if resp.gained_focus() { - app.session.ui.caption_edit_before = Some(( - ci, - app.doc.canvases[ci].caption.clone(), - app.doc.canvases[ci].caption_visible, - )); - } - if resp.changed() { - app.doc.dirty = true; - } - if resp.lost_focus() { - commit_caption_edit(app, ci); - } +mod canvas_settings; +mod panel_note_edit; +mod project; +mod text_edit; - ui.add_space(12.0); - ui.separator(); - panels_section(app, ci, ui); - }); - if !open { - commit_caption_edit(app, ci); - commit_note_edit(app); - app.session.ui.canvas_settings = None; - } -} - -/// Notes are auto-listed below the page on the board. -fn panels_section(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { - ui.strong("Panels"); - ui.add_space(6.0); - - let style = app.doc.canvases[ci].panel_label_style; - ui.horizontal(|ui| { - ui.label("Letter style"); - egui::ComboBox::from_id_salt(("panel_label_style", ci)) - .selected_text(style.label()) - .show_ui(ui, |ui| { - for option in PanelLabelStyle::ALL { - if ui - .selectable_label(style == option, option.label()) - .clicked() - { - if option != style { - app.execute_action(Action::SetPanelLabelStyle { - canvas: ci, - before: style, - after: option, - }); - } - ui.close(); - } - } - }); - }); - - ui.add_space(6.0); - ui.weak("Letters are top-left in each plot; notes list below the page (board only)."); - ui.add_space(4.0); - - let order = app.doc.canvases[ci].plot_reading_order(); - if order.is_empty() { - ui.weak("No plots on this page yet."); - return; - } - for (i, id) in order.into_iter().enumerate() { - let letter = app.doc.canvases[ci].panel_label_style.format(i); - let Some(title) = app.doc.canvases[ci] - .object(id) - .and_then(|o| o.plot()) - .map(|p| p.panel.clone()) - else { - continue; - }; - ui.horizontal(|ui| { - let mut visible = title.visible; - if ui - .checkbox(&mut visible, "") - .on_hover_text("Show this panel's letter") - .changed() - { - let mut after = title.clone(); - after.visible = visible; - app.execute_action(Action::set_panel_meta(ci, id, title.clone(), after)); - } - ui.strong(&letter); - let Some(plot) = app.doc.canvases[ci] - .object_mut(id) - .and_then(|o| o.plot_mut()) - else { - return; - }; - let resp = ui.add( - egui::TextEdit::singleline(&mut plot.panel.user_note) - .desired_width(260.0) - .hint_text("Panel note…"), - ); - if resp.gained_focus() { - commit_note_edit(app); - app.session.ui.note_edit_before = Some((ci, id, title.clone())); - } - if resp.changed() { - app.doc.dirty = true; - } - if resp.lost_focus() { - commit_note_edit(app); - } - }); - } -} - -/// Commit an in-progress per-panel note edit as one undoable step. A no-op when -/// nothing changed (or the panel/page is gone). -fn commit_note_edit(app: &mut PlotxApp) { - let Some((ci, id, before)) = app.session.ui.note_edit_before.take() else { - return; - }; - let Some(after) = app - .doc - .canvases - .get(ci) - .and_then(|c| c.object(id)) - .and_then(|o| o.plot()) - .map(|p| p.panel.clone()) - else { - return; - }; - app.execute_action(Action::set_panel_meta(ci, id, before, after)); -} - -/// Commit an in-progress caption text edit for `ci` as one undoable step. A no-op -/// when nothing changed during the edit session (or it targeted another canvas). -fn commit_caption_edit(app: &mut PlotxApp, ci: usize) { - let Some((canvas, before_text, before_visible)) = app.session.ui.caption_edit_before.take() - else { - return; - }; - if canvas != ci || ci >= app.doc.canvases.len() { - return; - } - let after = ( - app.doc.canvases[ci].caption.clone(), - app.doc.canvases[ci].caption_visible, - ); - app.execute_action(Action::set_canvas_caption( - ci, - (before_text, before_visible), - after, - )); -} - -/// Commits a page-layout edit as one undoable step, coalescing a slider drag -/// into a single history entry (mirrors `handle_canvas_dimension_response`). -/// The caller has already applied the live change to `canvas.layout`. -pub(super) fn handle_layout_response( - app: &mut PlotxApp, - ci: usize, - resp: &Response, - before_fallback: PageLayout, -) { - if resp.drag_started() { - app.session.ui.page_layout_edit = Some(PendingPageLayoutEdit { - canvas: ci, - before: before_fallback, - }); - } - if resp.drag_stopped() { - let before = app - .session - .ui - .page_layout_edit - .take() - .filter(|edit| edit.canvas == ci) - .map(|edit| edit.before) - .unwrap_or(before_fallback); - let after = app.doc.canvases[ci].layout; - app.commit_page_layout(ci, before, after); - } else if resp.changed() && !resp.dragged() { - let after = app.doc.canvases[ci].layout; - app.commit_page_layout(ci, before_fallback, after); - } -} - -pub(super) fn margin_drag( - app: &mut PlotxApp, - ci: usize, - ui: &mut Ui, - unit: CanvasSizeUnit, - idx: usize, - label: &str, -) { - ui.label(label); - let before = app.doc.canvases[ci].layout; - let mut value = unit.from_mm(before.margin_mm[idx]); - let resp = ui.add( - egui::DragValue::new(&mut value) - .speed(unit.drag_speed()) - .range(unit.from_mm(0.0)..=unit.from_mm(100.0)) - .max_decimals(unit.decimals()), - ); - if resp.changed() { - app.doc.canvases[ci].layout.margin_mm[idx] = unit.to_mm(value).clamp(0.0, 100.0); - app.doc.dirty = true; - } - handle_layout_response(app, ci, &resp, before); -} - -pub(super) fn gutter_drag(app: &mut PlotxApp, ci: usize, ui: &mut Ui, unit: CanvasSizeUnit) { - let before = app.doc.canvases[ci].layout; - let mut value = unit.from_mm(before.gutter_mm); - let resp = ui.add( - egui::DragValue::new(&mut value) - .speed(unit.drag_speed()) - .range(unit.from_mm(0.0)..=unit.from_mm(100.0)) - .max_decimals(unit.decimals()), - ); - if resp.changed() { - app.doc.canvases[ci].layout.gutter_mm = unit.to_mm(value).clamp(0.0, 100.0); - app.doc.dirty = true; - } - handle_layout_response(app, ci, &resp, before); -} - -pub(super) fn grid_count_drag(app: &mut PlotxApp, ci: usize, ui: &mut Ui, rows: bool) { - let before = app.doc.canvases[ci].layout; - let mut value = if rows { before.rows } else { before.cols }; - let resp = ui.add(egui::DragValue::new(&mut value).speed(0.1).range(1..=12)); - if resp.changed() { - let value = value.clamp(1, 12); - if rows { - app.doc.canvases[ci].layout.rows = value; - } else { - app.doc.canvases[ci].layout.cols = value; - } - app.doc.dirty = true; - } - handle_layout_response(app, ci, &resp, before); -} - -pub(super) fn panel_note_edit_window(app: &mut PlotxApp, ctx: &egui::Context) { - let Some(edit) = app.session.ui.panel_note_edit.as_ref() else { - return; - }; - let ci = edit.canvas; - let object_id = edit.object; - if ci >= app.doc.canvases.len() - || app.doc.canvases[ci] - .object(object_id) - .and_then(|object| object.plot()) - .is_none() - { - app.session.ui.panel_note_edit = None; - app.session.ui.selection = Selection::None; - return; - } - - let mut open = true; - let mut save = false; - let mut delete = false; - let mut cancel = false; - egui::Window::new("Edit panel note") - .collapsible(false) - .resizable(false) - .open(&mut open) - .show(ctx, |ui| { - let Some(edit) = app.session.ui.panel_note_edit.as_mut() else { - return; - }; - let resp = ui.add( - egui::TextEdit::multiline(&mut edit.buffer) - .desired_width(320.0) - .desired_rows(3), - ); - if edit.focus { - resp.request_focus(); - edit.focus = false; - } - ui.add_space(8.0); - ui.horizontal(|ui| { - if ui.button("Save").clicked() { - save = true; - } - if ui.button("Clear").clicked() { - delete = true; - } - if ui.button("Cancel").clicked() { - cancel = true; - } - }); - }); - - if save { - let buffer = app - .session - .ui - .panel_note_edit - .as_ref() - .map(|edit| edit.buffer.trim().to_owned()) - .unwrap_or_default(); - if let Some(before) = app.doc.canvases[ci] - .object(object_id) - .and_then(|object| object.plot()) - .map(|plot| plot.panel.clone()) - { - let mut after = before.clone(); - after.user_note = buffer; - app.execute_action(Action::set_panel_meta(ci, object_id, before, after)); - app.select_panel_label(ci, object_id); - app.session.status = "Panel note updated.".to_owned(); - } - app.session.ui.panel_note_edit = None; - } else if delete { - if let Some(before) = app.doc.canvases[ci] - .object(object_id) - .and_then(|object| object.plot()) - .map(|plot| plot.panel.clone()) - { - let mut after = before.clone(); - after.user_note.clear(); - app.execute_action(Action::set_panel_meta(ci, object_id, before, after)); - app.session.status = "Panel note cleared.".to_owned(); - } - app.session.ui.panel_note_edit = None; - app.select_object(ci, object_id); - } else if cancel || !open { - app.session.ui.panel_note_edit = None; - } -} - -pub(super) fn text_edit_window(app: &mut PlotxApp, ctx: &egui::Context) { - let Some(edit) = app.session.ui.text_edit.as_ref() else { - return; - }; - let ci = edit.canvas; - let object_id = edit.object; - if ci >= app.doc.canvases.len() - || app.doc.canvases[ci] - .object(object_id) - .and_then(|object| object.text()) - .is_none() - { - app.session.ui.text_edit = None; - return; - } - - let mut open = true; - let mut save = false; - let mut delete = false; - let mut cancel = false; - egui::Window::new("Edit text") - .collapsible(false) - .resizable(false) - .open(&mut open) - .show(ctx, |ui| { - let Some(edit) = app.session.ui.text_edit.as_mut() else { - return; - }; - let resp = ui.add( - egui::TextEdit::multiline(&mut edit.buffer) - .desired_width(320.0) - .desired_rows(3), - ); - if edit.focus { - resp.request_focus(); - edit.focus = false; - } - ui.add_space(8.0); - ui.horizontal(|ui| { - if ui.button("Save").clicked() { - save = true; - } - if ui.button("Delete").clicked() { - delete = true; - } - if ui.button("Cancel").clicked() { - cancel = true; - } - }); - }); +use super::*; - if save { - let buffer = app - .session - .ui - .text_edit - .as_ref() - .map(|edit| edit.buffer.trim().to_owned()) - .unwrap_or_default(); - if let Some(before) = app.doc.canvases[ci] - .object(object_id) - .and_then(|object| object.text()) - .cloned() - { - let mut after = before.clone(); - if !buffer.is_empty() { - after.text = buffer; - } - app.execute_action(Action::set_object_text(ci, object_id, before, after)); - app.select_object(ci, object_id); - app.session.status = "Text updated.".to_owned(); - } - app.session.ui.text_edit = None; - } else if delete { - if let Some(action) = Action::delete_object(app, ci, object_id) { - app.execute_action(action); - } - app.session.ui.text_edit = None; - app.session.status = "Object deleted.".to_owned(); - } else if cancel || !open { - app.session.ui.text_edit = None; - } -} +pub(super) use canvas_settings::{canvas_settings_layer, canvas_settings_window}; +pub(super) use panel_note_edit::panel_note_edit_window; +pub(super) use project::{handle_close_request, quit_confirm_window, save_project_window}; +pub(super) use text_edit::text_edit_window; diff --git a/crates/app/src/ui/windows/canvas_settings.rs b/crates/app/src/ui/windows/canvas_settings.rs new file mode 100644 index 00000000..c0a8dd6c --- /dev/null +++ b/crates/app/src/ui/windows/canvas_settings.rs @@ -0,0 +1,167 @@ +//! Canvas settings window and layout editing controls. + +use super::*; + +/// Stable layer of the Canvas settings window, shared with the size chip so a +/// chip click can raise an already-open window above the chip's own layer. +pub(in crate::ui) fn canvas_settings_layer() -> egui::LayerId { + egui::LayerId::new(egui::Order::Middle, egui::Id::new("canvas_settings_window")) +} + +pub(in crate::ui) fn canvas_settings_window(app: &mut PlotxApp, ctx: &egui::Context) { + let Some(ci) = app.session.ui.canvas_settings else { + return; + }; + if ci >= app.doc.canvases.len() { + app.session.ui.canvas_settings = None; + return; + } + let mut open = true; + let title = format!("Canvas settings — {}", app.doc.canvases[ci].name); + let target = app.canvas_target(app.doc.canvases[ci].resource_id); + egui::Window::new(title) + .id(canvas_settings_layer().id) + .collapsible(false) + .resizable(false) + .open(&mut open) + .show(ctx, |ui| { + super::canvas_size::size_section(app, ci, ui); + crate::ui::properties::panel::canvas_size_section(app, &target, ui); + crate::ui::properties::panel::canvas_margins_section(app, &target, ui); + ui.weak("Visual spacing is a minimum request; axis furniture may make it larger."); + ui.horizontal(|ui| { + ui.label("Presets"); + for preset in plotx_core::layout::GutterPreset::ALL { + let selected = (app.doc.canvases[ci].layout.gutter_mm - preset.millimetres()) + .abs() + < 0.001; + if ui.selectable_label(selected, preset.label()).clicked() { + app.set_gutter_preset(preset); + } + } + }); + + crate::ui::properties::panel::canvas_grid_section(app, &target, ui); + ui.horizontal(|ui| { + let (rows, cols) = { + let l = app.doc.canvases[ci].layout; + (l.rows, l.cols) + }; + let simplify_id = egui::Id::new(("apply_grid_simplify", ci)); + let mut simplify = ui + .data_mut(|data| data.get_temp::(simplify_id)) + .unwrap_or(false); + if ui.checkbox(&mut simplify, "Simplify inner axes").changed() { + ui.data_mut(|data| data.insert_temp(simplify_id, simplify)); + } + if ui + .button("Apply grid") + .on_hover_text("Reposition all plots into these cells") + .clicked() + { + app.arrange_active_canvas_grid_with_simplify(rows, cols, simplify); + } + }); + + crate::ui::properties::panel::canvas_caption_section(app, &target, ui); + ui.weak("Shown below the page on the board only — not exported or presented."); + + let resp = ui.add( + egui::TextEdit::multiline(&mut app.doc.canvases[ci].caption) + .desired_width(340.0) + .desired_rows(3) + .hint_text("e.g. Figure 1. Concentration vs. time…"), + ); + if resp.gained_focus() { + app.session.ui.caption_edit_before = Some(( + ci, + app.doc.canvases[ci].caption.clone(), + app.doc.canvases[ci].caption_visible, + )); + } + if resp.changed() { + app.doc.dirty = true; + } + if resp.lost_focus() { + commit_caption_edit(app, ci); + } + + ui.add_space(12.0); + ui.separator(); + panels_section(app, ci, ui); + }); + if !open { + commit_caption_edit(app, ci); + commit_note_edit(app); + app.session.ui.canvas_settings = None; + } +} + +/// Notes are auto-listed below the page on the board. +fn panels_section(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { + ui.strong("Panels"); + ui.add_space(6.0); + + ui.weak("Letters are top-left in each plot; notes list below the page (board only)."); + ui.add_space(4.0); + + let order = app.doc.canvases[ci].plot_reading_order(); + if order.is_empty() { + ui.weak("No plots on this page yet."); + return; + } + for (i, id) in order.into_iter().enumerate() { + let letter = app.doc.canvases[ci].panel_label_style.format(i); + let Some(_panel) = app.doc.canvases[ci] + .object(id) + .and_then(|o| o.plot()) + .map(|p| p.panel.clone()) + else { + continue; + }; + ui.horizontal(|ui| { + ui.strong(&letter); + crate::ui::properties::panel::panel_inline_section(app, ci, id, ui); + }); + } +} + +/// Commit an in-progress per-panel note edit as one undoable step. A no-op when +/// nothing changed (or the panel/page is gone). +fn commit_note_edit(app: &mut PlotxApp) { + let Some((ci, id, before)) = app.session.ui.note_edit_before.take() else { + return; + }; + let Some(after) = app + .doc + .canvases + .get(ci) + .and_then(|c| c.object(id)) + .and_then(|o| o.plot()) + .map(|p| p.panel.clone()) + else { + return; + }; + app.execute_action(Action::set_panel_meta(ci, id, before, after)); +} + +/// Commit an in-progress caption text edit for `ci` as one undoable step. A no-op +/// when nothing changed during the edit session (or it targeted another canvas). +fn commit_caption_edit(app: &mut PlotxApp, ci: usize) { + let Some((canvas, before_text, before_visible)) = app.session.ui.caption_edit_before.take() + else { + return; + }; + if canvas != ci || ci >= app.doc.canvases.len() { + return; + } + let after = ( + app.doc.canvases[ci].caption.clone(), + app.doc.canvases[ci].caption_visible, + ); + app.execute_action(Action::set_canvas_caption( + ci, + (before_text, before_visible), + after, + )); +} diff --git a/crates/app/src/ui/windows/panel_note_edit.rs b/crates/app/src/ui/windows/panel_note_edit.rs new file mode 100644 index 00000000..4d17d724 --- /dev/null +++ b/crates/app/src/ui/windows/panel_note_edit.rs @@ -0,0 +1,94 @@ +//! Panel note editing window. + +use super::*; +use plotx_core::state::ObjectId; + +pub(in crate::ui) fn panel_note_edit_window(app: &mut PlotxApp, ctx: &egui::Context) { + let Some(edit) = app.session.ui.panel_note_edit.as_ref() else { + return; + }; + let ci = edit.canvas; + let object_id = edit.object; + if ci >= app.doc.canvases.len() + || app.doc.canvases[ci] + .object(object_id) + .and_then(|object| object.plot()) + .is_none() + { + app.session.ui.panel_note_edit = None; + app.session.ui.selection = Selection::None; + return; + } + + let mut open = true; + let mut save = false; + let mut delete = false; + let mut cancel = false; + egui::Window::new("Edit panel note") + .collapsible(false) + .resizable(false) + .open(&mut open) + .show(ctx, |ui| { + let Some(edit) = app.session.ui.panel_note_edit.as_mut() else { + return; + }; + let resp = ui.add( + egui::TextEdit::multiline(&mut edit.buffer) + .desired_width(320.0) + .desired_rows(3), + ); + if edit.focus { + resp.request_focus(); + edit.focus = false; + } + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Save").clicked() { + save = true; + } + if ui.button("Clear").clicked() { + delete = true; + } + if ui.button("Cancel").clicked() { + cancel = true; + } + }); + }); + + if save { + let buffer = app + .session + .ui + .panel_note_edit + .as_ref() + .map(|edit| edit.buffer.trim().to_owned()) + .unwrap_or_default(); + if write_panel_note(app, ci, object_id, buffer) { + app.select_panel_label(ci, object_id); + app.session.status = "Panel note updated.".to_owned(); + } + app.session.ui.panel_note_edit = None; + } else if delete { + if write_panel_note(app, ci, object_id, String::new()) { + app.session.status = "Panel note cleared.".to_owned(); + } + app.session.ui.panel_note_edit = None; + app.select_object(ci, object_id); + } else if cancel || !open { + app.session.ui.panel_note_edit = None; + } +} + +fn write_panel_note(app: &mut PlotxApp, canvas: usize, object: ObjectId, note: String) -> bool { + let Some(target) = app.object_target(canvas, object) else { + return false; + }; + let Ok(commit) = app.plan_property_write( + plotx_core::properties::object::PANEL_USER_NOTE, + std::slice::from_ref(&target), + &plotx_core::properties::PropertyValue::Text(note), + ) else { + return false; + }; + app.commit_property(commit) == 1 +} diff --git a/crates/app/src/ui/windows/project.rs b/crates/app/src/ui/windows/project.rs new file mode 100644 index 00000000..72c59d7e --- /dev/null +++ b/crates/app/src/ui/windows/project.rs @@ -0,0 +1,139 @@ +//! Project saving and quit-confirmation windows. + +use super::*; + +pub(in crate::ui) fn save_project_window(app: &mut PlotxApp, ctx: &egui::Context) { + if !app.session.ui.save_project_options { + return; + } + + let mut save = false; + let mut save_as = false; + let mut cancel = false; + let modal = super::modal(ctx, "save_project_modal", ModalKind::Dialog).show(ctx, |ui| { + ui.set_width(390.0); + ui.heading("Save project"); + ui.separator(); + if app.session.status.starts_with("Save failed:") { + ui.colored_label(ui.visuals().error_fg_color, &app.session.status); + if ui.link("Open diagnostic details").clicked() { + app.session.ui.diagnostics_open = true; + } + ui.add_space(8.0); + } + ui.checkbox( + &mut app.doc.save_include_view_snapshots, + "Include rendered canvas snapshots", + ) + .on_hover_text( + "Stores materialized view data for faster and more stable reopening. \ + This can make .plotx files much larger.", + ); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Save").clicked() { + save = true; + } + if ui.button("Save As…").clicked() { + save_as = true; + } + if ui.button("Cancel").clicked() { + cancel = true; + } + }); + }); + + if save { + if let Some(path) = app.doc.project_path.clone() { + app.session.ui.save_project_options = + !app.save_project_to(&path, app.doc.save_include_view_snapshots); + } else { + crate::ui::file_dialogs::save_project_as(app, app.doc.save_include_view_snapshots); + app.session.ui.save_project_options = app.doc.dirty; + } + } else if save_as { + crate::ui::file_dialogs::save_project_as(app, app.doc.save_include_view_snapshots); + app.session.ui.save_project_options = app.doc.dirty; + } else if cancel || modal.should_close() { + app.session.ui.save_project_options = false; + } +} + +/// Intercept a window-close request when the project has unsaved changes: veto the +/// close and raise the confirm dialog. Once the user confirms (Save or Discard), +/// `allow_close` lets the re-issued request through. +pub(in crate::ui) fn handle_close_request(app: &mut PlotxApp, ctx: &egui::Context) { + if !ctx.input(|i| i.viewport().close_requested()) { + return; + } + if app.doc.dirty && !app.session.allow_close { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + app.session.ui.quit_confirm = true; + } +} + +/// Save / Discard / Cancel dialog shown when a close was intercepted on a dirty +/// project. Save routes through the normal save flow (opening Save As… if the +/// project has no path yet) and only closes once the save actually succeeds. +pub(in crate::ui) fn quit_confirm_window(app: &mut PlotxApp, ctx: &egui::Context) { + if !app.session.ui.quit_confirm { + return; + } + let mut save = false; + let mut discard = false; + let mut cancel = false; + let modal = super::modal(ctx, "quit_confirm_modal", ModalKind::Dialog).show(ctx, |ui| { + ui.set_width(420.0); + ui.heading("Unsaved changes"); + ui.separator(); + ui.label("This project has unsaved changes. Save before closing?"); + if app.session.status.starts_with("Save failed:") { + ui.add_space(8.0); + egui::Frame::new() + .fill(ui.visuals().error_fg_color.linear_multiply(0.12)) + .corner_radius(6) + .inner_margin(8) + .show(ui, |ui| { + ui.colored_label(ui.visuals().error_fg_color, &app.session.status); + if ui.link("Open diagnostic details").clicked() { + app.session.ui.diagnostics_open = true; + } + }); + } + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Save").clicked() { + save = true; + } + if ui.button("Discard").clicked() { + discard = true; + } + if ui.button("Cancel").clicked() { + cancel = true; + } + }); + }); + + if save { + let saved = if let Some(path) = app.doc.project_path.clone() { + app.save_project_to(&path, app.doc.save_include_view_snapshots) + } else { + crate::ui::file_dialogs::save_project_as(app, app.doc.save_include_view_snapshots); + !app.doc.dirty + }; + if saved { + app.session.ui.quit_confirm = false; + app.session.allow_close = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } else { + crate::cancel_relaunch(); + } + } else if discard { + app.session.ui.quit_confirm = false; + app.session.allow_close = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } else if cancel || modal.should_close() { + app.session.ui.quit_confirm = false; + crate::cancel_relaunch(); + } +} diff --git a/crates/app/src/ui/windows/text_edit.rs b/crates/app/src/ui/windows/text_edit.rs new file mode 100644 index 00000000..e8ea3062 --- /dev/null +++ b/crates/app/src/ui/windows/text_edit.rs @@ -0,0 +1,87 @@ +//! Text object editing window. + +use super::*; + +pub(in crate::ui) fn text_edit_window(app: &mut PlotxApp, ctx: &egui::Context) { + let Some(edit) = app.session.ui.text_edit.as_ref() else { + return; + }; + let ci = edit.canvas; + let object_id = edit.object; + if ci >= app.doc.canvases.len() + || app.doc.canvases[ci] + .object(object_id) + .and_then(|object| object.text()) + .is_none() + { + app.session.ui.text_edit = None; + return; + } + + let mut open = true; + let mut save = false; + let mut delete = false; + let mut cancel = false; + egui::Window::new("Edit text") + .collapsible(false) + .resizable(false) + .open(&mut open) + .show(ctx, |ui| { + let Some(edit) = app.session.ui.text_edit.as_mut() else { + return; + }; + let resp = ui.add( + egui::TextEdit::multiline(&mut edit.buffer) + .desired_width(320.0) + .desired_rows(3), + ); + if edit.focus { + resp.request_focus(); + edit.focus = false; + } + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Save").clicked() { + save = true; + } + if ui.button("Delete").clicked() { + delete = true; + } + if ui.button("Cancel").clicked() { + cancel = true; + } + }); + }); + + if save { + let buffer = app + .session + .ui + .text_edit + .as_ref() + .map(|edit| edit.buffer.trim().to_owned()) + .unwrap_or_default(); + if let Some(before) = app.doc.canvases[ci] + .object(object_id) + .and_then(|object| object.text()) + .cloned() + { + let mut after = before.clone(); + if !buffer.is_empty() { + after.text = buffer; + } + app.execute_action(Action::set_object_text(ci, object_id, before, after)); + app.select_object(ci, object_id); + app.session.status = "Text updated.".to_owned(); + } + app.session.ui.text_edit = None; + } else if delete { + if let Some(action) = Action::delete_object(app, ci, object_id) { + app.execute_action(action); + } + app.session.ui.text_edit = None; + app.session.status = "Object deleted.".to_owned(); + } else if cancel || !open { + app.session.ui.text_edit = None; + } +} diff --git a/crates/core/src/actions/app_impl/apply.rs b/crates/core/src/actions/app_impl/apply.rs new file mode 100644 index 00000000..175247ae --- /dev/null +++ b/crates/core/src/actions/app_impl/apply.rs @@ -0,0 +1,330 @@ +//! Applies validated actions to the document and session state. + +use super::*; + +impl PlotxApp { + /// Apply an action's `after` state to the live document without touching + /// history. Callers that record the step themselves — a paused processing + /// commit, a coalesced gesture — use this and then record once. + pub(crate) fn apply_action(&mut self, action: &Action) { + macro_rules! dataset_index { + ($id:expr) => { + match self.doc.dataset_index($id) { + Some(index) => index, + None => return, + } + }; + } + match action { + Action::Composite(actions) => { + for action in actions { + self.apply_action(action); + } + } + Action::UpdateDatasetProcessing { dataset, after, .. } => { + self.set_dataset_processing_state(dataset_index!(*dataset), after); + } + Action::SetObjectViewport { + canvas, + object, + after, + .. + } => { + self.set_object_viewport(*canvas, *object, after); + } + Action::SetAxisOverrides { + canvas, + object, + after, + .. + } => { + self.set_axis_overrides_value(*canvas, *object, after); + } + Action::MoveResizeObject { + canvas, + object, + after, + .. + } => { + self.set_object_frame(*canvas, *object, *after); + } + Action::SetObjectFrames { canvas, after, .. } => { + for &(id, frame) in after { + self.set_object_frame(*canvas, id, frame); + } + } + Action::SetObjectGroups { canvas, after, .. } => { + self.set_object_groups(*canvas, after); + } + Action::ReorderObjects { canvas, after, .. } => { + self.reorder_objects_value(*canvas, after); + } + Action::SetCanvasSize { canvas, after, .. } => { + self.set_canvas_size(*canvas, after); + } + Action::MoveCanvasOnBoard { canvas, after, .. } => { + if let Some(c) = self.doc.canvases.get_mut(*canvas) { + c.board_pos = *after; + } + } + Action::MoveSheetOnBoard { dataset, after, .. } => { + let dataset = dataset_index!(*dataset); + if let Some(t) = self + .doc + .datasets + .get_mut(dataset) + .and_then(Dataset::as_table_mut) + { + t.board_pos = *after; + } + } + Action::TidyBoard { after, .. } => { + for &(frame, pos) in after { + crate::state::set_frame_board_pos(self, frame, pos); + } + } + Action::SetPageLayout { canvas, after, .. } => { + self.set_page_layout_value(*canvas, *after); + } + Action::ArrangeObjects { + canvas, + after_layout, + after, + .. + } => { + self.apply_arrangement(*canvas, *after_layout, after); + } + Action::SetPanelMeta { + canvas, + object, + after, + .. + } => { + self.set_panel_meta(*canvas, *object, after.clone()); + } + Action::SetObjectFlags { + canvas, + object, + after, + .. + } => { + self.set_object_flags(*canvas, *object, *after); + } + Action::BoardViewInsert { index, view } => { + self.board_view_do_insert(*index, view); + } + Action::BoardViewRemove { index, view } => { + self.board_view_do_remove(*index, view); + } + Action::SetDataBinding { + canvas, + object, + after, + .. + } => { + self.set_object_binding(*canvas, *object, after); + } + Action::SetChartType { + canvas, + object, + after, + .. + } => { + self.set_object_chart(*canvas, *object, after); + } + Action::SetStackSpec { + canvas, + object, + after, + .. + } => { + self.set_object_stack(*canvas, *object, after); + } + Action::SetAxisProjections { + canvas, + object, + after, + .. + } => { + self.set_object_projections(*canvas, *object, after); + } + Action::RenameCanvas { canvas, after, .. } => { + if let Some(c) = self.doc.canvases.get_mut(*canvas) { + c.name = after.clone(); + } + } + Action::RenameObject { + canvas, + object, + after, + .. + } => { + if let Some(object) = self + .doc + .canvases + .get_mut(*canvas) + .and_then(|canvas| canvas.object_mut(*object)) + { + object.name.clone_from(after); + } + } + Action::SetCanvasCaption { canvas, after, .. } => { + self.set_canvas_caption_value(*canvas, after); + } + Action::SetPanelLabelStyle { canvas, after, .. } => { + if let Some(c) = self.doc.canvases.get_mut(*canvas) { + c.panel_label_style = *after; + } + } + Action::RenameDataset { dataset, after, .. } => { + let dataset = dataset_index!(*dataset); + if let Some(d) = self.doc.datasets.get_mut(dataset) { + d.set_name(after.clone()); + } + } + Action::SetCurveFitAnalyses { dataset, after, .. } => { + self.set_curve_fit_analyses(dataset_index!(*dataset), after); + } + Action::EditTable { dataset, delta } => { + self.apply_table_edit(dataset_index!(*dataset), delta, true); + } + Action::SetTypedTableState { dataset, after, .. } => { + self.set_typed_table_state(dataset_index!(*dataset), after); + } + Action::SetRegions { dataset, after, .. } => { + self.set_regions(dataset_index!(*dataset), after); + } + Action::SetIntegrals { dataset, after, .. } => { + self.set_integrals(dataset_index!(*dataset), after); + } + Action::SetIntegrals2D { dataset, after, .. } => { + self.set_integrals_2d(dataset_index!(*dataset), after); + } + Action::SetPeaks { dataset, after, .. } => { + self.set_peaks(dataset_index!(*dataset), after); + } + Action::SetLineFits { dataset, after, .. } => { + self.set_line_fits(dataset_index!(*dataset), after); + } + Action::SetMultiplets { dataset, after, .. } => { + self.set_multiplets(dataset_index!(*dataset), after); + } + Action::SetTableStatistics { dataset, after, .. } => { + self.set_table_statistics(dataset_index!(*dataset), after); + } + Action::InsertObject { canvas, object, .. } => { + self.insert_object_value(*canvas, object.as_ref().clone()); + } + Action::DeleteObject { canvas, object, .. } => { + self.remove_object_value(*canvas, object.id); + } + Action::SetObjectText { + canvas, + object, + after, + .. + } => { + self.set_object_text_value(*canvas, *object, after.clone()); + } + Action::SetObjectStyle { canvas, after, .. } => { + self.set_object_styles(*canvas, after); + } + Action::DeleteCanvas { + index, + active_after, + .. + } => { + if *index < self.doc.canvases.len() { + self.doc.canvases.remove(*index); + self.session.active_canvas = *active_after; + if let Some(ci) = self.session.active_canvas { + let active = self.doc.canvases[ci] + .active_dataset() + .and_then(|id| self.doc.dataset_index(id)); + self.set_active_dataset(active); + } + self.reset_interaction(); + self.session.ui.wheel_zoom = None; + self.session.ui.selection = Selection::None; + self.session.ui.panel_note_inline_edit = None; + self.session.ui.panel_note_edit = None; + self.session.ui.axis_overrides_before = None; + self.session.ui.canvas_settings = None; + self.session.ui.rename = None; + } + } + Action::InsertCanvas { index, canvas, .. } => { + self.insert_canvas_value(*index, canvas.as_ref().clone()); + } + Action::ApplyTheme { canvas, after, .. } => { + self.apply_theme_snapshot(*canvas, after); + } + Action::SetFigureTypography { after, .. } => { + self.set_figure_typography_value(*after); + } + Action::InsertDatasetWithCanvas { + dataset_index, + canvas_index, + canvas_resource_id, + dataset, + canvas_name, + size_mm, + inserted_into_existing_canvas, + inserted_object_id, + .. + } => { + if *dataset_index != self.doc.datasets.len() { + return; + } + if !self.register_loaded_dataset_fields(dataset.as_ref()) { + return; + } + self.doc.datasets.push(dataset.as_ref().clone()); + if let Some(ci) = inserted_into_existing_canvas { + let Some(canvas) = self.doc.canvases.get(*ci) else { + return; + }; + let page = canvas.size_pt(); + let offset = 18.0 * canvas.objects.len() as f32; + let object_name = format!("Plot {}", canvas.objects.len() + 1); + let frame = ObjectFrame::new( + 24.0 + offset, + 24.0 + offset, + (page[0] * 0.58).max(120.0), + (page[1] * 0.45).max(90.0), + ); + let id = inserted_object_id.unwrap_or(canvas.next_object_id); + let object = self.build_plot_object(*dataset_index, frame, id, object_name); + let canvas = self.doc.canvases.get_mut(*ci).unwrap(); + canvas.next_object_id = canvas.next_object_id.max(id.checked_advance(1)); + canvas.objects.push(object); + self.session.active_canvas = Some(*ci); + } else { + if *canvas_index != self.doc.canvases.len() { + return; + } + let mut canvas = crate::workflow::build_default_canvas_for_dataset( + &self.doc.datasets[*dataset_index], + *dataset_index, + canvas_name.clone(), + *size_mm, + ); + canvas.resource_id.clone_from(canvas_resource_id); + canvas.board_pos = crate::state::next_page_board_pos(self); + for object in &mut canvas.objects { + if let Some(plot) = object.plot_mut() { + plot.set_figure_typography(self.doc.style_library.figure_typography); + } + } + self.doc.canvases.push(canvas); + self.rebuild_canvases_for(*dataset_index); + self.session.active_canvas = Some(*canvas_index); + } + self.focus_single(*dataset_index); + self.session.view = PrimaryView::Canvas; + } + Action::TransferObjects { .. } => self.apply_transfer(action), + Action::TileDrop { .. } => self.apply_tile_drop(action), + } + } +} diff --git a/crates/core/src/actions/app_impl/axis_overrides.rs b/crates/core/src/actions/app_impl/axis_overrides.rs index 0f084a3b..39528fba 100644 --- a/crates/core/src/actions/app_impl/axis_overrides.rs +++ b/crates/core/src/actions/app_impl/axis_overrides.rs @@ -17,7 +17,7 @@ impl PlotxApp { return; }; plot.viewport = viewport.clone(); - plot.viewport.apply_to(&mut plot.figure); + plot.apply_viewport(); } /// Finish a live Inspector edit before another command can change its target @@ -100,7 +100,11 @@ impl PlotxApp { let needs_automatic_rebuild = cleared(&before.x_label, &after.x_label) || cleared(&before.y_label, &after.y_label) || cleared(&before.x_range, &after.x_range) - || cleared(&before.y_range, &after.y_range); + || cleared(&before.y_range, &after.y_range) + || cleared(&before.x_show_tick_labels, &after.x_show_tick_labels) + || cleared(&before.x_show_label, &after.x_show_label) + || cleared(&before.y_show_tick_labels, &after.y_show_tick_labels) + || cleared(&before.y_show_label, &after.y_show_label); let rebuilt = needs_automatic_rebuild.then(|| { let size = [ @@ -121,68 +125,32 @@ impl PlotxApp { }; plot.axis_overrides = after; - if let Some(mut figure) = rebuilt { - plot.axis_overrides.apply_to(&mut figure); - let effective_y_range = - plot.axis_overrides.y_range.is_some() && figure.y.categories.is_none(); - if y_range_changed { - plot.viewport.auto_y = !effective_y_range; - } else if effective_y_range { - plot.viewport.auto_y = false; - } - plot.viewport.sync_full_from(&figure); - reset_changed_ranges( - plot, - &figure, - x_range_changed, - y_range_changed, - effective_y_range, - ); - plot.viewport.apply_to(&mut figure); - plot.figure = figure; + if let Some(figure) = rebuilt { + plot.rebuild_for_axis_overrides(figure, x_range_changed, y_range_changed); return; } - plot.axis_overrides.apply_to(&mut plot.figure); + plot.apply_axis_overrides(); if x_range_changed - && plot.figure.x.categories.is_none() + && plot.figure().x.categories.is_none() && let Some(range) = plot.axis_overrides.x_range { plot.viewport.full_x = range; plot.viewport.view_x = range; if plot.viewport.auto_y { - plot.viewport.reset_x(&plot.figure); + let figure = plot.figure().clone(); + plot.viewport.reset_x(&figure); } } if y_range_changed - && plot.figure.y.categories.is_none() + && plot.figure().y.categories.is_none() && let Some(range) = plot.axis_overrides.y_range { plot.viewport.full_y = range; plot.viewport.view_y = range; plot.viewport.auto_y = false; } - plot.viewport.apply_to(&mut plot.figure); - } -} - -fn reset_changed_ranges( - plot: &mut crate::state::PlotObject, - figure: &plotx_figure::Figure, - x_changed: bool, - y_changed: bool, - effective_y_range: bool, -) { - if x_changed { - plot.viewport.reset_x(figure); - } - if y_changed { - if effective_y_range { - plot.viewport.view_y = plot.viewport.full_y; - plot.viewport.auto_y = false; - } else { - plot.viewport.reset_y(figure); - } + plot.apply_viewport(); } } diff --git a/crates/core/src/actions/app_impl/mod.rs b/crates/core/src/actions/app_impl/mod.rs index 21448df2..82995963 100644 --- a/crates/core/src/actions/app_impl/mod.rs +++ b/crates/core/src/actions/app_impl/mod.rs @@ -1,4 +1,5 @@ use super::*; +mod apply; mod axis_overrides; mod meta_edits; mod processing; @@ -73,7 +74,6 @@ impl PlotxApp { self.reset_interaction(); self.session.ui.wheel_zoom = None; self.session.ui.canvas_size_edit = None; - self.session.ui.processing_edit = None; self.session.ui.processing_session = None; self.session.ui.property_gesture = None; self.session.ui.inspector_edit = None; @@ -108,331 +108,6 @@ impl PlotxApp { ); } } - /// Apply an action's `after` state to the live document without touching - /// history. Callers that record the step themselves — a paused processing - /// commit, a coalesced gesture — use this and then record once. - pub(crate) fn apply_action(&mut self, action: &Action) { - macro_rules! dataset_index { - ($id:expr) => { - match self.doc.dataset_index($id) { - Some(index) => index, - None => return, - } - }; - } - match action { - Action::Composite(actions) => { - for action in actions { - self.apply_action(action); - } - } - Action::UpdateDatasetProcessing { dataset, after, .. } => { - self.set_dataset_processing_state(dataset_index!(*dataset), after); - } - Action::SetObjectViewport { - canvas, - object, - after, - .. - } => { - self.set_object_viewport(*canvas, *object, after); - } - Action::SetAxisOverrides { - canvas, - object, - after, - .. - } => { - self.set_axis_overrides_value(*canvas, *object, after); - } - Action::MoveResizeObject { - canvas, - object, - after, - .. - } => { - self.set_object_frame(*canvas, *object, *after); - } - Action::SetObjectFrames { canvas, after, .. } => { - for &(id, frame) in after { - self.set_object_frame(*canvas, id, frame); - } - } - Action::SetObjectGroups { canvas, after, .. } => { - self.set_object_groups(*canvas, after); - } - Action::ReorderObjects { canvas, after, .. } => { - self.reorder_objects_value(*canvas, after); - } - Action::SetCanvasSize { canvas, after, .. } => { - self.set_canvas_size(*canvas, after); - } - Action::MoveCanvasOnBoard { canvas, after, .. } => { - if let Some(c) = self.doc.canvases.get_mut(*canvas) { - c.board_pos = *after; - } - } - Action::MoveSheetOnBoard { dataset, after, .. } => { - let dataset = dataset_index!(*dataset); - if let Some(t) = self - .doc - .datasets - .get_mut(dataset) - .and_then(Dataset::as_table_mut) - { - t.board_pos = *after; - } - } - Action::TidyBoard { after, .. } => { - for &(frame, pos) in after { - crate::state::set_frame_board_pos(self, frame, pos); - } - } - Action::SetPageLayout { canvas, after, .. } => { - self.set_page_layout_value(*canvas, *after); - } - Action::ArrangeObjects { - canvas, - after_layout, - after, - .. - } => { - self.apply_arrangement(*canvas, *after_layout, after); - } - Action::SetPanelMeta { - canvas, - object, - after, - .. - } => { - self.set_panel_meta(*canvas, *object, after.clone()); - } - Action::SetObjectFlags { - canvas, - object, - after, - .. - } => { - self.set_object_flags(*canvas, *object, *after); - } - Action::BoardViewInsert { index, view } => { - self.board_view_do_insert(*index, view); - } - Action::BoardViewRemove { index, view } => { - self.board_view_do_remove(*index, view); - } - Action::SetDataBinding { - canvas, - object, - after, - .. - } => { - self.set_object_binding(*canvas, *object, after); - } - Action::SetChartType { - canvas, - object, - after, - .. - } => { - self.set_object_chart(*canvas, *object, after); - } - Action::SetStackSpec { - canvas, - object, - after, - .. - } => { - self.set_object_stack(*canvas, *object, after); - } - Action::SetAxisProjections { - canvas, - object, - after, - .. - } => { - self.set_object_projections(*canvas, *object, after); - } - Action::RenameCanvas { canvas, after, .. } => { - if let Some(c) = self.doc.canvases.get_mut(*canvas) { - c.name = after.clone(); - } - } - Action::RenameObject { - canvas, - object, - after, - .. - } => { - if let Some(object) = self - .doc - .canvases - .get_mut(*canvas) - .and_then(|canvas| canvas.object_mut(*object)) - { - object.name.clone_from(after); - } - } - Action::SetCanvasCaption { canvas, after, .. } => { - self.set_canvas_caption_value(*canvas, after); - } - Action::SetPanelLabelStyle { canvas, after, .. } => { - if let Some(c) = self.doc.canvases.get_mut(*canvas) { - c.panel_label_style = *after; - } - } - Action::RenameDataset { dataset, after, .. } => { - let dataset = dataset_index!(*dataset); - if let Some(d) = self.doc.datasets.get_mut(dataset) { - d.set_name(after.clone()); - } - } - Action::SetCurveFitAnalyses { dataset, after, .. } => { - self.set_curve_fit_analyses(dataset_index!(*dataset), after); - } - Action::EditTable { dataset, delta } => { - self.apply_table_edit(dataset_index!(*dataset), delta, true); - } - Action::SetTypedTableState { dataset, after, .. } => { - self.set_typed_table_state(dataset_index!(*dataset), after); - } - Action::SetRegions { dataset, after, .. } => { - self.set_regions(dataset_index!(*dataset), after); - } - Action::SetIntegrals { dataset, after, .. } => { - self.set_integrals(dataset_index!(*dataset), after); - } - Action::SetIntegrals2D { dataset, after, .. } => { - self.set_integrals_2d(dataset_index!(*dataset), after); - } - Action::SetPeaks { dataset, after, .. } => { - self.set_peaks(dataset_index!(*dataset), after); - } - Action::SetLineFits { dataset, after, .. } => { - self.set_line_fits(dataset_index!(*dataset), after); - } - Action::SetMultiplets { dataset, after, .. } => { - self.set_multiplets(dataset_index!(*dataset), after); - } - Action::SetTableStatistics { dataset, after, .. } => { - self.set_table_statistics(dataset_index!(*dataset), after); - } - Action::InsertObject { canvas, object, .. } => { - self.insert_object_value(*canvas, object.as_ref().clone()); - } - Action::DeleteObject { canvas, object, .. } => { - self.remove_object_value(*canvas, object.id); - } - Action::SetObjectText { - canvas, - object, - after, - .. - } => { - self.set_object_text_value(*canvas, *object, after.clone()); - } - Action::SetObjectStyle { canvas, after, .. } => { - self.set_object_styles(*canvas, after); - } - Action::DeleteCanvas { - index, - active_after, - .. - } => { - if *index < self.doc.canvases.len() { - self.doc.canvases.remove(*index); - self.session.active_canvas = *active_after; - if let Some(ci) = self.session.active_canvas { - let active = self.doc.canvases[ci] - .active_dataset() - .and_then(|id| self.doc.dataset_index(id)); - self.set_active_dataset(active); - } - self.reset_interaction(); - self.session.ui.wheel_zoom = None; - self.session.ui.selection = Selection::None; - self.session.ui.panel_note_inline_edit = None; - self.session.ui.panel_note_edit = None; - self.session.ui.axis_overrides_before = None; - self.session.ui.canvas_settings = None; - self.session.ui.rename = None; - } - } - Action::InsertCanvas { index, canvas, .. } => { - self.insert_canvas_value(*index, canvas.as_ref().clone()); - } - Action::ApplyTheme { canvas, after, .. } => { - self.apply_theme_snapshot(*canvas, after); - } - Action::SetFigureTypography { after, .. } => { - self.set_figure_typography_value(*after); - } - Action::InsertDatasetWithCanvas { - dataset_index, - canvas_index, - canvas_resource_id, - dataset, - canvas_name, - size_mm, - inserted_into_existing_canvas, - inserted_object_id, - .. - } => { - if *dataset_index != self.doc.datasets.len() { - return; - } - if !self.register_loaded_dataset_fields(dataset.as_ref()) { - return; - } - self.doc.datasets.push(dataset.as_ref().clone()); - if let Some(ci) = inserted_into_existing_canvas { - let Some(canvas) = self.doc.canvases.get(*ci) else { - return; - }; - let page = canvas.size_pt(); - let offset = 18.0 * canvas.objects.len() as f32; - let object_name = format!("Plot {}", canvas.objects.len() + 1); - let frame = ObjectFrame::new( - 24.0 + offset, - 24.0 + offset, - (page[0] * 0.58).max(120.0), - (page[1] * 0.45).max(90.0), - ); - let id = inserted_object_id.unwrap_or(canvas.next_object_id); - let object = self.build_plot_object(*dataset_index, frame, id, object_name); - let canvas = self.doc.canvases.get_mut(*ci).unwrap(); - canvas.next_object_id = canvas.next_object_id.max(id.checked_advance(1)); - canvas.objects.push(object); - self.session.active_canvas = Some(*ci); - } else { - if *canvas_index != self.doc.canvases.len() { - return; - } - let mut canvas = crate::workflow::build_default_canvas_for_dataset( - &self.doc.datasets[*dataset_index], - *dataset_index, - canvas_name.clone(), - *size_mm, - ); - canvas.resource_id.clone_from(canvas_resource_id); - canvas.board_pos = crate::state::next_page_board_pos(self); - for object in &mut canvas.objects { - if let Some(plot) = object.plot_mut() { - plot.figure.typography = self.doc.style_library.figure_typography; - } - } - self.doc.canvases.push(canvas); - self.rebuild_canvases_for(*dataset_index); - self.session.active_canvas = Some(*canvas_index); - } - self.focus_single(*dataset_index); - self.session.view = PrimaryView::Canvas; - } - Action::TransferObjects { .. } => self.apply_transfer(action), - Action::TileDrop { .. } => self.apply_tile_drop(action), - } - } - pub fn set_object_frame(&mut self, canvas: usize, object: ObjectId, frame: ObjectFrame) { let Some(o) = self .doc diff --git a/crates/core/src/actions/app_impl/processing.rs b/crates/core/src/actions/app_impl/processing.rs index 0c6c3282..ae0f00ce 100644 --- a/crates/core/src/actions/app_impl/processing.rs +++ b/crates/core/src/actions/app_impl/processing.rs @@ -39,9 +39,17 @@ impl PlotxApp { n.pipeline = pipeline.clone(); n.group_delay_correct = *group_delay_correct; } - (Dataset::Nmr2D(n), DatasetProcessingState::Nmr2D { params, preset }) => { + ( + Dataset::Nmr2D(n), + DatasetProcessingState::Nmr2D { + params, + preset, + group_delay_correct, + }, + ) => { n.params = params.clone(); n.preset = *preset; + n.group_delay_correct = *group_delay_correct; } _ => {} } @@ -52,12 +60,20 @@ impl PlotxApp { /// the recipes, not by the caller. Lives beside the pause gate because it is /// the other half of it: this is what "not paused" does. pub fn set_dataset_processing_state(&mut self, dataset: usize, state: &DatasetProcessingState) { - if let (Some(Dataset::Nmr2D(current)), DatasetProcessingState::Nmr2D { params, preset }) = - (self.doc.datasets.get_mut(dataset), state) + if let ( + Some(Dataset::Nmr2D(current)), + DatasetProcessingState::Nmr2D { + params, + preset, + group_delay_correct, + }, + ) = (self.doc.datasets.get_mut(dataset), state) { + let force_full = current.group_delay_correct != *group_delay_correct; current.params = params.clone(); current.preset = *preset; - self.schedule_2d_processing(dataset, false); + current.group_delay_correct = *group_delay_correct; + self.schedule_2d_processing(dataset, force_full); return; } let Some(current) = self.doc.datasets.get_mut(dataset) else { diff --git a/crates/core/src/actions/arrange.rs b/crates/core/src/actions/arrange.rs index 970fb8fa..26168c19 100644 --- a/crates/core/src/actions/arrange.rs +++ b/crates/core/src/actions/arrange.rs @@ -109,9 +109,7 @@ impl PlotxApp { return; }; let before = self.doc.canvases[ci].layout; - let mut after = before; - after.spacing_mode = mode; - self.commit_page_layout(ci, before, after); + self.execute_action(Action::set_spacing_mode(ci, before, mode)); } pub fn set_gutter_preset(&mut self, preset: crate::layout::GutterPreset) { @@ -270,14 +268,34 @@ impl PlotxApp { } } + /// Toggle content-driven page height without creating an undo step. + /// + /// Auto height was historically a live page preference rather than an + /// action. Keeping the mutation beside `set_show_grid` makes the catalog + /// path preserve that boundary. + pub fn set_canvas_auto_height(&mut self, canvas: usize, enabled: bool) { + if let Some(c) = self.doc.canvases.get_mut(canvas) + && c.auto_height != enabled + { + c.auto_height = enabled; + self.doc.dirty = true; + } + } + pub fn set_snap_enabled(&mut self, enabled: bool) { - self.session.ui.snap_enabled = enabled; - if !enabled { - self.session.ui.snap_guides.clear(); + let target = self.app_target(); + match self.plan_property_write( + crate::properties::app_preferences::SNAP_ENABLED, + std::slice::from_ref(&target), + &crate::properties::PropertyValue::Bool(enabled), + ) { + Ok(commit) => { + self.commit_property(commit); + } + Err(error) => { + self.session.status = format!("Could not change object snapping: {error}"); + } } - self.settings.export.include_view_snapshots = self.doc.save_include_view_snapshots; - self.settings.general.snap_enabled = enabled; - self.persist_settings(); } } @@ -296,11 +314,11 @@ fn layout_items( .find_map(|(candidate, frame)| (*candidate == id).then_some(*frame)) .unwrap_or(object.frame); if let Some(change) = axis_changes.iter().find(|change| change.id == id) { - let mut figure = plot.figure.clone(); + let mut figure = plot.figure().clone(); change.after.apply_to(&mut figure); Some(crate::layout::layout_item(id, &figure, frame)) } else { - Some(crate::layout::layout_item(id, &plot.figure, frame)) + Some(crate::layout::layout_item(id, plot.figure(), frame)) } }) .collect() diff --git a/crates/core/src/actions/build.rs b/crates/core/src/actions/build.rs index e9ebd59c..fbd698e4 100644 --- a/crates/core/src/actions/build.rs +++ b/crates/core/src/actions/build.rs @@ -151,6 +151,16 @@ impl Action { } } + pub fn set_spacing_mode( + canvas: usize, + before: PageLayout, + mode: crate::layout::SpacingMode, + ) -> Self { + let mut after = before; + after.spacing_mode = mode; + Self::set_page_layout(canvas, before, after) + } + pub fn set_panel_meta( canvas: usize, object: ObjectId, diff --git a/crates/core/src/actions/mod.rs b/crates/core/src/actions/mod.rs index 3e8131af..4b4fb65e 100644 --- a/crates/core/src/actions/mod.rs +++ b/crates/core/src/actions/mod.rs @@ -31,6 +31,7 @@ pub enum DatasetProcessingState { Nmr2D { params: Params2D, preset: Preset2D, + group_delay_correct: bool, }, /// A table has no reversible processing recipe; its curve fits are edited /// through their own actions. @@ -63,6 +64,16 @@ impl PageSizeState { preset_id: canvas.size_preset_id.clone(), } } + + /// Reconcile a manually edited physical size with the preset identity that + /// preceded it. The identity survives only while the new dimensions still + /// describe that preset. + pub fn after_manual_resize(&self, size_mm: [f32; 2]) -> Self { + let preset_id = self.preset_id.clone().filter(|id| { + crate::state::preset_by_id(id).is_some_and(|preset| preset.matches(size_mm)) + }); + Self { size_mm, preset_id } + } } #[derive(Clone)] @@ -109,14 +120,11 @@ pub struct PendingPropertyGesture { pub owns_processing_session: bool, } -/// Coalesces a single object-inspector interaction (a DragValue drag, a colour -/// pick, a text edit) into one undo step: the pre-edit frames and styles of the -/// touched objects, committed once the interaction ends. +/// Coalesces an object-inspector geometry interaction into one undo step. #[derive(Clone)] pub struct PendingInspectorEdit { pub canvas: usize, pub frames: Vec<(ObjectId, ObjectFrame)>, - pub styles: Vec<(ObjectId, ObjectStyle)>, } #[derive(Clone)] diff --git a/crates/core/src/actions/processing_state.rs b/crates/core/src/actions/processing_state.rs index 9fe1b442..b0717036 100644 --- a/crates/core/src/actions/processing_state.rs +++ b/crates/core/src/actions/processing_state.rs @@ -2,6 +2,20 @@ use super::*; use plotx_processing::ProcessingStep; impl DatasetProcessingState { + pub(crate) fn group_delay_correct_mut(&mut self) -> Option<&mut bool> { + match self { + Self::Nmr { + group_delay_correct, + .. + } + | Self::Nmr2D { + group_delay_correct, + .. + } => Some(group_delay_correct), + Self::Table | Self::Electrophysiology(_) | Self::Afm => None, + } + } + pub fn from_dataset(dataset: &Dataset) -> Self { match dataset { Dataset::Nmr(n) => Self::Nmr { @@ -11,6 +25,7 @@ impl DatasetProcessingState { Dataset::Nmr2D(n) => Self::Nmr2D { params: n.params.clone(), preset: n.preset, + group_delay_correct: n.group_delay_correct, }, Dataset::Table(_) => Self::Table, Dataset::Electrophysiology(d) => Self::Electrophysiology(d.processing), @@ -70,11 +85,20 @@ impl DatasetProcessingState { n.recompute_integrals(); Ok(rebuild) } - (Dataset::Nmr2D(n), Self::Nmr2D { params, preset }) => { + ( + Dataset::Nmr2D(n), + Self::Nmr2D { + params, + preset, + group_delay_correct, + }, + ) => { let full = plotx_processing::needs_retransform_2d(params, &n.params); + let full = full || *group_delay_correct != n.group_delay_correct; n.params = params.clone(); n.repair_step_allocator(); n.preset = *preset; + n.group_delay_correct = *group_delay_correct; if full { n.retransform(); Ok(ProcessingRebuild::Retransformed) diff --git a/crates/core/src/actions/tests/authoring.rs b/crates/core/src/actions/tests/authoring.rs index a30778a4..af940f6e 100644 --- a/crates/core/src/actions/tests/authoring.rs +++ b/crates/core/src/actions/tests/authoring.rs @@ -68,7 +68,7 @@ fn apply_theme_changes_background_and_text_colour_reversibly() { ); assert_eq!(app.doc.style_library.text.color, theme.text_color); assert_eq!( - first_plot(&app).figure.series[0].color, + first_plot(&app).figure().series[0].color, theme.trace_palette[0] ); @@ -96,11 +96,14 @@ fn apply_theme_restyles_figure_typography_on_every_plot() { app.doc.style_library.figure_typography, theme.figure_typography ); - assert_eq!(first_plot(&app).figure.typography, theme.figure_typography); + assert_eq!( + first_plot(&app).figure().typography, + theme.figure_typography + ); app.undo(); assert_eq!(app.doc.style_library.figure_typography, before); - assert_eq!(first_plot(&app).figure.typography, before); + assert_eq!(first_plot(&app).figure().typography, before); } #[test] @@ -116,22 +119,22 @@ fn set_figure_typography_restamps_plots_and_is_undoable() { app.execute_action(Action::set_figure_typography(before, after)); assert_eq!(app.doc.style_library.figure_typography, after); - assert_eq!(first_plot(&app).figure.typography, after); + assert_eq!(first_plot(&app).figure().typography, after); app.undo(); assert_eq!(app.doc.style_library.figure_typography, before); - assert_eq!(first_plot(&app).figure.typography, before); + assert_eq!(first_plot(&app).figure().typography, before); app.redo(); - assert_eq!(first_plot(&app).figure.typography, after); + assert_eq!(first_plot(&app).figure().typography, after); } #[test] fn axis_overrides_survive_rebuild_and_roundtrip_through_undo() { let mut app = sample_app(); let object = app.doc.canvases[0].objects[0].id; - let automatic_x_label = first_plot(&app).figure.x.label.clone(); - let automatic_y_label = first_plot(&app).figure.y.label.clone(); + let automatic_x_label = first_plot(&app).figure().x.label.clone(); + let automatic_y_label = first_plot(&app).figure().y.label.clone(); let automatic_x = first_plot(&app).viewport.full_x; let automatic_y = first_plot(&app).viewport.full_y; let before = AxisOverrides::default(); @@ -150,8 +153,8 @@ fn axis_overrides_survive_rebuild_and_roundtrip_through_undo() { after.clone(), )); assert_eq!(first_plot(&app).axis_overrides, after); - assert_eq!(first_plot(&app).figure.x.label, "Chemical shift"); - assert_eq!(first_plot(&app).figure.y.label, "Response"); + assert_eq!(first_plot(&app).figure().x.label, "Chemical shift"); + assert_eq!(first_plot(&app).figure().y.label, "Response"); assert_eq!(first_plot(&app).viewport.full_x, AxisRange::new(1.0, 8.0)); assert_eq!(first_plot(&app).viewport.full_y, AxisRange::new(-2.0, 12.0)); assert!(!first_plot(&app).viewport.auto_y); @@ -160,7 +163,7 @@ fn axis_overrides_survive_rebuild_and_roundtrip_through_undo() { { let plot = app.doc.canvases[0].objects[0].plot_mut().unwrap(); plot.viewport.view_x = zoomed_x; - plot.viewport.apply_to(&mut plot.figure); + plot.apply_viewport(); } app.rebuild_canvases_for(0); assert_eq!(first_plot(&app).axis_overrides, after); @@ -169,8 +172,8 @@ fn axis_overrides_survive_rebuild_and_roundtrip_through_undo() { app.undo(); assert_eq!(first_plot(&app).axis_overrides, before); - assert_eq!(first_plot(&app).figure.x.label, automatic_x_label); - assert_eq!(first_plot(&app).figure.y.label, automatic_y_label); + assert_eq!(first_plot(&app).figure().x.label, automatic_x_label); + assert_eq!(first_plot(&app).figure().y.label, automatic_y_label); assert_eq!(first_plot(&app).viewport.full_x, automatic_x); assert_eq!(first_plot(&app).viewport.full_y, automatic_y); assert!(first_plot(&app).viewport.auto_y); diff --git a/crates/core/src/actions/tests/integral_curve.rs b/crates/core/src/actions/tests/integral_curve.rs index eef68c09..4e7885b0 100644 --- a/crates/core/src/actions/tests/integral_curve.rs +++ b/crates/core/src/actions/tests/integral_curve.rs @@ -29,7 +29,7 @@ fn set_integrals_apply_undo_redo_keeps_all_primary_figures_synced() { vec![integral], )); assert!(app.doc.canvases.iter().all(|canvas| { - let curve = &canvas.objects[0].plot().unwrap().figure.integral_curves; + let curve = &canvas.objects[0].plot().unwrap().figure().integral_curves; curve.len() == 1 && curve[0].label == "3.000" })); @@ -38,7 +38,7 @@ fn set_integrals_apply_undo_redo_keeps_all_primary_figures_synced() { canvas.objects[0] .plot() .unwrap() - .figure + .figure() .integral_curves .is_empty() })); @@ -47,7 +47,7 @@ fn set_integrals_apply_undo_redo_keeps_all_primary_figures_synced() { canvas.objects[0] .plot() .unwrap() - .figure + .figure() .integral_curves .len() == 1 @@ -79,7 +79,7 @@ fn cancelling_live_integral_edit_restores_curve_description() { app.doc.canvases[0].objects[0] .plot() .unwrap() - .figure + .figure() .integral_curves[0] .label, "9.000" @@ -91,7 +91,7 @@ fn cancelling_live_integral_edit_restores_curve_description() { app.doc.canvases[0].objects[0] .plot() .unwrap() - .figure + .figure() .integral_curves[0] .label, "3.000" @@ -173,7 +173,7 @@ fn lightweight_sync_respects_hidden_primary_series() { app.set_integrals(0, &[sample_integral(2, 3.0, Some(3.0))]); let plot = app.doc.canvases[0].objects[0].plot_mut().unwrap(); plot.binding.series[0].visible = false; - assert_eq!(plot.figure.integral_curves.len(), 1); + assert_eq!(plot.figure().integral_curves.len(), 1); app.sync_integral_curves_for(0); @@ -181,7 +181,7 @@ fn lightweight_sync_respects_hidden_primary_series() { app.doc.canvases[0].objects[0] .plot() .unwrap() - .figure + .figure() .integral_curves .is_empty() ); @@ -202,7 +202,7 @@ fn one_dimensional_processing_commit_recomputes_integral_and_curve() { let curve = &app.doc.canvases[0].objects[0] .plot() .unwrap() - .figure + .figure() .integral_curves[0]; assert_eq!(curve.normalized_area, recomputed.normalized_area); assert_eq!(curve.start_ppm, recomputed.start_ppm); @@ -251,7 +251,7 @@ fn processing_action_apply_undo_and_redo_recompute_integrals() { app.doc.canvases[0].objects[0] .plot() .unwrap() - .figure + .figure() .integral_curves[0] .label, "3.000" @@ -278,7 +278,7 @@ fn reference_accepts_arbitrary_target_without_plot_marker() { let curve = &app.doc.canvases[0].objects[0] .plot() .unwrap() - .figure + .figure() .integral_curves[0]; assert_eq!(curve.label, "100.000"); assert_eq!(curve.color, plotx_figure::Color::rgb(0x2b, 0x6c, 0xb0)); diff --git a/crates/core/src/actions/tests/linefit.rs b/crates/core/src/actions/tests/linefit.rs index 13e85703..43fe6677 100644 --- a/crates/core/src/actions/tests/linefit.rs +++ b/crates/core/src/actions/tests/linefit.rs @@ -131,7 +131,7 @@ fn run_line_fit_stores_inline_result_and_materializes_on_request() { let series_names: Vec<&str> = app.doc.canvases[0].objects[0] .plot() .unwrap() - .figure + .figure() .series .iter() .map(|s| s.name.as_str()) @@ -149,7 +149,7 @@ fn run_line_fit_stores_inline_result_and_materializes_on_request() { app.doc.canvases[0].objects[0] .plot() .unwrap() - .figure + .figure() .series .len(), 1 diff --git a/crates/core/src/actions/tests/mod.rs b/crates/core/src/actions/tests/mod.rs index 9b29299f..084bda15 100644 --- a/crates/core/src/actions/tests/mod.rs +++ b/crates/core/src/actions/tests/mod.rs @@ -198,7 +198,7 @@ fn processing_undo_redo_rebuilds_spectrum_and_canvas() { }; let mut app = sample_app(); - let original_y = first_plot(&app).figure.series[0].points[3][1]; + let original_y = first_plot(&app).figure().series[0].points[3][1]; let (_, original_auto) = phase0_of(&app); let before = DatasetProcessingState::from_dataset(&app.doc.datasets[0]); let mut after = before.clone(); @@ -218,17 +218,17 @@ fn processing_undo_redo_rebuilds_spectrum_and_canvas() { before, after, )); - let edited_y = first_plot(&app).figure.series[0].points[3][1]; + let edited_y = first_plot(&app).figure().series[0].points[3][1]; assert_ne!(edited_y, original_y); assert_eq!(phase0_of(&app), (0.5, false)); app.undo(); assert_eq!(phase0_of(&app).1, original_auto); - assert_eq!(first_plot(&app).figure.series[0].points[3][1], original_y); + assert_eq!(first_plot(&app).figure().series[0].points[3][1], original_y); app.redo(); assert_eq!(phase0_of(&app), (0.5, false)); - assert_eq!(first_plot(&app).figure.series[0].points[3][1], edited_y); + assert_eq!(first_plot(&app).figure().series[0].points[3][1], edited_y); } #[test] @@ -315,15 +315,15 @@ fn viewport_undo_redo_keeps_figure_axes_in_sync() { after.clone(), )); assert_eq!(first_plot(&app).viewport.view_x, after.view_x); - assert_eq!(first_plot(&app).figure.x.min, after.view_x.min); + assert_eq!(first_plot(&app).figure().x.min, after.view_x.min); app.undo(); assert_eq!(first_plot(&app).viewport.view_x, before.view_x); - assert_eq!(first_plot(&app).figure.x.min, before.view_x.min); + assert_eq!(first_plot(&app).figure().x.min, before.view_x.min); app.redo(); assert_eq!(first_plot(&app).viewport.view_x, after.view_x); - assert_eq!(first_plot(&app).figure.x.max, after.view_x.max); + assert_eq!(first_plot(&app).figure().x.max, after.view_x.max); } fn size_state(size_mm: [f32; 2], preset_id: Option<&str>) -> PageSizeState { diff --git a/crates/core/src/actions/tests/more.rs b/crates/core/src/actions/tests/more.rs index 706a52ee..9a9e4cf0 100644 --- a/crates/core/src/actions/tests/more.rs +++ b/crates/core/src/actions/tests/more.rs @@ -25,7 +25,7 @@ fn stacked_binding_builds_distinctly_coloured_series_with_legend() { binding, )); - let fig = &first_plot(&app).figure; + let fig = first_plot(&app).figure(); assert!(fig.series.len() >= 2, "stack should draw both traces"); assert!(fig.show_legend, "stack should show a legend"); assert_ne!( @@ -35,7 +35,7 @@ fn stacked_binding_builds_distinctly_coloured_series_with_legend() { app.undo(); assert_eq!(first_plot(&app).binding.series.len(), 1); - assert!(!first_plot(&app).figure.show_legend); + assert!(!first_plot(&app).figure().show_legend); } #[test] @@ -99,7 +99,7 @@ fn set_chart_type_switches_table_to_categorical_bars_and_undoes() { let before = first_plot(&app).chart.clone(); assert_eq!(before.type_id, "table_line"); - let line_series = first_plot(&app).figure.series.len(); + let line_series = first_plot(&app).figure().series.len(); assert_eq!(line_series, 1, "line chart draws one series per column"); let overrides = AxisOverrides { x_range: Some(AxisRange::new(1.0, 8.0)), @@ -114,20 +114,20 @@ fn set_chart_type_switches_table_to_categorical_bars_and_undoes() { app.execute_action(Action::set_chart_type(0, id, before, after.clone())); assert_eq!(first_plot(&app).chart, after); // One-series grouped bars draw one filled rectangle per categorical row. - assert_eq!(first_plot(&app).figure.polygons.len(), 3); - assert!(first_plot(&app).figure.x.categories.is_some()); - assert_eq!(first_plot(&app).figure.x.min, -0.5); - assert_eq!(first_plot(&app).figure.x.max, 2.5); + assert_eq!(first_plot(&app).figure().polygons.len(), 3); + assert!(first_plot(&app).figure().x.categories.is_some()); + assert_eq!(first_plot(&app).figure().x.min, -0.5); + assert_eq!(first_plot(&app).figure().x.max, 2.5); assert_eq!(first_plot(&app).axis_overrides.x_range, overrides.x_range); app.undo(); assert_eq!(first_plot(&app).chart.type_id, "table_line"); - assert_eq!(first_plot(&app).figure.series.len(), line_series); + assert_eq!(first_plot(&app).figure().series.len(), line_series); assert_eq!(first_plot(&app).viewport.full_x, AxisRange::new(1.0, 8.0)); app.redo(); assert_eq!(first_plot(&app).chart.type_id, "table_bar_grouped"); - assert_eq!(first_plot(&app).figure.polygons.len(), 3); + assert_eq!(first_plot(&app).figure().polygons.len(), 3); } #[test] @@ -191,7 +191,7 @@ fn axis_projections_attach_and_project_survive_undo() { after: after.clone(), }); - let fig = &app.doc.canvases[ci].objects[0].plot().unwrap().figure; + let fig = app.doc.canvases[ci].objects[0].plot().unwrap().figure(); let top = fig.top_projection.as_ref().expect("attached top trace"); let left = fig.left_projection.as_ref().expect("sum left trace"); let expected = app.doc.datasets[0].as_nmr().unwrap().spectrum.ppm.len(); @@ -207,7 +207,7 @@ fn axis_projections_attach_and_project_survive_undo() { ); app.undo(); - let fig = &app.doc.canvases[ci].objects[0].plot().unwrap().figure; + let fig = app.doc.canvases[ci].objects[0].plot().unwrap().figure(); assert!(fig.top_projection.is_none() && fig.left_projection.is_none()); } diff --git a/crates/core/src/automation/properties.rs b/crates/core/src/automation/properties.rs index 1907cf73..77990072 100644 --- a/crates/core/src/automation/properties.rs +++ b/crates/core/src/automation/properties.rs @@ -16,9 +16,9 @@ use super::registry::parse; use super::*; use crate::properties::{ - AggregateValue, Availability, EnumVariant, FloatBounds, PropertyAccess, PropertyAddress, - PropertyDefinition, PropertyError, PropertySkip, PropertyValue, ResolvedProperty, - ResolvedSchema, ValueSchema, definition_by_key, variant_list, + AggregateValue, Availability, EnumVariant, FloatBounds, FloatDisplay, PropertyAccess, + PropertyAddress, PropertyDefinition, PropertyError, PropertySkip, PropertyValue, + ResolvedProperty, ResolvedSchema, ValueSchema, definition_by_key, variant_list, }; use crate::state::PlotxApp; use serde::{Deserialize, Serialize}; @@ -337,7 +337,11 @@ fn decode_value( .as_bool() .map(PropertyValue::Bool) .ok_or_else(|| invalid(format!("expected true or false, got {}", json_kind(value)))), - ValueSchema::Int { min, max } => { + ValueSchema::Text => value + .as_str() + .map(|value| PropertyValue::Text(value.to_owned())) + .ok_or_else(|| invalid(format!("expected text, got {}", json_kind(value)))), + ValueSchema::Int { min, max } | ValueSchema::IntWithDrag { min, max, .. } => { let number = value .as_i64() .ok_or_else(|| invalid(format!("expected an integer, got {}", json_kind(value))))?; @@ -348,6 +352,22 @@ fn decode_value( } Ok(PropertyValue::Int(number)) } + ValueSchema::SteppedInt { min, max, step, .. } => { + if step <= 0 { + return Err(invalid(format!( + "internal schema error: integer step must be positive, got {step}" + ))); + } + let number = value + .as_i64() + .ok_or_else(|| invalid(format!("expected an integer, got {}", json_kind(value))))?; + if number < min || number > max || (number - min) % step != 0 { + return Err(invalid(format!( + "{number} is out of range: it must be between {min} and {max} in steps of {step}" + ))); + } + Ok(PropertyValue::Int(number)) + } ValueSchema::Float { bounds, .. } => { let number = value .as_f64() @@ -460,6 +480,8 @@ struct ReadingDto { #[serde(skip_serializing_if = "Option::is_none")] default_value: Option, availability: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + disabled_reason: Option<&'static str>, modified: bool, schema: ResolvedSchemaDto, } @@ -476,7 +498,10 @@ enum AggregateValueDto { #[serde(tag = "type", content = "value", rename_all = "snake_case")] enum PropertyValueDto { Bool(bool), + Text(String), Int(i64), + /// Domain value, even when the resolved UI schema displays degrees or a + /// logarithm. Phase values therefore remain radians on the wire. Float(f64), Enum(&'static str), Color(String), @@ -486,16 +511,36 @@ enum PropertyValueDto { #[serde(tag = "type", rename_all = "snake_case")] enum ResolvedSchemaDto { Bool, + Text, Int { min: i64, max: i64, + #[serde(skip_serializing_if = "Option::is_none")] + drag_step: Option, + #[serde(skip_serializing_if = "str::is_empty")] + unit: &'static str, + }, + SteppedInt { + min: i64, + max: i64, + step: i64, + drag_step: f64, + #[serde(skip_serializing_if = "str::is_empty")] + unit: &'static str, }, Float { + // Bounds validate the domain value carried by `PropertyValueDto::Float`. + // `display` and `unit` describe only a UI projection of that value. min: f64, max: f64, exclusive_min: bool, + #[serde(skip_serializing_if = "Option::is_none")] + excluded: Option, + #[serde(skip_serializing_if = "Option::is_none")] + excluded_magnitude: Option, log: bool, unit: &'static str, + display: &'static str, }, Enum { variants: Vec, @@ -513,11 +558,16 @@ fn reading(resolved: &ResolvedProperty) -> Result { Ok(ReadingDto { target: resolved.address.target.clone(), value: aggregate_dto(&resolved.value), - default_value: resolved.default_value.map(value_dto), + default_value: resolved.default_value.as_ref().map(value_dto), availability: match resolved.availability { Availability::Editable => "editable", + Availability::Disabled(_) => "disabled", Availability::ReadOnly => "read_only", }, + disabled_reason: match resolved.availability { + Availability::Disabled(reason) => Some(reason), + Availability::Editable | Availability::ReadOnly => None, + }, modified: resolved.is_modified(), schema: schema_dto(&resolved.schema), }) @@ -526,18 +576,19 @@ fn reading(resolved: &ResolvedProperty) -> Result { fn aggregate_dto(value: &AggregateValue) -> AggregateValueDto { match value { AggregateValue::Uniform(value) => AggregateValueDto::Uniform { - value: value_dto(*value), + value: value_dto(value), }, AggregateValue::Mixed => AggregateValueDto::Mixed, AggregateValue::Unavailable => AggregateValueDto::Unavailable, } } -fn value_dto(value: PropertyValue) -> PropertyValueDto { +fn value_dto(value: &PropertyValue) -> PropertyValueDto { match value { - PropertyValue::Bool(value) => PropertyValueDto::Bool(value), - PropertyValue::Int(value) => PropertyValueDto::Int(value), - PropertyValue::Float(value) => PropertyValueDto::Float(value), + PropertyValue::Bool(value) => PropertyValueDto::Bool(*value), + PropertyValue::Text(value) => PropertyValueDto::Text(value.clone()), + PropertyValue::Int(value) => PropertyValueDto::Int(*value), + PropertyValue::Float(value) => PropertyValueDto::Float(*value), PropertyValue::Enum(value) => PropertyValueDto::Enum(value), PropertyValue::Color(value) => PropertyValueDto::Color(value.to_hex()), } @@ -546,11 +597,38 @@ fn value_dto(value: PropertyValue) -> PropertyValueDto { fn schema_dto(schema: &ResolvedSchema) -> ResolvedSchemaDto { match schema { ResolvedSchema::Bool => ResolvedSchemaDto::Bool, - ResolvedSchema::Int { min, max } => ResolvedSchemaDto::Int { + ResolvedSchema::Text => ResolvedSchemaDto::Text, + ResolvedSchema::Int { min, max, unit } => ResolvedSchemaDto::Int { + min: *min, + max: *max, + drag_step: None, + unit, + }, + ResolvedSchema::IntWithDrag { + min, + max, + drag_step, + unit, + } => ResolvedSchemaDto::Int { + min: *min, + max: *max, + drag_step: Some(*drag_step), + unit, + }, + ResolvedSchema::SteppedInt { + min, + max, + step, + drag_step, + unit, + } => ResolvedSchemaDto::SteppedInt { min: *min, max: *max, + step: *step, + drag_step: *drag_step, + unit, }, - ResolvedSchema::Float { bounds, log, unit } => float_schema_dto(*bounds, *log, unit), + ResolvedSchema::Float { bounds, display } => float_schema_dto(*bounds, *display), ResolvedSchema::Enum { variants } => ResolvedSchemaDto::Enum { variants: variants.iter().copied().map(variant_dto).collect(), }, @@ -558,13 +636,16 @@ fn schema_dto(schema: &ResolvedSchema) -> ResolvedSchemaDto { } } -fn float_schema_dto(bounds: FloatBounds, log: bool, unit: &'static str) -> ResolvedSchemaDto { +fn float_schema_dto(bounds: FloatBounds, display: FloatDisplay) -> ResolvedSchemaDto { ResolvedSchemaDto::Float { min: bounds.min, max: bounds.max, exclusive_min: bounds.exclusive_min, - log, - unit, + excluded: bounds.excluded, + excluded_magnitude: bounds.excluded_magnitude, + log: matches!(display, FloatDisplay::Log10(_)), + unit: display.unit(), + display: display.as_str(), } } diff --git a/crates/core/src/automation/properties_tests.rs b/crates/core/src/automation/properties_tests.rs index 4ead83d8..2d85ed32 100644 --- a/crates/core/src/automation/properties_tests.rs +++ b/crates/core/src/automation/properties_tests.rs @@ -8,8 +8,8 @@ use super::*; use crate::properties::ilt_tests::ilt_app; use crate::properties::tests::{contour_app, contour_spec}; use crate::properties::{ - AggregateValue, PropertyAddress, PropertyValue, apodization, contour, definition_by_key, ilt, - typography, + AggregateValue, PropertyAddress, PropertyValue, apodization, app_preferences, axis, contour, + definition_by_key, ilt, phase, smooth, typography, }; use crate::state::{ CONTOUR_BASE_FRACTION_OF_RANGE, CONTOUR_BASE_NOISE_FLOOR, CanvasObject, CanvasObjectKind, @@ -70,665 +70,13 @@ fn add_line_series(app: &mut PlotxApp) { plot.binding.series.push(series); } -#[test] -fn automation_inspects_stored_ilt_provenance_and_refuses_set_and_reset_as_read_only() { - let (mut app, target) = ilt_app(0.07); - let id = target.resource.id.clone(); - let inspect = request( - &app, - TOOL_INSPECT, - serde_json::json!({"key": ilt::RESULT_LAMBDA.as_str()}), - vec![id.clone()], - CallerType::Agent, - ); - let result = run(&mut app, inspect).expect("properties.inspect reads provenance"); - let value = result.value.to_string(); - assert!(value.contains("0.07"), "{value}"); - assert!(value.contains("read_only"), "{value}"); - - for (tool, parameters) in [ - ( - TOOL_SET, - serde_json::json!({"key": ilt::RESULT_LAMBDA.as_str(), "value": 0.2}), - ), - ( - TOOL_RESET, - serde_json::json!({"key": ilt::RESULT_LAMBDA.as_str()}), - ), - ] { - let error = plan_tool( - &app, - request(&app, tool, parameters, vec![id.clone()], CallerType::Agent), - ) - .expect_err("non-inspect automation must refuse read-only provenance"); - let message = error.to_string(); - assert!(message.contains("read-only"), "{message}"); - assert!(message.contains(ilt::RESULT_LAMBDA.as_str()), "{message}"); - } -} - -#[test] -fn an_unknown_property_key_is_refused_rather_than_skipped() { - let (mut app, _) = contour_app(); - let error = plan_tool( - &app, - set_request(&app, "series.contour.nonexistent", serde_json::json!(3)), - ) - .expect_err("an unknown key cannot be planned"); - let message = error.to_string(); - assert!( - message.contains("unknown property 'series.contour.nonexistent'"), - "{message}" - ); - // And it never reaches execution, so nothing is silently committed. - let before = app.doc.automation_revision; - let request = set_request(&app, "series.contour.nonexistent", serde_json::json!(3)); - assert!(run(&mut app, request).is_err()); - assert_eq!(app.doc.automation_revision, before); -} - -/// A value outside the declared bound must report both the value that was -/// rejected and the bound that rejected it. -#[test] -fn an_out_of_range_value_names_the_value_and_the_bound() { - let (app, _) = contour_app(); - let error = plan_tool( - &app, - set_request(&app, contour::RATIO.as_str(), serde_json::json!(50.0)), - ) - .expect_err("50 is above the declared ratio bound"); - let message = error.to_string(); - assert!( - message.contains("50"), - "the rejected value is named: {message}" - ); - assert!( - message.contains("greater than 1") && message.contains("at most 10"), - "the bound is named: {message}" - ); -} - -/// The bound a *context-dependent* schema imposes is enforced by the shared -/// planner, not by the adapter, and it too has to name both numbers. The -/// definition's static bound admits this value; only the target's current -/// anchor rejects it. -#[test] -fn a_bound_that_only_the_anchor_knows_still_names_the_value() { - let (mut app, target) = contour_app(); - assert!(matches!( - contour_spec(&app, &target).positive.base, - plotx_figure::ContourBasePolicy::NoiseFloor { .. } - )); - let request = set_request( - &app, - contour::BASE_MAGNITUDE.as_str(), - serde_json::json!(1.0e9), - ); - let error = - run(&mut app, request).expect_err("a multiplier of 1e9 is beyond what the anchor accepts"); - let message = error.to_string(); - assert!( - message.contains("1000000000"), - "the rejected value is named: {message}" - ); - assert!( - message.contains("10000"), - "the anchor's own bound is named: {message}" - ); -} - -/// A string that names no choice at all is a wire-format error, and it lists -/// the choices the setting has. -#[test] -fn an_unknown_enum_choice_lists_the_settings_options() { - let (app, _) = contour_app(); - let error = plan_tool( - &app, - set_request( - &app, - contour::BASE_POLICY.as_str(), - serde_json::json!("dark_magic"), - ), - ) - .expect_err("'dark_magic' is not a base policy"); - let message = error.to_string(); - assert!(message.contains("dark_magic"), "{message}"); - assert!( - message.contains(CONTOUR_BASE_NOISE_FLOOR) - && message.contains(CONTOUR_BASE_FRACTION_OF_RANGE), - "every declared choice is listed: {message}" - ); -} - -/// A choice the setting has but this field's capabilities withhold is refused -/// by the planner, and the refusal lists what the field does allow. The fixture -/// draws a signed plane, which is exactly the case where a fraction of the -/// value range is meaningless. -#[test] -fn a_capability_withheld_choice_lists_what_the_field_allows() { - let (mut app, _) = contour_app(); - let request = set_request( - &app, - contour::BASE_POLICY.as_str(), - serde_json::json!(CONTOUR_BASE_FRACTION_OF_RANGE), - ); - let error = - run(&mut app, request).expect_err("a signed field withholds the fraction-of-range anchor"); - let message = error.to_string(); - assert!( - message.contains(CONTOUR_BASE_FRACTION_OF_RANGE), - "{message}" - ); - assert!( - message.contains("this field allows") && message.contains(CONTOUR_BASE_NOISE_FLOOR), - "the permitted choices are named: {message}" - ); -} - -#[test] -fn a_value_of_the_wrong_shape_is_refused() { - let (app, _) = contour_app(); - let error = plan_tool( - &app, - set_request(&app, contour::COUNT.as_str(), serde_json::json!(true)), - ) - .expect_err("a count is not a boolean"); - assert!(error.to_string().contains("expected an integer"), "{error}"); -} - -/// A target the property does not apply to is reported with its reason, and the -/// one it does apply to still lands. -#[test] -fn a_skipped_component_is_reported_not_dropped() { - let (mut app, _) = contour_app(); - add_line_series(&mut app); - let request = set_request(&app, contour::COUNT.as_str(), serde_json::json!(9)); - let result = run(&mut app, request).expect("the contour series accepts the write"); - let succeeded = result - .targets - .iter() - .filter(|target| target.outcome == TargetOutcome::Succeeded) - .collect::>(); - let skipped = result - .targets - .iter() - .filter(|target| target.outcome == TargetOutcome::Skipped) - .collect::>(); - assert_eq!(succeeded.len(), 1, "{:?}", result.targets); - assert_eq!(skipped.len(), 1, "{:?}", result.targets); - assert!( - skipped[0].message.contains("line"), - "the skip names the encoding that caused it: {}", - skipped[0].message - ); - // The two rows must be distinguishable, or a results panel shows one plot - // object twice with nothing to tell the rows apart. - assert_ne!( - succeeded[0].target.describe(), - skipped[0].target.describe(), - "expanded targets carry their component" - ); - assert!(succeeded[0].target.describe().contains("series")); -} - -/// A validation failure leaves every target exactly as it was. A commit that -/// applied to the first series and then failed on the second would be a partial -/// landing, and the ladder of the first would silently disagree with the panel. -#[test] -fn a_validation_failure_lands_on_no_target_at_all() { - let (mut app, target) = contour_app(); - add_line_series(&mut app); - let before_revision = app.doc.automation_revision; - let before_spec = contour_spec(&app, &target); - let request = set_request(&app, contour::COUNT.as_str(), serde_json::json!(0)); - let error = run(&mut app, request).expect_err("a level count of zero is out of range"); - assert!(error.to_string().contains("out of range"), "{error}"); - assert_eq!( - app.doc.automation_revision, before_revision, - "a rejected write never advances the document" - ); - assert_eq!( - contour_spec(&app, &target), - before_spec, - "a rejected write never reaches a spec" - ); -} - -/// A canvas object with no plot binding is skipped by the shared declared -/// capability gate, with the shared reason, and needs no special case here. -#[test] -fn an_object_without_components_is_skipped_by_the_shared_gate() { - let (mut app, _) = contour_app(); - let canvas = &mut app.doc.canvases[0]; - let id = canvas.allocate_object_id(); - canvas.objects.push(CanvasObject { - id, - name: "Caption".to_owned(), - frame: ObjectFrame::new(0.0, 0.0, 20.0, 10.0), - locked: false, - visible: true, - group: None, - kind: CanvasObjectKind::Text(TextBox::label("hello".to_owned())), - }); - let text_id = format!("{}/{id}", app.doc.canvases[0].resource_id); - let plan = plan_tool( - &app, - request( - &app, - TOOL_INSPECT, - serde_json::json!({"key": contour::COUNT.as_str()}), - vec![text_id.clone()], - CallerType::Agent, - ), - ) - .expect("planning succeeds; the target is merely skipped"); - assert_eq!(plan.targets.len(), 1); - assert_eq!(plan.targets[0].status, TargetCompatibility::Skipped); - assert!( - plan.targets[0] - .reason - .contains("lacks a required kind or capability"), - "{}", - plan.targets[0].reason - ); - assert!(plan.targets[0].target.component.is_none()); -} - -#[test] -fn inspect_reads_the_value_and_reports_skips() { - let (mut app, _) = contour_app(); - add_line_series(&mut app); - let inspect = request( - &app, - TOOL_INSPECT, - serde_json::json!({"key": contour::COUNT.as_str()}), - vec![plot_resource_id(&app)], - CallerType::Agent, - ); - let result = run(&mut app, inspect).expect("inspect succeeds"); - assert_eq!(result.value["property"], contour::COUNT.as_str()); - assert_eq!(result.value["aggregate"]["state"], "uniform"); - assert_eq!(result.value["readings"].as_array().map(Vec::len), Some(1)); - assert_eq!(result.value["readings"][0]["schema"]["type"], "int"); - assert_eq!( - result - .targets - .iter() - .filter(|target| target.outcome == TargetOutcome::Skipped) - .count(), - 1, - "the line series is reported, not dropped" - ); -} - -/// A document-scoped property expands to the document root itself instead of -/// pretending it owns a series. This is the `ComponentKind::None` counterpart -/// to the existing plot-object expansion test. -#[test] -fn document_property_tools_address_the_document_root() { - let (mut app, _) = contour_app(); - let request = request( - &app, - TOOL_SET, - serde_json::json!({"key": typography::TICK_PT.as_str(), "value": 9.5}), - vec![DOCUMENT_RESOURCE_ID.to_owned()], - CallerType::Agent, - ); - let plan = plan_tool(&app, request).expect("the document property plans"); - assert_eq!(plan.targets.len(), 1); - assert_eq!(plan.targets[0].status, TargetCompatibility::Compatible); - assert!(plan.targets[0].target.component.is_none()); - let authority = plan.required_authority; - let result = execute_tool(&mut app, plan, authority).expect("the document property executes"); - assert!( - result - .targets - .iter() - .any(|target| target.outcome == TargetOutcome::Succeeded), - "the document root is reported as an applied target" - ); - assert_eq!(app.doc.style_library.figure_typography.tick_pt, 9.5); -} - -/// Dataset resources expand to their stable processing-step components. Only -/// the apodization step accepts this property; the other real pipeline steps -/// remain visible as reported skips rather than being silently omitted. -#[test] -fn dataset_property_tools_expand_processing_steps_and_report_non_apodization_skips() { - let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(NmrData { - points: (0..32) - .map(|value| num_complex::Complex64::new(f64::from(value), 0.0)) - .collect(), - domain: Domain::Time, - spectral_width_hz: 4_000.0, - observe_freq_mhz: 400.0, - carrier_ppm: 0.0, - nucleus: "1H".to_owned(), - source: "automation apodization".to_owned(), - group_delay: 0.0, - })))); - let dataset = app.doc.datasets[0].resource_id().to_string(); - let request = request( - &app, - TOOL_SET, - serde_json::json!({ - "key": apodization::KIND.as_str(), - "value": apodization::APODIZATION_EXPONENTIAL, - }), - vec![dataset], - CallerType::Agent, - ); - let plan = plan_tool(&app, request).expect("the dataset step property plans"); - let compatible = plan - .targets - .iter() - .filter(|target| target.status == TargetCompatibility::Compatible) - .collect::>(); - assert_eq!(compatible.len(), 1, "only the apodization step accepts it"); - assert!( - plan.targets - .iter() - .any(|target| target.status == TargetCompatibility::Skipped), - "the rest of the real pipeline is reported as skipped" - ); - let target = compatible[0].target.clone(); - let authority = plan.required_authority; - let result = execute_tool(&mut app, plan, authority).expect("the accepted step executes"); - assert!( - result - .targets - .iter() - .any(|target| target.outcome == TargetOutcome::Succeeded), - "the apodization component reports success" - ); - assert!( - result - .targets - .iter() - .any(|target| target.outcome == TargetOutcome::Skipped), - "the non-apodization components report their skips" - ); - assert_eq!( - app.resolve_property(&PropertyAddress::new(target, apodization::KIND)) - .expect("the stable step target still resolves") - .value, - AggregateValue::Uniform(PropertyValue::Enum(apodization::APODIZATION_EXPONENTIAL)), - ); -} - -/// A read-only tool must not be usable to write, and the refusal has to happen -/// before anything is planned. -#[test] -fn a_read_only_property_cannot_be_written() { - let (app, target) = ilt_app(0.07); - let error = plan_tool( - &app, - request( - &app, - TOOL_SET, - serde_json::json!({"key": ilt::RESULT_LAMBDA.as_str(), "value": 0.2}), - vec![target.resource.id], - CallerType::Agent, - ), - ); - let message = error - .expect_err("the read-only definition must be refused before planning") - .to_string(); - assert!(message.contains("read-only"), "{message}"); - assert!(message.contains(ilt::RESULT_LAMBDA.as_str()), "{message}"); -} - -// --------------------------------------------------------------------------- -// The pre-existing tools -// --------------------------------------------------------------------------- - -/// A syntactically valid relation plan, for the tools whose parameters carry -/// one. It never executes here — planning only has to decode it — so the ids it -/// names need not exist. -fn relation_plan() -> serde_json::Value { - let plan = plotx_data::RelPlanV1::new(plotx_data::Relation::SnapshotRead( - plotx_data::SnapshotRead { - table: plotx_data::TableId::new(), - revision: plotx_data::RevisionId::new(), - fingerprint: plotx_data::ContentHash::of(b"pre-existing-tool planning"), - }, - )); - serde_json::to_value(plan).expect("a relation plan serializes") -} - -/// The per-tool planning seam is additive. Every tool that existed before it -/// must still be planned by the shared gate alone: one planned target per frozen -/// resource, no component, and the shared reasons. -#[test] -fn the_planning_of_pre_existing_tools_is_unchanged() { - let (app, _) = contour_app(); - let canvas = app.doc.canvases[0].resource_id.to_string(); - let dataset = app.doc.datasets[0].resource_id().to_string(); - let object = plot_resource_id(&app); - let transform = serde_json::json!({ - "plan": relation_plan(), - "name": "Projected", - "memory_limit_bytes": 16 * 1024 * 1024, - }); - let cases: &[(&str, serde_json::Value, &str)] = &[ - ("project.get_blueprint", serde_json::json!({}), &canvas), - ( - "resources.search", - serde_json::json!({"query": {}}), - &canvas, - ), - ("resources.inspect", serde_json::json!({}), &object), - ("data.preview", serde_json::json!({}), &dataset), - ("render.preview", serde_json::json!({}), &canvas), - ("results.compare", serde_json::json!({}), &canvas), - ("resource.rename", serde_json::json!({"name": "x"}), &object), - ( - "figure.apply_theme", - serde_json::json!({"theme_id": "default"}), - &canvas, - ), - ( - "processing.apply_scheme", - serde_json::json!({"path": "scheme.plotxproc"}), - &dataset, - ), - ("data.import", serde_json::json!({"paths": []}), &canvas), - ("data.transform", transform, &dataset), - ( - "figure.export", - serde_json::json!({"directory": ".", "format": "svg"}), - &canvas, - ), - ]; - assert_eq!( - cases.len(), - ToolRegistry::built_in().descriptors().count() - 3, - "every tool that predates the three property tools is covered here" - ); - for (tool_id, parameters, target) in cases { - let plan = plan_tool( - &app, - request( - &app, - tool_id, - parameters.clone(), - vec![(*target).to_owned()], - CallerType::Agent, - ), - ) - .unwrap_or_else(|error| panic!("{tool_id} plans: {error}")); - assert_eq!(plan.targets.len(), 1, "{tool_id} expands nothing"); - assert!( - plan.targets[0].target.component.is_none(), - "{tool_id} names no component" - ); - assert_eq!(plan.targets[0].target.resource.id, **target); - assert!( - plan.targets[0] - .reason - .contains("declared kind and capabilities") - || plan.targets[0] - .reason - .contains("lacks a required kind or capability"), - "{tool_id} keeps the shared reason: {}", - plan.targets[0].reason - ); - } -} - -/// The three new tools are admitted by capability, and the descriptors say so -/// rather than naming an object type. -#[test] -fn the_property_tools_are_gated_by_capability() { - let registry = ToolRegistry::built_in(); - registry.validate_unique().expect("ids stay unique"); - for id in [TOOL_INSPECT, TOOL_SET, TOOL_RESET] { - let descriptor = registry.get(id).unwrap_or_else(|| panic!("{id} exists")); - assert_eq!( - descriptor.required_capabilities, - vec![CapabilityId::new(CAP_PROPERTY_CATALOG)], - "{id}" - ); - assert_eq!( - descriptor.target_kinds, - vec![ - ResourceKindId::new(KIND_DOCUMENT), - ResourceKindId::new(KIND_DATASET), - ResourceKindId::new(KIND_CANVAS_OBJECT), - ], - "{id}" - ); - } - assert_eq!( - registry.get(TOOL_INSPECT).unwrap().effect, - EffectLevel::ReadOnly - ); - assert_eq!( - registry.get(TOOL_SET).unwrap().effect, - EffectLevel::Reversible - ); - assert_eq!( - registry.get(TOOL_RESET).unwrap().effect, - EffectLevel::Reversible - ); - assert!(registry.get(TOOL_SET).unwrap().undoable); -} - -/// Whole-encoding reset is deliberately not exposed: its scope needs the caller -/// to name an encoding kind, and a JSON caller that omits it would rebuild every -/// series of an object from defaults while naming only one of them. -#[test] -fn whole_encoding_reset_is_not_a_tool() { - assert!( - ToolRegistry::built_in() - .descriptors() - .all(|descriptor| descriptor.id != "properties.reset_encoding"), - ); -} - -#[test] -fn every_property_definition_is_reachable_by_key() { - for definition in crate::properties::catalog() { - assert!( - definition_by_key(definition.id.as_str()).is_some(), - "{} is not reachable by its own key", - definition.id - ); - } -} - -/// Admission to the property tools is a capability question, and the capability -/// is the catalog's own answer about whether a resource has components to -/// address. Deriving it from the dataset variant instead would put a data-domain -/// branch in the admission gate — the one thing the encoding and property -/// registries exist to avoid — and would drift the moment a kind gained or lost -/// addressable components. -#[test] -fn the_catalog_capability_follows_addressable_components_not_the_dataset_kind() { - let (mut app, _) = contour_app(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(NmrData { - points: (0..8) - .map(|value| num_complex::Complex64::new(f64::from(value), 0.0)) - .collect(), - domain: Domain::Frequency, - spectral_width_hz: 4_000.0, - observe_freq_mhz: 400.0, - carrier_ppm: 0.0, - nucleus: "1H".to_owned(), - source: "capability gate".to_owned(), - group_delay: 0.0, - })))); - let catalog = CapabilityId::new(CAP_PROPERTY_CATALOG); - let provider = ProjectResourceProvider::new(&app); - let descriptors = provider.descriptors(); - let mut checked = 0; - for dataset in &app.doc.datasets { - let id = dataset.resource_id().to_string(); - let descriptor = descriptors - .iter() - .find(|descriptor| descriptor.resource.id == id) - .unwrap_or_else(|| panic!("dataset {id} has a descriptor")); - assert_eq!( - descriptor.capabilities.contains(&catalog), - crate::properties::has_addressable_components(dataset), - "the capability of {id} disagrees with what the catalog can address" - ); - checked += 1; - } - assert!( - checked >= 2, - "the fixture covers more than one dataset kind" - ); -} - -/// A caller has to be able to tell "that is already the value" from "that does -/// not apply here" without reading English. The two are opposite answers to -/// whether the call addressed the right thing, and a re-sent value is the -/// ordinary way a skip reaches the result at all. -#[test] -fn a_same_value_write_reports_a_typed_skip_rather_than_a_denial() { - let (mut app, _) = contour_app(); - add_line_series(&mut app); - let request = set_request(&app, contour::COUNT.as_str(), serde_json::json!(9)); - run(&mut app, request).expect("the contour series accepts the write"); - - let request = set_request(&app, contour::COUNT.as_str(), serde_json::json!(9)); - let result = run(&mut app, request).expect("re-sending the same value is not an error"); - let skipped: Vec<_> = result - .targets - .iter() - .filter(|target| target.outcome == TargetOutcome::Skipped) - .collect(); - assert_eq!(skipped.len(), 2, "{:?}", result.targets); - - let reasons: Vec> = skipped - .iter() - .map(|target| target.skip_reason.as_deref()) - .collect(); - assert!( - reasons.contains(&Some("already_at_value")), - "the contour series held the value already: {reasons:?}" - ); - // The line series was ruled out at plan time, by the same applicability - // question, and reaches the result through the shared gate's own list. It - // carries no catalog reason — which is precisely what makes the two - // distinguishable without reading either message. - assert!( - reasons.contains(&None), - "the line series never had this property: {reasons:?}" - ); - - // The verification line may not claim the property was refused: one of these - // targets accepted it and simply had nothing to change. - let verification = &result.verification[0]; - assert!( - !verification.message.contains("no target accepted"), - "a same-value write is not a denial: {}", - verification.message - ); -} +#[path = "properties_tests_inbound_id.rs"] +mod inbound_id_tests; +#[path = "properties_tests_inbound_value.rs"] +mod inbound_value_tests; +#[path = "properties_tests_outbound.rs"] +mod outbound_tests; +#[path = "properties_tests_planning.rs"] +mod planning_tests; +#[path = "properties_tests_rejections.rs"] +mod rejection_tests; diff --git a/crates/core/src/automation/properties_tests_inbound_id.rs b/crates/core/src/automation/properties_tests_inbound_id.rs new file mode 100644 index 00000000..98b3a7d5 --- /dev/null +++ b/crates/core/src/automation/properties_tests_inbound_id.rs @@ -0,0 +1,34 @@ +//! Inbound property identity decoding tests. + +use super::*; + +#[test] +fn an_unknown_property_key_is_refused_rather_than_skipped() { + let (mut app, _) = contour_app(); + let error = plan_tool( + &app, + set_request(&app, "series.contour.nonexistent", serde_json::json!(3)), + ) + .expect_err("an unknown key cannot be planned"); + let message = error.to_string(); + assert!( + message.contains("unknown property 'series.contour.nonexistent'"), + "{message}" + ); + // And it never reaches execution, so nothing is silently committed. + let before = app.doc.automation_revision; + let request = set_request(&app, "series.contour.nonexistent", serde_json::json!(3)); + assert!(run(&mut app, request).is_err()); + assert_eq!(app.doc.automation_revision, before); +} + +#[test] +fn every_property_definition_is_reachable_by_key() { + for definition in crate::properties::catalog() { + assert!( + definition_by_key(definition.id.as_str()).is_some(), + "{} is not reachable by its own key", + definition.id + ); + } +} diff --git a/crates/core/src/automation/properties_tests_inbound_value.rs b/crates/core/src/automation/properties_tests_inbound_value.rs new file mode 100644 index 00000000..e7480cfd --- /dev/null +++ b/crates/core/src/automation/properties_tests_inbound_value.rs @@ -0,0 +1,326 @@ +//! Inbound property value and schema decoding tests. + +use super::*; +use crate::properties::{ + Applicability, ComponentKind, DefaultPolicy, PropertyAccess, PropertyDefinition, PropertyId, + ScopeKind, Tier, ValueCopies, ValueSchema, +}; + +#[test] +fn automation_accepts_text_for_an_axis_label() { + let (mut app, _) = contour_app(); + let request = set_request( + &app, + axis::X_LABEL.as_str(), + serde_json::json!("Chemical shift"), + ); + run(&mut app, request).expect("text property writes through automation"); + + assert_eq!( + app.doc.canvases[0].objects[0] + .plot() + .expect("plot") + .axis_overrides + .x_label + .as_deref(), + Some("Chemical shift") + ); +} + +#[test] +fn automation_writes_new_object_text_color_and_enum_properties() { + let (mut app, _) = contour_app(); + let canvas = &mut app.doc.canvases[0]; + let id = canvas.allocate_object_id(); + canvas.objects.push(CanvasObject { + id, + name: "Caption".to_owned(), + frame: ObjectFrame::new(0.0, 0.0, 40.0, 20.0), + locked: false, + visible: true, + group: None, + kind: CanvasObjectKind::Text(TextBox::label(String::new())), + }); + let resource = format!("{}/{id}", canvas.resource_id); + for (property, value) in [ + ( + crate::properties::object::TEXT, + serde_json::json!("Automation caption"), + ), + ( + crate::properties::object::TEXT_COLOR, + serde_json::json!("#123456"), + ), + ( + crate::properties::object::TEXT_ALIGN, + serde_json::json!(crate::properties::object::ALIGN_CENTER), + ), + ] { + let request = request( + &app, + TOOL_SET, + serde_json::json!({"key": property.as_str(), "value": value}), + vec![resource.clone()], + CallerType::Agent, + ); + run(&mut app, request).unwrap_or_else(|error| panic!("{property}: {error}")); + } + let text = app.doc.canvases[0].object(id).unwrap().text().unwrap(); + assert_eq!(text.text, "Automation caption"); + assert_eq!(text.color, plotx_figure::Color::rgb(0x12, 0x34, 0x56)); + assert_eq!(text.align, crate::state::TextAlign::Center); +} + +/// A value outside the declared bound must report both the value that was +/// rejected and the bound that rejected it. +#[test] +fn an_out_of_range_value_names_the_value_and_the_bound() { + let (app, _) = contour_app(); + let error = plan_tool( + &app, + set_request(&app, contour::RATIO.as_str(), serde_json::json!(50.0)), + ) + .expect_err("50 is above the declared ratio bound"); + let message = error.to_string(); + assert!( + message.contains("50"), + "the rejected value is named: {message}" + ); + assert!( + message.contains("greater than 1") && message.contains("at most 10"), + "the bound is named: {message}" + ); +} + +/// The bound a *context-dependent* schema imposes is enforced by the shared +/// planner, not by the adapter, and it too has to name both numbers. The +/// definition's static bound admits this value; only the target's current +/// anchor rejects it. +#[test] +fn a_bound_that_only_the_anchor_knows_still_names_the_value() { + let (mut app, target) = contour_app(); + assert!(matches!( + contour_spec(&app, &target).positive.base, + plotx_figure::ContourBasePolicy::NoiseFloor { .. } + )); + let request = set_request( + &app, + contour::BASE_MAGNITUDE.as_str(), + serde_json::json!(1.0e9), + ); + let error = + run(&mut app, request).expect_err("a multiplier of 1e9 is beyond what the anchor accepts"); + let message = error.to_string(); + assert!( + message.contains("1000000000"), + "the rejected value is named: {message}" + ); + assert!( + message.contains("10000"), + "the anchor's own bound is named: {message}" + ); +} + +/// A string that names no choice at all is a wire-format error, and it lists +/// the choices the setting has. +#[test] +fn an_unknown_enum_choice_lists_the_settings_options() { + let (app, _) = contour_app(); + let error = plan_tool( + &app, + set_request( + &app, + contour::BASE_POLICY.as_str(), + serde_json::json!("dark_magic"), + ), + ) + .expect_err("'dark_magic' is not a base policy"); + let message = error.to_string(); + assert!(message.contains("dark_magic"), "{message}"); + assert!( + message.contains(CONTOUR_BASE_NOISE_FLOOR) + && message.contains(CONTOUR_BASE_FRACTION_OF_RANGE), + "every declared choice is listed: {message}" + ); +} + +/// A choice the setting has but this field's capabilities withhold is refused +/// by the planner, and the refusal lists what the field does allow. The fixture +/// draws a signed plane, which is exactly the case where a fraction of the +/// value range is meaningless. +#[test] +fn a_capability_withheld_choice_lists_what_the_field_allows() { + let (mut app, _) = contour_app(); + let request = set_request( + &app, + contour::BASE_POLICY.as_str(), + serde_json::json!(CONTOUR_BASE_FRACTION_OF_RANGE), + ); + let error = + run(&mut app, request).expect_err("a signed field withholds the fraction-of-range anchor"); + let message = error.to_string(); + assert!( + message.contains(CONTOUR_BASE_FRACTION_OF_RANGE), + "{message}" + ); + assert!( + message.contains("this field allows") && message.contains(CONTOUR_BASE_NOISE_FLOOR), + "the permitted choices are named: {message}" + ); +} + +#[test] +fn a_value_of_the_wrong_shape_is_refused() { + let (app, _) = contour_app(); + let error = plan_tool( + &app, + set_request(&app, contour::COUNT.as_str(), serde_json::json!(true)), + ) + .expect_err("a count is not a boolean"); + assert!(error.to_string().contains("expected an integer"), "{error}"); +} + +/// A read-only tool must not be usable to write, and the refusal has to happen +/// before anything is planned. +#[test] +fn a_read_only_property_cannot_be_written() { + let (app, target) = ilt_app(0.07); + let error = plan_tool( + &app, + request( + &app, + TOOL_SET, + serde_json::json!({"key": ilt::RESULT_LAMBDA.as_str(), "value": 0.2}), + vec![target.resource.id], + CallerType::Agent, + ), + ); + let message = error + .expect_err("the read-only definition must be refused before planning") + .to_string(); + assert!(message.contains("read-only"), "{message}"); + assert!(message.contains(ilt::RESULT_LAMBDA.as_str()), "{message}"); +} + +fn smoothing_app() -> (PlotxApp, String) { + let data = NmrData { + points: (0..64) + .map(|index| num_complex::Complex64::new(index as f64, 0.0)) + .collect(), + domain: Domain::Time, + spectral_width_hz: 2_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 4.7, + nucleus: "1H".to_owned(), + source: "automation smoothing".to_owned(), + group_delay: 0.0, + }; + let mut dataset = NmrDataset::load(data); + let id = dataset.allocate_step_id(); + dataset + .pipeline + .steps + .push(plotx_processing::ProcessingStep::new( + id, + plotx_processing::StepKind::Smooth(plotx_processing::SmoothMethod::DEFAULT), + plotx_processing::StepSource::User, + )); + let resource = ResourceRef::from(dataset.resource_id).id; + let mut app = PlotxApp::new(); + app.doc.datasets.push(Dataset::Nmr(Box::new(dataset))); + (app, resource) +} + +fn smoothing_request( + app: &PlotxApp, + resource: &str, + key: PropertyId, + value: serde_json::Value, +) -> ToolRequest { + request( + app, + TOOL_SET, + serde_json::json!({"key": key.as_str(), "value": value}), + vec![resource.to_owned()], + CallerType::Agent, + ) +} + +#[test] +fn stepped_int_wire_values_accept_the_lattice_and_name_step_and_bounds_on_rejection() { + let (app, resource) = smoothing_app(); + plan_tool( + &app, + smoothing_request(&app, &resource, smooth::WINDOW, serde_json::json!(11)), + ) + .expect("an odd window on the declared lattice decodes"); + + for (value, expected) in [(10, "steps of 2"), (203, "between 3 and 201")] { + let error = plan_tool( + &app, + smoothing_request(&app, &resource, smooth::WINDOW, serde_json::json!(value)), + ) + .expect_err("the wire value violates the static stepped schema"); + let message = error.to_string(); + assert!(message.contains(&value.to_string()), "{message}"); + assert!(message.contains(expected), "{message}"); + } +} + +#[test] +fn int_with_drag_wire_values_decode_as_integers_and_report_actual_bounds() { + let (app, resource) = smoothing_app(); + plan_tool( + &app, + smoothing_request( + &app, + &resource, + smooth::POLYNOMIAL_ORDER, + serde_json::json!(4), + ), + ) + .expect("a polynomial order inside the static IntWithDrag bound decodes"); + let error = plan_tool( + &app, + smoothing_request( + &app, + &resource, + smooth::POLYNOMIAL_ORDER, + serde_json::json!(9), + ), + ) + .expect_err("nine exceeds the IntWithDrag bound"); + let message = error.to_string(); + assert!(message.contains('9'), "{message}"); + assert!(message.contains("between 1 and 8"), "{message}"); +} + +#[test] +fn a_non_positive_schema_step_is_reported_as_an_internal_error() { + const MALFORMED: PropertyDefinition = PropertyDefinition { + id: PropertyId("test.malformed.step"), + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::SteppedInt { + min: 1, + max: 9, + step: 0, + drag_step: 1.0, + }, + access: PropertyAccess::ReadWrite, + applicability: Applicability::component(ComponentKind::None), + default_policy: DefaultPolicy::None, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Malformed test", + canonical_aliases: &[], + }; + let error = decode_value(TOOL_SET, &MALFORMED, &serde_json::json!(3)) + .expect_err("a malformed catalog schema is not a user range error"); + let message = error.to_string(); + assert!(message.contains("internal schema error"), "{message}"); + assert!(!message.contains("steps of 0"), "{message}"); +} + +// --------------------------------------------------------------------------- +// The pre-existing tools +// --------------------------------------------------------------------------- diff --git a/crates/core/src/automation/properties_tests_outbound.rs b/crates/core/src/automation/properties_tests_outbound.rs new file mode 100644 index 00000000..d93486b2 --- /dev/null +++ b/crates/core/src/automation/properties_tests_outbound.rs @@ -0,0 +1,409 @@ +//! Outbound property DTO and skip-shape tests. + +use super::*; + +#[test] +fn automation_reports_text_values_and_schema() { + let (mut app, _) = contour_app(); + let set = set_request(&app, axis::Y_LABEL.as_str(), serde_json::json!("Intensity")); + run(&mut app, set).expect("text property writes"); + let inspect = request( + &app, + TOOL_INSPECT, + serde_json::json!({"key": axis::Y_LABEL.as_str()}), + vec![plot_resource_id(&app)], + CallerType::Agent, + ); + let result = run(&mut app, inspect).expect("text property inspects"); + let reading = &result.value["readings"][0]; + assert_eq!(reading["value"]["value"]["type"], "text"); + assert_eq!(reading["value"]["value"]["value"], "Intensity"); + assert_eq!(reading["schema"]["type"], "text"); +} + +#[test] +fn automation_reports_new_object_color_and_enum_values_and_schemas() { + let (mut app, _) = contour_app(); + let canvas = &mut app.doc.canvases[0]; + let id = canvas.allocate_object_id(); + let mut text = TextBox::label("caption".to_owned()); + text.color = plotx_figure::Color::rgb(0x12, 0x34, 0x56); + text.align = crate::state::TextAlign::Right; + canvas.objects.push(CanvasObject { + id, + name: "Caption".to_owned(), + frame: ObjectFrame::new(0.0, 0.0, 40.0, 20.0), + locked: false, + visible: true, + group: None, + kind: CanvasObjectKind::Text(text), + }); + let resource = format!("{}/{id}", canvas.resource_id); + for (property, kind, value) in [ + ( + crate::properties::object::TEXT_COLOR, + "color", + serde_json::json!("#123456"), + ), + ( + crate::properties::object::TEXT_ALIGN, + "enum", + serde_json::json!(crate::properties::object::ALIGN_RIGHT), + ), + ] { + let inspect = request( + &app, + TOOL_INSPECT, + serde_json::json!({"key": property.as_str()}), + vec![resource.clone()], + CallerType::Agent, + ); + let result = run(&mut app, inspect).unwrap_or_else(|error| panic!("{property}: {error}")); + let reading = &result.value["readings"][0]; + assert_eq!(reading["value"]["value"]["type"], kind); + assert_eq!(reading["value"]["value"]["value"], value); + assert_eq!(reading["schema"]["type"], kind); + } +} + +#[test] +fn automation_reports_app_preference_enum_and_color_values_and_schemas() { + let mut settings = crate::settings::Settings::default(); + settings.appearance.theme = crate::settings::ThemeMode::Dark; + settings.appearance.canvas_accent = Some([0x12, 0x34, 0x56]); + let mut app = PlotxApp::new_with_settings(settings); + + for (property, kind, value) in [ + ( + app_preferences::THEME, + "enum", + serde_json::json!(app_preferences::THEME_DARK), + ), + ( + app_preferences::ACCENT_COLOR, + "color", + serde_json::json!("#123456"), + ), + ] { + let inspect = request( + &app, + TOOL_INSPECT, + serde_json::json!({"key": property.as_str()}), + vec![APP_RESOURCE_ID.to_owned()], + CallerType::Agent, + ); + let result = run(&mut app, inspect).unwrap_or_else(|error| panic!("{property}: {error}")); + let reading = &result.value["readings"][0]; + assert_eq!( + reading["value"]["value"]["type"], kind, + "{}; targets: {:?}", + result.value, result.targets + ); + assert_eq!(reading["value"]["value"]["value"], value); + assert_eq!(reading["schema"]["type"], kind); + } +} + +#[test] +fn automation_inspects_stored_ilt_provenance_and_refuses_set_and_reset_as_read_only() { + let (mut app, target) = ilt_app(0.07); + let id = target.resource.id.clone(); + let inspect = request( + &app, + TOOL_INSPECT, + serde_json::json!({"key": ilt::RESULT_LAMBDA.as_str()}), + vec![id.clone()], + CallerType::Agent, + ); + let result = run(&mut app, inspect).expect("properties.inspect reads provenance"); + let value = result.value.to_string(); + assert!(value.contains("0.07"), "{value}"); + assert!(value.contains("read_only"), "{value}"); + + for (tool, parameters) in [ + ( + TOOL_SET, + serde_json::json!({"key": ilt::RESULT_LAMBDA.as_str(), "value": 0.2}), + ), + ( + TOOL_RESET, + serde_json::json!({"key": ilt::RESULT_LAMBDA.as_str()}), + ), + ] { + let error = plan_tool( + &app, + request(&app, tool, parameters, vec![id.clone()], CallerType::Agent), + ) + .expect_err("non-inspect automation must refuse read-only provenance"); + let message = error.to_string(); + assert!(message.contains("read-only"), "{message}"); + assert!(message.contains(ilt::RESULT_LAMBDA.as_str()), "{message}"); + } +} + +#[test] +fn inspect_reads_the_value_and_reports_skips() { + let (mut app, _) = contour_app(); + add_line_series(&mut app); + let inspect = request( + &app, + TOOL_INSPECT, + serde_json::json!({"key": contour::COUNT.as_str()}), + vec![plot_resource_id(&app)], + CallerType::Agent, + ); + let result = run(&mut app, inspect).expect("inspect succeeds"); + assert_eq!(result.value["property"], contour::COUNT.as_str()); + assert_eq!(result.value["aggregate"]["state"], "uniform"); + assert_eq!(result.value["readings"].as_array().map(Vec::len), Some(1)); + assert_eq!(result.value["readings"][0]["schema"]["type"], "int"); + assert_eq!( + result + .targets + .iter() + .filter(|target| target.outcome == TargetOutcome::Skipped) + .count(), + 1, + "the line series is reported, not dropped" + ); +} + +/// A document-scoped property expands to the document root itself instead of +/// pretending it owns a series. This is the `ComponentKind::None` counterpart +/// to the existing plot-object expansion test. +#[test] +fn document_property_tools_address_the_document_root() { + let (mut app, _) = contour_app(); + let request = request( + &app, + TOOL_SET, + serde_json::json!({"key": typography::TICK_PT.as_str(), "value": 9.5}), + vec![DOCUMENT_RESOURCE_ID.to_owned()], + CallerType::Agent, + ); + let plan = plan_tool(&app, request).expect("the document property plans"); + assert_eq!(plan.targets.len(), 1); + assert_eq!(plan.targets[0].status, TargetCompatibility::Compatible); + assert!(plan.targets[0].target.component.is_none()); + let authority = plan.required_authority; + let result = execute_tool(&mut app, plan, authority).expect("the document property executes"); + assert!( + result + .targets + .iter() + .any(|target| target.outcome == TargetOutcome::Succeeded), + "the document root is reported as an applied target" + ); + assert_eq!(app.doc.style_library.figure_typography.tick_pt, 9.5); +} + +/// Dataset resources expand to their stable processing-step components. Only +/// the apodization step accepts this property; the other real pipeline steps +/// remain visible as reported skips rather than being silently omitted. +#[test] +fn dataset_property_tools_expand_processing_steps_and_report_non_apodization_skips() { + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Nmr(Box::new(NmrDataset::load(NmrData { + points: (0..32) + .map(|value| num_complex::Complex64::new(f64::from(value), 0.0)) + .collect(), + domain: Domain::Time, + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 0.0, + nucleus: "1H".to_owned(), + source: "automation apodization".to_owned(), + group_delay: 0.0, + })))); + let dataset = app.doc.datasets[0].resource_id().to_string(); + let request = request( + &app, + TOOL_SET, + serde_json::json!({ + "key": apodization::KIND.as_str(), + "value": apodization::APODIZATION_EXPONENTIAL, + }), + vec![dataset], + CallerType::Agent, + ); + let plan = plan_tool(&app, request).expect("the dataset step property plans"); + let compatible = plan + .targets + .iter() + .filter(|target| target.status == TargetCompatibility::Compatible) + .collect::>(); + assert_eq!(compatible.len(), 1, "only the apodization step accepts it"); + assert!( + plan.targets + .iter() + .any(|target| target.status == TargetCompatibility::Skipped), + "the rest of the real pipeline is reported as skipped" + ); + let target = compatible[0].target.clone(); + let authority = plan.required_authority; + let result = execute_tool(&mut app, plan, authority).expect("the accepted step executes"); + assert!( + result + .targets + .iter() + .any(|target| target.outcome == TargetOutcome::Succeeded), + "the apodization component reports success" + ); + assert!( + result + .targets + .iter() + .any(|target| target.outcome == TargetOutcome::Skipped), + "the non-apodization components report their skips" + ); + assert_eq!( + app.resolve_property(&PropertyAddress::new(target, apodization::KIND)) + .expect("the stable step target still resolves") + .value, + AggregateValue::Uniform(PropertyValue::Enum(apodization::APODIZATION_EXPONENTIAL)), + ); +} + +#[test] +fn inspect_reports_the_actionable_reason_for_a_disabled_phase_parameter() { + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Nmr(Box::new(NmrDataset::load(NmrData { + points: (0..32) + .map(|value| num_complex::Complex64::new(f64::from(value), 0.25)) + .collect(), + domain: Domain::Time, + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 0.0, + nucleus: "1H".to_owned(), + source: "automation phase availability".to_owned(), + group_delay: 0.0, + })))); + let dataset = app.doc.datasets[0].resource_id().to_string(); + let inspect = request( + &app, + TOOL_INSPECT, + serde_json::json!({"key": phase::PHASE0.as_str()}), + vec![dataset], + CallerType::Agent, + ); + let result = run(&mut app, inspect).expect("the real JSON entry inspects phase0"); + assert_eq!(result.value["readings"][0]["availability"], "disabled"); + assert_eq!( + result.value["readings"][0]["disabled_reason"], + phase::MANUAL_PHASE0_REASON + ); +} + +#[test] +fn degree_schema_dto_keeps_display_log_and_unit_consistent() { + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Nmr(Box::new(NmrDataset::load(NmrData { + points: (0..32) + .map(|value| num_complex::Complex64::new(f64::from(value), 0.25)) + .collect(), + domain: Domain::Time, + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 0.0, + nucleus: "1H".to_owned(), + source: "automation phase display".to_owned(), + group_delay: 0.0, + })))); + let dataset = app.doc.datasets[0].resource_id().to_string(); + let inspect = request( + &app, + TOOL_INSPECT, + serde_json::json!({"key": phase::PHASE0.as_str()}), + vec![dataset], + CallerType::Agent, + ); + let result = run(&mut app, inspect).expect("phase0 schema serializes"); + let schema = &result.value["readings"][0]["schema"]; + assert_eq!(schema["log"], false); + assert_eq!(schema["unit"], "°"); + assert_eq!(schema["display"], "degrees"); +} + +/// A client reads `unit` together with `display`. The unit is the stored +/// quantity's, so a client that plots or converts the value does not have to +/// parse an exponent out of a caption written for a human. +#[test] +fn a_logarithmic_schema_reports_the_domain_unit_beside_its_display() { + let dto = schema_dto(&ResolvedSchema::Float { + bounds: FloatBounds::inclusive(1.0, 1.0e6), + display: FloatDisplay::Log10("λ"), + }); + let json = serde_json::to_value(dto).expect("the schema DTO serializes"); + assert_eq!(json["unit"], "λ"); + assert_eq!(json["display"], "log10"); + assert_eq!(json["log"], true); +} + +#[test] +fn magnitude_exclusion_dto_does_not_understate_the_rejected_interval() { + let bounds = FloatBounds::excluding_magnitude(-f64::MAX, f64::MAX, f64::MIN_POSITIVE); + let dto = schema_dto(&ResolvedSchema::Float { + bounds, + display: FloatDisplay::Linear(""), + }); + let json = serde_json::to_value(dto).expect("the schema DTO serializes"); + assert!( + json.get("excluded").is_none(), + "zero is not a separately excluded value: {json}" + ); + assert_eq!(json["excluded_magnitude"], f64::MIN_POSITIVE); +} + +/// A caller has to be able to tell "that is already the value" from "that does +/// not apply here" without reading English. The two are opposite answers to +/// whether the call addressed the right thing, and a re-sent value is the +/// ordinary way a skip reaches the result at all. +#[test] +fn a_same_value_write_reports_a_typed_skip_rather_than_a_denial() { + let (mut app, _) = contour_app(); + add_line_series(&mut app); + let request = set_request(&app, contour::COUNT.as_str(), serde_json::json!(9)); + run(&mut app, request).expect("the contour series accepts the write"); + + let request = set_request(&app, contour::COUNT.as_str(), serde_json::json!(9)); + let result = run(&mut app, request).expect("re-sending the same value is not an error"); + let skipped: Vec<_> = result + .targets + .iter() + .filter(|target| target.outcome == TargetOutcome::Skipped) + .collect(); + assert_eq!(skipped.len(), 2, "{:?}", result.targets); + + let reasons: Vec> = skipped + .iter() + .map(|target| target.skip_reason.as_deref()) + .collect(); + assert!( + reasons.contains(&Some("already_at_value")), + "the contour series held the value already: {reasons:?}" + ); + // The line series was ruled out at plan time, by the same applicability + // question, and reaches the result through the shared gate's own list. It + // carries no catalog reason — which is precisely what makes the two + // distinguishable without reading either message. + assert!( + reasons.contains(&None), + "the line series never had this property: {reasons:?}" + ); + + // The verification line may not claim the property was refused: one of these + // targets accepted it and simply had nothing to change. + let verification = &result.verification[0]; + assert!( + !verification.message.contains("no target accepted"), + "a same-value write is not a denial: {}", + verification.message + ); +} diff --git a/crates/core/src/automation/properties_tests_planning.rs b/crates/core/src/automation/properties_tests_planning.rs new file mode 100644 index 00000000..b6dd242e --- /dev/null +++ b/crates/core/src/automation/properties_tests_planning.rs @@ -0,0 +1,97 @@ +//! Property-tool plan refinement tests. + +use super::*; + +/// A syntactically valid relation plan, for the tools whose parameters carry +/// one. It never executes here — planning only has to decode it — so the ids it +/// names need not exist. +fn relation_plan() -> serde_json::Value { + let plan = plotx_data::RelPlanV1::new(plotx_data::Relation::SnapshotRead( + plotx_data::SnapshotRead { + table: plotx_data::TableId::new(), + revision: plotx_data::RevisionId::new(), + fingerprint: plotx_data::ContentHash::of(b"pre-existing-tool planning"), + }, + )); + serde_json::to_value(plan).expect("a relation plan serializes") +} + +/// The per-tool planning seam is additive. Every tool that existed before it +/// must still be planned by the shared gate alone: one planned target per frozen +/// resource, no component, and the shared reasons. +#[test] +fn the_planning_of_pre_existing_tools_is_unchanged() { + let (app, _) = contour_app(); + let canvas = app.doc.canvases[0].resource_id.to_string(); + let dataset = app.doc.datasets[0].resource_id().to_string(); + let object = plot_resource_id(&app); + let transform = serde_json::json!({ + "plan": relation_plan(), + "name": "Projected", + "memory_limit_bytes": 16 * 1024 * 1024, + }); + let cases: &[(&str, serde_json::Value, &str)] = &[ + ("project.get_blueprint", serde_json::json!({}), &canvas), + ( + "resources.search", + serde_json::json!({"query": {}}), + &canvas, + ), + ("resources.inspect", serde_json::json!({}), &object), + ("data.preview", serde_json::json!({}), &dataset), + ("render.preview", serde_json::json!({}), &canvas), + ("results.compare", serde_json::json!({}), &canvas), + ("resource.rename", serde_json::json!({"name": "x"}), &object), + ( + "figure.apply_theme", + serde_json::json!({"theme_id": "default"}), + &canvas, + ), + ( + "processing.apply_scheme", + serde_json::json!({"path": "scheme.plotxproc"}), + &dataset, + ), + ("data.import", serde_json::json!({"paths": []}), &canvas), + ("data.transform", transform, &dataset), + ( + "figure.export", + serde_json::json!({"directory": ".", "format": "svg"}), + &canvas, + ), + ]; + assert_eq!( + cases.len(), + ToolRegistry::built_in().descriptors().count() - 3, + "every tool that predates the three property tools is covered here" + ); + for (tool_id, parameters, target) in cases { + let plan = plan_tool( + &app, + request( + &app, + tool_id, + parameters.clone(), + vec![(*target).to_owned()], + CallerType::Agent, + ), + ) + .unwrap_or_else(|error| panic!("{tool_id} plans: {error}")); + assert_eq!(plan.targets.len(), 1, "{tool_id} expands nothing"); + assert!( + plan.targets[0].target.component.is_none(), + "{tool_id} names no component" + ); + assert_eq!(plan.targets[0].target.resource.id, **target); + assert!( + plan.targets[0] + .reason + .contains("declared kind and capabilities") + || plan.targets[0] + .reason + .contains("lacks a required kind or capability"), + "{tool_id} keeps the shared reason: {}", + plan.targets[0].reason + ); + } +} diff --git a/crates/core/src/automation/properties_tests_rejections.rs b/crates/core/src/automation/properties_tests_rejections.rs new file mode 100644 index 00000000..c9fb5978 --- /dev/null +++ b/crates/core/src/automation/properties_tests_rejections.rs @@ -0,0 +1,197 @@ +//! Layered validation and capability rejection tests. + +use super::*; + +/// A target the property does not apply to is reported with its reason, and the +/// one it does apply to still lands. +#[test] +fn a_skipped_component_is_reported_not_dropped() { + let (mut app, _) = contour_app(); + add_line_series(&mut app); + let request = set_request(&app, contour::COUNT.as_str(), serde_json::json!(9)); + let result = run(&mut app, request).expect("the contour series accepts the write"); + let succeeded = result + .targets + .iter() + .filter(|target| target.outcome == TargetOutcome::Succeeded) + .collect::>(); + let skipped = result + .targets + .iter() + .filter(|target| target.outcome == TargetOutcome::Skipped) + .collect::>(); + assert_eq!(succeeded.len(), 1, "{:?}", result.targets); + assert_eq!(skipped.len(), 1, "{:?}", result.targets); + assert!( + skipped[0].message.contains("line"), + "the skip names the encoding that caused it: {}", + skipped[0].message + ); + // The two rows must be distinguishable, or a results panel shows one plot + // object twice with nothing to tell the rows apart. + assert_ne!( + succeeded[0].target.describe(), + skipped[0].target.describe(), + "expanded targets carry their component" + ); + assert!(succeeded[0].target.describe().contains("series")); +} + +/// A validation failure leaves every target exactly as it was. A commit that +/// applied to the first series and then failed on the second would be a partial +/// landing, and the ladder of the first would silently disagree with the panel. +#[test] +fn a_validation_failure_lands_on_no_target_at_all() { + let (mut app, target) = contour_app(); + add_line_series(&mut app); + let before_revision = app.doc.automation_revision; + let before_spec = contour_spec(&app, &target); + let request = set_request(&app, contour::COUNT.as_str(), serde_json::json!(0)); + let error = run(&mut app, request).expect_err("a level count of zero is out of range"); + assert!(error.to_string().contains("out of range"), "{error}"); + assert_eq!( + app.doc.automation_revision, before_revision, + "a rejected write never advances the document" + ); + assert_eq!( + contour_spec(&app, &target), + before_spec, + "a rejected write never reaches a spec" + ); +} + +/// A text object exposes the catalog for its own properties, while a plot-only +/// property is still skipped by the owning provider. +#[test] +fn an_object_without_components_is_skipped_by_the_shared_gate() { + let (mut app, _) = contour_app(); + let canvas = &mut app.doc.canvases[0]; + let id = canvas.allocate_object_id(); + canvas.objects.push(CanvasObject { + id, + name: "Caption".to_owned(), + frame: ObjectFrame::new(0.0, 0.0, 20.0, 10.0), + locked: false, + visible: true, + group: None, + kind: CanvasObjectKind::Text(TextBox::label("hello".to_owned())), + }); + let text_id = format!("{}/{id}", app.doc.canvases[0].resource_id); + let plan = plan_tool( + &app, + request( + &app, + TOOL_INSPECT, + serde_json::json!({"key": contour::COUNT.as_str()}), + vec![text_id.clone()], + CallerType::Agent, + ), + ) + .expect("planning succeeds; the target is merely skipped"); + assert_eq!(plan.targets.len(), 1); + assert_eq!(plan.targets[0].status, TargetCompatibility::Skipped); + assert!( + plan.targets[0].reason.contains("series component"), + "{}", + plan.targets[0].reason + ); + assert!(plan.targets[0].target.component.is_none()); +} + +/// The three new tools are admitted by capability, and the descriptors say so +/// rather than naming an object type. +#[test] +fn the_property_tools_are_gated_by_capability() { + let registry = ToolRegistry::built_in(); + registry.validate_unique().expect("ids stay unique"); + for id in [TOOL_INSPECT, TOOL_SET, TOOL_RESET] { + let descriptor = registry.get(id).unwrap_or_else(|| panic!("{id} exists")); + assert_eq!( + descriptor.required_capabilities, + vec![CapabilityId::new(CAP_PROPERTY_CATALOG)], + "{id}" + ); + assert_eq!( + descriptor.target_kinds, + vec![ + ResourceKindId::new(KIND_APP), + ResourceKindId::new(KIND_DOCUMENT), + ResourceKindId::new(KIND_DATASET), + ResourceKindId::new(KIND_CANVAS), + ResourceKindId::new(KIND_CANVAS_OBJECT), + ], + "{id}" + ); + } + assert_eq!( + registry.get(TOOL_INSPECT).unwrap().effect, + EffectLevel::ReadOnly + ); + assert_eq!( + registry.get(TOOL_SET).unwrap().effect, + EffectLevel::Reversible + ); + assert_eq!( + registry.get(TOOL_RESET).unwrap().effect, + EffectLevel::Reversible + ); + assert!(registry.get(TOOL_SET).unwrap().undoable); +} + +/// Whole-encoding reset is deliberately not exposed: its scope needs the caller +/// to name an encoding kind, and a JSON caller that omits it would rebuild every +/// series of an object from defaults while naming only one of them. +#[test] +fn whole_encoding_reset_is_not_a_tool() { + assert!( + ToolRegistry::built_in() + .descriptors() + .all(|descriptor| descriptor.id != "properties.reset_encoding"), + ); +} + +/// Admission to the property tools is a capability question, and the capability +/// is the catalog's own answer about whether a resource has components to +/// address. Deriving it from the dataset variant instead would put a data-domain +/// branch in the admission gate — the one thing the encoding and property +/// registries exist to avoid — and would drift the moment a kind gained or lost +/// addressable components. +#[test] +fn the_catalog_capability_follows_addressable_components_not_the_dataset_kind() { + let (mut app, _) = contour_app(); + app.doc + .datasets + .push(Dataset::Nmr(Box::new(NmrDataset::load(NmrData { + points: (0..8) + .map(|value| num_complex::Complex64::new(f64::from(value), 0.0)) + .collect(), + domain: Domain::Frequency, + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 0.0, + nucleus: "1H".to_owned(), + source: "capability gate".to_owned(), + group_delay: 0.0, + })))); + let catalog = CapabilityId::new(CAP_PROPERTY_CATALOG); + let provider = ProjectResourceProvider::new(&app); + let descriptors = provider.descriptors(); + let mut checked = 0; + for dataset in &app.doc.datasets { + let id = dataset.resource_id().to_string(); + let descriptor = descriptors + .iter() + .find(|descriptor| descriptor.resource.id == id) + .unwrap_or_else(|| panic!("dataset {id} has a descriptor")); + assert_eq!( + descriptor.capabilities.contains(&catalog), + crate::properties::has_addressable_components(dataset), + "the capability of {id} disagrees with what the catalog can address" + ); + checked += 1; + } + assert!( + checked >= 2, + "the fixture covers more than one dataset kind" + ); +} diff --git a/crates/core/src/automation/registry.rs b/crates/core/src/automation/registry.rs index 97d46e33..ef7ddea6 100644 --- a/crates/core/src/automation/registry.rs +++ b/crates/core/src/automation/registry.rs @@ -334,7 +334,13 @@ fn descriptors() -> Vec { "Inspect a property", "Read one catalog property across the components of the selected resources", super::properties::PropertyKeyParams, - [KIND_DOCUMENT, KIND_DATASET, KIND_CANVAS_OBJECT], + [ + KIND_APP, + KIND_DOCUMENT, + KIND_DATASET, + KIND_CANVAS, + KIND_CANVAS_OBJECT + ], [CAP_PROPERTY_CATALOG], EffectLevel::ReadOnly, true @@ -344,7 +350,13 @@ fn descriptors() -> Vec { "Set a property", "Write one catalog property through the same planner the panel controls use", super::properties::PropertyWriteParams, - [KIND_DOCUMENT, KIND_DATASET, KIND_CANVAS_OBJECT], + [ + KIND_APP, + KIND_DOCUMENT, + KIND_DATASET, + KIND_CANVAS, + KIND_CANVAS_OBJECT + ], [CAP_PROPERTY_CATALOG], EffectLevel::Reversible, true @@ -354,7 +366,13 @@ fn descriptors() -> Vec { "Reset a property", "Re-derive one catalog property from its default policy in each target's context", super::properties::PropertyKeyParams, - [KIND_DOCUMENT, KIND_DATASET, KIND_CANVAS_OBJECT], + [ + KIND_APP, + KIND_DOCUMENT, + KIND_DATASET, + KIND_CANVAS, + KIND_CANVAS_OBJECT + ], [CAP_PROPERTY_CATALOG], EffectLevel::Reversible, true diff --git a/crates/core/src/automation/resources.rs b/crates/core/src/automation/resources.rs index f8aa6b42..3d9d139d 100644 --- a/crates/core/src/automation/resources.rs +++ b/crates/core/src/automation/resources.rs @@ -215,6 +215,7 @@ impl<'a> ProjectResourceProvider<'a> { cap(CAP_RENDER), cap(CAP_THEME), cap(CAP_EXPORT), + cap(CAP_PROPERTY_CATALOG), ], children, dimensions: vec![canvas.objects.len()], @@ -265,14 +266,14 @@ impl ResourceProvider for ProjectResourceProvider<'_> { } fn descriptors(&self) -> Vec { - let mut descriptors = vec![ResourceDescriptor { + let root = |id: &str, kind: &str, name: &str| ResourceDescriptor { resource: ResourceRef { - id: DOCUMENT_RESOURCE_ID.to_owned(), - kind: ResourceKindId::new(KIND_DOCUMENT), + id: id.to_owned(), + kind: ResourceKindId::new(kind), parent_id: None, local_id: None, }, - name: "PlotX document".to_owned(), + name: name.to_owned(), capabilities: vec![cap(CAP_PROPERTY_CATALOG)], children: Vec::new(), dimensions: Vec::new(), @@ -280,7 +281,11 @@ impl ResourceProvider for ProjectResourceProvider<'_> { metadata: BTreeMap::new(), lineage: Vec::new(), revision: self.revision(), - }]; + }; + let mut descriptors = vec![ + root(APP_RESOURCE_ID, KIND_APP, "PlotX application"), + root(DOCUMENT_RESOURCE_ID, KIND_DOCUMENT, "PlotX document"), + ]; for (index, dataset) in self.app.doc.datasets.iter().enumerate() { let parent = self.dataset_descriptor(index, dataset); if let Dataset::Table(table) = dataset { @@ -329,15 +334,7 @@ impl ResourceProvider for ProjectResourceProvider<'_> { let parent = self.canvas_descriptor(index); let canvas = &self.app.doc.canvases[index]; for object in &canvas.objects { - let mut capabilities = vec![cap(CAP_RENAME)]; - // A text box or an image has a name and nothing the catalog - // addresses; only an object with a plot binding carries - // components. Text objects are therefore skipped by the same - // declared-capability gate every other tool uses, with the same - // reason, and the property tools need no special case for them. - if object.plot().is_some() { - capabilities.push(cap(CAP_PROPERTY_CATALOG)); - } + let capabilities = vec![cap(CAP_RENAME), cap(CAP_PROPERTY_CATALOG)]; descriptors.push(ResourceDescriptor { resource: child_ref( canvas.resource_id, diff --git a/crates/core/src/export/precheck.rs b/crates/core/src/export/precheck.rs index 5e44a930..74f907de 100644 --- a/crates/core/src/export/precheck.rs +++ b/crates/core/src/export/precheck.rs @@ -79,22 +79,22 @@ pub fn page_metrics(canvas: &CanvasDocument) -> PageMetrics { if plot.panel.visible { fonts.push(plot.panel.font_size); } - let typography = plot.figure.typography; - if plot.figure.axis_frame != AxisFrame::Hidden { + let typography = plot.figure().typography; + if plot.figure().axis_frame != AxisFrame::Hidden { fonts.extend([typography.tick_pt, typography.label_pt]); } - if !plot.figure.title.trim().is_empty() { + if !plot.figure().title.trim().is_empty() { fonts.push(typography.title_pt); } - for annotation in &plot.figure.annotations { + for annotation in &plot.figure().annotations { fonts.push(annotation.size); } - for series in &plot.figure.series { + for series in &plot.figure().series { if !series.points.is_empty() { lines.push(series.width); } } - for contour in &plot.figure.contours { + for contour in &plot.figure().contours { lines.push(contour.width); } } @@ -273,21 +273,24 @@ mod tests { locked: false, visible: true, group: None, - kind: CanvasObjectKind::Plot(Box::new(PlotObject { - next_series_id: crate::state::SeriesId::new(1), - binding: DataBinding { series: Vec::new() }, - chart: ChartSpec::default(), - stack: StackSpec::default(), - projections: AxisProjections::default(), - axis_overrides: AxisOverrides::default(), + kind: CanvasObjectKind::Plot(Box::new(PlotObject::new( + crate::state::SeriesId::new(1), + DataBinding { series: Vec::new() }, + ChartSpec::default(), + StackSpec::default(), + AxisProjections::default(), + AxisOverrides::default(), figure, viewport, panel, - })), + ))), }); assert_eq!(page_metrics(&canvas).min_font_pt, None); - canvas.objects[0].plot_mut().unwrap().figure.axis_frame = AxisFrame::Open; + canvas.objects[0] + .plot_mut() + .unwrap() + .set_axis_frame(AxisFrame::Open); assert_eq!(page_metrics(&canvas).min_font_pt, Some(3.0)); } } diff --git a/crates/core/src/project/axis_overrides.rs b/crates/core/src/project/axis_overrides.rs index 89efa32a..2e85473d 100644 --- a/crates/core/src/project/axis_overrides.rs +++ b/crates/core/src/project/axis_overrides.rs @@ -50,3 +50,21 @@ impl AxisOverridesDto { .normalized() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn explicit_label_override_is_not_elided_when_text_matches_the_derived_label() { + let derived_label = "Chemical shift"; + let overrides = AxisOverrides { + x_label: Some(derived_label.to_owned()), + ..AxisOverrides::default() + }; + + let dto = AxisOverridesDto::from_overrides(&overrides) + .expect("an explicit label makes the override structure non-default"); + assert_eq!(dto.x_label.as_deref(), Some(derived_label)); + } +} diff --git a/crates/core/src/project/cleanup_tests.rs b/crates/core/src/project/cleanup_tests.rs index 4c1a9a44..f38ed724 100644 --- a/crates/core/src/project/cleanup_tests.rs +++ b/crates/core/src/project/cleanup_tests.rs @@ -61,3 +61,29 @@ fn project_and_scheme_roundtrips_preserve_cleanup_steps() { .collect(); assert_eq!(tail, expected); } + +#[test] +fn applying_a_scheme_reports_an_invalid_stored_smoothing_window() { + let target = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()))); + let mut pipeline = AxisPipeline::default_1d(); + pipeline.steps.push(ProcessingStep::new( + StepId::new(99), + StepKind::Smooth(SmoothMethod::SavitzkyGolay { + window: 8, + poly_order: 7, + }), + StepSource::User, + )); + let scheme = ProcessingScheme { + schema_version: 1, + dimension_count: 1, + pipelines: vec![pipeline_to_dto(&pipeline)], + layout: None, + group_delay_correct: true, + }; + let error = apply_scheme(&scheme, &target) + .expect_err("an even persisted window must be diagnosed at the load boundary"); + let message = error.to_string(); + assert!(message.contains("stored smoothing window 8"), "{message}"); + assert!(message.contains("odd value between 3 and 201"), "{message}"); +} diff --git a/crates/core/src/project/convert_recipes.rs b/crates/core/src/project/convert_recipes.rs index d81d59fa..042192eb 100644 --- a/crates/core/src/project/convert_recipes.rs +++ b/crates/core/src/project/convert_recipes.rs @@ -9,6 +9,8 @@ pub fn apply_1d_recipe(dataset: &mut NmrDataset, recipe: &RecipeObject) -> Resul if let Some(dto) = p.pipelines.first() { dataset.pipeline = pipeline_from_dto(dto); } + validate_1d_pipeline(&dataset.data, &dataset.pipeline, p.group_delay_correct) + .map_err(ProjectError::Invalid)?; dataset.next_step_id = recipe .extensions .get("plotx.step_allocator") diff --git a/crates/core/src/project/convert_views.rs b/crates/core/src/project/convert_views.rs index 8f418014..a4163934 100644 --- a/crates/core/src/project/convert_views.rs +++ b/crates/core/src/project/convert_views.rs @@ -416,6 +416,13 @@ pub fn view_to_canvas( // does not, so a bypassed snapshot has to be treated as absent // here or the rebuilt figure would lose both. let snapshot_backed = view_object.snapshot.is_some() && !map_unavailable; + let derived_axes = if snapshot_backed { + let derived = + app.build_object_figure(&binding, &chart, &stack, &projections, size_mm); + crate::state::DerivedAxes::from_figure(&derived) + } else { + crate::state::DerivedAxes::from_figure(&figure) + }; if !snapshot_backed { axis_overrides.apply_to(&mut figure); } @@ -438,17 +445,18 @@ pub fn view_to_canvas( .or_else(|| view_object.title.clone()) .map(PanelDto::into_panel) .unwrap_or_else(|| PanelMeta::new(app.default_plot_title(di), frame.width)); - CanvasObjectKind::Plot(Box::new(PlotObject { - next_series_id: SeriesId::new(view_object.next_series_id), + CanvasObjectKind::Plot(Box::new(PlotObject::from_materialized_figure( + SeriesId::new(view_object.next_series_id), binding, chart, stack, projections, axis_overrides, + derived_axes, figure, viewport, panel, - })) + ))) } _ => continue, }; diff --git a/crates/core/src/project/mod.rs b/crates/core/src/project/mod.rs index 3b3a9c47..6eb8f95e 100644 --- a/crates/core/src/project/mod.rs +++ b/crates/core/src/project/mod.rs @@ -160,7 +160,7 @@ pub fn save_project( include_view_snapshots, revision.clone(), None, - usize::from(app.session.project_backup_generations), + usize::from(app.settings.general.project_backup_generations), )?; Ok(SaveOutcome { backup_warning, @@ -332,7 +332,7 @@ fn save_project_impl( }; let figure_path = format!("views/{view_id}.snapshot/object_{}.figure.json", object.id); - write_json(&mut zip, options, &figure_path, &plot.figure)?; + write_json(&mut zip, options, &figure_path, plot.figure())?; object.snapshot = Some(ViewSnapshot { kind: SNAPSHOT_KIND.to_owned(), schema_version: SCHEMA_VERSION, diff --git a/crates/core/src/project/persistence_tests.rs b/crates/core/src/project/persistence_tests.rs index aee47b43..7fb4199d 100644 --- a/crates/core/src/project/persistence_tests.rs +++ b/crates/core/src/project/persistence_tests.rs @@ -157,7 +157,7 @@ fn recovery_older_than_a_committed_revision_is_rejected() { let root = dir.join("recovery"); let original = dir.join("project.plotx"); let mut app = crate::state::PlotxApp::new(); - app.session.project_backup_generations = 0; + app.settings.general.project_backup_generations = 0; let first = super::super::save_project(&app, &original, false).unwrap(); app.doc.project_path = Some(original.clone()); app.doc.project_revision = Some(first.revision); diff --git a/crates/core/src/project/pipeline_conv.rs b/crates/core/src/project/pipeline_conv.rs index 4bc1d2f0..813a3219 100644 --- a/crates/core/src/project/pipeline_conv.rs +++ b/crates/core/src/project/pipeline_conv.rs @@ -14,6 +14,75 @@ pub fn pipeline_from_dto(dto: &AxisPipelineDto) -> AxisPipeline { } } +/// Validate persisted cleanup parameters against the exact input each kernel +/// will receive. The error names the stored value and the data-derived bound so +/// a malformed project or scheme never opens into a silently rewritten state. +pub fn validate_1d_pipeline( + data: &plotx_io::NmrData, + pipeline: &AxisPipeline, + group_delay_correct: bool, +) -> std::result::Result<(), String> { + let mut spectrum = plotx_processing::transform_base(data, pipeline, group_delay_correct); + for step in pipeline + .steps + .iter() + .skip_while(|step| step.kind.at_or_before_fft()) + { + match step.kind { + StepKind::Smooth(method) => { + let capped = spectrum.values.len().min(201); + let max_window = if capped.is_multiple_of(2) { + capped.saturating_sub(1) + } else { + capped + }; + let (window, order) = match method { + SmoothMethod::MovingAverage { window } => (usize::from(window), None), + SmoothMethod::SavitzkyGolay { window, poly_order } => { + (usize::from(window), Some(usize::from(poly_order))) + } + }; + if max_window < 3 || window < 3 || window > max_window || window.is_multiple_of(2) { + return Err(format!( + "stored smoothing window {window} is out of range: it must be an odd value between 3 and {max_window} for this {}-point spectrum", + spectrum.values.len() + )); + } + if let Some(order) = order { + let max_order = 8.min(window - 1); + if order < 1 || order > max_order { + return Err(format!( + "stored smoothing polynomial order {order} is out of range: it must be between 1 and {max_order} for window {window}" + )); + } + } + } + StepKind::Normalize(NormalizeMethod::Constant { divisor }) + if !divisor.is_finite() || divisor.abs() <= f64::MIN_POSITIVE => + { + return Err(format!( + "stored normalization divisor {divisor} is out of range: its magnitude must be greater than {}", + f64::MIN_POSITIVE + )); + } + StepKind::Bin(params) => { + let minimum = 1.5 * plotx_processing::cleanup::axis_step(&spectrum.ppm); + if !params.width.is_finite() || params.width <= minimum { + return Err(format!( + "stored bin width {} is out of range: it must be greater than {minimum} for this axis", + params.width + )); + } + } + _ => {} + } + if step.enabled { + plotx_processing::apply_freq_step(&mut spectrum, &step.kind); + } + } + Ok(()) +} + /// Drop step identities from a pipeline destined for a detached recipe /// (`.plotxproc`), which has no owner to make them meaningful. pub fn strip_step_identities(dto: &mut AxisPipelineDto) { diff --git a/crates/core/src/project/pseudo_tests.rs b/crates/core/src/project/pseudo_tests.rs index 01b0e72d..c3057b67 100644 --- a/crates/core/src/project/pseudo_tests.rs +++ b/crates/core/src/project/pseudo_tests.rs @@ -459,7 +459,7 @@ fn a_snapshot_is_not_replayed_when_the_stored_map_could_not_be_restored() { .iter() .find_map(|object| object.plot()) .expect("the saved canvas has a plot") - .figure + .figure() .contours .len(); assert!(saved_contours > 0, "the snapshot must hold DOSY contours"); @@ -476,7 +476,7 @@ fn a_snapshot_is_not_replayed_when_the_stored_map_could_not_be_restored() { .iter() .find_map(|object| object.plot()) .expect("the canvas still has a plot") - .figure + .figure() .contours .len(); assert_eq!( diff --git a/crates/core/src/project/scheme.rs b/crates/core/src/project/scheme.rs index 23702207..ddc03424 100644 --- a/crates/core/src/project/scheme.rs +++ b/crates/core/src/project/scheme.rs @@ -192,7 +192,7 @@ pub fn apply_scheme( dataset: &Dataset, ) -> Result { match dataset { - Dataset::Nmr(_) => { + Dataset::Nmr(n) => { if scheme.dimension_count != 1 { return Err(incompatible("a 1D spectrum needs a single-axis scheme")); } @@ -202,6 +202,8 @@ pub fn apply_scheme( .ok_or_else(|| incompatible("scheme carries no pipeline"))?; require_fft(dto)?; let mut pipeline = pipeline_from_dto(dto); + validate_1d_pipeline(&n.data, &pipeline, scheme.group_delay_correct) + .map_err(ProjectError::Invalid)?; remint_pipeline(&mut pipeline, &mut dataset_next_step_id(dataset)); Ok(DatasetProcessingState::Nmr { pipeline, @@ -237,6 +239,7 @@ pub fn apply_scheme( Ok(DatasetProcessingState::Nmr2D { params, preset: n.preset, + group_delay_correct: scheme.group_delay_correct, }) } Dataset::Table(_) => Err(incompatible("a data table has no processing pipeline")), @@ -268,13 +271,14 @@ fn remint_pipeline(pipeline: &mut AxisPipeline, next: &mut u64) { pub fn reset_processing(dataset: &Dataset) -> Option { let mut state = match dataset { - Dataset::Nmr(_) => Some(DatasetProcessingState::Nmr { + Dataset::Nmr(n) => Some(DatasetProcessingState::Nmr { pipeline: AxisPipeline::default_1d(), - group_delay_correct: true, + group_delay_correct: crate::state::default_group_delay_correct(n.data.domain), }), Dataset::Nmr2D(n) => Some(DatasetProcessingState::Nmr2D { params: Params2D::default_for(n.preset), preset: n.preset, + group_delay_correct: crate::state::default_group_delay_correct(n.data.domain), }), Dataset::Table(_) => None, Dataset::Electrophysiology(_) => None, diff --git a/crates/core/src/project/tests.rs b/crates/core/src/project/tests.rs index fd603594..fa8bf75e 100644 --- a/crates/core/src/project/tests.rs +++ b/crates/core/src/project/tests.rs @@ -112,7 +112,7 @@ pub(super) fn sample_app() -> PlotxApp { let mut object = app.build_plot_object(0, ObjectFrame::new(0.0, 0.0, w, h), id, "Plot 1".to_owned()); let plot = object.plot_mut().unwrap(); - plot.figure = figure; + plot.adopt_rebuilt_figure(figure); plot.viewport = viewport; canvas.selected_object = Some(id); canvas.objects.push(object); @@ -314,7 +314,7 @@ fn project_roundtrip_preserves_data_recipe_and_view() { loaded.doc.canvases[0].objects[0] .plot() .unwrap() - .figure + .figure() .typography, custom_typography ); @@ -347,8 +347,8 @@ fn project_roundtrip_preserves_data_recipe_and_view() { ); assert_eq!(first_plot(&loaded).panel.position, [33.0, 14.0]); assert_eq!(first_plot(&loaded).axis_overrides, axis_overrides); - assert_eq!(first_plot(&loaded).figure.x.label, "Chemical shift"); - assert_eq!(first_plot(&loaded).figure.y.label, "Response"); + assert_eq!(first_plot(&loaded).figure().x.label, "Chemical shift"); + assert_eq!(first_plot(&loaded).figure().y.label, "Response"); assert_eq!( first_plot(&loaded).viewport.full_x, AxisRange::new(1.0, 8.0) @@ -619,7 +619,7 @@ fn project_roundtrip_preserves_overlay_binding() { Some(Color::rgb(10, 20, 30)) ); assert_eq!(binding.series[1].label.as_deref(), Some("treated")); - assert!(first_plot(&loaded).figure.show_legend); + assert!(first_plot(&loaded).figure().show_legend); } #[test] @@ -731,9 +731,11 @@ fn scheme_save_load_apply_roundtrips() { fn snapshot_roundtrip_restores_materialized_figure() { let mut app = sample_app(); let plot = first_plot_mut(&mut app); - plot.figure.x.label = "snapshot-only x label".to_owned(); - plot.figure.x.min = 2.25; - plot.figure.x.max = 3.75; + let mut figure = plot.figure().clone(); + figure.x.label = "snapshot-only x label".to_owned(); + figure.x.min = 2.25; + figure.x.max = 3.75; + plot.adopt_rebuilt_figure(figure); plot.viewport.full_x = AxisRange::new(0.0, 10.0); plot.viewport.view_x = AxisRange::new(5.0, 6.0); let path = temp_project("snapshot"); @@ -744,9 +746,12 @@ fn snapshot_roundtrip_restores_materialized_figure() { let _ = std::fs::remove_file(&path); assert!(loaded.doc.save_include_view_snapshots); - assert_eq!(first_plot(&loaded).figure.x.label, "snapshot-only x label"); - assert_eq!(first_plot(&loaded).figure.x.min, 2.25); - assert_eq!(first_plot(&loaded).figure.x.max, 3.75); + assert_eq!( + first_plot(&loaded).figure().x.label, + "snapshot-only x label" + ); + assert_eq!(first_plot(&loaded).figure().x.min, 2.25); + assert_eq!(first_plot(&loaded).figure().x.max, 3.75); assert_eq!( first_plot(&loaded).viewport.view_x, AxisRange::new(5.0, 6.0) diff --git a/crates/core/src/project/tests_charts.rs b/crates/core/src/project/tests_charts.rs index fa9ab9a0..c002bb55 100644 --- a/crates/core/src/project/tests_charts.rs +++ b/crates/core/src/project/tests_charts.rs @@ -60,7 +60,7 @@ fn project_roundtrip_preserves_non_default_chart_type() { assert_eq!(chart.type_id, "table_bar"); assert_eq!(chart.column, Some(selected_column)); // The materialised figure is the bar chart (one rectangle per x row). - assert_eq!(first_plot(&loaded).figure.polygons.len(), 3); + assert_eq!(first_plot(&loaded).figure().polygons.len(), 3); } #[test] @@ -103,3 +103,56 @@ fn project_roundtrip_preserves_chart_options() { assert_eq!(chart.view_angles, [-30.0, 55.0]); assert_eq!(chart.column, Some(selected_column)); } + +#[test] +fn catalog_read_preserves_the_empty_follow_default_chart_sentinel_on_resave() { + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Table(Box::new(chart_table()))); + let mut canvas = CanvasDocument::new("table".to_owned(), [120.0, 80.0]); + let id = canvas.allocate_object_id(); + canvas.objects.push(app.build_plot_object( + 0, + ObjectFrame::new(0.0, 0.0, 100.0, 70.0), + id, + "Plot".to_owned(), + )); + canvas + .object_mut(id) + .and_then(|object| object.plot_mut()) + .expect("plot") + .chart + .type_id + .clear(); + app.doc.canvases.push(canvas); + let first_path = temp_project("chart-sentinel-first"); + let second_path = temp_project("chart-sentinel-second"); + let _ = std::fs::remove_file(&first_path); + let _ = std::fs::remove_file(&second_path); + save_project(&app, &first_path, false).unwrap(); + + let loaded = load_project(&first_path).unwrap(); + let target = loaded.object_target(0, id).expect("plot target"); + let resolved = loaded + .resolve_property(&crate::properties::PropertyAddress::new( + target, + crate::properties::object::CHART_TYPE_ID, + )) + .expect("catalog read"); + assert_eq!( + resolved.value, + crate::properties::AggregateValue::Uniform(crate::properties::PropertyValue::Enum( + "table_line" + )) + ); + assert!( + first_plot(&loaded).chart.type_id.is_empty(), + "reading resolves the sentinel for display without materializing it" + ); + save_project(&loaded, &second_path, false).unwrap(); + let resaved = load_project(&second_path).unwrap(); + let _ = std::fs::remove_file(&first_path); + let _ = std::fs::remove_file(&second_path); + assert!(first_plot(&resaved).chart.type_id.is_empty()); +} diff --git a/crates/core/src/properties/apodization.rs b/crates/core/src/properties/apodization.rs index 38f3617e..2afaf6ba 100644 --- a/crates/core/src/properties/apodization.rs +++ b/crates/core/src/properties/apodization.rs @@ -9,14 +9,14 @@ use super::provider::PropertyProvider; use super::target::dataset_steps; use super::{ AggregateValue, Applicability, Availability, ComponentKind, DefaultPolicy, EditOp, EnumVariant, - FloatBounds, PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, PropertyId, - PropertyTransaction, PropertyValue, ResolvedProperty, ResolvedSchema, ScopeKind, Tier, - ValueCopies, ValueSchema, definition, + FloatBounds, FloatDisplay, PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, + PropertyId, PropertyTransaction, PropertyValue, ResolvedProperty, ResolvedSchema, ScopeKind, + Tier, ValueCopies, ValueSchema, definition, }; use crate::actions::DatasetProcessingState; use crate::automation::ComponentRef; use crate::state::{Dataset, DatasetId, PhaseAxis, PlotxApp}; -use plotx_processing::{Apodization, ProcessingStep, StepId, StepKind, StepSource}; +use plotx_processing::{Apodization, ProcessingStep, StepId, StepKind}; pub const KIND: PropertyId = PropertyId("dataset.processing.apodization.kind"); pub const LB_HZ: PropertyId = PropertyId("dataset.processing.apodization.lb_hz"); @@ -85,7 +85,7 @@ pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ scope_kind: ScopeKind::Dataset, value_schema: ValueSchema::Float { bounds: LB_BOUNDS, - log: false, + display: FloatDisplay::Linear("Hz"), drag_step: Some(PARAMETER_STEP), }, access: PropertyAccess::ReadWrite, @@ -104,7 +104,7 @@ pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ scope_kind: ScopeKind::Dataset, value_schema: ValueSchema::Float { bounds: GB_BOUNDS, - log: false, + display: FloatDisplay::Linear("Hz"), drag_step: Some(PARAMETER_STEP), }, access: PropertyAccess::ReadWrite, @@ -137,6 +137,7 @@ impl PropertyProvider for ApodizationProvider { let value = value_of(definition, context.current)?; Ok(ResolvedProperty { address: address.clone(), + modified: None, value: AggregateValue::Uniform(value), default_value: default_value(definition, context.factory)?, availability: Availability::Editable, @@ -149,7 +150,7 @@ impl PropertyProvider for ApodizationProvider { app: &PlotxApp, transaction: &mut PropertyTransaction, address: &PropertyAddress, - operation: EditOp, + operation: &EditOp<'_>, ) -> Result<(), PropertyError> { let definition = property_definition(address.definition)?; let context = context(app, address, definition)?; @@ -265,18 +266,12 @@ fn factory_default( axis: PhaseAxis, step: &ProcessingStep, ) -> Option { - if step.source != StepSource::Default { - return None; - } - dataset - .factory_pipeline(axis)? - .steps - .iter() - .find(|candidate| candidate.id == step.id) - .and_then(|candidate| match candidate.kind { + super::processing_common::factory_step(dataset, axis, step).and_then( + |candidate| match candidate.kind { StepKind::Apodize(apodization) => Some(apodization), _ => None, - }) + }, + ) } fn value_of( @@ -297,7 +292,7 @@ fn default_value( definition: &'static PropertyDefinition, factory: Option, ) -> Result, PropertyError> { - match definition.default_policy { + match &definition.default_policy { DefaultPolicy::ProcessingFactory => { let Some(factory) = factory else { return Ok(None); @@ -321,11 +316,13 @@ fn default_value( )), } } - DefaultPolicy::Fixed(value) => Ok(Some(value)), - DefaultPolicy::EncodingFactory | DefaultPolicy::None => Err(PropertyError::InvalidValue { - property: definition.id, - message: "this property has no processing default".to_owned(), - }), + DefaultPolicy::Fixed(value) => Ok(Some(value.clone())), + DefaultPolicy::EncodingFactory | DefaultPolicy::Derived | DefaultPolicy::None => { + Err(PropertyError::InvalidValue { + property: definition.id, + message: "this property has no processing default".to_owned(), + }) + } } } @@ -363,17 +360,19 @@ fn parameter_bounds(definition: &'static PropertyDefinition) -> FloatBounds { } fn parameter_schema(definition: &'static PropertyDefinition) -> ResolvedSchema { + let ValueSchema::Float { display, .. } = definition.value_schema else { + unreachable!("apodization parameters are declared as floats"); + }; ResolvedSchema::Float { bounds: parameter_bounds(definition), - log: false, - unit: "Hz", + display, } } fn checked_value( definition: &'static PropertyDefinition, apodization: Apodization, - value: PropertyValue, + value: &PropertyValue, ) -> Result { match definition.id { KIND => match value { @@ -393,9 +392,9 @@ fn checked_value( parameter_bounds(definition).check( definition.id, definition.canonical_label, - value, + *value, )?; - Ok(PropertyValue::Float(value)) + Ok(PropertyValue::Float(*value)) } value => wrong_kind(definition, value, "a number"), } @@ -408,7 +407,7 @@ fn checked_value( fn wrong_kind( definition: &'static PropertyDefinition, - value: PropertyValue, + value: &PropertyValue, expected: &str, ) -> Result { Err(PropertyError::InvalidValue { diff --git a/crates/core/src/properties/apodization_tests.rs b/crates/core/src/properties/apodization_tests.rs index 2d0ba6c0..7147befc 100644 --- a/crates/core/src/properties/apodization_tests.rs +++ b/crates/core/src/properties/apodization_tests.rs @@ -1,5 +1,6 @@ //! Dataset processing-step catalog slice. +use super::processing_test_support::{states_2d_app, target_for_axis}; use super::*; use crate::actions::Action; use crate::automation::{ComponentRef, ResourceRef, TargetRef}; @@ -139,7 +140,10 @@ fn apodization_step_has_a_dependent_schema_and_a_typed_processing_action() { ); assert!(matches!( resolved_lb.schema, - ResolvedSchema::Float { unit: "Hz", .. } + ResolvedSchema::Float { + display: FloatDisplay::Linear("Hz"), + .. + } )); assert!(matches!( app.resolve_property(&gb), @@ -159,7 +163,10 @@ fn apodization_step_has_a_dependent_schema_and_a_typed_processing_action() { app.resolve_property(&gb) .expect("GB appears only for Gaussian") .schema, - ResolvedSchema::Float { unit: "Hz", .. } + ResolvedSchema::Float { + display: FloatDisplay::Linear("Hz"), + .. + } )); for (property, value) in [ @@ -475,6 +482,41 @@ fn gaussian_broadening_is_open_at_zero_while_line_broadening_keeps_both_signs() ); } +#[test] +fn states_f1_default_apodization_resolves_and_resets_by_provenance_not_step_id() { + let mut app = states_2d_app(10, 6); + let target = target_for_axis(&app, PhaseAxis::F1, |kind| { + matches!(kind, StepKind::Apodize(_)) + }); + let resolved = app + .resolve_property(&PropertyAddress::new(target.clone(), apodization::KIND)) + .expect("the reminted F1 default resolves"); + assert_eq!( + resolved.default_value, + Some(PropertyValue::Enum(apodization::APODIZATION_COSINE_BELL)) + ); + + let changed = app + .plan_property_write( + apodization::KIND, + std::slice::from_ref(&target), + &PropertyValue::Enum(apodization::APODIZATION_EXPONENTIAL), + ) + .expect("F1 apodization changes"); + app.commit_property(changed); + let reset = app + .plan_property_reset(apodization::KIND, std::slice::from_ref(&target)) + .expect("F1 reset plans through the real catalog entry"); + assert_eq!(reset.applied.len(), 1); + app.commit_property(reset); + assert_eq!( + app.resolve_property(&PropertyAddress::new(target, apodization::KIND)) + .expect("the reset F1 step resolves") + .value, + AggregateValue::Uniform(PropertyValue::Enum(apodization::APODIZATION_COSINE_BELL)) + ); +} + /// A sentence a user reads names the choice the way the control names it. The /// wire id is what the value is stored and transmitted under, and putting it in /// prose leaks an identifier into the interface. diff --git a/crates/core/src/properties/app_preferences.rs b/crates/core/src/properties/app_preferences.rs new file mode 100644 index 00000000..4b497a73 --- /dev/null +++ b/crates/core/src/properties/app_preferences.rs @@ -0,0 +1,454 @@ +//! Persistent application preferences exposed through the property catalog. + +use super::provider::PropertyProvider; +use super::target::require_app_target; +use super::{ + AggregateValue, Applicability, Availability, ComponentKind, DefaultPolicy, EditOp, EnumVariant, + PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, PropertyId, + PropertyTransaction, PropertyValue, ResolvedProperty, ResolvedSchema, ScopeKind, Tier, + ValueCopies, ValueSchema, definition, +}; +use crate::settings::{GraphicsPowerPreference, MAX_PROJECT_BACKUP_GENERATIONS, ThemeMode}; +use crate::state::PlotxApp; +use crate::update::{UpdateChannel, UpdateChannelSetting}; +use plotx_figure::Color; + +pub const SNAP_ENABLED: PropertyId = PropertyId("settings.general.snap_enabled"); +pub const KEEP_EMPTY_SOURCE_CANVAS: PropertyId = + PropertyId("settings.general.keep_empty_source_canvas"); +pub const PROJECT_BACKUP_GENERATIONS: PropertyId = + PropertyId("settings.general.project_backup_generations"); +pub const THEME: PropertyId = PropertyId("settings.appearance.theme"); +pub const GRAPHICS_POWER: PropertyId = PropertyId("settings.appearance.graphics_power"); +pub const ACCENT_COLOR: PropertyId = PropertyId("settings.appearance.accent.color"); +pub const INCLUDE_VIEW_SNAPSHOTS: PropertyId = PropertyId("settings.export.include_view_snapshots"); +pub const TRIM_TO_VISIBLE_CONTENT: PropertyId = + PropertyId("settings.export.trim_to_visible_content"); +pub const SCALE_CONTENT: PropertyId = PropertyId("settings.canvas_size.scale_content"); +pub const AUTO_CHECK_UPDATES: PropertyId = PropertyId("settings.updates.auto_check"); +pub const UPDATE_CHANNEL: PropertyId = PropertyId("settings.updates.channel"); + +pub const THEME_SYSTEM: &str = "system"; +pub const THEME_LIGHT: &str = "light"; +pub const THEME_DARK: &str = "dark"; +pub const GRAPHICS_LOW_POWER: &str = "low_power"; +pub const GRAPHICS_HIGH_PERFORMANCE: &str = "high_performance"; +pub const UPDATE_AUTO: &str = "auto"; +pub const UPDATE_STABLE: &str = "stable"; +pub const UPDATE_BETA: &str = "beta"; +pub const UPDATE_ALPHA: &str = "alpha"; + +/// Core reports this derived default as a headless-environment substitute, +/// because no live theme colour exists there. The desktop presentation layer +/// replaces it with the current theme colour. +pub const ACCENT_PLACEHOLDER: Color = Color::BLACK; + +const THEME_VARIANTS: &[EnumVariant] = &[ + EnumVariant::new(THEME_SYSTEM, "Follow system"), + EnumVariant::new(THEME_LIGHT, "Light"), + EnumVariant::new(THEME_DARK, "Dark"), +]; +const GRAPHICS_VARIANTS: &[EnumVariant] = &[ + EnumVariant::new(GRAPHICS_LOW_POWER, "Power saving (integrated GPU)"), + EnumVariant::new(GRAPHICS_HIGH_PERFORMANCE, "High performance (discrete GPU)"), +]; +const UPDATE_VARIANTS: &[EnumVariant] = &[ + EnumVariant::new(UPDATE_AUTO, "Follow build"), + EnumVariant::new(UPDATE_STABLE, "Stable"), + EnumVariant::new(UPDATE_BETA, "Beta"), + EnumVariant::new(UPDATE_ALPHA, "Alpha"), +]; +const UPDATE_VARIANTS_STABLE: &[EnumVariant] = &[ + EnumVariant::new(UPDATE_AUTO, "Follow build (stable)"), + EnumVariant::new(UPDATE_STABLE, "Stable"), + EnumVariant::new(UPDATE_BETA, "Beta"), + EnumVariant::new(UPDATE_ALPHA, "Alpha"), +]; +const UPDATE_VARIANTS_BETA: &[EnumVariant] = &[ + EnumVariant::new(UPDATE_AUTO, "Follow build (beta)"), + EnumVariant::new(UPDATE_STABLE, "Stable"), + EnumVariant::new(UPDATE_BETA, "Beta"), + EnumVariant::new(UPDATE_ALPHA, "Alpha"), +]; +const UPDATE_VARIANTS_ALPHA: &[EnumVariant] = &[ + EnumVariant::new(UPDATE_AUTO, "Follow build (alpha)"), + EnumVariant::new(UPDATE_STABLE, "Stable"), + EnumVariant::new(UPDATE_BETA, "Beta"), + EnumVariant::new(UPDATE_ALPHA, "Alpha"), +]; + +const fn app_definition( + id: PropertyId, + value_schema: ValueSchema, + default_policy: DefaultPolicy, + canonical_label: &'static str, + canonical_aliases: &'static [&'static str], +) -> PropertyDefinition { + PropertyDefinition { + id, + scope_kind: ScopeKind::App, + value_schema, + access: PropertyAccess::ReadWrite, + applicability: Applicability::component(ComponentKind::None), + default_policy, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label, + canonical_aliases, + } +} + +pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ + app_definition( + SNAP_ENABLED, + ValueSchema::Bool, + DefaultPolicy::Fixed(PropertyValue::Bool(true)), + "Object snapping", + &["snap", "snap to guides"], + ), + app_definition( + KEEP_EMPTY_SOURCE_CANVAS, + ValueSchema::Bool, + DefaultPolicy::Fixed(PropertyValue::Bool(false)), + "Keep empty source canvas", + &["keep source canvas", "empty canvas after tiling"], + ), + app_definition( + PROJECT_BACKUP_GENERATIONS, + ValueSchema::Int { + min: 0, + max: MAX_PROJECT_BACKUP_GENERATIONS as i64, + }, + DefaultPolicy::Fixed(PropertyValue::Int(1)), + "Project backup copies", + &["backup generations", "previous project saves"], + ), + app_definition( + THEME, + ValueSchema::Enum { + variants: THEME_VARIANTS, + }, + DefaultPolicy::Fixed(PropertyValue::Enum(THEME_SYSTEM)), + "Chrome theme", + &["appearance theme", "light mode", "dark mode"], + ), + app_definition( + GRAPHICS_POWER, + ValueSchema::Enum { + variants: GRAPHICS_VARIANTS, + }, + DefaultPolicy::Fixed(PropertyValue::Enum(GRAPHICS_LOW_POWER)), + "Graphics processor", + &["GPU preference", "graphics power"], + ), + app_definition( + ACCENT_COLOR, + ValueSchema::Color, + DefaultPolicy::Derived, + "Canvas accent", + &["selection colour", "guide color", "accent color"], + ), + app_definition( + INCLUDE_VIEW_SNAPSHOTS, + ValueSchema::Bool, + DefaultPolicy::Fixed(PropertyValue::Bool(false)), + "Embed view snapshots", + &["save view snapshots", "project snapshots"], + ), + app_definition( + TRIM_TO_VISIBLE_CONTENT, + ValueSchema::Bool, + DefaultPolicy::Fixed(PropertyValue::Bool(false)), + "Trim to visible content", + &["trim export", "remove page whitespace"], + ), + app_definition( + SCALE_CONTENT, + ValueSchema::Bool, + DefaultPolicy::Fixed(PropertyValue::Bool(false)), + "Scale content with page size", + &["scale page content", "resize canvas content"], + ), + app_definition( + AUTO_CHECK_UPDATES, + ValueSchema::Bool, + DefaultPolicy::Fixed(PropertyValue::Bool(true)), + "Automatic updates", + &["check for updates", "background updates"], + ), + app_definition( + UPDATE_CHANNEL, + ValueSchema::Enum { + variants: UPDATE_VARIANTS, + }, + DefaultPolicy::Fixed(PropertyValue::Enum(UPDATE_AUTO)), + "Update channel", + &["release channel", "update train"], + ), +]; + +pub(crate) struct AppPreferencesProvider; +pub(crate) static PROVIDER: AppPreferencesProvider = AppPreferencesProvider; + +impl PropertyProvider for AppPreferencesProvider { + 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)?; + let default_value = if definition.id == ACCENT_COLOR { + Some(PropertyValue::Color(ACCENT_PLACEHOLDER)) + } else { + fixed_default(definition) + }; + Ok(ResolvedProperty { + address: address.clone(), + modified: (definition.id == ACCENT_COLOR) + .then_some(app.settings.appearance.canvas_accent.is_some()), + value: AggregateValue::Uniform(value_of(app, definition.id)?), + default_value, + availability: Availability::Editable, + schema: resolved_schema(definition), + }) + } + + 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)?; + if definition.id == ACCENT_COLOR && matches!(operation, EditOp::Reset) { + transaction.app_preferences(app).appearance.canvas_accent = None; + return Ok(()); + } + let value = match operation { + EditOp::Set(value) => checked_value(definition, value)?, + EditOp::Reset => { + fixed_default(definition).ok_or_else(|| PropertyError::InvalidValue { + property: definition.id, + message: "this preference has no reset value".to_owned(), + })? + } + EditOp::Step(_) => { + return Err(PropertyError::InvalidValue { + property: definition.id, + message: "this preference has no step gesture".to_owned(), + }); + } + }; + write_value(transaction.app_preferences(app), definition.id, value) + } +} + +fn property_definition(id: PropertyId) -> Result<&'static PropertyDefinition, PropertyError> { + definition(id).ok_or_else(|| PropertyError::UnknownProperty(id.as_str().to_owned())) +} + +fn fixed_default(definition: &'static PropertyDefinition) -> Option { + match &definition.default_policy { + DefaultPolicy::Fixed(value) => Some(value.clone()), + DefaultPolicy::EncodingFactory + | DefaultPolicy::ProcessingFactory + | DefaultPolicy::Derived + | DefaultPolicy::None => None, + } +} + +fn resolved_schema(definition: &'static PropertyDefinition) -> ResolvedSchema { + if definition.id == UPDATE_CHANNEL { + let variants = match UpdateChannel::built_in() { + UpdateChannel::Stable => UPDATE_VARIANTS_STABLE, + UpdateChannel::Beta => UPDATE_VARIANTS_BETA, + UpdateChannel::Alpha => UPDATE_VARIANTS_ALPHA, + }; + return ResolvedSchema::Enum { + variants: variants.iter().collect(), + }; + } + match definition.value_schema { + ValueSchema::Bool => ResolvedSchema::Bool, + ValueSchema::Int { min, max } => ResolvedSchema::Int { min, max, unit: "" }, + ValueSchema::Enum { variants } => ResolvedSchema::Enum { + variants: variants.iter().collect(), + }, + ValueSchema::Color => ResolvedSchema::Color, + ValueSchema::Text + | ValueSchema::IntWithDrag { .. } + | ValueSchema::SteppedInt { .. } + | ValueSchema::Float { .. } => { + unreachable!("application preference definitions use bool, int, enum, or color") + } + } +} + +fn value_of(app: &PlotxApp, id: PropertyId) -> Result { + let settings = &app.settings; + Ok(match id { + SNAP_ENABLED => PropertyValue::Bool(settings.general.snap_enabled), + KEEP_EMPTY_SOURCE_CANVAS => PropertyValue::Bool(settings.general.keep_empty_source_canvas), + PROJECT_BACKUP_GENERATIONS => { + PropertyValue::Int(i64::from(settings.general.project_backup_generations)) + } + THEME => PropertyValue::Enum(theme_key(settings.appearance.theme)), + GRAPHICS_POWER => PropertyValue::Enum(graphics_key(settings.appearance.graphics_power)), + ACCENT_COLOR => { + let [r, g, b] = settings.appearance.canvas_accent.unwrap_or([ + ACCENT_PLACEHOLDER.r, + ACCENT_PLACEHOLDER.g, + ACCENT_PLACEHOLDER.b, + ]); + PropertyValue::Color(Color::rgb(r, g, b)) + } + INCLUDE_VIEW_SNAPSHOTS => PropertyValue::Bool(settings.export.include_view_snapshots), + TRIM_TO_VISIBLE_CONTENT => PropertyValue::Bool(settings.export.trim_to_visible_content), + SCALE_CONTENT => PropertyValue::Bool(settings.canvas_size.scale_content), + AUTO_CHECK_UPDATES => PropertyValue::Bool(settings.updates.auto_check), + UPDATE_CHANNEL => PropertyValue::Enum(update_key(settings.updates.channel)), + _ => return Err(PropertyError::UnknownProperty(id.to_string())), + }) +} + +fn checked_value( + definition: &'static PropertyDefinition, + value: &PropertyValue, +) -> Result { + match (definition.value_schema, value) { + (ValueSchema::Bool, PropertyValue::Bool(value)) => Ok(PropertyValue::Bool(*value)), + (ValueSchema::Int { min, max }, PropertyValue::Int(value)) + if (min..=max).contains(value) => + { + Ok(PropertyValue::Int(*value)) + } + (ValueSchema::Int { min, max }, PropertyValue::Int(value)) => { + Err(PropertyError::InvalidValue { + property: definition.id, + message: format!( + "{} {value} is out of range: it must be between {min} and {max}", + definition.canonical_label + ), + }) + } + (ValueSchema::Enum { variants }, PropertyValue::Enum(value)) + if variants.iter().any(|variant| variant.id == *value) => + { + Ok(PropertyValue::Enum(value)) + } + (ValueSchema::Color, PropertyValue::Color(value)) => Ok(PropertyValue::Color(*value)), + (_, value) => Err(PropertyError::InvalidValue { + property: definition.id, + message: format!( + "{} does not accept {}", + definition.canonical_label, + value.kind() + ), + }), + } +} + +fn write_value( + settings: &mut crate::settings::Settings, + id: PropertyId, + value: PropertyValue, +) -> Result<(), PropertyError> { + match (id, value) { + (SNAP_ENABLED, PropertyValue::Bool(value)) => settings.general.snap_enabled = value, + (KEEP_EMPTY_SOURCE_CANVAS, PropertyValue::Bool(value)) => { + settings.general.keep_empty_source_canvas = value + } + (PROJECT_BACKUP_GENERATIONS, PropertyValue::Int(value)) => { + settings.general.project_backup_generations = value as u8 + } + (THEME, PropertyValue::Enum(value)) => { + settings.appearance.theme = theme(value).expect("validated theme") + } + (GRAPHICS_POWER, PropertyValue::Enum(value)) => { + settings.appearance.graphics_power = graphics(value).expect("validated graphics power") + } + (ACCENT_COLOR, PropertyValue::Color(value)) => { + settings.appearance.canvas_accent = Some([value.r, value.g, value.b]) + } + (INCLUDE_VIEW_SNAPSHOTS, PropertyValue::Bool(value)) => { + settings.export.include_view_snapshots = value + } + (TRIM_TO_VISIBLE_CONTENT, PropertyValue::Bool(value)) => { + settings.export.trim_to_visible_content = value + } + (SCALE_CONTENT, PropertyValue::Bool(value)) => settings.canvas_size.scale_content = value, + (AUTO_CHECK_UPDATES, PropertyValue::Bool(value)) => settings.updates.auto_check = value, + (UPDATE_CHANNEL, PropertyValue::Enum(value)) => { + settings.updates.channel = update(value).expect("validated update channel") + } + _ => { + return Err(PropertyError::InvalidValue { + property: id, + message: "the validated preference value changed shape".to_owned(), + }); + } + } + Ok(()) +} + +fn theme_key(value: ThemeMode) -> &'static str { + match value { + ThemeMode::System => THEME_SYSTEM, + ThemeMode::Light => THEME_LIGHT, + ThemeMode::Dark => THEME_DARK, + } +} + +fn theme(value: &str) -> Option { + match value { + THEME_SYSTEM => Some(ThemeMode::System), + THEME_LIGHT => Some(ThemeMode::Light), + THEME_DARK => Some(ThemeMode::Dark), + _ => None, + } +} + +fn graphics_key(value: GraphicsPowerPreference) -> &'static str { + match value { + GraphicsPowerPreference::LowPower => GRAPHICS_LOW_POWER, + GraphicsPowerPreference::HighPerformance => GRAPHICS_HIGH_PERFORMANCE, + } +} + +fn graphics(value: &str) -> Option { + match value { + GRAPHICS_LOW_POWER => Some(GraphicsPowerPreference::LowPower), + GRAPHICS_HIGH_PERFORMANCE => Some(GraphicsPowerPreference::HighPerformance), + _ => None, + } +} + +fn update_key(value: UpdateChannelSetting) -> &'static str { + match value { + UpdateChannelSetting::Auto => UPDATE_AUTO, + UpdateChannelSetting::Stable => UPDATE_STABLE, + UpdateChannelSetting::Beta => UPDATE_BETA, + UpdateChannelSetting::Alpha => UPDATE_ALPHA, + } +} + +fn update(value: &str) -> Option { + match value { + UPDATE_AUTO => Some(UpdateChannelSetting::Auto), + UPDATE_STABLE => Some(UpdateChannelSetting::Stable), + UPDATE_BETA => Some(UpdateChannelSetting::Beta), + UPDATE_ALPHA => Some(UpdateChannelSetting::Alpha), + _ => None, + } +} + +#[cfg(test)] +#[path = "app_preferences_tests.rs"] +mod tests; diff --git a/crates/core/src/properties/app_preferences_tests.rs b/crates/core/src/properties/app_preferences_tests.rs new file mode 100644 index 00000000..e0ba6f63 --- /dev/null +++ b/crates/core/src/properties/app_preferences_tests.rs @@ -0,0 +1,210 @@ +use super::*; +use crate::properties::{AggregateValue, PropertyCommit}; +use crate::settings::Settings; +use std::path::PathBuf; + +fn plan(app: &PlotxApp, property: PropertyId, value: PropertyValue) -> PropertyCommit { + app.plan_property_write(property, std::slice::from_ref(&app.app_target()), &value) + .unwrap_or_else(|error| panic!("{property}: {error}")) +} + +fn commit(app: &mut PlotxApp, property: PropertyId, value: PropertyValue) { + let planned = plan(app, property, value); + assert_eq!( + app.commit_property_with_settings_writer(planned, |_| Ok(())), + 1 + ); +} + +fn temp_project(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-app-preference-{name}-{}.plotx", + std::process::id() + )) +} + +#[test] +fn catalog_snap_edit_survives_project_save() { + let path = temp_project("snap-save"); + if path.exists() { + std::fs::remove_file(&path).expect("remove stale project"); + } + let mut app = PlotxApp::new_with_settings(Settings::default()); + commit(&mut app, SNAP_ENABLED, PropertyValue::Bool(false)); + assert!(!app.settings.general.snap_enabled); + + assert!(app.save_project_to(&path, false), "project save succeeds"); + if path.exists() { + std::fs::remove_file(&path).expect("remove project"); + } + assert!( + !app.settings.general.snap_enabled, + "saving a project must not restore a stale session mirror" + ); +} + +#[test] +fn app_preference_write_is_not_document_undo() { + let mut app = PlotxApp::new_with_settings(Settings::default()); + let undo = app.session.undo_stack.len(); + let dirty = app.doc.dirty; + commit( + &mut app, + KEEP_EMPTY_SOURCE_CANVAS, + PropertyValue::Bool(true), + ); + assert!(app.settings.general.keep_empty_source_canvas); + assert_eq!(app.session.undo_stack.len(), undo); + assert_eq!(app.doc.dirty, dirty); +} + +#[test] +fn accent_color_reports_the_headless_derived_default() { + let app = PlotxApp::new_with_settings(Settings::default()); + let address = PropertyAddress::new(app.app_target(), ACCENT_COLOR); + let resolved = app.resolve_property(&address).expect("accent resolves"); + assert_eq!( + resolved.value, + AggregateValue::Uniform(PropertyValue::Color(ACCENT_PLACEHOLDER)) + ); + assert_eq!( + resolved.default_value, + Some(PropertyValue::Color(ACCENT_PLACEHOLDER)) + ); + assert!(!resolved.is_modified()); + assert_eq!(resolved.availability, Availability::Editable); +} + +#[test] +fn accent_color_set_and_reset_use_one_catalog_operation_each() { + let mut app = PlotxApp::new_with_settings(Settings::default()); + let write = app + .plan_property_write( + ACCENT_COLOR, + std::slice::from_ref(&app.app_target()), + &PropertyValue::Color(Color::rgb(12, 34, 56)), + ) + .expect("accent write plans"); + assert_eq!(write.applied.len(), 1); + assert_eq!( + app.commit_property_with_settings_writer(write, |_| Ok(())), + 1 + ); + assert_eq!(app.settings.appearance.canvas_accent, Some([12, 34, 56])); + + let reset = app + .plan_property_reset(ACCENT_COLOR, std::slice::from_ref(&app.app_target())) + .expect("accent reset plans"); + assert_eq!(reset.applied.len(), 1); + assert_eq!( + app.commit_property_with_settings_writer(reset, |_| Ok(())), + 1 + ); + assert_eq!(app.settings.appearance.canvas_accent, None); +} + +#[test] +fn backup_bound_rejects_the_value_and_names_the_actual_limit() { + let app = PlotxApp::new_with_settings(Settings::default()); + let rejected = i64::from(MAX_PROJECT_BACKUP_GENERATIONS) + 1; + let error = app + .plan_property_write( + PROJECT_BACKUP_GENERATIONS, + std::slice::from_ref(&app.app_target()), + &PropertyValue::Int(rejected), + ) + .expect_err("the declared bound is enforced by the provider"); + let message = error.to_string(); + assert!(message.contains(&rejected.to_string()), "{message}"); + assert!( + message.contains(&MAX_PROJECT_BACKUP_GENERATIONS.to_string()), + "{message}" + ); +} + +#[test] +fn all_eleven_app_preferences_reset_through_their_catalog_definitions() { + let mut settings = Settings::default(); + settings.general.snap_enabled = false; + settings.general.keep_empty_source_canvas = true; + settings.general.project_backup_generations = MAX_PROJECT_BACKUP_GENERATIONS; + settings.appearance.theme = ThemeMode::Dark; + settings.appearance.graphics_power = GraphicsPowerPreference::HighPerformance; + settings.appearance.canvas_accent = Some([12, 34, 56]); + settings.export.include_view_snapshots = true; + settings.export.trim_to_visible_content = true; + settings.canvas_size.scale_content = true; + settings.updates.auto_check = false; + settings.updates.channel = UpdateChannelSetting::Beta; + let mut app = PlotxApp::new_with_settings(settings); + + for property in [ + SNAP_ENABLED, + KEEP_EMPTY_SOURCE_CANVAS, + PROJECT_BACKUP_GENERATIONS, + THEME, + GRAPHICS_POWER, + ACCENT_COLOR, + INCLUDE_VIEW_SNAPSHOTS, + TRIM_TO_VISIBLE_CONTENT, + SCALE_CONTENT, + AUTO_CHECK_UPDATES, + UPDATE_CHANNEL, + ] { + let planned = app + .plan_property_reset(property, std::slice::from_ref(&app.app_target())) + .unwrap_or_else(|error| panic!("{property}: {error}")); + assert_eq!(planned.applied.len(), 1, "{property}"); + assert_eq!( + app.commit_property_with_settings_writer(planned, |_| Ok(())), + 1, + "{property}" + ); + } + + let defaults = Settings::default(); + assert_eq!(app.settings.general, defaults.general); + assert_eq!(app.settings.appearance.theme, defaults.appearance.theme); + assert_eq!( + app.settings.appearance.graphics_power, + defaults.appearance.graphics_power + ); + assert_eq!(app.settings.appearance.canvas_accent, None); + assert_eq!(app.settings.export, defaults.export); + assert_eq!( + app.settings.canvas_size.scale_content, + defaults.canvas_size.scale_content + ); + assert_eq!(app.settings.updates, defaults.updates); +} + +#[test] +fn snap_catalog_and_legacy_setter_share_one_authoritative_value_and_clear_guides() { + let mut app = PlotxApp::new_with_settings(Settings::default()); + app.session.ui.snap_guides.push(crate::layout::SnapGuide { + vertical: true, + pos: 12.0, + }); + + commit(&mut app, SNAP_ENABLED, PropertyValue::Bool(false)); + assert!(!app.settings.general.snap_enabled); + assert!(app.session.ui.snap_guides.is_empty()); + assert_eq!( + app.resolve_property(&PropertyAddress::new(app.app_target(), SNAP_ENABLED)) + .expect("snap resolves") + .value, + AggregateValue::Uniform(PropertyValue::Bool(false)) + ); + + app.set_snap_enabled(true); + assert!(app.settings.general.snap_enabled); + assert_eq!( + app.resolve_property(&PropertyAddress::new(app.app_target(), SNAP_ENABLED)) + .expect("snap resolves after the toolbar setter") + .value, + AggregateValue::Uniform(PropertyValue::Bool(true)) + ); +} diff --git a/crates/core/src/properties/axis.rs b/crates/core/src/properties/axis.rs new file mode 100644 index 00000000..5b2ce9aa --- /dev/null +++ b/crates/core/src/properties/axis.rs @@ -0,0 +1,218 @@ +//! Plot-object axis labels and visibility overrides. + +use super::provider::PropertyProvider; +use super::target::{require_plot_object_target, resolved_schema}; +use super::{ + AggregateValue, Applicability, Availability, ComponentKind, DefaultPolicy, EditOp, + PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, PropertyId, + PropertyTransaction, PropertyValue, ResolvedProperty, ScopeKind, Tier, ValueCopies, + ValueSchema, +}; +use crate::state::{FieldCapabilities, PlotObject, PlotxApp}; +use plotx_figure::AxisFrame; + +pub const X_LABEL: PropertyId = PropertyId("object.axes.x_label"); +pub const Y_LABEL: PropertyId = PropertyId("object.axes.y_label"); +pub const X_SHOW_TICK_LABELS: PropertyId = PropertyId("object.axes.x_show_tick_labels"); +pub const X_SHOW_LABEL: PropertyId = PropertyId("object.axes.x_show_label"); +pub const Y_SHOW_TICK_LABELS: PropertyId = PropertyId("object.axes.y_show_tick_labels"); +pub const Y_SHOW_LABEL: PropertyId = PropertyId("object.axes.y_show_label"); + +const OBJECT: Applicability = Applicability::component(ComponentKind::None); + +const fn axis_definition( + id: PropertyId, + value_schema: ValueSchema, + canonical_label: &'static str, + canonical_aliases: &'static [&'static str], +) -> PropertyDefinition { + PropertyDefinition { + id, + scope_kind: ScopeKind::Object, + value_schema, + access: PropertyAccess::ReadWrite, + applicability: OBJECT, + default_policy: DefaultPolicy::Derived, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label, + canonical_aliases, + } +} + +pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ + axis_definition(X_LABEL, ValueSchema::Text, "X-axis title", &["x label"]), + axis_definition(Y_LABEL, ValueSchema::Text, "Y-axis title", &["y label"]), + axis_definition( + X_SHOW_TICK_LABELS, + ValueSchema::Bool, + "Show x-axis tick labels", + &["x ticks", "x tick labels"], + ), + axis_definition( + X_SHOW_LABEL, + ValueSchema::Bool, + "Show x-axis title", + &["x title visibility"], + ), + axis_definition( + Y_SHOW_TICK_LABELS, + ValueSchema::Bool, + "Show y-axis tick labels", + &["y ticks", "y tick labels"], + ), + axis_definition( + Y_SHOW_LABEL, + ValueSchema::Bool, + "Show y-axis title", + &["y title visibility"], + ), +]; + +pub(crate) struct AxisProvider; + +pub(crate) static PROVIDER: AxisProvider = AxisProvider; + +impl PropertyProvider for AxisProvider { + fn definitions(&self) -> &'static [PropertyDefinition] { + DEFINITIONS + } + + fn read( + &self, + app: &PlotxApp, + address: &PropertyAddress, + ) -> Result { + let definition = property_definition(address.definition)?; + let (canvas, object) = require_plot_object_target(app, &address.target, definition)?; + let plot = app.doc.canvases[canvas] + .object(object) + .and_then(|object| object.plot()) + .ok_or_else(|| PropertyError::UnknownTarget(address.target.describe()))?; + let availability = if matches!(definition.id, X_LABEL | Y_LABEL) + && plot.figure().axis_frame == AxisFrame::Hidden + { + Availability::Disabled("Choose a chart with visible axes to edit axis settings.") + } else { + Availability::Editable + }; + Ok(ResolvedProperty { + address: address.clone(), + value: AggregateValue::Uniform(value_of(definition.id, plot)?), + default_value: Some(default_value(definition.id, plot)?), + modified: Some(has_override(definition.id, plot)?), + availability, + schema: resolved_schema(definition, &FieldCapabilities::default()), + }) + } + + fn edit( + &self, + app: &PlotxApp, + transaction: &mut PropertyTransaction, + address: &PropertyAddress, + operation: &EditOp<'_>, + ) -> Result<(), PropertyError> { + let definition = property_definition(address.definition)?; + let (canvas, object) = require_plot_object_target(app, &address.target, definition)?; + if matches!(definition.id, X_LABEL | Y_LABEL) + && app.doc.canvases[canvas] + .object(object) + .and_then(|object| object.plot()) + .is_some_and(|plot| plot.figure().axis_frame == AxisFrame::Hidden) + { + return Err(PropertyError::NotApplicable( + "Choose a chart with visible axes to edit axis settings.".to_owned(), + )); + } + let overrides = transaction.axis_overrides(app, canvas, object)?; + match (definition.id, operation) { + (X_LABEL, EditOp::Set(PropertyValue::Text(value))) => { + overrides.x_label = Some(value.clone()) + } + (Y_LABEL, EditOp::Set(PropertyValue::Text(value))) => { + overrides.y_label = Some(value.clone()) + } + (X_SHOW_TICK_LABELS, EditOp::Set(PropertyValue::Bool(value))) => { + overrides.x_show_tick_labels = Some(*value) + } + (X_SHOW_LABEL, EditOp::Set(PropertyValue::Bool(value))) => { + overrides.x_show_label = Some(*value) + } + (Y_SHOW_TICK_LABELS, EditOp::Set(PropertyValue::Bool(value))) => { + overrides.y_show_tick_labels = Some(*value) + } + (Y_SHOW_LABEL, EditOp::Set(PropertyValue::Bool(value))) => { + overrides.y_show_label = Some(*value) + } + (X_LABEL, EditOp::Reset) => overrides.x_label = None, + (Y_LABEL, EditOp::Reset) => overrides.y_label = None, + (X_SHOW_TICK_LABELS, EditOp::Reset) => overrides.x_show_tick_labels = None, + (X_SHOW_LABEL, EditOp::Reset) => overrides.x_show_label = None, + (Y_SHOW_TICK_LABELS, EditOp::Reset) => overrides.y_show_tick_labels = None, + (Y_SHOW_LABEL, EditOp::Reset) => overrides.y_show_label = None, + (_, EditOp::Step(_)) => { + return Err(PropertyError::InvalidValue { + property: definition.id, + message: "axis settings have no step gesture".to_owned(), + }); + } + (_, EditOp::Set(value)) => { + return Err(PropertyError::InvalidValue { + property: definition.id, + message: format!( + "{} does not accept a value of kind {}", + definition.canonical_label, + value.kind() + ), + }); + } + (_, EditOp::Reset) => { + return Err(PropertyError::UnknownProperty( + definition.id.as_str().to_owned(), + )); + } + } + Ok(()) + } +} + +fn property_definition(id: PropertyId) -> Result<&'static PropertyDefinition, PropertyError> { + super::definition(id).ok_or_else(|| PropertyError::UnknownProperty(id.as_str().to_owned())) +} + +fn value_of(id: PropertyId, plot: &PlotObject) -> Result { + match id { + X_LABEL => Ok(PropertyValue::Text(plot.figure().x.label.clone())), + Y_LABEL => Ok(PropertyValue::Text(plot.figure().y.label.clone())), + X_SHOW_TICK_LABELS => Ok(PropertyValue::Bool(plot.figure().x.show_tick_labels)), + X_SHOW_LABEL => Ok(PropertyValue::Bool(plot.figure().x.show_label)), + Y_SHOW_TICK_LABELS => Ok(PropertyValue::Bool(plot.figure().y.show_tick_labels)), + Y_SHOW_LABEL => Ok(PropertyValue::Bool(plot.figure().y.show_label)), + _ => Err(PropertyError::UnknownProperty(id.as_str().to_owned())), + } +} + +fn default_value(id: PropertyId, plot: &PlotObject) -> Result { + match id { + X_LABEL => Ok(PropertyValue::Text(plot.derived_axes().x_label.clone())), + Y_LABEL => Ok(PropertyValue::Text(plot.derived_axes().y_label.clone())), + X_SHOW_TICK_LABELS => Ok(PropertyValue::Bool(plot.derived_axes().x_show_tick_labels)), + X_SHOW_LABEL => Ok(PropertyValue::Bool(plot.derived_axes().x_show_label)), + Y_SHOW_TICK_LABELS => Ok(PropertyValue::Bool(plot.derived_axes().y_show_tick_labels)), + Y_SHOW_LABEL => Ok(PropertyValue::Bool(plot.derived_axes().y_show_label)), + _ => Err(PropertyError::UnknownProperty(id.as_str().to_owned())), + } +} + +fn has_override(id: PropertyId, plot: &PlotObject) -> Result { + match id { + X_LABEL => Ok(plot.axis_overrides.x_label.is_some()), + Y_LABEL => Ok(plot.axis_overrides.y_label.is_some()), + X_SHOW_TICK_LABELS => Ok(plot.axis_overrides.x_show_tick_labels.is_some()), + X_SHOW_LABEL => Ok(plot.axis_overrides.x_show_label.is_some()), + Y_SHOW_TICK_LABELS => Ok(plot.axis_overrides.y_show_tick_labels.is_some()), + Y_SHOW_LABEL => Ok(plot.axis_overrides.y_show_label.is_some()), + _ => Err(PropertyError::UnknownProperty(id.as_str().to_owned())), + } +} diff --git a/crates/core/src/properties/axis_tests.rs b/crates/core/src/properties/axis_tests.rs new file mode 100644 index 00000000..9a98a7ec --- /dev/null +++ b/crates/core/src/properties/axis_tests.rs @@ -0,0 +1,256 @@ +use super::*; +use crate::automation::TargetRef; +use crate::properties::tests::contour_app; +use crate::state::{AxisOverrides, PlotxApp}; + +fn axis_app() -> (PlotxApp, TargetRef, crate::state::ObjectId) { + let (app, series) = contour_app(); + let object = series + .resource + .local_id + .as_deref() + .expect("plot object local id") + .parse() + .expect("object id parses"); + (app, TargetRef::resource(series.resource), object) +} + +fn overrides(app: &PlotxApp, object: crate::state::ObjectId) -> &AxisOverrides { + &app.doc.canvases[0] + .object(object) + .and_then(|object| object.plot()) + .expect("plot") + .axis_overrides +} + +type ResetCase = (PropertyId, fn(&AxisOverrides) -> bool); + +#[test] +fn every_axis_property_reset_clears_its_stored_override() { + let (mut app, target, object) = axis_app(); + app.set_axis_overrides_value( + 0, + object, + &AxisOverrides { + x_label: Some("X".to_owned()), + y_label: Some("Y".to_owned()), + x_show_tick_labels: Some(false), + x_show_label: Some(false), + y_show_tick_labels: Some(false), + y_show_label: Some(false), + ..AxisOverrides::default() + }, + ); + + let cases: &[ResetCase] = &[ + (axis::X_LABEL, |value: &AxisOverrides| { + value.x_label.is_none() + }), + (axis::Y_LABEL, |value: &AxisOverrides| { + value.y_label.is_none() + }), + (axis::X_SHOW_TICK_LABELS, |value: &AxisOverrides| { + value.x_show_tick_labels.is_none() + }), + (axis::X_SHOW_LABEL, |value: &AxisOverrides| { + value.x_show_label.is_none() + }), + (axis::Y_SHOW_TICK_LABELS, |value: &AxisOverrides| { + value.y_show_tick_labels.is_none() + }), + (axis::Y_SHOW_LABEL, |value: &AxisOverrides| { + value.y_show_label.is_none() + }), + ]; + for &(property, cleared) in cases { + let commit = app + .plan_property_reset(property, std::slice::from_ref(&target)) + .expect("axis reset plans"); + app.commit_property(commit); + assert!(cleared(overrides(&app, object)), "{property} did not clear"); + } +} + +#[test] +fn visibility_read_reports_effective_value_and_distinct_derived_default() { + let (mut app, target, object) = axis_app(); + assert!( + app.doc.canvases[0] + .object(object) + .and_then(|object| object.plot()) + .expect("plot") + .derived_axes() + .x_show_tick_labels + ); + app.set_axis_overrides_value( + 0, + object, + &AxisOverrides { + x_show_tick_labels: Some(false), + ..AxisOverrides::default() + }, + ); + + let resolved = app + .resolve_property(&PropertyAddress::new(target, axis::X_SHOW_TICK_LABELS)) + .expect("visibility resolves"); + assert_eq!( + resolved.value, + AggregateValue::Uniform(PropertyValue::Bool(false)) + ); + assert_eq!(resolved.default_value, Some(PropertyValue::Bool(true))); +} + +#[test] +fn label_value_is_effective_while_modified_tracks_override_presence() { + let (mut app, target, object) = axis_app(); + let derived_label = app.doc.canvases[0] + .object(object) + .and_then(|object| object.plot()) + .expect("plot") + .derived_axes() + .x_label + .clone(); + let automatic_label = app + .resolve_property(&PropertyAddress::new(target.clone(), axis::X_LABEL)) + .expect("label resolves"); + assert_eq!( + automatic_label.value, + AggregateValue::Uniform(PropertyValue::Text(derived_label.clone())) + ); + assert_eq!( + automatic_label.default_value, + Some(PropertyValue::Text(derived_label.clone())) + ); + assert!(!automatic_label.is_modified()); + + let commit = app + .plan_property_write( + axis::X_LABEL, + std::slice::from_ref(&target), + &PropertyValue::Text(derived_label.clone()), + ) + .expect("equal explicit override plans"); + app.commit_property(commit); + let explicit = app + .resolve_property(&PropertyAddress::new(target, axis::X_LABEL)) + .expect("label resolves"); + assert_eq!( + explicit.value, + AggregateValue::Uniform(PropertyValue::Text(derived_label.clone())) + ); + assert_eq!( + explicit.default_value, + Some(PropertyValue::Text(derived_label)) + ); + assert!(explicit.is_modified()); +} + +#[test] +fn visibility_modified_tracks_override_presence_even_when_values_match() { + let (mut app, target, object) = axis_app(); + let derived_visibility = app.doc.canvases[0] + .object(object) + .and_then(|object| object.plot()) + .expect("plot") + .derived_axes() + .x_show_label; + let commit = app + .plan_property_write( + axis::X_SHOW_LABEL, + std::slice::from_ref(&target), + &PropertyValue::Bool(derived_visibility), + ) + .expect("equal explicit override plans"); + app.commit_property(commit); + let explicit = app + .resolve_property(&PropertyAddress::new(target, axis::X_SHOW_LABEL)) + .expect("visibility resolves"); + assert!(explicit.is_modified()); +} + +#[test] +fn whitespace_axis_label_uses_the_existing_live_write_normalization() { + let (mut app, target, object) = axis_app(); + let commit = app + .plan_property_write( + axis::X_LABEL, + std::slice::from_ref(&target), + &PropertyValue::Text(" \t ".to_owned()), + ) + .expect("text write plans"); + app.commit_property(commit); + + assert_eq!(overrides(&app, object).x_label, None); + let resolved = app + .resolve_property(&PropertyAddress::new(target, axis::X_LABEL)) + .expect("label resolves"); + assert_eq!( + resolved.value, + resolved + .default_value + .clone() + .map(AggregateValue::Uniform) + .expect("derived label default") + ); + assert!(!resolved.is_modified()); +} + +#[test] +fn one_multi_target_write_makes_two_plot_overrides_agree() { + let (mut app, first_target, first) = axis_app(); + let mut second_object = app.doc.canvases[0] + .object(first) + .expect("first object") + .clone(); + let second = app.doc.canvases[0].allocate_object_id(); + second_object.id = second; + app.doc.canvases[0].objects.push(second_object); + let second_target = app.object_target(0, second).expect("second target"); + + let commit = app + .plan_property_write( + axis::Y_SHOW_LABEL, + &[first_target, second_target], + &PropertyValue::Bool(false), + ) + .expect("multi-target write plans"); + assert_eq!(commit.applied.len(), 2); + app.commit_property(commit); + + assert_eq!(overrides(&app, first).y_show_label, Some(false)); + assert_eq!(overrides(&app, second).y_show_label, Some(false)); +} + +#[test] +fn grouped_visibility_reset_clears_four_overrides_in_one_undo_step() { + let (mut app, target, object) = axis_app(); + let explicit = AxisOverrides { + x_show_tick_labels: Some(false), + x_show_label: Some(false), + y_show_tick_labels: Some(false), + y_show_label: Some(false), + ..AxisOverrides::default() + }; + app.set_axis_overrides_value(0, object, &explicit); + let undo_before = app.session.undo_stack.len(); + + let commit = app + .plan_property_resets( + &[ + axis::X_SHOW_TICK_LABELS, + axis::X_SHOW_LABEL, + axis::Y_SHOW_TICK_LABELS, + axis::Y_SHOW_LABEL, + ], + std::slice::from_ref(&target), + ) + .expect("grouped reset plans"); + assert_eq!(commit.applied.len(), 4); + app.commit_property(commit); + assert_eq!(app.session.undo_stack.len(), undo_before + 1); + assert_eq!(overrides(&app, object), &AxisOverrides::default()); + + app.undo(); + assert_eq!(overrides(&app, object), &explicit); +} diff --git a/crates/core/src/properties/baseline.rs b/crates/core/src/properties/baseline.rs new file mode 100644 index 00000000..186bf62d --- /dev/null +++ b/crates/core/src/properties/baseline.rs @@ -0,0 +1,486 @@ +//! Dataset-owned baseline-step properties. + +use super::processing_common::{ + no_factory_default, no_step_gesture, property_definition, step_context, step_mut, wrong_kind, +}; +use super::provider::PropertyProvider; +use super::{ + AggregateValue, Applicability, Availability, ComponentKind, DefaultPolicy, EditOp, EnumVariant, + FloatBounds, FloatDisplay, PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, + PropertyId, PropertyTransaction, PropertyValue, ResolvedProperty, ResolvedSchema, ScopeKind, + Tier, ValueCopies, ValueSchema, +}; +use crate::state::PlotxApp; +use plotx_processing::{BaselineMethod, StepKind}; + +pub const METHOD: PropertyId = PropertyId("dataset.processing.baseline.method"); +pub const POLYNOMIAL_ORDER: PropertyId = PropertyId("dataset.processing.baseline.polynomial_order"); +pub const SMOOTHNESS: PropertyId = PropertyId("dataset.processing.baseline.smoothness"); +pub const ASYMMETRY: PropertyId = PropertyId("dataset.processing.baseline.asymmetry"); +pub const ITERATIONS: PropertyId = PropertyId("dataset.processing.baseline.iterations"); + +pub const OFFSET: &str = "offset"; +pub const POLYNOMIAL: &str = "polynomial"; +pub const ASYMMETRIC_LEAST_SQUARES: &str = "asymmetric_least_squares"; + +const METHODS: &[EnumVariant] = &[ + EnumVariant::new(OFFSET, "Offset"), + EnumVariant::new(POLYNOMIAL, "Polynomial"), + EnumVariant::new(ASYMMETRIC_LEAST_SQUARES, "Automatic (AsLS)"), +]; +const ORDER_MIN: i64 = 1; +const ORDER_MAX: i64 = 8; +const SMOOTHNESS_BOUNDS: FloatBounds = FloatBounds::inclusive(1.0, 1.0e12); +// The kernel clamps to this effective scientific range. Admitting a wider +// stored value would make the catalog report a number the algorithm did not use. +const ASYMMETRY_BOUNDS: FloatBounds = FloatBounds::inclusive(1.0e-6, 0.5); +const ITERATIONS_MIN: i64 = 1; +const ITERATIONS_MAX: i64 = 100; +/// A polynomial step switched in without a carried order starts at the old +/// editor's order, which is inside [`ORDER_MIN`]–[`ORDER_MAX`]. +pub const POLYNOMIAL_ORDER_SEED: u8 = 2; +/// These are the processing kernel's own AsLS defaults. Each is inside the +/// schema declared for its parameter, so switching method cannot create a step +/// its controls immediately reject. +pub const SMOOTHNESS_SEED: f64 = 5.0e4; +pub const ASYMMETRY_SEED: f64 = 0.001; +pub const ITERATIONS_SEED: u16 = 20; +const STEP: Applicability = Applicability::component(ComponentKind::ProcessingStep); + +pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ + PropertyDefinition { + id: METHOD, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::Enum { variants: METHODS }, + access: PropertyAccess::ReadWrite, + applicability: STEP, + default_policy: DefaultPolicy::ProcessingFactory, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Baseline method", + canonical_aliases: &["baseline correction", "AsLS", "polynomial baseline"], + }, + PropertyDefinition { + id: POLYNOMIAL_ORDER, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::IntWithDrag { + min: ORDER_MIN, + max: ORDER_MAX, + drag_step: 0.1, + }, + access: PropertyAccess::ReadWrite, + applicability: STEP, + default_policy: DefaultPolicy::None, + // Migration preserves the old editor's directly visible order row. + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Baseline polynomial order", + canonical_aliases: &["baseline order", "polynomial degree"], + }, + PropertyDefinition { + id: SMOOTHNESS, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::Float { + bounds: SMOOTHNESS_BOUNDS, + display: FloatDisplay::Log10("λ"), + drag_step: None, + }, + access: PropertyAccess::ReadWrite, + applicability: STEP, + default_policy: DefaultPolicy::ProcessingFactory, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Baseline smoothness", + canonical_aliases: &["AsLS lambda", "baseline lambda", "smoothness"], + }, + PropertyDefinition { + id: ASYMMETRY, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::Float { + bounds: ASYMMETRY_BOUNDS, + display: FloatDisplay::Linear(""), + drag_step: Some(0.0005), + }, + access: PropertyAccess::ReadWrite, + applicability: STEP, + default_policy: DefaultPolicy::ProcessingFactory, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Baseline peak weight", + canonical_aliases: &["AsLS asymmetry", "peak weight", "asymmetry"], + }, + PropertyDefinition { + id: ITERATIONS, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::IntWithDrag { + min: ITERATIONS_MIN, + max: ITERATIONS_MAX, + drag_step: 0.2, + }, + access: PropertyAccess::ReadWrite, + applicability: STEP, + default_policy: DefaultPolicy::ProcessingFactory, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Baseline iterations", + canonical_aliases: &["AsLS iterations", "baseline passes"], + }, +]; + +pub(crate) struct BaselineProvider; + +pub(crate) static PROVIDER: BaselineProvider = BaselineProvider; + +impl PropertyProvider for BaselineProvider { + fn definitions(&self) -> &'static [PropertyDefinition] { + DEFINITIONS + } + + fn read( + &self, + app: &PlotxApp, + address: &PropertyAddress, + ) -> Result { + let definition = property_definition(address.definition)?; + let context = step_context(app, address, definition, |kind| { + matches!(kind, StepKind::Baseline(_)) + })?; + let StepKind::Baseline(current) = context.step.kind else { + unreachable!("the shared context checked the step kind"); + }; + let factory = context.factory.as_ref().and_then(|step| match step.kind { + StepKind::Baseline(value) => Some(value), + _ => None, + }); + Ok(ResolvedProperty { + address: address.clone(), + modified: None, + value: AggregateValue::Uniform(value_of(definition, current)?), + default_value: default_value(definition, factory)?, + availability: Availability::Editable, + schema: schema_for(definition, current)?, + }) + } + + fn edit( + &self, + app: &PlotxApp, + transaction: &mut PropertyTransaction, + address: &PropertyAddress, + operation: &EditOp<'_>, + ) -> Result<(), PropertyError> { + let definition = property_definition(address.definition)?; + let context = step_context(app, address, definition, |kind| { + matches!(kind, StepKind::Baseline(_)) + })?; + let StepKind::Baseline(current) = context.step.kind else { + unreachable!("the shared context checked the step kind"); + }; + let factory = context.factory.as_ref().and_then(|step| match step.kind { + StepKind::Baseline(value) => Some(value), + _ => None, + }); + let value = match operation { + EditOp::Set(value) => checked_value(definition, current, value)?, + EditOp::Reset => { + default_value(definition, factory)?.ok_or_else(|| no_factory_default(definition))? + } + EditOp::Step(_) => return Err(no_step_gesture(definition)), + }; + let state = transaction.processing_state(app, context.dataset_id)?; + let step = step_mut(state, context.step.id, &address.target)?; + let StepKind::Baseline(current) = &mut step.kind else { + return Err(PropertyError::NotApplicable( + "the addressed step is no longer a baseline step".to_owned(), + )); + }; + write(definition, current, value) + } +} + +fn value_of( + definition: &'static PropertyDefinition, + current: BaselineMethod, +) -> Result { + if !property_applies_to_method(definition.id, method_of(current)) { + return Err(unavailable_parameter(definition, current)); + } + match (definition.id, current) { + (METHOD, value) => Ok(PropertyValue::Enum(method_of(value))), + (POLYNOMIAL_ORDER, BaselineMethod::Polynomial { order }) => { + Ok(PropertyValue::Int(i64::from(order))) + } + (SMOOTHNESS, BaselineMethod::AsymmetricLeastSquares { smoothness, .. }) => { + Ok(PropertyValue::Float(smoothness)) + } + (ASYMMETRY, BaselineMethod::AsymmetricLeastSquares { asymmetry, .. }) => { + Ok(PropertyValue::Float(asymmetry)) + } + (ITERATIONS, BaselineMethod::AsymmetricLeastSquares { iterations, .. }) => { + Ok(PropertyValue::Int(i64::from(iterations))) + } + _ => Err(unavailable_parameter(definition, current)), + } +} + +/// Whether a Baseline-section property is rendered for the selected method. +/// +/// Panel-density calibration enumerates the method discriminator through this +/// same predicate, so its mutually exclusive parameter rows cannot be counted +/// as though they appeared together. +#[doc(hidden)] +pub fn property_applies_to_method(property: PropertyId, method: &str) -> bool { + match property { + METHOD => true, + POLYNOMIAL_ORDER => method == POLYNOMIAL, + SMOOTHNESS | ASYMMETRY | ITERATIONS => method == ASYMMETRIC_LEAST_SQUARES, + _ => false, + } +} + +fn default_value( + definition: &'static PropertyDefinition, + factory: Option, +) -> Result, PropertyError> { + let Some(factory) = factory else { + return Ok(None); + }; + let value = match definition.id { + METHOD => PropertyValue::Enum(method_of(factory)), + POLYNOMIAL_ORDER => match factory { + BaselineMethod::Polynomial { order } => PropertyValue::Int(i64::from(order)), + _ => return Ok(None), + }, + SMOOTHNESS => match factory { + BaselineMethod::AsymmetricLeastSquares { smoothness, .. } => { + PropertyValue::Float(smoothness) + } + _ => return Ok(None), + }, + ASYMMETRY => match factory { + BaselineMethod::AsymmetricLeastSquares { asymmetry, .. } => { + PropertyValue::Float(asymmetry) + } + _ => return Ok(None), + }, + ITERATIONS => match factory { + BaselineMethod::AsymmetricLeastSquares { iterations, .. } => { + PropertyValue::Int(i64::from(iterations)) + } + _ => return Ok(None), + }, + _ => return Err(PropertyError::UnknownProperty(definition.id.to_string())), + }; + Ok(Some(value)) +} + +fn schema_for( + definition: &'static PropertyDefinition, + current: BaselineMethod, +) -> Result { + value_of(definition, current)?; + match definition.id { + METHOD => Ok(ResolvedSchema::Enum { + variants: METHODS.iter().collect(), + }), + POLYNOMIAL_ORDER => Ok(ResolvedSchema::IntWithDrag { + min: ORDER_MIN, + max: ORDER_MAX, + drag_step: 0.1, + unit: "", + }), + SMOOTHNESS => Ok(ResolvedSchema::Float { + bounds: SMOOTHNESS_BOUNDS, + display: FloatDisplay::Log10("λ"), + }), + ASYMMETRY => Ok(ResolvedSchema::Float { + bounds: ASYMMETRY_BOUNDS, + display: FloatDisplay::Linear(""), + }), + ITERATIONS => Ok(ResolvedSchema::IntWithDrag { + min: ITERATIONS_MIN, + max: ITERATIONS_MAX, + drag_step: 0.2, + unit: "", + }), + _ => Err(PropertyError::UnknownProperty(definition.id.to_string())), + } +} + +fn checked_value( + definition: &'static PropertyDefinition, + current: BaselineMethod, + value: &PropertyValue, +) -> Result { + if definition.id != METHOD { + value_of(definition, current)?; + } + match (definition.id, value) { + (METHOD, PropertyValue::Enum(value)) if variant(value).is_some() => { + Ok(PropertyValue::Enum(value)) + } + (METHOD, PropertyValue::Enum(value)) => Err(PropertyError::InvalidValue { + property: definition.id, + message: format!("'{value}' is not a baseline method"), + }), + (METHOD, value) => Err(wrong_kind(definition, value, "a baseline method")), + (POLYNOMIAL_ORDER, PropertyValue::Int(value)) | (ITERATIONS, PropertyValue::Int(value)) => { + let (min, max) = if definition.id == POLYNOMIAL_ORDER { + (ORDER_MIN, ORDER_MAX) + } else { + (ITERATIONS_MIN, ITERATIONS_MAX) + }; + if !(min..=max).contains(value) { + return Err(PropertyError::InvalidValue { + property: definition.id, + message: format!( + "{} {value} is out of range: it must be between {min} and {max}", + definition.canonical_label + ), + }); + } + Ok(PropertyValue::Int(*value)) + } + (SMOOTHNESS, PropertyValue::Float(value)) => Ok(PropertyValue::Float( + SMOOTHNESS_BOUNDS.check(definition.id, definition.canonical_label, *value)?, + )), + (ASYMMETRY, PropertyValue::Float(value)) => Ok(PropertyValue::Float( + ASYMMETRY_BOUNDS.check(definition.id, definition.canonical_label, *value)?, + )), + (POLYNOMIAL_ORDER | ITERATIONS, value) => Err(wrong_kind(definition, value, "an integer")), + (SMOOTHNESS | ASYMMETRY, value) => Err(wrong_kind(definition, value, "a number")), + (_, _) => Err(PropertyError::UnknownProperty(definition.id.to_string())), + } +} + +fn write( + definition: &'static PropertyDefinition, + current: &mut BaselineMethod, + value: PropertyValue, +) -> Result<(), PropertyError> { + match (definition.id, value) { + (METHOD, PropertyValue::Enum(value)) => { + *current = match variant(value) { + Some(BaselineVariant::Offset) => BaselineMethod::Offset, + Some(BaselineVariant::Polynomial) => BaselineMethod::Polynomial { + order: carried_order(*current), + }, + Some(BaselineVariant::Asls) => carried_asls(*current), + None => { + return Err(PropertyError::InvalidValue { + property: definition.id, + message: format!("'{value}' is not a baseline method"), + }); + } + }; + Ok(()) + } + (POLYNOMIAL_ORDER, PropertyValue::Int(value)) => { + let BaselineMethod::Polynomial { order } = current else { + return Err(unavailable_parameter(definition, *current)); + }; + *order = u8::try_from(value).map_err(|_| { + wrong_kind( + definition, + &PropertyValue::Int(value), + "an order from 1 to 8", + ) + })?; + Ok(()) + } + (SMOOTHNESS, PropertyValue::Float(value)) => { + let BaselineMethod::AsymmetricLeastSquares { smoothness, .. } = current else { + return Err(unavailable_parameter(definition, *current)); + }; + *smoothness = value; + Ok(()) + } + (ASYMMETRY, PropertyValue::Float(value)) => { + let BaselineMethod::AsymmetricLeastSquares { asymmetry, .. } = current else { + return Err(unavailable_parameter(definition, *current)); + }; + *asymmetry = value; + Ok(()) + } + (ITERATIONS, PropertyValue::Int(value)) => { + let BaselineMethod::AsymmetricLeastSquares { iterations, .. } = current else { + return Err(unavailable_parameter(definition, *current)); + }; + *iterations = u16::try_from(value).map_err(|_| { + wrong_kind( + definition, + &PropertyValue::Int(value), + "an iteration count from 1 to 100", + ) + })?; + Ok(()) + } + (_, value) => Err(wrong_kind( + definition, + &value, + "the declared baseline value", + )), + } +} + +fn carried_order(current: BaselineMethod) -> u8 { + match current { + BaselineMethod::Polynomial { order } => order, + _ => POLYNOMIAL_ORDER_SEED, + } +} + +fn carried_asls(current: BaselineMethod) -> BaselineMethod { + match current { + value @ BaselineMethod::AsymmetricLeastSquares { .. } => value, + _ => BaselineMethod::AsymmetricLeastSquares { + smoothness: SMOOTHNESS_SEED, + asymmetry: ASYMMETRY_SEED, + iterations: ITERATIONS_SEED, + }, + } +} + +#[derive(Clone, Copy)] +enum BaselineVariant { + Offset, + Polynomial, + Asls, +} + +fn variant(value: &str) -> Option { + match value { + OFFSET => Some(BaselineVariant::Offset), + POLYNOMIAL => Some(BaselineVariant::Polynomial), + ASYMMETRIC_LEAST_SQUARES => Some(BaselineVariant::Asls), + _ => None, + } +} + +fn method_of(method: BaselineMethod) -> &'static str { + match method { + BaselineMethod::Offset => OFFSET, + BaselineMethod::Polynomial { .. } => POLYNOMIAL, + BaselineMethod::AsymmetricLeastSquares { .. } => ASYMMETRIC_LEAST_SQUARES, + } +} + +fn unavailable_parameter( + definition: &'static PropertyDefinition, + current: BaselineMethod, +) -> PropertyError { + let required = if definition.id == POLYNOMIAL_ORDER { + "Polynomial" + } else { + "Automatic (AsLS)" + }; + PropertyError::NotApplicable(format!( + "{} is available only with {required}; this step uses {}", + definition.canonical_label, + METHODS + .iter() + .find(|variant| variant.id == method_of(current)) + .map(|variant| variant.canonical_label) + .unwrap_or("an unknown method") + )) +} diff --git a/crates/core/src/properties/baseline_tests.rs b/crates/core/src/properties/baseline_tests.rs new file mode 100644 index 00000000..e87a28af --- /dev/null +++ b/crates/core/src/properties/baseline_tests.rs @@ -0,0 +1,110 @@ +use super::processing_test_support::{step_mut, target_for, time_domain_app}; +use super::*; +use plotx_processing::{BaselineMethod, StepKind}; + +#[test] +fn baseline_schema_is_dependent_and_smoothness_remains_the_domain_value() { + let mut app = time_domain_app(); + let target = target_for(&app, |kind| matches!(kind, StepKind::Baseline(_))); + assert!(matches!( + app.resolve_property(&PropertyAddress::new( + target.clone(), + baseline::POLYNOMIAL_ORDER + )), + Err(PropertyError::NotApplicable(_)) + )); + + step_mut(&mut app, &target).kind = StepKind::Baseline(BaselineMethod::AUTO); + let smoothness = app + .resolve_property(&PropertyAddress::new(target.clone(), baseline::SMOOTHNESS)) + .expect("AsLS exposes smoothness"); + assert_eq!( + smoothness.value, + AggregateValue::Uniform(PropertyValue::Float(5.0e4)) + ); + assert!(matches!( + smoothness.schema, + ResolvedSchema::Float { + display: FloatDisplay::Log10("λ"), + .. + } + )); + // The definition carries the domain unit; the exponent the control shows is + // announced by the caption the same value derives. + assert_eq!(FloatDisplay::Log10("λ").caption(), "log₁₀ λ"); +} + +#[test] +fn baseline_bounds_reject_the_actual_value_and_name_the_effective_limit() { + let app = time_domain_app(); + let target = target_for(&app, |kind| matches!(kind, StepKind::Baseline(_))); + let error = app + .plan_property_write( + baseline::ASYMMETRY, + std::slice::from_ref(&target), + &PropertyValue::Float(0.75), + ) + .expect_err("the kernel never uses an asymmetry above one half"); + let message = error.to_string(); + assert!(message.contains("0.75"), "{message}"); + assert!(message.contains("at most 0.5"), "{message}"); +} + +#[test] +fn baseline_reset_restores_the_factory_asls_parameter() { + let mut app = time_domain_app(); + let target = target_for(&app, |kind| matches!(kind, StepKind::Baseline(_))); + let changed = app + .plan_property_write( + baseline::SMOOTHNESS, + std::slice::from_ref(&target), + &PropertyValue::Float(1.0e7), + ) + .unwrap(); + app.commit_property(changed); + let reset = app + .plan_property_reset(baseline::SMOOTHNESS, std::slice::from_ref(&target)) + .unwrap(); + assert_eq!(reset.applied.len(), 1); + app.commit_property(reset); + assert_eq!( + app.resolve_property(&PropertyAddress::new(target, baseline::SMOOTHNESS)) + .unwrap() + .value, + AggregateValue::Uniform(PropertyValue::Float(baseline::SMOOTHNESS_SEED)) + ); +} + +#[test] +fn polynomial_order_does_not_claim_a_factory_default_the_factory_never_contains() { + assert_eq!( + definition(baseline::POLYNOMIAL_ORDER) + .unwrap() + .default_policy, + DefaultPolicy::None + ); + let mut app = time_domain_app(); + let target = target_for(&app, |kind| matches!(kind, StepKind::Baseline(_))); + let polynomial = app + .plan_property_write( + baseline::METHOD, + std::slice::from_ref(&target), + &PropertyValue::Enum(baseline::POLYNOMIAL), + ) + .unwrap(); + app.commit_property(polynomial); + assert_eq!( + app.resolve_property(&PropertyAddress::new( + target.clone(), + baseline::POLYNOMIAL_ORDER, + )) + .unwrap() + .default_value, + None + ); + let reset = app + .plan_property_reset(baseline::POLYNOMIAL_ORDER, std::slice::from_ref(&target)) + .unwrap(); + assert!(reset.applied.is_empty()); + assert_eq!(reset.skipped.len(), 1); +} diff --git a/crates/core/src/properties/bin.rs b/crates/core/src/properties/bin.rs new file mode 100644 index 00000000..bedd85e6 --- /dev/null +++ b/crates/core/src/properties/bin.rs @@ -0,0 +1,190 @@ +//! Dataset-owned binning-step properties. + +use super::processing_common::{ + no_step_gesture, property_definition, spectrum_before_step, step_context, step_mut, wrong_kind, +}; +use super::provider::PropertyProvider; +use super::{ + AggregateValue, Applicability, Availability, ComponentKind, DefaultPolicy, EditOp, EnumVariant, + FloatBounds, FloatDisplay, PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, + PropertyId, PropertyTransaction, PropertyValue, ResolvedProperty, ResolvedSchema, ScopeKind, + Tier, ValueCopies, ValueSchema, +}; +use crate::state::PlotxApp; +use plotx_processing::{BinMethod, BinParams, StepKind}; + +pub const WIDTH: PropertyId = PropertyId("dataset.processing.bin.width"); +pub const METHOD: PropertyId = PropertyId("dataset.processing.bin.method"); + +pub const SUM: &str = "sum"; +pub const MEAN: &str = "mean"; + +const METHODS: &[EnumVariant] = &[EnumVariant::new(SUM, "Sum"), EnumVariant::new(MEAN, "Mean")]; +const WIDTH_BOUNDS: FloatBounds = FloatBounds::above(0.0, f64::MAX); +const STEP: Applicability = Applicability::component(ComponentKind::ProcessingStep); + +pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ + PropertyDefinition { + id: WIDTH, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::Float { + bounds: WIDTH_BOUNDS, + display: FloatDisplay::Linear("ppm"), + drag_step: Some(0.005), + }, + access: PropertyAccess::ReadWrite, + applicability: STEP, + default_policy: DefaultPolicy::None, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Bin width", + canonical_aliases: &["binning width", "bucket width", "ppm bins"], + }, + PropertyDefinition { + id: METHOD, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::Enum { variants: METHODS }, + access: PropertyAccess::ReadWrite, + applicability: STEP, + default_policy: DefaultPolicy::None, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Bin aggregation", + canonical_aliases: &["bin method", "sum bins", "mean bins"], + }, +]; + +pub(crate) struct BinProvider; + +pub(crate) static PROVIDER: BinProvider = BinProvider; + +impl PropertyProvider for BinProvider { + fn definitions(&self) -> &'static [PropertyDefinition] { + DEFINITIONS + } + + fn read( + &self, + app: &PlotxApp, + address: &PropertyAddress, + ) -> Result { + let definition = property_definition(address.definition)?; + let context = step_context(app, address, definition, |kind| { + matches!(kind, StepKind::Bin(_)) + })?; + let StepKind::Bin(current) = context.step.kind else { + unreachable!("the shared context checked the step kind"); + }; + let bounds = resolved_width_bounds(&context)?; + Ok(ResolvedProperty { + address: address.clone(), + modified: None, + value: AggregateValue::Uniform(value_of(definition, current)?), + default_value: None, + availability: Availability::Editable, + schema: schema_for(definition, bounds), + }) + } + + fn edit( + &self, + app: &PlotxApp, + transaction: &mut PropertyTransaction, + address: &PropertyAddress, + operation: &EditOp<'_>, + ) -> Result<(), PropertyError> { + let definition = property_definition(address.definition)?; + let context = step_context(app, address, definition, |kind| { + matches!(kind, StepKind::Bin(_)) + })?; + let bounds = resolved_width_bounds(&context)?; + let value = match operation { + EditOp::Set(value) => checked_value(definition, bounds, value)?, + EditOp::Reset => { + return Err(PropertyError::NotApplicable( + "User-added binning steps have no factory setting to reset to.".to_owned(), + )); + } + EditOp::Step(_) => return Err(no_step_gesture(definition)), + }; + let state = transaction.processing_state(app, context.dataset_id)?; + let step = step_mut(state, context.step.id, &address.target)?; + let StepKind::Bin(current) = &mut step.kind else { + return Err(PropertyError::NotApplicable( + "the addressed step is no longer a binning step".to_owned(), + )); + }; + match (definition.id, value) { + (WIDTH, PropertyValue::Float(value)) => current.width = value, + (METHOD, PropertyValue::Enum(SUM)) => current.method = BinMethod::Sum, + (METHOD, PropertyValue::Enum(MEAN)) => current.method = BinMethod::Mean, + (_, value) => { + return Err(wrong_kind(definition, &value, "the declared binning value")); + } + } + Ok(()) + } +} + +fn value_of( + definition: &'static PropertyDefinition, + current: BinParams, +) -> Result { + match definition.id { + WIDTH => Ok(PropertyValue::Float(current.width)), + METHOD => Ok(PropertyValue::Enum(match current.method { + BinMethod::Sum => SUM, + BinMethod::Mean => MEAN, + })), + _ => Err(PropertyError::UnknownProperty(definition.id.to_string())), + } +} + +fn schema_for(definition: &'static PropertyDefinition, bounds: FloatBounds) -> ResolvedSchema { + if definition.id == WIDTH { + ResolvedSchema::Float { + bounds, + display: FloatDisplay::Linear("ppm"), + } + } else { + ResolvedSchema::Enum { + variants: METHODS.iter().collect(), + } + } +} + +fn checked_value( + definition: &'static PropertyDefinition, + bounds: FloatBounds, + value: &PropertyValue, +) -> Result { + match (definition.id, value) { + (WIDTH, PropertyValue::Float(value)) => Ok(PropertyValue::Float(bounds.check( + definition.id, + definition.canonical_label, + *value, + )?)), + (WIDTH, value) => Err(wrong_kind(definition, value, "a positive number")), + (METHOD, PropertyValue::Enum(value)) if METHODS.iter().any(|item| item.id == *value) => { + Ok(PropertyValue::Enum(value)) + } + (METHOD, PropertyValue::Enum(value)) => Err(PropertyError::InvalidValue { + property: definition.id, + message: format!("'{value}' is not a bin aggregation"), + }), + (METHOD, value) => Err(wrong_kind(definition, value, "a bin aggregation")), + (_, _) => Err(PropertyError::UnknownProperty(definition.id.to_string())), + } +} + +fn resolved_width_bounds( + context: &super::processing_common::StepContext<'_>, +) -> Result { + let spectrum = spectrum_before_step(context).ok_or_else(|| { + PropertyError::NotApplicable( + "Binning needs a one-dimensional input spectrum with an axis.".to_owned(), + ) + })?; + let axis_step = plotx_processing::cleanup::axis_step(&spectrum.ppm); + Ok(FloatBounds::above(1.5 * axis_step, f64::MAX)) +} diff --git a/crates/core/src/properties/bin_tests.rs b/crates/core/src/properties/bin_tests.rs new file mode 100644 index 00000000..0a0ba180 --- /dev/null +++ b/crates/core/src/properties/bin_tests.rs @@ -0,0 +1,79 @@ +use super::processing_test_support::{add_step, step, time_domain_app}; +use super::*; +use plotx_processing::{BinMethod, BinParams, StepKind}; + +#[test] +fn bin_width_is_strictly_positive_and_method_is_independently_addressable() { + let mut app = time_domain_app(); + let target = add_step(&mut app, StepKind::Bin(BinParams::DEFAULT)); + let error = app + .plan_property_write( + bin::WIDTH, + std::slice::from_ref(&target), + &PropertyValue::Float(0.0), + ) + .expect_err("a zero-width bin cannot aggregate an axis"); + let message = error.to_string(); + assert!(message.contains("Bin width 0"), "{message}"); + assert!(message.contains("greater than"), "{message}"); + + let commit = app + .plan_property_write( + bin::METHOD, + std::slice::from_ref(&target), + &PropertyValue::Enum(bin::MEAN), + ) + .expect("mean aggregation plans"); + app.commit_property(commit); + assert!(matches!( + step(&app, &target).kind, + StepKind::Bin(BinParams { + method: BinMethod::Mean, + .. + }) + )); +} + +#[test] +fn bin_width_lower_bound_tracks_the_real_axis_step() { + let mut app = time_domain_app(); + let target = add_step(&mut app, StepKind::Bin(BinParams::DEFAULT)); + let resolved = app + .resolve_property(&PropertyAddress::new(target.clone(), bin::WIDTH)) + .unwrap(); + let ResolvedSchema::Float { + bounds, + display: FloatDisplay::Linear("ppm"), + .. + } = resolved.schema + else { + panic!("bin width is a ppm float"); + }; + assert!(bounds.min > 0.0); + let refused = bounds.min; + let error = app + .plan_property_write( + bin::WIDTH, + std::slice::from_ref(&target), + &PropertyValue::Float(refused), + ) + .expect_err("the open effective-width boundary is not a bin"); + let message = error.to_string(); + assert!(message.contains(&refused.to_string()), "{message}"); + assert!(message.contains("greater than"), "{message}"); +} + +#[test] +fn bin_reset_honestly_skips_a_user_only_step() { + let mut app = time_domain_app(); + let target = add_step(&mut app, StepKind::Bin(BinParams::DEFAULT)); + assert_eq!( + definition(bin::WIDTH).unwrap().default_policy, + DefaultPolicy::None + ); + let reset = app + .plan_property_reset(bin::WIDTH, std::slice::from_ref(&target)) + .unwrap(); + assert!(reset.applied.is_empty()); + assert_eq!(reset.skipped.len(), 1); +} diff --git a/crates/core/src/properties/canvas.rs b/crates/core/src/properties/canvas.rs new file mode 100644 index 00000000..773696e2 --- /dev/null +++ b/crates/core/src/properties/canvas.rs @@ -0,0 +1,433 @@ +//! Canvas-owned page, grid, caption, and size properties. + +use super::provider::PropertyProvider; +use super::target::{require_canvas_target, resolved_schema}; +use super::{ + AggregateValue, Applicability, Availability, ComponentKind, DefaultPolicy, EditOp, EnumVariant, + FloatBounds, FloatDisplay, PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, + PropertyId, PropertyTransaction, PropertyValue, ResolvedProperty, ScopeKind, Tier, ValueCopies, + ValueSchema, definition, +}; +use crate::layout::SpacingMode; +use crate::state::{CanvasDocument, FieldCapabilities, PanelLabelStyle, PlotxApp}; + +pub const MARGIN_TOP_MM: PropertyId = PropertyId("canvas.layout.margin_top_mm"); +pub const MARGIN_RIGHT_MM: PropertyId = PropertyId("canvas.layout.margin_right_mm"); +pub const MARGIN_BOTTOM_MM: PropertyId = PropertyId("canvas.layout.margin_bottom_mm"); +pub const MARGIN_LEFT_MM: PropertyId = PropertyId("canvas.layout.margin_left_mm"); +pub const GUTTER_MM: PropertyId = PropertyId("canvas.layout.gutter_mm"); +pub const ROWS: PropertyId = PropertyId("canvas.layout.rows"); +pub const COLS: PropertyId = PropertyId("canvas.layout.cols"); +pub const SHOW_GRID: PropertyId = PropertyId("canvas.layout.show_grid"); +pub const SPACING_MODE: PropertyId = PropertyId("canvas.layout.spacing_mode"); +pub const WIDTH_MM: PropertyId = PropertyId("canvas.size.width_mm"); +pub const HEIGHT_MM: PropertyId = PropertyId("canvas.size.height_mm"); +pub const AUTO_HEIGHT: PropertyId = PropertyId("canvas.size.auto_height"); +pub const CAPTION_VISIBLE: PropertyId = PropertyId("canvas.caption_visible"); +pub const PANEL_LABEL_STYLE: PropertyId = PropertyId("canvas.panel_label_style"); + +const LENGTH_BOUNDS: FloatBounds = FloatBounds::inclusive(0.0, 100.0); +const SIZE_BOUNDS: FloatBounds = FloatBounds::inclusive(10.0, 1000.0); +const LENGTH_STEP_MM: f64 = 1.0; +const GRID_MIN: i64 = 1; +const GRID_MAX: i64 = 12; +const GRID_DRAG_STEP: f64 = 0.1; + +pub const SPACING_FRAME: &str = "frame"; +pub const SPACING_VISUAL: &str = "visual"; +pub const LABEL_LOWER_ALPHA: &str = "lower_alpha"; +pub const LABEL_UPPER_ALPHA: &str = "upper_alpha"; +pub const LABEL_LOWER_ROMAN: &str = "lower_roman"; +pub const LABEL_ARABIC: &str = "arabic"; + +const SPACING_VARIANTS: &[EnumVariant] = &[ + EnumVariant::new(SPACING_FRAME, "Frame"), + EnumVariant::new(SPACING_VISUAL, "Visual"), +]; +const PANEL_LABEL_VARIANTS: &[EnumVariant] = &[ + EnumVariant::new(LABEL_LOWER_ALPHA, "a, b, c"), + EnumVariant::new(LABEL_UPPER_ALPHA, "A, B, C"), + EnumVariant::new(LABEL_LOWER_ROMAN, "i, ii, iii"), + EnumVariant::new(LABEL_ARABIC, "1, 2, 3"), +]; + +const CANVAS_VALUE: Applicability = Applicability::component(ComponentKind::None); + +const fn float_definition( + id: PropertyId, + bounds: FloatBounds, + default: f64, + label: &'static str, + aliases: &'static [&'static str], +) -> PropertyDefinition { + PropertyDefinition { + id, + scope_kind: ScopeKind::Canvas, + value_schema: ValueSchema::Float { + bounds, + display: FloatDisplay::Linear("mm"), + drag_step: Some(LENGTH_STEP_MM), + }, + access: PropertyAccess::ReadWrite, + applicability: CANVAS_VALUE, + default_policy: DefaultPolicy::Fixed(PropertyValue::Float(default)), + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: label, + canonical_aliases: aliases, + } +} + +pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ + float_definition( + MARGIN_TOP_MM, + LENGTH_BOUNDS, + 0.0, + "Top page margin", + &["top margin", "page margin"], + ), + float_definition( + MARGIN_RIGHT_MM, + LENGTH_BOUNDS, + 0.0, + "Right page margin", + &["right margin", "page margin"], + ), + float_definition( + MARGIN_BOTTOM_MM, + LENGTH_BOUNDS, + 0.0, + "Bottom page margin", + &["bottom margin", "page margin"], + ), + float_definition( + MARGIN_LEFT_MM, + LENGTH_BOUNDS, + 0.0, + "Left page margin", + &["left margin", "page margin"], + ), + float_definition( + GUTTER_MM, + LENGTH_BOUNDS, + 5.0, + "Minimum panel spacing", + &["gutter", "panel spacing"], + ), + PropertyDefinition { + id: ROWS, + scope_kind: ScopeKind::Canvas, + value_schema: ValueSchema::IntWithDrag { + min: GRID_MIN, + max: GRID_MAX, + drag_step: GRID_DRAG_STEP, + }, + access: PropertyAccess::ReadWrite, + applicability: CANVAS_VALUE, + default_policy: DefaultPolicy::Fixed(PropertyValue::Int(1)), + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Layout grid rows", + canonical_aliases: &["grid rows", "page rows"], + }, + PropertyDefinition { + id: COLS, + scope_kind: ScopeKind::Canvas, + value_schema: ValueSchema::IntWithDrag { + min: GRID_MIN, + max: GRID_MAX, + drag_step: GRID_DRAG_STEP, + }, + access: PropertyAccess::ReadWrite, + applicability: CANVAS_VALUE, + default_policy: DefaultPolicy::Fixed(PropertyValue::Int(1)), + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Layout grid columns", + canonical_aliases: &["grid columns", "page columns"], + }, + PropertyDefinition { + id: SHOW_GRID, + scope_kind: ScopeKind::Canvas, + value_schema: ValueSchema::Bool, + access: PropertyAccess::ReadWrite, + applicability: CANVAS_VALUE, + default_policy: DefaultPolicy::Fixed(PropertyValue::Bool(false)), + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Show layout grid", + canonical_aliases: &["grid overlay", "show grid"], + }, + PropertyDefinition { + id: SPACING_MODE, + scope_kind: ScopeKind::Canvas, + value_schema: ValueSchema::Enum { + variants: SPACING_VARIANTS, + }, + access: PropertyAccess::ReadWrite, + applicability: CANVAS_VALUE, + default_policy: DefaultPolicy::Fixed(PropertyValue::Enum(SPACING_VISUAL)), + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Panel spacing basis", + canonical_aliases: &["spacing mode", "visual spacing", "frame spacing"], + }, + float_definition( + WIDTH_MM, + SIZE_BOUNDS, + crate::state::DEFAULT_CANVAS_SIZE_MM[0] as f64, + "Canvas width", + &["page width", "figure width"], + ), + float_definition( + HEIGHT_MM, + SIZE_BOUNDS, + crate::state::DEFAULT_CANVAS_SIZE_MM[1] as f64, + "Canvas height", + &["page height", "figure height"], + ), + PropertyDefinition { + id: AUTO_HEIGHT, + scope_kind: ScopeKind::Canvas, + value_schema: ValueSchema::Bool, + access: PropertyAccess::ReadWrite, + applicability: CANVAS_VALUE, + default_policy: DefaultPolicy::Fixed(PropertyValue::Bool(false)), + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Automatic canvas height", + canonical_aliases: &["auto height", "content height"], + }, + PropertyDefinition { + id: CAPTION_VISIBLE, + scope_kind: ScopeKind::Canvas, + value_schema: ValueSchema::Bool, + access: PropertyAccess::ReadWrite, + applicability: CANVAS_VALUE, + default_policy: DefaultPolicy::Fixed(PropertyValue::Bool(true)), + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Show canvas caption", + canonical_aliases: &["caption visibility", "show caption"], + }, + PropertyDefinition { + id: PANEL_LABEL_STYLE, + scope_kind: ScopeKind::Canvas, + value_schema: ValueSchema::Enum { + variants: PANEL_LABEL_VARIANTS, + }, + access: PropertyAccess::ReadWrite, + applicability: CANVAS_VALUE, + default_policy: DefaultPolicy::Fixed(PropertyValue::Enum(LABEL_LOWER_ALPHA)), + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Panel label style", + canonical_aliases: &["panel letters", "panel numbering"], + }, +]; + +pub(crate) struct CanvasProvider; + +pub(crate) static PROVIDER: CanvasProvider = CanvasProvider; + +impl PropertyProvider for CanvasProvider { + fn definitions(&self) -> &'static [PropertyDefinition] { + DEFINITIONS + } + + fn read( + &self, + app: &PlotxApp, + address: &PropertyAddress, + ) -> Result { + let definition = property_definition(address.definition)?; + let id = require_canvas_target(app, &address.target, definition)?; + let index = app + .doc + .canvas_index(id) + .ok_or_else(|| PropertyError::UnknownTarget(address.target.resource.id.clone()))?; + let canvas = &app.doc.canvases[index]; + let availability = if definition.id == HEIGHT_MM && canvas.auto_height { + Availability::Disabled("Turn off Auto height to set the height manually.") + } else { + Availability::Editable + }; + Ok(ResolvedProperty { + address: address.clone(), + modified: None, + value: AggregateValue::Uniform(value_of(definition.id, canvas)?), + default_value: fixed_default(definition), + availability, + schema: resolved_schema(definition, &FieldCapabilities::default()), + }) + } + + fn edit( + &self, + app: &PlotxApp, + transaction: &mut PropertyTransaction, + address: &PropertyAddress, + operation: &EditOp<'_>, + ) -> Result<(), PropertyError> { + let definition = property_definition(address.definition)?; + let id = require_canvas_target(app, &address.target, definition)?; + let value = match operation { + EditOp::Set(value) => checked_value(definition, value)?, + EditOp::Reset => { + fixed_default(definition).ok_or_else(|| PropertyError::InvalidValue { + property: definition.id, + message: "this canvas property has no fixed default".to_owned(), + })? + } + EditOp::Step(_) => { + return Err(PropertyError::InvalidValue { + property: definition.id, + message: "this canvas setting has no step gesture".to_owned(), + }); + } + }; + match (definition.id, value) { + (SHOW_GRID, PropertyValue::Bool(show)) => { + transaction.set_canvas_show_grid(app, id, show) + } + (SPACING_MODE, PropertyValue::Enum(value)) => transaction.set_canvas_spacing_mode( + app, + id, + spacing_mode(value).expect("validated spacing mode"), + ), + (_, value) => write(definition.id, transaction.canvas(app, id)?, value), + } + } +} + +fn property_definition(id: PropertyId) -> Result<&'static PropertyDefinition, PropertyError> { + definition(id).ok_or_else(|| PropertyError::UnknownProperty(id.as_str().to_owned())) +} + +fn fixed_default(definition: &'static PropertyDefinition) -> Option { + match &definition.default_policy { + DefaultPolicy::Fixed(value) => Some(value.clone()), + DefaultPolicy::EncodingFactory + | DefaultPolicy::ProcessingFactory + | DefaultPolicy::Derived + | DefaultPolicy::None => None, + } +} + +fn margin_index(id: PropertyId) -> Option { + match id { + MARGIN_TOP_MM => Some(0), + MARGIN_RIGHT_MM => Some(1), + MARGIN_BOTTOM_MM => Some(2), + MARGIN_LEFT_MM => Some(3), + _ => None, + } +} + +fn value_of(id: PropertyId, canvas: &CanvasDocument) -> Result { + if let Some(index) = margin_index(id) { + return Ok(PropertyValue::Float(f64::from( + canvas.layout.margin_mm[index], + ))); + } + match id { + GUTTER_MM => Ok(PropertyValue::Float(f64::from(canvas.layout.gutter_mm))), + ROWS => Ok(PropertyValue::Int(i64::from(canvas.layout.rows))), + COLS => Ok(PropertyValue::Int(i64::from(canvas.layout.cols))), + SHOW_GRID => Ok(PropertyValue::Bool(canvas.layout.show_grid)), + SPACING_MODE => Ok(PropertyValue::Enum(spacing_key(canvas.layout.spacing_mode))), + WIDTH_MM => Ok(PropertyValue::Float(f64::from(canvas.size_mm[0]))), + HEIGHT_MM => Ok(PropertyValue::Float(f64::from(canvas.size_mm[1]))), + AUTO_HEIGHT => Ok(PropertyValue::Bool(canvas.auto_height)), + CAPTION_VISIBLE => Ok(PropertyValue::Bool(canvas.caption_visible)), + PANEL_LABEL_STYLE => Ok(PropertyValue::Enum(canvas.panel_label_style.as_key())), + _ => Err(PropertyError::UnknownProperty(id.as_str().to_owned())), + } +} + +fn checked_value( + definition: &'static PropertyDefinition, + value: &PropertyValue, +) -> Result { + match (definition.value_schema, value) { + (ValueSchema::Float { bounds, .. }, PropertyValue::Float(value)) => { + bounds.check(definition.id, definition.canonical_label, *value)?; + Ok(PropertyValue::Float(*value)) + } + ( + ValueSchema::Int { min, max } | ValueSchema::IntWithDrag { min, max, .. }, + PropertyValue::Int(value), + ) if (min..=max).contains(value) => Ok(PropertyValue::Int(*value)), + (ValueSchema::Bool, PropertyValue::Bool(value)) => Ok(PropertyValue::Bool(*value)), + (ValueSchema::Enum { variants }, PropertyValue::Enum(value)) + if variants.iter().any(|variant| variant.id == *value) => + { + Ok(PropertyValue::Enum(value)) + } + (_, value) => Err(PropertyError::InvalidValue { + property: definition.id, + message: format!( + "{} does not accept a value of kind {}", + definition.canonical_label, + value.kind() + ), + }), + } +} + +fn write( + id: PropertyId, + canvas: &mut super::transaction::CanvasPropertyState, + value: PropertyValue, +) -> Result<(), PropertyError> { + match (id, value) { + (id, PropertyValue::Float(value)) if margin_index(id).is_some() => { + canvas.layout.margin_mm[margin_index(id).expect("matched margin")] = value as f32 + } + (GUTTER_MM, PropertyValue::Float(value)) => canvas.layout.gutter_mm = value as f32, + (ROWS, PropertyValue::Int(value)) => canvas.layout.rows = value as u32, + (COLS, PropertyValue::Int(value)) => canvas.layout.cols = value as u32, + (WIDTH_MM | HEIGHT_MM, PropertyValue::Float(value)) => { + let mut size_mm = canvas.page_size.size_mm; + size_mm[usize::from(id == HEIGHT_MM)] = value as f32; + canvas.page_size = canvas.page_size.after_manual_resize(size_mm); + } + (AUTO_HEIGHT, PropertyValue::Bool(value)) => canvas.auto_height = value, + (CAPTION_VISIBLE, PropertyValue::Bool(value)) => canvas.caption.1 = value, + (PANEL_LABEL_STYLE, PropertyValue::Enum(value)) => { + canvas.panel_label_style = panel_label_style(value).expect("validated panel style") + } + (_, value) => { + return Err(PropertyError::InvalidValue { + property: id, + message: format!("the canvas property cannot store {}", value.kind()), + }); + } + } + Ok(()) +} + +fn spacing_key(mode: SpacingMode) -> &'static str { + match mode { + SpacingMode::Frame => SPACING_FRAME, + SpacingMode::Visual => SPACING_VISUAL, + } +} + +fn spacing_mode(value: &str) -> Option { + match value { + SPACING_FRAME => Some(SpacingMode::Frame), + SPACING_VISUAL => Some(SpacingMode::Visual), + _ => None, + } +} + +fn panel_label_style(value: &str) -> Option { + match value { + LABEL_LOWER_ALPHA => Some(PanelLabelStyle::LowerAlpha), + LABEL_UPPER_ALPHA => Some(PanelLabelStyle::UpperAlpha), + LABEL_LOWER_ROMAN => Some(PanelLabelStyle::LowerRoman), + LABEL_ARABIC => Some(PanelLabelStyle::Arabic), + _ => None, + } +} diff --git a/crates/core/src/properties/canvas_tests.rs b/crates/core/src/properties/canvas_tests.rs new file mode 100644 index 00000000..64478631 --- /dev/null +++ b/crates/core/src/properties/canvas_tests.rs @@ -0,0 +1,204 @@ +use super::*; +use crate::automation::{ResourceKindId, ResourceRef, TargetRef}; +use crate::state::{CanvasDocument, CanvasId, NATURE_SINGLE_COLUMN, PlotxApp}; + +fn canvas_app() -> (PlotxApp, TargetRef) { + let mut app = PlotxApp::new(); + app.doc.canvases.push(CanvasDocument::new( + "Page".to_owned(), + crate::state::DEFAULT_CANVAS_SIZE_MM, + )); + let id = app.doc.canvases[0].resource_id; + let target = app.canvas_target(id); + (app, target) +} + +fn write(app: &mut PlotxApp, target: &TargetRef, property: PropertyId, value: PropertyValue) { + let commit = app + .plan_property_write(property, std::slice::from_ref(target), &value) + .expect("canvas property plans"); + assert_eq!(commit.applied.len(), 1); + assert_eq!(app.commit_property(commit), 1); +} + +fn different_value(definition: &PropertyDefinition) -> PropertyValue { + match &definition.default_policy { + DefaultPolicy::Fixed(PropertyValue::Float(value)) => { + let bounds = definition + .value_schema + .float_bounds() + .expect("float definition has bounds"); + let candidate = if bounds.admits(*value + 1.0) { + *value + 1.0 + } else { + *value - 1.0 + }; + PropertyValue::Float(candidate) + } + DefaultPolicy::Fixed(PropertyValue::Int(value)) => PropertyValue::Int(*value + 1), + DefaultPolicy::Fixed(PropertyValue::Bool(value)) => PropertyValue::Bool(!*value), + DefaultPolicy::Fixed(PropertyValue::Enum(value)) => { + let ValueSchema::Enum { variants } = definition.value_schema else { + panic!("enum default has enum schema"); + }; + PropertyValue::Enum( + variants + .iter() + .find(|variant| variant.id != *value) + .expect("an enum has an alternative") + .id, + ) + } + policy => panic!("canvas definitions have scalar fixed defaults, got {policy:?}"), + } +} + +#[test] +fn every_canvas_property_resets_to_its_declared_default() { + for definition in canvas::DEFINITIONS { + let (mut app, target) = canvas_app(); + write( + &mut app, + &target, + definition.id, + different_value(definition), + ); + let reset = app + .plan_property_reset(definition.id, std::slice::from_ref(&target)) + .expect("every canvas property resets"); + assert_eq!(reset.applied.len(), 1, "{}", definition.id); + app.commit_property(reset); + let resolved = app + .resolve_property(&PropertyAddress::new(target, definition.id)) + .expect("reset property resolves"); + assert_eq!( + resolved.value.uniform(), + resolved.default_value.as_ref(), + "{}", + definition.id + ); + } +} + +#[test] +fn two_canvas_targets_are_written_together_by_stable_identity() { + let (mut app, first) = canvas_app(); + app.doc.canvases.push(CanvasDocument::new( + "Second".to_owned(), + crate::state::DEFAULT_CANVAS_SIZE_MM, + )); + let second = app.canvas_target(app.doc.canvases[1].resource_id); + let commit = app + .plan_property_write( + canvas::GUTTER_MM, + &[first, second], + &PropertyValue::Float(9.0), + ) + .expect("both canvases plan atomically"); + assert_eq!(commit.applied.len(), 2); + app.commit_property(commit); + assert!( + app.doc + .canvases + .iter() + .all(|canvas| canvas.layout.gutter_mm == 9.0) + ); +} + +#[test] +fn canvas_target_rejects_unknown_ids_and_wrong_resource_kinds() { + let (app, target) = canvas_app(); + let unknown = app.canvas_target(CanvasId::new()); + assert!(matches!( + app.resolve_property(&PropertyAddress::new(unknown, canvas::ROWS)), + Err(PropertyError::UnknownTarget(_)) + )); + + let wrong_kind = TargetRef::resource(ResourceRef { + id: target.resource.id, + kind: ResourceKindId::new(crate::automation::KIND_DATASET), + parent_id: None, + local_id: None, + }); + assert!(matches!( + app.resolve_property(&PropertyAddress::new(wrong_kind, canvas::ROWS)), + Err(PropertyError::NotApplicable(_)) + )); +} + +#[test] +fn a_canvas_drag_is_one_undo_step() { + let (mut app, target) = canvas_app(); + let history = app.session.undo_stack.len(); + app.begin_property_gesture(canvas::MARGIN_TOP_MM); + for value in [1.0, 2.0, 3.0] { + write( + &mut app, + &target, + canvas::MARGIN_TOP_MM, + PropertyValue::Float(value), + ); + } + assert_eq!( + app.session.undo_stack.len(), + history, + "live drag frames stay out of history" + ); + app.end_property_gesture(); + assert_eq!(app.session.undo_stack.len(), history + 1); + app.undo(); + assert_eq!(app.doc.canvases[0].layout.margin_mm[0], 0.0); +} + +#[test] +fn manual_canvas_size_edits_share_the_existing_preset_reconciliation() { + let (mut app, target) = canvas_app(); + app.doc.canvases[0].size_preset_id = Some(NATURE_SINGLE_COLUMN.id.to_owned()); + + write( + &mut app, + &target, + canvas::HEIGHT_MM, + PropertyValue::Float(75.0), + ); + assert_eq!( + app.doc.canvases[0].size_preset_id.as_deref(), + Some(NATURE_SINGLE_COLUMN.id), + "a journal preset is still identified by its unchanged width" + ); + + write( + &mut app, + &target, + canvas::WIDTH_MM, + PropertyValue::Float(90.0), + ); + assert_eq!(app.doc.canvases[0].size_preset_id, None); + app.undo(); + assert_eq!( + app.doc.canvases[0].size_preset_id.as_deref(), + Some(NATURE_SINGLE_COLUMN.id), + "undo restores size and preset identity as one PageSizeState" + ); +} + +#[test] +fn auto_height_and_grid_visibility_remain_non_undoable() { + let (mut app, target) = canvas_app(); + let history = app.session.undo_stack.len(); + write( + &mut app, + &target, + canvas::AUTO_HEIGHT, + PropertyValue::Bool(true), + ); + write( + &mut app, + &target, + canvas::SHOW_GRID, + PropertyValue::Bool(true), + ); + assert!(app.doc.canvases[0].auto_height); + assert!(app.doc.canvases[0].layout.show_grid); + assert_eq!(app.session.undo_stack.len(), history); +} diff --git a/crates/core/src/properties/contour.rs b/crates/core/src/properties/contour.rs index fb754656..14b246e7 100644 --- a/crates/core/src/properties/contour.rs +++ b/crates/core/src/properties/contour.rs @@ -88,7 +88,7 @@ pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ scope_kind: ScopeKind::Object, value_schema: ValueSchema::Float { bounds: FloatBounds::above(0.0, f64::MAX), - log: true, + display: FloatDisplay::Log10("intensity"), drag_step: None, }, access: PropertyAccess::ReadWrite, @@ -143,7 +143,7 @@ pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ // value the write path refuses. value_schema: ValueSchema::Float { bounds: RATIO_BOUNDS, - log: false, + display: FloatDisplay::Linear(""), drag_step: None, }, access: PropertyAccess::ReadWrite, @@ -195,7 +195,7 @@ pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ scope_kind: ScopeKind::Object, value_schema: ValueSchema::Float { bounds: LINE_WIDTH_BOUNDS, - log: false, + display: FloatDisplay::Linear(""), drag_step: None, }, access: PropertyAccess::ReadWrite, @@ -232,13 +232,14 @@ impl PropertyProvider for ContourProvider { let value = read(definition.id, spec) .ok_or_else(|| PropertyError::UnknownProperty(definition.id.as_str().to_owned()))?; let default_value = - default_value(definition, &context, spec).and_then(|value| value.uniform().copied()); + default_value(definition, &context, spec).and_then(|value| value.uniform().cloned()); let availability = match definition.access { PropertyAccess::ReadOnly => Availability::ReadOnly, PropertyAccess::ReadWrite => Availability::Editable, }; Ok(ResolvedProperty { address: address.clone(), + modified: None, value, default_value, availability, @@ -279,7 +280,7 @@ impl PropertyProvider for ContourProvider { app: &PlotxApp, transaction: &mut PropertyTransaction, address: &PropertyAddress, - operation: EditOp, + operation: &EditOp<'_>, ) -> Result<(), PropertyError> { let definition = definition(address.definition).ok_or_else(|| { PropertyError::UnknownProperty(address.definition.as_str().to_owned()) @@ -292,7 +293,7 @@ impl PropertyProvider for ContourProvider { EditOp::Set(value) => { let permitted = permitted_variants(&definition.value_schema, &context.capabilities); if let PropertyValue::Enum(choice) = value - && !permitted.iter().any(|variant| variant.id == choice) + && !permitted.iter().any(|variant| variant.id == *choice) { return Err(PropertyError::InvalidValue { property: definition.id, @@ -302,10 +303,10 @@ impl PropertyProvider for ContourProvider { ), }); } - value + (*value).clone() } EditOp::Reset => default_value(definition, &context, current) - .and_then(|value| value.uniform().copied()) + .and_then(|value| value.uniform().cloned()) .ok_or(PropertyError::InvalidValue { property: definition.id, message: "the default factory has no single value for this setting".to_owned(), @@ -322,7 +323,7 @@ impl PropertyProvider for ContourProvider { "the series is no longer a contour".to_owned(), )); }; - return step(definition.id, spec, direction); + return step(definition.id, spec, *direction); } }; let binding = transaction.data_binding(app, context.canvas, context.object)?; @@ -347,9 +348,9 @@ fn default_value( context: &super::target::SeriesContext<'_>, current: &ContourSpec, ) -> Option> { - match definition.default_policy { - DefaultPolicy::ProcessingFactory | DefaultPolicy::None => None, - DefaultPolicy::Fixed(value) => Some(AggregateValue::Uniform(value)), + match &definition.default_policy { + DefaultPolicy::ProcessingFactory | DefaultPolicy::Derived | DefaultPolicy::None => None, + DefaultPolicy::Fixed(value) => Some(AggregateValue::Uniform(value.clone())), DefaultPolicy::EncodingFactory => { let defaults = default_contour_spec(&context.capabilities, &|| { field_peak_magnitude(context.dataset, context.field) @@ -467,23 +468,19 @@ pub(super) fn resolved_schema(id: PropertyId, spec: &ContourSpec) -> Option ResolvedSchema::Float { bounds: FloatBounds::above(0.0, f64::MAX), - log: true, - unit: "intensity", + display: FloatDisplay::Log10("intensity"), }, ContourBasePolicy::NoiseFloor { .. } => ResolvedSchema::Float { bounds: FloatBounds::above(0.0, MAX_MULTIPLIER), - log: false, - unit: "× noise floor", + display: FloatDisplay::Linear("× noise floor"), }, ContourBasePolicy::BackgroundScale { .. } => ResolvedSchema::Float { bounds: FloatBounds::above(0.0, MAX_MULTIPLIER), - log: false, - unit: "× spread", + display: FloatDisplay::Linear("× spread"), }, ContourBasePolicy::FractionOfRange(_) => ResolvedSchema::Float { bounds: FloatBounds::above(0.0, 1.0), - log: false, - unit: "of range", + display: FloatDisplay::Linear("of range"), }, }; Some(schema) diff --git a/crates/core/src/properties/export_dpi.rs b/crates/core/src/properties/export_dpi.rs index 37b157ad..3620d535 100644 --- a/crates/core/src/properties/export_dpi.rs +++ b/crates/core/src/properties/export_dpi.rs @@ -49,17 +49,20 @@ impl PropertyProvider for ExportDpiProvider { require_app_target(&address.target, definition)?; Ok(ResolvedProperty { address: address.clone(), + modified: None, value: AggregateValue::Uniform(PropertyValue::Int(i64::from(app.settings.export.dpi))), - default_value: match definition.default_policy { - DefaultPolicy::Fixed(value) => Some(value), + default_value: match &definition.default_policy { + DefaultPolicy::Fixed(value) => Some(value.clone()), DefaultPolicy::EncodingFactory | DefaultPolicy::ProcessingFactory + | DefaultPolicy::Derived | DefaultPolicy::None => None, }, availability: Availability::Editable, schema: ResolvedSchema::Int { min: i64::from(MIN_EXPORT_DPI), max: i64::from(MAX_EXPORT_DPI), + unit: "dpi", }, }) } @@ -69,25 +72,26 @@ impl PropertyProvider for ExportDpiProvider { app: &PlotxApp, transaction: &mut PropertyTransaction, address: &PropertyAddress, - operation: EditOp, + 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(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 { + EditOp::Reset => match &definition.default_policy { DefaultPolicy::Fixed(PropertyValue::Int(value)) => { - checked_dpi(definition.id, value)? + checked_dpi(definition.id, *value)? } DefaultPolicy::Fixed(_) | DefaultPolicy::EncodingFactory | DefaultPolicy::ProcessingFactory + | DefaultPolicy::Derived | DefaultPolicy::None => { return Err(PropertyError::InvalidValue { property: definition.id, diff --git a/crates/core/src/properties/group_delay.rs b/crates/core/src/properties/group_delay.rs new file mode 100644 index 00000000..ae22bf12 --- /dev/null +++ b/crates/core/src/properties/group_delay.rs @@ -0,0 +1,134 @@ +//! Dataset-owned digital-filter group-delay correction. + +use super::processing_common::{no_step_gesture, property_definition, wrong_kind}; +use super::provider::PropertyProvider; +use super::{ + AggregateValue, Applicability, Availability, ComponentKind, DefaultPolicy, EditOp, + PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, PropertyId, + PropertyTransaction, PropertyValue, ResolvedProperty, ResolvedSchema, ScopeKind, Tier, + ValueCopies, ValueSchema, +}; +use crate::state::{Dataset, DatasetId, Nmr2DDataset, NmrDataset, PlotxApp}; + +pub const CORRECT: PropertyId = PropertyId("dataset.processing.group_delay"); + +pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[PropertyDefinition { + id: CORRECT, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::Bool, + access: PropertyAccess::ReadWrite, + applicability: Applicability::component(ComponentKind::None), + default_policy: DefaultPolicy::ProcessingFactory, + tier: Tier::Advanced, + copies: ValueCopies::PerTarget, + canonical_label: "Group-delay correction", + canonical_aliases: &["digital filter", "group delay", "GRPDLY"], +}]; + +pub(crate) struct GroupDelayProvider; + +pub(crate) static PROVIDER: GroupDelayProvider = GroupDelayProvider; + +impl PropertyProvider for GroupDelayProvider { + fn definitions(&self) -> &'static [PropertyDefinition] { + DEFINITIONS + } + + fn read( + &self, + app: &PlotxApp, + address: &PropertyAddress, + ) -> Result { + let definition = property_definition(address.definition)?; + let (_, dataset) = dataset_context(app, address, definition)?; + Ok(ResolvedProperty { + address: address.clone(), + modified: None, + value: AggregateValue::Uniform(PropertyValue::Bool(dataset.current_value())), + default_value: Some(PropertyValue::Bool(dataset.factory_value())), + availability: Availability::Editable, + schema: ResolvedSchema::Bool, + }) + } + + fn edit( + &self, + app: &PlotxApp, + transaction: &mut PropertyTransaction, + address: &PropertyAddress, + operation: &EditOp<'_>, + ) -> Result<(), PropertyError> { + let definition = property_definition(address.definition)?; + let (dataset_id, dataset) = dataset_context(app, address, definition)?; + let value = match operation { + EditOp::Set(PropertyValue::Bool(value)) => *value, + EditOp::Set(value) => return Err(wrong_kind(definition, value, "true or false")), + EditOp::Reset => dataset.factory_value(), + EditOp::Step(_) => return Err(no_step_gesture(definition)), + }; + let state = transaction.processing_state(app, dataset_id)?; + let group_delay_correct = state.group_delay_correct_mut().ok_or_else(|| { + PropertyError::NotApplicable( + "Group-delay correction applies only to NMR datasets.".to_owned(), + ) + })?; + *group_delay_correct = value; + Ok(()) + } +} + +fn dataset_context<'a>( + app: &'a PlotxApp, + address: &PropertyAddress, + definition: &'static PropertyDefinition, +) -> Result<(DatasetId, NmrDatasetContext<'a>), PropertyError> { + let actual = ComponentKind::of(address.target.component.as_ref()); + if actual != ComponentKind::None { + return Err(PropertyError::ComponentKind { + property: definition.id, + expected: ComponentKind::None.as_str(), + actual: actual.as_str(), + }); + } + let dataset_id = DatasetId::try_from(&address.target.resource).map_err(|error| { + PropertyError::NotApplicable(format!( + "{} needs a dataset resource: {error}", + definition.id + )) + })?; + let dataset = app + .doc + .dataset_by_id(dataset_id) + .ok_or_else(|| PropertyError::UnknownTarget(address.target.resource.id.clone()))?; + match dataset { + Dataset::Nmr(dataset) => Ok((dataset_id, NmrDatasetContext::One(dataset))), + Dataset::Nmr2D(dataset) => Ok((dataset_id, NmrDatasetContext::Two(dataset))), + Dataset::Table(_) | Dataset::Electrophysiology(_) | Dataset::Afm(_) => { + Err(PropertyError::NotApplicable( + "Group-delay correction applies only to NMR datasets.".to_owned(), + )) + } + } +} + +#[derive(Clone, Copy)] +enum NmrDatasetContext<'a> { + One(&'a NmrDataset), + Two(&'a Nmr2DDataset), +} + +impl NmrDatasetContext<'_> { + fn current_value(self) -> bool { + match self { + Self::One(dataset) => dataset.group_delay_correct, + Self::Two(dataset) => dataset.group_delay_correct, + } + } + + fn factory_value(self) -> bool { + match self { + Self::One(dataset) => crate::state::default_group_delay_correct(dataset.data.domain), + Self::Two(dataset) => crate::state::default_group_delay_correct(dataset.data.domain), + } + } +} diff --git a/crates/core/src/properties/group_delay_tests.rs b/crates/core/src/properties/group_delay_tests.rs new file mode 100644 index 00000000..11dca4ed --- /dev/null +++ b/crates/core/src/properties/group_delay_tests.rs @@ -0,0 +1,149 @@ +use super::*; +use crate::actions::{Action, DatasetProcessingState}; +use crate::automation::{ResourceRef, TargetRef}; +use crate::state::{Dataset, Nmr2DDataset, PlotxApp}; +use num_complex::Complex64; +use plotx_io::{Dim, Domain, NmrData2D, QuadMode}; + +fn time_domain_2d_app() -> PlotxApp { + let dimension = |nucleus: &str| Dim { + spectral_width_hz: 2_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 4.7, + nucleus: nucleus.to_owned(), + group_delay: 4.0, + }; + let data = NmrData2D { + data: (0..64) + .map(|index| Complex64::new((index as f64 * 0.2).sin(), 0.1)) + .collect(), + rows: 8, + cols: 8, + domain: Domain::Time, + direct: dimension("1H"), + indirect: dimension("13C"), + quad: QuadMode::Complex, + indirect_conjugate: false, + experiment: None, + pseudo_axis: None, + diffusion: None, + nus: None, + source: "group delay property".to_owned(), + }; + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data)))); + app +} + +#[test] +fn two_dimensional_group_delay_is_in_the_typed_action_and_is_undoable() { + let mut app = time_domain_2d_app(); + let resource = app.doc.datasets[0].resource_id(); + let target = TargetRef { + resource: ResourceRef::from(resource), + component: None, + }; + let commit = app + .plan_property_write( + group_delay::CORRECT, + std::slice::from_ref(&target), + &PropertyValue::Bool(false), + ) + .expect("the dataset-level property plans"); + let Some(Action::Composite(actions)) = &commit.document_action else { + panic!("the catalog commit is composite"); + }; + assert!(matches!( + actions.as_slice(), + [Action::UpdateDatasetProcessing { + before: DatasetProcessingState::Nmr2D { + group_delay_correct: true, + .. + }, + after: DatasetProcessingState::Nmr2D { + group_delay_correct: false, + .. + }, + .. + }] + )); + app.commit_property(commit); + assert!( + !app.doc.datasets[0] + .as_nmr2d() + .expect("the dataset remains 2D NMR") + .group_delay_correct + ); + let disabled_input = app.doc.datasets[0] + .as_nmr2d() + .expect("the dataset remains 2D NMR") + .processing_data(); + assert_eq!(disabled_input.direct.group_delay, 0.0); + assert_eq!(disabled_input.indirect.group_delay, 4.0); + + app.undo(); + assert!( + app.doc.datasets[0] + .as_nmr2d() + .expect("the dataset remains 2D NMR") + .group_delay_correct + ); +} + +#[test] +fn two_dimensional_group_delay_settings_produce_different_real_spectra() { + let mut app = time_domain_2d_app(); + let target = TargetRef { + resource: ResourceRef::from(app.doc.datasets[0].resource_id()), + component: None, + }; + let corrected = { + let dataset = app.doc.datasets[0].as_nmr2d().unwrap(); + plotx_processing::process_2d(&dataset.processing_data(), &dataset.params) + }; + let changed = app + .plan_property_write( + group_delay::CORRECT, + std::slice::from_ref(&target), + &PropertyValue::Bool(false), + ) + .unwrap(); + app.commit_property(changed); + let uncorrected = { + let dataset = app.doc.datasets[0].as_nmr2d().unwrap(); + plotx_processing::process_2d(&dataset.processing_data(), &dataset.params) + }; + let ( + plotx_processing::Processed2D::Ft(corrected), + plotx_processing::Processed2D::Ft(uncorrected), + ) = (corrected, uncorrected) + else { + panic!("the HSQC fixture produces a true 2D spectrum"); + }; + assert_ne!(corrected.data, uncorrected.data); +} + +#[test] +fn group_delay_reset_uses_the_same_factory_rule_as_dataset_construction() { + let mut app = time_domain_2d_app(); + let target = TargetRef { + resource: ResourceRef::from(app.doc.datasets[0].resource_id()), + component: None, + }; + let changed = app + .plan_property_write( + group_delay::CORRECT, + std::slice::from_ref(&target), + &PropertyValue::Bool(false), + ) + .unwrap(); + app.commit_property(changed); + let reset = app + .plan_property_reset(group_delay::CORRECT, std::slice::from_ref(&target)) + .unwrap(); + assert_eq!(reset.applied.len(), 1); + app.commit_property(reset); + assert!(app.doc.datasets[0].as_nmr2d().unwrap().group_delay_correct); +} diff --git a/crates/core/src/properties/ilt.rs b/crates/core/src/properties/ilt.rs index 4f26bb6b..9c95ca1c 100644 --- a/crates/core/src/properties/ilt.rs +++ b/crates/core/src/properties/ilt.rs @@ -4,7 +4,7 @@ use super::provider::PropertyProvider; use super::target::require_app_target; use super::{ AggregateValue, Applicability, Availability, ComponentKind, DefaultPolicy, EditOp, FloatBounds, - PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, PropertyId, + FloatDisplay, PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, PropertyId, PropertyTransaction, PropertyValue, ResolvedProperty, ResolvedSchema, ScopeKind, Tier, ValueCopies, ValueSchema, definition, }; @@ -23,7 +23,7 @@ pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ scope_kind: ScopeKind::App, value_schema: ValueSchema::Float { bounds: LAMBDA_BOUNDS, - log: true, + display: FloatDisplay::Log10("λ"), drag_step: Some(0.001), }, access: PropertyAccess::ReadWrite, @@ -39,7 +39,7 @@ pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ scope_kind: ScopeKind::Dataset, value_schema: ValueSchema::Float { bounds: LAMBDA_BOUNDS, - log: true, + display: FloatDisplay::Log10("λ"), drag_step: None, }, access: PropertyAccess::ReadOnly, @@ -72,10 +72,11 @@ impl PropertyProvider for IltProvider { require_app_target(&address.target, definition)?; ( app.settings.processing.ilt_lambda, - match definition.default_policy { - DefaultPolicy::Fixed(value) => Some(value), + match &definition.default_policy { + DefaultPolicy::Fixed(value) => Some(value.clone()), DefaultPolicy::EncodingFactory | DefaultPolicy::ProcessingFactory + | DefaultPolicy::Derived | DefaultPolicy::None => None, }, Availability::Editable, @@ -90,13 +91,13 @@ impl PropertyProvider for IltProvider { }; Ok(ResolvedProperty { address: address.clone(), + modified: None, value: AggregateValue::Uniform(PropertyValue::Float(value)), default_value, availability, schema: ResolvedSchema::Float { bounds: LAMBDA_BOUNDS, - log: true, - unit: "", + display: FloatDisplay::Log10("λ"), }, }) } @@ -106,7 +107,7 @@ impl PropertyProvider for IltProvider { app: &PlotxApp, transaction: &mut PropertyTransaction, address: &PropertyAddress, - operation: EditOp, + operation: &EditOp<'_>, ) -> Result<(), PropertyError> { let definition = property_definition(address.definition)?; // `PropertyService::plan_edit` already refuses read-only definitions before @@ -120,20 +121,21 @@ impl PropertyProvider for IltProvider { } require_app_target(&address.target, definition)?; let value = match operation { - EditOp::Set(PropertyValue::Float(value)) => checked_lambda(definition.id, value)?, + EditOp::Set(PropertyValue::Float(value)) => checked_lambda(definition.id, *value)?, EditOp::Set(value) => { return Err(PropertyError::InvalidValue { property: definition.id, message: format!("expected a float, got {}", value.kind()), }); } - EditOp::Reset => match definition.default_policy { + EditOp::Reset => match &definition.default_policy { DefaultPolicy::Fixed(PropertyValue::Float(value)) => { - checked_lambda(definition.id, value)? + checked_lambda(definition.id, *value)? } DefaultPolicy::Fixed(_) | DefaultPolicy::EncodingFactory | DefaultPolicy::ProcessingFactory + | DefaultPolicy::Derived | DefaultPolicy::None => { return Err(PropertyError::InvalidValue { property: definition.id, diff --git a/crates/core/src/properties/ilt_tests.rs b/crates/core/src/properties/ilt_tests.rs index 8425995f..da32cf8d 100644 --- a/crates/core/src/properties/ilt_tests.rs +++ b/crates/core/src/properties/ilt_tests.rs @@ -84,8 +84,7 @@ fn ilt_default_catalog_edit_uses_shared_bounds_and_persists() { resolved.schema, ResolvedSchema::Float { bounds: FloatBounds::inclusive(MIN_ILT_LAMBDA, MAX_ILT_LAMBDA), - log: true, - unit: "", + display: FloatDisplay::Log10("λ"), } ); diff --git a/crates/core/src/properties/line.rs b/crates/core/src/properties/line.rs index 63dbe187..55d5770b 100644 --- a/crates/core/src/properties/line.rs +++ b/crates/core/src/properties/line.rs @@ -4,9 +4,9 @@ use super::provider::PropertyProvider; use super::target::{SeriesContext, not_applicable_encoding, resolved_schema, series_context}; use super::{ AggregateValue, Applicability, Availability, ComponentKind, DefaultPolicy, EditOp, - EncodingKind, FloatBounds, PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, - PropertyId, PropertyTransaction, PropertyValue, ResolvedProperty, ScopeKind, Tier, ValueCopies, - ValueSchema, definition, + EncodingKind, FloatBounds, FloatDisplay, PropertyAccess, PropertyAddress, PropertyDefinition, + PropertyError, PropertyId, PropertyTransaction, PropertyValue, ResolvedProperty, ScopeKind, + Tier, ValueCopies, ValueSchema, definition, }; use crate::state::{ PlotxApp, PresentationProfile, RequestedChart, default_encoding, field_peak_magnitude, @@ -23,7 +23,7 @@ pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[PropertyDefinition { scope_kind: ScopeKind::Object, value_schema: ValueSchema::Float { bounds: WIDTH_BOUNDS, - log: false, + display: FloatDisplay::Linear(""), drag_step: Some(WIDTH_STEP), }, access: PropertyAccess::ReadWrite, @@ -58,6 +58,7 @@ impl PropertyProvider for LineProvider { }; Ok(ResolvedProperty { address: address.clone(), + modified: None, value: AggregateValue::Uniform(PropertyValue::Float(f64::from(line.width.get()))), default_value: factory_width(&context).map(PropertyValue::Float), availability: Availability::Editable, @@ -70,7 +71,7 @@ impl PropertyProvider for LineProvider { app: &PlotxApp, transaction: &mut PropertyTransaction, address: &PropertyAddress, - operation: EditOp, + operation: &EditOp<'_>, ) -> Result<(), PropertyError> { let definition = definition(address.definition).ok_or_else(|| { PropertyError::UnknownProperty(address.definition.as_str().to_owned()) @@ -81,7 +82,7 @@ impl PropertyProvider for LineProvider { }; let width = match operation { EditOp::Set(PropertyValue::Float(value)) => { - WIDTH_BOUNDS.check(definition.id, "line width", value)? + WIDTH_BOUNDS.check(definition.id, "line width", *value)? } EditOp::Set(value) => { return Err(PropertyError::InvalidValue { diff --git a/crates/core/src/properties/mod.rs b/crates/core/src/properties/mod.rs index 4255beb6..a9f202c2 100644 --- a/crates/core/src/properties/mod.rs +++ b/crates/core/src/properties/mod.rs @@ -9,20 +9,34 @@ //! application crate and is keyed by the same [`PropertyId`]. pub mod apodization; +pub mod app_preferences; +pub mod axis; +pub mod baseline; +pub mod bin; +pub mod canvas; pub mod contour; pub mod export_dpi; +pub mod group_delay; pub mod ilt; pub mod line; mod model; +pub mod normalize; +pub mod object; +pub mod phase; +mod processing_common; mod provider; mod readout; +pub mod reference; mod service; +pub mod smooth; +pub mod step_enabled; mod target; mod transaction; pub mod typography; +pub mod zero_fill; pub use model::*; -pub use readout::{ContourAnchor, ContourBaseReadout, PropertyReadout}; +pub use readout::{ContourAnchor, ContourBaseReadout, PropertyReadout, ZeroFillTargetReadout}; pub(crate) use provider::{PropertyProvider, PropertyProviderGroup}; pub(crate) use transaction::PropertyTransaction; @@ -37,21 +51,60 @@ pub(crate) static GROUPS: &[PropertyProviderGroup] = &[ PropertyProviderGroup { provider: &apodization::PROVIDER, }, + PropertyProviderGroup { + provider: &app_preferences::PROVIDER, + }, + PropertyProviderGroup { + provider: &axis::PROVIDER, + }, + PropertyProviderGroup { + provider: &baseline::PROVIDER, + }, + PropertyProviderGroup { + provider: &bin::PROVIDER, + }, + PropertyProviderGroup { + provider: &canvas::PROVIDER, + }, PropertyProviderGroup { provider: &contour::PROVIDER, }, PropertyProviderGroup { provider: &export_dpi::PROVIDER, }, + PropertyProviderGroup { + provider: &group_delay::PROVIDER, + }, PropertyProviderGroup { provider: &ilt::PROVIDER, }, PropertyProviderGroup { provider: &line::PROVIDER, }, + PropertyProviderGroup { + provider: &normalize::PROVIDER, + }, + PropertyProviderGroup { + provider: &object::PROVIDER, + }, + PropertyProviderGroup { + provider: &phase::PROVIDER, + }, + PropertyProviderGroup { + provider: &reference::PROVIDER, + }, + PropertyProviderGroup { + provider: &smooth::PROVIDER, + }, + PropertyProviderGroup { + provider: &step_enabled::PROVIDER, + }, PropertyProviderGroup { provider: &typography::PROVIDER, }, + PropertyProviderGroup { + provider: &zero_fill::PROVIDER, + }, ]; static CATALOG: LazyLock> = LazyLock::new(|| { @@ -169,6 +222,58 @@ mod scope_tests; #[path = "apodization_tests.rs"] mod apodization_tests; +#[cfg(test)] +#[path = "axis_tests.rs"] +mod axis_tests; + +#[cfg(test)] +#[path = "object_tests.rs"] +mod object_tests; + +#[cfg(test)] +#[path = "processing_test_support.rs"] +mod processing_test_support; + +#[cfg(test)] +#[path = "zero_fill_tests.rs"] +mod zero_fill_tests; + +#[cfg(test)] +#[path = "phase_tests.rs"] +mod phase_tests; + +#[cfg(test)] +#[path = "baseline_tests.rs"] +mod baseline_tests; + +#[cfg(test)] +#[path = "reference_tests.rs"] +mod reference_tests; + +#[cfg(test)] +#[path = "smooth_tests.rs"] +mod smooth_tests; + +#[cfg(test)] +#[path = "normalize_tests.rs"] +mod normalize_tests; + +#[cfg(test)] +#[path = "bin_tests.rs"] +mod bin_tests; + +#[cfg(test)] +#[path = "canvas_tests.rs"] +mod canvas_tests; + +#[cfg(test)] +#[path = "step_enabled_tests.rs"] +mod step_enabled_tests; + +#[cfg(test)] +#[path = "group_delay_tests.rs"] +mod group_delay_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 3617da98..393c41a9 100644 --- a/crates/core/src/properties/model.rs +++ b/crates/core/src/properties/model.rs @@ -8,722 +8,10 @@ //! model. There is, for the same reason, no generic value store here; writes are //! compiled into typed commits for their owning persistence boundary. -use crate::automation::{ComponentRef, TargetRef}; -use plotx_figure::Color; -use std::fmt; +mod identity; +mod resolution; +mod schema; -/// Stable, language-neutral identity of one catalog entry. Definitions are -/// static, so the identity is a `&'static str`: it cannot be minted at runtime -/// and cannot drift between releases without a visible source change. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct PropertyId(pub &'static str); - -impl PropertyId { - pub const fn as_str(self) -> &'static str { - self.0 - } - - /// The dotted segments of the id, used as search tokens so a headless - /// caller can find `series.contour.count` by typing "contour count". - pub fn tokens(self) -> impl Iterator { - self.0.split('.') - } -} - -impl fmt::Display for PropertyId { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.0) - } -} - -/// Who owns the value and how many instances of it exist. Ownership decides the -/// scope, never "does editing it trigger a recomputation": a contour threshold -/// is owned by one series in one plot even though changing it rebuilds geometry. -/// -/// There is no `Session` scope on purpose — the current slice, panel expansion -/// and board zoom are navigation state and stay out of the catalog (§8.4). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ScopeKind { - App, - Document, - Canvas, - Dataset, - Object, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum PropertyAccess { - ReadOnly, - ReadWrite, -} - -/// Panel budget tier. This is the single definition of a property's tier; -/// the presentation layer reads it rather than storing its own copy, so the two -/// cannot drift apart. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub enum Tier { - Essential, - Advanced, - Expert, -} - -impl Tier { - pub const fn as_str(self) -> &'static str { - match self { - Self::Essential => "essential", - Self::Advanced => "advanced", - Self::Expert => "expert", - } - } -} - -/// One selectable choice of an enumerated property, together with the field -/// capabilities that make it selectable at all. The gate is declared here rather -/// than in a `match` on a data domain, so a new provider gains or loses a choice -/// purely by exposing or withholding a capability. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct EnumVariant { - pub id: &'static str, - pub canonical_label: &'static str, - /// Every capability the target's field must expose. - pub required_capabilities: &'static [&'static str], - /// Capabilities that make this choice meaningless even when the required - /// ones are present — a fraction of the value range says nothing useful on a - /// field with both signs. - pub forbidden_capabilities: &'static [&'static str], -} - -impl EnumVariant { - pub const fn new(id: &'static str, canonical_label: &'static str) -> Self { - Self { - id, - canonical_label, - required_capabilities: &[], - forbidden_capabilities: &[], - } - } - - pub const fn requiring(mut self, capabilities: &'static [&'static str]) -> Self { - self.required_capabilities = capabilities; - self - } - - pub const fn forbidding(mut self, capabilities: &'static [&'static str]) -> Self { - self.forbidden_capabilities = capabilities; - self - } -} - -/// The numeric range of a float property. -/// -/// A bound can be *open*: a level ratio must be strictly greater than one, or -/// the ladder it describes stops rising. Openness is part of the rule and -/// therefore belongs to the schema, not to whichever control or writer happens -/// to re-state it. The alternative — a control that stops at a rounded literal -/// while the writer tests the real bound — is two copies of one rule, and the -/// copies drift. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct FloatBounds { - pub min: f64, - pub max: f64, - /// Whether `min` itself is admitted. - pub exclusive_min: bool, -} - -impl FloatBounds { - /// `min ..= max`. - pub const fn inclusive(min: f64, max: f64) -> Self { - Self { - min, - max, - exclusive_min: false, - } - } - - /// Strictly above `min`, up to and including `max`. - pub const fn above(min: f64, max: f64) -> Self { - Self { - min, - max, - exclusive_min: true, - } - } - - pub fn admits(self, value: f64) -> bool { - value.is_finite() - && value <= self.max - && if self.exclusive_min { - value > self.min - } else { - value >= self.min - } - } - - /// The smallest value the bound admits. - /// - /// A control whose range is inclusive has to start here rather than at - /// `min`, so it cannot offer a value the write path will reject. Deriving it - /// keeps the offset out of the hands of whoever writes the next control. - pub fn lowest(self) -> f64 { - if self.exclusive_min { - self.min.next_up() - } else { - self.min - } - } - - /// The rule in words, for an error a user reads. - pub fn describe(self) -> String { - let low = if self.exclusive_min { - format!("greater than {}", self.min) - } else { - format!("at least {}", self.min) - }; - format!("{low} and at most {}", self.max) - } - - /// Validate a value against the bound, naming the property in the failure. - /// - /// The failure states the value that was rejected as well as the rule. - /// "Level ratio must be greater than 1 and at most 10" leaves a caller that - /// sent 10.0000001, or sent a string that parsed to something else - /// entirely, unable to tell which end it fell off — and a headless caller - /// has no control to look at. Naming both closes that. - pub fn check( - self, - property: PropertyId, - subject: &str, - value: f64, - ) -> Result { - if self.admits(value) { - return Ok(value); - } - Err(PropertyError::InvalidValue { - property, - message: format!( - "{subject} {value} is out of range: it must be {}", - self.describe() - ), - }) - } -} - -/// The static value schema. Bounds that depend on the target's current state are -/// reported by [`ResolvedProperty::schema`] instead, keeping the definition -/// context-free. -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum ValueSchema { - Bool, - Int { - min: i64, - max: i64, - }, - Float { - bounds: FloatBounds, - log: bool, - /// How far one notch of a direct-manipulation drag moves the value. - /// - /// A control that has to invent this can only derive it from the range, - /// and a range is a statement about what is *admissible*, not about what - /// is *usual*: line broadening is legal out to ±10 kHz and typically set - /// between 0.3 and 5 Hz, so a range-derived notch moves it by a hundred - /// hertz a pixel. The quantity's own scale is knowledge the definition - /// has and the control does not, so the definition states it. `None` - /// leaves the control to fall back on the range. - drag_step: Option, - }, - Enum { - variants: &'static [EnumVariant], - }, - Color, -} - -impl ValueSchema { - /// The declared numeric range, when this is a float schema. Both the control - /// and the write path ask for it here rather than restating it. - pub const fn float_bounds(&self) -> Option { - match self { - Self::Float { bounds, .. } => Some(*bounds), - _ => None, - } - } -} - -/// A property value in transit between the catalog and a control. It is never -/// stored: the authoritative value stays in the typed domain model. -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum PropertyValue { - Bool(bool), - Int(i64), - Float(f64), - /// One of the owning schema's static variant ids. - Enum(&'static str), - Color(Color), -} - -impl PropertyValue { - pub const fn kind(&self) -> &'static str { - match self { - Self::Bool(_) => "bool", - Self::Int(_) => "int", - Self::Float(_) => "float", - Self::Enum(_) => "enum", - Self::Color(_) => "color", - } - } - - pub const fn as_bool(&self) -> Option { - match self { - Self::Bool(value) => Some(*value), - _ => None, - } - } - - pub const fn as_int(&self) -> Option { - match self { - Self::Int(value) => Some(*value), - _ => None, - } - } - - pub const fn as_float(&self) -> Option { - match self { - Self::Float(value) => Some(*value), - _ => None, - } - } - - pub const fn as_enum(&self) -> Option<&'static str> { - match self { - Self::Enum(value) => Some(*value), - _ => None, - } - } - - pub const fn as_color(&self) -> Option { - match self { - Self::Color(value) => Some(*value), - _ => None, - } - } -} - -/// How the default of a property is obtained. Defaults are *derived*, never -/// stored next to the current value. -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum DefaultPolicy { - /// Re-run the same factory that materializes a new encoding, in the - /// target's current context, and read this property out of the result. - EncodingFactory, - /// Re-run the typed processing recipe factory appropriate to the addressed - /// step. Unlike an encoding factory, this is owned by a dataset pipeline - /// and can vary between a 1D and a 2D default recipe. - ProcessingFactory, - /// A literal that does not depend on the target. - Fixed(PropertyValue), - /// Values with no meaningful reset target, normally read-only provenance. - None, -} - -/// How many copies of one setting a single target holds. -/// -/// Most settings have exactly one copy per target, so a single-target read can -/// only ever be uniform. A few describe a shape the target mirrors — a contour -/// ladder keeps a positive and a negative half that share base, count and -/// ratio — and those have one copy per half, which is why even a single-target -/// read is an aggregate. -/// -/// The distinction is declared here rather than inferred by a control, so a -/// frontend can say *which* sources disagree without knowing what the target's -/// domain model looks like. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ValueCopies { - /// Exactly one copy per target. - PerTarget, - /// One copy per mirrored half of a symmetric pair the target holds. - PerMirroredHalf, -} - -/// One step of a direct-manipulation gesture along a property's own scale. -/// -/// The gesture names a direction, never a value: what one step *is* belongs to -/// the property, so a canvas key and a panel control cannot disagree about it. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum PropertyStep { - Raise, - Lower, -} - -impl PropertyStep { - pub const fn as_str(self) -> &'static str { - match self { - Self::Raise => "raise", - Self::Lower => "lower", - } - } -} - -/// One typed edit operation shared by all catalog entry points. -/// -/// The service owns selection-wide planning; a provider receives one target and -/// this operation, applies it to its typed working copy, or explains why that -/// target cannot accept it. Keeping the operation here prevents set/reset/step -/// from growing three structurally identical planners as new providers arrive. -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum EditOp { - Set(PropertyValue), - Reset, - Step(PropertyStep), -} - -/// Which owner-local component the address must name. Field and column -/// properties are addressed through their own child `ResourceRef` with no -/// component at all, so they are absent here (§3.1). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ComponentKind { - None, - Series, - ProcessingStep, -} - -impl ComponentKind { - pub const fn as_str(self) -> &'static str { - match self { - Self::None => "none", - Self::Series => "series", - Self::ProcessingStep => "processing_step", - } - } - - pub fn of(component: Option<&ComponentRef>) -> Self { - match component { - None => Self::None, - Some(ComponentRef::Series(_)) => Self::Series, - Some(ComponentRef::ProcessingStep(_)) => Self::ProcessingStep, - } - } -} - -/// Which concrete visual encoding a property belongs to. This is a fact about -/// the rendering model, not about a data domain: any field that can be drawn as -/// a contour exposes the same contour properties, whatever it measures. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum EncodingKind { - Line, - Contour, - Heatmap, - Image, -} - -impl EncodingKind { - pub const fn as_str(self) -> &'static str { - match self { - Self::Line => "line", - Self::Contour => "contour", - Self::Heatmap => "heatmap", - Self::Image => "image", - } - } - - pub const fn of(encoding: &plotx_figure::SeriesEncoding) -> Self { - match encoding { - plotx_figure::SeriesEncoding::Line(_) => Self::Line, - plotx_figure::SeriesEncoding::Contour(_) => Self::Contour, - plotx_figure::SeriesEncoding::Heatmap(_) => Self::Heatmap, - plotx_figure::SeriesEncoding::Image(_) => Self::Image, - } - } -} - -/// When a property applies to a target. Everything here is expressed as a -/// component shape plus rendering capabilities; no branch of the catalog may -/// name a data domain. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Applicability { - pub component: ComponentKind, - pub encoding: Option, - pub required_capabilities: &'static [&'static str], -} - -impl Applicability { - pub const fn component(component: ComponentKind) -> Self { - Self { - component, - encoding: None, - required_capabilities: &[], - } - } - - pub const fn encoding(component: ComponentKind, encoding: EncodingKind) -> Self { - Self { - component, - encoding: Some(encoding), - required_capabilities: &[], - } - } - - pub const fn requiring(mut self, capabilities: &'static [&'static str]) -> Self { - self.required_capabilities = capabilities; - self - } -} - -/// The static, language-neutral description of one property. -#[derive(Clone, Copy, Debug)] -pub struct PropertyDefinition { - pub id: PropertyId, - pub scope_kind: ScopeKind, - pub value_schema: ValueSchema, - pub access: PropertyAccess, - pub applicability: Applicability, - pub default_policy: DefaultPolicy, - pub tier: Tier, - /// How many copies of this setting one target holds, so a control can word - /// a disagreement precisely instead of listing every way one could arise. - pub copies: ValueCopies, - pub canonical_label: &'static str, - /// Stable English search terms. The presentation layer adds localized ones; - /// it may never introduce an entry that has no definition here. - pub canonical_aliases: &'static [&'static str], -} - -/// Where a property lives: a target (resource plus at most one owner-local -/// component) and the definition being addressed. -#[derive(Clone, Debug, PartialEq)] -pub struct PropertyAddress { - pub target: TargetRef, - pub definition: PropertyId, -} - -impl PropertyAddress { - pub fn new(target: TargetRef, definition: PropertyId) -> Self { - Self { target, definition } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Availability { - Editable, - ReadOnly, -} - -/// The schema narrowed to one concrete target: the enum choices its field's -/// capabilities permit, and the bounds and unit that its current state implies. -#[derive(Clone, Debug, PartialEq)] -pub enum ResolvedSchema { - Bool, - Int { - min: i64, - max: i64, - }, - Float { - bounds: FloatBounds, - log: bool, - /// A short unit or multiplier caption ("× σ", "fraction"), empty when - /// the number speaks for itself. - unit: &'static str, - }, - Enum { - variants: Vec<&'static EnumVariant>, - }, - Color, -} - -/// A property read against one target. The value and its default are derived on -/// the spot; neither is stored anywhere in the catalog. -#[derive(Clone, Debug, PartialEq)] -pub struct ResolvedProperty { - pub address: PropertyAddress, - /// One target can already hold the setting more than once — a contour - /// ladder keeps a positive and a negative half that share base, count and - /// ratio — so even a single-target read is an aggregate. `Mixed` here means - /// the target's own copies disagree, and the read refuses to pass one of - /// them off as the whole setting. - pub value: AggregateValue, - /// `None` for read-only properties, which have nothing to reset to. - pub default_value: Option, - pub availability: Availability, - pub schema: ResolvedSchema, -} - -impl ResolvedProperty { - /// Whether the target currently differs from what the default policy would - /// produce for it right now. A target whose own copies disagree cannot be - /// what the factory produced, which always writes one value to every copy. - pub fn is_modified(&self) -> bool { - match &self.value { - AggregateValue::Uniform(value) => { - self.default_value.is_some_and(|default| default != *value) - } - AggregateValue::Mixed => true, - AggregateValue::Unavailable => false, - } - } -} - -/// The read side of an aggregate: several sources of one setting, folded into -/// the single answer a control can show. -/// -/// The sources are not necessarily targets. Both the copies one target holds of -/// a shared setting and the targets of a multi-selection aggregate through this -/// same type, so "the two halves of this ladder disagree" and "these two series -/// disagree" are one fact with one representation rather than two parallel ones. -#[derive(Clone, Debug, PartialEq)] -pub enum AggregateValue { - Uniform(T), - Mixed, - Unavailable, -} - -impl AggregateValue { - pub const fn uniform(&self) -> Option<&T> { - match self { - Self::Uniform(value) => Some(value), - Self::Mixed | Self::Unavailable => None, - } - } -} - -impl AggregateValue { - /// Fold one more source in. - /// - /// `Unavailable` is the empty read and therefore the identity, so folding - /// over no sources at all yields it. Once any source is `Mixed`, or two - /// sources carry different values, the result stays `Mixed`: disagreement - /// inside a source and disagreement between sources compose instead of one - /// hiding the other. - #[must_use] - pub fn merge(self, other: Self) -> Self { - match (self, other) { - (Self::Unavailable, other) => other, - (current, Self::Unavailable) => current, - (Self::Uniform(current), Self::Uniform(next)) if current == next => { - Self::Uniform(current) - } - _ => Self::Mixed, - } - } -} - -/// Why one target of a selection-wide read or write did nothing. -/// -/// The reason is typed because callers branch on it. "This target already holds -/// the value you asked for" is a success a caller should carry on from; "this -/// property does not apply to that target" means it addressed the wrong thing. -/// Leaving the two indistinguishable behind free text forces a caller to match -/// on prose that exists to be read by a person and is free to be reworded. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SkipReason { - /// The target already holds the requested value, so nothing was written. - AlreadyAtValue, - /// The property does not apply to this target. - NotApplicable, - /// The address no longer names anything in the document. - TargetMissing, -} - -impl SkipReason { - pub const fn as_str(self) -> &'static str { - match self { - Self::AlreadyAtValue => "already_at_value", - Self::NotApplicable => "not_applicable", - Self::TargetMissing => "target_missing", - } - } - - /// The reason a failed read or edit amounts to. - pub const fn of(error: &PropertyError) -> Self { - match error { - PropertyError::UnknownTarget(_) => Self::TargetMissing, - _ => Self::NotApplicable, - } - } -} - -/// One target a selection-wide operation passed over, carrying the reason in -/// both the form a caller branches on and the form a person reads. -#[derive(Clone, Debug, PartialEq)] -pub struct PropertySkip { - pub target: TargetRef, - pub reason: SkipReason, - pub message: String, -} - -impl PropertySkip { - pub fn new(target: TargetRef, reason: SkipReason, message: String) -> Self { - Self { - target, - reason, - message, - } - } - - /// The skip a failed read or edit amounts to, keeping the error's own words. - pub fn from_error(target: TargetRef, error: &PropertyError) -> Self { - Self::new(target, SkipReason::of(error), error.to_string()) - } -} - -/// One property read across a selection. Targets the property does not apply to -/// are reported with a reason rather than silently dropped. -#[derive(Clone, Debug, PartialEq)] -pub struct ResolvedPropertySet { - pub applicable_targets: Vec, - pub skipped_targets: Vec, - pub value: AggregateValue, -} - -/// 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(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() - } -} - -#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] -pub enum PropertyError { - #[error("unknown property '{0}'")] - UnknownProperty(String), - #[error("property {property} addresses a {expected} component, not {actual}")] - ComponentKind { - property: PropertyId, - expected: &'static str, - actual: &'static str, - }, - #[error("no such target: {0}")] - UnknownTarget(String), - #[error("{0}")] - NotApplicable(String), - #[error("property {0} is read-only")] - ReadOnly(PropertyId), - #[error("invalid value for {property}: {message}")] - InvalidValue { - property: PropertyId, - message: String, - }, - #[error("one property commit cannot cross multiple storages: {storages}")] - MixedStorage { storages: String }, -} +pub use identity::*; +pub use resolution::*; +pub use schema::*; diff --git a/crates/core/src/properties/model/identity.rs b/crates/core/src/properties/model/identity.rs new file mode 100644 index 00000000..438fbcfd --- /dev/null +++ b/crates/core/src/properties/model/identity.rs @@ -0,0 +1,68 @@ +//! Property identity, ownership scope, access, and panel tier. + +use std::fmt; + +/// Stable, language-neutral identity of one catalog entry. Definitions are +/// static, so the identity is a `&'static str`: it cannot be minted at runtime +/// and cannot drift between releases without a visible source change. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PropertyId(pub &'static str); + +impl PropertyId { + pub const fn as_str(self) -> &'static str { + self.0 + } + + /// The dotted segments of the id, used as search tokens so a headless + /// caller can find `series.contour.count` by typing "contour count". + pub fn tokens(self) -> impl Iterator { + self.0.split('.') + } +} + +impl fmt::Display for PropertyId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.0) + } +} + +/// Who owns the value and how many instances of it exist. Ownership decides the +/// scope, never "does editing it trigger a recomputation": a contour threshold +/// is owned by one series in one plot even though changing it rebuilds geometry. +/// +/// There is no `Session` scope on purpose — the current slice, panel expansion +/// and board zoom are navigation state and stay out of the catalog (§8.4). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScopeKind { + App, + Document, + Canvas, + Dataset, + Object, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PropertyAccess { + ReadOnly, + ReadWrite, +} + +/// Panel budget tier. This is the single definition of a property's tier; +/// the presentation layer reads it rather than storing its own copy, so the two +/// cannot drift apart. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Tier { + Essential, + Advanced, + Expert, +} + +impl Tier { + pub const fn as_str(self) -> &'static str { + match self { + Self::Essential => "essential", + Self::Advanced => "advanced", + Self::Expert => "expert", + } + } +} diff --git a/crates/core/src/properties/model/resolution.rs b/crates/core/src/properties/model/resolution.rs new file mode 100644 index 00000000..3067ae88 --- /dev/null +++ b/crates/core/src/properties/model/resolution.rs @@ -0,0 +1,382 @@ +//! Property addressing, applicability, resolution, and commit results. + +use super::{ + DefaultPolicy, EnumVariant, FloatBounds, FloatDisplay, PropertyAccess, PropertyId, + PropertyValue, ScopeKind, Tier, ValueCopies, ValueSchema, +}; +use crate::automation::{ComponentRef, TargetRef}; +use std::fmt; + +/// Which owner-local component the address must name. Field and column +/// properties are addressed through their own child `ResourceRef` with no +/// component at all, so they are absent here (§3.1). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ComponentKind { + None, + Series, + ProcessingStep, +} + +impl ComponentKind { + pub const fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::Series => "series", + Self::ProcessingStep => "processing_step", + } + } + + pub fn of(component: Option<&ComponentRef>) -> Self { + match component { + None => Self::None, + Some(ComponentRef::Series(_)) => Self::Series, + Some(ComponentRef::ProcessingStep(_)) => Self::ProcessingStep, + } + } +} + +/// Which concrete visual encoding a property belongs to. This is a fact about +/// the rendering model, not about a data domain: any field that can be drawn as +/// a contour exposes the same contour properties, whatever it measures. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EncodingKind { + Line, + Contour, + Heatmap, + Image, +} + +impl EncodingKind { + pub const fn as_str(self) -> &'static str { + match self { + Self::Line => "line", + Self::Contour => "contour", + Self::Heatmap => "heatmap", + Self::Image => "image", + } + } + + pub const fn of(encoding: &plotx_figure::SeriesEncoding) -> Self { + match encoding { + plotx_figure::SeriesEncoding::Line(_) => Self::Line, + plotx_figure::SeriesEncoding::Contour(_) => Self::Contour, + plotx_figure::SeriesEncoding::Heatmap(_) => Self::Heatmap, + plotx_figure::SeriesEncoding::Image(_) => Self::Image, + } + } +} + +/// When a property applies to a target. Everything here is expressed as a +/// component shape plus rendering capabilities; no branch of the catalog may +/// name a data domain. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Applicability { + pub component: ComponentKind, + pub encoding: Option, + pub required_capabilities: &'static [&'static str], +} + +impl Applicability { + pub const fn component(component: ComponentKind) -> Self { + Self { + component, + encoding: None, + required_capabilities: &[], + } + } + + pub const fn encoding(component: ComponentKind, encoding: EncodingKind) -> Self { + Self { + component, + encoding: Some(encoding), + required_capabilities: &[], + } + } + + pub const fn requiring(mut self, capabilities: &'static [&'static str]) -> Self { + self.required_capabilities = capabilities; + self + } +} + +/// The static, language-neutral description of one property. +#[derive(Clone, Debug)] +pub struct PropertyDefinition { + pub id: PropertyId, + pub scope_kind: ScopeKind, + pub value_schema: ValueSchema, + pub access: PropertyAccess, + pub applicability: Applicability, + pub default_policy: DefaultPolicy, + pub tier: Tier, + /// How many copies of this setting one target holds, so a control can word + /// a disagreement precisely instead of listing every way one could arise. + pub copies: ValueCopies, + pub canonical_label: &'static str, + /// Stable English search terms. The presentation layer adds localized ones; + /// it may never introduce an entry that has no definition here. + pub canonical_aliases: &'static [&'static str], +} + +/// Where a property lives: a target (resource plus at most one owner-local +/// component) and the definition being addressed. +#[derive(Clone, Debug, PartialEq)] +pub struct PropertyAddress { + pub target: TargetRef, + pub definition: PropertyId, +} + +impl PropertyAddress { + pub fn new(target: TargetRef, definition: PropertyId) -> Self { + Self { target, definition } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Availability { + Editable, + Disabled(&'static str), + ReadOnly, +} + +/// The schema narrowed to one concrete target: the enum choices its field's +/// capabilities permit, and the bounds and unit that its current state implies. +#[derive(Clone, Debug, PartialEq)] +pub enum ResolvedSchema { + Bool, + Text, + Int { + min: i64, + max: i64, + unit: &'static str, + }, + IntWithDrag { + min: i64, + max: i64, + drag_step: f64, + unit: &'static str, + }, + SteppedInt { + min: i64, + max: i64, + step: i64, + drag_step: f64, + unit: &'static str, + }, + Float { + bounds: FloatBounds, + display: FloatDisplay, + }, + Enum { + variants: Vec<&'static EnumVariant>, + }, + Color, +} + +/// A property read against one target. The value and its default are derived on +/// the spot; neither is stored anywhere in the catalog. +#[derive(Clone, Debug, PartialEq)] +pub struct ResolvedProperty { + pub address: PropertyAddress, + /// One target can already hold the setting more than once — a contour + /// ladder keeps a positive and a negative half that share base, count and + /// ratio — so even a single-target read is an aggregate. `Mixed` here means + /// the target's own copies disagree, and the read refuses to pass one of + /// them off as the whole setting. + pub value: AggregateValue, + /// `None` for read-only properties, which have nothing to reset to. + pub default_value: Option, + /// Provider-owned storage can distinguish an explicit override from a + /// derived value that happens to be equal. `None` uses value comparison. + pub modified: Option, + pub availability: Availability, + pub schema: ResolvedSchema, +} + +impl ResolvedProperty { + /// Whether the row should expose its reset affordance. Most providers infer + /// this from value comparison; override-backed providers can report storage + /// presence explicitly when an override equals its current derived value. + pub fn is_modified(&self) -> bool { + if let Some(modified) = self.modified { + return modified; + } + match &self.value { + AggregateValue::Uniform(value) => self + .default_value + .as_ref() + .is_some_and(|default| default != value), + AggregateValue::Mixed => true, + AggregateValue::Unavailable => false, + } + } +} + +/// The read side of an aggregate: several sources of one setting, folded into +/// the single answer a control can show. +/// +/// The sources are not necessarily targets. Both the copies one target holds of +/// a shared setting and the targets of a multi-selection aggregate through this +/// same type, so "the two halves of this ladder disagree" and "these two series +/// disagree" are one fact with one representation rather than two parallel ones. +#[derive(Clone, Debug, PartialEq)] +pub enum AggregateValue { + Uniform(T), + Mixed, + Unavailable, +} + +impl AggregateValue { + pub const fn uniform(&self) -> Option<&T> { + match self { + Self::Uniform(value) => Some(value), + Self::Mixed | Self::Unavailable => None, + } + } +} + +impl AggregateValue { + /// Fold one more source in. + /// + /// `Unavailable` is the empty read and therefore the identity, so folding + /// over no sources at all yields it. Once any source is `Mixed`, or two + /// sources carry different values, the result stays `Mixed`: disagreement + /// inside a source and disagreement between sources compose instead of one + /// hiding the other. + #[must_use] + pub fn merge(self, other: Self) -> Self { + match (self, other) { + (Self::Unavailable, other) => other, + (current, Self::Unavailable) => current, + (Self::Uniform(current), Self::Uniform(next)) if current == next => { + Self::Uniform(current) + } + _ => Self::Mixed, + } + } +} + +/// Why one target of a selection-wide read or write did nothing. +/// +/// The reason is typed because callers branch on it. "This target already holds +/// the value you asked for" is a success a caller should carry on from; "this +/// property does not apply to that target" means it addressed the wrong thing. +/// Leaving the two indistinguishable behind free text forces a caller to match +/// on prose that exists to be read by a person and is free to be reworded. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SkipReason { + /// The target already holds the requested value, so nothing was written. + AlreadyAtValue, + /// The property does not apply to this target. + NotApplicable, + /// The address no longer names anything in the document. + TargetMissing, +} + +impl SkipReason { + pub const fn as_str(self) -> &'static str { + match self { + Self::AlreadyAtValue => "already_at_value", + Self::NotApplicable => "not_applicable", + Self::TargetMissing => "target_missing", + } + } + + /// The reason a failed read or edit amounts to. + pub const fn of(error: &PropertyError) -> Self { + match error { + PropertyError::UnknownTarget(_) => Self::TargetMissing, + _ => Self::NotApplicable, + } + } +} + +/// One target a selection-wide operation passed over, carrying the reason in +/// both the form a caller branches on and the form a person reads. +#[derive(Clone, Debug, PartialEq)] +pub struct PropertySkip { + pub target: TargetRef, + pub reason: SkipReason, + pub message: String, +} + +impl PropertySkip { + pub fn new(target: TargetRef, reason: SkipReason, message: String) -> Self { + Self { + target, + reason, + message, + } + } + + /// The skip a failed read or edit amounts to, keeping the error's own words. + pub fn from_error(target: TargetRef, error: &PropertyError) -> Self { + Self::new(target, SkipReason::of(error), error.to_string()) + } +} + +/// One property read across a selection. Targets the property does not apply to +/// are reported with a reason rather than silently dropped. +#[derive(Clone, Debug, PartialEq)] +pub struct ResolvedPropertySet { + pub applicable_targets: Vec, + pub skipped_targets: Vec, + pub value: AggregateValue, +} + +/// 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(crate) document_action: Option, + pub(crate) canvas_direct: Vec, + 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() || !self.canvas_direct.is_empty() + } +} + +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("canvas_direct", &self.canvas_direct.len()) + .field("app_preferences", &self.app_preferences.is_some()) + .field("applied", &self.applied) + .field("skipped", &self.skipped) + .finish_non_exhaustive() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum PropertyError { + #[error("unknown property '{0}'")] + UnknownProperty(String), + #[error("property {property} addresses a {expected} component, not {actual}")] + ComponentKind { + property: PropertyId, + expected: &'static str, + actual: &'static str, + }, + #[error("no such target: {0}")] + UnknownTarget(String), + #[error("{0}")] + NotApplicable(String), + #[error("property {0} is read-only")] + ReadOnly(PropertyId), + #[error("invalid value for {property}: {message}")] + InvalidValue { + property: PropertyId, + message: String, + }, + #[error("one property commit cannot cross multiple storages: {storages}")] + MixedStorage { storages: String }, +} diff --git a/crates/core/src/properties/model/schema.rs b/crates/core/src/properties/model/schema.rs new file mode 100644 index 00000000..6f7e549f --- /dev/null +++ b/crates/core/src/properties/model/schema.rs @@ -0,0 +1,439 @@ +//! Static schemas and values carried through property edits. + +use super::{PropertyError, PropertyId}; +use plotx_figure::Color; +use std::borrow::Cow; + +/// One selectable choice of an enumerated property, together with the field +/// capabilities that make it selectable at all. The gate is declared here rather +/// than in a `match` on a data domain, so a new provider gains or loses a choice +/// purely by exposing or withholding a capability. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EnumVariant { + pub id: &'static str, + pub canonical_label: &'static str, + /// Every capability the target's field must expose. + pub required_capabilities: &'static [&'static str], + /// Capabilities that make this choice meaningless even when the required + /// ones are present — a fraction of the value range says nothing useful on a + /// field with both signs. + pub forbidden_capabilities: &'static [&'static str], +} + +impl EnumVariant { + pub const fn new(id: &'static str, canonical_label: &'static str) -> Self { + Self { + id, + canonical_label, + required_capabilities: &[], + forbidden_capabilities: &[], + } + } + + pub const fn requiring(mut self, capabilities: &'static [&'static str]) -> Self { + self.required_capabilities = capabilities; + self + } + + pub const fn forbidding(mut self, capabilities: &'static [&'static str]) -> Self { + self.forbidden_capabilities = capabilities; + self + } +} + +/// The numeric range of a float property. +/// +/// A bound can be *open*: a level ratio must be strictly greater than one, or +/// the ladder it describes stops rising. Openness is part of the rule and +/// therefore belongs to the schema, not to whichever control or writer happens +/// to re-state it. The alternative — a control that stops at a rounded literal +/// while the writer tests the real bound — is two copies of one rule, and the +/// copies drift. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FloatBounds { + pub min: f64, + pub max: f64, + /// Whether `min` itself is admitted. + pub exclusive_min: bool, + /// One otherwise in-range value that the domain excludes. + pub excluded: Option, + /// Reject every value whose magnitude is at or below this threshold. + pub excluded_magnitude: Option, +} + +impl FloatBounds { + /// `min ..= max`. + pub const fn inclusive(min: f64, max: f64) -> Self { + Self { + min, + max, + exclusive_min: false, + excluded: None, + excluded_magnitude: None, + } + } + + /// Strictly above `min`, up to and including `max`. + pub const fn above(min: f64, max: f64) -> Self { + Self { + min, + max, + exclusive_min: true, + excluded: None, + excluded_magnitude: None, + } + } + + /// `min ..= max`, except for one value with no valid domain meaning. + pub const fn excluding(min: f64, max: f64, excluded: f64) -> Self { + Self { + min, + max, + exclusive_min: false, + excluded: Some(excluded), + excluded_magnitude: None, + } + } + + /// `min ..= max`, excluding values too close to zero for the kernel to use. + pub const fn excluding_magnitude(min: f64, max: f64, threshold: f64) -> Self { + Self { + min, + max, + exclusive_min: false, + excluded: None, + excluded_magnitude: Some(threshold), + } + } + + pub fn admits(self, value: f64) -> bool { + value.is_finite() + && value <= self.max + && self.excluded != Some(value) + && self + .excluded_magnitude + .is_none_or(|threshold| value.abs() > threshold) + && if self.exclusive_min { + value > self.min + } else { + value >= self.min + } + } + + /// The smallest value the bound admits. + /// + /// A control whose range is inclusive has to start here rather than at + /// `min`, so it cannot offer a value the write path will reject. Deriving it + /// keeps the offset out of the hands of whoever writes the next control. + pub fn lowest(self) -> f64 { + if self.exclusive_min { + self.min.next_up() + } else { + self.min + } + } + + /// The rule in words, for an error a user reads. + pub fn describe(self) -> String { + let low = if self.exclusive_min { + format!("greater than {}", self.min) + } else { + format!("at least {}", self.min) + }; + let range = format!("{low} and at most {}", self.max); + match (self.excluded_magnitude, self.excluded) { + (Some(threshold), _) => { + format!("{range}, with magnitude greater than {threshold}") + } + (None, Some(excluded)) => format!("{range}, and not {excluded}"), + (None, None) => range, + } + } + + /// Validate a value against the bound, naming the property in the failure. + /// + /// The failure states the value that was rejected as well as the rule. + /// "Level ratio must be greater than 1 and at most 10" leaves a caller that + /// sent 10.0000001, or sent a string that parsed to something else + /// entirely, unable to tell which end it fell off — and a headless caller + /// has no control to look at. Naming both closes that. + pub fn check( + self, + property: PropertyId, + subject: &str, + value: f64, + ) -> Result { + if self.admits(value) { + return Ok(value); + } + Err(PropertyError::InvalidValue { + property, + message: format!( + "{subject} {value} is out of range: it must be {}", + self.describe() + ), + }) + } +} + +/// A reversible transformation between a stored domain value and the number a +/// user edits. +/// +/// The display-space unit is part of the transformation and must always be +/// obtained through [`Self::unit`]. Keeping both facts in this one value makes +/// a control that displays one numeric space while independently labelling +/// another unrepresentable. +/// +/// The unit each variant carries is the unit of the *domain* quantity: a +/// logarithmic control still measures λ, it just edits its exponent. The +/// `log₁₀` the user reads therefore belongs to [`Self::caption`], which derives +/// it, rather than to each definition's string — a per-site prefix is a copy of +/// the transformation that can be forgotten, and was: the contour base level +/// edited an exponent under a bare "intensity" caption. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FloatDisplay { + Linear(&'static str), + Log10(&'static str), + Degrees, +} + +impl FloatDisplay { + /// The unit of the stored domain value. + pub const fn unit(self) -> &'static str { + match self { + Self::Linear(unit) | Self::Log10(unit) => unit, + Self::Degrees => "°", + } + } + + /// What a control writes beside the number it shows. This is the only + /// caption a human should read, because it is the only one derived from the + /// same value that projects the number. + pub fn caption(self) -> Cow<'static, str> { + match self { + Self::Log10("") => Cow::Borrowed("log₁₀"), + Self::Log10(unit) => Cow::Owned(format!("log₁₀ {unit}")), + other => Cow::Borrowed(other.unit()), + } + } + + pub fn to_display(self, value: f64) -> f64 { + match self { + Self::Linear(_) => value, + Self::Log10(_) => value.log10(), + Self::Degrees => value.to_degrees(), + } + } + + pub fn to_domain(self, value: f64) -> f64 { + match self { + Self::Linear(_) => value, + Self::Log10(_) => 10.0_f64.powf(value), + Self::Degrees => value.to_radians(), + } + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::Linear(_) => "linear", + Self::Log10(_) => "log10", + Self::Degrees => "degrees", + } + } +} + +/// The static value schema. Bounds that depend on the target's current state are +/// reported by [`ResolvedProperty::schema`] instead, keeping the definition +/// context-free. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ValueSchema { + Bool, + Text, + Int { + min: i64, + max: i64, + }, + /// An integer whose established direct-manipulation scale is independent + /// of the distance between admitted values. + IntWithDrag { + min: i64, + max: i64, + drag_step: f64, + }, + SteppedInt { + min: i64, + max: i64, + /// Distance between values admitted from `min`. + step: i64, + /// How far one notch of a direct-manipulation drag moves the value. + drag_step: f64, + }, + Float { + bounds: FloatBounds, + display: FloatDisplay, + /// How far one notch of a direct-manipulation drag moves the value. + /// + /// A control that has to invent this can only derive it from the range, + /// and a range is a statement about what is *admissible*, not about what + /// is *usual*: line broadening is legal out to ±10 kHz and typically set + /// between 0.3 and 5 Hz, so a range-derived notch moves it by a hundred + /// hertz a pixel. The quantity's own scale is knowledge the definition + /// has and the control does not, so the definition states it. `None` + /// leaves the control to fall back on the range. + drag_step: Option, + }, + Enum { + variants: &'static [EnumVariant], + }, + Color, +} + +impl ValueSchema { + /// The declared numeric range, when this is a float schema. Both the control + /// and the write path ask for it here rather than restating it. + pub const fn float_bounds(&self) -> Option { + match self { + Self::Float { bounds, .. } => Some(*bounds), + _ => None, + } + } +} + +/// A property value in transit between the catalog and a control. It is never +/// stored: the authoritative value stays in the typed domain model. +#[derive(Clone, Debug, PartialEq)] +pub enum PropertyValue { + Bool(bool), + Text(String), + Int(i64), + Float(f64), + /// One of the owning schema's static variant ids. + Enum(&'static str), + Color(Color), +} + +impl PropertyValue { + pub const fn kind(&self) -> &'static str { + match self { + Self::Bool(_) => "bool", + Self::Text(_) => "text", + Self::Int(_) => "int", + Self::Float(_) => "float", + Self::Enum(_) => "enum", + Self::Color(_) => "color", + } + } + + pub const fn as_bool(&self) -> Option { + match self { + Self::Bool(value) => Some(*value), + _ => None, + } + } + + pub fn as_text(&self) -> Option<&str> { + match self { + Self::Text(value) => Some(value), + _ => None, + } + } + + pub const fn as_int(&self) -> Option { + match self { + Self::Int(value) => Some(*value), + _ => None, + } + } + + pub const fn as_float(&self) -> Option { + match self { + Self::Float(value) => Some(*value), + _ => None, + } + } + + pub const fn as_enum(&self) -> Option<&'static str> { + match self { + Self::Enum(value) => Some(*value), + _ => None, + } + } + + pub const fn as_color(&self) -> Option { + match self { + Self::Color(value) => Some(*value), + _ => None, + } + } +} + +/// How the default of a property is obtained. Defaults are *derived*, never +/// stored next to the current value. +#[derive(Clone, Debug, PartialEq)] +pub enum DefaultPolicy { + /// Re-run the same factory that materializes a new encoding, in the + /// target's current context, and read this property out of the result. + EncodingFactory, + /// Re-run the typed processing recipe factory appropriate to the addressed + /// step. Unlike an encoding factory, this is owned by a dataset pipeline + /// and can vary between a 1D and a 2D default recipe. + ProcessingFactory, + /// The default is whatever the target's derived artifact currently shows, so it + /// varies with the target and is recomputed on every read. + Derived, + /// A literal that does not depend on the target. + Fixed(PropertyValue), + /// Values with no meaningful reset target, normally read-only provenance. + None, +} + +/// How many copies of one setting a single target holds. +/// +/// Most settings have exactly one copy per target, so a single-target read can +/// only ever be uniform. A few describe a shape the target mirrors — a contour +/// ladder keeps a positive and a negative half that share base, count and +/// ratio — and those have one copy per half, which is why even a single-target +/// read is an aggregate. +/// +/// The distinction is declared here rather than inferred by a control, so a +/// frontend can say *which* sources disagree without knowing what the target's +/// domain model looks like. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ValueCopies { + /// Exactly one copy per target. + PerTarget, + /// One copy per mirrored half of a symmetric pair the target holds. + PerMirroredHalf, +} + +/// One step of a direct-manipulation gesture along a property's own scale. +/// +/// The gesture names a direction, never a value: what one step *is* belongs to +/// the property, so a canvas key and a panel control cannot disagree about it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PropertyStep { + Raise, + Lower, +} + +impl PropertyStep { + pub const fn as_str(self) -> &'static str { + match self { + Self::Raise => "raise", + Self::Lower => "lower", + } + } +} + +/// One typed edit operation shared by all catalog entry points. +/// +/// The service owns selection-wide planning; a provider receives one target and +/// this operation, applies it to its typed working copy, or explains why that +/// target cannot accept it. Keeping the operation here prevents set/reset/step +/// from growing three structurally identical planners as new providers arrive. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum EditOp<'a> { + Set(&'a PropertyValue), + Reset, + Step(PropertyStep), +} diff --git a/crates/core/src/properties/normalize.rs b/crates/core/src/properties/normalize.rs new file mode 100644 index 00000000..e1a54cc6 --- /dev/null +++ b/crates/core/src/properties/normalize.rs @@ -0,0 +1,265 @@ +//! Dataset-owned normalization-step properties. + +use super::processing_common::{ + no_step_gesture, property_definition, step_context, step_mut, wrong_kind, +}; +use super::provider::PropertyProvider; +use super::{ + AggregateValue, Applicability, Availability, ComponentKind, DefaultPolicy, EditOp, EnumVariant, + FloatBounds, FloatDisplay, PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, + PropertyId, PropertyTransaction, PropertyValue, ResolvedProperty, ResolvedSchema, ScopeKind, + Tier, ValueCopies, ValueSchema, +}; +use crate::state::PlotxApp; +use plotx_processing::{NormalizeMethod, StepKind}; + +pub const METHOD: PropertyId = PropertyId("dataset.processing.normalize.method"); +pub const DIVISOR: PropertyId = PropertyId("dataset.processing.normalize.divisor"); + +pub const MAX_PEAK: &str = "max_peak"; +pub const TOTAL_AREA: &str = "total_area"; +pub const CONSTANT: &str = "constant"; + +const METHODS: &[EnumVariant] = &[ + EnumVariant::new(MAX_PEAK, "Largest peak = 1"), + EnumVariant::new(TOTAL_AREA, "Total area = 1"), + EnumVariant::new(CONSTANT, "Divide by constant"), +]; +const DIVISOR_BOUNDS: FloatBounds = + FloatBounds::excluding_magnitude(-f64::MAX, f64::MAX, f64::MIN_POSITIVE); +/// One is the multiplicative identity and is admitted by the non-zero schema, +/// so switching to Constant never changes the spectrum before the user chooses +/// a divisor. +pub const DIVISOR_SEED: f64 = 1.0; +const STEP: Applicability = Applicability::component(ComponentKind::ProcessingStep); + +pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ + PropertyDefinition { + id: METHOD, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::Enum { variants: METHODS }, + access: PropertyAccess::ReadWrite, + applicability: STEP, + default_policy: DefaultPolicy::None, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Normalization method", + canonical_aliases: &["normalize", "scale spectrum", "normalization"], + }, + PropertyDefinition { + id: DIVISOR, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::Float { + bounds: DIVISOR_BOUNDS, + display: FloatDisplay::Linear(""), + drag_step: Some(0.1), + }, + access: PropertyAccess::ReadWrite, + applicability: STEP, + default_policy: DefaultPolicy::None, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Normalization divisor", + canonical_aliases: &["constant divisor", "divide by"], + }, +]; + +pub(crate) struct NormalizeProvider; + +pub(crate) static PROVIDER: NormalizeProvider = NormalizeProvider; + +impl PropertyProvider for NormalizeProvider { + fn definitions(&self) -> &'static [PropertyDefinition] { + DEFINITIONS + } + + fn read( + &self, + app: &PlotxApp, + address: &PropertyAddress, + ) -> Result { + let definition = property_definition(address.definition)?; + let context = step_context(app, address, definition, |kind| { + matches!(kind, StepKind::Normalize(_)) + })?; + let StepKind::Normalize(current) = context.step.kind else { + unreachable!("the shared context checked the step kind"); + }; + Ok(ResolvedProperty { + address: address.clone(), + modified: None, + value: AggregateValue::Uniform(value_of(definition, current)?), + default_value: None, + availability: Availability::Editable, + schema: schema_for(definition, current)?, + }) + } + + fn edit( + &self, + app: &PlotxApp, + transaction: &mut PropertyTransaction, + address: &PropertyAddress, + operation: &EditOp<'_>, + ) -> Result<(), PropertyError> { + let definition = property_definition(address.definition)?; + let context = step_context(app, address, definition, |kind| { + matches!(kind, StepKind::Normalize(_)) + })?; + let StepKind::Normalize(current) = context.step.kind else { + unreachable!("the shared context checked the step kind"); + }; + let value = match operation { + EditOp::Set(value) => checked_value(definition, current, value)?, + EditOp::Reset => { + return Err(PropertyError::NotApplicable( + "User-added normalization steps have no factory setting to reset to." + .to_owned(), + )); + } + EditOp::Step(_) => return Err(no_step_gesture(definition)), + }; + let state = transaction.processing_state(app, context.dataset_id)?; + let step = step_mut(state, context.step.id, &address.target)?; + let StepKind::Normalize(current) = &mut step.kind else { + return Err(PropertyError::NotApplicable( + "the addressed step is no longer a normalization step".to_owned(), + )); + }; + write(definition, current, value) + } +} + +fn value_of( + definition: &'static PropertyDefinition, + current: NormalizeMethod, +) -> Result { + match (definition.id, current) { + (METHOD, value) => Ok(PropertyValue::Enum(method_of(value))), + (DIVISOR, NormalizeMethod::Constant { divisor }) => Ok(PropertyValue::Float(divisor)), + _ => Err(divisor_unavailable(current)), + } +} + +fn schema_for( + definition: &'static PropertyDefinition, + current: NormalizeMethod, +) -> Result { + match definition.id { + METHOD => Ok(ResolvedSchema::Enum { + variants: METHODS.iter().collect(), + }), + DIVISOR if matches!(current, NormalizeMethod::Constant { .. }) => { + Ok(ResolvedSchema::Float { + bounds: DIVISOR_BOUNDS, + display: FloatDisplay::Linear(""), + }) + } + DIVISOR => Err(divisor_unavailable(current)), + _ => Err(PropertyError::UnknownProperty(definition.id.to_string())), + } +} + +fn checked_value( + definition: &'static PropertyDefinition, + current: NormalizeMethod, + value: &PropertyValue, +) -> Result { + match (definition.id, value) { + (METHOD, PropertyValue::Enum(value)) if variant(value).is_some() => { + Ok(PropertyValue::Enum(value)) + } + (METHOD, PropertyValue::Enum(value)) => Err(PropertyError::InvalidValue { + property: definition.id, + message: format!("'{value}' is not a normalization method"), + }), + (METHOD, value) => Err(wrong_kind(definition, value, "a normalization method")), + (DIVISOR, PropertyValue::Float(value)) + if matches!(current, NormalizeMethod::Constant { .. }) => + { + Ok(PropertyValue::Float(DIVISOR_BOUNDS.check( + definition.id, + definition.canonical_label, + *value, + )?)) + } + (DIVISOR, PropertyValue::Float(_)) => Err(divisor_unavailable(current)), + (DIVISOR, value) => Err(wrong_kind(definition, value, "a non-zero number")), + (_, _) => Err(PropertyError::UnknownProperty(definition.id.to_string())), + } +} + +fn write( + definition: &'static PropertyDefinition, + current: &mut NormalizeMethod, + value: PropertyValue, +) -> Result<(), PropertyError> { + match (definition.id, value) { + (METHOD, PropertyValue::Enum(value)) => { + *current = match variant(value) { + Some(NormalizeVariant::MaxPeak) => NormalizeMethod::MaxPeak, + Some(NormalizeVariant::TotalArea) => NormalizeMethod::TotalArea, + Some(NormalizeVariant::Constant) => NormalizeMethod::Constant { + divisor: match *current { + NormalizeMethod::Constant { divisor } => divisor, + _ => DIVISOR_SEED, + }, + }, + None => { + return Err(PropertyError::InvalidValue { + property: definition.id, + message: format!("'{value}' is not a normalization method"), + }); + } + }; + Ok(()) + } + (DIVISOR, PropertyValue::Float(value)) => { + let NormalizeMethod::Constant { divisor } = current else { + return Err(divisor_unavailable(*current)); + }; + *divisor = value; + Ok(()) + } + (_, value) => Err(wrong_kind( + definition, + &value, + "the declared normalization value", + )), + } +} + +#[derive(Clone, Copy)] +enum NormalizeVariant { + MaxPeak, + TotalArea, + Constant, +} + +fn variant(value: &str) -> Option { + match value { + MAX_PEAK => Some(NormalizeVariant::MaxPeak), + TOTAL_AREA => Some(NormalizeVariant::TotalArea), + CONSTANT => Some(NormalizeVariant::Constant), + _ => None, + } +} + +fn method_of(method: NormalizeMethod) -> &'static str { + match method { + NormalizeMethod::MaxPeak => MAX_PEAK, + NormalizeMethod::TotalArea => TOTAL_AREA, + NormalizeMethod::Constant { .. } => CONSTANT, + } +} + +fn divisor_unavailable(current: NormalizeMethod) -> PropertyError { + PropertyError::NotApplicable(format!( + "Normalization divisor is available only with Divide by constant; this step uses {}", + METHODS + .iter() + .find(|variant| variant.id == method_of(current)) + .map(|variant| variant.canonical_label) + .unwrap_or("an unknown method") + )) +} diff --git a/crates/core/src/properties/normalize_tests.rs b/crates/core/src/properties/normalize_tests.rs new file mode 100644 index 00000000..7415270a --- /dev/null +++ b/crates/core/src/properties/normalize_tests.rs @@ -0,0 +1,121 @@ +use super::processing_test_support::{add_step, spectrum, time_domain_app}; +use super::*; +use plotx_processing::{NormalizeMethod, StepKind}; + +#[test] +fn constant_normalization_rejects_zero_in_the_schema_and_provider() { + let mut app = time_domain_app(); + let target = add_step( + &mut app, + StepKind::Normalize(NormalizeMethod::Constant { divisor: 1.0 }), + ); + let resolved = app + .resolve_property(&PropertyAddress::new(target.clone(), normalize::DIVISOR)) + .expect("the divisor resolves"); + let ResolvedSchema::Float { bounds, .. } = resolved.schema else { + panic!("the divisor is a float"); + }; + assert!(!bounds.admits(0.0)); + + let error = app + .plan_property_write( + normalize::DIVISOR, + std::slice::from_ref(&target), + &PropertyValue::Float(0.0), + ) + .expect_err("zero has no normalization meaning"); + let message = error.to_string(); + assert!(message.contains("divisor 0"), "{message}"); + assert!(message.contains("magnitude greater than"), "{message}"); +} + +#[test] +fn constant_normalization_rejects_subnormal_values_the_kernel_ignores() { + let mut app = time_domain_app(); + let target = add_step( + &mut app, + StepKind::Normalize(NormalizeMethod::Constant { divisor: 1.0 }), + ); + let error = app + .plan_property_write( + normalize::DIVISOR, + std::slice::from_ref(&target), + &PropertyValue::Float(1.0e-320), + ) + .expect_err("subnormal divisors are numerical no-ops"); + let message = error.to_string(); + assert!(message.contains("0.000000"), "{message}"); + assert!(message.contains("magnitude greater than"), "{message}"); +} + +#[test] +fn magnitude_exclusion_reports_the_rule_it_actually_enforces() { + let bounds = FloatBounds::excluding_magnitude(-f64::MAX, f64::MAX, f64::MIN_POSITIVE); + assert_eq!(bounds.excluded, None); + assert_eq!(bounds.excluded_magnitude, Some(f64::MIN_POSITIVE)); + assert_eq!(bounds.lowest(), -f64::MAX); + assert!(!bounds.admits(0.0)); + assert!(!bounds.admits(f64::MIN_POSITIVE)); + assert!(!bounds.admits(-f64::MIN_POSITIVE)); + assert!(bounds.admits(f64::MIN_POSITIVE.next_up())); + assert_eq!( + bounds.describe(), + format!( + "at least {} and at most {}, with magnitude greater than {}", + -f64::MAX, + f64::MAX, + f64::MIN_POSITIVE + ) + ); + let error = bounds + .check(normalize::DIVISOR, "normalization divisor", 0.0) + .expect_err("zero remains excluded"); + assert!( + error.to_string().contains("magnitude greater than"), + "{error}" + ); +} + +#[test] +fn normalization_reset_honestly_skips_a_user_only_step() { + let mut app = time_domain_app(); + let target = add_step( + &mut app, + StepKind::Normalize(NormalizeMethod::Constant { divisor: 2.0 }), + ); + assert_eq!( + definition(normalize::DIVISOR).unwrap().default_policy, + DefaultPolicy::None + ); + let reset = app + .plan_property_reset(normalize::DIVISOR, std::slice::from_ref(&target)) + .unwrap(); + assert!(reset.applied.is_empty()); + assert_eq!(reset.skipped.len(), 1); +} + +#[test] +fn normalization_catalog_write_reprocesses_real_fid_and_one_undo_restores_it() { + let mut app = time_domain_app(); + let target = add_step( + &mut app, + StepKind::Normalize(NormalizeMethod::Constant { divisor: 1.0 }), + ); + let before = spectrum(&app); + let commit = app + .plan_property_write( + normalize::DIVISOR, + std::slice::from_ref(&target), + &PropertyValue::Float(2.0), + ) + .expect("the divisor plans through the real property entry"); + app.commit_property(commit); + let after = spectrum(&app); + assert_ne!(after.0, before.0); + for (scaled, original) in after.0.iter().zip(&before.0) { + assert!((*scaled * 2.0 - *original).norm() < 1.0e-9); + } + + app.undo(); + assert_eq!(spectrum(&app), before); +} diff --git a/crates/core/src/properties/object.rs b/crates/core/src/properties/object.rs new file mode 100644 index 00000000..4c4e0d83 --- /dev/null +++ b/crates/core/src/properties/object.rs @@ -0,0 +1,732 @@ +//! Persistent properties owned by canvas objects and plot-object children. + +use super::provider::PropertyProvider; +use super::target::{require_object_target, resolved_schema, series_context_unchecked}; +use super::*; +use crate::state::{ + DataDomain, FieldCapabilities, ObjectStyle, PlotObject, PlotxApp, ShapeKind, StackKind, + StackMode, TextAlign, default_chart_type, +}; +use plotx_analysis::statistics::{BinRule, histogram}; +use plotx_figure::ColormapId; + +#[path = "object_definitions.rs"] +mod definitions; +pub use definitions::{ + ALIGN_CENTER, ALIGN_LEFT, ALIGN_RIGHT, CHART_BINS_AUTO, CHART_BINS_COUNT, CHART_COLORMAP, + CHART_STACKED, CHART_TYPE_ID, CHART_VIEW_AZIMUTH, CHART_VIEW_ELEVATION, COLOR_OVERLAY, LOCKED, + OFFSET, PANEL_USER_NOTE, PANEL_VISIBLE, SERIES_VISIBLE, SHAPE_ARROW, SHAPE_ELLIPSE, + SHAPE_FILL_COLOR, SHAPE_FILL_ENABLED, SHAPE_KIND, SHAPE_LINE, SHAPE_RECT, SHAPE_STROKE, + SHAPE_STROKE_WIDTH, STACK_MODE, STACK_NORMALIZE, STACK_SHEAR_X, STACK_SPACING_Y, SUPERIMPOSED, + TEXT, TEXT_ALIGN, TEXT_BOLD, TEXT_COLOR, TEXT_FONT_SIZE, +}; +use definitions::{DEFINITIONS, FILL_FALLBACK, STACK_MODES}; + +type ResolvedObjectValue = ( + PropertyValue, + Option, + Option, + Availability, + ResolvedSchema, +); + +pub(crate) struct ObjectProvider; +pub(crate) static PROVIDER: ObjectProvider = ObjectProvider; + +impl PropertyProvider for ObjectProvider { + fn definitions(&self) -> &'static [PropertyDefinition] { + DEFINITIONS + } + + fn read( + &self, + app: &PlotxApp, + address: &PropertyAddress, + ) -> Result { + let definition = property_definition(address.definition)?; + if definition.id == SERIES_VISIBLE { + return read_series(app, address, definition); + } + let (canvas, object) = require_object_target(app, &address.target, definition)?; + let object_ref = app.doc.canvases[canvas] + .object(object) + .ok_or_else(|| PropertyError::UnknownTarget(address.target.describe()))?; + let (value, default_value, modified, availability, schema) = + object_value(app, canvas, object_ref, definition)?; + Ok(ResolvedProperty { + address: address.clone(), + value: AggregateValue::Uniform(value), + default_value, + modified, + availability, + schema, + }) + } + + fn edit( + &self, + app: &PlotxApp, + transaction: &mut PropertyTransaction, + address: &PropertyAddress, + operation: &EditOp<'_>, + ) -> Result<(), PropertyError> { + let definition = property_definition(address.definition)?; + if definition.id == SERIES_VISIBLE { + return edit_series(app, transaction, address, definition, operation); + } + let (canvas, object) = require_object_target(app, &address.target, definition)?; + let object_ref = app.doc.canvases[canvas] + .object(object) + .ok_or_else(|| PropertyError::UnknownTarget(address.target.describe()))?; + let (_, default, _, availability, schema) = + object_value(app, canvas, object_ref, definition)?; + if let Availability::Disabled(reason) = availability { + return Err(PropertyError::NotApplicable(reason.to_owned())); + } + let value = match operation { + EditOp::Set(value) => { + let checked = checked_value(definition, value)?; + if let (ResolvedSchema::Enum { variants }, PropertyValue::Enum(value)) = + (&schema, &checked) + && !variants.iter().any(|variant| variant.id == *value) + { + return Err(PropertyError::NotApplicable(format!( + "'{value}' is not available for this object" + ))); + } + checked + } + EditOp::Reset => reset_value(definition, default)?, + EditOp::Step(_) => return Err(no_step(definition)), + }; + write_object(app, transaction, canvas, object, definition.id, value) + } +} + +fn property_definition(id: PropertyId) -> Result<&'static PropertyDefinition, PropertyError> { + super::definition(id).ok_or_else(|| PropertyError::UnknownProperty(id.to_string())) +} + +fn plot_context<'a>( + app: &'a PlotxApp, + canvas: usize, + object: &'a crate::state::CanvasObject, +) -> Result< + ( + &'a PlotObject, + &'a crate::state::Dataset, + DataDomain, + FieldCapabilities, + ), + PropertyError, +> { + let plot = object.plot().ok_or_else(|| { + PropertyError::NotApplicable("This property belongs to a plot object.".to_owned()) + })?; + let series = plot.binding.series.first().ok_or_else(|| { + PropertyError::NotApplicable("The plot has no primary series.".to_owned()) + })?; + let dataset = app + .doc + .dataset_by_id(series.source.resource) + .ok_or_else(|| { + PropertyError::UnknownTarget(format!("{canvas}/{}", series.source.resource)) + })?; + let capabilities = dataset + .field_descriptor(series.source.field) + .map(|field| field.capabilities) + .unwrap_or_default(); + Ok((plot, dataset, dataset.domain(), capabilities)) +} + +#[allow(clippy::type_complexity)] +fn object_value( + app: &PlotxApp, + canvas: usize, + object: &crate::state::CanvasObject, + definition: &'static PropertyDefinition, +) -> Result< + ( + PropertyValue, + Option, + Option, + Availability, + ResolvedSchema, + ), + PropertyError, +> { + let fixed = fixed_default(definition); + let standard_schema = resolved_schema(definition, &FieldCapabilities::default()); + match definition.id { + LOCKED => Ok(( + PropertyValue::Bool(object.locked), + fixed, + None, + Availability::Editable, + standard_schema, + )), + TEXT | TEXT_FONT_SIZE | TEXT_BOLD | TEXT_ALIGN | TEXT_COLOR => { + let text = object.text().ok_or_else(|| { + PropertyError::NotApplicable( + "This property belongs to a text or panel-label object.".to_owned(), + ) + })?; + let default = crate::state::TextBox::panel_label(String::new()); + let default = if object.is_panel_label() { + default + } else { + crate::state::TextBox::label(String::new()) + }; + let value = text_value(definition.id, text)?; + let default = Some(text_value(definition.id, &default)?); + Ok(( + value, + default, + None, + Availability::Editable, + standard_schema, + )) + } + SHAPE_KIND | SHAPE_STROKE | SHAPE_STROKE_WIDTH | SHAPE_FILL_ENABLED | SHAPE_FILL_COLOR => { + let shape = object.shape().ok_or_else(|| { + PropertyError::NotApplicable("This property belongs to a shape object.".to_owned()) + })?; + let availability = if definition.id == SHAPE_FILL_COLOR && shape.fill.is_none() { + Availability::Disabled("Turn on Fill to choose a fill color.") + } else { + Availability::Editable + }; + Ok(( + shape_value(definition.id, shape)?, + fixed, + None, + availability, + standard_schema, + )) + } + _ => { + let (plot, _, domain, capabilities) = plot_context(app, canvas, object)?; + plot_value(app, definition, plot, domain, capabilities) + } + } +} + +fn plot_value( + app: &PlotxApp, + definition: &'static PropertyDefinition, + plot: &PlotObject, + domain: DataDomain, + capabilities: FieldCapabilities, +) -> Result { + let fixed = fixed_default(definition); + let schema = resolved_schema(definition, &capabilities); + match definition.id { + STACK_MODE | STACK_SPACING_Y | STACK_SHEAR_X | STACK_NORMALIZE => { + if plot.binding.series.len() <= 1 || !app.series_stackable(&plot.binding) { + return Err(PropertyError::NotApplicable( + "Stack settings require a stackable plot with multiple series.".to_owned(), + )); + } + let kind = domain.stack_kind(); + if definition.id != STACK_MODE + && (kind != Some(StackKind::Line) || plot.stack.mode != StackMode::Offset) + { + return Err(PropertyError::NotApplicable( + "Spacing, shear, and normalization apply to Offset line stacks.".to_owned(), + )); + } + let availability = if definition.id == STACK_MODE && kind == Some(StackKind::Field) { + Availability::Disabled("Field stacks always use Color overlay.") + } else { + Availability::Editable + }; + let schema = if definition.id == STACK_MODE { + let variants = match kind { + Some(StackKind::Line) => STACK_MODES[..2].iter().collect(), + Some(StackKind::Field) => STACK_MODES[2..].iter().collect(), + None => Vec::new(), + }; + ResolvedSchema::Enum { variants } + } else { + schema + }; + Ok(( + stack_value(definition.id, plot)?, + fixed, + None, + availability, + schema, + )) + } + CHART_TYPE_ID | CHART_BINS_AUTO | CHART_BINS_COUNT | CHART_STACKED | CHART_COLORMAP + | CHART_VIEW_AZIMUTH | CHART_VIEW_ELEVATION => { + let current_id = crate::state::resolved_chart_type_for_field( + &capabilities, + domain, + &plot.chart.type_id, + ) + .id; + if !chart_property_applies_to_type(definition.id, current_id) { + return Err(PropertyError::NotApplicable( + "This option does not apply to the selected chart type.".to_owned(), + )); + } + let availability = if definition.id == CHART_BINS_COUNT && plot.chart.bins.is_none() { + Availability::Disabled( + "Turn off Automatic histogram bins to set the count manually.", + ) + } else { + Availability::Editable + }; + let default = if definition.id == CHART_TYPE_ID { + Some(PropertyValue::Enum(default_chart_type(domain).id)) + } else { + fixed + }; + let modified = + (definition.id == CHART_TYPE_ID).then_some(!plot.chart.type_id.is_empty()); + Ok(( + chart_value(app, definition.id, plot)?, + default, + modified, + availability, + schema, + )) + } + PANEL_USER_NOTE | PANEL_VISIBLE => Ok(( + if definition.id == PANEL_USER_NOTE { + PropertyValue::Text(plot.panel.user_note.clone()) + } else { + PropertyValue::Bool(plot.panel.visible) + }, + fixed, + None, + Availability::Editable, + schema, + )), + _ => Err(PropertyError::UnknownProperty(definition.id.to_string())), + } +} + +/// Whether a Chart-section property is rendered for a resolved chart type. +/// +/// The panel-density calibration calls this same predicate while enumerating +/// the chart-type discriminator, so the budget cannot drift from the provider +/// that decides which rows a user actually sees. +#[doc(hidden)] +pub fn chart_property_applies_to_type(property: PropertyId, chart_type: &str) -> bool { + match property { + CHART_TYPE_ID => true, + CHART_BINS_AUTO | CHART_BINS_COUNT => chart_type == "table_histogram", + CHART_STACKED => chart_type == "table_bar_grouped", + CHART_COLORMAP => matches!(chart_type, "table_heatmap" | "table_surface"), + CHART_VIEW_AZIMUTH | CHART_VIEW_ELEVATION => chart_type == "table_surface", + _ => false, + } +} + +fn read_series( + app: &PlotxApp, + address: &PropertyAddress, + definition: &'static PropertyDefinition, +) -> Result { + let context = series_context_unchecked(app, &address.target)?; + let plot = app.doc.canvases[context.canvas] + .object(context.object) + .and_then(|object| object.plot()) + .ok_or_else(|| PropertyError::UnknownTarget(address.target.describe()))?; + if plot.binding.series.len() <= 1 || !app.series_stackable(&plot.binding) { + return Err(PropertyError::NotApplicable( + "Series visibility is available only on stackable multi-series plots.".to_owned(), + )); + } + let visible = plot + .binding + .series + .iter() + .find(|series| series.id == context.series) + .map(|series| series.visible) + .ok_or_else(|| PropertyError::UnknownTarget(address.target.describe()))?; + Ok(ResolvedProperty { + address: address.clone(), + value: AggregateValue::Uniform(PropertyValue::Bool(visible)), + default_value: Some(PropertyValue::Bool(true)), + modified: None, + availability: Availability::Editable, + schema: resolved_schema(definition, &context.capabilities), + }) +} + +fn edit_series( + app: &PlotxApp, + transaction: &mut PropertyTransaction, + address: &PropertyAddress, + definition: &'static PropertyDefinition, + operation: &EditOp<'_>, +) -> Result<(), PropertyError> { + let context = series_context_unchecked(app, &address.target)?; + let plot = app.doc.canvases[context.canvas] + .object(context.object) + .and_then(|object| object.plot()) + .ok_or_else(|| PropertyError::UnknownTarget(address.target.describe()))?; + if plot.binding.series.len() <= 1 || !app.series_stackable(&plot.binding) { + return Err(PropertyError::NotApplicable( + "Series visibility is available only on stackable multi-series plots.".to_owned(), + )); + } + let visible = match operation { + EditOp::Set(PropertyValue::Bool(value)) => *value, + EditOp::Reset => true, + EditOp::Set(value) => return Err(wrong_kind(definition, value)), + EditOp::Step(_) => return Err(no_step(definition)), + }; + let binding = transaction.data_binding(app, context.canvas, context.object)?; + let series = binding + .series + .iter_mut() + .find(|series| series.id == context.series) + .ok_or_else(|| PropertyError::UnknownTarget(address.target.describe()))?; + series.visible = visible; + Ok(()) +} + +fn write_object( + app: &PlotxApp, + transaction: &mut PropertyTransaction, + canvas: usize, + object: crate::state::ObjectId, + id: PropertyId, + value: PropertyValue, +) -> Result<(), PropertyError> { + match id { + STACK_MODE | STACK_SPACING_Y | STACK_SHEAR_X | STACK_NORMALIZE => { + let stack = transaction.stack_spec(app, canvas, object)?; + match (id, value) { + (STACK_MODE, PropertyValue::Enum(value)) => { + stack.mode = stack_mode(value).expect("validated stack mode") + } + (STACK_SPACING_Y, PropertyValue::Float(value)) => stack.spacing_y = value, + (STACK_SHEAR_X, PropertyValue::Float(value)) => stack.shear_x = value, + (STACK_NORMALIZE, PropertyValue::Bool(value)) => stack.normalize = value, + _ => unreachable!("validated stack value"), + } + } + CHART_TYPE_ID | CHART_BINS_AUTO | CHART_BINS_COUNT | CHART_STACKED | CHART_COLORMAP + | CHART_VIEW_AZIMUTH | CHART_VIEW_ELEVATION => { + let automatic_seed = (id == CHART_BINS_AUTO && value == PropertyValue::Bool(false)) + .then(|| live_auto_bins(app, canvas, object)) + .flatten() + .unwrap_or(20); + let chart = transaction.chart_spec(app, canvas, object)?; + match (id, value) { + (CHART_TYPE_ID, PropertyValue::Enum(value)) => chart.type_id = value.to_owned(), + (CHART_TYPE_ID, PropertyValue::Text(value)) if value.is_empty() => { + chart.type_id.clear() + } + (CHART_BINS_AUTO, PropertyValue::Bool(true)) => chart.bins = None, + (CHART_BINS_AUTO, PropertyValue::Bool(false)) => chart.bins = Some(automatic_seed), + (CHART_BINS_COUNT, PropertyValue::Int(value)) => chart.bins = Some(value as usize), + (CHART_STACKED, PropertyValue::Bool(value)) => chart.stacked = value, + (CHART_COLORMAP, PropertyValue::Enum(value)) => { + chart.colormap = ColormapId::from_id(value).expect("validated colormap") + } + (CHART_VIEW_AZIMUTH, PropertyValue::Float(value)) => { + chart.view_angles[0] = value.to_degrees() as f32 + } + (CHART_VIEW_ELEVATION, PropertyValue::Float(value)) => { + chart.view_angles[1] = value.to_degrees() as f32 + } + _ => unreachable!("validated chart value"), + } + } + PANEL_USER_NOTE | PANEL_VISIBLE => { + let panel = transaction.panel_meta(app, canvas, object)?; + match (id, value) { + (PANEL_USER_NOTE, PropertyValue::Text(value)) => panel.user_note = value, + (PANEL_VISIBLE, PropertyValue::Bool(value)) => panel.visible = value, + _ => unreachable!("validated panel value"), + } + } + LOCKED => { + let flags = transaction.object_flags(app, canvas, object)?; + let PropertyValue::Bool(value) = value else { + unreachable!("validated lock value") + }; + flags.1 = value; + } + TEXT | TEXT_FONT_SIZE | TEXT_BOLD | TEXT_ALIGN | TEXT_COLOR | SHAPE_KIND | SHAPE_STROKE + | SHAPE_STROKE_WIDTH | SHAPE_FILL_ENABLED | SHAPE_FILL_COLOR => { + write_style(transaction.object_style(app, canvas, object)?, id, value)?; + } + _ => return Err(PropertyError::UnknownProperty(id.to_string())), + } + Ok(()) +} + +fn write_style( + style: &mut ObjectStyle, + id: PropertyId, + value: PropertyValue, +) -> Result<(), PropertyError> { + match (style, id, value) { + (ObjectStyle::Text(text), TEXT, PropertyValue::Text(value)) => text.text = value, + (ObjectStyle::Text(text), TEXT_FONT_SIZE, PropertyValue::Float(value)) => { + text.font_size = value as f32 + } + (ObjectStyle::Text(text), TEXT_BOLD, PropertyValue::Bool(value)) => text.bold = value, + (ObjectStyle::Text(text), TEXT_ALIGN, PropertyValue::Enum(value)) => { + text.align = text_align(value).expect("validated alignment") + } + (ObjectStyle::Text(text), TEXT_COLOR, PropertyValue::Color(value)) => text.color = value, + (ObjectStyle::Shape(shape), SHAPE_KIND, PropertyValue::Enum(value)) => { + shape.shape = shape_kind(value).expect("validated shape kind") + } + (ObjectStyle::Shape(shape), SHAPE_STROKE, PropertyValue::Color(value)) => { + shape.stroke = value + } + (ObjectStyle::Shape(shape), SHAPE_STROKE_WIDTH, PropertyValue::Float(value)) => { + shape.stroke_width = value as f32 + } + (ObjectStyle::Shape(shape), SHAPE_FILL_ENABLED, PropertyValue::Bool(true)) => { + shape.fill = Some(shape.fill.unwrap_or(FILL_FALLBACK)); + } + (ObjectStyle::Shape(shape), SHAPE_FILL_ENABLED, PropertyValue::Bool(false)) => { + shape.fill = None + } + (ObjectStyle::Shape(shape), SHAPE_FILL_COLOR, PropertyValue::Color(value)) => { + shape.fill = Some(value) + } + (_, _, _) => { + return Err(PropertyError::NotApplicable( + "The object style changed kind before the edit was applied.".to_owned(), + )); + } + } + Ok(()) +} + +fn live_auto_bins(app: &PlotxApp, canvas: usize, object: crate::state::ObjectId) -> Option { + let plot = app.doc.canvases.get(canvas)?.object(object)?.plot()?; + let dataset = app + .doc + .dataset_by_id(plot.binding.primary_dataset()?)? + .as_table()?; + let data = dataset.typed_plot_data(100_000).ok()?; + let index = plot + .chart + .column + .and_then(|column| { + data.series + .iter() + .position(|series| series.binding.value_column == column) + }) + .unwrap_or(0); + let values = data + .series + .get(index)? + .y + .iter() + .copied() + .filter(|value| value.is_finite()) + .collect::>(); + histogram(&values, BinRule::Auto) + .ok() + .map(|histogram| histogram.counts.len()) +} + +fn chart_value( + app: &PlotxApp, + id: PropertyId, + plot: &PlotObject, +) -> Result { + match id { + CHART_TYPE_ID => { + let series = plot.binding.series.first().ok_or_else(|| { + PropertyError::NotApplicable("The plot has no primary series.".to_owned()) + })?; + let dataset = app + .doc + .dataset_by_id(series.source.resource) + .ok_or_else(|| PropertyError::UnknownTarget(series.source.resource.to_string()))?; + let capabilities = dataset + .field_descriptor(series.source.field) + .map(|field| field.capabilities) + .unwrap_or_default(); + Ok(PropertyValue::Enum( + crate::state::resolved_chart_type_for_field( + &capabilities, + dataset.domain(), + &plot.chart.type_id, + ) + .id, + )) + } + CHART_BINS_AUTO => Ok(PropertyValue::Bool(plot.chart.bins.is_none())), + CHART_BINS_COUNT => Ok(PropertyValue::Int(plot.chart.bins.unwrap_or(20) as i64)), + CHART_STACKED => Ok(PropertyValue::Bool(plot.chart.stacked)), + CHART_COLORMAP => Ok(PropertyValue::Enum(plot.chart.colormap.id())), + CHART_VIEW_AZIMUTH => Ok(PropertyValue::Float( + f64::from(plot.chart.view_angles[0]).to_radians(), + )), + CHART_VIEW_ELEVATION => Ok(PropertyValue::Float( + f64::from(plot.chart.view_angles[1]).to_radians(), + )), + _ => Err(PropertyError::UnknownProperty(id.to_string())), + } +} + +fn stack_value(id: PropertyId, plot: &PlotObject) -> Result { + match id { + STACK_MODE => Ok(PropertyValue::Enum(stack_mode_key(plot.stack.mode))), + STACK_SPACING_Y => Ok(PropertyValue::Float(plot.stack.spacing_y)), + STACK_SHEAR_X => Ok(PropertyValue::Float(plot.stack.shear_x)), + STACK_NORMALIZE => Ok(PropertyValue::Bool(plot.stack.normalize)), + _ => Err(PropertyError::UnknownProperty(id.to_string())), + } +} + +fn text_value( + id: PropertyId, + text: &crate::state::TextBox, +) -> Result { + match id { + TEXT => Ok(PropertyValue::Text(text.text.clone())), + TEXT_FONT_SIZE => Ok(PropertyValue::Float(f64::from(text.font_size))), + TEXT_BOLD => Ok(PropertyValue::Bool(text.bold)), + TEXT_ALIGN => Ok(PropertyValue::Enum(text_align_key(text.align))), + TEXT_COLOR => Ok(PropertyValue::Color(text.color)), + _ => Err(PropertyError::UnknownProperty(id.to_string())), + } +} + +fn shape_value( + id: PropertyId, + shape: &crate::state::ShapeObject, +) -> Result { + match id { + SHAPE_KIND => Ok(PropertyValue::Enum(shape_kind_key(shape.shape))), + SHAPE_STROKE => Ok(PropertyValue::Color(shape.stroke)), + SHAPE_STROKE_WIDTH => Ok(PropertyValue::Float(f64::from(shape.stroke_width))), + SHAPE_FILL_ENABLED => Ok(PropertyValue::Bool(shape.fill.is_some())), + SHAPE_FILL_COLOR => Ok(PropertyValue::Color(shape.fill.unwrap_or(FILL_FALLBACK))), + _ => Err(PropertyError::UnknownProperty(id.to_string())), + } +} + +fn checked_value( + definition: &'static PropertyDefinition, + value: &PropertyValue, +) -> Result { + match (definition.value_schema, value) { + (ValueSchema::Bool, PropertyValue::Bool(value)) => Ok(PropertyValue::Bool(*value)), + (ValueSchema::Text, PropertyValue::Text(value)) => Ok(PropertyValue::Text(value.clone())), + ( + ValueSchema::Int { min, max } | ValueSchema::IntWithDrag { min, max, .. }, + PropertyValue::Int(value), + ) if (min..=max).contains(value) => Ok(PropertyValue::Int(*value)), + (ValueSchema::Float { bounds, .. }, PropertyValue::Float(value)) => { + bounds.check(definition.id, definition.canonical_label, *value)?; + Ok(PropertyValue::Float(*value)) + } + (ValueSchema::Enum { variants }, PropertyValue::Enum(value)) + if variants.iter().any(|variant| variant.id == *value) => + { + Ok(PropertyValue::Enum(value)) + } + (ValueSchema::Color, PropertyValue::Color(value)) => Ok(PropertyValue::Color(*value)), + (_, value) => Err(wrong_kind(definition, value)), + } +} + +fn reset_value( + definition: &'static PropertyDefinition, + resolved_default: Option, +) -> Result { + if definition.id == CHART_TYPE_ID { + // The write layer interprets the default chart as a concrete selection. + // Reset needs the persisted sentinel, so it is handled before validation. + return Ok(PropertyValue::Text(String::new())); + } + resolved_default.ok_or_else(|| PropertyError::InvalidValue { + property: definition.id, + message: "this property has no reset value".to_owned(), + }) +} + +fn fixed_default(definition: &'static PropertyDefinition) -> Option { + match &definition.default_policy { + DefaultPolicy::Fixed(value) => Some(value.clone()), + _ => None, + } +} + +fn wrong_kind(definition: &'static PropertyDefinition, value: &PropertyValue) -> PropertyError { + PropertyError::InvalidValue { + property: definition.id, + message: format!( + "{} does not accept {}", + definition.canonical_label, + value.kind() + ), + } +} + +fn no_step(definition: &'static PropertyDefinition) -> PropertyError { + PropertyError::InvalidValue { + property: definition.id, + message: "this object property has no step gesture".to_owned(), + } +} + +fn stack_mode_key(value: StackMode) -> &'static str { + match value { + StackMode::Superimposed => SUPERIMPOSED, + StackMode::Offset => OFFSET, + StackMode::ColorOverlay => COLOR_OVERLAY, + } +} + +fn stack_mode(value: &str) -> Option { + match value { + SUPERIMPOSED => Some(StackMode::Superimposed), + OFFSET => Some(StackMode::Offset), + COLOR_OVERLAY => Some(StackMode::ColorOverlay), + _ => None, + } +} + +fn text_align_key(value: TextAlign) -> &'static str { + match value { + TextAlign::Left => ALIGN_LEFT, + TextAlign::Center => ALIGN_CENTER, + TextAlign::Right => ALIGN_RIGHT, + } +} + +fn text_align(value: &str) -> Option { + match value { + ALIGN_LEFT => Some(TextAlign::Left), + ALIGN_CENTER => Some(TextAlign::Center), + ALIGN_RIGHT => Some(TextAlign::Right), + _ => None, + } +} + +fn shape_kind_key(value: ShapeKind) -> &'static str { + match value { + ShapeKind::Rect => SHAPE_RECT, + ShapeKind::Ellipse => SHAPE_ELLIPSE, + ShapeKind::Line => SHAPE_LINE, + ShapeKind::Arrow => SHAPE_ARROW, + } +} + +fn shape_kind(value: &str) -> Option { + match value { + SHAPE_RECT => Some(ShapeKind::Rect), + SHAPE_ELLIPSE => Some(ShapeKind::Ellipse), + SHAPE_LINE => Some(ShapeKind::Line), + SHAPE_ARROW => Some(ShapeKind::Arrow), + _ => None, + } +} diff --git a/crates/core/src/properties/object_definitions.rs b/crates/core/src/properties/object_definitions.rs new file mode 100644 index 00000000..e879ffb4 --- /dev/null +++ b/crates/core/src/properties/object_definitions.rs @@ -0,0 +1,160 @@ +use super::*; +use plotx_figure::Color; + +pub const STACK_MODE: PropertyId = PropertyId("object.stack.mode"); +pub const STACK_SPACING_Y: PropertyId = PropertyId("object.stack.spacing_y"); +pub const STACK_SHEAR_X: PropertyId = PropertyId("object.stack.shear_x"); +pub const STACK_NORMALIZE: PropertyId = PropertyId("object.stack.normalize"); +pub const CHART_TYPE_ID: PropertyId = PropertyId("object.chart.type_id"); +pub const CHART_BINS_AUTO: PropertyId = PropertyId("object.chart.bins.auto"); +pub const CHART_BINS_COUNT: PropertyId = PropertyId("object.chart.bins.count"); +pub const CHART_STACKED: PropertyId = PropertyId("object.chart.stacked"); +pub const CHART_COLORMAP: PropertyId = PropertyId("object.chart.colormap"); +pub const CHART_VIEW_AZIMUTH: PropertyId = PropertyId("object.chart.view_angles.0"); +pub const CHART_VIEW_ELEVATION: PropertyId = PropertyId("object.chart.view_angles.1"); +pub const PANEL_USER_NOTE: PropertyId = PropertyId("object.panel.user_note"); +pub const PANEL_VISIBLE: PropertyId = PropertyId("object.panel.visible"); +pub const SERIES_VISIBLE: PropertyId = PropertyId("series.visible"); +pub const TEXT: PropertyId = PropertyId("object.text.text"); +pub const TEXT_FONT_SIZE: PropertyId = PropertyId("object.text.font_size"); +pub const TEXT_BOLD: PropertyId = PropertyId("object.text.bold"); +pub const TEXT_ALIGN: PropertyId = PropertyId("object.text.align"); +pub const TEXT_COLOR: PropertyId = PropertyId("object.text.color"); +pub const SHAPE_KIND: PropertyId = PropertyId("object.shape.shape"); +pub const SHAPE_STROKE: PropertyId = PropertyId("object.shape.stroke"); +pub const SHAPE_STROKE_WIDTH: PropertyId = PropertyId("object.shape.stroke_width"); +pub const SHAPE_FILL_ENABLED: PropertyId = PropertyId("object.shape.fill.enabled"); +pub const SHAPE_FILL_COLOR: PropertyId = PropertyId("object.shape.fill.color"); +pub const LOCKED: PropertyId = PropertyId("object.locked"); + +pub const SUPERIMPOSED: &str = "superimposed"; +pub const OFFSET: &str = "offset"; +pub const COLOR_OVERLAY: &str = "color_overlay"; +pub const ALIGN_LEFT: &str = "left"; +pub const ALIGN_CENTER: &str = "center"; +pub const ALIGN_RIGHT: &str = "right"; +pub const SHAPE_RECT: &str = "rect"; +pub const SHAPE_ELLIPSE: &str = "ellipse"; +pub const SHAPE_LINE: &str = "line"; +pub const SHAPE_ARROW: &str = "arrow"; + +pub(super) const STACK_MODES: &[EnumVariant] = &[ + EnumVariant::new(SUPERIMPOSED, "Superimposed"), + EnumVariant::new(OFFSET, "Offset"), + EnumVariant::new(COLOR_OVERLAY, "Color overlay"), +]; +const ALIGNMENTS: &[EnumVariant] = &[ + EnumVariant::new(ALIGN_LEFT, "Left"), + EnumVariant::new(ALIGN_CENTER, "Center"), + EnumVariant::new(ALIGN_RIGHT, "Right"), +]; +const SHAPES: &[EnumVariant] = &[ + EnumVariant::new(SHAPE_RECT, "Rectangle"), + EnumVariant::new(SHAPE_ELLIPSE, "Ellipse"), + EnumVariant::new(SHAPE_LINE, "Line"), + EnumVariant::new(SHAPE_ARROW, "Arrow"), +]; +const COLORMAPS: &[EnumVariant] = &[ + EnumVariant::new("viridis", "Viridis"), + EnumVariant::new("plasma", "Plasma"), + EnumVariant::new("inferno", "Inferno"), + EnumVariant::new("magma", "Magma"), + EnumVariant::new("turbo", "Turbo"), + EnumVariant::new("coolwarm", "Coolwarm"), + EnumVariant::new("grays", "Grays"), +]; +const CHART_TYPES: &[EnumVariant] = &[ + EnumVariant::new("afm_map", "AFM Map").requiring(&[ + crate::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR, + crate::automation::CAP_FIELD_AFM_MAP, + ]), + EnumVariant::new("afm_force_curve", "Force Curve").requiring(&[ + crate::automation::CAP_FIELD_CURVE_1D, + crate::automation::CAP_FIELD_FORCE_CURVE, + ]), + EnumVariant::new("electrophysiology_sweeps", "Sweeps").requiring(&[ + crate::automation::CAP_FIELD_CURVE_1D, + crate::automation::CAP_FIELD_SWEEP_COLLECTION, + ]), + EnumVariant::new("nmr_spectrum", "Spectrum").requiring(&[ + crate::automation::CAP_FIELD_CURVE_1D, + crate::automation::CAP_FIELD_NMR_SPECTRUM, + ]), + EnumVariant::new("nmr_contour", "Contour").requiring(&[ + crate::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR, + crate::automation::CAP_FIELD_NMR_CONTOUR, + ]), + EnumVariant::new("nmr_pseudo", "Stack / analysis").requiring(&[ + crate::automation::CAP_FIELD_CURVE_1D, + crate::automation::CAP_FIELD_NMR_STACK, + ]), + EnumVariant::new("table_line", "Line").requiring(&[ + crate::automation::CAP_FIELD_CURVE_1D, + crate::automation::CAP_FIELD_TABLE, + ]), + EnumVariant::new("table_bar", "Bar").requiring(&[crate::automation::CAP_FIELD_TABLE]), + EnumVariant::new("table_bar_grouped", "Grouped bars") + .requiring(&[crate::automation::CAP_FIELD_TABLE]), + EnumVariant::new("table_histogram", "Histogram") + .requiring(&[crate::automation::CAP_FIELD_TABLE]), + EnumVariant::new("table_box", "Box").requiring(&[crate::automation::CAP_FIELD_TABLE]), + EnumVariant::new("table_violin", "Violin").requiring(&[crate::automation::CAP_FIELD_TABLE]), + EnumVariant::new("table_heatmap", "Heatmap").requiring(&[crate::automation::CAP_FIELD_TABLE]), + EnumVariant::new("table_pie", "Pie").requiring(&[crate::automation::CAP_FIELD_TABLE]), + EnumVariant::new("table_surface", "Surface 3D") + .requiring(&[crate::automation::CAP_FIELD_TABLE]), +]; + +pub(super) const FILL_FALLBACK: Color = Color::rgb(200, 200, 200); +const OBJECT: Applicability = Applicability::component(ComponentKind::None); +const SERIES: Applicability = Applicability::component(ComponentKind::Series); + +const fn definition( + id: PropertyId, + schema: ValueSchema, + default_policy: DefaultPolicy, + label: &'static str, + aliases: &'static [&'static str], +) -> PropertyDefinition { + PropertyDefinition { + id, + scope_kind: ScopeKind::Object, + value_schema: schema, + access: PropertyAccess::ReadWrite, + applicability: OBJECT, + default_policy, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: label, + canonical_aliases: aliases, + } +} + +#[rustfmt::skip] +pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ + definition(STACK_MODE, ValueSchema::Enum { variants: STACK_MODES }, DefaultPolicy::Fixed(PropertyValue::Enum(SUPERIMPOSED)), "Stack mode", &["overlay mode"]), + definition(STACK_SPACING_Y, ValueSchema::Float { bounds: FloatBounds::inclusive(0.0, 1.0), display: FloatDisplay::Linear(""), drag_step: Some(0.01) }, DefaultPolicy::Fixed(PropertyValue::Float(0.12)), "Vertical stack spacing", &["vertical spacing"]), + definition(STACK_SHEAR_X, ValueSchema::Float { bounds: FloatBounds::inclusive(-0.5, 0.5), display: FloatDisplay::Linear(""), drag_step: Some(0.01) }, DefaultPolicy::Fixed(PropertyValue::Float(0.0)), "Horizontal stack shear", &["3D shear"]), + definition(STACK_NORMALIZE, ValueSchema::Bool, DefaultPolicy::Fixed(PropertyValue::Bool(false)), "Normalize stacked traces", &["normalize stack"]), + definition(CHART_TYPE_ID, ValueSchema::Enum { variants: CHART_TYPES }, DefaultPolicy::Derived, "Chart type", &["plot type"]), + definition(CHART_BINS_AUTO, ValueSchema::Bool, DefaultPolicy::Fixed(PropertyValue::Bool(true)), "Automatic histogram bins", &["auto bins"]), + definition(CHART_BINS_COUNT, ValueSchema::IntWithDrag { min: 1, max: 512, drag_step: 1.0 }, DefaultPolicy::Fixed(PropertyValue::Int(20)), "Histogram bin count", &["bins", "bucket count"]), + definition(CHART_STACKED, ValueSchema::Bool, DefaultPolicy::Fixed(PropertyValue::Bool(false)), "Stack grouped bars", &["stacked bars"]), + definition(CHART_COLORMAP, ValueSchema::Enum { variants: COLORMAPS }, DefaultPolicy::Fixed(PropertyValue::Enum("viridis")), "Chart colormap", &["colour map"]), + definition(CHART_VIEW_AZIMUTH, ValueSchema::Float { bounds: FloatBounds::inclusive(-180.0_f64.to_radians(), 180.0_f64.to_radians()), display: FloatDisplay::Degrees, drag_step: Some(1.0) }, DefaultPolicy::Fixed(PropertyValue::Float(-50.0_f64.to_radians())), "Surface azimuth", &["view azimuth"]), + definition(CHART_VIEW_ELEVATION, ValueSchema::Float { bounds: FloatBounds::inclusive(5.0_f64.to_radians(), 90.0_f64.to_radians()), display: FloatDisplay::Degrees, drag_step: Some(1.0) }, DefaultPolicy::Fixed(PropertyValue::Float(30.0_f64.to_radians())), "Surface elevation", &["view elevation"]), + definition(PANEL_USER_NOTE, ValueSchema::Text, DefaultPolicy::Fixed(PropertyValue::Text(String::new())), "Panel note", &["figure note"]), + definition(PANEL_VISIBLE, ValueSchema::Bool, DefaultPolicy::Fixed(PropertyValue::Bool(true)), "Show panel letter", &["panel label visible"]), + PropertyDefinition { id: SERIES_VISIBLE, scope_kind: ScopeKind::Object, value_schema: ValueSchema::Bool, access: PropertyAccess::ReadWrite, applicability: SERIES, default_policy: DefaultPolicy::Fixed(PropertyValue::Bool(true)), tier: Tier::Essential, copies: ValueCopies::PerTarget, canonical_label: "Series visibility", canonical_aliases: &["show series"] }, + definition(TEXT, ValueSchema::Text, DefaultPolicy::Derived, "Text", &["label text"]), + definition(TEXT_FONT_SIZE, ValueSchema::Float { bounds: FloatBounds::inclusive(4.0, 200.0), display: FloatDisplay::Linear("pt"), drag_step: Some(0.5) }, DefaultPolicy::Derived, "Text size", &["font size"]), + definition(TEXT_BOLD, ValueSchema::Bool, DefaultPolicy::Derived, "Bold text", &["font weight"]), + definition(TEXT_ALIGN, ValueSchema::Enum { variants: ALIGNMENTS }, DefaultPolicy::Derived, "Text alignment", &["align"]), + definition(TEXT_COLOR, ValueSchema::Color, DefaultPolicy::Derived, "Text color", &["text colour"]), + definition(SHAPE_KIND, ValueSchema::Enum { variants: SHAPES }, DefaultPolicy::Fixed(PropertyValue::Enum(SHAPE_RECT)), "Shape kind", &["shape primitive"]), + definition(SHAPE_STROKE, ValueSchema::Color, DefaultPolicy::Fixed(PropertyValue::Color(Color::BLACK)), "Shape stroke color", &["outline colour"]), + definition(SHAPE_STROKE_WIDTH, ValueSchema::Float { bounds: FloatBounds::inclusive(0.1, 40.0), display: FloatDisplay::Linear("pt"), drag_step: Some(0.1) }, DefaultPolicy::Fixed(PropertyValue::Float(1.5)), "Shape stroke width", &["outline width"]), + definition(SHAPE_FILL_ENABLED, ValueSchema::Bool, DefaultPolicy::Fixed(PropertyValue::Bool(false)), "Fill shape", &["fill enabled"]), + definition(SHAPE_FILL_COLOR, ValueSchema::Color, DefaultPolicy::Fixed(PropertyValue::Color(FILL_FALLBACK)), "Shape fill color", &["fill colour"]), + definition(LOCKED, ValueSchema::Bool, DefaultPolicy::Fixed(PropertyValue::Bool(false)), "Lock object", &["locked"]), +]; diff --git a/crates/core/src/properties/object_tests.rs b/crates/core/src/properties/object_tests.rs new file mode 100644 index 00000000..9186da88 --- /dev/null +++ b/crates/core/src/properties/object_tests.rs @@ -0,0 +1,513 @@ +use super::*; +use crate::automation::TargetRef; +use crate::state::{ + CanvasDocument, CanvasObject, CanvasObjectKind, Dataset, FloatSeries, ObjectFrame, ObjectId, + PlotxApp, SeriesBinding, ShapeKind, ShapeObject, StackMode, TextBox, + materialized_float_series_table, +}; +use plotx_figure::Color; + +fn object_app(kind: CanvasObjectKind) -> (PlotxApp, TargetRef, ObjectId) { + let mut app = PlotxApp::new(); + let mut canvas = CanvasDocument::new("objects".to_owned(), [120.0, 80.0]); + let id = canvas.allocate_object_id(); + canvas.objects.push(CanvasObject { + id, + name: "Object".to_owned(), + frame: ObjectFrame::new(1.0, 2.0, 30.0, 20.0), + locked: false, + visible: true, + group: None, + kind, + }); + app.doc.canvases.push(canvas); + app.session.active_canvas = Some(0); + let target = app.object_target(0, id).expect("object target"); + (app, target, id) +} + +fn table_app() -> (PlotxApp, TargetRef, ObjectId) { + let values = (0..1000).map(|value| Some(f64::from(value))).collect(); + let table = materialized_float_series_table( + ( + "x".to_owned(), + String::new(), + (0..1000).map(|value| Some(f64::from(value))).collect(), + ), + vec![FloatSeries { + name: "signal".to_owned(), + unit: String::new(), + values, + uncertainty: None, + fit: None, + }], + "plotx.test.object-properties.v1", + ) + .expect("table fixture"); + let mut app = PlotxApp::new(); + app.doc.datasets.push(Dataset::Table(Box::new(table))); + let mut canvas = CanvasDocument::new("table".to_owned(), [120.0, 80.0]); + let id = canvas.allocate_object_id(); + canvas.objects.push(app.build_plot_object( + 0, + ObjectFrame::new(0.0, 0.0, 100.0, 70.0), + id, + "Plot".to_owned(), + )); + app.doc.canvases.push(canvas); + app.session.active_canvas = Some(0); + let target = app.object_target(0, id).expect("plot target"); + (app, target, id) +} + +fn stack_app() -> (PlotxApp, ObjectId) { + let mut app = PlotxApp::new(); + for source in ["first", "second"] { + let data = plotx_io::NmrData { + points: (0..32) + .map(|value| num_complex::Complex64::new(f64::from(value), 0.0)) + .collect(), + domain: plotx_io::Domain::Frequency, + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 0.0, + nucleus: "1H".to_owned(), + source: source.to_owned(), + group_delay: 0.0, + }; + app.doc + .datasets + .push(Dataset::Nmr(Box::new(crate::state::NmrDataset::load(data)))); + } + let mut canvas = CanvasDocument::new("stack".to_owned(), [120.0, 80.0]); + let id = canvas.allocate_object_id(); + canvas.objects.push(app.build_plot_object( + 0, + ObjectFrame::new(0.0, 0.0, 100.0, 70.0), + id, + "Plot".to_owned(), + )); + let plot = canvas.object_mut(id).unwrap().plot_mut().unwrap(); + let mut second = SeriesBinding::from_dataset(&app.doc.datasets[1]).expect("series"); + second.id = plot.allocate_series_id(); + plot.binding.series.push(second); + plot.stack.mode = StackMode::Offset; + let series = plot.binding.series[0].id; + app.doc.canvases.push(canvas); + app.session.active_canvas = Some(0); + let _ = series; + (app, id) +} + +fn real_stack_targets(app: &PlotxApp, id: ObjectId) -> (TargetRef, TargetRef) { + let object = app.object_target(0, id).unwrap(); + let series = app.series_targets(0, id).remove(0); + (object, series) +} + +fn set(app: &mut PlotxApp, target: &TargetRef, property: PropertyId, value: PropertyValue) { + let commit = app + .plan_property_write(property, std::slice::from_ref(target), &value) + .unwrap_or_else(|error| panic!("{property}: {error}")); + app.commit_property(commit); +} + +fn reset(app: &mut PlotxApp, target: &TargetRef, property: PropertyId) { + let commit = app + .plan_property_reset(property, std::slice::from_ref(target)) + .unwrap_or_else(|error| panic!("{property}: {error}")); + assert_eq!(commit.applied.len(), 1, "{property}"); + app.commit_property(commit); +} + +#[test] +fn every_stack_and_series_property_supports_reset() { + let (mut app, id) = stack_app(); + let (target, series) = real_stack_targets(&app, id); + set( + &mut app, + &target, + object::STACK_MODE, + PropertyValue::Enum(object::SUPERIMPOSED), + ); + set( + &mut app, + &target, + object::STACK_MODE, + PropertyValue::Enum(object::OFFSET), + ); + reset(&mut app, &target, object::STACK_MODE); + set( + &mut app, + &target, + object::STACK_MODE, + PropertyValue::Enum(object::OFFSET), + ); + for (property, changed) in [ + (object::STACK_SPACING_Y, PropertyValue::Float(0.7)), + (object::STACK_SHEAR_X, PropertyValue::Float(0.3)), + (object::STACK_NORMALIZE, PropertyValue::Bool(true)), + ] { + set(&mut app, &target, property, changed); + reset(&mut app, &target, property); + } + set( + &mut app, + &series, + object::SERIES_VISIBLE, + PropertyValue::Bool(false), + ); + reset(&mut app, &series, object::SERIES_VISIBLE); +} + +#[test] +fn every_chart_property_supports_reset() { + let (mut app, target, id) = table_app(); + set( + &mut app, + &target, + object::CHART_TYPE_ID, + PropertyValue::Enum("table_bar"), + ); + reset(&mut app, &target, object::CHART_TYPE_ID); + assert!( + app.doc.canvases[0] + .object(id) + .unwrap() + .plot() + .unwrap() + .chart + .type_id + .is_empty() + ); + + set( + &mut app, + &target, + object::CHART_TYPE_ID, + PropertyValue::Enum("table_histogram"), + ); + set( + &mut app, + &target, + object::CHART_BINS_AUTO, + PropertyValue::Bool(false), + ); + reset(&mut app, &target, object::CHART_BINS_COUNT); + reset(&mut app, &target, object::CHART_BINS_AUTO); + + set( + &mut app, + &target, + object::CHART_TYPE_ID, + PropertyValue::Enum("table_bar_grouped"), + ); + set( + &mut app, + &target, + object::CHART_STACKED, + PropertyValue::Bool(true), + ); + reset(&mut app, &target, object::CHART_STACKED); + + set( + &mut app, + &target, + object::CHART_TYPE_ID, + PropertyValue::Enum("table_surface"), + ); + set( + &mut app, + &target, + object::CHART_COLORMAP, + PropertyValue::Enum("plasma"), + ); + reset(&mut app, &target, object::CHART_COLORMAP); + for (property, changed) in [ + ( + object::CHART_VIEW_AZIMUTH, + PropertyValue::Float(10_f64.to_radians()), + ), + ( + object::CHART_VIEW_ELEVATION, + PropertyValue::Float(60_f64.to_radians()), + ), + ] { + set(&mut app, &target, property, changed); + reset(&mut app, &target, property); + } +} + +#[test] +fn every_panel_text_shape_and_object_property_supports_reset() { + let (mut panel_app, panel_target, panel_id) = table_app(); + set( + &mut panel_app, + &panel_target, + object::PANEL_USER_NOTE, + PropertyValue::Text("note".to_owned()), + ); + reset(&mut panel_app, &panel_target, object::PANEL_USER_NOTE); + set( + &mut panel_app, + &panel_target, + object::PANEL_VISIBLE, + PropertyValue::Bool(false), + ); + reset(&mut panel_app, &panel_target, object::PANEL_VISIBLE); + set( + &mut panel_app, + &panel_target, + object::LOCKED, + PropertyValue::Bool(true), + ); + reset(&mut panel_app, &panel_target, object::LOCKED); + assert!(!panel_app.doc.canvases[0].object(panel_id).unwrap().locked); + + let (mut text_app, text_target, _) = + object_app(CanvasObjectKind::Text(TextBox::label(String::new()))); + for (property, changed) in [ + (object::TEXT, PropertyValue::Text("caption".to_owned())), + (object::TEXT_FONT_SIZE, PropertyValue::Float(22.0)), + (object::TEXT_BOLD, PropertyValue::Bool(true)), + (object::TEXT_ALIGN, PropertyValue::Enum(object::ALIGN_RIGHT)), + ( + object::TEXT_COLOR, + PropertyValue::Color(Color::rgb(1, 2, 3)), + ), + ] { + set(&mut text_app, &text_target, property, changed); + reset(&mut text_app, &text_target, property); + } + + let (mut shape_app, shape_target, _) = + object_app(CanvasObjectKind::Shape(ShapeObject::new(ShapeKind::Rect))); + for (property, changed) in [ + (object::SHAPE_KIND, PropertyValue::Enum(object::SHAPE_ARROW)), + ( + object::SHAPE_STROKE, + PropertyValue::Color(Color::rgb(1, 2, 3)), + ), + (object::SHAPE_STROKE_WIDTH, PropertyValue::Float(8.0)), + ] { + set(&mut shape_app, &shape_target, property, changed); + reset(&mut shape_app, &shape_target, property); + } + set( + &mut shape_app, + &shape_target, + object::SHAPE_FILL_ENABLED, + PropertyValue::Bool(true), + ); + reset(&mut shape_app, &shape_target, object::SHAPE_FILL_ENABLED); + set( + &mut shape_app, + &shape_target, + object::SHAPE_FILL_ENABLED, + PropertyValue::Bool(true), + ); + set( + &mut shape_app, + &shape_target, + object::SHAPE_FILL_COLOR, + PropertyValue::Color(Color::rgb(4, 5, 6)), + ); + reset(&mut shape_app, &shape_target, object::SHAPE_FILL_COLOR); +} + +#[test] +fn automatic_bins_disable_count_and_manual_mode_seeds_the_live_auto_result() { + let (mut app, target, id) = table_app(); + set( + &mut app, + &target, + object::CHART_TYPE_ID, + PropertyValue::Enum("table_histogram"), + ); + assert_eq!( + app.doc.canvases[0] + .object(id) + .unwrap() + .plot() + .unwrap() + .chart + .bins, + None + ); + let count = app + .resolve_property(&PropertyAddress::new( + target.clone(), + object::CHART_BINS_COUNT, + )) + .unwrap(); + let Availability::Disabled(reason) = count.availability else { + panic!("automatic bins must disable the count"); + }; + assert!(reason.contains("Turn off Automatic"), "{reason}"); + + set( + &mut app, + &target, + object::CHART_BINS_AUTO, + PropertyValue::Bool(false), + ); + assert_eq!( + app.doc.canvases[0] + .object(id) + .unwrap() + .plot() + .unwrap() + .chart + .bins, + Some(10), + "the 0..999 sample's live Freedman–Diaconis result seeds manual mode" + ); +} + +#[test] +fn fill_disabled_round_trip_discards_the_old_color_and_uses_the_existing_gray_fallback() { + let mut shape = ShapeObject::new(ShapeKind::Rect); + shape.fill = Some(Color::rgb(9, 8, 7)); + let (mut app, target, id) = object_app(CanvasObjectKind::Shape(shape)); + set( + &mut app, + &target, + object::SHAPE_FILL_ENABLED, + PropertyValue::Bool(false), + ); + let fill = app + .resolve_property(&PropertyAddress::new( + target.clone(), + object::SHAPE_FILL_COLOR, + )) + .unwrap(); + assert!(matches!(fill.availability, Availability::Disabled(_))); + set( + &mut app, + &target, + object::SHAPE_FILL_ENABLED, + PropertyValue::Bool(true), + ); + assert_eq!( + app.doc.canvases[0] + .object(id) + .unwrap() + .shape() + .unwrap() + .fill, + Some(Color::rgb(200, 200, 200)) + ); +} + +#[test] +fn style_properties_write_multiple_objects_atomically() { + let (mut text_app, first, first_id) = + object_app(CanvasObjectKind::Text(TextBox::label(String::new()))); + let mut second = text_app.doc.canvases[0].object(first_id).unwrap().clone(); + second.id = text_app.doc.canvases[0].allocate_object_id(); + let second_id = second.id; + text_app.doc.canvases[0].objects.push(second); + let second_target = text_app.object_target(0, second_id).unwrap(); + let commit = text_app + .plan_property_write( + object::TEXT_COLOR, + &[first, second_target], + &PropertyValue::Color(Color::rgb(3, 4, 5)), + ) + .unwrap(); + assert_eq!(commit.applied.len(), 2); + text_app.commit_property(commit); +} + +#[test] +fn chart_properties_write_multiple_objects_atomically() { + let (mut chart_app, first, first_id) = table_app(); + let mut second = chart_app.doc.canvases[0].object(first_id).unwrap().clone(); + second.id = chart_app.doc.canvases[0].allocate_object_id(); + let second_id = second.id; + chart_app.doc.canvases[0].objects.push(second); + let second_target = chart_app.object_target(0, second_id).unwrap(); + let commit = chart_app + .plan_property_write( + object::CHART_TYPE_ID, + &[first, second_target], + &PropertyValue::Enum("table_bar"), + ) + .unwrap(); + assert_eq!(commit.applied.len(), 2); + chart_app.commit_property(commit); +} + +#[test] +fn continuous_style_drag_records_one_undo_step() { + let (mut app, target, id) = + object_app(CanvasObjectKind::Shape(ShapeObject::new(ShapeKind::Rect))); + let before = app.doc.canvases[0] + .object(id) + .unwrap() + .shape() + .unwrap() + .stroke_width; + app.begin_property_gesture(object::SHAPE_STROKE_WIDTH); + for width in [2.0, 3.0, 4.0] { + set( + &mut app, + &target, + object::SHAPE_STROKE_WIDTH, + PropertyValue::Float(width), + ); + } + app.end_property_gesture(); + assert_eq!(app.session.undo_stack.len(), 1); + app.undo(); + assert_eq!( + app.doc.canvases[0] + .object(id) + .unwrap() + .shape() + .unwrap() + .stroke_width, + before + ); +} + +#[test] +fn continuous_chart_drag_records_one_undo_step() { + let (mut app, target, id) = table_app(); + set( + &mut app, + &target, + object::CHART_TYPE_ID, + PropertyValue::Enum("table_surface"), + ); + let history = app.session.undo_stack.len(); + let before = app.doc.canvases[0] + .object(id) + .unwrap() + .plot() + .unwrap() + .chart + .view_angles[0]; + app.begin_property_gesture(object::CHART_VIEW_AZIMUTH); + for degrees in [-40.0_f64, -30.0, -20.0] { + set( + &mut app, + &target, + object::CHART_VIEW_AZIMUTH, + PropertyValue::Float(degrees.to_radians()), + ); + } + app.end_property_gesture(); + assert_eq!(app.session.undo_stack.len(), history + 1); + app.undo(); + assert_eq!( + app.doc.canvases[0] + .object(id) + .unwrap() + .plot() + .unwrap() + .chart + .view_angles[0], + before + ); +} diff --git a/crates/core/src/properties/phase.rs b/crates/core/src/properties/phase.rs new file mode 100644 index 00000000..2f3a910f --- /dev/null +++ b/crates/core/src/properties/phase.rs @@ -0,0 +1,384 @@ +//! Dataset-owned phase-step properties. + +use super::processing_common::{ + no_factory_default, no_step_gesture, property_definition, step_context, step_mut, wrong_kind, +}; +use super::provider::PropertyProvider; +use super::{ + AggregateValue, Applicability, Availability, ComponentKind, DefaultPolicy, EditOp, EnumVariant, + FloatBounds, FloatDisplay, PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, + PropertyId, PropertyReadout, PropertyTransaction, PropertyValue, ResolvedProperty, + ResolvedSchema, ScopeKind, Tier, ValueCopies, ValueSchema, +}; +use crate::state::PlotxApp; +use plotx_processing::{AutoPhaseMethod, PhaseParams, StepKind}; + +pub const MODE: PropertyId = PropertyId("dataset.processing.phase.mode"); +pub const PHASE0: PropertyId = PropertyId("dataset.processing.phase.phase0"); +pub const PHASE1: PropertyId = PropertyId("dataset.processing.phase.phase1"); +pub const PIVOT: PropertyId = PropertyId("dataset.processing.phase.pivot"); + +pub const MANUAL: &str = "manual"; +pub const ROBUST_CONSENSUS: &str = "robust_consensus"; +pub const ABSORPTIVE_PEAK: &str = "absorptive_peak"; +pub const ENTROPY: &str = "entropy"; +pub const NEGATIVE_MINIMIZATION: &str = "negative_minimization"; +pub const PEAK_REGRESSION: &str = "peak_regression"; + +const MODES: &[EnumVariant] = &[ + EnumVariant::new(MANUAL, "Manual"), + EnumVariant::new(ROBUST_CONSENSUS, "Auto: Robust consensus"), + EnumVariant::new(ABSORPTIVE_PEAK, "Auto: Absorptive peak"), + EnumVariant::new(ENTROPY, "Auto: Entropy (ACME)"), + EnumVariant::new(NEGATIVE_MINIMIZATION, "Auto: Min. negative area"), + EnumVariant::new(PEAK_REGRESSION, "Auto: Peak regression"), +]; +const UNBOUNDED: FloatBounds = FloatBounds::inclusive(-f64::MAX, f64::MAX); +const PIVOT_BOUNDS: FloatBounds = FloatBounds::inclusive(0.0, 1.0); +/// The old editor moved half a degree per notch. Drag steps are declared in the +/// display space, while the stored property and automation value stay radians. +const PHASE_STEP_DEGREES: f64 = 0.5; +const PIVOT_STEP: f64 = 0.001; +const STEP: Applicability = Applicability::component(ComponentKind::ProcessingStep); + +pub const MANUAL_PHASE0_REASON: &str = "Switch the phase mode to Manual before setting φ0."; +pub const MANUAL_PHASE1_REASON: &str = "Switch the phase mode to Manual before setting φ1."; +pub const MANUAL_PIVOT_REASON: &str = "Switch the phase mode to Manual before setting the pivot."; + +pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ + PropertyDefinition { + id: MODE, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::Enum { variants: MODES }, + access: PropertyAccess::ReadWrite, + applicability: STEP, + default_policy: DefaultPolicy::ProcessingFactory, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Phase mode", + canonical_aliases: &["manual phase", "automatic phase", "autophase"], + }, + PropertyDefinition { + id: PHASE0, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::Float { + bounds: UNBOUNDED, + display: FloatDisplay::Degrees, + drag_step: Some(PHASE_STEP_DEGREES), + }, + access: PropertyAccess::ReadWrite, + applicability: STEP, + default_policy: DefaultPolicy::ProcessingFactory, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Zero-order phase", + canonical_aliases: &["phase0", "phi0", "φ0"], + }, + PropertyDefinition { + id: PHASE1, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::Float { + bounds: UNBOUNDED, + display: FloatDisplay::Degrees, + drag_step: Some(PHASE_STEP_DEGREES), + }, + access: PropertyAccess::ReadWrite, + applicability: STEP, + default_policy: DefaultPolicy::ProcessingFactory, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "First-order phase", + canonical_aliases: &["phase1", "phi1", "φ1"], + }, + PropertyDefinition { + id: PIVOT, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::Float { + bounds: PIVOT_BOUNDS, + display: FloatDisplay::Linear("fraction"), + drag_step: Some(PIVOT_STEP), + }, + access: PropertyAccess::ReadWrite, + applicability: STEP, + default_policy: DefaultPolicy::ProcessingFactory, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Phase pivot", + canonical_aliases: &["phase pivot fraction", "pivot_frac", "phase origin"], + }, +]; + +pub(crate) struct PhaseProvider; + +pub(crate) static PROVIDER: PhaseProvider = PhaseProvider; + +impl PropertyProvider for PhaseProvider { + fn definitions(&self) -> &'static [PropertyDefinition] { + DEFINITIONS + } + + fn read( + &self, + app: &PlotxApp, + address: &PropertyAddress, + ) -> Result { + let definition = property_definition(address.definition)?; + let context = step_context(app, address, definition, |kind| { + matches!(kind, StepKind::Phase(_)) + })?; + let StepKind::Phase(current) = context.step.kind else { + unreachable!("the shared context checked the step kind"); + }; + let factory = context.factory.as_ref().and_then(|step| match step.kind { + StepKind::Phase(value) => Some(value), + _ => None, + }); + Ok(ResolvedProperty { + address: address.clone(), + modified: None, + value: AggregateValue::Uniform(value_of(definition, current)?), + default_value: default_value(definition, factory)?, + availability: availability(definition, current), + schema: schema_for(definition)?, + }) + } + + fn edit( + &self, + app: &PlotxApp, + transaction: &mut PropertyTransaction, + address: &PropertyAddress, + operation: &EditOp<'_>, + ) -> Result<(), PropertyError> { + let definition = property_definition(address.definition)?; + let context = step_context(app, address, definition, |kind| { + matches!(kind, StepKind::Phase(_)) + })?; + let StepKind::Phase(current) = context.step.kind else { + unreachable!("the shared context checked the step kind"); + }; + let factory = context.factory.as_ref().and_then(|step| match step.kind { + StepKind::Phase(value) => Some(value), + _ => None, + }); + let value = match operation { + EditOp::Set(value) => checked_value(definition, current, value)?, + EditOp::Reset => checked_reset_value( + definition, + current, + default_value(definition, factory)? + .ok_or_else(|| no_factory_default(definition))?, + )?, + EditOp::Step(_) => return Err(no_step_gesture(definition)), + }; + // This is the same live automatic result the old `set_phase_method` + // seeded. It is read before selecting the working copy because the + // cached base belongs to the live dataset, not the transaction. + let automatic_seed = if definition.id == MODE + && value == PropertyValue::Enum(MANUAL) + && current.auto.is_some() + { + context.dataset.automatic_phase_params(context.axis) + } else { + None + }; + let state = transaction.processing_state(app, context.dataset_id)?; + let step = step_mut(state, context.step.id, &address.target)?; + let StepKind::Phase(current) = &mut step.kind else { + return Err(PropertyError::NotApplicable( + "the addressed step is no longer a phase step".to_owned(), + )); + }; + write(definition, current, value, automatic_seed) + } + + fn readout( + &self, + app: &PlotxApp, + address: &PropertyAddress, + ) -> Result { + let definition = property_definition(address.definition)?; + let context = step_context(app, address, definition, |kind| { + matches!(kind, StepKind::Phase(_)) + })?; + if definition.id == PIVOT { + return context + .dataset + .pivot_ppm(context.axis) + .map(|ppm| PropertyReadout::PhasePivotPpm { ppm }) + .ok_or_else(|| { + PropertyError::NotApplicable( + "The addressed phase axis has no ppm ruler.".to_owned(), + ) + }); + } + super::readout::uniform_readout(self.read(app, address)?) + } +} + +fn value_of( + definition: &'static PropertyDefinition, + current: PhaseParams, +) -> Result { + match definition.id { + MODE => Ok(PropertyValue::Enum(mode_of(current.auto))), + PHASE0 => Ok(PropertyValue::Float(current.phase0)), + PHASE1 => Ok(PropertyValue::Float(current.phase1)), + PIVOT => Ok(PropertyValue::Float(current.pivot_frac)), + _ => Err(PropertyError::UnknownProperty(definition.id.to_string())), + } +} + +fn default_value( + definition: &'static PropertyDefinition, + factory: Option, +) -> Result, PropertyError> { + let Some(factory) = factory else { + return Ok(None); + }; + Ok(Some(value_of(definition, factory)?)) +} + +fn availability(definition: &'static PropertyDefinition, current: PhaseParams) -> Availability { + if current.auto.is_none() { + return Availability::Editable; + } + match definition.id { + PHASE0 => Availability::Disabled(MANUAL_PHASE0_REASON), + PHASE1 => Availability::Disabled(MANUAL_PHASE1_REASON), + PIVOT => Availability::Disabled(MANUAL_PIVOT_REASON), + _ => Availability::Editable, + } +} + +fn schema_for(definition: &'static PropertyDefinition) -> Result { + match definition.id { + MODE => Ok(ResolvedSchema::Enum { + variants: MODES.iter().collect(), + }), + PHASE0 | PHASE1 => Ok(ResolvedSchema::Float { + bounds: UNBOUNDED, + display: FloatDisplay::Degrees, + }), + PIVOT => Ok(ResolvedSchema::Float { + bounds: PIVOT_BOUNDS, + display: FloatDisplay::Linear("fraction"), + }), + _ => Err(PropertyError::UnknownProperty(definition.id.to_string())), + } +} + +fn checked_reset_value( + definition: &'static PropertyDefinition, + current: PhaseParams, + value: PropertyValue, +) -> Result { + if current.auto.is_some() { + let subject = match definition.id { + PHASE0 => "φ0", + PHASE1 => "φ1", + PIVOT => "the pivot", + _ => return checked_value(definition, current, &value), + }; + return Err(PropertyError::NotApplicable(format!( + "Switch the phase mode to Manual before resetting {subject}." + ))); + } + checked_value(definition, current, &value) +} + +fn checked_value( + definition: &'static PropertyDefinition, + current: PhaseParams, + value: &PropertyValue, +) -> Result { + if let Availability::Disabled(reason) = availability(definition, current) { + return Err(PropertyError::NotApplicable(reason.to_owned())); + } + match (definition.id, value) { + (MODE, PropertyValue::Enum(value)) if method_of(value).is_some() => { + Ok(PropertyValue::Enum(value)) + } + (MODE, PropertyValue::Enum(value)) => Err(PropertyError::InvalidValue { + property: definition.id, + message: format!("'{value}' is not a phase mode"), + }), + (MODE, value) => Err(wrong_kind(definition, value, "a phase mode")), + (PHASE0 | PHASE1, PropertyValue::Float(value)) => { + UNBOUNDED.check(definition.id, definition.canonical_label, *value)?; + Ok(PropertyValue::Float(*value)) + } + (PIVOT, PropertyValue::Float(value)) => { + PIVOT_BOUNDS.check(definition.id, definition.canonical_label, *value)?; + Ok(PropertyValue::Float(*value)) + } + (PHASE0 | PHASE1 | PIVOT, value) => Err(wrong_kind(definition, value, "a number")), + (_, _) => Err(PropertyError::UnknownProperty(definition.id.to_string())), + } +} + +fn write( + definition: &'static PropertyDefinition, + current: &mut PhaseParams, + value: PropertyValue, + automatic_seed: Option<(f64, f64, f64)>, +) -> Result<(), PropertyError> { + match (definition.id, value) { + (MODE, PropertyValue::Enum(value)) => { + match method_of(value) { + Some(None) => { + if let Some((phase0, phase1, pivot_frac)) = automatic_seed { + current.phase0 = phase0; + current.phase1 = phase1; + current.pivot_frac = pivot_frac; + } + current.auto = None; + } + Some(Some(method)) => current.auto = Some(method), + None => { + return Err(PropertyError::InvalidValue { + property: definition.id, + message: format!("'{value}' is not a phase mode"), + }); + } + } + Ok(()) + } + (PHASE0, PropertyValue::Float(value)) => { + current.phase0 = value; + Ok(()) + } + (PHASE1, PropertyValue::Float(value)) => { + current.phase1 = value; + Ok(()) + } + (PIVOT, PropertyValue::Float(value)) => { + current.pivot_frac = value; + Ok(()) + } + (_, value) => Err(wrong_kind(definition, &value, "the declared phase value")), + } +} + +fn mode_of(method: Option) -> &'static str { + match method { + None => MANUAL, + Some(AutoPhaseMethod::RobustConsensus) => ROBUST_CONSENSUS, + Some(AutoPhaseMethod::AbsorptivePeak) => ABSORPTIVE_PEAK, + Some(AutoPhaseMethod::Entropy) => ENTROPY, + Some(AutoPhaseMethod::NegativeMinimization) => NEGATIVE_MINIMIZATION, + Some(AutoPhaseMethod::PeakRegression) => PEAK_REGRESSION, + } +} + +fn method_of(value: &str) -> Option> { + match value { + MANUAL => Some(None), + ROBUST_CONSENSUS => Some(Some(AutoPhaseMethod::RobustConsensus)), + ABSORPTIVE_PEAK => Some(Some(AutoPhaseMethod::AbsorptivePeak)), + ENTROPY => Some(Some(AutoPhaseMethod::Entropy)), + NEGATIVE_MINIMIZATION => Some(Some(AutoPhaseMethod::NegativeMinimization)), + PEAK_REGRESSION => Some(Some(AutoPhaseMethod::PeakRegression)), + _ => None, + } +} diff --git a/crates/core/src/properties/phase_tests.rs b/crates/core/src/properties/phase_tests.rs new file mode 100644 index 00000000..3015edf1 --- /dev/null +++ b/crates/core/src/properties/phase_tests.rs @@ -0,0 +1,233 @@ +use super::processing_test_support::{add_step, spectrum, step, target_for, time_domain_app}; +use super::*; +use plotx_processing::{PhaseParams, StepKind}; + +#[test] +fn automatic_phase_keeps_manual_parameters_visible_but_disabled_with_actions() { + let app = time_domain_app(); + let target = target_for(&app, |kind| matches!(kind, StepKind::Phase(_))); + for (property, reason) in [ + (phase::PHASE0, phase::MANUAL_PHASE0_REASON), + (phase::PHASE1, phase::MANUAL_PHASE1_REASON), + (phase::PIVOT, phase::MANUAL_PIVOT_REASON), + ] { + let resolved = app + .resolve_property(&PropertyAddress::new(target.clone(), property)) + .expect("an automatic phase parameter remains present"); + assert_eq!(resolved.availability, Availability::Disabled(reason)); + assert!(matches!(resolved.value, AggregateValue::Uniform(_))); + } +} + +#[test] +fn switching_to_manual_seeds_the_live_automatic_phase_without_a_jump() { + let mut app = time_domain_app(); + let target = target_for(&app, |kind| matches!(kind, StepKind::Phase(_))); + let before = spectrum(&app); + let expected = app.doc.datasets[0] + .automatic_phase_params(crate::state::PhaseAxis::Direct) + .expect("the enabled auto step has a live result"); + + let commit = app + .plan_property_write( + phase::MODE, + std::slice::from_ref(&target), + &PropertyValue::Enum(phase::MANUAL), + ) + .expect("manual mode plans"); + app.commit_property(commit); + let StepKind::Phase(params) = step(&app, &target).kind else { + panic!("the step remains Phase"); + }; + assert_eq!( + params, + PhaseParams { + phase0: expected.0, + phase1: expected.1, + pivot_frac: expected.2, + auto: None, + } + ); + let after = spectrum(&app); + for (left, right) in after.0.iter().zip(&before.0) { + assert!((*left - *right).norm() < 1.0e-9); + } +} + +#[test] +fn phase_catalog_write_reprocesses_real_fid_and_one_undo_restores_it() { + let mut app = time_domain_app(); + let target = target_for(&app, |kind| matches!(kind, StepKind::Phase(_))); + let manual = app + .plan_property_write( + phase::MODE, + std::slice::from_ref(&target), + &PropertyValue::Enum(phase::MANUAL), + ) + .expect("manual mode plans"); + app.commit_property(manual); + let before = spectrum(&app); + + let commit = app + .plan_property_write( + phase::PHASE0, + std::slice::from_ref(&target), + &PropertyValue::Float(0.4), + ) + .expect("a radian phase writes through the real entry"); + app.commit_property(commit); + assert_ne!(spectrum(&app).0, before.0); + + app.undo(); + assert_eq!(spectrum(&app), before); +} + +#[test] +fn pivot_fraction_changes_only_the_addressed_phase_step() { + let mut app = time_domain_app(); + let first = target_for(&app, |kind| matches!(kind, StepKind::Phase(_))); + let second = add_step(&mut app, StepKind::Phase(PhaseParams::MANUAL_ZERO)); + let StepKind::Phase(first_before) = step(&app, &first).kind else { + panic!("the factory step is Phase"); + }; + + let commit = app + .plan_property_write( + phase::PIVOT, + std::slice::from_ref(&second), + &PropertyValue::Float(0.75), + ) + .expect("the addressed pivot plans"); + app.commit_property(commit); + let StepKind::Phase(first_after) = step(&app, &first).kind else { + panic!("the factory step remains Phase"); + }; + let StepKind::Phase(second_after) = step(&app, &second).kind else { + panic!("the added step remains Phase"); + }; + assert_eq!(first_after, first_before); + assert_eq!(second_after.pivot_frac, 0.75); +} + +#[test] +fn manual_mode_fallback_keeps_stored_terms_when_no_enabled_auto_step_can_seed_it() { + let mut app = time_domain_app(); + let factory = target_for(&app, |kind| matches!(kind, StepKind::Phase(_))); + super::processing_test_support::step_mut(&mut app, &factory).enabled = false; + let target = add_step( + &mut app, + StepKind::Phase(PhaseParams { + phase0: 0.2, + phase1: -0.4, + pivot_frac: 0.3, + auto: Some(plotx_processing::AutoPhaseMethod::Entropy), + }), + ); + super::processing_test_support::step_mut(&mut app, &target).enabled = false; + let commit = app + .plan_property_write( + phase::MODE, + std::slice::from_ref(&target), + &PropertyValue::Enum(phase::MANUAL), + ) + .expect("a disabled auto step can still be made manual"); + app.commit_property(commit); + assert_eq!( + step(&app, &target).kind, + StepKind::Phase(PhaseParams { + phase0: 0.2, + phase1: -0.4, + pivot_frac: 0.3, + auto: None, + }) + ); +} + +#[test] +fn phase_reset_uses_radians_but_reports_degrees_and_uses_reset_wording() { + let mut app = time_domain_app(); + let target = target_for(&app, |kind| matches!(kind, StepKind::Phase(_))); + let auto_reset = app + .plan_property_reset(phase::PHASE0, std::slice::from_ref(&target)) + .expect("an inapplicable reset is a typed skip"); + assert!(auto_reset.applied.is_empty()); + assert_eq!(auto_reset.skipped.len(), 1); + let message = &auto_reset.skipped[0].message; + assert!(message.contains("before resetting φ0"), "{message}"); + assert!(!message.contains("before setting φ0"), "{message}"); + + let manual = app + .plan_property_write( + phase::MODE, + std::slice::from_ref(&target), + &PropertyValue::Enum(phase::MANUAL), + ) + .unwrap(); + app.commit_property(manual); + let changed = app + .plan_property_write( + phase::PHASE0, + std::slice::from_ref(&target), + &PropertyValue::Float(45.0_f64.to_radians()), + ) + .unwrap(); + app.commit_property(changed); + let schema = app + .resolve_property(&PropertyAddress::new(target.clone(), phase::PHASE0)) + .unwrap() + .schema; + assert!(matches!( + schema, + ResolvedSchema::Float { + display: FloatDisplay::Degrees, + .. + } + )); + assert!(matches!( + definition(phase::PHASE0).unwrap().value_schema, + ValueSchema::Float { + display: FloatDisplay::Degrees, + drag_step: Some(0.5), + .. + } + )); + let reset = app + .plan_property_reset(phase::PHASE0, std::slice::from_ref(&target)) + .unwrap(); + assert_eq!(reset.applied.len(), 1); +} + +#[test] +fn phase_pivot_keeps_fraction_storage_and_derives_a_ppm_readout() { + let mut app = time_domain_app(); + let target = target_for(&app, |kind| matches!(kind, StepKind::Phase(_))); + let manual = app + .plan_property_write( + phase::MODE, + std::slice::from_ref(&target), + &PropertyValue::Enum(phase::MANUAL), + ) + .unwrap(); + app.commit_property(manual); + let changed = app + .plan_property_write( + phase::PIVOT, + std::slice::from_ref(&target), + &PropertyValue::Float(0.25), + ) + .unwrap(); + app.commit_property(changed); + assert_eq!( + app.resolve_property(&PropertyAddress::new(target.clone(), phase::PIVOT)) + .unwrap() + .value, + AggregateValue::Uniform(PropertyValue::Float(0.25)) + ); + let PropertyReadout::PhasePivotPpm { ppm } = app + .property_readout(&PropertyAddress::new(target, phase::PIVOT)) + .unwrap() + else { + panic!("pivot supplies a ppm projection"); + }; + assert!(ppm.is_finite()); +} diff --git a/crates/core/src/properties/processing_common.rs b/crates/core/src/properties/processing_common.rs new file mode 100644 index 00000000..1a800639 --- /dev/null +++ b/crates/core/src/properties/processing_common.rs @@ -0,0 +1,189 @@ +//! Shared addressing helpers for processing-property providers. + +use super::target::dataset_steps; +use super::{ + ComponentKind, PropertyAddress, PropertyDefinition, PropertyError, PropertyId, PropertyValue, + definition, +}; +use crate::actions::DatasetProcessingState; +use crate::automation::{ComponentRef, TargetRef}; +use crate::state::{Dataset, DatasetId, PhaseAxis, PlotxApp}; +use plotx_processing::{ProcessingStep, Spectrum, StepId, StepKind, StepSource}; + +pub(super) struct StepContext<'a> { + pub dataset_id: DatasetId, + pub dataset: &'a Dataset, + pub axis: PhaseAxis, + pub step: &'a ProcessingStep, + pub factory: Option, +} + +pub(super) fn property_definition( + id: PropertyId, +) -> Result<&'static PropertyDefinition, PropertyError> { + definition(id).ok_or_else(|| PropertyError::UnknownProperty(id.as_str().to_owned())) +} + +pub(super) fn step_context<'a>( + app: &'a PlotxApp, + address: &PropertyAddress, + definition: &'static PropertyDefinition, + accepts: impl FnOnce(&StepKind) -> bool, +) -> Result, PropertyError> { + let actual = ComponentKind::of(address.target.component.as_ref()); + if actual != definition.applicability.component { + return Err(PropertyError::ComponentKind { + property: definition.id, + expected: definition.applicability.component.as_str(), + actual: actual.as_str(), + }); + } + let Some(ComponentRef::ProcessingStep(id)) = address.target.component else { + return Err(PropertyError::ComponentKind { + property: definition.id, + expected: ComponentKind::ProcessingStep.as_str(), + actual: actual.as_str(), + }); + }; + let dataset_id = DatasetId::try_from(&address.target.resource).map_err(|error| { + PropertyError::NotApplicable(format!( + "{} needs a dataset resource: {error}", + definition.id + )) + })?; + let dataset = app + .doc + .dataset_by_id(dataset_id) + .ok_or_else(|| PropertyError::UnknownTarget(address.target.resource.id.clone()))?; + let (axis, step) = dataset_steps(dataset) + .find(|(_, step)| step.id == id) + .ok_or_else(|| PropertyError::UnknownTarget(address.target.describe()))?; + if !accepts(&step.kind) { + return Err(PropertyError::NotApplicable(format!( + "{} does not apply because step {} is {}, not the required processing step", + definition.canonical_label, + id.get(), + step_kind_name(&step.kind) + ))); + } + let factory = factory_step(dataset, axis, step); + Ok(StepContext { + dataset_id, + dataset, + axis, + step, + factory, + }) +} + +pub(super) fn factory_step( + dataset: &Dataset, + axis: PhaseAxis, + step: &ProcessingStep, +) -> Option { + if step.source != StepSource::Default { + return None; + } + dataset + .factory_pipeline(axis)? + .steps + .into_iter() + // Live 2D ids are owner-global while each detached axis template starts + // at zero, so provenance matches the factory slot by typed step kind. + .find(|candidate| same_step_kind(&candidate.kind, &step.kind)) +} + +fn same_step_kind(left: &StepKind, right: &StepKind) -> bool { + std::mem::discriminant(left) == std::mem::discriminant(right) +} + +pub(super) fn step_mut<'a>( + state: &'a mut DatasetProcessingState, + id: StepId, + target: &TargetRef, +) -> Result<&'a mut ProcessingStep, PropertyError> { + state + .steps_mut() + .find(|step| step.id == id) + .ok_or_else(|| PropertyError::UnknownTarget(target.describe())) +} + +pub(super) fn wrong_kind( + definition: &'static PropertyDefinition, + value: &PropertyValue, + expected: &str, +) -> PropertyError { + PropertyError::InvalidValue { + property: definition.id, + message: format!("expected {expected}, got {}", value.kind()), + } +} + +pub(super) fn no_step_gesture(definition: &'static PropertyDefinition) -> PropertyError { + PropertyError::InvalidValue { + property: definition.id, + message: "this processing setting has no step gesture".to_owned(), + } +} + +pub(super) fn no_factory_default(definition: &'static PropertyDefinition) -> PropertyError { + PropertyError::NotApplicable(format!( + "{} was added by hand and has no factory setting to reset to", + definition.canonical_label + )) +} + +pub(super) fn step_kind_name(kind: &StepKind) -> &'static str { + match kind { + StepKind::Apodize(_) => "apodization", + StepKind::ZeroFill(_) => "zero fill", + StepKind::Fft => "FFT", + StepKind::Phase(_) => "phase", + StepKind::Baseline(_) => "baseline", + StepKind::Reference(_) => "reference", + StepKind::Magnitude => "magnitude", + StepKind::Smooth(_) => "smoothing", + StepKind::Normalize(_) => "normalization", + StepKind::Bin(_) => "binning", + StepKind::Reverse => "reverse", + StepKind::Invert => "invert", + } +} + +pub(super) fn raw_point_count(dataset: &Dataset, axis: PhaseAxis) -> usize { + match dataset { + Dataset::Nmr(n) => n.data.len(), + Dataset::Nmr2D(n) => match axis { + PhaseAxis::F1 => n.data.nus.as_ref().map_or_else( + || plotx_processing::fft2::f1_increments(n.data.rows, n.data.quad), + |nus| nus.grid, + ), + PhaseAxis::F2 | PhaseAxis::Direct => n.data.cols, + }, + Dataset::Table(_) | Dataset::Electrophysiology(_) | Dataset::Afm(_) => 0, + } +} + +/// The real spectrum presented to one frequency-domain step. This reuses the +/// cached FFT result and the processing kernel's own step dispatcher, so schema +/// bounds follow prior binning and cleanup exactly without duplicating them. +pub(super) fn spectrum_before_step(context: &StepContext<'_>) -> Option { + let Dataset::Nmr(dataset) = context.dataset else { + return None; + }; + let mut spectrum = dataset.base.clone(); + for step in dataset + .pipeline + .steps + .iter() + .skip_while(|step| step.kind.at_or_before_fft()) + { + if step.id == context.step.id { + return Some(spectrum); + } + if step.enabled { + plotx_processing::apply_freq_step(&mut spectrum, &step.kind); + } + } + None +} diff --git a/crates/core/src/properties/processing_test_support.rs b/crates/core/src/properties/processing_test_support.rs new file mode 100644 index 00000000..a323de2a --- /dev/null +++ b/crates/core/src/properties/processing_test_support.rs @@ -0,0 +1,159 @@ +use crate::automation::{ComponentRef, ResourceRef, TargetRef}; +use crate::state::{Dataset, Nmr2DDataset, NmrDataset, PhaseAxis, PlotxApp}; +use num_complex::Complex64; +use plotx_io::{Dim, Domain, NmrData, NmrData2D, QuadMode}; +use plotx_processing::{ProcessingStep, StepKind, StepSource}; + +pub(super) fn time_domain_app() -> PlotxApp { + let points = (0..64) + .map(|index| { + let time = index as f64 / 2_000.0; + let envelope = (-18.0 * time).exp(); + let phase = std::f64::consts::TAU * 230.0 * time; + Complex64::from_polar(envelope, phase) + + Complex64::from_polar(0.35 * (-7.0 * time).exp(), phase * 0.43 + 0.2) + }) + .collect(); + let data = NmrData { + points, + domain: Domain::Time, + spectral_width_hz: 2_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 4.7, + nucleus: "1H".to_owned(), + source: "processing property test".to_owned(), + group_delay: 0.0, + }; + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Nmr(Box::new(NmrDataset::load(data)))); + app +} + +pub(super) fn states_2d_app(rows: usize, cols: usize) -> PlotxApp { + let dim = |nucleus: &str, width| Dim { + spectral_width_hz: width, + observe_freq_mhz: 400.0, + carrier_ppm: 4.7, + nucleus: nucleus.to_owned(), + group_delay: 0.0, + }; + let data = NmrData2D { + data: (0..rows * cols) + .map(|index| Complex64::new((index as f64 * 0.17).sin(), 0.2)) + .collect(), + rows, + cols, + domain: Domain::Time, + direct: dim("1H", 2_400.0), + indirect: dim("13C", 1_200.0), + quad: QuadMode::States, + indirect_conjugate: false, + experiment: Some("hsqc".to_owned()), + pseudo_axis: None, + diffusion: None, + nus: None, + source: "States property test".to_owned(), + }; + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data)))); + app +} + +pub(super) fn target_for_axis( + app: &PlotxApp, + axis: PhaseAxis, + accepts: impl Fn(&StepKind) -> bool, +) -> TargetRef { + let Dataset::Nmr2D(dataset) = &app.doc.datasets[0] else { + panic!("the fixture owns a 2D NMR dataset"); + }; + let pipeline = match axis { + PhaseAxis::F1 => &dataset.params.f1, + PhaseAxis::F2 => &dataset.params.f2, + PhaseAxis::Direct => panic!("a 2D fixture has no Direct axis"), + }; + let step = pipeline + .steps + .iter() + .find(|step| accepts(&step.kind)) + .expect("the requested axis step exists"); + TargetRef { + resource: ResourceRef::from(dataset.resource_id), + component: Some(ComponentRef::ProcessingStep(step.id)), + } +} + +pub(super) fn target_for(app: &PlotxApp, accepts: impl Fn(&StepKind) -> bool) -> TargetRef { + let Dataset::Nmr(dataset) = &app.doc.datasets[0] else { + panic!("the fixture owns a 1D NMR dataset"); + }; + let step = dataset + .pipeline + .steps + .iter() + .find(|step| accepts(&step.kind)) + .expect("the requested processing step exists"); + TargetRef { + resource: ResourceRef::from(dataset.resource_id), + component: Some(ComponentRef::ProcessingStep(step.id)), + } +} + +pub(super) fn add_step(app: &mut PlotxApp, kind: StepKind) -> TargetRef { + let Dataset::Nmr(dataset) = &mut app.doc.datasets[0] else { + panic!("the fixture owns a 1D NMR dataset"); + }; + let id = dataset.allocate_step_id(); + dataset + .pipeline + .steps + .push(ProcessingStep::new(id, kind, StepSource::User)); + TargetRef { + resource: ResourceRef::from(dataset.resource_id), + component: Some(ComponentRef::ProcessingStep(id)), + } +} + +pub(super) fn step<'a>(app: &'a PlotxApp, target: &TargetRef) -> &'a ProcessingStep { + let Some(ComponentRef::ProcessingStep(id)) = target.component else { + panic!("the target names a processing step"); + }; + let Dataset::Nmr(dataset) = &app.doc.datasets[0] else { + panic!("the fixture owns a 1D NMR dataset"); + }; + dataset + .pipeline + .steps + .iter() + .find(|step| step.id == id) + .expect("the stable step id resolves") +} + +pub(super) fn step_mut<'a>(app: &'a mut PlotxApp, target: &TargetRef) -> &'a mut ProcessingStep { + let Some(ComponentRef::ProcessingStep(id)) = target.component else { + panic!("the target names a processing step"); + }; + let Dataset::Nmr(dataset) = &mut app.doc.datasets[0] else { + panic!("the fixture owns a 1D NMR dataset"); + }; + dataset + .pipeline + .steps + .iter_mut() + .find(|step| step.id == id) + .expect("the stable step id resolves") +} + +pub(super) fn spectrum(app: &PlotxApp) -> (Vec, Vec) { + let Dataset::Nmr(dataset) = &app.doc.datasets[0] else { + panic!("the fixture owns a 1D NMR dataset"); + }; + ( + dataset.spectrum.values.clone(), + dataset.spectrum.ppm.clone(), + ) +} diff --git a/crates/core/src/properties/provider.rs b/crates/core/src/properties/provider.rs index c6c5d2c5..a9600f1d 100644 --- a/crates/core/src/properties/provider.rs +++ b/crates/core/src/properties/provider.rs @@ -30,7 +30,7 @@ pub(crate) trait PropertyProvider: Sync { app: &PlotxApp, transaction: &mut PropertyTransaction, address: &PropertyAddress, - operation: EditOp, + operation: &EditOp<'_>, ) -> Result<(), PropertyError>; /// Return the value one canvas or panel label should show for this exact diff --git a/crates/core/src/properties/provider_tests.rs b/crates/core/src/properties/provider_tests.rs index 20d76b34..fa9fbdb5 100644 --- a/crates/core/src/properties/provider_tests.rs +++ b/crates/core/src/properties/provider_tests.rs @@ -41,6 +41,55 @@ fn document_typography_is_addressable_without_a_canvas_object() { assert_eq!(app.doc.style_library.figure_typography.tick_pt, 9.5); } +#[test] +fn every_document_typography_property_resets_to_its_declared_default() { + let mut app = PlotxApp::new(); + let target = app.document_target(); + for (property, changed) in [ + (typography::TICK_PT, 12.0), + (typography::LABEL_PT, 13.0), + (typography::TITLE_PT, 14.0), + ] { + let commit = app + .plan_property_write( + property, + std::slice::from_ref(&target), + &PropertyValue::Float(changed), + ) + .expect("typography write plans"); + app.commit_property(commit); + let reset = app + .plan_property_reset(property, std::slice::from_ref(&target)) + .expect("typography reset plans"); + assert_eq!(reset.applied.len(), 1, "{property}"); + app.commit_property(reset); + let resolved = app + .resolve_property(&PropertyAddress::new(target.clone(), property)) + .expect("typography resolves after reset"); + assert_eq!(resolved.value.uniform(), resolved.default_value.as_ref()); + } +} + +#[test] +fn all_three_typography_sizes_share_the_declared_point_schema() { + for property in [ + typography::TICK_PT, + typography::LABEL_PT, + typography::TITLE_PT, + ] { + let definition = definition(property).expect("typography is registered"); + assert_eq!( + definition.value_schema, + ValueSchema::Float { + bounds: FloatBounds::inclusive(1.0, 72.0), + display: FloatDisplay::Linear("pt"), + drag_step: Some(0.25), + } + ); + assert_eq!(definition.tier, Tier::Essential); + } +} + /// A write of the value already in typed storage is not an applied edit. The /// caller gets an explicit skip, and the empty composite cannot create a fake /// undo/revision entry. diff --git a/crates/core/src/properties/readout.rs b/crates/core/src/properties/readout.rs index 8df3ff7b..70f4b737 100644 --- a/crates/core/src/properties/readout.rs +++ b/crates/core/src/properties/readout.rs @@ -32,10 +32,21 @@ use plotx_figure::{ContourBasePolicy, SeriesEncoding, UnitInterval}; /// Most settings need only their resolved scalar. Providers with a value whose /// meaning depends on cached scientific state can return a richer variant /// without teaching the service which encoding owns it. -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub enum PropertyReadout { Value(PropertyValue), ContourBase(ContourBaseReadout), + ZeroFillTarget(ZeroFillTargetReadout), + /// The addressed phase step's fractional pivot projected onto its axis. + PhasePivotPpm { + ppm: f64, + }, +} + +/// The FFT length produced by one addressed zero-fill step. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ZeroFillTargetReadout { + pub points: usize, } /// Turn an ordinary resolved value into a readout. @@ -47,12 +58,15 @@ pub(crate) fn uniform_readout( resolved: ResolvedProperty, ) -> Result { let property = resolved.address.definition; - let value = resolved.value.uniform().copied().ok_or_else(|| { - PropertyError::NotApplicable(format!( - "{} has no single value to show in a readout", - property.as_str() - )) - })?; + let value = match resolved.value { + super::AggregateValue::Uniform(value) => value, + super::AggregateValue::Mixed | super::AggregateValue::Unavailable => { + return Err(PropertyError::NotApplicable(format!( + "{} has no single value to show in a readout", + property.as_str() + ))); + } + }; Ok(PropertyReadout::Value(value)) } diff --git a/crates/core/src/properties/readout_tests.rs b/crates/core/src/properties/readout_tests.rs index 195ed50d..11bd87ce 100644 --- a/crates/core/src/properties/readout_tests.rs +++ b/crates/core/src/properties/readout_tests.rs @@ -36,6 +36,12 @@ fn contour_readout(app: &PlotxApp, target: &crate::automation::TargetRef) -> Con { PropertyReadout::ContourBase(readout) => readout, PropertyReadout::Value(value) => panic!("expected a contour payload, got {value:?}"), + PropertyReadout::ZeroFillTarget(readout) => { + panic!("expected a contour payload, got {readout:?}") + } + PropertyReadout::PhasePivotPpm { ppm } => { + panic!("expected a contour payload, got pivot {ppm}") + } } } diff --git a/crates/core/src/properties/reference.rs b/crates/core/src/properties/reference.rs new file mode 100644 index 00000000..97b5d734 --- /dev/null +++ b/crates/core/src/properties/reference.rs @@ -0,0 +1,157 @@ +//! Dataset-owned chemical-shift reference-step properties. + +use super::processing_common::{ + no_step_gesture, property_definition, step_context, step_mut, wrong_kind, +}; +use super::provider::PropertyProvider; +use super::{ + AggregateValue, Applicability, Availability, ComponentKind, DefaultPolicy, EditOp, FloatBounds, + FloatDisplay, PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, PropertyId, + PropertyTransaction, PropertyValue, ResolvedProperty, ResolvedSchema, ScopeKind, Tier, + ValueCopies, ValueSchema, +}; +use crate::state::PlotxApp; +use plotx_processing::{ReferenceParams, StepKind}; + +pub const AT_PPM: PropertyId = PropertyId("dataset.processing.reference.at_ppm"); +pub const TARGET_PPM: PropertyId = PropertyId("dataset.processing.reference.target_ppm"); + +const PPM_BOUNDS: FloatBounds = FloatBounds::inclusive(-f64::MAX, f64::MAX); +const PPM_STEP: f64 = 0.01; +const STEP: Applicability = Applicability::component(ComponentKind::ProcessingStep); + +pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ + PropertyDefinition { + id: AT_PPM, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::Float { + bounds: PPM_BOUNDS, + display: FloatDisplay::Linear("ppm"), + drag_step: Some(PPM_STEP), + }, + access: PropertyAccess::ReadWrite, + applicability: STEP, + default_policy: DefaultPolicy::None, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Reference source position", + canonical_aliases: &["reference at ppm", "source chemical shift"], + }, + PropertyDefinition { + id: TARGET_PPM, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::Float { + bounds: PPM_BOUNDS, + display: FloatDisplay::Linear("ppm"), + drag_step: Some(PPM_STEP), + }, + access: PropertyAccess::ReadWrite, + applicability: STEP, + default_policy: DefaultPolicy::None, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Reference target position", + canonical_aliases: &["reference target ppm", "target chemical shift"], + }, +]; + +pub(crate) struct ReferenceProvider; + +pub(crate) static PROVIDER: ReferenceProvider = ReferenceProvider; + +impl PropertyProvider for ReferenceProvider { + fn definitions(&self) -> &'static [PropertyDefinition] { + DEFINITIONS + } + + fn read( + &self, + app: &PlotxApp, + address: &PropertyAddress, + ) -> Result { + let definition = property_definition(address.definition)?; + let context = step_context(app, address, definition, |kind| { + matches!(kind, StepKind::Reference(_)) + })?; + let StepKind::Reference(current) = context.step.kind else { + unreachable!("the shared context checked the step kind"); + }; + Ok(ResolvedProperty { + address: address.clone(), + modified: None, + value: AggregateValue::Uniform(value_of(definition, current)?), + default_value: None, + availability: Availability::Editable, + schema: ResolvedSchema::Float { + bounds: PPM_BOUNDS, + display: FloatDisplay::Linear("ppm"), + }, + }) + } + + fn edit( + &self, + app: &PlotxApp, + transaction: &mut PropertyTransaction, + address: &PropertyAddress, + operation: &EditOp<'_>, + ) -> Result<(), PropertyError> { + let definition = property_definition(address.definition)?; + let context = step_context(app, address, definition, |kind| { + matches!(kind, StepKind::Reference(_)) + })?; + let StepKind::Reference(_) = context.step.kind else { + unreachable!("the shared context checked the step kind"); + }; + let value = match operation { + EditOp::Set(value) => checked_value(definition, value)?, + EditOp::Reset => { + return Err(PropertyError::NotApplicable( + "User-added reference steps have no factory setting to reset to.".to_owned(), + )); + } + EditOp::Step(_) => return Err(no_step_gesture(definition)), + }; + let state = transaction.processing_state(app, context.dataset_id)?; + let step = step_mut(state, context.step.id, &address.target)?; + let StepKind::Reference(current) = &mut step.kind else { + return Err(PropertyError::NotApplicable( + "the addressed step is no longer a reference step".to_owned(), + )); + }; + match (definition.id, value) { + (AT_PPM, PropertyValue::Float(value)) => current.at_ppm = value, + (TARGET_PPM, PropertyValue::Float(value)) => current.target_ppm = value, + (_, value) => { + return Err(wrong_kind( + definition, + &value, + "the declared reference value", + )); + } + } + Ok(()) + } +} + +fn value_of( + definition: &'static PropertyDefinition, + current: ReferenceParams, +) -> Result { + match definition.id { + AT_PPM => Ok(PropertyValue::Float(current.at_ppm)), + TARGET_PPM => Ok(PropertyValue::Float(current.target_ppm)), + _ => Err(PropertyError::UnknownProperty(definition.id.to_string())), + } +} + +fn checked_value( + definition: &'static PropertyDefinition, + value: &PropertyValue, +) -> Result { + let PropertyValue::Float(value) = value else { + return Err(wrong_kind(definition, value, "a number")); + }; + PPM_BOUNDS.check(definition.id, definition.canonical_label, *value)?; + Ok(PropertyValue::Float(*value)) +} diff --git a/crates/core/src/properties/reference_tests.rs b/crates/core/src/properties/reference_tests.rs new file mode 100644 index 00000000..5036e8e4 --- /dev/null +++ b/crates/core/src/properties/reference_tests.rs @@ -0,0 +1,49 @@ +use super::processing_test_support::{add_step, step, time_domain_app}; +use super::*; +use plotx_processing::{ReferenceParams, StepKind}; + +#[test] +fn reference_properties_address_the_user_step_by_stable_id() { + let mut app = time_domain_app(); + let target = add_step( + &mut app, + StepKind::Reference(ReferenceParams { + at_ppm: 4.7, + target_ppm: 0.0, + }), + ); + let commit = app + .plan_property_write( + reference::TARGET_PPM, + std::slice::from_ref(&target), + &PropertyValue::Float(1.25), + ) + .expect("the target ppm plans"); + app.commit_property(commit); + assert!(matches!( + step(&app, &target).kind, + StepKind::Reference(ReferenceParams { target_ppm, .. }) + if (target_ppm - 1.25).abs() < f64::EPSILON + )); +} + +#[test] +fn reference_reset_honestly_skips_a_user_only_step() { + let mut app = time_domain_app(); + let target = add_step( + &mut app, + StepKind::Reference(ReferenceParams { + at_ppm: 4.7, + target_ppm: 0.0, + }), + ); + assert_eq!( + definition(reference::AT_PPM).unwrap().default_policy, + DefaultPolicy::None + ); + let reset = app + .plan_property_reset(reference::AT_PPM, std::slice::from_ref(&target)) + .unwrap(); + assert!(reset.applied.is_empty()); + assert_eq!(reset.skipped.len(), 1); +} diff --git a/crates/core/src/properties/service.rs b/crates/core/src/properties/service.rs index a00a8544..537d9a86 100644 --- a/crates/core/src/properties/service.rs +++ b/crates/core/src/properties/service.rs @@ -6,8 +6,8 @@ //! planner for every property family. use super::target::{ - app_target, canvas_object, document_target, processing_step_targets, series_context_unchecked, - series_targets, + app_target, canvas_object, canvas_target, document_target, processing_step_targets, + series_context_unchecked, series_targets, }; use super::{ AggregateValue, ComponentKind, EditOp, EncodingKind, PropertyAccess, PropertyAddress, @@ -18,7 +18,7 @@ use super::{ use crate::actions::{Action, DatasetProcessingState, PendingPropertyGesture}; use crate::automation::{ComponentRef, ResourceRef, TargetRef, canvas_object_ref}; use crate::state::{ - DatasetId, ObjectId, PlotxApp, PresentationProfile, RequestedChart, default_encoding, + CanvasId, DatasetId, ObjectId, PlotxApp, PresentationProfile, RequestedChart, default_encoding, field_peak_magnitude, }; use plotx_figure::SeriesEncoding; @@ -31,6 +31,11 @@ impl PlotxApp { document_target() } + /// The stable resource target of one canvas. + pub fn canvas_target(&self, id: CanvasId) -> TargetRef { + canvas_target(id) + } + /// The singleton target for application-owned persistent preferences. pub fn app_target(&self) -> TargetRef { app_target() @@ -50,6 +55,13 @@ impl PlotxApp { }) } + /// The component-free property target of one canvas object. + pub fn object_target(&self, canvas: usize, object: ObjectId) -> Option { + let canvas_id = self.doc.canvases.get(canvas)?.resource_id; + self.doc.canvases[canvas].object(object)?; + Some(TargetRef::resource(canvas_object_ref(canvas_id, object))) + } + /// Every series of one plot object, in binding order. pub fn series_targets(&self, canvas: usize, object: ObjectId) -> Vec { series_targets(self, canvas, object) @@ -153,7 +165,7 @@ impl PlotxApp { targets: &[TargetRef], value: &PropertyValue, ) -> Result { - self.plan_edit(property, targets, EditOp::Set(*value)) + self.plan_edit(property, targets, &EditOp::Set(value)) } /// Move a property along the scale its provider owns. @@ -163,7 +175,7 @@ impl PlotxApp { targets: &[TargetRef], step: PropertyStep, ) -> Result { - self.plan_edit(property, targets, EditOp::Step(step)) + self.plan_edit(property, targets, &EditOp::Step(step)) } /// Reset one property in each target's current context. @@ -172,7 +184,46 @@ impl PlotxApp { property: PropertyId, targets: &[TargetRef], ) -> Result { - self.plan_edit(property, targets, EditOp::Reset) + self.plan_edit(property, targets, &EditOp::Reset) + } + + /// Reset a related group through one transaction and therefore one undo + /// action. Each member still dispatches through its catalog provider. + pub fn plan_property_resets( + &self, + properties: &[PropertyId], + targets: &[TargetRef], + ) -> Result { + let mut transaction = PropertyTransaction::default(); + let mut skipped = Vec::new(); + let mut applied = Vec::new(); + for &property in properties { + let definition = self.definition_for(property)?; + if definition.access == PropertyAccess::ReadOnly { + return Err(PropertyError::ReadOnly(property)); + } + let provider = provider_for(property) + .ok_or_else(|| PropertyError::UnknownProperty(property.as_str().to_owned()))?; + for target in targets { + let address = PropertyAddress::new(target.clone(), property); + transaction.begin_target(); + match provider.edit(self, &mut transaction, &address, &EditOp::Reset) { + Ok(()) if transaction.target_changed() => applied.push(address), + Ok(()) => skipped.push(PropertySkip::new( + target.clone(), + SkipReason::AlreadyAtValue, + format!("{} already has that value", definition.canonical_label), + )), + Err(error) if skipped_target(&error) => { + transaction.rollback_target(); + skipped.push(PropertySkip::from_error(target.clone(), &error)); + } + Err(error) => return Err(error), + } + } + } + transaction.ensure_single_storage()?; + Ok(transaction.into_commit(self, applied, skipped)) } /// Reset a complete encoding through its existing default factory. @@ -263,7 +314,7 @@ impl PlotxApp { } } transaction.ensure_single_storage()?; - Ok(transaction.into_commit(applied, skipped)) + Ok(transaction.into_commit(self, applied, skipped)) } /// Execute a validated commit and report the number of targets that were @@ -287,6 +338,24 @@ impl PlotxApp { if let Some(action) = commit.document_action { self.execute_property_action(action); } + let direct_changed = !commit.canvas_direct.is_empty(); + for edit in commit.canvas_direct { + match edit { + super::transaction::CanvasDirectEdit::ShowGrid { canvas, show } => { + if let Some(index) = self.doc.canvas_index(canvas) { + self.set_show_grid(index, show); + } + } + super::transaction::CanvasDirectEdit::AutoHeight { canvas, enabled } => { + if let Some(index) = self.doc.canvas_index(canvas) { + self.set_canvas_auto_height(index, enabled); + } + } + } + } + if direct_changed { + self.doc.automation_revision = self.doc.automation_revision.saturating_add(1); + } if let Some(settings) = commit.app_preferences { self.apply_settings(settings); persist(self); @@ -405,7 +474,7 @@ impl PlotxApp { &self, property: PropertyId, targets: &[TargetRef], - operation: EditOp, + operation: &EditOp<'_>, ) -> Result { let definition = self.definition_for(property)?; if definition.access == PropertyAccess::ReadOnly { @@ -437,7 +506,7 @@ impl PlotxApp { } } transaction.ensure_single_storage()?; - Ok(transaction.into_commit(applied, skipped)) + Ok(transaction.into_commit(self, applied, skipped)) } fn definition_for( diff --git a/crates/core/src/properties/smooth.rs b/crates/core/src/properties/smooth.rs new file mode 100644 index 00000000..b4c15382 --- /dev/null +++ b/crates/core/src/properties/smooth.rs @@ -0,0 +1,421 @@ +//! Dataset-owned smoothing-step properties. + +use super::processing_common::{ + no_step_gesture, property_definition, spectrum_before_step, step_context, step_mut, wrong_kind, +}; +use super::provider::PropertyProvider; +use super::{ + AggregateValue, Applicability, Availability, ComponentKind, DefaultPolicy, EditOp, EnumVariant, + PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, PropertyId, + PropertyTransaction, PropertyValue, ResolvedProperty, ResolvedSchema, ScopeKind, Tier, + ValueCopies, ValueSchema, +}; +use crate::state::PlotxApp; +use plotx_processing::{SmoothMethod, StepKind}; + +pub const METHOD: PropertyId = PropertyId("dataset.processing.smooth.method"); +pub const WINDOW: PropertyId = PropertyId("dataset.processing.smooth.window"); +pub const POLYNOMIAL_ORDER: PropertyId = PropertyId("dataset.processing.smooth.polynomial_order"); + +pub const MOVING_AVERAGE: &str = "moving_average"; +pub const SAVITZKY_GOLAY: &str = "savitzky_golay"; + +const METHODS: &[EnumVariant] = &[ + EnumVariant::new(MOVING_AVERAGE, "Moving average"), + EnumVariant::new(SAVITZKY_GOLAY, "Polynomial (Savitzky-Golay)"), +]; +const WINDOW_MIN: i64 = 3; +const WINDOW_MAX: i64 = 201; +const ORDER_MIN: i64 = 1; +const ORDER_MAX: i64 = 8; +/// The old editor seeded order three. A small carried window lowers this seed +/// so `poly_order < window` is true immediately after switching methods. +pub const POLYNOMIAL_ORDER_SEED: u8 = 3; +/// Nine is the processing domain's smoothing default and is odd, inside +/// [`WINDOW_MIN`]–[`WINDOW_MAX`], and larger than the order seed. +pub const WINDOW_SEED: u16 = 9; +const STEP: Applicability = Applicability::component(ComponentKind::ProcessingStep); + +pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ + PropertyDefinition { + id: METHOD, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::Enum { variants: METHODS }, + access: PropertyAccess::ReadWrite, + applicability: STEP, + default_policy: DefaultPolicy::None, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Smoothing method", + canonical_aliases: &["moving average", "Savitzky-Golay", "smoothing"], + }, + PropertyDefinition { + id: WINDOW, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::SteppedInt { + min: WINDOW_MIN, + max: WINDOW_MAX, + step: 2, + drag_step: 0.2, + }, + access: PropertyAccess::ReadWrite, + applicability: STEP, + default_policy: DefaultPolicy::None, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Smoothing window", + canonical_aliases: &["window points", "smoothing points"], + }, + PropertyDefinition { + id: POLYNOMIAL_ORDER, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::IntWithDrag { + min: ORDER_MIN, + max: ORDER_MAX, + drag_step: 0.1, + }, + access: PropertyAccess::ReadWrite, + applicability: STEP, + default_policy: DefaultPolicy::None, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Smoothing polynomial order", + canonical_aliases: &["Savitzky-Golay order", "polynomial degree"], + }, +]; + +pub(crate) struct SmoothProvider; + +pub(crate) static PROVIDER: SmoothProvider = SmoothProvider; + +impl PropertyProvider for SmoothProvider { + fn definitions(&self) -> &'static [PropertyDefinition] { + DEFINITIONS + } + + fn read( + &self, + app: &PlotxApp, + address: &PropertyAddress, + ) -> Result { + let definition = property_definition(address.definition)?; + let context = step_context(app, address, definition, |kind| { + matches!(kind, StepKind::Smooth(_)) + })?; + let StepKind::Smooth(current) = context.step.kind else { + unreachable!("the shared context checked the step kind"); + }; + let point_count = spectrum_before_step(&context) + .map(|spectrum| spectrum.values.len()) + .ok_or_else(|| smoothing_unavailable("its input spectrum is unavailable"))?; + Ok(ResolvedProperty { + address: address.clone(), + modified: None, + value: AggregateValue::Uniform(value_of(definition, current)?), + default_value: None, + availability: Availability::Editable, + schema: schema_for(definition, current, point_count)?, + }) + } + + fn edit( + &self, + app: &PlotxApp, + transaction: &mut PropertyTransaction, + address: &PropertyAddress, + operation: &EditOp<'_>, + ) -> Result<(), PropertyError> { + let definition = property_definition(address.definition)?; + let context = step_context(app, address, definition, |kind| { + matches!(kind, StepKind::Smooth(_)) + })?; + let StepKind::Smooth(current) = context.step.kind else { + unreachable!("the shared context checked the step kind"); + }; + let point_count = spectrum_before_step(&context) + .map(|spectrum| spectrum.values.len()) + .ok_or_else(|| smoothing_unavailable("its input spectrum is unavailable"))?; + let value = match operation { + EditOp::Set(value) => checked_value(definition, current, point_count, value)?, + EditOp::Reset => { + return Err(PropertyError::NotApplicable( + "User-added smoothing steps have no factory setting to reset to.".to_owned(), + )); + } + EditOp::Step(_) => return Err(no_step_gesture(definition)), + }; + let state = transaction.processing_state(app, context.dataset_id)?; + let step = step_mut(state, context.step.id, &address.target)?; + let StepKind::Smooth(current) = &mut step.kind else { + return Err(PropertyError::NotApplicable( + "the addressed step is no longer a smoothing step".to_owned(), + )); + }; + write(definition, current, value) + } +} + +fn value_of( + definition: &'static PropertyDefinition, + current: SmoothMethod, +) -> Result { + match (definition.id, current) { + (METHOD, value) => Ok(PropertyValue::Enum(method_of(value))), + (WINDOW, SmoothMethod::MovingAverage { window }) + | (WINDOW, SmoothMethod::SavitzkyGolay { window, .. }) => { + Ok(PropertyValue::Int(i64::from(window))) + } + (POLYNOMIAL_ORDER, SmoothMethod::SavitzkyGolay { poly_order, .. }) => { + Ok(PropertyValue::Int(i64::from(poly_order))) + } + _ => Err(polynomial_unavailable(current)), + } +} + +fn schema_for( + definition: &'static PropertyDefinition, + current: SmoothMethod, + point_count: usize, +) -> Result { + let window_max = maximum_odd_window(point_count)?; + match definition.id { + METHOD => Ok(ResolvedSchema::Enum { + variants: METHODS.iter().collect(), + }), + WINDOW => { + let min = match current { + SmoothMethod::SavitzkyGolay { poly_order, .. } => minimum_odd_window(poly_order), + SmoothMethod::MovingAverage { .. } => WINDOW_MIN, + }; + Ok(ResolvedSchema::SteppedInt { + min, + max: window_max, + step: 2, + drag_step: 0.2, + unit: "points", + }) + } + POLYNOMIAL_ORDER => { + let SmoothMethod::SavitzkyGolay { window, .. } = current else { + return Err(polynomial_unavailable(current)); + }; + Ok(ResolvedSchema::IntWithDrag { + min: ORDER_MIN, + max: ORDER_MAX.min(i64::from(window.min(window_max as u16)) - 1), + drag_step: 0.1, + unit: "", + }) + } + _ => Err(PropertyError::UnknownProperty(definition.id.to_string())), + } +} + +fn checked_value( + definition: &'static PropertyDefinition, + current: SmoothMethod, + point_count: usize, + value: &PropertyValue, +) -> Result { + let window_max = maximum_odd_window(point_count)?; + match (definition.id, value) { + (METHOD, PropertyValue::Enum(value)) if variant(value).is_some() => { + validate_current_window(current, window_max)?; + Ok(PropertyValue::Enum(value)) + } + (METHOD, PropertyValue::Enum(value)) => Err(PropertyError::InvalidValue { + property: definition.id, + message: format!("'{value}' is not a smoothing method"), + }), + (METHOD, value) => Err(wrong_kind(definition, value, "a smoothing method")), + (WINDOW, PropertyValue::Int(value)) => { + let min = match current { + SmoothMethod::SavitzkyGolay { poly_order, .. } => minimum_odd_window(poly_order), + SmoothMethod::MovingAverage { .. } => WINDOW_MIN, + }; + if *value < min || *value > window_max || value % 2 == 0 { + return Err(PropertyError::InvalidValue { + property: definition.id, + message: format!( + "smoothing window {value} is out of range: it must be an odd value between {min} and {window_max} for this {point_count}-point spectrum" + ), + }); + } + Ok(PropertyValue::Int(*value)) + } + (POLYNOMIAL_ORDER, PropertyValue::Int(value)) => { + let SmoothMethod::SavitzkyGolay { window, .. } = current else { + return Err(polynomial_unavailable(current)); + }; + let effective_window = i64::from(window).min(window_max); + let max = ORDER_MAX.min(effective_window - 1); + if *value < ORDER_MIN || *value > max { + return Err(PropertyError::InvalidValue { + property: definition.id, + message: format!( + "smoothing polynomial order {value} is out of range for window {window}: it must be between {ORDER_MIN} and {max}, and strictly less than the window" + ), + }); + } + Ok(PropertyValue::Int(*value)) + } + (WINDOW | POLYNOMIAL_ORDER, value) => Err(wrong_kind(definition, value, "an integer")), + (_, _) => Err(PropertyError::UnknownProperty(definition.id.to_string())), + } +} + +fn write( + definition: &'static PropertyDefinition, + current: &mut SmoothMethod, + value: PropertyValue, +) -> Result<(), PropertyError> { + match (definition.id, value) { + (METHOD, PropertyValue::Enum(value)) => { + let window = window_of(*current); + *current = match variant(value) { + Some(SmoothVariant::MovingAverage) => SmoothMethod::MovingAverage { window }, + Some(SmoothVariant::SavitzkyGolay) => SmoothMethod::SavitzkyGolay { + window, + poly_order: match *current { + SmoothMethod::SavitzkyGolay { poly_order, .. } => { + poly_order.min((window - 1) as u8) + } + _ => seed_order(window), + }, + }, + None => { + return Err(PropertyError::InvalidValue { + property: definition.id, + message: format!("'{value}' is not a smoothing method"), + }); + } + }; + Ok(()) + } + (WINDOW, PropertyValue::Int(value)) => { + let window = u16::try_from(value).map_err(|_| PropertyError::InvalidValue { + property: definition.id, + message: format!( + "smoothing window {value} is out of range: it must be between {WINDOW_MIN} and {WINDOW_MAX}" + ), + })?; + match current { + SmoothMethod::MovingAverage { + window: current_window, + } + | SmoothMethod::SavitzkyGolay { + window: current_window, + .. + } => *current_window = window, + } + Ok(()) + } + (POLYNOMIAL_ORDER, PropertyValue::Int(value)) => { + let SmoothMethod::SavitzkyGolay { poly_order, .. } = current else { + return Err(polynomial_unavailable(*current)); + }; + *poly_order = u8::try_from(value).map_err(|_| PropertyError::InvalidValue { + property: definition.id, + message: format!( + "smoothing polynomial order {value} is out of range: it must be between {ORDER_MIN} and {ORDER_MAX}" + ), + })?; + Ok(()) + } + (_, value) => Err(wrong_kind( + definition, + &value, + "the declared smoothing value", + )), + } +} + +fn minimum_odd_window(poly_order: u8) -> i64 { + let minimum = (i64::from(poly_order) + 1).max(WINDOW_MIN); + if minimum % 2 == 0 { + minimum + 1 + } else { + minimum + } +} + +fn seed_order(window: u16) -> u8 { + POLYNOMIAL_ORDER_SEED + .min((window.saturating_sub(1)) as u8) + .max(1) +} + +fn maximum_odd_window(point_count: usize) -> Result { + let capped = point_count.min(WINDOW_MAX as usize); + let maximum = if capped.is_multiple_of(2) { + capped.saturating_sub(1) + } else { + capped + }; + if maximum < WINDOW_MIN as usize { + return Err(smoothing_unavailable( + "it needs an input spectrum with at least 3 points", + )); + } + Ok(maximum as i64) +} + +fn validate_current_window(current: SmoothMethod, window_max: i64) -> Result<(), PropertyError> { + let window = i64::from(window_of(current)); + let min = match current { + SmoothMethod::SavitzkyGolay { poly_order, .. } => minimum_odd_window(poly_order), + SmoothMethod::MovingAverage { .. } => WINDOW_MIN, + }; + if window < min || window > window_max || window % 2 == 0 { + return Err(PropertyError::InvalidValue { + property: METHOD, + message: format!( + "stored smoothing window {window} is out of range: it must be an odd value between {min} and {window_max}; correct the window before switching methods" + ), + }); + } + Ok(()) +} + +fn smoothing_unavailable(reason: &str) -> PropertyError { + PropertyError::NotApplicable(format!("Smoothing is unavailable because {reason}.")) +} + +fn window_of(method: SmoothMethod) -> u16 { + match method { + SmoothMethod::MovingAverage { window } | SmoothMethod::SavitzkyGolay { window, .. } => { + window + } + } +} + +#[derive(Clone, Copy)] +enum SmoothVariant { + MovingAverage, + SavitzkyGolay, +} + +fn variant(value: &str) -> Option { + match value { + MOVING_AVERAGE => Some(SmoothVariant::MovingAverage), + SAVITZKY_GOLAY => Some(SmoothVariant::SavitzkyGolay), + _ => None, + } +} + +fn method_of(method: SmoothMethod) -> &'static str { + match method { + SmoothMethod::MovingAverage { .. } => MOVING_AVERAGE, + SmoothMethod::SavitzkyGolay { .. } => SAVITZKY_GOLAY, + } +} + +fn polynomial_unavailable(current: SmoothMethod) -> PropertyError { + PropertyError::NotApplicable(format!( + "Smoothing polynomial order is available only with Polynomial (Savitzky-Golay); this step uses {}", + METHODS + .iter() + .find(|variant| variant.id == method_of(current)) + .map(|variant| variant.canonical_label) + .unwrap_or("an unknown method") + )) +} diff --git a/crates/core/src/properties/smooth_tests.rs b/crates/core/src/properties/smooth_tests.rs new file mode 100644 index 00000000..9976dbfc --- /dev/null +++ b/crates/core/src/properties/smooth_tests.rs @@ -0,0 +1,168 @@ +use super::processing_test_support::{add_step, step, time_domain_app}; +use super::*; +use plotx_processing::{SmoothMethod, StepKind}; + +#[test] +fn smoothing_rejects_even_windows_and_orders_that_do_not_fit_the_actual_window() { + let mut app = time_domain_app(); + let target = add_step(&mut app, StepKind::Smooth(SmoothMethod::DEFAULT)); + + let even = app + .plan_property_write( + smooth::WINDOW, + std::slice::from_ref(&target), + &PropertyValue::Int(10), + ) + .expect_err("an even Savitzky-Golay window is invalid"); + let message = even.to_string(); + assert!(message.contains("10"), "{message}"); + assert!(message.contains("odd value"), "{message}"); + + let window = app + .plan_property_write( + smooth::WINDOW, + std::slice::from_ref(&target), + &PropertyValue::Int(5), + ) + .expect("an odd window plans"); + app.commit_property(window); + let order = app + .plan_property_write( + smooth::POLYNOMIAL_ORDER, + std::slice::from_ref(&target), + &PropertyValue::Int(5), + ) + .expect_err("the polynomial order must be below the current window"); + let message = order.to_string(); + assert!(message.contains("order 5"), "{message}"); + assert!(message.contains("window 5"), "{message}"); + assert!(message.contains("between 1 and 4"), "{message}"); +} + +#[test] +fn switching_to_savitzky_golay_seeds_an_order_admitted_by_its_schema() { + let mut app = time_domain_app(); + let target = add_step( + &mut app, + StepKind::Smooth(SmoothMethod::MovingAverage { window: 3 }), + ); + let commit = app + .plan_property_write( + smooth::METHOD, + std::slice::from_ref(&target), + &PropertyValue::Enum(smooth::SAVITZKY_GOLAY), + ) + .expect("the method switch plans"); + app.commit_property(commit); + let StepKind::Smooth(SmoothMethod::SavitzkyGolay { window, poly_order }) = + step(&app, &target).kind + else { + panic!("the method switched"); + }; + assert_eq!((window, poly_order), (3, 2)); + let schema = app + .resolve_property(&PropertyAddress::new(target, smooth::POLYNOMIAL_ORDER)) + .expect("the seeded order resolves") + .schema; + assert!(matches!( + schema, + ResolvedSchema::IntWithDrag { + min: 1, + max: 2, + drag_step: 0.1, + unit: "" + } + )); +} + +#[test] +fn smoothing_bounds_follow_the_real_spectrum_after_binning() { + let mut app = time_domain_app(); + let bin = add_step( + &mut app, + StepKind::Bin(plotx_processing::BinParams { + width: 0.5, + method: plotx_processing::BinMethod::Mean, + }), + ); + let target = add_step(&mut app, StepKind::Smooth(SmoothMethod::DEFAULT)); + let before = super::processing_common::spectrum_before_step( + &super::processing_common::step_context( + &app, + &PropertyAddress::new(target.clone(), smooth::WINDOW), + definition(smooth::WINDOW).unwrap(), + |kind| matches!(kind, StepKind::Smooth(_)), + ) + .unwrap(), + ) + .unwrap(); + assert!(before.values.len() < 64); + let expected_max = if before.values.len().is_multiple_of(2) { + before.values.len() - 1 + } else { + before.values.len() + } as i64; + let resolved = app + .resolve_property(&PropertyAddress::new(target.clone(), smooth::WINDOW)) + .unwrap(); + assert!(matches!( + resolved.schema, + ResolvedSchema::SteppedInt { + max, + unit: "points", + .. + } if max == expected_max + )); + let error = app + .plan_property_write( + smooth::WINDOW, + std::slice::from_ref(&target), + &PropertyValue::Int(expected_max + 2), + ) + .expect_err("the catalog must reject a window the kernel would clamp"); + let message = error.to_string(); + assert!( + message.contains(&(expected_max + 2).to_string()), + "{message}" + ); + assert!(message.contains(&expected_max.to_string()), "{message}"); + assert!(step(&app, &bin).enabled); +} + +#[test] +fn method_switch_rejects_an_invalid_stored_window_instead_of_rewriting_it() { + let mut app = time_domain_app(); + let target = add_step( + &mut app, + StepKind::Smooth(SmoothMethod::MovingAverage { window: 8 }), + ); + let error = app + .plan_property_write( + smooth::METHOD, + std::slice::from_ref(&target), + &PropertyValue::Enum(smooth::SAVITZKY_GOLAY), + ) + .expect_err("an invalid persisted value must not be silently normalized"); + let message = error.to_string(); + assert!(message.contains("stored smoothing window 8"), "{message}"); + assert!(message.contains("odd value between 3 and 63"), "{message}"); + assert_eq!( + step(&app, &target).kind, + StepKind::Smooth(SmoothMethod::MovingAverage { window: 8 }) + ); +} + +#[test] +fn smoothing_reset_honestly_skips_a_user_only_step() { + let mut app = time_domain_app(); + let target = add_step(&mut app, StepKind::Smooth(SmoothMethod::DEFAULT)); + assert_eq!( + definition(smooth::WINDOW).unwrap().default_policy, + DefaultPolicy::None + ); + let reset = app + .plan_property_reset(smooth::WINDOW, std::slice::from_ref(&target)) + .unwrap(); + assert!(reset.applied.is_empty()); + assert_eq!(reset.skipped.len(), 1); +} diff --git a/crates/core/src/properties/step_enabled.rs b/crates/core/src/properties/step_enabled.rs new file mode 100644 index 00000000..dbce4718 --- /dev/null +++ b/crates/core/src/properties/step_enabled.rs @@ -0,0 +1,88 @@ +//! The enabled flag shared by every processing-step component. + +use super::processing_common::{ + no_factory_default, no_step_gesture, property_definition, step_context, step_mut, wrong_kind, +}; +use super::provider::PropertyProvider; +use super::{ + AggregateValue, Applicability, Availability, ComponentKind, DefaultPolicy, EditOp, + PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, PropertyId, + PropertyTransaction, PropertyValue, ResolvedProperty, ResolvedSchema, ScopeKind, Tier, + ValueCopies, ValueSchema, +}; +use crate::state::PlotxApp; + +pub const ENABLED: PropertyId = PropertyId("dataset.processing.step.enabled"); + +const STEP: Applicability = Applicability::component(ComponentKind::ProcessingStep); + +pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[PropertyDefinition { + id: ENABLED, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::Bool, + access: PropertyAccess::ReadWrite, + applicability: STEP, + default_policy: DefaultPolicy::ProcessingFactory, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Processing step enabled", + canonical_aliases: &["enable step", "disable step", "processing toggle"], +}]; + +pub(crate) struct StepEnabledProvider; + +pub(crate) static PROVIDER: StepEnabledProvider = StepEnabledProvider; + +impl PropertyProvider for StepEnabledProvider { + fn definitions(&self) -> &'static [PropertyDefinition] { + DEFINITIONS + } + + fn read( + &self, + app: &PlotxApp, + address: &PropertyAddress, + ) -> Result { + let definition = property_definition(address.definition)?; + let context = step_context(app, address, definition, |kind| { + !matches!(kind, plotx_processing::StepKind::Fft) + })?; + Ok(ResolvedProperty { + address: address.clone(), + modified: None, + value: AggregateValue::Uniform(PropertyValue::Bool(context.step.enabled)), + default_value: context + .factory + .as_ref() + .map(|factory| PropertyValue::Bool(factory.enabled)), + availability: Availability::Editable, + schema: ResolvedSchema::Bool, + }) + } + + fn edit( + &self, + app: &PlotxApp, + transaction: &mut PropertyTransaction, + address: &PropertyAddress, + operation: &EditOp<'_>, + ) -> Result<(), PropertyError> { + let definition = property_definition(address.definition)?; + let context = step_context(app, address, definition, |kind| { + !matches!(kind, plotx_processing::StepKind::Fft) + })?; + let value = match operation { + EditOp::Set(PropertyValue::Bool(value)) => *value, + EditOp::Set(value) => return Err(wrong_kind(definition, value, "true or false")), + EditOp::Reset => context + .factory + .as_ref() + .map(|factory| factory.enabled) + .ok_or_else(|| no_factory_default(definition))?, + EditOp::Step(_) => return Err(no_step_gesture(definition)), + }; + let state = transaction.processing_state(app, context.dataset_id)?; + step_mut(state, context.step.id, &address.target)?.enabled = value; + Ok(()) + } +} diff --git a/crates/core/src/properties/step_enabled_tests.rs b/crates/core/src/properties/step_enabled_tests.rs new file mode 100644 index 00000000..532b451c --- /dev/null +++ b/crates/core/src/properties/step_enabled_tests.rs @@ -0,0 +1,68 @@ +use super::processing_test_support::{add_step, step, target_for, time_domain_app}; +use super::*; +use plotx_processing::StepKind; + +#[test] +fn enabled_applies_to_any_processing_step_and_uses_the_typed_undo_path() { + let mut app = time_domain_app(); + let target = add_step(&mut app, StepKind::Reverse); + assert!(step(&app, &target).enabled); + + let commit = app + .plan_property_write( + step_enabled::ENABLED, + std::slice::from_ref(&target), + &PropertyValue::Bool(false), + ) + .expect("the cross-step flag plans"); + app.commit_property(commit); + assert!(!step(&app, &target).enabled); + app.undo(); + assert!(step(&app, &target).enabled); +} + +#[test] +fn a_user_step_has_no_invented_enabled_default() { + let app = time_domain_app(); + let mut app = app; + let target = add_step(&mut app, StepKind::Invert); + let resolved = app + .resolve_property(&PropertyAddress::new(target.clone(), step_enabled::ENABLED)) + .expect("the flag resolves"); + assert_eq!(resolved.default_value, None); + let reset = app + .plan_property_reset(step_enabled::ENABLED, std::slice::from_ref(&target)) + .expect("a missing factory default becomes a skip"); + assert!(reset.applied.is_empty()); + assert_eq!(reset.skipped.len(), 1); +} + +#[test] +fn a_factory_step_enabled_flag_resets_through_the_catalog() { + let mut app = time_domain_app(); + let target = target_for(&app, |kind| matches!(kind, StepKind::Phase(_))); + let changed = app + .plan_property_write( + step_enabled::ENABLED, + std::slice::from_ref(&target), + &PropertyValue::Bool(false), + ) + .unwrap(); + app.commit_property(changed); + let reset = app + .plan_property_reset(step_enabled::ENABLED, std::slice::from_ref(&target)) + .unwrap(); + assert_eq!(reset.applied.len(), 1); + app.commit_property(reset); + assert!(step(&app, &target).enabled); +} + +#[test] +fn fft_anchor_does_not_expose_a_no_op_enabled_property() { + let app = time_domain_app(); + let target = target_for(&app, |kind| matches!(kind, StepKind::Fft)); + assert!(matches!( + app.resolve_property(&PropertyAddress::new(target, step_enabled::ENABLED)), + Err(PropertyError::NotApplicable(message)) if message.contains("FFT") + )); +} diff --git a/crates/core/src/properties/target.rs b/crates/core/src/properties/target.rs index ce40baad..aeb5f47c 100644 --- a/crates/core/src/properties/target.rs +++ b/crates/core/src/properties/target.rs @@ -24,6 +24,10 @@ pub(crate) fn document_target() -> TargetRef { }) } +pub(crate) fn canvas_target(id: CanvasId) -> TargetRef { + TargetRef::resource(ResourceRef::from(id)) +} + pub(crate) fn app_target() -> TargetRef { TargetRef::resource(ResourceRef { id: crate::automation::APP_RESOURCE_ID.to_owned(), @@ -33,6 +37,31 @@ pub(crate) fn app_target() -> TargetRef { }) } +pub(crate) fn require_canvas_target( + app: &PlotxApp, + target: &TargetRef, + definition: &'static PropertyDefinition, +) -> Result { + 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_CANVAS { + return Err(PropertyError::NotApplicable(format!( + "{} belongs to a canvas, not {}", + definition.canonical_label, target.resource.id + ))); + } + let unknown = || PropertyError::UnknownTarget(target.resource.id.clone()); + let id = CanvasId::try_from(&target.resource).map_err(|_| unknown())?; + app.doc.canvas_index(id).ok_or_else(unknown)?; + Ok(id) +} + pub(crate) fn require_app_target( target: &TargetRef, definition: &'static PropertyDefinition, @@ -186,6 +215,52 @@ pub(crate) fn canvas_object( Ok((canvas, object)) } +pub(crate) fn require_plot_object_target( + app: &PlotxApp, + target: &TargetRef, + definition: &'static PropertyDefinition, +) -> Result<(usize, ObjectId), 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(), + }); + } + let (canvas, object) = canvas_object(app, &target.resource)?; + app.doc.canvases[canvas] + .object(object) + .and_then(|object| object.plot()) + .ok_or_else(|| { + PropertyError::NotApplicable(format!( + "{} belongs to a plot object", + definition.canonical_label + )) + })?; + Ok((canvas, object)) +} + +pub(crate) fn require_object_target( + app: &PlotxApp, + target: &TargetRef, + definition: &'static PropertyDefinition, +) -> Result<(usize, ObjectId), 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(), + }); + } + let (canvas, object) = canvas_object(app, &target.resource)?; + app.doc.canvases[canvas] + .object(object) + .ok_or_else(|| PropertyError::UnknownTarget(target.resource.id.clone()))?; + Ok((canvas, object)) +} + pub(crate) fn series_targets(app: &PlotxApp, canvas: usize, object: ObjectId) -> Vec { let Some(canvas_document) = app.doc.canvases.get(canvas) else { return Vec::new(); @@ -277,12 +352,33 @@ pub(crate) fn resolved_schema( ) -> ResolvedSchema { match definition.value_schema { ValueSchema::Bool => ResolvedSchema::Bool, - ValueSchema::Int { min, max } => ResolvedSchema::Int { min, max }, - ValueSchema::Float { bounds, log, .. } => ResolvedSchema::Float { - bounds, - log, + ValueSchema::Text => ResolvedSchema::Text, + ValueSchema::Int { min, max } => ResolvedSchema::Int { min, max, unit: "" }, + ValueSchema::IntWithDrag { + min, + max, + drag_step, + } => ResolvedSchema::IntWithDrag { + min, + max, + drag_step, + unit: "", + }, + ValueSchema::SteppedInt { + min, + max, + step, + drag_step, + } => ResolvedSchema::SteppedInt { + min, + max, + step, + drag_step, unit: "", }, + ValueSchema::Float { + bounds, display, .. + } => ResolvedSchema::Float { bounds, display }, ValueSchema::Enum { .. } => ResolvedSchema::Enum { variants: permitted_variants(&definition.value_schema, capabilities), }, diff --git a/crates/core/src/properties/tests.rs b/crates/core/src/properties/tests.rs index 39f543e3..1fe45aee 100644 --- a/crates/core/src/properties/tests.rs +++ b/crates/core/src/properties/tests.rs @@ -10,768 +10,21 @@ use crate::state::{ SeriesId, }; -/// The default plane: values running -7..8, so its noise estimate is an -/// ordinary fraction of its peak and no contour floor is ever reached. -fn default_plane() -> Vec { - (0..16).map(|value| f64::from(value) - 7.0).collect() -} +#[path = "tests_fixture.rs"] +mod fixture; +use fixture::nmr1d_with; +pub(crate) use fixture::{contour_app, contour_app_with_plane, contour_spec}; + +#[path = "tests_addressing.rs"] +mod addressing_tests; +#[path = "tests_aggregate.rs"] +mod aggregate_tests; +#[path = "tests_capability.rs"] +mod capability_tests; +#[path = "tests_catalog.rs"] +mod catalog_tests; +#[path = "tests_editing.rs"] +mod editing_tests; -fn nmr2d_with(source: &str, values: &[f64]) -> plotx_io::NmrData2D { - let dimension = |nucleus: &str| plotx_io::Dim { - spectral_width_hz: 4_000.0, - observe_freq_mhz: 400.0, - carrier_ppm: 0.0, - nucleus: nucleus.to_owned(), - group_delay: 0.0, - }; - plotx_io::NmrData2D { - data: values - .iter() - .map(|value| num_complex::Complex64::new(*value, 0.5)) - .collect(), - rows: 4, - cols: 4, - domain: plotx_io::Domain::Frequency, - direct: dimension("1H"), - indirect: dimension("13C"), - quad: plotx_io::QuadMode::Complex, - indirect_conjugate: false, - experiment: None, - pseudo_axis: None, - diffusion: None, - nus: None, - source: source.to_owned(), - } -} - -fn nmr1d_with(source: &str) -> plotx_io::NmrData { - plotx_io::NmrData { - points: (0..32) - .map(|value| num_complex::Complex64::new(f64::from(value), 0.0)) - .collect(), - domain: plotx_io::Domain::Frequency, - spectral_width_hz: 4_000.0, - observe_freq_mhz: 400.0, - carrier_ppm: 0.0, - nucleus: "1H".to_owned(), - source: source.to_owned(), - group_delay: 0.0, - } -} - -/// One page holding one plot bound to a true-2D spectrum, i.e. the exact shape -/// the driving case has: a contour drawn from a signed scalar grid. -pub(crate) fn contour_app() -> (PlotxApp, TargetRef) { - contour_app_with_plane(&default_plane()) -} - -/// The same page over a plane the caller chooses, so a test can put a field of -/// a given dynamic range in front of the catalog. -pub(crate) fn contour_app_with_plane(values: &[f64]) -> (PlotxApp, TargetRef) { - let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(nmr2d_with( - "contour", values, - ))))); - let mut canvas = CanvasDocument::new("page".to_owned(), [120.0, 80.0]); - let id = canvas.allocate_object_id(); - let object = app.build_plot_object( - 0, - ObjectFrame::new(0.0, 0.0, 100.0, 80.0), - id, - "Plot".into(), - ); - canvas.objects.push(object); - app.doc.canvases.push(canvas); - app.session.active_canvas = Some(0); - let series = app.doc.canvases[0] - .object(id) - .and_then(|object| object.plot()) - .and_then(|plot| plot.binding.series.first()) - .map(|series| series.id) - .expect("the plot has a series"); - let target = app.series_target(0, id, series).expect("target resolves"); - (app, target) -} - -pub(crate) fn contour_spec(app: &PlotxApp, target: &TargetRef) -> plotx_figure::ContourSpec { - let Some(ComponentRef::Series(series)) = target.component else { - panic!("the fixture addresses a series"); - }; - let binding = &app.doc.canvases[0] - .object( - target - .resource - .local_id - .as_deref() - .unwrap() - .parse() - .unwrap(), - ) - .and_then(|object| object.plot()) - .expect("plot") - .binding; - match &binding - .series - .iter() - .find(|candidate| candidate.id == series) - .expect("series") - .encoding - { - plotx_figure::SeriesEncoding::Contour(spec) => spec.clone(), - other => panic!("expected a contour, got {other:?}"), - } -} - -#[test] -fn the_fixture_draws_a_contour() { - let (app, target) = contour_app(); - let address = PropertyAddress::new(target.clone(), contour::BASE_MAGNITUDE); - let resolved = app.resolve_property(&address).expect("contour resolves"); - assert_eq!(resolved.availability, Availability::Editable); -} - -/// Two definitions sharing an id would make every lookup, every search hit and -/// every reset ambiguous. -#[test] -fn stable_property_ids_are_unique() { - let mut ids: Vec<&str> = catalog() - .iter() - .map(|definition| definition.id.as_str()) - .collect(); - let total = ids.len(); - ids.sort_unstable(); - ids.dedup(); - assert_eq!(ids.len(), total, "catalog ids must be unique"); -} - -/// Provider modules are registered only through `GROUPS`. Keeping the catalog -/// derived from that one list means a new family cannot become searchable while -/// its reader/writer was forgotten, or vice versa. -#[test] -fn provider_groups_are_the_catalog_registration() { - let grouped: Vec = GROUPS - .iter() - .flat_map(|group| group.provider.definitions()) - .map(|definition| definition.id) - .collect(); - let catalogued: Vec = catalog().iter().map(|definition| definition.id).collect(); - assert_eq!(catalogued, grouped, "catalog entries come only from GROUPS"); - assert!( - catalogued.contains(&contour::COUNT), - "the contour provider must be registered through GROUPS" - ); - for id in grouped { - assert!( - provider_for(id).is_some(), - "{id} has a definition but no dispatch provider" - ); - } -} - -/// Every entry must be reachable: a definition nothing can address is dead -/// weight that still costs a panel row and a search hit. -#[test] -fn every_definition_declares_an_addressable_shape() { - for definition in catalog() { - assert!( - !definition.canonical_label.is_empty(), - "{} has no canonical label", - definition.id - ); - if definition.access == PropertyAccess::ReadOnly { - assert!( - matches!(definition.default_policy, DefaultPolicy::None), - "{} is read-only and cannot have a default to reset to", - definition.id - ); - } - } -} - -/// The addressing rule the whole design turns on. A contour setting belongs to -/// the series that draws it, so its address is the plot object plus a -/// `Series(SeriesId)` component — never the dataset, never the field child -/// resource the series happens to read, and never a bare object. -#[test] -fn a_contour_property_is_addressed_by_series_and_nothing_else() { - let (app, target) = contour_app(); - assert!(matches!(target.component, Some(ComponentRef::Series(_)))); - assert_eq!( - target.resource.kind.0, - crate::automation::KIND_CANVAS_OBJECT - ); - - let resolved = app - .resolve_property(&PropertyAddress::new(target.clone(), contour::COUNT)) - .expect("a series component resolves"); - assert!(matches!( - resolved.value, - AggregateValue::Uniform(PropertyValue::Int(_)) - )); - - // The same object without a component names no series at all. - let bare = TargetRef::resource(target.resource.clone()); - let error = app - .resolve_property(&PropertyAddress::new(bare, contour::COUNT)) - .expect_err("a bare object is not a contour target"); - assert!(matches!(error, PropertyError::ComponentKind { .. })); - - // The dataset that owns the values is not the owner of the setting. - let dataset = TargetRef { - resource: ResourceRef::from(app.doc.datasets[0].resource_id()), - component: target.component, - }; - let error = app - .resolve_property(&PropertyAddress::new(dataset, contour::COUNT)) - .expect_err("a dataset is not a contour target"); - assert!(matches!(error, PropertyError::NotApplicable(_))); - - // Neither is the field child resource the series reads from: fields carry - // their own stats and provenance properties, addressed with no component. - let field = TargetRef { - resource: ResourceRef { - id: format!("{}/nmr.real", app.doc.datasets[0].resource_id()), - kind: crate::automation::ResourceKindId::new(KIND_FIELD), - parent_id: Some(app.doc.datasets[0].resource_id().to_string()), - local_id: Some("nmr.real".to_owned()), - }, - component: target.component, - }; - let error = app - .resolve_property(&PropertyAddress::new(field, contour::COUNT)) - .expect_err("a field child resource is not a contour target"); - assert!(matches!(error, PropertyError::NotApplicable(_))); -} - -/// Applicability is decided from the definition before the target is looked up -/// at all, so a misaddressed property never reaches plot code. Pinned with a -/// resource that does not exist: the component-kind rejection must still win -/// over "no such target", which is only true if it happens first. -#[test] -fn the_component_shape_is_rejected_before_any_document_lookup() { - let (app, target) = contour_app(); - let nowhere = TargetRef { - resource: ResourceRef { - id: "00000000-0000-0000-0000-000000000000/999".to_owned(), - kind: crate::automation::ResourceKindId::new(crate::automation::KIND_CANVAS_OBJECT), - parent_id: Some("00000000-0000-0000-0000-000000000000".to_owned()), - local_id: Some("999".to_owned()), - }, - component: Some(ComponentRef::ProcessingStep(plotx_processing::StepId::new( - 0, - ))), - }; - let error = app - .resolve_property(&PropertyAddress::new(nowhere, contour::COUNT)) - .expect_err("a processing step does not own contour levels"); - match error { - PropertyError::ComponentKind { - property, - expected, - actual, - } => { - assert_eq!(property, contour::COUNT, "the real property is named"); - assert_eq!(expected, "series"); - assert_eq!(actual, "processing_step"); - } - other => panic!("expected the definition's own component gate, got {other:?}"), - } - // The same address with the right component shape does get as far as the - // document, proving the rejection above was the component gate and not an - // accident of the bogus id. - assert!(matches!( - app.resolve_property(&PropertyAddress::new( - TargetRef { - resource: ResourceRef { - id: "00000000-0000-0000-0000-000000000000/999".to_owned(), - kind: crate::automation::ResourceKindId::new( - crate::automation::KIND_CANVAS_OBJECT - ), - parent_id: Some("00000000-0000-0000-0000-000000000000".to_owned()), - local_id: Some("999".to_owned()), - }, - component: target.component, - }, - contour::COUNT, - )), - Err(PropertyError::UnknownTarget(_)) - )); -} - -/// A panel edit must reach the document as the ordinary typed binding action, -/// so it undoes, redoes and rebuilds like every other binding change. -#[test] -fn an_edit_compiles_into_a_typed_binding_action() { - let (mut app, target) = contour_app(); - let before = contour_spec(&app, &target); - let commit = app - .plan_property_write( - contour::COUNT, - std::slice::from_ref(&target), - &PropertyValue::Int(7), - ) - .expect("count is writable"); - assert_eq!(commit.applied.len(), 1); - assert!(commit.skipped.is_empty()); - let Some(Action::Composite(actions)) = &commit.document_action else { - panic!("a commit is always one atomic composite"); - }; - assert_eq!(actions.len(), 1); - assert!(matches!(actions[0], Action::SetDataBinding { .. })); - - app.commit_property(commit); - let after = contour_spec(&app, &target); - assert_eq!(before.positive.count, 14); - assert_eq!(after.positive.count, 7); - assert_eq!( - after.negative.as_ref().map(|half| half.count), - Some(7), - "the mirrored half follows the shared ladder" - ); -} - -/// Two series of one object must fold into a single action. Two actions built -/// from the same pre-edit snapshot would make the second overwrite the first. -#[test] -fn several_series_of_one_object_fold_into_one_action() { - let (mut app, first) = contour_app(); - let object: crate::state::ObjectId = - first.resource.local_id.as_deref().unwrap().parse().unwrap(); - let second_id = { - let plot = app.doc.canvases[0] - .object_mut(object) - .and_then(|object| object.plot_mut()) - .expect("plot"); - let id = plot.allocate_series_id(); - let mut extra = plot.binding.series[0].clone(); - extra.id = id; - plot.binding.series.push(extra); - id - }; - let second = app.series_target(0, object, second_id).expect("target"); - - let commit = app - .plan_property_write( - contour::COUNT, - &[first.clone(), second.clone()], - &PropertyValue::Int(9), - ) - .expect("count is writable"); - assert_eq!(commit.applied.len(), 2); - let Some(Action::Composite(actions)) = &commit.document_action else { - panic!("expected a composite"); - }; - assert_eq!( - actions.len(), - 1, - "both series belong to one object and share one binding action" - ); - - app.commit_property(commit); - for target in [first, second] { - assert_eq!(contour_spec(&app, &target).positive.count, 9); - } -} - -/// A target the property does not apply to is reported with a reason. Silently -/// dropping it would leave the user believing the edit landed everywhere. -#[test] -fn an_inapplicable_target_is_reported_rather_than_ignored() { - let (mut app, target) = contour_app(); - let object: crate::state::ObjectId = target - .resource - .local_id - .as_deref() - .unwrap() - .parse() - .unwrap(); - let line_id = { - let plot = app.doc.canvases[0] - .object_mut(object) - .and_then(|object| object.plot_mut()) - .expect("plot"); - let id = plot.allocate_series_id(); - let mut extra = plot.binding.series[0].clone(); - extra.id = id; - extra.encoding = plotx_figure::SeriesEncoding::Line(plotx_figure::LineEncoding::default()); - plot.binding.series.push(extra); - id - }; - let line = app.series_target(0, object, line_id).expect("target"); - - let set = app.resolve_property_set(contour::COUNT, &[target.clone(), line.clone()]); - assert_eq!(set.applicable_targets.len(), 1); - assert_eq!(set.skipped_targets.len(), 1); - assert!( - set.skipped_targets[0].message.contains("contour"), - "the reason names the mismatch: {}", - set.skipped_targets[0].message - ); - - let commit = app - .plan_property_write(contour::COUNT, &[target, line], &PropertyValue::Int(5)) - .expect("the compatible target still commits"); - assert_eq!(commit.applied.len(), 1); - assert_eq!(commit.skipped.len(), 1); -} - -/// A value one target rejects must abort the entire commit; a partially applied -/// multi-selection edit is exactly what the atomic composite exists to prevent. -#[test] -fn a_rejected_value_aborts_the_whole_commit() { - let (app, target) = contour_app(); - let before = contour_spec(&app, &target); - let error = app - .plan_property_write( - contour::COUNT, - std::slice::from_ref(&target), - &PropertyValue::Int(0), - ) - .expect_err("zero levels is not a ladder"); - assert!(matches!(error, PropertyError::InvalidValue { .. })); - assert_eq!( - contour_spec(&app, &target).positive.count, - before.positive.count, - "nothing may change when planning failed" - ); -} - -/// The base-policy gate is a capability gate, not a domain check: a signed field -/// is never offered a fraction of its value range, and a field with no noise -/// estimator is never offered a multiple of σ. -#[test] -fn base_policies_are_gated_by_field_capability() { - let signed = crate::state::FieldCapabilities::new([ - CapabilityId::new(crate::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR), - CapabilityId::new(CAP_FIELD_SIGNED), - CapabilityId::new(CAP_FIELD_NOISE_SCALE), - ]); - let schema = definition(contour::BASE_POLICY) - .expect("the policy property is registered") - .value_schema; - let offered: Vec<&str> = permitted_variants(&schema, &signed) - .into_iter() - .map(|variant| variant.id) - .collect(); - assert!(offered.contains(&CONTOUR_BASE_NOISE_FLOOR)); - assert!( - !offered.contains(&CONTOUR_BASE_FRACTION_OF_RANGE), - "a fraction of a range that straddles zero is not a threshold" - ); - - let bounded = crate::state::FieldCapabilities::new([ - CapabilityId::new(crate::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR), - CapabilityId::new(crate::automation::CAP_FIELD_BOUNDED), - ]); - let offered: Vec<&str> = permitted_variants(&schema, &bounded) - .into_iter() - .map(|variant| variant.id) - .collect(); - assert!(offered.contains(&CONTOUR_BASE_FRACTION_OF_RANGE)); - assert!(!offered.contains(&CONTOUR_BASE_NOISE_FLOOR)); -} - -/// The gate must also hold on the write path, not only in the control that -/// offers the choices. -#[test] -fn an_ungated_base_policy_is_refused_on_write() { - let (app, target) = contour_app(); - let error = app - .plan_property_write( - contour::BASE_POLICY, - std::slice::from_ref(&target), - &PropertyValue::Enum(CONTOUR_BASE_FRACTION_OF_RANGE), - ) - .expect_err("the signed NMR plane must not accept a range fraction"); - assert!(matches!(error, PropertyError::InvalidValue { .. })); -} - -/// Reset re-derives the value from the factory in the target's current context -/// rather than restoring a stored snapshot. -#[test] -fn reset_rederives_the_factory_default() { - let (mut app, target) = contour_app(); - let commit = app - .plan_property_write( - contour::BASE_MAGNITUDE, - std::slice::from_ref(&target), - &PropertyValue::Float(11.0), - ) - .expect("the multiplier is writable"); - app.commit_property(commit); - let address = PropertyAddress::new(target.clone(), contour::BASE_MAGNITUDE); - let resolved = app.resolve_property(&address).expect("resolves"); - assert!(resolved.is_modified()); - assert_eq!(resolved.default_value, Some(PropertyValue::Float(5.0))); - - let commit = app - .plan_property_reset(contour::BASE_MAGNITUDE, std::slice::from_ref(&target)) - .expect("reset plans"); - app.commit_property(commit); - let resolved = app.resolve_property(&address).expect("resolves"); - assert!(!resolved.is_modified()); - assert_eq!( - resolved.value, - AggregateValue::Uniform(PropertyValue::Float(5.0)) - ); -} - -/// Resetting a whole encoding goes back through the default factory, so it -/// yields a complete concrete encoding rather than a patched-up old one. -#[test] -fn resetting_an_encoding_calls_the_default_factory() { - let (mut app, target) = contour_app(); - let commit = app - .plan_property_write( - contour::NEGATIVE_ENABLED, - std::slice::from_ref(&target), - &PropertyValue::Bool(false), - ) - .expect("the negative half is writable on a signed field"); - app.commit_property(commit); - assert!(contour_spec(&app, &target).negative.is_none()); - - let commit = app - .plan_encoding_reset(EncodingKind::Contour, std::slice::from_ref(&target)) - .expect("encoding reset plans"); - app.commit_property(commit); - let spec = contour_spec(&app, &target); - assert!( - spec.negative.is_some(), - "the factory restores the negative half a signed field gets by default" - ); - assert_eq!(spec.positive.count, 14); -} - -/// Reading across a heterogeneous selection reports `Mixed` instead of picking -/// one target's value and pretending it speaks for all of them. -#[test] -fn a_heterogeneous_selection_reads_as_mixed() { - let (mut app, first) = contour_app(); - let object: crate::state::ObjectId = - first.resource.local_id.as_deref().unwrap().parse().unwrap(); - let second_id = { - let plot = app.doc.canvases[0] - .object_mut(object) - .and_then(|object| object.plot_mut()) - .expect("plot"); - let id = plot.allocate_series_id(); - let mut extra = plot.binding.series[0].clone(); - extra.id = id; - if let plotx_figure::SeriesEncoding::Contour(spec) = &mut extra.encoding { - spec.positive.count = 3; - } - plot.binding.series.push(extra); - id - }; - let second = app.series_target(0, object, second_id).expect("target"); - let set = app.resolve_property_set(contour::COUNT, &[first, second]); - assert_eq!(set.value, AggregateValue::Mixed); - - let (app, only) = contour_app(); - let set = app.resolve_property_set(contour::COUNT, std::slice::from_ref(&only)); - assert_eq!(set.value, AggregateValue::Uniform(PropertyValue::Int(14))); - let set = app.resolve_property_set(contour::COUNT, &[]); - assert_eq!(set.value, AggregateValue::Unavailable); -} - -/// A series binding whose id no longer exists must not resolve to a neighbour. -#[test] -fn an_unknown_series_does_not_resolve_to_another_one() { - let (app, target) = contour_app(); - let stale = TargetRef { - resource: target.resource.clone(), - component: Some(ComponentRef::Series(SeriesId::new(4_242))), - }; - let error = app - .resolve_property(&PropertyAddress::new(stale, contour::COUNT)) - .expect_err("a stale series id is not a target"); - assert!(matches!(error, PropertyError::UnknownTarget(_))); -} - -/// The catalog never grows a parallel value store: a definition describes, and -/// the value stays in the encoding. This pins the property that makes that true -/// — the resolved value always equals what the domain model holds. -#[test] -fn resolved_values_come_from_the_domain_model() { - let (mut app, target) = contour_app(); - let object: crate::state::ObjectId = target - .resource - .local_id - .as_deref() - .unwrap() - .parse() - .unwrap(); - if let Some(plotx_figure::SeriesEncoding::Contour(spec)) = app.doc.canvases[0] - .object_mut(object) - .and_then(|object| object.plot_mut()) - .and_then(|plot| plot.binding.series.first_mut()) - .map(|series| &mut series.encoding) - { - // Both halves, so this pins where the value comes from rather than - // re-testing what an asymmetric ladder reads as. - spec.positive.count = 21; - if let Some(negative) = spec.negative.as_mut() { - negative.count = 21; - } - } - let resolved = app - .resolve_property(&PropertyAddress::new(target, contour::COUNT)) - .expect("resolves"); - assert_eq!( - resolved.value, - AggregateValue::Uniform(PropertyValue::Int(21)) - ); -} - -/// `SeriesSource.field` says where the values come from; it is not the -/// component of a contour address. Two series of one object reading the very -/// same field must therefore still be told apart, and each keep its own levels. -#[test] -fn the_source_field_is_not_the_component() { - let (mut app, first) = contour_app(); - let object: crate::state::ObjectId = - first.resource.local_id.as_deref().unwrap().parse().unwrap(); - let second_id = { - let plot = app.doc.canvases[0] - .object_mut(object) - .and_then(|object| object.plot_mut()) - .expect("plot"); - let id = plot.allocate_series_id(); - let mut extra = plot.binding.series[0].clone(); - extra.id = id; - plot.binding.series.push(extra); - id - }; - let second = app.series_target(0, object, second_id).expect("target"); - let sources: Vec = app.doc.canvases[0] - .object(object) - .and_then(|object| object.plot()) - .map(|plot| { - plot.binding - .series - .iter() - .map(|series: &SeriesBinding| series.source.field) - .collect() - }) - .expect("plot"); - assert_eq!( - sources[0], sources[1], - "both series must read one field for this to prove anything" - ); - - let commit = app - .plan_property_write( - contour::COUNT, - std::slice::from_ref(&second), - &PropertyValue::Int(3), - ) - .expect("count is writable"); - app.commit_property(commit); - assert_eq!(contour_spec(&app, &first).positive.count, 14); - assert_eq!(contour_spec(&app, &second).positive.count, 3); -} - -/// The typed entry point's own out-of-range refusal names both the value that -/// was rejected and the rule that rejected it. -/// -/// The automation adapter checks the declared bound before it ever builds a -/// typed value, so this bound is reached only through the panel's path — and -/// a panel user who typed a number and saw "must be greater than 1 and at most -/// 10" still cannot tell which end their number fell off. -#[test] -fn the_typed_planner_names_the_rejected_value_and_the_bound() { - let (app, target) = contour_app(); - let error = app - .plan_property_write( - contour::RATIO, - std::slice::from_ref(&target), - &PropertyValue::Float(42.0), - ) - .expect_err("42 is above the declared ratio bound"); - let message = error.to_string(); - assert!( - message.contains("42"), - "the rejected value is named: {message}" - ); - assert!( - message.contains("greater than 1") && message.contains("at most 10"), - "the bound is named: {message}" - ); -} - -#[cfg(test)] #[path = "provider_tests.rs"] mod provider_tests; - -/// A write that is valid for one target and refused by the next may not leave -/// the first one changed. -/// -/// The refusal has to arise *inside* the planner to mean anything: a value the -/// wire format already rejects never reaches a target at all, so a test that -/// fails at decoding proves nothing about the transaction. Here both values are -/// well-formed numbers and both series accept the property — the second series -/// is anchored to its noise floor, whose ceiling is a fact about that target's -/// current state, so only the planner can know the write is out of range. By -/// then the first series' working copy has already been modified. -#[test] -fn a_refusal_on_a_later_target_leaves_the_earlier_one_untouched() { - let (mut app, first) = contour_app(); - let object: crate::state::ObjectId = - first.resource.local_id.as_deref().unwrap().parse().unwrap(); - let second_id = { - let plot = app.doc.canvases[0] - .object_mut(object) - .and_then(|object| object.plot_mut()) - .expect("plot"); - let id = plot.allocate_series_id(); - let mut extra = plot.binding.series[0].clone(); - extra.id = id; - plot.binding.series.push(extra); - id - }; - let second = app.series_target(0, object, second_id).expect("target"); - - // The two series share one binding, which is exactly the case a per-target - // rollback exists for: the second target selects a working copy the first - // has already written to. - for (target, policy) in [ - (&first, CONTOUR_BASE_ABSOLUTE), - (&second, CONTOUR_BASE_NOISE_FLOOR), - ] { - let commit = app - .plan_property_write( - contour::BASE_POLICY, - std::slice::from_ref(target), - &PropertyValue::Enum(policy), - ) - .expect("both anchors are available on this field"); - app.commit_property(commit); - } - let before_first = contour_spec(&app, &first).positive.base.clone(); - let before_second = contour_spec(&app, &second).positive.base.clone(); - let revision = app.doc.automation_revision; - - // Well above any multiplier, well inside an absolute level. - let error = app - .plan_property_write( - contour::BASE_MAGNITUDE, - &[first.clone(), second.clone()], - &PropertyValue::Float(1.0e6), - ) - .expect_err("the noise-anchored series has a ceiling this value clears"); - assert!( - matches!(error, PropertyError::InvalidValue { .. }), - "an out-of-range value is a refusal, not a skip: {error}" - ); - - assert_eq!( - contour_spec(&app, &first).positive.base, - before_first, - "the first series was already written in the transaction and must be rolled back with it" - ); - assert_eq!(contour_spec(&app, &second).positive.base, before_second); - assert_eq!(app.doc.automation_revision, revision); -} diff --git a/crates/core/src/properties/tests_addressing.rs b/crates/core/src/properties/tests_addressing.rs new file mode 100644 index 00000000..d4e790d9 --- /dev/null +++ b/crates/core/src/properties/tests_addressing.rs @@ -0,0 +1,113 @@ +//! Property addressing and component-shape tests. + +use super::*; + +/// The addressing rule the whole design turns on. A contour setting belongs to +/// the series that draws it, so its address is the plot object plus a +/// `Series(SeriesId)` component — never the dataset, never the field child +/// resource the series happens to read, and never a bare object. +#[test] +fn a_contour_property_is_addressed_by_series_and_nothing_else() { + let (app, target) = contour_app(); + assert!(matches!(target.component, Some(ComponentRef::Series(_)))); + assert_eq!( + target.resource.kind.0, + crate::automation::KIND_CANVAS_OBJECT + ); + + let resolved = app + .resolve_property(&PropertyAddress::new(target.clone(), contour::COUNT)) + .expect("a series component resolves"); + assert!(matches!( + resolved.value, + AggregateValue::Uniform(PropertyValue::Int(_)) + )); + + // The same object without a component names no series at all. + let bare = TargetRef::resource(target.resource.clone()); + let error = app + .resolve_property(&PropertyAddress::new(bare, contour::COUNT)) + .expect_err("a bare object is not a contour target"); + assert!(matches!(error, PropertyError::ComponentKind { .. })); + + // The dataset that owns the values is not the owner of the setting. + let dataset = TargetRef { + resource: ResourceRef::from(app.doc.datasets[0].resource_id()), + component: target.component, + }; + let error = app + .resolve_property(&PropertyAddress::new(dataset, contour::COUNT)) + .expect_err("a dataset is not a contour target"); + assert!(matches!(error, PropertyError::NotApplicable(_))); + + // Neither is the field child resource the series reads from: fields carry + // their own stats and provenance properties, addressed with no component. + let field = TargetRef { + resource: ResourceRef { + id: format!("{}/nmr.real", app.doc.datasets[0].resource_id()), + kind: crate::automation::ResourceKindId::new(KIND_FIELD), + parent_id: Some(app.doc.datasets[0].resource_id().to_string()), + local_id: Some("nmr.real".to_owned()), + }, + component: target.component, + }; + let error = app + .resolve_property(&PropertyAddress::new(field, contour::COUNT)) + .expect_err("a field child resource is not a contour target"); + assert!(matches!(error, PropertyError::NotApplicable(_))); +} + +/// Applicability is decided from the definition before the target is looked up +/// at all, so a misaddressed property never reaches plot code. Pinned with a +/// resource that does not exist: the component-kind rejection must still win +/// over "no such target", which is only true if it happens first. +#[test] +fn the_component_shape_is_rejected_before_any_document_lookup() { + let (app, target) = contour_app(); + let nowhere = TargetRef { + resource: ResourceRef { + id: "00000000-0000-0000-0000-000000000000/999".to_owned(), + kind: crate::automation::ResourceKindId::new(crate::automation::KIND_CANVAS_OBJECT), + parent_id: Some("00000000-0000-0000-0000-000000000000".to_owned()), + local_id: Some("999".to_owned()), + }, + component: Some(ComponentRef::ProcessingStep(plotx_processing::StepId::new( + 0, + ))), + }; + let error = app + .resolve_property(&PropertyAddress::new(nowhere, contour::COUNT)) + .expect_err("a processing step does not own contour levels"); + match error { + PropertyError::ComponentKind { + property, + expected, + actual, + } => { + assert_eq!(property, contour::COUNT, "the real property is named"); + assert_eq!(expected, "series"); + assert_eq!(actual, "processing_step"); + } + other => panic!("expected the definition's own component gate, got {other:?}"), + } + // The same address with the right component shape does get as far as the + // document, proving the rejection above was the component gate and not an + // accident of the bogus id. + assert!(matches!( + app.resolve_property(&PropertyAddress::new( + TargetRef { + resource: ResourceRef { + id: "00000000-0000-0000-0000-000000000000/999".to_owned(), + kind: crate::automation::ResourceKindId::new( + crate::automation::KIND_CANVAS_OBJECT + ), + parent_id: Some("00000000-0000-0000-0000-000000000000".to_owned()), + local_id: Some("999".to_owned()), + }, + component: target.component, + }, + contour::COUNT, + )), + Err(PropertyError::UnknownTarget(_)) + )); +} diff --git a/crates/core/src/properties/tests_aggregate.rs b/crates/core/src/properties/tests_aggregate.rs new file mode 100644 index 00000000..65189185 --- /dev/null +++ b/crates/core/src/properties/tests_aggregate.rs @@ -0,0 +1,132 @@ +//! Multi-target aggregation and domain-value resolution tests. + +use super::*; + +/// Reading across a heterogeneous selection reports `Mixed` instead of picking +/// one target's value and pretending it speaks for all of them. +#[test] +fn a_heterogeneous_selection_reads_as_mixed() { + let (mut app, first) = contour_app(); + let object: crate::state::ObjectId = + first.resource.local_id.as_deref().unwrap().parse().unwrap(); + let second_id = { + let plot = app.doc.canvases[0] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .expect("plot"); + let id = plot.allocate_series_id(); + let mut extra = plot.binding.series[0].clone(); + extra.id = id; + if let plotx_figure::SeriesEncoding::Contour(spec) = &mut extra.encoding { + spec.positive.count = 3; + } + plot.binding.series.push(extra); + id + }; + let second = app.series_target(0, object, second_id).expect("target"); + let set = app.resolve_property_set(contour::COUNT, &[first, second]); + assert_eq!(set.value, AggregateValue::Mixed); + + let (app, only) = contour_app(); + let set = app.resolve_property_set(contour::COUNT, std::slice::from_ref(&only)); + assert_eq!(set.value, AggregateValue::Uniform(PropertyValue::Int(14))); + let set = app.resolve_property_set(contour::COUNT, &[]); + assert_eq!(set.value, AggregateValue::Unavailable); +} + +/// A series binding whose id no longer exists must not resolve to a neighbour. +#[test] +fn an_unknown_series_does_not_resolve_to_another_one() { + let (app, target) = contour_app(); + let stale = TargetRef { + resource: target.resource.clone(), + component: Some(ComponentRef::Series(SeriesId::new(4_242))), + }; + let error = app + .resolve_property(&PropertyAddress::new(stale, contour::COUNT)) + .expect_err("a stale series id is not a target"); + assert!(matches!(error, PropertyError::UnknownTarget(_))); +} + +/// The catalog never grows a parallel value store: a definition describes, and +/// the value stays in the encoding. This pins the property that makes that true +/// — the resolved value always equals what the domain model holds. +#[test] +fn resolved_values_come_from_the_domain_model() { + let (mut app, target) = contour_app(); + let object: crate::state::ObjectId = target + .resource + .local_id + .as_deref() + .unwrap() + .parse() + .unwrap(); + if let Some(plotx_figure::SeriesEncoding::Contour(spec)) = app.doc.canvases[0] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .and_then(|plot| plot.binding.series.first_mut()) + .map(|series| &mut series.encoding) + { + // Both halves, so this pins where the value comes from rather than + // re-testing what an asymmetric ladder reads as. + spec.positive.count = 21; + if let Some(negative) = spec.negative.as_mut() { + negative.count = 21; + } + } + let resolved = app + .resolve_property(&PropertyAddress::new(target, contour::COUNT)) + .expect("resolves"); + assert_eq!( + resolved.value, + AggregateValue::Uniform(PropertyValue::Int(21)) + ); +} + +/// `SeriesSource.field` says where the values come from; it is not the +/// component of a contour address. Two series of one object reading the very +/// same field must therefore still be told apart, and each keep its own levels. +#[test] +fn the_source_field_is_not_the_component() { + let (mut app, first) = contour_app(); + let object: crate::state::ObjectId = + first.resource.local_id.as_deref().unwrap().parse().unwrap(); + let second_id = { + let plot = app.doc.canvases[0] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .expect("plot"); + let id = plot.allocate_series_id(); + let mut extra = plot.binding.series[0].clone(); + extra.id = id; + plot.binding.series.push(extra); + id + }; + let second = app.series_target(0, object, second_id).expect("target"); + let sources: Vec = app.doc.canvases[0] + .object(object) + .and_then(|object| object.plot()) + .map(|plot| { + plot.binding + .series + .iter() + .map(|series: &SeriesBinding| series.source.field) + .collect() + }) + .expect("plot"); + assert_eq!( + sources[0], sources[1], + "both series must read one field for this to prove anything" + ); + + let commit = app + .plan_property_write( + contour::COUNT, + std::slice::from_ref(&second), + &PropertyValue::Int(3), + ) + .expect("count is writable"); + app.commit_property(commit); + assert_eq!(contour_spec(&app, &first).positive.count, 14); + assert_eq!(contour_spec(&app, &second).positive.count, 3); +} diff --git a/crates/core/src/properties/tests_capability.rs b/crates/core/src/properties/tests_capability.rs new file mode 100644 index 00000000..e5b19ac7 --- /dev/null +++ b/crates/core/src/properties/tests_capability.rs @@ -0,0 +1,110 @@ +//! Capability gating and reset behavior tests. + +use super::*; + +/// The base-policy gate is a capability gate, not a domain check: a signed field +/// is never offered a fraction of its value range, and a field with no noise +/// estimator is never offered a multiple of σ. +#[test] +fn base_policies_are_gated_by_field_capability() { + let signed = crate::state::FieldCapabilities::new([ + CapabilityId::new(crate::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR), + CapabilityId::new(CAP_FIELD_SIGNED), + CapabilityId::new(CAP_FIELD_NOISE_SCALE), + ]); + let schema = definition(contour::BASE_POLICY) + .expect("the policy property is registered") + .value_schema; + let offered: Vec<&str> = permitted_variants(&schema, &signed) + .into_iter() + .map(|variant| variant.id) + .collect(); + assert!(offered.contains(&CONTOUR_BASE_NOISE_FLOOR)); + assert!( + !offered.contains(&CONTOUR_BASE_FRACTION_OF_RANGE), + "a fraction of a range that straddles zero is not a threshold" + ); + + let bounded = crate::state::FieldCapabilities::new([ + CapabilityId::new(crate::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR), + CapabilityId::new(crate::automation::CAP_FIELD_BOUNDED), + ]); + let offered: Vec<&str> = permitted_variants(&schema, &bounded) + .into_iter() + .map(|variant| variant.id) + .collect(); + assert!(offered.contains(&CONTOUR_BASE_FRACTION_OF_RANGE)); + assert!(!offered.contains(&CONTOUR_BASE_NOISE_FLOOR)); +} + +/// The gate must also hold on the write path, not only in the control that +/// offers the choices. +#[test] +fn an_ungated_base_policy_is_refused_on_write() { + let (app, target) = contour_app(); + let error = app + .plan_property_write( + contour::BASE_POLICY, + std::slice::from_ref(&target), + &PropertyValue::Enum(CONTOUR_BASE_FRACTION_OF_RANGE), + ) + .expect_err("the signed NMR plane must not accept a range fraction"); + assert!(matches!(error, PropertyError::InvalidValue { .. })); +} + +/// Reset re-derives the value from the factory in the target's current context +/// rather than restoring a stored snapshot. +#[test] +fn reset_rederives_the_factory_default() { + let (mut app, target) = contour_app(); + let commit = app + .plan_property_write( + contour::BASE_MAGNITUDE, + std::slice::from_ref(&target), + &PropertyValue::Float(11.0), + ) + .expect("the multiplier is writable"); + app.commit_property(commit); + let address = PropertyAddress::new(target.clone(), contour::BASE_MAGNITUDE); + let resolved = app.resolve_property(&address).expect("resolves"); + assert!(resolved.is_modified()); + assert_eq!(resolved.default_value, Some(PropertyValue::Float(5.0))); + + let commit = app + .plan_property_reset(contour::BASE_MAGNITUDE, std::slice::from_ref(&target)) + .expect("reset plans"); + app.commit_property(commit); + let resolved = app.resolve_property(&address).expect("resolves"); + assert!(!resolved.is_modified()); + assert_eq!( + resolved.value, + AggregateValue::Uniform(PropertyValue::Float(5.0)) + ); +} + +/// Resetting a whole encoding goes back through the default factory, so it +/// yields a complete concrete encoding rather than a patched-up old one. +#[test] +fn resetting_an_encoding_calls_the_default_factory() { + let (mut app, target) = contour_app(); + let commit = app + .plan_property_write( + contour::NEGATIVE_ENABLED, + std::slice::from_ref(&target), + &PropertyValue::Bool(false), + ) + .expect("the negative half is writable on a signed field"); + app.commit_property(commit); + assert!(contour_spec(&app, &target).negative.is_none()); + + let commit = app + .plan_encoding_reset(EncodingKind::Contour, std::slice::from_ref(&target)) + .expect("encoding reset plans"); + app.commit_property(commit); + let spec = contour_spec(&app, &target); + assert!( + spec.negative.is_some(), + "the factory restores the negative half a signed field gets by default" + ); + assert_eq!(spec.positive.count, 14); +} diff --git a/crates/core/src/properties/tests_catalog.rs b/crates/core/src/properties/tests_catalog.rs new file mode 100644 index 00000000..44130d8b --- /dev/null +++ b/crates/core/src/properties/tests_catalog.rs @@ -0,0 +1,136 @@ +//! Catalog registration and definition uniqueness tests. + +use super::*; + +/// Two definitions sharing an id would make every lookup, every search hit and +/// every reset ambiguous. +#[test] +fn stable_property_ids_are_unique() { + let mut ids: Vec<&str> = catalog() + .iter() + .map(|definition| definition.id.as_str()) + .collect(); + let total = ids.len(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), total, "catalog ids must be unique"); +} + +/// Provider modules are registered only through `GROUPS`. Keeping the catalog +/// derived from that one list means a new family cannot become searchable while +/// its reader/writer was forgotten, or vice versa. +#[test] +fn provider_groups_are_the_catalog_registration() { + let grouped: Vec = GROUPS + .iter() + .flat_map(|group| group.provider.definitions()) + .map(|definition| definition.id) + .collect(); + let catalogued: Vec = catalog().iter().map(|definition| definition.id).collect(); + assert_eq!(catalogued, grouped, "catalog entries come only from GROUPS"); + assert!( + catalogued.contains(&contour::COUNT), + "the contour provider must be registered through GROUPS" + ); + for id in grouped { + assert!( + provider_for(id).is_some(), + "{id} has a definition but no dispatch provider" + ); + } +} + +/// Every entry must be reachable: a definition nothing can address is dead +/// weight that still costs a panel row and a search hit. +#[test] +fn every_definition_declares_an_addressable_shape() { + for definition in catalog() { + assert!( + !definition.canonical_label.is_empty(), + "{} has no canonical label", + definition.id + ); + if definition.access == PropertyAccess::ReadOnly { + assert!( + matches!(definition.default_policy, DefaultPolicy::None), + "{} is read-only and cannot have a default to reset to", + definition.id + ); + } + } +} + +#[test] +fn every_derived_default_read_reports_a_reset_target() { + use crate::state::{CanvasObject, CanvasObjectKind, TextBox}; + + let (mut app, series) = super::contour_app(); + let canvas = &mut app.doc.canvases[0]; + let text_id = canvas.allocate_object_id(); + canvas.objects.push(CanvasObject { + id: text_id, + name: "Text".to_owned(), + frame: ObjectFrame::new(0.0, 0.0, 20.0, 10.0), + locked: false, + visible: true, + group: None, + kind: CanvasObjectKind::Text(TextBox::label("derived default".to_owned())), + }); + let object = crate::automation::TargetRef::resource(series.resource); + let text = app.object_target(0, text_id).expect("text target"); + let application = app.app_target(); + for definition in catalog() + .iter() + .filter(|definition| matches!(&definition.default_policy, DefaultPolicy::Derived)) + { + let target = if definition.scope_kind == ScopeKind::App { + application.clone() + } else if matches!( + definition.id, + object::TEXT + | object::TEXT_FONT_SIZE + | object::TEXT_BOLD + | object::TEXT_ALIGN + | object::TEXT_COLOR + ) { + text.clone() + } else { + object.clone() + }; + let resolved = app + .resolve_property(&PropertyAddress::new(target, definition.id)) + .unwrap_or_else(|error| panic!("{} did not resolve: {error}", definition.id)); + assert!( + resolved.default_value.is_some(), + "{} declares Derived but read reports no default_value", + definition.id + ); + } +} + +/// A float definition states the unit of the value it stores, never the unit of +/// the number a control happens to draw. `FloatDisplay::caption` derives the +/// second from the first, so a definition that spells the transformation out +/// again would have it announced twice ("log₁₀ log₁₀ λ"). +#[test] +fn a_logarithmic_unit_does_not_restate_its_own_transformation() { + for definition in catalog() { + let ValueSchema::Float { display, .. } = definition.value_schema else { + continue; + }; + let FloatDisplay::Log10(unit) = display else { + continue; + }; + assert!( + !unit.contains("log"), + "{} declares the unit {unit:?}; state the domain unit and let the \ + caption add the exponent", + definition.id + ); + assert!( + display.caption().starts_with("log₁₀"), + "{} must announce that its control edits an exponent", + definition.id + ); + } +} diff --git a/crates/core/src/properties/tests_editing.rs b/crates/core/src/properties/tests_editing.rs new file mode 100644 index 00000000..a31a99aa --- /dev/null +++ b/crates/core/src/properties/tests_editing.rs @@ -0,0 +1,239 @@ +//! Typed property editing and atomic composite tests. + +use super::*; + +/// A panel edit must reach the document as the ordinary typed binding action, +/// so it undoes, redoes and rebuilds like every other binding change. +#[test] +fn an_edit_compiles_into_a_typed_binding_action() { + let (mut app, target) = contour_app(); + let before = contour_spec(&app, &target); + let commit = app + .plan_property_write( + contour::COUNT, + std::slice::from_ref(&target), + &PropertyValue::Int(7), + ) + .expect("count is writable"); + assert_eq!(commit.applied.len(), 1); + assert!(commit.skipped.is_empty()); + let Some(Action::Composite(actions)) = &commit.document_action else { + panic!("a commit is always one atomic composite"); + }; + assert_eq!(actions.len(), 1); + assert!(matches!(actions[0], Action::SetDataBinding { .. })); + + app.commit_property(commit); + let after = contour_spec(&app, &target); + assert_eq!(before.positive.count, 14); + assert_eq!(after.positive.count, 7); + assert_eq!( + after.negative.as_ref().map(|half| half.count), + Some(7), + "the mirrored half follows the shared ladder" + ); +} + +/// Two series of one object must fold into a single action. Two actions built +/// from the same pre-edit snapshot would make the second overwrite the first. +#[test] +fn several_series_of_one_object_fold_into_one_action() { + let (mut app, first) = contour_app(); + let object: crate::state::ObjectId = + first.resource.local_id.as_deref().unwrap().parse().unwrap(); + let second_id = { + let plot = app.doc.canvases[0] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .expect("plot"); + let id = plot.allocate_series_id(); + let mut extra = plot.binding.series[0].clone(); + extra.id = id; + plot.binding.series.push(extra); + id + }; + let second = app.series_target(0, object, second_id).expect("target"); + + let commit = app + .plan_property_write( + contour::COUNT, + &[first.clone(), second.clone()], + &PropertyValue::Int(9), + ) + .expect("count is writable"); + assert_eq!(commit.applied.len(), 2); + let Some(Action::Composite(actions)) = &commit.document_action else { + panic!("expected a composite"); + }; + assert_eq!( + actions.len(), + 1, + "both series belong to one object and share one binding action" + ); + + app.commit_property(commit); + for target in [first, second] { + assert_eq!(contour_spec(&app, &target).positive.count, 9); + } +} + +/// A target the property does not apply to is reported with a reason. Silently +/// dropping it would leave the user believing the edit landed everywhere. +#[test] +fn an_inapplicable_target_is_reported_rather_than_ignored() { + let (mut app, target) = contour_app(); + let object: crate::state::ObjectId = target + .resource + .local_id + .as_deref() + .unwrap() + .parse() + .unwrap(); + let line_id = { + let plot = app.doc.canvases[0] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .expect("plot"); + let id = plot.allocate_series_id(); + let mut extra = plot.binding.series[0].clone(); + extra.id = id; + extra.encoding = plotx_figure::SeriesEncoding::Line(plotx_figure::LineEncoding::default()); + plot.binding.series.push(extra); + id + }; + let line = app.series_target(0, object, line_id).expect("target"); + + let set = app.resolve_property_set(contour::COUNT, &[target.clone(), line.clone()]); + assert_eq!(set.applicable_targets.len(), 1); + assert_eq!(set.skipped_targets.len(), 1); + assert!( + set.skipped_targets[0].message.contains("contour"), + "the reason names the mismatch: {}", + set.skipped_targets[0].message + ); + + let commit = app + .plan_property_write(contour::COUNT, &[target, line], &PropertyValue::Int(5)) + .expect("the compatible target still commits"); + assert_eq!(commit.applied.len(), 1); + assert_eq!(commit.skipped.len(), 1); +} + +/// A value one target rejects must abort the entire commit; a partially applied +/// multi-selection edit is exactly what the atomic composite exists to prevent. +#[test] +fn a_rejected_value_aborts_the_whole_commit() { + let (app, target) = contour_app(); + let before = contour_spec(&app, &target); + let error = app + .plan_property_write( + contour::COUNT, + std::slice::from_ref(&target), + &PropertyValue::Int(0), + ) + .expect_err("zero levels is not a ladder"); + assert!(matches!(error, PropertyError::InvalidValue { .. })); + assert_eq!( + contour_spec(&app, &target).positive.count, + before.positive.count, + "nothing may change when planning failed" + ); +} + +/// The typed entry point's own out-of-range refusal names both the value that +/// was rejected and the rule that rejected it. +/// +/// The automation adapter checks the declared bound before it ever builds a +/// typed value, so this bound is reached only through the panel's path — and +/// a panel user who typed a number and saw "must be greater than 1 and at most +/// 10" still cannot tell which end their number fell off. +#[test] +fn the_typed_planner_names_the_rejected_value_and_the_bound() { + let (app, target) = contour_app(); + let error = app + .plan_property_write( + contour::RATIO, + std::slice::from_ref(&target), + &PropertyValue::Float(42.0), + ) + .expect_err("42 is above the declared ratio bound"); + let message = error.to_string(); + assert!( + message.contains("42"), + "the rejected value is named: {message}" + ); + assert!( + message.contains("greater than 1") && message.contains("at most 10"), + "the bound is named: {message}" + ); +} + +/// A write that is valid for one target and refused by the next may not leave +/// the first one changed. +/// +/// The refusal has to arise *inside* the planner to mean anything: a value the +/// wire format already rejects never reaches a target at all, so a test that +/// fails at decoding proves nothing about the transaction. Here both values are +/// well-formed numbers and both series accept the property — the second series +/// is anchored to its noise floor, whose ceiling is a fact about that target's +/// current state, so only the planner can know the write is out of range. By +/// then the first series' working copy has already been modified. +#[test] +fn a_refusal_on_a_later_target_leaves_the_earlier_one_untouched() { + let (mut app, first) = contour_app(); + let object: crate::state::ObjectId = + first.resource.local_id.as_deref().unwrap().parse().unwrap(); + let second_id = { + let plot = app.doc.canvases[0] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .expect("plot"); + let id = plot.allocate_series_id(); + let mut extra = plot.binding.series[0].clone(); + extra.id = id; + plot.binding.series.push(extra); + id + }; + let second = app.series_target(0, object, second_id).expect("target"); + + // The two series share one binding, which is exactly the case a per-target + // rollback exists for: the second target selects a working copy the first + // has already written to. + for (target, policy) in [ + (&first, CONTOUR_BASE_ABSOLUTE), + (&second, CONTOUR_BASE_NOISE_FLOOR), + ] { + let commit = app + .plan_property_write( + contour::BASE_POLICY, + std::slice::from_ref(target), + &PropertyValue::Enum(policy), + ) + .expect("both anchors are available on this field"); + app.commit_property(commit); + } + let before_first = contour_spec(&app, &first).positive.base.clone(); + let before_second = contour_spec(&app, &second).positive.base.clone(); + let revision = app.doc.automation_revision; + + // Well above any multiplier, well inside an absolute level. + let error = app + .plan_property_write( + contour::BASE_MAGNITUDE, + &[first.clone(), second.clone()], + &PropertyValue::Float(1.0e6), + ) + .expect_err("the noise-anchored series has a ceiling this value clears"); + assert!( + matches!(error, PropertyError::InvalidValue { .. }), + "an out-of-range value is a refusal, not a skip: {error}" + ); + + assert_eq!( + contour_spec(&app, &first).positive.base, + before_first, + "the first series was already written in the transaction and must be rolled back with it" + ); + assert_eq!(contour_spec(&app, &second).positive.base, before_second); + assert_eq!(app.doc.automation_revision, revision); +} diff --git a/crates/core/src/properties/tests_fixture.rs b/crates/core/src/properties/tests_fixture.rs new file mode 100644 index 00000000..5dfc8fa0 --- /dev/null +++ b/crates/core/src/properties/tests_fixture.rs @@ -0,0 +1,125 @@ +//! Shared contour property fixtures and their smoke test. + +use super::*; + +/// The default plane: values running -7..8, so its noise estimate is an +/// ordinary fraction of its peak and no contour floor is ever reached. +fn default_plane() -> Vec { + (0..16).map(|value| f64::from(value) - 7.0).collect() +} + +fn nmr2d_with(source: &str, values: &[f64]) -> plotx_io::NmrData2D { + let dimension = |nucleus: &str| plotx_io::Dim { + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 0.0, + nucleus: nucleus.to_owned(), + group_delay: 0.0, + }; + plotx_io::NmrData2D { + data: values + .iter() + .map(|value| num_complex::Complex64::new(*value, 0.5)) + .collect(), + rows: 4, + cols: 4, + domain: plotx_io::Domain::Frequency, + direct: dimension("1H"), + indirect: dimension("13C"), + quad: plotx_io::QuadMode::Complex, + indirect_conjugate: false, + experiment: None, + pseudo_axis: None, + diffusion: None, + nus: None, + source: source.to_owned(), + } +} + +pub(super) fn nmr1d_with(source: &str) -> plotx_io::NmrData { + plotx_io::NmrData { + points: (0..32) + .map(|value| num_complex::Complex64::new(f64::from(value), 0.0)) + .collect(), + domain: plotx_io::Domain::Frequency, + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 0.0, + nucleus: "1H".to_owned(), + source: source.to_owned(), + group_delay: 0.0, + } +} + +/// One page holding one plot bound to a true-2D spectrum, i.e. the exact shape +/// the driving case has: a contour drawn from a signed scalar grid. +pub(crate) fn contour_app() -> (PlotxApp, TargetRef) { + contour_app_with_plane(&default_plane()) +} + +/// The same page over a plane the caller chooses, so a test can put a field of +/// a given dynamic range in front of the catalog. +pub(crate) fn contour_app_with_plane(values: &[f64]) -> (PlotxApp, TargetRef) { + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(nmr2d_with( + "contour", values, + ))))); + let mut canvas = CanvasDocument::new("page".to_owned(), [120.0, 80.0]); + let id = canvas.allocate_object_id(); + let object = app.build_plot_object( + 0, + ObjectFrame::new(0.0, 0.0, 100.0, 80.0), + id, + "Plot".into(), + ); + canvas.objects.push(object); + app.doc.canvases.push(canvas); + app.session.active_canvas = Some(0); + let series = app.doc.canvases[0] + .object(id) + .and_then(|object| object.plot()) + .and_then(|plot| plot.binding.series.first()) + .map(|series| series.id) + .expect("the plot has a series"); + let target = app.series_target(0, id, series).expect("target resolves"); + (app, target) +} + +pub(crate) fn contour_spec(app: &PlotxApp, target: &TargetRef) -> plotx_figure::ContourSpec { + let Some(ComponentRef::Series(series)) = target.component else { + panic!("the fixture addresses a series"); + }; + let binding = &app.doc.canvases[0] + .object( + target + .resource + .local_id + .as_deref() + .unwrap() + .parse() + .unwrap(), + ) + .and_then(|object| object.plot()) + .expect("plot") + .binding; + match &binding + .series + .iter() + .find(|candidate| candidate.id == series) + .expect("series") + .encoding + { + plotx_figure::SeriesEncoding::Contour(spec) => spec.clone(), + other => panic!("expected a contour, got {other:?}"), + } +} + +#[test] +fn the_fixture_draws_a_contour() { + let (app, target) = contour_app(); + let address = PropertyAddress::new(target.clone(), contour::BASE_MAGNITUDE); + let resolved = app.resolve_property(&address).expect("contour resolves"); + assert_eq!(resolved.availability, Availability::Editable); +} diff --git a/crates/core/src/properties/transaction.rs b/crates/core/src/properties/transaction.rs index d58c24d9..bd9d7125 100644 --- a/crates/core/src/properties/transaction.rs +++ b/crates/core/src/properties/transaction.rs @@ -5,11 +5,18 @@ //! and a future app preference from being dispatched by `ScopeKind` in the //! planner. -use crate::actions::{Action, DatasetProcessingState}; +use crate::actions::{Action, DatasetProcessingState, PageSizeState}; +use crate::layout::PageLayout; use crate::settings::Settings; -use crate::state::{DataBinding, DatasetId, ObjectId, PlotxApp}; +use crate::state::{ + AxisOverrides, CanvasId, DataBinding, DatasetId, ObjectId, PanelLabelStyle, PlotxApp, +}; use plotx_figure::FigureTypography; +#[path = "transaction_object.rs"] +mod object; +use object::{ObjectPlans, ObjectTargetSnapshot}; + /// Persistence boundaries a provider can select. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum StorageClass { @@ -30,6 +37,9 @@ impl StorageClass { #[derive(Default)] pub(crate) struct PropertyTransaction { bindings: BindingPlan, + canvases: Vec, + axis_overrides: Vec, + objects: ObjectPlans, typography: Option<(FigureTypography, FigureTypography)>, processing: Vec<(DatasetId, DatasetProcessingState, DatasetProcessingState)>, settings: Option<(Settings, Settings)>, @@ -47,6 +57,16 @@ enum TargetSnapshot { before: DataBinding, }, Typography(FigureTypography), + Canvas { + id: CanvasId, + before: CanvasPropertyState, + }, + AxisOverrides { + canvas: usize, + object: ObjectId, + before: AxisOverrides, + }, + Object(ObjectTargetSnapshot), Processing { dataset: DatasetId, before: DatasetProcessingState, @@ -57,6 +77,46 @@ enum TargetSnapshot { }, } +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct CanvasPropertyState { + pub(crate) layout: PageLayout, + pub(crate) page_size: PageSizeState, + pub(crate) auto_height: bool, + pub(crate) caption: (String, bool), + pub(crate) panel_label_style: PanelLabelStyle, +} + +impl CanvasPropertyState { + fn of(canvas: &crate::state::CanvasDocument) -> Self { + Self { + layout: canvas.layout, + page_size: PageSizeState::of(canvas), + auto_height: canvas.auto_height, + caption: (canvas.caption.clone(), canvas.caption_visible), + panel_label_style: canvas.panel_label_style, + } + } +} + +struct CanvasPlan { + id: CanvasId, + before: CanvasPropertyState, + after: CanvasPropertyState, +} + +struct AxisOverridesPlan { + canvas: usize, + object: ObjectId, + before: AxisOverrides, + after: AxisOverrides, +} + +#[derive(Clone)] +pub(crate) enum CanvasDirectEdit { + ShowGrid { canvas: CanvasId, show: bool }, + AutoHeight { canvas: CanvasId, enabled: bool }, +} + impl PropertyTransaction { /// Start measuring one provider operation. The service calls this around /// every target so it can report a same-value write instead of claiming an @@ -82,6 +142,21 @@ impl PropertyTransaction { TargetSnapshot::Typography(before) => { self.typography.is_some_and(|(_, after)| after != *before) } + TargetSnapshot::Canvas { id, before } => self + .canvases + .iter() + .find(|plan| plan.id == *id) + .is_some_and(|plan| plan.after != *before), + TargetSnapshot::AxisOverrides { + canvas, + object, + before, + } => self + .axis_overrides + .iter() + .find(|plan| plan.canvas == *canvas && plan.object == *object) + .is_some_and(|plan| plan.after != *before), + TargetSnapshot::Object(snapshot) => self.objects.target_changed(snapshot), TargetSnapshot::Processing { dataset, before } => self .processing .iter() @@ -120,6 +195,25 @@ impl PropertyTransaction { *after = *before; } } + TargetSnapshot::Canvas { id, before } => { + if let Some(plan) = self.canvases.iter_mut().find(|plan| plan.id == *id) { + plan.after = before.clone(); + } + } + TargetSnapshot::AxisOverrides { + canvas, + object, + before, + } => { + if let Some(plan) = self + .axis_overrides + .iter_mut() + .find(|plan| plan.canvas == *canvas && plan.object == *object) + { + plan.after = before.clone(); + } + } + TargetSnapshot::Object(snapshot) => self.objects.rollback(snapshot), TargetSnapshot::Processing { dataset, before } => { if let Some((_, _, after)) = self .processing @@ -185,6 +279,113 @@ impl PropertyTransaction { typography } + /// Select one canvas by stable identity. Its collection position is used + /// only for this lookup and never leaves the function. + pub(crate) fn canvas( + &mut self, + app: &PlotxApp, + id: CanvasId, + ) -> Result<&mut CanvasPropertyState, super::PropertyError> { + let plan_index = if let Some(index) = self.canvases.iter().position(|plan| plan.id == id) { + index + } else { + let index = app + .doc + .canvas_index(id) + .ok_or_else(|| super::PropertyError::UnknownTarget(id.to_string()))?; + let state = CanvasPropertyState::of(&app.doc.canvases[index]); + self.canvases.push(CanvasPlan { + id, + before: state.clone(), + after: state, + }); + self.canvases.len() - 1 + }; + if !self.target_before.iter().any( + |snapshot| matches!(snapshot, TargetSnapshot::Canvas { id: candidate, .. } if *candidate == id), + ) { + self.target_before.push(TargetSnapshot::Canvas { + id, + before: self.canvases[plan_index].after.clone(), + }); + } + Ok(&mut self.canvases[plan_index].after) + } + + /// Stage the spacing mode through the same typed action constructor used + /// by `PlotxApp::set_spacing_mode`. + pub(crate) fn set_canvas_spacing_mode( + &mut self, + app: &PlotxApp, + id: CanvasId, + mode: crate::layout::SpacingMode, + ) -> Result<(), super::PropertyError> { + self.canvas(app, id)?.layout.spacing_mode = mode; + Ok(()) + } + + /// Stage grid visibility for the direct, non-undoable + /// `PlotxApp::set_show_grid` commit path. + pub(crate) fn set_canvas_show_grid( + &mut self, + app: &PlotxApp, + id: CanvasId, + show: bool, + ) -> Result<(), super::PropertyError> { + self.canvas(app, id)?.layout.show_grid = show; + Ok(()) + } + + /// Select one plot's complete axis override value. The existing + /// `SetAxisOverrides` action is the undo and persistence boundary. + pub(crate) fn axis_overrides( + &mut self, + app: &PlotxApp, + canvas: usize, + object: ObjectId, + ) -> Result<&mut AxisOverrides, super::PropertyError> { + let plan_index = if let Some(index) = self + .axis_overrides + .iter() + .position(|plan| plan.canvas == canvas && plan.object == object) + { + index + } else { + let current = app + .doc + .canvases + .get(canvas) + .and_then(|canvas| canvas.object(object)) + .and_then(|object| object.plot()) + .map(|plot| plot.axis_overrides.clone()) + .ok_or_else(|| super::PropertyError::UnknownTarget(object.to_string()))?; + self.axis_overrides.push(AxisOverridesPlan { + canvas, + object, + before: current.clone(), + after: current, + }); + self.axis_overrides.len() - 1 + }; + if !self.target_before.iter().any(|snapshot| { + matches!( + snapshot, + TargetSnapshot::AxisOverrides { + canvas: candidate_canvas, + object: candidate_object, + .. + } if *candidate_canvas == canvas && *candidate_object == object + ) + }) { + self.target_before.push(TargetSnapshot::AxisOverrides { + canvas, + object, + before: self.axis_overrides[plan_index].after.clone(), + }); + } + Ok(&mut self.axis_overrides[plan_index].after) + } + /// Select one dataset's existing processing snapshot for mutation. The /// provider chooses this store; the service never switches on scope to do /// so. Multiple component edits to one dataset still become one action. @@ -245,6 +446,9 @@ impl PropertyTransaction { pub(crate) fn storage_classes(&self) -> Vec { let mut classes = Vec::with_capacity(2); if !self.bindings.entries.is_empty() + || !self.canvases.is_empty() + || !self.axis_overrides.is_empty() + || !self.objects.is_empty() || self.typography.is_some() || !self.processing.is_empty() { @@ -273,10 +477,78 @@ impl PropertyTransaction { pub(crate) fn into_commit( self, + app: &PlotxApp, applied: Vec, skipped: Vec, ) -> super::PropertyCommit { let mut actions = self.bindings.into_actions(); + actions.extend( + self.axis_overrides + .into_iter() + .filter(|plan| plan.before != plan.after) + .map(|plan| { + Action::set_axis_overrides(plan.canvas, plan.object, plan.before, plan.after) + }), + ); + actions.extend(self.objects.into_actions()); + let mut canvas_direct = Vec::new(); + for plan in self.canvases { + let Some(canvas) = app.doc.canvas_index(plan.id) else { + continue; + }; + let mut layout_after = plan.after.layout; + layout_after.show_grid = plan.before.layout.show_grid; + if plan.before.layout != layout_after { + let mut without_spacing = layout_after; + without_spacing.spacing_mode = plan.before.layout.spacing_mode; + if without_spacing == plan.before.layout { + actions.push(Action::set_spacing_mode( + canvas, + plan.before.layout, + layout_after.spacing_mode, + )); + } else { + actions.push(Action::set_page_layout( + canvas, + plan.before.layout, + layout_after, + )); + } + } + if plan.before.page_size != plan.after.page_size { + actions.push(Action::set_canvas_size( + canvas, + plan.before.page_size, + plan.after.page_size, + )); + } + if plan.before.caption != plan.after.caption { + actions.push(Action::set_canvas_caption( + canvas, + plan.before.caption, + plan.after.caption, + )); + } + if plan.before.panel_label_style != plan.after.panel_label_style { + actions.push(Action::SetPanelLabelStyle { + canvas, + before: plan.before.panel_label_style, + after: plan.after.panel_label_style, + }); + } + if plan.before.layout.show_grid != plan.after.layout.show_grid { + canvas_direct.push(CanvasDirectEdit::ShowGrid { + canvas: plan.id, + show: plan.after.layout.show_grid, + }); + } + if plan.before.auto_height != plan.after.auto_height { + canvas_direct.push(CanvasDirectEdit::AutoHeight { + canvas: plan.id, + enabled: plan.after.auto_height, + }); + } + } if let Some((before, after)) = self.typography && before != after { @@ -296,6 +568,7 @@ impl PropertyTransaction { .and_then(|(before, after)| (before != after).then_some(after)); super::PropertyCommit { document_action, + canvas_direct, app_preferences, applied, skipped, diff --git a/crates/core/src/properties/transaction_object.rs b/crates/core/src/properties/transaction_object.rs new file mode 100644 index 00000000..44e8b952 --- /dev/null +++ b/crates/core/src/properties/transaction_object.rs @@ -0,0 +1,356 @@ +use super::{PropertyTransaction, TargetSnapshot}; +use crate::actions::Action; +use crate::state::{ChartSpec, ObjectId, ObjectStyle, PanelMeta, PlotxApp, StackSpec}; + +type ObjectFlags = (bool, bool); +type ObjectFlagPlan = (usize, ObjectId, ObjectFlags, ObjectFlags); + +#[derive(Default)] +pub(super) struct ObjectPlans { + stacks: Vec<(usize, ObjectId, StackSpec, StackSpec)>, + charts: Vec<(usize, ObjectId, ChartSpec, ChartSpec)>, + panels: Vec<(usize, ObjectId, PanelMeta, PanelMeta)>, + flags: Vec, + styles: Vec<(usize, ObjectId, ObjectStyle, ObjectStyle)>, +} + +pub(super) enum ObjectTargetSnapshot { + Stack(usize, ObjectId, StackSpec), + Chart(usize, ObjectId, ChartSpec), + Panel(usize, ObjectId, PanelMeta), + Flags(usize, ObjectId, ObjectFlags), + Style(usize, ObjectId, ObjectStyle), +} + +impl ObjectPlans { + pub(super) fn is_empty(&self) -> bool { + self.stacks.is_empty() + && self.charts.is_empty() + && self.panels.is_empty() + && self.flags.is_empty() + && self.styles.is_empty() + } + + pub(super) fn target_changed(&self, snapshot: &ObjectTargetSnapshot) -> bool { + match snapshot { + ObjectTargetSnapshot::Stack(canvas, object, before) => self + .stacks + .iter() + .find(|entry| entry.0 == *canvas && entry.1 == *object) + .is_some_and(|entry| entry.3 != *before), + ObjectTargetSnapshot::Chart(canvas, object, before) => self + .charts + .iter() + .find(|entry| entry.0 == *canvas && entry.1 == *object) + .is_some_and(|entry| entry.3 != *before), + ObjectTargetSnapshot::Panel(canvas, object, before) => self + .panels + .iter() + .find(|entry| entry.0 == *canvas && entry.1 == *object) + .is_some_and(|entry| entry.3 != *before), + ObjectTargetSnapshot::Flags(canvas, object, before) => self + .flags + .iter() + .find(|entry| entry.0 == *canvas && entry.1 == *object) + .is_some_and(|entry| entry.3 != *before), + ObjectTargetSnapshot::Style(canvas, object, before) => self + .styles + .iter() + .find(|entry| entry.0 == *canvas && entry.1 == *object) + .is_some_and(|entry| entry.3 != *before), + } + } + + pub(super) fn rollback(&mut self, snapshot: &ObjectTargetSnapshot) { + match snapshot { + ObjectTargetSnapshot::Stack(canvas, object, before) => { + if let Some(entry) = self + .stacks + .iter_mut() + .find(|entry| entry.0 == *canvas && entry.1 == *object) + { + entry.3 = *before; + } + } + ObjectTargetSnapshot::Chart(canvas, object, before) => { + if let Some(entry) = self + .charts + .iter_mut() + .find(|entry| entry.0 == *canvas && entry.1 == *object) + { + entry.3 = before.clone(); + } + } + ObjectTargetSnapshot::Panel(canvas, object, before) => { + if let Some(entry) = self + .panels + .iter_mut() + .find(|entry| entry.0 == *canvas && entry.1 == *object) + { + entry.3 = before.clone(); + } + } + ObjectTargetSnapshot::Flags(canvas, object, before) => { + if let Some(entry) = self + .flags + .iter_mut() + .find(|entry| entry.0 == *canvas && entry.1 == *object) + { + entry.3 = *before; + } + } + ObjectTargetSnapshot::Style(canvas, object, before) => { + if let Some(entry) = self + .styles + .iter_mut() + .find(|entry| entry.0 == *canvas && entry.1 == *object) + { + entry.3 = before.clone(); + } + } + } + } + + pub(super) fn into_actions(self) -> Vec { + let mut actions = Vec::new(); + actions.extend( + self.stacks + .into_iter() + .filter(|(_, _, before, after)| before != after) + .map(|(canvas, object, before, after)| { + Action::set_stack_spec(canvas, object, before, after) + }), + ); + actions.extend( + self.charts + .into_iter() + .filter(|(_, _, before, after)| before != after) + .map(|(canvas, object, before, after)| { + Action::set_chart_type(canvas, object, before, after) + }), + ); + actions.extend( + self.panels + .into_iter() + .filter(|(_, _, before, after)| before != after) + .map(|(canvas, object, before, after)| { + Action::set_panel_meta(canvas, object, before, after) + }), + ); + actions.extend( + self.flags + .into_iter() + .filter(|(_, _, before, after)| before != after) + .map(|(canvas, object, before, after)| { + Action::set_object_flags(canvas, object, before, after) + }), + ); + actions.extend( + self.styles + .into_iter() + .filter(|(_, _, before, after)| before != after) + .map(|(canvas, object, before, after)| { + Action::set_object_style(canvas, vec![(object, before)], vec![(object, after)]) + }), + ); + actions + } +} + +impl PropertyTransaction { + pub(crate) fn stack_spec( + &mut self, + app: &PlotxApp, + canvas: usize, + object: ObjectId, + ) -> Result<&mut StackSpec, crate::properties::PropertyError> { + let index = if let Some(index) = self + .objects + .stacks + .iter() + .position(|entry| entry.0 == canvas && entry.1 == object) + { + index + } else { + let current = plot(app, canvas, object)?.stack; + self.objects.stacks.push((canvas, object, current, current)); + self.objects.stacks.len() - 1 + }; + if !has_object_snapshot(&self.target_before, canvas, object, "stack") { + self.target_before + .push(TargetSnapshot::Object(ObjectTargetSnapshot::Stack( + canvas, + object, + self.objects.stacks[index].3, + ))); + } + Ok(&mut self.objects.stacks[index].3) + } + + pub(crate) fn chart_spec( + &mut self, + app: &PlotxApp, + canvas: usize, + object: ObjectId, + ) -> Result<&mut ChartSpec, crate::properties::PropertyError> { + let index = if let Some(index) = self + .objects + .charts + .iter() + .position(|entry| entry.0 == canvas && entry.1 == object) + { + index + } else { + let current = plot(app, canvas, object)?.chart.clone(); + self.objects + .charts + .push((canvas, object, current.clone(), current)); + self.objects.charts.len() - 1 + }; + if !has_object_snapshot(&self.target_before, canvas, object, "chart") { + self.target_before + .push(TargetSnapshot::Object(ObjectTargetSnapshot::Chart( + canvas, + object, + self.objects.charts[index].3.clone(), + ))); + } + Ok(&mut self.objects.charts[index].3) + } + + pub(crate) fn panel_meta( + &mut self, + app: &PlotxApp, + canvas: usize, + object: ObjectId, + ) -> Result<&mut PanelMeta, crate::properties::PropertyError> { + let index = if let Some(index) = self + .objects + .panels + .iter() + .position(|entry| entry.0 == canvas && entry.1 == object) + { + index + } else { + let current = plot(app, canvas, object)?.panel.clone(); + self.objects + .panels + .push((canvas, object, current.clone(), current)); + self.objects.panels.len() - 1 + }; + if !has_object_snapshot(&self.target_before, canvas, object, "panel") { + self.target_before + .push(TargetSnapshot::Object(ObjectTargetSnapshot::Panel( + canvas, + object, + self.objects.panels[index].3.clone(), + ))); + } + Ok(&mut self.objects.panels[index].3) + } + + pub(crate) fn object_flags( + &mut self, + app: &PlotxApp, + canvas: usize, + object: ObjectId, + ) -> Result<&mut (bool, bool), crate::properties::PropertyError> { + let index = if let Some(index) = self + .objects + .flags + .iter() + .position(|entry| entry.0 == canvas && entry.1 == object) + { + index + } else { + let current = canvas_object(app, canvas, object)?; + let flags = (current.visible, current.locked); + self.objects.flags.push((canvas, object, flags, flags)); + self.objects.flags.len() - 1 + }; + if !has_object_snapshot(&self.target_before, canvas, object, "flags") { + self.target_before + .push(TargetSnapshot::Object(ObjectTargetSnapshot::Flags( + canvas, + object, + self.objects.flags[index].3, + ))); + } + Ok(&mut self.objects.flags[index].3) + } + + pub(crate) fn object_style( + &mut self, + app: &PlotxApp, + canvas: usize, + object: ObjectId, + ) -> Result<&mut ObjectStyle, crate::properties::PropertyError> { + let index = if let Some(index) = self + .objects + .styles + .iter() + .position(|entry| entry.0 == canvas && entry.1 == object) + { + index + } else { + let current = canvas_object(app, canvas, object)?.style().ok_or_else(|| { + crate::properties::PropertyError::UnknownTarget(object.to_string()) + })?; + self.objects + .styles + .push((canvas, object, current.clone(), current)); + self.objects.styles.len() - 1 + }; + if !has_object_snapshot(&self.target_before, canvas, object, "style") { + self.target_before + .push(TargetSnapshot::Object(ObjectTargetSnapshot::Style( + canvas, + object, + self.objects.styles[index].3.clone(), + ))); + } + Ok(&mut self.objects.styles[index].3) + } +} + +fn has_object_snapshot( + snapshots: &[TargetSnapshot], + canvas: usize, + object: ObjectId, + kind: &str, +) -> bool { + snapshots.iter().any(|snapshot| { + let TargetSnapshot::Object(snapshot) = snapshot else { + return false; + }; + match snapshot { + ObjectTargetSnapshot::Stack(c, o, _) => kind == "stack" && *c == canvas && *o == object, + ObjectTargetSnapshot::Chart(c, o, _) => kind == "chart" && *c == canvas && *o == object, + ObjectTargetSnapshot::Panel(c, o, _) => kind == "panel" && *c == canvas && *o == object, + ObjectTargetSnapshot::Flags(c, o, _) => kind == "flags" && *c == canvas && *o == object, + ObjectTargetSnapshot::Style(c, o, _) => kind == "style" && *c == canvas && *o == object, + } + }) +} + +fn canvas_object( + app: &PlotxApp, + canvas: usize, + object: ObjectId, +) -> Result<&crate::state::CanvasObject, crate::properties::PropertyError> { + app.doc + .canvases + .get(canvas) + .and_then(|canvas| canvas.object(object)) + .ok_or_else(|| crate::properties::PropertyError::UnknownTarget(object.to_string())) +} + +fn plot( + app: &PlotxApp, + canvas: usize, + object: ObjectId, +) -> Result<&crate::state::PlotObject, crate::properties::PropertyError> { + canvas_object(app, canvas, object)? + .plot() + .ok_or_else(|| crate::properties::PropertyError::UnknownTarget(object.to_string())) +} diff --git a/crates/core/src/properties/typography.rs b/crates/core/src/properties/typography.rs index 1a6b3653..b39abdad 100644 --- a/crates/core/src/properties/typography.rs +++ b/crates/core/src/properties/typography.rs @@ -4,13 +4,15 @@ use super::provider::PropertyProvider; use super::target::require_document_target; use super::{ AggregateValue, Applicability, Availability, ComponentKind, DefaultPolicy, EditOp, FloatBounds, - PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, PropertyId, + FloatDisplay, PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, PropertyId, PropertyTransaction, PropertyValue, ResolvedProperty, ResolvedSchema, ScopeKind, Tier, ValueCopies, ValueSchema, definition, }; use crate::state::PlotxApp; pub const TICK_PT: PropertyId = PropertyId("document.figure.typography.tick_pt"); +pub const LABEL_PT: PropertyId = PropertyId("document.figure.typography.label_pt"); +pub const TITLE_PT: PropertyId = PropertyId("document.figure.typography.title_pt"); const POINT_BOUNDS: FloatBounds = FloatBounds::inclusive(1.0, 72.0); /// A quarter point per drag notch: point sizes are chosen to a half point, and @@ -18,22 +20,50 @@ const POINT_BOUNDS: FloatBounds = FloatBounds::inclusive(1.0, 72.0); /// about how finely the value is usually set. const POINT_STEP: f64 = 0.25; -pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[PropertyDefinition { - id: TICK_PT, - scope_kind: ScopeKind::Document, - value_schema: ValueSchema::Float { - bounds: POINT_BOUNDS, - log: false, - drag_step: Some(POINT_STEP), - }, - access: PropertyAccess::ReadWrite, - applicability: Applicability::component(ComponentKind::None), - default_policy: DefaultPolicy::Fixed(PropertyValue::Float(7.0)), - tier: Tier::Essential, - copies: ValueCopies::PerTarget, - canonical_label: "Figure tick-label size", - canonical_aliases: &["figure typography", "tick size", "font size", "points"], -}]; +const fn typography_definition( + id: PropertyId, + default: f64, + label: &'static str, + aliases: &'static [&'static str], +) -> PropertyDefinition { + PropertyDefinition { + id, + scope_kind: ScopeKind::Document, + value_schema: ValueSchema::Float { + bounds: POINT_BOUNDS, + display: FloatDisplay::Linear("pt"), + drag_step: Some(POINT_STEP), + }, + access: PropertyAccess::ReadWrite, + applicability: Applicability::component(ComponentKind::None), + default_policy: DefaultPolicy::Fixed(PropertyValue::Float(default)), + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: label, + canonical_aliases: aliases, + } +} + +pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ + typography_definition( + TICK_PT, + 7.0, + "Figure tick-label size", + &["figure typography", "tick size", "font size", "points"], + ), + typography_definition( + LABEL_PT, + 8.0, + "Figure axis-title size", + &["axis title size", "axis label size", "figure typography"], + ), + typography_definition( + TITLE_PT, + 8.0, + "Figure title size", + &["title size", "figure heading", "figure typography"], + ), +]; pub(crate) struct TypographyProvider; @@ -53,22 +83,27 @@ impl PropertyProvider for TypographyProvider { PropertyError::UnknownProperty(address.definition.as_str().to_owned()) })?; require_document_target(&address.target, definition)?; + let typography = app.doc.style_library.figure_typography; Ok(ResolvedProperty { address: address.clone(), - value: AggregateValue::Uniform(PropertyValue::Float(f64::from( - app.doc.style_library.figure_typography.tick_pt, - ))), - default_value: match definition.default_policy { - DefaultPolicy::Fixed(value) => Some(value), + modified: None, + value: AggregateValue::Uniform(PropertyValue::Float(f64::from(match definition.id { + TICK_PT => typography.tick_pt, + LABEL_PT => typography.label_pt, + TITLE_PT => typography.title_pt, + _ => return Err(PropertyError::UnknownProperty(definition.id.to_string())), + }))), + default_value: match &definition.default_policy { + DefaultPolicy::Fixed(value) => Some(value.clone()), DefaultPolicy::EncodingFactory | DefaultPolicy::ProcessingFactory + | DefaultPolicy::Derived | DefaultPolicy::None => None, }, availability: Availability::Editable, schema: ResolvedSchema::Float { bounds: POINT_BOUNDS, - log: false, - unit: "pt", + display: FloatDisplay::Linear("pt"), }, }) } @@ -78,7 +113,7 @@ impl PropertyProvider for TypographyProvider { app: &PlotxApp, transaction: &mut PropertyTransaction, address: &PropertyAddress, - operation: EditOp, + operation: &EditOp<'_>, ) -> Result<(), PropertyError> { let definition = definition(address.definition).ok_or_else(|| { PropertyError::UnknownProperty(address.definition.as_str().to_owned()) @@ -86,7 +121,7 @@ impl PropertyProvider for TypographyProvider { require_document_target(&address.target, definition)?; let value = match operation { EditOp::Set(PropertyValue::Float(value)) => { - POINT_BOUNDS.check(definition.id, "tick-label size", value)? + POINT_BOUNDS.check(definition.id, definition.canonical_label, *value)? } EditOp::Set(value) => { return Err(PropertyError::InvalidValue { @@ -94,11 +129,12 @@ impl PropertyProvider for TypographyProvider { message: format!("expected a number, got {}", value.kind()), }); } - EditOp::Reset => match definition.default_policy { - DefaultPolicy::Fixed(PropertyValue::Float(value)) => value, + EditOp::Reset => match &definition.default_policy { + DefaultPolicy::Fixed(PropertyValue::Float(value)) => *value, DefaultPolicy::Fixed(_) | DefaultPolicy::EncodingFactory | DefaultPolicy::ProcessingFactory + | DefaultPolicy::Derived | DefaultPolicy::None => { return Err(PropertyError::InvalidValue { property: definition.id, @@ -113,7 +149,13 @@ impl PropertyProvider for TypographyProvider { }); } }; - transaction.figure_typography(app).tick_pt = value as f32; + let typography = transaction.figure_typography(app); + match definition.id { + TICK_PT => typography.tick_pt = value as f32, + LABEL_PT => typography.label_pt = value as f32, + TITLE_PT => typography.title_pt = value as f32, + _ => return Err(PropertyError::UnknownProperty(definition.id.to_string())), + } Ok(()) } } diff --git a/crates/core/src/properties/zero_fill.rs b/crates/core/src/properties/zero_fill.rs new file mode 100644 index 00000000..206f209a --- /dev/null +++ b/crates/core/src/properties/zero_fill.rs @@ -0,0 +1,328 @@ +//! Dataset-owned zero-fill-step properties. + +use super::processing_common::{ + no_factory_default, no_step_gesture, property_definition, raw_point_count, step_context, + step_mut, wrong_kind, +}; +use super::provider::PropertyProvider; +use super::{ + AggregateValue, Applicability, Availability, ComponentKind, DefaultPolicy, EditOp, EnumVariant, + PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, PropertyId, + PropertyReadout, PropertyTransaction, PropertyValue, ResolvedProperty, ResolvedSchema, + ScopeKind, Tier, ValueCopies, ValueSchema, ZeroFillTargetReadout, +}; +use crate::state::PlotxApp; +use plotx_processing::{StepKind, ZeroFill}; + +pub const MODE: PropertyId = PropertyId("dataset.processing.zero_fill.mode"); +pub const POINTS: PropertyId = PropertyId("dataset.processing.zero_fill.points"); + +pub const NONE: &str = "none"; +pub const X2: &str = "x2"; +pub const X4: &str = "x4"; +pub const X8: &str = "x8"; +pub const CUSTOM: &str = "custom"; + +const MODES: &[EnumVariant] = &[ + EnumVariant::new(NONE, "None"), + EnumVariant::new(X2, "×2"), + EnumVariant::new(X4, "×4"), + EnumVariant::new(X8, "×8"), + EnumVariant::new(CUSTOM, "Custom"), +]; +const STEP: Applicability = Applicability::component(ComponentKind::ProcessingStep); + +pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ + PropertyDefinition { + id: MODE, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::Enum { variants: MODES }, + access: PropertyAccess::ReadWrite, + applicability: STEP, + default_policy: DefaultPolicy::ProcessingFactory, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Zero-fill mode", + canonical_aliases: &["zero fill", "FFT size", "padding factor"], + }, + PropertyDefinition { + id: POINTS, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::IntWithDrag { + min: 1, + max: i64::MAX, + drag_step: 256.0, + }, + access: PropertyAccess::ReadWrite, + applicability: STEP, + default_policy: DefaultPolicy::ProcessingFactory, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Zero-fill points", + canonical_aliases: &["FFT points", "padded points", "zero-fill size"], + }, +]; + +pub(crate) struct ZeroFillProvider; + +pub(crate) static PROVIDER: ZeroFillProvider = ZeroFillProvider; + +impl PropertyProvider for ZeroFillProvider { + fn definitions(&self) -> &'static [PropertyDefinition] { + DEFINITIONS + } + + fn read( + &self, + app: &PlotxApp, + address: &PropertyAddress, + ) -> Result { + let definition = property_definition(address.definition)?; + let context = step_context(app, address, definition, |kind| { + matches!(kind, StepKind::ZeroFill(_)) + })?; + let StepKind::ZeroFill(current) = context.step.kind else { + unreachable!("the shared context checked the step kind"); + }; + let raw = raw_point_count(context.dataset, context.axis); + let factory = context.factory.as_ref().and_then(|step| match step.kind { + StepKind::ZeroFill(value) => Some(value), + _ => None, + }); + Ok(ResolvedProperty { + address: address.clone(), + modified: None, + value: AggregateValue::Uniform(value_of(definition, current, raw)?), + default_value: default_value(definition, factory, raw)?, + availability: Availability::Editable, + schema: schema_for(definition, current, raw)?, + }) + } + + fn edit( + &self, + app: &PlotxApp, + transaction: &mut PropertyTransaction, + address: &PropertyAddress, + operation: &EditOp<'_>, + ) -> Result<(), PropertyError> { + let definition = property_definition(address.definition)?; + let context = step_context(app, address, definition, |kind| { + matches!(kind, StepKind::ZeroFill(_)) + })?; + let StepKind::ZeroFill(current) = context.step.kind else { + unreachable!("the shared context checked the step kind"); + }; + let raw = raw_point_count(context.dataset, context.axis); + let factory = context.factory.as_ref().and_then(|step| match step.kind { + StepKind::ZeroFill(value) => Some(value), + _ => None, + }); + let value = match operation { + EditOp::Set(value) => checked_value(definition, current, raw, value)?, + EditOp::Reset => default_value(definition, factory, raw)? + .ok_or_else(|| no_factory_default(definition))?, + EditOp::Step(_) => return Err(no_step_gesture(definition)), + }; + let state = transaction.processing_state(app, context.dataset_id)?; + let step = step_mut(state, context.step.id, &address.target)?; + let StepKind::ZeroFill(current) = &mut step.kind else { + return Err(PropertyError::NotApplicable( + "the addressed step is no longer a zero-fill step".to_owned(), + )); + }; + write(definition, current, raw, value) + } + + fn readout( + &self, + app: &PlotxApp, + address: &PropertyAddress, + ) -> Result { + let definition = property_definition(address.definition)?; + let context = step_context(app, address, definition, |kind| { + matches!(kind, StepKind::ZeroFill(_)) + })?; + let StepKind::ZeroFill(current) = context.step.kind else { + unreachable!("the shared context checked the step kind"); + }; + Ok(PropertyReadout::ZeroFillTarget(ZeroFillTargetReadout { + points: current.target(raw_point_count(context.dataset, context.axis)), + })) + } +} + +fn value_of( + definition: &'static PropertyDefinition, + current: ZeroFill, + raw: usize, +) -> Result { + match definition.id { + MODE => Ok(PropertyValue::Enum(mode_of(current))), + POINTS if mode_of(current) == CUSTOM => { + Ok(PropertyValue::Int(as_i64(current.target(raw), definition)?)) + } + POINTS => Err(points_unavailable(current)), + _ => Err(PropertyError::UnknownProperty(definition.id.to_string())), + } +} + +fn default_value( + definition: &'static PropertyDefinition, + factory: Option, + raw: usize, +) -> Result, PropertyError> { + let Some(factory) = factory else { + return Ok(None); + }; + match definition.id { + MODE => Ok(Some(PropertyValue::Enum(mode_of(factory)))), + POINTS => Ok(Some(PropertyValue::Int(as_i64( + factory.target(raw), + definition, + )?))), + _ => Err(PropertyError::UnknownProperty(definition.id.to_string())), + } +} + +fn schema_for( + definition: &'static PropertyDefinition, + current: ZeroFill, + raw: usize, +) -> Result { + match definition.id { + MODE => Ok(ResolvedSchema::Enum { + variants: MODES.iter().collect(), + }), + POINTS if mode_of(current) == CUSTOM => Ok(ResolvedSchema::IntWithDrag { + min: as_i64(raw, definition)?, + max: i64::MAX, + drag_step: 256.0, + unit: "points", + }), + POINTS => Err(points_unavailable(current)), + _ => Err(PropertyError::UnknownProperty(definition.id.to_string())), + } +} + +fn checked_value( + definition: &'static PropertyDefinition, + current: ZeroFill, + raw: usize, + value: &PropertyValue, +) -> Result { + match (definition.id, value) { + (MODE, PropertyValue::Enum(value)) if MODES.iter().any(|variant| variant.id == *value) => { + Ok(PropertyValue::Enum(value)) + } + (MODE, PropertyValue::Enum(value)) => Err(PropertyError::InvalidValue { + property: definition.id, + message: format!("'{value}' is not a zero-fill mode"), + }), + (MODE, value) => Err(wrong_kind(definition, value, "a zero-fill mode")), + (POINTS, PropertyValue::Int(value)) if mode_of(current) == CUSTOM => { + let raw = as_i64(raw, definition)?; + if *value < raw { + return Err(PropertyError::InvalidValue { + property: definition.id, + message: format!( + "zero-fill points {value} is out of range: it must be at least the dataset's {raw} original points" + ), + }); + } + usize::try_from(*value).map_err(|_| PropertyError::InvalidValue { + property: definition.id, + message: format!( + "zero-fill points {value} is out of range: this platform supports at most {} points", + usize::MAX + ), + })?; + Ok(PropertyValue::Int(*value)) + } + (POINTS, PropertyValue::Int(_)) => Err(points_unavailable(current)), + (POINTS, value) => Err(wrong_kind(definition, value, "an integer point count")), + (_, _) => Err(PropertyError::UnknownProperty(definition.id.to_string())), + } +} + +fn write( + definition: &'static PropertyDefinition, + current: &mut ZeroFill, + raw: usize, + value: PropertyValue, +) -> Result<(), PropertyError> { + match (definition.id, value) { + (MODE, PropertyValue::Enum(mode)) => { + if mode == mode_of(*current) { + return Ok(()); + } + *current = match mode { + NONE => ZeroFill::None, + X2 => ZeroFill::Factor(2), + X4 => ZeroFill::Factor(3), + X8 => ZeroFill::Factor(4), + // The current effective size is always at least `raw`, so the + // seed is admitted by the dependent points schema. + CUSTOM => ZeroFill::Size(current.target(raw)), + _ => { + return Err(PropertyError::InvalidValue { + property: definition.id, + message: format!("'{mode}' is not a zero-fill mode"), + }); + } + }; + Ok(()) + } + (POINTS, PropertyValue::Int(points)) => { + *current = ZeroFill::Size(usize::try_from(points).map_err(|_| { + PropertyError::InvalidValue { + property: definition.id, + message: format!( + "zero-fill points {points} is out of range: this platform supports at most {} points", + usize::MAX + ), + } + })?); + Ok(()) + } + (_, value) => Err(wrong_kind( + definition, + &value, + "the declared zero-fill value", + )), + } +} + +/// Only the three factors the UI names get a factor label. Every other stored +/// factor is represented as Custom and retains its exact effective point count +/// until the user deliberately replaces it. +fn mode_of(value: ZeroFill) -> &'static str { + match value { + ZeroFill::None => NONE, + ZeroFill::Factor(2) => X2, + ZeroFill::Factor(3) => X4, + ZeroFill::Factor(4) => X8, + ZeroFill::Factor(_) | ZeroFill::Size(_) => CUSTOM, + } +} + +fn points_unavailable(current: ZeroFill) -> PropertyError { + PropertyError::NotApplicable(format!( + "Zero-fill points is available only in Custom mode; this step uses {}", + MODES + .iter() + .find(|variant| variant.id == mode_of(current)) + .map(|variant| variant.canonical_label) + .unwrap_or("an unknown mode") + )) +} + +fn as_i64(points: usize, definition: &'static PropertyDefinition) -> Result { + i64::try_from(points).map_err(|_| PropertyError::InvalidValue { + property: definition.id, + message: format!( + "point count {points} is out of range: the property interface supports at most {}", + i64::MAX + ), + }) +} diff --git a/crates/core/src/properties/zero_fill_tests.rs b/crates/core/src/properties/zero_fill_tests.rs new file mode 100644 index 00000000..1bd07dc0 --- /dev/null +++ b/crates/core/src/properties/zero_fill_tests.rs @@ -0,0 +1,185 @@ +use super::processing_test_support::{ + spectrum, states_2d_app, step, step_mut, target_for, target_for_axis, time_domain_app, +}; +use super::*; +use crate::state::Dataset; +use plotx_processing::{StepKind, ZeroFill}; + +#[test] +fn unsupported_factors_are_lossless_custom_values_with_a_target_readout() { + let mut app = time_domain_app(); + let target = target_for(&app, |kind| matches!(kind, StepKind::ZeroFill(_))); + step_mut(&mut app, &target).kind = StepKind::ZeroFill(ZeroFill::Factor(5)); + + let mode = app + .resolve_property(&PropertyAddress::new(target.clone(), zero_fill::MODE)) + .expect("the mode resolves"); + assert_eq!( + mode.value, + AggregateValue::Uniform(PropertyValue::Enum(zero_fill::CUSTOM)) + ); + let points = app + .resolve_property(&PropertyAddress::new(target.clone(), zero_fill::POINTS)) + .expect("an unlisted factor exposes its exact effective size"); + assert_eq!( + points.value, + AggregateValue::Uniform(PropertyValue::Int(1_024)) + ); + assert_eq!( + app.property_readout(&PropertyAddress::new(target.clone(), zero_fill::MODE)) + .expect("the derived target resolves"), + PropertyReadout::ZeroFillTarget(ZeroFillTargetReadout { points: 1_024 }) + ); + + let commit = app + .plan_property_write( + zero_fill::MODE, + std::slice::from_ref(&target), + &PropertyValue::Enum(zero_fill::CUSTOM), + ) + .expect("writing the displayed mode plans"); + assert!(commit.applied.is_empty()); + assert_eq!( + step(&app, &target).kind, + StepKind::ZeroFill(ZeroFill::Factor(5)), + "setting the already displayed mode preserves the stored factor" + ); +} + +#[test] +fn custom_points_reject_the_set_value_and_name_the_dataset_boundary() { + let mut app = time_domain_app(); + let target = target_for(&app, |kind| matches!(kind, StepKind::ZeroFill(_))); + let commit = app + .plan_property_write( + zero_fill::MODE, + std::slice::from_ref(&target), + &PropertyValue::Enum(zero_fill::CUSTOM), + ) + .expect("custom mode plans"); + app.commit_property(commit); + + let error = app + .plan_property_write( + zero_fill::POINTS, + std::slice::from_ref(&target), + &PropertyValue::Int(32), + ) + .expect_err("the FFT target cannot shrink the 64-point FID"); + let message = error.to_string(); + assert!(message.contains("32"), "{message}"); + assert!(message.contains("64 original points"), "{message}"); +} + +#[test] +fn zero_fill_catalog_write_reprocesses_real_fid_and_one_undo_restores_it() { + let mut app = time_domain_app(); + let target = target_for(&app, |kind| matches!(kind, StepKind::ZeroFill(_))); + let before = spectrum(&app); + assert_eq!(before.0.len(), 64); + + let commit = app + .plan_property_write( + zero_fill::MODE, + std::slice::from_ref(&target), + &PropertyValue::Enum(zero_fill::X2), + ) + .expect("doubling plans through the real property entry"); + app.commit_property(commit); + let after = spectrum(&app); + assert_eq!(after.0.len(), 128); + assert_ne!(after.0, before.0); + + app.undo(); + assert_eq!(spectrum(&app), before); +} + +#[test] +fn states_f1_uses_complex_increments_as_its_raw_point_count() { + let mut app = states_2d_app(10, 6); + let target = target_for_axis(&app, crate::state::PhaseAxis::F1, |kind| { + matches!(kind, StepKind::ZeroFill(_)) + }); + let commit = app + .plan_property_write( + zero_fill::MODE, + std::slice::from_ref(&target), + &PropertyValue::Enum(zero_fill::CUSTOM), + ) + .expect("custom F1 zero fill plans"); + app.commit_property(commit); + + let resolved = app + .resolve_property(&PropertyAddress::new(target.clone(), zero_fill::POINTS)) + .expect("F1 points resolve"); + assert!(matches!( + resolved.schema, + ResolvedSchema::IntWithDrag { min: 5, .. } + )); + let accepted = app + .plan_property_write( + zero_fill::POINTS, + std::slice::from_ref(&target), + &PropertyValue::Int(8), + ) + .expect("eight points exceeds the five complex States increments"); + app.commit_property(accepted); + assert_eq!( + app.property_readout(&PropertyAddress::new(target, zero_fill::MODE)) + .expect("the F1 target readout resolves"), + PropertyReadout::ZeroFillTarget(ZeroFillTargetReadout { points: 8 }) + ); +} + +#[test] +fn nus_f1_uses_the_nominal_reconstruction_grid_as_its_raw_count() { + let mut app = states_2d_app(10, 6); + let Dataset::Nmr2D(dataset) = &mut app.doc.datasets[0] else { + panic!("the fixture is 2D NMR"); + }; + std::sync::Arc::make_mut(&mut dataset.data).nus = Some(plotx_io::NusMeta { + grid: 17, + acquired: 5, + idx_base: 0, + mode: "test".to_owned(), + echo_antiecho: false, + schedule: Some(vec![0, 2, 5, 9, 16]), + }); + let target = target_for_axis(&app, crate::state::PhaseAxis::F1, |kind| { + matches!(kind, StepKind::ZeroFill(_)) + }); + let custom = app + .plan_property_write( + zero_fill::MODE, + std::slice::from_ref(&target), + &PropertyValue::Enum(zero_fill::CUSTOM), + ) + .unwrap(); + app.commit_property(custom); + assert!(matches!( + app.resolve_property(&PropertyAddress::new(target, zero_fill::POINTS)) + .unwrap() + .schema, + ResolvedSchema::IntWithDrag { min: 17, .. } + )); +} + +#[test] +fn zero_fill_reset_restores_the_factory_value_through_the_catalog() { + let mut app = time_domain_app(); + let target = target_for(&app, |kind| matches!(kind, StepKind::ZeroFill(_))); + let changed = app + .plan_property_write( + zero_fill::MODE, + std::slice::from_ref(&target), + &PropertyValue::Enum(zero_fill::X2), + ) + .unwrap(); + app.commit_property(changed); + let reset = app + .plan_property_reset(zero_fill::MODE, std::slice::from_ref(&target)) + .unwrap(); + assert_eq!(reset.applied.len(), 1); + app.commit_property(reset); + assert_eq!(step(&app, &target).kind, StepKind::ZeroFill(ZeroFill::None)); +} diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index 862843cc..352e668e 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -57,19 +57,13 @@ impl PlotxApp { files.truncate(crate::settings::MAX_RECENT_FILES); files }, - canvas_accent: settings.appearance.canvas_accent, ui: UiState { - snap_enabled: settings.general.snap_enabled, // `ilt_params` deliberately starts empty rather than mirroring // the preference here: a copy taken at construction would not // follow later preference edits, and resolving at build time // is what keeps the default reachable as a lifecycle stage. ..Default::default() }, - project_backup_generations: settings - .general - .project_backup_generations - .min(crate::settings::MAX_PROJECT_BACKUP_GENERATIONS), compute: ComputeService::new(), updates: crate::update::UpdateService::new(&settings.updates), line_fit_job: None, @@ -216,8 +210,7 @@ impl PlotxApp { }; let figure = self.build_object_figure(&binding, &chart, &stack, &projections, size_mm); if let Some(plot) = object.plot_mut() { - plot.viewport = CanvasViewport::from_figure(&figure); - plot.figure = figure; + plot.adopt_rebuilt_figure(figure); } object } diff --git a/crates/core/src/state/app_impl_compute.rs b/crates/core/src/state/app_impl_compute.rs index 2c1eca2a..4ad6c3cf 100644 --- a/crates/core/src/state/app_impl_compute.rs +++ b/crates/core/src/state/app_impl_compute.rs @@ -446,12 +446,9 @@ impl PlotxApp { .flatten() .collect::>(); let outcome = if full { - self.session.compute.request_2d_full( - dataset_id, - &fields, - std::sync::Arc::clone(&d2.data), - params, - ) + self.session + .compute + .request_2d_full(dataset_id, &fields, d2.processing_data(), params) } else { self.session .compute diff --git a/crates/core/src/state/app_impl_io.rs b/crates/core/src/state/app_impl_io.rs index 78cf7e44..2e60e4d9 100644 --- a/crates/core/src/state/app_impl_io.rs +++ b/crates/core/src/state/app_impl_io.rs @@ -124,15 +124,9 @@ impl PlotxApp { /// 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) { - self.session.ui.snap_enabled = settings.general.snap_enabled; - self.session.canvas_accent = settings.appearance.canvas_accent; if !settings.general.snap_enabled { self.session.ui.snap_guides.clear(); } - self.session.project_backup_generations = settings - .general - .project_backup_generations - .min(crate::settings::MAX_PROJECT_BACKUP_GENERATIONS); self.doc.save_include_view_snapshots = settings.export.include_view_snapshots; let mut recent = settings.recent.files.clone(); recent.truncate(crate::settings::MAX_RECENT_FILES); @@ -215,9 +209,6 @@ impl PlotxApp { self.doc.project_path = Some(path.to_owned()); self.doc.save_include_view_snapshots = include_view_snapshots; 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( @@ -247,7 +238,7 @@ impl PlotxApp { ); } self.session.record_operation(report); - // Flush the three preferences harvested above on their own, + // Flush the save-profile preference above on its 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 diff --git a/crates/core/src/state/app_impl_peaks.rs b/crates/core/src/state/app_impl_peaks.rs index 2f0c6e11..67921ac6 100644 --- a/crates/core/src/state/app_impl_peaks.rs +++ b/crates/core/src/state/app_impl_peaks.rs @@ -123,12 +123,8 @@ impl PlotxApp { let Some(plot) = object.plot_mut() else { continue; }; - if plot.binding.primary_dataset() == Some(dataset_id) - && plot.binding.primary_visible() - { - plot.figure.integral_curves.clone_from(&curves); - } else if plot.binding.primary_dataset() == Some(dataset_id) { - plot.figure.integral_curves.clear(); + if plot.binding.primary_dataset() == Some(dataset_id) { + plot.set_integral_curves(&curves, plot.binding.primary_visible()); } } } diff --git a/crates/core/src/state/app_impl_slice.rs b/crates/core/src/state/app_impl_slice.rs index cf5f621f..2057596c 100644 --- a/crates/core/src/state/app_impl_slice.rs +++ b/crates/core/src/state/app_impl_slice.rs @@ -34,6 +34,7 @@ impl NmrDataset { source: source.clone(), group_delay: 0.0, }; + let group_delay_correct = super::default_group_delay_correct(data.domain); // The trace is already a phased spectrum: the pipeline is the bare FFT // anchor, so the transform reproduces the values with no further steps. // This dataset owns the pipeline it is built with, so the single step @@ -54,7 +55,7 @@ impl NmrDataset { base: spectrum.clone(), pipeline, next_step_id: 1, - group_delay_correct: true, + group_delay_correct, has_imaginary: true, spectrum, name: Some(source), @@ -227,4 +228,14 @@ mod tests { )) ); } + + #[test] + fn frequency_domain_slices_share_the_factory_group_delay_default() { + let dataset = NmrDataset::from_slice(slice(), "slice".to_owned()); + assert!(!dataset.group_delay_correct); + assert_eq!( + dataset.group_delay_correct, + default_group_delay_correct(dataset.data.domain) + ); + } } diff --git a/crates/core/src/state/datasets.rs b/crates/core/src/state/datasets.rs index 6c9cbd1b..0c6378cf 100644 --- a/crates/core/src/state/datasets.rs +++ b/crates/core/src/state/datasets.rs @@ -1,6 +1,11 @@ use super::*; use std::sync::Arc; +/// Factory rule shared by dataset construction, reset, and property defaults. +pub(crate) fn default_group_delay_correct(domain: Domain) -> bool { + matches!(domain, Domain::Time) +} + #[derive(Clone, Copy, PartialEq, Eq)] pub enum PhaseDragKind { Pivot, @@ -61,7 +66,7 @@ impl NmrDataset { Domain::Time => AxisPipeline::default_1d(), Domain::Frequency => AxisPipeline::frequency_1d(), }; - let group_delay_correct = data.domain == Domain::Time; + let group_delay_correct = default_group_delay_correct(data.domain); let has_imaginary = data.domain == Domain::Time || data.points.iter().any(|v| v.im != 0.0); let base = fft::transform_base(&data, &pipeline, group_delay_correct); let spectrum = reapply(&base, &pipeline); @@ -211,7 +216,7 @@ impl Nmr2DDataset { Domain::Time => Params2D::default_for(preset), Domain::Frequency => Params2D::frequency_domain(preset), }; - let group_delay_correct = data.domain == Domain::Time; + let group_delay_correct = default_group_delay_correct(data.domain); let has_imaginary = data.domain == Domain::Time || data.data.iter().any(|v| v.im != 0.0); let base = process_2d(&data, ¶ms); let processed = reapply_2d(&base, ¶ms); @@ -268,11 +273,28 @@ impl Nmr2DDataset { /// Rebuild `base` from the FID (a time-domain step or the layout changed) then /// re-derive the display result. pub fn retransform(&mut self) { - self.base = process_2d(&self.data, &self.params); + let data = self.processing_data(); + self.base = process_2d(&data, &self.params); self.base_params = self.params.clone(); self.base_stale = false; self.rebuild(); } + + /// Input view for the 2D transform's existing unconditional direct-axis + /// delay removal. + /// + /// Keeping the switch here avoids a second FFT implementation: disabling + /// correction presents zero delay metadata to the same scientific kernel. + /// The uncommon disabled path owns one copy so the persisted acquisition + /// metadata remains untouched. + pub(crate) fn processing_data(&self) -> Arc { + if self.group_delay_correct { + return Arc::clone(&self.data); + } + let mut data = (*self.data).clone(); + data.direct.group_delay = 0.0; + Arc::new(data) + } /// A true-2D (contour) result, as opposed to a pseudo-2D stack of slices. pub fn is_true_2d(&self) -> bool { matches!(self.processed, Processed2D::Ft(_)) diff --git a/crates/core/src/state/derived_axes.rs b/crates/core/src/state/derived_axes.rs new file mode 100644 index 00000000..07338421 --- /dev/null +++ b/crates/core/src/state/derived_axes.rs @@ -0,0 +1,26 @@ +//! Axis presentation captured before author overrides are applied. + +use plotx_figure::Figure; + +#[derive(Clone, Debug, PartialEq)] +pub struct DerivedAxes { + pub x_label: String, + pub y_label: String, + pub x_show_tick_labels: bool, + pub x_show_label: bool, + pub y_show_tick_labels: bool, + pub y_show_label: bool, +} + +impl DerivedAxes { + pub fn from_figure(figure: &Figure) -> Self { + Self { + x_label: figure.x.label.clone(), + y_label: figure.y.label.clone(), + x_show_tick_labels: figure.x.show_tick_labels, + x_show_label: figure.x.show_label, + y_show_tick_labels: figure.y.show_tick_labels, + y_show_label: figure.y.show_label, + } + } +} diff --git a/crates/core/src/state/document.rs b/crates/core/src/state/document.rs index 7a3562c2..b4410629 100644 --- a/crates/core/src/state/document.rs +++ b/crates/core/src/state/document.rs @@ -297,25 +297,6 @@ impl AxisProjections { } } -#[derive(Clone)] -pub struct PlotObject { - /// Persistent high-water mark for owner-local series identities. This is - /// deliberately outside `binding`, which actions may replace wholesale. - pub next_series_id: SeriesId, - pub binding: DataBinding, - /// The selected chart type (registry id) + its context, driving figure - /// rebuilds through `state::charts`. Defaults to the dataset domain's default. - pub chart: ChartSpec, - /// The multi-series stacking layout. Default = superimposed overlay. - pub stack: StackSpec, - /// Marginal 1D axis projections for a 2D contour (empty for other plots). - pub projections: AxisProjections, - pub axis_overrides: AxisOverrides, - pub figure: Figure, - pub viewport: CanvasViewport, - pub panel: PanelMeta, -} - /// Horizontal alignment of a text box's lines within its frame. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TextAlign { @@ -572,7 +553,7 @@ pub fn document_item( plotx_render::DocumentItem::Plot(plotx_render::DocumentObject { id: format!("object_{}", object.id), frame: object.frame.rect(), - figure: &plot.figure, + figure: plot.figure(), visible: object.visible, title: plot.panel.visible.then_some(letter).flatten().map(|text| { plotx_render::DocumentText { diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index d5b15095..1a0fa12d 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -50,6 +50,7 @@ mod datasets; mod datasets_2d_figure; mod datasets_2d_maps; mod datasets_dispatch; +mod derived_axes; mod document; mod document_identity; mod electrophysiology; @@ -107,6 +108,7 @@ pub(crate) use datasets_2d_figure::build_processed_figure; pub(crate) use datasets_2d_maps::{ MONO_EXP_SNR_FRAC, dosy_data_fingerprint, ilt_provenance, mono_exp_provenance, }; +pub use derived_axes::*; pub use document::*; pub use electrophysiology::*; pub use field::*; @@ -132,6 +134,7 @@ pub use multiplet::*; pub use page_fit::*; pub use panel_label::*; pub use peaks::*; +pub use plot_object::*; pub use region::*; pub use series_binding::*; pub use size_presets::*; diff --git a/crates/core/src/state/plot_object.rs b/crates/core/src/state/plot_object.rs index e03bdc88..832668a0 100644 --- a/crates/core/src/state/plot_object.rs +++ b/crates/core/src/state/plot_object.rs @@ -1,7 +1,96 @@ -use super::{CanvasViewport, DatasetId, PlotObject, SeriesId}; -use plotx_figure::Figure; +use super::{ + AxisOverrides, AxisProjections, CanvasViewport, ChartSpec, DataBinding, DatasetId, DerivedAxes, + PanelMeta, SeriesId, StackSpec, +}; +use plotx_figure::{Figure, FigureTypography}; + +#[derive(Clone)] +pub struct PlotObject { + /// Persistent high-water mark for owner-local series identities. This is + /// deliberately outside `binding`, which actions may replace wholesale. + pub next_series_id: SeriesId, + pub binding: DataBinding, + /// The selected chart type (registry id) + its context, driving figure + /// rebuilds through `state::charts`. Defaults to the dataset domain's default. + pub chart: ChartSpec, + /// The multi-series stacking layout. Default = superimposed overlay. + pub stack: StackSpec, + /// Marginal 1D axis projections for a 2D contour (empty for other plots). + pub projections: AxisProjections, + pub axis_overrides: AxisOverrides, + /// Axis presentation emitted by the latest figure build, before author + /// overrides are applied. Derived property defaults read this artifact. + derived_axes: DerivedAxes, + figure: Figure, + pub viewport: CanvasViewport, + pub panel: PanelMeta, +} impl PlotObject { + #[allow(clippy::too_many_arguments)] + pub fn new( + next_series_id: SeriesId, + binding: DataBinding, + chart: ChartSpec, + stack: StackSpec, + projections: AxisProjections, + axis_overrides: AxisOverrides, + figure: Figure, + viewport: CanvasViewport, + panel: PanelMeta, + ) -> Self { + let derived_axes = DerivedAxes::from_figure(&figure); + Self { + next_series_id, + binding, + chart, + stack, + projections, + axis_overrides, + derived_axes, + figure, + viewport, + panel, + } + } + + /// Restore a materialized figure that already contains author overrides and + /// viewport state while retaining the separately rebuilt automatic axes. + #[allow(clippy::too_many_arguments)] + pub(crate) fn from_materialized_figure( + next_series_id: SeriesId, + binding: DataBinding, + chart: ChartSpec, + stack: StackSpec, + projections: AxisProjections, + axis_overrides: AxisOverrides, + derived_axes: DerivedAxes, + figure: Figure, + viewport: CanvasViewport, + panel: PanelMeta, + ) -> Self { + Self { + next_series_id, + binding, + chart, + stack, + projections, + axis_overrides, + derived_axes, + figure, + viewport, + panel, + } + } + + pub fn figure(&self) -> &Figure { + &self.figure + } + + pub fn derived_axes(&self) -> &DerivedAxes { + &self.derived_axes + } + pub fn allocate_series_id(&mut self) -> SeriesId { let id = self.next_series_id; self.next_series_id = id.checked_advance(1); @@ -40,28 +129,105 @@ impl PlotObject { self.binding.primary_dataset() } + fn commit_rebuilt_figure( + &mut self, + mut figure: Figure, + prepare: impl FnOnce(&mut Self, &mut Figure), + ) { + self.derived_axes = DerivedAxes::from_figure(&figure); + prepare(self, &mut figure); + self.figure = figure; + } + + /// Adopt a rebuilt figure whose chart semantics may have changed. + pub(crate) fn adopt_rebuilt_figure(&mut self, figure: Figure) { + self.commit_rebuilt_figure(figure, |plot, figure| { + plot.axis_overrides.apply_to(figure); + plot.viewport = CanvasViewport::from_figure(figure); + if plot.has_manual_y_range(figure) { + plot.viewport.auto_y = false; + } + plot.viewport.apply_to(figure); + }); + } + /// Rebuild → overrides → viewport sync/apply. Effective range overrides /// replace the full data bounds; zoom and pan remain constrained within them. - pub(crate) fn preserve_viewport_on_rebuild(&mut self, mut figure: Figure) { - self.axis_overrides.apply_to(&mut figure); - if self.has_manual_y_range(&figure) { - self.viewport.auto_y = false; - } - self.viewport.sync_full_from(&figure); - self.viewport.apply_to(&mut figure); - self.figure = figure; + pub(crate) fn preserve_viewport_on_rebuild(&mut self, figure: Figure) { + self.commit_rebuilt_figure(figure, |plot, figure| { + plot.axis_overrides.apply_to(figure); + if plot.has_manual_y_range(figure) { + plot.viewport.auto_y = false; + } + plot.viewport.sync_full_from(figure); + plot.viewport.apply_to(figure); + }); } /// Rebuild a plot whose chart semantics changed, starting its viewport at /// the effective overridden ranges rather than retaining an incompatible view. - pub(crate) fn reset_viewport_on_rebuild(&mut self, mut figure: Figure) { - self.axis_overrides.apply_to(&mut figure); - self.viewport = CanvasViewport::from_figure(&figure); - if self.has_manual_y_range(&figure) { - self.viewport.auto_y = false; + pub(crate) fn reset_viewport_on_rebuild(&mut self, figure: Figure) { + self.adopt_rebuilt_figure(figure); + } + + pub(crate) fn rebuild_for_axis_overrides( + &mut self, + figure: Figure, + x_range_changed: bool, + y_range_changed: bool, + ) { + self.commit_rebuilt_figure(figure, |plot, figure| { + plot.axis_overrides.apply_to(figure); + let effective_y_range = plot.has_manual_y_range(figure); + if y_range_changed { + plot.viewport.auto_y = !effective_y_range; + } else if effective_y_range { + plot.viewport.auto_y = false; + } + plot.viewport.sync_full_from(figure); + if x_range_changed { + plot.viewport.reset_x(figure); + } + if y_range_changed { + if effective_y_range { + plot.viewport.view_y = plot.viewport.full_y; + plot.viewport.auto_y = false; + } else { + plot.viewport.reset_y(figure); + } + } + plot.viewport.apply_to(figure); + }); + } + + pub fn apply_viewport(&mut self) { + self.viewport.apply_to(&mut self.figure); + } + + pub(crate) fn apply_axis_overrides(&mut self) { + self.axis_overrides.apply_to(&mut self.figure); + } + + pub(crate) fn set_figure_typography(&mut self, typography: FigureTypography) { + self.figure.typography = typography; + } + + pub(crate) fn set_integral_curves( + &mut self, + curves: &[plotx_figure::IntegralCurve], + visible: bool, + ) { + if visible { + self.figure.integral_curves.clear(); + self.figure.integral_curves.extend_from_slice(curves); + } else { + self.figure.integral_curves.clear(); } - self.viewport.apply_to(&mut figure); - self.figure = figure; + } + + #[cfg(test)] + pub(crate) fn set_axis_frame(&mut self, axis_frame: plotx_figure::AxisFrame) { + self.figure.axis_frame = axis_frame; } pub(crate) fn normalize_viewport(&self, viewport: &mut CanvasViewport) { diff --git a/crates/core/src/state/stack.rs b/crates/core/src/state/stack.rs index 1e83e106..6fe8424e 100644 --- a/crates/core/src/state/stack.rs +++ b/crates/core/src/state/stack.rs @@ -370,17 +370,17 @@ impl PlotxApp { let figure = self.build_binding_figure(&binding, &chart, &stack, canvas.size_mm); let viewport = CanvasViewport::from_figure(&figure); let panel = PanelMeta::new(self.default_plot_title(sel[0]), frame.width); - let mut plot = PlotObject { - next_series_id: SeriesId::new(0), + let mut plot = PlotObject::new( + SeriesId::new(0), binding, chart, stack, - projections: AxisProjections::default(), - axis_overrides: AxisOverrides::default(), + AxisProjections::default(), + AxisOverrides::default(), figure, viewport, panel, - }; + ); // One place decides how a freshly materialized binding is numbered, so // the ids and the allocator cannot drift apart. plot.mint_series_ids(); diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index 87f998da..28c26b83 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -274,6 +274,15 @@ impl PropertyFocus { } } +/// Persistent buffer for one catalog text control. It is keyed by the exact +/// target selection so changing objects cannot carry uncommitted text across. +pub struct PropertyTextEditState { + pub property: crate::properties::PropertyId, + pub targets: Vec, + pub text: String, + pub editing: bool, +} + pub struct UiState { /// The single in-flight direct-manipulation gesture; see [`Interaction`]. pub interaction: Interaction, @@ -285,10 +294,10 @@ pub struct UiState { pub wheel_zoom: Option, pub canvas_size_edit: Option, pub page_layout_edit: Option, - pub processing_edit: Option, pub processing_session: Option, /// The continuous property-catalog control currently being dragged, if any. pub property_gesture: Option, + pub property_text_edits: Vec, pub inspector_edit: Option, /// Pre-edit snapshot for a plot-local axis text/range gesture. pub axis_overrides_before: Option<(usize, ObjectId, AxisOverrides)>, @@ -377,7 +386,6 @@ pub struct UiState { pub panel_note_inline_edit: Option, pub panel_note_edit: Option, pub text_edit: Option, - pub snap_enabled: bool, /// Snap guide previews painted during an `Interaction::Object` drag; cleared /// alongside it. pub snap_guides: Vec, @@ -467,9 +475,9 @@ impl Default for UiState { wheel_zoom: None, canvas_size_edit: None, page_layout_edit: None, - processing_edit: None, processing_session: None, property_gesture: None, + property_text_edits: Vec::new(), inspector_edit: None, axis_overrides_before: None, canvas_settings: None, @@ -520,7 +528,6 @@ impl Default for UiState { panel_note_inline_edit: None, panel_note_edit: None, text_edit: None, - snap_enabled: true, snap_guides: Vec::new(), selected_region: None, selected_integral: None, @@ -633,10 +640,7 @@ pub struct Session { /// from settings at construction and kept in sync by `note_recent_file` / /// `clear_recent_files` / `apply_settings`. Not serialized with projects. pub recent_files: Vec, - pub canvas_accent: Option<[u8; 3]>, pub ui: UiState, - /// Complete previous project files to retain after a successful save. - pub project_backup_generations: u8, /// Off-thread runner for the heaviest button-triggered DOSY computations. /// Not serialized; rebuilt fresh whenever a `PlotxApp` is constructed. pub compute: ComputeService, diff --git a/crates/core/src/workflow.rs b/crates/core/src/workflow.rs index 57f4d19d..6fd18bed 100644 --- a/crates/core/src/workflow.rs +++ b/crates/core/src/workflow.rs @@ -288,17 +288,17 @@ pub fn build_plot_object( locked: false, visible: true, group: None, - kind: CanvasObjectKind::Plot(Box::new(PlotObject { - next_series_id: crate::state::SeriesId::new(1), - binding: default_binding(dataset), + kind: CanvasObjectKind::Plot(Box::new(PlotObject::new( + crate::state::SeriesId::new(1), + default_binding(dataset), chart, - stack: StackSpec::default(), - projections: AxisProjections::default(), - axis_overrides: AxisOverrides::default(), + StackSpec::default(), + AxisProjections::default(), + AxisOverrides::default(), figure, viewport, panel, - })), + ))), } } @@ -366,12 +366,12 @@ pub fn build_default_canvas_for_dataset( series.source.field = field.id; series.encoding = plotx_figure::SeriesEncoding::default(); } - plot.figure = build_dataset_figure( + let figure = build_dataset_figure( dataset, &plot.chart, [width / 2.0 / MM_TO_PT, height / MM_TO_PT], ); - plot.viewport = CanvasViewport::from_figure(&plot.figure); + plot.adopt_rebuilt_figure(figure); } canvas.objects.push(second); } diff --git a/crates/core/tests/afm_canvas.rs b/crates/core/tests/afm_canvas.rs index 6da0a2a7..88f8d4c8 100644 --- a/crates/core/tests/afm_canvas.rs +++ b/crates/core/tests/afm_canvas.rs @@ -1,7 +1,8 @@ use plotx_core::actions::Action; use plotx_core::automation::{KIND_FIELD, ProjectResourceProvider, ResourceProvider}; use plotx_core::state::{ - CanvasObjectKind, DEFAULT_CANVAS_SIZE_MM, Dataset, NATURE_DOUBLE_COLUMN, PlotxApp, StackSpec, + CanvasObjectKind, DEFAULT_CANVAS_SIZE_MM, Dataset, DerivedAxes, NATURE_DOUBLE_COLUMN, PlotxApp, + StackSpec, }; use plotx_figure::{ContourSpec, SeriesEncoding}; use plotx_io::{AfmData, AfmForceSet, AfmFrameDirection, AfmImageChannel, AfmScale}; @@ -71,10 +72,10 @@ fn force_only_gui_insertion_builds_a_nonempty_force_curve() { panic!("expected plot"); }; assert_eq!(plot.chart.type_id, "afm_force_curve"); - assert_eq!(plot.figure.series.len(), 2); - assert_eq!([plot.figure.x.min, plot.figure.x.max], [-100.0, 100.0]); - assert_eq!(plot.figure.y.label, "Force (nN)"); - assert!((plot.figure.series[0].points[0][1] - 0.1).abs() < 1.0e-12); + assert_eq!(plot.figure().series.len(), 2); + assert_eq!([plot.figure().x.min, plot.figure().x.max], [-100.0, 100.0]); + assert_eq!(plot.figure().y.label, "Force (nN)"); + assert!((plot.figure().series[0].points[0][1] - 0.1).abs() < 1.0e-12); } #[test] @@ -132,6 +133,34 @@ fn map_and_force_gui_insertion_builds_side_by_side_plots() { )); } +#[test] +fn afm_double_view_force_curve_keeps_its_own_derived_axes() { + let dataset = afm_dataset(true); + let canvas = plotx_core::workflow::build_default_canvas_for_dataset( + &dataset, + 0, + "AFM".to_owned(), + DEFAULT_CANVAS_SIZE_MM, + ); + let CanvasObjectKind::Plot(map) = &canvas.objects[0].kind else { + panic!("expected AFM map plot"); + }; + let CanvasObjectKind::Plot(force) = &canvas.objects[1].kind else { + panic!("expected AFM force plot"); + }; + + assert_eq!( + force.derived_axes(), + &DerivedAxes::from_figure(force.figure()), + "Force Curve derived axes must describe its own rebuilt figure" + ); + assert_ne!( + force.derived_axes().x_label, + map.derived_axes().x_label, + "AFM map and Force Curve should expose distinct derived x-axis labels" + ); +} + #[test] fn afm_scalar_field_can_render_a_contour_without_a_domain_chart_branch() { let mut app = insert(afm_dataset(true)); diff --git a/crates/core/tests/slice2d.rs b/crates/core/tests/slice2d.rs index 50932504..f61f1039 100644 --- a/crates/core/tests/slice2d.rs +++ b/crates/core/tests/slice2d.rs @@ -130,7 +130,7 @@ fn contour_slice_places_peak_and_exports_svg() { let fig = app.doc.canvases[0].objects[0] .plot() .unwrap() - .figure + .figure() .clone(); assert!(!fig.contours.is_empty()); assert!(!fig.contours[0].segments.is_empty()); @@ -551,7 +551,7 @@ fn a_json_property_write_reaches_the_drawn_figure() { .object(object) .and_then(|object| object.plot()) .expect("plot") - .figure + .figure() .contours .len(); assert!(before > 0, "the page draws contours to begin with"); @@ -569,10 +569,10 @@ fn a_json_property_write_reaches_the_drawn_figure() { .and_then(|object| object.plot()) .expect("plot"); assert!( - !plot.figure.contours.is_empty(), + !plot.figure().contours.is_empty(), "the positive half is still drawn" ); - let svg = plotx_render::svg::export(&plot.figure); + let svg = plotx_render::svg::export(plot.figure()); assert!(svg.contains(" f64 { +/// Effective spacing of the axis consumed by binning. +pub fn axis_step(ppm: &[f64]) -> f64 { if ppm.len() < 2 { return 1.0; } diff --git a/crates/processing/src/fft2.rs b/crates/processing/src/fft2.rs index 74feb323..8250e0ec 100644 --- a/crates/processing/src/fft2.rs +++ b/crates/processing/src/fft2.rs @@ -297,7 +297,8 @@ fn build_t1_rows( Some(rows) } -fn f1_increments(rows: usize, quad: QuadMode) -> usize { +/// Number of complex indirect increments the F1 transform actually receives. +pub fn f1_increments(rows: usize, quad: QuadMode) -> usize { match quad { QuadMode::Complex => rows, QuadMode::States | QuadMode::StatesTppi | QuadMode::EchoAntiecho => rows / 2, diff --git a/crates/processing/src/lib.rs b/crates/processing/src/lib.rs index 561a8587..07247f33 100644 --- a/crates/processing/src/lib.rs +++ b/crates/processing/src/lib.rs @@ -531,7 +531,8 @@ impl AxisPipeline { pub use fft::transform_base; -fn apply_freq_step(spec: &mut Spectrum, kind: &StepKind) { +/// Apply one frequency-domain step to an already transformed spectrum. +pub fn apply_freq_step(spec: &mut Spectrum, kind: &StepKind) { match kind { StepKind::Phase(p) => { let (p0, p1, piv) = match p.auto { diff --git a/docs/src/content/docs/guides/automation.md b/docs/src/content/docs/guides/automation.md index c4d34610..0c1b7c31 100644 --- a/docs/src/content/docs/guides/automation.md +++ b/docs/src/content/docs/guides/automation.md @@ -33,10 +33,13 @@ Name the setting in **Parameters (JSON)** by its id: `{"key": "series.contour.count", "value": 12}` for **Set a property**, and `{"key": "series.contour.count"}` for the other two. -All three tools accept plot objects, datasets, and the document itself — listed -as **PlotX document**. Which of those to check follows from the setting: a -contour or line setting lives on a plot object, an apodization setting on a -dataset, and figure typography on the document. +The three tools reach every setting the panels edit: object and series styling, +processing-step parameters, document and canvas settings, and application +preferences. Which resource to name follows from the setting — a contour or +line setting lives on a plot object, an apodization setting on a dataset, +figure typography on the document (listed as **PlotX document**), a page size +on a canvas, and a preference on the application (listed as **PlotX +application**). | Setting in the Object inspector | id | Accepts | | --- | --- | --- | @@ -51,6 +54,11 @@ dataset, and figure typography on the document. | **Stroke width**, in the **Line** section | `series.line.stroke_width` | 0.05 to 10 | | **Tick-label size**, in the **Figure typography** section | `document.figure.typography.tick_pt` | 1 to 72 | +Application preferences take the same three tools. For +`settings.appearance.accent.color`, **Set a property** accepts `"#rrggbb"` and +pins the canvas accent to that colour; **Reset a property** clears it, and the +accent follows the theme again. + | Setting on an apodization step | id | Accepts | | --- | --- | --- | | **Window** | `dataset.processing.apodization.kind` | `none`, `cosine_bell`, `exponential`, `gaussian` | @@ -119,6 +127,32 @@ the numbers appear under **Result value (JSON)**, below the per-component rows: one reading per component, each with its current value, its default and the range it accepts. +#### What a reading contains + +Each reading names its `target` and carries the current `value`, a +`default_value` where the setting has one, `modified` — whether the value +differs from that default — an `availability`, and the `schema` that bounds it. + +A setting the current state does not allow you to write is still read back: +`availability` is `"disabled"` and `disabled_reason` names what has to change +first, such as switching the phase mode to *Manual* before φ0 can be set. + +Schemas are tagged by `type`: `bool`, `text`, `int`, `stepped_int`, `float`, +`enum`, or `color`. + +- `int` and `stepped_int` carry `min` and `max`, plus a `unit` where the setting + has one. `stepped_int` also carries the `step` its values must land on — a + Savitzky-Golay window, for instance, runs from 3 to 201 in steps of 2. +- `float` carries `min`, `max` and `exclusive_min`, and where a setting refuses + particular values, `excluded` (one value) or `excluded_magnitude` (everything + at or below that magnitude). Its `display` — `linear`, `degrees` or `log10` — + is how the panel shows the number; the `unit` and `log` fields beside it + restate the same thing. +- `enum` lists its variants, each with a stable id and a label. + +Bounds and values are always in the setting's own units, whatever `display` +says: a phase whose `display` is `degrees` is radians on the wire. + ## External Inputs Runs a saved workflow that starts from files on disk — for example: import diff --git a/docs/src/content/docs/guides/processing.md b/docs/src/content/docs/guides/processing.md index 4be43de7..9d424063 100644 --- a/docs/src/content/docs/guides/processing.md +++ b/docs/src/content/docs/guides/processing.md @@ -65,6 +65,8 @@ frequency-domain steps. Some spectrometers record a digital filter delay at the start of the FID that shows up as distorted first points. Digital group-delay correction removes it; it is a per-dataset switch next to the step list, applied before the pipeline. +It governs 1D and 2D data alike: switch it off on a 2D dataset and the direct +dimension is left uncorrected too. ## Apodization @@ -122,6 +124,18 @@ until you press it. Automatic phase correction is enabled by default; you can switch methods or adjust φ0 / φ1 manually with live preview. +Open the **Phase** step and its four rows sit together: **Mode**, **φ0**, **φ1** +and **Pivot**. φ0 and φ1 are in degrees. The pivot is a fraction of the axis +from 0 to 1, with the ppm position it currently lands on shown beside it. While +the step is open a pivot handle is drawn on the spectrum, and dragging it there +places the pivot in ppm. + +φ0, φ1 and the pivot are editable only while **Mode** is *Manual*; under an +automatic method each row says which switch to flip first. + +Through [Automation](/guides/automation/) these values keep their own units: +phase angles in radians, the pivot as a fraction. + ## Baseline correction Baseline correction is off by default. Enable the step when your spectrum diff --git a/docs/src/content/docs/guides/tables.md b/docs/src/content/docs/guides/tables.md index 4245a333..9e559f21 100644 --- a/docs/src/content/docs/guides/tables.md +++ b/docs/src/content/docs/guides/tables.md @@ -69,7 +69,9 @@ entire matrix of values at once, **Heatmap** or **Surface 3D**. **Stacked** to pile the columns instead (positive values stack upward, negative downward). - **Histogram** — the value distribution of one chosen column. Bins follow the - Freedman–Diaconis rule; uncheck **Auto bins** to set a fixed count. + Freedman–Diaconis rule; uncheck **Auto bins** to set a fixed **Bins** count + between 1 and 512. The count you inherit is the one the automatic rule just + produced, so unchecking the box leaves the histogram as it was. - **Box** — one box per column: median, quartile box, 1.5 × IQR whiskers, and individual outlier points. - **Violin** — one kernel-density silhouette per column with an inner quartile diff --git a/docs/src/content/docs/reference/ui-overview.md b/docs/src/content/docs/reference/ui-overview.md index 978c3f5f..f81dc440 100644 --- a/docs/src/content/docs/reference/ui-overview.md +++ b/docs/src/content/docs/reference/ui-overview.md @@ -39,6 +39,18 @@ introduces the same regions in walkthrough form. directly; the rest are folded into **Advanced**. Its **Contour** and **Line** sections appear only when the selection draws one; **Figure typography** belongs to the document and is always shown. +- **Setting row** — one setting as it appears in the Object inspector, Canvas + settings, the Processing panel, or Preferences. Every row behaves the same + way wherever it is shown. A dot marks a value that differs from its default — + hover it to see that default — and the reset button beside it goes back. + Ctrl+K searches these settings by name: activating a + result opens the panel that holds the row, expands what it is folded into, + scrolls to it, and highlights it. A row that cannot be edited in the current + state stays visible but greyed; hover it for the switch to change first. + When your selection covers several objects, one edit applies to all of them. + If they do not already agree, the row reads **mixed** and shows a dash in + place of a value rather than presenting one object's as the answer; hover it + to see how many disagree and what setting a value now would do. - **Settings group** — a named set of related settings with one home. The Ribbon carries a button per group that opens that home rather than repeating the controls: **Contour settings**, **Line settings** and **Figure typography @@ -46,6 +58,9 @@ introduces the same regions in walkthrough form. **Process → Processing**. The canvas right-click menu lists the same groups that currently apply, as *Contour settings…* and so on, and Ctrl+K finds the individual settings inside them. + Application preferences belong to no object, so the canvas menu does not list + them: reach them with **Preferences…** in **View → Display**, from the + command palette, or with Ctrl+,. - **Data sheet** — the spreadsheet view of a data table, opened by double-clicking the table. - **Command palette** — the searchable list of commands, settings, and data on diff --git a/docs/src/content/docs/zh-cn/guides/automation.md b/docs/src/content/docs/zh-cn/guides/automation.md index 74de2527..efead0c1 100644 --- a/docs/src/content/docs/zh-cn/guides/automation.md +++ b/docs/src/content/docs/zh-cn/guides/automation.md @@ -31,9 +31,11 @@ selection** 载入当前选择——再选择一个工具,点击 **Preflight** `{"key": "series.contour.count", "value": 12}`,另外两个用 `{"key": "series.contour.count"}`。 -三个工具都接受图对象、数据集,以及文档本身(列为 **PlotX document**)。该勾 -选哪一类由参数本身决定:等高线和线条参数在图对象上,切趾参数在数据集上,图形 -排印参数在文档上。 +这三个工具够得到各面板能编辑的一切设置:对象与序列的样式、处理步骤的参数、 +文档与画布的设置,以及应用偏好。该指定哪个资源由参数本身决定——等高线和线条 +参数在图对象上,切趾参数在数据集上,图形排印在文档上(列为 +**PlotX document**),页面尺寸在画布上,偏好设置在应用上(列为 +**PlotX application**)。 | 对象检查器中的设置 | id | 取值 | | --- | --- | --- | @@ -48,6 +50,10 @@ selection** 载入当前选择——再选择一个工具,点击 **Preflight** | **Stroke width**(**Line** 区域) | `series.line.stroke_width` | 0.05 到 10 | | **Tick-label size**(**Figure typography** 区域) | `document.figure.typography.tick_pt` | 1 到 72 | +应用偏好设置同样用这三个工具。对于 +`settings.appearance.accent.color`,**Set a property** 接受 `"#rrggbb"`,把画布 +强调色固定为该颜色;**Reset a property** 清除它,强调色重新跟随主题。 + | 切趾步骤上的设置 | id | 取值 | | --- | --- | --- | | **Window** | `dataset.processing.apodization.kind` | `none`、`cosine_bell`、`exponential`、`gaussian` | @@ -108,6 +114,31 @@ selection** 载入当前选择——再选择一个工具,点击 **Preflight** 内容。在窗口里,数值显示在逐部件结果下方的 **Result value (JSON)** 区域: 每个部件一份读数,包含当前值、默认值和它接受的范围。 +#### 一条读数包含什么 + +每条读数写出自己的 `target`,并带上当前值 `value`、参数有默认值时的 +`default_value`、表示当前值是否偏离默认值的 `modified`、可用状态 +`availability`,以及约束取值的 `schema`。 + +当前状态下不允许写入的参数照样会被读出:`availability` 为 `"disabled"`, +`disabled_reason` 说明要先改什么——例如设置 φ0 之前,先把相位模式切到 +*Manual*。 + +schema 以 `type` 标记,取值为 `bool`、`text`、`int`、`stepped_int`、`float`、 +`enum` 或 `color`。 + +- `int` 与 `stepped_int` 带 `min` 与 `max`,参数有单位时还带 `unit`。 + `stepped_int` 另有取值必须落在的格点 `step`——例如 Savitzky-Golay 窗口为 + 3 到 201,步长为 2。 +- `float` 带 `min`、`max` 与 `exclusive_min`;若参数拒绝某些取值,还带 + `excluded`(单个取值)或 `excluded_magnitude`(绝对值不超过该阈值的全部 + 取值)。其 `display`(`linear`、`degrees` 或 `log10`)说明面板如何显示这个 + 数;旁边的 `unit` 与 `log` 只是同一件事的另一种写法。 +- `enum` 列出各变体,每个都带稳定 id 和标签。 + +无论 `display` 是什么,边界和取值始终使用参数自身的单位:`display` 为 +`degrees` 的相位值,在线格式中仍是弧度。 + ## External Inputs 运行一个从磁盘文件开始的已保存工作流——例如:导入文件夹里的每个实验、 diff --git a/docs/src/content/docs/zh-cn/guides/processing.md b/docs/src/content/docs/zh-cn/guides/processing.md index eba3d922..5a2f8377 100644 --- a/docs/src/content/docs/zh-cn/guides/processing.md +++ b/docs/src/content/docs/zh-cn/guides/processing.md @@ -56,7 +56,8 @@ PlotX 的处理是应用于原始数据的**有序步骤列表**。步骤可随 部分谱仪会在 FID 开头记录数字滤波延迟,表现为谱图前几个点的畸变。 数字群延迟校正用于消除它;这是步骤列表旁的按数据集开关,在管线之前 -应用。 +应用。它对 1D 与 2D 数据一视同仁:在 2D 数据集上关掉它,直接维同样保持 +未校正。 ## 切趾 @@ -104,6 +105,17 @@ PlotX 的处理是应用于原始数据的**有序步骤列表**。步骤可随 自动相位校正默认启用;也可以切换算法,或手动调节 φ0 / φ1 并实时预览。 +展开 **Phase** 步骤,四行设置集中在一起:**Mode**、**φ0**、**φ1** 与 +**Pivot**。φ0 与 φ1 以度为单位。Pivot 是 0 到 1 的轴分数,旁边给出它当前 +对应的 ppm 位置。步骤展开时谱图上会画出 pivot 手柄,在谱面拖动它即按 ppm +选取位置。 + +只有 **Mode** 为 *Manual* 时才能编辑 φ0、φ1 和 pivot;使用自动算法时, +每一行都会写明要先切换哪个开关。 + +通过[自动化](/zh-cn/guides/automation/)读写时,这些值保持各自的单位:相位角 +为弧度,pivot 为分数。 + ## 基线校正 基线校正默认关闭。谱图需要时启用该步骤即可。 diff --git a/docs/src/content/docs/zh-cn/guides/tables.md b/docs/src/content/docs/zh-cn/guides/tables.md index 6ef0f9fd..be687647 100644 --- a/docs/src/content/docs/zh-cn/guides/tables.md +++ b/docs/src/content/docs/zh-cn/guides/tables.md @@ -56,7 +56,9 @@ these names** 把其取值变成新列名。工作表工具栏中的 **Combine** - **Grouped bars(分组柱)**——每行一组、组内各列并排;勾选 **Stacked** 改为堆叠(正值向上、负值向下堆叠)。 - **Histogram(直方图)**——所选单列的数值分布。默认按 - Freedman–Diaconis 规则自动分箱;取消勾选 **Auto bins** 可指定箱数。 + Freedman–Diaconis 规则自动分箱;取消勾选 **Auto bins** 后可在 **Bins** 中 + 指定 1 到 512 的固定箱数。取消勾选时沿用自动规则刚算出的箱数,因此直方图 + 不会随之变样。 - **Box(箱线图)**——每列一箱:中位数、四分位箱体、1.5 × IQR 须线, 以及逐个绘制的离群点。 - **Violin(小提琴图)**——每列一个核密度轮廓,内嵌四分位条 diff --git a/docs/src/content/docs/zh-cn/reference/ui-overview.md b/docs/src/content/docs/zh-cn/reference/ui-overview.md index 6f48943f..11735b31 100644 --- a/docs/src/content/docs/zh-cn/reference/ui-overview.md +++ b/docs/src/content/docs/zh-cn/reference/ui-overview.md @@ -35,13 +35,24 @@ PlotX 的界面为英文;手册中加粗的英文词即界面上的原文标 [等高线层级](/zh-cn/guides/contour-levels/)。常用设置直接显示,其余收在 **Advanced**(高级)折叠区中。其中 **Contour** 与 **Line** 两个区域只在 所选内容确实这样绘制时才出现;**Figure typography** 属于文档,始终显示。 +- **设置行(setting row)**——一项设置在对象检查器、Canvas settings、处理 + 面板或 Preferences 中的呈现。同一行无论出现在哪里,行为都一致。偏离默认值的 + 行会显示圆点,悬停可看到默认值,旁边的重置按钮可恢复它。按 + Ctrl+K 可按名称搜索这些设置:激活结果后,PlotX 会打开 + 该行所在的面板,展开收起它的折叠区,滚动到该行并高亮。当前状态下不能编辑的 + 行仍然显示,只是变灰;悬停即可看到要先改哪个开关。 + 多选对象时,一次编辑会应用到全部对象;若它们原本取值不一致,该行标注 + **mixed** 并以短横线代替数值,而不是拿其中一个对象的值冒充整组结果。悬停可 + 看到有多少个不一致,以及此时写入一个值会产生什么效果。 - **设置分组(settings group)**——一组同属一处的相关设置。Ribbon 为每个 分组提供一个按钮,它只负责打开这些设置的所在处,而不重复摆一套控件: **Figure → Style** 中的 **Contour settings**、**Line settings** 与 **Figure typography settings**,**Process → Processing** 中的 **Apodization settings**。画布右键菜单会列出当前适用的同一批分组,形如 *Contour settings…*;Ctrl+K 则能找到分组里的单项 - 设置。 + 设置。应用偏好不属于任何对象,因此画布右键菜单不列出它们:请用 **View → + Display** 中的 **Preferences…**、命令面板,或按 Ctrl+, + 打开。 - **数据表(Data sheet)**——数据表格的电子表格视图,双击表格打开。 - **命令面板(Command palette)**——Ctrl+K 打开 的可搜索列表,涵盖命令、设置与数据;见