716 lines
22 KiB
Rust
716 lines
22 KiB
Rust
use std::any::Any;
|
|
use std::ops::Range;
|
|
use std::sync::Arc;
|
|
use std::{collections::VecDeque, fmt, fmt::Debug};
|
|
|
|
pub mod html_parser;
|
|
pub mod markdown_parser;
|
|
pub mod weight;
|
|
pub use html_parser::parse_html;
|
|
use itertools::Itertools;
|
|
pub use markdown_parser::{
|
|
parse_image_prefix, parse_image_run_line, parse_inline_markdown, parse_markdown,
|
|
parse_markdown_with_gfm_tables,
|
|
};
|
|
use serde_yaml::Mapping;
|
|
use weight::CustomWeight;
|
|
|
|
/// Trait for an "action" that can be dispatched via a hyperlink click handler.
|
|
/// This purposefully shadows the `Action` trait from `warpui`.
|
|
///
|
|
/// Since `warpui` depends on this crate, we can't depend on the `warpui::Action` trait directly.
|
|
/// Instead, we create a new trait with a blanket implementation that implicitly results
|
|
/// in any `warpui::Action` implementing this `Action`.
|
|
pub trait Action: Any + Debug + Send + Sync {
|
|
fn as_any(&self) -> &dyn Any;
|
|
}
|
|
|
|
impl<T> Action for T
|
|
where
|
|
T: Any + Debug + Send + Sync,
|
|
{
|
|
fn as_any(&self) -> &dyn Any {
|
|
self
|
|
}
|
|
}
|
|
|
|
pub trait LineCount {
|
|
fn num_lines(&self) -> usize;
|
|
}
|
|
|
|
/// A simple line-based delta between two [`FormattedText`] values.
|
|
///
|
|
/// `common_prefix_lines` is the number of leading lines that are identical
|
|
/// between the old and new formatted text. `new_suffix`
|
|
/// contains the replacement lines from the new value to replace from after common_prefix_lines
|
|
/// to the end of the buffer.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct FormattedTextDelta {
|
|
/// The number of actual lines in the common prefix (corresponding to a row in Point)
|
|
/// Note that a FormattedTextLine can have multiple lines - this refers to the actual line count,
|
|
/// not the FormattedTextLine count.
|
|
pub common_prefix_lines: usize,
|
|
/// The number of existing formatted text lines to be replaced
|
|
pub old_suffix_formatted_text_lines: usize,
|
|
pub new_suffix: VecDeque<FormattedTextLine>,
|
|
}
|
|
|
|
impl FormattedTextDelta {
|
|
pub fn is_noop(&self) -> bool {
|
|
self.old_suffix_formatted_text_lines == 0 && self.new_suffix.is_empty()
|
|
}
|
|
}
|
|
|
|
pub fn compute_formatted_text_delta(old: FormattedText, new: FormattedText) -> FormattedTextDelta {
|
|
let mut common_prefix_formatted_text_lines = 0usize;
|
|
let mut common_prefix_lines = 0usize;
|
|
let old_len = old.lines.len();
|
|
let new_len = new.lines.len();
|
|
let shared_len = old_len.min(new_len);
|
|
|
|
while common_prefix_formatted_text_lines < shared_len {
|
|
let old_line = &old.lines[common_prefix_formatted_text_lines];
|
|
let new_line = &new.lines[common_prefix_formatted_text_lines];
|
|
|
|
// Special handling for code blocks: only compare the code, not the language
|
|
// This is because the lang string in our internal buffer representation may not match
|
|
// the lang string in the parsed markdown exactly (e.g. "Python" vs "python path=/path/to/file.py start=1")
|
|
let lines_equal = match (old_line, new_line) {
|
|
(FormattedTextLine::CodeBlock(old_block), FormattedTextLine::CodeBlock(new_block)) => {
|
|
old_block.code == new_block.code
|
|
}
|
|
_ => old_line == new_line,
|
|
};
|
|
|
|
if !lines_equal {
|
|
break;
|
|
}
|
|
|
|
common_prefix_formatted_text_lines += 1;
|
|
common_prefix_lines += old_line.num_lines();
|
|
}
|
|
|
|
let old_suffix_formatted_text_lines =
|
|
old_len.saturating_sub(common_prefix_formatted_text_lines);
|
|
let new_suffix = new
|
|
.lines
|
|
.iter()
|
|
.skip(common_prefix_formatted_text_lines)
|
|
.cloned()
|
|
.collect();
|
|
|
|
FormattedTextDelta {
|
|
common_prefix_lines,
|
|
old_suffix_formatted_text_lines,
|
|
new_suffix,
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct FormattedText {
|
|
pub lines: VecDeque<FormattedTextLine>,
|
|
}
|
|
|
|
impl FormattedText {
|
|
pub fn new(lines: impl Into<VecDeque<FormattedTextLine>>) -> Self {
|
|
Self {
|
|
lines: lines.into(),
|
|
}
|
|
}
|
|
|
|
/// Creates a new FormattedText where the first and last line breaks are removed, if any.
|
|
pub fn new_trimmed(lines: impl Into<VecDeque<FormattedTextLine>>) -> Self {
|
|
let mut new = Self::new(lines);
|
|
new.trim();
|
|
new
|
|
}
|
|
|
|
fn trim(&mut self) {
|
|
// Since we exhaust contiguous new lines into a single line break,
|
|
// there won't be multiple contiguous line breaks; there's at most one to remove.
|
|
if let Some(FormattedTextLine::LineBreak) = self.lines.front() {
|
|
self.lines.pop_front();
|
|
}
|
|
|
|
// Similarly for the end.
|
|
if let Some(FormattedTextLine::LineBreak) = self.lines.back() {
|
|
self.lines.pop_back();
|
|
}
|
|
}
|
|
|
|
/// Returns the raw text of the markdown, without any of the markdown
|
|
/// markers.
|
|
pub fn raw_text(&self) -> String {
|
|
self.lines.iter().map(|line| line.raw_text()).join("")
|
|
}
|
|
|
|
pub fn append_line(mut self, line: FormattedTextLine) -> Self {
|
|
self.lines.push_back(line);
|
|
self
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Eq, PartialEq)]
|
|
pub enum FormattedTextLine {
|
|
Heading(FormattedTextHeader),
|
|
Line(FormattedTextInline),
|
|
OrderedList(OrderedFormattedIndentTextInline),
|
|
UnorderedList(FormattedIndentTextInline),
|
|
CodeBlock(CodeBlockText),
|
|
TaskList(FormattedTaskList),
|
|
LineBreak,
|
|
HorizontalRule,
|
|
Embedded(Mapping),
|
|
Image(FormattedImage),
|
|
Table(FormattedTable),
|
|
}
|
|
|
|
impl FormattedTextLine {
|
|
pub fn raw_text(&self) -> String {
|
|
let mut text = match self {
|
|
Self::CodeBlock(text) => text.code.clone(),
|
|
Self::Heading(header) => header
|
|
.text
|
|
.iter()
|
|
.map(|fragment| fragment.raw_text())
|
|
.join(""),
|
|
Self::Line(line) => line.iter().map(|fragment| fragment.raw_text()).join(""),
|
|
Self::TaskList(line) => line
|
|
.text
|
|
.iter()
|
|
.map(|fragment| fragment.raw_text())
|
|
.join(""),
|
|
Self::OrderedList(list) => list
|
|
.indented_text
|
|
.text
|
|
.iter()
|
|
.map(|fragment| fragment.raw_text())
|
|
.join(""),
|
|
Self::UnorderedList(list) => list
|
|
.text
|
|
.iter()
|
|
.map(|fragment| fragment.raw_text())
|
|
.join(""),
|
|
Self::LineBreak | Self::HorizontalRule | Self::Embedded(_) => "\n".to_string(),
|
|
Self::Image(image) => format!("{}\n", image.alt_text),
|
|
Self::Table(table) => table.to_internal_format(),
|
|
};
|
|
// Each `FormattedTextLine` unit represents a complete line. If it doesn't already end in
|
|
// a newline, add one.
|
|
if !text.ends_with('\n') {
|
|
text.push('\n');
|
|
}
|
|
text
|
|
}
|
|
|
|
pub fn set_weight(&mut self, weight: Option<CustomWeight>) -> &Self {
|
|
match self {
|
|
Self::Heading(header) => {
|
|
for fragment in &mut header.text {
|
|
fragment.styles.weight = weight;
|
|
}
|
|
}
|
|
Self::Line(line) => {
|
|
for fragment in line {
|
|
fragment.styles.weight = weight;
|
|
}
|
|
}
|
|
Self::OrderedList(list) => {
|
|
for fragment in &mut list.indented_text.text {
|
|
fragment.styles.weight = weight;
|
|
}
|
|
}
|
|
Self::UnorderedList(list) => {
|
|
for fragment in &mut list.text {
|
|
fragment.styles.weight = weight;
|
|
}
|
|
}
|
|
Self::TaskList(list) => {
|
|
for fragment in &mut list.text {
|
|
fragment.styles.weight = weight;
|
|
}
|
|
}
|
|
Self::Table(_)
|
|
| Self::CodeBlock(_)
|
|
| Self::LineBreak
|
|
| Self::HorizontalRule
|
|
| Self::Embedded(_)
|
|
| Self::Image(_) => {}
|
|
}
|
|
self
|
|
}
|
|
|
|
fn inline_fragments(&self) -> Option<&FormattedTextInline> {
|
|
match &self {
|
|
FormattedTextLine::Heading(header) => Some(&header.text),
|
|
FormattedTextLine::Line(texts) => Some(texts),
|
|
FormattedTextLine::OrderedList(texts) => Some(&texts.indented_text.text),
|
|
FormattedTextLine::UnorderedList(texts) => Some(&texts.text),
|
|
FormattedTextLine::TaskList(list) => Some(&list.text),
|
|
FormattedTextLine::CodeBlock(_)
|
|
| FormattedTextLine::LineBreak
|
|
| FormattedTextLine::HorizontalRule
|
|
| FormattedTextLine::Embedded(_)
|
|
| FormattedTextLine::Image(_)
|
|
| FormattedTextLine::Table(_) => None,
|
|
}
|
|
}
|
|
|
|
pub fn hyperlinks(&self, skip_raw_links: bool) -> Vec<(Range<usize>, Hyperlink)> {
|
|
let mut hyperlinks: Vec<(Range<usize>, Hyperlink)> = Vec::new();
|
|
if let Some(inline_fragments) = self.inline_fragments() {
|
|
let mut char_count = 0;
|
|
for fragment in inline_fragments {
|
|
let range_start = char_count;
|
|
char_count += fragment.text.chars().count();
|
|
if let Some(link) = &fragment.styles.hyperlink
|
|
&& (!skip_raw_links
|
|
|| !matches!(&link, Hyperlink::Url(url) if url == &fragment.text))
|
|
{
|
|
hyperlinks.push((range_start..char_count, link.clone()));
|
|
}
|
|
}
|
|
}
|
|
hyperlinks
|
|
}
|
|
|
|
pub fn is_empty_line(&self) -> bool {
|
|
matches!(self, Self::Line(line) if line.iter().all(|fragment| fragment.text.is_empty()))
|
|
}
|
|
}
|
|
|
|
impl LineCount for FormattedTextLine {
|
|
fn num_lines(&self) -> usize {
|
|
match self {
|
|
Self::CodeBlock(text) => text.code.matches('\n').count(),
|
|
Self::Heading(_) => 1,
|
|
Self::Line(_) => 1,
|
|
Self::OrderedList(_) => 1,
|
|
Self::UnorderedList(_) => 1,
|
|
Self::TaskList(_) => 1,
|
|
Self::LineBreak => 0,
|
|
Self::HorizontalRule => 0,
|
|
Self::Embedded(_) => 1,
|
|
Self::Image(_) => 1,
|
|
Self::Table(table) => 1 + table.rows.len(), // Header + data rows (separator not counted as a line)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Eq, PartialEq)]
|
|
pub struct FormattedTextHeader {
|
|
pub heading_size: usize,
|
|
pub text: FormattedTextInline,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Eq, PartialEq)]
|
|
pub struct FormattedTaskList {
|
|
pub complete: bool,
|
|
pub indent_level: usize,
|
|
pub text: FormattedTextInline,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Eq, PartialEq)]
|
|
pub struct FormattedIndentTextInline {
|
|
pub indent_level: usize,
|
|
pub text: FormattedTextInline,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Eq, PartialEq)]
|
|
pub struct CodeBlockText {
|
|
pub lang: String,
|
|
pub code: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Eq, PartialEq)]
|
|
pub struct OrderedFormattedIndentTextInline {
|
|
/// The number of this item, which may be `None` if it was unspecified or invalid in the source
|
|
/// document.
|
|
pub number: Option<usize>,
|
|
pub indented_text: FormattedIndentTextInline,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Eq, PartialEq)]
|
|
pub struct FormattedImage {
|
|
pub alt_text: String,
|
|
pub source: String,
|
|
/// Optional CommonMark image title, e.g. the `title` in ``.
|
|
/// Empty titles are normalized to `None` by the parser.
|
|
pub title: Option<String>,
|
|
}
|
|
|
|
/// Column alignment for table cells
|
|
#[derive(Debug, Clone, Copy, Eq, PartialEq, Default, Hash)]
|
|
pub enum TableAlignment {
|
|
#[default]
|
|
Left,
|
|
Center,
|
|
Right,
|
|
}
|
|
|
|
/// A formatted table with headers, alignments, and rows
|
|
#[derive(Debug, Clone, Eq, PartialEq)]
|
|
pub struct FormattedTable {
|
|
pub headers: Vec<FormattedTextInline>,
|
|
pub alignments: Vec<TableAlignment>,
|
|
pub rows: Vec<Vec<FormattedTextInline>>,
|
|
}
|
|
|
|
impl FormattedTable {
|
|
/// Parse from the internal tab-separated format used in `warp-markdown-table` code blocks.
|
|
pub fn from_internal_format(content: &str) -> Self {
|
|
let parse_line = |line: &str| -> Vec<FormattedTextInline> {
|
|
line.split('\t')
|
|
.map(|cell| vec![FormattedTextFragment::plain_text(cell)])
|
|
.collect()
|
|
};
|
|
|
|
let mut lines = content.lines().peekable();
|
|
let headers = lines.next().map(parse_line).unwrap_or_default();
|
|
let rows: Vec<Vec<FormattedTextInline>> = lines.map(parse_line).collect();
|
|
let col_count = headers.len();
|
|
|
|
Self {
|
|
headers,
|
|
alignments: vec![TableAlignment::default(); col_count],
|
|
rows,
|
|
}
|
|
}
|
|
|
|
pub fn from_internal_format_with_alignments(
|
|
content: &str,
|
|
mut alignments: Vec<TableAlignment>,
|
|
) -> Self {
|
|
let mut table = Self::from_internal_format(content);
|
|
let col_count = table.headers.len();
|
|
alignments.resize(col_count, TableAlignment::default());
|
|
alignments.truncate(col_count);
|
|
table.alignments = alignments;
|
|
table
|
|
}
|
|
|
|
/// Serialize to the internal tab-separated format used in `warp-markdown-table` code blocks.
|
|
/// Inline formatting is preserved as markdown syntax so it survives the buffer round-trip.
|
|
pub fn to_internal_format(&self) -> String {
|
|
if self.headers.is_empty() && self.rows.is_empty() {
|
|
return String::new();
|
|
}
|
|
|
|
let mut result = String::new();
|
|
let headers: Vec<String> = self.headers.iter().map(inline_to_markdown).collect();
|
|
result.push_str(&headers.join("\t"));
|
|
result.push('\n');
|
|
for row in &self.rows {
|
|
let cells: Vec<String> = row.iter().map(inline_to_markdown).collect();
|
|
result.push_str(&cells.join("\t"));
|
|
result.push('\n');
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Pad ragged rows/headers to a uniform column count.
|
|
pub fn normalize_shape(&mut self) {
|
|
let mut column_count = self
|
|
.headers
|
|
.len()
|
|
.max(self.rows.iter().map(Vec::len).max().unwrap_or(0));
|
|
if column_count == 0 {
|
|
column_count = 1;
|
|
}
|
|
|
|
self.headers.resize_with(column_count, Vec::new);
|
|
self.alignments
|
|
.resize(column_count, TableAlignment::default());
|
|
for row in &mut self.rows {
|
|
row.resize_with(column_count, Vec::new);
|
|
}
|
|
}
|
|
|
|
/// Serialize to GFM pipe-table markdown.
|
|
pub fn to_plain_text(&self) -> String {
|
|
fn inline_to_text(inline: &FormattedTextInline) -> String {
|
|
inline.iter().map(|f| f.text.as_str()).collect()
|
|
}
|
|
|
|
let mut lines = Vec::new();
|
|
let headers: Vec<String> = self.headers.iter().map(inline_to_text).collect();
|
|
lines.push(format!("| {} |", headers.join(" | ")));
|
|
let separator: Vec<String> = self
|
|
.alignments
|
|
.iter()
|
|
.map(|alignment| match alignment {
|
|
TableAlignment::Left => "---".to_string(),
|
|
TableAlignment::Center => ":---:".to_string(),
|
|
TableAlignment::Right => "---:".to_string(),
|
|
})
|
|
.collect();
|
|
lines.push(format!("| {} |", separator.join(" | ")));
|
|
for row in &self.rows {
|
|
let cells: Vec<String> = row.iter().map(inline_to_text).collect();
|
|
lines.push(format!("| {} |", cells.join(" | ")));
|
|
}
|
|
lines.join("\n")
|
|
}
|
|
}
|
|
|
|
/// Convert a `FormattedTextInline` back to markdown syntax.
|
|
fn inline_to_markdown(inline: &FormattedTextInline) -> String {
|
|
let mut result = String::new();
|
|
for fragment in inline {
|
|
let mut text = fragment.text.clone();
|
|
if text.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
if fragment.styles.inline_code {
|
|
result.push('`');
|
|
result.push_str(&text);
|
|
result.push('`');
|
|
continue;
|
|
}
|
|
|
|
if let Some(Hyperlink::Url(url)) = &fragment.styles.hyperlink {
|
|
text = format!("[{text}]({url})");
|
|
}
|
|
if fragment.styles.strikethrough {
|
|
text = format!("~~{text}~~");
|
|
}
|
|
if fragment.styles.underline {
|
|
text = format!("<u>{text}</u>");
|
|
}
|
|
let is_bold = fragment
|
|
.styles
|
|
.weight
|
|
.is_some_and(|w| matches!(w, CustomWeight::Bold));
|
|
if is_bold && fragment.styles.italic {
|
|
text = format!("***{text}***");
|
|
} else if is_bold {
|
|
text = format!("**{text}**");
|
|
} else if fragment.styles.italic {
|
|
text = format!("*{text}*");
|
|
}
|
|
|
|
result.push_str(&text);
|
|
}
|
|
result
|
|
}
|
|
pub type FormattedTableAlignment = TableAlignment;
|
|
|
|
pub type FormattedTextInline = Vec<FormattedTextFragment>;
|
|
|
|
/// A fragment of formatted text, containing the text itself and formatting flags/metadata.
|
|
#[derive(Debug, Clone, Eq, PartialEq, Default)]
|
|
pub struct FormattedTextFragment {
|
|
pub text: String,
|
|
pub styles: FormattedTextStyles,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum Hyperlink {
|
|
Url(String),
|
|
Action(Arc<dyn Action>),
|
|
}
|
|
|
|
impl Hyperlink {
|
|
/// Returns the URL if this is a URL, or `None` otherwise.
|
|
pub fn url(self) -> Option<String> {
|
|
match self {
|
|
Hyperlink::Url(url) => Some(url),
|
|
Hyperlink::Action(_) => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PartialEq for Hyperlink {
|
|
// Stub implementation for [`Hyperlink`] that only compares URLs and not Actions.
|
|
// This is an unfortunate byproduct of the fact that an [`Action`] does not implement [`PartialEq`]
|
|
// but we require [`PartialEq`] to consolidate [`FormattedTextStyles`].
|
|
// To get around this, we only compare URLs, which works for style consolidation since this is only
|
|
// needed when generating formatted text from markdown, which provably does not support URLs that dispatch
|
|
// actions
|
|
fn eq(&self, other: &Self) -> bool {
|
|
match (self, other) {
|
|
(Self::Url(left), Self::Url(right)) => left == right,
|
|
_ => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Eq for Hyperlink {}
|
|
|
|
/// Formatted text styling, with no attached content.
|
|
#[derive(Clone, Default, Eq, PartialEq)]
|
|
pub struct FormattedTextStyles {
|
|
pub weight: Option<CustomWeight>,
|
|
pub italic: bool,
|
|
pub underline: bool,
|
|
pub strikethrough: bool,
|
|
pub inline_code: bool,
|
|
pub hyperlink: Option<Hyperlink>,
|
|
}
|
|
|
|
impl FormattedTextFragment {
|
|
pub fn plain_text(text: impl Into<String>) -> Self {
|
|
Self {
|
|
text: text.into(),
|
|
styles: Default::default(),
|
|
}
|
|
}
|
|
|
|
pub fn weighted(text: impl Into<String>, weight: Option<CustomWeight>) -> Self {
|
|
Self {
|
|
text: text.into(),
|
|
styles: FormattedTextStyles {
|
|
weight,
|
|
..Default::default()
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn with_weight(&mut self, weight: Option<CustomWeight>) -> &Self {
|
|
self.styles.weight = weight;
|
|
self
|
|
}
|
|
|
|
pub fn bold(text: impl Into<String>) -> Self {
|
|
Self {
|
|
text: text.into(),
|
|
styles: FormattedTextStyles {
|
|
weight: Some(CustomWeight::Bold),
|
|
..Default::default()
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn italic(text: impl Into<String>) -> Self {
|
|
Self {
|
|
text: text.into(),
|
|
styles: FormattedTextStyles {
|
|
italic: true,
|
|
..Default::default()
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn bold_italic(text: impl Into<String>) -> Self {
|
|
Self {
|
|
text: text.into(),
|
|
styles: FormattedTextStyles {
|
|
weight: Some(CustomWeight::Bold),
|
|
italic: true,
|
|
..Default::default()
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn hyperlink(tag: impl Into<String>, url: impl Into<String>) -> Self {
|
|
Self {
|
|
text: tag.into(),
|
|
styles: FormattedTextStyles {
|
|
hyperlink: Some(Hyperlink::Url(url.into())),
|
|
..Default::default()
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Constructs a new hyperlink that dispatches an action when clicked.
|
|
pub fn hyperlink_action<A: Action>(tag: impl Into<String>, action: A) -> Self {
|
|
Self {
|
|
text: tag.into(),
|
|
styles: FormattedTextStyles {
|
|
hyperlink: Some(Hyperlink::Action(Arc::new(action))),
|
|
..Default::default()
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn inline_code(text: impl Into<String>) -> Self {
|
|
Self {
|
|
text: text.into(),
|
|
styles: FormattedTextStyles {
|
|
inline_code: true,
|
|
..Default::default()
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn strikethrough(text: impl Into<String>) -> Self {
|
|
Self {
|
|
text: text.into(),
|
|
styles: FormattedTextStyles {
|
|
strikethrough: true,
|
|
..Default::default()
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn underline(text: impl Into<String>) -> Self {
|
|
Self {
|
|
text: text.into(),
|
|
styles: FormattedTextStyles {
|
|
underline: true,
|
|
..Default::default()
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn raw_text(&self) -> &String {
|
|
&self.text
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for FormattedTextStyles {
|
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
// For readability, only show active styles.
|
|
let mut first = true;
|
|
|
|
if let Some(weight) = self.weight {
|
|
if !first {
|
|
f.write_str(" | ")?;
|
|
}
|
|
write!(f, "{weight:?}")?;
|
|
first = false;
|
|
}
|
|
|
|
if self.italic {
|
|
if !first {
|
|
f.write_str(" | ")?;
|
|
}
|
|
f.write_str("Italic")?;
|
|
first = false;
|
|
}
|
|
|
|
if self.strikethrough {
|
|
if !first {
|
|
f.write_str(" | ")?;
|
|
}
|
|
f.write_str("Strikethrough")?;
|
|
first = false;
|
|
}
|
|
|
|
if self.inline_code {
|
|
if !first {
|
|
f.write_str(" | ")?;
|
|
}
|
|
f.write_str("InlineCode")?;
|
|
first = false;
|
|
}
|
|
|
|
if let Some(link) = &self.hyperlink {
|
|
if !first {
|
|
f.write_str(" | ")?;
|
|
}
|
|
|
|
write!(f, "Hyperlink({link:?})")?;
|
|
first = false;
|
|
}
|
|
|
|
if first {
|
|
// No styles are active, so this is plain text.
|
|
f.write_str("PlainText")?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|