use super::super::super::app::{App, Control, Link};
use super::super::super::keymap::{Binding, ENTRY_ROWS, Group, KEY_CELL, TEXT_CELL, rows};
use super::super::super::theme::Palette;
use super::super::flock::fit;
use super::super::status;
use ratatui::text::{Line, Span};
pub(in crate::lookout::view) const INTERIOR: u16 = 126;
pub(super) const COLUMN_COUNT: u16 = 4;
const GAP: u16 = 1;
pub(super) const GUTTER: u16 = 2;
pub(super) const COLUMN: u16 = KEY_CELL + GAP + TEXT_CELL;
const SHEEP_FIRST_ROW: usize = 4;
pub(super) const SHEEP: [&str; 4] = [
" \u{259f}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2599}",
" \u{259f}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2599}",
" \u{259c}\u{2588}\u{2588}\u{2580} \u{2580}\u{2588}\u{2588}\u{259b}",
" \u{2580}\u{2598} \u{2580}\u{2598}",
];
fn gutter_span() -> Span<'static> {
Span::raw(" ".repeat(usize::from(GUTTER)))
}
pub(super) fn heading_line_for(groups: &[Group], palette: Palette) -> Line<'static> {
let mut spans = Vec::with_capacity(groups.len() * 2);
for (index, &group) in groups.iter().enumerate() {
if index > 0 {
spans.push(gutter_span());
}
spans.push(Span::styled(
fit(group.heading(), COLUMN),
palette.band(group.role()),
));
}
Line::from(spans)
}
fn heading_line(palette: Palette) -> Line<'static> {
heading_line_for(&Group::DRAWN, palette)
}
pub(super) fn entry_line_for(
groups: &[Group],
all_rows: &[Binding],
index: usize,
palette: Palette,
show_sheep: bool,
) -> Line<'static> {
let mut spans = Vec::new();
for (column, &group) in groups.iter().enumerate() {
if column > 0 {
spans.push(gutter_span());
}
let binding = all_rows
.iter()
.copied()
.filter(|row| row.group == group)
.nth(index);
spans.extend(entry_cell(group, binding, index, palette, show_sheep));
}
Line::from(spans)
}
fn entry_cell(
group: Group,
binding: Option<Binding>,
index: usize,
palette: Palette,
show_sheep: bool,
) -> Vec<Span<'static>> {
if let Some(binding) = binding {
return vec![
Span::styled(fit(binding.keys, KEY_CELL), palette.attention()),
Span::raw(format!(
"{}{}",
" ".repeat(usize::from(GAP)),
fit(binding.does, TEXT_CELL)
)),
];
}
if show_sheep
&& group == Group::Doing
&& let Some(row) = index
.checked_sub(SHEEP_FIRST_ROW)
.and_then(|row| SHEEP.get(row))
{
return vec![Span::raw(fit(row, COLUMN))];
}
vec![Span::raw(" ".repeat(usize::from(COLUMN)))]
}
pub(super) fn gate_line(app: &App, palette: Palette, width: u16) -> Line<'static> {
Line::from(Span::styled(
fit(&gate_text(app), width),
palette.attention(),
))
}
fn gate_text(app: &App) -> String {
if matches!(app.link(), Link::Lost { .. }) {
" the three above are refused \u{b7} \u{2588} the link is down \u{b7} nothing acts"
.to_string()
} else {
let label = match app.control() {
Control::ReadOnly => status::READ_ONLY_LABEL,
Control::Allowed => status::CONTROL_ENABLED_LABEL,
};
format!(
" the three above each arm a confirm \u{b7} \u{21b5} confirms \u{b7} 10s to \
answer \u{b7} \u{2588} {label}"
)
}
}
const fn colour_sentence() -> &'static str {
" colour is decoration only: every coloured cell says the same \
thing in words. NO_COLOR loses nothing but the colour."
}
#[track_caller]
fn quit_text(all_rows: &[Binding]) -> String {
let quit = all_rows
.iter()
.find(|row| row.group == Group::Closing)
.expect("binding() always gives Quit a Closing row");
format!(" h or ? closes this \u{b7} {} quits lookout", quit.keys)
}
pub(super) fn quit_line(all_rows: &[Binding], palette: Palette, width: u16) -> Line<'static> {
Line::from(Span::styled(
fit(&quit_text(all_rows), width),
palette.muted(),
))
}
pub(super) fn closing_lines(
all_rows: &[Binding],
palette: Palette,
width: u16,
) -> [Line<'static>; 2] {
[
Line::from(Span::styled(fit(colour_sentence(), width), palette.muted())),
quit_line(all_rows, palette, width),
]
}
pub(super) fn folded_gate_and_quit_line(
app: &App,
all_rows: &[Binding],
palette: Palette,
width: u16,
) -> Line<'static> {
let text = format!(
"{} \u{b7} {}",
gate_text(app).trim(),
quit_text(all_rows).trim()
);
Line::from(Span::styled(fit(&text, width), palette.attention()))
}
#[must_use]
pub(in crate::lookout::view) fn lines(app: &App, interior: u16) -> Vec<Line<'static>> {
let palette = app.palette();
let all_rows = rows();
let mut out = Vec::with_capacity(ENTRY_ROWS + 5);
out.push(heading_line(palette));
out.extend(
(0..ENTRY_ROWS).map(|index| entry_line_for(&Group::DRAWN, &all_rows, index, palette, true)),
);
out.push(Line::default());
out.push(gate_line(app, palette, interior));
out.extend(closing_lines(&all_rows, palette, interior));
out
}
#[cfg(test)]
mod tests {
use super::super::super::super::keymap::{Group, KEY_CELL, TEXT_CELL};
use super::super::super::overlay;
use super::*;
use super::super::testing::*;
use crate::output::width::visible_width;
#[test]
fn the_columns_sum_to_the_interior() {
assert_eq!((KEY_CELL, GAP, TEXT_CELL), (12, 1, 17));
assert_eq!(
COLUMN * COLUMN_COUNT + GUTTER * (COLUMN_COUNT - 1),
INTERIOR
);
assert_eq!(COLUMN, 30);
assert_eq!(INTERIOR, 126);
}
#[test]
fn every_row_is_the_interior_wide() {
let app = app_with_overlay();
for line in lines(&app, INTERIOR) {
let text: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert!(
text.is_empty() || visible_width(&text) == usize::from(INTERIOR),
"a row is {} cells against {INTERIOR}: {text:?}",
visible_width(&text)
);
}
}
#[test]
fn the_box_is_nineteen_rows() {
let app = app_with_overlay();
assert_eq!(overlay::boxed_height(&lines(&app, INTERIOR)), 19);
}
#[test]
fn the_four_headings_sit_at_their_column_starts() {
assert_eq!(
Group::DRAWN,
[Group::Moving, Group::Looking, Group::Changing, Group::Doing]
);
let app = app_with_overlay();
let rendered = render_overlay(&app, 160, 48);
let heading_row = rendered
.lines()
.find(|row| row.contains(Group::Moving.heading()))
.expect("no heading row");
let chars: Vec<char> = heading_row.chars().collect();
const MARGIN: usize = 16;
assert_eq!(usize::from(160 - (INTERIOR + 2)) / 2, MARGIN);
for (index, group) in Group::DRAWN.iter().enumerate() {
let offset = usize::from(COLUMN + GUTTER) * index;
let start = MARGIN + 1 + offset;
let from_start: String = chars[start..].iter().collect();
assert!(
from_start.starts_with(group.heading()),
"{} is not at column {start}: {heading_row:?}",
group.heading()
);
}
}
#[test]
fn the_sheep_draws_in_the_doing_column() {
let app = app_with_overlay();
let rendered = render_overlay(&app, 160, 48);
assert!(rendered.contains(SHEEP[0]), "no sheep: {rendered}");
let row = rendered
.lines()
.find(|row| row.contains(SHEEP[0]))
.expect("no sheep row");
let column = row
.find(SHEEP[0])
.map(|at| row[..at].chars().count())
.expect("no sheep on the row that contains it");
let doing = 113..113 + usize::from(COLUMN);
assert!(
doing.contains(&column),
"the sheep starts at column {column}, outside DOING's {doing:?}: {row:?}"
);
const UNDER_THE_HEADING: usize = 5;
assert_eq!(1 + SHEEP_FIRST_ROW, UNDER_THE_HEADING);
let rows: Vec<&str> = rendered.lines().collect();
let heading_at = rows
.iter()
.position(|row| row.contains(Group::Moving.heading()))
.expect("no heading row");
let sheep_at = rows
.iter()
.position(|row| row.contains(SHEEP[0]))
.expect("no sheep row");
assert_eq!(
sheep_at - heading_at,
UNDER_THE_HEADING,
"the sheep is {} rows under the heading, not {UNDER_THE_HEADING}",
sheep_at - heading_at
);
}
#[test]
fn three_columns_put_doing_on_its_own_bank() {
let rendered = render_overlay(&app_with_overlay(), 100, 48);
let heading_rows: Vec<&str> = rendered
.lines()
.filter(|row| Group::DRAWN.iter().any(|g| row.contains(g.heading())))
.collect();
assert_eq!(heading_rows.len(), 2, "{heading_rows:?}");
for group in [Group::Moving, Group::Looking, Group::Changing] {
assert!(
heading_rows[0].contains(group.heading()),
"{} missing from the first bank: {:?}",
group.heading(),
heading_rows[0]
);
}
assert!(heading_rows[1].contains(Group::Doing.heading()));
assert!(
!heading_rows[1].contains(Group::Moving.heading()),
"the second bank should hold DOING alone: {:?}",
heading_rows[1]
);
}
}