use std::{
collections::{HashMap, HashSet},
fmt,
};
use crossterm::event::{KeyCode, KeyEvent};
use gen_core::HashId;
use gen_models::{
block_group::BlockGroup,
collection::Collection,
db::{GraphConnection, OperationsConnection},
file_types::FileTypes,
sample::Sample,
traits::Query,
};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Paragraph, StatefulWidget, Wrap},
};
use rusqlite::params;
use tui_widget_list::{ListBuilder, ListState, ListView};
use crate::{
config::get_theme_color,
views::{
annotation_files::{AnnotationFileEntry, load_annotation_file_entries},
annotation_groups::{AnnotationGroupEntry, load_annotation_group_entries},
},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FocusZone {
Canvas,
Panel,
Sidebar,
}
impl fmt::Display for FocusZone {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FocusZone::Canvas => write!(f, "canvas"),
FocusZone::Panel => write!(f, "panel"),
FocusZone::Sidebar => write!(f, "sidebar"),
}
}
}
fn normalize_collection_name(mut full_collection: &str) -> &str {
if full_collection == "/" {
return "/";
}
full_collection = full_collection.trim_end_matches('/');
if full_collection.is_empty() {
"/"
} else {
full_collection
}
}
fn collection_basename(full_collection: &str) -> &str {
let normalized = normalize_collection_name(full_collection);
if normalized == "/" {
return "/";
}
if let Some(idx) = normalized.rfind('/') {
&normalized[idx + 1..]
} else {
normalized
}
}
fn parent_collection(full_collection: &str) -> String {
let normalized = normalize_collection_name(full_collection);
if normalized == "/" {
return "/".to_string();
}
if let Some(idx) = normalized.rfind('/') {
if idx == 0 {
"/".to_string()
} else {
normalized[..idx].to_string()
}
} else {
".".to_string()
}
}
#[derive(Debug)]
pub struct CollectionExplorerData {
pub current_collection: String,
pub reference_block_groups: Vec<(gen_core::HashId, String)>,
pub collection_samples: Vec<String>,
pub sample_block_groups: HashMap<String, Vec<(gen_core::HashId, String)>>,
pub nested_collections: Vec<String>,
pub annotation_files: Vec<AnnotationFileEntry>,
pub annotation_groups: Vec<AnnotationGroupEntry>,
}
pub fn gather_collection_explorer_data(
conn: &GraphConnection,
op_conn: &OperationsConnection,
sample_name: Option<&str>,
full_collection_name: &str,
) -> CollectionExplorerData {
let current_collection = collection_basename(full_collection_name).to_string();
let _parent = parent_collection(full_collection_name);
let base_bgs = BlockGroup::query(
conn,
"SELECT * FROM block_groups
WHERE collection_name = ?1
AND sample_name IS NULL",
params![full_collection_name],
);
let reference_block_groups: Vec<(HashId, String)> =
base_bgs.iter().map(|bg| (bg.id, bg.name.clone())).collect();
let all_blocks = Collection::get_block_groups(conn, full_collection_name);
let mut sample_names: HashSet<String> = all_blocks
.iter()
.filter_map(|bg| bg.sample_name.clone())
.collect();
let mut collection_samples: Vec<String> = sample_names.drain().collect();
collection_samples.sort();
let mut sample_block_groups = HashMap::new();
for sample in &collection_samples {
let bgs = Sample::get_block_groups(conn, full_collection_name, Some(sample));
let pairs = bgs
.iter()
.map(|bg| (bg.id, bg.name.clone()))
.collect::<Vec<(HashId, String)>>();
sample_block_groups.insert(sample.clone(), pairs);
}
let direct_prefix = format!("{}{}", full_collection_name, "/");
let sibling_candidates = Collection::query(
conn,
"SELECT * FROM collections
WHERE name GLOB ?1",
params![format!("{}*", direct_prefix)],
);
let mut nested_collections = Vec::new();
for child in sibling_candidates {
let remainder = &child.name[direct_prefix.len()..];
if !remainder.is_empty() && !remainder.contains('/') {
nested_collections.push(remainder.to_string());
}
}
let annotation_files = load_annotation_file_entries(op_conn);
let annotation_groups = load_annotation_group_entries(conn, sample_name);
CollectionExplorerData {
current_collection,
reference_block_groups,
collection_samples,
sample_block_groups,
nested_collections,
annotation_files,
annotation_groups,
}
}
#[derive(Debug)]
pub enum ExplorerItem {
Collection {
name: String,
is_current: bool,
},
BlockGroup {
id: HashId,
name: String,
},
Sample {
name: String,
expanded: bool,
},
Header {
text: String,
},
AnnotationFile {
id: HashId,
display_name: String,
file_type: FileTypes,
active: bool,
},
AnnotationGroup {
name: String,
active: bool,
},
}
impl ExplorerItem {
pub fn is_selectable(&self) -> bool {
match self {
ExplorerItem::Collection { is_current, .. } => !is_current,
ExplorerItem::BlockGroup { .. } => true,
ExplorerItem::Sample { .. } => true,
ExplorerItem::Header { .. } => false,
ExplorerItem::AnnotationFile { .. } => true,
ExplorerItem::AnnotationGroup { .. } => true,
}
}
}
#[derive(Debug, Default)]
pub struct CollectionExplorerState {
pub list_state: ListState,
pub total_items: usize,
pub has_focus: bool,
pub selected_block_group_id: Option<HashId>,
expanded_samples: HashSet<String>,
pub focus_change_requested: Option<FocusZone>,
pub active_annotation_files: HashSet<HashId>,
pub active_annotation_groups: HashSet<String>,
pub annotation_file_toggle_requested: Option<HashId>,
pub annotation_group_toggle_requested: Option<String>,
}
impl CollectionExplorerState {
pub fn new() -> Self {
Self::with_selected_block_group(None)
}
pub fn with_selected_block_group(block_group_id: Option<HashId>) -> Self {
Self {
list_state: ListState::default(),
total_items: 0,
has_focus: false,
selected_block_group_id: block_group_id,
expanded_samples: HashSet::new(),
focus_change_requested: None,
active_annotation_files: HashSet::new(),
active_annotation_groups: HashSet::new(),
annotation_file_toggle_requested: None,
annotation_group_toggle_requested: None,
}
}
pub fn toggle_sample(&mut self, sample_name: &str) {
if self.expanded_samples.contains(sample_name) {
self.expanded_samples.remove(sample_name);
} else {
self.expanded_samples.insert(sample_name.to_string());
}
}
pub fn is_sample_expanded(&self, sample_name: &str) -> bool {
self.expanded_samples.contains(sample_name)
}
pub fn toggle_annotation_file(&mut self, id: HashId) {
if self.active_annotation_files.contains(&id) {
self.active_annotation_files.remove(&id);
} else {
self.active_annotation_files.insert(id);
}
}
pub fn deactivate_annotation_file(&mut self, id: &HashId) {
self.active_annotation_files.remove(id);
}
pub fn is_annotation_file_active(&self, id: &HashId) -> bool {
self.active_annotation_files.contains(id)
}
pub fn retain_annotation_files(
&mut self,
entries: &[crate::views::annotation_files::AnnotationFileEntry],
) {
let valid_ids: HashSet<HashId> =
entries.iter().map(|entry| entry.file_addition.id).collect();
self.active_annotation_files
.retain(|id| valid_ids.contains(id));
}
pub fn toggle_annotation_group(&mut self, name: &str) {
if self.active_annotation_groups.contains(name) {
self.active_annotation_groups.remove(name);
} else {
self.active_annotation_groups.insert(name.to_string());
}
}
pub fn deactivate_annotation_group(&mut self, name: &str) {
self.active_annotation_groups.remove(name);
}
pub fn is_annotation_group_active(&self, name: &str) -> bool {
self.active_annotation_groups.contains(name)
}
pub fn retain_annotation_groups(
&mut self,
entries: &[crate::views::annotation_groups::AnnotationGroupEntry],
) {
let valid: HashSet<String> = entries.iter().map(|entry| entry.name.clone()).collect();
self.active_annotation_groups
.retain(|name| valid.contains(name));
}
}
#[derive(Debug)]
pub struct CollectionExplorer {
pub data: CollectionExplorerData,
}
impl CollectionExplorer {
pub fn new(
conn: &GraphConnection,
op_conn: &gen_models::db::OperationsConnection,
sample_name: Option<&str>,
full_collection_name: &str,
) -> Self {
let data =
gather_collection_explorer_data(conn, op_conn, sample_name, full_collection_name);
Self { data }
}
pub fn refresh(
&mut self,
conn: &GraphConnection,
op_conn: &gen_models::db::OperationsConnection,
sample_name: Option<&str>,
full_collection_name: &str,
) -> bool {
let new_data =
gather_collection_explorer_data(conn, op_conn, sample_name, full_collection_name);
let changed = self.data.reference_block_groups.len()
!= new_data.reference_block_groups.len()
|| self.data.sample_block_groups != new_data.sample_block_groups;
self.data = new_data;
changed
}
pub fn annotation_file_entry(
&self,
id: &HashId,
) -> Option<&crate::views::annotation_files::AnnotationFileEntry> {
self.data
.annotation_files
.iter()
.find(|entry| entry.file_addition.id == *id)
}
pub fn force_reload(&self, state: &mut CollectionExplorerState) {
state.list_state = ListState::default();
state.list_state.selected = self.find_next_selectable(state, 0);
}
fn find_next_selectable(
&self,
state: &CollectionExplorerState,
from_idx: usize,
) -> Option<usize> {
let items = self.get_display_items(state);
items
.iter()
.enumerate()
.skip(from_idx)
.find(|(_, item)| item.is_selectable())
.map(|(i, _)| i)
.or_else(|| {
items
.iter()
.enumerate()
.take(from_idx)
.find(|(_, item)| item.is_selectable())
.map(|(i, _)| i)
})
}
fn find_prev_selectable(
&self,
state: &CollectionExplorerState,
from_idx: usize,
) -> Option<usize> {
let items = self.get_display_items(state);
items
.iter()
.enumerate()
.take(from_idx)
.rev()
.find(|(_, item)| item.is_selectable())
.map(|(i, _)| i)
.or_else(|| {
items
.iter()
.enumerate()
.skip(from_idx)
.rev()
.find(|(_, item)| item.is_selectable())
.map(|(i, _)| i)
})
}
pub fn next(&self, state: &mut CollectionExplorerState) {
let items = self.get_display_items(state);
if items.is_empty() {
return;
}
let current_idx = state.list_state.selected.unwrap_or(0);
state.list_state.selected = self.find_next_selectable(state, current_idx + 1);
}
pub fn previous(&self, state: &mut CollectionExplorerState) {
let items = self.get_display_items(state);
if items.is_empty() {
return;
}
let current_idx = state.list_state.selected.unwrap_or(0);
state.list_state.selected = self.find_prev_selectable(state, current_idx);
}
pub fn handle_input(&self, state: &mut CollectionExplorerState, key: KeyEvent) {
match key.code {
KeyCode::Up => self.previous(state),
KeyCode::Down => self.next(state),
KeyCode::Enter | KeyCode::Char(' ') => {
if let Some(selected_idx) = state.list_state.selected {
let items = self.get_display_items(state);
match &items[selected_idx] {
ExplorerItem::BlockGroup { id, .. } => {
state.selected_block_group_id = Some(*id);
state.focus_change_requested = Some(FocusZone::Canvas);
}
ExplorerItem::Sample { .. } => {
self.toggle_sample_expansion(state);
}
ExplorerItem::AnnotationFile { id, .. } => {
state.toggle_annotation_file(*id);
state.annotation_file_toggle_requested = Some(*id);
}
ExplorerItem::AnnotationGroup { name, .. } => {
state.toggle_annotation_group(name);
state.annotation_group_toggle_requested = Some(name.clone());
}
_ => {}
}
}
}
_ => {}
}
}
pub fn get_status_line() -> String {
"*â–¼ â–²* navigate | *return* select | *space* toggle".to_string()
}
fn get_display_items(&self, state: &CollectionExplorerState) -> Vec<ExplorerItem> {
let mut items = Vec::new();
items.push(ExplorerItem::Collection {
name: self.data.current_collection.clone(),
is_current: true,
});
items.push(ExplorerItem::Header {
text: String::new(),
});
items.push(ExplorerItem::Header {
text: "Reference graphs:".to_string(),
});
for (id, name) in &self.data.reference_block_groups {
items.push(ExplorerItem::BlockGroup {
id: *id,
name: name.clone(),
});
}
items.push(ExplorerItem::Header {
text: String::new(),
});
items.push(ExplorerItem::Header {
text: "Sample graphs:".to_string(),
});
for sample in &self.data.collection_samples {
items.push(ExplorerItem::Sample {
name: sample.clone(),
expanded: state.is_sample_expanded(sample),
});
if state.is_sample_expanded(sample)
&& let Some(block_groups) = self.data.sample_block_groups.get(sample)
{
for (id, name) in block_groups {
items.push(ExplorerItem::BlockGroup {
id: *id,
name: name.clone(),
});
}
}
}
items.push(ExplorerItem::Header {
text: String::new(),
});
items.push(ExplorerItem::Header {
text: "Nested Collections:".to_string(),
});
for collection in &self.data.nested_collections {
items.push(ExplorerItem::Collection {
name: collection.clone(),
is_current: false,
});
}
items.push(ExplorerItem::Header {
text: String::new(),
});
items.push(ExplorerItem::Header {
text: "Annotation Files:".to_string(),
});
for entry in &self.data.annotation_files {
let display_name = std::path::Path::new(&entry.file_addition.file_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&entry.file_addition.file_path)
.to_string();
items.push(ExplorerItem::AnnotationFile {
id: entry.file_addition.id,
display_name,
file_type: entry.file_addition.file_type,
active: state.is_annotation_file_active(&entry.file_addition.id),
});
}
if !self.data.annotation_groups.is_empty() {
items.push(ExplorerItem::Header {
text: String::new(),
});
items.push(ExplorerItem::Header {
text: "Annotation Groups:".to_string(),
});
for entry in &self.data.annotation_groups {
items.push(ExplorerItem::AnnotationGroup {
name: entry.name.clone(),
active: state.is_annotation_group_active(&entry.name),
});
}
}
items
}
pub fn toggle_sample_expansion(&self, state: &mut CollectionExplorerState) {
if let Some(selected_idx) = state.list_state.selected {
let items = self.get_display_items(state);
if let Some(ExplorerItem::Sample { name, .. }) = items.get(selected_idx) {
state.toggle_sample(name);
}
}
}
}
impl StatefulWidget for &CollectionExplorer {
type State = CollectionExplorerState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let items = self.get_display_items(state);
let mut display_items = Vec::new();
for item in &items {
let paragraph = match item {
ExplorerItem::Collection { name, is_current } => {
if *is_current {
Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled(
"Collection:",
Style::default().add_modifier(Modifier::UNDERLINED),
),
Span::raw(format!(" {}", name)),
]))
.wrap(Wrap { trim: false })
} else {
Paragraph::new(Line::from(vec![Span::raw(format!(" • {}", name))]))
.wrap(Wrap { trim: false })
}
}
ExplorerItem::BlockGroup { id, name, .. } => {
let is_reference = self
.data
.reference_block_groups
.iter()
.any(|(ref_id, _)| ref_id == id);
if is_reference {
Paragraph::new(Line::from(vec![Span::raw(format!(" • {}", name))]))
.wrap(Wrap { trim: false })
} else {
Paragraph::new(Line::from(vec![Span::raw(format!(" • {}", name))]))
.wrap(Wrap { trim: false })
}
}
ExplorerItem::Sample { name, expanded } => Paragraph::new(Line::from(vec![
Span::raw(if *expanded { " â–¼ " } else { " â–¶ " }),
Span::styled(name, Style::default()),
]))
.wrap(Wrap { trim: false }),
ExplorerItem::Header { text } => Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled(text, Style::default().add_modifier(Modifier::UNDERLINED)),
]))
.wrap(Wrap { trim: false }),
ExplorerItem::AnnotationFile {
display_name,
file_type,
active,
..
} => {
let checkbox = if *active { "[✓]" } else { "[ ]" };
let type_str = match file_type {
FileTypes::Gff3 => "gff3",
FileTypes::Bed => "bed",
_ => "other",
};
Paragraph::new(Line::from(vec![
Span::raw(format!(" {} ", checkbox)),
Span::styled(display_name, Style::default()),
Span::raw(format!(" ({})", type_str)),
]))
.wrap(Wrap { trim: false })
}
ExplorerItem::AnnotationGroup { name, active } => {
let checkbox = if *active { "[✓]" } else { "[ ]" };
Paragraph::new(Line::from(vec![
Span::raw(format!(" {} ", checkbox)),
Span::styled(name, Style::default()),
]))
.wrap(Wrap { trim: false })
}
};
display_items.push(paragraph);
}
let total_items = display_items.len();
let has_focus = state.has_focus;
let builder = ListBuilder::new(move |context| {
let item = display_items[context.index].clone();
let available_width = context.cross_axis_size;
let item_height = item.line_count(available_width) as u16;
if context.is_selected {
let style = if has_focus {
Style::default()
.fg(get_theme_color("text_muted").unwrap())
.bg(get_theme_color("highlight").unwrap())
} else {
Style::default()
.fg(get_theme_color("text").unwrap())
.bg(get_theme_color("highlight_muted").unwrap())
};
(item.style(style), item_height)
} else {
(item, item_height)
}
});
let list = ListView::new(builder, total_items).block(Block::default());
state.total_items = total_items;
if state.list_state.selected.is_none() || state.list_state.selected.unwrap() >= total_items
{
state.list_state.selected = if let Some(ref block_group_id) =
state.selected_block_group_id
{
self.get_display_items(state).iter()
.enumerate()
.find(|(_, item)| matches!(item, ExplorerItem::BlockGroup { id, .. } if id == block_group_id))
.map(|(i, _)| i)
.or_else(|| self.find_next_selectable(state, 0))
} else {
self.find_next_selectable(state, 0)
};
}
list.render(area, buf, &mut state.list_state);
}
}
#[cfg(test)]
mod tests {
use gen_models::{block_group::BlockGroup, sample::Sample};
use super::*;
use crate::test_helpers::setup_gen;
#[test]
fn test_gather_collection_explorer_data() {
let context = setup_gen();
let conn = context.graph().conn();
Collection::create(conn, "/foo/bar");
Collection::create(conn, "/foo/bar/a");
Collection::create(conn, "/foo/bar/a/b");
Collection::create(conn, "/foo/bar2");
Collection::create(conn, "/foo/baz");
let sample_alpha = Sample::get_or_create(conn, "SampleAlpha");
let sample_beta = Sample::get_or_create(conn, "SampleBeta");
BlockGroup::create(conn, "/foo/bar", None, "BG_ReferenceA");
BlockGroup::create(conn, "/foo/bar", None, "BG_ReferenceB");
BlockGroup::create(conn, "/foo/bar", Some(&sample_alpha.name), "BG_Alpha1");
BlockGroup::create(conn, "/foo/bar", Some(&sample_beta.name), "BG_Beta1");
let op_conn = context.operations().conn();
let explorer_data = gather_collection_explorer_data(conn, op_conn, None, "/foo/bar");
assert_eq!(explorer_data.current_collection, "bar");
let base_names: Vec<_> = explorer_data
.reference_block_groups
.iter()
.map(|(_, name)| name.clone())
.collect();
assert_eq!(base_names.len(), 2);
assert!(base_names.contains(&"BG_ReferenceA".to_string()));
assert!(base_names.contains(&"BG_ReferenceB".to_string()));
assert_eq!(explorer_data.collection_samples.len(), 2);
assert!(
explorer_data
.collection_samples
.contains(&"SampleAlpha".to_string())
);
assert!(
explorer_data
.collection_samples
.contains(&"SampleBeta".to_string())
);
let alpha_bg = explorer_data
.sample_block_groups
.get("SampleAlpha")
.unwrap();
let alpha_bg_names: Vec<_> = alpha_bg.iter().map(|(_, n)| n.clone()).collect();
assert_eq!(alpha_bg_names, vec!["BG_Alpha1".to_string()]);
let beta_bg = explorer_data.sample_block_groups.get("SampleBeta").unwrap();
let beta_bg_names: Vec<_> = beta_bg.iter().map(|(_, n)| n.clone()).collect();
assert_eq!(beta_bg_names, vec!["BG_Beta1".to_string()]);
assert_eq!(explorer_data.nested_collections, vec!["a".to_string()]);
}
#[test]
fn test_trailing_delimiter_behavior() {
assert_eq!(normalize_collection_name("/foo/bar/"), "/foo/bar");
assert_eq!(normalize_collection_name("////"), "/");
assert_eq!(normalize_collection_name("/"), "/");
assert_eq!(collection_basename("/foo/bar/"), "bar");
assert_eq!(collection_basename("////"), "/");
assert_eq!(collection_basename("/"), "/");
assert_eq!(parent_collection("/foo/bar/"), "/foo");
assert_eq!(parent_collection("/foo/"), "/");
assert_eq!(parent_collection("////"), "/");
assert_eq!(parent_collection("bar"), ".");
}
}