use std::ops::Range;
use vello::peniko::Color;
use crate::editor::EditorTheme;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CalloutKind {
Note,
Abstract,
Info,
Todo,
Tip,
Success,
Question,
Warning,
Failure,
Danger,
Bug,
Example,
Quote,
}
impl CalloutKind {
pub fn from_label(label: &str) -> Option<Self> {
let l = label.to_ascii_lowercase();
Some(match l.as_str() {
"note" => Self::Note,
"abstract" | "summary" | "tldr" => Self::Abstract,
"info" => Self::Info,
"todo" => Self::Todo,
"tip" | "hint" | "important" => Self::Tip,
"success" | "check" | "done" => Self::Success,
"question" | "help" | "faq" => Self::Question,
"warning" | "caution" | "attention" => Self::Warning,
"failure" | "fail" | "missing" => Self::Failure,
"danger" | "error" => Self::Danger,
"bug" => Self::Bug,
"example" => Self::Example,
"quote" | "cite" => Self::Quote,
_ => return None,
})
}
pub fn icon(self) -> &'static str {
match self {
Self::Note => "✎",
Self::Abstract => "≡",
Self::Info => "ⓘ",
Self::Todo => "○",
Self::Tip => "★",
Self::Success => "✓",
Self::Question => "?",
Self::Warning => "⚠",
Self::Failure => "✗",
Self::Danger => "⚡",
Self::Bug => "⊗",
Self::Example => "❯",
Self::Quote => "❝",
}
}
pub fn default_title(self) -> &'static str {
match self {
Self::Note => "Note",
Self::Abstract => "Abstract",
Self::Info => "Info",
Self::Todo => "Todo",
Self::Tip => "Tip",
Self::Success => "Success",
Self::Question => "Question",
Self::Warning => "Warning",
Self::Failure => "Failure",
Self::Danger => "Danger",
Self::Bug => "Bug",
Self::Example => "Example",
Self::Quote => "Quote",
}
}
pub fn color(self, theme: &EditorTheme) -> Color {
match self {
Self::Note | Self::Info | Self::Todo => theme.cyan,
Self::Abstract | Self::Tip | Self::Success => theme.green,
Self::Question => theme.yellow,
Self::Warning => theme.orange,
Self::Failure | Self::Danger | Self::Bug => theme.red,
Self::Example => theme.purple,
Self::Quote => theme.comment,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalloutInfo {
pub kind: CalloutKind,
pub title: String,
pub foldable: bool,
pub default_collapsed: bool,
pub header_line: usize,
pub byte_offset: usize,
pub end_line: usize,
pub depth: usize,
}
impl CalloutInfo {
pub fn body_extent(&self) -> Range<usize> {
(self.header_line + 1)..self.end_line.max(self.header_line + 1)
}
pub fn is_foldable(&self) -> bool {
self.foldable && self.end_line > self.header_line + 1
}
}
pub fn parse_callout_header(line: &str) -> Option<(CalloutKind, String, bool, bool)> {
let s = line.trim_start();
let s = s.strip_prefix('>')?;
let s = s.strip_prefix(' ').unwrap_or(s);
let s = s.strip_prefix("[!")?;
let close = s.find(']')?;
let label = &s[..close];
if label.is_empty() {
return None;
}
let kind = CalloutKind::from_label(label)?;
let after = &s[close + 1..];
let (foldable, default_collapsed, rest) = match after.as_bytes().first() {
Some(b'-') => (true, true, &after[1..]),
Some(b'+') => (true, false, &after[1..]),
_ => (false, false, after),
};
let custom = rest.trim();
let title = if custom.is_empty() {
kind.default_title().to_string()
} else {
custom.to_string()
};
Some((kind, title, foldable, default_collapsed))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aliases_resolve() {
assert_eq!(CalloutKind::from_label("note"), Some(CalloutKind::Note));
assert_eq!(
CalloutKind::from_label("WARNING"),
Some(CalloutKind::Warning)
);
assert_eq!(CalloutKind::from_label("tldr"), Some(CalloutKind::Abstract));
assert_eq!(
CalloutKind::from_label("caution"),
Some(CalloutKind::Warning)
);
assert_eq!(CalloutKind::from_label("error"), Some(CalloutKind::Danger));
assert_eq!(CalloutKind::from_label("nonsense"), None);
}
#[test]
fn parses_plain() {
let (kind, title, foldable, collapsed) = parse_callout_header("> [!note]").unwrap();
assert_eq!(kind, CalloutKind::Note);
assert_eq!(title, "Note");
assert!(!foldable);
assert!(!collapsed);
}
#[test]
fn parses_custom_title() {
let (kind, title, _, _) = parse_callout_header("> [!warning] Be careful now").unwrap();
assert_eq!(kind, CalloutKind::Warning);
assert_eq!(title, "Be careful now");
}
#[test]
fn parses_fold_signs() {
let (_, _, foldable, collapsed) = parse_callout_header("> [!tip]- Collapsed").unwrap();
assert!(foldable);
assert!(collapsed);
let (_, _, foldable, collapsed) = parse_callout_header("> [!tip]+ Expanded").unwrap();
assert!(foldable);
assert!(!collapsed);
}
#[test]
fn no_space_after_marker() {
assert!(parse_callout_header(">[!info]").is_some());
}
#[test]
fn indented_blockquote() {
assert_eq!(
parse_callout_header(" > [!success] Done").map(|t| t.0),
Some(CalloutKind::Success)
);
}
#[test]
fn not_a_callout() {
assert!(parse_callout_header("> just a normal quote").is_none());
assert!(parse_callout_header("> [!] empty type").is_none());
assert!(parse_callout_header("no blockquote").is_none());
assert!(parse_callout_header("> [!mystery] unknown type").is_none());
}
#[test]
fn extent_and_foldability() {
let c = CalloutInfo {
kind: CalloutKind::Note,
title: "Note".into(),
foldable: true,
default_collapsed: false,
header_line: 3,
byte_offset: 300,
end_line: 6,
depth: 1,
};
assert_eq!(c.body_extent(), 4..6);
assert!(c.is_foldable());
let empty = CalloutInfo {
end_line: 4,
..c.clone()
};
assert_eq!(empty.body_extent(), 4..4);
assert!(!empty.is_foldable());
let plain = CalloutInfo {
foldable: false,
..c
};
assert!(!plain.is_foldable());
}
}