mod filter_row;
mod geometry;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use super::super::app::{App, RowKey};
use super::super::pane_bleats::BleatsPane;
use super::super::tail::FEED_WINDOW_BYTES;
use super::cell;
use super::flock::fit;
use crate::output::human_bytes;
use crate::vocabulary::Role;
use filter_row::filter_row_line;
use geometry::{TAG_PREFIX_WIDTH, feed_line_rows, row_height, window_range};
pub(crate) use geometry::{max_scroll_offset, page_amount_down, page_amount_up};
pub fn draw(app: &App, pane: &BleatsPane, area: Rect, buffer: &mut Buffer) {
let width = area.width;
let rows = usize::from(area.height);
for (offset, line) in lines(app, pane, width, rows).into_iter().enumerate() {
let Ok(offset) = u16::try_from(offset) else {
break;
};
let y = area.y + offset;
if y >= area.y + area.height {
break;
}
buffer.set_line(area.x, y, &line, width);
}
}
#[cfg(test)]
#[must_use]
pub(crate) fn draw_lines(app: &App, width: u16, rows: usize) -> Vec<Line<'static>> {
let pane = app
.bleats_pane()
.expect("draw_lines is only called while the bleats pane is open");
lines(app, pane, width, rows)
}
fn lines(app: &App, pane: &BleatsPane, width: u16, rows: usize) -> Vec<Line<'static>> {
let mut out = Vec::with_capacity(rows);
out.push(title_line(app, pane, width));
let filters = pane.filters();
let feed = app.feed();
let survivors = pane.visible(&feed.lines, &app.feed_classifier());
if let Some(row) = filter_row_line(app, filters, survivors.len(), feed.lines.len(), width) {
out.push(row);
}
if feed.lines.is_empty() {
if rows > out.len()
&& let Some(note) = feed.note.as_deref()
{
out.push(Line::from(Span::styled(
fit(note, width),
app.palette().muted(),
)));
}
return out;
}
let body_rows = rows.saturating_sub(out.len());
let wrap = pane.wrapped();
let text_width = width.saturating_sub(TAG_PREFIX_WIDTH);
let window = window_range(survivors.len(), pane.scroll_offset(), body_rows, |i| {
row_height(&survivors[i].text, text_width, wrap)
});
for line in &survivors[window] {
out.extend(feed_line_rows(app, filters, line, width, wrap));
}
out.truncate(rows);
out
}
fn title_line(app: &App, pane: &BleatsPane, width: u16) -> Line<'static> {
let text = match sheep_id(pane).and_then(|id| app.row(id)) {
Some(row) => {
let feed = app.feed();
let window = format!(
"{} window, {} read",
human_bytes(FEED_WINDOW_BYTES),
human_bytes(feed.read_bytes),
);
let below = match feed.missed_bytes {
0 => String::new(),
bytes => format!(" {} below the window, unread", human_bytes(bytes)),
};
format!(
"{} out {} err {} {window}{below}",
row.info.name,
row.info.out_file.as_deref().unwrap_or("-"),
row.info.err_file.as_deref().unwrap_or("-"),
)
}
None => match sheep_id(pane) {
Some(id) => format!("sheep {id}: it is no longer in the flock"),
None => "no sheep is selected".to_string(),
},
};
Line::from(Span::styled(
cell::band(&text, usize::from(width)),
app.palette().band(Role::Meadow),
))
}
fn sheep_id(pane: &BleatsPane) -> Option<u32> {
match pane.sheep() {
RowKey::Sheep(id) => Some(*id),
RowKey::Group(_) | RowKey::Section(_) | RowKey::Fold(_) => None,
}
}
#[cfg(test)]
mod tests {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
use super::super::super::app::{KeyPress, Msg};
use super::super::super::frames::render_text;
use super::super::super::tail::{Stream, Tail, TailLine};
use super::super::fixtures::{
full_app, render_all, with_feed, with_no_selection, with_selection,
};
use super::*;
fn drawn(app: &App, pane: &BleatsPane, width: u16, height: u16) -> String {
let area = Rect::new(0, 0, width, height);
let mut buffer = Buffer::empty(area);
draw(app, pane, area, &mut buffer);
render_text(&buffer)
}
#[test]
fn an_empty_feed_shows_the_note_in_the_body() {
let app = with_feed(Tail {
lines: Vec::new(),
missed_lines: 0,
missed_bytes: 0,
read_bytes: 0,
note: Some("no log path is configured for this sheep".to_string()),
});
let pane = BleatsPane::new(app.selected().expect("with_feed selects a sheep"));
let text = drawn(&app, &pane, 80, 6);
assert!(
text.contains("no log path is configured for this sheep"),
"got {text:?}"
);
}
#[test]
fn a_feed_with_lines_never_shows_the_note() {
let app = with_feed(Tail {
lines: vec![TailLine::new(Stream::Out, "listening")],
missed_lines: 0,
missed_bytes: 0,
read_bytes: 9,
note: Some("should never reach the screen".to_string()),
});
let pane = BleatsPane::new(app.selected().expect("with_feed selects a sheep"));
let text = drawn(&app, &pane, 80, 6);
assert!(
!text.contains("should never reach the screen"),
"got {text:?}"
);
assert!(text.contains("listening"), "got {text:?}");
}
#[test]
fn the_title_names_the_sheep_and_both_log_paths() {
let info = ProcessInfo::builder(3, "catcher", ProcStatus::Online)
.out_file(Some("/home/ada/.shep/logs/catcher-out.log".to_string()))
.err_file(Some("/home/ada/.shep/logs/catcher-err.log".to_string()))
.build();
let app = with_selection(info);
let pane = BleatsPane::new(RowKey::Sheep(3));
let text = title_line(&app, &pane, 120);
let rendered: String = text
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert!(rendered.contains("catcher"), "got {rendered:?}");
assert!(
rendered.contains("/home/ada/.shep/logs/catcher-out.log"),
"got {rendered:?}"
);
assert!(
rendered.contains("/home/ada/.shep/logs/catcher-err.log"),
"got {rendered:?}"
);
}
#[test]
fn the_title_says_when_the_pinned_sheep_left_the_flock() {
let info = ProcessInfo::builder(3, "catcher", ProcStatus::Online).build();
let app = with_selection(info);
let pane = BleatsPane::new(RowKey::Sheep(404));
let text = title_line(&app, &pane, 120);
let rendered: String = text
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert!(
rendered.contains("sheep 404: it is no longer in the flock"),
"got {rendered:?}"
);
}
#[test]
fn the_title_says_no_sheep_is_selected_for_a_non_sheep_row() {
let app = with_no_selection();
let pane = BleatsPane::new(RowKey::Group("web".to_string()));
let text = title_line(&app, &pane, 120);
let rendered: String = text
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert!(
rendered.contains("no sheep is selected"),
"got {rendered:?}"
);
}
#[test]
fn draw_keeps_the_newest_lines_when_more_exist_than_fit() {
let app = full_app();
let pane = BleatsPane::new(app.selected().expect("full_app selects a sheep"));
let text = drawn(&app, &pane, 80, 4);
for n in 7..10 {
assert!(text.contains(&format!("line-{n}")), "got {text:?}");
}
for n in 0..7 {
assert!(!text.contains(&format!("line-{n} ")), "got {text:?}");
}
let lines: Vec<&str> = text.lines().collect();
assert!(
lines.last().is_some_and(|line| line.contains("line-9")),
"the newest line is on the bottom row: {text:?}"
);
}
#[test]
fn no_axis_set_draws_no_filter_row() {
let mut app = full_app();
app.update(Msg::Key(KeyPress::Bleats));
let text = render_all(&draw_lines(&app, 80, 6));
assert!(!text.contains("must hold"), "got {text:?}");
assert!(!text.contains("lines in the window"), "got {text:?}");
}
#[test]
fn the_title_names_the_window_and_what_fell_below_it() {
let mut app = with_selection(
ProcessInfo::builder(9, "web", ProcStatus::Online)
.out_file(Some("/logs/web-out.log".to_string()))
.build(),
);
app.update(Msg::Bleats {
tail: Tail {
lines: vec![TailLine::new(Stream::Out, "listening")],
missed_lines: 0,
missed_bytes: 831 * 1024,
read_bytes: 4096,
note: None,
},
});
app.update(Msg::Key(KeyPress::Bleats));
let text = render_all(&draw_lines(&app, 200, 40));
assert!(text.contains("64.0K window"), "the window it reads: {text}");
assert!(text.contains("4.0K read"), "and what it read: {text}");
assert!(
text.contains("below the window, unread"),
"and what it could not: {text}"
);
}
}