use std::collections::HashMap;
use tastty::{Attrs, Color};
use crate::snapshot::{CellSnapshot, Snapshot};
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Hash)]
pub enum RowRange {
#[default]
All,
Range(u16, u16),
From(u16),
UpTo(u16),
}
impl RowRange {
#[must_use]
pub const fn all() -> Self {
Self::All
}
#[must_use]
pub const fn new(start: u16, end: u16) -> Self {
Self::Range(start, end)
}
#[must_use]
pub const fn from(start: u16) -> Self {
Self::From(start)
}
#[must_use]
pub const fn up_to(end: u16) -> Self {
Self::UpTo(end)
}
#[must_use]
pub const fn single(row: u16) -> Self {
Self::Range(row, row)
}
}
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
#[non_exhaustive]
pub enum ColorFilter {
Default,
NonDefault,
Named(String),
Index(u8),
Rgb {
r: u8,
g: u8,
b: u8,
},
}
impl ColorFilter {
fn matches(&self, color: Color) -> bool {
match self {
ColorFilter::Default => color.is_default(),
ColorFilter::NonDefault => !color.is_default(),
ColorFilter::Named(name) => match color.as_index() {
Some(idx) => name_matches_index(name, idx),
None => false,
},
ColorFilter::Index(value) => color.as_index() == Some(*value),
ColorFilter::Rgb { r, g, b } => color.as_rgb() == Some((*r, *g, *b)),
}
}
}
fn name_matches_index(name: &str, idx: u8) -> bool {
match ansi_name_indices(name) {
Some((primary, bright)) => idx == primary || bright == Some(idx),
None => false,
}
}
fn ansi_name_indices(name: &str) -> Option<(u8, Option<u8>)> {
match name {
"black" => Some((0, Some(8))),
"red" => Some((1, Some(9))),
"green" => Some((2, Some(10))),
"yellow" => Some((3, Some(11))),
"blue" => Some((4, Some(12))),
"magenta" => Some((5, Some(13))),
"cyan" => Some((6, Some(14))),
"white" => Some((7, Some(15))),
"bright_black" => Some((8, None)),
"bright_red" => Some((9, None)),
"bright_green" => Some((10, None)),
"bright_yellow" => Some((11, None)),
"bright_blue" => Some((12, None)),
"bright_magenta" => Some((13, None)),
"bright_cyan" => Some((14, None)),
"bright_white" => Some((15, None)),
_ => None,
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
#[non_exhaustive]
pub struct StyleFilter {
pub fg: Option<ColorFilter>,
pub bg: Option<ColorFilter>,
pub bold: Option<bool>,
pub dim: Option<bool>,
pub italic: Option<bool>,
pub underline: Option<bool>,
pub inverse: Option<bool>,
pub negate: bool,
}
impl StyleFilter {
#[must_use]
pub fn matches(&self, attrs: &Attrs) -> bool {
let positive = self.matches_positive(attrs);
if self.negate { !positive } else { positive }
}
fn matches_positive(&self, attrs: &Attrs) -> bool {
if let Some(ref fg) = self.fg
&& !fg.matches(attrs.fg_color)
{
return false;
}
if let Some(ref bg) = self.bg
&& !bg.matches(attrs.bg_color)
{
return false;
}
if let Some(bold) = self.bold
&& attrs.bold() != bold
{
return false;
}
if let Some(dim) = self.dim
&& attrs.dim() != dim
{
return false;
}
if let Some(italic) = self.italic
&& attrs.italic() != italic
{
return false;
}
if let Some(underline) = self.underline
&& attrs.underline() != underline
{
return false;
}
if let Some(inverse) = self.inverse
&& attrs.inverse() != inverse
{
return false;
}
true
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
#[non_exhaustive]
pub struct StyleInventory {
pub entries: Vec<StyleInventoryEntry>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct StyleInventoryEntry {
pub attrs: Attrs,
pub cell_count: u32,
pub row_count: u16,
pub rows: Vec<u16>,
pub sample: String,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
#[non_exhaustive]
pub struct StyleMatches {
pub match_count: u32,
pub rows: Vec<StyleMatchRow>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct StyleMatchRow {
pub row: u16,
pub spans: Vec<StyleMatchSpan>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct StyleMatchSpan {
pub col: u16,
pub end_col: u16,
pub text: String,
pub attrs: Attrs,
}
impl Snapshot {
#[must_use]
pub fn styles(&self, range: RowRange) -> StyleInventory {
let Some((start, end)) = clamp_range(range, self.size().rows) else {
return StyleInventory::default();
};
let mut map: HashMap<Attrs, Accumulator> = HashMap::new();
for row in start..=end {
for (_col, cell) in row_cells(self, row) {
let nonempty = is_nonempty_cell(&cell.contents);
let acc = map.entry(cell.attrs).or_default();
if nonempty {
acc.cell_count += 1;
}
if acc.rows.last() != Some(&row) {
acc.rows.push(row);
}
if !acc.sample_found {
if nonempty {
acc.sample.push_str(&cell.contents);
} else if !acc.sample.is_empty() {
acc.sample_found = true;
}
}
}
for acc in map.values_mut() {
if !acc.sample.is_empty() && !acc.sample_found {
acc.sample_found = true;
}
}
}
for acc in map.values_mut() {
if acc.sample.chars().count() > SAMPLE_CHAR_LIMIT {
acc.sample = acc.sample.chars().take(SAMPLE_CHAR_LIMIT).collect();
}
}
let mut entries: Vec<StyleInventoryEntry> = map
.into_iter()
.map(|(attrs, acc)| StyleInventoryEntry {
attrs,
cell_count: acc.cell_count,
row_count: acc.rows.len() as u16,
rows: acc.rows,
sample: acc.sample,
})
.collect();
let default_attrs = Attrs::default();
entries.sort_by(|a, b| {
let a_default = a.attrs == default_attrs;
let b_default = b.attrs == default_attrs;
match (a_default, b_default) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => b.cell_count.cmp(&a.cell_count),
}
});
StyleInventory { entries }
}
#[must_use]
pub fn style_matches(&self, filter: &StyleFilter, range: RowRange) -> StyleMatches {
let Some((start, end)) = clamp_range(range, self.size().rows) else {
return StyleMatches::default();
};
let mut match_count: u32 = 0;
let mut rows: Vec<StyleMatchRow> = Vec::new();
for row in start..=end {
let mut spans: Vec<StyleMatchSpan> = Vec::new();
let mut current: Option<StyleMatchSpan> = None;
for (col, cell) in row_cells(self, row) {
if filter.matches(&cell.attrs) {
match_count += 1;
match current.as_mut() {
Some(span) if span.attrs == cell.attrs && col == span.end_col + 1 => {
span.end_col = col;
span.text.push_str(&cell.contents);
}
_ => {
if let Some(span) = current.take() {
spans.push(span);
}
current = Some(StyleMatchSpan {
col,
end_col: col,
text: cell.contents.clone(),
attrs: cell.attrs,
});
}
}
} else if let Some(span) = current.take() {
spans.push(span);
}
}
if let Some(span) = current.take() {
spans.push(span);
}
if !spans.is_empty() {
rows.push(StyleMatchRow { row, spans });
}
}
StyleMatches { match_count, rows }
}
}
const SAMPLE_CHAR_LIMIT: usize = 60;
#[derive(Default)]
struct Accumulator {
cell_count: u32,
rows: Vec<u16>,
sample: String,
sample_found: bool,
}
fn clamp_range(range: RowRange, rows: u16) -> Option<(u16, u16)> {
if rows == 0 {
return None;
}
let last = rows - 1;
let (start, end) = match range {
RowRange::All => (0, last),
RowRange::Range(start, end) => (start, end),
RowRange::From(start) => (start, last),
RowRange::UpTo(end) => (0, end),
};
if start > last {
return None;
}
Some((start, end.min(last)))
}
fn row_cells(snapshot: &Snapshot, row: u16) -> impl Iterator<Item = (u16, &CellSnapshot)> {
snapshot
.cells()
.get(row as usize)
.into_iter()
.flat_map(|cells| cells.iter().enumerate())
.map(|(col, cell)| (col as u16, cell))
.filter(|(_, cell)| !cell.wide_continuation)
}
fn is_nonempty_cell(contents: &str) -> bool {
!contents.is_empty() && contents != " "
}
#[cfg(test)]
mod tests {
use super::*;
use crate::snapshot::CellSnapshot;
use tastty::{Position, TerminalSize};
fn make_cell(_row: u16, _col: u16, contents: &str, attrs: Attrs) -> CellSnapshot {
CellSnapshot {
contents: contents.to_string(),
attrs,
wide: false,
wide_continuation: false,
hyperlink: None,
}
}
fn make_wide(row: u16, col: u16, contents: &str, attrs: Attrs) -> [CellSnapshot; 2] {
let mut head = make_cell(row, col, contents, attrs);
head.wide = true;
let mut tail = make_cell(row, col + 1, "", attrs);
tail.wide_continuation = true;
[head, tail]
}
fn make_snapshot(rows: u16, cols: u16, cells: Vec<Vec<CellSnapshot>>) -> Snapshot {
Snapshot {
size: TerminalSize { rows, cols },
cursor: Position { row: 0, col: 0 },
cursor_visible: true,
alternate_screen: false,
title: String::new(),
icon_name: String::new(),
cells,
}
}
fn pad_row(cells: Vec<CellSnapshot>, row: u16, cols: u16) -> Vec<CellSnapshot> {
let mut out = cells;
let next_col = out.len() as u16;
let default_attrs = Attrs::default();
for col in next_col..cols {
out.push(make_cell(row, col, " ", default_attrs));
}
out
}
fn bold() -> Attrs {
let mut a = Attrs::default();
a.set_bold();
a
}
fn italic() -> Attrs {
let mut a = Attrs::default();
a.set_italic(true);
a
}
#[test]
fn empty_snapshot_yields_empty_inventory() {
let snapshot = make_snapshot(0, 0, Vec::new());
let inv = snapshot.styles(RowRange::all());
assert!(inv.entries.is_empty());
}
#[test]
fn single_style_inventory_counts_only_nonempty_cells() {
let row0: Vec<CellSnapshot> = vec![
make_cell(0, 0, "h", bold()),
make_cell(0, 1, "i", bold()),
make_cell(0, 2, " ", bold()),
make_cell(0, 3, "x", bold()),
make_cell(0, 4, "y", bold()),
];
let snapshot = make_snapshot(1, 5, vec![row0]);
let inv = snapshot.styles(RowRange::all());
assert_eq!(inv.entries.len(), 1);
let entry = &inv.entries[0];
assert_eq!(entry.attrs, bold());
assert_eq!(entry.cell_count, 4);
assert_eq!(entry.row_count, 1);
assert_eq!(entry.rows, vec![0]);
assert_eq!(entry.sample, "hi");
}
#[test]
fn multiple_styles_default_first_then_descending_count() {
let row0 = pad_row(
vec![
make_cell(0, 0, "x", bold()),
make_cell(0, 1, "a", Attrs::default()),
make_cell(0, 2, "b", Attrs::default()),
make_cell(0, 3, "c", Attrs::default()),
make_cell(0, 4, "y", italic()),
make_cell(0, 5, "z", italic()),
],
0,
8,
);
let snapshot = make_snapshot(1, 8, vec![row0]);
let inv = snapshot.styles(RowRange::all());
assert_eq!(inv.entries.len(), 3);
assert_eq!(inv.entries[0].attrs, Attrs::default());
assert_eq!(inv.entries[0].cell_count, 3);
assert_eq!(inv.entries[1].attrs, italic());
assert_eq!(inv.entries[1].cell_count, 2);
assert_eq!(inv.entries[2].attrs, bold());
assert_eq!(inv.entries[2].cell_count, 1);
}
#[test]
fn wide_continuation_cells_do_not_inflate_counts() {
let mut row0_cells: Vec<CellSnapshot> = Vec::new();
let wide_pair = make_wide(0, 0, "W", bold());
row0_cells.push(wide_pair[0].clone());
row0_cells.push(wide_pair[1].clone());
row0_cells.push(make_cell(0, 2, "x", bold()));
let row0 = pad_row(row0_cells, 0, 5);
let snapshot = make_snapshot(1, 5, vec![row0]);
let inv = snapshot.styles(RowRange::all());
let bold_entry = inv
.entries
.iter()
.find(|e| e.attrs == bold())
.expect("bold entry");
assert_eq!(bold_entry.cell_count, 2);
assert_eq!(bold_entry.sample, "Wx");
}
#[test]
fn row_range_constructors_map_to_variants() {
assert_eq!(RowRange::all(), RowRange::All);
assert_eq!(RowRange::new(2, 5), RowRange::Range(2, 5));
assert_eq!(RowRange::from(3), RowRange::From(3));
assert_eq!(RowRange::up_to(8), RowRange::UpTo(8));
assert_eq!(RowRange::single(7), RowRange::Range(7, 7));
assert_eq!(RowRange::default(), RowRange::All);
}
#[test]
fn row_range_from_clamps_end_to_last_row() {
let row0 = pad_row(vec![make_cell(0, 0, "a", bold())], 0, 3);
let row1 = pad_row(vec![make_cell(1, 0, "b", italic())], 1, 3);
let snapshot = make_snapshot(2, 3, vec![row0, row1]);
let inv = snapshot.styles(RowRange::from(1));
let italic_entry = inv.entries.iter().find(|e| e.attrs == italic()).unwrap();
assert_eq!(italic_entry.cell_count, 1);
assert!(inv.entries.iter().all(|e| e.attrs != bold()));
}
#[test]
fn row_range_up_to_caps_iteration_at_end() {
let row0 = pad_row(vec![make_cell(0, 0, "a", bold())], 0, 3);
let row1 = pad_row(vec![make_cell(1, 0, "b", italic())], 1, 3);
let snapshot = make_snapshot(2, 3, vec![row0, row1]);
let inv = snapshot.styles(RowRange::up_to(0));
let bold_entry = inv.entries.iter().find(|e| e.attrs == bold()).unwrap();
assert_eq!(bold_entry.cell_count, 1);
assert!(inv.entries.iter().all(|e| e.attrs != italic()));
}
#[test]
fn row_range_from_past_end_yields_empty() {
let row0 = pad_row(vec![make_cell(0, 0, "a", bold())], 0, 3);
let snapshot = make_snapshot(1, 3, vec![row0]);
let inv = snapshot.styles(RowRange::from(5));
assert!(inv.entries.is_empty());
}
#[test]
fn row_range_all_variant_covers_every_row() {
let row0 = pad_row(vec![make_cell(0, 0, "a", bold())], 0, 3);
let row1 = pad_row(vec![make_cell(1, 0, "b", italic())], 1, 3);
let snapshot = make_snapshot(2, 3, vec![row0, row1]);
let inv = snapshot.styles(RowRange::All);
let bold_entry = inv.entries.iter().find(|e| e.attrs == bold()).unwrap();
let italic_entry = inv.entries.iter().find(|e| e.attrs == italic()).unwrap();
assert_eq!(bold_entry.cell_count, 1);
assert_eq!(italic_entry.cell_count, 1);
}
#[test]
fn row_range_clamps_end_past_screen() {
let row0 = pad_row(vec![make_cell(0, 0, "a", bold())], 0, 3);
let row1 = pad_row(vec![make_cell(1, 0, "b", italic())], 1, 3);
let snapshot = make_snapshot(2, 3, vec![row0, row1]);
let inv = snapshot.styles(RowRange::new(0, 99));
let bold_entry = inv.entries.iter().find(|e| e.attrs == bold()).unwrap();
let italic_entry = inv.entries.iter().find(|e| e.attrs == italic()).unwrap();
assert_eq!(bold_entry.cell_count, 1);
assert_eq!(italic_entry.cell_count, 1);
}
#[test]
fn row_range_start_past_screen_yields_empty() {
let row0 = pad_row(vec![make_cell(0, 0, "a", bold())], 0, 3);
let snapshot = make_snapshot(1, 3, vec![row0]);
let inv = snapshot.styles(RowRange::new(5, 10));
assert!(inv.entries.is_empty());
}
#[test]
fn row_range_single_isolates_one_row() {
let row0 = pad_row(vec![make_cell(0, 0, "a", bold())], 0, 3);
let row1 = pad_row(vec![make_cell(1, 0, "b", italic())], 1, 3);
let snapshot = make_snapshot(2, 3, vec![row0, row1]);
let inv = snapshot.styles(RowRange::single(1));
let only = inv
.entries
.iter()
.find(|e| e.attrs == italic())
.expect("italic entry from row 1");
assert_eq!(only.cell_count, 1);
assert!(inv.entries.iter().all(|e| e.attrs != bold()));
}
#[test]
fn style_matches_coalesces_adjacent_cells() {
let row0 = pad_row(
vec![
make_cell(0, 0, "a", bold()),
make_cell(0, 1, "b", bold()),
make_cell(0, 2, "c", Attrs::default()),
make_cell(0, 3, "d", bold()),
],
0,
5,
);
let snapshot = make_snapshot(1, 5, vec![row0]);
let filter = StyleFilter {
bold: Some(true),
..StyleFilter::default()
};
let matches = snapshot.style_matches(&filter, RowRange::all());
assert_eq!(matches.match_count, 3);
assert_eq!(matches.rows.len(), 1);
let spans = &matches.rows[0].spans;
assert_eq!(spans.len(), 2);
assert_eq!(spans[0].col, 0);
assert_eq!(spans[0].end_col, 1);
assert_eq!(spans[0].text, "ab");
assert_eq!(spans[1].col, 3);
assert_eq!(spans[1].end_col, 3);
assert_eq!(spans[1].text, "d");
}
#[test]
fn style_matches_filter_negate_inverts() {
let row0 = pad_row(
vec![
make_cell(0, 0, "a", bold()),
make_cell(0, 1, "b", Attrs::default()),
],
0,
3,
);
let snapshot = make_snapshot(1, 3, vec![row0]);
let filter = StyleFilter {
bold: Some(true),
negate: true,
..StyleFilter::default()
};
let matches = snapshot.style_matches(&filter, RowRange::all());
assert!(matches.match_count >= 1);
let texts: String = matches
.rows
.iter()
.flat_map(|r| r.spans.iter())
.map(|s| s.text.clone())
.collect();
assert!(texts.contains('b'));
assert!(!texts.contains('a'));
}
#[test]
fn color_filter_named_red_matches_both_normal_and_bright_palette() {
let mut red = Attrs::default();
red.fg_color = Color::Index(1);
let mut bright_red = Attrs::default();
bright_red.fg_color = Color::Index(9);
let mut blue = Attrs::default();
blue.fg_color = Color::Index(4);
let filter = StyleFilter {
fg: Some(ColorFilter::Named("red".to_string())),
..StyleFilter::default()
};
assert!(filter.matches(&red));
assert!(filter.matches(&bright_red));
assert!(!filter.matches(&blue));
}
#[test]
fn color_filter_bright_named_only_matches_bright_palette() {
let mut red = Attrs::default();
red.fg_color = Color::Index(1);
let mut bright_red = Attrs::default();
bright_red.fg_color = Color::Index(9);
let filter = StyleFilter {
fg: Some(ColorFilter::Named("bright_red".to_string())),
..StyleFilter::default()
};
assert!(!filter.matches(&red));
assert!(filter.matches(&bright_red));
}
#[test]
fn color_filter_default_and_non_default_are_complementary() {
let plain = Attrs::default();
let mut colored = Attrs::default();
colored.fg_color = Color::Index(1);
let default_filter = StyleFilter {
fg: Some(ColorFilter::Default),
..StyleFilter::default()
};
let non_default_filter = StyleFilter {
fg: Some(ColorFilter::NonDefault),
..StyleFilter::default()
};
assert!(default_filter.matches(&plain));
assert!(!default_filter.matches(&colored));
assert!(!non_default_filter.matches(&plain));
assert!(non_default_filter.matches(&colored));
}
#[test]
fn color_filter_rgb_matches_exact_channels() {
let mut attrs = Attrs::default();
attrs.fg_color = Color::Rgb(255, 0, 0);
let filter = StyleFilter {
fg: Some(ColorFilter::Rgb { r: 255, g: 0, b: 0 }),
..StyleFilter::default()
};
assert!(filter.matches(&attrs));
let other = StyleFilter {
fg: Some(ColorFilter::Rgb { r: 0, g: 255, b: 0 }),
..StyleFilter::default()
};
assert!(!other.matches(&attrs));
}
#[test]
fn empty_filter_matches_every_cell() {
let filter = StyleFilter::default();
assert!(filter.matches(&Attrs::default()));
assert!(filter.matches(&bold()));
}
#[test]
fn empty_negated_filter_rejects_every_cell() {
let filter = StyleFilter {
negate: true,
..StyleFilter::default()
};
assert!(!filter.matches(&Attrs::default()));
assert!(!filter.matches(&bold()));
}
#[test]
fn snapshot_styles_through_parser_does_not_double_count_wide_cells() {
use crate::snapshot::snapshot_from_screen;
use tastty::{Parser, TerminalSize};
let mut parser = Parser::new(TerminalSize { rows: 1, cols: 6 }, 0);
parser.process(b"\x1b[1mAB\xe3\x81\x82");
let snapshot = snapshot_from_screen(parser.screen());
let inv = snapshot.styles(RowRange::all());
let bold_entry = inv
.entries
.iter()
.find(|e| e.attrs.bold())
.expect("bold entry from styled run");
assert_eq!(
bold_entry.cell_count, 3,
"wide-glyph continuation must not inflate cell_count: \
expected 3 (A + B + wide head), got {}",
bold_entry.cell_count,
);
}
}