use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use super::super::super::app::App;
use super::super::super::pane::{ConfigPane, PaneTarget};
use super::super::super::theme::Palette;
use super::super::flock::fit;
use super::super::overlay;
use super::super::scroll;
use super::body::{body_from, cursor_only, grouped_body_lines};
use super::chrome::{
column_header_line, hairline_line, has_groups, legend_line, tab_row_line, title_band_line,
title_line,
};
use super::close::draw_close_dialog;
use super::field_row::push_wrapped_blurb;
use super::layout::{body_width, lands_fits_beside_panel, line_columns, panel_width};
use super::list::list_lines;
use super::panel::panel_lines;
pub(super) fn grouped_pane_lines(
pane: &ConfigPane,
palette: Palette,
width: u16,
budget: usize,
) -> Vec<Line<'static>> {
grouped_pane_lines_with_panel(pane, palette, width, budget, None)
}
pub(super) fn grouped_pane_with_panel_lines(
pane: &ConfigPane,
palette: Palette,
width: u16,
panel_w: u16,
budget: usize,
) -> Vec<Line<'static>> {
let panel = panel_lines(pane, palette, panel_w);
grouped_pane_lines_with_panel(pane, palette, width, budget, Some((panel, panel_w)))
}
pub(super) fn grouped_pane_lines_with_panel(
pane: &ConfigPane,
palette: Palette,
width: u16,
budget: usize,
panel: Option<(Vec<Line<'static>>, u16)>,
) -> Vec<Line<'static>> {
let has_panel = panel.is_some();
let show_lands = lands_fits_beside_panel(width, has_panel);
let left_width = panel
.as_ref()
.map_or(width, |(_, panel_width)| width.saturating_sub(*panel_width));
let mut lines = vec![title_band_line(pane, palette, width)];
let mut remaining = budget - 1;
if remaining == 0 {
return lines;
}
if remaining > 1 {
lines.push(tab_row_line(pane, palette, width));
remaining -= 1;
}
if remaining > 1 {
lines.push(hairline_line(palette, width));
remaining -= 1;
}
if remaining > 1 {
lines.push(column_header_line(palette, left_width, show_lands));
remaining -= 1;
}
push_wrapped_blurb(&mut lines, &mut remaining, pane, palette, width);
if remaining == 0 {
return lines;
}
let footer_lines = if remaining > 1 {
2.min(remaining - 1)
} else {
0
};
let body_budget = remaining - footer_lines;
if body_budget > 0 {
let body = grouped_body_lines(pane, palette, left_width, body_budget, show_lands);
lines.extend(match panel {
Some((panel, _)) => merge_beside_panel(body, panel, left_width, body_budget),
None => body,
});
}
if footer_lines >= 1 {
lines.push(hairline_line(palette, width));
}
if footer_lines >= 2 {
lines.push(legend_line(palette, width));
}
lines
}
pub(super) fn merge_beside_panel(
left: Vec<Line<'static>>,
panel: Vec<Line<'static>>,
left_width: u16,
budget: usize,
) -> Vec<Line<'static>> {
let left_width = usize::from(left_width);
let rows = left.len().max(panel.len()).min(budget);
let mut left = left.into_iter();
let mut panel = panel.into_iter();
(0..rows)
.map(|_| {
let line = left.next().unwrap_or_default();
let used = line_columns(&line);
let mut spans = line.spans;
if used < left_width {
spans.push(Span::raw(" ".repeat(left_width - used)));
}
if let Some(panel_line) = panel.next() {
spans.extend(panel_line.spans);
}
Line::from(spans)
})
.collect()
}
pub(super) fn dog_footer_text(pane: &ConfigPane, body_budget: usize) -> Option<String> {
let PaneTarget::Dog { name, .. } = pane.target() else {
return None;
};
(body_budget > 0).then(|| format!("shep publishes the change; {name} decides what to reload"))
}
pub(super) fn push_footer_line(
lines: &mut Vec<Line<'static>>,
footer: Option<String>,
width: u16,
palette: Palette,
) {
if let Some(text) = footer {
lines.push(Line::from(Span::styled(
format!(" {}", fit(&text, body_width(width))),
palette.muted(),
)));
}
}
#[must_use]
pub fn pane_lines(
pane: &ConfigPane,
palette: Palette,
width: u16,
height: u16,
) -> Vec<Line<'static>> {
let budget = if height == 0 {
usize::MAX
} else {
usize::from(height)
};
if budget == 0 {
return Vec::new();
}
if let Some(list) = pane.list() {
return list_lines(pane, list, palette, width, budget);
}
if has_groups(pane) {
if let Some(panel_w) = panel_width(width) {
return grouped_pane_with_panel_lines(pane, palette, width, panel_w, budget);
}
return grouped_pane_lines(pane, palette, width, budget);
}
let panel = panel_width(width).map(|panel_w| (panel_lines(pane, palette, panel_w), panel_w));
ungrouped_pane_lines_with_panel(pane, palette, width, budget, panel)
}
pub(super) fn ungrouped_pane_lines_with_panel(
pane: &ConfigPane,
palette: Palette,
width: u16,
budget: usize,
panel: Option<(Vec<Line<'static>>, u16)>,
) -> Vec<Line<'static>> {
let show_lands = lands_fits_beside_panel(width, panel.is_some());
let left_width = panel
.as_ref()
.map_or(width, |(_, panel_width)| width.saturating_sub(*panel_width));
let mut lines = vec![title_line(pane, palette, width)];
let mut body_budget = budget - 1;
let footer = dog_footer_text(pane, body_budget);
if footer.is_some() {
body_budget -= 1;
}
push_wrapped_blurb(&mut lines, &mut body_budget, pane, palette, width);
if !pane.fields().is_empty() && body_budget > 0 {
let total = pane.rows().len();
let cursor_row = pane.view().cursor().min(total - 1);
let body = scroll::to_cursor(
cursor_row,
pane.view().offset(),
|offset| {
body_from(
pane,
palette,
left_width,
body_budget,
offset,
true,
show_lands,
)
},
|| {
cursor_only(
pane,
palette,
left_width,
body_budget,
cursor_row,
show_lands,
)
},
);
lines.extend(match panel {
Some((panel, _)) => merge_beside_panel(body, panel, left_width, body_budget),
None => body,
});
}
push_footer_line(&mut lines, footer, width, palette);
lines
}
pub fn draw_pane(app: &App, pane: &ConfigPane, area: Rect, buffer: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let lines = pane_lines(pane, app.palette(), area.width, area.height);
for (offset, line) in lines.iter().enumerate().take(usize::from(area.height)) {
let offset = u16::try_from(offset).unwrap_or(0);
buffer.set_line(area.x, area.y + offset, line, area.width);
}
if let Some(dialog) = app.close_dialog() {
overlay::mute(buffer, area, app.palette());
draw_close_dialog(dialog, app.palette(), app.now(), area, buffer);
}
}
#[cfg(test)]
mod tests {
use super::super::fixtures::{
bark_pane, config_pane_lines_for_tests, pane_to, text_of, web_pane,
};
use super::super::layout::{FULL_WIDTH, LANDS_WITH_PANEL_MIN};
use super::*;
use crate::lookout::app::{Effect, KeyPress, Msg};
use crate::lookout::view::MIN_TERM_WIDTH;
use crate::lookout::view::fixtures;
use crate::output::width::visible_width;
#[test]
fn a_sheep_pane_at_a_comfortable_width() {
let lines = pane_lines(&web_pane(), fixtures::plain(), 120, 0);
insta::assert_snapshot!("sheep_pane_wide", text_of(&lines).join("\n"));
}
#[test]
fn every_pane_line_fits_the_width_it_was_drawn_for() {
let pane = web_pane();
for width in MIN_TERM_WIDTH..=200 {
for line in text_of(&pane_lines(&pane, fixtures::plain(), width, 0)) {
assert!(
visible_width(&line) <= usize::from(width),
"width {width} drew {}: {line:?}",
visible_width(&line)
);
}
}
}
#[test]
fn the_cost_column_predicts_and_the_status_bar_reports() {
for (key, column, pending, sentence) in [
("watch", "now", true, "waits for `shep reload web`"),
("autostart", "next start", false, "set to false"),
] {
let mut app = fixtures::app_in_sheep_pane_with_control();
pane_to(&mut app, key);
let text = text_of(&pane_lines(
app.config_pane().unwrap(),
fixtures::plain(),
89,
0,
));
let row = text
.iter()
.find(|line| {
let mut words = line.split_whitespace();
let mut rest = line.get(3..).map(str::split_whitespace);
(words.next() == Some(key) && words.next().is_some())
|| rest
.as_mut()
.is_some_and(|w| w.next() == Some(key) && w.next().is_some())
})
.unwrap_or_else(|| panic!("{key} is drawn at 89 columns: {text:?}"));
assert!(row.contains(column), "{key}: {row:?}");
app.update(Msg::Key(KeyPress::Cycle));
let _ = app.update(Msg::Key(KeyPress::Escape));
let effect = if app.close_dialog().is_some() {
app.update(Msg::Key(KeyPress::Continue))
} else {
Effect::None
};
let Effect::SendAll(mut batch) = effect else {
panic!("{key}: closing the pane sends");
};
app.update(Msg::Replied {
sent: batch.remove(0),
result: Ok(shep_core::protocol::Response::SheepFieldSet {
name: "web".to_string(),
key: key.to_string(),
pending,
warning: None,
}),
});
let bar = crate::lookout::view::status::status_line(&app, 200).to_string();
assert!(bar.contains(sentence), "{key}: {bar:?}");
}
}
#[test]
fn a_dog_pane_at_a_comfortable_width() {
let text = text_of(&pane_lines(&bark_pane(), fixtures::plain(), 120, 0));
assert!(
!text.iter().any(|line| line.contains("hooks.example")),
"a secret is never rendered: {text:?}"
);
assert!(text.iter().any(|line| line.contains("<set>")), "{text:?}");
assert!(
text.iter()
.any(|line| line.contains("decides what to reload")),
"{text:?}"
);
insta::assert_snapshot!("dog_pane_wide", text.join("\n"));
}
#[test]
fn a_dog_panes_footer_is_paid_for_out_of_the_height_it_was_given() {
let mut pane = bark_pane();
for height in 1..=20u16 {
pane.set_rows(usize::from(height.saturating_sub(1)));
for cursor in [0usize, 2, 4] {
pane.move_to_first();
pane.move_by(isize::try_from(cursor).unwrap());
let text = text_of(&pane_lines(&pane, fixtures::plain(), 120, height));
assert!(
text.len() <= usize::from(height),
"height {height}, cursor {cursor}: {text:?}"
);
}
}
}
#[test]
fn every_dog_pane_line_fits_the_width_it_was_drawn_for() {
let pane = bark_pane();
for width in MIN_TERM_WIDTH..=200 {
for line in text_of(&pane_lines(&pane, fixtures::plain(), width, 0)) {
assert!(
visible_width(&line) <= usize::from(width),
"width {width} drew {}: {line:?}",
visible_width(&line)
);
}
}
}
#[test]
fn no_row_spills_across_the_panel_boundary_or_past_its_own_width() {
let app = fixtures::app_in_sheep_pane();
let pane = app.config_pane().expect("the pane is open");
for width in [160, 200] {
for line in pane_lines(pane, fixtures::plain(), width, 48) {
let cols = line_columns(&line);
assert!(
cols <= usize::from(width),
"a row is {cols} columns wide at terminal width {width}: {line:?}"
);
}
}
}
#[test]
fn lands_gives_way_to_the_panel_below_the_design_target_and_joins_it_above() {
let app = fixtures::app_in_sheep_pane();
for width in MIN_TERM_WIDTH..=200 {
let rows = fixtures::render_all(&config_pane_lines_for_tests(&app, width, 48));
let panel = rows.contains("FOCUSED");
let lands = rows.contains("LANDS");
let has_panel = panel_width(width).is_some();
match (has_panel, width >= LANDS_WITH_PANEL_MIN) {
(true, true) => assert!(
panel && lands,
"the panel and LANDS must both draw at {width} columns"
),
(true, false) => assert!(
panel && !lands,
"LANDS must give way to the panel at {width} columns"
),
(false, _) => {
assert!(!panel, "the panel must not draw at {width} columns");
let expected_lands = body_width(width) >= FULL_WIDTH;
assert_eq!(
lands, expected_lands,
"LANDS mismatch at {width} columns, no panel"
);
}
}
}
}
#[test]
fn every_row_fits_the_width_it_was_drawn_for() {
for width in MIN_TERM_WIDTH..=200 {
let app = fixtures::app_in_sheep_pane();
for row in config_pane_lines_for_tests(&app, width, 48) {
assert!(
fixtures::render_all(std::slice::from_ref(&row))
.chars()
.count()
<= usize::from(width),
"a row overflows at {width}"
);
}
}
}
}