diff --git a/crates/app/src/shot.rs b/crates/app/src/shot.rs index 8e4f29a..e9444e3 100644 --- a/crates/app/src/shot.rs +++ b/crates/app/src/shot.rs @@ -32,6 +32,7 @@ use plotx_io::xps::{ use plotx_io::{AxisSource, Dim, Domain, NmrData, NmrData2D, PseudoAxis, PseudoKind, QuadMode}; mod craft_shot; +mod thumbnail_shot; const FIT_LO: f64 = 1.4; const FIT_HI: f64 = 3.2; @@ -76,6 +77,7 @@ enum Op { Setup, /// Fit the FID band and frame the result. LineFit, + Thumbnails, /// Load a homonuclear COSY plane and open its symmetry-review workflow. SymmetrySetup, /// Pin the ordinary readout cursor on the synthetic COSY. @@ -138,6 +140,8 @@ const SCENES: &[Scene] = &[ shot(8, "band"), act(2, Op::LineFit), shot(10, "fitted"), + act(2, Op::Thumbnails), + shot(8, "canvas_thumbnails"), // The three widths bracket the Ribbon's width budget: 720 steps the // low-priority groups down to their Small and Collapsed scales, 900 // catches the first Medium steps, and 1440 shows every group at Large. @@ -368,6 +372,7 @@ fn run_op(op: Op, app: &mut PlotxApp, ctx: &egui::Context) -> Result<(), String> setup(app); } Op::LineFit => line_fit(app, ctx)?, + Op::Thumbnails => thumbnail_shot::setup(app), Op::SymmetrySetup => symmetry_setup(app)?, Op::InspectCursor => inspect_cursor(app)?, Op::DeltaCursor => delta_cursor(app)?, @@ -773,24 +778,5 @@ fn save_png(path: &Path, image: &egui::ColorImage) -> Result<(), String> { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn automated_exit_bypasses_dirty_project_prompt() { - let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); - app.mark_document_dirty(); - let ctx = egui::Context::default(); - - let output = ctx.run_ui(egui::RawInput::default(), |ui| { - request_exit(&mut app, ui.ctx()); - }); - - assert!(app.session.allow_close); - let root = output - .viewport_output - .get(&egui::ViewportId::ROOT) - .expect("root viewport output"); - assert!(root.commands.contains(&egui::ViewportCommand::Close)); - } -} +#[path = "shot/tests.rs"] +mod tests; diff --git a/crates/app/src/shot/tests.rs b/crates/app/src/shot/tests.rs new file mode 100644 index 0000000..7cde2dd --- /dev/null +++ b/crates/app/src/shot/tests.rs @@ -0,0 +1,19 @@ +use super::*; + +#[test] +fn automated_exit_bypasses_dirty_project_prompt() { + let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + app.mark_document_dirty(); + let ctx = egui::Context::default(); + + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + request_exit(&mut app, ui.ctx()); + }); + + assert!(app.session.allow_close); + let root = output + .viewport_output + .get(&egui::ViewportId::ROOT) + .expect("root viewport output"); + assert!(root.commands.contains(&egui::ViewportCommand::Close)); +} diff --git a/crates/app/src/shot/thumbnail_shot.rs b/crates/app/src/shot/thumbnail_shot.rs new file mode 100644 index 0000000..8e32313 --- /dev/null +++ b/crates/app/src/shot/thumbnail_shot.rs @@ -0,0 +1,18 @@ +use plotx_core::actions::Action; +use plotx_core::state::{CanvasId, PlotxApp, PrimaryView}; + +pub(super) fn setup(app: &mut PlotxApp) { + app.session.view = PrimaryView::Canvas; + app.session.ui.close_task_cards(); + let mut portrait = app.doc.canvases[0].clone(); + portrait.resource_id = CanvasId::new(); + portrait.name = "Portrait comparison with a long canvas name".to_owned(); + portrait.size_mm = [110.0, 180.0]; + portrait.board_pos[0] += 600.0; + app.execute_action(Action::insert_canvas( + app.doc.canvases.len(), + portrait, + app.session.active_canvas, + )); + app.activate_canvas(0); +} diff --git a/crates/app/src/ui/canvas/image_painting.rs b/crates/app/src/ui/canvas/image_painting.rs index 7d39544..866d44c 100644 --- a/crates/app/src/ui/canvas/image_painting.rs +++ b/crates/app/src/ui/canvas/image_painting.rs @@ -1,5 +1,5 @@ use super::*; -pub(super) fn paint_document(app: &PlotxApp, ci: usize, rect: egui::Rect, painter: &egui::Painter) { +pub(crate) fn canvas_document(app: &PlotxApp, ci: usize) -> plotx_render::Document<'_> { let canvas = &app.doc.canvases[ci]; let [width, height] = canvas.size_pt(); let mut items = plotx_core::state::document_items(canvas); @@ -61,12 +61,17 @@ pub(super) fn paint_document(app: &PlotxApp, ci: usize, rect: egui::Rect, painte visible: panel_visible && object.visible, }); } - let document = plotx_render::Document { + plotx_render::Document { width, height, background: canvas.background, items, - }; + } +} + +pub(super) fn paint_document(app: &PlotxApp, ci: usize, rect: egui::Rect, painter: &egui::Painter) { + let canvas = &app.doc.canvases[ci]; + let document = canvas_document(app, ci); let zoom = app.session.board.zoom; let bp = canvas.board_pos; let detail = workspace_render_detail(painter.ctx(), painter.ctx().input(|input| input.time)); diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index 3c0cd66..74ef28b 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -42,7 +42,7 @@ mod cursors; mod furniture; mod geometry; mod image_drop; -mod image_painting; +pub(crate) mod image_painting; mod selection_painting; pub(crate) use image_drop::{ImagePanelDropTarget, image_drop_target}; mod integrals; diff --git a/crates/app/src/ui/primary_sidebar.rs b/crates/app/src/ui/primary_sidebar.rs index 2c449ea..fc85df5 100644 --- a/crates/app/src/ui/primary_sidebar.rs +++ b/crates/app/src/ui/primary_sidebar.rs @@ -10,9 +10,9 @@ mod data_browser; mod layer_controls; mod layers_tree; pub(crate) mod selection; +mod thumbnails; use board_views::board_views_section; use data_browser::{AnalysisItem, AnalysisKind, DataTree, DatasetNode}; -use layer_controls::truncated_selectable; use selection::*; pub fn render(app: &mut PlotxApp, ui: &mut Ui) { @@ -101,8 +101,12 @@ fn canvas_list(app: &mut PlotxApp, ui: &mut Ui) { Some(RenameState { target: RenameTarget::Canvas(i), .. }) if *i == ci ); if renaming { + let row = thumbnails::canvas_row(app, ci, ui, true, ""); + let mut rect = row.rect.shrink2(egui::vec2(6.0, 12.0)); + rect.min.x += 80.0; + let mut editor = ui.new_child(egui::UiBuilder::new().max_rect(rect)); let rs = app.session.ui.rename.as_mut().unwrap(); - match rename_edit(ui, rs, egui::Id::new(("rename_canvas", ci))) { + match rename_edit(&mut editor, rs, egui::Id::new(("rename_canvas", ci))) { RenameOutcome::Commit(s) => commit = Some((ci, s)), RenameOutcome::Cancel => cancel = true, RenameOutcome::Editing => {} @@ -111,7 +115,7 @@ fn canvas_list(app: &mut PlotxApp, ui: &mut Ui) { } let name = app.doc.canvases[ci].name.clone(); let selected = crate::ui::canvas::frame_is_selected(app, FrameRef::Page(ci)); - let resp = truncated_selectable(ui, selected, name); + let resp = thumbnails::canvas_row(app, ci, ui, selected, &name); if resp.clicked() { claim_list_keyboard_focus(ui, &resp); select = Some((ci, select_modifiers(ui))); diff --git a/crates/app/src/ui/primary_sidebar/thumbnails.rs b/crates/app/src/ui/primary_sidebar/thumbnails.rs new file mode 100644 index 0000000..648e656 --- /dev/null +++ b/crates/app/src/ui/primary_sidebar/thumbnails.rs @@ -0,0 +1,78 @@ +use egui::{Align2, Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, vec2}; +use plotx_core::state::PlotxApp; + +pub(super) fn canvas_row( + app: &PlotxApp, + ci: usize, + ui: &mut Ui, + selected: bool, + name: &str, +) -> Response { + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 64.0), Sense::hover()); + let response = ui.interact( + rect, + ui.id() + .with(("canvas_row", app.doc.canvases[ci].resource_id)), + Sense::click(), + ); + response.widget_info(|| { + egui::WidgetInfo::selected( + egui::WidgetType::SelectableLabel, + ui.is_enabled(), + selected, + name, + ) + }); + if !ui.is_rect_visible(rect) { + return response; + } + let visuals = ui.style().interact_selectable(&response, selected); + let painter = ui.painter_at(rect); + if selected || response.hovered() || response.has_focus() { + painter.rect_filled(rect, 4.0, visuals.weak_bg_fill); + painter.rect_stroke(rect, 4.0, visuals.bg_stroke, StrokeKind::Inside); + } + let preview = Rect::from_min_size(rect.min + vec2(6.0, 6.0), vec2(72.0, 52.0)); + painter.rect_filled(preview, 2.0, ui.visuals().faint_bg_color); + let document = crate::ui::canvas::image_painting::canvas_document(app, ci); + if document.width.is_finite() + && document.height.is_finite() + && document.width > 0.0 + && document.height > 0.0 + { + let available = preview.shrink(3.0); + let zoom = (available.width() / document.width).min(available.height() / document.height); + let size = vec2(document.width, document.height) * zoom; + let page = Rect::from_center_size(available.center(), size); + // A thumbnail has its own viewport, independent of board navigation. + plotx_render::screen::paint_document_for_editor_with_detail( + &painter.with_clip_rect(preview.intersect(ui.clip_rect())), + plotx_render::Rect::new(page.left(), page.top(), page.width(), page.height()), + &document, + plotx_render::DocumentViewport { + zoom, + pan: [0.0, 0.0], + }, + plotx_render::screen::ScreenRenderDetail::Interactive, + ); + painter.rect_stroke( + page, + 0.0, + Stroke::new(0.5_f32, Color32::from_gray(150)), + StrokeKind::Inside, + ); + } + let text_rect = Rect::from_min_max( + egui::pos2(preview.right() + 8.0, rect.top()), + rect.max - vec2(6.0, 0.0), + ); + let galley = egui::WidgetText::from(name).into_galley( + ui, + Some(egui::TextWrapMode::Truncate), + text_rect.width().max(0.0), + egui::TextStyle::Button, + ); + let label = Align2::LEFT_CENTER.align_size_within_rect(galley.size(), text_rect); + painter.galley(label.min, galley, visuals.text_color()); + response.on_hover_text(name) +} diff --git a/docs/src/content/docs/reference/ui-overview.md b/docs/src/content/docs/reference/ui-overview.md index 8418b18..da0458d 100644 --- a/docs/src/content/docs/reference/ui-overview.md +++ b/docs/src/content/docs/reference/ui-overview.md @@ -12,7 +12,8 @@ introduces the same regions in walkthrough form. - **Primary Side Bar** — the left panel. Its **Canvas** mode lists plots, pages, and saved board views; its **Data** mode shows every dataset and the - results derived from it. + results derived from it. Each canvas row includes a live page thumbnail; + click to select it, double-click its name to rename it, or use its context menu. - **Canvas** / **the board** — the central area: an infinite board holding your plots and ordinary data sheets. "Page" is one framed area of the board that exports as one figure. Drag a page or table-sheet header to place it; diff --git a/docs/src/content/docs/zh-cn/reference/ui-overview.md b/docs/src/content/docs/zh-cn/reference/ui-overview.md index 89ee82a..1e7807d 100644 --- a/docs/src/content/docs/zh-cn/reference/ui-overview.md +++ b/docs/src/content/docs/zh-cn/reference/ui-overview.md @@ -13,6 +13,7 @@ PlotX 的界面为英文;手册中加粗的英文词即界面上的原文标 - **主侧栏(Primary Side Bar)**——左侧面板。**Canvas** 模式列出图形、 页面和已保存的画板视野;**Data** 模式显示每个数据集及其派生结果。 + 每个画布条目都带有实时页面缩略图;单击选择,双击名称重命名,右键打开上下文菜单。 - **画布(Canvas)/ 画板(board)**——中央区域:承载图形与普通数据表的 无限画板。"页面"是画板上的一块带框区域,导出时对应一张图。拖动页面或 数据表标题可调整位置;靠近其他框架时,会吸附到其边缘和框架间的标准间距。