use ratatui::prelude::*;
use ratatui::widgets::Paragraph;
use super::burst;
use super::font;
use super::theme;
use crate::app::App;
use crate::units::fmt_duration;
fn logo4_lines() -> Vec<Line<'static>> {
font::bitmap_lines(&font::big_text("WHIRR"), theme::ACCENT)
}
const FAN_COLS: u16 = 19;
const FAN_ROWS: u16 = 7;
const COMPACT_FAN_COLS: u16 = 11;
const COMPACT_FAN_ROWS: u16 = 5;
const COMPACT_LOGO_COLS: u16 = 24;
pub fn render(f: &mut Frame, area: Rect, app: &App) {
if area.height >= 9 {
render_full(f, area, app);
} else {
render_compact(f, area, app);
}
}
fn render_full(f: &mut Frame, area: Rect, app: &App) {
let cols = Layout::horizontal([
Constraint::Length(26), Constraint::Length(FAN_COLS), Constraint::Min(0), ])
.split(area);
let logo_area = Rect { y: area.y + 2, height: area.height.saturating_sub(2).min(5), ..cols[0] };
f.render_widget(Paragraph::new(logo4_lines()), logo_area);
if !app.no_fan {
let fan = Rect {
y: area.y + area.height.saturating_sub(FAN_ROWS) / 2,
height: FAN_ROWS.min(area.height),
..cols[1]
};
f.render_widget(Paragraph::new(burst::render(fan.width, fan.height, app.fan_angle_deg)), fan);
}
let facts_area = Rect {
y: area.y + 3,
height: area.height.saturating_sub(3).min(3),
..cols[2]
};
f.render_widget(facts_paragraph(app), facts_area);
}
fn render_compact(f: &mut Frame, area: Rect, app: &App) {
if area.height < COMPACT_FAN_ROWS {
let cols = Layout::horizontal([Constraint::Length(8), Constraint::Min(0)]).split(area);
f.render_widget(
Paragraph::new(Line::styled("whirr", Style::default().fg(theme::ACCENT).bold())),
cols[0],
);
f.render_widget(facts_paragraph(app), cols[1]);
return;
}
let room_for_fan = area.width >= COMPACT_LOGO_COLS + COMPACT_FAN_COLS + 20;
let fan_cols = if room_for_fan { COMPACT_FAN_COLS } else { 0 };
let cols = Layout::horizontal([
Constraint::Length(COMPACT_LOGO_COLS),
Constraint::Length(fan_cols),
Constraint::Min(0), ])
.split(area);
f.render_widget(Paragraph::new(logo4_lines()), cols[0]);
if !app.no_fan && room_for_fan {
let fan = Rect {
height: COMPACT_FAN_ROWS.min(area.height),
..cols[1]
};
f.render_widget(Paragraph::new(burst::render(fan.width, fan.height, app.fan_angle_deg)), fan);
}
f.render_widget(facts_paragraph(app), cols[2]);
}
fn facts_paragraph(app: &App) -> Paragraph<'_> {
let (uptime, load) = (
app.medium.as_ref().map_or(0, |m| m.uptime_secs),
app.fast.as_ref().map_or(0.0, |f| f.load_avg),
);
let facts = vec![
Line::from(format!(
"{} · macOS {}",
app.statics.chip, app.statics.os_version
)),
Line::from(format!("up {} · load {:.2}", fmt_duration(uptime), load)),
Line::from(""),
];
Paragraph::new(facts)
.style(Style::default().fg(theme::DIM))
.alignment(Alignment::Right)
}
#[cfg(test)]
mod tests {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use crate::app::App;
use crate::ui::theme;
#[test]
fn logo4_is_five_uniform_rows_within_budget() {
let rows = crate::ui::font::big_text("WHIRR");
assert_eq!(rows.len(), 5);
let w = rows[0].chars().count();
assert!(rows.iter().all(|r| r.chars().count() == w));
assert_eq!(w, 22);
assert!(w <= 26);
}
#[test]
fn logo4_is_painted_as_background_fills_not_foreground_glyphs() {
let mut t = Terminal::new(TestBackend::new(80, 9)).unwrap();
let app = App::demo();
t.draw(|f| super::render(f, f.area(), &app)).unwrap();
let buf = t.backend().buffer().clone();
let logo_symbols: String = (0..26).flat_map(|x| (2..7).map(move |y| (x, y))).map(|(x, y)| buf[(x, y)].symbol().to_string()).collect();
assert!(
logo_symbols.chars().all(|c| c == ' '),
"logo area must render as spaces with a background colour, found {logo_symbols:?}"
);
let filled_cells = buf
.content()
.iter()
.filter(|cell| cell.style().bg == Some(theme::ACCENT))
.count();
let bitmap = crate::ui::font::big_text("WHIRR");
let expected: usize = bitmap.iter().flat_map(|r| r.chars()).filter(|&c| c == '#').count();
assert_eq!(filled_cells, expected, "background-filled cell count must match the WHIRR bitmap");
}
fn draw_header(w: u16, h: u16) -> String {
let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
let app = App::demo();
t.draw(|f| super::render(f, f.area(), &app)).unwrap();
t.backend().buffer().content().iter().map(|c| c.symbol()).collect()
}
fn has_braille(s: &str) -> bool {
s.chars().any(|c| ('\u{2801}'..='\u{28FF}').contains(&c))
}
#[test]
fn both_tiers_draw_the_burst_at_their_own_size() {
let full = draw_header(80, 9);
let compact = draw_header(80, 5);
assert!(has_braille(&full), "burst missing from the full header");
assert!(has_braille(&compact), "burst missing from the compact header");
assert!(!compact.contains('✻'), "hand-drawn compact fan should be gone");
let ink = |s: &str| s.chars().filter(|&c| ('\u{2801}'..='\u{28FF}').contains(&c)).count();
assert!(
ink(&full) > ink(&compact),
"the full tier's 19x7 burst should carry more ink than the compact 11x5 one"
);
}
#[test]
fn compact_wordmark_is_also_painted_as_background_fills() {
let mut t = Terminal::new(TestBackend::new(80, 5)).unwrap();
let app = App::demo();
t.draw(|f| super::render(f, f.area(), &app)).unwrap();
let buf = t.backend().buffer().clone();
let filled = buf.content().iter().filter(|c| c.style().bg == Some(theme::ACCENT)).count();
let expected: usize = crate::ui::font::big_text("WHIRR")
.iter()
.flat_map(|r| r.chars())
.filter(|&c| c == '#')
.count();
assert_eq!(filled, expected, "compact wordmark bitmap pixel count mismatch");
}
#[test]
fn burst_is_centred_in_the_nine_row_band() {
let mut t = Terminal::new(TestBackend::new(80, 9)).unwrap();
let app = App::demo();
t.draw(|f| super::render(f, f.area(), &app)).unwrap();
let buf = t.backend().buffer().clone();
let row = |y: u16| -> String { (0..80).map(|x| buf[(x, y)].symbol()).collect() };
assert!(has_braille(&row(1)), "no burst ink in header row 1");
assert!(has_braille(&row(7)), "no burst ink in header row 7");
assert!(!has_braille(&row(0)), "burst should not reach row 0");
assert!(!has_braille(&row(8)), "burst should not reach row 8");
}
#[test]
fn burst_rotates_between_angles() {
let draw = |deg: f32| -> String {
let mut t = Terminal::new(TestBackend::new(80, 9)).unwrap();
let mut app = App::demo();
app.fan_angle_deg = deg;
t.draw(|fr| super::render(fr, fr.area(), &app)).unwrap();
t.backend().buffer().content().iter().map(|c| c.symbol()).collect()
};
let base = draw(0.0);
for deg in [9.0, 18.0, 27.0] {
assert_ne!(base, draw(deg), "{deg}° renders identically to 0°");
}
}
#[test]
fn burst_uses_only_blends_of_the_two_brand_tones() {
let mut t = Terminal::new(TestBackend::new(80, 9)).unwrap();
let app = App::demo();
t.draw(|f| super::render(f, f.area(), &app)).unwrap();
let buf = t.backend().buffer().clone();
let (mut saw_text, mut saw_accent) = (false, false);
for cell in buf.content() {
let c = cell.symbol().chars().next().unwrap();
if !('\u{2801}'..='\u{28FF}').contains(&c) {
continue;
}
let fg = cell.style().fg.unwrap();
if crate::ui::burst::tests::is_blend_of(fg, theme::TEXT) {
saw_text = true;
} else if crate::ui::burst::tests::is_blend_of(fg, theme::ACCENT) {
saw_accent = true;
} else {
panic!("off-brand colour {fg:?}");
}
}
assert!(saw_text && saw_accent, "both brand tones must appear");
}
#[test]
fn no_fan_leaves_the_header_free_of_braille() {
let mut t = Terminal::new(TestBackend::new(80, 9)).unwrap();
let mut app = App::demo();
app.no_fan = true;
t.draw(|f| super::render(f, f.area(), &app)).unwrap();
let s: String = t.backend().buffer().content().iter().map(|c| c.symbol()).collect();
assert!(!has_braille(&s), "--no-fan must not draw the burst");
}
}