diff --git a/crates/app/src/ui/data_navigation.rs b/crates/app/src/ui/data_navigation.rs index 254b1eb..fa81a59 100644 --- a/crates/app/src/ui/data_navigation.rs +++ b/crates/app/src/ui/data_navigation.rs @@ -90,7 +90,7 @@ pub(super) struct CanvasComposition { } impl PlotIndex { - fn build(app: &PlotiumApp) -> Self { + pub(super) fn build(app: &PlotiumApp) -> Self { let mut result = Self::default(); let datasets: HashMap<_, _> = app .doc diff --git a/crates/app/src/ui/primary_sidebar.rs b/crates/app/src/ui/primary_sidebar.rs index 06435f2..fbb3d13 100644 --- a/crates/app/src/ui/primary_sidebar.rs +++ b/crates/app/src/ui/primary_sidebar.rs @@ -6,6 +6,7 @@ use plotium_core::state::{ FrameRef, PlotiumApp, PrimaryView, RegionSelection, RenameState, RenameTarget, }; mod board_views; +pub(crate) mod canvas_filter; mod data_browser; mod dataset_rows; mod layer_controls; @@ -23,22 +24,52 @@ pub fn render(app: &mut PlotiumApp, ui: &mut Ui) { ui.add_space(8.0); ui.separator(); ui.add_space(4.0); - if app.session.view == PrimaryView::Data { + { + let hint = if app.session.view == PrimaryView::Data { + "Search data and analysis" + } else { + "Search canvases and images" + }; ui.add( egui::TextEdit::singleline(&mut app.session.ui.data_browser_filter) - .hint_text(format!( - "{} Search data and analysis", - icon::MAGNIFYING_GLASS - )) + .hint_text(format!("{} {hint}", icon::MAGNIFYING_GLASS)) .desired_width(f32::INFINITY), ); dataset_rows::filters(app, ui); ui.add_space(4.0); } + if app.session.view == PrimaryView::Canvas { + ui.checkbox(&mut app.session.ui.canvas_list_compact, "Compact rows"); + } if app.session.view == PrimaryView::Canvas && !app.doc.canvases.is_empty() { egui::Panel::bottom("board_views") .resizable(false) .show_inside(ui, |ui| board_views_section(app, ui)); + egui::Panel::bottom("canvas_layers") + .resizable(true) + .default_size(180.0) + .show_inside(ui, |ui| { + ui.label(crate::typography::headline("Layers")); + egui::ScrollArea::vertical() + .id_salt("canvas_layers_scroll") + .show(ui, |ui| { + if let Some(ci) = app + .session + .active_canvas + .filter(|&ci| ci < app.doc.canvases.len()) + { + if app.doc.canvases[ci].objects.is_empty() + && app.doc.canvases[ci].panels.is_empty() + { + ui.weak("No layers. Add an image or plot to this canvas."); + } else { + layers_tree::object_list(app, ci, ui); + } + } else { + ui.weak("No active canvas. Select a canvas to inspect its layers."); + } + }); + }); } egui::ScrollArea::vertical() .id_salt(( @@ -104,7 +135,14 @@ fn canvas_list(app: &mut PlotiumApp, ui: &mut Ui) { let mut start_rename: Option = None; let mut commit: Option<(usize, String)> = None; let mut cancel = false; + let matches = canvas_filter::matching_canvases(app, &index); + if matches.is_empty() { + ui.weak("No matching canvases. Clear or change the filters."); + } for ci in 0..app.doc.canvases.len() { + if !matches.contains(&app.doc.canvases[ci].resource_id) { + continue; + } let renaming = matches!( &app.session.ui.rename, Some(RenameState { target: RenameTarget::Canvas(i), .. }) if *i == ci @@ -125,7 +163,12 @@ fn canvas_list(app: &mut PlotiumApp, ui: &mut Ui) { let name = app.doc.canvases[ci].name.clone(); let selected = crate::ui::canvas::frame_is_selected(app, FrameRef::Page(ci)); let summary = index.summaries.get(&app.doc.canvases[ci].resource_id); - let resp = thumbnails::canvas_row(app, ci, ui, selected, &name, summary); + let resp = if app.session.ui.canvas_list_compact { + ui.selectable_label(selected, &name) + .on_hover_text(summary.map_or("", |s| s.details.as_str())) + } else { + thumbnails::canvas_row(app, ci, ui, selected, &name, summary) + }; if resp.clicked() { claim_list_keyboard_focus(ui, &resp); select = Some((ci, select_modifiers(ui))); @@ -151,16 +194,6 @@ fn canvas_list(app: &mut PlotiumApp, ui: &mut Ui) { } }); } - if let Some(ci) = app.session.active_canvas - && ci < app.doc.canvases.len() - && !app.doc.canvases[ci].objects.is_empty() - { - ui.add_space(8.0); - ui.separator(); - ui.add_space(4.0); - ui.label(crate::typography::headline("Layers")); - layers_tree::object_list(app, ci, ui); - } if let Some((ci, name)) = commit { let trimmed = name.trim(); if !trimmed.is_empty() { diff --git a/crates/app/src/ui/primary_sidebar/canvas_filter.rs b/crates/app/src/ui/primary_sidebar/canvas_filter.rs new file mode 100644 index 0000000..a476303 --- /dev/null +++ b/crates/app/src/ui/primary_sidebar/canvas_filter.rs @@ -0,0 +1,147 @@ +use std::collections::HashSet; + +use plotium_core::state::{CanvasId, PlotiumApp}; + +use crate::ui::data_navigation::PlotIndex; + +/// Uses the same query and metadata filters as the dataset browser. Canvas +/// names and image layer names remain searchable without inventing datasets. +pub(crate) fn matching_canvases(app: &PlotiumApp, index: &PlotIndex) -> HashSet { + let terms: Vec<_> = app + .session + .ui + .data_browser_filter + .split_whitespace() + .map(str::to_lowercase) + .collect(); + let tag = app.session.ui.data_browser_tag.as_deref(); + let batch = app.session.ui.data_browser_batch; + if terms.is_empty() && tag.is_none() && batch.is_none() { + return app + .doc + .canvases + .iter() + .map(|canvas| canvas.resource_id) + .collect(); + } + let mut members = HashSet::new(); + let mut paths = std::collections::HashMap::new(); + for import in app.session.data_imports.batches() { + for item in &import.items { + if let Ok(target) = &item.result { + if Some(import.id) == batch { + members.insert(target.dataset); + } + paths.insert(target.dataset, item.path.to_string_lossy()); + } + } + } + app.doc + .canvases + .iter() + .filter(|canvas| { + let datasets: Vec<_> = index + .summaries + .get(&canvas.resource_id) + .into_iter() + .flat_map(|s| &s.datasets) + .filter_map(|id| app.doc.dataset_index(*id)) + .map(|di| &app.doc.datasets[di]) + .filter(|d| { + tag.is_none_or(|tag| d.organization().has_tag(tag)) + && (batch.is_none() || members.contains(&d.resource_id())) + }) + .collect(); + if (tag.is_some() || batch.is_some()) && datasets.is_empty() { + return false; + } + let fields: Vec<_> = std::iter::once(canvas.name.to_lowercase()) + .chain(canvas.objects.iter().map(|o| o.name.to_lowercase())) + .collect(); + terms.iter().all(|term| { + fields.iter().any(|field| field.contains(term)) + || datasets.iter().any(|d| { + super::data_browser::matches_dataset( + d, + std::slice::from_ref(term), + paths.get(&d.resource_id()).map(|p| p.as_ref()), + ) + }) + }) + }) + .map(|canvas| canvas.resource_id) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use plotium_core::state::CanvasDocument; + + #[test] + fn canvas_search_is_case_insensitive_and_requires_every_term() { + let mut app = PlotiumApp::new(); + app.doc + .canvases + .push(CanvasDocument::new("TEM sample A".into(), [100.0, 80.0])); + app.doc + .canvases + .push(CanvasDocument::new("TEM control".into(), [100.0, 80.0])); + app.session.ui.data_browser_filter = "tem SAMPLE".into(); + let index = PlotIndex::build(&app); + assert_eq!( + matching_canvases(&app, &index), + [app.doc.canvases[0].resource_id].into() + ); + app.session.ui.data_browser_filter = "TEM missing".into(); + assert!(matching_canvases(&app, &index).is_empty()); + app.session.ui.data_browser_filter.clear(); + assert_eq!(matching_canvases(&app, &index).len(), 2); + app.session.ui.data_browser_tag = Some("control".into()); + assert!(matching_canvases(&app, &index).is_empty()); + } + #[test] + fn metadata_search_uses_bound_dataset_experiment_and_tags() { + use plotium_core::state::OrganizationEdit; + let mut app = PlotiumApp::new(); + app.load_from( + &std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/jcamp-ppm.dx"), + ); + assert_eq!(app.doc.datasets.len(), 1); + let dataset = app.doc.datasets[0].resource_id(); + app.edit_dataset_organization( + &[dataset], + OrganizationEdit::Experiment(Some("Cooling run")), + ); + app.edit_dataset_organization(&[dataset], OrganizationEdit::AddTag("Reference")); + app.doc.canvases[0].name = "Untitled".into(); + app.session.ui.data_browser_filter = "cooling reference".into(); + let index = PlotIndex::build(&app); + assert_eq!(matching_canvases(&app, &index).len(), 1); + app.session.ui.data_browser_tag = Some("missing".into()); + assert!(matching_canvases(&app, &index).is_empty()); + } + + #[test] + fn image_layer_filename_is_searchable_without_a_dataset() { + use plotium_core::state::{ + AssetId, CanvasObject, CanvasObjectKind, ObjectFrame, RasterImageContent, + }; + let mut app = PlotiumApp::new(); + let mut canvas = CanvasDocument::new("Figure 1".into(), [100.0, 80.0]); + let id = canvas.allocate_object_id(); + canvas.objects.push(CanvasObject { + id, + name: "TEM_2026_control.png".into(), + frame: ObjectFrame::new(0.0, 0.0, 100.0, 80.0), + locked: false, + visible: true, + kind: CanvasObjectKind::RasterImage(RasterImageContent::new(AssetId::new())), + }); + app.doc.canvases.push(canvas); + app.session.ui.data_browser_filter = "tem_2026 CONTROL".into(); + assert_eq!(matching_canvases(&app, &PlotIndex::build(&app)).len(), 1); + assert!(app.doc.datasets.is_empty()); + } +} diff --git a/crates/app/src/ui/primary_sidebar/data_browser.rs b/crates/app/src/ui/primary_sidebar/data_browser.rs index 9fec635..110d733 100644 --- a/crates/app/src/ui/primary_sidebar/data_browser.rs +++ b/crates/app/src/ui/primary_sidebar/data_browser.rs @@ -360,7 +360,11 @@ pub(super) fn filtering(app: &PlotiumApp) -> bool { || app.session.ui.data_browser_batch.is_some() } -fn matches_dataset(dataset: &Dataset, terms: &[String], import_path: Option<&str>) -> bool { +pub(super) fn matches_dataset( + dataset: &Dataset, + terms: &[String], + import_path: Option<&str>, +) -> bool { if terms.is_empty() { return true; } diff --git a/crates/app/src/ui/primary_sidebar/selection.rs b/crates/app/src/ui/primary_sidebar/selection.rs index 0db65aa..a7744a6 100644 --- a/crates/app/src/ui/primary_sidebar/selection.rs +++ b/crates/app/src/ui/primary_sidebar/selection.rs @@ -43,8 +43,10 @@ pub(super) fn select_canvas_range(app: &mut PlotiumApp, clicked: usize, additive } else { (clicked, anchor) }; + let matches = super::canvas_filter::matching_canvases(app, &super::PlotIndex::build(app)); let range = app.doc.canvases[start..=end] .iter() + .filter(|canvas| matches.contains(&canvas.resource_id)) .map(|canvas| BoardFrameId::Page(canvas.resource_id)); if !additive { app.session.ui.frame_selection.clear(); @@ -245,7 +247,23 @@ pub(crate) fn handle_keyboard_selection(app: &mut PlotiumApp, ctx: &egui::Contex .and_then(|id| app.doc.canvas_index(id)) .or(app.session.active_canvas) .unwrap_or(0); - let target = keyboard_target(current, app.doc.canvases.len(), input.0, input.1); + let matches = + super::canvas_filter::matching_canvases(app, &super::PlotIndex::build(app)); + let visible: Vec<_> = app + .doc + .canvases + .iter() + .enumerate() + .filter(|(_, c)| matches.contains(&c.resource_id)) + .map(|(i, _)| i) + .collect(); + if visible.is_empty() { + return; + } + let current_position = visible.iter().position(|&i| i == current).unwrap_or(0); + let current = visible[current_position]; + let target = + visible[keyboard_target(current_position, visible.len(), input.0, input.1)]; if input.3 { plotium_core::state::toggle_frame_selection_synced( app, @@ -365,6 +383,26 @@ mod tests { assert_eq!(app.session.ui.selection_anchors.canvas_lead, Some(ids[2])); } + #[test] + fn filtered_range_does_not_select_hidden_rows() { + let mut app = PlotiumApp::new(); + for name in ["match first", "hidden", "match last"] { + app.doc + .canvases + .push(CanvasDocument::new(name.into(), [100.0, 80.0])); + } + app.session.ui.selection_anchors.canvas = Some(app.doc.canvases[0].resource_id); + app.session.ui.data_browser_filter = "match".into(); + select_canvas_range(&mut app, 2, false); + assert_eq!(app.session.ui.frame_selection.len(), 2); + assert!( + !app.session + .ui + .frame_selection + .contains(&BoardFrameId::Page(app.doc.canvases[1].resource_id)) + ); + } + #[test] fn keyboard_navigation_advances_from_the_moving_lead() { let first = keyboard_target(0, 4, None, 1); diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index ab259bb..f1ae134 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -1,4 +1,7 @@ use super::*; +#[path = "ui_state_preferences.rs"] +mod preferences; +pub use preferences::{SettingsCategory, SettingsDialog}; #[path = "ui_state_nmr_import.rs"] mod nmr_import; use crate::actions::PendingWheelPropertyEdit; @@ -59,74 +62,6 @@ pub struct AuthorDrag { pub current: [f32; 2], } -/// A rail row in Preferences, including controls that only live for this session. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum SettingsCategory { - #[default] - General, - Appearance, - Processing, - Export, - Recent, - AgentConnections, -} - -impl SettingsCategory { - pub const ALL: [SettingsCategory; 6] = [ - SettingsCategory::General, - SettingsCategory::Appearance, - SettingsCategory::Processing, - SettingsCategory::Export, - SettingsCategory::Recent, - SettingsCategory::AgentConnections, - ]; - - pub const fn label(self) -> &'static str { - match self { - SettingsCategory::General => "General", - SettingsCategory::Appearance => "Appearance", - SettingsCategory::Processing => "Processing", - SettingsCategory::Export => "Export", - SettingsCategory::Recent => "Recent", - SettingsCategory::AgentConnections => "AI connections (MCP)", - } - } - - pub const fn section_id(self) -> &'static str { - match self { - SettingsCategory::General => "preferences.general", - SettingsCategory::Appearance => "preferences.appearance", - SettingsCategory::Processing => "preferences.processing", - SettingsCategory::Export => "preferences.export", - SettingsCategory::Recent => "preferences.recent", - SettingsCategory::AgentConnections => "preferences.agent_connections", - } - } -} - -/// The Preferences window's working state: the draft [`Settings`] every widget -/// edits, the selected rail category, a scheduled debounced-flush time, and the -/// last non-fatal save error. The draft is applied to the live app on every edit -/// and persisted on the debounce or on close; the on-disk file is only ever -/// replaced wholesale by a valid draft. -pub struct SettingsDialog { - pub category: SettingsCategory, - pub draft: crate::settings::Settings, - pub flush_at: Option, - pub last_error: Option, -} - -impl SettingsDialog { - pub fn new(settings: crate::settings::Settings) -> Self { - Self { - category: SettingsCategory::default(), - draft: settings, - flush_at: None, - last_error: None, - } - } -} - /// Cached model-editor validation: the parsed definition plus its /// unclassified symbols on success, or the parse/compile error text. #[derive(Clone)] @@ -275,6 +210,7 @@ pub struct UiState { pub board_viewport_aspect: f32, // Used only to place new import blocks. /// Transient Data-browser state. Dataset and Derived-data branches default /// open, so only explicit collapses are recorded; Analysis defaults closed. + pub canvas_list_compact: bool, pub data_browser_filter: String, pub data_browser_tag: Option, pub data_browser_batch: Option, @@ -512,6 +448,7 @@ impl Default for UiState { frame_selection: Vec::new(), data_selection: Vec::new(), board_viewport_aspect: 1.6, + canvas_list_compact: false, data_browser_filter: String::new(), data_browser_tag: None, data_browser_batch: None, diff --git a/crates/core/src/state/ui_state_preferences.rs b/crates/core/src/state/ui_state_preferences.rs new file mode 100644 index 0000000..6797242 --- /dev/null +++ b/crates/core/src/state/ui_state_preferences.rs @@ -0,0 +1,67 @@ +/// A rail row in Preferences, including controls that only live for this session. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum SettingsCategory { + #[default] + General, + Appearance, + Processing, + Export, + Recent, + AgentConnections, +} + +impl SettingsCategory { + pub const ALL: [SettingsCategory; 6] = [ + SettingsCategory::General, + SettingsCategory::Appearance, + SettingsCategory::Processing, + SettingsCategory::Export, + SettingsCategory::Recent, + SettingsCategory::AgentConnections, + ]; + + pub const fn label(self) -> &'static str { + match self { + SettingsCategory::General => "General", + SettingsCategory::Appearance => "Appearance", + SettingsCategory::Processing => "Processing", + SettingsCategory::Export => "Export", + SettingsCategory::Recent => "Recent", + SettingsCategory::AgentConnections => "AI connections (MCP)", + } + } + + pub const fn section_id(self) -> &'static str { + match self { + SettingsCategory::General => "preferences.general", + SettingsCategory::Appearance => "preferences.appearance", + SettingsCategory::Processing => "preferences.processing", + SettingsCategory::Export => "preferences.export", + SettingsCategory::Recent => "preferences.recent", + SettingsCategory::AgentConnections => "preferences.agent_connections", + } + } +} + +/// The Preferences window's working state: the draft [`crate::settings::Settings`] every widget +/// edits, the selected rail category, a scheduled debounced-flush time, and the +/// last non-fatal save error. The draft is applied to the live app on every edit +/// and persisted on the debounce or on close; the on-disk file is only ever +/// replaced wholesale by a valid draft. +pub struct SettingsDialog { + pub category: SettingsCategory, + pub draft: crate::settings::Settings, + pub flush_at: Option, + pub last_error: Option, +} + +impl SettingsDialog { + pub fn new(settings: crate::settings::Settings) -> Self { + Self { + category: SettingsCategory::default(), + draft: settings, + flush_at: None, + last_error: None, + } + } +} diff --git a/docs/src/content/docs/guides/images.md b/docs/src/content/docs/guides/images.md index 31a9f17..aba5170 100644 --- a/docs/src/content/docs/guides/images.md +++ b/docs/src/content/docs/guides/images.md @@ -41,6 +41,9 @@ For formats that need special handling, use these commands: cannot be read, earlier pages are kept and the remaining pages of that TIFF are skipped. The standard **Add Images…** command adds only the first page. +Use the **Canvas** sidebar search to find image pages by their names or image +layer names. **Compact rows** switches to a text list. + Preview detail adapts to the visible size and available memory; publication export still uses the source. diff --git a/docs/src/content/docs/guides/organizing-data.md b/docs/src/content/docs/guides/organizing-data.md index 05ec6e1..83c4005 100644 --- a/docs/src/content/docs/guides/organizing-data.md +++ b/docs/src/content/docs/guides/organizing-data.md @@ -75,6 +75,19 @@ In **Canvas**, the line under each title summarizes the datasets directly plotted there, including hidden series. A dataset used more than once is counted once. Hover the title to see the full composition. +## Find canvases and images + +The **Canvas** and **Data** tabs share the search text, tag filter and import +batch filter. Canvas search also matches canvas names and image layer names, +including canvases without plotted datasets. A tag or data import filter only +includes canvases containing a matching dataset. Clear these filters to include +pure image pages again. + +Choose **Compact rows** for a text-only canvas list. Layers have their own +scrolling area below the list. Selecting a result locates its existing canvas; +it does not create a duplicate. Shift-selection and keyboard navigation follow +the filtered rows. + ## Board views In **Canvas** mode, the lower part of the side bar holds named **Board diff --git a/docs/src/content/docs/zh-cn/guides/images.md b/docs/src/content/docs/zh-cn/guides/images.md index 045f0ca..37f5737 100644 --- a/docs/src/content/docs/zh-cn/guides/images.md +++ b/docs/src/content/docs/zh-cn/guides/images.md @@ -32,6 +32,9 @@ Plotium 创建图片页面时,会优先采用图片的 DPI 信息确定实际 - **Add All TIFF Pages…** 按顺序读取每个 TIFF 的页面。如果某页无法读取,已读取的页面会保留, 该 TIFF 的剩余页面会被跳过。常规的 **Add Images…** 只添加第一页。 +在 **Canvas** 侧栏中搜索页面名或图片图层名即可找到图片;**Compact rows** 可切换为 +文字列表。 + 预览精度会适应可见尺寸和可用内存,出版导出仍使用原始图片。 ## 调整图片 diff --git a/docs/src/content/docs/zh-cn/guides/organizing-data.md b/docs/src/content/docs/zh-cn/guides/organizing-data.md index 932635b..2d50a8a 100644 --- a/docs/src/content/docs/zh-cn/guides/organizing-data.md +++ b/docs/src/content/docs/zh-cn/guides/organizing-data.md @@ -86,3 +86,12 @@ description: 浏览数据集与派生结果,并把工作保存为项目。 当前项目有未保存修改时,都使用同一个 **Save / Discard / Cancel** 确认流程。 在欢迎页可打开数据、导入表格或打开已保存的项目。**Close Project** 会返回欢迎页。 + +## 查找画布和图片 + +**Canvas** 与 **Data** 页签共用搜索词、标签和导入批次筛选。画布搜索还匹配画布名和 +图片图层名,包括没有绘制数据集的画布。标签或数据导入批次筛选只保留含有匹配数据集的 +画布;清除这些筛选即可重新显示纯图片页面。 + +选择 **Compact rows** 可使用纯文字画布列表。Layers 在列表下方有独立滚动区域。 +选择搜索结果会定位已有画布,不创建副本;Shift 范围选择和键盘导航只经过筛选后的行。