use super::super::super::app::{App, SecretsPane};
use super::super::super::secrets::Source;
use super::super::super::theme::Palette;
use super::super::flock::{GUTTER, gutter};
use super::super::status;
use super::column_tiers::{Column, MIN_WIDTH, columns_for};
use super::detail_panels::{PANEL_ROWS, draw_panels};
use super::pane_chrome::{
STORE_LINE, gates_line, group_header_line, heading_line, pane_band, roll_status_line, tab_line,
};
use super::row_cells::{new_key_row_line, row_line};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
const CHROME_ROWS: u16 = 7;
const PANELS_MIN_ROWS: u16 = CHROME_ROWS + 2 + PANEL_ROWS;
fn content_bottom(area: Rect) -> u16 {
let bottom = area.y + area.height;
if area.height >= PANELS_MIN_ROWS {
bottom - PANEL_ROWS
} else {
bottom
}
}
fn draw_new_key_row(
pane: &SecretsPane,
columns: &[Column],
palette: Palette,
area: Rect,
buffer: &mut Buffer,
y: &mut u16,
) {
let bottom = content_bottom(area);
if *y >= bottom {
return;
}
let table_width = area.width.saturating_sub(GUTTER);
let selected = pane.selected_is_new_key_row();
let (gutter_text, gutter_style) = gutter(selected, palette);
buffer.set_line(
area.x,
*y,
&Line::from(Span::styled(gutter_text, gutter_style)),
1,
);
buffer.set_line(
area.x + GUTTER,
*y,
&new_key_row_line(pane, columns, table_width, palette, selected),
table_width,
);
*y += 1;
}
fn draw_too_narrow(width: u16, palette: Palette, area: Rect, buffer: &mut Buffer) {
buffer.set_line(
area.x,
area.y,
&Line::from(Span::styled("too narrow for secrets", palette.refusal())),
width,
);
if area.height < 2 {
return;
}
buffer.set_line(
area.x,
area.y + 1,
&Line::from(Span::styled(
format!("need {MIN_WIDTH} columns"),
palette.muted(),
)),
width,
);
}
pub fn draw(app: &App, pane: &SecretsPane, area: Rect, buffer: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let palette = app.palette();
let width = area.width;
if width < MIN_WIDTH {
draw_too_narrow(width, palette, area, buffer);
return;
}
let table_width = width.saturating_sub(GUTTER);
let columns = columns_for(width);
let bottom = area.y + area.height;
let content_bottom = content_bottom(area);
let mut y = area.y;
buffer.set_line(area.x, y, &pane_band(width, palette), width);
y += 1;
if y >= content_bottom {
return;
}
buffer.set_line(
area.x,
y,
&Line::from(Span::styled(STORE_LINE, palette.muted())),
width,
);
y += 1;
if y >= content_bottom {
return;
}
buffer.set_line(area.x, y, &gates_line(pane, app.control(), palette), width);
y += 1;
if y >= content_bottom {
return;
}
buffer.set_line(area.x, y, &roll_status_line(pane, palette), width);
y += 1;
if y >= content_bottom {
return;
}
buffer.set_line(area.x, y, &tab_line(pane, palette, width), width);
y += 1;
if y >= content_bottom {
return;
}
buffer.set_line(
area.x + GUTTER,
y,
&heading_line(columns, palette),
table_width,
);
y += 1;
if y >= content_bottom {
return;
}
buffer.set_line(area.x, y, &status::rule_line(palette.line(), width), width);
y += 1;
let anchor = pane.new_key_anchor();
let mut new_key_row_drawn = false;
if anchor.is_none() {
draw_new_key_row(pane, columns, palette, area, buffer, &mut y);
new_key_row_drawn = true;
}
let mut last_source: Option<&Source> = None;
for (index, row) in pane.model.rows.iter().enumerate() {
if y >= content_bottom {
break;
}
if last_source != Some(&row.source) {
buffer.set_line(
area.x + GUTTER,
y,
&group_header_line(pane, &row.source, palette, table_width),
table_width,
);
y += 1;
last_source = Some(&row.source);
if y >= content_bottom {
break;
}
}
if pane.is_collapsed(&row.source) {
continue;
}
let selected = index == pane.selected;
let (gutter_text, gutter_style) = gutter(selected, palette);
buffer.set_line(
area.x,
y,
&Line::from(Span::styled(gutter_text, gutter_style)),
1,
);
buffer.set_line(
area.x + GUTTER,
y,
&row_line(
pane,
row,
columns,
table_width,
palette,
selected,
app.now(),
),
table_width,
);
y += 1;
if !new_key_row_drawn && anchor == Some(index) {
draw_new_key_row(pane, columns, palette, area, buffer, &mut y);
new_key_row_drawn = true;
if y >= content_bottom {
break;
}
}
}
if !new_key_row_drawn {
draw_new_key_row(pane, columns, palette, area, buffer, &mut y);
}
if content_bottom < bottom {
draw_panels(pane, palette, area, buffer, content_bottom);
}
}
#[cfg(test)]
pub(in crate::lookout::view) fn cell(buffer: &Buffer, row: u16, column: Column) -> String {
let columns = columns_for(buffer.area.width);
let mut x = GUTTER;
for candidate in columns {
if *candidate == column {
return (x..x + column.width())
.map(|x| buffer[(x, row)].symbol())
.collect();
}
x += candidate.width();
}
panic!("{column:?} is not drawn at width {}", buffer.area.width);
}
#[cfg(test)]
mod tests {
use super::super::column_tiers::Column;
use ratatui::layout::Rect;
use std::time::Duration;
use super::*;
use crate::lookout::app::{Body, KeyPress, Msg};
use super::super::testing::*;
use crate::lookout::view::fixtures;
#[test]
fn the_chrome_is_the_rows_it_claims() {
let app = fixtures::app_with_secrets();
let buffer = fixtures::render(&app, 160, 48);
let band = row_of(&buffer, "SECRETS flock-wide");
let hairline = row_of(&buffer, "\u{2500}\u{2500}\u{2500}");
assert_eq!(hairline - band + 1, CHROME_ROWS, "{}", frame_text(&buffer));
}
#[test]
fn a_terminal_too_short_for_both_spends_the_panel_rows_on_the_table() {
let app = fixtures::app_with_secrets();
let buffer = fixtures::render(&app, 160, 14);
let text = frame_text(&buffer);
assert!(
!text.contains("FOCUSED"),
"no room for the panels at this height: {text}"
);
assert!(
text.contains(FIRST_DATA_ROW),
"their rows go to the table instead: {text}"
);
assert!(
text.contains("operator \u{d7}"),
"the group header is drawn too: {text}"
);
}
#[test]
fn the_panels_arrive_with_the_row_that_makes_room_for_them() {
let app = fixtures::app_with_secrets();
let short = usize::from(PANELS_MIN_ROWS - 1);
let exact = usize::from(PANELS_MIN_ROWS);
let below = frame_text(&fixtures::render(
&app,
160,
u16::try_from(short + 2).unwrap(),
));
let at = frame_text(&fixtures::render(
&app,
160,
u16::try_from(exact + 2).unwrap(),
));
assert!(!below.contains("FOCUSED"), "one row short: {below}");
assert!(at.contains("FOCUSED"), "exactly enough: {at}");
assert!(at.contains("WHO READS IT"), "both panels or neither: {at}");
assert!(
at.contains("operator \u{d7}"),
"and the group header above them: {at}"
);
assert!(
at.contains(FIRST_DATA_ROW),
"and a secret under it, which is the point of the pane: {at}"
);
}
#[test]
fn no_supported_height_reserves_panel_rows_it_never_draws() {
let app = fixtures::app_with_secrets();
for height in crate::lookout::view::flock::MIN_HEIGHT..=40 {
let buffer = fixtures::render(&app, 160, height);
let area = Rect::new(0, 0, 160, crate::lookout::view::body_rows(buffer.area));
let text = frame_text(&buffer);
if content_bottom(area) < area.y + area.height {
assert!(
text.contains("FOCUSED"),
"{height} rows reserved the panels: {text}"
);
assert!(
text.contains(FIRST_DATA_ROW),
"{height} rows drew the panels over an empty table: {text}"
);
} else {
assert!(
!text.contains("FOCUSED"),
"{height} rows reserved nothing for them: {text}"
);
}
}
}
#[test]
fn a_value_outgrowing_its_column_is_shown_from_the_end_being_typed() {
let typed = "abcdefghijklmnopqrstuvwxyz0123456789-THE-TAIL";
let app = app_typing(typed);
let buffer = fixtures::render(&app, 160, 48);
let drawn = cell(&buffer, row_of(&buffer, "DB_PASSWORD"), Column::Value);
assert!(
drawn.trim_end().ends_with('\u{2588}'),
"the cursor is the last thing on the cell: {drawn:?}"
);
assert!(
drawn.contains("THE-TAIL"),
"and the characters just typed are next to it: {drawn:?}"
);
assert!(
drawn.starts_with('\u{2026}'),
"with the marker where the head went: {drawn:?}"
);
assert!(
!drawn.contains("abcdef"),
"the head is what gives way, not the tail: {drawn:?}"
);
assert!(
drawn.ends_with(' '),
"one column short, so the cursor never touches IN FORCE: {drawn:?}"
);
assert_eq!(
drawn.chars().count(),
usize::from(Column::Value.width()),
"the cell still measures its column: {drawn:?}"
);
}
#[test]
fn in_force_is_read_out_of_its_own_column_not_found_anywhere_in_the_row() {
let app = fixtures::app_with_secrets();
let buffer = fixtures::render(&app, 160, 48);
assert_eq!(
cell(&buffer, first_row(), Column::InForce).trim(),
"production"
);
}
#[test]
fn a_key_with_no_slot_here_says_so_rather_than_showing_a_block_run() {
let app = fixtures::app_with_secrets();
let buffer = fixtures::render(&app, 160, 48);
let row = row_of(&buffer, "ELSEWHERE_ONLY");
assert_eq!(cell(&buffer, row, Column::Value).trim(), "not set here");
assert_eq!(cell(&buffer, row, Column::InForce).trim(), "-");
}
#[test]
fn the_new_key_row_sits_before_the_first_namespace_group() {
let app = fixtures::app_with_secrets_and_a_provider_row();
let buffer = fixtures::render(&app, 160, 48);
let operator_row = row_of(&buffer, "DB_PASSWORD");
let new_key_row = row_of(&buffer, "+ new key");
let provider_header = row_of(&buffer, "vercel (dog)");
assert_eq!(
new_key_row,
operator_row + 1,
"right after the last operator row"
);
assert_eq!(
provider_header,
new_key_row + 1,
"and right before the provider group's own header"
);
}
#[test]
fn interleaved_sources_still_agree_between_the_cursor_and_the_drawn_affordance() {
let mut app = fixtures::app_with_interleaved_secret_sources();
app.update(Msg::Key(KeyPress::SelectLast));
let Body::Secrets(pane) = app.body() else {
panic!("pane is open");
};
assert!(
pane.selected_is_new_key_row(),
"G lands on the affordance: it is the last screen slot here"
);
let buffer = fixtures::render(&app, 160, 48);
let last_operator_row = row_of(&buffer, "SECOND_OPERATOR_KEY");
let new_key_row = row_of(&buffer, "+ new key");
assert_eq!(
new_key_row,
last_operator_row + 1,
"the affordance draws right after the highest-index operator \
row, matching the cursor's own anchor"
);
}
#[test]
fn the_header_denominator_matches_set_in_for_all_and_a_named_environment() {
let app = fixtures::app_with_secrets();
let buffer = fixtures::render(&app, 160, 48);
let header_count = header_environment_count(&buffer);
let named_row = row_of(&buffer, "DB_PASSWORD");
let all_row = row_of(&buffer, "SET_EVERYWHERE");
assert_eq!(
cell(&buffer, named_row, Column::SetIn).trim(),
format!("1 of {header_count} \u{b7} production")
);
assert_eq!(
cell(&buffer, all_row, Column::SetIn).trim(),
format!("1 of {header_count} \u{b7} all")
);
}
#[test]
fn a_revealed_row_prints_its_value_and_its_countdown() {
let dir = tempfile::tempdir().unwrap();
let app = fixtures::app_revealing(dir.path());
let buffer = fixtures::render(&app, 160, 48);
let revealed = row_of(&buffer, "DB_PASSWORD");
assert_eq!(
cell(&buffer, revealed, Column::Value).trim(),
fixtures::REVEALED_VALUE
);
assert_eq!(
cell(&buffer, revealed, Column::Lands).trim(),
"visible 10s \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}"
);
let untouched = row_of(&buffer, "OTHER_KEY");
assert_eq!(
cell(&buffer, untouched, Column::Value).trim(),
"\u{2588}\u{2588}\u{2588} 3 bytes",
"a reveal reaches one row, not the pane"
);
assert_eq!(cell(&buffer, untouched, Column::Lands).trim(), "-");
}
#[test]
fn the_countdown_and_its_gauge_spend_together() {
let dir = tempfile::tempdir().unwrap();
let mut app = fixtures::app_revealing(dir.path());
let start = app.now();
let _ = app.update(Msg::Tick {
now: start + Duration::from_secs(6),
});
let buffer = fixtures::render(&app, 160, 48);
assert_eq!(
cell(&buffer, row_of(&buffer, "DB_PASSWORD"), Column::Lands).trim(),
"visible 4s \u{2588}\u{2588}\u{2588}\u{2588}\u{2591}\u{2591}\u{2591}\u{2591}\u{2591}\u{2591}"
);
let _ = app.update(Msg::Tick {
now: start + Duration::from_millis(6_500),
});
let buffer = fixtures::render(&app, 160, 48);
assert_eq!(
cell(&buffer, row_of(&buffer, "DB_PASSWORD"), Column::Lands).trim(),
"visible 4s \u{2588}\u{2588}\u{2588}\u{2588}\u{2591}\u{2591}\u{2591}\u{2591}\u{2591}\u{2591}"
);
}
}