pub(super) mod dogs;
mod scalars;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use super::super::app::{App, Settings, SettingsRow};
use super::super::theme::Palette;
use super::flock::{GUTTER, fit};
use super::scroll::Attempt;
use crate::commands::settings::SettingField;
use dogs::{DOGS_CAPTION, dog_header_line, dog_line};
use scalars::{scalar_line, scalar_view};
pub use dogs::{columns_for, dog_rows};
pub(super) use scalars::field_label;
const fn body_width(width: u16) -> u16 {
width.saturating_sub(GUTTER)
}
fn section_header(label: &str, palette: Palette) -> Line<'static> {
Line::from(Span::styled(format!(" {label}"), palette.muted()))
}
fn section_for(settings: &Settings, field: SettingField) -> &str {
settings
.fields()
.by_key(field.key())
.and_then(|f| f.group.as_deref())
.unwrap_or("")
}
fn content_lines(
app: &App,
settings: &Settings,
palette: Palette,
width: u16,
height: u16,
) -> Vec<Line<'static>> {
let total_rows = settings.rows().len();
let cursor_row = settings.view().cursor().min(total_rows.saturating_sub(1));
let budget = if height == 0 {
usize::MAX
} else {
usize::from(height)
};
if total_rows == 0 {
return body_from(app, settings, palette, width, budget, 0).lines;
}
super::scroll::to_cursor(
cursor_row,
settings.view().offset(),
|offset| body_from(app, settings, palette, width, budget, offset),
|| cursor_only(app, settings, palette, width, budget, cursor_row),
)
}
fn cursor_only(
app: &App,
settings: &Settings,
palette: Palette,
width: u16,
budget: usize,
cursor_row: usize,
) -> Vec<Line<'static>> {
let rows = settings.rows();
let table_width = body_width(width);
let mut lines = Vec::new();
match rows.get(cursor_row) {
Some(SettingsRow::Scalar(field)) => lines.push(scalar_line(
*field,
scalar_view(settings.snapshot(), *field),
true,
table_width,
)),
Some(SettingsRow::Dog(index)) => {
let dogs = dog_rows(app, table_width);
if let Some(dog) = dogs.get(*index) {
lines.push(dog_line(dog, columns_for(table_width), table_width, true));
}
}
None => {}
}
let hidden_below = rows.len().saturating_sub(cursor_row + 1);
if cursor_row > 0 && lines.len() < budget {
lines.insert(
0,
Line::from(Span::styled(
format!(" ... {cursor_row} above"),
palette.muted(),
)),
);
}
if hidden_below > 0 && lines.len() < budget {
lines.push(Line::from(Span::styled(
format!(" ... {hidden_below} below"),
palette.muted(),
)));
}
lines
}
fn body_from(
app: &App,
settings: &Settings,
palette: Palette,
width: u16,
budget: usize,
offset: usize,
) -> Attempt {
let snapshot = settings.snapshot();
let cursor = settings.cursor();
let rows = settings.rows();
let total_rows = rows.len();
let cursor_row = settings.view().cursor().min(total_rows.saturating_sub(1));
let above = usize::from(offset > 0);
let room = |taken: usize, need: usize, index: usize| {
taken + need + above + usize::from(index + 1 < total_rows) <= budget
};
let mut lines: Vec<Line<'static>> = Vec::new();
let mut current_section: Option<&str> = None;
let mut pending_header: Vec<Line<'static>> = Vec::new();
let mut drawn = 0usize;
let mut row_index = 0usize;
let mut full = false;
for row in rows.iter().copied() {
let SettingsRow::Scalar(field) = row else {
break;
};
let index = row_index;
row_index += 1;
let section = section_for(settings, field);
if current_section != Some(section) {
let mut header = Vec::new();
if current_section.is_some() {
header.push(Line::default());
}
header.push(section_header(section, palette));
pending_header = header;
current_section = Some(section);
}
if index < offset {
continue;
}
if !room(lines.len(), pending_header.len() + 1, index) {
full = true;
break;
}
lines.append(&mut pending_header);
lines.push(scalar_line(
field,
scalar_view(snapshot, field),
cursor == Some(row),
body_width(width),
));
drawn += 1;
}
let table_width = body_width(width);
if !full {
let mut dogs_header = vec![Line::default(), section_header("[dogs]", palette)];
dogs_header.push(Line::from(Span::styled(
format!(" {}", fit(DOGS_CAPTION, table_width)),
palette.muted(),
)));
let rendered_columns = columns_for(table_width);
dogs_header.push(dog_header_line(rendered_columns, table_width, palette));
let dogs = dog_rows(app, table_width);
if dogs.is_empty() {
if lines.len() + dogs_header.len() + above <= budget {
lines.append(&mut dogs_header);
}
} else {
let mut pending_dogs_header = dogs_header;
for (index, dog) in dogs.iter().enumerate() {
let global_index = row_index + index;
if global_index < offset {
continue;
}
if !room(lines.len(), pending_dogs_header.len() + 1, global_index) {
break;
}
lines.append(&mut pending_dogs_header);
let selected = cursor == Some(SettingsRow::Dog(index));
lines.push(dog_line(dog, rendered_columns, table_width, selected));
drawn += 1;
}
}
}
let hidden_below = total_rows.saturating_sub(offset + drawn);
if hidden_below > 0 {
lines.push(Line::from(Span::styled(
format!(" ... {hidden_below} below"),
palette.muted(),
)));
}
if offset > 0 {
lines.insert(
0,
Line::from(Span::styled(
format!(" ... {offset} above"),
palette.muted(),
)),
);
}
if lines.len() + 2 <= budget {
if let Some(prompt) = settings.pending() {
lines.push(Line::default());
let text = if prompt.sent {
format!("{} sent, waiting for the shepherd", prompt.text)
} else {
format!("{} enter confirms, any other key cancels", prompt.text)
};
lines.push(Line::from(Span::styled(
format!(" {}", fit(&text, table_width)),
palette.attention(),
)));
} else if let Some((field, buffer)) = settings.typing() {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
format!(
" {}",
fit(
&buffer.line(
&format!("editing {}: ", field_label(*field)),
" enter applies esc cancels",
table_width,
),
table_width,
)
),
palette.attention(),
)));
}
}
Attempt {
cursor_drawn: drawn > 0 && (offset..offset + drawn).contains(&cursor_row),
lines,
}
}
pub fn draw_settings(app: &App, settings: &Settings, area: Rect, buffer: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let palette = app.palette();
for (offset, line) in content_lines(app, settings, palette, area.width, area.height)
.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);
}
}
#[cfg(test)]
mod tests {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use super::super::fixtures;
use super::*;
use crate::lookout::app::{KeyPress, Msg};
use crate::lookout::frames::render_text;
use crate::output::width::visible_width;
#[test]
fn settings_at_a_comfortable_width() {
let app = fixtures::app_in_settings();
let mut terminal = Terminal::new(TestBackend::new(120, 30)).unwrap();
terminal
.draw(|frame| super::super::draw(&app, frame))
.unwrap();
insta::assert_snapshot!(render_text(terminal.backend().buffer()));
}
#[test]
fn every_settings_line_fits_the_terminal_it_was_drawn_for() {
let app = fixtures::app_in_settings_with_dog_drift();
let settings = app.settings().unwrap();
let palette = app.palette();
for width in super::super::MIN_TERM_WIDTH..=200 {
for line in content_lines(&app, settings, palette, width, 0) {
let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
visible_width(&rendered) <= usize::from(width),
"width {width} drew {}: {rendered:?}",
visible_width(&rendered)
);
}
}
}
#[test]
fn the_cursor_mark_sits_on_exactly_the_selected_row() {
let app = fixtures::app_in_settings();
let settings = app.settings().unwrap();
let palette = app.palette();
let lines = content_lines(&app, settings, palette, 120, 0);
let rendered: Vec<String> = lines
.iter()
.map(|line| line.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
let marked: Vec<&String> = rendered.iter().filter(|l| l.starts_with('>')).collect();
assert_eq!(marked.len(), 1, "exactly one row is marked: {rendered:?}");
assert!(
marked[0].contains("log_level"),
"the cursor opens on the first row: {rendered:?}"
);
}
fn body_at(app: &App, height: u16) -> Vec<String> {
let settings = app.settings().unwrap();
content_lines(app, settings, app.palette(), 120, height)
.iter()
.map(std::string::ToString::to_string)
.collect()
}
#[test]
fn a_short_terminal_scrolls_and_says_what_it_hid() {
let mut app = fixtures::app_in_settings();
let height = 10;
app.note_body_rows(height);
app.update(Msg::Key(KeyPress::SelectLast));
let text = body_at(&app, height);
assert!(
text.len() <= usize::from(height),
"the body fits the height it was given: {text:?}"
);
assert!(text[0].contains("above"), "{text:?}");
assert!(
text.iter().any(|l| l.contains("[dogs]")),
"the visible section is labelled"
);
assert_eq!(
text.iter().filter(|l| l.starts_with('>')).count(),
1,
"the cursor's own row is drawn: {text:?}"
);
let settings = app.settings().unwrap();
assert_eq!(settings.view().cursor(), settings.rows().len() - 1);
assert_eq!(settings.view().offset(), 0);
}
fn screen_at(app: &mut App, height: u16) -> String {
let area = Rect::new(0, 0, 120, height);
app.note_body_rows(super::super::body_rows(area));
let mut terminal = Terminal::new(TestBackend::new(120, height)).unwrap();
terminal
.draw(|frame| super::super::draw(app, frame))
.unwrap();
render_text(terminal.backend().buffer())
}
fn marked(text: &str) -> usize {
text.lines().filter(|line| line.starts_with('>')).count()
}
#[test]
fn the_cursor_survives_every_step_of_a_walk_down_and_back_up() {
for height in [6u16, 7, 8, 10, 14, 20] {
let mut app = fixtures::app_in_settings_with_dog_drift();
let total = app.settings().unwrap().rows().len();
for step in 0..=total {
let text = screen_at(&mut app, height);
assert_eq!(marked(&text), 1, "{height} rows, {step} down:\n{text}");
app.update(Msg::Key(KeyPress::SelectDown));
}
for step in 0..=total {
let text = screen_at(&mut app, height);
assert_eq!(marked(&text), 1, "{height} rows, {step} up:\n{text}");
app.update(Msg::Key(KeyPress::SelectUp));
}
}
}
#[test]
fn a_window_opening_mid_section_still_draws_the_header_it_scrolled_past() {
let mut app = fixtures::app_in_settings_on(SettingField::MaxCronSleep);
let text = screen_at(&mut app, 6);
let body: Vec<&str> = text.lines().map(str::trim_end).collect();
assert!(
body.iter().any(|line| line.trim() == "[daemon]"),
"the section above the window is named: {text}"
);
assert!(
!text.contains("log_level"),
"and the rows between its header and the window are gone: {text}"
);
assert_eq!(marked(&text), 1, "{text}");
assert!(
body.iter().any(|line| line.starts_with("> max_cron_sleep")),
"the marked row is the cursor's: {text}"
);
}
#[test]
fn the_below_marker_survives_the_height_that_made_it_necessary() {
let mut app = fixtures::app_in_settings();
let height = 6;
app.note_body_rows(height);
let text = body_at(&app, height);
assert!(
text.len() <= usize::from(height),
"the body fits the height it was given: {text:?}"
);
assert!(
text.last().is_some_and(|l| l.contains("below")),
"the last line says how many rows were cut: {text:?}"
);
assert!(
text.iter().any(|l| l.starts_with("> log_level")),
"the cursor's own row is drawn: {text:?}"
);
}
#[test]
fn the_editor_line_names_its_field_and_shows_the_buffer() {
let mut app = fixtures::app_in_settings_on(SettingField::Socket);
let _ = app.update(Msg::Key(KeyPress::Confirm));
let settings = app.settings().unwrap();
let palette = app.palette();
let lines = content_lines(&app, settings, palette, 120, 0);
let rendered: Vec<String> = lines
.iter()
.map(|line| line.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert!(
rendered.iter().any(|line| line.contains("editing socket:")
&& line.contains("/home/ada/.shep/run/shep.sock")),
"got: {rendered:?}"
);
}
}