use std::ops::Range;
use ratatui::{
Frame,
layout::Rect,
style::Style,
text::{Line, Span},
widgets::Paragraph,
};
use crate::{
app::AppState,
normalization::TextMetrics,
ui::{
padded_line, padded_line_content_width, selectable_list_line, text_preview::safe_cluster,
theme::ColorTheme,
},
unicode::text::{NormalizationForm, TextAnalysis},
};
pub fn render(frame: &mut Frame, area: Rect, state: &AppState, theme: &ColorTheme) {
let original = state.sequence().unwrap().analysis();
let normalization = state.normalization().unwrap();
let comparison = normalization.comparison();
let result = comparison.result.analysis();
let canvas = Rect::new(
area.x + 1,
area.y,
area.width.saturating_sub(3),
area.height,
);
let width = usize::from(canvas.width);
let before_ranges = comparison
.diff
.regions()
.iter()
.map(|region| region.original.clone())
.collect::<Vec<_>>();
let after_ranges = comparison
.diff
.regions()
.iter()
.map(|region| region.result.clone())
.collect::<Vec<_>>();
let before = preview(original, &before_ranges, width.saturating_sub(1), theme);
let after = preview(result, &after_ranges, width.saturating_sub(1), theme);
let metrics = TextMetrics::original(original);
let mut lines = vec![
heading(
"Original",
&format!(
"{} CP ยท {} Bytes ยท {} GC",
metrics.code_points, metrics.bytes, metrics.graphemes
),
width,
theme,
),
before.0,
before.1,
Line::default(),
];
let metrics = NormalizationForm::ALL.map(|form| normalization.metrics(form));
let cp_width = metrics
.iter()
.map(|value| value.code_points.to_string().len())
.max()
.unwrap()
.max(2);
let byte_width = metrics
.iter()
.map(|value| value.bytes.to_string().len())
.max()
.unwrap()
.max(5);
let gc_width = metrics
.iter()
.map(|value| value.graphemes.to_string().len())
.max()
.unwrap()
.max(2);
let table_width = padded_line_content_width(width);
lines.push(padded_line(
table_row(
"Form vs Original",
&format!(
"{:>cp_width$} {:>byte_width$} {:>gc_width$}",
"CP", "Bytes", "GC"
),
table_width,
Style::new().fg(theme.muted),
),
width,
));
for (index, form) in NormalizationForm::ALL.into_iter().enumerate() {
let selected = normalization.form() == form;
let metrics = metrics[index];
let changed = original.normalization().get(form).is_changed();
let left = format!(
"{:<4} {}",
form.label(),
if changed { "Changed" } else { "Unchanged" }
);
let right = format!(
"{:>cp_width$} {:>byte_width$} {:>gc_width$}",
metrics.code_points, metrics.bytes, metrics.graphemes
);
lines.push(selectable_list_line(
table_row(&left, &right, table_width, Style::default()),
selected,
canvas.width,
theme.selection,
));
}
lines.push(Line::default());
let changed = !comparison.diff.regions().is_empty();
let status = if !changed {
"Same as Original".to_owned()
} else if comparison.diff.is_grouped() {
"Changed span (grouped)".to_owned()
} else {
format!(
"{} changed region{}",
comparison.diff.regions().len(),
if comparison.diff.regions().len() == 1 {
""
} else {
"s"
}
)
};
lines.push(heading(
&format!("{} Result", normalization.form().label()),
&status,
width,
theme,
));
if changed {
lines.extend([after.0, after.1]);
} else {
lines.extend([
Line::styled(
" Same text and code points as Original",
Style::new().fg(theme.muted),
),
Line::default(),
]);
}
if canvas.height > 13 && changed && (before.2 || after.2) {
lines.push(Line::styled(
" Preview near first change ยท โฆ omitted",
Style::new().fg(theme.muted),
));
}
frame.render_widget(Paragraph::new(lines), canvas);
}
fn heading(left: &str, right: &str, width: usize, theme: &ColorTheme) -> Line<'static> {
let mut line = table_row(left, right, width, Style::new().fg(theme.muted));
line.spans[0].style = theme.heading_style();
line
}
fn table_row(left: &str, right: &str, width: usize, style: Style) -> Line<'static> {
let padding = width.saturating_sub(Line::from(left).width() + Line::from(right).width());
Line::from(vec![
Span::styled(left.to_owned(), style),
Span::styled(" ".repeat(padding), style),
Span::styled(right.to_owned(), style),
])
}
fn preview(
analysis: &TextAnalysis,
changes: &[Range<usize>],
width: usize,
theme: &ColorTheme,
) -> (Line<'static>, Line<'static>, bool) {
let first = changes.first().map_or(0, |range| range.start);
let start = first.saturating_sub(2);
let clusters = analysis
.graphemes()
.iter()
.enumerate()
.skip(start)
.map(|(index, cluster)| {
(
safe_cluster(&analysis.source()[cluster.byte_range()]),
changes.iter().any(|range| range.contains(&index)),
None,
)
});
let (text, clipped_text) = fit_preview(clusters, start > 0, width, theme.base_style(), theme);
let first_point = analysis
.graphemes()
.get(first)
.map_or(0, |cluster| cluster.code_point_range().start);
let point_start = first_point.saturating_sub(2);
let points = analysis
.code_points()
.iter()
.enumerate()
.skip(point_start)
.map(|(index, point)| {
let changed = changes
.iter()
.any(|range| range.contains(&point.grapheme_index()));
(
point.code_point().to_string(),
changed,
(index > point_start).then(|| {
changed
&& analysis.code_points()[index - 1].grapheme_index()
== point.grapheme_index()
}),
)
});
let (code_points, clipped_points) = fit_preview(
points,
point_start > 0,
width,
Style::new().fg(theme.muted),
theme,
);
(text, code_points, clipped_text || clipped_points)
}
fn fit_preview(
atoms: impl Iterator<Item = (String, bool, Option<bool>)>,
leading: bool,
width: usize,
base: Style,
theme: &ColorTheme,
) -> (Line<'static>, bool) {
let mut spans = vec![Span::raw(" ")];
let mut used = 0;
if leading {
spans.push(Span::styled("โฆ ", base));
used = 2;
}
let mut truncated = false;
for (text, changed, separator) in atoms {
let atom_width = Line::from(text.as_str()).width() + usize::from(separator.is_some());
if used + atom_width + 2 > width {
truncated = true;
break;
}
used += atom_width;
if let Some(changed) = separator {
spans.push(Span::styled(
" ",
if changed {
theme.difference_style()
} else {
base
},
));
}
spans.push(Span::styled(
text,
if changed {
theme.difference_style()
} else {
base
},
));
}
if truncated {
spans.push(Span::styled(" โฆ", base));
}
(Line::from(spans), leading || truncated)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{Action, update};
#[test]
fn preserves_safe_clusters_and_escapes_controls_and_bidi() {
assert_eq!(safe_cluster("A\u{0301}"), "A\u{0301}");
assert_eq!(safe_cluster("๐ฉโ๐ป"), "๐ฉโ๐ป");
assert_eq!(safe_cluster("\r\n"), "<CR><LF>");
assert_eq!(safe_cluster("\u{202e}"), "<U+202E>");
assert_eq!(safe_cluster("\u{0301}"), "โ\u{0301}");
assert_eq!(safe_cluster("\u{200d}"), "<U+200D>");
assert_eq!(safe_cluster("\u{00a0}"), "<U+00A0>");
}
#[test]
fn emphasizes_the_cluster_and_all_its_code_points_with_configured_colors() {
let mut state = AppState::with_sequence("A\u{0301} โ ๐ฉโ๐ป".to_owned());
update(&mut state, Action::OpenNormalization);
let original = state.sequence().unwrap().analysis();
let theme = ColorTheme::default();
let (text, points, _) = preview(original, std::slice::from_ref(&(0..1)), 56, &theme);
assert_eq!(
text.spans
.iter()
.filter(|span| span.style == theme.difference_style())
.count(),
1
);
assert_eq!(
points
.spans
.iter()
.filter(|span| span.style == theme.difference_style())
.count(),
3
);
assert_ne!(theme.difference_style(), theme.selection.style());
}
#[test]
fn code_point_separators_are_emphasized_only_inside_a_grapheme() {
let original = TextAnalysis::new("A\u{0301}โ โก".to_owned());
let theme = ColorTheme::default();
let (_, points, _) = preview(&original, std::slice::from_ref(&(0..3)), 56, &theme);
let spans = &points.spans;
assert_eq!(
spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>(),
" U+0041 U+0301 U+2460 U+2461"
);
assert_eq!(spans[2].content, " ");
assert_eq!(spans[2].style, theme.difference_style());
for index in [4, 6] {
assert_eq!(spans[index].content, " ");
assert_ne!(spans[index].style, theme.difference_style());
}
assert_eq!(spans.last().unwrap().content, "U+2461");
}
#[test]
fn clipped_code_point_previews_do_not_leave_highlighted_trailing_spaces() {
let original = TextAnalysis::new("A\u{0301}โ ".to_owned());
let theme = ColorTheme::default();
let (_, points, clipped) = preview(&original, std::slice::from_ref(&(0..2)), 8, &theme);
assert!(clipped);
assert_eq!(points.spans.last().unwrap().content, " โฆ");
assert_ne!(points.spans.last().unwrap().style, theme.difference_style());
assert_eq!(points.spans[1].content, "U+0041");
}
}