use std::time::Instant;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use super::super::super::app::{CONFIRM_EXPIRY, CloseDialog};
use super::super::super::pane::ReloadKind;
use super::super::super::theme::Palette;
use super::super::cell;
use super::super::flock::fit;
use super::super::overlay;
use super::layout::{BOX_WIDTH, body_width, wrap};
use crate::output::width::text_columns;
use crate::vocabulary::Role;
const fn plural(count: usize) -> &'static str {
if count == 1 { "" } else { "S" }
}
const fn needs_or_need(count: usize) -> &'static str {
if count == 1 { "NEEDS" } else { "NEED" }
}
const HEADING_SHEEP_COLUMN: usize = 36;
fn close_dialog_heading_row(dialog: &CloseDialog, body: u16) -> String {
let left = close_dialog_heading(dialog);
let right = close_dialog_sheep_clause(dialog);
let left_w = text_columns(&left);
let gap = HEADING_SHEEP_COLUMN.saturating_sub(left_w).max(1);
if left_w + gap + text_columns(&right) > usize::from(body) {
return left;
}
format!("{left}{}{right}", " ".repeat(gap))
}
fn close_dialog_sheep_clause(dialog: &CloseDialog) -> String {
let state = format!("{} is {}", dialog.target_name(), dialog.status());
match dialog.pid() {
Some(pid) => format!("{state}, pid {pid}"),
None => state,
}
}
fn close_dialog_heading(dialog: &CloseDialog) -> String {
match (dialog.unsent(), dialog.parked()) {
(0, parked) => format!("{parked} FIELD{} ALREADY WAITING", plural(parked)),
(unsent, 0) => format!(
"{unsent} EDIT{} {} A RESPAWN",
plural(unsent),
needs_or_need(unsent)
),
(unsent, parked) => format!(
"{unsent} EDIT{} {} A RESPAWN, {parked} FIELD{} ALREADY DID",
plural(unsent),
needs_or_need(unsent),
plural(parked)
),
}
}
fn close_dialog_naming_sentence(fields: &[String]) -> String {
let list = match fields {
[] => String::new(),
[one] => one.clone(),
[first, second] => format!("{first} and {second}"),
[first, second, third] => format!("{first}, {second} and {third}"),
[first, second, rest @ ..] => {
format!("{first}, {second} and {} more", rest.len())
}
};
let verb = if fields.len() == 1 { "takes" } else { "take" };
format!("{list} {verb} hold when the process starts again.")
}
fn close_dialog_reload_sentence(dialog: &CloseDialog) -> String {
let graceful = dialog.graceful_timeout();
match (dialog.reload(), dialog.instances()) {
(ReloadKind::Overlap, 1) => {
"the replacement starts alongside and takes over. No gap, if the app sets \
SO_REUSEPORT itself."
.to_owned()
}
(ReloadKind::Overlap, _) => {
"one instance at a time, each replacement alongside the one it replaces. No gap, \
if the app sets SO_REUSEPORT itself."
.to_owned()
}
(ReloadKind::Serial, 1) => format!(
"drains it, then starts the replacement. Up to {graceful}, so slower than a \
restart for the same gap."
),
(ReloadKind::Serial, n) => format!(
"one instance at a time, each drained before its replacement starts. Up to \
{graceful} each, 1 of {n} down at a time."
),
}
}
fn close_dialog_option_line(text: String, palette: Palette, width: u16) -> Line<'static> {
Line::from(Span::styled(
format!(" {}", fit(&text, width)),
palette.ground(),
))
}
const RELOAD_LABEL: &str = "L reload ";
fn close_dialog_reload_lines(
dialog: &CloseDialog,
palette: Palette,
body: u16,
) -> Vec<Line<'static>> {
let label_w = u16::try_from(RELOAD_LABEL.chars().count()).unwrap_or(0);
let sentence = close_dialog_reload_sentence(dialog);
let available = usize::from(body.saturating_sub(label_w));
let indent = " ".repeat(usize::from(label_w));
wrap(&sentence, available)
.into_iter()
.enumerate()
.map(|(index, chunk)| {
let text = if index == 0 {
format!("{RELOAD_LABEL}{chunk}")
} else {
format!("{indent}{chunk}")
};
close_dialog_option_line(text, palette, body)
})
.collect()
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum DialogRow {
Key,
Heading,
Continuation,
Naming,
Live,
Blank,
}
#[must_use]
pub(in crate::lookout::view) fn close_dialog_lines(
dialog: &CloseDialog,
palette: Palette,
width: u16,
now: Instant,
) -> Vec<Line<'static>> {
close_dialog_rows(dialog, palette, width, now)
.into_iter()
.map(|(_, line)| line)
.collect()
}
fn close_dialog_rows(
dialog: &CloseDialog,
palette: Palette,
width: u16,
now: Instant,
) -> Vec<(DialogRow, Line<'static>)> {
let body = body_width(width);
let mut lines = vec![(
DialogRow::Heading,
Line::from(Span::styled(
format!(" {}", fit(&close_dialog_heading_row(dialog, body), body)),
palette.band(Role::Butter),
)),
)];
if dialog.unsent() > 0 {
let sentence = close_dialog_naming_sentence(dialog.unsent_fields());
lines.push((
DialogRow::Naming,
close_dialog_option_line(sentence, palette, body),
));
if dialog.live() > 0 {
lines.push((
DialogRow::Live,
close_dialog_option_line(
"Everything else you changed is already live.".to_owned(),
palette,
body,
),
));
}
}
lines.push((DialogRow::Blank, Line::from(Span::raw(""))));
lines.push((
DialogRow::Key,
close_dialog_option_line(
format!(
"R restart now stop, then start. The stop takes up to {}.",
dialog.kill_timeout()
),
palette,
body,
),
));
lines.extend(
close_dialog_reload_lines(dialog, palette, body)
.into_iter()
.enumerate()
.map(|(index, line)| {
let kind = if index == 0 {
DialogRow::Key
} else {
DialogRow::Continuation
};
(kind, line)
}),
);
lines.push((
DialogRow::Key,
close_dialog_option_line(
"c continue write them and leave it running. They wait for a respawn."
.to_owned(),
palette,
body,
),
));
lines.push((DialogRow::Blank, Line::from(Span::raw(""))));
let elapsed = now.saturating_duration_since(dialog.at());
let remaining = CONFIRM_EXPIRY.saturating_sub(elapsed);
lines.push((
DialogRow::Key,
close_dialog_option_line(
format!(
"esc keep editing, write nothing \u{b7} this prompt expires in {}s {}",
remaining.as_secs(),
cell::gauge(
u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX),
Some(u64::try_from(CONFIRM_EXPIRY.as_millis()).unwrap_or(u64::MAX)),
10
)
),
palette,
body,
),
));
lines
}
fn shed_dialog_rows(rows: &mut Vec<(DialogRow, Line<'static>)>, height: u16, width: u16) {
let height = usize::from(height);
while rows.len() > height {
let sheddable = rows
.iter()
.enumerate()
.filter(|(_, (kind, _))| *kind != DialogRow::Key)
.max_by_key(|(index, (kind, _))| (*kind, *index))
.map(|(index, _)| index);
let Some(index) = sheddable else { break };
let cut = rows.remove(index).0 == DialogRow::Continuation;
if cut && let Some((_, line)) = index.checked_sub(1).and_then(|above| rows.get_mut(above)) {
mark_cut(line, width);
}
}
rows.truncate(height);
}
fn mark_cut(line: &mut Line<'static>, width: u16) {
debug_assert!(width > 0, "mark_cut needs a column to put the marker in");
let Some(span) = line.spans.last_mut() else {
return;
};
let mut text = span.content.trim_end().to_owned();
if text.ends_with('\u{2026}') {
return;
}
while text_columns(&text) + 1 > usize::from(width) {
text.pop();
}
text.push('\u{2026}');
span.content = text.into();
}
pub(super) fn draw_close_dialog(
dialog: &CloseDialog,
palette: Palette,
now: Instant,
area: Rect,
buffer: &mut Buffer,
) {
if overlay::is_boxed(area.width, BOX_WIDTH) {
let lines = close_dialog_lines(dialog, palette, BOX_WIDTH, now);
if overlay::boxed_height(&lines) <= area.height {
overlay::draw_boxed(&lines, BOX_WIDTH, palette, Style::reset(), area, buffer);
return;
}
}
draw_borderless_close_dialog(dialog, palette, now, area, buffer);
}
fn draw_borderless_close_dialog(
dialog: &CloseDialog,
palette: Palette,
now: Instant,
area: Rect,
buffer: &mut Buffer,
) {
let mut rows = close_dialog_rows(dialog, palette, area.width, now);
shed_dialog_rows(&mut rows, area.height, area.width);
let top = area.y
+ area
.height
.saturating_sub(u16::try_from(rows.len()).unwrap_or(0));
let blank = overlay::blank_of(area.width);
for (offset, (_, line)) in rows.iter().enumerate() {
let offset = u16::try_from(offset).unwrap_or(0);
overlay::blank_row(buffer, area.x, top + offset, &blank, Style::reset());
buffer.set_line(area.x, top + offset, line, area.width);
}
}
#[cfg(test)]
mod tests {
use super::super::fixtures::text_of;
use super::*;
use crate::lookout::frames::render_text;
use crate::lookout::view::MIN_TERM_WIDTH;
use crate::lookout::view::fixtures;
use crate::lookout::view::flock::MIN_HEIGHT;
use crate::output::width::visible_width;
#[test]
fn the_dialog_names_both_halves_in_its_heading() {
let dialog = fixtures::close_dialog_with(2, 1);
let lines = close_dialog_lines(&dialog, fixtures::plain(), 120, dialog.at());
let heading = text_of(&lines)[0].trim().to_string();
assert!(
heading.starts_with("2 EDITS NEED A RESPAWN, 1 FIELD ALREADY DID"),
"{heading:?}"
);
}
#[test]
fn the_heading_names_the_sheep_its_state_and_its_pid() {
let dialog = fixtures::close_dialog_with(1, 0);
let lines = close_dialog_lines(&dialog, fixtures::plain(), 120, dialog.at());
let heading = text_of(&lines)[0].trim().to_string();
assert!(heading.ends_with("web is online, pid 71578"), "{heading:?}");
}
#[test]
fn a_sheep_with_no_single_pid_gets_a_heading_that_names_none() {
let dialog = fixtures::close_dialog_without_a_pid();
let lines = close_dialog_lines(&dialog, fixtures::plain(), 120, dialog.at());
let heading = text_of(&lines)[0].trim().to_string();
assert!(heading.ends_with("web is online"), "{heading:?}");
assert!(!heading.contains("pid"), "{heading:?}");
}
#[test]
fn a_narrow_heading_drops_the_sheep_and_keeps_the_question() {
for width in [MIN_TERM_WIDTH, 40, 51, 60, 69, 70, BOX_WIDTH, 89, 120, 160] {
let dialog = fixtures::close_dialog_with(2, 1);
let lines = close_dialog_lines(&dialog, fixtures::plain(), width, dialog.at());
let heading = text_of(&lines)[0].clone();
assert!(
visible_width(&heading) <= usize::from(width),
"{width}: {heading:?}"
);
let trimmed = heading.trim();
assert!(
trimmed.starts_with("2 EDIT"),
"the question survives at {width}: {heading:?}"
);
let named = heading.contains("web is online, pid 71578");
assert_eq!(named, width >= 70, "{width}: {heading:?}");
}
}
#[test]
fn a_single_edit_gets_a_singular_verb() {
let dialog = fixtures::close_dialog_with(1, 0);
let lines = close_dialog_lines(&dialog, fixtures::plain(), 120, dialog.at());
assert!(
text_of(&lines)[0]
.trim()
.starts_with("1 EDIT NEEDS A RESPAWN"),
"{:?}",
text_of(&lines)[0]
);
}
#[test]
fn a_serial_reload_does_not_promise_no_gap() {
let dialog = fixtures::close_dialog_reloading(ReloadKind::Serial, 1);
let lines = close_dialog_lines(&dialog, fixtures::plain(), 120, dialog.at());
let reload = fixtures::row_starting_with(&lines, "L");
assert!(reload.contains("slower than a restart"), "{reload}");
assert!(!reload.contains("No gap"), "{reload}");
}
#[test]
fn an_overlapping_reload_carries_the_reuse_port_caveat() {
let dialog = fixtures::close_dialog_reloading(ReloadKind::Overlap, 1);
let lines = close_dialog_lines(&dialog, fixtures::plain(), 120, dialog.at());
let reload = fixtures::row_starting_with(&lines, "L");
assert!(
reload.contains("if the app sets SO_REUSEPORT itself"),
"{reload}"
);
}
#[test]
fn the_reload_sentence_wraps_rather_than_truncates_at_every_width() {
for width in [
BOX_WIDTH,
overlay::floor_for(BOX_WIDTH) - 1,
MIN_TERM_WIDTH,
160,
] {
for kind in [ReloadKind::Overlap, ReloadKind::Serial] {
for instances in [1, 3] {
let dialog = fixtures::close_dialog_reloading(kind, instances);
let sentence = close_dialog_reload_sentence(&dialog);
let last_word = sentence.split_whitespace().next_back().unwrap();
let lines = close_dialog_lines(&dialog, fixtures::plain(), width, dialog.at());
let joined = text_of(&lines).join(" ");
assert!(
joined.contains(last_word),
"width {width}, {kind:?}, {instances} instances: {joined}"
);
}
}
}
}
#[test]
fn no_line_calls_an_instance_a_lamb() {
for kind in [ReloadKind::Overlap, ReloadKind::Serial] {
for instances in [1, 3] {
let dialog = fixtures::close_dialog_reloading(kind, instances);
let lines = close_dialog_lines(&dialog, fixtures::plain(), 120, dialog.at());
for row in text_of(&lines) {
assert!(!row.contains("lamb"), "{row}");
}
}
}
}
#[test]
fn the_restart_row_states_the_sheeps_own_kill_timeout() {
let dialog = fixtures::close_dialog_with(1, 0);
let lines = close_dialog_lines(&dialog, fixtures::plain(), 120, dialog.at());
let restart = fixtures::row_starting_with(&lines, "R");
assert!(restart.contains("5s"), "{restart}");
}
#[test]
fn everything_else_is_already_live_draws_beside_a_live_edit() {
let dialog = fixtures::close_dialog_with_live_edit(true);
let lines = close_dialog_lines(&dialog, fixtures::plain(), 120, dialog.at());
assert!(
text_of(&lines)
.iter()
.any(|line| line.contains("Everything else you changed is already live")),
"{:?}",
text_of(&lines)
);
}
#[test]
fn everything_else_is_already_live_does_not_draw_with_nothing_else_filed() {
let dialog = fixtures::close_dialog_with_live_edit(false);
let lines = close_dialog_lines(&dialog, fixtures::plain(), 120, dialog.at());
assert!(
!text_of(&lines)
.iter()
.any(|line| line.contains("Everything else you changed is already live")),
"{:?}",
text_of(&lines)
);
}
#[test]
fn the_gauge_shortens_as_now_advances() {
let dialog = fixtures::close_dialog_with(1, 0);
let fresh = close_dialog_lines(&dialog, fixtures::plain(), 120, dialog.at());
let fresh_row = fixtures::row_starting_with(&fresh, "esc");
let fresh_filled = fresh_row.matches('\u{2588}').count();
let halfway = dialog.at() + CONFIRM_EXPIRY / 2;
let later = close_dialog_lines(&dialog, fixtures::plain(), 120, halfway);
let later_row = fixtures::row_starting_with(&later, "esc");
let later_filled = later_row.matches('\u{2588}').count();
assert_eq!(fresh_filled, 10, "{fresh_row}");
assert_eq!(later_filled, 5, "{later_row}");
}
#[test]
fn the_box_draws_at_its_floor_and_not_one_column_below() {
let floor = overlay::floor_for(BOX_WIDTH);
assert!(overlay::is_boxed(floor, BOX_WIDTH));
assert!(!overlay::is_boxed(floor - 1, BOX_WIDTH));
}
#[test]
fn the_borderless_form_spans_the_whole_width_and_never_clips() {
let rendered = fixtures::render_dialog(89, 48);
let heading = fixtures::row_containing(&rendered, "NEEDS A RESPAWN");
assert!(
!heading.contains('▐'),
"no border below the floor: {heading}"
);
assert!(
visible_width(&heading) <= 89,
"clipped or overran: {heading}"
);
}
#[test]
fn the_dialog_fits_every_size_the_dashboard_supports_and_stays_answerable() {
for width in [MIN_TERM_WIDTH, 40, 51, 89, 90, 120, 160] {
for height in MIN_HEIGHT..=24 {
let rendered = render_text(&fixtures::render_dialog(width, height));
for needle in ["restart now", "reload", "continue", "keep editing"] {
assert!(
rendered.contains(needle),
"{width}x{height} lost {needle:?}:\n{rendered}"
);
}
let _ = fixtures::draw_pane_with_dialog(width, height);
}
}
}
#[test]
fn the_reload_caveat_outlives_the_naming_sentence() {
let app = fixtures::app_with_close_dialog();
let dialog = app.close_dialog().expect("the dialog is up");
let whole = close_dialog_reload_sentence(dialog);
let naming = close_dialog_naming_sentence(dialog.unsent_fields());
let rendered = render_text(&fixtures::render_dialog(90, 8));
let rows = reload_rows(&rendered);
let last = rows.last().expect("the reload row draws at every size");
assert!(
rows.join(" ").contains(&whole),
"the caveat is what survives: {rows:?}"
);
assert!(
!last.ends_with('\u{2026}'),
"nothing was cut, so nothing is marked: {rows:?}"
);
assert!(
!rendered.contains(&naming),
"the naming sentence is what went instead:\n{rendered}"
);
}
#[test]
fn a_continuation_that_cannot_survive_leaves_the_cut_marked() {
for (width, height) in [(90u16, 7u16), (MIN_TERM_WIDTH, MIN_HEIGHT)] {
let rendered = render_text(&fixtures::render_dialog(width, height));
let rows = reload_rows(&rendered);
assert_eq!(rows.len(), 1, "{width}x{height}: {rows:?}");
let last = rows.last().expect("the reload row draws at every size");
assert!(
last.ends_with('\u{2026}'),
"{width}x{height}: the cut is unmarked: {rows:?}"
);
}
}
#[test]
fn a_shed_reload_continuation_leaves_the_cut_marked() {
let app = fixtures::app_with_close_dialog();
let dialog = app.close_dialog().expect("the dialog is up");
let whole = close_dialog_reload_sentence(dialog);
for width in [MIN_TERM_WIDTH, 40, 51, 89, 90, 120, 160] {
for height in MIN_HEIGHT..=24 {
let rendered = render_text(&fixtures::render_dialog(width, height));
let rows = reload_rows(&rendered);
let joined = rows.join(" ");
assert!(
joined.contains(&whole) || rows.iter().any(|row| row.ends_with('\u{2026}')),
"{width}x{height}: the caveat went missing unmarked: {rows:?}"
);
}
}
}
#[track_caller]
fn reload_rows(frame: &str) -> Vec<String> {
let mut rows = Vec::new();
for line in frame.lines() {
let text = dialog_interior(line);
if rows.is_empty() {
if text.starts_with("L reload") {
rows.push(text);
}
continue;
}
if text.starts_with("c continue") || text.is_empty() {
break;
}
rows.push(text);
}
assert!(!rows.is_empty(), "no reload row in:\n{frame}");
rows
}
fn dialog_interior(line: &str) -> String {
let inside = match (line.find('▐'), line.rfind('▌')) {
(Some(left), Some(right)) if left < right => &line[left + '▐'.len_utf8()..right],
_ => line,
};
inside.trim().to_owned()
}
#[test]
fn the_pane_behind_the_dialog_is_muted() {
let buffer = fixtures::draw_pane_with_dialog(160, 48);
let behind = buffer[(2, 4)].style();
assert_eq!(behind.fg, fixtures::plain_dimmed().fg);
}
#[test]
fn no_color_flattens_the_pane_behind_instead_of_leaving_it_lit() {
let buffer = fixtures::draw_pane_with_dialog_and_palette(160, 48, fixtures::no_color());
let title_band = buffer[(2, 0)].style();
assert_eq!(title_band.add_modifier, ratatui::style::Modifier::empty());
}
#[track_caller]
fn cell_in_row_containing(buffer: &Buffer, needle: &str, offset: u16) -> ratatui::style::Style {
let text = render_text(buffer);
let (y, line) = text
.lines()
.enumerate()
.find(|(_, line)| line.contains(needle))
.unwrap_or_else(|| panic!("no row contains {needle:?}"));
let x = line.find(needle).unwrap_or(0);
let x = u16::try_from(x).unwrap_or(0) + offset;
let y = u16::try_from(y).unwrap_or(0);
buffer[(x, y)].style()
}
#[test]
fn the_heading_stays_a_reversed_band_under_no_color() {
let buffer = fixtures::draw_pane_with_dialog_and_palette(160, 48, fixtures::no_color());
let heading = cell_in_row_containing(&buffer, "NEEDS A RESPAWN", 0);
assert!(
heading
.add_modifier
.contains(ratatui::style::Modifier::REVERSED)
);
}
}