use rich::cells::cell_len;
use rich::{Console, ConsoleOptions, Renderable, Segment, Table, Text};
use serde::{Deserialize, Serialize};
use super::{
content_chars, is_ellipsis, max_width, measure_shortfall, plain_lines, plural, table_then_line,
visible_width, Probe,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StressOptions {
pub widths: Vec<usize>,
pub heights: Vec<Option<usize>>,
pub unicode: bool,
pub line_tolerance: usize,
pub strict_minimum: bool,
}
impl Default for StressOptions {
fn default() -> Self {
StressOptions {
widths: vec![1, 2, 3, 4, 10, 20, 40, 80, 120, 200],
heights: vec![None, Some(5), Some(24)],
unicode: true,
line_tolerance: 1,
strict_minimum: false,
}
}
}
impl StressOptions {
pub fn widths(widths: impl Into<Vec<usize>>) -> Self {
StressOptions {
widths: widths.into(),
heights: vec![None],
..StressOptions::default()
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IssueKind {
Overflow,
Clipping,
UnstableWrapping,
Panic,
MeasureMismatch,
}
impl IssueKind {
pub fn name(self) -> &'static str {
match self {
IssueKind::Overflow => "overflow",
IssueKind::Clipping => "clipping",
IssueKind::UnstableWrapping => "unstable wrapping",
IssueKind::Panic => "panic",
IssueKind::MeasureMismatch => "measure mismatch",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Issue {
pub width: usize,
pub height: Option<usize>,
pub kind: IssueKind,
pub detail: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct StressReport {
pub renders: usize,
pub issues: Vec<Issue>,
}
impl StressReport {
pub fn is_clean(&self) -> bool {
self.issues.is_empty()
}
pub fn of(&self, kind: IssueKind) -> impl Iterator<Item = &Issue> {
self.issues.iter().filter(move |i| i.kind == kind)
}
}
impl Renderable for StressReport {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let table = (!self.issues.is_empty()).then(|| {
let mut table = Table::new();
for header in ["Width", "Height", "Issue", "Detail"] {
table.add_column(header);
}
for issue in &self.issues {
table.add_row_text(vec![
Text::new(issue.width.to_string()),
Text::new(issue.height.map_or("-".to_owned(), |h| h.to_string())),
Text::new(issue.kind.name()),
Text::new(issue.detail.clone()),
]);
}
table
});
let summary = format!(
"{}, {}",
plural(self.renders, "render"),
plural(self.issues.len(), "issue")
);
table_then_line(table, summary, console, options)
}
}
pub(crate) struct Sample {
pub width: usize,
pub lines: Vec<String>,
}
fn counts(chars: &[char]) -> std::collections::BTreeMap<char, usize> {
let mut map = std::collections::BTreeMap::new();
for &c in chars {
*map.entry(c).or_insert(0) += 1;
}
map
}
pub(crate) fn lost_chars(reference: &[String], lines: &[String], ascii: bool) -> String {
let have = counts(&content_chars(lines, ascii));
let mut lost = String::new();
for (c, n) in counts(&content_chars(reference, ascii)) {
let missing = n.saturating_sub(have.get(&c).copied().unwrap_or(0));
for _ in 0..missing {
lost.push(c);
}
}
lost
}
pub fn stress(renderable: &dyn Renderable, options: &StressOptions) -> StressReport {
let mut report = StressReport::default();
let mut widths = options.widths.clone();
widths.sort_unstable();
widths.dedup();
let ascii = !options.unicode;
for &height in &options.heights {
let mut samples: Vec<Sample> = Vec::new();
let mut minimum = 0;
for &width in &widths {
let mut probe = Probe::new(width);
probe.height = height;
probe.unicode = options.unicode;
probe.color = crate::capabilities::ColorDepth::None;
report.renders += 1;
let issue = |kind, detail: String| Issue {
width,
height,
kind,
detail,
};
let segments = match probe.try_segments(renderable) {
Ok(segments) => segments,
Err(message) => {
report.issues.push(issue(IssueKind::Panic, message));
continue;
}
};
let lines = plain_lines(&segments);
if let Some((i, line)) = lines.iter().enumerate().find(|(_, l)| cell_len(l) > width) {
report.issues.push(issue(
IssueKind::Overflow,
format!(
"line {} is {} cells wide: {:?}",
i + 1,
cell_len(line),
line.trim_end()
),
));
}
let measured = {
let console = probe.target().console();
let options = probe.options(&console);
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
renderable.measure(&console, &options)
}))
.ok()
};
if let Some(m) = measured {
if width == *widths.last().unwrap_or(&width) && m.minimum < width {
minimum = m.minimum;
}
let visible = lines.iter().map(|l| visible_width(l)).max().unwrap_or(0);
if m.minimum > m.maximum {
report.issues.push(issue(
IssueKind::MeasureMismatch,
format!("measure minimum {} > maximum {}", m.minimum, m.maximum),
));
} else if let Some(why) = (visible > m.maximum.min(width)
&& visible <= width
&& m.maximum >= 1)
.then(|| {
measure_shortfall(renderable, &probe, m.maximum.min(width), &lines, true)
})
.flatten()
{
report.issues.push(issue(IssueKind::MeasureMismatch, why));
} else if options.strict_minimum && m.minimum > width && max_width(&lines) <= width
{
report.issues.push(issue(
IssueKind::MeasureMismatch,
format!("fits in {width} cells but measure minimum is {}", m.minimum),
));
}
}
samples.push(Sample { width, lines });
}
let Some(reference) = samples.last() else {
continue;
};
for sample in &samples[..samples.len() - 1] {
if height.is_some() || sample.width < minimum {
continue;
}
let truncated = sample.lines.iter().any(|l| l.chars().any(is_ellipsis));
if truncated {
continue;
}
let lost = lost_chars(&reference.lines, &sample.lines, ascii);
if !lost.is_empty() {
report.issues.push(Issue {
width: sample.width,
height,
kind: IssueKind::Clipping,
detail: format!(
"{} lost vs width {}: {:?}",
plural(lost.chars().count(), "character"),
reference.width,
truncate_detail(&lost)
),
});
}
}
let stable: Vec<&Sample> = samples
.iter()
.filter(|s| {
s.width >= minimum
&& !s.lines.iter().any(|l| l.chars().any(is_ellipsis))
&& lost_chars(&reference.lines, &s.lines, ascii).is_empty()
})
.collect();
for pair in stable.windows(2) {
let (a, b) = (pair[0], pair[1]);
if b.lines.len() > a.lines.len() + options.line_tolerance {
report.issues.push(Issue {
width: b.width,
height,
kind: IssueKind::UnstableWrapping,
detail: format!(
"{} at width {}, {} at width {}",
plural(a.lines.len(), "line"),
a.width,
plural(b.lines.len(), "line"),
b.width
),
});
}
}
}
report
}
fn truncate_detail(s: &str) -> String {
if s.chars().count() > 40 {
let mut out: String = s.chars().take(40).collect();
out.push('…');
out
} else {
s.to_owned()
}
}