#![allow(clippy::large_enum_variant)]
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
use eframe::egui;
use copernicus_viewer::comparison::{
ComparisonResult, ComparisonTool, compare_products_with_options,
};
use copernicus_viewer::display::{InspectorView, render_inspector};
use copernicus_viewer::plot::{
PlotLoadResult, PlotPanel, PlotSlotId, load_plot_data, shared_progress,
};
use copernicus_viewer::product::{Product, ProductHandle, open_product};
use copernicus_viewer::zarr::{
DownloadProgressCallback, ZarrNodeKind, ZarrTreeNode, download_s3_product, is_s3_product,
parse_s3_location, resolve_zarr_product_path,
};
#[derive(Clone, Debug, PartialEq, Eq)]
struct SelectedNode {
store_index: usize,
path: String,
}
struct OpenProductDialog {
show: bool,
path: String,
browser_location: crate::file_browser::BrowserLocation,
browse_request_id: u64,
browse_items: Option<Result<Vec<crate::file_browser::BrowserItem>, String>>,
browse_loading: bool,
}
impl OpenProductDialog {
fn new() -> Self {
Self {
show: false,
path: String::new(),
browser_location: crate::file_browser::BrowserLocation::Local(
crate::file_browser::home_dir().unwrap_or_else(|| PathBuf::from("/")),
),
browse_request_id: 0,
browse_items: None,
browse_loading: false,
}
}
}
struct PendingNativeOpen {
path_hint: String,
}
struct PendingDownloadPick {
store_index: usize,
}
enum LoadMessage {
StoreReady {
location: String,
result: Result<Product, String>,
},
BrowseListReady {
request_id: u64,
location: crate::file_browser::BrowserLocation,
result: Result<Vec<crate::file_browser::BrowserItem>, String>,
},
PlotProgress {
slot_id: PlotSlotId,
fraction: f32,
message: String,
},
PlotReady {
slot_id: PlotSlotId,
store_index: usize,
path: String,
result: Result<PlotLoadResult, String>,
},
DownloadProgress {
_store_index: usize,
objects_done: usize,
bytes_done: u64,
current_key: String,
},
DownloadReady {
_store_index: usize,
result: Result<PathBuf, String>,
},
ComparisonReady {
result: ComparisonResult,
},
}
pub struct CopernicusViewer {
stores: Vec<ProductHandle>,
selected: Option<SelectedNode>,
inspector: InspectorView,
plot_panel: PlotPanel,
status_message: String,
load_tx: Sender<LoadMessage>,
load_rx: Receiver<LoadMessage>,
pending_native_open: Option<PendingNativeOpen>,
open_product_dialog: OpenProductDialog,
comparison: ComparisonTool,
s3_config: crate::s3_config_dialog::S3ConfigDialog,
help: crate::help_dialog::HelpDialog,
demo_capture: Option<crate::demo_capture::DemoCapture>,
pending_download_pick: Option<PendingDownloadPick>,
download_in_progress: bool,
}
impl CopernicusViewer {
pub fn new(initial_locations: Vec<String>) -> Self {
let (load_tx, load_rx) = mpsc::channel();
let mut app = Self {
stores: Vec::new(),
selected: None,
inspector: InspectorView::default(),
plot_panel: PlotPanel::default(),
status_message: String::new(),
load_tx,
load_rx,
pending_native_open: None,
open_product_dialog: OpenProductDialog::new(),
comparison: ComparisonTool::default(),
s3_config: crate::s3_config_dialog::S3ConfigDialog::default(),
help: crate::help_dialog::HelpDialog::default(),
demo_capture: crate::demo_capture::DemoCapture::from_env(),
pending_download_pick: None,
download_in_progress: false,
};
for location in initial_locations {
app.open_path(location);
}
app
}
fn show_s3_config_dialog(&mut self) {
self.s3_config.show();
}
fn on_s3_config_saved(&mut self) {
self.status_message = "S3 configuration saved.".to_string();
if matches!(
self.open_product_dialog.browser_location,
crate::file_browser::BrowserLocation::S3Root
) && self.open_product_dialog.show
{
self.request_browse_list();
}
}
fn show_open_product_dialog(&mut self) {
let last_root = self.stores.last().map(|store| store.root_path());
let store_root = last_root
.filter(|root| !root.starts_with("s3://"))
.map(PathBuf::from);
self.open_product_dialog.browser_location = crate::file_browser::initial_browser_location(
&self.open_product_dialog.path,
store_root.as_deref(),
);
if self.open_product_dialog.path.is_empty()
&& let Some(root) = last_root
{
self.open_product_dialog.path = root.to_string();
}
self.open_product_dialog.browse_items = None;
self.open_product_dialog.show = true;
self.request_browse_list();
}
fn request_browse_list(&mut self) {
self.open_product_dialog.browse_request_id += 1;
let request_id = self.open_product_dialog.browse_request_id;
let location = self.open_product_dialog.browser_location.clone();
self.open_product_dialog.browse_loading = true;
let tx = self.load_tx.clone();
thread::spawn(move || {
let result = crate::s3_browser::list_browser_items(&location);
let _ = tx.send(LoadMessage::BrowseListReady {
request_id,
location,
result,
});
});
}
fn set_browser_location(&mut self, location: crate::file_browser::BrowserLocation) {
self.open_product_dialog.browser_location = location;
self.request_browse_list();
}
fn request_native_open(&mut self, path_hint: String) {
self.pending_native_open = Some(PendingNativeOpen { path_hint });
}
fn open_path(&mut self, location: String) {
let trimmed = location.trim();
if trimmed.is_empty() {
return;
}
if !trimmed.starts_with("s3://") {
let canonical = resolve_zarr_product_path(Path::new(trimmed))
.display()
.to_string();
if self
.stores
.iter()
.any(|store| store.root_path() == canonical)
{
self.status_message = format!("Already open: {canonical}");
return;
}
} else if self.stores.iter().any(|store| store.root_path() == trimmed) {
self.status_message = format!("Already open: {trimmed}");
return;
}
self.status_message = format!("Opening {trimmed}…");
let input = trimmed.to_string();
let tx = self.load_tx.clone();
thread::spawn(move || {
let result = open_product(&input).map_err(|e| e.to_string());
let _ = tx.send(LoadMessage::StoreReady {
location: input,
result,
});
});
}
fn open_product_dialog_ui(&mut self, ctx: &egui::Context) {
if !self.open_product_dialog.show {
return;
}
let mut submit_path: Option<String> = None;
let mut keep_open = true;
let mut selected_path = self.open_product_dialog.path.clone();
let mut navigate_to: Option<crate::file_browser::BrowserLocation> = None;
let is_s3 = self.open_product_dialog.browser_location.is_s3();
let window = egui::Window::new("Open Product")
.collapsible(false)
.resizable(true)
.default_width(640.0)
.default_height(460.0)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.label("EOPF Zarr directory, .zarr.zip archive, or s3:// URI:");
ui.horizontal(|ui| {
ui.label("Location:");
ui.add(
egui::TextEdit::singleline(&mut self.open_product_dialog.path)
.desired_width(f32::INFINITY)
.hint_text("/path/to/product.zarr or s3://bucket/path/product.zarr"),
);
});
ui.add_space(6.0);
ui.horizontal(|ui| {
let can_go_up = self.open_product_dialog.browser_location.can_go_up();
ui.add_enabled_ui(can_go_up, |ui| {
if ui.button("⬆ Up").clicked()
&& let Some(parent) = self.open_product_dialog.browser_location.go_up()
{
navigate_to = Some(parent);
}
});
if ui.button("🏠 Home").clicked() {
navigate_to = Some(if is_s3 {
crate::file_browser::BrowserLocation::S3Root
} else {
crate::file_browser::BrowserLocation::Local(
crate::file_browser::home_dir()
.unwrap_or_else(|| PathBuf::from("/")),
)
});
}
if ui.button("Local").clicked() {
navigate_to = Some(crate::file_browser::BrowserLocation::Local(
crate::file_browser::initial_browser_dir(
&self.open_product_dialog.path,
None,
),
));
}
if ui.button("S3").clicked() {
navigate_to = Some(crate::file_browser::BrowserLocation::S3Root);
}
if !is_s3
&& ui.button("System picker…").clicked()
{
self.request_native_open(self.open_product_dialog.path.clone());
}
});
ui.label(
egui::RichText::new(format!(
"Browse: {}",
self.open_product_dialog.browser_location.display_label()
))
.strong(),
);
ui.label(
egui::RichText::new(if is_s3 {
"Select a .zarr prefix. Double-click a folder to open it, \
or double-click a product to load it."
} else {
"Select a .zarr folder or .zip archive. Double-click a folder to open it, \
or double-click a product to load it."
})
.small()
.weak(),
);
if self.open_product_dialog.browse_loading {
ui.add_space(8.0);
ui.label(egui::RichText::new("Loading…").weak());
} else if let Some(result) = &self.open_product_dialog.browse_items {
match result {
Ok(items) if items.is_empty() => {
ui.add_space(8.0);
let message = if matches!(
self.open_product_dialog.browser_location,
crate::file_browser::BrowserLocation::S3Root
) {
"No buckets in s3.conf — use File → Configure S3… \
or type s3://bucket/path above."
} else if is_s3 {
"No child prefixes here."
} else {
"No .zarr folders or zip archives here."
};
ui.label(egui::RichText::new(message).weak());
}
Ok(items) => {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.max_height(240.0)
.show(ui, |ui| {
for item in items {
let location = item.location();
match &item {
crate::file_browser::BrowserItem::Directory {
name,
zarr_product,
..
} => {
let label = if *zarr_product {
format!("📦 {name}")
} else {
format!("📁 {name}")
};
let selected = selected_path == location;
let response = ui.selectable_label(selected, label);
if response.clicked() {
selected_path = location.to_string();
self.open_product_dialog.path =
selected_path.clone();
}
if response.double_clicked() {
if *zarr_product {
submit_path = Some(location.to_string());
keep_open = false;
} else if let Some(next) =
crate::file_browser::BrowserLocation::from_path_hint(location)
{
navigate_to = Some(next);
selected_path.clear();
self.open_product_dialog.path.clear();
}
}
}
crate::file_browser::BrowserItem::ZipArchive {
name,
..
} => {
let label = format!("🗜 {name}");
let selected = selected_path == location;
let response = ui.selectable_label(selected, label);
if response.clicked() {
selected_path = location.to_string();
self.open_product_dialog.path =
selected_path.clone();
}
if response.double_clicked() {
submit_path = Some(location.to_string());
keep_open = false;
}
}
}
}
});
}
Err(err) => {
ui.colored_label(egui::Color32::LIGHT_RED, err);
}
}
}
ui.add_space(8.0);
ui.horizontal(|ui| {
let can_open = !self.open_product_dialog.path.trim().is_empty();
ui.add_enabled_ui(can_open, |ui| {
if ui.button("Open").clicked() {
submit_path =
Some(self.open_product_dialog.path.trim().to_string());
keep_open = false;
}
});
if ui.button("Cancel").clicked() {
keep_open = false;
}
});
});
if let Some(location) = navigate_to {
self.set_browser_location(location);
}
if window.is_none() || !keep_open {
self.open_product_dialog.show = false;
}
if let Some(path) = submit_path {
self.open_path(path);
}
}
fn product_name(store: &Product) -> String {
let root = &store.root_path();
if let Some(rest) = root.strip_prefix("s3://")
&& let Some(name) = rest.rsplit('/').next().filter(|s| !s.is_empty())
{
return name.to_string();
}
PathBuf::from(root)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "product".to_string())
}
fn close_product(&mut self, store_index: usize) {
if store_index >= self.stores.len() {
return;
}
let closed_name = Self::product_name(&self.stores[store_index]);
self.stores.remove(store_index);
let selection_was_closed = self
.selected
.as_ref()
.is_some_and(|sel| sel.store_index == store_index);
if let Some(sel) = &self.selected
&& sel.store_index > store_index
{
self.selected = Some(SelectedNode {
store_index: sel.store_index - 1,
path: sel.path.clone(),
});
}
self.plot_panel
.on_product_closed(store_index, selection_was_closed);
if selection_was_closed {
self.selected = None;
self.inspector = InspectorView::default();
if let Some(store) = self.stores.first() {
let root = store.tree().root.clone();
self.select_node(0, &root);
}
}
let count = self.stores.len();
if count == 0 {
self.status_message = format!("Closed {closed_name}");
} else {
self.status_message = format!(
"Closed {closed_name} ({count} product{} open)",
if count == 1 { "" } else { "s" }
);
}
}
fn close_product_for_selection(&mut self) {
let index = self
.selected
.as_ref()
.map(|sel| sel.store_index)
.unwrap_or_else(|| self.stores.len().saturating_sub(1));
self.close_product(index);
}
fn can_download_product(&self, store_index: usize) -> bool {
self.stores
.get(store_index)
.is_some_and(|store| is_s3_product(store.root_path()))
}
fn can_download_selected_product(&self) -> bool {
!self.download_in_progress
&& self
.selected
.as_ref()
.is_some_and(|sel| self.can_download_product(sel.store_index))
}
fn request_product_download(&mut self, store_index: usize) {
if self.download_in_progress {
self.status_message = "Download already in progress".to_string();
return;
}
if !self.can_download_product(store_index) {
self.status_message = "Product is not on S3 storage".to_string();
return;
}
self.pending_download_pick = Some(PendingDownloadPick { store_index });
}
fn start_download(&mut self, store_index: usize, dest_parent: PathBuf) {
let Some(store) = self.stores.get(store_index) else {
return;
};
let root_path = store.root_path();
let (bucket, prefix) = match parse_s3_location(root_path) {
Ok(location) => location,
Err(err) => {
self.status_message = format!("Download failed: {err}");
return;
}
};
self.download_in_progress = true;
self.status_message = format!("Downloading {root_path}…");
let tx = self.load_tx.clone();
let (progress_tx, progress_rx) = mpsc::channel();
let progress_forward_tx = tx.clone();
thread::spawn(move || {
while let Ok((objects_done, bytes_done, current_key)) = progress_rx.recv() {
let _ = progress_forward_tx.send(LoadMessage::DownloadProgress {
_store_index: store_index,
objects_done,
bytes_done,
current_key,
});
}
});
let progress_tx_for_callback = progress_tx.clone();
let progress: DownloadProgressCallback = Arc::new(move |update| {
let _ = progress_tx_for_callback.send((
update.objects_done,
update.bytes_done,
update.current_key,
));
});
thread::spawn(move || {
let result = download_s3_product(&bucket, &prefix, &dest_parent, Some(progress))
.map_err(|err| err.to_string());
drop(progress_tx);
let _ = tx.send(LoadMessage::DownloadReady {
_store_index: store_index,
result,
});
});
}
fn select_node(&mut self, store_index: usize, node: &ZarrTreeNode) {
self.selected = Some(SelectedNode {
store_index,
path: node.path.clone(),
});
let store = self.stores.get(store_index);
let root = store.map(|s| s.tree().root.clone());
let product_name = store
.as_ref()
.map(|s| Self::product_name(s))
.unwrap_or_else(|| "product".to_string());
let mut inspector = if let Some(root) = &root {
InspectorView::from_node_with_root(node, &product_name, Some(root))
} else {
InspectorView::from_node(node, &product_name)
};
if !matches!(node.kind, ZarrNodeKind::Array { .. }) {
inspector.clear_array_extras();
}
self.inspector = inspector;
if let ZarrNodeKind::Array {
shape,
attributes,
fill_value,
..
} = &node.kind
{
let product_name = store
.as_ref()
.map(|s| Self::product_name(s))
.unwrap_or_else(|| "product".to_string());
let (_, is_new) = self.plot_panel.open_or_focus(
store_index,
&node.path,
shape,
attributes,
fill_value.as_ref(),
&product_name,
);
if is_new {
self.start_pending_plot_loads();
}
}
}
fn start_pending_plot_loads(&mut self) -> bool {
let pending: Vec<(PlotSlotId, copernicus_viewer::plot::PlotRequest)> =
self.plot_panel.take_pending_requests();
let started = !pending.is_empty();
for (slot_id, request) in pending {
self.request_plot_load(slot_id, request);
}
started
}
fn request_plot_load(
&mut self,
slot_id: PlotSlotId,
request: copernicus_viewer::plot::PlotRequest,
) {
let Some(store_index) = self.plot_panel.store_index_for_slot(slot_id) else {
return;
};
let Some(store) = self.stores.get(store_index).cloned() else {
return;
};
let path = request.array_path.clone();
let Some(node) = store.tree().root.find_by_path(&path) else {
return;
};
let ZarrNodeKind::Array { .. } = &node.kind else {
return;
};
let kind = node.kind.clone();
let product = store.clone();
let tree = store.tree().root.clone();
let tx = self.load_tx.clone();
let (progress_tx, progress_rx) = mpsc::channel();
let progress_forward_tx = tx.clone();
let progress_forward_slot = slot_id;
thread::spawn(move || {
while let Ok((fraction, message)) = progress_rx.recv() {
let _ = progress_forward_tx.send(LoadMessage::PlotProgress {
slot_id: progress_forward_slot,
fraction,
message,
});
}
});
thread::spawn(move || {
let progress = shared_progress(progress_tx);
let result = load_plot_data(&product, &tree, &kind, &request, Some(progress))
.map_err(|e| e.to_string());
let _ = tx.send(LoadMessage::PlotReady {
slot_id,
store_index,
path,
result,
});
});
}
fn poll_background_tasks(&mut self, ctx: &egui::Context) -> bool {
let mut needs_repaint = false;
while let Ok(msg) = self.load_rx.try_recv() {
needs_repaint = true;
match msg {
LoadMessage::BrowseListReady {
request_id,
location,
result,
} => {
if request_id != self.open_product_dialog.browse_request_id {
continue;
}
if location != self.open_product_dialog.browser_location {
continue;
}
self.open_product_dialog.browse_loading = false;
self.open_product_dialog.browse_items = Some(result);
}
LoadMessage::StoreReady { location, result } => match result {
Ok(store) => {
let root_path = store.root_path().to_string();
if self
.stores
.iter()
.any(|existing| existing.root_path() == root_path)
{
self.status_message = format!("Already open: {root_path}");
continue;
}
let is_first = self.stores.is_empty();
self.stores.push(Arc::new(store));
let count = self.stores.len();
self.status_message = format!(
"Loaded {root_path} ({count} product{} open)",
if count == 1 { "" } else { "s" }
);
if is_first {
let root = self.stores[0].tree().root.clone();
self.select_node(0, &root);
}
}
Err(err) => {
self.status_message = format!("Failed to open {location}: {err}");
}
},
LoadMessage::PlotProgress {
slot_id,
fraction,
message,
} => {
self.plot_panel
.set_load_progress(slot_id, fraction, &message);
}
LoadMessage::PlotReady {
slot_id,
store_index,
path,
result,
} => {
match result {
Ok(loaded) => {
if self.selected.as_ref().is_some_and(|sel| {
sel.store_index == store_index && sel.path == path
}) {
self.inspector
.set_array_extras(loaded.stats.clone(), loaded.preview.clone());
}
self.plot_panel.set_load_result(slot_id, loaded);
}
Err(err) => self.plot_panel.set_error(slot_id, err),
}
}
LoadMessage::DownloadProgress {
objects_done,
bytes_done,
current_key,
..
} => {
if !self.download_in_progress {
continue;
}
let mib = bytes_done as f64 / (1024.0 * 1024.0);
self.status_message = format!(
"Downloading… {objects_done} objects ({mib:.1} MiB) — {current_key}"
);
}
LoadMessage::DownloadReady { result, .. } => {
self.download_in_progress = false;
match result {
Ok(path) => {
self.status_message = format!("Downloaded to {}", path.display());
}
Err(err) => {
self.status_message = format!("Download failed: {err}");
}
}
}
LoadMessage::ComparisonReady { result } => {
self.comparison.set_result(result);
if let Some(r) = &self.comparison.result() {
self.status_message = if r.success {
"Comparison finished: PASSED".to_string()
} else {
"Comparison finished: FAILED".to_string()
};
}
}
}
}
if self.start_pending_plot_loads() {
needs_repaint = true;
}
if needs_repaint {
ctx.request_repaint();
}
needs_repaint
}
fn node_is_selected(&self, store_index: usize, node: &ZarrTreeNode) -> bool {
self.selected
.as_ref()
.is_some_and(|sel| sel.store_index == store_index && sel.path == node.path)
}
fn tree_ui(&mut self, ui: &mut egui::Ui, store_index: usize, node: &ZarrTreeNode) {
if node.path == "/" {
for child in &node.children {
self.tree_ui(ui, store_index, child);
}
return;
}
let label = match &node.kind {
ZarrNodeKind::Group { .. } => format!("📁 {}", node.name),
ZarrNodeKind::Array { shape, dtype, .. } => {
if shape.is_empty() {
format!("📊 {} (scalar, {dtype})", node.name)
} else if shape.len() >= 2 {
format!(
"📊 {} [{}, {}] {dtype}",
node.name,
shape[shape.len() - 2],
shape[shape.len() - 1]
)
} else {
format!("📊 {} {:?} {dtype}", node.name, shape)
}
}
};
let id = format!("{store_index}{}", node.path);
if node.children.is_empty() {
let selected = self.node_is_selected(store_index, node);
let response = ui.selectable_label(selected, label);
if response.clicked() || response.double_clicked() {
self.select_node(store_index, node);
}
} else {
let default_open = node.path == "/measurements" || node.path == "/conditions";
let response = egui::CollapsingHeader::new(label)
.id_salt(id)
.default_open(default_open)
.show(ui, |ui| {
for child in &node.children {
self.tree_ui(ui, store_index, child);
}
});
if response.header_response.double_clicked() {
self.select_node(store_index, node);
}
}
}
fn products_tree_ui(&mut self, ui: &mut egui::Ui) {
if self.stores.is_empty() {
ui.label("No product loaded.");
return;
}
let products: Vec<(usize, String)> = self
.stores
.iter()
.enumerate()
.map(|(index, store)| (index, Self::product_name(store)))
.collect();
let mut to_close: Option<usize> = None;
let mut to_download: Option<usize> = None;
for (store_index, product_name) in products {
let root = self.stores[store_index].tree().root.clone();
let is_s3 = is_s3_product(self.stores[store_index].root_path());
ui.horizontal_top(|ui| {
ui.horizontal(|ui| {
if ui
.small_button("✕")
.on_hover_text("Close product")
.clicked()
{
to_close = Some(store_index);
}
if is_s3 {
ui.add_enabled_ui(!self.download_in_progress, |ui| {
if ui
.small_button("⬇")
.on_hover_text("Download product")
.clicked()
{
to_download = Some(store_index);
}
});
}
});
ui.vertical(|ui| {
ui.set_min_width(0.0);
let header_id = egui::Id::new(format!("product_{store_index}"));
let default_open = self.stores.len() <= 2;
let header = egui::collapsing_header::CollapsingState::load_with_default_open(
ui.ctx(),
header_id,
default_open,
)
.show_header(ui, |ui| {
ui.add(
egui::Label::new(format!("📦 {product_name}"))
.wrap_mode(egui::TextWrapMode::Wrap),
)
.on_hover_text(&product_name);
});
let (_toggle_response, header_inner, _body) = header.body(|ui| {
self.tree_ui(ui, store_index, &root);
});
header_inner.response.context_menu(|ui| {
if is_s3 {
ui.add_enabled_ui(!self.download_in_progress, |ui| {
if ui.button("Download product…").clicked() {
to_download = Some(store_index);
ui.close();
}
});
}
if ui.button("Close product").clicked() {
to_close = Some(store_index);
ui.close();
}
});
if header_inner.response.double_clicked() {
self.select_node(store_index, &root);
}
});
});
}
if let Some(store_index) = to_close {
self.close_product(store_index);
}
if let Some(store_index) = to_download {
self.request_product_download(store_index);
}
}
}
impl eframe::App for CopernicusViewer {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if let Some(demo) = &mut self.demo_capture {
demo.handle_events(ctx);
let action = demo.tick(ctx, &self.stores, &self.plot_panel, &self.comparison);
match action {
Some(crate::demo_capture::DemoAction::SelectLst) => {
if let Some(node) = self.stores[0]
.tree()
.root
.find_by_path("/measurements/lst")
.cloned()
{
self.select_node(0, &node);
}
}
Some(crate::demo_capture::DemoAction::RunComparison) => {
self.comparison.open_and_compare(0, 1, &self.stores);
}
Some(crate::demo_capture::DemoAction::Close) => {}
None => {}
}
}
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
let area = ui.clip_rect().size();
if area.x <= 0.0 || area.y <= 0.0 {
return;
}
self.poll_background_tasks(ui.ctx());
if let Some(pending) = self.pending_native_open.take() {
let kind = crate::platform::zarr_native_pick_for_hint(&pending.path_hint);
if let Some(path) = crate::platform::pick_zarr_product(frame, kind) {
self.open_product_dialog.show = false;
self.open_path(path.display().to_string());
}
}
if let Some(pending) = self.pending_download_pick.take() {
if let Some(dest_parent) = crate::platform::pick_download_folder(frame) {
self.start_download(pending.store_index, dest_parent);
} else {
self.status_message = "Download cancelled".to_string();
}
}
egui::Panel::top("menu").show_inside(ui, |ui| {
egui::MenuBar::new().ui(ui, |ui| {
ui.menu_button("File", |ui| {
if ui.button("Open Product…").clicked() {
self.show_open_product_dialog();
ui.close();
}
if ui.button("Configure S3…").clicked() {
self.show_s3_config_dialog();
ui.close();
}
ui.add_enabled_ui(!self.stores.is_empty(), |ui| {
if ui.button("Close product").clicked() {
self.close_product_for_selection();
ui.close();
}
});
ui.add_enabled_ui(self.can_download_selected_product(), |ui| {
if ui.button("Download product…").clicked() {
if let Some(sel) = &self.selected {
self.request_product_download(sel.store_index);
}
ui.close();
}
});
if ui.button("Quit").clicked() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
}
});
ui.menu_button("Tools", |ui| {
if ui.button("Comparison…").clicked() {
self.comparison.show(self.stores.len());
ui.close();
}
});
ui.menu_button("Help", |ui| {
if ui.button("About Copernicus Viewer…").clicked() {
self.help.show();
ui.close();
}
});
});
});
if !self.status_message.is_empty() {
egui::Panel::bottom("status")
.resizable(false)
.show_inside(ui, |ui| {
ui.label(&self.status_message);
});
}
egui::Panel::left("tree_panel")
.default_size(260.0)
.min_size(96.0)
.resizable(true)
.show_inside(ui, |ui| {
ui.heading("Hierarchy");
ui.separator();
ui.add(
egui::Label::new(
egui::RichText::new("Click a variable to inspect and plot.")
.small()
.weak(),
)
.wrap_mode(egui::TextWrapMode::Wrap),
);
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([true, false])
.show(ui, |ui| {
ui.set_min_width(0.0);
self.products_tree_ui(ui);
});
});
egui::Panel::left("inspector_panel")
.default_size(380.0)
.resizable(true)
.show_inside(ui, |ui| {
ui.heading("Inspector");
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
render_inspector(ui, &self.inspector);
});
});
egui::CentralPanel::default().show_inside(ui, |ui| {
let ctx = ui.ctx().clone();
self.plot_panel.ui(ui, &ctx);
if self.start_pending_plot_loads() {
ctx.request_repaint();
}
});
self.open_product_dialog_ui(ui.ctx());
self.comparison.ui(ui.ctx(), &self.stores);
if let Some((left_idx, right_idx, options, _verbose)) = self.comparison.take_pending_run() {
if left_idx < self.stores.len()
&& right_idx < self.stores.len()
&& left_idx != right_idx
{
let left_store = self.stores[left_idx].clone();
let right_store = self.stores[right_idx].clone();
let tx = self.load_tx.clone();
self.comparison.start_running();
self.status_message = format!(
"Comparing {} vs {}…",
Self::product_name(&left_store),
Self::product_name(&right_store)
);
thread::spawn(move || {
let result = compare_products_with_options(
left_store.as_ref(),
right_store.as_ref(),
&options,
);
let _ = tx.send(LoadMessage::ComparisonReady { result });
});
} else {
self.status_message = "Comparison request invalid (products changed)".to_string();
}
}
self.s3_config.ui(ui.ctx());
if self.s3_config.take_saved() {
self.on_s3_config_saved();
}
self.help.ui(ui.ctx());
}
}