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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/app/src/ui/data_navigation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 49 additions & 16 deletions crates/app/src/ui/primary_sidebar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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((
Expand Down Expand Up @@ -104,7 +135,14 @@ fn canvas_list(app: &mut PlotiumApp, ui: &mut Ui) {
let mut start_rename: Option<usize> = 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
Expand All @@ -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)));
Expand All @@ -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() {
Expand Down
147 changes: 147 additions & 0 deletions crates/app/src/ui/primary_sidebar/canvas_filter.rs
Original file line number Diff line number Diff line change
@@ -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<CanvasId> {
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());
}
}
6 changes: 5 additions & 1 deletion crates/app/src/ui/primary_sidebar/data_browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
40 changes: 39 additions & 1 deletion crates/app/src/ui/primary_sidebar/selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading