use std::any::Any;
use std::hash::{Hash, Hasher};
use rustc_hash::FxHasher;
use std::sync::Arc;
#[cfg(feature = "profiling-tracing")]
use tracing::trace_span;
use crate::style::{DiffPalette, Span, Style, Theme};
use super::diagram::ParsedDiagram;
#[derive(Clone, Copy, Debug)]
pub struct FormatInput<'a> {
pub value: &'a str,
pub content_type: Option<&'a str>,
pub document_styles: Option<&'a DocumentStyles>,
}
#[cfg_attr(
feature = "markdown",
doc = " `markdown` feature, [`MarkdownFormatter`](super::MarkdownFormatter)."
)]
#[cfg_attr(
not(feature = "markdown"),
doc = " `markdown` feature, `MarkdownFormatter`."
)]
pub trait ContentFormatter: Any {
fn format(&self, input: FormatInput<'_>) -> FormattedDocument;
fn measure_format(&self, input: FormatInput<'_>) -> FormattedDocument {
self.format(input)
}
fn cache_key(&self) -> u64 {
0
}
fn measure_cache_key(&self) -> u64 {
self.cache_key()
}
fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any;
fn clone_box(&self) -> Box<dyn ContentFormatter>;
fn set_app_theme_if_absent(&mut self, _theme: &Theme) {}
}
#[derive(Clone, Debug, Default)]
pub struct FormattedDocument {
pub blocks: Vec<FormattedBlock>,
}
#[derive(Clone, Debug)]
pub enum FormattedBlock {
Lines(Vec<FormattedLine>),
Table(FormattedTable),
CodeBlock(FormattedCodeBlock),
Diagram(FormattedDiagramBlock),
HorizontalRule {
source_line: usize,
},
BlockQuote(FormattedBlockQuote),
List(FormattedList),
}
#[derive(Clone, Debug)]
pub struct FormattedLine {
pub spans: Vec<Span>,
pub source_line: usize,
pub indent: u16,
pub links: Vec<FormattedLink>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct FormattedLink {
pub start: usize,
pub end: usize,
pub url: Arc<str>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ColumnAlign {
#[default]
Left,
Center,
Right,
}
#[derive(Clone, Debug)]
pub struct FormattedTable {
pub headers: Vec<Vec<Span>>,
pub rows: Vec<Vec<Vec<Span>>>,
pub alignments: Vec<ColumnAlign>,
pub source_line_start: usize,
}
#[derive(Clone, Debug)]
pub struct FormattedCodeBlock {
pub language: Option<Arc<str>>,
pub code: Arc<str>,
pub source_line_start: usize,
}
#[derive(Clone, Debug)]
pub struct FormattedDiagramBlock {
pub diagram: ParsedDiagram,
pub source_code: Arc<str>,
pub source_line_start: usize,
}
#[derive(Clone, Debug)]
pub struct FormattedBlockQuote {
pub blocks: Vec<FormattedBlock>,
pub depth: u16,
pub source_line_start: usize,
}
#[derive(Clone, Debug)]
pub struct FormattedList {
pub ordered: bool,
pub start: usize,
pub items: Vec<FormattedListItem>,
pub source_line_start: usize,
}
#[derive(Clone, Debug)]
pub struct FormattedListItem {
pub content: Vec<FormattedBlock>,
pub source_line: usize,
}
#[derive(Clone, Debug, Default)]
pub struct PlainFormatter;
impl ContentFormatter for PlainFormatter {
fn clone_box(&self) -> Box<dyn ContentFormatter> {
Box::new(self.clone())
}
fn format(&self, input: FormatInput<'_>) -> FormattedDocument {
let lines: Vec<FormattedLine> = if input.value.is_empty() {
vec![FormattedLine {
spans: vec![Span::new("")],
source_line: 0,
indent: 0,
links: Vec::new(),
}]
} else {
input
.value
.split('\n')
.enumerate()
.map(|(i, line)| FormattedLine {
spans: vec![Span::new(line)],
source_line: i,
indent: 0,
links: Vec::new(),
})
.collect()
};
FormattedDocument {
blocks: vec![FormattedBlock::Lines(lines)],
}
}
fn measure_cache_key(&self) -> u64 {
0
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct FormatCacheKey {
pub value_hash: u64,
pub formatter_hash: u64,
pub content_type_hash: u64,
pub document_styles_hash: u64,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct FormatCache {
pub key: Option<FormatCacheKey>,
pub document: FormattedDocument,
}
impl FormatCache {
pub fn update(
&mut self,
formatter: &dyn ContentFormatter,
value: &str,
content_type: Option<&str>,
document_styles: &DocumentStyles,
) -> bool {
#[cfg(feature = "profiling-tracing")]
let _span = trace_span!("document_view.format_cache_update").entered();
let value_hash = hash_str(value);
let formatter_hash = formatter.cache_key();
let content_type_hash = content_type.map(hash_str).unwrap_or(0);
let new_key = FormatCacheKey {
value_hash,
formatter_hash,
content_type_hash,
document_styles_hash: hash_document_styles(document_styles),
};
if self.key.as_ref() == Some(&new_key) {
#[cfg(feature = "profiling-tracing")]
tracing::trace!(target: "tui_lipan::perf", cache_hit = true);
return false;
}
#[cfg(feature = "profiling-tracing")]
let _format_span = trace_span!("document_view.format").entered();
self.document = formatter.format(FormatInput {
value,
content_type,
document_styles: Some(document_styles),
});
#[cfg(feature = "profiling-tracing")]
tracing::trace!(
target: "tui_lipan::perf",
cache_hit = false,
bytes = value.len(),
has_content_type = content_type.is_some()
);
self.key = Some(new_key);
true
}
}
fn hash_str(s: &str) -> u64 {
let mut hasher = FxHasher::default();
s.hash(&mut hasher);
hasher.finish()
}
fn hash_document_styles(styles: &DocumentStyles) -> u64 {
let mut hasher = FxHasher::default();
styles.heading_styles.hash(&mut hasher);
styles.code_inline_style.hash(&mut hasher);
styles.code_block_style.hash(&mut hasher);
styles.emphasis_style.hash(&mut hasher);
styles.strong_style.hash(&mut hasher);
styles.strikethrough_style.hash(&mut hasher);
styles.link_style.hash(&mut hasher);
styles.blockquote_bar_style.hash(&mut hasher);
styles.table_border_style.hash(&mut hasher);
styles.table_header_style.hash(&mut hasher);
styles.hr_style.hash(&mut hasher);
styles.list_item_style.hash(&mut hasher);
styles.list_enumeration_style.hash(&mut hasher);
styles.diagram_node_fill_style.hash(&mut hasher);
styles.diagram_node_border_style.hash(&mut hasher);
styles.diagram_node_label_style.hash(&mut hasher);
styles.diagram_edge_style.hash(&mut hasher);
styles.diagram_muted_style.hash(&mut hasher);
styles.diff_palette.hash(&mut hasher);
hasher.finish()
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct DocumentStyles {
pub heading_styles: [Style; 6],
pub code_inline_style: Style,
pub code_block_style: Style,
pub emphasis_style: Style,
pub strong_style: Style,
pub strikethrough_style: Style,
pub link_style: Style,
pub blockquote_bar_style: Style,
pub table_border_style: Style,
pub table_header_style: Style,
pub hr_style: Style,
pub list_item_style: Style,
pub list_enumeration_style: Style,
pub diagram_node_fill_style: Style,
pub diagram_node_border_style: Style,
pub diagram_node_label_style: Style,
pub diagram_edge_style: Style,
pub diagram_muted_style: Style,
pub diff_palette: DiffPalette,
}
impl DocumentStyles {
pub(crate) fn from_theme(theme: &Theme) -> Self {
Self {
heading_styles: theme.document.heading_styles,
code_inline_style: theme.document.code_inline,
code_block_style: theme.document.code_block,
emphasis_style: theme.document.emphasis,
strong_style: theme.document.strong,
strikethrough_style: theme.document.strikethrough,
link_style: theme.document.link,
blockquote_bar_style: theme.document.blockquote_bar,
table_border_style: theme.document.table_border,
table_header_style: theme.document.table_header,
hr_style: theme.document.hr,
list_item_style: theme.document.list_item,
list_enumeration_style: theme.document.list_enumeration,
diagram_node_fill_style: theme.document.diagram_node_fill_style,
diagram_node_border_style: theme.document.diagram_node_border_style,
diagram_node_label_style: theme.document.diagram_node_label_style,
diagram_edge_style: theme.document.diagram_edge_style,
diagram_muted_style: theme.document.diagram_muted_style,
diff_palette: theme.diff,
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use super::*;
use crate::core::element::Element;
use crate::style::apply_document_theme_carve_out;
use crate::widgets::{DocumentView, ThemeProvider};
fn as_lines(document: FormattedDocument) -> Vec<FormattedLine> {
let mut blocks = document.blocks;
assert_eq!(blocks.len(), 1);
match blocks.remove(0) {
FormattedBlock::Lines(lines) => lines,
other => panic!("expected FormattedBlock::Lines, got {other:?}"),
}
}
#[test]
fn format_cache_update_invalidates_then_hits_then_invalidates_on_change() {
let formatter = PlainFormatter;
let mut cache = FormatCache::default();
assert!(cache.update(&formatter, "hello", None, &DocumentStyles::default()));
assert!(!cache.update(&formatter, "hello", None, &DocumentStyles::default(),));
assert!(cache.update(&formatter, "hello world", None, &DocumentStyles::default(),));
}
#[derive(Clone)]
struct RecordingFormatter {
flag: Arc<AtomicBool>,
}
impl ContentFormatter for RecordingFormatter {
fn clone_box(&self) -> Box<dyn ContentFormatter> {
Box::new(self.clone())
}
fn format(&self, input: FormatInput<'_>) -> FormattedDocument {
PlainFormatter.format(input)
}
fn set_app_theme_if_absent(&mut self, _theme: &Theme) {
self.flag.store(true, Ordering::SeqCst);
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
#[test]
fn theme_provider_invokes_formatter_set_app_theme_when_rc_unique() {
let flag = Arc::new(AtomicBool::new(false));
let el: Element = ThemeProvider::new(Theme::default())
.child(DocumentView::new("hello").formatter(RecordingFormatter { flag: flag.clone() }))
.into();
let _ = apply_document_theme_carve_out(&Theme::default(), el);
assert!(flag.load(Ordering::SeqCst));
}
#[test]
fn theme_provider_invokes_formatter_set_app_theme_when_formatter_rc_shared() {
let flag = Arc::new(AtomicBool::new(false));
let dv = DocumentView::new("hello").formatter(RecordingFormatter { flag: flag.clone() });
let _share = dv.clone();
let el: Element = ThemeProvider::new(Theme::default()).child(dv).into();
let _ = apply_document_theme_carve_out(&Theme::default(), el);
assert!(flag.load(Ordering::SeqCst));
}
#[test]
fn plain_formatter_empty_input_produces_single_empty_line() {
let formatter = PlainFormatter;
let lines = as_lines(formatter.format(FormatInput {
value: "",
content_type: None,
document_styles: None,
}));
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].source_line, 0);
assert_eq!(lines[0].spans.len(), 1);
assert_eq!(lines[0].spans[0].content.as_ref(), "");
}
#[test]
fn plain_formatter_splits_lines_and_keeps_empty_middle_lines() {
let formatter = PlainFormatter;
let lines = as_lines(formatter.format(FormatInput {
value: "alpha\n\nbeta",
content_type: None,
document_styles: None,
}));
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].spans[0].content.as_ref(), "alpha");
assert_eq!(lines[1].spans[0].content.as_ref(), "");
assert_eq!(lines[2].spans[0].content.as_ref(), "beta");
assert_eq!(lines[0].source_line, 0);
assert_eq!(lines[1].source_line, 1);
assert_eq!(lines[2].source_line, 2);
}
#[test]
fn plain_formatter_preserves_trailing_newline_as_empty_last_line() {
let formatter = PlainFormatter;
let lines = as_lines(formatter.format(FormatInput {
value: "alpha\n",
content_type: None,
document_styles: None,
}));
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].spans[0].content.as_ref(), "alpha");
assert_eq!(lines[1].spans[0].content.as_ref(), "");
assert_eq!(lines[1].source_line, 1);
}
}