use std::{
cmp::{max, min},
collections::{HashMap, HashSet},
};
use gen_core::{HashId, is_end_node, is_start_node};
use gen_graph::GenGraph;
use gen_tui::{CroppedGraph, GraphController, ViewportState, VisualDetail, WorldPos, WorldRect};
use petgraph::visit::NodeIndexable;
use ratatui::{
layout::Rect,
style::{Color, Style},
};
use crate::{config::get_theme_color, views::gen_graph_widget::GenGraphNodeSizer};
#[derive(Clone, Debug)]
pub struct AnnotationSegment {
pub node_id: HashId,
pub start: i64,
pub end: i64,
}
#[derive(Clone, Debug)]
pub struct AnnotationSpan {
pub id: HashId,
pub name: String,
pub segments: Vec<AnnotationSegment>,
}
#[derive(Clone, Debug)]
pub struct AnnotationTrack {
pub name: String,
pub annotations: Vec<AnnotationSpan>,
pub annotation_segments_by_node: HashMap<HashId, Vec<(usize, AnnotationSegment)>>,
}
impl AnnotationTrack {
pub fn new(name: impl Into<String>, annotations: Vec<AnnotationSpan>) -> Self {
let name = name.into();
let mut segments_by_node: HashMap<HashId, Vec<(usize, AnnotationSegment)>> = HashMap::new();
for (idx, annotation) in annotations.iter().enumerate() {
for segment in &annotation.segments {
segments_by_node
.entry(segment.node_id)
.or_default()
.push((idx, segment.clone()));
}
}
AnnotationTrack {
name,
annotations,
annotation_segments_by_node: segments_by_node,
}
}
}
type AnnotationSegmentsByIndex = HashMap<usize, Vec<(i64, i64)>>;
type AnnotationSegmentsResult = (Vec<usize>, AnnotationSegmentsByIndex);
fn collect_annotation_segments(
track: &AnnotationTrack,
viewport_graph: &CroppedGraph,
viewport_state: &ViewportState,
graph: &GenGraph,
) -> AnnotationSegmentsResult {
if track.annotations.is_empty() {
return (Vec::new(), HashMap::new());
}
const HORIZONTAL_LOOKAHEAD: i64 = 8;
let mut visible_indices = Vec::new();
let mut visible_index_set = HashSet::new();
let mut segments_by_annotation: AnnotationSegmentsByIndex = HashMap::new();
let camera_rect = viewport_state.camera_rect();
let window_start = camera_rect.min.x;
let window_end = camera_rect.max.x;
let left_bound = window_start - HORIZONTAL_LOOKAHEAD;
let right_bound = window_end + HORIZONTAL_LOOKAHEAD;
for (world_pos, domain_idx, layout_node) in viewport_graph.data_nodes() {
let block = <&GenGraph as NodeIndexable>::from_index(&graph, domain_idx.index());
if is_start_node(block.node_id) || is_end_node(block.node_id) {
continue;
}
let node_rect = WorldRect::from_center_and_size(world_pos, layout_node.size);
let x1 = node_rect.min.x;
let x2 = node_rect.max.x;
let near_horizontally = x2 >= left_bound && x1 <= right_bound;
if !near_horizontally {
continue;
}
let Some(segments) = track.annotation_segments_by_node.get(&block.node_id) else {
continue;
};
let node_len = block.sequence_end - block.sequence_start;
if node_len <= 0 {
continue;
}
let label_len = x2 - x1;
if label_len <= 0 {
continue;
}
for (idx, segment) in segments {
let overlap_start = max(segment.start, block.sequence_start);
let overlap_end = min(segment.end, block.sequence_end);
if overlap_end <= overlap_start {
continue;
}
let seg_x1 = x1 + (overlap_start - block.sequence_start) * label_len / node_len;
let seg_x2 = x1 + (overlap_end - block.sequence_start) * label_len / node_len;
let (seg_x1, seg_x2) = if seg_x2 < seg_x1 {
(seg_x2, seg_x1)
} else {
(seg_x1, seg_x2)
};
let is_on_screen = seg_x2 >= window_start && seg_x1 <= window_end;
if is_on_screen && visible_index_set.insert(*idx) {
visible_indices.push(*idx);
}
segments_by_annotation
.entry(*idx)
.or_default()
.push((seg_x1, seg_x2));
}
}
visible_indices.sort_unstable();
(visible_indices, segments_by_annotation)
}
pub fn annotation_panel_height(track: &AnnotationTrack, max_height: u16) -> u16 {
if max_height < 2 {
return 0;
}
if track.annotations.is_empty() {
return if track.name.is_empty() {
0
} else {
2.min(max_height)
};
}
let desired = track.annotations.len().saturating_add(1) as u16;
let cap = max_height.saturating_div(3).max(3);
desired.min(cap).min(max_height)
}
pub fn draw_annotations_panel(
frame: &mut ratatui::Frame,
area: Rect,
track: &AnnotationTrack,
controller: &GraphController<GenGraph, GenGraphNodeSizer>,
) {
if area.height < 2 {
return;
}
let divider_style = Style::default().fg(get_theme_color("separator").unwrap());
let divider_y = area.y;
let divider = "─".repeat(area.width as usize);
frame
.buffer_mut()
.set_string(area.x, divider_y, divider, divider_style);
if !track.name.is_empty() {
frame.buffer_mut().set_string(
area.x + 1,
divider_y,
&track.name,
Style::default().fg(get_theme_color("text_muted").unwrap()),
);
}
let inner = Rect {
x: area.x,
y: area.y + 1,
width: area.width,
height: area.height - 1,
};
if inner.height == 0 || inner.width == 0 {
return;
}
let bg_color = get_theme_color("canvas").unwrap();
let bg_style = Style::default().bg(bg_color);
for row in inner.y..inner.y + inner.height {
let blank = " ".repeat(inner.width as usize);
frame.buffer_mut().set_string(inner.x, row, blank, bg_style);
}
let zoomed_out = controller.get_detail_level() == VisualDetail::Minimal;
let annotation_color = get_theme_color("base0b").unwrap_or(Color::Green);
let annotation_label_style = Style::default().fg(annotation_color).bg(bg_color);
let annotation_bar_style = Style::default().bg(annotation_color);
let annotation_dot_style = Style::default().fg(annotation_color).bg(bg_color);
let (visible_indices, segments_by_annotation) = collect_annotation_segments(
track,
controller.get_viewport_graph(),
&controller.viewport_state,
controller.graph(),
);
if visible_indices.is_empty() {
return;
}
let max_rows = inner.height as usize;
let row_count = visible_indices.len().min(max_rows);
let viewport_state = &controller.viewport_state;
for (row, idx) in visible_indices.iter().take(row_count).enumerate() {
let Some(mut segments) = segments_by_annotation.get(idx).cloned() else {
continue;
};
segments.sort_by(|a, b| a.0.cmp(&b.0));
let terminal_y = inner.y + row as u16;
let annotation_name = &track.annotations[*idx].name;
if let Some((first_x1, _)) = segments.first() {
let label_len = annotation_name.chars().count() as i64;
let label_world_x = first_x1 - label_len - 1;
if let Some((term_x, _)) =
viewport_state.world_to_terminal(WorldPos::new(label_world_x, 0))
{
let label_start = term_x.max(inner.x);
if label_start < inner.x + inner.width {
frame.buffer_mut().set_string(
label_start,
terminal_y,
annotation_name,
annotation_label_style,
);
}
}
}
let mut prev_end: Option<i64> = None;
for (x1, x2) in &segments {
if zoomed_out {
let center = (x1 + x2) / 2;
if let Some((term_x, _)) =
viewport_state.world_to_terminal(WorldPos::new(center, 0))
&& term_x >= inner.x
&& term_x < inner.x + inner.width
{
frame
.buffer_mut()
.set_string(term_x, terminal_y, "●", annotation_dot_style);
}
} else {
place_bar(
frame,
inner,
viewport_state,
*x1,
*x2,
terminal_y,
annotation_bar_style,
);
}
if !zoomed_out
&& let Some(prev) = prev_end
&& x1 - prev > 1
{
draw_dashed_connector(
frame,
inner,
viewport_state,
prev + 1,
x1 - 1,
terminal_y,
annotation_label_style,
);
}
prev_end = Some(*x2);
}
}
}
fn place_bar(
frame: &mut ratatui::Frame,
inner: Rect,
viewport_state: &ViewportState,
world_x1: i64,
world_x2: i64,
terminal_y: u16,
style: Style,
) {
let term_x1 = viewport_state
.world_to_terminal(WorldPos::new(world_x1, 0))
.map(|(x, _)| x);
let term_x2 = viewport_state
.world_to_terminal(WorldPos::new(world_x2, 0))
.map(|(x, _)| x);
let start_x = term_x1.unwrap_or(inner.x).max(inner.x);
let end_x = term_x2
.unwrap_or(inner.x + inner.width - 1)
.min(inner.x + inner.width - 1);
if start_x > end_x {
return;
}
let width = (end_x - start_x + 1) as usize;
let bar = " ".repeat(width);
frame
.buffer_mut()
.set_string(start_x, terminal_y, bar, style);
}
fn draw_dashed_connector(
frame: &mut ratatui::Frame,
inner: Rect,
viewport_state: &ViewportState,
world_x_start: i64,
world_x_end: i64,
terminal_y: u16,
style: Style,
) {
if world_x_end <= world_x_start {
return;
}
let term_x1 = viewport_state
.world_to_terminal(WorldPos::new(world_x_start, 0))
.map(|(x, _)| x);
let term_x2 = viewport_state
.world_to_terminal(WorldPos::new(world_x_end, 0))
.map(|(x, _)| x);
let start_x = term_x1.unwrap_or(inner.x).max(inner.x);
let end_x = term_x2
.unwrap_or(inner.x + inner.width - 1)
.min(inner.x + inner.width - 1);
if start_x > end_x {
return;
}
let visible_width = (end_x - start_x + 1) as usize;
let pattern_offset =
(start_x as i64 - term_x1.unwrap_or(start_x) as i64).unsigned_abs() as usize;
let mut label = String::with_capacity(visible_width);
for i in 0..visible_width {
if (pattern_offset + i).is_multiple_of(2) {
label.push('-');
} else {
label.push(' ');
}
}
frame
.buffer_mut()
.set_string(start_x, terminal_y, label, style);
}