use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::{Block, Borders, Widget};
use igv_core::region::genomic_to_screen;
use igv_core::render::RenderMode;
use igv_core::source::{
AnnotationBlock, AnnotationTranscript, BlockKind, Strand,
};
use crate::app::state::AppState;
use crate::ui::theme::Theme;
const LOWER_EIGHTHS: [char; 9] = [
' ', '\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}',
'\u{2587}', '\u{2588}',
];
const WIDE_LABEL_THRESHOLD: u64 = 10_000;
pub struct AnnotationsWidget<'a> {
pub state: &'a AppState,
pub theme: &'a Theme,
pub track_index: usize,
}
impl Widget for AnnotationsWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let title = self
.state
.annotations
.get(self.track_index)
.map(|t| t.display.clone())
.unwrap_or_else(|| format!("annotation {}", self.track_index));
let block = Block::default()
.borders(Borders::TOP | Borders::BOTTOM)
.style(self.theme.get("BORDER"))
.title(title);
let inner = block.inner(area);
block.render(area, buf);
if inner.area() == 0 {
return;
}
let region = &self.state.region;
let mode = self.state.thresholds.classify(region.width());
let txs = match self.state.annotation_rows.get(self.track_index) {
Some(r) => r,
None => return,
};
if txs.is_empty() {
return;
}
if matches!(mode, RenderMode::OverviewOnly) {
draw_density(buf, inner, region, txs, self.theme);
return;
}
let label_below = region.width() >= WIDE_LABEL_THRESHOLD && inner.height >= 2;
let rows_per_lane: u16 = if label_below { 2 } else { 1 };
let lane_count = (inner.height / rows_per_lane).max(1) as usize;
let lanes = stack_transcripts(txs, lane_count);
for (lane_idx, lane) in lanes.iter().enumerate() {
let y = inner.y + (lane_idx as u16) * rows_per_lane;
for tx in lane {
draw_transcript(buf, inner, y, region, tx, self.theme, label_below);
}
}
}
}
fn draw_density(
buf: &mut Buffer,
inner: Rect,
region: &igv_core::region::Region,
txs: &[AnnotationTranscript],
theme: &Theme,
) {
let cols = inner.width as u32;
if cols == 0 || inner.height == 0 {
return;
}
let span = region.width().max(1);
let mut counts = vec![0u32; cols as usize];
for tx in txs {
let (s, e) = match tx.span() {
Some(p) => p,
None => continue,
};
if e < region.start || s > region.end {
continue;
}
let lo = s.max(region.start);
let hi = e.min(region.end);
let lo_col = ((lo - region.start) * cols as u64 / span) as u32;
let hi_col = ((hi - region.start) * cols as u64 / span) as u32;
let hi_col = hi_col.min(cols.saturating_sub(1));
for c in lo_col..=hi_col {
counts[c as usize] = counts[c as usize].saturating_add(1);
}
}
let max = counts.iter().copied().max().unwrap_or(0);
if max == 0 {
return;
}
let style = theme.get("ANNOTATION_EXON");
let height = inner.height as f32;
for (col, &n) in counts.iter().enumerate() {
if n == 0 {
continue;
}
let frac = (n as f32 / max as f32).clamp(0.0, 1.0) * height;
let eighths = (frac * 8.0).round() as u32;
if eighths == 0 {
continue;
}
let full_rows = (eighths / 8) as u16;
let partial = (eighths % 8) as u8;
let x = inner.x + col as u16;
for row in 0..full_rows.min(inner.height) {
let y = inner.y + inner.height.saturating_sub(1) - row;
buf[(x, y)].set_char('\u{2588}').set_style(style);
}
if partial > 0 && full_rows < inner.height {
let y = inner.y + inner.height.saturating_sub(1) - full_rows;
buf[(x, y)]
.set_char(LOWER_EIGHTHS[partial as usize])
.set_style(style);
}
}
}
#[allow(clippy::needless_lifetimes)]
fn stack_transcripts<'a>(
txs: &'a [AnnotationTranscript],
lane_count: usize,
) -> Vec<Vec<&'a AnnotationTranscript>> {
let mut lanes: Vec<Vec<&AnnotationTranscript>> = (0..lane_count).map(|_| Vec::new()).collect();
'tx: for tx in txs {
let (s, _e) = match tx.span() {
Some(p) => p,
None => continue,
};
for lane in lanes.iter_mut() {
let last_end = lane
.last()
.and_then(|t| t.span())
.map(|(_, e)| e)
.unwrap_or(0);
if last_end + 1 < s {
lane.push(tx);
continue 'tx;
}
}
}
lanes
}
fn draw_transcript(
buf: &mut Buffer,
inner: Rect,
y: u16,
region: &igv_core::region::Region,
tx: &AnnotationTranscript,
theme: &Theme,
label_below: bool,
) {
let view_start_0 = region.start - 1;
let view_width = region.width();
let intron_style = theme.get("ANNOTATION_INTRON");
let utr_style = theme.get("ANNOTATION_UTR");
let exon_style = theme.get("ANNOTATION_EXON");
let strand_style = theme.get("ANNOTATION_STRAND");
let name_style = theme.get("ANNOTATION_NAME");
if let Some((s, e)) = tx.span() {
let mut g = s.saturating_sub(1);
let g_end = e.saturating_sub(1);
while g <= g_end {
if let Some(col) = genomic_to_screen(g, view_start_0, view_width, inner.width as u32) {
if col < inner.width as u32 {
let cell = &mut buf[(inner.x + col as u16, y)];
if cell.symbol().chars().next().unwrap_or(' ') == ' ' {
cell.set_char('─').set_style(intron_style);
}
}
}
g += 1;
}
}
let mut blocks: Vec<&AnnotationBlock> = tx.blocks.iter().collect();
blocks.sort_by_key(|b| match b.kind {
BlockKind::Utr5 | BlockKind::Utr3 => 0,
_ => 1,
});
for blk in blocks {
let (glyph, style) = match blk.kind {
BlockKind::Utr5 | BlockKind::Utr3 => ('▯', utr_style),
BlockKind::Exon | BlockKind::Cds | BlockKind::BedSegment => ('▮', exon_style),
};
let g_start = blk.start.saturating_sub(1);
let g_end = blk.end.saturating_sub(1);
let mut g = g_start;
while g <= g_end {
if let Some(col) = genomic_to_screen(g, view_start_0, view_width, inner.width as u32) {
if col < inner.width as u32 {
buf[(inner.x + col as u16, y)].set_char(glyph).set_style(style);
}
}
g += 1;
}
}
if let Some((_, e)) = tx.span() {
let g0 = e.saturating_sub(1);
if let Some(col) = genomic_to_screen(g0, view_start_0, view_width, inner.width as u32) {
let glyph = match tx.strand {
Strand::Forward => '>',
Strand::Reverse => '<',
Strand::Unknown => return,
};
if col < inner.width as u32 {
buf[(inner.x + col as u16, y)].set_char(glyph).set_style(strand_style);
}
}
}
if !tx.name.is_empty() {
if label_below {
draw_name_below(buf, inner, y, region, tx, name_style);
} else {
draw_name_left(buf, inner, y, region, tx, name_style);
}
}
}
fn draw_name_left(
buf: &mut Buffer,
inner: Rect,
y: u16,
region: &igv_core::region::Region,
tx: &AnnotationTranscript,
name_style: ratatui::style::Style,
) {
let view_start_0 = region.start - 1;
let view_width = region.width();
let (s, _) = match tx.span() {
Some(p) => p,
None => return,
};
let g0 = s.saturating_sub(1);
let col = match genomic_to_screen(g0, view_start_0, view_width, inner.width as u32) {
Some(c) => c,
None => return,
};
let label = format!("{} ", tx.name);
let needed = label.len() as u32;
if col < needed {
return;
}
let start_col = col - needed;
for (i, ch) in label.chars().enumerate() {
if start_col as u16 + i as u16 >= inner.width {
break;
}
buf[(inner.x + start_col as u16 + i as u16, y)]
.set_char(ch)
.set_style(name_style);
}
}
fn draw_name_below(
buf: &mut Buffer,
inner: Rect,
y: u16,
region: &igv_core::region::Region,
tx: &AnnotationTranscript,
name_style: ratatui::style::Style,
) {
let label_y = y + 1;
if label_y >= inner.y + inner.height {
return;
}
let view_start_0 = region.start - 1;
let view_width = region.width();
let (s, e) = match tx.span() {
Some(p) => p,
None => return,
};
let g0_start = s.saturating_sub(1);
let g0_end = e.saturating_sub(1);
if g0_end < view_start_0 || g0_start >= view_start_0 + view_width {
return;
}
let start_col =
genomic_to_screen(g0_start, view_start_0, view_width, inner.width as u32).unwrap_or(0);
for (i, ch) in tx.name.chars().enumerate() {
let col = start_col as u16 + i as u16;
if col >= inner.width {
break;
}
buf[(inner.x + col, label_y)]
.set_char(ch)
.set_style(name_style);
}
}
#[cfg(test)]
mod tests {
use super::*;
use igv_core::region::Region;
use igv_core::source::TranscriptKind;
fn make_tx(s: u64, e: u64) -> AnnotationTranscript {
AnnotationTranscript {
name: "g".into(),
id: "t".into(),
gene_id: None,
strand: Strand::Forward,
blocks: vec![AnnotationBlock { start: s, end: e, kind: BlockKind::Cds }],
kind: TranscriptKind::Mrna,
}
}
#[test]
fn density_renders_block_chars_in_overview() {
let region = Region::new("chr1", 1, 100_000_000).unwrap();
let txs = vec![make_tx(1_000_000, 1_500_000), make_tx(1_200_000, 1_600_000)];
let theme = Theme::dark();
let area = Rect::new(0, 0, 80, 4);
let mut buf = Buffer::empty(area);
draw_density(&mut buf, area, ®ion, &txs, &theme);
let mut found = false;
for x in 0..3u16 {
for y in 0..area.height {
let ch = buf[(x, y)].symbol().chars().next().unwrap_or(' ');
if matches!(ch, '\u{2581}'..='\u{2588}') {
found = true;
}
}
}
assert!(found, "expected density blocks near chr1 start");
}
#[test]
fn density_skips_when_no_overlap() {
let region = Region::new("chr1", 1, 100_000_000).unwrap();
let txs = vec![make_tx(99_000_000, 99_500_000)]; let theme = Theme::dark();
let area = Rect::new(0, 0, 80, 4);
let mut buf = Buffer::empty(area);
draw_density(&mut buf, area, ®ion, &txs, &theme);
let mut found = false;
for x in 76..80u16 {
for y in 0..area.height {
let ch = buf[(x, y)].symbol().chars().next().unwrap_or(' ');
if matches!(ch, '\u{2581}'..='\u{2588}') {
found = true;
}
}
}
assert!(found, "expected density block near chr1 end");
}
}