From 8544bed887c5249884d6cfe24395170600a13d6b Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Wed, 26 Aug 2026 17:54:12 +0800 Subject: [PATCH] feat(ui): make task cards draggable and resizable - centralize floating task-card geometry and workspace bounds - add drag and edge-resize interactions with persisted per-tool dimensions - update task panels and add layout and interaction coverage - keep persisted settings at v1 and remove legacy migration paths --- AGENTS.md | 4 + crates/app/src/ui/canvas/board_tests.rs | 3 + crates/app/src/ui/canvas/mod.rs | 6 +- crates/app/src/ui/tools/craft.rs | 79 +- crates/app/src/ui/tools/craft/results.rs | 4 +- crates/app/src/ui/tools/craft/setup.rs | 177 ++-- crates/app/src/ui/tools/curve_fit.rs | 54 +- crates/app/src/ui/tools/mod.rs | 2 + crates/app/src/ui/tools/processing/surface.rs | 365 ++++---- crates/app/src/ui/tools/region_analysis.rs | 118 ++- crates/app/src/ui/tools/statistics.rs | 51 +- crates/app/src/ui/tools/task_card.rs | 884 ++++++++++++------ crates/app/src/ui/tools/task_card_layout.rs | 66 ++ crates/app/src/ui/tools/task_card_resize.rs | 81 ++ crates/app/src/ui/tools/task_card_tests.rs | 452 +++++++++ crates/app/src/ui/workspace_geometry.rs | 23 +- .../core/src/properties/export_dpi_tests.rs | 2 +- crates/core/src/properties/ilt_tests.rs | 2 +- crates/core/src/settings/io.rs | 21 +- crates/core/src/settings/migrate.rs | 37 - crates/core/src/settings/mod.rs | 3 +- crates/core/src/settings/model.rs | 80 ++ crates/core/src/settings/paths.rs | 19 - crates/core/src/settings/tests.rs | 86 +- 24 files changed, 1808 insertions(+), 811 deletions(-) create mode 100644 crates/app/src/ui/tools/task_card_layout.rs create mode 100644 crates/app/src/ui/tools/task_card_resize.rs create mode 100644 crates/app/src/ui/tools/task_card_tests.rs delete mode 100644 crates/core/src/settings/migrate.rs diff --git a/AGENTS.md b/AGENTS.md index a667907..dda344f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,10 @@ These instructions apply to the entire repository. `docs/dist/`, or `docs/node_modules/`. - Keep public documentation and user-visible behavior in sync. - Never add credentials or confidential or proprietary data. +- Until the first stable release, persisted formats remain fixed at v1. Do not + add migrations, legacy readers, compatibility fallbacks, old serialization + adapters, or development-draft format handling. Framework-native default + filling such as Serde `#[serde(default)]` is sufficient. ## Product name diff --git a/crates/app/src/ui/canvas/board_tests.rs b/crates/app/src/ui/canvas/board_tests.rs index 95f9558..5126163 100644 --- a/crates/app/src/ui/canvas/board_tests.rs +++ b/crates/app/src/ui/canvas/board_tests.rs @@ -20,6 +20,7 @@ fn screen() -> egui::Rect { fn workspace(board_rect: egui::Rect) -> crate::ui::workspace_geometry::WorkspaceGeometry { crate::ui::workspace_geometry::WorkspaceGeometry { board_rect, + task_card_bounds: board_rect, fit_occluders: Vec::new(), revision: 0, } @@ -235,6 +236,7 @@ fn fit_uses_the_open_space_below_a_floating_task_card_when_it_is_better() { 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, + task_card_bounds: board, fit_occluders: vec![egui::Rect::from_min_max( egui::pos2(680.0, 0.0), egui::pos2(1000.0, 420.0), @@ -263,6 +265,7 @@ fn floating_task_cards_do_not_change_a_manual_camera() { let board = screen(); let geometry = crate::ui::workspace_geometry::WorkspaceGeometry { board_rect: board, + task_card_bounds: board, fit_occluders: vec![egui::Rect::from_min_max( egui::pos2(1500.0, 0.0), egui::pos2(2000.0, 900.0), diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index 8fbddbe..62802f0 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -156,11 +156,7 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { // Raw-pointer gestures must not start through UI layered over the canvas. let pointer_hits_canvas_layer = ui .input(|input| input.pointer.hover_pos()) - .is_none_or(|pos| { - ui.ctx() - .layer_id_at(pos) - .is_none_or(|layer| layer == ui.layer_id()) - }); + .is_none_or(|pos| super::tools::task_card::pointer_allows_canvas(app, ui, pos)); let pointer_owned = app.session.ui.interaction.is_active() || (ui.rect_contains_pointer(rect) && pointer_hits_canvas_layer); diff --git a/crates/app/src/ui/tools/craft.rs b/crates/app/src/ui/tools/craft.rs index 6a4c9aa..1cdc797 100644 --- a/crates/app/src/ui/tools/craft.rs +++ b/crates/app/src/ui/tools/craft.rs @@ -189,9 +189,14 @@ pub(crate) fn render_task(app: &mut PlotxApp, host: &mut Ui) { let TaskCardGeometry { pos, width, - min_body_height, - max_body_height, - } = task_card::geometry_with_width(host, 420.0, 820.0); + body_height, + } = task_card::geometry( + app, + host, + TaskDockTab::Craft, + 420.0, + app.session.ui.craft_task_collapsed, + ); let collapsed = app.session.ui.craft_task_collapsed; let dark = host.visuals().dark_mode; let mut close = false; @@ -200,52 +205,42 @@ pub(crate) fn render_task(app: &mut PlotxApp, host: &mut Ui) { task_card::area(host, area_id, pos).show(host.ctx(), |ui| { ui.set_width(width); crate::ui::card_frame(dark, egui::Margin::ZERO).show(ui, |ui| { + task_card::header(ui, area_id, "CRAFT", None::<&str>, |ui| { + if ui + .small_button(icon::X) + .on_hover_text("Close CRAFT") + .clicked() + { + close = true; + } + let glyph = if collapsed { + icon::CARET_DOWN + } else { + icon::CARET_UP + }; + if ui + .small_button(glyph) + .on_hover_text(if collapsed { + "Expand CRAFT" + } else { + "Collapse CRAFT" + }) + .clicked() + { + toggle_collapse = true; + } + }); if task_card::tab_bar(app, TaskDockTab::Craft, ui) { ui.separator(); } - let nmr = app.doc.datasets[index].as_nmr().unwrap(); - let runs = nmr.craft_runs.len(); - task_card::header(ui, area_id, |ui| { - ui.label(crate::typography::headline("CRAFT")); - ui.weak(format!("{} points · {runs} run(s)", nmr.data.points.len())); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui - .small_button(icon::X) - .on_hover_text("Close CRAFT") - .clicked() - { - close = true; - } - let glyph = if collapsed { - icon::CARET_DOWN - } else { - icon::CARET_UP - }; - if ui - .small_button(glyph) - .on_hover_text(if collapsed { - "Expand CRAFT" - } else { - "Collapse CRAFT" - }) - .clicked() - { - toggle_collapse = true; - } - }); - }); if !collapsed { ui.separator(); - task_card::resizable_body( - ui, - "craft_task_body_resize", - 650.0, - min_body_height, - max_body_height, - |ui| body(app, index, ui), - ); + task_card::sized_body(ui, body_height, |ui| body(app, index, ui)); } }); + if !collapsed { + task_card::resize_handles(app, ui, area_id, TaskDockTab::Craft, width, body_height); + } }); if toggle_collapse { app.session.ui.craft_task_collapsed = !collapsed; diff --git a/crates/app/src/ui/tools/craft/results.rs b/crates/app/src/ui/tools/craft/results.rs index 43bef75..f8247c2 100644 --- a/crates/app/src/ui/tools/craft/results.rs +++ b/crates/app/src/ui/tools/craft/results.rs @@ -107,7 +107,7 @@ pub(super) fn show(app: &mut PlotxApp, index: usize, ui: &mut Ui) { } }); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { ui.selectable_value( &mut app.session.ui.craft_result_tab, CraftResultTab::Overview, @@ -304,7 +304,7 @@ fn components( .filter(|summary| summary.component_count > 0) .map(|summary| summary.region) .collect::>(); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { egui::ComboBox::from_id_salt(("craft_component_sort", run.id.0)) .selected_text(match app.session.ui.craft_component_sort { CraftComponentSort::ChemicalShift => "Sort: ppm", diff --git a/crates/app/src/ui/tools/craft/setup.rs b/crates/app/src/ui/tools/craft/setup.rs index 498faa1..07ea9ca 100644 --- a/crates/app/src/ui/tools/craft/setup.rs +++ b/crates/app/src/ui/tools/craft/setup.rs @@ -182,90 +182,101 @@ fn settings(app: &mut PlotxApp, index: usize, invocation: &CraftInvocation, ui: "3. Confirm acquisition and fit settings", )); ui.collapsing("Advanced fit settings", |ui| { - egui::Grid::new("craft_advanced_settings") - .num_columns(4) - .show(ui, |ui| { - ui.label("Minimum A/N"); - let mut value = invocation.params.min_amplitude_to_noise; - if ui - .add(DragValue::new(&mut value).range(0.1..=100.0).speed(0.1)) + let mut value = invocation.params.min_amplitude_to_noise; + if setting_row( + ui, + "Minimum A/N", + invocation.sources.min_amplitude_to_noise, + &nmr, + &mut overrides.min_amplitude_to_noise, + |ui| { + ui.add(DragValue::new(&mut value).range(0.1..=100.0).speed(0.1)) .changed() - { - overrides.min_amplitude_to_noise = Some(value); - } - source_cell(invocation.sources.min_amplitude_to_noise, &nmr, ui); - reset_button(&mut overrides.min_amplitude_to_noise, ui); - ui.end_row(); + }, + ) { + overrides.min_amplitude_to_noise = Some(value); + } - ui.label("Max components / fit window"); - let mut value = invocation.params.max_components_per_fit_window; - if ui.add(DragValue::new(&mut value).range(1..=64)).changed() { - overrides.max_components_per_fit_window = Some(value); - } - source_cell(invocation.sources.max_components_per_fit_window, &nmr, ui); - reset_button(&mut overrides.max_components_per_fit_window, ui); - ui.end_row(); + let mut value = invocation.params.max_components_per_fit_window; + if setting_row( + ui, + "Max components / fit window", + invocation.sources.max_components_per_fit_window, + &nmr, + &mut overrides.max_components_per_fit_window, + |ui| ui.add(DragValue::new(&mut value).range(1..=64)).changed(), + ) { + overrides.max_components_per_fit_window = Some(value); + } - ui.label("Linewidth range (Hz)"); - let mut value = invocation.params.linewidth_hz; - if ui - .horizontal(|ui| { - let first = ui - .add(DragValue::new(&mut value.0).range(0.001..=1_000.0)) - .changed(); - ui.label("to"); - first - | ui.add(DragValue::new(&mut value.1).range(0.002..=2_000.0)) - .changed() - }) - .inner - { - overrides.linewidth_hz = Some(value); - } - source_cell(invocation.sources.linewidth_hz, &nmr, ui); - reset_button(&mut overrides.linewidth_hz, ui); - ui.end_row(); + let mut value = invocation.params.linewidth_hz; + if setting_row( + ui, + "Linewidth range (Hz)", + invocation.sources.linewidth_hz, + &nmr, + &mut overrides.linewidth_hz, + |ui| { + let first = ui + .add(DragValue::new(&mut value.0).range(0.001..=1_000.0)) + .changed(); + ui.label("to"); + first + | ui.add(DragValue::new(&mut value.1).range(0.002..=2_000.0)) + .changed() + }, + ) { + overrides.linewidth_hz = Some(value); + } - ui.label("Fit window width (Hz)"); - let mut value = invocation.params.max_fit_window_width_hz; - if ui - .add(DragValue::new(&mut value).range(10.0..=10_000.0)) + let mut value = invocation.params.max_fit_window_width_hz; + if setting_row( + ui, + "Fit window width (Hz)", + invocation.sources.max_fit_window_width_hz, + &nmr, + &mut overrides.max_fit_window_width_hz, + |ui| { + ui.add(DragValue::new(&mut value).range(10.0..=10_000.0)) .changed() - { - overrides.max_fit_window_width_hz = Some(value); - } - source_cell(invocation.sources.max_fit_window_width_hz, &nmr, ui); - reset_button(&mut overrides.max_fit_window_width_hz, ui); - ui.end_row(); + }, + ) { + overrides.max_fit_window_width_hz = Some(value); + } - ui.label("FIR taps"); - let mut value = invocation.params.filter_taps; - if ui - .add(DragValue::new(&mut value).range(3..=4_095)) + let mut value = invocation.params.filter_taps; + if setting_row( + ui, + "FIR taps", + invocation.sources.filter_taps, + &nmr, + &mut overrides.filter_taps, + |ui| { + ui.add(DragValue::new(&mut value).range(3..=4_095)) .changed() - { - overrides.filter_taps = Some(value | 1); - } - source_cell(invocation.sources.filter_taps, &nmr, ui); - reset_button(&mut overrides.filter_taps, ui); - ui.end_row(); + }, + ) { + overrides.filter_taps = Some(value | 1); + } - ui.label("Max downsampled points"); - let mut value = invocation.params.max_downsampled_points; - if ui - .add(DragValue::new(&mut value).range(64..=65_536)) + let mut value = invocation.params.max_downsampled_points; + if setting_row( + ui, + "Max downsampled points", + invocation.sources.max_downsampled_points, + &nmr, + &mut overrides.max_downsampled_points, + |ui| { + ui.add(DragValue::new(&mut value).range(64..=65_536)) .changed() - { - overrides.max_downsampled_points = Some(value); - } - source_cell(invocation.sources.max_downsampled_points, &nmr, ui); - reset_button(&mut overrides.max_downsampled_points, ui); - ui.end_row(); - }); + }, + ) { + overrides.max_downsampled_points = Some(value); + } if invocation.params.profile == CraftProfile::Ssfp { let mut skip_ms = invocation.params.skip_duration_s * 1_000.0; - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { ui.label("Skip initial"); if ui .add( @@ -350,7 +361,7 @@ fn regions( let mut changed = false; let mut remove = None; for (position, region) in regions.iter_mut().enumerate() { - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { changed |= ui .add(DragValue::new(&mut region.start_ppm).suffix(" ppm")) .changed(); @@ -431,7 +442,7 @@ fn regions( fn run_controls(app: &mut PlotxApp, index: usize, ui: &mut Ui) { let dataset = app.doc.datasets[index].resource_id(); if let Some(elapsed) = app.session.compute.progress(dataset, ComputeKind::Craft) { - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { ui.spinner(); ui.label(format!("Running… {:.1} s", elapsed.as_secs_f32())); }); @@ -466,8 +477,22 @@ fn run_controls(app: &mut PlotxApp, index: usize, ui: &mut Ui) { } } -fn source_cell(source: CraftParamSource, nmr: &plotx_core::state::NmrDataset, ui: &mut Ui) { - ui.weak(source_text(source, nmr)); +fn setting_row( + ui: &mut Ui, + label: &str, + source: CraftParamSource, + nmr: &plotx_core::state::NmrDataset, + reset: &mut Option, + add_control: impl FnOnce(&mut Ui) -> bool, +) -> bool { + ui.horizontal_wrapped(|ui| { + ui.label(label); + let changed = add_control(ui); + ui.weak(source_text(source, nmr)); + reset_button(reset, ui); + changed + }) + .inner } fn source_text(source: CraftParamSource, _nmr: &plotx_core::state::NmrDataset) -> String { diff --git a/crates/app/src/ui/tools/curve_fit.rs b/crates/app/src/ui/tools/curve_fit.rs index b173fcb..5ca247d 100644 --- a/crates/app/src/ui/tools/curve_fit.rs +++ b/crates/app/src/ui/tools/curve_fit.rs @@ -51,10 +51,14 @@ pub(crate) fn render_task(app: &mut PlotxApp, host: &mut Ui) { let TaskCardGeometry { pos, width, - min_body_height, - max_body_height, - } = task_card::geometry(host, 280.0); - let default_body_height = 380.0; + body_height, + } = task_card::geometry( + app, + host, + TaskDockTab::CurveFit, + 280.0, + app.session.ui.curve_fit_task_collapsed, + ); let collapsed = app.session.ui.curve_fit_task_collapsed; let dark = host.visuals().dark_mode; let mut close = false; @@ -64,21 +68,20 @@ pub(crate) fn render_task(app: &mut PlotxApp, host: &mut Ui) { task_card::area(host, area_id, pos).show(host.ctx(), |ui| { ui.set_width(width); crate::ui::card_frame(dark, egui::Margin::ZERO).show(ui, |ui| { - if task_card::tab_bar(app, TaskDockTab::CurveFit, ui) { - ui.separator(); - } let table = app.doc.datasets[di].as_table().unwrap(); let curves = table.series_bindings.len(); let points = table.typed_state.envelope.revision.snapshot.row_count; - task_card::header(ui, area_id, |ui| { - crate::typography::headline_label(ui, "Curve Fit"); - let curve_count = if curves == 1 { - "1 curve".to_owned() - } else { - format!("{curves} curves") - }; - ui.weak(format!("{curve_count} · {points} points each")); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let curve_count = if curves == 1 { + "1 curve".to_owned() + } else { + format!("{curves} curves") + }; + task_card::header( + ui, + area_id, + "Curve Fit", + Some(format!("{curve_count} · {points} points each")), + |ui| { if ui .small_button(icon::X) .on_hover_text("Close Curve Fit") @@ -102,20 +105,19 @@ pub(crate) fn render_task(app: &mut PlotxApp, host: &mut Ui) { { toggle_collapse = true; } - }); - }); + }, + ); + if task_card::tab_bar(app, TaskDockTab::CurveFit, ui) { + ui.separator(); + } if !collapsed { ui.separator(); - task_card::resizable_body( - ui, - "curve_fit_task_body_resize", - default_body_height, - min_body_height, - max_body_height, - |ui| curve_fit_task_body(app, di, ui), - ); + task_card::sized_body(ui, body_height, |ui| curve_fit_task_body(app, di, ui)); } }); + if !collapsed { + task_card::resize_handles(app, ui, area_id, TaskDockTab::CurveFit, width, body_height); + } }); if toggle_collapse { diff --git a/crates/app/src/ui/tools/mod.rs b/crates/app/src/ui/tools/mod.rs index 56d91a9..ec7f3c1 100644 --- a/crates/app/src/ui/tools/mod.rs +++ b/crates/app/src/ui/tools/mod.rs @@ -16,6 +16,8 @@ mod statistics; mod statistics_config; mod symmetry; pub(crate) mod task_card; +mod task_card_layout; +mod task_card_resize; mod xps; use cursors::cursor_group; diff --git a/crates/app/src/ui/tools/processing/surface.rs b/crates/app/src/ui/tools/processing/surface.rs index 69913d3..8346c22 100644 --- a/crates/app/src/ui/tools/processing/surface.rs +++ b/crates/app/src/ui/tools/processing/surface.rs @@ -97,7 +97,7 @@ pub(super) fn render(app: &mut PlotxApp, host: &mut Ui) { return; } if app.doc.datasets[di].as_xrd().is_some() { - render_xrd(app, host, di, owner); + render_xrd(app, host, di); return; } let Some(shape) = surface_shape(&app.doc.datasets[di]) else { @@ -107,9 +107,14 @@ pub(super) fn render(app: &mut PlotxApp, host: &mut Ui) { let TaskCardGeometry { pos, width, - min_body_height, - max_body_height, - } = task_card::geometry(host, 300.0); + body_height, + } = task_card::geometry( + app, + host, + TaskDockTab::Processing, + 300.0, + app.session.ui.processing_task_collapsed, + ); let collapsed = app.session.ui.processing_task_collapsed; let dark = host.visuals().dark_mode; let mut close = false; @@ -119,9 +124,6 @@ pub(super) fn render(app: &mut PlotxApp, host: &mut Ui) { task_card::area(host, area_id, pos).show(host.ctx(), |ui| { ui.set_width(width); crate::ui::card_frame(dark, egui::Margin::ZERO).show(ui, |ui| { - if task_card::tab_bar(app, TaskDockTab::Processing, ui) { - ui.separator(); - } let name = app.doc.datasets[di].display_name(); let output = shape .axes @@ -132,32 +134,32 @@ pub(super) fn render(app: &mut PlotxApp, host: &mut Ui) { .unwrap_or(shape.input_domain) }) .collect::>(); - task_card::header(ui, area_id, |ui| { - ui.label(crate::typography::headline("Processing")); - ui.weak(if output.iter().all(|d| *d == Domain::Time) { - "Time-domain output" + let domain = if output.iter().all(|d| *d == Domain::Time) { + "Time-domain output" + } else { + "Frequency-domain output" + }; + task_card::header(ui, area_id, "Processing", Some(domain), |ui| { + if ui + .small_button(icon::X) + .on_hover_text("Close Processing") + .clicked() + { + close = true; + } + let glyph = if collapsed { + icon::CARET_DOWN } else { - "Frequency-domain output" - }); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui - .small_button(icon::X) - .on_hover_text("Close Processing") - .clicked() - { - close = true; - } - let glyph = if collapsed { - icon::CARET_DOWN - } else { - icon::CARET_UP - }; - if ui.small_button(glyph).clicked() { - toggle = true; - } - ui.menu_button(icon::DOTS_THREE_VERTICAL, |ui| panel_menu(app, di, ui)); - }); + icon::CARET_UP + }; + if ui.small_button(glyph).clicked() { + toggle = true; + } + ui.menu_button(icon::DOTS_THREE_VERTICAL, |ui| panel_menu(app, di, ui)); }); + if task_card::tab_bar(app, TaskDockTab::Processing, ui) { + ui.separator(); + } ui.add(egui::Label::new(RichText::new(name).small()).truncate()); ui.small(format!( "{} · {}", @@ -170,30 +172,33 @@ pub(super) fn render(app: &mut PlotxApp, host: &mut Ui) { )); if !collapsed { ui.separator(); - task_card::resizable_body( - ui, - "processing_task_body_resize", - 430.0, - min_body_height, - max_body_height, - |ui| { - egui::ScrollArea::vertical() - .id_salt(("processing_task", owner)) - .auto_shrink([false, true]) - .show(ui, |ui| { - if shape.axes.len() == 2 { - ui.small("Axes are processed F2 direct, then F1 indirect."); - } - for axis in &shape.axes { - render_axis(app, di, owner, shape.input_domain, axis, ui); - } - action_bar(app, ui); - analysis_card(app, di, ui); - }); - }, - ); + task_card::sized_body(ui, body_height, |ui| { + egui::ScrollArea::vertical() + .id_salt(("processing_task", owner)) + .auto_shrink([false, true]) + .show(ui, |ui| { + if shape.axes.len() == 2 { + ui.small("Axes are processed F2 direct, then F1 indirect."); + } + for axis in &shape.axes { + render_axis(app, di, owner, shape.input_domain, axis, ui); + } + action_bar(app, ui); + analysis_card(app, di, ui); + }); + }); } }); + if !collapsed { + task_card::resize_handles( + app, + ui, + area_id, + TaskDockTab::Processing, + width, + body_height, + ); + } }); if toggle { app.session.ui.processing_task_collapsed = !collapsed; @@ -203,7 +208,7 @@ pub(super) fn render(app: &mut PlotxApp, host: &mut Ui) { } } -fn render_xrd(app: &mut PlotxApp, host: &mut Ui, di: usize, owner: DatasetId) { +fn render_xrd(app: &mut PlotxApp, host: &mut Ui, di: usize) { let Some(dataset) = app.doc.datasets[di].as_xrd() else { return; }; @@ -213,9 +218,14 @@ fn render_xrd(app: &mut PlotxApp, host: &mut Ui, di: usize, owner: DatasetId) { let TaskCardGeometry { pos, width, - min_body_height, - max_body_height, - } = task_card::geometry(host, 300.0); + body_height, + } = task_card::geometry( + app, + host, + TaskDockTab::Processing, + 300.0, + app.session.ui.processing_task_collapsed, + ); let collapsed = app.session.ui.processing_task_collapsed; let dark = host.visuals().dark_mode; let mut close = false; @@ -227,139 +237,138 @@ fn render_xrd(app: &mut PlotxApp, host: &mut Ui, di: usize, owner: DatasetId) { task_card::area(host, area_id, pos).show(host.ctx(), |ui| { ui.set_width(width); crate::ui::card_frame(dark, egui::Margin::ZERO).show(ui, |ui| { + task_card::header(ui, area_id, "XRD Processing", None::<&str>, |ui| { + if ui + .small_button(icon::X) + .on_hover_text("Close Processing") + .clicked() + { + close = true; + } + let glyph = if collapsed { + icon::CARET_DOWN + } else { + icon::CARET_UP + }; + if ui.small_button(glyph).clicked() { + toggle = true; + } + ui.menu_button(icon::DOTS_THREE_VERTICAL, |ui| panel_menu(app, di, ui)); + if ui + .small_button(icon::ARROW_ARC_LEFT) + .on_hover_text("Reset processing") + .clicked() + { + reset = true; + } + }); if task_card::tab_bar(app, TaskDockTab::Processing, ui) { ui.separator(); } - task_card::header(ui, area_id, |ui| { - ui.label(crate::typography::headline("XRD Processing")); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add(egui::Label::new(RichText::new(name).small()).truncate()); + if !collapsed { + ui.separator(); + task_card::sized_body(ui, body_height, |ui| { + let mut background = params.background.is_some(); if ui - .small_button(icon::X) - .on_hover_text("Close Processing") - .clicked() + .checkbox(&mut background, "SNIP background subtraction") + .changed() { - close = true; + params.background = background.then_some(SnipBackground { iterations: 40 }); + changed = true; } - let glyph = if collapsed { - icon::CARET_DOWN - } else { - icon::CARET_UP - }; - if ui.small_button(glyph).clicked() { - toggle = true; + if let Some(settings) = &mut params.background { + ui.horizontal_wrapped(|ui| { + ui.label("Iterations"); + changed |= ui + .add( + egui::DragValue::new(&mut settings.iterations) + .range(1..=MAX_SNIP_ITERATIONS), + ) + .changed(); + }); } - ui.menu_button(icon::DOTS_THREE_VERTICAL, |ui| panel_menu(app, di, ui)); + let mut smoothing = params.smoothing.is_some(); if ui - .small_button(icon::ARROW_ARC_LEFT) - .on_hover_text("Reset processing") - .clicked() + .checkbox(&mut smoothing, "Savitzky-Golay smoothing") + .changed() { - reset = true; + params.smoothing = smoothing.then_some(SavitzkyGolay { + window: 11, + polynomial_order: 3, + }); + changed = true; } - }); - }); - ui.add(egui::Label::new(RichText::new(name).small()).truncate()); - if !collapsed { - ui.separator(); - task_card::resizable_body( - ui, - ("xrd_processing_task_body_resize", owner), - 250.0, - min_body_height, - max_body_height, - |ui| { - let mut background = params.background.is_some(); - if ui - .checkbox(&mut background, "SNIP background subtraction") - .changed() - { - params.background = - background.then_some(SnipBackground { iterations: 40 }); - changed = true; - } - if let Some(settings) = &mut params.background { - ui.horizontal(|ui| { - ui.label("Iterations"); - changed |= ui - .add( - egui::DragValue::new(&mut settings.iterations) - .range(1..=MAX_SNIP_ITERATIONS), - ) - .changed(); - }); - } - let mut smoothing = params.smoothing.is_some(); - if ui - .checkbox(&mut smoothing, "Savitzky-Golay smoothing") - .changed() - { - params.smoothing = smoothing.then_some(SavitzkyGolay { - window: 11, - polynomial_order: 3, - }); - changed = true; - } - if let Some(settings) = &mut params.smoothing { - ui.horizontal(|ui| { - ui.label("Window"); - let mut window = settings.window; - if ui - .add( - egui::DragValue::new(&mut window) - .range(3..=MAX_SAVGOL_WINDOW) - .speed(2), - ) - .changed() - { - settings.window = window | 1; - changed = true; - } - ui.label("Order"); - changed |= ui - .add( - egui::DragValue::new(&mut settings.polynomial_order) - .range(1..=MAX_SAVGOL_ORDER), - ) - .changed(); - }); - if settings.polynomial_order >= settings.window as u8 { - settings.polynomial_order = (settings.window - 1) as u8; + if let Some(settings) = &mut params.smoothing { + ui.horizontal_wrapped(|ui| { + ui.label("Window"); + let mut window = settings.window; + if ui + .add( + egui::DragValue::new(&mut window) + .range(3..=MAX_SAVGOL_WINDOW) + .speed(2), + ) + .changed() + { + settings.window = window | 1; + changed = true; } + ui.label("Order"); + changed |= ui + .add( + egui::DragValue::new(&mut settings.polynomial_order) + .range(1..=MAX_SAVGOL_ORDER), + ) + .changed(); + }); + if settings.polynomial_order >= settings.window as u8 { + settings.polynomial_order = (settings.window - 1) as u8; } - egui::ComboBox::from_label("Normalize") - .selected_text(match params.normalization { - XrdNormalization::None => "None", - XrdNormalization::Maximum => "Maximum intensity", - XrdNormalization::Area => "Integrated area", - }) - .show_ui(ui, |ui| { - changed |= ui - .selectable_value( - &mut params.normalization, - XrdNormalization::None, - "None", - ) - .changed(); - changed |= ui - .selectable_value( - &mut params.normalization, - XrdNormalization::Maximum, - "Maximum intensity", - ) - .changed(); - changed |= ui - .selectable_value( - &mut params.normalization, - XrdNormalization::Area, - "Integrated area", - ) - .changed(); - }); - action_bar(app, ui); - }, - ); + } + egui::ComboBox::from_label("Normalize") + .selected_text(match params.normalization { + XrdNormalization::None => "None", + XrdNormalization::Maximum => "Maximum intensity", + XrdNormalization::Area => "Integrated area", + }) + .show_ui(ui, |ui| { + changed |= ui + .selectable_value( + &mut params.normalization, + XrdNormalization::None, + "None", + ) + .changed(); + changed |= ui + .selectable_value( + &mut params.normalization, + XrdNormalization::Maximum, + "Maximum intensity", + ) + .changed(); + changed |= ui + .selectable_value( + &mut params.normalization, + XrdNormalization::Area, + "Integrated area", + ) + .changed(); + }); + action_bar(app, ui); + }); } }); + if !collapsed { + task_card::resize_handles( + app, + ui, + area_id, + TaskDockTab::Processing, + width, + body_height, + ); + } }); if reset { params = XrdProcessing::default(); @@ -390,7 +399,7 @@ fn render_axis( .output_domain(input_domain) .unwrap_or(input_domain); ui.add_space(6.0); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { ui.label(crate::typography::headline(axis.label())); ui.weak(format!( "· {} to {}", @@ -471,7 +480,7 @@ fn step_row( component: Some(plotx_core::automation::ComponentRef::ProcessingStep(id)), }; egui::Frame::group(ui.style()).show(ui, |ui| { - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { crate::ui::properties::panel::processing_step_section(app, &target, ui); let response = ui .selectable_label( diff --git a/crates/app/src/ui/tools/region_analysis.rs b/crates/app/src/ui/tools/region_analysis.rs index a6e3a7b..185e030 100644 --- a/crates/app/src/ui/tools/region_analysis.rs +++ b/crates/app/src/ui/tools/region_analysis.rs @@ -60,10 +60,14 @@ pub(crate) fn render_task(app: &mut PlotxApp, host: &mut Ui) { let TaskCardGeometry { pos, width, - min_body_height, - max_body_height, - } = task_card::geometry(host, 300.0); - let default_body_height = 460.0; + body_height, + } = task_card::geometry( + app, + host, + TaskDockTab::Regions, + 300.0, + app.session.ui.region_task_collapsed, + ); let collapsed = app.session.ui.region_task_collapsed; let dark = host.visuals().dark_mode; let mut close = false; @@ -74,75 +78,67 @@ pub(crate) fn render_task(app: &mut PlotxApp, host: &mut Ui) { task_card::area(host, area_id, pos).show(host.ctx(), |ui| { ui.set_width(width); crate::ui::card_frame(dark, egui::Margin::ZERO).show(ui, |ui| { - if task_card::tab_bar(app, TaskDockTab::Regions, ui) { - ui.separator(); - } let count = app.doc.datasets[di] .region_analysis() .map_or(0, |state| state.regions.len()); - task_card::header(ui, area_id, |ui| { - ui.label(crate::typography::headline("Regions")); - let state = if app.session.tool == Tool::Regions { - if count == 0 { - "Drawing".to_owned() - } else { - format!("Drawing · {count}") - } - } else if count == 1 { - "1 region".to_owned() + let state = if app.session.tool == Tool::Regions { + if count == 0 { + "Drawing".to_owned() } else { - format!("{count} regions") + format!("Drawing · {count}") + } + } else if count == 1 { + "1 region".to_owned() + } else { + format!("{count} regions") + }; + task_card::header(ui, area_id, "Regions", Some(state), |ui| { + if ui + .small_button(icon::X) + .on_hover_text("Close region tools") + .clicked() + { + close = true; + } + let glyph = if collapsed { + icon::CARET_DOWN + } else { + icon::CARET_UP }; - ui.weak(state); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui - .small_button(icon::X) - .on_hover_text("Close region tools") - .clicked() - { - close = true; - } - let glyph = if collapsed { - icon::CARET_DOWN + if ui + .small_button(glyph) + .on_hover_text(if collapsed { + "Expand region tools" } else { - icon::CARET_UP - }; - if ui - .small_button(glyph) - .on_hover_text(if collapsed { - "Expand region tools" - } else { - "Collapse region tools" - }) + "Collapse region tools" + }) + .clicked() + { + toggle_collapse = true; + } + if collapsed + && count > 0 + && ui + .small_button(icon::TABLE) + .on_hover_text("View extracted curves") .clicked() - { - toggle_collapse = true; - } - if collapsed - && count > 0 - && ui - .small_button(icon::TABLE) - .on_hover_text("View extracted curves") - .clicked() - { - open_table = true; - } - }); + { + open_table = true; + } }); + if task_card::tab_bar(app, TaskDockTab::Regions, ui) { + ui.separator(); + } if !collapsed { ui.separator(); - task_card::resizable_body( - ui, - "region_task_body_resize", - default_body_height, - min_body_height, - max_body_height, - |ui| { - region_task_body(app, di, ui); - }, - ); + task_card::sized_body(ui, body_height, |ui| { + region_task_body(app, di, ui); + }); } }); + if !collapsed { + task_card::resize_handles(app, ui, area_id, TaskDockTab::Regions, width, body_height); + } }); if toggle_collapse { diff --git a/crates/app/src/ui/tools/statistics.rs b/crates/app/src/ui/tools/statistics.rs index 04b4d00..02c7dfc 100644 --- a/crates/app/src/ui/tools/statistics.rs +++ b/crates/app/src/ui/tools/statistics.rs @@ -57,10 +57,14 @@ pub(crate) fn render_task(app: &mut PlotxApp, host: &mut Ui) { let TaskCardGeometry { pos, width, - min_body_height, - max_body_height, - } = task_card::geometry(host, 320.0); - let default_body_height = 460.0; + body_height, + } = task_card::geometry( + app, + host, + TaskDockTab::Statistics, + 320.0, + app.session.ui.stat_task_collapsed, + ); let collapsed = app.session.ui.stat_task_collapsed; let dark = host.visuals().dark_mode; let mut close = false; @@ -70,16 +74,15 @@ pub(crate) fn render_task(app: &mut PlotxApp, host: &mut Ui) { task_card::area(host, area_id, pos).show(host.ctx(), |ui| { ui.set_width(width); crate::ui::card_frame(dark, egui::Margin::ZERO).show(ui, |ui| { - if task_card::tab_bar(app, TaskDockTab::Statistics, ui) { - ui.separator(); - } let table = app.doc.datasets[di].as_table().unwrap(); let columns = table.numeric_analysis_columns().len(); let points = table.typed_state.envelope.revision.snapshot.row_count; - task_card::header(ui, area_id, |ui| { - ui.label(crate::typography::headline("Statistics")); - ui.weak(format!("{columns} columns · {points} rows")); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + task_card::header( + ui, + area_id, + "Statistics", + Some(format!("{columns} columns · {points} rows")), + |ui| { if ui .small_button(icon::X) .on_hover_text("Close Statistics") @@ -103,20 +106,26 @@ pub(crate) fn render_task(app: &mut PlotxApp, host: &mut Ui) { { toggle_collapse = true; } - }); - }); + }, + ); + if task_card::tab_bar(app, TaskDockTab::Statistics, ui) { + ui.separator(); + } if !collapsed { ui.separator(); - task_card::resizable_body( - ui, - "statistics_task_body_resize", - default_body_height, - min_body_height, - max_body_height, - |ui| statistics_task_body(app, di, ui), - ); + task_card::sized_body(ui, body_height, |ui| statistics_task_body(app, di, ui)); } }); + if !collapsed { + task_card::resize_handles( + app, + ui, + area_id, + TaskDockTab::Statistics, + width, + body_height, + ); + } }); if toggle_collapse { diff --git a/crates/app/src/ui/tools/task_card.rs b/crates/app/src/ui/tools/task_card.rs index 51741d1..88d4d3a 100644 --- a/crates/app/src/ui/tools/task_card.rs +++ b/crates/app/src/ui/tools/task_card.rs @@ -5,25 +5,32 @@ use egui::{ Align, Area, CursorIcon, Id, Layout, Order, Pos2, RichText, Sense, Ui, UiBuilder, Vec2, }; use egui_phosphor::regular as icon; -use plotx_core::state::{PlotxApp, TaskDockTab, Tool}; -use std::hash::Hash; +use plotx_core::{ + settings::TaskCardSize, + state::{PlotxApp, TaskDockTab, Tool}, +}; + +use super::task_card_layout::{CardLayout, HorizontalAnchor, VerticalAnchor, fit_layout}; -/// Width shared by every task card, and the gap it keeps from the canvas edges. -const WIDTH: f32 = 310.0; -const MARGIN: f32 = 12.0; -const TOP_OFFSET: f32 = 8.0; +const COLLAPSED_WIDTH: f32 = 310.0; +// Sidebar/Ribbon spacing is already applied by the central workspace panel. +// Task cards use that same boundary and must not add a second local gap. +const TOP_OFFSET: f32 = 0.0; /// Room the card header, frame and footprint need outside the resizable body. const CHROME: f32 = 64.0; -/// Below this the body is useless anyway; the card is allowed to overhang. +/// Preferred interaction minimum. A smaller viewport may temporarily force a +/// smaller body; viewport fitting always wins over this preference. const FLOOR: f32 = 120.0; -const MIN_SAFE_EDGE: f32 = 72.0; -const MIN_CARD_WIDTH: f32 = 120.0; +const MIN_CANVAS_WIDTH: f32 = 320.0; +const STANDARD_MIN_WIDTH: f32 = 300.0; +const CRAFT_MIN_WIDTH: f32 = 380.0; +const STANDARD_MAX_WIDTH: f32 = 520.0; +const CRAFT_MAX_WIDTH: f32 = 760.0; pub(super) struct TaskCardGeometry { pub pos: Pos2, pub width: f32, - pub min_body_height: f32, - pub max_body_height: f32, + pub body_height: f32, } /// Anchors a card to the host's top-right corner and sizes its body to the @@ -34,37 +41,122 @@ pub(super) struct TaskCardGeometry { /// the fitted max and force the card past the canvas. `Area` then constrains it /// 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 { - geometry_with_width(host, preferred_min_body, WIDTH) -} - -/// Variant for analysis tasks that need a wide visual workspace. The requested -/// width is still clamped to the central board, preserving the basic controls -/// on small windows without making compact cards wider. -pub(super) fn geometry_with_width( +pub(super) fn geometry( + app: &PlotxApp, host: &Ui, + tab: TaskDockTab, preferred_min_body: f32, - preferred_width: f32, + collapsed: bool, ) -> TaskCardGeometry { - let host_rect = - crate::ui::workspace_geometry::board_rect(host.ctx()).unwrap_or_else(|| host.max_rect()); - let width = card_width(host_rect, preferred_width); - let pos = host_rect.right_top() + egui::vec2(-width - MARGIN, TOP_OFFSET); - let max_body_height = (host_rect.bottom() - pos.y - CHROME).max(FLOOR); + let host_rect = bounds(host); + let id = area_id(tab); + let preferred = preferred_size(app, tab); + let requested_width = if collapsed { + COLLAPSED_WIDTH.min(host_rect.width().max(1.0)) + } else { + card_width(host_rect, tab, preferred.width) + }; + let stored = host + .ctx() + .data(|data| data.get_temp::(id.with("layout"))); + let chrome = stored.map_or(CHROME, |layout| layout.chrome_height); + let extra_width = stored.map_or(0.0, |layout| layout.extra_width); + let preferred_body = preferred.body_height.max(preferred_min_body); + let desired_size = Vec2::new( + requested_width + extra_width, + if collapsed { + chrome + } else { + chrome + preferred_body + }, + ); + let initial = CardLayout { + rect: egui::Rect::from_min_size( + host_rect.right_top() + egui::vec2(-desired_size.x, TOP_OFFSET), + desired_size, + ), + bounds: host_rect, + horizontal: HorizontalAnchor::Right, + vertical: VerticalAnchor::Top, + chrome_height: chrome, + extra_width, + collapsed, + }; + let layout = fit_layout( + stored.unwrap_or(initial), + host_rect, + desired_size, + collapsed, + ); + host.ctx() + .data_mut(|data| data.insert_temp(id.with("layout"), layout)); TaskCardGeometry { - pos, - width, - min_body_height: preferred_min_body.min(max_body_height), - max_body_height, + pos: layout.rect.min, + width: (layout.rect.width() - layout.extra_width).max(1.0), + body_height: if collapsed { + 0.0 + } else { + (layout.rect.height() - layout.chrome_height).max(0.0) + }, } } -fn card_width(host: egui::Rect, preferred_width: f32) -> f32 { - preferred_width.min( - (host.width() - MIN_SAFE_EDGE - MARGIN * 3.0) - .max(MIN_CARD_WIDTH) - .min((host.width() - MARGIN * 2.0).max(1.0)), - ) +fn card_width(host: egui::Rect, tab: TaskDockTab, preferred_width: f32) -> f32 { + let (minimum, absolute_maximum) = width_limits(tab); + let physical_maximum = host.width().max(1.0); + let canvas_preserving_maximum = + (host.width() - MIN_CANVAS_WIDTH).max(minimum.min(physical_maximum)); + preferred_width + .clamp( + minimum.min(physical_maximum), + absolute_maximum.min(physical_maximum), + ) + .min(canvas_preserving_maximum) +} + +fn area_id(tab: TaskDockTab) -> Id { + match tab { + TaskDockTab::Processing => Id::new("processing_task_card"), + TaskDockTab::Regions => Id::new("region_task_card"), + TaskDockTab::CurveFit => Id::new("curve_fit_task_card"), + TaskDockTab::Statistics => Id::new("statistics_task_card"), + TaskDockTab::Craft => Id::new("craft_task_card"), + } +} + +fn bounds(host: &Ui) -> egui::Rect { + crate::ui::workspace_geometry::task_card_bounds(host.ctx()) + .unwrap_or_else(|| host.ctx().content_rect()) +} + +fn width_limits(tab: TaskDockTab) -> (f32, f32) { + if tab == TaskDockTab::Craft { + (CRAFT_MIN_WIDTH, CRAFT_MAX_WIDTH) + } else { + (STANDARD_MIN_WIDTH, STANDARD_MAX_WIDTH) + } +} + +fn preferred_size(app: &PlotxApp, tab: TaskDockTab) -> TaskCardSize { + let cards = &app.settings.window.task_cards; + match tab { + TaskDockTab::Processing => cards.processing, + TaskDockTab::Regions => cards.regions, + TaskDockTab::CurveFit => cards.curve_fit, + TaskDockTab::Statistics => cards.statistics, + TaskDockTab::Craft => cards.craft, + } +} + +fn preferred_size_mut(app: &mut PlotxApp, tab: TaskDockTab) -> &mut TaskCardSize { + let cards = &mut app.settings.window.task_cards; + match tab { + TaskDockTab::Processing => &mut cards.processing, + TaskDockTab::Regions => &mut cards.regions, + TaskDockTab::CurveFit => &mut cards.curve_fit, + TaskDockTab::Statistics => &mut cards.statistics, + TaskDockTab::Craft => &mut cards.craft, + } } pub(crate) fn visible_area_id(app: &PlotxApp) -> Option { @@ -106,96 +198,448 @@ pub(crate) fn visible_area_id(app: &PlotxApp) -> Option { } } -/// 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) +/// Whether the pointer is over the visible card or its resize grips. Canvas +/// gestures are dispatched before the card is rendered in the current frame, +/// so they must consult the previous authoritative area rectangle explicitly +/// instead of relying only on `layer_id_at`. +pub(crate) fn pointer_allows_canvas(app: &PlotxApp, ui: &Ui, pos: Pos2) -> bool { + let card_hit = visible_area_id(app) + .and_then(|id| ui.ctx().memory(|memory| memory.area_rect(id))) + .is_some_and(|rect| rect.expand(6.0).contains(pos)); + !card_hit + && ui + .ctx() + .layer_id_at(pos) + .is_none_or(|layer| layer == ui.layer_id()) +} + +#[derive(Clone, Copy)] +struct DragOrigin { + rect: egui::Rect, +} + +pub(super) fn area(_host: &Ui, id: Id, pos: Pos2) -> Area { + // Position and size are constrained together by the task-card gesture + // model. Area's own constraint pass uses its previous-frame size, which + // creates a visible one-frame correction whenever both change at once. + Area::new(id) .order(Order::Middle) .movable(false) - .constrain_to(bounds); - if let Some(stored) = stored { - area.current_pos(stored) - } else { - // `default_pos` only applies when egui first creates the Area. The - // central workspace can move when sidebars or workflow chrome change, - // so an untouched task card must be re-anchored to the current host on - // every frame. A user drag stores an explicit position above. - area.current_pos(pos) - } + .fixed_pos(pos) } -/// Renders a draggable title row. The drag zone is registered before its child -/// buttons so close, collapse and menu controls retain priority. -pub(super) fn header(ui: &mut Ui, area_id: Id, add_contents: impl FnOnce(&mut Ui) -> R) -> R { +/// Renders the task-card title as one move surface with a dedicated action +/// slot. Text responses participate in the same gesture while buttons remain +/// ordinary controls, so the visual and interactive title bars are identical. +pub(super) fn header( + ui: &mut Ui, + area_id: Id, + title: &str, + detail: Option>, + add_actions: impl FnOnce(&mut Ui) -> R, +) -> R { let (drag_rect, drag) = ui.allocate_exact_size( Vec2::new(ui.available_width(), ui.spacing().interact_size.y), Sense::drag(), ); let drag = drag.on_hover_cursor(CursorIcon::Grab); update_drag_position(ui, area_id, &drag); - let mut header = ui.new_child( + let mut actions = ui.new_child( UiBuilder::new() - .id_salt(area_id.with("title_contents")) + .id_salt(area_id.with("title_actions")) .max_rect(drag_rect) + .layout(Layout::right_to_left(Align::Center)), + ); + actions.set_clip_rect(drag_rect); + let result = add_actions(&mut actions); + let text_right = (actions.min_rect().left() - ui.spacing().item_spacing.x) + .clamp(drag_rect.left(), drag_rect.right()); + let text_rect = + egui::Rect::from_min_max(drag_rect.min, egui::pos2(text_right, drag_rect.max.y)); + let mut text = ui.new_child( + UiBuilder::new() + .id_salt(area_id.with("title_text")) + .max_rect(text_rect) .layout(Layout::left_to_right(Align::Center)), ); - add_contents(&mut header) + text.set_clip_rect(text_rect); + let title_drag = text + .label(crate::typography::headline(title)) + .interact(Sense::drag()) + .on_hover_cursor(CursorIcon::Grab); + update_drag_position(&text, area_id, &title_drag); + if let Some(detail) = detail { + let detail_drag = text + .label(detail.into().weak()) + .interact(Sense::drag()) + .on_hover_cursor(CursorIcon::Grab); + update_drag_position(&text, area_id, &detail_drag); + } + result } fn update_drag_position(ui: &Ui, area_id: Id, drag: &egui::Response) { let origin_id = area_id.with("drag_origin"); - let position_id = area_id.with("position"); + let current_rect = ui + .ctx() + .data(|data| data.get_temp::(area_id.with("layout"))) + .map(|layout| layout.rect) + .or_else(|| ui.ctx().memory(|memory| memory.area_rect(area_id))); if drag.drag_started() - && let Some(origin) = ui - .ctx() - .memory(|memory| memory.area_rect(area_id).map(|rect| rect.min)) + && let Some(rect) = current_rect { ui.ctx() - .data_mut(|data| data.insert_temp(origin_id, origin)); + .data_mut(|data| data.insert_temp(origin_id, DragOrigin { rect })); } if drag.dragged() - && let Some(origin) = ui.ctx().data(|data| data.get_temp::(origin_id)) + && let Some(origin) = ui.ctx().data(|data| data.get_temp::(origin_id)) && let Some(delta) = drag.total_drag_delta() { - ui.ctx() - .data_mut(|data| data.insert_temp(position_id, origin + delta)); + let bounds = bounds(ui); + let desired_min = origin.rect.min + delta; + let pos = egui::pos2( + desired_min.x.clamp( + bounds.left(), + (bounds.right() - origin.rect.width()).max(bounds.left()), + ), + desired_min.y.clamp( + bounds.top(), + (bounds.bottom() - origin.rect.height()).max(bounds.top()), + ), + ); + let rect = egui::Rect::from_min_size(pos, origin.rect.size().min(bounds.size())); + ui.ctx().data_mut(|data| { + let mut layout = data + .get_temp::(area_id.with("layout")) + .unwrap_or(CardLayout { + rect: origin.rect, + bounds, + horizontal: HorizontalAnchor::Right, + vertical: VerticalAnchor::Top, + chrome_height: CHROME, + extra_width: 0.0, + collapsed: false, + }); + layout.rect = rect; + layout.bounds = bounds; + layout.horizontal = if rect.left() - bounds.left() <= bounds.right() - rect.right() { + HorizontalAnchor::Left + } else { + HorizontalAnchor::Right + }; + layout.vertical = if rect.top() - bounds.top() <= bounds.bottom() - rect.bottom() { + VerticalAnchor::Top + } else { + VerticalAnchor::Bottom + }; + data.insert_temp(area_id.with("layout"), layout); + }); ui.ctx().request_repaint(); } if drag.drag_stopped() { - ui.ctx().data_mut(|data| data.remove::(origin_id)); + ui.ctx() + .data_mut(|data| data.remove::(origin_id)); } } -/// Renders the vertically resizable body shared by every task card. -/// -/// `egui::Resize` normally collapses a non-resizable axis to its content size. -/// Keeping the content UI at the card width aligns its handle with the card. -pub(super) fn resizable_body( +/// Gives the card body the height resolved by the shared whole-card geometry. +pub(super) fn sized_body( ui: &mut Ui, - id_salt: impl Hash, - default_height: f32, - min_height: f32, - max_height: f32, + height: f32, add_contents: impl FnOnce(&mut Ui) -> R, ) -> R { - let width = ui.available_width(); - egui::Resize::default() - .id_salt(id_salt) - .default_size([width, default_height]) - .min_size([width, min_height]) - .max_size([width, max_height]) - .resizable([false, true]) - .with_stroke(false) - .show(ui, |ui| { - ui.set_min_width(width); - add_contents(ui) - }) + let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover()); + let mut body = ui.new_child( + UiBuilder::new() + .id_salt("task_card_body") + .max_rect(rect) + .layout(*ui.layout()), + ); + add_contents(&mut body) +} + +#[derive(Clone, Copy)] +struct ResizeOrigin { + rect: egui::Rect, + chrome_height: f32, +} + +#[derive(Clone, Copy)] +pub(super) struct ResizeEdges { + pub(super) left: bool, + pub(super) right: bool, + pub(super) top: bool, + pub(super) bottom: bool, +} + +/// Adds resize handles on every edge and corner of the whole card. The result +/// is constrained before it is stored, so pulling beyond an edge cannot move +/// the opposite edge or create a growing sidebar gap. +pub(super) fn resize_handles( + app: &mut PlotxApp, + ui: &mut Ui, + area_id: Id, + tab: TaskDockTab, + requested_width: f32, + body_height: f32, +) { + // At this point the card has been laid out in the current pass. Reading + // Area memory here returns the previous pass's rectangle, which is exactly + // the wrong origin when a viewport change and a resize happen together. + let rect = ui.min_rect(); + let chrome_height = (rect.height() - body_height).max(0.0); + let extra_width = (rect.width() - requested_width).max(0.0); + ui.ctx().data_mut(|data| { + if let Some(mut layout) = data.get_temp::(area_id.with("layout")) { + // Measurement refines content-to-frame metrics only. Gesture and + // workspace fitting are the sole writers of the authoritative + // rectangle; copying the just-rendered rectangle here would + // overwrite a title drag performed earlier in the same pass. + layout.chrome_height = chrome_height; + layout.extra_width = extra_width; + data.insert_temp(area_id.with("layout"), layout); + } + }); + let grip = 5.0; + let corner = 12.0; + let handles = [ + ( + egui::Rect::from_min_max( + egui::pos2(rect.left() - grip, rect.top() - grip), + egui::pos2(rect.left() + corner, rect.top() + corner), + ), + "resize_north_west", + CursorIcon::ResizeNorthWest, + ResizeEdges { + left: true, + right: false, + top: true, + bottom: false, + }, + ), + ( + egui::Rect::from_min_max( + egui::pos2(rect.right() - corner, rect.top() - grip), + egui::pos2(rect.right() + grip, rect.top() + corner), + ), + "resize_north_east", + CursorIcon::ResizeNorthEast, + ResizeEdges { + left: false, + right: true, + top: true, + bottom: false, + }, + ), + ( + egui::Rect::from_min_max( + egui::pos2(rect.left() - grip, rect.bottom() - corner), + egui::pos2(rect.left() + corner, rect.bottom() + grip), + ), + "resize_south_west", + CursorIcon::ResizeSouthWest, + ResizeEdges { + left: true, + right: false, + top: false, + bottom: true, + }, + ), + ( + egui::Rect::from_min_max( + egui::pos2(rect.right() - corner, rect.bottom() - corner), + egui::pos2(rect.right() + grip, rect.bottom() + grip), + ), + "resize_south_east", + CursorIcon::ResizeSouthEast, + ResizeEdges { + left: false, + right: true, + top: false, + bottom: true, + }, + ), + ( + egui::Rect::from_min_max( + egui::pos2(rect.left() - grip, rect.top() + corner), + egui::pos2(rect.left() + grip, rect.bottom() - corner), + ), + "resize_west", + CursorIcon::ResizeWest, + ResizeEdges { + left: true, + right: false, + top: false, + bottom: false, + }, + ), + ( + egui::Rect::from_min_max( + egui::pos2(rect.right() - grip, rect.top() + corner), + egui::pos2(rect.right() + grip, rect.bottom() - corner), + ), + "resize_east", + CursorIcon::ResizeEast, + ResizeEdges { + left: false, + right: true, + top: false, + bottom: false, + }, + ), + ( + egui::Rect::from_min_max( + egui::pos2(rect.left() + corner, rect.top() - grip), + egui::pos2(rect.right() - corner, rect.top() + grip), + ), + "resize_north", + CursorIcon::ResizeNorth, + ResizeEdges { + left: false, + right: false, + top: true, + bottom: false, + }, + ), + ( + egui::Rect::from_min_max( + egui::pos2(rect.left() + corner, rect.bottom() - grip), + egui::pos2(rect.right() - corner, rect.bottom() + grip), + ), + "resize_south", + CursorIcon::ResizeSouth, + ResizeEdges { + left: false, + right: false, + top: false, + bottom: true, + }, + ), + ]; + let responses = handles.map(|(rect, salt, cursor, edges)| { + ( + ui.interact(rect, area_id.with(salt), Sense::drag()) + .on_hover_cursor(cursor), + edges, + rect, + ) + }); + for (response, edges, hit_rect) in &responses { + super::task_card_resize::paint_feedback(ui, rect, *hit_rect, *edges, response); + } + let origin_id = area_id.with("resize_origin"); + let active = responses.iter().find(|(response, _, _)| { + response.drag_started() || response.dragged() || response.drag_stopped() + }); + let Some((response, edges, _)) = active else { + return; + }; + if response.drag_started() { + ui.ctx().data_mut(|data| { + data.insert_temp( + origin_id, + ResizeOrigin { + rect, + chrome_height, + }, + ) + }); + } + if response.dragged() + && let Some(origin) = ui + .ctx() + .data(|data| data.get_temp::(origin_id)) + && let Some(delta) = response.total_drag_delta() + { + let (minimum, maximum) = width_limits(tab); + let bounds = bounds(ui); + // Geometry and interaction must use the same effective maximum. The + // canvas-preserving cap can be lower than the product-level maximum; + // storing the larger width made the next layout shrink without moving + // the left edge, which appeared as a growing right-hand gap. + let effective_maximum = card_width(bounds, tab, maximum); + let resized = resized_rect( + origin, + *edges, + delta, + bounds, + minimum + extra_width, + effective_maximum + extra_width, + ); + *preferred_size_mut(app, tab) = TaskCardSize::new( + (resized.width() - extra_width).max(1.0), + (resized.height() - origin.chrome_height).max(1.0), + ); + ui.ctx().data_mut(|data| { + if let Some(mut layout) = data.get_temp::(area_id.with("layout")) { + layout.rect = resized; + layout.bounds = bounds; + if edges.left { + layout.horizontal = HorizontalAnchor::Right; + } else if edges.right { + layout.horizontal = HorizontalAnchor::Left; + } + if edges.top { + layout.vertical = VerticalAnchor::Bottom; + } else if edges.bottom { + layout.vertical = VerticalAnchor::Top; + } + data.insert_temp(area_id.with("layout"), layout); + } + }); + ui.ctx().request_repaint(); + } + if response.drag_stopped() { + ui.ctx() + .data_mut(|data| data.remove::(origin_id)); + app.persist_settings(); + } +} + +fn resized_rect( + origin: ResizeOrigin, + edges: ResizeEdges, + delta: Vec2, + bounds: egui::Rect, + minimum_width: f32, + maximum_width: f32, +) -> egui::Rect { + let minimum_width = minimum_width.min(bounds.width()); + let maximum_width = maximum_width.min(bounds.width()); + let minimum_height = (origin.chrome_height + FLOOR).min(bounds.height()); + let mut resized = origin.rect; + if edges.left { + resized.min.x = (origin.rect.left() + delta.x).clamp( + bounds.left(), + (origin.rect.right() - minimum_width).max(bounds.left()), + ); + } + if edges.right { + resized.max.x = (origin.rect.right() + delta.x).clamp( + (origin.rect.left() + minimum_width).min(bounds.right()), + bounds.right(), + ); + } + if resized.width() > maximum_width { + if edges.left { + resized.min.x = resized.right() - maximum_width; + } else { + resized.max.x = resized.left() + maximum_width; + } + } + if edges.top { + resized.min.y = (origin.rect.top() + delta.y).clamp( + bounds.top(), + (origin.rect.bottom() - minimum_height).max(bounds.top()), + ); + } + if edges.bottom { + resized.max.y = (origin.rect.bottom() + delta.y).clamp( + (origin.rect.top() + minimum_height).min(bounds.bottom()), + bounds.bottom(), + ); + } + resized } pub(super) fn is_active(app: &PlotxApp, tab: TaskDockTab) -> bool { @@ -254,22 +698,60 @@ pub(super) fn tab_bar(app: &mut PlotxApp, current: TaskDockTab, ui: &mut Ui) -> if open.len() < 2 { return false; } - let mut selected = None; - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing.x = 3.0; - for (tab, glyph, label, dataset) in &open { - if ui - .selectable_label( - *tab == current, - RichText::new(format!("{glyph} {label}")).small(), - ) - .clicked() - { - selected = Some((*tab, *dataset)); + let mut action = None; + ui.scope(|ui| { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (tab, glyph, label, dataset) in &open { + let selected = *tab == current; + let (fill, stroke) = if selected { + ( + ui.visuals().selection.bg_fill, + ui.visuals().selection.stroke, + ) + } else { + ( + ui.visuals().widgets.inactive.weak_bg_fill, + ui.visuals().widgets.inactive.bg_stroke, + ) + }; + egui::Frame::NONE + .fill(fill) + .stroke(stroke) + .corner_radius(ui.visuals().widgets.inactive.corner_radius) + .inner_margin(egui::Margin::symmetric(6, 2)) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.x = 2.0; + ui.horizontal(|ui| { + if ui + .add( + egui::Button::selectable( + selected, + RichText::new(format!("{glyph} {label}")).small(), + ) + .frame(false), + ) + .clicked() + { + action = Some((false, *tab, *dataset)); + } + if ui + .add(egui::Button::new(RichText::new(icon::X).small()).frame(false)) + .on_hover_text(format!("Close {label}")) + .clicked() + { + action = Some((true, *tab, *dataset)); + } + }); + }); } - } + }); }); - if let Some((tab, dataset)) = selected { + if let Some((close, tab, dataset)) = action { + if close { + app.session.ui.close_task_tab(tab); + return true; + } if app.session.ui.task_dock_active == Some(TaskDockTab::Regions) && tab != TaskDockTab::Regions && app.session.tool == Tool::Regions @@ -285,193 +767,5 @@ pub(super) fn tab_bar(app: &mut PlotxApp, current: TaskDockTab, ui: &mut Ui) -> } #[cfg(test)] -mod tests { - use super::*; - use plotx_core::state::{CanvasDocument, Dataset, NmrDataset}; - use plotx_io::{Domain, NmrData}; - use std::cell::Cell; - - fn app_with_task(tab: TaskDockTab, collapsed: bool) -> PlotxApp { - let mut app = PlotxApp::new(); - let data = NmrData { - points: vec![num_complex::Complex64::new(0.0, 0.0); 2], - domain: Domain::Time, - spectral_width_hz: 1.0, - observe_freq_mhz: 1.0, - carrier_ppm: 0.0, - nucleus: "1H".into(), - source: "test".into(), - group_delay: 0.0, - }; - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(data)))); - app.doc - .canvases - .push(CanvasDocument::new("p".into(), [100.0, 80.0])); - app.focus_single(0); - app.session.ui.task_dock_active = Some(tab); - let id = app.doc.datasets[0].resource_id(); - match tab { - TaskDockTab::Processing => { - app.session.ui.processing_task_dataset = Some(id); - app.session.ui.processing_task_collapsed = collapsed; - } - TaskDockTab::Regions => { - app.session.ui.region_task_dataset = Some(id); - app.session.ui.region_task_collapsed = collapsed; - } - _ => unreachable!(), - } - app - } - - #[test] - 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); - - 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 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 _ = 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))); - }, - ); - - 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 narrow_boards_keep_a_valid_card_width() { - let board = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(250.0, 140.0)); - - assert!(card_width(board, WIDTH) < WIDTH); - assert!(card_width(board, WIDTH) >= MIN_CARD_WIDTH); - } - - #[test] - fn title_drag_moves_the_shared_task_card() { - let ctx = egui::Context::default(); - let screen = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(640.0, 480.0)); - let area_id = Id::new("test_task_card"); - let start = Pos2::new(100.0, 80.0); - - let frame = |events| { - let input = egui::RawInput { - screen_rect: Some(screen), - events, - ..Default::default() - }; - let _ = ctx.run_ui(input, |ui| { - egui::CentralPanel::default().show_inside(ui, |ui| { - area(ui, area_id, start).show(ui.ctx(), |ui| { - ui.set_width(WIDTH); - header(ui, area_id, |ui| { - ui.label("Processing"); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - let _ = ui.button("Close"); - }); - }); - ui.label("Body"); - }); - }); - }); - }; - - frame(Vec::new()); - frame(Vec::new()); - let initial = ctx - .memory(|memory| memory.area_rect(area_id)) - .expect("laid-out task card"); - let pointer_start = initial.min + egui::vec2(80.0, 12.0); - let pointer_end = pointer_start + egui::vec2(120.0, 100.0); - frame(vec![egui::Event::PointerMoved(pointer_start)]); - frame(vec![ - egui::Event::PointerMoved(pointer_start), - egui::Event::PointerButton { - pos: pointer_start, - button: egui::PointerButton::Primary, - pressed: true, - modifiers: egui::Modifiers::default(), - }, - ]); - frame(vec![egui::Event::PointerMoved(pointer_end)]); - frame(vec![egui::Event::PointerButton { - pos: pointer_end, - button: egui::PointerButton::Primary, - pressed: false, - modifiers: egui::Modifiers::default(), - }]); - - let moved = ctx - .memory(|memory| memory.area_rect(area_id)) - .expect("task card area"); - assert_eq!(moved.min, initial.min + (pointer_end - pointer_start)); - } - - #[test] - fn resize_handle_stays_at_the_task_card_right_edge() { - let ctx = egui::Context::default(); - let screen = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(640.0, 480.0)); - let expected_right = Cell::new(0.0); - let actual_right = Cell::new(None); - - let frame = || { - let _ = ctx.run_ui( - egui::RawInput { - screen_rect: Some(screen), - ..Default::default() - }, - |ui| { - egui::CentralPanel::default().show_inside(ui, |ui| { - Area::new(Id::new("resize_test_card")) - .fixed_pos(Pos2::new(100.0, 80.0)) - .show(ui.ctx(), |ui| { - ui.set_width(WIDTH); - let body_id = Id::new("resize_test_body"); - let corner_id = ui - .make_persistent_id(Id::new(body_id)) - .with("__resize_corner"); - expected_right - .set(ui.next_widget_position().x + ui.available_width()); - resizable_body(ui, body_id, 200.0, 120.0, 300.0, |ui| { - ui.label("Short content"); - }); - actual_right.set( - ui.ctx() - .read_response(corner_id) - .map(|response| response.rect.right()), - ); - }); - }); - }, - ); - }; - - frame(); - frame(); - assert_eq!(actual_right.get(), Some(expected_right.get())); - } -} +#[path = "task_card_tests.rs"] +mod tests; diff --git a/crates/app/src/ui/tools/task_card_layout.rs b/crates/app/src/ui/tools/task_card_layout.rs new file mode 100644 index 0000000..74d13ac --- /dev/null +++ b/crates/app/src/ui/tools/task_card_layout.rs @@ -0,0 +1,66 @@ +use egui::{Rect, Vec2}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum HorizontalAnchor { + Left, + Right, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum VerticalAnchor { + Top, + Bottom, +} + +#[derive(Clone, Copy, Debug)] +pub(super) struct CardLayout { + pub rect: Rect, + pub bounds: Rect, + pub horizontal: HorizontalAnchor, + pub vertical: VerticalAnchor, + pub chrome_height: f32, + pub extra_width: f32, + 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. +pub(super) fn fit_layout( + mut layout: CardLayout, + bounds: Rect, + desired_size: Vec2, + collapsed: bool, +) -> CardLayout { + let rect = match layout.horizontal { + HorizontalAnchor::Left => { + let left = (layout.rect.left() + bounds.left() - layout.bounds.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()) + } + HorizontalAnchor::Right => { + let right = (layout.rect.right() + bounds.right() - layout.bounds.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()) + } + }; + layout.rect = match layout.vertical { + VerticalAnchor::Top => { + let top = (rect.top() + bounds.top() - layout.bounds.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) + } + VerticalAnchor::Bottom => { + let bottom = (rect.bottom() + bounds.bottom() - layout.bounds.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) + } + }; + layout.bounds = bounds; + layout.collapsed = collapsed; + layout +} diff --git a/crates/app/src/ui/tools/task_card_resize.rs b/crates/app/src/ui/tools/task_card_resize.rs new file mode 100644 index 0000000..ff67702 --- /dev/null +++ b/crates/app/src/ui/tools/task_card_resize.rs @@ -0,0 +1,81 @@ +use egui::{Color32, Mesh, Pos2, Ui}; + +use super::task_card::ResizeEdges; + +const HIGHLIGHT_RADIUS: f32 = 90.0; +const HIGHLIGHT_STEP: f32 = 6.0; + +pub(super) fn paint_feedback( + ui: &Ui, + card: egui::Rect, + hit_rect: egui::Rect, + edges: ResizeEdges, + response: &egui::Response, +) { + if !response.hovered() && !response.dragged() { + return; + } + let pointer = if response.dragged() { + response + .interact_pointer_pos() + .or_else(|| ui.ctx().pointer_interact_pos()) + } else { + ui.ctx().pointer_hover_pos() + }; + let Some(pointer) = pointer.filter(|point| response.dragged() || hit_rect.contains(*point)) + else { + return; + }; + let color = if ui.visuals().dark_mode { + Color32::from_gray(if response.dragged() { 112 } else { 88 }) + } else { + Color32::from_gray(if response.dragged() { 145 } else { 174 }) + }; + if edges.left { + paint_line(ui, card.left(), pointer.y, card.y_range(), true, color); + } + if edges.right { + paint_line(ui, card.right(), pointer.y, card.y_range(), true, color); + } + if edges.top { + paint_line(ui, card.top(), pointer.x, card.x_range(), false, color); + } + if edges.bottom { + paint_line(ui, card.bottom(), pointer.x, card.x_range(), false, color); + } +} + +fn paint_line( + ui: &Ui, + fixed: f32, + pointer: f32, + range: egui::Rangef, + vertical: bool, + color: Color32, +) { + let min = range.min.max(pointer - HIGHLIGHT_RADIUS); + let max = range.max.min(pointer + HIGHLIGHT_RADIUS); + if min >= max { + return; + } + let segments = ((max - min) / HIGHLIGHT_STEP).ceil().max(1.0) as usize; + let mut mesh = Mesh::default(); + for index in 0..=segments { + let along = egui::lerp(min..=max, index as f32 / segments as f32); + let t = ((along - pointer).abs() / HIGHLIGHT_RADIUS).clamp(0.0, 1.0); + let faded = color.linear_multiply(1.0 - t * t * (3.0 - 2.0 * t)); + let vertex = mesh.vertices.len() as u32; + let (a, b) = if vertical { + (Pos2::new(fixed - 0.5, along), Pos2::new(fixed + 0.5, along)) + } else { + (Pos2::new(along, fixed - 0.5), Pos2::new(along, fixed + 0.5)) + }; + mesh.colored_vertex(a, faded); + mesh.colored_vertex(b, faded); + if index > 0 { + mesh.add_triangle(vertex - 2, vertex - 1, vertex); + mesh.add_triangle(vertex, vertex - 1, vertex + 1); + } + } + ui.painter().add(mesh); +} diff --git a/crates/app/src/ui/tools/task_card_tests.rs b/crates/app/src/ui/tools/task_card_tests.rs new file mode 100644 index 0000000..c6b6e88 --- /dev/null +++ b/crates/app/src/ui/tools/task_card_tests.rs @@ -0,0 +1,452 @@ +use super::*; +use plotx_core::state::{CanvasDocument, Dataset, NmrDataset}; +use plotx_io::{Domain, NmrData}; +use std::cell::Cell; + +fn app_with_task(tab: TaskDockTab, collapsed: bool) -> PlotxApp { + let mut app = PlotxApp::new(); + let data = NmrData { + points: vec![num_complex::Complex64::new(0.0, 0.0); 2], + domain: Domain::Time, + spectral_width_hz: 1.0, + observe_freq_mhz: 1.0, + carrier_ppm: 0.0, + nucleus: "1H".into(), + source: "test".into(), + group_delay: 0.0, + }; + app.doc + .datasets + .push(Dataset::Nmr(Box::new(NmrDataset::load(data)))); + app.doc + .canvases + .push(CanvasDocument::new("p".into(), [100.0, 80.0])); + app.focus_single(0); + app.session.ui.task_dock_active = Some(tab); + let id = app.doc.datasets[0].resource_id(); + match tab { + TaskDockTab::Processing => { + app.session.ui.processing_task_dataset = Some(id); + app.session.ui.processing_task_collapsed = collapsed; + } + TaskDockTab::Regions => { + app.session.ui.region_task_dataset = Some(id); + app.session.ui.region_task_collapsed = collapsed; + } + _ => unreachable!(), + } + app +} + +#[test] +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); + + 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 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 _ = 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( + &app, + ui, + TaskDockTab::Processing, + 200.0, + false, + ))); + }, + ); + + let card = observed.take().expect("task-card geometry"); + assert!(card.pos.x >= board.left()); + assert_eq!(card.pos.x + card.width, board.right()); + assert!(card.pos.y >= board.top()); +} + +#[test] +fn narrow_boards_keep_a_valid_card_width() { + let board = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(250.0, 140.0)); + + let width = card_width(board, TaskDockTab::Processing, 340.0); + assert!(width < 340.0); + assert!(width > 0.0); +} + +#[test] +fn title_drag_moves_the_shared_task_card() { + let ctx = egui::Context::default(); + let mut fonts = egui::FontDefinitions::default(); + let emphasized = fonts + .families + .get(&egui::FontFamily::Proportional) + .cloned() + .expect("default proportional fonts"); + 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(640.0, 480.0)); + let area_id = Id::new("test_task_card"); + let start = Pos2::new(100.0, 80.0); + let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + + let mut frame = |events| { + let input = egui::RawInput { + screen_rect: Some(screen), + events, + ..Default::default() + }; + let _ = ctx.run_ui(input, |ui| { + egui::CentralPanel::default().show_inside(ui, |ui| { + let pos = ui + .ctx() + .data(|data| data.get_temp::(area_id.with("layout"))) + .map_or(start, |layout| layout.rect.min); + area(ui, area_id, pos).show(ui.ctx(), |ui| { + ui.set_width(COLLAPSED_WIDTH); + header(ui, area_id, "Processing", None::<&str>, |ui| { + let _ = ui.button("Close"); + }); + sized_body(ui, 120.0, |ui| { + ui.label("Body"); + }); + resize_handles( + &mut app, + ui, + area_id, + TaskDockTab::Processing, + COLLAPSED_WIDTH, + 120.0, + ); + }); + }); + }); + }; + + frame(Vec::new()); + frame(Vec::new()); + let initial = ctx + .memory(|memory| memory.area_rect(area_id)) + .expect("laid-out task card"); + // Start directly over the rendered title text, not merely empty chrome. + let pointer_start = initial.min + egui::vec2(20.0, 12.0); + let pointer_end = pointer_start + egui::vec2(120.0, 100.0); + frame(vec![egui::Event::PointerMoved(pointer_start)]); + let before_drag = ctx + .memory(|memory| memory.area_rect(area_id)) + .expect("hovered task card"); + frame(vec![ + egui::Event::PointerMoved(pointer_start), + egui::Event::PointerButton { + pos: pointer_start, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::default(), + }, + ]); + frame(vec![egui::Event::PointerMoved(pointer_end)]); + frame(vec![egui::Event::PointerButton { + pos: pointer_end, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::default(), + }]); + + let moved = ctx + .memory(|memory| memory.area_rect(area_id)) + .expect("task card area"); + assert_eq!(moved.min, before_drag.min + (pointer_end - pointer_start)); +} + +#[test] +fn collapsed_cards_use_the_compact_width_without_overwriting_the_preference() { + let app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + let board = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1_000.0, 700.0)); + + assert_eq!(COLLAPSED_WIDTH.min(board.width()), 310.0); + assert_eq!(preferred_size(&app, TaskDockTab::Craft).width, 520.0); +} + +#[test] +fn resize_clamps_every_edge_without_moving_the_opposite_edge() { + let bounds = egui::Rect::from_min_max(egui::pos2(20.0, 30.0), egui::pos2(780.0, 690.0)); + let origin = ResizeOrigin { + rect: egui::Rect::from_min_max(egui::pos2(400.0, 80.0), egui::pos2(760.0, 600.0)), + chrome_height: 72.0, + }; + let west = resized_rect( + origin, + ResizeEdges { + left: true, + right: false, + top: false, + bottom: false, + }, + Vec2::new(-1_000.0, 0.0), + bounds, + 300.0, + 520.0, + ); + assert_eq!(west.left(), origin.rect.right() - 520.0); + assert_eq!(west.right(), origin.rect.right()); + + let south_east = resized_rect( + origin, + ResizeEdges { + left: false, + right: true, + top: false, + bottom: true, + }, + Vec2::splat(1_000.0), + bounds, + 300.0, + 520.0, + ); + assert_eq!(south_east.right(), bounds.right()); + assert_eq!(south_east.bottom(), bounds.bottom()); + assert_eq!(south_east.min, origin.rect.min); +} + +#[test] +fn maximum_vertical_resize_aligns_with_the_sidebar_bottom() { + let bounds = egui::Rect::from_min_max(egui::pos2(20.0, 30.0), egui::pos2(780.0, 690.0)); + let origin = ResizeOrigin { + rect: egui::Rect::from_min_max(egui::pos2(400.0, 80.0), egui::pos2(760.0, 500.0)), + chrome_height: 72.0, + }; + let resized = resized_rect( + origin, + ResizeEdges { + left: false, + right: false, + top: false, + bottom: true, + }, + Vec2::new(0.0, 1_000.0), + bounds, + 300.0, + 520.0, + ); + + assert_eq!(resized.bottom(), bounds.bottom()); + assert_eq!(resized.top(), origin.rect.top()); + + let further = resized_rect( + origin, + ResizeEdges { + left: false, + right: false, + top: false, + bottom: true, + }, + Vec2::new(0.0, 2_000.0), + bounds, + 300.0, + 520.0, + ); + assert_eq!(further, resized); +} + +#[test] +fn shrinking_from_the_left_keeps_the_right_edge_fixed() { + let bounds = egui::Rect::from_min_max(egui::pos2(20.0, 30.0), egui::pos2(780.0, 690.0)); + let origin = ResizeOrigin { + rect: egui::Rect::from_min_max(egui::pos2(240.0, 80.0), egui::pos2(760.0, 600.0)), + chrome_height: 72.0, + }; + let resized = resized_rect( + origin, + ResizeEdges { + left: true, + right: false, + top: false, + bottom: false, + }, + Vec2::new(140.0, 0.0), + bounds, + 300.0, + 520.0, + ); + + assert_eq!(resized.left(), origin.rect.left() + 140.0); + assert_eq!(resized.right(), origin.rect.right()); +} + +fn layout(rect: egui::Rect, bounds: egui::Rect) -> CardLayout { + CardLayout { + rect, + bounds, + horizontal: HorizontalAnchor::Right, + vertical: VerticalAnchor::Top, + chrome_height: 70.0, + extra_width: 0.0, + collapsed: false, + } +} + +#[test] +fn right_anchored_width_change_is_atomic() { + let bounds = egui::Rect::from_min_max(Pos2::ZERO, egui::pos2(900.0, 700.0)); + let original = layout( + egui::Rect::from_min_max(egui::pos2(380.0, 40.0), egui::pos2(880.0, 600.0)), + bounds, + ); + + let narrower = fit_layout(original, bounds, Vec2::new(360.0, 560.0), false); + + assert_eq!(narrower.rect.right(), original.rect.right()); + assert_eq!(narrower.rect.left(), original.rect.right() - 360.0); +} + +#[test] +fn bottom_right_resize_keeps_the_top_left_corner() { + let bounds = egui::Rect::from_min_max(Pos2::ZERO, egui::pos2(900.0, 700.0)); + let mut original = layout( + egui::Rect::from_min_max(egui::pos2(240.0, 40.0), egui::pos2(600.0, 500.0)), + bounds, + ); + original.horizontal = HorizontalAnchor::Left; + + let larger = fit_layout(original, bounds, Vec2::new(500.0, 620.0), false); + + assert_eq!(larger.rect.min, original.rect.min); +} + +#[test] +fn shrinking_viewport_preserves_top_anchor_without_overflow() { + let bounds = egui::Rect::from_min_max(Pos2::ZERO, egui::pos2(900.0, 700.0)); + let original = layout( + egui::Rect::from_min_max(egui::pos2(380.0, 40.0), egui::pos2(880.0, 700.0)), + bounds, + ); + let shorter = egui::Rect::from_min_max(Pos2::ZERO, egui::pos2(900.0, 480.0)); + + let fitted = fit_layout(original, shorter, Vec2::new(500.0, 660.0), false); + + assert_eq!(fitted.rect.top(), original.rect.top()); + assert_eq!(fitted.rect.bottom(), shorter.bottom()); +} + +#[test] +fn left_anchored_card_follows_primary_sidebar_boundary() { + let bounds = egui::Rect::from_min_max(egui::pos2(200.0, 0.0), egui::pos2(900.0, 700.0)); + let mut original = layout( + egui::Rect::from_min_max(egui::pos2(200.0, 40.0), egui::pos2(600.0, 600.0)), + bounds, + ); + original.horizontal = HorizontalAnchor::Left; + let sidebar_wider = egui::Rect::from_min_max(egui::pos2(320.0, 0.0), egui::pos2(900.0, 700.0)); + + let fitted = fit_layout(original, sidebar_wider, Vec2::new(400.0, 560.0), false); + + assert_eq!(fitted.rect.left(), sidebar_wider.left()); + assert!(fitted.rect.right() <= sidebar_wider.right()); +} + +#[test] +fn right_anchored_card_follows_secondary_sidebar_visibility() { + let without_sidebar = + egui::Rect::from_min_max(egui::pos2(200.0, 0.0), egui::pos2(1196.0, 700.0)); + let original = layout( + egui::Rect::from_min_max(egui::pos2(696.0, 40.0), egui::pos2(1196.0, 600.0)), + without_sidebar, + ); + let with_sidebar = egui::Rect::from_min_max(egui::pos2(200.0, 0.0), egui::pos2(892.0, 700.0)); + + let fitted = fit_layout(original, with_sidebar, Vec2::new(500.0, 560.0), false); + + assert_eq!(fitted.rect.right(), with_sidebar.right()); + assert!(with_sidebar.contains(fitted.rect.min)); + assert!(with_sidebar.contains(fitted.rect.max)); +} + +#[test] +fn rendered_card_and_title_actions_stay_inside_the_workspace() { + 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, Vec2::new(1_000.0, 700.0)); + let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + let action_rect = Cell::new(None); + let mut frame = |board: egui::Rect| { + let _ = ctx.run_ui( + egui::RawInput { + screen_rect: Some(screen), + ..Default::default() + }, + |ui| { + crate::ui::workspace_geometry::resolve(&app, board, ui.ctx()); + let card = geometry(&app, ui, TaskDockTab::Processing, 120.0, false); + let id = area_id(TaskDockTab::Processing); + area(ui, id, card.pos).show(ui.ctx(), |ui| { + ui.set_width(card.width); + crate::ui::card_frame(false, egui::Margin::ZERO).show(ui, |ui| { + header( + ui, + id, + "Processing", + Some("A deliberately long status that must yield to actions"), + |ui| action_rect.set(Some(ui.small_button("Close").rect)), + ); + sized_body(ui, card.body_height, |ui| { + ui.label("Body"); + }); + }); + resize_handles( + &mut app, + ui, + id, + TaskDockTab::Processing, + card.width, + card.body_height, + ); + }); + }, + ); + }; + + let wide = egui::Rect::from_min_max(egui::pos2(100.0, 60.0), egui::pos2(996.0, 680.0)); + frame(wide); + frame(wide); + let wide_card = ctx + .memory(|memory| memory.area_rect(area_id(TaskDockTab::Processing))) + .expect("right-anchored task card"); + assert_eq!(wide_card.right(), wide.right()); + let narrow = egui::Rect::from_min_max(egui::pos2(100.0, 60.0), egui::pos2(692.0, 680.0)); + frame(narrow); + frame(narrow); + + let card = ctx + .memory(|memory| memory.area_rect(area_id(TaskDockTab::Processing))) + .expect("rendered task card"); + let action = action_rect.get().expect("title action"); + assert_eq!(card.right(), narrow.right()); + assert!(action.right() <= card.right()); +} diff --git a/crates/app/src/ui/workspace_geometry.rs b/crates/app/src/ui/workspace_geometry.rs index 6c90950..ebc1131 100644 --- a/crates/app/src/ui/workspace_geometry.rs +++ b/crates/app/src/ui/workspace_geometry.rs @@ -18,6 +18,10 @@ struct SidebarRects { #[derive(Clone, Debug, PartialEq)] pub(crate) struct WorkspaceGeometry { pub board_rect: Rect, + /// Bounds available to floating task cards. Horizontal bounds preserve the + /// sidebar gaps; the bottom follows the persistent sidebars rather than a + /// shorter canvas child so a maximized card aligns with their edge. + pub task_card_bounds: Rect, pub fit_occluders: Vec, pub revision: u64, } @@ -70,6 +74,10 @@ pub(super) fn resolve(app: &PlotxApp, host_rect: Rect, ctx: &egui::Context) -> W if board_rect.min.x >= board_rect.max.x { board_rect = host_rect; } + let mut task_card_bounds = board_rect; + if let Some(sidebar) = sidebars.secondary.or(sidebars.primary) { + task_card_bounds.max.y = sidebar.bottom(); + } 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)) @@ -78,14 +86,17 @@ pub(super) fn resolve(app: &PlotxApp, host_rect: Rect, ctx: &egui::Context) -> W .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 changed = previous.as_ref().is_none_or(|old| { + old.board_rect != board_rect + || old.task_card_bounds != task_card_bounds + || 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, + task_card_bounds, fit_occluders, revision, }; @@ -93,10 +104,10 @@ pub(super) fn resolve(app: &PlotxApp, host_rect: Rect, ctx: &egui::Context) -> W geometry } -pub(crate) fn board_rect(ctx: &egui::Context) -> Option { +pub(crate) fn task_card_bounds(ctx: &egui::Context) -> Option { ctx.data(|data| { data.get_temp::(Id::new(GEOMETRY_ID)) - .map(|geometry| geometry.board_rect) + .map(|geometry| geometry.task_card_bounds) }) } @@ -126,6 +137,7 @@ mod tests { let card = Rect::from_min_max(egui::pos2(680.0, 20.0), egui::pos2(990.0, 420.0)); let geometry = WorkspaceGeometry { board_rect: board, + task_card_bounds: board, fit_occluders: vec![card], revision: 0, }; @@ -156,5 +168,6 @@ mod tests { assert_eq!(geometry.board_rect.left(), primary.right() + SIDEBAR_GAP); assert_eq!(geometry.board_rect.right(), secondary.left() - SIDEBAR_GAP); + assert_eq!(geometry.task_card_bounds.bottom(), secondary.bottom()); } } diff --git a/crates/core/src/properties/export_dpi_tests.rs b/crates/core/src/properties/export_dpi_tests.rs index fc4ac40..904cadb 100644 --- a/crates/core/src/properties/export_dpi_tests.rs +++ b/crates/core/src/properties/export_dpi_tests.rs @@ -99,7 +99,7 @@ fn dpi_edit_roundtrips_and_supplies_the_next_export_default() { crate::settings::save_to_path(&path, settings) }); - let loaded = crate::settings::load_from_paths(&path, None); + let loaded = crate::settings::load_from_path(&path); if path.exists() { std::fs::remove_file(&path).expect("remove test settings"); } diff --git a/crates/core/src/properties/ilt_tests.rs b/crates/core/src/properties/ilt_tests.rs index da32cf8..a924905 100644 --- a/crates/core/src/properties/ilt_tests.rs +++ b/crates/core/src/properties/ilt_tests.rs @@ -98,7 +98,7 @@ fn ilt_default_catalog_edit_uses_shared_bounds_and_persists() { app.commit_property_with_settings_writer(commit, |settings| { crate::settings::save_to_path(&path, settings) }); - let loaded = crate::settings::load_from_paths(&path, None); + let loaded = crate::settings::load_from_path(&path); let _ = std::fs::remove_file(&path); assert_eq!(loaded.processing.ilt_lambda, 0.4); } diff --git a/crates/core/src/settings/io.rs b/crates/core/src/settings/io.rs index 2ce6769..a7c2145 100644 --- a/crates/core/src/settings/io.rs +++ b/crates/core/src/settings/io.rs @@ -1,4 +1,4 @@ -use super::{SETTINGS_SCHEMA_VERSION, Settings, migrate, paths}; +use super::{SETTINGS_SCHEMA_VERSION, Settings, paths}; use std::io; use std::path::{Path, PathBuf}; @@ -9,7 +9,7 @@ pub fn load() -> Settings { let Some(path) = paths::settings_file() else { return Settings::default(); }; - load_from_paths(&path, paths::legacy_preferences_file().as_deref()) + load_from_path(&path) } pub fn save(settings: &Settings) -> io::Result<()> { @@ -19,15 +19,10 @@ pub fn save(settings: &Settings) -> io::Result<()> { save_to_path(&path, settings) } -pub(crate) fn load_from_paths(path: &Path, legacy: Option<&Path>) -> Settings { +pub(crate) fn load_from_path(path: &Path) -> Settings { if let Ok(data) = std::fs::read(path) { return load_from_bytes(&data, Some(path)); } - if let Some(legacy) = legacy - && let Ok(data) = std::fs::read(legacy) - { - return load_from_bytes(&data, None); - } Settings::default() } @@ -42,6 +37,7 @@ pub(crate) fn save_to_path(path: &Path, settings: &Settings) -> io::Result<()> { .general .project_backup_generations .min(super::MAX_PROJECT_BACKUP_GENERATIONS); + settings.window.task_cards.sanitize(); let data = serde_json::to_vec_pretty(&settings).map_err(io::Error::other)?; let tmp = temporary_path(path); std::fs::write(&tmp, data)?; @@ -62,13 +58,7 @@ fn load_from_bytes(data: &[u8], quarantine_path: Option<&Path>) -> Settings { } return Settings::default(); }; - let from = raw - .get("schema_version") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0) - .min(u32::MAX as u64) as u32; - let migrated = migrate::migrate(raw, from); - let mut settings: Settings = serde_json::from_value(migrated).unwrap_or_default(); + let mut settings: Settings = serde_json::from_value(raw).unwrap_or_default(); settings.schema_version = SETTINGS_SCHEMA_VERSION; settings.general.project_backup_generations = settings .general @@ -78,6 +68,7 @@ fn load_from_bytes(data: &[u8], quarantine_path: Option<&Path>) -> Settings { // live list the Preferences panel shows, so a hand-edited file would // otherwise display more entries than the cap promises. settings.recent.files.truncate(super::MAX_RECENT_FILES); + settings.window.task_cards.sanitize(); // `app_version` deliberately keeps the value the file was written with: // it is how the shell detects "first launch after an update". Saving // stamps the current version (see `save_to_path`). diff --git a/crates/core/src/settings/migrate.rs b/crates/core/src/settings/migrate.rs deleted file mode 100644 index b15310d..0000000 --- a/crates/core/src/settings/migrate.rs +++ /dev/null @@ -1,37 +0,0 @@ -use serde_json::{Value, json}; - -pub fn migrate(value: Value, from: u32) -> Value { - if from == 0 && is_legacy_preferences(&value) { - return v0_to_v1(value); - } - value -} - -fn is_legacy_preferences(value: &Value) -> bool { - let Value::Object(object) = value else { - return false; - }; - (object.contains_key("include_view_snapshots") || object.contains_key("snap_enabled")) - && !object.contains_key("general") - && !object.contains_key("export") -} - -fn v0_to_v1(value: Value) -> Value { - let include_view_snapshots = value - .get("include_view_snapshots") - .and_then(Value::as_bool) - .unwrap_or(false); - let snap_enabled = value - .get("snap_enabled") - .and_then(Value::as_bool) - .unwrap_or(true); - json!({ - "schema_version": 1, - "general": { - "snap_enabled": snap_enabled - }, - "export": { - "include_view_snapshots": include_view_snapshots - } - }) -} diff --git a/crates/core/src/settings/mod.rs b/crates/core/src/settings/mod.rs index c6426ca..0b13111 100644 --- a/crates/core/src/settings/mod.rs +++ b/crates/core/src/settings/mod.rs @@ -1,5 +1,4 @@ mod io; -mod migrate; mod model; mod paths; @@ -9,7 +8,7 @@ pub use io::load; // disagreeing. pub(crate) use io::save; #[cfg(test)] -pub(crate) use io::{load_from_paths, save_to_path}; +pub(crate) use io::{load_from_path, save_to_path}; pub use model::*; pub use paths::{config_dir, data_local_dir}; diff --git a/crates/core/src/settings/model.rs b/crates/core/src/settings/model.rs index 309957b..d4754f3 100644 --- a/crates/core/src/settings/model.rs +++ b/crates/core/src/settings/model.rs @@ -225,6 +225,86 @@ pub struct WindowState { pub main: Option, #[serde(default)] pub last_open_directory: Option, + /// User-selected sizes for the floating canvas task cards. These are UI + /// preferences, not project content; a temporarily small window clamps the + /// rendered size without overwriting them. + #[serde(default)] + pub task_cards: TaskCardSettings, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct TaskCardSize { + #[serde(default)] + pub width: f32, + /// Height of the scrollable body. Header and tab chrome are measured by + /// the UI and do not need a guessed persisted allowance. + #[serde(default)] + pub body_height: f32, +} + +impl TaskCardSize { + pub const fn new(width: f32, body_height: f32) -> Self { + Self { width, body_height } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TaskCardSettings { + #[serde(default = "default_processing_card_size")] + pub processing: TaskCardSize, + #[serde(default = "default_craft_card_size")] + pub craft: TaskCardSize, + #[serde(default = "default_standard_card_size")] + pub regions: TaskCardSize, + #[serde(default = "default_standard_card_size")] + pub curve_fit: TaskCardSize, + #[serde(default = "default_standard_card_size")] + pub statistics: TaskCardSize, +} + +const fn default_standard_card_size() -> TaskCardSize { + TaskCardSize::new(340.0, 436.0) +} + +const fn default_processing_card_size() -> TaskCardSize { + TaskCardSize::new(340.0, 430.0) +} + +const fn default_craft_card_size() -> TaskCardSize { + TaskCardSize::new(520.0, 500.0) +} + +impl Default for TaskCardSettings { + fn default() -> Self { + Self { + processing: default_processing_card_size(), + craft: default_craft_card_size(), + regions: default_standard_card_size(), + curve_fit: default_standard_card_size(), + statistics: default_standard_card_size(), + } + } +} + +impl TaskCardSettings { + pub(crate) fn sanitize(&mut self) { + sanitize_card_size(&mut self.processing, default_processing_card_size()); + sanitize_card_size(&mut self.craft, default_craft_card_size()); + sanitize_card_size(&mut self.regions, default_standard_card_size()); + sanitize_card_size(&mut self.curve_fit, default_standard_card_size()); + sanitize_card_size(&mut self.statistics, default_standard_card_size()); + } +} + +fn sanitize_card_size(size: &mut TaskCardSize, fallback: TaskCardSize) { + if !size.width.is_finite() || size.width <= 0.0 { + size.width = fallback.width; + } + if !size.body_height.is_finite() || size.body_height <= 0.0 { + size.body_height = fallback.body_height; + } + size.width = size.width.min(4_096.0); + size.body_height = size.body_height.min(4_096.0); } #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/core/src/settings/paths.rs b/crates/core/src/settings/paths.rs index 3cf0b3a..ea273a7 100644 --- a/crates/core/src/settings/paths.rs +++ b/crates/core/src/settings/paths.rs @@ -12,22 +12,3 @@ pub fn data_local_dir() -> Option { pub fn settings_file() -> Option { config_dir().map(|dir| dir.join("settings.json")) } - -pub fn legacy_preferences_file() -> Option { - if let Ok(appdata) = std::env::var("APPDATA") { - return Some( - PathBuf::from(appdata) - .join("plotx") - .join("preferences.json"), - ); - } - if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { - return Some(PathBuf::from(xdg).join("plotx").join("preferences.json")); - } - std::env::var("HOME").ok().map(|home| { - PathBuf::from(home) - .join(".config") - .join("plotx") - .join("preferences.json") - }) -} diff --git a/crates/core/src/settings/tests.rs b/crates/core/src/settings/tests.rs index ff9e10e..53a4f76 100644 --- a/crates/core/src/settings/tests.rs +++ b/crates/core/src/settings/tests.rs @@ -20,26 +20,8 @@ fn missing_fields_take_defaults() { settings.appearance.graphics_power, GraphicsPowerPreference::LowPower ); -} - -#[test] -fn v0_preferences_load_as_settings() { - let path = temp_settings("v0"); - let legacy = temp_settings("legacy"); - let _ = std::fs::remove_file(&path); - let _ = std::fs::remove_file(&legacy); - std::fs::write( - &legacy, - r#"{"include_view_snapshots":true,"snap_enabled":false}"#, - ) - .unwrap(); - - let settings = io::load_from_paths(&path, Some(&legacy)); - let _ = std::fs::remove_file(&legacy); - - assert!(settings.export.include_view_snapshots); - assert!(!settings.general.snap_enabled); - assert_eq!(settings.schema_version, SETTINGS_SCHEMA_VERSION); + assert_eq!(settings.window.task_cards.craft.width, 520.0); + assert_eq!(settings.window.task_cards.processing.width, 340.0); } #[test] @@ -52,7 +34,7 @@ fn unversioned_nested_settings_keep_their_fields() { ) .unwrap(); - let settings = io::load_from_paths(&path, None); + let settings = io::load_from_path(&path); let _ = std::fs::remove_file(&path); assert!(!settings.general.snap_enabled); @@ -81,7 +63,7 @@ fn backup_generation_count_is_bounded_on_load() { let _ = std::fs::remove_file(&path); std::fs::write(&path, r#"{"general":{"project_backup_generations":255}}"#).unwrap(); - let settings = io::load_from_paths(&path, None); + let settings = io::load_from_path(&path); let _ = std::fs::remove_file(&path); assert_eq!( @@ -103,7 +85,7 @@ fn recent_files_are_bounded_on_load() { ) .unwrap(); - let settings = io::load_from_paths(&path, None); + let settings = io::load_from_path(&path); let _ = std::fs::remove_file(&path); // The loaded list is what the Preferences panel shows, so the documented @@ -121,9 +103,10 @@ fn save_and_load_roundtrip() { settings.general.project_backup_generations = 3; settings.export.include_view_snapshots = true; settings.export.trim_to_visible_content = true; + settings.window.task_cards.craft = TaskCardSize::new(612.0, 688.0); io::save_to_path(&path, &settings).unwrap(); - let loaded = io::load_from_paths(&path, None); + let loaded = io::load_from_path(&path); let _ = std::fs::remove_file(&path); assert!(!loaded.general.snap_enabled); @@ -131,6 +114,59 @@ fn save_and_load_roundtrip() { assert_eq!(loaded.general.project_backup_generations, 3); assert!(loaded.export.include_view_snapshots); assert!(loaded.export.trim_to_visible_content); + assert_eq!( + loaded.window.task_cards.craft, + TaskCardSize::new(612.0, 688.0) + ); +} + +#[test] +fn invalid_task_card_sizes_are_repaired_at_the_load_boundary() { + let path = temp_settings("task-card-bound"); + let _ = std::fs::remove_file(&path); + std::fs::write( + &path, + r#"{"schema_version":1,"window":{"task_cards":{"craft":{"width":-1.0,"body_height":0.0}}}}"#, + ) + .unwrap(); + + let settings = io::load_from_path(&path); + let _ = std::fs::remove_file(&path); + + assert_eq!( + settings.window.task_cards.craft, + TaskCardSize::new(520.0, 500.0) + ); +} + +#[test] +fn missing_task_card_size_fields_only_use_that_card_defaults() { + let path = temp_settings("task-card-missing-field"); + let _ = std::fs::remove_file(&path); + std::fs::write( + &path, + r#"{ + "general":{"snap_enabled":false}, + "window":{"task_cards":{ + "processing":{"width":352.0}, + "craft":{"body_height":712.0} + }} + }"#, + ) + .unwrap(); + + let settings = io::load_from_path(&path); + let _ = std::fs::remove_file(&path); + + assert!(!settings.general.snap_enabled); + assert_eq!( + settings.window.task_cards.craft, + TaskCardSize::new(520.0, 712.0) + ); + assert_eq!( + settings.window.task_cards.processing, + TaskCardSize::new(352.0, 430.0) + ); } #[test] @@ -139,7 +175,7 @@ fn corrupt_file_quarantines_and_defaults() { let _ = std::fs::remove_file(&path); std::fs::write(&path, b"{").unwrap(); - let settings = io::load_from_paths(&path, None); + let settings = io::load_from_path(&path); let quarantined = (1..1000) .map(|i| { path.parent()