From fb6cf4452117e43acb7613aa653b046eb8efde9b Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Wed, 19 Aug 2026 15:53:45 +0800 Subject: [PATCH] feat(app): stabilize board viewport across workspace changes --- Cargo.lock | 55 ++-- crates/app/src/ui/canvas/board.rs | 150 ++++++---- crates/app/src/ui/canvas/board_tests.rs | 176 ++++++++++-- crates/app/src/ui/canvas/geometry.rs | 90 +++--- crates/app/src/ui/canvas/image_drop.rs | 2 + crates/app/src/ui/canvas/image_painting.rs | 4 +- crates/app/src/ui/canvas/interactions.rs | 15 - crates/app/src/ui/canvas/mod.rs | 12 +- crates/app/src/ui/canvas/navigation.rs | 32 +-- crates/app/src/ui/canvas/navigation_tests.rs | 49 ++-- .../navigation_trackpad_regression_tests.rs | 28 +- crates/app/src/ui/canvas_size.rs | 3 + crates/app/src/ui/command_exec.rs | 83 +++++- crates/app/src/ui/mod.rs | 108 +------- crates/app/src/ui/primary_sidebar.rs | 183 ++++++------ .../app/src/ui/primary_sidebar/board_views.rs | 10 +- .../src/ui/primary_sidebar/layer_controls.rs | 132 +++++++++ .../app/src/ui/primary_sidebar/layers_tree.rs | 262 ++++++++---------- crates/app/src/ui/shortcuts.rs | 23 +- crates/app/src/ui/sidebars.rs | 116 ++++++++ crates/app/src/ui/tools/task_card.rs | 119 +++----- crates/app/src/ui/workspace_geometry.rs | 160 +++++++++++ crates/core/src/actions/tests/board.rs | 4 +- crates/core/src/actions/tests/mod.rs | 2 +- crates/core/src/project/dto.rs | 4 +- crates/core/src/project/mod.rs | 10 +- crates/core/src/project/tests.rs | 15 +- crates/core/src/state/app_impl.rs | 16 +- crates/core/src/state/document.rs | 41 --- crates/core/src/state/mod.rs | 2 + crates/core/src/state/ui_state.rs | 4 +- crates/core/src/state/viewport.rs | 50 ++++ deny.toml | 6 - 33 files changed, 1217 insertions(+), 749 deletions(-) create mode 100644 crates/app/src/ui/primary_sidebar/layer_controls.rs create mode 100644 crates/app/src/ui/sidebars.rs create mode 100644 crates/app/src/ui/workspace_geometry.rs create mode 100644 crates/core/src/state/viewport.rs diff --git a/Cargo.lock b/Cargo.lock index 7689eb5..448c244 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -831,7 +831,7 @@ dependencies = [ "encoding_rs", "fast-float2", "log", - "quick-xml 0.41.0", + "quick-xml", "serde", "zip 8.6.0", ] @@ -1051,16 +1051,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1074,7 +1064,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation 0.9.4", + "core-foundation", "core-graphics-types", "foreign-types", "libc", @@ -1087,7 +1077,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation 0.9.4", + "core-foundation", "libc", ] @@ -1853,7 +1843,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2179,7 +2169,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3296,7 +3286,7 @@ dependencies = [ "once_cell", "png 0.18.1", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4174,7 +4164,7 @@ dependencies = [ "image", "memmap2", "num-complex", - "quick-xml 0.41.0", + "quick-xml", "rust_xlsxwriter", "serde", "serde_json", @@ -4423,15 +4413,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quick-xml" -version = "0.39.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.41.0" @@ -4816,7 +4797,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5439,7 +5420,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5837,7 +5818,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6232,12 +6213,12 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.10" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml 0.39.4", + "quick-xml", "quote", ] @@ -6275,15 +6256,15 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +checksum = "ef62a3d5f7b2411119a11b6f62570dbff91d7105e011a20fb83fbf8f5761c40f" dependencies = [ - "core-foundation 0.10.1", "jni", "log", "ndk-context", "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "url", "web-sys", @@ -6497,7 +6478,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6790,7 +6771,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation 0.9.4", + "core-foundation", "core-graphics", "cursor-icon", "dpi", diff --git a/crates/app/src/ui/canvas/board.rs b/crates/app/src/ui/canvas/board.rs index 1dc5c55..24b1d79 100644 --- a/crates/app/src/ui/canvas/board.rs +++ b/crates/app/src/ui/canvas/board.rs @@ -11,19 +11,18 @@ pub(crate) const FRAME_HEADER_PX: f32 = 18.0; pub(crate) const FIT_CHROME_PX: f32 = FRAME_HEADER_PX + 30.0; const FIT_ZOOM_ID: &str = "board_fit_zoom"; -const FIT_PAN_X_ID: &str = "board_fit_pan_x"; -const FIT_PAN_Y_ID: &str = "board_fit_pan_y"; -const FIT_SETTLE_EPS: f32 = 1e-3; - -/// Begin a spring-animated zoom-to-fit onto `frame` (a page or a sheet). Clears -/// `auto_fit` so the all-frames fit stops fighting the glide, and seeds the -/// springs from the live board so the animation starts where the view is now -/// instead of snapping. +const FIT_CENTER_X_ID: &str = "board_fit_center_x"; +const FIT_CENTER_Y_ID: &str = "board_fit_center_y"; +const FIT_CANDIDATE_ID: &str = "board_fit_candidate"; +const FIT_CANDIDATE_HYSTERESIS: f32 = 0.95; + +/// Begin a persistent spring-animated fit onto `frame` (a page or a sheet), +/// seeding the springs from the live camera so the transition does not snap. pub(crate) fn request_board_fit(app: &mut PlotxApp, ctx: &egui::Context, frame: FrameRef) { let Some(frame) = board_frame_id(app, frame) else { return; }; - app.session.board_fit = Some(BoardFitTarget::Frame(frame)); + app.session.viewport_mode = ViewportMode::Fit(BoardFitTarget::Frame(frame)); seed_board_fit_springs(app, ctx); } @@ -32,13 +31,13 @@ pub(crate) fn consume_board_reveal(app: &mut PlotxApp, ctx: &egui::Context) { if let Some(frame) = app.session.board_reveal.take() && board_frame_ref(app, frame).is_some() { - app.session.board_fit = Some(BoardFitTarget::Frame(frame)); + app.session.viewport_mode = ViewportMode::Fit(BoardFitTarget::Frame(frame)); seed_board_fit_springs(app, ctx); } } pub(crate) fn request_board_fit_region(app: &mut PlotxApp, ctx: &egui::Context, bbox: [f32; 4]) { - app.session.board_fit = Some(BoardFitTarget::Region(bbox)); + app.session.viewport_mode = ViewportMode::Fit(BoardFitTarget::Region(bbox)); seed_board_fit_springs(app, ctx); } @@ -46,9 +45,9 @@ pub(crate) fn request_board_fit_viewport( app: &mut PlotxApp, ctx: &egui::Context, zoom: f32, - pan: [f32; 2], + world_center: [f32; 2], ) { - app.session.board_fit = Some(BoardFitTarget::Viewport { zoom, pan }); + app.session.viewport_mode = ViewportMode::Fit(BoardFitTarget::Viewport { zoom, world_center }); seed_board_fit_springs(app, ctx); } @@ -61,25 +60,30 @@ pub(crate) fn request_board_fit_viewport( /// outright (not merely pause it) and stop auto-fit. Call once at every gesture /// start; `UiState::gesture_active` then keeps the animators out until it ends. pub(crate) fn freeze_board_for_gesture(app: &mut PlotxApp) { - app.session.board_fit = None; - app.session.board.auto_fit = false; + app.session.viewport_mode = ViewportMode::Manual; } -/// Clear `auto_fit` and seed the fit springs from the live board so the glide -/// starts where the view is now instead of snapping. +/// Seed the fit springs from the live camera. fn seed_board_fit_springs(app: &mut PlotxApp, ctx: &egui::Context) { - app.session.board.auto_fit = false; crate::ui::switcher::seed_spring(ctx, egui::Id::new(FIT_ZOOM_ID), app.session.board.zoom); - crate::ui::switcher::seed_spring(ctx, egui::Id::new(FIT_PAN_X_ID), app.session.board.pan[0]); - crate::ui::switcher::seed_spring(ctx, egui::Id::new(FIT_PAN_Y_ID), app.session.board.pan[1]); + crate::ui::switcher::seed_spring( + ctx, + egui::Id::new(FIT_CENTER_X_ID), + app.session.board.world_center[0], + ); + crate::ui::switcher::seed_spring( + ctx, + egui::Id::new(FIT_CENTER_Y_ID), + app.session.board.world_center[1], + ); } pub(crate) fn frame_zoom_menu(app: &mut PlotxApp, ui: &mut Ui) { let n = app.session.ui.frame_selection.len(); - let label = if n > 1 { - format!("Zoom to {n} selected frames") - } else { - "Zoom to fit all frames".to_owned() + let label = match n { + 0 => "Zoom to fit all frames".to_owned(), + 1 => "Zoom to selected frame".to_owned(), + _ => format!("Zoom to {n} selected frames"), }; if ui.button(label).clicked() { zoom_to_selection(app, ui.ctx()); @@ -107,60 +111,105 @@ pub(crate) fn zoom_to_selection(app: &mut PlotxApp, ctx: &egui::Context) { } } -pub(crate) fn drive_board_fit(app: &mut PlotxApp, ui: &Ui, screen: egui::Rect, safe: egui::Rect) { +pub(crate) fn drive_board_fit( + app: &mut PlotxApp, + ui: &Ui, + geometry: &crate::ui::workspace_geometry::WorkspaceGeometry, +) { if app.session.ui.gesture_active() { debug_assert!( - app.session.board_fit.is_none(), - "a gesture must freeze board_fit; something re-armed it mid-gesture" + matches!(app.session.viewport_mode, ViewportMode::Manual), + "a gesture must leave the viewport in manual mode" ); return; } - let Some(fit) = app.session.board_fit else { + let ViewportMode::Fit(fit) = app.session.viewport_mode else { return; }; - let (target_zoom, target_pan) = match fit { + let (target_zoom, target_center) = match fit { BoardFitTarget::Frame(frame) => { match board_frame_ref(app, frame).and_then(|frame| frame_board_rect(app, frame)) { Some(r) => { - let vp = board_fit_bbox_with_chrome_in_rect( + let vp = fit_bbox_around_occluders( (r.left, r.top, r.right(), r.bottom()), - screen, - safe, + geometry, + ui.ctx(), ); - (vp.zoom, vp.pan) + (vp.zoom, vp.world_center) } None => { - app.session.board_fit = None; + app.session.viewport_mode = ViewportMode::Manual; return; } } } + BoardFitTarget::AllFrames => { + let Some(b) = all_frames_bbox(app) else { + return; + }; + let vp = fit_bbox_around_occluders(b, geometry, ui.ctx()); + (vp.zoom, vp.world_center) + } BoardFitTarget::Region(b) => { - let vp = board_fit_bbox_with_chrome_in_rect((b[0], b[1], b[2], b[3]), screen, safe); - (vp.zoom, vp.pan) + let vp = fit_bbox_around_occluders((b[0], b[1], b[2], b[3]), geometry, ui.ctx()); + (vp.zoom, vp.world_center) } - BoardFitTarget::Viewport { zoom, pan } => (zoom, pan), + BoardFitTarget::Viewport { zoom, world_center } => (zoom, world_center), }; let target = BoardViewport { zoom: target_zoom, - pan: target_pan, - auto_fit: false, + world_center: target_center, }; let ctx = ui.ctx(); let dt = ui.input(|i| i.stable_dt); app.session.board.zoom = crate::ui::switcher::animate_spring(ctx, egui::Id::new(FIT_ZOOM_ID), target.zoom, dt); - app.session.board.pan[0] = - crate::ui::switcher::animate_spring(ctx, egui::Id::new(FIT_PAN_X_ID), target.pan[0], dt); - app.session.board.pan[1] = - crate::ui::switcher::animate_spring(ctx, egui::Id::new(FIT_PAN_Y_ID), target.pan[1], dt); - app.session.board.auto_fit = false; - if (app.session.board.zoom - target.zoom).abs() < FIT_SETTLE_EPS - && (app.session.board.pan[0] - target.pan[0]).abs() < FIT_SETTLE_EPS - && (app.session.board.pan[1] - target.pan[1]).abs() < FIT_SETTLE_EPS - { - app.session.board_fit = None; + app.session.board.world_center[0] = crate::ui::switcher::animate_spring( + ctx, + egui::Id::new(FIT_CENTER_X_ID), + target.world_center[0], + dt, + ); + app.session.board.world_center[1] = crate::ui::switcher::animate_spring( + ctx, + egui::Id::new(FIT_CENTER_Y_ID), + target.world_center[1], + dt, + ); +} + +fn fit_bbox_around_occluders( + bbox: (f32, f32, f32, f32), + geometry: &crate::ui::workspace_geometry::WorkspaceGeometry, + ctx: &egui::Context, +) -> BoardViewport { + let mut best = None; + for (index, safe) in geometry.fit_candidates().enumerate() { + let viewport = board_fit_bbox_with_chrome_in_rect(bbox, geometry.board_rect, safe); + if best + .as_ref() + .is_none_or(|(_, current): &(usize, BoardViewport)| viewport.zoom > current.zoom) + { + best = Some((index, viewport)); + } } + let (best_index, best_viewport) = best.expect("board geometry always has a fit candidate"); + let id = egui::Id::new(FIT_CANDIDATE_ID); + let previous_index = ctx.data(|data| data.get_temp::(id)); + let chosen = previous_index + .filter(|index| *index != best_index) + .and_then(|index| { + geometry.fit_candidates().nth(index).map(|safe| { + ( + index, + board_fit_bbox_with_chrome_in_rect(bbox, geometry.board_rect, safe), + ) + }) + }) + .filter(|(_, viewport)| viewport.zoom >= best_viewport.zoom * FIT_CANDIDATE_HYSTERESIS) + .unwrap_or((best_index, best_viewport)); + ctx.data_mut(|data| data.insert_temp(id, chosen.0)); + chosen.1 } /// Rounding of a header strip's top corners; the frame shadow shares it so the @@ -641,6 +690,7 @@ fn handle_frame_drag(app: &mut PlotxApp, rect: egui::Rect, ui: &Ui) -> bool { }) .collect::>(); if !before.is_empty() { + freeze_board_for_gesture(app); let start = BoardTransform::from_board(app.session.board, rect).screen_to_world(p); app.begin_interaction(Interaction::Frame(FrameDrag { frame: frame_id, @@ -678,8 +728,6 @@ fn handle_frame_drag(app: &mut PlotxApp, rect: egui::Rect, ui: &Ui) -> bool { set_frame_board_pos(app, frame, [before[0] + delta[0], before[1] + delta[1]]); } } - app.session.board_fit = None; - app.session.board.auto_fit = false; ui.ctx().request_repaint(); } diff --git a/crates/app/src/ui/canvas/board_tests.rs b/crates/app/src/ui/canvas/board_tests.rs index db7433c..95f9558 100644 --- a/crates/app/src/ui/canvas/board_tests.rs +++ b/crates/app/src/ui/canvas/board_tests.rs @@ -9,8 +9,7 @@ fn page(board_pos: [f32; 2]) -> CanvasDocument { fn unit_board() -> BoardViewport { BoardViewport { zoom: 1.0, - pan: [0.0, 0.0], - auto_fit: false, + world_center: [1000.0, 1000.0], } } @@ -18,6 +17,14 @@ fn screen() -> egui::Rect { egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(2000.0, 2000.0)) } +fn workspace(board_rect: egui::Rect) -> crate::ui::workspace_geometry::WorkspaceGeometry { + crate::ui::workspace_geometry::WorkspaceGeometry { + board_rect, + fit_occluders: Vec::new(), + revision: 0, + } +} + fn app_with_pages(positions: &[[f32; 2]]) -> PlotxApp { let mut app = PlotxApp::new(); for &bp in positions { @@ -89,8 +96,8 @@ fn zoom_to_selection_targets_selected_then_all_frames() { vec![plotx_core::state::board_frame_id(&app, FrameRef::Page(1)).unwrap()]; zoom_to_selection(&mut app, &ctx); let r = app.doc.canvases[1].board_rect_pt(); - match app.session.board_fit { - Some(BoardFitTarget::Region(b)) => { + match app.session.viewport_mode { + ViewportMode::Fit(BoardFitTarget::Region(b)) => { assert!((b[0] - r.left).abs() < 1e-3 && (b[2] - r.right()).abs() < 1e-3); } other => panic!("expected a region fit, got {other:?}"), @@ -99,8 +106,8 @@ fn zoom_to_selection_targets_selected_then_all_frames() { app.session.ui.frame_selection.clear(); zoom_to_selection(&mut app, &ctx); let all = all_frames_bbox(&app).unwrap(); - match app.session.board_fit { - Some(BoardFitTarget::Region(b)) => { + match app.session.viewport_mode { + ViewportMode::Fit(BoardFitTarget::Region(b)) => { assert!((b[0] - all.0).abs() < 1e-3 && (b[2] - all.2).abs() < 1e-3); } other => panic!("expected a region fit, got {other:?}"), @@ -108,15 +115,15 @@ fn zoom_to_selection_targets_selected_then_all_frames() { } #[test] -fn request_board_fit_viewport_targets_exact_zoom_and_pan() { +fn request_board_fit_viewport_targets_exact_camera() { let mut app = app_with_pages(&[[0.0, 0.0]]); let ctx = egui::Context::default(); request_board_fit_viewport(&mut app, &ctx, 2.5, [30.0, -40.0]); assert_eq!( - app.session.board_fit, - Some(BoardFitTarget::Viewport { + app.session.viewport_mode, + ViewportMode::Fit(BoardFitTarget::Viewport { zoom: 2.5, - pan: [30.0, -40.0] + world_center: [30.0, -40.0] }) ); } @@ -132,8 +139,8 @@ fn transient_reveal_is_consumed_into_the_fit_animation() { assert_eq!(app.session.board_reveal, None); assert_eq!( - app.session.board_fit, - Some(BoardFitTarget::Frame( + app.session.viewport_mode, + ViewportMode::Fit(BoardFitTarget::Frame( plotx_core::state::BoardFrameId::Page(page_id) )) ); @@ -150,7 +157,10 @@ fn reveal_and_fit_keep_target_identity_when_page_indices_shift() { consume_board_reveal(&mut app, &ctx); let target = plotx_core::state::BoardFrameId::Page(target_id); - assert_eq!(app.session.board_fit, Some(BoardFitTarget::Frame(target))); + assert_eq!( + app.session.viewport_mode, + ViewportMode::Fit(BoardFitTarget::Frame(target)) + ); assert_eq!(board_frame_ref(&app, target), Some(FrameRef::Page(0))); app.doc.canvases.swap(0, 1); @@ -168,7 +178,10 @@ fn reveal_of_a_removed_page_is_discarded_without_retargeting() { consume_board_reveal(&mut app, &ctx); assert_eq!(app.session.board_reveal, None); - assert_eq!(app.session.board_fit, None); + assert_eq!( + app.session.viewport_mode, + ViewportMode::Fit(BoardFitTarget::AllFrames) + ); } #[test] @@ -176,20 +189,149 @@ fn fit_animation_cancels_when_its_stable_target_is_removed() { let mut app = app_with_pages(&[[0.0, 0.0], [500.0, 0.0]]); let ctx = egui::Context::default(); request_board_fit(&mut app, &ctx, FrameRef::Page(0)); - assert!(app.session.board_fit.is_some()); + assert!(matches!(app.session.viewport_mode, ViewportMode::Fit(_))); app.doc.canvases.remove(0); let rect = screen(); let _ = ctx.run_ui(egui::RawInput::default(), |ui| { - drive_board_fit(&mut app, ui, rect, rect); + drive_board_fit(&mut app, ui, &workspace(rect)); }); - assert_eq!(app.session.board_fit, None); + assert_eq!(app.session.viewport_mode, ViewportMode::Manual); +} + +#[test] +fn fit_intent_survives_settling_and_resolves_again_for_a_new_layout() { + let mut app = app_with_pages(&[[0.0, 0.0]]); + let ctx = egui::Context::default(); + request_board_fit(&mut app, &ctx, FrameRef::Page(0)); + let wide = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1200.0, 800.0)); + for _ in 0..180 { + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + drive_board_fit(&mut app, ui, &workspace(wide)); + }); + } + let wide_camera = app.session.board; + assert!(matches!( + app.session.viewport_mode, + ViewportMode::Fit(BoardFitTarget::Frame(_)) + )); + + let narrow = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(700.0, 800.0)); + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + drive_board_fit(&mut app, ui, &workspace(narrow)); + }); + + assert!(app.session.board.zoom < wide_camera.zoom); + assert!(matches!( + app.session.viewport_mode, + ViewportMode::Fit(BoardFitTarget::Frame(_)) + )); +} + +#[test] +fn fit_uses_the_open_space_below_a_floating_task_card_when_it_is_better() { + let ctx = egui::Context::default(); + let board = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1000.0, 700.0)); + let geometry = crate::ui::workspace_geometry::WorkspaceGeometry { + board_rect: board, + fit_occluders: vec![egui::Rect::from_min_max( + egui::pos2(680.0, 0.0), + egui::pos2(1000.0, 420.0), + )], + revision: 1, + }; + + let _ = fit_bbox_around_occluders((0.0, 0.0, 800.0, 100.0), &geometry, &ctx); + + assert_eq!( + ctx.data(|data| data.get_temp::(egui::Id::new(FIT_CANDIDATE_ID))), + Some(1), + "a wide target should use the full-width space below the card" + ); +} + +#[test] +fn floating_task_cards_do_not_change_a_manual_camera() { + let mut app = app_with_pages(&[[0.0, 0.0]]); + app.session.viewport_mode = ViewportMode::Manual; + app.session.board = BoardViewport { + zoom: 1.75, + world_center: [240.0, 180.0], + }; + let camera = app.session.board; + let board = screen(); + let geometry = crate::ui::workspace_geometry::WorkspaceGeometry { + board_rect: board, + fit_occluders: vec![egui::Rect::from_min_max( + egui::pos2(1500.0, 0.0), + egui::pos2(2000.0, 900.0), + )], + revision: 1, + }; + let ctx = egui::Context::default(); + + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + drive_board_fit(&mut app, ui, &geometry); + }); + + assert_eq!(app.session.board, camera); +} + +#[test] +fn dragging_a_focused_frame_immediately_takes_ownership_from_fit() { + let mut app = app_with_pages(&[[100.0, 100.0]]); + let ctx = egui::Context::default(); + request_board_fit(&mut app, &ctx, FrameRef::Page(0)); + let rect = screen(); + let header = Pos2::new(120.0, 90.0); + + let frame = |app: &mut PlotxApp, events| { + let _ = ctx.run_ui( + egui::RawInput { + screen_rect: Some(rect), + events, + ..Default::default() + }, + |ui| { + handle_frame_drag(app, rect, ui); + }, + ); + }; + frame( + &mut app, + vec![ + egui::Event::PointerMoved(header), + egui::Event::PointerButton { + pos: header, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::default(), + }, + ], + ); + assert_eq!(app.session.viewport_mode, ViewportMode::Manual); + + frame( + &mut app, + vec![egui::Event::PointerMoved(header + egui::vec2(80.0, 50.0))], + ); + assert_eq!(app.doc.canvases[0].board_pos, [180.0, 150.0]); + + let moved = app.doc.canvases[0].board_pos; + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + drive_board_fit(&mut app, ui, &workspace(rect)); + }); + assert_eq!(app.doc.canvases[0].board_pos, moved); } /// Presses the primary button at `p` with a right side bar covering x >= 800. /// The first pass registers the layers; only the second one carries the press. fn press_with_side_bar(app: &mut PlotxApp, p: Pos2) { + // Use a deterministic manual camera with the world origin at the visible + // board's top-left corner, so each pointer targets a known frame. + app.session.board.world_center = [400.0, 400.0]; + app.session.viewport_mode = ViewportMode::Manual; let ctx = egui::Context::default(); let screen_rect = Some(egui::Rect::from_min_size( Pos2::ZERO, diff --git a/crates/app/src/ui/canvas/geometry.rs b/crates/app/src/ui/canvas/geometry.rs index 6840724..e670b95 100644 --- a/crates/app/src/ui/canvas/geometry.rs +++ b/crates/app/src/ui/canvas/geometry.rs @@ -141,26 +141,27 @@ pub(crate) fn clear_canvas_interaction_state( } /// The single page/object/screen coordinate transform for the board: -/// `world_pt = canvas.board_pos + page_pt` then `screen = origin + pan + world_pt * zoom`. +/// `world_pt = canvas.board_pos + page_pt`, then +/// `screen = visible_center + (world_pt - world_center) * zoom`. #[derive(Clone, Copy)] pub(crate) struct BoardTransform { - pub origin: Pos2, - pub pan: egui::Vec2, + pub visible_center: Pos2, + pub world_center: Pos2, pub zoom: f32, } impl BoardTransform { pub fn from_board(board: BoardViewport, screen: egui::Rect) -> Self { Self { - origin: screen.min, - pan: egui::vec2(board.pan[0], board.pan[1]), + visible_center: screen.center(), + world_center: Pos2::new(board.world_center[0], board.world_center[1]), zoom: board.zoom, } } pub fn board_rect_screen(&self, r: PlotRect) -> egui::Rect { egui::Rect::from_min_size( - self.origin + self.pan + egui::vec2(r.left, r.top) * self.zoom, + self.visible_center + (egui::pos2(r.left, r.top) - self.world_center) * self.zoom, egui::vec2(r.width * self.zoom, r.height * self.zoom), ) } @@ -213,8 +214,8 @@ impl BoardTransform { /// Screen px → board world (pt), before any per-page `board_pos` offset. pub fn screen_to_world(&self, p: Pos2) -> Pos2 { Pos2::new( - (p.x - self.origin.x - self.pan.x) / self.zoom, - (p.y - self.origin.y - self.pan.y) / self.zoom, + self.world_center.x + (p.x - self.visible_center.x) / self.zoom, + self.world_center.y + (p.y - self.visible_center.y) / self.zoom, ) } @@ -354,14 +355,13 @@ fn board_fit_bbox_in_rect( let h = (max_y - min_y).max(1.0); let safe_w = safe.width().max(1.0); let safe_h = safe.height().max(1.0); - let zoom = ((safe_w / w).min(safe_h / h) * 0.9).clamp(0.1, FIT_ZOOM_MAX); + let zoom = ((safe_w / w).min(safe_h / h) * 0.9).clamp(f32::MIN_POSITIVE, FIT_ZOOM_MAX); BoardViewport { zoom, - pan: [ - safe.center().x - viewport.left() - (min_x + w * 0.5) * zoom, - safe.center().y - viewport.top() - (min_y + h * 0.5) * zoom, + world_center: [ + min_x + w * 0.5 - (safe.center().x - viewport.center().x) / zoom, + min_y + h * 0.5 - (safe.center().y - viewport.center().y) / zoom, ], - auto_fit: true, } } @@ -476,17 +476,17 @@ mod tests { } #[test] - fn board_transform_page_rect_matches_origin_pan_boardpos() { + fn board_transform_page_rect_uses_visible_and_world_centers() { let canvas = board_canvas(); let bt = BoardTransform { - origin: Pos2::new(100.0, 50.0), - pan: egui::vec2(10.0, -20.0), + visible_center: Pos2::new(100.0, 50.0), + world_center: Pos2::new(-5.0, 10.0), zoom: 2.0, }; let rect = bt.page_screen_rect(&canvas); let [w, h] = canvas.size_pt(); - assert!((rect.min.x - (100.0 + 10.0 + 30.0 * 2.0)).abs() < 1e-3); - assert!((rect.min.y - (50.0 - 20.0 + 40.0 * 2.0)).abs() < 1e-3); + assert!((rect.min.x - (100.0 + (30.0 + 5.0) * 2.0)).abs() < 1e-3); + assert!((rect.min.y - (50.0 + (40.0 - 10.0) * 2.0)).abs() < 1e-3); assert!((rect.width() - w * 2.0).abs() < 1e-3); assert!((rect.height() - h * 2.0).abs() < 1e-3); } @@ -495,8 +495,8 @@ mod tests { fn board_transform_screen_to_page_roundtrip() { let canvas = board_canvas(); let bt = BoardTransform { - origin: Pos2::new(100.0, 50.0), - pan: egui::vec2(10.0, -20.0), + visible_center: Pos2::new(100.0, 50.0), + world_center: Pos2::new(-5.0, 10.0), zoom: 2.0, }; let page = bt.page_screen_rect(&canvas); @@ -514,8 +514,8 @@ mod tests { .objects .push(text_object(5, ObjectFrame::new(12.0, 8.0, 40.0, 20.0))); let bt = BoardTransform { - origin: Pos2::new(100.0, 50.0), - pan: egui::vec2(10.0, -20.0), + visible_center: Pos2::new(100.0, 50.0), + world_center: Pos2::new(-5.0, 10.0), zoom: 2.0, }; let page = bt.page_screen_rect(&canvas); @@ -550,7 +550,7 @@ mod tests { } #[test] - fn board_fit_fills_large_viewports_beyond_former_cap() { + fn board_fit_scales_to_fill_large_viewports() { // A Nature double-column page on a 2.5K-class canvas area must fill the // limiting dimension (90% margin), not stall at the old 1.4 zoom cap. let screen = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(2400.0, 1300.0)); @@ -575,22 +575,36 @@ mod tests { assert!((vp.zoom - FIT_ZOOM_MAX).abs() < 1e-6); } + #[test] + fn board_fit_can_zoom_below_point_one_to_keep_the_whole_target_visible() { + let screen = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(320.0, 240.0)); + let bbox = (0.0, 0.0, 20_000.0, 10_000.0); + let vp = board_fit_bbox(bbox, screen); + let target = BoardTransform::from_board(vp, screen).board_rect_screen(PlotRect::new( + bbox.0, + bbox.1, + bbox.2 - bbox.0, + bbox.3 - bbox.1, + )); + + assert!(vp.zoom < 0.1); + assert!(screen.contains(target.min) && screen.contains(target.max)); + assert!((target.center() - screen.center()).length() < 0.01); + } + #[test] fn board_fit_centers_the_target_inside_an_offset_safe_rect() { let viewport = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1000.0, 700.0)); let safe = egui::Rect::from_min_max(Pos2::ZERO, Pos2::new(640.0, 700.0)); let bbox = (400.0, 200.0, 600.0, 300.0); let vp = board_fit_bbox_with_chrome_in_rect(bbox, viewport, safe); - let target = egui::Rect::from_min_max( - Pos2::new( - viewport.left() + vp.pan[0] + bbox.0 * vp.zoom, - viewport.top() + vp.pan[1] + bbox.1 * vp.zoom, - ), - Pos2::new( - viewport.left() + vp.pan[0] + bbox.2 * vp.zoom, - viewport.top() + vp.pan[1] + bbox.3 * vp.zoom, - ), - ); + let transform = BoardTransform::from_board(vp, viewport); + let target = transform.board_rect_screen(PlotRect::new( + bbox.0, + bbox.1, + bbox.2 - bbox.0, + bbox.3 - bbox.1, + )); assert!(safe.contains(target.min) && safe.contains(target.max)); assert!((target.center().x - safe.center().x).abs() < 0.01); assert!(target.top() >= safe.top() + FIT_CHROME_PX - 0.01); @@ -625,15 +639,14 @@ mod tests { let bt = BoardTransform::from_board(vp, screen); let cx = (bbox.0 + bbox.2) * 0.5; let cy = (bbox.1 + bbox.3) * 0.5; - let sx = screen.min.x + vp.pan[0] + cx * vp.zoom; - let sy = screen.min.y + vp.pan[1] + cy * vp.zoom; + let sx = screen.center().x + (cx - vp.world_center[0]) * vp.zoom; + let sy = screen.center().y + (cy - vp.world_center[1]) * vp.zoom; assert!((sx - screen.center().x).abs() < 1e-2); assert!((sy - screen.center().y).abs() < 1e-2); let pa = bt.page_screen_rect(&canvases[0]); let pb = bt.page_screen_rect(&canvases[1]); - assert!((pa.min.x - (screen.min.x + vp.pan[0])).abs() < 1e-2); - assert!((pb.min.x - (screen.min.x + vp.pan[0] + rb.left * vp.zoom)).abs() < 1e-2); + assert!((pb.min.x - pa.min.x - rb.left * vp.zoom).abs() < 1e-2); } #[test] @@ -759,8 +772,7 @@ mod tests { app.session.active_canvas = Some(0); app.session.board = BoardViewport { zoom: 1.0, - pan: [0.0, 0.0], - auto_fit: false, + world_center: [0.0, 0.0], }; let screen = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(500.0, 400.0)); let transform = BoardTransform::from_board(app.session.board, screen); diff --git a/crates/app/src/ui/canvas/image_drop.rs b/crates/app/src/ui/canvas/image_drop.rs index 3ab2429..5381f04 100644 --- a/crates/app/src/ui/canvas/image_drop.rs +++ b/crates/app/src/ui/canvas/image_drop.rs @@ -61,6 +61,8 @@ mod tests { canvas.create_panel("Panel".to_owned(), ObjectFrame::new(10.0, 10.0, 80.0, 60.0)); app.doc.canvases.push(canvas); app.session.active_canvas = Some(0); + app.session.board.world_center = [200.0, 200.0]; + app.session.viewport_mode = ViewportMode::Manual; (app, panel) } diff --git a/crates/app/src/ui/canvas/image_painting.rs b/crates/app/src/ui/canvas/image_painting.rs index c390384..284f9ff 100644 --- a/crates/app/src/ui/canvas/image_painting.rs +++ b/crates/app/src/ui/canvas/image_painting.rs @@ -76,8 +76,8 @@ pub(super) fn paint_document(app: &PlotxApp, ci: usize, rect: egui::Rect, painte plotx_render::DocumentViewport { zoom, pan: [ - app.session.board.pan[0] + bp[0] * zoom, - app.session.board.pan[1] + bp[1] * zoom, + rect.width() * 0.5 + (bp[0] - app.session.board.world_center[0]) * zoom, + rect.height() * 0.5 + (bp[1] - app.session.board.world_center[1]) * zoom, ], }, ); diff --git a/crates/app/src/ui/canvas/interactions.rs b/crates/app/src/ui/canvas/interactions.rs index 3ca7265..b232b3f 100644 --- a/crates/app/src/ui/canvas/interactions.rs +++ b/crates/app/src/ui/canvas/interactions.rs @@ -236,21 +236,6 @@ pub(crate) fn finish_zoom_drag( app.session.status = "Zoomed selection.".into(); } -pub(crate) fn ensure_board_view(app: &mut PlotxApp, rect: egui::Rect) { - // A gesture owns the viewport: never re-fit under an active drag, even if a - // fit shortcut re-armed `auto_fit` mid-gesture (see `freeze_board_for_gesture`). - if app.session.ui.gesture_active() { - return; - } - if !app.session.board.auto_fit { - return; - } - if let Some(bbox) = all_frames_bbox(app) { - let safe = crate::ui::tools::task_card::safe_fit_rect(app, rect); - app.session.board = board_fit_bbox_with_chrome_in_rect(bbox, rect, safe); - } -} - /// Single-click select only — the actual data drag is owned by the data block's /// per-tool handler. pub(crate) fn handle_data_tool_target( diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index 23fb1da..5753869 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -10,8 +10,8 @@ use plotx_core::state::{ PanelDrag, PanelId, PanelLabelDrag, PanelNoteEditState, PhaseDrag, PhaseDragKind, PhaseOrient, PlotxApp, Region, RegionDrag, RegionDragKind, RegionId, RegionSelection, ResizeHandle, SHEET_COL_W_PT, SHEET_HEADER_H_PT, SHEET_MAX_ROWS, SHEET_ROW_H_PT, Selection, SelectionDrag, - TableDataset, TextEditState, TileDropCacheKey, TileDropPreview, Tool, ZoomAxis, ZoomDrag, - board_frame_id, board_frame_ref, board_frames, frame_board_pos, frame_board_rect, + TableDataset, TextEditState, TileDropCacheKey, TileDropPreview, Tool, ViewportMode, ZoomAxis, + ZoomDrag, board_frame_id, board_frame_ref, board_frames, frame_board_pos, frame_board_rect, set_frame_board_pos, toggle_frame_selection_synced, }; use plotx_core::{Integral2D, IntegralResult}; @@ -134,15 +134,15 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { canvas_breadcrumb(app, ci, 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 geometry = super::workspace_geometry(app, resp.rect, ui.ctx()); + let rect = geometry.board_rect; + let painter = painter.with_clip_rect(rect); ui.ctx().data_mut(|data| { data.insert_temp(egui::Id::new("plotx.canvas.navigation_rect"), rect); }); let chrome = ChromeStyle::from_visuals(ui.visuals(), app.settings.appearance.canvas_accent); - ensure_board_view(app, rect); consume_board_reveal(app, ui.ctx()); - let safe_fit = super::tools::task_card::safe_fit_rect(app, rect); - drive_board_fit(app, ui, rect, safe_fit); + drive_board_fit(app, ui, &geometry); // Raw-pointer gestures must not start through UI layered over the canvas. let pointer_hits_canvas_layer = ui diff --git a/crates/app/src/ui/canvas/navigation.rs b/crates/app/src/ui/canvas/navigation.rs index 03156b3..4052961 100644 --- a/crates/app/src/ui/canvas/navigation.rs +++ b/crates/app/src/ui/canvas/navigation.rs @@ -186,7 +186,7 @@ pub(crate) fn handle_navigation(app: &mut PlotxApp, ci: usize, rect: egui::Rect, && delta != Vec2::ZERO && let Some(plot) = plot_inner_rect(app, dci, did, rect) { - app.session.board_fit = None; + app.session.viewport_mode = ViewportMode::Manual; apply_plot_pan(app, dci, did, plot, delta); ui.ctx().request_repaint(); } @@ -220,7 +220,7 @@ pub(crate) fn handle_navigation(app: &mut PlotxApp, ci: usize, rect: egui::Rect, Some((id, outer, plot)) => { app.finish_pending_wheel_property(now, true); let scale = (1.0 / f64::from(zoom_delta)).clamp(0.2, 5.0); - app.session.board_fit = None; + app.session.viewport_mode = ViewportMode::Manual; zoom_plot_viewport( app, ci, @@ -235,7 +235,7 @@ pub(crate) fn handle_navigation(app: &mut PlotxApp, ci: usize, rect: egui::Rect, ); } None => { - app.session.board_fit = None; + app.session.viewport_mode = ViewportMode::Manual; zoom_board_view(app, rect, p, zoom_delta); ui.ctx().request_repaint(); } @@ -256,14 +256,14 @@ pub(crate) fn handle_navigation(app: &mut PlotxApp, ci: usize, rect: egui::Rect, }; let scale = f64::from((-amount * WHEEL_ZOOM_SPEED).exp()).clamp(0.2, 5.0); - app.session.board_fit = None; + app.session.viewport_mode = ViewportMode::Manual; zoom_plot_viewport(app, ci, id, outer, plot, p, scale, axes, now, ui); true } } None => { let factor = (amount * WHEEL_ZOOM_SPEED).exp(); - app.session.board_fit = None; + app.session.viewport_mode = ViewportMode::Manual; zoom_board_view(app, rect, p, factor); ui.ctx().request_repaint(); true @@ -329,23 +329,21 @@ pub(crate) fn handle_navigation(app: &mut PlotxApp, ci: usize, rect: egui::Rect, } pub(crate) fn pan_board_view(app: &mut PlotxApp, delta: Vec2) { - app.session.board_fit = None; - app.session.board.auto_fit = false; - app.session.board.pan[0] += delta.x; - app.session.board.pan[1] += delta.y; + app.session.viewport_mode = ViewportMode::Manual; + let zoom = app.session.board.zoom.max(0.01); + app.session.board.world_center[0] -= delta.x / zoom; + app.session.board.world_center[1] -= delta.y / zoom; } pub(crate) fn zoom_board_view(app: &mut PlotxApp, rect: egui::Rect, anchor: Pos2, factor: f32) { let old_zoom = app.session.board.zoom.max(0.01); let new_zoom = (old_zoom * factor).clamp(0.05, 8.0); - let world_x = (anchor.x - rect.left() - app.session.board.pan[0]) / old_zoom; - let world_y = (anchor.y - rect.top() - app.session.board.pan[1]) / old_zoom; + let offset = anchor - rect.center(); + let world_x = app.session.board.world_center[0] + offset.x / old_zoom; + let world_y = app.session.board.world_center[1] + offset.y / old_zoom; app.session.board.zoom = new_zoom; - app.session.board.pan = [ - anchor.x - rect.left() - world_x * new_zoom, - anchor.y - rect.top() - world_y * new_zoom, - ]; - app.session.board.auto_fit = false; + app.session.board.world_center = [world_x - offset.x / new_zoom, world_y - offset.y / new_zoom]; + app.session.viewport_mode = ViewportMode::Manual; } /// The pan gesture's undo bracket is owned by the caller (`handle_navigation`). @@ -433,7 +431,7 @@ pub(crate) fn pan_plot_viewport( if !update_pending_viewport_edit(app, ci, object_id, now) { return; } - app.session.board_fit = None; + app.session.viewport_mode = ViewportMode::Manual; apply_plot_pan(app, ci, object_id, plot, delta); } diff --git a/crates/app/src/ui/canvas/navigation_tests.rs b/crates/app/src/ui/canvas/navigation_tests.rs index 7533575..d3fe57e 100644 --- a/crates/app/src/ui/canvas/navigation_tests.rs +++ b/crates/app/src/ui/canvas/navigation_tests.rs @@ -35,6 +35,8 @@ fn zoomed_plot_fixture() -> (PlotxApp, ObjectId, PlotRect) { ))), }); app.doc.canvases.push(canvas); + app.session.board.world_center = [200.0, 150.0]; + app.session.viewport_mode = ViewportMode::Manual; ( app, PLOT_ID, @@ -83,8 +85,7 @@ fn a_double_click_release_beats_the_zero_distance_box_zoom_and_resets_the_plot() app.set_tool(Tool::BrowseZoom); app.session.board = BoardViewport { zoom: 1.0, - pan: [0.0, 0.0], - auto_fit: false, + world_center: [500.0, 400.0], }; let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0)); let plot = plot_inner_rect(&app, 0, object, screen).expect("plot is on the board"); @@ -144,8 +145,7 @@ fn a_double_click_during_a_pan_commits_the_pan_before_it_resets() { let object = ids[0]; app.session.board = BoardViewport { zoom: 1.0, - pan: [0.0, 0.0], - auto_fit: false, + world_center: [500.0, 400.0], }; let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0)); let plot = plot_inner_rect(&app, 0, object, screen).expect("plot is on the board"); @@ -222,8 +222,7 @@ fn assert_axis_double_click_resets_only(axis: ZoomAxis) { let object = ids[0]; app.session.board = BoardViewport { zoom: 1.0, - pan: [0.0, 0.0], - auto_fit: false, + world_center: [500.0, 400.0], }; let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0)); let outer = object_screen_rect(app.session.board, &app.doc.canvases[0], object, screen) @@ -481,7 +480,7 @@ fn horizontal_non_precise_scroll_is_ignored() { #[test] fn point_scroll_over_plot_pans_viewport_without_moving_board() { let (mut app, _, _) = zoomed_plot_fixture(); - app.session.board.auto_fit = false; + app.session.viewport_mode = ViewportMode::Manual; let before_viewport = plot_viewport(&app); let before_board = app.session.board; @@ -507,7 +506,7 @@ fn point_scroll_over_plot_pans_viewport_without_moving_board() { #[test] fn command_point_scroll_over_plot_pans_board_without_changing_viewport() { let (mut app, _, _) = zoomed_plot_fixture(); - app.session.board.auto_fit = false; + app.session.viewport_mode = ViewportMode::Manual; let before_viewport = plot_viewport(&app); let modifiers = egui::Modifiers { command: true, @@ -523,7 +522,7 @@ fn command_point_scroll_over_plot_pans_board_without_changing_viewport() { assert!(consumed); assert_eq!(plot_viewport(&app), before_viewport); - assert_eq!(app.session.board.pan, [4.0, -3.0]); + assert_eq!(app.session.board.world_center, [196.0, 153.0]); } #[cfg(target_os = "macos")] @@ -531,7 +530,7 @@ fn assert_modified_point_scroll_keeps_board_target_after_modifier_release( modifiers: egui::Modifiers, ) { let (mut app, _, _) = zoomed_plot_fixture(); - app.session.board.auto_fit = false; + app.session.viewport_mode = ViewportMode::Manual; let before_viewport = plot_viewport(&app); let ctx = egui::Context::default(); let pointer = Pos2::new(100.0, 60.0); @@ -553,7 +552,7 @@ fn assert_modified_point_scroll_keeps_board_target_after_modifier_release( assert!(start_consumed); assert_eq!(plot_viewport(&app), before_viewport); - assert_eq!(app.session.board.pan, [4.0, -3.0]); + assert_eq!(app.session.board.world_center, [196.0, 153.0]); let (move_consumed, _) = run_navigation_events_on( &ctx, @@ -569,7 +568,7 @@ fn assert_modified_point_scroll_keeps_board_target_after_modifier_release( assert!(move_consumed); assert_eq!(plot_viewport(&app), before_viewport); - assert_eq!(app.session.board.pan, [6.0, -5.0]); + assert_eq!(app.session.board.world_center, [194.0, 155.0]); } #[cfg(target_os = "macos")] @@ -593,7 +592,7 @@ fn control_point_scroll_keeps_board_target_after_modifier_release() { #[cfg(target_os = "macos")] fn assert_board_gesture_finish_releases_target(finish_phase: egui::TouchPhase) { let (mut app, _, _) = zoomed_plot_fixture(); - app.session.board.auto_fit = false; + app.session.viewport_mode = ViewportMode::Manual; let ctx = egui::Context::default(); let pointer = Pos2::new(100.0, 60.0); let command = egui::Modifiers { @@ -644,7 +643,7 @@ fn assert_board_gesture_finish_releases_target(finish_phase: egui::TouchPhase) { assert!(consumed); assert_ne!(plot_viewport(&app), before_viewport); - assert_eq!(app.session.board.pan, [4.0, -3.0]); + assert_eq!(app.session.board.world_center, [196.0, 153.0]); } #[cfg(target_os = "macos")] @@ -663,7 +662,7 @@ fn command_point_scroll_releases_board_target_on_cancel() { #[test] fn point_scroll_over_blank_board_pans_both_axes() { let (mut app, _, _) = zoomed_plot_fixture(); - app.session.board.auto_fit = false; + app.session.viewport_mode = ViewportMode::Manual; let before_viewport = plot_viewport(&app); let (consumed, _) = run_navigation_frame( @@ -675,14 +674,14 @@ fn point_scroll_over_blank_board_pans_both_axes() { assert!(consumed); assert_eq!(plot_viewport(&app), before_viewport); - assert_eq!(app.session.board.pan, [4.0, -3.0]); + assert_eq!(app.session.board.world_center, [196.0, 153.0]); } #[cfg(target_os = "macos")] #[test] fn native_pinch_has_priority_over_command_point_scroll() { let (mut app, _, _) = zoomed_plot_fixture(); - app.session.board.auto_fit = false; + app.session.viewport_mode = ViewportMode::Manual; let modifiers = egui::Modifiers { command: true, ..Default::default() @@ -700,14 +699,14 @@ fn native_pinch_has_priority_over_command_point_scroll() { assert!(consumed); assert!((app.session.board.zoom - 1.25).abs() < f32::EPSILON); - assert_eq!(app.session.board.pan, [-25.0, -15.0]); + assert_eq!(app.session.board.world_center, [180.0, 132.0]); } #[cfg(target_os = "macos")] #[test] fn native_pinch_does_not_inherit_board_target_from_point_gesture() { let (mut app, _, _) = zoomed_plot_fixture(); - app.session.board.auto_fit = false; + app.session.viewport_mode = ViewportMode::Manual; let ctx = egui::Context::default(); let pointer = Pos2::new(100.0, 60.0); let command = egui::Modifiers { @@ -744,21 +743,19 @@ fn native_pinch_does_not_inherit_board_target_from_point_gesture() { assert!(after_viewport.view_x.span() < before_viewport.view_x.span()); assert!(after_viewport.view_y.span() < before_viewport.view_y.span()); assert_eq!(app.session.board.zoom, 1.0); - assert_eq!(app.session.board.pan, [4.0, -3.0]); + assert_eq!(app.session.board.world_center, [196.0, 153.0]); } #[test] fn board_pan_clears_fit_and_moves_both_axes() { let mut app = PlotxApp::new(); - app.session.board.pan = [10.0, -4.0]; - app.session.board.auto_fit = true; - app.session.board_fit = Some(BoardFitTarget::Region([0.0, 0.0, 1.0, 1.0])); + app.session.board.world_center = [10.0, -4.0]; + app.session.viewport_mode = ViewportMode::Fit(BoardFitTarget::Region([0.0, 0.0, 1.0, 1.0])); pan_board_view(&mut app, Vec2::new(7.0, -3.0)); - assert_eq!(app.session.board.pan, [17.0, -7.0]); - assert!(!app.session.board.auto_fit); - assert!(app.session.board_fit.is_none()); + assert_eq!(app.session.board.world_center, [3.0, -1.0]); + assert_eq!(app.session.viewport_mode, ViewportMode::Manual); } #[test] diff --git a/crates/app/src/ui/canvas/navigation_trackpad_regression_tests.rs b/crates/app/src/ui/canvas/navigation_trackpad_regression_tests.rs index fd5c9d9..803678a 100644 --- a/crates/app/src/ui/canvas/navigation_trackpad_regression_tests.rs +++ b/crates/app/src/ui/canvas/navigation_trackpad_regression_tests.rs @@ -37,7 +37,7 @@ fn line_and_page_wheels_do_not_activate_trackpad_state() { #[test] fn point_wheel_without_touch_start_retains_wheel_zoom() { let (mut app, _, _) = zoomed_plot_fixture(); - app.session.board.auto_fit = false; + app.session.viewport_mode = ViewportMode::Manual; let before = plot_viewport(&app); let (consumed, _) = run_navigation_events( @@ -55,13 +55,13 @@ fn point_wheel_without_touch_start_retains_wheel_zoom() { assert!(consumed); assert!(after.view_x.span() < before.view_x.span()); assert_eq!(after.view_y, before.view_y); - assert_eq!(app.session.board.pan, [0.0, 0.0]); + assert_eq!(app.session.board.world_center, [200.0, 150.0]); } #[test] fn alt_point_wheel_without_touch_start_adjusts_display_intensity() { let (mut app, _, _) = zoomed_plot_fixture(); - app.session.board.auto_fit = false; + app.session.viewport_mode = ViewportMode::Manual; let before = plot_viewport(&app); let screen = egui::Rect::from_min_size(Pos2::ZERO, Vec2::new(400.0, 300.0)); let (_, outer, plot) = plot_under_cursor(&app, 0, screen, Pos2::new(100.0, 60.0)).unwrap(); @@ -87,13 +87,13 @@ fn alt_point_wheel_without_touch_start_adjusts_display_intensity() { assert!(consumed); assert!(after.view_y.span() < before.view_y.span()); assert!(app.session.status.starts_with("Adjusted plot intensity")); - assert_eq!(app.session.board.pan, [0.0, 0.0]); + assert_eq!(app.session.board.world_center, [200.0, 150.0]); } #[test] fn point_wheel_without_touch_start_over_y_axis_zooms_only_y() { let (mut app, _, _) = zoomed_plot_fixture(); - app.session.board.auto_fit = false; + app.session.viewport_mode = ViewportMode::Manual; let screen = egui::Rect::from_min_size(Pos2::ZERO, Vec2::new(400.0, 300.0)); let (_, outer, plot) = plot_under_cursor(&app, 0, screen, Pos2::new(100.0, 60.0)).unwrap(); let pointer = Pos2::new( @@ -123,7 +123,7 @@ fn point_wheel_without_touch_start_over_y_axis_zooms_only_y() { #[test] fn trackpad_target_stays_on_plot_when_control_changes_mid_gesture() { let (mut app, _, _) = zoomed_plot_fixture(); - app.session.board.auto_fit = false; + app.session.viewport_mode = ViewportMode::Manual; let ctx = egui::Context::default(); let pointer = Pos2::new(100.0, 60.0); let before = plot_viewport(&app); @@ -150,7 +150,7 @@ fn trackpad_target_stays_on_plot_when_control_changes_mid_gesture() { let after_start = plot_viewport(&app); assert!(start_consumed); assert_ne!(after_start, before); - assert_eq!(app.session.board.pan, [0.0, 0.0]); + assert_eq!(app.session.board.world_center, [200.0, 150.0]); let control = egui::Modifiers { ctrl: true, @@ -170,7 +170,7 @@ fn trackpad_target_stays_on_plot_when_control_changes_mid_gesture() { let after_control = plot_viewport(&app); assert!(control_consumed); assert_ne!(after_control, after_start); - assert_eq!(app.session.board.pan, [0.0, 0.0]); + assert_eq!(app.session.board.world_center, [200.0, 150.0]); let (release_consumed, _) = run_navigation_events_on( &ctx, @@ -186,13 +186,13 @@ fn trackpad_target_stays_on_plot_when_control_changes_mid_gesture() { assert!(release_consumed); assert_ne!(plot_viewport(&app), after_control); - assert_eq!(app.session.board.pan, [0.0, 0.0]); + assert_eq!(app.session.board.world_center, [200.0, 150.0]); } #[test] fn trackpad_target_stays_on_board_when_plot_moves_under_pointer() { let (mut app, _, _) = zoomed_plot_fixture(); - app.session.board.auto_fit = false; + app.session.viewport_mode = ViewportMode::Manual; let ctx = egui::Context::default(); let pointer = Pos2::new(300.0, 60.0); let before_viewport = plot_viewport(&app); @@ -217,7 +217,7 @@ fn trackpad_target_stays_on_board_when_plot_moves_under_pointer() { ], ); assert!(start_consumed); - assert_eq!(app.session.board.pan, [120.0, 0.0]); + assert_eq!(app.session.board.world_center, [80.0, 150.0]); assert_eq!(plot_viewport(&app), before_viewport); let (move_consumed, _) = run_navigation_events_on( @@ -233,7 +233,7 @@ fn trackpad_target_stays_on_board_when_plot_moves_under_pointer() { ); assert!(move_consumed); - assert_eq!(app.session.board.pan, [122.0, -2.0]); + assert_eq!(app.session.board.world_center, [78.0, 152.0]); assert_eq!(plot_viewport(&app), before_viewport); } @@ -267,7 +267,7 @@ fn trackpad_end_outside_canvas_clears_the_active_sequence() { #[test] fn trackpad_sequence_started_outside_canvas_stays_suppressed() { let (mut app, _, _) = zoomed_plot_fixture(); - app.session.board.auto_fit = false; + app.session.viewport_mode = ViewportMode::Manual; let ctx = egui::Context::default(); let before = plot_viewport(&app); let start = mouse_wheel_with_phase( @@ -292,7 +292,7 @@ fn trackpad_sequence_started_outside_canvas_stays_suppressed() { assert!(!consumed); assert_eq!(plot_viewport(&app), before); - assert_eq!(app.session.board.pan, [0.0, 0.0]); + assert_eq!(app.session.board.world_center, [200.0, 150.0]); let (end_consumed, _) = run_navigation_events_on( &ctx, diff --git a/crates/app/src/ui/canvas_size.rs b/crates/app/src/ui/canvas_size.rs index 9697db4..b66d2b7 100644 --- a/crates/app/src/ui/canvas_size.rs +++ b/crates/app/src/ui/canvas_size.rs @@ -371,7 +371,10 @@ pub(crate) fn page_size_chrome( .order(egui::Order::Middle) .pivot(egui::Align2::LEFT_BOTTOM) .fixed_pos(pos) + .constrain_to(view) .show(&ctx, |ui| { + ui.set_clip_rect(view); + ui.set_max_width((view.right() - pos.x).max(1.0)); ui.horizontal(|ui| { if ui .small_button(label) diff --git a/crates/app/src/ui/command_exec.rs b/crates/app/src/ui/command_exec.rs index ba9774f..ea440a5 100644 --- a/crates/app/src/ui/command_exec.rs +++ b/crates/app/src/ui/command_exec.rs @@ -3,8 +3,8 @@ //! within the repository size limit. use plotx_core::state::{ - CanvasDocument, CommandPaletteState, LineShapeKind, ObjectFrame, PlotxApp, ProjectTransition, - Tool, ToolGroup, + CanvasDocument, CommandPaletteState, FrameRef, LineShapeKind, ObjectFrame, PlotxApp, + ProjectTransition, Tool, ToolGroup, }; use super::clipboard_table::ClipboardTablePaste; @@ -118,8 +118,21 @@ fn execute_inner( CommandId::ToggleSecondarySidebar => { app.session.secondary_sidebar_visible = !app.session.secondary_sidebar_visible; } - CommandId::ZoomToFit => app.zoom_active_canvas_to_fit(), - CommandId::ZoomToSelection => super::canvas::zoom_to_selection(app, ctx), + CommandId::ZoomToFit => { + if let Some(canvas) = app.session.active_canvas { + super::canvas::request_board_fit(app, ctx, FrameRef::Page(canvas)); + app.session.status = "Fit page to view.".into(); + } + } + CommandId::ZoomToSelection => { + let count = app.session.ui.frame_selection.len(); + super::canvas::zoom_to_selection(app, ctx); + app.session.status = match count { + 0 => "Fit all frames to view.".to_owned(), + 1 => "Fit selected frame to view.".to_owned(), + _ => format!("Fit {count} selected frames to view."), + }; + } CommandId::UiScaleUp => crate::scale::nudge_ui_zoom(app, ctx, 1), CommandId::UiScaleDown => crate::scale::nudge_ui_zoom(app, ctx, -1), CommandId::UiScaleReset => crate::scale::reset_ui_zoom(app, ctx), @@ -580,8 +593,19 @@ mod tests { use plotx_core::actions::Action; use plotx_core::properties::{AggregateValue, PropertyAddress, PropertyValue, app_preferences}; use plotx_core::settings::Settings; - use plotx_core::state::CanvasDocument; - use plotx_core::state::DEFAULT_CANVAS_SIZE_MM; + use plotx_core::state::{BoardFitTarget, CanvasDocument, DEFAULT_CANVAS_SIZE_MM, ViewportMode}; + + fn app_with_active_canvases(count: usize) -> PlotxApp { + let mut app = PlotxApp::new_with_settings(Settings::default()); + for index in 0..count { + app.doc.canvases.push(CanvasDocument::new( + format!("page {index}"), + DEFAULT_CANVAS_SIZE_MM, + )); + } + app.session.active_canvas = (count > 0).then_some(0); + app + } fn catalog_snap(app: &mut PlotxApp, enabled: bool) { let commit = app @@ -594,6 +618,53 @@ mod tests { app.commit_property(commit); } + #[test] + fn zoom_to_fit_command_seeds_springs_from_the_live_camera() { + let mut app = app_with_active_canvases(1); + let ctx = egui::Context::default(); + app.session.board.zoom = 2.5; + app.session.board.world_center = [30.0, -40.0]; + + for (id, stale) in [ + ("board_fit_zoom", 9.0), + ("board_fit_center_x", 900.0), + ("board_fit_center_y", -900.0), + ] { + crate::ui::switcher::animate_spring(&ctx, egui::Id::new(id), stale, 0.0); + } + + execute_without_clipboard(CommandId::ZoomToFit, &mut app, &ctx); + + let page_id = app.doc.canvases[0].resource_id; + assert_eq!( + app.session.viewport_mode, + ViewportMode::Fit(BoardFitTarget::Frame( + plotx_core::state::BoardFrameId::Page(page_id) + )) + ); + for (id, live) in [ + ("board_fit_zoom", 2.5), + ("board_fit_center_x", 30.0), + ("board_fit_center_y", -40.0), + ] { + let seeded = crate::ui::switcher::animate_spring(&ctx, egui::Id::new(id), live, 0.0); + assert_eq!(seeded, live); + } + assert_eq!(app.session.status, "Fit page to view."); + } + + #[test] + fn zoom_to_selection_command_reports_one_selected_frame() { + let mut app = app_with_active_canvases(1); + let ctx = egui::Context::default(); + app.session.ui.frame_selection = + vec![plotx_core::state::board_frame_id(&app, FrameRef::Page(0)).unwrap()]; + + execute_without_clipboard(CommandId::ZoomToSelection, &mut app, &ctx); + + assert_eq!(app.session.status, "Fit selected frame to view."); + } + #[test] fn new_panel_frame_avoids_existing_panel_overlap_when_space_exists() { let mut page = CanvasDocument::new("page".to_owned(), [120.0, 90.0]); diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index ba9aba5..8a7d019 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -31,6 +31,7 @@ mod scientific_script; mod secondary_sidebar; mod settings_dialog; mod shortcuts; +mod sidebars; mod switcher; #[cfg(not(target_os = "macos"))] mod title_bar; @@ -38,6 +39,7 @@ pub(crate) mod tools; mod trace_alignment; mod trace_composer; mod windows; +mod workspace_geometry; use data_sheet::*; use diagnostics::*; @@ -54,6 +56,7 @@ pub(crate) use settings_dialog::apply_chrome_theme; use settings_dialog::{settings_window, sync_chrome_theme}; use shortcuts::*; use windows::*; +use workspace_geometry::resolve as workspace_geometry; pub fn render( app: &mut PlotxApp, @@ -110,7 +113,6 @@ pub fn render( handle_command_shortcuts(app, clipboard_table_paste, &ctx); handle_escape_shortcut(app, &ctx); handle_rename_shortcut(app, &ctx); - handle_fit_shortcut(app, &ctx); handle_focus_shortcut(app, &ctx); handle_hierarchy_traversal(app, &ctx); handle_delete_shortcut(app, &ctx); @@ -141,7 +143,7 @@ pub fn render( render_status(app, ui, dark); let workspace_width = ui.available_width(); - render_sidebars(app, ui, dark, workspace_width); + sidebars::render(app, ui, dark, workspace_width); // A sidebar may have changed an expanded Phase step before the canvas paints. app.sync_phase_interaction(); @@ -354,108 +356,6 @@ fn feedback_banner(app: &mut PlotxApp, ui: &mut Ui, dark: bool) { }); } -fn render_sidebars(app: &mut PlotxApp, ui: &mut Ui, dark: bool, workspace_width: f32) { - let compact = workspace_width < 1200.0; - let inspector_visible = app.session.secondary_sidebar_visible; - if !inspector_visible { - app.finish_axis_overrides_edit(); - } - object_inspector::finish_series_edit_if_inactive(app, inspector_visible); - if app.session.primary_sidebar_visible { - let panel = egui::Panel::left("primary_sidebar") - .frame(egui::Frame::NONE.inner_margin(egui::Margin { - left: 8, - right: 0, - top: 4, - bottom: 8, - })) - .show_separator_line(false); - let panel = if compact { - panel - .resizable(true) - .default_size(180.0) - .size_range(150.0..=420.0) - } else { - panel - .resizable(true) - .default_size(app.session.primary_sidebar_width) - .size_range(190.0..=420.0) - }; - let response = show_resizable_sidebar( - panel, - ui, - Id::new("primary_sidebar"), - SidebarEdge::Right, - |ui| { - let size = ui.available_size(); - let frame = card_frame(dark, egui::Margin::ZERO); - let inset = frame.total_margin().sum(); - frame - .show(ui, |ui| { - ui.set_min_size((size - inset).max(Vec2::ZERO)); - primary_sidebar::render(app, ui); - }) - .response - .rect - }, - ); - paint_sidebar_resize_edge( - ui, - Id::new("primary_sidebar"), - response.inner, - SidebarEdge::Right, - dark, - ); - } - - if app.session.secondary_sidebar_visible { - let panel = egui::Panel::right("secondary_sidebar") - .frame(egui::Frame::NONE.inner_margin(egui::Margin { - left: 0, - right: 8, - top: 4, - bottom: 8, - })) - .show_separator_line(false); - let panel = if compact { - panel - .resizable(true) - .default_size(230.0) - .size_range(180.0..=460.0) - } else { - panel - .resizable(true) - .default_size(app.session.secondary_sidebar_width) - .size_range(230.0..=460.0) - }; - let response = show_resizable_sidebar( - panel, - ui, - Id::new("secondary_sidebar"), - SidebarEdge::Left, - |ui| { - let size = ui.available_size(); - let frame = card_frame(dark, egui::Margin::ZERO); - let inset = frame.total_margin().sum(); - frame - .show(ui, |ui| { - ui.set_min_size((size - inset).max(Vec2::ZERO)); - secondary_sidebar::render(app, ui); - }) - .response - .rect - }, - ); - paint_sidebar_resize_edge( - ui, - Id::new("secondary_sidebar"), - response.inner, - SidebarEdge::Left, - dark, - ); - } -} - #[derive(Clone, Copy)] enum SidebarEdge { Left, diff --git a/crates/app/src/ui/primary_sidebar.rs b/crates/app/src/ui/primary_sidebar.rs index 5305d0e..f3e09d9 100644 --- a/crates/app/src/ui/primary_sidebar.rs +++ b/crates/app/src/ui/primary_sidebar.rs @@ -3,16 +3,20 @@ use egui::Ui; use egui_phosphor::regular as icon; use plotx_core::actions::{Action, ZOrder}; use plotx_core::state::{ - CanvasObjectKind, FrameRef, ObjectId, PlotxApp, PrimaryView, RegionSelection, RenameState, - RenameTarget, + FrameRef, ObjectId, PlotxApp, PrimaryView, RegionSelection, RenameState, RenameTarget, }; mod board_views; mod data_browser; +mod layer_controls; mod layers_tree; pub(crate) mod selection; use board_views::board_views_section; use data_browser::{AnalysisItem, AnalysisKind, DataTree, DatasetNode}; +use layer_controls::{ + kind_glyph, kind_label, lock_button, row as layer_row, truncated_selectable, visibility_button, +}; use selection::*; + pub fn render(app: &mut PlotxApp, ui: &mut Ui) { ui.add_space(6.0); switcher::segmented(ui, &mut app.session.view); @@ -109,7 +113,7 @@ fn canvas_list(app: &mut PlotxApp, ui: &mut Ui) { } let name = app.doc.canvases[ci].name.clone(); let selected = crate::ui::canvas::frame_is_selected(app, FrameRef::Page(ci)); - let resp = ui.selectable_label(selected, name); + let resp = truncated_selectable(ui, selected, name); if resp.clicked() { claim_list_keyboard_focus(ui, &resp); select = Some((ci, select_modifiers(ui))); @@ -222,81 +226,72 @@ fn object_list(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { if app.doc.canvases[ci].parent_panel(object_id).is_some() { continue; } - ui.horizontal(|ui| { - let mut visible = app.doc.canvases[ci].objects[oi].visible; - if ui - .checkbox(&mut visible, "") - .on_hover_text("Visible") - .changed() - { - let before = ( - app.doc.canvases[ci].objects[oi].visible, - app.doc.canvases[ci].objects[oi].locked, - ); - app.execute_action(Action::set_object_flags( - ci, - object_id, - before, - (visible, before.1), - )); - } - ui.weak(kind_glyph(&app.doc.canvases[ci].objects[oi].kind)) - .on_hover_text(kind_label(&app.doc.canvases[ci].objects[oi].kind)); - if app.doc.canvases[ci] - .content_group(app.doc.canvases[ci].objects[oi].id) - .is_some() - { - ui.weak(egui::RichText::new("⛓").small()) - .on_hover_text("Grouped"); - } - let selected = app.session.ui.selection.contains(object_id) - || app.session.ui.selection.object() == Some(object_id); - let resp = ui.add( - egui::Button::selectable(selected, app.doc.canvases[ci].objects[oi].name.clone()) - .sense(egui::Sense::click_and_drag()), - ); - if resp.drag_started() { - app.session.ui.layers_drag_content = Some(object_id); - } - if resp.clicked() || (resp.secondary_clicked() && !selected) { - claim_list_keyboard_focus(ui, &resp); - select = Some((object_id, select_modifiers(ui))); - } - resp.context_menu(|ui| { - object_transfer_menu(ui, object_id, &others, &mut transfer); - if !panel_destinations.is_empty() { - ui.menu_button("Move into panel", |ui| { - for (panel, name) in &panel_destinations { - if ui.button(name).clicked() { - app.select_content(ci, object_id); - crate::ui::commands::execute_without_clipboard( - crate::ui::commands::CommandId::MoveContentToPanel(Some( - *panel, - )), - app, - ui.ctx(), - ); - ui.close(); - } - } - }); + let locked_before = app.doc.canvases[ci].objects[oi].locked; + let (_, (lock_change, row_reorder)) = layer_row( + ui, + |ui| { + let mut visible = app.doc.canvases[ci].objects[oi].visible; + if visibility_button(ui, &mut visible).changed() { + let before = ( + app.doc.canvases[ci].objects[oi].visible, + app.doc.canvases[ci].objects[oi].locked, + ); + app.execute_action(Action::set_object_flags( + ci, + object_id, + before, + (visible, before.1), + )); } - }); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let mut locked = app.doc.canvases[ci].objects[oi].locked; - if ui - .checkbox(&mut locked, "") - .on_hover_text("Locked") - .changed() - && let Some(target) = app.object_target(ci, object_id) - && let Ok(commit) = app.plan_property_write( - plotx_core::properties::object::LOCKED, - std::slice::from_ref(&target), - &plotx_core::properties::PropertyValue::Bool(locked), - ) + ui.weak(kind_glyph(&app.doc.canvases[ci].objects[oi].kind)) + .on_hover_text(kind_label(&app.doc.canvases[ci].objects[oi].kind)); + if app.doc.canvases[ci] + .content_group(app.doc.canvases[ci].objects[oi].id) + .is_some() { - app.commit_property(commit); + ui.weak(egui::RichText::new("⛓").small()) + .on_hover_text("Grouped"); + } + let selected = app.session.ui.selection.contains(object_id) + || app.session.ui.selection.object() == Some(object_id); + let resp = truncated_selectable( + ui, + selected, + app.doc.canvases[ci].objects[oi].name.clone(), + ) + .interact(egui::Sense::click_and_drag()); + if resp.drag_started() { + app.session.ui.layers_drag_content = Some(object_id); + } + if resp.clicked() || (resp.secondary_clicked() && !selected) { + claim_list_keyboard_focus(ui, &resp); + select = Some((object_id, select_modifiers(ui))); } + resp.context_menu(|ui| { + object_transfer_menu(ui, object_id, &others, &mut transfer); + if !panel_destinations.is_empty() { + ui.menu_button("Move into panel", |ui| { + for (panel, name) in &panel_destinations { + if ui.button(name).clicked() { + app.select_content(ci, object_id); + crate::ui::commands::execute_without_clipboard( + crate::ui::commands::CommandId::MoveContentToPanel(Some( + *panel, + )), + app, + ui.ctx(), + ); + ui.close(); + } + } + }); + } + }); + }, + |ui| { + let mut locked = locked_before; + let lock_change = lock_button(ui, &mut locked).changed().then_some(locked); + let mut row_reorder = None; if ui .add_enabled( row + 1 < count, @@ -305,7 +300,7 @@ fn object_list(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { .on_hover_text("Send backward") .clicked() { - reorder = Some((object_id, ZOrder::Backward)); + row_reorder = Some(ZOrder::Backward); } if ui .add_enabled( @@ -315,10 +310,24 @@ fn object_list(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { .on_hover_text("Bring forward") .clicked() { - reorder = Some((object_id, ZOrder::Forward)); + row_reorder = Some(ZOrder::Forward); } - }); - }); + (lock_change, row_reorder) + }, + ); + if let Some(locked) = lock_change + && let Some(target) = app.object_target(ci, object_id) + && let Ok(commit) = app.plan_property_write( + plotx_core::properties::object::LOCKED, + std::slice::from_ref(&target), + &plotx_core::properties::PropertyValue::Bool(locked), + ) + { + app.commit_property(commit); + } + if let Some(operation) = row_reorder { + reorder = Some((object_id, operation)); + } } if let Some((object_id, modifiers)) = select { app.session.ui.selection_scope = plotx_core::state::SelectionScope::Layers; @@ -357,22 +366,6 @@ fn object_transfer_menu( *transfer = Some((object_id, to, is_move)); } } -fn kind_glyph(kind: &CanvasObjectKind) -> &'static str { - match kind { - CanvasObjectKind::Plot(_) => icon::CHART_LINE, - CanvasObjectKind::Text(_) => "T", - CanvasObjectKind::Shape(_) => icon::SHAPES, - CanvasObjectKind::RasterImage(_) => icon::FILE, - } -} -fn kind_label(kind: &CanvasObjectKind) -> &'static str { - match kind { - CanvasObjectKind::Plot(_) => "Plot", - CanvasObjectKind::Text(_) => "Text", - CanvasObjectKind::Shape(_) => "Shape", - CanvasObjectKind::RasterImage(_) => "Image", - } -} fn data_list(app: &mut PlotxApp, ui: &mut Ui) { if app.doc.datasets.is_empty() { ui.weak("No data yet. Open an acquisition from the toolbar."); diff --git a/crates/app/src/ui/primary_sidebar/board_views.rs b/crates/app/src/ui/primary_sidebar/board_views.rs index cf78769..6aeb447 100644 --- a/crates/app/src/ui/primary_sidebar/board_views.rs +++ b/crates/app/src/ui/primary_sidebar/board_views.rs @@ -14,13 +14,13 @@ pub(super) fn board_views_section(app: &mut PlotxApp, ui: &mut Ui) { let can_save = !app.session.ui.board_view_name.trim().is_empty(); if ui .add_enabled(can_save, egui::Button::new("Save").small()) - .on_hover_text("Bookmark the current board zoom and pan") + .on_hover_text("Bookmark the current board framing") .clicked() { let view = NamedView { name: app.session.ui.board_view_name.trim().to_owned(), zoom: app.session.board.zoom, - pan: app.session.board.pan, + world_center: app.session.board.world_center, }; app.execute_action(Action::board_view_insert( app.session.board_views.len(), @@ -57,11 +57,11 @@ pub(super) fn board_views_section(app: &mut PlotxApp, ui: &mut Ui) { ui.add_space(4.0); if let Some(i) = jump { - let (zoom, pan) = ( + let (zoom, world_center) = ( app.session.board_views[i].zoom, - app.session.board_views[i].pan, + app.session.board_views[i].world_center, ); - crate::ui::canvas::request_board_fit_viewport(app, ui.ctx(), zoom, pan); + crate::ui::canvas::request_board_fit_viewport(app, ui.ctx(), zoom, world_center); app.session.status = format!("Jumped to view “{}”.", app.session.board_views[i].name); } if let Some(i) = delete { diff --git a/crates/app/src/ui/primary_sidebar/layer_controls.rs b/crates/app/src/ui/primary_sidebar/layer_controls.rs new file mode 100644 index 0000000..9870562 --- /dev/null +++ b/crates/app/src/ui/primary_sidebar/layer_controls.rs @@ -0,0 +1,132 @@ +use egui::{Response, Ui}; +use egui_phosphor::regular as icon; +use plotx_core::state::CanvasObjectKind; + +const TOGGLE_WIDTH: f32 = 20.0; + +pub(super) fn visibility_button(ui: &mut Ui, visible: &mut bool) -> Response { + let (glyph, tooltip) = if *visible { + (icon::EYE, "Hide") + } else { + (icon::EYE_SLASH, "Show") + }; + let mut response = ui.add_sized( + [TOGGLE_WIDTH, ui.spacing().interact_size.y], + egui::Button::new(glyph) + .small() + .frame(false) + .selected(!*visible), + ); + if response.clicked() { + *visible = !*visible; + response.mark_changed(); + } + response.on_hover_text(tooltip) +} + +pub(super) fn lock_button(ui: &mut Ui, locked: &mut bool) -> Response { + let (glyph, tooltip) = if *locked { + (icon::LOCK, "Unlock") + } else { + (icon::LOCK_OPEN, "Lock") + }; + let mut response = ui.add_sized( + [TOGGLE_WIDTH, ui.spacing().interact_size.y], + egui::Button::new(glyph) + .small() + .frame(false) + .selected(*locked), + ); + if response.clicked() { + *locked = !*locked; + response.mark_changed(); + } + response.on_hover_text(tooltip) +} + +pub(super) fn row( + ui: &mut Ui, + left: impl FnOnce(&mut Ui) -> Left, + right: impl FnOnce(&mut Ui) -> Right, +) -> (Left, Right) { + egui::containers::Sides::new() + .shrink_left() + .truncate() + .show(ui, left, right) +} + +pub(super) fn truncated_selectable( + ui: &mut Ui, + selected: bool, + text: impl Into, +) -> Response { + ui.add_sized( + [ui.available_width().max(0.0), ui.spacing().interact_size.y], + egui::Button::selectable(selected, text).truncate(), + ) +} + +pub(super) fn kind_glyph(kind: &CanvasObjectKind) -> &'static str { + match kind { + CanvasObjectKind::Plot(_) => icon::CHART_LINE, + CanvasObjectKind::Text(_) => "T", + CanvasObjectKind::Shape(_) => icon::SHAPES, + CanvasObjectKind::RasterImage(_) => icon::FILE, + } +} + +pub(super) fn kind_label(kind: &CanvasObjectKind) -> &'static str { + match kind { + CanvasObjectKind::Plot(_) => "Plot", + CanvasObjectKind::Text(_) => "Text", + CanvasObjectKind::Shape(_) => "Shape", + CanvasObjectKind::RasterImage(_) => "Image", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + + #[test] + fn long_names_cannot_overlap_the_trailing_layer_controls() { + let ctx = egui::Context::default(); + let observed = Cell::new(None); + let _ = ctx.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(150.0, 80.0), + )), + ..Default::default() + }, + |ui| { + ui.set_width(150.0); + let (name, lock) = row( + ui, + |ui| { + let mut visible = true; + visibility_button(ui, &mut visible); + ui.weak(icon::CHART_LINE); + truncated_selectable( + ui, + false, + "A canvas layer name that is much wider than the sidebar", + ) + }, + |ui| { + let mut locked = false; + lock_button(ui, &mut locked) + }, + ); + observed.set(Some((name.rect, lock.rect))); + }, + ); + + let (name, lock) = observed.take().expect("layer row responses"); + assert!(name.right() <= lock.left()); + assert_eq!(lock.width(), TOGGLE_WIDTH); + assert!(lock.right() <= 150.0); + } +} diff --git a/crates/app/src/ui/primary_sidebar/layers_tree.rs b/crates/app/src/ui/primary_sidebar/layers_tree.rs index 76aba03..e04cd7d 100644 --- a/crates/app/src/ui/primary_sidebar/layers_tree.rs +++ b/crates/app/src/ui/primary_sidebar/layers_tree.rs @@ -1,9 +1,7 @@ use egui::Ui; use egui_phosphor::regular as icon; use plotx_core::actions::{Action, PanelState}; -use plotx_core::state::{ - CanvasObjectKind, ContentId, Panel, PanelId, PanelLabelMode, PlotxApp, SelectionPath, -}; +use plotx_core::state::{ContentId, Panel, PanelId, PanelLabelMode, PlotxApp, SelectionPath}; pub(super) fn render_panels(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { let panels: Vec<_> = app.doc.canvases[ci] @@ -40,69 +38,67 @@ fn render_panel(app: &mut PlotxApp, ci: usize, panel_id: PanelId, ui: &mut Ui) { let mut flags = None; let mut drop_content = None; let display_name = panel_tree_name(app, ci, &panel); - ui.horizontal(|ui| { - if ui - .small_button(if open { - icon::CARET_DOWN - } else { - icon::CARET_RIGHT - }) - .clicked() - { - open = !open; - ui.data_mut(|data| data.insert_temp(collapse_id, open)); - } - let mut visible = panel.visible; - if ui - .checkbox(&mut visible, "") - .on_hover_text("Visible") - .changed() - { - flags = Some((visible, panel.locked)); - } - ui.weak(icon::RECTANGLE).on_hover_text("Panel"); - let response = ui.add( - egui::Button::selectable(selected, display_name).sense(egui::Sense::click_and_drag()), - ); - if response.drag_started() { - app.session.ui.panel_drop_target = None; - } - if let Some(content) = app.session.ui.layers_drag_content - && response.hovered() - && !panel.locked - && app.doc.canvases[ci].parent_panel(content) != Some(panel_id) - { - app.session.ui.panel_drop_target = Some(panel_id); - if ui.input(|input| input.pointer.primary_released()) { - drop_content = Some(content); - } - } - if app.session.ui.panel_drop_target == Some(panel_id) { - ui.painter().rect_stroke( - response.rect, - 2.0, - egui::Stroke::new(1.5_f32, ui.visuals().selection.stroke.color), - egui::StrokeKind::Inside, - ); - } - if response.clicked() || (response.secondary_clicked() && !selected) { - select_panel = true; - } - if response.double_clicked() { - enter_panel = true; - } - response.context_menu(|ui| panel_context_menu(app, ui)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let mut locked = panel.locked; + let (visible_flags, locked_flags) = super::layer_controls::row( + ui, + |ui| { if ui - .checkbox(&mut locked, "") - .on_hover_text("Locked") - .changed() + .small_button(if open { + icon::CARET_DOWN + } else { + icon::CARET_RIGHT + }) + .clicked() { - flags = Some((panel.visible, locked)); + open = !open; + ui.data_mut(|data| data.insert_temp(collapse_id, open)); } - }); - }); + let mut visible = panel.visible; + if super::layer_controls::visibility_button(ui, &mut visible).changed() { + flags = Some((visible, panel.locked)); + } + ui.weak(icon::RECTANGLE).on_hover_text("Panel"); + let response = super::layer_controls::truncated_selectable(ui, selected, display_name) + .interact(egui::Sense::click_and_drag()); + if response.drag_started() { + app.session.ui.panel_drop_target = None; + } + if let Some(content) = app.session.ui.layers_drag_content + && response.hovered() + && !panel.locked + && app.doc.canvases[ci].parent_panel(content) != Some(panel_id) + { + app.session.ui.panel_drop_target = Some(panel_id); + if ui.input(|input| input.pointer.primary_released()) { + drop_content = Some(content); + } + } + if app.session.ui.panel_drop_target == Some(panel_id) { + ui.painter().rect_stroke( + response.rect, + 2.0, + egui::Stroke::new(1.5_f32, ui.visuals().selection.stroke.color), + egui::StrokeKind::Inside, + ); + } + if response.clicked() || (response.secondary_clicked() && !selected) { + select_panel = true; + } + if response.double_clicked() { + enter_panel = true; + } + response.context_menu(|ui| panel_context_menu(app, ui)); + flags + }, + |ui| { + let mut locked = panel.locked; + if super::layer_controls::lock_button(ui, &mut locked).changed() { + Some((panel.visible, locked)) + } else { + None + } + }, + ); + flags = visible_flags.or(locked_flags); if let Some(content) = drop_content { app.select_content(ci, content); crate::ui::commands::execute_without_clipboard( @@ -137,10 +133,14 @@ fn render_panel(app: &mut PlotxApp, ci: usize, panel_id: PanelId, ui: &mut Ui) { render_content(app, ci, panel_id, content, ui); } if panel.item_order.is_empty() { - ui.horizontal(|ui| { - ui.add_space(36.0); - ui.weak("Empty panel — add or move content here."); - }); + super::layer_controls::row( + ui, + |ui| { + ui.add_space(36.0); + ui.add(egui::Label::new("Empty panel — add or move content here.").truncate()); + }, + |_| {}, + ); } } } @@ -173,65 +173,65 @@ fn render_content(app: &mut PlotxApp, ci: usize, panel: PanelId, content: Conten .filter(|candidate| candidate.id != panel && !candidate.locked) .map(|candidate| (candidate.id, candidate.name.clone())) .collect(); - ui.horizontal(|ui| { - ui.add_space(24.0); - let mut visible = item.visible; - if ui - .checkbox(&mut visible, "") - .on_hover_text("Visible") - .changed() - { - flags = Some((visible, item.locked)); - } - ui.weak(kind_glyph(&item.kind)) - .on_hover_text(kind_label(&item.kind)); - let response = ui.add( - egui::Button::selectable(selected, item.name.clone()) - .sense(egui::Sense::click_and_drag()), - ); - if response.drag_started() { - app.session.ui.layers_drag_content = Some(content); - } - if response.clicked() || (response.secondary_clicked() && !selected) { - select = true; - } - response.context_menu(|ui| { - if ui.button("Move out of panel").clicked() { - app.select_content(ci, content); - crate::ui::commands::execute_without_clipboard( - crate::ui::commands::CommandId::MoveContentToPanel(None), - app, - ui.ctx(), - ); - ui.close(); + let (visible_flags, locked_flags) = super::layer_controls::row( + ui, + |ui| { + ui.add_space(24.0); + let mut visible = item.visible; + if super::layer_controls::visibility_button(ui, &mut visible).changed() { + flags = Some((visible, item.locked)); } - if !destinations.is_empty() { - ui.menu_button("Move to panel", |ui| { - for (target, name) in &destinations { - if ui.button(name).clicked() { - app.select_content(ci, content); - crate::ui::commands::execute_without_clipboard( - crate::ui::commands::CommandId::MoveContentToPanel(Some(*target)), - app, - ui.ctx(), - ); - ui.close(); - } - } - }); + ui.weak(super::layer_controls::kind_glyph(&item.kind)) + .on_hover_text(super::layer_controls::kind_label(&item.kind)); + let response = + super::layer_controls::truncated_selectable(ui, selected, item.name.clone()) + .interact(egui::Sense::click_and_drag()); + if response.drag_started() { + app.session.ui.layers_drag_content = Some(content); } - }); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if response.clicked() || (response.secondary_clicked() && !selected) { + select = true; + } + response.context_menu(|ui| { + if ui.button("Move out of panel").clicked() { + app.select_content(ci, content); + crate::ui::commands::execute_without_clipboard( + crate::ui::commands::CommandId::MoveContentToPanel(None), + app, + ui.ctx(), + ); + ui.close(); + } + if !destinations.is_empty() { + ui.menu_button("Move to panel", |ui| { + for (target, name) in &destinations { + if ui.button(name).clicked() { + app.select_content(ci, content); + crate::ui::commands::execute_without_clipboard( + crate::ui::commands::CommandId::MoveContentToPanel(Some( + *target, + )), + app, + ui.ctx(), + ); + ui.close(); + } + } + }); + } + }); + flags + }, + |ui| { let mut locked = item.locked; - if ui - .checkbox(&mut locked, "") - .on_hover_text("Locked") - .changed() - { - flags = Some((item.visible, locked)); + if super::layer_controls::lock_button(ui, &mut locked).changed() { + Some((item.visible, locked)) + } else { + None } - }); - }); + }, + ); + flags = visible_flags.or(locked_flags); if let Some((visible, locked)) = flags { app.execute_action(Action::set_object_flags( ci, @@ -305,21 +305,3 @@ fn replace_panel( }); } } - -fn kind_glyph(kind: &CanvasObjectKind) -> &'static str { - match kind { - CanvasObjectKind::Plot(_) => icon::CHART_LINE, - CanvasObjectKind::Text(_) => "T", - CanvasObjectKind::Shape(_) => icon::SHAPES, - CanvasObjectKind::RasterImage(_) => icon::FILE, - } -} - -fn kind_label(kind: &CanvasObjectKind) -> &'static str { - match kind { - CanvasObjectKind::Plot(_) => "Plot", - CanvasObjectKind::Text(_) => "Text", - CanvasObjectKind::Shape(_) => "Shape", - CanvasObjectKind::RasterImage(_) => "Image", - } -} diff --git a/crates/app/src/ui/shortcuts.rs b/crates/app/src/ui/shortcuts.rs index d56c09c..0cc2218 100644 --- a/crates/app/src/ui/shortcuts.rs +++ b/crates/app/src/ui/shortcuts.rs @@ -145,8 +145,7 @@ static BINDINGS: &[CommandBinding] = &[ id: commands::CommandId::ZoomToSelection, primary: plain(egui::Key::F), aliases: &[], - // Owned by `handle_fit_shortcut`, which adds selection-aware status. - dispatch: false, + dispatch: true, menu_accelerator: false, }, // §8.5 channel 3 / §12: bare `+` and `-` step the canvas-steppable setting. @@ -436,26 +435,6 @@ pub(super) fn handle_rename_shortcut(app: &mut PlotxApp, ctx: &egui::Context) { } } -/// F zooms the board to fit the frame selection, or every frame when nothing is -/// selected. -pub(super) fn handle_fit_shortcut(app: &mut PlotxApp, ctx: &egui::Context) { - if ctx.egui_wants_keyboard_input() { - return; - } - let fit = - ctx.input(|i| !i.modifiers.command && !i.modifiers.ctrl && i.key_pressed(egui::Key::F)); - if !fit { - return; - } - canvas::zoom_to_selection(app, ctx); - let n = app.session.ui.frame_selection.len(); - app.session.status = if n > 1 { - format!("Zoomed to {n} selected frames.") - } else { - "Zoomed to fit.".to_owned() - }; -} - /// Enter springs the board to zoom-to-fit the active frame — the lone selected /// page/sheet, or the active page when the selection is empty or multiple. `F` /// still fits the whole frame selection. diff --git a/crates/app/src/ui/sidebars.rs b/crates/app/src/ui/sidebars.rs new file mode 100644 index 0000000..59cb609 --- /dev/null +++ b/crates/app/src/ui/sidebars.rs @@ -0,0 +1,116 @@ +use super::*; + +const MIN_WORKSPACE_WIDTH: f32 = 320.0; + +pub(super) fn render(app: &mut PlotxApp, ui: &mut Ui, dark: bool, workspace_width: f32) { + let mut primary_rect = None; + let mut secondary_rect = None; + let compact = workspace_width < 1200.0; + let inspector_visible = app.session.secondary_sidebar_visible; + if !inspector_visible { + app.finish_axis_overrides_edit(); + } + object_inspector::finish_series_edit_if_inactive(app, inspector_visible); + if app.session.primary_sidebar_visible { + let min_width = if compact { 150.0 } else { 190.0 }; + let other_width = if app.session.secondary_sidebar_visible { + app.session.secondary_sidebar_width + } else { + 0.0 + }; + let max_width = + (workspace_width - other_width - MIN_WORKSPACE_WIDTH).clamp(min_width, 420.0); + let panel = egui::Panel::left("primary_sidebar") + .frame(egui::Frame::NONE.inner_margin(egui::Margin { + left: 8, + right: 0, + top: 4, + bottom: 8, + })) + .show_separator_line(false) + .resizable(true) + .default_size( + app.session + .primary_sidebar_width + .clamp(min_width, max_width), + ) + .size_range(min_width..=max_width); + let response = show_sidebar(panel, app, ui, dark, true); + paint_sidebar_resize_edge( + ui, + Id::new("primary_sidebar"), + response.inner, + SidebarEdge::Right, + dark, + ); + app.session.primary_sidebar_width = response.response.rect.width(); + primary_rect = Some(response.inner); + } + + if app.session.secondary_sidebar_visible { + let min_width = if compact { 180.0 } else { 230.0 }; + let other_width = if app.session.primary_sidebar_visible { + app.session.primary_sidebar_width + } else { + 0.0 + }; + let max_width = + (workspace_width - other_width - MIN_WORKSPACE_WIDTH).clamp(min_width, 460.0); + let panel = egui::Panel::right("secondary_sidebar") + .frame(egui::Frame::NONE.inner_margin(egui::Margin { + left: 0, + right: 8, + top: 4, + bottom: 8, + })) + .show_separator_line(false) + .resizable(true) + .default_size( + app.session + .secondary_sidebar_width + .clamp(min_width, max_width), + ) + .size_range(min_width..=max_width); + let response = show_sidebar(panel, app, ui, dark, false); + paint_sidebar_resize_edge( + ui, + Id::new("secondary_sidebar"), + response.inner, + SidebarEdge::Left, + dark, + ); + app.session.secondary_sidebar_width = response.response.rect.width(); + secondary_rect = Some(response.inner); + } + super::workspace_geometry::set_sidebar_rects(ui.ctx(), primary_rect, secondary_rect); +} + +fn show_sidebar( + panel: egui::Panel, + app: &mut PlotxApp, + ui: &mut Ui, + dark: bool, + primary: bool, +) -> InnerResponse { + let (id, edge) = if primary { + (Id::new("primary_sidebar"), SidebarEdge::Right) + } else { + (Id::new("secondary_sidebar"), SidebarEdge::Left) + }; + show_resizable_sidebar(panel, ui, id, edge, |ui| { + let size = ui.available_size(); + let frame = card_frame(dark, egui::Margin::ZERO); + let inset = frame.total_margin().sum(); + frame + .show(ui, |ui| { + ui.set_min_size((size - inset).max(Vec2::ZERO)); + if primary { + primary_sidebar::render(app, ui); + } else { + secondary_sidebar::render(app, ui); + } + }) + .response + .rect + }) +} diff --git a/crates/app/src/ui/tools/task_card.rs b/crates/app/src/ui/tools/task_card.rs index 5a0e795..0756fe8 100644 --- a/crates/app/src/ui/tools/task_card.rs +++ b/crates/app/src/ui/tools/task_card.rs @@ -16,7 +16,6 @@ const TOP_OFFSET: f32 = 8.0; const CHROME: f32 = 64.0; /// Below this the body is useless anyway; the card is allowed to overhang. const FLOOR: f32 = 120.0; -const COLLAPSED_HEIGHT: f32 = 96.0; const MIN_SAFE_EDGE: f32 = 72.0; const MIN_CARD_WIDTH: f32 = 120.0; @@ -36,7 +35,8 @@ pub(super) struct TaskCardGeometry { /// to the screen and slides it up over the Ribbon, hiding the very buttons that /// opened it. Clamping the min keeps a short window shrinking instead. pub(super) fn geometry(host: &Ui, preferred_min_body: f32) -> TaskCardGeometry { - let host_rect = host.max_rect(); + let host_rect = + crate::ui::workspace_geometry::board_rect(host.ctx()).unwrap_or_else(|| host.max_rect()); let width = card_width(host_rect); let pos = host_rect.right_top() + egui::vec2(-width - MARGIN, TOP_OFFSET); let max_body_height = (host_rect.bottom() - pos.y - CHROME).max(FLOOR); @@ -56,7 +56,7 @@ fn card_width(host: egui::Rect) -> f32 { ) } -fn visible_card_collapsed(app: &PlotxApp) -> Option { +pub(crate) fn visible_area_id(app: &PlotxApp) -> Option { let active = app.active_dataset(); match app.session.ui.task_dock_active? { TaskDockTab::Processing => app @@ -65,63 +65,42 @@ fn visible_card_collapsed(app: &PlotxApp) -> Option { .processing_task_dataset .and_then(|id| app.doc.dataset_index(id)) .filter(|dataset| Some(*dataset) == active) - .map(|_| app.session.ui.processing_task_collapsed), + .map(|_| Id::new("processing_task_card")), TaskDockTab::Regions => app .session .ui .region_task_dataset .and_then(|id| app.doc.dataset_index(id)) .filter(|dataset| Some(*dataset) == active) - .map(|_| app.session.ui.region_task_collapsed), + .map(|_| Id::new("region_task_card")), TaskDockTab::CurveFit => app .session .ui .curve_fit_task_dataset .filter(|dataset| Some(*dataset) == active) - .map(|_| app.session.ui.curve_fit_task_collapsed), + .map(|_| Id::new("curve_fit_task_card")), TaskDockTab::Statistics => app .session .ui .stat_task_dataset .filter(|dataset| Some(*dataset) == active) - .map(|_| app.session.ui.stat_task_collapsed), + .map(|_| Id::new("statistics_task_card")), } } -/// Largest practical board-fit rectangle not covered by the visible task card. -/// Persistent sidebars are already excluded from `host`; expanded cards reserve -/// the right dock strip, while a collapsed one leaves the full-width band below -/// its header available. -pub(crate) fn safe_fit_rect(app: &PlotxApp, host: egui::Rect) -> egui::Rect { - let Some(collapsed) = visible_card_collapsed(app) else { - return host; - }; - if collapsed { - if host.height() <= MIN_SAFE_EDGE { - return host; - } - let top = (host.top() + TOP_OFFSET + COLLAPSED_HEIGHT + MARGIN) - .min(host.bottom() - MIN_SAFE_EDGE); - return egui::Rect::from_min_max(egui::pos2(host.left(), top), host.max); - } - if host.width() <= MIN_SAFE_EDGE { - return host; - } - let right = (host.right() - card_width(host) - MARGIN * 2.0) - .clamp(host.left() + MIN_SAFE_EDGE, host.right()); - egui::Rect::from_min_max(host.min, egui::pos2(right, host.bottom())) -} - -/// A foreground task card that starts at `pos` and follows the shared title-bar -/// drag position maintained by [`header`]. +/// A task card that starts at `pos` and follows the shared title-bar drag +/// position maintained by [`header`]. It stays inside the central board and +/// below popup/dialog layers. pub(super) fn area(host: &Ui, id: Id, pos: Pos2) -> Area { let stored = host .ctx() .data(|data| data.get_temp::(id.with("position"))); + let bounds = + crate::ui::workspace_geometry::board_rect(host.ctx()).unwrap_or_else(|| host.max_rect()); let area = Area::new(id) - .order(Order::Foreground) + .order(Order::Middle) .movable(false) - .constrain_to(host.max_rect()); + .constrain_to(bounds); if let Some(stored) = stored { area.current_pos(stored) } else { @@ -321,54 +300,48 @@ mod tests { } #[test] - fn expanded_card_reserves_the_right_dock_strip() { - let app = app_with_task(TaskDockTab::Processing, false); - let host = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0)); - let safe = safe_fit_rect(&app, host); - assert_eq!(safe.left(), host.left()); - assert!(safe.right() < host.right() - WIDTH); - assert_eq!(safe.height(), host.height()); - } - - #[test] - fn collapsed_card_uses_the_full_width_below_its_header() { - let app = app_with_task(TaskDockTab::Regions, true); - let host = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0)); - let safe = safe_fit_rect(&app, host); - assert_eq!(safe.width(), host.width()); - assert!(safe.top() > host.top()); - } + fn visible_task_uses_the_area_id_that_is_actually_rendered() { + let processing = app_with_task(TaskDockTab::Processing, false); + let regions = app_with_task(TaskDockTab::Regions, true); - #[test] - fn narrow_hosts_keep_valid_nonempty_fit_geometry() { - let app = app_with_task(TaskDockTab::Processing, false); - let host = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(250.0, 140.0)); - let safe = safe_fit_rect(&app, host); - assert!(safe.is_finite()); - assert!(card_width(host) < WIDTH); - assert!(safe.width() >= MIN_SAFE_EDGE && safe.height() > 0.0); + assert_eq!( + visible_area_id(&processing), + Some(Id::new("processing_task_card")) + ); + assert_eq!(visible_area_id(®ions), Some(Id::new("region_task_card"))); } #[test] - fn sub_minimum_width_expanded_card_uses_the_recoverable_host_rect() { - let app = app_with_task(TaskDockTab::Processing, false); - let host = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(48.0, 140.0)); + fn task_card_geometry_uses_the_central_board_boundary() { + let app = PlotxApp::new(); + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1000.0, 700.0)); + let board = egui::Rect::from_min_max(egui::pos2(180.0, 80.0), egui::pos2(760.0, 680.0)); + let observed = Cell::new(None); - let safe = safe_fit_rect(&app, host); + let _ = ctx.run_ui( + egui::RawInput { + screen_rect: Some(screen), + ..Default::default() + }, + |ui| { + crate::ui::workspace_geometry::resolve(&app, board, ui.ctx()); + observed.set(Some(geometry(ui, 200.0))); + }, + ); - assert_eq!(safe, host); - assert!(safe.is_finite()); + let card = observed.take().expect("task-card geometry"); + assert!(card.pos.x >= board.left()); + assert!(card.pos.x + card.width + MARGIN <= board.right()); + assert!(card.pos.y >= board.top()); } #[test] - fn sub_minimum_height_collapsed_card_uses_the_recoverable_host_rect() { - let app = app_with_task(TaskDockTab::Regions, true); - let host = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(250.0, 48.0)); - - let safe = safe_fit_rect(&app, host); + fn narrow_boards_keep_a_valid_card_width() { + let board = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(250.0, 140.0)); - assert_eq!(safe, host); - assert!(safe.is_finite()); + assert!(card_width(board) < WIDTH); + assert!(card_width(board) >= MIN_CARD_WIDTH); } #[test] diff --git a/crates/app/src/ui/workspace_geometry.rs b/crates/app/src/ui/workspace_geometry.rs new file mode 100644 index 0000000..6c90950 --- /dev/null +++ b/crates/app/src/ui/workspace_geometry.rs @@ -0,0 +1,160 @@ +use egui::{Id, Rect}; +use plotx_core::state::PlotxApp; + +const GEOMETRY_ID: &str = "plotx.workspace_geometry"; +const SIDEBAR_RECTS_ID: &str = "plotx.workspace_sidebar_rects"; +const OCCLUDER_CLEARANCE: f32 = 12.0; +const SIDEBAR_GAP: f32 = 8.0; + +#[derive(Clone, Copy, Debug, Default)] +struct SidebarRects { + primary: Option, + secondary: Option, +} + +/// Authoritative geometry snapshot for one workspace frame. Persistent panels +/// are already removed from `board_rect`. Floating task cards contribute only +/// their actual bounds, and only to viewport-fit avoidance. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct WorkspaceGeometry { + pub board_rect: Rect, + pub fit_occluders: Vec, + pub revision: u64, +} + +impl WorkspaceGeometry { + /// Rectangles in which a fitted target can remain wholly unobscured. There + /// is at most one workflow task card, so this remains constant-time. + pub(crate) fn fit_candidates(&self) -> impl Iterator { + let board = self.board_rect; + let candidates = if let Some(occluder) = self.fit_occluders.first() { + [ + Rect::from_min_max(board.min, egui::pos2(occluder.left(), board.bottom())), + Rect::from_min_max(egui::pos2(board.left(), occluder.bottom()), board.max), + Rect::from_min_max(board.min, egui::pos2(board.right(), occluder.top())), + Rect::from_min_max(egui::pos2(occluder.right(), board.top()), board.max), + ] + } else { + [board, Rect::NOTHING, Rect::NOTHING, Rect::NOTHING] + }; + candidates + .into_iter() + .filter(|rect| rect.width() > 1.0 && rect.height() > 1.0) + } +} + +pub(crate) fn set_sidebar_rects( + ctx: &egui::Context, + primary: Option, + secondary: Option, +) { + ctx.data_mut(|data| { + data.insert_temp( + Id::new(SIDEBAR_RECTS_ID), + SidebarRects { primary, secondary }, + ); + }); +} + +pub(super) fn resolve(app: &PlotxApp, host_rect: Rect, ctx: &egui::Context) -> WorkspaceGeometry { + let sidebars = ctx + .data(|data| data.get_temp::(Id::new(SIDEBAR_RECTS_ID))) + .unwrap_or_default(); + let mut board_rect = host_rect; + if let Some(primary) = sidebars.primary { + board_rect.min.x = board_rect.min.x.max(primary.right() + SIDEBAR_GAP); + } + if let Some(secondary) = sidebars.secondary { + board_rect.max.x = board_rect.max.x.min(secondary.left() - SIDEBAR_GAP); + } + if board_rect.min.x >= board_rect.max.x { + board_rect = host_rect; + } + let fit_occluders = super::tools::task_card::visible_area_id(app) + .and_then(|id| ctx.memory(|memory| memory.area_rect(id))) + .map(|rect| rect.expand(OCCLUDER_CLEARANCE).intersect(board_rect)) + .filter(|rect| rect.width() > 1.0 && rect.height() > 1.0) + .into_iter() + .collect(); + let id = Id::new(GEOMETRY_ID); + let previous = ctx.data(|data| data.get_temp::(id)); + let changed = previous + .as_ref() + .is_none_or(|old| old.board_rect != board_rect || old.fit_occluders != fit_occluders); + let revision = previous + .as_ref() + .map_or(0, |old| old.revision.saturating_add(u64::from(changed))); + let geometry = WorkspaceGeometry { + board_rect, + fit_occluders, + revision, + }; + ctx.data_mut(|data| data.insert_temp(id, geometry.clone())); + geometry +} + +pub(crate) fn board_rect(ctx: &egui::Context) -> Option { + ctx.data(|data| { + data.get_temp::(Id::new(GEOMETRY_ID)) + .map(|geometry| geometry.board_rect) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{Pos2, Vec2}; + + #[test] + fn revision_changes_only_when_workspace_bounds_change() { + let app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + let ctx = egui::Context::default(); + let first_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 800.0)); + let first = resolve(&app, first_rect, &ctx); + let same = resolve(&app, first_rect, &ctx); + assert_eq!(same.revision, first.revision); + + let changed_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(900.0, 800.0)); + let changed = resolve(&app, changed_rect, &ctx); + assert_eq!(changed.revision, first.revision + 1); + assert_eq!(changed.board_rect, changed_rect); + } + + #[test] + fn candidates_preserve_the_space_below_a_floating_card() { + let board = Rect::from_min_size(Pos2::ZERO, Vec2::new(1000.0, 700.0)); + let card = Rect::from_min_max(egui::pos2(680.0, 20.0), egui::pos2(990.0, 420.0)); + let geometry = WorkspaceGeometry { + board_rect: board, + fit_occluders: vec![card], + revision: 0, + }; + let candidates = geometry.fit_candidates().collect::>(); + + assert!(candidates.iter().any(|rect| { + rect.left() == board.left() + && rect.right() == board.right() + && rect.top() == card.bottom() + && rect.bottom() == board.bottom() + })); + assert!(candidates.iter().all(|rect| { + let overlap = rect.intersect(card); + overlap.width() <= 0.0 || overlap.height() <= 0.0 + })); + } + + #[test] + fn persistent_sidebars_are_hard_board_boundaries() { + let app = PlotxApp::new(); + let ctx = egui::Context::default(); + let host = Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 700.0)); + let primary = Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(220.0, 700.0)); + let secondary = Rect::from_min_max(egui::pos2(900.0, 0.0), egui::pos2(1200.0, 700.0)); + set_sidebar_rects(&ctx, Some(primary), Some(secondary)); + + let geometry = resolve(&app, host, &ctx); + + assert_eq!(geometry.board_rect.left(), primary.right() + SIDEBAR_GAP); + assert_eq!(geometry.board_rect.right(), secondary.left() - SIDEBAR_GAP); + } +} diff --git a/crates/core/src/actions/tests/board.rs b/crates/core/src/actions/tests/board.rs index c12c403..173ce17 100644 --- a/crates/core/src/actions/tests/board.rs +++ b/crates/core/src/actions/tests/board.rs @@ -132,7 +132,7 @@ fn board_view_bookmarks_are_undoable() { let view = crate::state::NamedView { name: "overview".to_owned(), zoom: 1.5, - pan: [10.0, 20.0], + world_center: [10.0, 20.0], }; app.execute_action(Action::board_view_insert(0, view.clone())); @@ -159,7 +159,7 @@ fn board_view_replay_degrades_symmetrically_on_stale_lists() { let view = |name: &str| crate::state::NamedView { name: name.to_owned(), zoom: 1.0, - pan: [0.0, 0.0], + world_center: [0.0, 0.0], }; app.execute_action(Action::board_view_insert(0, view("a"))); app.execute_action(Action::board_view_insert(1, view("b"))); diff --git a/crates/core/src/actions/tests/mod.rs b/crates/core/src/actions/tests/mod.rs index e121859..6440116 100644 --- a/crates/core/src/actions/tests/mod.rs +++ b/crates/core/src/actions/tests/mod.rs @@ -427,7 +427,7 @@ fn page_view_zoom_pan_does_not_change_svg_or_object_geometry() { let before_svg = crate::state::render_document_svg(&app.doc.canvases[0]); app.session.board.zoom = 2.0; - app.session.board.pan = [120.0, -40.0]; + app.session.board.world_center = [120.0, -40.0]; assert_eq!(app.doc.canvases[0].objects[0].frame, before_frame); assert_eq!(first_plot(&app).viewport, before_viewport); diff --git a/crates/core/src/project/dto.rs b/crates/core/src/project/dto.rs index d9f492e..98b066e 100644 --- a/crates/core/src/project/dto.rs +++ b/crates/core/src/project/dto.rs @@ -634,14 +634,14 @@ pub struct Workspace { #[derive(Serialize, Deserialize, Clone, Copy)] pub struct BoardDto { pub zoom: f32, - pub pan: [f32; 2], + pub world_center: [f32; 2], } #[derive(Serialize, Deserialize, Clone)] pub struct BoardViewDto { pub name: String, pub zoom: f32, - pub pan: [f32; 2], + pub world_center: [f32; 2], } #[derive(Serialize, Deserialize)] diff --git a/crates/core/src/project/mod.rs b/crates/core/src/project/mod.rs index 73bd951..a2292fb 100644 --- a/crates/core/src/project/mod.rs +++ b/crates/core/src/project/mod.rs @@ -130,7 +130,7 @@ impl WorkspaceSnapshot { secondary_sidebar_visible: app.session.secondary_sidebar_visible, board: BoardDto { zoom: app.session.board.zoom, - pan: app.session.board.pan, + world_center: app.session.board.world_center, }, board_views: app .session @@ -139,7 +139,7 @@ impl WorkspaceSnapshot { .map(|view| BoardViewDto { name: view.name.clone(), zoom: view.zoom, - pan: view.pan, + world_center: view.world_center, }) .collect(), figure_typography: app.doc.style_library.figure_typography, @@ -559,9 +559,9 @@ pub fn load_project(path: &Path) -> Result { if let Some(board) = workspace.board { app.session.board = crate::state::BoardViewport { zoom: board.zoom, - pan: board.pan, - auto_fit: true, + world_center: board.world_center, }; + app.session.viewport_mode = crate::state::ViewportMode::Manual; } app.session.board_views = workspace .board_views @@ -569,7 +569,7 @@ pub fn load_project(path: &Path) -> Result { .map(|v| crate::state::NamedView { name: v.name.clone(), zoom: v.zoom, - pan: v.pan, + world_center: v.world_center, }) .collect(); app.doc.save_include_view_snapshots = manifest.save_profile.include_view_snapshots; diff --git a/crates/core/src/project/tests.rs b/crates/core/src/project/tests.rs index 0636f1e..e3fbc06 100644 --- a/crates/core/src/project/tests.rs +++ b/crates/core/src/project/tests.rs @@ -242,19 +242,20 @@ fn project_roundtrip_preserves_data_recipe_and_view() { app.doc.canvases[0].panel_label_style = crate::state::PanelLabelStyle::UpperAlpha; app.session.board = crate::state::BoardViewport { zoom: 1.75, - pan: [-40.0, 12.0], - auto_fit: true, + world_center: [-40.0, 12.0], }; + app.session.primary_sidebar_width = 317.0; + app.session.secondary_sidebar_width = 411.0; app.session.board_views = vec![ crate::state::NamedView { name: "overview".to_owned(), zoom: 0.5, - pan: [10.0, 20.0], + world_center: [10.0, 20.0], }, crate::state::NamedView { name: "detail".to_owned(), zoom: 3.0, - pan: [-5.0, -8.0], + world_center: [-5.0, -8.0], }, ]; let axis_overrides = AxisOverrides { @@ -302,7 +303,7 @@ fn project_roundtrip_preserves_data_recipe_and_view() { assert_eq!(loaded.session.board_views.len(), 2); assert_eq!(loaded.session.board_views[0].name, "overview"); assert_eq!(loaded.session.board_views[1].zoom, 3.0); - assert_eq!(loaded.session.board_views[1].pan, [-5.0, -8.0]); + assert_eq!(loaded.session.board_views[1].world_center, [-5.0, -8.0]); // Document typography survives the round-trip and is re-stamped onto the // rebuilt figures. assert_eq!( @@ -324,7 +325,9 @@ fn project_roundtrip_preserves_data_recipe_and_view() { crate::state::PanelLabelStyle::UpperAlpha ); assert_eq!(loaded.session.board.zoom, 1.75); - assert_eq!(loaded.session.board.pan, [-40.0, 12.0]); + assert_eq!(loaded.session.board.world_center, [-40.0, 12.0]); + assert_eq!(loaded.session.primary_sidebar_width, 317.0); + assert_eq!(loaded.session.secondary_sidebar_width, 411.0); assert_eq!( loaded.doc.canvases[0].layout, PageLayout { diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index a2a069a..070f6a9 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -54,7 +54,7 @@ impl PlotxApp { active_canvas: None, board: BoardViewport::default(), board_views: Vec::new(), - board_fit: None, + viewport_mode: ViewportMode::Fit(BoardFitTarget::AllFrames), board_reveal: None, view: PrimaryView::Canvas, tool: Tool::Select, @@ -651,20 +651,6 @@ impl PlotxApp { self.apply_viewport_to_plot_object(ci, id, fig); } } - - pub fn zoom_canvas_to_fit(&mut self, ci: usize) { - if self.doc.canvases.get(ci).is_none() { - return; - } - self.session.board.auto_fit = true; - self.session.status = "Fit page to view.".into(); - } - - pub fn zoom_active_canvas_to_fit(&mut self) { - if let Some(ci) = self.session.active_canvas { - self.zoom_canvas_to_fit(ci); - } - } } pub fn visible_y_range(fig: &Figure, x: AxisRange) -> Option { diff --git a/crates/core/src/state/document.rs b/crates/core/src/state/document.rs index 993e131..a5acc01 100644 --- a/crates/core/src/state/document.rs +++ b/crates/core/src/state/document.rs @@ -118,25 +118,6 @@ impl ObjectFrame { } } -/// Pan/zoom of the board that holds every page-frame in world (pt) space. -/// `auto_fit` refits the active page to the screen until the user pans/zooms. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct BoardViewport { - pub zoom: f32, - pub pan: [f32; 2], - pub auto_fit: bool, -} - -impl Default for BoardViewport { - fn default() -> Self { - Self { - zoom: 1.0, - pan: [0.0, 0.0], - auto_fit: true, - } - } -} - /// Legacy fallback resting position on the board (pt) for a page loaded from an /// old `.plotx` that predates saved `board_pos`: a tidy index-keyed grid. Live /// creation uses the collision-aware flow in `crate::state::next_board_frame_pos` @@ -167,28 +148,6 @@ pub enum BoardFrameId { Sheet(DatasetId), } -/// What an in-flight board zoom-to-fit glides toward. -#[derive(Clone, Copy, PartialEq, Debug)] -pub enum BoardFitTarget { - /// A single frame, re-read each tick so the glide tracks it if it moves. - Frame(BoardFrameId), - /// A fixed world-pt region `(min_x, min_y, max_x, max_y)` — e.g. the bounding - /// box of a multi-frame selection. - Region([f32; 4]), - /// An exact board viewport (zoom + pan), e.g. a saved named view. - Viewport { zoom: f32, pan: [f32; 2] }, -} - -/// A saved board bookmark: a named board viewport the user can jump back to. The -/// scalable answer to co-viewing many chart/data frames — save a framing, name -/// it, return to it later. -#[derive(Clone, Debug, PartialEq)] -pub struct NamedView { - pub name: String, - pub zoom: f32, - pub pan: [f32; 2], -} - /// How a multi-dataset plot combines its members. Line kinds: `Superimposed` /// overlays every trace on a shared axis; `Offset` steps each successive trace /// vertically (and optionally horizontally, for a pseudo-3D look). Field kind: diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index 810d6e9..b4e5a88 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -118,6 +118,7 @@ mod trace_provider_tests; mod ui_drag; mod ui_state; mod units; +mod viewport; mod workflow_tab; mod xps; mod xrd; @@ -197,6 +198,7 @@ pub use trace_alignment::*; pub use ui_drag::*; pub use ui_state::*; pub use units::*; +pub use viewport::*; pub use workflow_tab::WorkflowTab; pub use xps::*; pub use xrd::*; diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index 29490f8..bc395cc 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -657,8 +657,8 @@ pub struct Session { pub board: BoardViewport, /// Saved board bookmarks (named viewports) the user can jump back to. pub board_views: Vec, - /// What the board is animating a zoom-to-fit toward, if any. - pub board_fit: Option, + /// Whether the user owns the camera or a persistent fit intent does. + pub viewport_mode: ViewportMode, /// One-shot core-to-UI request to animate toward a newly created frame. pub board_reveal: Option, pub view: PrimaryView, diff --git a/crates/core/src/state/viewport.rs b/crates/core/src/state/viewport.rs new file mode 100644 index 0000000..8970018 --- /dev/null +++ b/crates/core/src/state/viewport.rs @@ -0,0 +1,50 @@ +use super::BoardFrameId; + +/// Layout-independent camera for the board that holds every page-frame. +/// +/// `world_center` is expressed in board points. The UI maps it to the center of +/// the current visible workspace, so changing sidebars or other chrome never +/// changes what point the camera is looking at. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BoardViewport { + pub zoom: f32, + pub world_center: [f32; 2], +} + +impl Default for BoardViewport { + fn default() -> Self { + Self { + zoom: 1.0, + world_center: [0.0, 0.0], + } + } +} + +/// What a persistent board fit intent targets. +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum BoardFitTarget { + /// A single frame, re-read each tick so the glide tracks it if it moves. + Frame(BoardFrameId), + /// A fixed world-pt region `(min_x, min_y, max_x, max_y)`. + Region([f32; 4]), + /// Every board frame, resolved again whenever the workspace changes. + AllFrames, + /// An exact layout-independent camera, e.g. a saved named view. + Viewport { zoom: f32, world_center: [f32; 2] }, +} + +/// Ownership of the board camera. Fit intent persists across layout changes; +/// direct pan or zoom returns control to the user. +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum ViewportMode { + Manual, + Fit(BoardFitTarget), +} + +/// A saved board bookmark that can be restored independently of UI layout. +#[derive(Clone, Debug, PartialEq)] +pub struct NamedView { + pub name: String, + pub zoom: f32, + pub world_center: [f32; 2], +} diff --git a/deny.toml b/deny.toml index 7640bf3..f9b895c 100644 --- a/deny.toml +++ b/deny.toml @@ -18,12 +18,6 @@ ignore = [ # libraries. { id = "RUSTSEC-2026-0192", reason = "ttf-parser is transitive and has no safe upgrade" }, { id = "RUSTSEC-2026-0206", reason = "rustybuzz is transitive and has no safe upgrade" }, - # quick-xml 0.39 is pinned by wayland-scanner (Linux graph), a compile-time - # proc-macro that only parses the Wayland protocol XML bundled with its - # dependents: never untrusted input, never in shipped binaries. Remove when - # wayland-scanner moves to quick-xml >= 0.41. - { id = "RUSTSEC-2026-0194", reason = "quick-xml is build-time only via wayland-scanner; no safe upgrade" }, - { id = "RUSTSEC-2026-0195", reason = "quick-xml is build-time only via wayland-scanner; no safe upgrade" }, { id = "RUSTSEC-2026-0249", reason = "smartstring is transitive through rhai; no safe upgrade is available" }, ]