use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use super::super::app::{App, RowKey};
use super::super::pane_bleats::{BleatsPane, Filters, MatchKind};
use super::super::tail::{FEED_WINDOW_BYTES, Stream, TailLine};
use super::cell;
use super::flock::fit;
use crate::output::human_bytes;
use crate::output::width::char_columns;
use crate::vocabulary::Role;
const TAG_PREFIX_WIDTH: u16 = 5;
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 row_height(text: &str, text_width: u16, wrap: bool) -> usize {
if !wrap || text_width == 0 {
return 1;
}
let width = usize::from(text_width);
let mut rows = 1usize;
let mut col = 0usize;
for c in text.chars() {
let w = char_columns(c);
if col > 0 && col + w > width {
rows += 1;
col = 0;
}
col += w;
}
rows
}
fn tail_window_start(len: usize, body_rows: usize, cost: impl Fn(usize) -> usize) -> usize {
let mut base = len;
let mut used = 0usize;
while base > 0 {
let row = cost(base - 1);
if used > 0 && used + row > body_rows {
break;
}
base -= 1;
used += row;
}
base
}
fn window_range(
len: usize,
scroll_offset: usize,
body_rows: usize,
row_height: impl Fn(usize) -> usize,
) -> std::ops::Range<usize> {
if len == 0 || body_rows == 0 {
return 0..0;
}
let base = tail_window_start(len, body_rows, &row_height);
let skip = base.saturating_sub(scroll_offset);
let mut end = skip;
let mut used = 0usize;
while end < len {
let cost = row_height(end);
if used > 0 && used + cost > body_rows {
break;
}
end += 1;
used += cost;
}
skip..end
}
#[must_use]
pub(crate) fn max_scroll_offset(app: &App, pane: &BleatsPane) -> usize {
let survivors = pane.visible(&app.feed().lines, &app.feed_classifier());
let body_rows = pane.body_rows();
if body_rows == 0 || survivors.is_empty() {
return 0;
}
let text_width = pane.width().saturating_sub(TAG_PREFIX_WIDTH);
let wrap = pane.wrapped() && pane.width() > 0;
let cost = |index: usize| row_height(&survivors[index].text, text_width, wrap);
tail_window_start(survivors.len(), body_rows, cost)
}
fn page_lines_back(start: usize, body_rows: usize, cost: impl Fn(usize) -> usize) -> usize {
let mut used = 0usize;
let mut count = 0usize;
let mut at = start;
while at > 0 {
let row = cost(at - 1);
if count > 0 && used + row > body_rows {
break;
}
at -= 1;
used += row;
count += 1;
}
count.max(1)
}
fn page_lines_forward(shown: &std::ops::Range<usize>) -> usize {
shown.len().max(1)
}
#[must_use]
pub(crate) fn page_amount_up(app: &App, pane: &BleatsPane) -> usize {
let body_rows = pane.body_rows();
if !pane.wrapped() || pane.width() == 0 {
return body_rows.max(1);
}
let survivors = pane.visible(&app.feed().lines, &app.feed_classifier());
let text_width = pane.width().saturating_sub(TAG_PREFIX_WIDTH);
let cost = |index: usize| row_height(&survivors[index].text, text_width, true);
let shown = window_range(survivors.len(), pane.scroll_offset(), body_rows, cost);
page_lines_back(shown.start, body_rows, cost)
}
#[must_use]
pub(crate) fn page_amount_down(app: &App, pane: &BleatsPane) -> usize {
let body_rows = pane.body_rows();
if !pane.wrapped() || pane.width() == 0 {
return body_rows.max(1);
}
let survivors = pane.visible(&app.feed().lines, &app.feed_classifier());
let text_width = pane.width().saturating_sub(TAG_PREFIX_WIDTH);
let shown = window_range(survivors.len(), pane.scroll_offset(), body_rows, |index| {
row_height(&survivors[index].text, text_width, true)
});
page_lines_forward(&shown)
}
fn feed_line_rows(
app: &App,
filters: &Filters,
line: &TailLine,
width: u16,
wrap: bool,
) -> Vec<Line<'static>> {
let palette = app.palette();
let tag = match line.stream {
Stream::Out => "out",
Stream::Err => "err",
};
let prefix = format!("{tag} ");
let text_width = width.saturating_sub(TAG_PREFIX_WIDTH);
if !wrap {
let text = fit(&line.text, text_width);
let mut spans = vec![Span::styled(prefix, palette.muted())];
spans.extend(highlighted(&text, filters, palette.band(Role::Butter)));
return vec![Line::from(spans)];
}
let spans = highlighted(&line.text, filters, palette.band(Role::Butter));
let indent = " ".repeat(usize::from(TAG_PREFIX_WIDTH));
wrap_spans(spans, usize::from(text_width))
.into_iter()
.enumerate()
.map(|(i, row_spans)| {
let lead = if i == 0 { &prefix } else { &indent };
let mut all = vec![Span::styled(lead.clone(), palette.muted())];
all.extend(row_spans);
Line::from(all)
})
.collect()
}
fn wrap_spans(spans: Vec<Span<'static>>, width: usize) -> Vec<Vec<Span<'static>>> {
if width == 0 {
return vec![spans];
}
let mut rows: Vec<Vec<Span<'static>>> = vec![Vec::new()];
let mut col = 0usize;
for span in spans {
let style = span.style;
let mut buf = String::new();
for c in span.content.chars() {
let w = char_columns(c);
if col > 0 && col + w > width {
if !buf.is_empty() {
rows.last_mut()
.expect("rows always has a current row")
.push(Span::styled(std::mem::take(&mut buf), style));
}
rows.push(Vec::new());
col = 0;
}
buf.push(c);
col += w;
}
if !buf.is_empty() {
rows.last_mut()
.expect("rows always has a current row")
.push(Span::styled(buf, style));
}
}
rows
}
fn highlighted(text: &str, filters: &Filters, highlight: Style) -> Vec<Span<'static>> {
let ranges = filters.match_ranges(text);
if ranges.is_empty() {
return vec![Span::raw(text.to_string())];
}
let mut spans = Vec::with_capacity(ranges.len() * 2 + 1);
let mut cursor = 0;
for (start, end) in ranges {
if start > cursor {
spans.push(Span::raw(text[cursor..start].to_string()));
}
spans.push(Span::styled(text[start..end].to_string(), highlight));
cursor = end;
}
if cursor < text.len() {
spans.push(Span::raw(text[cursor..].to_string()));
}
spans
}
fn filter_row_line(
app: &App,
filters: &Filters,
survivors: usize,
total: usize,
width: u16,
) -> Option<Line<'static>> {
if filters.is_empty() {
return None;
}
let palette = app.palette();
let ground = palette.ground();
let mut spans: Vec<Span<'static>> = Vec::new();
let mut used: u16 = 0;
for chip in chip_labels(filters) {
let text = format!(" {chip} ");
let chip_columns = text.chars().map(char_columns).sum::<usize>();
used = used.saturating_add(u16::try_from(chip_columns).unwrap_or(width));
spans.push(Span::styled(text, ground));
spans.push(Span::raw(" "));
used = used.saturating_add(1);
}
let sentence = format!(
"{} of {} lines in the window: all three must hold, a line with no \
detectable level always shows, esc drops the newest chip",
group_thousands(survivors),
group_thousands(total),
);
spans.push(Span::styled(
fit(&sentence, width.saturating_sub(used)),
palette.muted(),
));
Some(Line::from(spans))
}
fn chip_labels(filters: &Filters) -> Vec<String> {
let mut chips = Vec::new();
if let Some(stream) = filters.stream {
let name = match stream {
Stream::Out => "out",
Stream::Err => "err",
};
chips.push(format!("stream {name}"));
}
if let Some(min) = filters.min_level {
chips.push(format!("level ≥ {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 group_thousands(mut n: usize) -> String {
let mut groups = Vec::new();
loop {
let rest = n / 1000;
if rest == 0 {
groups.push(n.to_string());
break;
}
groups.push(format!("{:03}", n % 1000));
n = rest;
}
groups.reverse();
groups.join(",")
}
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::level::Level;
use super::super::super::pane_bleats::{MatchKind, compiles};
use super::super::super::tail::{Stream, Tail, TailLine};
use super::super::fixtures::{
bleats_pane_with_filters, full_app, render_all, rendered, 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 {
stream: Stream::Out,
text: "listening".to_string(),
}],
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_filter_row_says_the_axes_compose_with_and() {
let app = bleats_pane_with_filters();
let text = render_all(&draw_lines(&app, 160, 40));
assert!(text.contains("all three must hold"), "got {text}");
}
#[test]
fn the_survivor_count_is_scoped_to_the_window() {
let app = bleats_pane_with_filters();
let text = render_all(&draw_lines(&app, 160, 40));
assert!(
text.contains("lines in the window"),
"the count must not claim a whole-file total: {text}"
);
}
#[test]
fn the_survivor_count_matches_what_visible_reports() {
let app = bleats_pane_with_filters();
let text = render_all(&draw_lines(&app, 160, 40));
assert!(text.contains("1 of 4 lines in the window"), "got {text}");
}
#[test]
fn only_the_set_axes_get_a_chip() {
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
app.update(Msg::Key(KeyPress::Bleats));
app.bleats_pane_mut_for_tests()
.expect("the key above opened the pane")
.set_stream(Some(Stream::Err));
let text = render_all(&draw_lines(&app, 160, 40));
assert!(text.contains("stream err"), "got {text}");
assert!(!text.contains("level ≥"), "got {text}");
assert!(!text.contains("match "), "got {text}");
}
#[test]
fn each_chip_reads_the_way_the_design_draws_it() {
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
app.update(Msg::Key(KeyPress::Bleats));
let pane = app
.bleats_pane_mut_for_tests()
.expect("the key above opened the pane");
pane.set_stream(Some(Stream::Err));
pane.set_min_level(Some(Level::Warn));
pane.set_match("pool".to_string());
let text = render_all(&draw_lines(&app, 160, 40));
assert!(text.contains("stream err"), "got {text}");
assert!(text.contains("level \u{2265} warn"), "got {text}");
assert!(text.contains("match pool"), "got {text}");
}
#[test]
fn a_literal_matcher_chip_carries_nothing_after_the_typed_text() {
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
app.update(Msg::Key(KeyPress::Bleats));
app.bleats_pane_mut_for_tests()
.expect("the key above opened the pane")
.set_match("pool".to_string());
let lines = draw_lines(&app, 160, 40);
let chips: Vec<&str> = lines
.iter()
.flat_map(|line| line.spans.iter())
.map(|span| span.content.as_ref())
.filter(|text| text.contains("match pool"))
.collect();
assert_eq!(chips, vec![" match pool "]);
}
#[test]
fn a_regex_matcher_chip_names_itself_a_regex() {
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
app.update(Msg::Key(KeyPress::Bleats));
app.bleats_pane_mut_for_tests()
.expect("the key above opened the pane")
.set_match("/po+l/".to_string());
let text = render_all(&draw_lines(&app, 160, 40));
assert!(text.contains("match /po+l/ (regex)"), "got {text}");
}
#[test]
fn an_invalid_regex_chip_names_itself_invalid() {
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
app.update(Msg::Key(KeyPress::Bleats));
app.bleats_pane_mut_for_tests()
.expect("the key above opened the pane")
.set_match("/pool(/".to_string());
assert_eq!(
app.bleats_pane().unwrap().filters().match_kind(),
Some(MatchKind::Invalid)
);
let text = render_all(&draw_lines(&app, 160, 40));
assert!(text.contains("invalid regex"), "got {text}");
}
#[test]
fn a_match_axis_highlights_its_hit_and_only_its_hit() {
let app = bleats_pane_with_filters();
let lines = draw_lines(&app, 160, 40);
let survivor = lines
.iter()
.find(|line| rendered(line).contains("exhausted"))
.expect("the one surviving line contains \"exhausted\"");
let hit = survivor
.spans
.iter()
.find(|span| span.content.as_ref() == "pool")
.expect("the matched text is its own span, not merged into a longer one");
assert_ne!(
hit.style,
Style::default(),
"the match must carry a style distinct from plain text"
);
let plain = survivor
.spans
.iter()
.find(|span| span.style == Style::default() && span.content.as_ref() != "pool");
assert!(
plain.is_some(),
"the rest of the line must stay unstyled, for contrast: {survivor:?}"
);
}
#[test]
fn group_thousands_pads_and_orders_its_groups() {
assert_eq!(group_thousands(0), "0");
assert_eq!(group_thousands(999), "999");
assert_eq!(group_thousands(2_847), "2,847");
assert_eq!(group_thousands(1_007), "1,007");
assert_eq!(group_thousands(12_847), "12,847");
assert_eq!(group_thousands(1_000_000), "1,000,000");
}
#[test]
fn the_filter_row_states_all_three_of_its_clauses() {
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
app.update(Msg::Key(KeyPress::Bleats));
app.bleats_pane_mut_for_tests()
.expect("open")
.set_min_level(Some(Level::Warn));
let text = render_all(&draw_lines(&app, 200, 40));
assert!(text.contains("all three must hold"), "got {text}");
assert!(
text.contains("no detectable level always shows"),
"got {text}"
);
assert!(text.contains("esc drops the newest chip"), "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 {
stream: Stream::Out,
text: "listening".to_string(),
}],
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}"
);
}
#[test]
fn row_height_and_the_rendered_rows_agree_on_a_wide_line() {
let wide = "\u{5e83}".repeat(10);
assert_eq!(row_height(&wide, 8, true), 3, "counted by columns");
assert_eq!(
row_height(&wide, 8, true),
wrap_spans(vec![Span::raw(wide.clone())], 8).len(),
"the counter and the renderer agree"
);
assert_eq!(row_height(&wide, 8, false), 1);
}
#[test]
fn neither_page_direction_steps_over_a_line() {
let profile = |seed: usize, index: usize| -> usize {
1 + (seed.wrapping_mul(31).wrapping_add(index.wrapping_mul(17))) % 4
};
for seed in 0..200usize {
for len in [3usize, 7, 12, 25] {
for body_rows in [2usize, 3, 5, 8] {
let cost = |index: usize| profile(seed, index);
for offset in 0..len {
let shown = window_range(len, offset, body_rows, cost);
if shown.is_empty() {
continue;
}
let back = page_lines_back(shown.start, body_rows, cost);
let up = window_range(len, offset + back, body_rows, cost);
assert!(
up.end + 1 >= shown.start,
"seed {seed} len {len} rows {body_rows} offset {offset}: \
ctrl-u went from {shown:?} to {up:?}"
);
let forward = page_lines_forward(&shown);
let down =
window_range(len, offset.saturating_sub(forward), body_rows, cost);
assert!(
down.start <= shown.end,
"seed {seed} len {len} rows {body_rows} offset {offset}: \
ctrl-d went from {shown:?} to {down:?}"
);
}
}
}
}
}
#[test]
fn a_redraw_compiles_the_match_axis_no_times() {
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
app.update(Msg::Bleats {
tail: Tail {
lines: (0..80)
.map(|i| TailLine {
stream: if i % 2 == 0 { Stream::Out } else { Stream::Err },
text: format!("worker[{i}] pool acquire ok user=ops{i}@example.com"),
})
.collect(),
missed_lines: 0,
missed_bytes: 0,
read_bytes: 65_536,
note: None,
},
});
app.update(Msg::Key(KeyPress::Bleats));
app.bleats_pane_mut_for_tests()
.expect("the key above opened the pane")
.set_match(r"/\w+@\w+\.\w+/".to_string());
let before = compiles();
let drawn_rows = draw_lines(&app, 160, 50);
assert_eq!(compiles(), before, "a redraw compiles nothing");
let body: Vec<_> = drawn_rows
.iter()
.filter(|line| render_all(std::slice::from_ref(*line)).contains("@example.com"))
.collect();
assert_eq!(body.len(), 48, "the feed filled every body row");
assert!(
body.iter().all(|line| line.spans.len() > 2),
"every body row split its hit into its own span"
);
}
}