use super::super::super::app::App;
use super::super::super::keymap::{Binding, ENTRY_ROWS, Group, rows_over};
use super::super::super::theme::Palette;
use super::super::overlay;
use super::keymap_rows::{
COLUMN, COLUMN_COUNT, GUTTER, INTERIOR, closing_lines, entry_line_for,
folded_gate_and_quit_line, gate_line, heading_line_for, lines, quit_line,
};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
pub(super) const fn columns_for(width: u16) -> u16 {
let fits = width.saturating_add(GUTTER) / (COLUMN + GUTTER);
if fits < 1 {
1
} else if fits > COLUMN_COUNT {
COLUMN_COUNT
} else {
fits
}
}
pub(super) const HEIGHT_FLOOR: u16 = 13;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Shed {
Boxed,
Nothing,
Decoration,
Blank,
Gate,
Refuse,
}
const fn rows_for_height(height: u16) -> Shed {
if height >= 19 {
Shed::Boxed
} else if height >= 17 {
Shed::Nothing
} else if height == 16 {
Shed::Decoration
} else if height == 15 {
Shed::Blank
} else if height >= HEIGHT_FLOOR {
Shed::Gate
} else {
Shed::Refuse
}
}
pub(in crate::lookout::view) fn draw(app: &App, area: Rect, buffer: &mut Buffer) {
let palette = app.palette();
if overlay::is_boxed(area.width, INTERIOR) {
let lines = lines(app, INTERIOR);
if overlay::boxed_height(&lines) <= area.height {
overlay::draw_boxed(&lines, INTERIOR, palette, palette.ground(), area, buffer);
return;
}
}
draw_borderless(app, area, buffer, palette);
}
pub(super) fn draw_borderless(app: &App, area: Rect, buffer: &mut Buffer, palette: Palette) {
let all_rows = rows_over(app.over_a_tree());
let columns = usize::from(columns_for(area.width));
let banks: Vec<&[Group]> = Group::DRAWN.chunks(columns).collect();
let bank_count = u16::try_from(banks.len()).expect("at most COLUMN_COUNT banks of groups");
let extra_banks_cost = bank_count.saturating_sub(1) * (HEIGHT_FLOOR + 1);
let effective_height = area.height.saturating_sub(extra_banks_cost);
let shed = rows_for_height(effective_height);
let lines = if shed == Shed::Refuse {
let needed = extra_banks_cost + HEIGHT_FLOOR;
vec![Line::from(Span::styled(
format!(
"the keymap needs {needed} rows, this terminal has {}",
area.height
),
palette.refusal(),
))]
} else {
borderless_lines(
&banks,
&all_rows,
app,
palette,
area.width,
effective_height,
shed,
)
};
let blank = overlay::blank_of(area.width);
for (offset, line) in lines.iter().enumerate() {
let Ok(offset) = u16::try_from(offset) else {
break;
};
if offset >= area.height {
break;
}
let y = area.y + offset;
overlay::blank_row(buffer, area.x, y, &blank, palette.ground());
buffer.set_line(area.x, y, line, area.width);
}
}
pub(super) fn borderless_lines(
banks: &[&[Group]],
all_rows: &[Binding],
app: &App,
palette: Palette,
width: u16,
effective_height: u16,
shed: Shed,
) -> Vec<Line<'static>> {
let show_sheep = false;
let mut out = Vec::new();
for (index, bank) in banks.iter().enumerate() {
if index > 0 {
out.push(Line::default());
}
out.push(heading_line_for(bank, palette));
out.extend(
(0..ENTRY_ROWS).map(|row| entry_line_for(bank, all_rows, row, palette, show_sheep)),
);
}
match shed {
Shed::Boxed | Shed::Nothing => {
out.push(Line::default());
out.push(gate_line(app, palette, width));
out.extend(closing_lines(all_rows, palette, width));
}
Shed::Decoration => {
out.push(Line::default());
out.push(gate_line(app, palette, width));
out.push(quit_line(all_rows, palette, width));
}
Shed::Blank => {
out.push(gate_line(app, palette, width));
out.push(quit_line(all_rows, palette, width));
}
Shed::Gate => {
if effective_height > HEIGHT_FLOOR {
out.push(folded_gate_and_quit_line(app, all_rows, palette, width));
}
}
Shed::Refuse => unreachable!("draw_borderless already returned on Shed::Refuse"),
}
out
}
#[cfg(test)]
mod tests {
use super::super::keymap_rows::{COLUMN_COUNT, GUTTER, SHEEP};
use super::super::super::super::keymap::Group;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use super::super::super::MIN_TERM_WIDTH;
use super::*;
use crate::lookout::app::{KeyPress, Msg};
use crate::lookout::view::fixtures;
use super::super::testing::*;
#[test]
fn every_derived_row_is_drawn() {
let app = app_with_overlay();
let rendered = render_overlay(&app, 160, 48);
for row in crate::lookout::keymap::rows() {
let wanted = if row.group == Group::Closing {
row.keys
} else {
row.does
};
assert!(
rendered.contains(wanted),
"`{wanted}` ({}) never reached the screen",
row.keys
);
}
}
#[test]
fn the_gate_line_names_the_gate_and_then_the_link() {
let allowed = render_overlay(&app_with_overlay(), 160, 48);
assert!(allowed.contains("control enabled"), "{allowed}");
assert!(!allowed.contains("read-only"));
assert!(!allowed.contains("the link is down"));
let read_only = render_overlay(&read_only_app_with_overlay(), 160, 48);
assert!(read_only.contains("read-only"), "{read_only}");
assert!(!read_only.contains("control enabled"));
assert!(!read_only.contains("the link is down"));
let frozen = render_overlay(&frozen_app_with_overlay(), 160, 48);
assert!(frozen.contains("the link is down"), "{frozen}");
assert!(
!frozen.contains("control enabled") && !frozen.contains("read-only"),
"a frozen overlay still names the control gate: {frozen}"
);
let frozen_read_only = render_overlay(&frozen_read_only_app_with_overlay(), 160, 48);
assert!(
frozen_read_only.contains("the link is down"),
"{frozen_read_only}"
);
assert!(
!frozen_read_only.contains("control enabled")
&& !frozen_read_only.contains("read-only"),
"a frozen, read-only overlay names the control gate instead of the link: {frozen_read_only}"
);
}
#[test]
fn the_overlay_renders_over_the_four_bodies_a_test_can_reach() {
let sheep = {
let mut app = fixtures::full_app();
let _ = app.update(Msg::Key(KeyPress::Confirm));
assert!(app.sheep_pane().is_some(), "not in the sheep pane");
app
};
let bleats = {
let mut app = fixtures::full_app();
let _ = app.update(Msg::Key(KeyPress::Confirm));
let _ = app.update(Msg::Key(KeyPress::Bleats));
assert!(app.bleats_pane().is_some(), "not in the bleats pane");
app
};
let config = {
let app = fixtures::app_in_sheep_pane();
assert!(app.config_pane().is_some(), "not in the config pane");
app
};
let secrets = {
let app = fixtures::app_with_secrets();
assert!(app.secrets_pane_is_open(), "not in the secrets pane");
app
};
for (name, mut app) in [
("sheep", sheep),
("bleats", bleats),
("config", config),
("secrets", secrets),
] {
let _ = app.update(Msg::Key(KeyPress::Help));
assert!(app.keymap_open(), "{name}: the overlay did not open");
let rendered = render_overlay(&app, 160, 48);
for group in Group::DRAWN {
assert!(
rendered.contains(group.heading()),
"{name}: {} missing from the overlay",
group.heading()
);
}
assert!(
rendered.contains(&top_border_row()),
"{name}: no box border over this body"
);
}
}
#[test]
fn the_body_behind_is_dimmed() {
let app = app_with_overlay();
let mut terminal = Terminal::new(TestBackend::new(160, 48)).expect("terminal");
terminal
.draw(|frame| super::super::super::draw(&app, frame))
.expect("draw");
let behind = terminal.backend().buffer()[(2, 4)].style();
assert_eq!(behind.fg, fixtures::plain_dimmed().fg);
}
#[test]
fn the_interior_carries_the_paper_two_ground() {
let app = coloured_app_with_overlay();
let mut terminal = Terminal::new(TestBackend::new(160, 48)).expect("terminal");
terminal
.draw(|frame| super::super::super::draw(&app, frame))
.expect("draw");
let buffer = terminal.backend().buffer();
let ground = app.palette().ground().bg;
assert!(
!matches!(ground, None | Some(ratatui::style::Color::Reset)),
"fixtures::coloured() should give `Palette::ground` a real colour"
);
let inside = buffer[(20, 20)].style();
assert_eq!(
inside.bg, ground,
"the interior does not carry the paper-2 ground: {inside:?}"
);
let outside = buffer[(2, 20)].style();
assert_ne!(
outside.bg, ground,
"the dimmed body behind the box carries the interior's own ground: {outside:?}"
);
}
#[test]
fn the_column_ladder_has_a_boundary_on_each_side() {
assert_eq!(columns_for(126), 4);
assert_eq!(columns_for(125), 3);
assert_eq!(columns_for(94), 3);
assert_eq!(columns_for(93), 2);
assert_eq!(columns_for(62), 2);
assert_eq!(columns_for(61), 1);
assert_eq!(columns_for(MIN_TERM_WIDTH), 1);
assert_eq!(columns_for(158), COLUMN_COUNT, "five columns' room");
assert_eq!(columns_for(u16::MAX - GUTTER), COLUMN_COUNT);
}
#[test]
fn one_column_under_the_floor_keeps_four_columns_and_loses_the_border() {
let app = app_with_overlay();
let boxed = render_overlay(&app, 130, 48);
let bare = render_overlay(&app, 129, 48);
let border = top_border_row();
assert!(boxed.contains(&border), "130 must be boxed: {boxed}");
assert!(!bare.contains(&border), "129 must not be: {bare}");
for group in Group::DRAWN {
assert!(
bare.contains(group.heading()),
"{} is missing at 129 columns",
group.heading()
);
}
}
#[test]
fn the_height_ladder_shows_its_boundaries() {
assert_eq!(rows_for_height(19), Shed::Boxed);
assert_eq!(rows_for_height(18), Shed::Nothing);
assert_eq!(rows_for_height(17), Shed::Nothing);
assert_eq!(rows_for_height(16), Shed::Decoration);
assert_eq!(rows_for_height(15), Shed::Blank);
assert_eq!(rows_for_height(14), Shed::Gate);
assert_eq!(rows_for_height(13), Shed::Gate);
assert_eq!(rows_for_height(12), Shed::Refuse);
}
#[test]
fn a_short_terminal_sheds_the_decoration_and_keeps_the_keys() {
let app = app_with_overlay();
let rendered = render_overlay(&app, 160, 16);
assert!(
!rendered.contains(SHEEP[0]),
"the sheep survived: {rendered}"
);
assert!(
!rendered.contains("decoration only"),
"the NO_COLOR line survived: {rendered}"
);
assert_every_visible_row_drawn(&rendered, "shed with the decoration");
}
#[test]
fn too_short_refuses_instead_of_clipping() {
let rendered = render_overlay(&app_with_overlay(), 160, 12);
assert!(
rendered.contains("the keymap needs"),
"no refusal at 12 rows: {rendered}"
);
assert!(
!rendered.contains(Group::Moving.heading()),
"a partial list drew anyway: {rendered}"
);
let app = coloured_app_with_overlay();
let mut terminal = Terminal::new(TestBackend::new(160, 12)).expect("terminal");
terminal
.draw(|frame| super::super::super::draw(&app, frame))
.expect("draw");
let refusal = app.palette().refusal().fg;
assert!(
refusal.is_some(),
"fixtures::coloured() should give Palette::refusal a real colour"
);
assert_eq!(
terminal.backend().buffer()[(0, 0)].style().fg,
refusal,
"the refusal line does not carry palette.refusal()'s own colour"
);
}
#[test]
fn a_multi_bank_width_crosses_the_height_ladder_cleanly() {
let app = app_with_overlay();
let width = 100;
let bank_count =
u16::try_from(Group::DRAWN.chunks(usize::from(columns_for(width))).count())
.expect("four groups never chunk into more banks than fit a u16");
let boundary = bank_count.saturating_sub(1) * (HEIGHT_FLOOR + 1) + HEIGHT_FLOOR;
let mut refusals = 0;
let mut whole_draws = 0;
for height in boundary.saturating_sub(4)..=boundary + 4 {
let rendered = render_overlay(&app, width, height);
let headings_present = Group::DRAWN
.iter()
.filter(|group| rendered.contains(group.heading()))
.count();
let refused = rendered.contains("the keymap needs");
let whole = headings_present == Group::DRAWN.len();
assert!(
refused != whole,
"height {height} is neither a clean refusal nor a whole draw \
({headings_present} of {} headings): {rendered}",
Group::DRAWN.len()
);
assert_eq!(
refused,
height < boundary,
"height {height} against a boundary of {boundary}: {rendered}"
);
if refused {
refusals += 1;
assert_eq!(
headings_present, 0,
"height {height} refused but still drew a heading: {rendered}"
);
} else {
whole_draws += 1;
assert_every_visible_row_drawn(
&rendered,
&format!("height {height} drew the headings but a row went missing"),
);
}
}
assert!(
refusals > 0 && whole_draws > 0,
"the sweep never crossed the boundary of {boundary}: {refusals} refusals and {whole_draws} whole draws"
);
}
}