diff --git a/crates/app/src/ui/canvas/board.rs b/crates/app/src/ui/canvas/board.rs index b5312a9..c7a99ef 100644 --- a/crates/app/src/ui/canvas/board.rs +++ b/crates/app/src/ui/canvas/board.rs @@ -134,11 +134,13 @@ pub(crate) fn drive_board_fit( BoardFitTarget::Frame(frame) => { match board_frame_ref(app, frame).and_then(|frame| frame_board_rect(app, frame)) { Some(r) => { - let vp = fit_bbox_around_occluders( + let Some(vp) = fit_bbox_around_occluders( (r.left, r.top, r.right(), r.bottom()), geometry, ui.ctx(), - ); + ) else { + return; + }; (vp.zoom, vp.world_center) } None => { @@ -151,11 +153,16 @@ pub(crate) fn drive_board_fit( let Some(b) = all_frames_bbox(app) else { return; }; - let vp = fit_bbox_around_occluders(b, geometry, ui.ctx()); + let Some(vp) = fit_bbox_around_occluders(b, geometry, ui.ctx()) else { + return; + }; (vp.zoom, vp.world_center) } BoardFitTarget::Region(b) => { - let vp = fit_bbox_around_occluders((b[0], b[1], b[2], b[3]), geometry, ui.ctx()); + let Some(vp) = fit_bbox_around_occluders((b[0], b[1], b[2], b[3]), geometry, ui.ctx()) + else { + return; + }; (vp.zoom, vp.world_center) } BoardFitTarget::Viewport { zoom, world_center } => (zoom, world_center), @@ -190,7 +197,7 @@ fn fit_bbox_around_occluders( bbox: (f32, f32, f32, f32), geometry: &crate::ui::workspace_geometry::WorkspaceGeometry, ctx: &egui::Context, -) -> BoardViewport { +) -> Option { 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); @@ -201,7 +208,9 @@ fn fit_bbox_around_occluders( best = Some((index, viewport)); } } - let (best_index, best_viewport) = best.expect("board geometry always has a fit candidate"); + // A card can cover the entire board while the window is being resized. + // Keep the camera until usable space returns; there is no valid fit yet. + let (best_index, best_viewport) = best?; let id = egui::Id::new(FIT_CANDIDATE_ID); let previous_index = ctx.data(|data| data.get_temp::(id)); let chosen = previous_index @@ -217,7 +226,7 @@ fn fit_bbox_around_occluders( .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 + Some(chosen.1) } /// Rounding of a header strip's top corners; the frame shadow shares it so the diff --git a/crates/app/src/ui/canvas/board_tests.rs b/crates/app/src/ui/canvas/board_tests.rs index a26076d..f492cd2 100644 --- a/crates/app/src/ui/canvas/board_tests.rs +++ b/crates/app/src/ui/canvas/board_tests.rs @@ -285,6 +285,31 @@ fn floating_task_cards_do_not_change_a_manual_camera() { assert_eq!(app.session.board, camera); } +#[test] +fn fully_occluded_board_preserves_the_camera_and_resumes_fit_when_uncovered() { + 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 before = app.session.board; + let board = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(260.0, 400.0)); + let mut geometry = workspace(board); + geometry.fit_occluders = vec![board]; + assert!(fit_bbox_around_occluders((0.0, 0.0, 800.0, 100.0), &geometry, &ctx).is_none()); + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + drive_board_fit(&mut app, ui, &geometry); + }); + assert_eq!(app.session.board, before); + assert!(matches!(app.session.viewport_mode, ViewportMode::Fit(_))); + geometry.fit_occluders.clear(); + for _ in 0..30 { + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + drive_board_fit(&mut app, ui, &geometry); + }); + } + assert_ne!(app.session.board, before); + assert!(app.session.board.zoom.is_finite()); +} + #[test] fn dragging_a_focused_frame_immediately_takes_ownership_from_fit() { let mut app = app_with_pages(&[[100.0, 100.0]]);