use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, Padding, Paragraph};
use crate::ssh_keys::SshKeyInfo;
use crate::ui::{design, theme};
pub(super) const BISHOP_CANONICAL: (usize, usize) = (17, 9);
pub(super) const BISHOP_LARGE: (usize, usize) = (25, 13);
const LARGE_MIN_TERMINAL_H: u16 = 30;
pub(super) const RANDOMART_PAD_H: u16 = 2;
pub(super) const RANDOMART_PAD_V: u16 = 1;
const TWINKLE_BUCKET: u64 = 10;
pub(super) fn pick_bishop_size(terminal_height: u16) -> (usize, usize) {
if terminal_height >= LARGE_MIN_TERMINAL_H {
BISHOP_LARGE
} else {
BISHOP_CANONICAL
}
}
pub(super) fn render_randomart_card(
frame: &mut Frame,
key: &SshKeyInfo,
area: Rect,
size: (usize, usize),
spinner_tick: u64,
) {
let block = design::main_block_line(Line::default()).padding(Padding::new(
RANDOMART_PAD_H,
RANDOMART_PAD_H,
RANDOMART_PAD_V,
RANDOMART_PAD_V,
));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width == 0 || inner.height == 0 {
return;
}
render_bishop(frame, key, inner, size, spinner_tick);
}
fn render_bishop(
frame: &mut Frame,
key: &SshKeyInfo,
area: Rect,
size: (usize, usize),
spinner_tick: u64,
) {
let lines = build_bishop_lines(key, size, spinner_tick);
if lines.is_empty() {
let fallback: Vec<Line> = key
.bishop_lines()
.iter()
.map(|l| Line::from(Span::styled((*l).to_string(), theme::muted())))
.collect();
if fallback.is_empty() {
let placeholder = Paragraph::new(Line::from(Span::styled(
"(no visual fingerprint)",
theme::muted(),
)));
frame.render_widget(placeholder, area);
} else {
const CANONICAL_W: u16 = 19;
let h = (fallback.len() as u16).min(area.height);
let left_pad = area.width.saturating_sub(CANONICAL_W) / 2;
let top_pad = area.height.saturating_sub(h) / 2;
let centered = Rect::new(
area.x + left_pad,
area.y + top_pad,
CANONICAL_W.min(area.width),
h,
);
frame.render_widget(Clear, centered);
frame.render_widget(Paragraph::new(fallback), centered);
}
return;
}
let total_w = size.0 as u16;
let total_h = lines.len() as u16;
let w = total_w.min(area.width);
let h = total_h.min(area.height);
let left_pad = area.width.saturating_sub(w) / 2;
let top_pad = area.height.saturating_sub(h) / 2;
let rect = Rect::new(area.x + left_pad, area.y + top_pad, w, h);
frame.render_widget(Clear, rect);
frame.render_widget(Paragraph::new(lines), rect);
}
fn build_bishop_lines(
key: &SshKeyInfo,
size: (usize, usize),
spinner_tick: u64,
) -> Vec<Line<'static>> {
let Some(bytes) = crate::ssh_keys::decode_fingerprint(&key.fingerprint) else {
return Vec::new();
};
let (cols, rows) = size;
let grid = crate::ssh_keys::drunken_bishop_grid(&bytes, cols, rows);
let mut lines: Vec<Line<'static>> = Vec::with_capacity(rows);
for (r, row) in grid.iter().enumerate() {
let spans: Vec<Span<'static>> = row
.iter()
.enumerate()
.map(|(c, &count)| {
let ch = crate::ssh_keys::bishop_char(count);
Span::styled(ch.to_string(), bishop_char_style(count, r, c, spinner_tick))
})
.collect();
lines.push(Line::from(spans));
}
lines
}
fn bishop_char_style(count: u8, row: usize, col: usize, tick: u64) -> Style {
if count == 0 {
return theme::muted();
}
if twinkle(row, col, tick) {
if twinkle_accent(row, col, tick) {
return theme::accent_bold();
}
return theme::bold();
}
theme::muted()
}
fn twinkle(row: usize, col: usize, tick: u64) -> bool {
let bucket = tick / TWINKLE_BUCKET;
let seed = (row as u64)
.wrapping_mul(0xA5A5_5A5A)
.wrapping_add((col as u64).wrapping_mul(0x9E37_79B9))
.wrapping_add(bucket.wrapping_mul(0xDEAD_BEEF));
let h = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15);
(h >> 33) % 12 == 0
}
fn twinkle_accent(row: usize, col: usize, tick: u64) -> bool {
let bucket = tick / TWINKLE_BUCKET;
let seed = (row as u64)
.wrapping_mul(0xC0FF_EE17)
.wrapping_add((col as u64).wrapping_mul(0xBADD_CAFE))
.wrapping_add(bucket.wrapping_mul(0xFEED_FACE));
let h = seed.wrapping_mul(0xBF58_476D_1CE4_E5B9);
(h >> 33) % 3 == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pick_bishop_size_short_terminal_picks_canonical() {
assert_eq!(pick_bishop_size(24), BISHOP_CANONICAL);
assert_eq!(pick_bishop_size(29), BISHOP_CANONICAL);
}
#[test]
fn pick_bishop_size_mid_terminal_picks_large() {
assert_eq!(pick_bishop_size(LARGE_MIN_TERMINAL_H), BISHOP_LARGE);
assert_eq!(pick_bishop_size(35), BISHOP_LARGE);
}
#[test]
fn pick_bishop_size_tall_terminal_caps_at_large() {
assert_eq!(pick_bishop_size(60), BISHOP_LARGE);
assert_eq!(pick_bishop_size(120), BISHOP_LARGE);
}
#[test]
fn twinkle_is_deterministic_for_same_inputs() {
let a = twinkle(3, 5, 7);
let b = twinkle(3, 5, 7);
assert_eq!(a, b);
}
#[test]
fn twinkle_accent_fires_at_a_minority_rate() {
let mut accent_count = 0usize;
let mut total = 0usize;
for row in 0..9 {
for col in 0..17 {
for tick in 0..20 {
if twinkle_accent(row, col, tick) {
accent_count += 1;
}
total += 1;
}
}
}
let pct = (accent_count * 100) / total;
assert!(
pct < 50,
"twinkle_accent fired on {}% of samples; expected minority",
pct
);
}
}