diff --git a/Cargo.lock b/Cargo.lock index fd5d563..c0fe995 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4141,6 +4141,7 @@ version = "0.1.0" dependencies = [ "colorous", "serde", + "serde_json", ] [[package]] diff --git a/crates/app/src/ui/canvas/chrome.rs b/crates/app/src/ui/canvas/chrome.rs new file mode 100644 index 0000000..9f1cbc9 --- /dev/null +++ b/crates/app/src/ui/canvas/chrome.rs @@ -0,0 +1,138 @@ +use egui::{Color32, Stroke, Visuals}; + +/// Colours used by editor-only canvas chrome. Figure content and exports never +/// consume this table. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ChromeStyle { + pub selection_fill: Color32, + pub selection_stroke: Color32, + pub selection_active: Color32, + pub layout_grid: Color32, + pub margin_guide: Color32, + pub snap_guide: Color32, + pub tile_existing_fill: Color32, + pub tile_existing_stroke: Color32, + pub tile_target_fill: Color32, + pub tile_target_stroke: Color32, + pub pivot: Color32, + pub integral: Color32, + pub peak: Color32, +} + +impl ChromeStyle { + pub fn from_visuals(visuals: &Visuals, accent: Option<[u8; 3]>) -> Self { + let source = accent + .map(|[r, g, b]| Color32::from_rgb(r, g, b)) + .unwrap_or(visuals.selection.bg_fill); + let background = visuals.panel_fill; + let normal = contrast_adjusted(source, background, 3.0); + let weak = blend(normal, background, 0.42); + let outline = contrast_adjusted(normal, background, 4.5); + let alternate = contrast_adjusted( + if visuals.dark_mode { + Color32::from_rgb(0xff, 0x72, 0xb6) + } else { + Color32::from_rgb(0xa2, 0x00, 0x59) + }, + background, + 3.0, + ); + Self { + selection_fill: with_alpha(normal, 32), + selection_stroke: outline, + selection_active: contrast_adjusted( + Color32::from_rgb(0x1f, 0x9d, 0x74), + background, + 3.0, + ), + layout_grid: weak, + margin_guide: contrast_adjusted(blend(normal, background, 0.25), background, 2.0), + snap_guide: alternate, + tile_existing_fill: with_alpha(normal, 15), + tile_existing_stroke: weak, + tile_target_fill: with_alpha(normal, 26), + tile_target_stroke: outline, + pivot: contrast_adjusted(Color32::from_rgb(0xe0, 0x6c, 0x22), background, 3.0), + integral: contrast_adjusted(Color32::from_rgb(0x2b, 0x6c, 0xb0), background, 3.0), + peak: contrast_adjusted(Color32::from_rgb(0x8a, 0x1c, 0x1c), background, 3.0), + } + } + + pub fn tile_existing_stroke(self) -> Stroke { + Stroke::new(1.0_f32, self.tile_existing_stroke) + } + + pub fn tile_target_stroke(self) -> Stroke { + Stroke::new(2.0_f32, self.tile_target_stroke) + } +} + +fn with_alpha(color: Color32, alpha: u8) -> Color32 { + Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), alpha) +} + +fn blend(foreground: Color32, background: Color32, amount: f32) -> Color32 { + let mix = |a: u8, b: u8| (a as f32 * (1.0 - amount) + b as f32 * amount).round() as u8; + Color32::from_rgb( + mix(foreground.r(), background.r()), + mix(foreground.g(), background.g()), + mix(foreground.b(), background.b()), + ) +} + +fn contrast_adjusted(mut color: Color32, background: Color32, minimum: f32) -> Color32 { + let target = if relative_luminance(background) > 0.45 { + Color32::BLACK + } else { + Color32::WHITE + }; + for _ in 0..12 { + if contrast_ratio(color, background) >= minimum { + break; + } + color = blend(color, target, 0.12); + } + color +} + +fn contrast_ratio(a: Color32, b: Color32) -> f32 { + let (lighter, darker) = if relative_luminance(a) >= relative_luminance(b) { + (relative_luminance(a), relative_luminance(b)) + } else { + (relative_luminance(b), relative_luminance(a)) + }; + (lighter + 0.05) / (darker + 0.05) +} + +fn relative_luminance(color: Color32) -> f32 { + let channel = |value: u8| { + let value = value as f32 / 255.0; + if value <= 0.04045 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * channel(color.r()) + 0.7152 * channel(color.g()) + 0.0722 * channel(color.b()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn translucent_roles_keep_unmultiplied_rgb() { + let style = ChromeStyle::from_visuals(&Visuals::light(), Some([120, 80, 40])); + let [r, g, b, alpha] = style.tile_existing_fill.to_srgba_unmultiplied(); + assert_eq!(alpha, 15); + assert_eq!(style.tile_target_fill.to_srgba_unmultiplied()[3], 26); + assert_eq!( + style.tile_existing_fill, + Color32::from_rgba_unmultiplied(r, g, b, alpha) + ); + assert_ne!( + style.tile_existing_fill, + Color32::from_rgba_premultiplied(r, g, b, alpha) + ); + } +} diff --git a/crates/app/src/ui/canvas/integrals2d.rs b/crates/app/src/ui/canvas/integrals2d.rs index 96bb140..b61fc54 100644 --- a/crates/app/src/ui/canvas/integrals2d.rs +++ b/crates/app/src/ui/canvas/integrals2d.rs @@ -455,6 +455,7 @@ pub(crate) fn paint_integrals_2d( dataset: usize, plot: PlotRect, painter: &egui::Painter, + chrome: ChromeStyle, ) { let Some(n) = app .doc @@ -487,7 +488,7 @@ pub(crate) fn paint_integrals_2d( if r.width() < 1.0 || r.height() < 1.0 { continue; } - let color = INTEGRAL_COLOR; + let color = chrome.integral; let [red, green, blue, _] = color.to_array(); painter.rect_filled( r, @@ -548,11 +549,11 @@ pub(crate) fn paint_integrals_2d( baseline: BaselineMode::None, }; let r = integral_screen_rect(&preview, plot, x, y).intersect(plot_rect(plot)); - painter.rect_filled(r, 0.0, SELECT_FILL); + painter.rect_filled(r, 0.0, chrome.selection_fill); painter.rect_stroke( r, 0.0, - Stroke::new(1.0_f32, SELECT_STROKE), + Stroke::new(1.0_f32, chrome.selection_stroke), StrokeKind::Inside, ); } diff --git a/crates/app/src/ui/canvas/interactions.rs b/crates/app/src/ui/canvas/interactions.rs index 1079e82..3d3e6d0 100644 --- a/crates/app/src/ui/canvas/interactions.rs +++ b/crates/app/src/ui/canvas/interactions.rs @@ -496,6 +496,38 @@ pub(crate) fn arrange_context_menu(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { } } }); + if ui.button("Simplify inner axes").clicked() { + app.simplify_inner_axes(); + ui.close(); + } + ui.menu_button("Spacing basis", |ui| { + for (label, mode) in [ + ("Frame", layout::SpacingMode::Frame), + ("Visual", layout::SpacingMode::Visual), + ] { + let checked = app.doc.canvases[ci].layout.spacing_mode == mode; + if ui.selectable_label(checked, label).clicked() { + app.set_spacing_mode(mode); + ui.close(); + } + } + }); + ui.menu_button("Minimum spacing", |ui| { + for preset in layout::GutterPreset::ALL { + let checked = + (app.doc.canvases[ci].layout.gutter_mm - preset.millimetres()).abs() < 0.001; + if ui + .selectable_label( + checked, + format!("{} ({} mm)", preset.label(), preset.millimetres()), + ) + .clicked() + { + app.set_gutter_preset(preset); + ui.close(); + } + } + }); if !app.session.ui.selection.objects().is_empty() { ui.menu_button("Order", |ui| { for (label, op) in [ diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index c82d553..ced33fc 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -21,22 +21,15 @@ const PIVOT_GRAB_PX: f32 = 6.0; const SELECT_MIN_PX: f32 = 6.0; const DRAG_START_PX: f32 = 5.0; const WHEEL_ZOOM_SPEED: f32 = 0.0015; -const PIVOT_COLOR: Color32 = Color32::from_rgb(0xE0, 0x6C, 0x22); -const SELECT_FILL: Color32 = Color32::from_rgba_premultiplied(0x1f, 0x6f, 0xeb, 32); -const SELECT_STROKE: Color32 = Color32::from_rgb(0x1f, 0x6f, 0xeb); -const SELECT_ACCENT: Color32 = Color32::from_rgb(0x1f, 0x9d, 0x74); const HANDLE_SIZE_PX: f32 = 8.0; const MIN_OBJECT_SIZE_PT: f32 = 24.0; const PANEL_LABEL_HIT_PAD_PX: f32 = 4.0; const SNAP_PX: f32 = 6.0; -const GRID_COLOR: Color32 = Color32::from_rgb(0x5a, 0xa9, 0xc4); -const GUIDE_COLOR: Color32 = Color32::from_rgb(0xff, 0x2d, 0x92); -const INTEGRAL_COLOR: Color32 = Color32::from_rgb(0x2b, 0x6c, 0xb0); -const PEAK_COLOR: Color32 = Color32::from_rgb(0x8a, 0x1c, 0x1c); mod authoring; mod board; mod board_notes; +mod chrome; mod geometry; mod integrals; mod integrals2d; @@ -54,6 +47,7 @@ mod tiling; pub(crate) use authoring::*; pub(crate) use board::*; pub(crate) use board_notes::*; +pub(crate) use chrome::*; pub(crate) use geometry::*; pub(crate) use integrals::*; pub(crate) use integrals2d::*; @@ -84,6 +78,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); ensure_board_view(app, rect); drive_board_fit(app, ui, rect); @@ -178,13 +173,13 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { paint_frame_captions(app, rect, ui, &painter); render_inline_panel_note_editor(app, rect, ui); paint_sheet_frames(app, rect, ui, &painter); - paint_layout_overlay(app, ci, rect, &painter); - paint_axis_zoom(app, ci, rect, &painter); - paint_author_drag(app, ci, rect, &painter); - paint_marquee(app, ci, rect, &painter); - paint_panel_label_selection(app, ci, rect, &painter); - paint_object_selection(app, ci, rect, page, &painter); - paint_tile_preview(app, rect, &painter); + paint_layout_overlay(app, ci, rect, &painter, chrome); + paint_axis_zoom(app, ci, rect, &painter, chrome); + paint_author_drag(app, ci, rect, &painter, chrome); + paint_marquee(app, ci, rect, &painter, chrome); + paint_panel_label_selection(app, ci, rect, &painter, chrome); + paint_object_selection(app, ci, rect, page, &painter, chrome); + paint_tile_preview(app, rect, &painter, chrome); super::canvas_size::page_size_chrome(app, ci, page, rect, ui); if pointer_owned { canvas_cursor(app, ci, rect, ui); @@ -245,7 +240,7 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { .clamp(plot.left, plot.right()); painter.line_segment( [Pos2::new(px, plot.top), Pos2::new(px, plot.bottom())], - Stroke::new(1.5_f32, PIVOT_COLOR), + Stroke::new(1.5_f32, chrome.pivot), ); } PhaseOrient::Horizontal => { @@ -254,7 +249,7 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { .clamp(plot.top, plot.bottom()); painter.line_segment( [Pos2::new(plot.left, py), Pos2::new(plot.right(), py)], - Stroke::new(1.5_f32, PIVOT_COLOR), + Stroke::new(1.5_f32, chrome.pivot), ); } } @@ -290,14 +285,14 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { } } - paint_zoom_drag(app, ci, object_id, plot, &painter); - paint_regions(app, ci, object_id, di, plot, &painter); - paint_integrals(app, ci, object_id, di, plot, &painter); - paint_integrals_2d(app, ci, object_id, di, plot, &painter); - paint_peaks(app, ci, object_id, di, plot, &painter); + paint_zoom_drag(app, ci, object_id, plot, &painter, chrome); + paint_regions(app, ci, object_id, di, plot, &painter, chrome); + paint_integrals(app, ci, object_id, di, plot, &painter, chrome); + paint_integrals_2d(app, ci, object_id, di, plot, &painter, chrome); + paint_peaks(app, ci, object_id, di, plot, &painter, chrome); paint_slice(app, ci, object_id, di, plot, &painter); - paint_analysis_selection(app, ci, object_id, plot, &painter); - paint_selection_drag(app, ci, object_id, plot, &painter); + paint_analysis_selection(app, ci, object_id, plot, &painter, chrome); + paint_selection_drag(app, ci, object_id, plot, &painter, chrome); } fn welcome_page(app: &mut PlotxApp, ui: &mut Ui) { diff --git a/crates/app/src/ui/canvas/painting.rs b/crates/app/src/ui/canvas/painting.rs index f33a05e..eb97ea7 100644 --- a/crates/app/src/ui/canvas/painting.rs +++ b/crates/app/src/ui/canvas/painting.rs @@ -7,6 +7,7 @@ pub(crate) fn paint_zoom_drag( object_id: ObjectId, plot: PlotRect, painter: &egui::Painter, + chrome: ChromeStyle, ) { let drag = match &app.session.ui.interaction { Interaction::Zoom(d) if d.axis == ZoomAxis::Box => *d, @@ -19,11 +20,11 @@ pub(crate) fn paint_zoom_drag( if r.width() < 1.0 || r.height() < 1.0 { return; } - painter.rect_filled(r, 0.0, SELECT_FILL); + painter.rect_filled(r, 0.0, chrome.selection_fill); painter.rect_stroke( r, 0.0, - Stroke::new(1.0_f32, SELECT_STROKE), + Stroke::new(1.0_f32, chrome.selection_stroke), StrokeKind::Inside, ); } @@ -35,6 +36,7 @@ pub(crate) fn paint_axis_zoom( ci: usize, rect: egui::Rect, painter: &egui::Painter, + chrome: ChromeStyle, ) { let drag = match &app.session.ui.interaction { Interaction::Zoom(d) if d.axis != ZoomAxis::Box => *d, @@ -62,11 +64,11 @@ pub(crate) fn paint_axis_zoom( if r.width() < 1.0 || r.height() < 1.0 { return; } - painter.rect_filled(r, 0.0, SELECT_FILL); + painter.rect_filled(r, 0.0, chrome.selection_fill); painter.rect_stroke( r, 0.0, - Stroke::new(1.0_f32, SELECT_STROKE), + Stroke::new(1.0_f32, chrome.selection_stroke), StrokeKind::Inside, ); } @@ -77,6 +79,7 @@ pub(crate) fn paint_analysis_selection( object_id: ObjectId, plot: PlotRect, painter: &egui::Painter, + chrome: ChromeStyle, ) { let Some(selection) = &app.session.ui.analysis_selection else { return; @@ -113,11 +116,11 @@ pub(crate) fn paint_analysis_selection( if r.width() < 1.0 { return; } - painter.rect_filled(r, 0.0, SELECT_FILL); + painter.rect_filled(r, 0.0, chrome.selection_fill); painter.rect_stroke( r, 0.0, - Stroke::new(1.0_f32, SELECT_STROKE), + Stroke::new(1.0_f32, chrome.selection_stroke), StrokeKind::Inside, ); } @@ -131,6 +134,7 @@ pub(crate) fn paint_regions( dataset: usize, plot: PlotRect, painter: &egui::Painter, + chrome: ChromeStyle, ) { let Some(fig) = app.doc.canvases[ci] .object(object_id) @@ -206,11 +210,11 @@ pub(crate) fn paint_regions( ) .intersect(plot_rect(plot)); if r.width() >= 1.0 { - painter.rect_filled(r, 0.0, SELECT_FILL); + painter.rect_filled(r, 0.0, chrome.selection_fill); painter.rect_stroke( r, 0.0, - Stroke::new(1.0_f32, SELECT_STROKE), + Stroke::new(1.0_f32, chrome.selection_stroke), StrokeKind::Inside, ); } @@ -224,6 +228,7 @@ pub(crate) fn paint_integrals( dataset: usize, plot: PlotRect, painter: &egui::Painter, + chrome: ChromeStyle, ) { if app.session.tool != Tool::Integrate { return; @@ -263,7 +268,7 @@ pub(crate) fn paint_integrals( if r.width() < 1.0 { continue; } - let color = INTEGRAL_COLOR; + let color = chrome.integral; let [cr, cg, cb, _] = color.to_array(); let is_sel = selected == Some(integ.id); let is_hovered = hover_x.is_some_and(|x| x >= r.left() && x <= r.right()); @@ -318,11 +323,11 @@ pub(crate) fn paint_integrals( ) .intersect(plot_rect(plot)); if r.width() >= 1.0 { - painter.rect_filled(r, 0.0, SELECT_FILL); + painter.rect_filled(r, 0.0, chrome.selection_fill); painter.rect_stroke( r, 0.0, - Stroke::new(1.0_f32, SELECT_STROKE), + Stroke::new(1.0_f32, chrome.selection_stroke), StrokeKind::Inside, ); } @@ -338,6 +343,7 @@ pub(crate) fn paint_peaks( dataset: usize, plot: PlotRect, painter: &egui::Painter, + chrome: ChromeStyle, ) { if app.session.tool != Tool::Peaks { return; @@ -380,7 +386,7 @@ pub(crate) fn paint_peaks( if ly >= plot.top && ly <= plot.bottom() { for seg in egui::Shape::dashed_line( &[Pos2::new(plot.left, ly), Pos2::new(plot.right(), ly)], - Stroke::new(1.0_f32, PEAK_COLOR), + Stroke::new(1.0_f32, chrome.peak), 6.0, 4.0, ) { @@ -392,7 +398,7 @@ pub(crate) fn paint_peaks( for (px, py) in PeakSet::detect_at(&trace, Some(y), peaks.detector.max_count) { let at = Pos2::new(sx(px), sy(py)); if plot_contains(plot, at) { - painter.circle_stroke(at, 3.0, Stroke::new(1.5_f32, PEAK_COLOR)); + painter.circle_stroke(at, 3.0, Stroke::new(1.5_f32, chrome.peak)); } } } @@ -405,11 +411,13 @@ pub(crate) fn paint_peaks( continue; } match peak.origin { - PeakOrigin::Manual => painter.circle_filled(p, 3.0, PEAK_COLOR), - PeakOrigin::Detected => painter.circle_stroke(p, 3.0, Stroke::new(1.5_f32, PEAK_COLOR)), + PeakOrigin::Manual => painter.circle_filled(p, 3.0, chrome.peak), + PeakOrigin::Detected => { + painter.circle_stroke(p, 3.0, Stroke::new(1.5_f32, chrome.peak)) + } }; if peak.mark_id.is_some() && peak.mark_id == selected { - painter.circle_stroke(p, 5.5, Stroke::new(2.0_f32, SELECT_ACCENT)); + painter.circle_stroke(p, 5.5, Stroke::new(2.0_f32, chrome.selection_active)); } } @@ -423,11 +431,11 @@ pub(crate) fn paint_peaks( ) .intersect(plot_rect(plot)); if r.width() >= 1.0 { - painter.rect_filled(r, 0.0, SELECT_FILL); + painter.rect_filled(r, 0.0, chrome.selection_fill); painter.rect_stroke( r, 0.0, - Stroke::new(1.0_f32, SELECT_STROKE), + Stroke::new(1.0_f32, chrome.selection_stroke), StrokeKind::Inside, ); } @@ -455,8 +463,8 @@ pub(crate) fn paint_peaks( let (px, py) = trace.snap(hover_x); let at = Pos2::new(sx(px), sy(py)); if plot_contains(plot, at) { - painter.circle_stroke(at, 4.0, Stroke::new(1.5_f32, SELECT_ACCENT)); - painter.circle_filled(at, 1.5, SELECT_ACCENT); + painter.circle_stroke(at, 4.0, Stroke::new(1.5_f32, chrome.selection_active)); + painter.circle_filled(at, 1.5, chrome.selection_active); } } @@ -466,6 +474,7 @@ pub(crate) fn paint_selection_drag( object_id: ObjectId, plot: PlotRect, painter: &egui::Painter, + chrome: ChromeStyle, ) { let drag = match &app.session.ui.interaction { Interaction::Selection(d) => *d, @@ -484,11 +493,11 @@ pub(crate) fn paint_selection_drag( if r.width() < 1.0 { return; } - painter.rect_filled(r, 0.0, SELECT_FILL); + painter.rect_filled(r, 0.0, chrome.selection_fill); painter.rect_stroke( r, 0.0, - Stroke::new(1.0_f32, SELECT_STROKE), + Stroke::new(1.0_f32, chrome.selection_stroke), StrokeKind::Inside, ); } @@ -523,14 +532,43 @@ pub(crate) fn paint_layout_overlay( ci: usize, rect: egui::Rect, painter: &egui::Painter, + chrome: ChromeStyle, ) { let canvas = &app.doc.canvases[ci]; let bt = BoardTransform::from_board(app.session.board, rect); let page = bt.page_screen_rect(canvas); let zoom = bt.zoom; - if canvas.layout.show_grid && app.session.tool.is_layout_tool() { - let stroke = Stroke::new(1.0_f32, GRID_COLOR); + let layout_tool = app.session.tool.is_layout_tool(); + if layout_tool { + let [top, right, bottom, left] = canvas.layout.margin_mm; + let mm = plotx_core::state::MM_TO_PT * zoom; + let stroke = Stroke::new(1.0_f32, chrome.margin_guide); + let dashed = |points: [Pos2; 2]| { + for segment in egui::Shape::dashed_line(&points, stroke, 5.0, 4.0) { + painter.add(segment); + } + }; + if top > 0.0 { + let y = page.top() + top * mm; + dashed([Pos2::new(page.left(), y), Pos2::new(page.right(), y)]); + } + if right > 0.0 { + let x = page.right() - right * mm; + dashed([Pos2::new(x, page.top()), Pos2::new(x, page.bottom())]); + } + if bottom > 0.0 { + let y = page.bottom() - bottom * mm; + dashed([Pos2::new(page.left(), y), Pos2::new(page.right(), y)]); + } + if left > 0.0 { + let x = page.left() + left * mm; + dashed([Pos2::new(x, page.top()), Pos2::new(x, page.bottom())]); + } + } + + if canvas.layout.show_grid && layout_tool { + let stroke = Stroke::new(1.0_f32, chrome.layout_grid); for cell in layout::grid_frames(canvas.size_pt(), &canvas.layout) { let r = EguiRect::from_min_size( Pos2::new(page.left() + cell.x * zoom, page.top() + cell.y * zoom), @@ -540,7 +578,7 @@ pub(crate) fn paint_layout_overlay( } } - let stroke = Stroke::new(1.0_f32, GUIDE_COLOR); + let stroke = Stroke::new(1.0_f32, chrome.snap_guide); for guide in &app.session.ui.snap_guides { if guide.vertical { let x = page.left() + guide.pos * zoom; @@ -563,6 +601,7 @@ pub(crate) fn paint_author_drag( ci: usize, rect: egui::Rect, painter: &egui::Painter, + chrome: ChromeStyle, ) { let drag = match &app.session.ui.interaction { Interaction::Author(d) => *d, @@ -576,11 +615,11 @@ pub(crate) fn paint_author_drag( let zoom = bt.zoom; let to_screen = |p: [f32; 2]| Pos2::new(page.left() + p[0] * zoom, page.top() + p[1] * zoom); let r = EguiRect::from_two_pos(to_screen(drag.start), to_screen(drag.current)); - painter.rect_filled(r, 0.0, SELECT_FILL); + painter.rect_filled(r, 0.0, chrome.selection_fill); painter.rect_stroke( r, 0.0, - Stroke::new(1.0_f32, SELECT_STROKE), + Stroke::new(1.0_f32, chrome.selection_stroke), StrokeKind::Inside, ); } @@ -590,6 +629,7 @@ pub(crate) fn paint_panel_label_selection( ci: usize, rect: egui::Rect, painter: &egui::Painter, + chrome: ChromeStyle, ) { let Some((canvas, object_id)) = app.panel_label_selection() else { return; @@ -605,7 +645,7 @@ pub(crate) fn paint_panel_label_selection( painter.rect_stroke( r, 0.0, - Stroke::new(1.0_f32, Color32::from_rgb(0x7a, 0x4d, 0xff)), + Stroke::new(1.0_f32, chrome.selection_active), StrokeKind::Inside, ); } @@ -616,6 +656,7 @@ pub(crate) fn paint_object_selection( rect: egui::Rect, _page: egui::Rect, painter: &egui::Painter, + chrome: ChromeStyle, ) { let selection = &app.session.ui.selection; let mut ids = selection.objects().to_vec(); @@ -630,9 +671,9 @@ pub(crate) fn paint_object_selection( }; let r = plot_rect(frame); let stroke = if data_edit_target(app, ci) == Some(id) { - Stroke::new(2.0_f32, SELECT_ACCENT) + Stroke::new(2.0_f32, chrome.selection_active) } else { - Stroke::new(1.5_f32, SELECT_STROKE) + Stroke::new(1.5_f32, chrome.selection_stroke) }; painter.rect_stroke(r, 0.0, stroke, StrokeKind::Inside); if handles { @@ -645,14 +686,20 @@ pub(crate) fn paint_object_selection( painter.rect_filled( egui::Rect::from_center_size(p, egui::vec2(HANDLE_SIZE_PX, HANDLE_SIZE_PX)), 0.0, - SELECT_STROKE, + chrome.selection_stroke, ); } } } } -pub(crate) fn paint_marquee(app: &PlotxApp, ci: usize, rect: egui::Rect, painter: &egui::Painter) { +pub(crate) fn paint_marquee( + app: &PlotxApp, + ci: usize, + rect: egui::Rect, + painter: &egui::Painter, + chrome: ChromeStyle, +) { let marq = match &app.session.ui.interaction { Interaction::Marquee(d) => *d, _ => return, @@ -665,11 +712,11 @@ pub(crate) fn paint_marquee(app: &PlotxApp, ci: usize, rect: egui::Rect, painter let zoom = bt.zoom; let to_screen = |p: [f32; 2]| Pos2::new(page.left() + p[0] * zoom, page.top() + p[1] * zoom); let r = EguiRect::from_two_pos(to_screen(marq.start), to_screen(marq.current)); - painter.rect_filled(r, 0.0, SELECT_FILL); + painter.rect_filled(r, 0.0, chrome.selection_fill); painter.rect_stroke( r, 0.0, - Stroke::new(1.0_f32, SELECT_STROKE), + Stroke::new(1.0_f32, chrome.selection_stroke), StrokeKind::Inside, ); } diff --git a/crates/app/src/ui/canvas/tiling.rs b/crates/app/src/ui/canvas/tiling.rs index 7866fe5..9ed79c4 100644 --- a/crates/app/src/ui/canvas/tiling.rs +++ b/crates/app/src/ui/canvas/tiling.rs @@ -42,10 +42,30 @@ pub(crate) fn update_tile_drop( let page_pt = app.doc.canvases[target].size_pt(); let layout = app.doc.canvases[target].layout; let existing_ids = app.doc.canvases[target].plot_object_ids(); - let plan = plotx_core::layout::compute_tiling_plan( + if app.session.ui.tile_drop.as_ref().is_some_and(|preview| { + preview.target == target + && preview + .existing + .iter() + .map(|(id, _)| *id) + .eq(existing_ids.iter().copied()) + && preview_cell_matches(preview, page_pt, existing_ids.len(), pointer_page) + }) { + return true; + } + let existing_items: Vec<_> = existing_ids + .iter() + .filter_map(|&id| layout_item(&app.doc.canvases[target], id)) + .collect(); + let Some(newcomer_item) = layout_item(&app.doc.canvases[drag.canvas], drag.object) else { + app.session.ui.tile_drop = None; + return false; + }; + let plan = plotx_core::layout::compute_tiling_plan_for_items( page_pt, &layout, - &existing_ids, + &existing_items, + newcomer_item, [pointer_page.x, pointer_page.y], ); app.session.ui.tile_drop = Some(TileDropPreview { @@ -56,6 +76,51 @@ pub(crate) fn update_tile_drop( true } +fn layout_item(canvas: &CanvasDocument, id: ObjectId) -> Option { + let object = canvas.object(id)?; + let plot = object.plot()?; + Some(plotx_core::layout::layout_item( + id, + &plot.figure, + object.frame, + )) +} + +fn preview_cell_matches( + preview: &TileDropPreview, + page: [f32; 2], + existing_count: usize, + pointer: Pos2, +) -> bool { + if existing_count != 1 { + return true; + } + let Some((_, existing)) = preview.existing.first() else { + return false; + }; + let nx = if page[0] > 0.0 { + pointer.x / page[0] + } else { + 0.5 + }; + let ny = if page[1] > 0.0 { + pointer.y / page[1] + } else { + 0.5 + }; + let horizontal = (nx - 0.5).abs() >= (ny - 0.5).abs(); + let newcomer_last = if horizontal { nx >= 0.5 } else { ny >= 0.5 }; + let dx = preview.newcomer.x - existing.x; + let dy = preview.newcomer.y - existing.y; + let preview_horizontal = dx.abs() >= dy.abs(); + let preview_last = if preview_horizontal { + dx >= 0.0 + } else { + dy >= 0.0 + }; + horizontal == preview_horizontal && newcomer_last == preview_last +} + /// Falls back to a plain move if the atomic action cannot be built. pub(crate) fn commit_tile_drop( app: &mut PlotxApp, @@ -81,7 +146,12 @@ pub(crate) fn commit_tile_drop( app.session.status = format!("Tiled plot into “{target}”."); } -pub(crate) fn paint_tile_preview(app: &PlotxApp, rect: egui::Rect, painter: &egui::Painter) { +pub(crate) fn paint_tile_preview( + app: &PlotxApp, + rect: egui::Rect, + painter: &egui::Painter, + chrome: ChromeStyle, +) { let Some(preview) = &app.session.ui.tile_drop else { return; }; @@ -97,18 +167,21 @@ pub(crate) fn paint_tile_preview(app: &PlotxApp, rect: egui::Rect, painter: &egu Vec2::new(f.width * zoom, f.height * zoom), ) }; - let existing_fill = Color32::from_rgba_premultiplied(0x5a, 0xa9, 0xc4, 40); for (_, f) in &preview.existing { let r = to_screen(f); - painter.rect_filled(r, 0.0, existing_fill); - painter.rect_stroke(r, 0.0, Stroke::new(1.0_f32, GRID_COLOR), StrokeKind::Inside); + painter.rect_filled(r, 0.0, chrome.tile_existing_fill); + painter.rect_stroke(r, 0.0, chrome.tile_existing_stroke(), StrokeKind::Inside); } let r = to_screen(&preview.newcomer); - painter.rect_filled(r, 0.0, SELECT_FILL); - painter.rect_stroke( - r, - 0.0, - Stroke::new(2.0_f32, SELECT_STROKE), - StrokeKind::Inside, - ); + painter.rect_filled(r, 0.0, chrome.tile_target_fill); + let outline = [ + r.left_top(), + r.right_top(), + r.right_bottom(), + r.left_bottom(), + r.left_top(), + ]; + for segment in egui::Shape::dashed_line(&outline, chrome.tile_target_stroke(), 6.0, 4.0) { + painter.add(segment); + } } diff --git a/crates/app/src/ui/command_exec.rs b/crates/app/src/ui/command_exec.rs index 5118eb2..d5cfb05 100644 --- a/crates/app/src/ui/command_exec.rs +++ b/crates/app/src/ui/command_exec.rs @@ -133,6 +133,9 @@ pub fn execute( CommandId::ArrangeGrid(rows, columns) => { app.arrange_active_canvas_grid(rows, columns); } + CommandId::SimplifyInnerAxes => app.simplify_inner_axes(), + CommandId::SetSpacingMode(mode) => app.set_spacing_mode(mode), + CommandId::SetGutterPreset(preset) => app.set_gutter_preset(preset), CommandId::Align(mode) => app.align_selected(mode), CommandId::Distribute(mode) => app.distribute_selected(mode), CommandId::ZOrder(mode) => app.z_order_selected(mode), diff --git a/crates/app/src/ui/commands.rs b/crates/app/src/ui/commands.rs index b04616b..ce77f4b 100644 --- a/crates/app/src/ui/commands.rs +++ b/crates/app/src/ui/commands.rs @@ -4,7 +4,7 @@ use plotx_core::actions::ZOrder; use plotx_core::export::ExportFormat; -use plotx_core::layout::{Align, Distribute}; +use plotx_core::layout::{Align, Distribute, GutterPreset, SpacingMode}; use plotx_core::state::{Dataset, ObjectId, PlotxApp, Tool, WorkflowTab}; pub use super::command_exec::execute; @@ -101,6 +101,9 @@ pub enum CommandId { /// palette-searchable. SetCanvasSizePreset(&'static str), ArrangeGrid(u32, u32), + SimplifyInnerAxes, + SetSpacingMode(SpacingMode), + SetGutterPreset(GutterPreset), Align(Align), Distribute(Distribute), ZOrder(ZOrder), @@ -206,6 +209,7 @@ pub fn catalog(app: &PlotxApp) -> Vec { CommandId::Multiplets, CommandId::TidyBoard, CommandId::CanvasSettings, + CommandId::SimplifyInnerAxes, ]; ids.extend((0..app.session.recent_files.len()).map(CommandId::OpenRecent)); ids.extend( @@ -214,6 +218,8 @@ pub fn catalog(app: &PlotxApp) -> Vec { .enumerate() .map(|(i, _)| CommandId::NewCanvas(i)), ); + ids.extend([SpacingMode::Frame, SpacingMode::Visual].map(CommandId::SetSpacingMode)); + ids.extend(GutterPreset::ALL.map(CommandId::SetGutterPreset)); ids.extend( [ ExportFormat::Svg, @@ -475,7 +481,10 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { CommandId::SetCanvasSizePreset(_) => { requires(has_canvas, "Open a canvas before changing its size.") } - CommandId::ArrangeGrid(_, _) => { + CommandId::ArrangeGrid(_, _) + | CommandId::SimplifyInnerAxes + | CommandId::SetSpacingMode(_) + | CommandId::SetGutterPreset(_) => { requires(has_canvas, "Open a canvas before arranging its plots.") } CommandId::ApplyTheme(_) => requires(has_canvas, "Open a canvas before applying a theme."), @@ -581,6 +590,9 @@ fn ribbon_placement(id: CommandId) -> Option { CommandId::Tool(Tool::Select) | CommandId::ArrangeGrid(1, 2) | CommandId::ArrangeGrid(2, 2) + | CommandId::SimplifyInnerAxes + | CommandId::SetSpacingMode(_) + | CommandId::SetGutterPreset(_) | CommandId::TidyBoard => (Arrange, "Layout", 0, Always), CommandId::Align(_) => (Arrange, "Align", 1, Always), CommandId::Distribute(_) => (Arrange, "Distribute", 2, Always), diff --git a/crates/app/src/ui/commands/identity.rs b/crates/app/src/ui/commands/identity.rs index f8df076..bd0257e 100644 --- a/crates/app/src/ui/commands/identity.rs +++ b/crates/app/src/ui/commands/identity.rs @@ -4,7 +4,7 @@ use egui_phosphor::regular as icon; use plotx_core::actions::ZOrder; -use plotx_core::layout::{Align, Distribute}; +use plotx_core::layout::{Align, Distribute, GutterPreset, SpacingMode}; use plotx_core::state::{PlotxApp, Tool}; use super::CommandId; @@ -16,6 +16,8 @@ impl CommandId { Self::NewCanvas(i) => format!("file.new_canvas.{i}"), Self::Export(f) => format!("file.export.{}", f.extension()), Self::ArrangeGrid(r, c) => format!("arrange.grid.{r}x{c}"), + Self::SetSpacingMode(mode) => format!("arrange.spacing_mode.{}", spacing_slug(mode)), + Self::SetGutterPreset(preset) => format!("arrange.gutter.{}", gutter_slug(preset)), Self::Align(mode) => format!("arrange.align.{}", align_slug(mode)), Self::Distribute(Distribute::Horizontal) => "arrange.distribute.horizontal".into(), Self::Distribute(Distribute::Vertical) => "arrange.distribute.vertical".into(), @@ -177,6 +179,32 @@ pub(super) fn command_identity( Some(icon::SQUARES_FOUR), None, ), + CommandId::SimplifyInnerAxes => plain("Simplify Inner Axes", Some(icon::SQUARES_FOUR)), + CommandId::SetSpacingMode(mode) => { + let checked = app + .session + .active_canvas + .is_some_and(|ci| app.doc.canvases[ci].layout.spacing_mode == mode); + ( + format!("Spacing: {}", spacing_label(mode)), + Some(icon::ARROWS_LEFT_RIGHT), + Some(checked), + ) + } + CommandId::SetGutterPreset(preset) => { + let checked = app.session.active_canvas.is_some_and(|ci| { + (app.doc.canvases[ci].layout.gutter_mm - preset.millimetres()).abs() < 0.001 + }); + ( + format!( + "Minimum spacing: {} ({} mm)", + preset.label(), + preset.millimetres() + ), + Some(icon::ARROWS_LEFT_RIGHT), + Some(checked), + ) + } CommandId::Align(mode) => ( format!("Align {}", align_label(mode)), Some(align_icon(mode)), @@ -328,10 +356,33 @@ fn simple_stable_id(id: CommandId) -> &'static str { CommandId::Multiplets => "analysis.multiplets", CommandId::TidyBoard => "arrange.tidy", CommandId::CanvasSettings => "figure.canvas_settings", + CommandId::SimplifyInnerAxes => "arrange.simplify_inner_axes", _ => unreachable!("dynamic commands have formatted stable IDs"), } } +fn spacing_slug(mode: SpacingMode) -> &'static str { + match mode { + SpacingMode::Frame => "frame", + SpacingMode::Visual => "visual", + } +} + +fn spacing_label(mode: SpacingMode) -> &'static str { + match mode { + SpacingMode::Frame => "Frame", + SpacingMode::Visual => "Visual", + } +} + +fn gutter_slug(preset: GutterPreset) -> &'static str { + match preset { + GutterPreset::Tight => "tight", + GutterPreset::Normal => "normal", + GutterPreset::Spacious => "spacious", + } +} + fn align_label(mode: Align) -> &'static str { match mode { Align::Left => "Left", diff --git a/crates/app/src/ui/commands_tests.rs b/crates/app/src/ui/commands_tests.rs index 16c7ec0..807ba87 100644 --- a/crates/app/src/ui/commands_tests.rs +++ b/crates/app/src/ui/commands_tests.rs @@ -106,6 +106,63 @@ fn stable_ids_cover_static_and_dynamic_commands() { assert_eq!(CommandId::ClearRecentFiles.stable_id(), "file.clear_recent"); assert_eq!(CommandId::HelpManual.stable_id(), "help.manual"); assert_eq!(CommandId::RunBatchWorkflow.stable_id(), "tools.automation"); + assert_eq!( + CommandId::SimplifyInnerAxes.stable_id(), + "arrange.simplify_inner_axes" + ); + assert_eq!( + CommandId::SetSpacingMode(plotx_core::layout::SpacingMode::Visual).stable_id(), + "arrange.spacing_mode.visual" + ); + assert_eq!( + CommandId::SetGutterPreset(plotx_core::layout::GutterPreset::Tight).stable_id(), + "arrange.gutter.tight" + ); +} + +#[test] +fn spacing_commands_are_registered_checked_and_execute() { + let mut app = app_with_nmr(); + assert!( + catalog(&app) + .iter() + .any(|entry| entry.id == CommandId::SimplifyInnerAxes) + ); + let ctx = egui::Context::default(); + let mut clipboard = crate::ui::clipboard_table::ClipboardTablePaste::default(); + execute( + CommandId::SetSpacingMode(plotx_core::layout::SpacingMode::Frame), + &mut app, + &mut clipboard, + &ctx, + ); + assert_eq!( + app.doc.canvases[0].layout.spacing_mode, + plotx_core::layout::SpacingMode::Frame + ); + assert_eq!( + describe( + &app, + CommandId::SetSpacingMode(plotx_core::layout::SpacingMode::Frame) + ) + .checked, + Some(true) + ); + execute( + CommandId::SetGutterPreset(plotx_core::layout::GutterPreset::Tight), + &mut app, + &mut clipboard, + &ctx, + ); + assert_eq!(app.doc.canvases[0].layout.gutter_mm, 2.0); + assert_eq!( + describe( + &app, + CommandId::SetGutterPreset(plotx_core::layout::GutterPreset::Tight) + ) + .checked, + Some(true) + ); } #[test] diff --git a/crates/app/src/ui/object_inspector/axes.rs b/crates/app/src/ui/object_inspector/axes.rs index 908b182..838fcad 100644 --- a/crates/app/src/ui/object_inspector/axes.rs +++ b/crates/app/src/ui/object_inspector/axes.rs @@ -106,10 +106,79 @@ 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); + } + } + } + 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; + } + } + app.execute_action(Action::set_axis_overrides(canvas, object, before, after)); + } + }); +} + #[allow(clippy::too_many_arguments)] fn label_row( app: &mut PlotxApp, diff --git a/crates/app/src/ui/settings_dialog.rs b/crates/app/src/ui/settings_dialog.rs index 2d6d9ad..506005d 100644 --- a/crates/app/src/ui/settings_dialog.rs +++ b/crates/app/src/ui/settings_dialog.rs @@ -263,6 +263,29 @@ fn render_category( Some("Light, dark, or follow the system appearance."), |ui| theme_combo(ui, &mut draft.appearance.theme), ); + setting_row( + 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; + } + }, + ); ui_scale_row(ui, draft, monitor); setting_row( ui, diff --git a/crates/app/src/ui/windows.rs b/crates/app/src/ui/windows.rs index 61dae3b..9a46940 100644 --- a/crates/app/src/ui/windows.rs +++ b/crates/app/src/ui/windows.rs @@ -175,10 +175,35 @@ pub(super) fn canvas_settings_window(app: &mut PlotxApp, ctx: &egui::Context) { }); ui.horizontal(|ui| { - ui.label("Gutter"); + 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"); @@ -190,12 +215,19 @@ pub(super) fn canvas_settings_window(app: &mut PlotxApp, ctx: &egui::Context) { 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(rows, cols); + app.arrange_active_canvas_grid_with_simplify(rows, cols, simplify); } }); diff --git a/crates/core/src/actions/arrange.rs b/crates/core/src/actions/arrange.rs index 786e588..266f8e6 100644 --- a/crates/core/src/actions/arrange.rs +++ b/crates/core/src/actions/arrange.rs @@ -5,6 +5,15 @@ impl PlotxApp { /// (row-major, current object order) as one undoable step. Objects beyond /// the cell count keep their frame. pub fn arrange_active_canvas_grid(&mut self, rows: u32, cols: u32) { + self.arrange_active_canvas_grid_with_simplify(rows, cols, false); + } + + pub fn arrange_active_canvas_grid_with_simplify( + &mut self, + rows: u32, + cols: u32, + simplify_inner_axes: bool, + ) { let Some(ci) = self.session.active_canvas else { return; }; @@ -17,20 +26,38 @@ impl PlotxApp { after_layout.cols = cols.max(1); let page = canvas.size_pt(); let ids = canvas.plot_object_ids(); - let after = crate::layout::assign_grid(page, &after_layout, &ids); + let axis_changes = if simplify_inner_axes { + simplified_axis_changes(canvas, &ids, rows, cols) + } else { + Vec::new() + }; + let items = layout_items(canvas, &ids, &[], &axis_changes); + let first_pass = crate::layout::arrange_grid(page, &after_layout, &items); + // Axis tick selection depends on the resized frame. One bounded + // refinement keeps Visual spacing object-aware without convergence + // loops, and measures the post-simplification figure when requested. + let refined_items = layout_items(canvas, &ids, &first_pass, &axis_changes); + let after = crate::layout::arrange_grid(page, &after_layout, &refined_items); let before: Vec<(ObjectId, ObjectFrame)> = after .iter() .filter_map(|(id, _)| canvas.object(*id).map(|o| (*id, o.frame))) .collect(); let placed = after.len(); let total = ids.len(); - self.execute_action(Action::ArrangeObjects { + let arrange = Action::ArrangeObjects { canvas: ci, before_layout, after_layout, before, after, - }); + }; + if simplify_inner_axes { + let mut actions = vec![arrange]; + actions.extend(axis_change_actions(ci, axis_changes)); + self.execute_action(Action::Composite(actions)); + } else { + self.execute_action(arrange); + } self.session.status = if placed < total { format!( "Arranged {placed} of {total} objects into {rows}×{cols}; {} kept in place.", @@ -41,6 +68,62 @@ impl PlotxApp { }; } + /// Hide inner axis text for the current grid without changing frames. + pub fn simplify_inner_axes(&mut self) { + let Some(ci) = self.session.active_canvas else { + return; + }; + let Some(canvas) = self.doc.canvases.get(ci) else { + return; + }; + let frames: Vec<_> = canvas + .objects + .iter() + .filter(|object| object.plot().is_some()) + .map(|object| (object.id, object.frame)) + .collect(); + if frames.len() < 2 { + self.session.status = + "Could not simplify axes: at least two plots are required.".to_owned(); + return; + } + let Some(grid) = crate::layout::infer_occupied_grid(&frames) else { + self.session.status = + "Could not simplify axes: arrange plots into a grid first.".to_owned(); + return; + }; + let actions = axis_change_actions( + ci, + simplified_axis_changes(canvas, &grid.ids, grid.rows, grid.cols), + ); + if actions.is_empty() { + self.session.status = "Axes are already simplified.".to_owned(); + return; + } + self.execute_action(Action::Composite(actions)); + self.session.status = "Simplified inner axes.".to_owned(); + } + + pub fn set_spacing_mode(&mut self, mode: crate::layout::SpacingMode) { + let Some(ci) = self.session.active_canvas else { + return; + }; + let before = self.doc.canvases[ci].layout; + let mut after = before; + after.spacing_mode = mode; + self.commit_page_layout(ci, before, after); + } + + pub fn set_gutter_preset(&mut self, preset: crate::layout::GutterPreset) { + let Some(ci) = self.session.active_canvas else { + return; + }; + let before = self.doc.canvases[ci].layout; + let mut after = before; + after.gutter_mm = preset.millimetres(); + self.commit_page_layout(ci, before, after); + } + /// Re-flow every board frame (pages and sheets) into an aligned grid with a /// uniform gutter, as one undoable step — the board's "Tidy up". No-op when /// nothing would move. @@ -198,3 +281,67 @@ impl PlotxApp { }); } } + +fn layout_items( + canvas: &crate::state::CanvasDocument, + ids: &[ObjectId], + frames: &[(ObjectId, ObjectFrame)], + axis_changes: &[AxisOverrideChange], +) -> Vec { + ids.iter() + .filter_map(|&id| { + let object = canvas.object(id)?; + let plot = object.plot()?; + let frame = frames + .iter() + .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(); + change.after.apply_to(&mut figure); + Some(crate::layout::layout_item(id, &figure, frame)) + } else { + Some(crate::layout::layout_item(id, &plot.figure, frame)) + } + }) + .collect() +} + +struct AxisOverrideChange { + id: ObjectId, + before: crate::state::AxisOverrides, + after: crate::state::AxisOverrides, +} + +fn simplified_axis_changes( + canvas: &crate::state::CanvasDocument, + ids: &[ObjectId], + rows: u32, + cols: u32, +) -> Vec { + ids.iter() + .zip(crate::layout::outer_axis_cells(ids.len(), rows, cols)) + .filter_map(|(&id, (keep_x, keep_y))| { + let before = canvas.object(id)?.plot()?.axis_overrides.clone(); + let mut after = before.clone(); + if !keep_x { + after.x_show_tick_labels = Some(false); + after.x_show_label = Some(false); + } + if !keep_y { + after.y_show_tick_labels = Some(false); + after.y_show_label = Some(false); + } + (after != before).then_some(AxisOverrideChange { id, before, after }) + }) + .collect() +} + +fn axis_change_actions(canvas_index: usize, changes: Vec) -> Vec { + changes + .into_iter() + .map(|change| { + Action::set_axis_overrides(canvas_index, change.id, change.before, change.after) + }) + .collect() +} diff --git a/crates/core/src/actions/tests/authoring.rs b/crates/core/src/actions/tests/authoring.rs index 3e51128..1273c98 100644 --- a/crates/core/src/actions/tests/authoring.rs +++ b/crates/core/src/actions/tests/authoring.rs @@ -140,6 +140,7 @@ fn axis_overrides_survive_rebuild_and_roundtrip_through_undo() { y_label: Some("Response".to_owned()), x_range: Some(AxisRange::new(1.0, 8.0)), y_range: Some(AxisRange::new(-2.0, 12.0)), + ..AxisOverrides::default() }; app.execute_action(Action::set_axis_overrides( diff --git a/crates/core/src/actions/tests/tiling.rs b/crates/core/src/actions/tests/tiling.rs index ee910c8..c6dd83b 100644 --- a/crates/core/src/actions/tests/tiling.rs +++ b/crates/core/src/actions/tests/tiling.rs @@ -1,7 +1,7 @@ use crate::actions::Action; use crate::actions::tests::{push_canvas, sample_app}; use crate::layout::compute_tiling_plan; -use crate::state::ObjectFrame; +use crate::state::{AxisOverrides, AxisRange, ObjectFrame}; /// A drop of canvas 0's plot onto canvas 1 (which already has one plot) transfers /// ownership and reframes both into a two-way split, undoably. @@ -51,3 +51,142 @@ fn tile_drop_transfers_reframes_and_round_trips() { assert_eq!(app.session.active_canvas, Some(1)); assert_eq!(app.doc.canvases[1].object(existing).unwrap().frame, ex); } + +#[test] +fn simplify_grid_is_one_undo_step_and_preserves_other_axis_overrides() { + let mut app = sample_app(); + let second_id = app.doc.canvases[0].allocate_object_id(); + let mut second = app.doc.canvases[0].objects[0].clone(); + second.id = second_id; + app.doc.canvases[0].objects.push(second); + let first_id = app.doc.canvases[0].objects[0].id; + let original = AxisOverrides { + x_label: Some("ppm".to_owned()), + y_range: Some(AxisRange::new(-2.0, 4.0)), + y_show_label: Some(false), + ..AxisOverrides::default() + }; + app.set_axis_overrides_value(0, first_id, &original); + let before_history = app.session.undo_stack.len(); + + app.arrange_active_canvas_grid_with_simplify(2, 1, true); + + assert_eq!(app.session.undo_stack.len(), before_history + 1); + let simplified = &app.doc.canvases[0] + .object(first_id) + .unwrap() + .plot() + .unwrap() + .axis_overrides; + assert_eq!(simplified.x_label, original.x_label); + assert_eq!(simplified.y_range, original.y_range); + assert_eq!(simplified.y_show_label, Some(false)); + assert_eq!(simplified.x_show_tick_labels, Some(false)); + assert_eq!(simplified.x_show_label, Some(false)); + + app.undo(); + assert_eq!( + app.doc.canvases[0] + .object(first_id) + .unwrap() + .plot() + .unwrap() + .axis_overrides, + original + ); + app.redo(); + assert_eq!( + app.doc.canvases[0] + .object(first_id) + .unwrap() + .plot() + .unwrap() + .axis_overrides + .x_show_label, + Some(false) + ); +} + +#[test] +fn standalone_simplify_infers_drag_tiled_frames_instead_of_layout_divisions() { + let mut app = sample_app(); + let template = app.doc.canvases[0].objects[0].clone(); + for _ in 1..4 { + let mut object = template.clone(); + object.id = app.doc.canvases[0].allocate_object_id(); + app.doc.canvases[0].objects.push(object); + } + for (object, frame) in app.doc.canvases[0].objects.iter_mut().zip([ + ObjectFrame::new(0.0, 0.0, 50.0, 40.0), + ObjectFrame::new(50.0, 0.0, 50.0, 40.0), + ObjectFrame::new(0.0, 40.0, 50.0, 40.0), + ObjectFrame::new(50.0, 40.0, 50.0, 40.0), + ]) { + object.frame = frame; + } + assert_eq!( + ( + app.doc.canvases[0].layout.rows, + app.doc.canvases[0].layout.cols + ), + (1, 1) + ); + let before_history = app.session.undo_stack.len(); + + app.simplify_inner_axes(); + + assert_eq!(app.session.undo_stack.len(), before_history + 1); + let overrides: Vec<_> = app.doc.canvases[0] + .objects + .iter() + .map(|object| &object.plot().unwrap().axis_overrides) + .collect(); + assert_eq!(overrides[0].x_show_label, Some(false)); + assert_eq!(overrides[1].x_show_label, Some(false)); + assert_eq!(overrides[1].y_show_label, Some(false)); + assert_eq!(overrides[3].y_show_label, Some(false)); + app.undo(); + assert!( + app.doc.canvases[0] + .objects + .iter() + .all(|object| { object.plot().unwrap().axis_overrides == AxisOverrides::default() }) + ); +} + +#[test] +fn standalone_simplify_rejects_free_layout_without_history_or_override_changes() { + let mut app = sample_app(); + let first_id = app.doc.canvases[0].objects[0].id; + let mut second = app.doc.canvases[0].objects[0].clone(); + second.id = app.doc.canvases[0].allocate_object_id(); + second.frame = ObjectFrame::new(50.0, 40.0, 40.0, 30.0); + app.doc.canvases[0].objects[0].frame = ObjectFrame::new(0.0, 0.0, 40.0, 30.0); + app.doc.canvases[0].objects.push(second); + let explicit = AxisOverrides { + x_show_tick_labels: Some(true), + y_show_label: Some(false), + ..AxisOverrides::default() + }; + app.set_axis_overrides_value(0, first_id, &explicit); + let before: Vec<_> = app.doc.canvases[0] + .objects + .iter() + .map(|object| object.plot().unwrap().axis_overrides.clone()) + .collect(); + let before_history = app.session.undo_stack.len(); + + app.simplify_inner_axes(); + + let after: Vec<_> = app.doc.canvases[0] + .objects + .iter() + .map(|object| object.plot().unwrap().axis_overrides.clone()) + .collect(); + assert_eq!(after, before); + assert_eq!(app.session.undo_stack.len(), before_history); + assert_eq!( + app.session.status, + "Could not simplify axes: arrange plots into a grid first." + ); +} diff --git a/crates/core/src/layout.rs b/crates/core/src/layout.rs index c701b2a..a054e20 100644 --- a/crates/core/src/layout.rs +++ b/crates/core/src/layout.rs @@ -3,6 +3,12 @@ use crate::state::{MM_TO_PT, ObjectFrame, ObjectId}; +mod visual_spacing; +pub use visual_spacing::{ + GutterPreset, LayoutItem, OccupiedGrid, SpacingMode, arrange_grid, + compute_tiling_plan_for_items, infer_occupied_grid, layout_item, outer_axis_cells, +}; + /// Grid presets offered in the Arrange menu, as `(label, rows, cols)`. pub const GRID_PRESETS: &[(&str, u32, u32)] = &[ ("1 × 1", 1, 1), @@ -26,6 +32,7 @@ pub struct PageLayout { pub rows: u32, pub cols: u32, pub show_grid: bool, + pub spacing_mode: SpacingMode, } impl Default for PageLayout { @@ -36,6 +43,7 @@ impl Default for PageLayout { rows: 1, cols: 1, show_grid: false, + spacing_mode: SpacingMode::Visual, } } } @@ -517,6 +525,7 @@ mod tests { rows: 2, cols: 2, show_grid: false, + spacing_mode: SpacingMode::Visual, }; let frames = grid_frames([200.0, 100.0], &layout); assert_eq!(frames.len(), 4); @@ -535,6 +544,7 @@ mod tests { rows: 1, cols: 2, show_grid: false, + spacing_mode: SpacingMode::Visual, }; let no_gutter = grid_frames([200.0, 100.0], &layout)[0].width; let with_gutter = grid_frames( @@ -548,6 +558,124 @@ mod tests { assert!(with_gutter < no_gutter); } + #[test] + fn outer_axis_cells_cover_full_and_partial_two_by_three_grids() { + assert_eq!( + outer_axis_cells(6, 2, 3), + vec![ + (false, true), + (false, false), + (false, false), + (true, true), + (true, false), + (true, false) + ] + ); + assert_eq!( + outer_axis_cells(5, 2, 3), + vec![ + (false, true), + (false, false), + (true, false), + (true, true), + (true, false) + ] + ); + } + + #[test] + fn frame_mode_keeps_frame_gutter_semantics() { + let layout = PageLayout { + rows: 1, + cols: 2, + gutter_mm: 5.0, + spacing_mode: SpacingMode::Frame, + ..PageLayout::default() + }; + let items = [ + LayoutItem { + id: 1, + insets: [20.0; 4], + }, + LayoutItem { + id: 2, + insets: [30.0; 4], + }, + ]; + let frames = arrange_grid([400.0, 200.0], &layout, &items); + let gap = frames[1].1.x - (frames[0].1.x + frames[0].1.width); + assert!((gap - 5.0 * MM_TO_PT).abs() < 0.01); + } + + #[test] + fn visual_mode_keeps_frames_disjoint_and_data_gap_at_least_requested() { + let layout = PageLayout { + rows: 1, + cols: 2, + gutter_mm: 10.0, + spacing_mode: SpacingMode::Visual, + ..PageLayout::default() + }; + let items = [ + LayoutItem { + id: 1, + insets: [10.0, 4.0, 18.0, 24.0], + }, + LayoutItem { + id: 2, + insets: [8.0, 5.0, 16.0, 6.0], + }, + ]; + let frames = arrange_grid([400.0, 200.0], &layout, &items); + let frame_gap = frames[1].1.x - (frames[0].1.x + frames[0].1.width); + let data_gap = frame_gap + items[0].insets[1] + items[1].insets[3]; + assert!(frame_gap >= -0.001); + assert!(data_gap + 0.001 >= 10.0 * MM_TO_PT); + assert!( + frames + .iter() + .all(|(_, frame)| frame.x >= 0.0 && frame.x + frame.width <= 400.01) + ); + } + + #[test] + fn smaller_axis_insets_never_increase_visual_frame_gap() { + let layout = PageLayout { + rows: 1, + cols: 2, + gutter_mm: 2.0, + ..PageLayout::default() + }; + let full = [ + LayoutItem { + id: 1, + insets: [20.0; 4], + }, + LayoutItem { + id: 2, + insets: [20.0; 4], + }, + ]; + let simple = [ + LayoutItem { + id: 1, + insets: [5.0; 4], + }, + LayoutItem { + id: 2, + insets: [5.0; 4], + }, + ]; + let full_frames = arrange_grid([400.0, 200.0], &layout, &full); + let simple_frames = arrange_grid([400.0, 200.0], &layout, &simple); + let data_gap = |frames: &[(ObjectId, ObjectFrame)], items: &[LayoutItem; 2]| { + frames[1].1.x - frames[0].1.x - frames[0].1.width + + items[0].insets[1] + + items[1].insets[3] + }; + assert!(data_gap(&simple_frames, &simple) <= data_gap(&full_frames, &full) + 0.001); + } + #[test] fn snap_move_pulls_edge_to_target_within_threshold() { let mut targets = SnapTargets::default(); diff --git a/crates/core/src/layout/visual_spacing.rs b/crates/core/src/layout/visual_spacing.rs new file mode 100644 index 0000000..7335b4f --- /dev/null +++ b/crates/core/src/layout/visual_spacing.rs @@ -0,0 +1,411 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SpacingMode { + Frame, + #[default] + Visual, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GutterPreset { + Tight, + Normal, + Spacious, +} + +impl GutterPreset { + pub const ALL: [Self; 3] = [Self::Tight, Self::Normal, Self::Spacious]; + + pub const fn millimetres(self) -> f32 { + match self { + Self::Tight => 2.0, + Self::Normal => 5.0, + Self::Spacious => 10.0, + } + } + + pub const fn label(self) -> &'static str { + match self { + Self::Tight => "Tight", + Self::Normal => "Normal", + Self::Spacious => "Spacious", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LayoutItem { + pub id: ObjectId, + /// Axis furniture insets in pt, ordered top, right, bottom, left. + pub insets: [f32; 4], +} + +pub fn layout_item(id: ObjectId, figure: &plotx_figure::Figure, frame: ObjectFrame) -> LayoutItem { + let margins = plotx_render::axis_layout(figure, frame.width, frame.height).margins; + LayoutItem { + id, + insets: [margins.top, margins.right, margins.bottom, margins.left], + } +} + +/// Tolerance for the small coordinate drift produced by PlotX auto-layout and +/// drag-tiling floating-point calculations. +const GRID_ALIGNMENT_TOLERANCE_PT: f32 = 1.0; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OccupiedGrid { + pub rows: u32, + pub cols: u32, + pub ids: Vec, +} + +/// Infer a validated row-major occupied grid from aligned object frames. This +/// is used only by standalone commands, where persisted layout divisions may +/// not describe frames produced by drag-tiling. +pub fn infer_occupied_grid(frames: &[(ObjectId, ObjectFrame)]) -> Option { + if frames.is_empty() { + return None; + } + let (rows, row_for) = coordinate_clusters(frames, |frame| frame.y); + let (cols, col_for) = coordinate_clusters(frames, |frame| frame.x); + let mut cells = vec![None; rows.checked_mul(cols)?]; + for (index, &(id, _)) in frames.iter().enumerate() { + let cell = row_for[index] + .checked_mul(cols)? + .checked_add(col_for[index])?; + if cells[cell].replace(id).is_some() { + return None; + } + } + let ids: Option> = cells.into_iter().take(frames.len()).collect(); + let ids = ids?; + Some(OccupiedGrid { + rows: u32::try_from(rows).ok()?, + cols: u32::try_from(cols).ok()?, + ids, + }) +} + +fn coordinate_clusters( + frames: &[(ObjectId, ObjectFrame)], + coordinate: impl Fn(ObjectFrame) -> f32, +) -> (usize, Vec) { + let mut ordered: Vec<_> = frames + .iter() + .enumerate() + .map(|(index, (_, frame))| (index, coordinate(*frame))) + .collect(); + ordered.sort_by(|a, b| a.1.total_cmp(&b.1)); + let mut assignments = vec![0; frames.len()]; + let mut cluster = 0; + let mut anchor = ordered[0].1; + for (position, &(index, value)) in ordered.iter().enumerate() { + if position > 0 && (value - anchor).abs() > GRID_ALIGNMENT_TOLERANCE_PT { + cluster += 1; + anchor = value; + } + assignments[index] = cluster; + } + (cluster + 1, assignments) +} + +/// Arrange occupied cells while interpreting `gutter_mm` as either frame gap +/// or minimum adjacent data-area clearance. Empty cells do not create phantom +/// inset requirements. +pub fn arrange_grid( + page_pt: [f32; 2], + layout: &PageLayout, + items: &[LayoutItem], +) -> Vec<(ObjectId, ObjectFrame)> { + if layout.spacing_mode == SpacingMode::Frame { + let ids: Vec = items.iter().map(|item| item.id).collect(); + return assign_grid(page_pt, layout, &ids); + } + let rows = layout.rows.max(1) as usize; + let cols = layout.cols.max(1) as usize; + let occupied = items.len().min(rows * cols); + let gutter = layout.gutter_pt(); + let mut col_gaps = vec![0.0_f32; cols.saturating_sub(1)]; + let mut row_gaps = vec![0.0_f32; rows.saturating_sub(1)]; + for index in 0..occupied { + let row = index / cols; + let col = index % cols; + if col + 1 < cols && index + 1 < occupied { + col_gaps[col] = col_gaps[col] + .max((gutter - items[index].insets[1] - items[index + 1].insets[3]).max(0.0)); + } + let below = index + cols; + if row + 1 < rows && below < occupied { + row_gaps[row] = row_gaps[row] + .max((gutter - items[index].insets[2] - items[below].insets[0]).max(0.0)); + } + } + let [mt, mr, mb, ml] = layout.margins_pt(); + let left = ml.clamp(0.0, page_pt[0].max(0.0)); + let top = mt.clamp(0.0, page_pt[1].max(0.0)); + let available_w = (page_pt[0] - left - mr.max(0.0)).max(0.0); + let available_h = (page_pt[1] - top - mb.max(0.0)).max(0.0); + fit_gaps(&mut col_gaps, (available_w - cols as f32).max(0.0)); + fit_gaps(&mut row_gaps, (available_h - rows as f32).max(0.0)); + let width = available_w - col_gaps.iter().sum::(); + let height = available_h - row_gaps.iter().sum::(); + let cell_w = width / cols as f32; + let cell_h = height / rows as f32; + let mut x = vec![left; cols]; + let mut y = vec![top; rows]; + for col in 1..cols { + x[col] = x[col - 1] + cell_w + col_gaps[col - 1]; + } + for row in 1..rows { + y[row] = y[row - 1] + cell_h + row_gaps[row - 1]; + } + items + .iter() + .take(occupied) + .enumerate() + .map(|(index, item)| { + let row = index / cols; + let col = index % cols; + (item.id, ObjectFrame::new(x[col], y[row], cell_w, cell_h)) + }) + .collect() +} + +fn fit_gaps(gaps: &mut [f32], available: f32) { + let total = gaps.iter().sum::(); + if total > available && total > 0.0 { + let scale = available / total; + for gap in gaps { + *gap *= scale; + } + } +} + +/// Row-major cells that retain axis text when inner axes are simplified. +pub fn outer_axis_cells(item_count: usize, rows: u32, cols: u32) -> Vec<(bool, bool)> { + let capacity = rows.max(1) as usize * cols.max(1) as usize; + let count = item_count.min(capacity); + let cols = cols.max(1) as usize; + (0..count) + .map(|index| { + let row_start = index / cols * cols; + (index + cols >= count, index == row_start) + }) + .collect() +} + +pub fn compute_tiling_plan_for_items( + page_pt: [f32; 2], + layout: &PageLayout, + existing_items: &[LayoutItem], + newcomer: LayoutItem, + pointer_page: [f32; 2], +) -> TilingPlan { + if layout.spacing_mode == SpacingMode::Frame { + let ids: Vec = existing_items.iter().map(|item| item.id).collect(); + return compute_tiling_plan(page_pt, layout, &ids, pointer_page); + } + match existing_items.len() { + 0 => TilingPlan { + newcomer: arrange_grid(page_pt, layout, &[newcomer]) + .first() + .map(|(_, frame)| *frame) + .unwrap_or_else(|| ObjectFrame::new(0.0, 0.0, page_pt[0], page_pt[1])), + existing: Vec::new(), + }, + 1 => split_plan(page_pt, layout, existing_items[0], newcomer, pointer_page), + _ => { + let (rows, cols) = even_grid_dims(existing_items.len() + 1); + let grid_layout = PageLayout { + rows, + cols, + ..*layout + }; + let mut items = existing_items.to_vec(); + items.push(newcomer); + let mut frames = arrange_grid(page_pt, &grid_layout, &items); + let newcomer = frames + .pop() + .map(|(_, frame)| frame) + .unwrap_or_else(|| ObjectFrame::new(0.0, 0.0, page_pt[0], page_pt[1])); + TilingPlan { + newcomer, + existing: frames, + } + } + } +} + +fn split_plan( + page_pt: [f32; 2], + layout: &PageLayout, + existing: LayoutItem, + newcomer: LayoutItem, + pointer: [f32; 2], +) -> TilingPlan { + let [w, h] = page_pt; + let nx = if w > 0.0 { pointer[0] / w } else { 0.5 }; + let ny = if h > 0.0 { pointer[1] / h } else { 0.5 }; + let horizontal = (nx - 0.5).abs() >= (ny - 0.5).abs(); + let newcomer_last = if horizontal { nx >= 0.5 } else { ny >= 0.5 }; + let split_layout = PageLayout { + rows: if horizontal { 1 } else { 2 }, + cols: if horizontal { 2 } else { 1 }, + ..*layout + }; + let ordered = if newcomer_last { + [existing, newcomer] + } else { + [newcomer, existing] + }; + let frames = arrange_grid(page_pt, &split_layout, &ordered); + let newcomer_index = usize::from(newcomer_last); + let newcomer = frames + .get(newcomer_index) + .map(|(_, frame)| *frame) + .unwrap_or_else(|| ObjectFrame::new(0.0, 0.0, page_pt[0], page_pt[1])); + let existing = frames + .get(1 - newcomer_index) + .copied() + .into_iter() + .collect(); + TilingPlan { newcomer, existing } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn item(id: ObjectId, inset: f32) -> LayoutItem { + LayoutItem { + id, + insets: [inset; 4], + } + } + + #[test] + fn split_retile_and_apply_grid_share_visual_geometry() { + let page = [400.0, 300.0]; + let layout = PageLayout { + rows: 1, + cols: 2, + ..PageLayout::default() + }; + let existing = item(1, 8.0); + let newcomer = item(2, 4.0); + let split = + compute_tiling_plan_for_items(page, &layout, &[existing], newcomer, [390.0, 150.0]); + let apply = arrange_grid(page, &layout, &[existing, newcomer]); + assert_eq!(split.existing[0].1, apply[0].1); + assert_eq!(split.newcomer, apply[1].1); + + let third = item(3, 6.0); + let retile = compute_tiling_plan_for_items( + page, + &layout, + &[existing, newcomer], + third, + [10.0, 10.0], + ); + let grid = PageLayout { + rows: 2, + cols: 2, + ..layout + }; + let apply = arrange_grid(page, &grid, &[existing, newcomer, third]); + assert_eq!(retile.existing, apply[..2]); + assert_eq!(retile.newcomer, apply[2].1); + } + + #[test] + fn impossible_requested_gap_still_keeps_frames_inside_page() { + let layout = PageLayout { + rows: 1, + cols: 3, + gutter_mm: 100.0, + ..PageLayout::default() + }; + let frames = arrange_grid( + [100.0, 50.0], + &layout, + &[item(1, 0.0), item(2, 0.0), item(3, 0.0)], + ); + assert!( + frames + .windows(2) + .all(|pair| pair[0].1.x + pair[0].1.width <= pair[1].1.x) + ); + assert!( + frames + .iter() + .all(|(_, frame)| frame.x >= 0.0 && frame.x + frame.width <= 100.001) + ); + } + + fn frame(id: ObjectId, col: u32, row: u32) -> (ObjectId, ObjectFrame) { + ( + id, + ObjectFrame::new(col as f32 * 20.0, row as f32 * 30.0, 10.0, 10.0), + ) + } + + #[test] + fn occupied_grid_accepts_complete_two_by_two() { + let grid = infer_occupied_grid(&[ + frame(4, 1, 1), + frame(2, 1, 0), + frame(3, 0, 1), + frame(1, 0, 0), + ]) + .unwrap(); + assert_eq!((grid.rows, grid.cols), (2, 2)); + assert_eq!(grid.ids, vec![1, 2, 3, 4]); + } + + #[test] + fn occupied_grid_accepts_partial_last_row_in_row_major_order() { + let grid = infer_occupied_grid(&[ + frame(5, 1, 1), + frame(3, 2, 0), + frame(1, 0, 0), + frame(4, 0, 1), + frame(2, 1, 0), + ]) + .unwrap(); + assert_eq!((grid.rows, grid.cols), (2, 3)); + assert_eq!(grid.ids, vec![1, 2, 3, 4, 5]); + } + + #[test] + fn occupied_grid_accepts_single_row_and_single_column() { + let row = infer_occupied_grid(&[frame(2, 1, 0), frame(1, 0, 0), frame(3, 2, 0)]).unwrap(); + assert_eq!((row.rows, row.cols, row.ids), (1, 3, vec![1, 2, 3])); + let column = + infer_occupied_grid(&[frame(3, 0, 2), frame(1, 0, 0), frame(2, 0, 1)]).unwrap(); + assert_eq!( + (column.rows, column.cols, column.ids), + (3, 1, vec![1, 2, 3]) + ); + } + + #[test] + fn occupied_grid_rejects_diagonal_scatter() { + assert!(infer_occupied_grid(&[frame(1, 0, 0), frame(2, 1, 1)]).is_none()); + } + + #[test] + fn occupied_grid_rejects_a_hole_before_a_later_cell() { + assert!(infer_occupied_grid(&[frame(1, 0, 0), frame(2, 2, 0), frame(3, 1, 1)]).is_none()); + } + + #[test] + fn occupied_grid_rejects_two_objects_in_one_tolerance_cell() { + let frames = [ + (1, ObjectFrame::new(0.0, 0.0, 10.0, 10.0)), + (2, ObjectFrame::new(0.5, 0.5, 10.0, 10.0)), + ]; + assert!(infer_occupied_grid(&frames).is_none()); + } +} diff --git a/crates/core/src/project/axis_overrides.rs b/crates/core/src/project/axis_overrides.rs index 608f3fb..89efa32 100644 --- a/crates/core/src/project/axis_overrides.rs +++ b/crates/core/src/project/axis_overrides.rs @@ -12,6 +12,14 @@ pub struct AxisOverridesDto { x_range: Option, #[serde(default, skip_serializing_if = "Option::is_none")] y_range: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + x_show_tick_labels: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + x_show_label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + y_show_tick_labels: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + y_show_label: Option, } impl AxisOverridesDto { @@ -21,6 +29,10 @@ impl AxisOverridesDto { y_label: overrides.y_label.clone(), x_range: overrides.x_range.map(RangeDto::from_range), y_range: overrides.y_range.map(RangeDto::from_range), + x_show_tick_labels: overrides.x_show_tick_labels, + x_show_label: overrides.x_show_label, + y_show_tick_labels: overrides.y_show_tick_labels, + y_show_label: overrides.y_show_label, }) } @@ -30,6 +42,10 @@ impl AxisOverridesDto { y_label: self.y_label.clone(), x_range: self.x_range.map(RangeDto::into_range), y_range: self.y_range.map(RangeDto::into_range), + x_show_tick_labels: self.x_show_tick_labels, + x_show_label: self.x_show_label, + y_show_tick_labels: self.y_show_tick_labels, + y_show_label: self.y_show_label, } .normalized() } diff --git a/crates/core/src/project/dto.rs b/crates/core/src/project/dto.rs index c5db2a4..eb9dda1 100644 --- a/crates/core/src/project/dto.rs +++ b/crates/core/src/project/dto.rs @@ -301,6 +301,8 @@ pub struct PageLayoutDto { pub cols: u32, #[serde(default)] pub show_grid: bool, + #[serde(default)] + pub spacing_mode: crate::layout::SpacingMode, } impl PageLayoutDto { @@ -311,6 +313,7 @@ impl PageLayoutDto { rows: l.rows, cols: l.cols, show_grid: l.show_grid, + spacing_mode: l.spacing_mode, } } @@ -321,10 +324,31 @@ impl PageLayoutDto { rows: self.rows.max(1), cols: self.cols.max(1), show_grid: self.show_grid, + spacing_mode: self.spacing_mode, } } } +#[cfg(test)] +mod page_layout_tests { + use super::*; + + #[test] + fn missing_spacing_mode_defaults_to_visual_and_writes_explicitly() { + let dto: PageLayoutDto = serde_json::from_str( + r#"{"margin_mm":[0.0,0.0,0.0,0.0],"gutter_mm":5.0,"rows":1,"cols":2}"#, + ) + .unwrap(); + assert_eq!( + dto.into_layout().spacing_mode, + crate::layout::SpacingMode::Visual + ); + let encoded = + serde_json::to_string(&PageLayoutDto::from_layout(&PageLayout::default())).unwrap(); + assert!(encoded.contains("\"spacing_mode\":\"visual\"")); + } +} + #[derive(Serialize, Deserialize)] pub struct ViewCanvasObject { pub id: String, diff --git a/crates/core/src/project/tests.rs b/crates/core/src/project/tests.rs index f1ef8c9..2d212fe 100644 --- a/crates/core/src/project/tests.rs +++ b/crates/core/src/project/tests.rs @@ -231,6 +231,7 @@ fn project_roundtrip_preserves_data_recipe_and_view() { rows: 2, cols: 3, show_grid: true, + spacing_mode: crate::layout::SpacingMode::Visual, }; app.doc.canvases[0].board_pos = [780.0, 123.0]; app.doc.canvases[0].caption = "Fig 1. Sample spectrum.".to_owned(); @@ -261,6 +262,7 @@ fn project_roundtrip_preserves_data_recipe_and_view() { y_label: Some("Response".to_owned()), x_range: Some(AxisRange::new(1.0, 8.0)), y_range: Some(AxisRange::new(-2.0, 12.0)), + ..AxisOverrides::default() }; let plot_id = app.doc.canvases[0].objects[0].id; app.set_axis_overrides_value(0, plot_id, &axis_overrides); @@ -332,6 +334,7 @@ fn project_roundtrip_preserves_data_recipe_and_view() { rows: 2, cols: 3, show_grid: true, + spacing_mode: crate::layout::SpacingMode::Visual, } ); assert_eq!( diff --git a/crates/core/src/settings/model.rs b/crates/core/src/settings/model.rs index 54cdab7..18d53df 100644 --- a/crates/core/src/settings/model.rs +++ b/crates/core/src/settings/model.rs @@ -69,6 +69,9 @@ pub struct AppearanceSettings { pub ui_scale: UiScaleSettings, #[serde(default)] pub graphics_power: GraphicsPowerPreference, + /// Optional editor-chrome accent. Figure colours and exports are unaffected. + #[serde(default)] + pub canvas_accent: Option<[u8; 3]>, } /// GPU adapter class requested at the next application start. The platform may diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index ecf362d..4aada95 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -57,6 +57,7 @@ impl PlotxApp { files.truncate(crate::settings::MAX_RECENT_FILES); files }, + canvas_accent: settings.appearance.canvas_accent, ui: UiState { snap_enabled: settings.general.snap_enabled, ..Default::default() diff --git a/crates/core/src/state/app_impl_io.rs b/crates/core/src/state/app_impl_io.rs index f2e718a..fcf525e 100644 --- a/crates/core/src/state/app_impl_io.rs +++ b/crates/core/src/state/app_impl_io.rs @@ -86,6 +86,7 @@ impl PlotxApp { /// 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(); } diff --git a/crates/core/src/state/axis_overrides.rs b/crates/core/src/state/axis_overrides.rs index 1dc2f25..28ab35f 100644 --- a/crates/core/src/state/axis_overrides.rs +++ b/crates/core/src/state/axis_overrides.rs @@ -159,6 +159,10 @@ pub struct AxisOverrides { pub y_label: Option, pub x_range: Option, pub y_range: Option, + pub x_show_tick_labels: Option, + pub x_show_label: Option, + pub y_show_tick_labels: Option, + pub y_show_label: Option, } impl AxisOverrides { @@ -181,6 +185,18 @@ impl AxisOverrides { figure.y.min = range.min; figure.y.max = range.max; } + if let Some(show) = self.x_show_tick_labels { + figure.x.show_tick_labels = show; + } + if let Some(show) = self.x_show_label { + figure.x.show_label = show; + } + if let Some(show) = self.y_show_tick_labels { + figure.y.show_tick_labels = show; + } + if let Some(show) = self.y_show_label { + figure.y.show_label = show; + } } pub fn normalized(mut self) -> Self { @@ -211,6 +227,7 @@ mod tests { min: 5.0, max: -5.0, }), + ..AxisOverrides::default() } .normalized(); assert_eq!(overrides.x_label, None); diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index 1a03dda..6e86abe 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -662,6 +662,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, diff --git a/crates/core/src/templates.rs b/crates/core/src/templates.rs index 7ab4884..ac577a7 100644 --- a/crates/core/src/templates.rs +++ b/crates/core/src/templates.rs @@ -31,6 +31,7 @@ impl CanvasTemplate { name: "Single-column figure (89 mm)", size_mm: [89.0, 60.0], layout: PageLayout { + spacing_mode: crate::layout::SpacingMode::Visual, margin_mm: [4.0, 4.0, 4.0, 4.0], gutter_mm: 3.0, rows: 1, @@ -44,6 +45,7 @@ impl CanvasTemplate { name: "Double-column figure (183 mm)", size_mm: [183.0, 120.0], layout: PageLayout { + spacing_mode: crate::layout::SpacingMode::Visual, margin_mm: [6.0, 6.0, 6.0, 6.0], gutter_mm: 5.0, rows: 1, @@ -57,6 +59,7 @@ impl CanvasTemplate { name: "Poster panel", size_mm: [300.0, 400.0], layout: PageLayout { + spacing_mode: crate::layout::SpacingMode::Visual, margin_mm: [14.0, 14.0, 14.0, 14.0], gutter_mm: 10.0, rows: 3, diff --git a/crates/figure/Cargo.toml b/crates/figure/Cargo.toml index 7cc4baf..a4ded75 100644 --- a/crates/figure/Cargo.toml +++ b/crates/figure/Cargo.toml @@ -13,3 +13,6 @@ path = "src/lib.rs" [dependencies] colorous.workspace = true serde.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/crates/figure/src/lib.rs b/crates/figure/src/lib.rs index a4522ff..74ab96e 100644 --- a/crates/figure/src/lib.rs +++ b/crates/figure/src/lib.rs @@ -56,6 +56,10 @@ impl Default for FigureTypography { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Axis { pub label: String, + #[serde(default = "default_true")] + pub show_tick_labels: bool, + #[serde(default = "default_true")] + pub show_label: bool, pub min: f64, pub max: f64, /// If true, larger values draw toward the lower screen coordinate (left for @@ -73,6 +77,8 @@ impl Axis { pub fn new(label: impl Into, min: f64, max: f64) -> Self { Self { label: label.into(), + show_tick_labels: true, + show_label: true, min, max, reversed: false, @@ -86,6 +92,8 @@ impl Axis { let n = names.len().max(1) as f64; Self { label: label.into(), + show_tick_labels: true, + show_label: true, min: -0.5, max: n - 0.5, reversed: false, @@ -111,6 +119,10 @@ impl Axis { } } +const fn default_true() -> bool { + true +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Series { pub name: String, @@ -461,3 +473,23 @@ impl Figure { self } } + +#[cfg(test)] +mod tests { + use super::Axis; + + #[test] + fn axis_text_visibility_defaults_to_visible() { + let axis = Axis::new("x", 0.0, 1.0); + assert!(axis.show_tick_labels); + assert!(axis.show_label); + } + + #[test] + fn missing_axis_visibility_fields_deserialize_as_visible() { + let axis: Axis = + serde_json::from_str(r#"{"label":"x","min":0.0,"max":1.0,"reversed":false}"#).unwrap(); + assert!(axis.show_tick_labels); + assert!(axis.show_label); + } +} diff --git a/crates/render/src/emf.rs b/crates/render/src/emf.rs index 5f7feba..0739327 100644 --- a/crates/render/src/emf.rs +++ b/crates/render/src/emf.rs @@ -256,14 +256,16 @@ fn write_figure(dc: &mut Dc, fig: &Figure, outer: Rect) { Color::AXIS, AXIS_LINE_WIDTH, ); - dc.text( - label, - ( - px, - plot.bottom() + TICK_LENGTH + TICK_LABEL_PAD + ty.tick_pt, - ), - TextStyle::new(ty.tick_pt, Color::AXIS, TA_CENTER), - ); + if fig.x.show_tick_labels { + dc.text( + label, + ( + px, + plot.bottom() + TICK_LENGTH + TICK_LABEL_PAD + ty.tick_pt, + ), + TextStyle::new(ty.tick_pt, Color::AXIS, TA_CENTER), + ); + } } let y_tick_x = y_axis_x - TICK_LENGTH - TICK_LABEL_PAD; for (&yt, label) in y_ticks.values.iter().zip(&y_ticks.labels) { @@ -274,20 +276,26 @@ fn write_figure(dc: &mut Dc, fig: &Figure, outer: Rect) { Color::AXIS, AXIS_LINE_WIDTH, ); - dc.text( - label, - (y_tick_x, py), - TextStyle::new(ty.tick_pt, Color::AXIS, TA_RIGHT).middle(), - ); + if fig.y.show_tick_labels { + dc.text( + label, + (y_tick_x, py), + TextStyle::new(ty.tick_pt, Color::AXIS, TA_RIGHT).middle(), + ); + } } - if let Some(multiplier) = y_ticks.multiplier() { + if fig.y.show_tick_labels + && let Some(multiplier) = y_ticks.multiplier() + { dc.text( &multiplier, (y_axis_x, plot.top - TICK_LABEL_PAD), TextStyle::new(ty.tick_pt, Color::AXIS, TA_LEFT), ); } - if let Some(multiplier) = x_ticks.multiplier() { + if fig.x.show_tick_labels + && let Some(multiplier) = x_ticks.multiplier() + { dc.text( &multiplier, (plot.right(), outer.top + outer.height - OUTER_PAD), @@ -295,15 +303,22 @@ fn write_figure(dc: &mut Dc, fig: &Figure, outer: Rect) { ); } - if !hidden_frame { + if !hidden_frame && fig.x.show_label { + let multiplier_clearance = if fig.x.show_tick_labels { + x_ticks.multiplier_clearance(ty.tick_pt) + } else { + 0.0 + }; dc.text( &fig.x.label, ( (plot.left + plot.right()) / 2.0, - outer.top + outer.height - OUTER_PAD - x_ticks.multiplier_clearance(ty.tick_pt), + outer.top + outer.height - OUTER_PAD - multiplier_clearance, ), TextStyle::new(ty.label_pt, Color::AXIS, TA_CENTER), ); + } + if !hidden_frame && fig.y.show_label { dc.text( &fig.y.label, ( diff --git a/crates/render/src/emf/tests.rs b/crates/render/src/emf/tests.rs index ce92bb6..e766106 100644 --- a/crates/render/src/emf/tests.rs +++ b/crates/render/src/emf/tests.rs @@ -57,3 +57,28 @@ fn round_trips_through_set_enh_meta_file_bits() { DeleteEnhMetaFile(hemf); } } + +#[test] +fn hidden_axis_text_is_absent_from_emf_while_drawing_records_remain() { + let mut fig = Figure::new( + "", + Axis::new("UNIQUE_X_TITLE", 0.0, 90_000.0), + Axis::new("UNIQUE_Y_TITLE", -90_000.0, 90_000.0), + ); + fig.x.show_tick_labels = false; + fig.x.show_label = false; + fig.y.show_tick_labels = false; + fig.y.show_label = false; + let bytes = export_document_emf(&demo_document(&fig)).expect("export"); + let contains_utf16 = |needle: &str| { + let encoded: Vec = needle.encode_utf16().flat_map(u16::to_le_bytes).collect(); + bytes.windows(encoded.len()).any(|window| window == encoded) + }; + assert!(!contains_utf16("UNIQUE_X_TITLE")); + assert!(!contains_utf16("UNIQUE_Y_TITLE")); + assert!(!contains_utf16("×10")); + assert!( + bytes.len() > 88, + "EMF still contains axis and tick drawing records" + ); +} diff --git a/crates/render/src/screen.rs b/crates/render/src/screen.rs index 0b18898..51a8c88 100644 --- a/crates/render/src/screen.rs +++ b/crates/render/src/screen.rs @@ -127,16 +127,18 @@ pub fn paint(painter: &egui::Painter, outer: Rect, fig: &Figure, scale: f32) { ], axis_stroke, ); - painter.text( - Pos2::new( - px, - plot.bottom() + (TICK_LENGTH + TICK_LABEL_PAD + ty.tick_pt * 0.5) * scale, - ), - Align2::CENTER_CENTER, - label, - FontId::proportional(ty.tick_pt * scale), - col(Color::AXIS), - ); + if fig.x.show_tick_labels { + painter.text( + Pos2::new( + px, + plot.bottom() + (TICK_LENGTH + TICK_LABEL_PAD + ty.tick_pt * 0.5) * scale, + ), + Align2::CENTER_CENTER, + label, + FontId::proportional(ty.tick_pt * scale), + col(Color::AXIS), + ); + } } // A left projection band sits between the contour and its ppm scale, so nudge // the F1 tick numbers out past the band to keep them clear of the trace. @@ -150,16 +152,20 @@ pub fn paint(painter: &egui::Painter, outer: Rect, fig: &Figure, scale: f32) { ], axis_stroke, ); - painter.text( - Pos2::new(y_tick_x, py), - Align2::RIGHT_CENTER, - label, - FontId::proportional(ty.tick_pt * scale), - col(Color::AXIS), - ); + if fig.y.show_tick_labels { + painter.text( + Pos2::new(y_tick_x, py), + Align2::RIGHT_CENTER, + label, + FontId::proportional(ty.tick_pt * scale), + col(Color::AXIS), + ); + } } - if let Some(multiplier) = y_ticks.multiplier() { + if fig.y.show_tick_labels + && let Some(multiplier) = y_ticks.multiplier() + { painter.text( Pos2::new(y_axis_x, plot.top - TICK_LABEL_PAD * scale), Align2::LEFT_BOTTOM, @@ -168,7 +174,9 @@ pub fn paint(painter: &egui::Painter, outer: Rect, fig: &Figure, scale: f32) { col(Color::AXIS), ); } - if let Some(multiplier) = x_ticks.multiplier() { + if fig.x.show_tick_labels + && let Some(multiplier) = x_ticks.multiplier() + { painter.text( Pos2::new( plot.right(), @@ -181,19 +189,24 @@ pub fn paint(painter: &egui::Painter, outer: Rect, fig: &Figure, scale: f32) { ); } - if !hidden_frame { + if !hidden_frame && fig.x.show_label { + let multiplier_clearance = if fig.x.show_tick_labels { + x_ticks.multiplier_clearance(ty.tick_pt) + } else { + 0.0 + }; painter.text( Pos2::new( (plot.left + plot.right()) / 2.0, - outer.bottom() - - (OUTER_PAD + x_ticks.multiplier_clearance(ty.tick_pt) + ty.label_pt * 0.5) - * scale, + outer.bottom() - (OUTER_PAD + multiplier_clearance + ty.label_pt * 0.5) * scale, ), Align2::CENTER_CENTER, &fig.x.label, FontId::proportional(ty.label_pt * scale), col(Color::AXIS), ); + } + if !hidden_frame && fig.y.show_label { let galley = painter.layout_no_wrap( fig.y.label.clone(), FontId::proportional(ty.label_pt * scale), @@ -587,6 +600,41 @@ pub fn paint_document( } } +#[cfg(test)] +mod visibility_tests { + use super::*; + use plotx_figure::Axis; + + #[test] + fn hidden_axis_text_keeps_screen_axis_and_tick_shapes() { + let mut fig = Figure::new( + "", + Axis::new("UNIQUE_X_TITLE", 0.0, 90_000.0), + Axis::new("UNIQUE_Y_TITLE", -90_000.0, 90_000.0), + ); + fig.x.show_tick_labels = false; + fig.x.show_label = false; + fig.y.show_tick_labels = false; + fig.y.show_label = false; + let ctx = egui::Context::default(); + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + paint(ui.painter(), Rect::new(0.0, 0.0, 400.0, 300.0), &fig, 1.0); + }); + let text = output + .shapes + .iter() + .filter(|shape| matches!(shape.shape, egui::Shape::Text(_))) + .count(); + let lines = output + .shapes + .iter() + .filter(|shape| matches!(shape.shape, egui::Shape::LineSegment { .. })) + .count(); + assert_eq!(text, 0); + assert!(lines > 2, "axis and tick marks remain on screen"); + } +} + fn paint_document_object( painter: &egui::Painter, page: Rect, diff --git a/crates/render/src/svg.rs b/crates/render/src/svg.rs index 5bc9595..14a84c7 100644 --- a/crates/render/src/svg.rs +++ b/crates/render/src/svg.rs @@ -260,30 +260,44 @@ fn write_figure(s: &mut String, fig: &Figure, outer: Rect, clip_id: &str) { let (px, _) = proj.project([xt, fig.y.min]); let _ = write!( s, - r#"{lab}"#, + r#""#, b = plot.bottom(), tick = TICK_LENGTH, width = AXIS_LINE_WIDTH, - y = plot.bottom() + TICK_LENGTH + TICK_LABEL_PAD + ty.tick_pt, - font = ty.tick_pt, - lab = escape(label), ); + if fig.x.show_tick_labels { + let _ = write!( + s, + r#"{lab}"#, + y = plot.bottom() + TICK_LENGTH + TICK_LABEL_PAD + ty.tick_pt, + font = ty.tick_pt, + lab = escape(label) + ); + } } let y_tick_x = y_axis_x - TICK_LENGTH - TICK_LABEL_PAD; for (&yt, label) in y_ticks.values.iter().zip(&y_ticks.labels) { let (_, py) = proj.project([fig.x.min, yt]); let _ = write!( s, - r#"{lab}"#, + r#""#, yl = y_axis_x - TICK_LENGTH, tick = TICK_LENGTH, width = AXIS_LINE_WIDTH, - x = y_tick_x, - font = ty.tick_pt, - lab = escape(label), ); + if fig.y.show_tick_labels { + let _ = write!( + s, + r#"{lab}"#, + x = y_tick_x, + font = ty.tick_pt, + lab = escape(label) + ); + } } - if let Some(multiplier) = y_ticks.multiplier() { + if fig.y.show_tick_labels + && let Some(multiplier) = y_ticks.multiplier() + { let _ = write!( s, r#"{label}"#, @@ -293,7 +307,9 @@ fn write_figure(s: &mut String, fig: &Figure, outer: Rect, clip_id: &str) { label = escape(&multiplier), ); } - if let Some(multiplier) = x_ticks.multiplier() { + if fig.x.show_tick_labels + && let Some(multiplier) = x_ticks.multiplier() + { let _ = write!( s, r#"{label}"#, @@ -304,15 +320,22 @@ fn write_figure(s: &mut String, fig: &Figure, outer: Rect, clip_id: &str) { ); } - if !hidden_frame { + if !hidden_frame && fig.x.show_label { + let multiplier_clearance = if fig.x.show_tick_labels { + x_ticks.multiplier_clearance(ty.tick_pt) + } else { + 0.0 + }; let _ = write!( s, r#"{lab}"#, cx = (plot.left + plot.right()) / 2.0, - y = outer.top + h - OUTER_PAD - x_ticks.multiplier_clearance(ty.tick_pt), + y = outer.top + h - OUTER_PAD - multiplier_clearance, font = ty.label_pt, lab = escape(&fig.x.label), ); + } + if !hidden_frame && fig.y.show_label { let _ = write!( s, r#"{lab}"#, diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 6455b2d..ccab767 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -83,6 +83,53 @@ fn multipliers_get_their_own_text_rows() { assert!((sci_margins.bottom - plain_margins.bottom - row).abs() < 1e-3); } +#[test] +fn hidden_axis_text_reduces_margins_but_keeps_tick_space() { + let visible = Figure::new( + "", + Axis::new("Long horizontal title", 0.0, 90_000.0), + Axis::new("Long vertical title", -90_000.0, 90_000.0), + ); + let mut hidden = visible.clone(); + hidden.x.show_tick_labels = false; + hidden.x.show_label = false; + hidden.y.show_tick_labels = false; + hidden.y.show_label = false; + let visible = Margins::for_figure(&visible); + let hidden = Margins::for_figure(&hidden); + assert!(hidden.left < visible.left && hidden.bottom < visible.bottom); + assert!(hidden.left >= OUTER_PAD + TICK_LENGTH); + assert!(hidden.bottom >= OUTER_PAD + TICK_LENGTH); +} + +#[test] +fn empty_adaptive_ticks_do_not_reserve_tick_mark_space() { + let mut fig = Figure::new("", Axis::new("", 0.0, 1.0), Axis::new("", 0.0, 1.0)); + fig.x.show_label = false; + fig.y.show_label = false; + let layout = axis_layout(&fig, 1.0, 1.0); + assert!(layout.x_ticks.values.is_empty() && layout.y_ticks.values.is_empty()); + assert_eq!(layout.margins.left, OUTER_PAD); + assert_eq!(layout.margins.bottom, OUTER_PAD); +} + +#[test] +fn svg_hidden_axis_text_omits_labels_and_multiplier_but_keeps_ticks() { + let mut fig = Figure::new( + "", + Axis::new("UNIQUE_X_TITLE", 0.0, 90_000.0), + Axis::new("UNIQUE_Y_TITLE", -90_000.0, 90_000.0), + ); + fig.x.show_tick_labels = false; + fig.x.show_label = false; + fig.y.show_tick_labels = false; + fig.y.show_label = false; + let out = crate::svg::export(&fig); + assert!(!out.contains("UNIQUE_X_TITLE") && !out.contains("UNIQUE_Y_TITLE")); + assert!(!out.contains("×10")); + assert!(out.matches("stroke=\"#272727\"").count() > 1); +} + #[test] fn projection_bands_shrink_plot_and_share_edges() { use plotx_figure::AxisTrace; diff --git a/crates/render/src/ticks.rs b/crates/render/src/ticks.rs index 2dadd7d..0414e31 100644 --- a/crates/render/src/ticks.rs +++ b/crates/render/src/ticks.rs @@ -181,38 +181,62 @@ fn margins_for_ticks_with_widths( }; } - let y_tick_clearance = if y_ticks.labels.is_empty() { + let y_tick_clearance = if y_ticks.values.is_empty() { 0.0 - } else { + } else if fig.y.show_tick_labels { widths.y + TICK_LENGTH + TICK_LABEL_PAD + } else { + TICK_LENGTH }; - let x_tick_clearance = if x_ticks.labels.is_empty() { + let x_tick_clearance = if x_ticks.values.is_empty() { 0.0 - } else { + } else if fig.x.show_tick_labels { ty.tick_pt + TICK_LABEL_PAD + TICK_LENGTH + } else { + TICK_LENGTH }; // Keep a left-end x label out of the rotated y-title lane even when the // y ticks themselves have been dropped on a short panel. - let x_endpoint_clearance = if x_ticks.labels.is_empty() { + let x_endpoint_clearance = if !fig.x.show_tick_labels || x_ticks.labels.is_empty() { 0.0 } else { widths.x * 0.5 }; - let axis_title_clearance = OUTER_PAD + ty.label_pt + AXIS_LABEL_GAP; - let left = axis_title_clearance + y_tick_clearance.max(x_endpoint_clearance); - let right = (OUTER_PAD + widths.x * 0.5).max(8.0); + let y_title_clearance = if fig.y.show_label { + ty.label_pt + AXIS_LABEL_GAP + } else { + 0.0 + }; + let left = OUTER_PAD + y_title_clearance + y_tick_clearance.max(x_endpoint_clearance); + let x_width = if fig.x.show_tick_labels { + widths.x + } else { + 0.0 + }; + let right = (OUTER_PAD + x_width * 0.5).max(8.0); let title_clearance = if fig.title.trim().is_empty() { 0.0 } else { ty.title_pt + AXIS_LABEL_GAP }; - let top = OUTER_PAD + title_clearance + y_ticks.multiplier_clearance(ty.tick_pt); - let bottom = OUTER_PAD - + x_ticks.multiplier_clearance(ty.tick_pt) - + ty.label_pt - + AXIS_LABEL_GAP - + x_tick_clearance; + let y_multiplier = if fig.y.show_tick_labels { + y_ticks.multiplier_clearance(ty.tick_pt) + } else { + 0.0 + }; + let x_multiplier = if fig.x.show_tick_labels { + x_ticks.multiplier_clearance(ty.tick_pt) + } else { + 0.0 + }; + let x_title_clearance = if fig.x.show_label { + ty.label_pt + AXIS_LABEL_GAP + } else { + 0.0 + }; + let top = OUTER_PAD + title_clearance + y_multiplier; + let bottom = OUTER_PAD + x_multiplier + x_title_clearance + x_tick_clearance; Margins { left, diff --git a/docs/src/content/docs/guides/layout-and-export.md b/docs/src/content/docs/guides/layout-and-export.md index 04fec47..763a06d 100644 --- a/docs/src/content/docs/guides/layout-and-export.md +++ b/docs/src/content/docs/guides/layout-and-export.md @@ -44,6 +44,59 @@ Two helpers automate the rest: The export dialog pre-selects the matching journal preset, so a page authored at a column width exports at that width by default. +## Spacing between panels + +Panels of a multi-panel figure read best when their data areas are evenly +spaced — but each plot reserves a different amount of room for its tick +labels and axis titles, so equal frame gaps rarely look equal. +**Canvas Size & Settings…** sets both the spacing you want and how it is +measured. + +**Minimum spacing** is the gap itself, in the canvas unit; the **Tight** +(2 mm), **Normal** (5 mm), and **Spacious** (10 mm) presets fill in common +values. + +**Spacing basis** decides what that gap is measured between: + +- **Visual** (the default) measures between the data areas of neighboring + plots, counting the tick labels and axis titles that sit between them, so a + panel with a long y title is given the room it needs. The value is a + minimum: the gap you see can end up wider, never narrower, and frames never + overlap. +- **Frame** measures between the plot frames and ignores axis text. Frames + then sit exactly the requested distance apart, and the visible space between + data areas varies from pair to pair. + +The basis applies wherever PlotX places plots for you — **Apply grid**, and +dragging a plot onto a page that already holds one. + +With the Select tool active, each non-zero page margin is drawn as a dashed +line across the page, showing the content area you are laying out into; a +margin of zero draws no line. Turning on the layout grid adds the cell +outlines, and snapping guides appear in a contrasting color while you drag. + +## Simplify inner axes + +In a grid of panels that share the same axes, repeating the tick numbers and +axis titles on every panel wastes space. **Simplify inner axes** keeps the +x-axis text only on the bottom plot of each column and the y-axis text only on +the leftmost plot of each row. Axis lines and tick marks stay on every panel. + +There are two ways in: + +- Tick **Simplify inner axes** beside **Apply grid** in Canvas settings to + arrange and simplify as one undoable step. The frames are then measured + against the simplified axes, so the panels grow into the space the hidden + text used to take. +- Run **Simplify Inner Axes** — on the Arrange Ribbon tab, in the canvas + right-click Arrange menu, or from the command palette — to simplify plots + that are already in place. It needs at least two plots aligned in a grid; + otherwise the status bar says what to fix. + +To bring text back on one panel, select it and use **Axes** in the Object +inspector: the **X text** and **Y text** rows toggle **Tick labels** and +**Title** individually, and **Automatic** returns that axis to showing both. + ## Stacked and multi-dataset plots A single plot frame can display several 1D datasets — superimposed, or @@ -63,7 +116,8 @@ size. What you control directly: - Select one plot and use **Axes** in the Object inspector to override its X - and Y titles or numeric ranges. Leave a title blank, or keep a range on + and Y titles or numeric ranges, or to hide either axis's tick labels and + title. Leave a title blank, or keep a range on **Auto**, to use the value derived from the data. A manual range becomes that axis's full range: zooming and panning stay inside it, and a double-click on the plot returns to it. Charts without visible axes offer no axis settings, diff --git a/docs/src/content/docs/reference/preferences.md b/docs/src/content/docs/reference/preferences.md index e032320..95f12b4 100644 --- a/docs/src/content/docs/reference/preferences.md +++ b/docs/src/content/docs/reference/preferences.md @@ -24,6 +24,11 @@ restores everything except your recent-files list. - **Chrome theme** — light, dark, or follow the system appearance. This styles the application window; the look of your figures is set per canvas with canvas themes. +- **Canvas accent** — the color of selection outlines and handles, the layout + grid, margin guides, and drag-to-tile previews. Pick a color, or use **Follow + theme** to take it from the chrome theme. Snap guides keep a contrasting + color of their own so they stay distinct, and figure content and exported + colors are never affected. - **UI scale** — the size of all interface text and controls, per display. Automatic picks a physically legible size from the display's reported pixel density; the manual choices and the `Ctrl` + `+` / `Ctrl` + `-` shortcuts diff --git a/docs/src/content/docs/zh-cn/guides/layout-and-export.md b/docs/src/content/docs/zh-cn/guides/layout-and-export.md index 320019a..190e4e0 100644 --- a/docs/src/content/docs/zh-cn/guides/layout-and-export.md +++ b/docs/src/content/docs/zh-cn/guides/layout-and-export.md @@ -38,6 +38,50 @@ ACS、Elsevier、PNAS 和 IEEE,数值取自各出版社的作图规范)、 导出对话框会预先选中匹配的期刊预设,因此按栏宽制作的页面默认按该宽度 导出。 +## 分图之间的间距 + +多分图的版面,只有各分图的数据区间隔均匀才好看;但每个图为刻度标签和 +轴标题预留的空间各不相同,图框等距往往看起来并不等距。 +**Canvas Size & Settings…** 里既设定想要的间距,也设定它按什么来量。 + +**Minimum spacing**(最小间距)就是这个间距本身,单位与画布一致; +**Tight**(2 mm)、**Normal**(5 mm)、**Spacious**(10 mm)三个预设可 +直接填入常用值。 + +**Spacing basis**(间距依据)决定这个间距量的是哪两处之间: + +- **Visual**(默认)量的是相邻两图数据区之间的距离,并把夹在中间的刻度 + 标签和轴标题计算在内,因此 y 轴标题较长的分图能自动获得所需空间。该值 + 是下限:实际间距只会更宽,不会更窄,图框也不会重叠。 +- **Frame** 量的是图框之间的距离,不计轴文字。图框会严格按设定值排开, + 但各对分图数据区之间的可见空隙会随标签长短而变化。 + +凡是由 PlotX 自动摆放图形的场合都遵循该依据——**Apply grid**,以及把图形 +拖到已有图形的页面上时的自动平铺。 + +使用 Select 工具时,每条非零页边距都会画成一条贯穿页面的虚线,标示出正在 +排版的内容区;设为 0 的一边不画线。打开布局网格会另外显示单元格轮廓, +拖动时出现的吸附参考线使用对比色。 + +## 精简内侧轴 + +一格格分图共用同一套坐标轴时,每个分图都重复刻度数字和轴标题只会浪费 +版面。**Simplify Inner Axes**(精简内侧轴)只在每列最下方的图上保留 x 轴 +文字,只在每行最左侧的图上保留 y 轴文字;轴线和短刻度在所有分图上都保留。 + +两种用法: + +- 在画布设置中勾选 **Apply grid** 旁的 **Simplify inner axes**,排布与精简 + 合并为一次可撤销的操作。图框会按精简后的坐标轴重新丈量,因此各分图能 + 占用原先被隐藏文字占据的空间。 +- 对已经排好的图形,可从 Arrange Ribbon 选项卡、画布右键的 Arrange 菜单 + 或命令面板运行 **Simplify Inner Axes**。它要求至少有两个图形已对齐成 + 网格;否则状态栏会提示需要先做什么。 + +若要让某个分图重新显示文字,选中它后在对象检查器的 **Axes** 区域操作: +**X text** 与 **Y text** 两行可分别切换 **Tick labels** 和 **Title**, +**Automatic** 则让该轴恢复为两者都显示。 + ## 堆叠与多数据集图 一个图框可以同时显示多个 1D 数据集——可叠加显示,也可以按可调的垂直 @@ -54,7 +98,8 @@ NMR 核素质量数。新数据集默认使用 89 × 60 mm 单栏画布:单个 你可以直接控制的部分: - 选中单个图形后,可在对象检查器的 **Axes** 区域覆盖 X/Y 轴标题或数值 - 范围。标题留空或范围保持 **Auto**,即可继续使用由数据自动推导的值。 + 范围,也可隐藏某条轴的刻度标签和标题。标题留空或范围保持 **Auto**, + 即可继续使用由数据自动推导的值。 手动范围会成为该轴的完整范围:缩放和平移仍限制在其中,双击图内即回到 这个手动范围。不显示坐标轴的图表没有轴设置;分类轴不提供范围控制。 - Figure Ribbon 选项卡的 **Figure Typography…** 一次设定文档内所有图的 diff --git a/docs/src/content/docs/zh-cn/reference/preferences.md b/docs/src/content/docs/zh-cn/reference/preferences.md index d7e6dc0..bca74c2 100644 --- a/docs/src/content/docs/zh-cn/reference/preferences.md +++ b/docs/src/content/docs/zh-cn/reference/preferences.md @@ -21,6 +21,9 @@ description: 偏好设置窗口中的每一项设置,按类别列出。 - **Chrome theme**——浅色、深色或跟随系统。它设置的是应用窗口的外观; 图形本身的外观由各画布的画布主题决定。 +- **Canvas accent**——选择框与控制柄、布局网格、页边距参考线和拖放平铺 + 预览的颜色。可自选颜色,也可用 **Follow theme** 跟随应用主题。吸附 + 参考线始终使用另一种对比色以便区分;图形内容和导出配色不受影响。 - **UI scale**——界面文字和控件的大小,按显示器分别设置。自动模式根据 显示器报告的像素密度选择物理上可读的尺寸;手动选项和 `Ctrl` + `+` / `Ctrl` + `-` 快捷键只覆盖当前显示器。