use std::path::PathBuf;
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::{fit, mark};
use crate::commands::settings::{ScalarView, SettingField, SettingsSnapshot};
use crate::style::StyleSource;
use crate::vocabulary::Reported;
const DOGS_CAPTION: &str = "space arms, Enter applies; a dog needs no reload";
const DOG_NAME_MIN: u16 = 8;
const GUTTER: u16 = 2;
const fn body_width(width: u16) -> u16 {
width.saturating_sub(GUTTER)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DogColumn {
Name,
InFile,
Source,
Running,
}
impl DogColumn {
#[must_use]
const fn header(self) -> &'static str {
match self {
Self::Name => "NAME",
Self::InFile => "IN FILE",
Self::Source => "SOURCE",
Self::Running => "RUNNING",
}
}
#[must_use]
const fn width(self) -> u16 {
match self {
Self::Name => 0,
Self::InFile => 7,
Self::Source => 24,
Self::Running => 15,
}
}
}
const ALL_DOG_COLUMNS: &[DogColumn] = &[
DogColumn::Name,
DogColumn::InFile,
DogColumn::Source,
DogColumn::Running,
];
const NO_SOURCE: &[DogColumn] = &[DogColumn::Name, DogColumn::InFile, DogColumn::Running];
const FLOOR_DOG_COLUMNS: &[DogColumn] = &[DogColumn::Name, DogColumn::Running];
const DOG_MIN_WIDTH: u16 = DogColumn::Running.width() + DOG_NAME_MIN + 2;
const DOG_TIERS: &[(u16, &[DogColumn])] = &[
(61, ALL_DOG_COLUMNS),
(34, NO_SOURCE),
(DOG_MIN_WIDTH, FLOOR_DOG_COLUMNS),
];
#[must_use]
pub fn columns_for(width: u16) -> &'static [DogColumn] {
DOG_TIERS
.iter()
.find(|(threshold, _)| width >= *threshold)
.map_or(FLOOR_DOG_COLUMNS, |(_, columns)| *columns)
}
fn dog_name_width(width: u16, columns: &[DogColumn]) -> u16 {
let fixed: u16 = columns.iter().map(|column| column.width()).sum();
let gaps = u16::try_from(columns.len().saturating_sub(1)).unwrap_or(0) * 2;
width
.saturating_sub(fixed)
.saturating_sub(gaps)
.max(DOG_NAME_MIN)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DogRow {
pub name: String,
pub enabled: bool,
pub running: Option<String>,
pub adopted_path: Option<PathBuf>,
}
#[must_use]
pub fn dog_rows(app: &App, width: u16) -> Vec<DogRow> {
let _ = width;
let Some(settings) = app.settings() else {
return Vec::new();
};
let running_by_name: std::collections::BTreeMap<&str, String> = app
.all_rows()
.into_iter()
.filter(|row| row.info.dog.is_some())
.map(|row| {
(
row.info.name.as_str(),
Reported::of(row.info.status, row.info.handshook).word(),
)
})
.collect();
settings
.snapshot()
.dogs
.iter()
.map(|dog| DogRow {
name: dog.name.clone(),
enabled: dog.enabled,
running: running_by_name.get(dog.name.as_str()).cloned(),
adopted_path: dog.adopted_path.clone(),
})
.collect()
}
fn dog_cell(dog: &DogRow, column: DogColumn) -> String {
match column {
DogColumn::Name => dog.name.clone(),
DogColumn::InFile => if dog.enabled { "yes" } else { "no" }.to_string(),
DogColumn::Source => dog
.adopted_path
.as_ref()
.map_or_else(|| "built-in".to_string(), |path| path.display().to_string()),
DogColumn::Running => dog
.running
.clone()
.unwrap_or_else(|| "not running".to_string()),
}
}
fn dog_header_line(columns: &[DogColumn], width: u16, palette: Palette) -> Line<'static> {
let name_width = dog_name_width(width, columns);
let mut text = String::from(" ");
for (index, column) in columns.iter().enumerate() {
if index > 0 {
text.push_str(" ");
}
let cell_width = if *column == DogColumn::Name {
name_width
} else {
column.width()
};
text.push_str(&fit(column.header(), cell_width));
}
Line::from(Span::styled(text, palette.muted()))
}
fn dog_line(dog: &DogRow, columns: &[DogColumn], width: u16, selected: bool) -> Line<'static> {
let name_width = dog_name_width(width, columns);
let mut text = format!("{} ", mark(selected));
for (index, column) in columns.iter().enumerate() {
if index > 0 {
text.push_str(" ");
}
let cell_width = if *column == DogColumn::Name {
name_width
} else {
column.width()
};
text.push_str(&fit(&dog_cell(dog, *column), cell_width));
}
Line::from(Span::raw(text))
}
fn section_header(label: &str, palette: Palette) -> Line<'static> {
Line::from(Span::styled(format!(" {label}"), palette.muted()))
}
fn scalar_view(snapshot: &SettingsSnapshot, field: SettingField) -> &ScalarView {
match field {
SettingField::LogLevel => &snapshot.log_level,
SettingField::LogJson => &snapshot.log_json,
SettingField::Socket => &snapshot.socket,
SettingField::MaxCronSleep => &snapshot.max_cron_sleep,
SettingField::AllowControl => &snapshot.allow_control,
SettingField::StyleLevel => &snapshot.style_level,
}
}
pub(super) const fn field_label(field: SettingField) -> &'static str {
match field {
SettingField::LogLevel => "log_level",
SettingField::LogJson => "log_json",
SettingField::Socket => "socket",
SettingField::MaxCronSleep => "max_cron_sleep",
SettingField::AllowControl => "allow_control",
SettingField::StyleLevel => "level",
}
}
const fn apply_cost(field: SettingField, source: StyleSource) -> &'static str {
match field {
SettingField::LogLevel | SettingField::LogJson | SettingField::MaxCronSleep => {
"needs shep daemon reload"
}
SettingField::Socket => "needs the shepherd stopped and started",
SettingField::AllowControl => "needs shep whistle restarted",
SettingField::StyleLevel => match source {
StyleSource::Config | StyleSource::Default => "the next command reads it",
StyleSource::Env | StyleSource::Flag => "written, but outranked",
},
}
}
const SCALAR_NAME_W: u16 = 15;
const SCALAR_VALUE_W: u16 = 30;
const SCALAR_VALUE_MIN: u16 = 10;
const SCALAR_SOURCE_W: u16 = 11;
const SCALAR_COST_MIN: u16 = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ScalarColumn {
Name,
Value,
Source,
Cost,
}
const SCALAR_ALL: &[ScalarColumn] = &[
ScalarColumn::Name,
ScalarColumn::Value,
ScalarColumn::Source,
ScalarColumn::Cost,
];
const SCALAR_NO_COST: &[ScalarColumn] = &[
ScalarColumn::Name,
ScalarColumn::Value,
ScalarColumn::Source,
];
const SCALAR_FLOOR: &[ScalarColumn] = &[ScalarColumn::Name, ScalarColumn::Value];
const SCALAR_MIN_WIDTH: u16 = SCALAR_NAME_W + 2 + SCALAR_VALUE_MIN;
const SCALAR_TIERS: &[(u16, &[ScalarColumn])] = &[
(
SCALAR_NAME_W + 2 + SCALAR_VALUE_MIN + 2 + SCALAR_SOURCE_W + 2 + SCALAR_COST_MIN,
SCALAR_ALL,
),
(
SCALAR_NAME_W + 2 + SCALAR_VALUE_MIN + 2 + SCALAR_SOURCE_W,
SCALAR_NO_COST,
),
(SCALAR_MIN_WIDTH, SCALAR_FLOOR),
];
fn scalar_columns_for(width: u16) -> &'static [ScalarColumn] {
SCALAR_TIERS
.iter()
.find(|(threshold, _)| width >= *threshold)
.map_or(SCALAR_FLOOR, |(_, columns)| *columns)
}
fn scalar_widths(width: u16, columns: &[ScalarColumn]) -> (u16, u16) {
let fixed = SCALAR_NAME_W
+ if columns.contains(&ScalarColumn::Source) {
SCALAR_SOURCE_W
} else {
0
};
let gaps = u16::try_from(columns.len().saturating_sub(1)).unwrap_or(0) * 2;
let remainder = width.saturating_sub(fixed).saturating_sub(gaps);
if columns.contains(&ScalarColumn::Cost) {
let value = remainder
.saturating_sub(SCALAR_COST_MIN)
.clamp(SCALAR_VALUE_MIN, SCALAR_VALUE_W);
(value, remainder.saturating_sub(value).max(SCALAR_COST_MIN))
} else {
(remainder.clamp(SCALAR_VALUE_MIN, SCALAR_VALUE_W), 0)
}
}
fn scalar_cell(field: SettingField, view: &ScalarView, column: ScalarColumn) -> String {
match column {
ScalarColumn::Name => field_label(field).to_string(),
ScalarColumn::Value => view.value.clone(),
ScalarColumn::Source => view.source.to_string(),
ScalarColumn::Cost => apply_cost(field, view.source).to_string(),
}
}
fn scalar_line(
field: SettingField,
view: &ScalarView,
selected: bool,
width: u16,
) -> Line<'static> {
let columns = scalar_columns_for(width);
let (value_width, cost_width) = scalar_widths(width, columns);
let mut text = format!("{} ", mark(selected));
for (index, column) in columns.iter().enumerate() {
if index > 0 {
text.push_str(" ");
}
let cell_width = match column {
ScalarColumn::Name => SCALAR_NAME_W,
ScalarColumn::Value => value_width,
ScalarColumn::Source => SCALAR_SOURCE_W,
ScalarColumn::Cost => cost_width,
};
text.push_str(&fit(&scalar_cell(field, view, *column), cell_width));
}
Line::from(Span::raw(text))
}
const fn section_for(field: SettingField) -> &'static str {
match field {
SettingField::LogLevel
| SettingField::LogJson
| SettingField::Socket
| SettingField::MaxCronSleep => "[daemon]",
SettingField::AllowControl => "[whistle]",
SettingField::StyleLevel => "[style]",
}
}
fn content_lines(
app: &App,
settings: &Settings,
palette: Palette,
width: u16,
) -> Vec<Line<'static>> {
let snapshot = settings.snapshot();
let cursor = settings.cursor();
let mut lines = Vec::new();
let mut current_section: Option<&'static str> = None;
for row in settings.rows() {
let SettingsRow::Scalar(field) = row else {
break;
};
let section = section_for(field);
if current_section != Some(section) {
if current_section.is_some() {
lines.push(Line::default());
}
lines.push(section_header(section, palette));
current_section = Some(section);
}
lines.push(scalar_line(
field,
scalar_view(snapshot, field),
cursor == Some(row),
body_width(width),
));
}
lines.push(Line::default());
lines.push(section_header("[dogs]", palette));
let table_width = body_width(width);
lines.push(Line::from(Span::styled(
format!(" {}", fit(DOGS_CAPTION, table_width)),
palette.muted(),
)));
let rendered_columns = columns_for(table_width);
lines.push(dog_header_line(rendered_columns, table_width, palette));
for (index, dog) in dog_rows(app, table_width).iter().enumerate() {
let selected = cursor == Some(SettingsRow::Dog(index));
lines.push(dog_line(dog, rendered_columns, table_width, selected));
}
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(
&format!(
"editing {}: {buffer}\u{258f} enter applies esc cancels",
field_label(*field)
),
table_width,
)
),
palette.attention(),
)));
}
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)
.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;
use crate::style::StyleSource;
#[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 the_dogs_source_column_drops_before_the_rest() {
assert!(columns_for(120).contains(&DogColumn::Source));
assert!(!columns_for(60).contains(&DogColumn::Source));
assert!(
columns_for(60).contains(&DogColumn::Running),
"RUNNING is the diagnostic half and outlives SOURCE"
);
}
#[test]
fn every_dogs_tier_fits_the_width_it_claims() {
let app = fixtures::app_in_settings_with_dog_drift();
let settings = app.settings().unwrap();
let palette = app.palette();
for width in (DOG_MIN_WIDTH + GUTTER)..=200 {
let rendered: Vec<String> = content_lines(&app, settings, palette, width)
.iter()
.map(|line| line.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
let table = rendered
.iter()
.position(|line| line.contains("[dogs]"))
.expect("the dogs section is always drawn");
for line in &rendered[table..] {
assert!(
visible_width(line) <= usize::from(width),
"width {width} drew {} columns: {line:?}",
visible_width(line)
);
}
}
}
#[test]
fn the_scalar_apply_cost_drops_before_its_source() {
assert!(scalar_columns_for(118).contains(&ScalarColumn::Cost));
assert!(!scalar_columns_for(43).contains(&ScalarColumn::Cost));
assert!(
scalar_columns_for(43).contains(&ScalarColumn::Source),
"SOURCE is the screen's own subject and outlives the cost"
);
assert!(!scalar_columns_for(33).contains(&ScalarColumn::Source));
assert!(
scalar_columns_for(33).contains(&ScalarColumn::Value),
"NAME and VALUE are the floor"
);
}
#[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) {
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_dogs_caption_says_arms_not_applies() {
assert_eq!(
DOGS_CAPTION,
"space arms, Enter applies; a dog needs no reload"
);
}
#[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);
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:?}"
);
}
#[test]
fn the_default_source_label_fits_the_column_it_was_sized_for() {
let rendered = fit(&StyleSource::Default.to_string(), SCALAR_SOURCE_W);
assert_eq!(
rendered.chars().count(),
usize::from(SCALAR_SOURCE_W),
"fit always pads or truncates to the exact column width"
);
assert!(
!rendered.contains('…'),
"SCALAR_SOURCE_W must fit \"the default\" whole: got {rendered:?}"
);
}
#[test]
fn the_style_cost_cell_stops_promising_the_next_command_when_it_is_outranked() {
let app = fixtures::app_in_settings_with_shadowed_style(StyleSource::Env);
let settings = app.settings().unwrap();
let lines = content_lines(&app, settings, app.palette(), 120);
let rendered: Vec<String> = lines
.iter()
.map(|line| line.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
let row = rendered
.iter()
.find(|line| {
line.get(2..)
.is_some_and(|cells| cells.starts_with("level "))
})
.unwrap_or_else(|| panic!("the style row is drawn: {rendered:?}"));
assert!(row.contains("$SHEP_STYLE"), "got: {row:?}");
assert!(row.contains("written, but outranked"), "got: {row:?}");
assert!(!row.contains("the next command reads it"), "got: {row:?}");
}
#[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);
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:?}"
);
}
#[test]
fn the_dogs_table_joins_the_file_against_the_running_flock() {
let app = fixtures::app_in_settings_with_dog_drift();
let rows = dog_rows(&app, 120);
let otel = rows.iter().find(|r| r.name == "otel").unwrap();
assert!(!otel.enabled);
assert_eq!(otel.running.as_deref(), Some("online"));
let ledger = rows.iter().find(|r| r.name == "ledger").unwrap();
assert!(ledger.enabled);
assert_eq!(ledger.running, None);
}
#[test]
fn a_dog_that_never_handshook_reads_silent_here_too() {
let app = fixtures::app_in_settings_with_silent_dog();
let rows = dog_rows(&app, 120);
let bark = rows.iter().find(|r| r.name == "bark").unwrap();
assert_eq!(bark.running.as_deref(), Some("silent"));
}
}