diff --git a/.gitignore b/.gitignore index f0191f78..0fcb4d35 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ fixtures/*.ipynb # IDE .vscode/* flamegraph.svg + +# Terminal recording via VHS +*.tape diff --git a/Cargo.lock b/Cargo.lock index 8335134e..b00755ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1249,6 +1249,7 @@ dependencies = [ "tempfile", "thiserror 1.0.69", "tui-textarea", + "tui-widget-list", ] [[package]] @@ -4542,6 +4543,15 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "tui-widget-list" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a417c8ac65119a0e5b8627d0ce492731b95affb29ec8602d9e72646bda5ecf6" +dependencies = [ + "ratatui", +] + [[package]] name = "twox-hash" version = "1.6.3" diff --git a/Cargo.toml b/Cargo.toml index 915ffc42..42311f6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ crossterm = "0.28.1" log = "0.4.25" env_logger = "0.11.6" tui-textarea = "0.7.0" +tui-widget-list = "0.13.2" [dev-dependencies] cargo-llvm-cov = "0.6.14" diff --git a/src/main.rs b/src/main.rs index 9c7e33f3..2d07ee55 100644 --- a/src/main.rs +++ b/src/main.rs @@ -724,7 +724,7 @@ fn main() { .id, ); if *interactive { - view_operations(&operation_conn, &operations); + view_operations(&conn, &operation_conn, &operations); } else { let mut indicator = ""; println!( diff --git a/src/models/block_group.rs b/src/models/block_group.rs index 2e3252de..12d93eca 100644 --- a/src/models/block_group.rs +++ b/src/models/block_group.rs @@ -183,6 +183,17 @@ impl BlockGroup { } } + pub fn get_by_ids(conn: &Connection, ids: &[i64]) -> Vec { + let query = "SELECT * FROM block_groups WHERE id IN rarray(?1)"; + Self::query( + conn, + query, + params!(Rc::new( + ids.iter().map(|i| SQLValue::from(*i)).collect::>() + )), + ) + } + pub fn clone(conn: &Connection, source_block_group_id: i64, target_block_group_id: i64) { let existing_paths = Path::query( conn, diff --git a/src/models/operations.rs b/src/models/operations.rs index c2f7a79a..ecc509f5 100644 --- a/src/models/operations.rs +++ b/src/models/operations.rs @@ -1,14 +1,22 @@ -use crate::graph::{all_simple_paths, OperationGraph}; +use crate::graph::{all_simple_paths, GraphEdge, GraphNode, OperationGraph}; use crate::models::file_types::FileTypes; use crate::models::traits::*; -use petgraph::graphmap::UnGraphMap; +use crate::operation_management::{ + load_changeset, load_changeset_dependencies, load_changeset_models, +}; +use crate::views::patch::get_change_graph; +use petgraph::graphmap::{DiGraphMap, UnGraphMap}; use petgraph::visit::{Dfs, Reversed}; use petgraph::Direction; +use rusqlite::session::ChangesetIter; use rusqlite::types::Value; +use rusqlite::Error as SQLError; use rusqlite::{params, params_from_iter, Connection, Result as SQLResult, Row}; use serde::{Deserialize, Serialize}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use std::io::Read; use std::string::ToString; +use thiserror::Error; #[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] pub struct Operation { @@ -19,6 +27,12 @@ pub struct Operation { pub change_type: String, } +#[derive(Debug, Error, PartialEq)] +pub enum OperationError { + #[error("SQLite Error: {0}")] + SqliteError(#[from] SQLError), +} + impl Operation { pub fn create( conn: &Connection, @@ -106,6 +120,23 @@ impl Operation { graph } + pub fn get_change_graph( + conn: &Connection, + hash: &str, + ) -> Result>, OperationError> { + let operation = Operation::get_by_hash(conn, hash)?; + + let changeset = load_changeset(&operation); + let dependencies = load_changeset_dependencies(&operation); + + let input: &mut dyn Read = &mut changeset.as_slice(); + let mut iter = ChangesetIter::start_strm(&input).unwrap(); + + let new_models = load_changeset_models(&mut iter); + + Ok(get_change_graph(&new_models, &dependencies)) + } + pub fn get_path_between( conn: &Connection, source_id: &str, @@ -160,19 +191,11 @@ impl Operation { patch_path } - pub fn get(conn: &Connection, query: &str, placeholders: Vec) -> SQLResult { - let mut stmt = conn.prepare(query).unwrap(); - let mut rows = stmt.query_map(params_from_iter(placeholders), |row| { - Ok(Self::process_row(row)) - })?; - rows.next().unwrap() - } - pub fn get_by_hash(conn: &Connection, op_hash: &str) -> SQLResult { Operation::get( conn, "select * from operation where hash LIKE ?1", - vec![Value::from(format!("{op_hash}%"))], + params![Value::from(format!("{op_hash}%"))], ) } } diff --git a/src/views.rs b/src/views.rs index cece2bec..c30517b0 100644 --- a/src/views.rs +++ b/src/views.rs @@ -1,5 +1,6 @@ pub mod block_group; pub mod block_group_viewer; pub mod block_layout; +pub mod collection; pub mod operations; pub mod patch; diff --git a/src/views/block_group.rs b/src/views/block_group.rs index de1bd435..feb79ce3 100644 --- a/src/views/block_group.rs +++ b/src/views/block_group.rs @@ -1,22 +1,22 @@ use crate::models::{block_group::BlockGroup, node::Node, traits::Query}; -use crate::views::block_group_viewer::{NavDirection, PlotParameters, Viewer}; +use crate::progress_bar::{get_handler, get_time_elapsed_bar}; +use crate::views::block_group_viewer::{PlotParameters, Viewer}; +use crate::views::collection::{CollectionExplorer, CollectionExplorerState}; use rusqlite::{params, Connection}; -use core::panic; -use std::error::Error; -use std::time::{Duration, Instant}; - use crossterm::{ event::{self, KeyCode, KeyEventKind, KeyModifiers}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{ - layout::{Constraint, Rect}, - style::{Color, Style}, - text::Text, + layout::Constraint, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, widgets::{Block, Clear, Padding, Paragraph, Wrap}, }; +use std::error::Error; +use std::time::{Duration, Instant}; pub fn view_block_group( conn: &Connection, @@ -25,6 +25,9 @@ pub fn view_block_group( collection_name: &str, position: Option, // Node ID and offset ) -> Result<(), Box> { + let progress_bar = get_handler(); + let bar = progress_bar.add(get_time_elapsed_bar()); + let _ = progress_bar.println("Loading block group"); // Get the block group for two cases: with and without a sample let block_group = if let Some(ref sample_name) = sample_name { BlockGroup::get(conn, "select * from block_groups where collection_name = ?1 AND sample_name = ?2 AND name = ?3", @@ -43,7 +46,10 @@ pub fn view_block_group( ); } - // Get the node object corresponding to a node id + let block_group = block_group.unwrap(); + let block_group_id = block_group.id; + + // Get the node object corresponding to the position given by the user let origin = if let Some(position_str) = position { let parts = position_str.split(":").collect::>(); if parts.len() != 2 { @@ -58,17 +64,18 @@ pub fn view_block_group( } else { None }; + bar.finish(); - let block_group_id = block_group.unwrap().id; - let block_graph = BlockGroup::get_graph(conn, block_group_id); - - // Create the viewer - println!("Pre-calculating chunked layout..."); + // Create the viewer and the initial graph + let bar = progress_bar.add(get_time_elapsed_bar()); + let _ = progress_bar.println("Pre-computing layout in chunks"); + let mut block_graph = BlockGroup::get_graph(conn, block_group_id); let mut viewer = if let Some(origin) = origin { Viewer::with_origin(&block_graph, conn, PlotParameters::default(), origin) } else { Viewer::new(&block_graph, conn, PlotParameters::default()) }; + bar.finish(); // Setup terminal enable_raw_mode()?; @@ -80,72 +87,185 @@ pub fn view_block_group( let tick_rate = Duration::from_millis(100); let mut last_tick = Instant::now(); let mut show_panel = false; + let show_sidebar = true; let mut tui_layout_change = false; + + // Focus management + let mut focus_zone = "canvas"; + + // Create explorer and its state that persists across frames + let mut explorer = CollectionExplorer::new(conn, collection_name); + let mut explorer_state = + CollectionExplorerState::with_selected_block_group(Some(block_group_id)); + if let Some(ref s) = sample_name { + explorer_state.toggle_sample(s); + } + + // Track the last selected block group to detect changes + let mut last_selected_block_group_id = Some(block_group_id); + let mut is_loading = false; + loop { + // Refresh explorer data and force reload on change + // TODO: this doesn't work as expected, and I don't understand why + if explorer.refresh(conn, collection_name) { + explorer.force_reload(&mut explorer_state); + } + + // Trigger reload if selection changed to a new block group + if explorer_state.selected_block_group_id != last_selected_block_group_id { + is_loading = true; + last_selected_block_group_id = explorer_state.selected_block_group_id; + } + // Draw the UI terminal.draw(|frame| { - // A layout consisting of a canvas and a status bar, with optionally a panel - // - The canvas is where the graph is drawn - // - The status bar is where the controls are displayed - // - The panel is a scrollable paragraph that can be toggled on and off let status_bar_height: u16 = 1; - // The outer layout is a vertical split between the canvas and the status bar + // The outer layout is a vertical split between the status bar and everything else let outer_layout = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) - .constraints(vec! - [ - ratatui::layout::Constraint::Min(1), - ratatui::layout::Constraint::Length(status_bar_height), - ] - ) + .constraints(vec![ + ratatui::layout::Constraint::Min(1), + ratatui::layout::Constraint::Length(status_bar_height), + ]) .split(frame.area()); + let status_bar_area = outer_layout[1]; - // The inner layout is a vertical split between the canvas and the panel - let inner_layout = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints(vec! - [ - Constraint::Percentage(75), - Constraint::Percentage(25), - ] - ) + // The sidebar is a horizontal split of the area above the status bar + let sidebar_layout = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Horizontal) + .constraints(vec![Constraint::Percentage(20), Constraint::Percentage(80)]) .split(outer_layout[0]); + let sidebar_area = sidebar_layout[0]; - let canvas_area = if show_panel { inner_layout[0] } else { outer_layout[0] }; - let panel_area = if show_panel { inner_layout[1] } else { Rect::default() }; - let status_bar_area = outer_layout[1]; + // The panel pops up in the canvas area, it does not overlap with the sidebar + let panel_layout = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints(vec![Constraint::Percentage(80), Constraint::Percentage(20)]) + .split(sidebar_layout[1]); + let panel_area = panel_layout[1]; + + let canvas_area = if show_panel { + panel_layout[0] + } else { + sidebar_layout[1] + }; - // Ask the viewer to paint the canvas - viewer.paint_canvas(frame, canvas_area); + // Sidebar + explorer_state.has_focus = focus_zone == "sidebar"; + if show_sidebar { + let sidebar_block = Block::default() + .padding(Padding::new(0, 0, 1, 1)) + .style(Style::default().bg(Color::Indexed(233))); + let sidebar_content_area = sidebar_block.inner(sidebar_area); + + frame.render_widget(sidebar_block.clone(), sidebar_area); + frame.render_stateful_widget(&explorer, sidebar_content_area, &mut explorer_state); + } // Status bar + let mut status_message = match focus_zone { + "canvas" => Viewer::get_status_line(), + "panel" => "esc: close panel".to_string(), + "sidebar" => CollectionExplorer::get_status_line(), + _ => "".to_string(), + }; + + // Add focus controls to status message + status_message.push_str(" | tab: cycle focus | q: quit"); + let status_bar_contents = format!( - "{:width$}", - "◀ ▼ ▲ ▶ select blocks (+shift/alt to scroll) | +/- zoom | return: show information on block | q=quit", - width = status_bar_area.width as usize); + "{status_message:^width$}", + width = status_bar_area.width as usize + ); - let status_bar = Paragraph::new(Text::styled(status_bar_contents, - Style::default().bg(Color::DarkGray).fg(Color::White))); + let status_bar = Paragraph::new(Text::styled( + status_bar_contents, + Style::default().bg(Color::Black).fg(Color::DarkGray), + )); frame.render_widget(status_bar, status_bar_area); + // Canvas area + if is_loading { + // Draw loading message in canvas area + let loading_text = Text::styled( + "Loading...", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ); + let loading_para = + Paragraph::new(loading_text).alignment(ratatui::layout::Alignment::Center); + + // Center the loading message vertically in the canvas area + let loading_area = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ + ratatui::layout::Constraint::Percentage(45), + ratatui::layout::Constraint::Length(1), + ratatui::layout::Constraint::Percentage(45), + ]) + .split(canvas_area)[1]; + + frame.render_widget(Clear, canvas_area); // Clear the canvas area first + frame.render_widget(loading_para, loading_area); + } else { + // Ask the viewer to paint the canvas + viewer.has_focus = focus_zone == "canvas"; + viewer.draw(frame, canvas_area); + } + // Panel if show_panel { - let panel_block = Block::bordered().padding(Padding::new(2, 2, 1, 1)).title("Details"); - let mut panel_text = Text::from("No content found"); - - // Get information about the currently selected block - if viewer.state.selected_block.is_some() { - let selected_block = viewer.state.selected_block.unwrap(); - panel_text = Text::from(format!("Block ID: {}\nNode ID: {}\nStart: {}\nEnd: {}\n", - selected_block.block_id, selected_block.node_id, selected_block.sequence_start, selected_block.sequence_end)); - } + let panel_block = Block::bordered() + .padding(Padding::new(2, 2, 1, 1)) + .title("Details") + .style(Style::default().bg(Color::Indexed(233))) + .border_style(if focus_zone == "panel" { + Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }); + + let panel_text = if let Some(selected_block) = viewer.state.selected_block { + vec![ + Line::from(vec![ + Span::styled( + "Block ID: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(selected_block.block_id.to_string()), + ]), + Line::from(vec![ + Span::styled( + "Node ID: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(selected_block.node_id.to_string()), + ]), + Line::from(vec![ + Span::styled("Start: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(selected_block.sequence_start.to_string()), + ]), + Line::from(vec![ + Span::styled("End: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(selected_block.sequence_end.to_string()), + ]), + ] + } else { + vec![Line::from(vec![Span::styled( + "No block selected", + Style::default().fg(Color::DarkGray), + )])] + }; let panel_content = Paragraph::new(panel_text) .wrap(Wrap { trim: true }) - .scroll((0, 0)) - .style(Style::default().bg(Color::Reset)) + .alignment(ratatui::layout::Alignment::Left) .block(panel_block); // Clear the panel area if we just changed the layout @@ -159,6 +279,18 @@ pub fn view_block_group( } })?; + // After drawing, update the viewer if needed + if is_loading { + if let Some(new_block_group_id) = explorer_state.selected_block_group_id { + // Create a new graph for the selected block group + block_graph = BlockGroup::get_graph(conn, new_block_group_id); + // Update the viewer + viewer = Viewer::new(&block_graph, conn, PlotParameters::default()); + viewer.state.selected_block = None; + is_loading = false; + } + } + // Handle input let timeout = tick_rate .checked_sub(last_tick.elapsed()) @@ -166,129 +298,67 @@ pub fn view_block_group( if crossterm::event::poll(timeout)? { if let event::Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { - // Exit on q - if key.code == KeyCode::Char('q') { - break; - } + // Global handlers match key.code { - // Scrolling through the graph - KeyCode::Left => { - if key.modifiers.contains(KeyModifiers::SHIFT) { - viewer.state.offset_x -= viewer.state.viewport.width as i32 / 3; - viewer.unselect_if_not_visible(); - } else if key.modifiers.contains(KeyModifiers::ALT) { - viewer.state.offset_x -= 1; - } else { - // If no block is selected, select the center block - if viewer.state.selected_block.is_none() { - viewer.select_center_block(); - } - viewer.move_selection(NavDirection::Left); - } - } - KeyCode::Right => { - if key.modifiers.contains(KeyModifiers::SHIFT) { - viewer.state.offset_x += viewer.state.viewport.width as i32 / 3; - viewer.unselect_if_not_visible(); - } else if key.modifiers.contains(KeyModifiers::ALT) { - viewer.state.offset_x += 1; - } else { - if viewer.state.selected_block.is_none() { - viewer.select_center_block(); - } - viewer.move_selection(NavDirection::Right); - } - } - KeyCode::Up => { - if key.modifiers.contains(KeyModifiers::SHIFT) { - viewer.state.offset_y += viewer.state.viewport.height as i32 / 3; - viewer.unselect_if_not_visible(); - } else if key.modifiers.contains(KeyModifiers::ALT) { - viewer.state.offset_y += 1; - } else { - if viewer.state.selected_block.is_none() { - viewer.select_center_block(); - } - viewer.move_selection(NavDirection::Down); - } - } - KeyCode::Down => { - if key.modifiers.contains(KeyModifiers::SHIFT) { - viewer.state.offset_y -= viewer.state.viewport.height as i32 / 3; - viewer.unselect_if_not_visible(); - } else if key.modifiers.contains(KeyModifiers::ALT) { - viewer.state.offset_y -= 1; + KeyCode::Char('q') => break, + KeyCode::Tab => { + if key.modifiers == KeyModifiers::SHIFT { + // Shift+Tab - cycle backwards + focus_zone = match focus_zone { + "canvas" => "sidebar", + "sidebar" => { + if show_panel { + "panel" + } else { + "canvas" + } + } + "panel" => "canvas", + _ => "canvas", + }; } else { - if viewer.state.selected_block.is_none() { - viewer.select_center_block(); - } - viewer.move_selection(NavDirection::Up); + // Tab - cycle forwards + focus_zone = match focus_zone { + "canvas" => { + if show_panel { + "panel" + } else { + "sidebar" + } + } + "sidebar" => "canvas", + "panel" => "sidebar", + _ => "canvas", + }; } + continue; } - // Zooming in and out - KeyCode::Char('+') | KeyCode::Char('=') => { - // Increase how much of the sequence is shown in each block label. - if viewer.parameters.label_width == u32::MAX { - viewer.parameters.scale += 1; - } else { - viewer.parameters.label_width = match viewer.parameters.label_width - { - 1 => 11, - 11 => 100, - 100 => u32::MAX, - _ => u32::MAX, + _ => {} + } + + // Focus-specific handlers + match focus_zone { + "canvas" => match key.code { + KeyCode::Enter => { + if viewer.state.selected_block.is_some() { + show_panel = true; + focus_zone = "panel"; + tui_layout_change = true; } } - - // If no block is selected, select the center block - if viewer.state.selected_block.is_none() { - viewer.select_center_block(); + _ => { + viewer.handle_input(key); } - - // Recalculate the layout. - viewer - .scaled_layout - .refresh(&viewer.base_layout, &viewer.parameters); - viewer.center_on_block(viewer.state.selected_block.unwrap()); - } - KeyCode::Char('-') | KeyCode::Char('_') => { - // Decrease how much of the sequence is shown in each block label. - if viewer.parameters.scale > 2 { - viewer.parameters.scale -= 1; - } else { - viewer.parameters.label_width = match viewer.parameters.label_width - { - u32::MAX => 100, - 100 => 11, - 11 => 1, - _ => 1, - }; - } - - // If no block is selected, select the center block - if viewer.state.selected_block.is_none() { - viewer.select_center_block(); + }, + "panel" => { + if key.code == KeyCode::Esc { + show_panel = false; + focus_zone = "canvas"; + tui_layout_change = true; } - - // Recalculate the layout. - viewer - .scaled_layout - .refresh(&viewer.base_layout, &viewer.parameters); - viewer.center_on_block(viewer.state.selected_block.unwrap()); - } - // Performing actions on blocks - KeyCode::Tab => { - // Future implementation: switch between panels - } - KeyCode::BackTab => { - // Future implementation: switch between panels - } - KeyCode::Esc => { - show_panel = false; } - KeyCode::Enter => { - // Show information on the selected block, if there is one - show_panel = viewer.state.selected_block.is_some(); + "sidebar" => { + explorer.handle_input(&mut explorer_state, key); } _ => {} } diff --git a/src/views/block_group_viewer.rs b/src/views/block_group_viewer.rs index a60f7342..db2ed83c 100644 --- a/src/views/block_group_viewer.rs +++ b/src/views/block_group_viewer.rs @@ -4,7 +4,8 @@ use crate::models::node::Node; use crate::models::sequence::Sequence; use crate::views::block_layout::{BaseLayout, ScaledLayout}; -use core::panic; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use log::warn; use petgraph::graphmap::DiGraphMap; use petgraph::Direction; use ratatui::{ @@ -17,6 +18,8 @@ use ratatui::{ use rusqlite::Connection; use std::collections::{HashMap, HashSet}; +// Show more visual information if DEBUG +const DEBUG: bool = false; /// Labels used in the graph visualization (selected, not-selected) /// the trick is to get them to align with the braille characters /// we use to draw lines: @@ -27,6 +30,15 @@ pub mod label { pub const NODE: &str = "⏺"; } +/// Used for scrolling through the graph. +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum NavDirection { + Left, + Right, + Up, + Down, +} + /// Holds parameters that don't change when you scroll. /// - `label_width` = how many characters to show at most in each block label. If 0, labels are not shown. /// - `scale` = data units per 1 terminal cell. @@ -34,12 +46,19 @@ pub mod label { /// - If `scale` = 2.0, each cell is 2 data units (you see *more* data). /// - If `scale` = 0.5, each cell is 0.5 data units (you see *less* data, zoomed in). /// - `aspect_ratio` = width / height of a terminal cell in data units. -/// - `vertical_offset` = how much to offset the lines vertically to align the braille characters with the labels +/// - `line_offset_y` = how much to offset the lines vertically to align the braille characters with the labels +/// - `edge_style` = draw the edges as straight lines or as splines. pub struct PlotParameters { pub label_width: u32, pub scale: u32, pub aspect_ratio: f32, pub line_offset_y: f64, + pub edge_style: EdgeStyle, +} + +pub enum EdgeStyle { + Straight, + Spline, } impl Default for PlotParameters { @@ -49,6 +68,7 @@ impl Default for PlotParameters { scale: 4, aspect_ratio: 0.5, line_offset_y: 0.125, + edge_style: EdgeStyle::Straight, } } } @@ -61,6 +81,7 @@ pub struct State { pub offset_x: i32, pub offset_y: i32, pub viewport: Rect, + pub world: ((f64, f64), (f64, f64)), // (min_x, min_y), (max_x, max_y) pub selected_block: Option, pub first_render: bool, } @@ -70,19 +91,13 @@ impl Default for State { offset_x: 0, offset_y: 0, viewport: Rect::new(0, 0, 0, 0), + world: ((0.0, 0.0), (0.0, 0.0)), selected_block: None, first_render: true, } } } -pub enum NavDirection { - Left, - Right, - Up, - Down, -} - pub struct Viewer<'a> { pub block_graph: &'a DiGraphMap, pub conn: &'a Connection, @@ -92,6 +107,8 @@ pub struct Viewer<'a> { pub state: State, pub parameters: PlotParameters, pub origin_block: Option, + view_block: Block<'a>, + pub has_focus: bool, } impl<'a> Viewer<'a> { @@ -148,6 +165,8 @@ impl<'a> Viewer<'a> { state: State::default(), parameters: plot_parameters, origin_block, + view_block: Block::default(), + has_focus: false, } } @@ -155,15 +174,37 @@ impl<'a> Viewer<'a> { pub fn refresh(&mut self) { self.scaled_layout .refresh(&self.base_layout, &self.parameters); + self.state.world = self.compute_bounding_box(); + } + + pub fn set_block(&mut self, block: Block<'a>) { + self.view_block = block; } /// Check if a block is visible in the viewport. pub fn is_block_visible(&self, block: GraphNode) -> bool { - if let Some(((x, y), _)) = self.scaled_layout.labels.get(&block) { - return (*y as i32) >= self.state.offset_y - && (*y as i32) < self.state.offset_y + self.state.viewport.height as i32 - && (*x as i32) >= self.state.offset_x - && (*x as i32) < self.state.offset_x + self.state.viewport.width as i32; + if let Some(((x1, y), (x2, _))) = self.scaled_layout.labels.get(&block) { + // Check vertical overlap first (simpler) + let y_visible = (*y as i32) >= self.state.offset_y + && (*y as i32) < self.state.offset_y + self.state.viewport.height as i32; + + if !y_visible { + return false; + } + + // Check horizontal overlap + // Block is visible if either: + // 1. Start point is in viewport + // 2. End point is in viewport + // 3. Block spans the entire viewport + let x1_in_view = (*x1 as i32) >= self.state.offset_x + && (*x1 as i32) < self.state.offset_x + self.state.viewport.width as i32; + let x2_in_view = (*x2 as i32) >= self.state.offset_x + && (*x2 as i32) < self.state.offset_x + self.state.viewport.width as i32; + let spans_viewport = (*x1 as i32) <= self.state.offset_x + && (*x2 as i32) >= self.state.offset_x + self.state.viewport.width as i32; + + return x1_in_view || x2_in_view || spans_viewport; } false } @@ -177,21 +218,21 @@ impl<'a> Viewer<'a> { } } - /// Center the viewport on a specific block with minimal whitespace around the layout bounds. - /// Only show at most 5 units of margin on the left side. - pub fn center_on_block(&mut self, block: GraphNode) { + /// Center the viewport on a specific block. + pub fn center_on_block(&mut self, block: GraphNode) -> Result<(f64, f64), String> { if let Some(((start, y), (end, _))) = self.scaled_layout.labels.get(&block) { - let block_center_x = (start + end) / 2.0; - let block_center_y = *y; - self.update_scroll_for_cursor(block_center_x, block_center_y); + let cursor_x = (start + end) / 2.0; + let cursor_y = *y; + self.update_scroll_for_cursor(cursor_x, cursor_y); + Ok((cursor_x, cursor_y)) } else { - panic!("Block ID {:?} not found in layout", block); + Err(format!("Block ID {:?} not found in layout", block)) } } /// Draw and render blocks and lines to a canvas through a scrollable window. /// TODO: turn this into the render function of a custom stateful widget - pub fn paint_canvas(&mut self, frame: &mut ratatui::Frame, area: Rect) { + pub fn draw(&mut self, frame: &mut ratatui::Frame, area: Rect) { // Set up the coordinate systems for the window and the canvas, // we need to keep a 1:1 mapping between coordinates to avoid glitches. @@ -201,10 +242,9 @@ impl<'a> Viewer<'a> { // // From the data's perspective, the viewport is a moving window defined by a width, height, and // offset from the data origin. When offset_x = 0 and offset_y = 0, the bottom-left corner of the - // viewport has data coordinates (0,0). We must keep a 1:1 mapping between the size of the viewport - // in data units and in terminal cells to avoid glitches. + // viewport has data coordinates (0,0) and the y-axis points upwards. - let canvas_block = Block::default(); + let canvas_block = self.view_block.clone(); let viewport = canvas_block.inner(area); // Check if the viewport has changed size, and if so, update the offset to keep our reference @@ -239,7 +279,9 @@ impl<'a> Viewer<'a> { } else { self.state.selected_block = Some(origin); } - self.center_on_block(self.state.selected_block.unwrap()); + self.state.world = self.compute_bounding_box(); + self.center_on_block(self.state.selected_block.unwrap()) + .unwrap(); self.state.first_render = false; } } @@ -258,26 +300,116 @@ impl<'a> Viewer<'a> { (self.state.offset_y + self.state.viewport.height as i32) as f64, ]) .paint(|ctx| { - // Draw the lines described in the processed layout - for &((x1, y1), (x2, y2)) in self.scaled_layout.lines.iter() { - // Clip the line to the visible area, skip if it's not visible itself - if let Some(((x1c, y1c), (x2c, y2c))) = clip_line( - (x1, y1 + self.parameters.line_offset_y), - (x2, y2 + self.parameters.line_offset_y), + if DEBUG { + // Show cartesian axes + coordinates of all nodes + let ((x_min, y_min), (x_max, y_max)) = self.state.world; + let viewport = ( (self.state.offset_x as f64, self.state.offset_y as f64), ( (self.state.offset_x + self.state.viewport.width as i32) as f64, (self.state.offset_y + self.state.viewport.height as i32) as f64, ), - ) { + ); + if let Some(((x1c, y1c), (x2c, y2c))) = + clip_line((x_min, 0.0), (x_max, 0.0), viewport.0, viewport.1) + { ctx.draw(&Line { x1: x1c, y1: y1c, x2: x2c, y2: y2c, - color: Color::DarkGray, + color: Color::Red, }); } + if let Some(((x1c, y1c), (x2c, y2c))) = + clip_line((0.0, y_min), (0.0, y_max), viewport.0, viewport.1) + { + ctx.draw(&Line { + x1: x1c, + y1: y1c, + x2: x2c, + y2: y2c, + color: Color::Red, + }); + } + ctx.print( + self.state.offset_x as f64, + self.state.offset_y as f64, + Span::styled( + format!("({},{})", self.state.offset_x, self.state.offset_y), + Style::default().fg(Color::Red), + ), + ); + } + // Draw the lines described in the processed layout + for &((x1, y1), (x2, y2)) in self.scaled_layout.lines.iter() { + match self.parameters.edge_style { + EdgeStyle::Straight => { + if let Some(((x1c, y1c), (x2c, y2c))) = clip_line( + (x1, y1 + self.parameters.line_offset_y), + (x2, y2 + self.parameters.line_offset_y), + (self.state.offset_x as f64, self.state.offset_y as f64), + ( + (self.state.offset_x + self.state.viewport.width as i32) as f64, + (self.state.offset_y + self.state.viewport.height as i32) + as f64, + ), + ) { + ctx.draw(&Line { + x1: x1c, + y1: y1c, + x2: x2c, + y2: y2c, + color: Color::DarkGray, + }); + } + } + EdgeStyle::Spline => { + // Bezier curves are always contained within the box defined by their endpoints, + // so we reject any curves that don't have a bounding box that intersects the viewport. + if !rectangles_intersect( + (x1, y1 + self.parameters.line_offset_y), + (x2, y2 + self.parameters.line_offset_y), + (self.state.offset_x as f64, self.state.offset_y as f64), + ( + (self.state.offset_x + self.state.viewport.width as i32) as f64, + (self.state.offset_y + self.state.viewport.height as i32) + as f64, + ), + ) { + continue; + } + let num_points = ((x2.round() - x1.round() + 1.0) as u32).min(16); // Don't go too crazy + let curve_points = generate_cubic_bezier_curve( + (x1, y1 + self.parameters.line_offset_y), + (x2, y2 + self.parameters.line_offset_y), + num_points, + ); + + // Draw lines between consecutive points of the curve + for points in curve_points.windows(2) { + if let Some(((x1c, y1c), (x2c, y2c))) = clip_line( + points[0], + points[1], + (self.state.offset_x as f64, self.state.offset_y as f64), + ( + (self.state.offset_x + self.state.viewport.width as i32) + as f64, + (self.state.offset_y + self.state.viewport.height as i32) + as f64, + ), + ) { + ctx.draw(&Line { + x1: x1c, + y1: y1c, + x2: x2c, + y2: y2c, + color: Color::DarkGray, + }); + } + } + } + } } // Print the labels for (block, ((x, y), (x2, _y2))) in self.scaled_layout.labels.iter() { @@ -303,15 +435,26 @@ impl<'a> Viewer<'a> { } else { label::NODE.to_string() }; - // Style the label depending on whether it's selected - let style = if Some(block) == self.state.selected_block.as_ref() { - // Selected blocks - match label.as_str() { - label::NODE => Style::default().fg(Color::LightGreen), - _ => Style::default().fg(Color::Black).bg(Color::White), + + // The style of the label is determined by 3 factors: + // 1. Whether the viewer has focus + // 2. Whether the block is selected + // 3. Whether the label consists of text or a glyph (the dot for zoomed out views) + + let is_selected = Some(block) == self.state.selected_block.as_ref(); + let is_glyph = label.as_str() == label::NODE; + + let style = match (self.has_focus, is_selected, is_glyph) { + (true, true, false) => Style::default().fg(Color::White).bg(Color::Blue), + (true, true, true) => Style::default().fg(Color::Blue), + (true, false, false) => { + Style::default().fg(Color::White).bg(Color::Indexed(236)) } - } else { - Style::default().fg(Color::White) + (true, false, true) => Style::default().fg(Color::White), + (false, _, false) => { + Style::default().fg(Color::White).bg(Color::Indexed(236)) + } + (false, _, true) => Style::default().fg(Color::White), }; // Clip labels that are potentially in the window (horizontal) @@ -328,6 +471,16 @@ impl<'a> Viewer<'a> { Span::styled(clipped_label, style), ); } + if DEBUG { + ctx.print( + *x, + *y + 1.0, + Span::styled( + format!("↓({},{})", *x, *y), + Style::default().fg(Color::Red), + ), + ); + } // Indicate if the block is connected to the start node (not shown) if self @@ -374,6 +527,22 @@ impl<'a> Viewer<'a> { ); } } + + // Draw a cursor if no block was selected or is visible + if !self + .state + .selected_block + .is_some_and(|selected| self.is_block_visible(selected)) + { + // Determine the middle of the viewport + let x_mid = self.state.offset_x + self.state.viewport.width as i32 / 2; + let y_mid = self.state.offset_y + self.state.viewport.height as i32 / 2; + ctx.print( + x_mid as f64, + y_mid as f64, + Span::styled("█", Style::default()), + ); + } } }); frame.render_widget(canvas, area); @@ -382,22 +551,9 @@ impl<'a> Viewer<'a> { self.auto_expand(); } - /// Check the viewport bounds against the layout and trigger expansion if needed. + /// Check the viewport bounds against the world bounds and trigger expansion if needed. pub fn auto_expand(&mut self) { - // Find the minimum and maximum x-coordinates of (left side of) labels in the layout so far - let xs: Vec = self - .scaled_layout - .labels - .values() - .map(|((x, _), _)| *x) - .collect(); - if xs.is_empty() { - return; - } - // For floats, min and max are not defined, so use fold instead. - let x_min = xs.iter().cloned().fold(f64::INFINITY, f64::min); - let x_max = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - + let ((x_min, _), (x_max, _)) = self.state.world; // Check if we're a screen width away from the left/right boundary and expand if needed // - if we can't expand any further, this will do nothing if (x_min as i32) > (self.state.offset_x - self.state.viewport.width as i32) { @@ -408,11 +564,10 @@ impl<'a> Viewer<'a> { } } - /// Cycle through nodes in a specified direction based on the label coordinates. - /// For moves to the left, it uses the end coordinate of the label; for right, the start coordinate; - /// and for up/down, the average of the start and end coordinates. + /// Cycle through nodes in a specified direction. pub fn move_selection(&mut self, direction: NavDirection) { - // Determine the reference point from BaseLayout's node_positions. + // Determine the reference point from BaseLayout's node_positions, + // or use the center of the viewport if no block is selected. let current_point = if let Some(selected) = self.state.selected_block { self.base_layout .node_positions @@ -431,58 +586,72 @@ impl<'a> Viewer<'a> { ) }; - let mut best_candidate: Option<(GraphNode, f64)> = None; - for (node, &position) in self.base_layout.node_positions.iter() { - // Skip the current selection and the start/end nodes. - if let Some(selected) = self.state.selected_block { - if *node == selected - || Node::is_start_node(node.node_id) - || Node::is_end_node(node.node_id) - { - continue; - } + // Try to find a node in the specified direction closest to the current point. + if let Some(new_selection) = self.find_closest_block_in_direction(current_point, direction) + { + self.state.selected_block = Some(new_selection); + if let Err(e) = self.center_on_block(new_selection) { + warn!("Viewer - error finding block to switch to: {}", e); } + } + } - let candidate_point = position; - - // For vertical movement, only consider candidates that are nearly horizontally aligned. - if matches!(direction, NavDirection::Up | NavDirection::Down) { - let horizontal_threshold = 1.0; - if (candidate_point.0 - current_point.0).abs() > horizontal_threshold { - continue; - } + // Helper function to find the closest node in the given direction, skipping + // the currently selected block and ignoring start/end dummy nodes. + fn find_closest_block_in_direction( + &self, + current_point: (f64, f64), + direction: NavDirection, + ) -> Option { + let mut closest_candidate: Option<(GraphNode, f64)> = None; + + for (&node, &node_pos) in &self.base_layout.node_positions { + // Skip the currently selected block and any start/end node + if Some(node) == self.state.selected_block + || Node::is_start_node(node.node_id) + || Node::is_end_node(node.node_id) + { + continue; } - let is_candidate = match direction { - NavDirection::Left => candidate_point.0 < current_point.0, - NavDirection::Right => candidate_point.0 > current_point.0, - NavDirection::Up => candidate_point.1 < current_point.1, - NavDirection::Down => candidate_point.1 > current_point.1, - }; - if !is_candidate { + let dx = node_pos.0 - current_point.0; + let dy = node_pos.1 - current_point.1; + + // Depending on the direction, decide if this node is a candidate + // - Up/Down: only consider nodes that are vertically aligned + // - Left/Right: consider all nodes in the correct half of the screen + if !match direction { + NavDirection::Up => node_pos.1 < current_point.1 && dx.abs() < f64::EPSILON, + NavDirection::Down => node_pos.1 > current_point.1 && dx.abs() < f64::EPSILON, + NavDirection::Left => dx < 0.0, + NavDirection::Right => dx > 0.0, + } { continue; } - let dx = candidate_point.0 - current_point.0; - let dy = candidate_point.1 - current_point.1; + // Calculate Euclidean distance let distance = (dx * dx + dy * dy).sqrt(); - if distance == 0.0 { - continue; - } - if let Some((_, best_distance)) = best_candidate { - if distance < best_distance { - best_candidate = Some((*node, distance)); + // Keep track if it's closer than any previous candidate + if let Some((_, best_dist)) = closest_candidate { + // When scrolling horizontally, break ties by preferring the down direction + // (otherwise it looks random to the user) + if (direction == NavDirection::Left || direction == NavDirection::Right) + && (distance - best_dist).abs() < f64::EPSILON + && dy < 0.0 + { + closest_candidate = Some((node, distance)); + } else if distance < best_dist { + // No tie-breaking needed + closest_candidate = Some((node, distance)); } } else { - best_candidate = Some((*node, distance)); + closest_candidate = Some((node, distance)); } } - if let Some((new_selection, _)) = best_candidate { - self.state.selected_block = Some(new_selection); - self.center_on_block(new_selection); - } + // Return the node with the minimum distance in the chosen direction + closest_candidate.map(|(n, _)| n) } /// Select the block closest to the center of the viewport using coordinates from scaled_layout. @@ -511,60 +680,262 @@ impl<'a> Viewer<'a> { } } - /// Update scroll offset based on the cursor position (world coordinates of the selected label). - /// This method computes the world bounds from all labels and clamps the viewport's offset - /// so that the cursor is centered when possible, but moves towards the viewport edges when near world bounds. - pub fn update_scroll_for_cursor(&mut self, cursor_x: f64, cursor_y: f64) { - // The tolerance_y parameter allows for flexibility on what's considered "centered" to avoid jitter, - // it's a ratio of the viewport height. - let margin = 5.0; - let tolerance_y = 0.3; - - let vp_width = self.state.viewport.width as f64; - let vp_height = self.state.viewport.height as f64; + fn compute_bounding_box(&self) -> ((f64, f64), (f64, f64)) { + let labels = &self.scaled_layout.labels; let mut xs = Vec::new(); let mut ys = Vec::new(); - for ((x, _), (x2, _)) in self.scaled_layout.labels.values() { + for ((x, _), (x2, _)) in labels.values() { xs.push(*x); xs.push(*x2); } - for ((_, y), (_, _)) in self.scaled_layout.labels.values() { + for ((_, y), (_, _)) in labels.values() { ys.push(*y); } - if xs.is_empty() || ys.is_empty() { - return; - } + let world_min_x = xs.iter().cloned().fold(f64::INFINITY, f64::min); + let world_max_x = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + let world_min_y = ys.iter().cloned().fold(f64::INFINITY, f64::min); + let world_max_y = ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + ((world_min_x, world_min_y), (world_max_x, world_max_y)) + } - let world_min_x = xs.iter().cloned().fold(f64::INFINITY, f64::min) - margin; - let world_max_x = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max) + margin; - let world_min_y = ys.iter().cloned().fold(f64::INFINITY, f64::min) - margin; - let world_max_y = ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max) + margin; + /// Update scroll offset based on the cursor position (world coordinates of the selected label). + /// This method computes the world bounds from all labels and clamps the viewport's offset. + /// On the initial call (first_render), it remains centered. Afterwards, we allow the cursor + /// to drift within a tolerance range vertically before scrolling. + pub fn update_scroll_for_cursor(&mut self, cursor_x: f64, cursor_y: f64) { + let margin = 10.0; + let vp_width = self.state.viewport.width as f64; + let vp_height = self.state.viewport.height as f64; + let bandwidth = 0.4; - let world_width = world_max_x - world_min_x; - let world_height = world_max_y - world_min_y; + let ((world_min_x, world_min_y), (world_max_x, world_max_y)) = self.state.world; + let min_x = world_min_x - margin; + let max_x = world_max_x + margin; + let min_y = world_min_y - margin; + let max_y = world_max_y + margin; - let desired_x = cursor_x - vp_width / 2.0; - let desired_y = cursor_y - vp_height / 2.0; + let total_width = max_x - min_x; + let total_height = max_y - min_y; + + // If it's the initial placement, there is only one allowed position. + if self.state.first_render { + let desired_x = cursor_x - vp_width / 2.0; + let desired_y = cursor_y - vp_height / 2.0; - let new_offset_x = if world_width >= vp_width { - desired_x.clamp(world_min_x, world_max_x - vp_width) + let new_offset_x = if total_width >= vp_width { + desired_x.clamp(min_x, max_x - vp_width) + } else { + min_x - (vp_width - total_width) / 2.0 + }; + let new_offset_y = if total_height >= vp_height { + desired_y.clamp(min_y, max_y - vp_height) + } else { + min_y - (vp_height - total_height) / 2.0 + }; + + self.state.offset_x = new_offset_x.round() as i32; + self.state.offset_y = new_offset_y.round() as i32; + + return; + } + + // In later iterations we treat vertical and horizontal movement differently. + // Horizontal still clamps to one point + let desired_x = cursor_x - vp_width / 2.0; + let new_offset_x = if total_width >= vp_width { + desired_x.clamp(min_x, max_x - vp_width) } else { - world_min_x - (vp_width - world_width) / 2.0 + min_x - (vp_width - total_width) / 2.0 }; - let new_offset_y = if world_height >= vp_height { - desired_y.clamp(world_min_y, world_max_y - vp_height) + // Vertical is centering and clamping to a range of y-coordinates. + let current_offset_y = self.state.offset_y as f64; + let top_boundary = current_offset_y + bandwidth * vp_height; + let bottom_boundary = current_offset_y + (1.0 - bandwidth) * vp_height; + + let mut desired_y = current_offset_y; + if cursor_y < top_boundary { + desired_y -= top_boundary - cursor_y; + } else if cursor_y > bottom_boundary { + desired_y += cursor_y - bottom_boundary; + } + + // Clamp vertically + if total_height >= vp_height { + desired_y = desired_y.clamp(min_y, max_y - vp_height); } else { - world_min_y - (vp_height - world_height) / 2.0 - }; + desired_y = min_y - (vp_height - total_height) / 2.0; + } self.state.offset_x = new_offset_x.round() as i32; + self.state.offset_y = desired_y.round() as i32; + } - if (new_offset_y - self.state.offset_y as f64).abs() > tolerance_y * vp_height { - self.state.offset_y = new_offset_y.round() as i32; + /// Get the terminal coordinates of a block's center point + fn get_block_terminal_coords(&self, block: GraphNode) -> Option<(f64, f64)> { + if let Some(((start, y), (end, _))) = self.scaled_layout.labels.get(&block) { + let block_center_x = (start + end) / 2.0; + let block_center_y = *y; + + // Convert from world coordinates to terminal coordinates + let terminal_x = block_center_x - self.state.offset_x as f64; + let terminal_y = block_center_y - self.state.offset_y as f64; + + Some((terminal_x, terminal_y)) + } else { + None + } + } + + pub fn handle_input(&mut self, key: KeyEvent) { + // Scrolling through the graph + match key.code { + KeyCode::Left => { + if key.modifiers.contains(KeyModifiers::SHIFT) { + self.state.offset_x -= self.state.viewport.width as i32 / 3; + self.unselect_if_not_visible(); + } else if key.modifiers.contains(KeyModifiers::ALT) { + self.state.offset_x -= 1; + } else { + self.move_selection(NavDirection::Left); + } + } + KeyCode::Right => { + if key.modifiers.contains(KeyModifiers::SHIFT) { + self.state.offset_x += self.state.viewport.width as i32 / 3; + self.unselect_if_not_visible(); + } else if key.modifiers.contains(KeyModifiers::ALT) { + self.state.offset_x += 1; + } else { + self.move_selection(NavDirection::Right); + } + } + KeyCode::Up => { + if key.modifiers.contains(KeyModifiers::SHIFT) { + self.state.offset_y += self.state.viewport.height as i32 / 3; + self.unselect_if_not_visible(); + } else if key.modifiers.contains(KeyModifiers::ALT) { + self.state.offset_y += 1; + } else { + self.move_selection(NavDirection::Down); + } + } + KeyCode::Down => { + if key.modifiers.contains(KeyModifiers::SHIFT) { + self.state.offset_y -= self.state.viewport.height as i32 / 3; + self.unselect_if_not_visible(); + } else if key.modifiers.contains(KeyModifiers::ALT) { + self.state.offset_y -= 1; + } else { + self.move_selection(NavDirection::Up); + } + } + // Zooming in and out + KeyCode::Char('+') | KeyCode::Char('=') => { + // Record terminal coordinates of selected block before zoom + let terminal_coords = self + .state + .selected_block + .and_then(|block| self.get_block_terminal_coords(block)); + + // Increase how much of the sequence is shown in each block label. + if self.parameters.label_width == u32::MAX { + self.parameters.scale += 2; // Even increments look better + } else { + self.parameters.label_width = match self.parameters.label_width { + 1 => 11, + 11 => 100, + 100 => u32::MAX, + _ => u32::MAX, + } + } + + // If no block is selected, try to select the center block + if self.state.selected_block.is_none() { + self.select_center_block(); + } + + // Recalculate the layout. + self.scaled_layout + .refresh(&self.base_layout, &self.parameters); + self.state.world = self.compute_bounding_box(); + + // Adjust viewport to maintain terminal coordinates of selected block + if let Some((old_x, old_y)) = terminal_coords { + if let Some(block) = self.state.selected_block { + if let Some(((start, y), (end, _))) = self.scaled_layout.labels.get(&block) + { + let new_center_x = (start + end) / 2.0; + let new_center_y = *y; + + // Calculate new offsets to maintain terminal coordinates + self.state.offset_x = (new_center_x - old_x).round() as i32; + self.state.offset_y = (new_center_y - old_y).round() as i32; + } + } + } + } + KeyCode::Char('-') | KeyCode::Char('_') => { + // Record terminal coordinates of selected block before zoom + let terminal_coords = self + .state + .selected_block + .and_then(|block| self.get_block_terminal_coords(block)); + + // Decrease how much of the sequence is shown in each block label. + if self.parameters.scale > 2 { + self.parameters.scale -= 2; + } else { + self.parameters.label_width = match self.parameters.label_width { + u32::MAX => 100, + 100 => 11, + 11 => 1, + _ => 1, + }; + } + + if self.state.selected_block.is_none() { + self.select_center_block(); + } + + self.scaled_layout + .refresh(&self.base_layout, &self.parameters); + self.state.world = self.compute_bounding_box(); + + // Adjust viewport to maintain terminal coordinates of selected block + if let Some((old_x, old_y)) = terminal_coords { + if let Some(block) = self.state.selected_block { + if let Some(((start, y), (end, _))) = self.scaled_layout.labels.get(&block) + { + let new_center_x = (start + end) / 2.0; + let new_center_y = *y; + + // Calculate new offsets to maintain terminal coordinates + self.state.offset_x = (new_center_x - old_x).round() as i32; + self.state.offset_y = (new_center_y - old_y).round() as i32; + } + } + } + } + KeyCode::Char('s') | KeyCode::Char('S') => { + // Toggle between straight lines and splines + // I ended up not liking them as much as I thought I would, + // so it's not the default and I'm not documenting it in the status bar. + self.parameters.edge_style = match self.parameters.edge_style { + EdgeStyle::Straight => EdgeStyle::Spline, + EdgeStyle::Spline => EdgeStyle::Straight, + }; + self.scaled_layout + .refresh(&self.base_layout, &self.parameters); + } + _ => {} } } + + pub fn get_status_line() -> String { + "◀ ▼ ▲ ▶ select blocks (+shift/alt to scroll) | +/- zoom".to_string() + } } /// Truncate a string to a certain length, adding an ellipsis in the middle @@ -573,7 +944,7 @@ fn inner_truncation(s: &str, target_length: u32) -> String { if input_length <= target_length { return s.to_string(); } else if target_length < 5 { - return "●".to_string(); // ○ is U+25CB; ● is U+25CF + return label::NODE.to_string(); // ○ is U+25CB; ● is U+25CF } // length - 3 because we need space for the ellipsis let left_len = (target_length - 3) / 2 + ((target_length - 3) % 2); @@ -694,6 +1065,73 @@ pub fn clip_line( } } +/// Check if two rectangles intersect. +/// - Each rectangle is defined by any two opposite corners. +/// - Returns true if the rectangles overlap or touch, false otherwise. +pub fn rectangles_intersect( + (x1, y1): (f64, f64), // First corner of rectangle 1 + (x2, y2): (f64, f64), // Opposite corner of rectangle 1 + (x3, y3): (f64, f64), // First corner of rectangle 2 + (x4, y4): (f64, f64), // Opposite corner of rectangle 2 +) -> bool { + // For each axis, one rectangle's maximum must be >= other's minimum + // and one rectangle's minimum must be <= other's maximum + x1.max(x2) >= x3.min(x4) + && x3.max(x4) >= x1.min(x2) + && y1.max(y2) >= y3.min(y4) + && y3.max(y4) >= y1.min(y2) +} + +/// Generate a cubic bezier curve between two points A and B, given a resolution value. +/// - Control points 0 and 3 are equal to A and B. +/// - Control point 1 is halfway between A and B, at the same height as A. +/// - Control point 2 is halfway between A and B, at the same height as B. +/// +/// The function returns resolution + 2 points: +/// - First point is exactly A +/// - Last point is exactly B +/// - For resolution=0: returns [A, B] +/// - For resolution=1: returns [A, midpoint, B] where midpoint is the true curve midpoint at t=0.5 +/// - For resolution>1: returns [A, ...resolution points along the curve..., B] +pub fn generate_cubic_bezier_curve( + a: (f64, f64), + b: (f64, f64), + num_points: u32, +) -> Vec<(f64, f64)> { + let (ax, ay) = a; + let (bx, by) = b; + // Define control points following Graphviz's style: + // p0: a, p1: midpoint between a and b at the same height as a, + // p2: midpoint between a and b at the same height as b, p3: b + let p0 = a; + let p1 = (((ax + bx) / 2.0), ay); + let p2 = (((ax + bx) / 2.0), by); + let p3 = b; + + let mut points = Vec::with_capacity(num_points as usize); + // First point is exactly a + points.push(a); + + // Calculate intermediate points + for i in 1..num_points - 1 { + let t = i as f64 / ((num_points - 1) as f64); + let one_minus_t = 1.0_f64 - t; + let x = one_minus_t.powi(3) * p0.0 + + 3.0_f64 * one_minus_t.powi(2) * t * p1.0 + + 3.0_f64 * one_minus_t * t.powi(2) * p2.0 + + t.powi(3) * p3.0; + let y = one_minus_t.powi(3) * p0.1 + + 3.0_f64 * one_minus_t.powi(2) * t * p1.1 + + 3.0_f64 * one_minus_t * t.powi(2) * p2.1 + + t.powi(3) * p3.1; + points.push((x, y)); + } + + // Last point is exactly b + points.push(b); + points +} + #[cfg(test)] mod tests { use super::*; @@ -831,4 +1269,55 @@ mod tests { let clipped = clip_label("ABCDEFGH", 2, 4, 3); assert_eq!(clipped, "…D…"); } + + #[test] + fn test_rectangles_intersect() { + // Overlapping rectangles (corners in standard order) + assert!(rectangles_intersect( + (0.0, 0.0), + (2.0, 2.0), + (1.0, 1.0), + (3.0, 3.0) + )); + + // Overlapping rectangles (corners in reverse order) + assert!(rectangles_intersect( + (2.0, 2.0), + (0.0, 0.0), // bottom-right to top-left + (3.0, 3.0), + (1.0, 1.0) // bottom-right to top-left + )); + + // Touching rectangles (edge) with mixed corner order + assert!(rectangles_intersect( + (2.0, 2.0), + (0.0, 0.0), // reversed + (2.0, 0.0), + (4.0, 2.0) // standard + )); + + // Touching rectangles (corner) with diagonal corners + assert!(rectangles_intersect( + (0.0, 2.0), + (2.0, 0.0), // top-right to bottom-left + (2.0, 2.0), + (4.0, 4.0) // standard + )); + + // Non-intersecting rectangles with mixed corners + assert!(!rectangles_intersect( + (1.0, 1.0), + (0.0, 0.0), // reversed + (3.0, 3.0), + (2.0, 2.0) // reversed + )); + + // One rectangle inside another with diagonal corners + assert!(rectangles_intersect( + (0.0, 4.0), + (4.0, 0.0), // top-right to bottom-left + (1.0, 2.0), + (2.0, 1.0) // bottom-right to top-left + )); + } } diff --git a/src/views/block_layout.rs b/src/views/block_layout.rs index 7dae1b46..b965a823 100644 --- a/src/views/block_layout.rs +++ b/src/views/block_layout.rs @@ -986,6 +986,7 @@ mod tests { scale: 1, aspect_ratio: 1.0, line_offset_y: 0.5, + ..Default::default() }; let scaled_layout = ScaledLayout::from_base_layout(&base_layout, ¶meters); @@ -1030,6 +1031,7 @@ mod tests { scale: 10, aspect_ratio: 1.0, line_offset_y: 0.5, + ..Default::default() }; let scaled_layout = ScaledLayout::from_base_layout(&base_layout, ¶meters); @@ -1075,6 +1077,7 @@ mod tests { scale: 1, aspect_ratio: 1.0, line_offset_y: 0.5, + ..Default::default() }; let scaled_layout = ScaledLayout::from_base_layout(&base_layout, ¶meters); diff --git a/src/views/collection.rs b/src/views/collection.rs new file mode 100644 index 00000000..c2a7e035 --- /dev/null +++ b/src/views/collection.rs @@ -0,0 +1,695 @@ +use crate::models::block_group::BlockGroup; +use crate::models::collection::Collection; +use crate::models::sample::Sample; +use crate::models::traits::Query; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Paragraph, StatefulWidget}, +}; +use rusqlite::{params, Connection}; +use std::collections::{HashMap, HashSet}; +use tui_widget_list::{ListBuilder, ListState, ListView}; + +/// Normalize a hierarchical collection name by removing trailing delimiters +/// (except if the entire collection name is "/"). For example: +/// "/foo/bar///" -> "/foo/bar", but "/" stays "/". +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() { + // If it was all delimiters (e.g. "////"), treat it as "/" + "/" + } else { + full_collection + } +} + +/// Return the final segment of a hierarchical collection name. For example, +/// given "/foo/bar", the final segment is "bar". Special case: "/" is root. +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 + } +} + +/// Return the parent portion of a hierarchical collection name. For example: +/// parent_collection("/foo/bar") -> "/foo" +/// parent_collection("/foo/bar/") -> "/foo" +/// parent_collection("/foo") -> "/" +/// parent_collection("/") -> "/" +/// parent_collection("bar") -> "." +/// +/// Note: If there's no slash in `full_collection`, we return "." to indicate +/// the "current directory" (matching typical Unix `dirname` behavior). +fn parent_collection(full_collection: &str) -> String { + let normalized = normalize_collection_name(full_collection); + if normalized == "/" { + // Root has no parent + return "/".to_string(); + } + if let Some(idx) = normalized.rfind('/') { + if idx == 0 { + // "/foo"; parent is "/" + "/".to_string() + } else { + normalized[..idx].to_string() + } + } else { + // If there's no slash, treat it as a single component => parent is "." + ".".to_string() + } +} + +#[derive(Debug)] +pub struct CollectionExplorerData { + /// The final segment of the current collection name. For example, + /// if the full collection is "/foo/bar", this would be "bar". + pub current_collection: String, + /// The block groups in the *entire* collection that have sample_name = NULL + pub reference_block_groups: Vec<(i64, String)>, + /// The samples in the entire collection + pub collection_samples: Vec, + /// The block groups for each sample + pub sample_block_groups: HashMap>, + /// Immediate sub-collections ("direct children") one level deeper + pub nested_collections: Vec, +} + +/// Gathers information about a hierarchical collection, enumerating reference (null-sample) +/// block groups, sample block groups, and immediate sub-collections. +pub fn gather_collection_explorer_data( + conn: &Connection, + full_collection_name: &str, +) -> CollectionExplorerData { + let current_collection = collection_basename(full_collection_name).to_string(); + let _parent = parent_collection(full_collection_name); + + // 2) Query block groups that have sample_name = NULL for the entire collection + 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<(i64, String)> = + base_bgs.iter().map(|bg| (bg.id, bg.name.clone())).collect(); + + // 3) Gather all samples associated with the entire collection + let all_blocks = Collection::get_block_groups(conn, full_collection_name); + let mut sample_names: HashSet = all_blocks + .iter() + .filter_map(|bg| bg.sample_name.clone()) + .collect(); + let mut collection_samples: Vec = sample_names.drain().collect(); + collection_samples.sort(); + + // 4) For each sample, retrieve block groups + 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::>(); + sample_block_groups.insert(sample.clone(), pairs); + } + + // 5) Direct "nested" collections: must start with "full_collection_name + /" but no further delimiter + 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 { + // The portion *after* "/foo/bar/" + let remainder = &child.name[direct_prefix.len()..]; + // If there's no further slash, it's a direct child + if !remainder.is_empty() && !remainder.contains('/') { + nested_collections.push(remainder.to_string()); + } + } + + CollectionExplorerData { + current_collection, + reference_block_groups, + collection_samples, + sample_block_groups, + nested_collections, + } +} + +#[derive(Debug)] +pub enum ExplorerItem { + Collection { + name: String, + /// Whether this is the current collection (listed at the top), or a link to another collection + is_current: bool, + }, + BlockGroup { + id: i64, + name: String, + }, + Sample { + name: String, + expanded: bool, + }, + Header { + text: String, + }, +} + +impl ExplorerItem { + /// Skip over headers and the top-level collection name + pub fn is_selectable(&self) -> bool { + match self { + ExplorerItem::Collection { is_current, .. } => !is_current, + ExplorerItem::BlockGroup { .. } => true, + ExplorerItem::Sample { .. } => true, + ExplorerItem::Header { .. } => false, + } + } +} + +#[derive(Debug, Default)] +pub struct CollectionExplorerState { + pub list_state: ListState, + pub total_items: usize, + pub has_focus: bool, + /// The currently selected block group + pub selected_block_group_id: Option, + /// Tracks which samples are expanded/collapsed + expanded_samples: HashSet, +} + +impl CollectionExplorerState { + pub fn new() -> Self { + Self::with_selected_block_group(None) + } + + pub fn with_selected_block_group(block_group_id: Option) -> Self { + Self { + list_state: ListState::default(), + total_items: 0, + has_focus: false, + selected_block_group_id: block_group_id, + expanded_samples: HashSet::new(), + } + } + + /// Toggle expansion state of a sample + 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()); + } + } + + /// Check if a sample is expanded + pub fn is_sample_expanded(&self, sample_name: &str) -> bool { + self.expanded_samples.contains(sample_name) + } +} + +#[derive(Debug)] +pub struct CollectionExplorer { + pub data: CollectionExplorerData, +} + +impl CollectionExplorer { + pub fn new(conn: &Connection, full_collection_name: &str) -> Self { + let data = gather_collection_explorer_data(conn, full_collection_name); + Self { data } + } + + /// Refresh the explorer data from the database and return true if data changed + pub fn refresh(&mut self, conn: &Connection, full_collection_name: &str) -> bool { + let new_data = gather_collection_explorer_data(conn, 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 + } + + /// Force the widget to reload by resetting its state + pub fn force_reload(&self, state: &mut CollectionExplorerState) { + state.list_state = ListState::default(); + // Find first selectable item to maintain a valid selection + state.list_state.selected = self.find_next_selectable(state, 0); + } + + /// Find the next selectable item after the given index, wrapping around to the start if needed + fn find_next_selectable( + &self, + state: &CollectionExplorerState, + from_idx: usize, + ) -> Option { + let items = self.get_display_items(state); + // First try after the current index + items + .iter() + .enumerate() + .skip(from_idx) + .find(|(_, item)| item.is_selectable()) + .map(|(i, _)| i) + // If nothing found after current index, wrap around to start + .or_else(|| { + items + .iter() + .enumerate() + .take(from_idx) + .find(|(_, item)| item.is_selectable()) + .map(|(i, _)| i) + }) + } + + /// Find the previous selectable item before the given index, wrapping around to the end if needed + fn find_prev_selectable( + &self, + state: &CollectionExplorerState, + from_idx: usize, + ) -> Option { + let items = self.get_display_items(state); + // First try before the current index + items + .iter() + .enumerate() + .take(from_idx) + .rev() + .find(|(_, item)| item.is_selectable()) + .map(|(i, _)| i) + // If nothing found before current index, wrap around to end + .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); + } + ExplorerItem::Sample { .. } => { + self.toggle_sample_expansion(state); + } + _ => {} + } + } + } + _ => {} + } + } + + pub fn get_status_line() -> String { + "▼ ▲ navigate | return: select".to_string() + } + + /// Get all items to display, taking into account the current state + fn get_display_items(&self, state: &CollectionExplorerState) -> Vec { + let mut items = Vec::new(); + + // Current collection name + items.push(ExplorerItem::Collection { + name: self.data.current_collection.clone(), + is_current: true, + }); + + // Blank line + items.push(ExplorerItem::Header { + text: String::new(), + }); + + // Reference graphs section + items.push(ExplorerItem::Header { + text: "Reference graphs:".to_string(), + }); + + // Reference block groups + for (id, name) in &self.data.reference_block_groups { + items.push(ExplorerItem::BlockGroup { + id: *id, + name: name.clone(), + }); + } + + // Blank line + items.push(ExplorerItem::Header { + text: String::new(), + }); + + // Samples section + items.push(ExplorerItem::Header { + text: "Samples:".to_string(), + }); + + // Samples and their block groups + 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) { + if 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(), + }); + } + } + } + } + + // Blank line + items.push(ExplorerItem::Header { + text: String::new(), + }); + + // Nested collections section + items.push(ExplorerItem::Header { + text: "Nested Collections:".to_string(), + }); + + // Nested collections + for collection in &self.data.nested_collections { + items.push(ExplorerItem::Collection { + name: collection.clone(), + is_current: false, + }); + } + + 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(); + + // Convert ExplorerItems to display items + for item in &items { + let paragraph = match item { + ExplorerItem::Collection { name, is_current } => { + if *is_current { + // This is the current collection header + Paragraph::new(Line::from(vec![ + Span::styled( + " Collection:", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!(" {}", name)), + ])) + } else { + // This is a link to another collection + Paragraph::new(Line::from(vec![Span::raw(format!(" • {}", name))])) + } + } + ExplorerItem::BlockGroup { id, name, .. } => { + // Check if this block group is one of the sample_name = NULL reference block groups + // This influences the indentation + 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))])) + } else { + Paragraph::new(Line::from(vec![Span::raw(format!(" • {}", name))])) + } + } + ExplorerItem::Sample { name, expanded } => Paragraph::new(Line::from(vec![ + Span::raw(if *expanded { " ▼ " } else { " ▶ " }), + Span::styled(name, Style::default().fg(Color::Gray)), + ])), + ExplorerItem::Header { text } => Paragraph::new(Line::from(vec![Span::styled( + format!(" {}", text), + Style::default().add_modifier(Modifier::BOLD), + )])), + }; + + display_items.push(paragraph); + } + + // Store total items + let total_items = display_items.len(); + let has_focus = state.has_focus; + + // Create and render the list + let builder = ListBuilder::new(move |context| { + let item = display_items[context.index].clone(); + if context.is_selected { + let style = if has_focus { + Style::default().bg(Color::Blue).fg(Color::White) + } else { + Style::default().bg(Color::DarkGray).fg(Color::Gray) + }; + (item.style(style), 1) + } else { + (item, 1) + } + }); + + let list = ListView::new(builder, total_items).block(Block::default()); + + state.total_items = total_items; + + // Ensure selection is valid for the current items + if state.list_state.selected.is_none() || state.list_state.selected.unwrap() >= total_items + { + // Selection is invalid or missing - try to find a valid one + state.list_state.selected = if let Some(block_group_id) = state.selected_block_group_id + { + // Try to find the selected block group in the current items + 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 { + // No block group selected, just find the next selectable item + self.find_next_selectable(state, 0) + }; + } + + list.render(area, buf, &mut state.list_state); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + /// For these tests we create an in-memory database, run minimal schema + /// creation, and insert data to test gather_collection_explorer_data. + #[test] + fn test_gather_collection_explorer_data() { + // 1) Set up an in-memory database + let conn = Connection::open_in_memory().unwrap(); + + // Minimal schema for the required tables, adapted from migrations + conn.execute_batch( + r#" + CREATE TABLE collections ( + name TEXT PRIMARY KEY NOT NULL + ) STRICT; + + CREATE TABLE block_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + collection_name TEXT NOT NULL, + sample_name TEXT, + name TEXT NOT NULL + ) STRICT; + CREATE TABLE samples ( + name TEXT PRIMARY KEY NOT NULL + ) STRICT; + "#, + ) + .unwrap(); + + // 2) Insert data: one collection path + nested sub-collections + // We'll store e.g. "/foo/bar" as the main path + conn.execute(r#"INSERT INTO collections(name) VALUES (?1)"#, ["/foo/bar"]) + .unwrap(); + conn.execute( + r#"INSERT INTO collections(name) VALUES (?1)"#, + ["/foo/bar/a"], + ) + .unwrap(); + conn.execute( + r#"INSERT INTO collections(name) VALUES (?1)"#, + ["/foo/bar/a/b"], + ) + .unwrap(); + conn.execute( + r#"INSERT INTO collections(name) VALUES (?1)"#, + ["/foo/bar2"], + ) + .unwrap(); + conn.execute(r#"INSERT INTO collections(name) VALUES (?1)"#, ["/foo/baz"]) + .unwrap(); + + // 3) Insert a couple of samples + conn.execute("INSERT INTO samples(name) VALUES (?1)", ["SampleAlpha"]) + .unwrap(); + conn.execute("INSERT INTO samples(name) VALUES (?1)", ["SampleBeta"]) + .unwrap(); + + // 4) Insert block groups: some with sample = null, some with a sample + // for the collection "/foo/bar" + conn.execute( + "INSERT INTO block_groups(collection_name, sample_name, name) VALUES(?1, NULL, ?2)", + ["/foo/bar", "BG_ReferenceA"], + ) + .unwrap(); + conn.execute( + "INSERT INTO block_groups(collection_name, sample_name, name) VALUES(?1, NULL, ?2)", + ["/foo/bar", "BG_ReferenceB"], + ) + .unwrap(); + + conn.execute( + "INSERT INTO block_groups(collection_name, sample_name, name) VALUES(?1, ?2, ?3)", + ["/foo/bar", "SampleAlpha", "BG_Alpha1"], + ) + .unwrap(); + conn.execute( + "INSERT INTO block_groups(collection_name, sample_name, name) VALUES(?1, ?2, ?3)", + ["/foo/bar", "SampleBeta", "BG_Beta1"], + ) + .unwrap(); + + // 5) Call the function under test—notice we pass the full path + let explorer_data = gather_collection_explorer_data(&conn, "/foo/bar"); + + // 6) Verify results + // (A) The final path component is "bar" + assert_eq!(explorer_data.current_collection, "bar"); + + // (B) Reference block groups (sample_name IS NULL) + 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())); + + // (C) Collection samples + // We expect SampleAlpha and SampleBeta + 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())); + + // (D) Sample block groups + // "SampleAlpha" + 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()]); + // "SampleBeta" + 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()]); + + // (E) Nested collections: we only want the direct child after "/foo/bar/" + // e.g. "/foo/bar/a" => child is "a" + // "/foo/bar/a/b" is not a direct child, it's an extra level + // "/foo/bar2" doesn't match the prefix "/foo/bar/" + // ... So only "a" is a direct nested collection + assert_eq!(explorer_data.nested_collections, vec!["a".to_string()]); + } + + #[test] + fn test_trailing_delimiter_behavior() { + // This verifies how we handle trailing hierarchical delimiters + 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"); + // parent of /foo => / + assert_eq!(parent_collection("/foo/"), "/"); + // parent of / => / + assert_eq!(parent_collection("////"), "/"); + // parent of a single "segment" => "." + assert_eq!(parent_collection("bar"), "."); + } +} diff --git a/src/views/operations.rs b/src/views/operations.rs index c791f639..930659ea 100644 --- a/src/views/operations.rs +++ b/src/views/operations.rs @@ -1,5 +1,8 @@ +use crate::graph::{GraphEdge, GraphNode}; +use crate::models::block_group::BlockGroup; use crate::models::operations::{Operation, OperationSummary}; use crate::models::traits::Query; +use crate::views::block_group_viewer::{PlotParameters, Viewer}; use crossterm::event::KeyModifiers; use crossterm::{ event::{self, KeyCode}, @@ -7,6 +10,7 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use itertools::Itertools; +use petgraph::prelude::DiGraphMap; use ratatui::prelude::{Color, Style, Text}; use ratatui::style::Modifier; use ratatui::widgets::Paragraph; @@ -17,6 +21,7 @@ use ratatui::{ Terminal, }; use rusqlite::{params, types::Value, Connection}; +use std::backtrace::Backtrace; use std::collections::HashMap; use std::io; use std::rc::Rc; @@ -36,7 +41,23 @@ struct OperationRow<'a> { summary: OperationSummary, } -pub fn view_operations(conn: &Connection, operations: &[Operation]) -> Result<(), io::Error> { +fn restore_terminal() { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen); +} + +pub fn view_operations( + conn: &Connection, + op_conn: &Connection, + operations: &[Operation], +) -> Result<(), io::Error> { + std::panic::set_hook(Box::new(|info| { + restore_terminal(); + eprintln!("Application crashed: {info}"); + let backtrace = Backtrace::capture(); + eprintln!("Stack trace:\n{}", backtrace); + })); + let operation_by_hash: HashMap = HashMap::from_iter( operations .iter() @@ -44,7 +65,7 @@ pub fn view_operations(conn: &Connection, operations: &[Operation]) -> Result<() .collect::>(), ); let summaries = OperationSummary::query( - conn, + op_conn, "select * from operation_summary where operation_hash in rarray(?1)", params![Rc::new( operations @@ -68,7 +89,18 @@ pub fn view_operations(conn: &Connection, operations: &[Operation]) -> Result<() let mut terminal = Terminal::new(backend)?; let mut textarea = TextArea::default(); + let mut empty_graph: DiGraphMap = DiGraphMap::new(); + let mut blockgroup_graphs: Vec<(i64, String, DiGraphMap)> = vec![]; + let mut selected_blockgroup_graph: usize = 0; + empty_graph.add_node(GraphNode { + node_id: 1, + block_id: 0, + sequence_start: 0, + sequence_end: 1, + }); + let mut graph_viewer = Viewer::new(&empty_graph, conn, PlotParameters::default()); let mut view_message_panel = false; + let mut view_graph = false; let mut panel_focus = "operations"; let mut focus_rotation = vec!["operations"]; let mut focus_index: usize = 0; @@ -143,9 +175,24 @@ pub fn view_operations(conn: &Connection, operations: &[Operation]) -> Result<() .borders(Borders::ALL) .border_style(unfocused_style), ); + if view_graph { + graph_viewer.set_block( + Block::default() + .title(if blockgroup_graphs.is_empty() { + "Change Graph".to_string() + } else { + format!( + "Change Graph {name}", + name = blockgroup_graphs[selected_blockgroup_graph].1 + ) + }) + .borders(Borders::ALL) + .border_style(unfocused_style), + ); + } if panel_focus == "message_editor" { - panel_messages.push_str(", ctrl+s=save message, esc=close message editor"); + panel_messages.push_str("| ctrl+s=save message | esc=close message editor"); textarea.set_block( Block::default() .title("Operation Summary") @@ -153,16 +200,50 @@ pub fn view_operations(conn: &Connection, operations: &[Operation]) -> Result<() .border_style(focused_style), ); } else if panel_focus == "operations" { - panel_messages.push_str(", e or enter=edit message, esc or q=exit"); + panel_messages.push_str("| e or enter=edit message | v=view graph | esc or q=exit"); + } else if panel_focus == "graph_view" { + panel_messages.push_str(&format!( + " | tab = cycle block group | {l} | esc or q=exit", + l = Viewer::get_status_line() + )); + graph_viewer.set_block( + Block::default() + .title(if blockgroup_graphs.is_empty() { + "Change Graph".to_string() + } else { + format!( + "Change Graph {name}", + name = blockgroup_graphs[selected_blockgroup_graph].1 + ) + }) + .borders(Borders::ALL) + .border_style(focused_style), + ); } if view_message_panel { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(main_area); + if view_graph { + let sub_chunk = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(chunks[1]); + f.render_widget(&textarea, sub_chunk[0]); + graph_viewer.draw(f, sub_chunk[1]); + } else { + f.render_widget(&textarea, chunks[1]); + } + f.render_widget(table, chunks[0]); + } else if view_graph { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(main_area); f.render_widget(table, chunks[0]); - f.render_widget(&textarea, chunks[1]); + graph_viewer.draw(f, chunks[1]); } else { let chunks = Layout::default() .direction(Direction::Vertical) @@ -218,7 +299,7 @@ pub fn view_operations(conn: &Connection, operations: &[Operation]) -> Result<() { let new_summary = textarea.lines().iter().join("\n"); let _ = OperationSummary::set_message( - conn, + op_conn, operation_summaries[selected].summary.id, &new_summary, ); @@ -226,6 +307,39 @@ pub fn view_operations(conn: &Connection, operations: &[Operation]) -> Result<() } else { textarea.input(key); } + } else if panel_focus == "graph_view" { + if key.code == KeyCode::Esc || key.code == KeyCode::Char('q') { + view_graph = false; + if let Some((p, _)) = + focus_rotation.iter().find_position(|s| **s == "graph_view") + { + focus_rotation.remove(p); + } + if focus_index >= focus_rotation.len() { + focus_index = 0; + } + panel_focus = focus_rotation[focus_index]; + } else if key.code == KeyCode::Tab || key.code == KeyCode::BackTab { + if key.code == KeyCode::BackTab { + if selected_blockgroup_graph == 0 { + selected_blockgroup_graph = blockgroup_graphs.len() - 1; + } else { + selected_blockgroup_graph -= 1; + } + } else { + selected_blockgroup_graph += 1; + if selected_blockgroup_graph >= blockgroup_graphs.len() { + selected_blockgroup_graph = 0; + } + } + graph_viewer = Viewer::new( + &blockgroup_graphs[selected_blockgroup_graph].2, + conn, + PlotParameters::default(), + ); + } else { + graph_viewer.handle_input(key); + } } else { let code = key.code; match code { @@ -256,6 +370,53 @@ pub fn view_operations(conn: &Connection, operations: &[Operation]) -> Result<() }; panel_focus = focus_rotation[focus_index]; } + KeyCode::Char('v') => { + view_graph = true; + focus_index = if let Some((i, _)) = + focus_rotation.iter().find_position(|s| **s == "graph_view") + { + i + } else { + focus_rotation.push("graph_view"); + focus_rotation.len() - 1 + }; + panel_focus = focus_rotation[focus_index]; + let hash = &operation_summaries[selected].operation.hash; + let graphs = Operation::get_change_graph(op_conn, hash).unwrap(); + blockgroup_graphs.clear(); + let bg_info = BlockGroup::get_by_ids( + conn, + &graphs.keys().copied().collect::>(), + ); + let bg_map: HashMap = + HashMap::from_iter(bg_info.iter().map(|k| (k.id, k))); + for (i, v) in graphs { + blockgroup_graphs.push(( + i, + format!( + "{collection} {sample} {name}", + collection = bg_map[&i].collection_name.clone(), + sample = bg_map[&i] + .sample_name + .clone() + .unwrap_or("Reference".to_string()), + name = bg_map[&i].name.clone() + ), + v, + )); + } + selected_blockgroup_graph = 0; + if blockgroup_graphs.is_empty() { + graph_viewer = + Viewer::new(&empty_graph, conn, PlotParameters::default()); + } else { + graph_viewer = Viewer::new( + &blockgroup_graphs[selected_blockgroup_graph].2, + conn, + PlotParameters::default(), + ); + } + } _ => {} } } diff --git a/src/views/patch.rs b/src/views/patch.rs index 773f3b25..320d5349 100644 --- a/src/views/patch.rs +++ b/src/views/patch.rs @@ -1,9 +1,13 @@ +use crate::graph::{GraphEdge, GraphNode}; use crate::models::block_group_edge::BlockGroupEdge; use crate::models::edge::Edge; use crate::models::node::Node; use crate::models::sequence::Sequence; +use crate::models::strand::Strand; +use crate::models::strand::Strand::Forward; use crate::operation_management::{ - load_changeset, load_changeset_dependencies, load_changeset_models, + load_changeset, load_changeset_dependencies, load_changeset_models, ChangesetModels, + DependencyModels, }; use crate::patch::OperationPatch; use html_escape; @@ -14,11 +18,174 @@ use rusqlite::session::ChangesetIter; use std::collections::{HashMap, HashSet}; use std::io::Read; +pub fn get_change_graph( + changes: &ChangesetModels, + dependencies: &DependencyModels, +) -> HashMap> { + let start_node = Node::get_start_node(); + let end_node = Node::get_end_node(); + let mut bges_by_bg: HashMap> = HashMap::new(); + let mut edges_by_id: HashMap = HashMap::new(); + let mut nodes_by_id: HashMap = HashMap::new(); + nodes_by_id.insert(start_node.id, &start_node); + nodes_by_id.insert(end_node.id, &end_node); + let mut sequences_by_hash: HashMap<&String, &Sequence> = HashMap::new(); + let mut block_graphs: HashMap> = HashMap::new(); + + for bge in changes.block_group_edges.iter() { + bges_by_bg + .entry(bge.block_group_id) + .and_modify(|l| l.push(bge)) + .or_insert_with(|| vec![bge]); + } + for edge in changes.edges.iter().chain(dependencies.edges.iter()) { + edges_by_id.insert(edge.id, edge); + } + for node in changes.nodes.iter().chain(dependencies.nodes.iter()) { + nodes_by_id.insert(node.id, node); + } + for seq in changes + .sequences + .iter() + .chain(dependencies.sequences.iter()) + { + sequences_by_hash.insert(&seq.hash, seq); + } + + for (bg_id, bg_edges) in bges_by_bg.iter() { + // There are 2 graphs created here. The first graph is our normal graph of nodes + // and edges. This graph is then used to make our second graph representing the spans + // of each node (blocks). + let mut graph: DiGraphMap = DiGraphMap::new(); + let mut block_graph: DiGraphMap = DiGraphMap::new(); + block_graph.add_node(GraphNode { + block_id: -1, + node_id: start_node.id, + sequence_start: 0, + sequence_end: 0, + }); + block_graph.add_node(GraphNode { + block_id: -1, + node_id: end_node.id, + sequence_start: 0, + sequence_end: 0, + }); + for bg_edge in bg_edges { + let edge = *edges_by_id.get(&bg_edge.edge_id).unwrap(); + // Because our model is an edge graph, the coordinate where an edge occurs is + // actually offset from the block. So we need to adjust coordinates when going + // to blocks. This isn't true for our start node though, which has a source + // coordinate of 0. + if Node::is_start_node(edge.source_node_id) { + graph.add_edge( + edge.source_node_id, + edge.target_node_id, + (edge.source_coordinate, edge.target_coordinate), + ); + } else { + graph.add_edge( + edge.source_node_id, + edge.target_node_id, + (edge.source_coordinate - 1, edge.target_coordinate), + ); + } + } + + for node in graph.nodes() { + // This is where we make the block graph. For this, we figure out the positions of + // all incoming and outgoing edges from the node. Then we make blocks between those + // positions. + if Node::is_terminal(node) { + continue; + } + let in_ports = graph + .edges_directed(node, Direction::Incoming) + .map(|(_src, _dest, (_fp, tp))| *tp) + .collect::>(); + let out_ports = graph + .edges_directed(node, Direction::Outgoing) + .map(|(_src, _dest, (fp, _tp))| *fp) + .collect::>(); + + let node_obj = *nodes_by_id.get(&node).unwrap(); + let sequence = *sequences_by_hash.get(&node_obj.sequence_hash).unwrap(); + let s_len = sequence.length; + let mut block_starts: HashSet = HashSet::from_iter(in_ports.iter().copied()); + block_starts.insert(0); + for x in out_ports.iter() { + if *x < s_len - 1 { + block_starts.insert(x + 1); + } + } + let mut block_ends: HashSet = HashSet::from_iter(out_ports.iter().copied()); + block_ends.insert(s_len); + for x in in_ports.iter() { + if *x > 0 { + block_ends.insert(x - 1); + } + } + + let block_starts = block_starts.into_iter().sorted().collect::>(); + let block_ends = block_ends.into_iter().sorted().collect::>(); + + let mut blocks = vec![]; + for (i, j) in block_starts.iter().zip(block_ends.iter()) { + let node = GraphNode { + block_id: -1, + node_id: node, + sequence_start: *i, + sequence_end: *j, + }; + block_graph.add_node(node); + blocks.push(node); + } + + for (i, j) in blocks.iter().tuple_windows() { + block_graph.add_edge( + *i, + *j, + GraphEdge { + edge_id: -1, + source_strand: Strand::Forward, + target_strand: Forward, + chromosome_index: 0, + phased: 0, + }, + ); + } + } + + for (src, dest, (fp, tp)) in graph.all_edges() { + if !(Node::is_end_node(src) && Node::is_start_node(dest)) { + let source_block = block_graph + .nodes() + .find(|node| node.node_id == src && node.sequence_end == *fp) + .unwrap(); + let dest_block = block_graph + .nodes() + .find(|node| node.node_id == dest && node.sequence_start == *tp) + .unwrap(); + block_graph.add_edge( + source_block, + dest_block, + GraphEdge { + edge_id: -1, + source_strand: Strand::Forward, + target_strand: Forward, + chromosome_index: 0, + phased: 0, + }, + ); + } + } + block_graphs.insert(*bg_id, block_graph); + } + block_graphs +} + pub fn view_patches(patches: &[OperationPatch]) -> HashMap> { // For each blockgroup in a patch, a .dot file is generated showing how the base sequence // has been updated. - let start_node = Node::get_start_node(); - let end_node = Node::get_end_node(); let mut diagrams: HashMap> = HashMap::new(); for patch in patches { @@ -34,25 +201,10 @@ pub fn view_patches(patches: &[OperationPatch]) -> HashMap> = HashMap::new(); - let mut edges_by_id: HashMap = HashMap::new(); - let mut nodes_by_id: HashMap = HashMap::new(); - nodes_by_id.insert(start_node.id, &start_node); - nodes_by_id.insert(end_node.id, &end_node); - let mut sequences_by_hash: HashMap<&String, &Sequence> = HashMap::new(); - for bge in new_models.block_group_edges.iter() { - bges_by_bg - .entry(bge.block_group_id) - .and_modify(|l| l.push(bge)) - .or_insert_with(|| vec![bge]); - } - for edge in new_models.edges.iter().chain(dependencies.edges.iter()) { - edges_by_id.insert(edge.id, edge); - } - for node in new_models.nodes.iter().chain(dependencies.nodes.iter()) { - nodes_by_id.insert(node.id, node); - } + let block_graphs = get_change_graph(&new_models, &dependencies); + + let mut sequences_by_hash: HashMap<&String, &Sequence> = HashMap::new(); for seq in new_models .sequences .iter() @@ -60,103 +212,20 @@ pub fn view_patches(patches: &[OperationPatch]) -> HashMap = HashMap::new(); + for node in new_models.nodes.iter().chain(dependencies.nodes.iter()) { + node_sequence_hashes.insert(node.id, &node.sequence_hash); + } - for (bg_id, bg_edges) in bges_by_bg.iter() { - // There are 2 graphs created here. The first graph is our normal graph of nodes - // and edges. This graph is then used to make our second graph representing the spans - // of each node (blocks). - let mut graph: DiGraphMap = DiGraphMap::new(); - let mut block_graph: DiGraphMap<(i64, i64, i64), ()> = DiGraphMap::new(); - block_graph.add_node((start_node.id, 0, 0)); - block_graph.add_node((end_node.id, 0, 0)); - for bg_edge in bg_edges { - let edge = *edges_by_id.get(&bg_edge.edge_id).unwrap(); - // Because our model is an edge graph, the coordinate where an edge occurs is - // actually offset from the block. So we need to adjust coordinates when going - // to blocks. This isn't true for our start node though, which has a source - // coordinate of 0. - if Node::is_start_node(edge.source_node_id) { - graph.add_edge( - edge.source_node_id, - edge.target_node_id, - (edge.source_coordinate, edge.target_coordinate), - ); - } else { - graph.add_edge( - edge.source_node_id, - edge.target_node_id, - (edge.source_coordinate - 1, edge.target_coordinate), - ); - } - } - - for node in graph.nodes() { - // This is where we make the block graph. For this, we figure out the positions of - // all incoming and outgoing edges from the node. Then we make blocks between those - // positions. - if Node::is_terminal(node) { - continue; - } - let in_ports = graph - .edges_directed(node, Direction::Incoming) - .map(|(_src, _dest, (_fp, tp))| *tp) - .collect::>(); - let out_ports = graph - .edges_directed(node, Direction::Outgoing) - .map(|(_src, _dest, (fp, _tp))| *fp) - .collect::>(); - - let node_obj = *nodes_by_id.get(&node).unwrap(); - let sequence = *sequences_by_hash.get(&node_obj.sequence_hash).unwrap(); - let s_len = sequence.length; - let mut block_starts: HashSet = HashSet::from_iter(in_ports.iter().copied()); - block_starts.insert(0); - for x in out_ports.iter() { - if *x < s_len - 1 { - block_starts.insert(x + 1); - } - } - let mut block_ends: HashSet = HashSet::from_iter(out_ports.iter().copied()); - block_ends.insert(s_len); - for x in in_ports.iter() { - if *x > 0 { - block_ends.insert(x - 1); - } - } - - let block_starts = block_starts.into_iter().sorted().collect::>(); - let block_ends = block_ends.into_iter().sorted().collect::>(); - - let mut blocks = vec![]; - for (i, j) in block_starts.iter().zip(block_ends.iter()) { - block_graph.add_node((node, *i, *j)); - blocks.push((node, *i, *j)); - } - - for (i, j) in blocks.iter().tuple_windows() { - block_graph.add_edge(*i, *j, ()); - } - } - - for (src, dest, (fp, tp)) in graph.all_edges() { - if !(Node::is_end_node(src) && Node::is_start_node(dest)) { - let source_block = block_graph - .nodes() - .find(|(node, _start, end)| *node == src && end == fp) - .unwrap(); - let dest_block = block_graph - .nodes() - .find(|(node, start, _end)| *node == dest && start == tp) - .unwrap(); - block_graph.add_edge(source_block, dest_block, ()); - } - } - + for (bg_id, block_graph) in block_graphs.iter() { let mut dot = "digraph {\n rankdir=LR\n node [shape=none]\n".to_string(); - for (node_id, start, end) in block_graph.nodes() { + for node in block_graph.nodes() { + let node_id = node.node_id; + let start = node.sequence_start; + let end = node.sequence_end; let block_id = format!("{node_id}.{start}.{end}"); - if Node::is_terminal(node_id) { - let label = if Node::is_start_node(node_id) { + if Node::is_terminal(node.node_id) { + let label = if Node::is_start_node(node.node_id) { "start" } else { "end" @@ -167,8 +236,8 @@ pub fn view_patches(patches: &[OperationPatch]) -> HashMap 7 { @@ -204,7 +273,13 @@ pub fn view_patches(patches: &[OperationPatch]) -> HashMap