Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions crates/app/src/ui/tools/task_card.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -313,6 +315,7 @@ fn update_drag_position(ui: &Ui, area_id: Id, drag: &egui::Response) {
.get_temp::<CardLayout>(area_id.with("layout"))
.unwrap_or(CardLayout {
rect: origin.rect,
preferred: origin.rect,
bounds,
horizontal: HorizontalAnchor::Right,
vertical: VerticalAnchor::Top,
Expand All @@ -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
Expand Down Expand Up @@ -573,6 +577,7 @@ pub(super) fn resize_handles(
ui.ctx().data_mut(|data| {
if let Some(mut layout) = data.get_temp::<CardLayout>(area_id.with("layout")) {
layout.rect = resized;
layout.preferred = resized;
layout.bounds = bounds;
if edges.left {
layout.horizontal = HorizontalAnchor::Right;
Expand Down
76 changes: 59 additions & 17 deletions crates/app/src/ui/tools/task_card_layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
121 changes: 121 additions & 0 deletions crates/app/src/ui/tools/task_card_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<egui::Event>| {
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());
Expand Down Expand Up @@ -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,
Expand Down
Loading