use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use crate::lookout::app::App;
use crate::lookout::pane_bleats::{BleatsPane, Filters, MatchKind};
use crate::lookout::pane_sheep::SheepPane;
use crate::lookout::tail::{Stream, TailLine};
use crate::lookout::theme::Palette;
use super::super::detail::chip_text;
use super::super::flock::fit;
use super::layout::{COLUMN_BODY_ROWS, FEED_WIDTH, FEED_X};
pub(super) fn draw_feed(
app: &App,
pane: &SheepPane,
top: u16,
area: Rect,
buffer: &mut Buffer,
palette: Palette,
) {
let feed = pane.feed();
let tail = app.feed();
let levels = app.feed_classifier();
let survivors = feed.visible(&tail.lines, &levels);
let hidden = survivors.len().saturating_sub(COLUMN_BODY_ROWS);
write_feed_row(buffer, area, top, &feed_header_line(feed, hidden, palette));
for (i, line) in survivors.iter().skip(hidden).enumerate() {
write_feed_row(buffer, area, top + 1 + i as u16, &feed_line(line, palette));
}
}
fn feed_line(line: &TailLine, palette: Palette) -> Line<'static> {
let tag = match line.stream {
Stream::Out => "out",
Stream::Err => "err",
};
Line::from(vec![
Span::styled(format!("{tag} "), palette.muted()),
Span::raw(fit(&line.text, FEED_WIDTH.saturating_sub(5))),
])
}
fn feed_header_line(feed: &BleatsPane, hidden: usize, palette: Palette) -> Line<'static> {
let chip = chip_text("BLEATS");
let chip_width = u16::try_from(chip.chars().count() + 1).unwrap_or(FEED_WIDTH);
let budget = FEED_WIDTH.saturating_sub(chip_width);
Line::from(vec![
Span::styled(chip, palette.band(crate::vocabulary::Role::Butter)),
Span::raw(" "),
Span::styled(
fit(&feed_header_text(feed, hidden), budget),
palette.muted(),
),
])
}
fn feed_header_text(feed: &BleatsPane, hidden: usize) -> String {
let mut parts = vec!["out then err".to_string()];
if hidden > 0 {
parts.push(format!("{hidden} earlier"));
}
for chip in feed_chip_labels(feed.filters()) {
parts.push(format!("[{chip}]"));
}
parts.push("/ narrow".to_string());
parts.join(" \u{b7} ")
}
fn feed_chip_labels(filters: &Filters) -> Vec<String> {
let mut chips = Vec::new();
if let Some(stream) = filters.stream {
chips.push(match stream {
Stream::Out => "out only".to_string(),
Stream::Err => "err only".to_string(),
});
}
if let Some(min) = filters.min_level {
chips.push(format!("level\u{2265}{min}"));
}
if let Some(text) = filters.match_text() {
let suffix = filters.match_kind().map_or("", MatchKind::chip_suffix);
chips.push(format!("match {text}{suffix}"));
}
chips
}
fn write_feed_row(buffer: &mut Buffer, area: Rect, row: u16, line: &Line<'static>) {
if row >= area.height {
return;
}
buffer.set_line(area.x + FEED_X, area.y + row, line, FEED_WIDTH);
}
#[cfg(test)]
mod tests {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
use crate::lookout::app::{Body, KeyPress, Msg, RowKey};
use crate::lookout::frames::render_text;
use crate::lookout::level::{Classifier, Level};
use crate::lookout::tail::Tail;
use super::super::super::fixtures;
use super::super::draw;
use super::super::layout::MIN_HEIGHT_FOR_COLUMN;
use super::*;
#[test]
fn the_feed_does_not_draw_the_sheep_that_replaced_the_pinned_one() {
let mut app = fixtures::app_with(
vec![
ProcessInfo::builder(1, "alpha", ProcStatus::Online).build(),
ProcessInfo::builder(2, "bravo", ProcStatus::Online).build(),
],
fixtures::plain(),
);
let _ = app.update(Msg::Key(KeyPress::Confirm));
assert!(
matches!(app.body(), Body::Sheep(_)),
"setup: the pane opened on alpha"
);
app.update(Msg::Bleats {
tail: Tail {
lines: vec![TailLine {
stream: Stream::Out,
text: "alpha wrote this".to_string(),
}],
missed_lines: 0,
missed_bytes: 0,
read_bytes: 0,
note: None,
},
});
let _ = app.update(Msg::Snapshot {
rows: vec![ProcessInfo::builder(2, "bravo", ProcStatus::Online).build()],
at: std::time::Instant::now(),
});
assert_eq!(
app.selected(),
Some(RowKey::Sheep(2)),
"setup: the reseat moved the selection to bravo"
);
let Body::Sheep(pane) = app.body() else {
panic!("the pane is still open");
};
let area = Rect::new(0, 0, FEED_X + FEED_WIDTH, MIN_HEIGHT_FOR_COLUMN);
let mut buffer = Buffer::empty(area);
draw(&app, pane, area, &mut buffer);
let text = render_text(&buffer);
assert!(
text.contains("alpha wrote this"),
"the pinned sheep's own line must still draw: {text:?}"
);
}
#[test]
fn the_headers_level_chip_names_the_minimum() {
let mut feed = BleatsPane::new(RowKey::Sheep(1));
feed.set_min_level(Some(Level::Warn));
let text = feed_header_text(&feed, 0);
assert!(text.contains("[level≥warn]"), "got {text:?}");
assert!(text.contains("/ narrow"), "got {text:?}");
}
#[test]
fn the_headers_match_chip_names_a_regex() {
let mut feed = BleatsPane::new(RowKey::Sheep(1));
feed.set_match("/po+l/".to_string());
let text = feed_header_text(&feed, 0);
assert!(text.contains("match /po+l/ (regex)"), "got {text:?}");
}
#[test]
fn the_headers_match_chip_carries_nothing_after_a_literal() {
let mut feed = BleatsPane::new(RowKey::Sheep(1));
feed.set_match("pool".to_string());
let text = feed_header_text(&feed, 0);
assert!(text.contains("[match pool]"), "got {text:?}");
}
#[test]
fn the_headers_match_chip_names_an_invalid_regex() {
let mut feed = BleatsPane::new(RowKey::Sheep(1));
feed.set_match("/pool(/".to_string());
let text = feed_header_text(&feed, 0);
assert!(
text.contains("invalid regex, matches nothing"),
"got {text:?}"
);
}
#[test]
fn the_headers_earlier_clause_counts_hidden_survivors_not_raw_lines() {
let feed = BleatsPane::new(RowKey::Sheep(1));
let tail = Tail {
lines: (0..COLUMN_BODY_ROWS + 1)
.map(|n| TailLine {
stream: Stream::Out,
text: format!("line-{n}"),
})
.collect(),
missed_lines: 0,
missed_bytes: 0,
read_bytes: 0,
note: None,
};
let hidden = feed
.visible(&tail.lines, &Classifier::new(&[]))
.len()
.saturating_sub(COLUMN_BODY_ROWS);
let text = feed_header_text(&feed, hidden);
assert!(text.contains("1 earlier"), "got {text:?}");
}
#[test]
fn the_feed_shows_the_newest_lines_and_truncates_a_long_one() {
let mut app = fixtures::app_with(
vec![ProcessInfo::builder(1, "alpha", ProcStatus::Online).build()],
fixtures::plain(),
);
let _ = app.update(Msg::Key(KeyPress::Confirm));
app.update(Msg::Bleats {
tail: Tail {
lines: (0..COLUMN_BODY_ROWS + 5)
.map(|n| TailLine {
stream: Stream::Out,
text: format!("line-{n}"),
})
.chain(std::iter::once(TailLine {
stream: Stream::Err,
text: "x".repeat(200),
}))
.collect(),
missed_lines: 0,
missed_bytes: 0,
read_bytes: 0,
note: None,
},
});
let Body::Sheep(pane) = app.body() else {
panic!("the pane is still open");
};
let area = Rect::new(0, 0, FEED_X + FEED_WIDTH, MIN_HEIGHT_FOR_COLUMN);
let mut buffer = Buffer::empty(area);
draw(&app, pane, area, &mut buffer);
let text = render_text(&buffer);
assert!(
!text.contains("line-0\n") && !text.contains("line-0 "),
"the oldest lines scroll off the top: {text:?}"
);
assert!(
text.contains(&format!("{}\u{2026}", "x".repeat(77))),
"the long line truncates with an ellipsis at the column's own \
width (78 cells: 77 characters plus the ellipsis), not before \
it and not after: {text:?}"
);
assert!(
!text.contains(&"x".repeat(78)),
"and no further: a truncated line, not a wrapped one: {text:?}"
);
}
}