diff --git a/crates/app/src/ui/tools/task_card.rs b/crates/app/src/ui/tools/task_card.rs index 88d4d3a..a646450 100644 --- a/crates/app/src/ui/tools/task_card.rs +++ b/crates/app/src/ui/tools/task_card.rs @@ -70,11 +70,13 @@ pub(super) fn geometry( chrome + preferred_body }, ); + let initial_rect = egui::Rect::from_min_size( + host_rect.right_top() + egui::vec2(-desired_size.x, TOP_OFFSET), + desired_size, + ); let initial = CardLayout { - rect: egui::Rect::from_min_size( - host_rect.right_top() + egui::vec2(-desired_size.x, TOP_OFFSET), - desired_size, - ), + rect: initial_rect, + preferred: initial_rect, bounds: host_rect, horizontal: HorizontalAnchor::Right, vertical: VerticalAnchor::Top, @@ -313,6 +315,7 @@ fn update_drag_position(ui: &Ui, area_id: Id, drag: &egui::Response) { .get_temp::(area_id.with("layout")) .unwrap_or(CardLayout { rect: origin.rect, + preferred: origin.rect, bounds, horizontal: HorizontalAnchor::Right, vertical: VerticalAnchor::Top, @@ -321,6 +324,7 @@ fn update_drag_position(ui: &Ui, area_id: Id, drag: &egui::Response) { collapsed: false, }); layout.rect = rect; + layout.preferred = rect; layout.bounds = bounds; layout.horizontal = if rect.left() - bounds.left() <= bounds.right() - rect.right() { HorizontalAnchor::Left @@ -573,6 +577,7 @@ pub(super) fn resize_handles( ui.ctx().data_mut(|data| { if let Some(mut layout) = data.get_temp::(area_id.with("layout")) { layout.rect = resized; + layout.preferred = resized; layout.bounds = bounds; if edges.left { layout.horizontal = HorizontalAnchor::Right; diff --git a/crates/app/src/ui/tools/task_card_layout.rs b/crates/app/src/ui/tools/task_card_layout.rs index 74d13ac..d76ecde 100644 --- a/crates/app/src/ui/tools/task_card_layout.rs +++ b/crates/app/src/ui/tools/task_card_layout.rs @@ -14,7 +14,14 @@ pub(super) enum VerticalAnchor { #[derive(Clone, Copy, Debug)] pub(super) struct CardLayout { + /// The rectangle actually rendered this frame: `preferred` fitted into the + /// current workspace bounds. pub rect: Rect, + /// The user-intended rectangle, written only by gestures (drag, resize) and + /// by boundary-following of a flush edge. It may lie outside the current + /// bounds; keeping it lets a card that a sidebar pushed aside return to its + /// place when the sidebar hides again. + pub preferred: Rect, pub bounds: Rect, pub horizontal: HorizontalAnchor, pub vertical: VerticalAnchor, @@ -23,43 +30,78 @@ pub(super) struct CardLayout { pub collapsed: bool, } -/// Rebuild a card from its preferred size and the edges fixed by the user's -/// last gesture. Viewport fitting may temporarily reduce the rendered size, -/// but never mutates that preferred size. +/// Distance within which a card edge counts as resting on a workspace boundary. +/// A resting edge keeps following that boundary (the default top-right docking +/// across sidebar toggles); a card parked anywhere else keeps its absolute +/// position and is only clamped back inside the new bounds. +const FLUSH_EPS: f32 = 1.0; + +/// Rebuild a card from its preferred rectangle, its preferred size and the +/// edges fixed by the user's last gesture. Viewport fitting may temporarily +/// move or shrink the rendered rectangle, but never mutates the preference: +/// bounds changes must not teleport a parked card (only a flush edge follows +/// its boundary), and a card displaced by a shrinking boundary returns once +/// the boundary recedes. pub(super) fn fit_layout( mut layout: CardLayout, bounds: Rect, desired_size: Vec2, collapsed: bool, ) -> CardLayout { - let rect = match layout.horizontal { + let mut preferred = layout.preferred; + match layout.horizontal { + HorizontalAnchor::Left => { + if (preferred.left() - layout.bounds.left()).abs() <= FLUSH_EPS { + preferred = + preferred.translate(Vec2::new(bounds.left() - layout.bounds.left(), 0.0)); + } + } + HorizontalAnchor::Right => { + if (layout.bounds.right() - preferred.right()).abs() <= FLUSH_EPS { + preferred = + preferred.translate(Vec2::new(bounds.right() - layout.bounds.right(), 0.0)); + } + } + } + match layout.vertical { + VerticalAnchor::Top => { + if (preferred.top() - layout.bounds.top()).abs() <= FLUSH_EPS { + preferred = preferred.translate(Vec2::new(0.0, bounds.top() - layout.bounds.top())); + } + } + VerticalAnchor::Bottom => { + if (layout.bounds.bottom() - preferred.bottom()).abs() <= FLUSH_EPS { + preferred = + preferred.translate(Vec2::new(0.0, bounds.bottom() - layout.bounds.bottom())); + } + } + } + let x_range = match layout.horizontal { HorizontalAnchor::Left => { - let left = (layout.rect.left() + bounds.left() - layout.bounds.left()) - .clamp(bounds.left(), bounds.right()); + let left = preferred.left().clamp(bounds.left(), bounds.right()); let width = desired_size.x.min((bounds.right() - left).max(1.0)); - Rect::from_x_y_ranges(left..=left + width, layout.rect.y_range()) + left..=left + width } HorizontalAnchor::Right => { - let right = (layout.rect.right() + bounds.right() - layout.bounds.right()) - .clamp(bounds.left(), bounds.right()); + let right = preferred.right().clamp(bounds.left(), bounds.right()); let width = desired_size.x.min((right - bounds.left()).max(1.0)); - Rect::from_x_y_ranges(right - width..=right, layout.rect.y_range()) + right - width..=right } }; - layout.rect = match layout.vertical { + let y_range = match layout.vertical { VerticalAnchor::Top => { - let top = (rect.top() + bounds.top() - layout.bounds.top()) - .clamp(bounds.top(), bounds.bottom()); + let top = preferred.top().clamp(bounds.top(), bounds.bottom()); let height = desired_size.y.min((bounds.bottom() - top).max(1.0)); - Rect::from_x_y_ranges(rect.x_range(), top..=top + height) + top..=top + height } VerticalAnchor::Bottom => { - let bottom = (rect.bottom() + bounds.bottom() - layout.bounds.bottom()) - .clamp(bounds.top(), bounds.bottom()); + let bottom = preferred.bottom().clamp(bounds.top(), bounds.bottom()); let height = desired_size.y.min((bottom - bounds.top()).max(1.0)); - Rect::from_x_y_ranges(rect.x_range(), bottom - height..=bottom) + bottom - height..=bottom } }; + layout.rect = Rect::from_x_y_ranges(x_range, y_range); + layout.preferred = preferred; layout.bounds = bounds; layout.collapsed = collapsed; layout diff --git a/crates/app/src/ui/tools/task_card_tests.rs b/crates/app/src/ui/tools/task_card_tests.rs index c6b6e88..aa1a6f8 100644 --- a/crates/app/src/ui/tools/task_card_tests.rs +++ b/crates/app/src/ui/tools/task_card_tests.rs @@ -178,6 +178,126 @@ fn title_drag_moves_the_shared_task_card() { assert_eq!(moved.min, before_drag.min + (pointer_end - pointer_start)); } +#[test] +fn sidebar_toggle_keeps_a_parked_card_and_its_drag_gesture() { + let ctx = egui::Context::default(); + let mut fonts = egui::FontDefinitions::default(); + let emphasized = fonts.families[&egui::FontFamily::Proportional].clone(); + fonts.families.insert( + egui::FontFamily::Name(crate::typography::EMPHASIZED_FAMILY_NAME.into()), + emphasized, + ); + ctx.set_fonts(fonts); + crate::typography::apply(&ctx); + let screen = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1400.0, 800.0)); + let mut app = app_with_task(TaskDockTab::Processing, false); + app.session.active_canvas = Some(0); + app.session.secondary_sidebar_visible = true; + let mut clipboard = crate::ui::clipboard_table::ClipboardTablePaste::default(); + let mut workflow = crate::ui::batch_workflow::AutomationUi::default(); + let mut title = None; + let id = area_id(TaskDockTab::Processing); + let mut frame = |app: &mut PlotxApp, events: Vec| { + let _ = ctx.run_ui( + egui::RawInput { + screen_rect: Some(screen), + events, + ..Default::default() + }, + |ui| { + crate::ui::render( + app, + &mut clipboard, + &mut workflow, + &mut title, + ui, + false, + crate::ui::RibbonChrome::default(), + ); + }, + ); + }; + let press = |pos: Pos2| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::default(), + }; + let release = |pos: Pos2| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::default(), + }; + for _ in 0..4 { + frame(&mut app, Vec::new()); + } + let card = ctx.memory(|m| m.area_rect(id)).expect("card rendered"); + + // Park the card away from every boundary by dragging its title. + let grab = card.min + egui::vec2(30.0, 12.0); + frame(&mut app, vec![egui::Event::PointerMoved(grab)]); + frame(&mut app, vec![egui::Event::PointerMoved(grab), press(grab)]); + let target = egui::pos2(700.0, 250.0); + frame(&mut app, vec![egui::Event::PointerMoved(target)]); + frame(&mut app, vec![release(target)]); + frame(&mut app, Vec::new()); + let parked = ctx.memory(|m| m.area_rect(id)).expect("card rendered"); + assert_eq!(parked.min, card.min + (target - grab)); + + // Hiding the secondary sidebar must not teleport the parked card. + app.session.secondary_sidebar_visible = false; + frame(&mut app, Vec::new()); + frame(&mut app, Vec::new()); + let after_hide = ctx.memory(|m| m.area_rect(id)).expect("card rendered"); + assert_eq!(after_hide, parked); + + // The next title drag moves the card by exactly the pointer travel. + let grab = after_hide.min + egui::vec2(30.0, 12.0); + frame(&mut app, vec![egui::Event::PointerMoved(grab)]); + frame(&mut app, vec![egui::Event::PointerMoved(grab), press(grab)]); + let target = grab + egui::vec2(-120.0, -40.0); + frame(&mut app, vec![egui::Event::PointerMoved(target)]); + frame(&mut app, vec![release(target)]); + frame(&mut app, Vec::new()); + let dragged = ctx.memory(|m| m.area_rect(id)).expect("card rendered"); + assert_eq!(dragged.min, after_hide.min + (target - grab)); +} + +#[test] +fn bounds_change_keeps_a_parked_card_in_place() { + let bounds = egui::Rect::from_min_max(egui::pos2(200.0, 0.0), egui::pos2(1112.0, 700.0)); + // Parked with a clear gap to the right boundary, but still right-anchored. + let original = layout( + egui::Rect::from_min_max(egui::pos2(660.0, 120.0), egui::pos2(1000.0, 560.0)), + bounds, + ); + let without_sidebar = + egui::Rect::from_min_max(egui::pos2(200.0, 0.0), egui::pos2(1396.0, 700.0)); + + let fitted = fit_layout(original, without_sidebar, Vec2::new(340.0, 440.0), false); + + assert_eq!(fitted.rect, original.rect); +} + +#[test] +fn a_card_displaced_by_a_sidebar_returns_when_it_hides() { + let wide = egui::Rect::from_min_max(egui::pos2(200.0, 0.0), egui::pos2(1396.0, 700.0)); + let original = layout( + egui::Rect::from_min_max(egui::pos2(816.0, 40.0), egui::pos2(1156.0, 480.0)), + wide, + ); + let with_sidebar = egui::Rect::from_min_max(egui::pos2(200.0, 0.0), egui::pos2(1112.0, 700.0)); + let size = Vec2::new(340.0, 440.0); + + let displaced = fit_layout(original, with_sidebar, size, false); + assert_eq!(displaced.rect.right(), with_sidebar.right()); + assert_eq!(displaced.preferred, original.preferred); + + let restored = fit_layout(displaced, wide, size, false); + assert_eq!(restored.rect, original.rect); +} + #[test] fn collapsed_cards_use_the_compact_width_without_overwriting_the_preference() { let app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); @@ -296,6 +416,7 @@ fn shrinking_from_the_left_keeps_the_right_edge_fixed() { fn layout(rect: egui::Rect, bounds: egui::Rect) -> CardLayout { CardLayout { rect, + preferred: rect, bounds, horizontal: HorizontalAnchor::Right, vertical: VerticalAnchor::Top,