mod glyphs;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
use crate::model::{AppMode, AppState, ViewMode};
use glyphs::Glyphs;
const NAME_PANEL_WIDTH: u16 = 20;
const MIN_SEQ_PANEL_WIDTH: u16 = 10;
const STATUS_BAR_HEIGHT: u16 = 1;
const HINT_BAR_HEIGHT: u16 = 1;
pub trait ColorScheme {
fn get_color(&self, c: char) -> Color;
}
pub struct DnaColorScheme;
impl ColorScheme for DnaColorScheme {
fn get_color(&self, c: char) -> Color {
match c.to_ascii_uppercase() {
'A' => Color::Red,
'C' => Color::Green,
'G' => Color::Yellow,
'T' | 'U' => Color::Blue,
'-' | '.' => Color::Rgb(180, 180, 180),
'N' | 'R' | 'Y' | 'S' | 'W' | 'K' | 'M' | 'B' | 'D' | 'H' | 'V' => Color::Rgb(140, 140, 140),
'*' | '!' => Color::White,
_ => Color::Rgb(140, 140, 140),
}
}
}
pub struct AminoAcidColorScheme;
impl ColorScheme for AminoAcidColorScheme {
fn get_color(&self, c: char) -> Color {
match c.to_ascii_uppercase() {
'K' | 'R' => Color::Red,
'A' | 'F' | 'I' | 'L' | 'M' | 'V' | 'W' => Color::Blue,
'N' | 'Q' | 'S' | 'T' => Color::Green,
'H' | 'Y' => Color::Cyan,
'C' => Color::Rgb(255, 180, 180),
'D' | 'E' => Color::Magenta,
'P' => Color::Yellow,
'G' => Color::Rgb(255, 200, 150),
'-' | '.' => Color::Rgb(180, 180, 180),
'X' | 'B' | 'Z' | 'J' | 'O' | 'U' => Color::Rgb(140, 140, 140),
'*' | '!' => Color::White,
_ => Color::Rgb(140, 140, 140),
}
}
}
fn get_color_for_sequence_type(c: char, seq_type: crate::model::SequenceType) -> Color {
if seq_type.is_nucleotide() {
DnaColorScheme.get_color(c)
} else {
AminoAcidColorScheme.get_color(c)
}
}
pub fn render(frame: &mut Frame, state: &AppState) {
let glyphs = glyphs::select(state.fancy_ui);
let area = frame.area();
let main_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3),
Constraint::Length(STATUS_BAR_HEIGHT),
Constraint::Length(HINT_BAR_HEIGHT),
])
.split(area);
let content_area = main_layout[0];
let status_area = main_layout[1];
let hint_area = main_layout[2];
let content_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(NAME_PANEL_WIDTH),
Constraint::Min(MIN_SEQ_PANEL_WIDTH),
])
.split(content_area);
let names_area = content_layout[0];
let sequences_area = content_layout[1];
let visible_rows = (sequences_area.height.saturating_sub(2)) as usize; let visible_cols = (sequences_area.width.saturating_sub(2)) as usize;
render_names_panel(frame, state, names_area, visible_rows);
render_sequences_panel(frame, state, sequences_area, visible_rows, visible_cols);
render_status_bar(frame, state, &glyphs, status_area);
render_hint_bar(frame, &glyphs, hint_area);
if state.show_help {
render_help_overlay(frame, state, &glyphs, area);
}
if state.mode == AppMode::TranslationSettings {
render_translation_settings_overlay(frame, state, &glyphs, area);
}
if state.loading_state.is_loading() {
render_loading_overlay(frame, state, area);
}
if let Some(error_msg) = &state.error_popup {
render_error_popup(frame, error_msg, area);
}
if let Some(browser) = &state.file_browser {
render_file_browser(frame, browser, &glyphs, area);
}
}
fn render_names_panel(frame: &mut Frame, state: &AppState, area: Rect, visible_rows: usize) {
let mut lines: Vec<Line> = Vec::new();
let alignment = state.active_alignment();
let start_row = state.viewport.first_row;
let end_row = (start_row + visible_rows).min(alignment.sequence_count());
for row_idx in start_row..end_row {
if let Some(seq) = alignment.get(row_idx) {
let is_current = row_idx == state.cursor.row;
let max_name_len = (NAME_PANEL_WIDTH.saturating_sub(3)) as usize;
let name = if seq.id.len() > max_name_len {
let truncate_len = max_name_len.saturating_sub(3);
format!("{}...", &seq.id[..truncate_len])
} else {
seq.id.clone()
};
let style = if is_current {
Style::default()
.fg(Color::Black)
.bg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
lines.push(Line::from(Span::styled(name, style)));
}
}
let block = Block::default()
.borders(Borders::ALL)
.title("Sequences");
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
fn render_sequences_panel(
frame: &mut Frame,
state: &AppState,
area: Rect,
visible_rows: usize,
visible_cols: usize,
) {
let alignment = state.active_alignment();
let seq_type = alignment.sequence_type;
let mut lines: Vec<Line> = Vec::new();
let start_row = state.viewport.first_row;
let end_row = (start_row + visible_rows).min(alignment.sequence_count());
let start_col = state.viewport.first_col;
let end_col = (start_col + visible_cols).min(alignment.alignment_length());
for row_idx in start_row..end_row {
if let Some(seq) = alignment.get(row_idx) {
let is_current_row = row_idx == state.cursor.row;
let mut spans: Vec<Span> = Vec::new();
for col_idx in start_col..end_col {
let c = seq.char_at(col_idx).unwrap_or(' ');
let is_cursor = is_current_row && col_idx == state.cursor.col;
let bg_color = get_color_for_sequence_type(c, seq_type);
let fg_color = Color::Black;
let style = if is_cursor {
Style::default()
.fg(bg_color)
.bg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(fg_color).bg(bg_color)
};
spans.push(Span::styled(c.to_string(), style));
}
lines.push(Line::from(spans));
}
}
let view_info = match state.view_mode {
ViewMode::Nucleotide => "NT".to_string(),
ViewMode::AminoAcid => format!(
"AA, code {}, frame +{}",
state.translation_settings.genetic_code_id,
state.translation_settings.frame + 1
),
};
let title = format!(
"{} ({}) [Site: {} | View: {}-{}/{}]",
state.file_name,
view_info,
state.cursor.col + 1,
start_col + 1,
end_col,
alignment.alignment_length()
);
let block = Block::default().borders(Borders::ALL).title(title);
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
fn render_status_bar(frame: &mut Frame, state: &AppState, glyphs: &Glyphs, area: Rect) {
let alignment = state.active_alignment();
let position_info = format!(
"Seq {}/{} | Col {}/{} ",
state.cursor.row + 1,
alignment.sequence_count(),
state.cursor.col + 1,
alignment.alignment_length()
);
let status_line = match &state.mode {
AppMode::Normal => {
let message = state.status_message.as_deref().unwrap_or("");
let left_content = format!(" NORMAL | {} ", message);
let left_len = left_content.len();
Line::from(vec![
Span::styled(
left_content,
Style::default().fg(Color::Black).bg(Color::Cyan),
),
Span::styled(
" ".repeat((area.width as usize).saturating_sub(left_len + position_info.len())),
Style::default().bg(Color::Cyan),
),
Span::styled(
position_info.clone(),
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD),
),
])
}
AppMode::Command(cmd) => {
let mode_label = " COMMAND ";
let input_with_cursor = format!(":{}{}", cmd, glyphs.cursor);
let input_len = input_with_cursor.len() + 1; let mode_len = mode_label.len();
Line::from(vec![
Span::styled(
mode_label,
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {} ", input_with_cursor),
Style::default().fg(Color::Black).bg(Color::White),
),
Span::styled(
" ".repeat((area.width as usize).saturating_sub(mode_len + input_len + 1 + position_info.len())),
Style::default().bg(Color::Cyan),
),
Span::styled(
position_info.clone(),
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD),
),
])
}
AppMode::Search(pattern) => {
let mode_label = " SEARCH ";
let input_with_cursor = format!("/{}{}", pattern, glyphs.cursor);
let input_len = input_with_cursor.len() + 1;
let mode_len = mode_label.len();
Line::from(vec![
Span::styled(
mode_label,
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {} ", input_with_cursor),
Style::default().fg(Color::Black).bg(Color::White),
),
Span::styled(
" ".repeat((area.width as usize).saturating_sub(mode_len + input_len + 1 + position_info.len())),
Style::default().bg(Color::Cyan),
),
Span::styled(
position_info.clone(),
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD),
),
])
}
AppMode::SearchBackward(pattern) => {
let mode_label = " SEARCH ";
let input_with_cursor = format!("?{}{}", pattern, glyphs.cursor);
let input_len = input_with_cursor.len() + 1;
let mode_len = mode_label.len();
Line::from(vec![
Span::styled(
mode_label,
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {} ", input_with_cursor),
Style::default().fg(Color::Black).bg(Color::White),
),
Span::styled(
" ".repeat((area.width as usize).saturating_sub(mode_len + input_len + 1 + position_info.len())),
Style::default().bg(Color::Cyan),
),
Span::styled(
position_info.clone(),
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD),
),
])
}
AppMode::TranslationSettings => {
let left_content = " TRANSLATE | Use j/k for code, h/l for frame ";
let left_len = left_content.len();
Line::from(vec![
Span::styled(
left_content,
Style::default().fg(Color::Black).bg(Color::Cyan),
),
Span::styled(
" ".repeat((area.width as usize).saturating_sub(left_len + position_info.len())),
Style::default().bg(Color::Cyan),
),
Span::styled(
position_info.clone(),
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD),
),
])
}
};
let paragraph = Paragraph::new(status_line);
frame.render_widget(paragraph, area);
}
fn render_hint_bar(frame: &mut Frame, glyphs: &Glyphs, area: Rect) {
let arrows = format!(
" {}{}{}{} ",
glyphs.arrow_left, glyphs.arrow_up, glyphs.arrow_down, glyphs.arrow_right
);
let hints = vec![
Span::styled(" :q ", Style::default().fg(Color::Black).bg(Color::DarkGray)),
Span::styled(" quit ", Style::default().fg(Color::Gray)),
Span::styled(" :h ", Style::default().fg(Color::Black).bg(Color::DarkGray)),
Span::styled(" help ", Style::default().fg(Color::Gray)),
Span::styled(" / ", Style::default().fg(Color::Black).bg(Color::DarkGray)),
Span::styled(" search ", Style::default().fg(Color::Gray)),
Span::styled(arrows, Style::default().fg(Color::Black).bg(Color::DarkGray)),
Span::styled(" navigate ", Style::default().fg(Color::Gray)),
];
let hint_line = Line::from(hints);
let paragraph = Paragraph::new(hint_line);
frame.render_widget(paragraph, area);
}
fn render_help_overlay(frame: &mut Frame, state: &AppState, glyphs: &Glyphs, area: Rect) {
use crate::model::HelpTab;
let popup_width = 58.min(area.width.saturating_sub(4));
let popup_height = 18.min(area.height.saturating_sub(4));
let popup_x = (area.width.saturating_sub(popup_width)) / 2;
let popup_y = (area.height.saturating_sub(popup_height)) / 2;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
frame.render_widget(Clear, popup_area);
let tab_spans: Vec<Span> = HelpTab::all()
.iter()
.map(|&tab| {
let name = format!(" {} ", tab.name());
if tab == state.help_tab {
Span::styled(
name,
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
} else {
Span::styled(name, Style::default().fg(Color::Gray))
}
})
.collect();
let mut help_lines: Vec<Line> = vec![
Line::from(tab_spans),
Line::from(""),
];
match state.help_tab {
HelpTab::Basics => {
let tab_hint = format!(
"Use {}/{} or h/l to switch tabs",
glyphs.arrow_left, glyphs.arrow_right
);
help_lines.extend(vec![
Line::from(Span::styled("QUICK COMMANDS", Style::default().add_modifier(Modifier::BOLD))),
Line::from(""),
Line::from(" :q Quit the application"),
Line::from(" :h Toggle this help"),
Line::from(" :e Open file browser"),
Line::from(" :<number> Jump to sequence/row number"),
Line::from(" :w file.fa Save to FASTA (single-line seqs)"),
Line::from(""),
Line::from(Span::styled("CLI MODE", Style::default().add_modifier(Modifier::BOLD))),
Line::from(""),
Line::from(" Run 'seqtui -h' for CLI options (convert,"),
Line::from(" concatenate, translate, VCF export)."),
Line::from(" https://github.com/ranwez-search/SeqTUI"),
Line::from(""),
Line::from(Span::styled(tab_hint, Style::default().fg(Color::DarkGray))),
]);
}
HelpTab::Navigation => {
let arrows = format!(
"{}{}{}{}",
glyphs.arrow_left, glyphs.arrow_up, glyphs.arrow_down, glyphs.arrow_right
);
let lr = format!("{}{}", glyphs.arrow_left, glyphs.arrow_right);
let ud = format!("{}{}", glyphs.arrow_up, glyphs.arrow_down);
let tab_hint = format!(
"Use {}/{} or h/l to switch tabs",
glyphs.arrow_left, glyphs.arrow_right
);
help_lines.extend(vec![
Line::from(Span::styled("ARROW KEY NAVIGATION", Style::default().add_modifier(Modifier::BOLD))),
Line::from(""),
Line::from(format!(" {} Move one position", arrows)),
Line::from(format!(" Shift + {} Half page left/right", lr)),
Line::from(format!(" Shift + {} Full page up/down", ud)),
Line::from(""),
Line::from(" Home Go to first column"),
Line::from(" End Go to last column"),
Line::from(" PgUp / PgDn Page up/down"),
Line::from(""),
Line::from(Span::styled(tab_hint, Style::default().fg(Color::DarkGray))),
]);
}
HelpTab::VimNav => {
let tab_hint = format!(
"Use {}/{} or h/l to switch tabs",
glyphs.arrow_left, glyphs.arrow_right
);
help_lines.extend(vec![
Line::from(Span::styled("VIM-STYLE NAVIGATION", Style::default().add_modifier(Modifier::BOLD))),
Line::from(""),
Line::from(" h / j / k / l Move left/down/up/right"),
Line::from(" w / b / e Next/prev/end of word"),
Line::from(" Ctrl+U / D Half page up/down"),
Line::from(" zH / zL Half page left/right"),
Line::from(""),
Line::from(" 0 / $ First / last column"),
Line::from(" g0 / gm / g$ First/middle/last visible column"),
Line::from(" <num>| Go to column (e.g., 50|)"),
Line::from(""),
Line::from(Span::styled(tab_hint, Style::default().fg(Color::DarkGray))),
]);
}
HelpTab::Search => {
let tab_hint = format!(
"Use {}/{} or h/l to switch tabs",
glyphs.arrow_left, glyphs.arrow_right
);
help_lines.extend(vec![
Line::from(Span::styled("SEARCH", Style::default().add_modifier(Modifier::BOLD))),
Line::from(""),
Line::from(" /pattern Search forward"),
Line::from(" ?pattern Search backward"),
Line::from(" n Find next match"),
Line::from(" N Find previous match"),
Line::from(""),
Line::from(" Searches both sequence names and sequences."),
Line::from(" Search is case-insensitive."),
Line::from(""),
Line::from(Span::styled(tab_hint, Style::default().fg(Color::DarkGray))),
]);
}
HelpTab::Translation => {
let ud = format!("{}/{}", glyphs.arrow_up, glyphs.arrow_down);
let lr = format!("{}/{}", glyphs.arrow_left, glyphs.arrow_right);
help_lines.extend(vec![
Line::from(Span::styled("TRANSLATION (NT -> AA)", Style::default().add_modifier(Modifier::BOLD))),
Line::from(""),
Line::from(" :asAA Translate (uses current settings)"),
Line::from(" :asNT Switch back to nucleotide view"),
Line::from(" :setcode Change genetic code and frame"),
Line::from(""),
Line::from(" Default: Standard code (1), frame +1"),
Line::from(" Settings are remembered between translations."),
Line::from(""),
Line::from(Span::styled("SETTINGS DIALOG (:setcode)", Style::default().add_modifier(Modifier::BOLD))),
Line::from(""),
Line::from(format!(" {} or j/k Select genetic code (33 available)", ud)),
Line::from(format!(" {} or h/l Select reading frame (+1, +2, +3)", lr)),
Line::from(" Enter Confirm Esc Cancel"),
]);
}
}
let block = Block::default()
.borders(Borders::ALL)
.title(" Help ")
.title_style(Style::default().add_modifier(Modifier::BOLD))
.style(Style::default().bg(Color::Black));
let paragraph = Paragraph::new(help_lines).block(block);
frame.render_widget(paragraph, popup_area);
}
fn render_translation_settings_overlay(
frame: &mut Frame,
state: &AppState,
glyphs: &Glyphs,
area: Rect,
) {
use crate::genetic_code::GeneticCodes;
let popup_width = 56.min(area.width.saturating_sub(4));
let popup_height = 12.min(area.height.saturating_sub(4));
let popup_x = (area.width.saturating_sub(popup_width)) / 2;
let popup_y = (area.height.saturating_sub(popup_height)) / 2;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
frame.render_widget(Clear, popup_area);
let codes = GeneticCodes::new();
let all_codes = codes.all();
let selected_idx = state.translation_settings.selected_code_index;
let selected_code = all_codes.get(selected_idx);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(Span::styled(
"Reading Frame:",
Style::default().add_modifier(Modifier::BOLD),
)));
let frame_spans: Vec<Span> = (0..3)
.map(|f| {
let label = format!(" +{} ", f + 1);
if f == state.translation_settings.selected_frame as usize {
Span::styled(
label,
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
} else {
Span::styled(label, Style::default().fg(Color::Gray))
}
})
.collect();
lines.push(Line::from(vec![
Span::raw(" "),
frame_spans[0].clone(),
Span::raw(" "),
frame_spans[1].clone(),
Span::raw(" "),
frame_spans[2].clone(),
]));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Genetic Code:",
Style::default().add_modifier(Modifier::BOLD),
)));
if let Some(code) = selected_code {
let code_label = format!(" {:2}. {}", code.id, code.name);
let max_label_len = popup_width as usize;
let truncated = if code_label.len() > max_label_len.saturating_sub(4) {
let truncate_len = max_label_len.saturating_sub(7);
format!("{}...", &code_label[..truncate_len])
} else {
code_label
};
lines.push(Line::from(Span::styled(
truncated,
Style::default()
.fg(Color::Black)
.bg(Color::White)
.add_modifier(Modifier::BOLD),
)));
}
let nav_hint = format!(
" {}/{} to change ({}/{})",
glyphs.arrow_up,
glyphs.arrow_down,
selected_idx + 1,
all_codes.len()
);
lines.push(Line::from(Span::styled(nav_hint, Style::default().fg(Color::DarkGray))));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" Enter ", Style::default().fg(Color::Black).bg(Color::DarkGray)),
Span::styled(" translate ", Style::default().fg(Color::Gray)),
Span::styled(" Esc ", Style::default().fg(Color::Black).bg(Color::DarkGray)),
Span::styled(" cancel ", Style::default().fg(Color::Gray)),
]));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Use :asNT to switch back to nucleotides",
Style::default().fg(Color::DarkGray),
)));
let block = Block::default()
.borders(Borders::ALL)
.title(" Translation Settings ")
.title_style(Style::default().add_modifier(Modifier::BOLD))
.style(Style::default().bg(Color::Black));
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, popup_area);
}
fn render_loading_overlay(frame: &mut Frame, state: &AppState, area: Rect) {
use crate::model::LoadingState;
let (message, progress_info) = match &state.loading_state {
LoadingState::Ready => return, LoadingState::LoadingFile { message, sequences_loaded, .. } => {
let extra = sequences_loaded
.map(|n| format!(" ({} sequences)", n))
.unwrap_or_default();
(message.clone(), extra)
}
LoadingState::Translating { message, sequences_done, total, .. } => {
let extra = format!(" ({}/{})", sequences_done, total);
(message.clone(), extra)
}
};
let popup_width = 50.min(area.width.saturating_sub(4));
let popup_height = 5;
let popup_x = (area.width.saturating_sub(popup_width)) / 2;
let popup_y = (area.height.saturating_sub(popup_height)) / 2;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
frame.render_widget(Clear, popup_area);
let spinner = state.spinner_char();
let lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(
format!(" {} ", spinner),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::styled(&message, Style::default().fg(Color::White)),
Span::styled(progress_info, Style::default().fg(Color::DarkGray)),
]),
Line::from(""),
];
let block = Block::default()
.borders(Borders::ALL)
.title(" Loading ")
.title_style(Style::default().add_modifier(Modifier::BOLD))
.style(Style::default().bg(Color::Black));
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, popup_area);
}
fn render_error_popup(frame: &mut Frame, error_msg: &str, area: Rect) {
let popup_width = 60.min(area.width.saturating_sub(4));
let popup_height = 7;
let popup_x = (area.width.saturating_sub(popup_width)) / 2;
let popup_y = (area.height.saturating_sub(popup_height)) / 2;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
frame.render_widget(Clear, popup_area);
let max_line_len = (popup_width - 4) as usize;
let wrapped_msg = textwrap::wrap(error_msg, max_line_len);
let mut lines = vec![Line::from("")];
for line in wrapped_msg.iter().take(3) {
lines.push(Line::from(Span::styled(
format!(" {}", line),
Style::default().fg(Color::Red),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Press any key to continue...",
Style::default().fg(Color::DarkGray),
)));
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red))
.title(" Error ")
.title_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
.style(Style::default().bg(Color::Black));
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, popup_area);
}
fn render_file_browser(
frame: &mut Frame,
browser: &crate::model::FileBrowserState,
glyphs: &Glyphs,
area: Rect,
) {
let popup_width = 70.min(area.width.saturating_sub(4));
let popup_height = 20.min(area.height.saturating_sub(4));
let popup_x = (area.width.saturating_sub(popup_width)) / 2;
let popup_y = (area.height.saturating_sub(popup_height)) / 2;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
frame.render_widget(Clear, popup_area);
let visible_height = (popup_height.saturating_sub(4)) as usize;
let mut lines: Vec<Line> = Vec::new();
let dir_str = browser.current_dir.display().to_string();
let max_path_len = (popup_width - 4) as usize;
let display_path = if dir_str.len() > max_path_len {
format!("...{}", &dir_str[dir_str.len() - max_path_len + 3..])
} else {
dir_str
};
lines.push(Line::from(Span::styled(
display_path,
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(glyphs.h_separator.repeat((popup_width - 2) as usize)));
let scroll_height = visible_height.saturating_sub(2).max(1);
let mut start_idx = browser.scroll_offset;
if browser.selected < start_idx {
start_idx = browser.selected;
} else if browser.selected >= start_idx + scroll_height {
start_idx = browser
.selected
.saturating_sub(scroll_height.saturating_sub(1));
}
let end_idx = (start_idx + scroll_height).min(browser.entries.len());
for idx in start_idx..end_idx {
let entry = &browser.entries[idx];
let is_selected = idx == browser.selected;
let (prefix, style) = if entry.is_dir {
(glyphs.dir_prefix, Style::default().fg(Color::Yellow))
} else {
(glyphs.file_prefix, Style::default().fg(Color::White))
};
let name_style = if is_selected {
style.bg(Color::DarkGray).add_modifier(Modifier::BOLD)
} else {
style
};
let max_name_len = (popup_width - 6) as usize;
let display_name = if entry.name.len() > max_name_len {
let truncate_len = max_name_len.saturating_sub(3);
format!("{}...", &entry.name[..truncate_len])
} else {
entry.name.clone()
};
let line_content = format!("{}{}", prefix, display_name);
let padded = if is_selected {
format!("{:<width$}", line_content, width = (popup_width - 2) as usize)
} else {
line_content
};
lines.push(Line::from(Span::styled(padded, name_style)));
}
lines.push(Line::from(""));
let filter_hint = if browser.show_all_files { "a:Filter" } else { "a:All" };
let nav_hint = format!(
" {}/{}:Navigate Enter:Select Backspace:Parent {} Esc:Quit",
glyphs.arrow_up, glyphs.arrow_down, filter_hint
);
lines.push(Line::from(Span::styled(
nav_hint,
Style::default().fg(Color::DarkGray),
)));
let title = format!(" Select File - {} ", browser.error_message);
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.style(Style::default().bg(Color::Black));
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, popup_area);
}
pub fn calculate_visible_dimensions(terminal_width: u16, terminal_height: u16) -> (usize, usize) {
let visible_cols = (terminal_width.saturating_sub(NAME_PANEL_WIDTH + 2)) as usize;
let visible_rows = (terminal_height.saturating_sub(STATUS_BAR_HEIGHT + HINT_BAR_HEIGHT + 2)) as usize;
(visible_rows, visible_cols)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dna_colors() {
let scheme = DnaColorScheme;
assert_eq!(scheme.get_color('A'), Color::Red);
assert_eq!(scheme.get_color('a'), Color::Red); assert_eq!(scheme.get_color('C'), Color::Green);
assert_eq!(scheme.get_color('G'), Color::Yellow);
assert_eq!(scheme.get_color('T'), Color::Blue);
assert_eq!(scheme.get_color('-'), Color::Rgb(180, 180, 180)); assert_eq!(scheme.get_color('N'), Color::Rgb(140, 140, 140)); assert_eq!(scheme.get_color('*'), Color::White); }
#[test]
fn test_amino_acid_colors() {
let scheme = AminoAcidColorScheme;
assert_eq!(scheme.get_color('K'), Color::Red); assert_eq!(scheme.get_color('L'), Color::Blue); assert_eq!(scheme.get_color('S'), Color::Green); assert_eq!(scheme.get_color('P'), Color::Yellow); assert_eq!(scheme.get_color('-'), Color::Rgb(180, 180, 180)); assert_eq!(scheme.get_color('X'), Color::Rgb(140, 140, 140)); assert_eq!(scheme.get_color('*'), Color::White); }
#[test]
fn test_visible_dimensions() {
let (rows, cols) = calculate_visible_dimensions(100, 50);
assert_eq!(cols, 78);
assert_eq!(rows, 46);
}
}