use crate::alerts::AlertBundle;
use egui::{FontFamily, RichText, TextBuffer, TextStyle, Ui, text::LayoutJob};
use std::collections::HashMap;
use crate::pulldown::ScrollableCache;
#[cfg(feature = "better_syntax_highlighting")]
use syntect::{
easy::HighlightLines,
highlighting::{Theme, ThemeSet},
parsing::{SyntaxDefinition, SyntaxSet},
util::LinesWithEndings,
};
#[cfg(feature = "better_syntax_highlighting")]
const DEFAULT_THEME_LIGHT: &str = "base16-ocean.light";
#[cfg(feature = "better_syntax_highlighting")]
const DEFAULT_THEME_DARK: &str = "base16-ocean.dark";
pub struct CommonMarkOptions<'f> {
pub indentation_spaces: usize,
pub max_image_width: Option<usize>,
pub show_alt_text_on_hover: bool,
pub default_width: Option<usize>,
#[cfg(feature = "better_syntax_highlighting")]
pub theme_light: String,
#[cfg(feature = "better_syntax_highlighting")]
pub theme_dark: String,
pub use_explicit_uri_scheme: bool,
pub default_implicit_uri_scheme: String,
pub alerts: AlertBundle,
pub mutable: bool,
pub math_fn: Option<&'f crate::RenderMathFn>,
pub html_fn: Option<&'f crate::RenderHtmlFn>,
}
impl std::fmt::Debug for CommonMarkOptions<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut s = f.debug_struct("CommonMarkOptions");
s.field("indentation_spaces", &self.indentation_spaces)
.field("max_image_width", &self.max_image_width)
.field("show_alt_text_on_hover", &self.show_alt_text_on_hover)
.field("default_width", &self.default_width);
#[cfg(feature = "better_syntax_highlighting")]
s.field("theme_light", &self.theme_light)
.field("theme_dark", &self.theme_dark);
s.field("use_explicit_uri_scheme", &self.use_explicit_uri_scheme)
.field(
"default_implicit_uri_scheme",
&self.default_implicit_uri_scheme,
)
.field("alerts", &self.alerts)
.field("mutable", &self.mutable)
.finish()
}
}
impl Default for CommonMarkOptions<'_> {
fn default() -> Self {
Self {
indentation_spaces: 4,
max_image_width: None,
show_alt_text_on_hover: true,
default_width: None,
#[cfg(feature = "better_syntax_highlighting")]
theme_light: DEFAULT_THEME_LIGHT.to_owned(),
#[cfg(feature = "better_syntax_highlighting")]
theme_dark: DEFAULT_THEME_DARK.to_owned(),
use_explicit_uri_scheme: false,
default_implicit_uri_scheme: "file://".to_owned(),
alerts: AlertBundle::gfm(),
mutable: false,
math_fn: None,
html_fn: None,
}
}
}
impl CommonMarkOptions<'_> {
#[cfg(feature = "better_syntax_highlighting")]
pub fn curr_theme(&self, ui: &Ui) -> &str {
if ui.style().visuals.dark_mode {
&self.theme_dark
} else {
&self.theme_light
}
}
pub fn max_width(&self, ui: &Ui) -> f32 {
let max_image_width = self.max_image_width.unwrap_or(0) as f32;
let available_width = ui.available_width();
let max_width = max_image_width.max(available_width);
if let Some(default_width) = self.default_width {
if default_width as f32 > max_width {
default_width as f32
} else {
max_width
}
} else {
max_width
}
}
}
#[derive(Default, Clone)]
pub struct Style {
pub heading: Option<u8>,
pub strong: bool,
pub emphasis: bool,
pub strikethrough: bool,
pub quote: bool,
pub code: bool,
}
impl Style {
pub fn to_richtext(&self, ui: &Ui, text: &str) -> RichText {
let mut text = RichText::new(text);
if let Some(level) = self.heading {
let body_size = ui
.style()
.text_styles
.get(&TextStyle::Body)
.map_or(15.0, |d| d.size);
match level {
0 => {
text = text.strong().size(29.0);
}
1 => {
text = text.strong().size(29.0);
}
2 => {
text = text.strong().size(20.0);
}
3 => {
text = text.size(20.0);
}
4 => {
text = text.strong().size(body_size);
}
5.. => {
text = text.size(body_size);
}
}
}
if self.quote {
text = text.weak();
}
if self.strong {
text = text.strong();
text = text.family(FontFamily::Name("IosevkaGorbieBold".into()));
}
if self.emphasis {
text = text.italics();
}
if self.strikethrough {
text = text.strikethrough();
}
if self.code {
text = text.code();
}
text
}
}
#[derive(Default)]
pub struct Link {
pub destination: String,
pub text: Vec<RichText>,
}
impl Link {
pub fn end(self, ui: &mut Ui, cache: &mut CommonMarkCache) {
let Self { destination, text } = self;
let mut layout_job = LayoutJob::default();
for t in text {
t.append_to(
&mut layout_job,
ui.style(),
egui::FontSelection::Default,
egui::Align::LEFT,
);
}
if cache.link_hooks().contains_key(&destination) {
let ui_link = ui.link(layout_job);
if ui_link.clicked() || ui_link.middle_clicked() {
cache.link_hooks_mut().insert(destination, true);
}
} else {
ui.hyperlink_to(layout_job, destination);
}
}
}
pub struct Image {
pub uri: String,
pub alt_text: Vec<RichText>,
}
impl Image {
pub fn new(uri: &str, options: &CommonMarkOptions) -> Self {
let has_scheme = uri.contains("://") || uri.starts_with("data:");
let uri = if options.use_explicit_uri_scheme || has_scheme {
uri.to_string()
} else {
format!("{}{uri}", options.default_implicit_uri_scheme)
};
Self {
uri,
alt_text: Vec::new(),
}
}
pub fn end(self, ui: &mut Ui, options: &CommonMarkOptions) {
let corner_radius = egui::CornerRadius::same(16);
let stroke = ui.visuals().widgets.noninteractive.bg_stroke;
let image = egui::Image::from_uri(&self.uri)
.fit_to_original_size(1.0)
.max_width(options.max_width(ui))
.corner_radius(corner_radius)
.show_loading_spinner(false);
let tlr = image.load_for_size(ui.ctx(), ui.available_size());
let image_source_size = tlr.as_ref().ok().and_then(|t| t.size());
let placeholder_size = image_placeholder_size(ui, options);
let ui_size = match &tlr {
Ok(egui::load::TexturePoll::Pending { .. }) => placeholder_size,
Ok(_) => image.calc_size(ui.available_size(), image_source_size),
Err(_) => placeholder_size,
};
let (rect, response) = ui.allocate_exact_size(ui_size, egui::Sense::hover());
if ui.is_rect_visible(rect) {
match &tlr {
Ok(egui::load::TexturePoll::Ready { texture }) => {
egui::paint_texture_at(ui.painter(), rect, image.image_options(), texture);
}
Ok(egui::load::TexturePoll::Pending { .. }) => {
paint_image_placeholder(ui, rect, corner_radius, stroke);
}
Err(_) => {
image.paint_at(ui, rect);
}
}
ui.painter().rect_stroke(
rect,
corner_radius,
stroke,
egui::StrokeKind::Inside,
);
}
if !self.alt_text.is_empty() && options.show_alt_text_on_hover {
response.on_hover_ui_at_pointer(|ui| {
for alt in self.alt_text {
ui.label(alt);
}
});
}
}
}
fn image_placeholder_size(ui: &Ui, options: &CommonMarkOptions) -> egui::Vec2 {
let mut width = ui.max_rect().width();
if let Some(max_width) = options.max_image_width {
width = width.min(max_width as f32);
}
if let Some(default_width) = options.default_width {
width = width.max(default_width as f32);
}
if !width.is_finite() || width <= 0.0 {
width = 256.0;
}
let height = (width * 0.5).max(1.0);
egui::vec2(width, height)
}
fn paint_image_placeholder(
ui: &Ui,
rect: egui::Rect,
corner_radius: egui::CornerRadius,
stroke: egui::Stroke,
) {
let fill = ui.visuals().widgets.noninteractive.bg_fill;
ui.painter().rect_filled(rect, corner_radius, fill);
let hatch_color = egui::Color32::from_rgba_unmultiplied(
stroke.color.r(),
stroke.color.g(),
stroke.color.b(),
80,
);
let hatch_stroke = egui::Stroke::new(1.0, hatch_color);
let inset = (corner_radius.average() * 0.5).max(2.0);
let hatch_rect = rect.shrink(inset);
if hatch_rect.width() <= 0.0 || hatch_rect.height() <= 0.0 {
return;
}
let painter = ui.painter().with_clip_rect(hatch_rect);
let spacing = 12.0;
let mut x = hatch_rect.left() - hatch_rect.height();
while x < hatch_rect.right() {
let start = egui::pos2(x, hatch_rect.top());
let end = egui::pos2(x + hatch_rect.height(), hatch_rect.bottom());
painter.line_segment([start, end], hatch_stroke);
x += spacing;
}
}
pub struct CodeBlock {
pub lang: Option<String>,
pub content: String,
}
impl CodeBlock {
pub fn end(
&self,
ui: &mut Ui,
cache: &mut CommonMarkCache,
options: &CommonMarkOptions,
max_width: f32,
) {
ui.scope(|ui| {
Self::pre_syntax_highlighting(cache, options, ui);
let mut layout = |ui: &Ui, string: &dyn TextBuffer, wrap_width: f32| {
let mut job = if let Some(lang) = &self.lang {
self.syntax_highlighting(cache, options, lang, ui, string.as_str())
} else {
plain_highlighting(ui, string.as_str())
};
job.wrap.max_width = wrap_width;
ui.fonts_mut(|f| f.layout_job(job))
};
crate::elements::code_block(ui, max_width, &self.content, &mut layout);
});
}
}
#[cfg(not(feature = "better_syntax_highlighting"))]
impl CodeBlock {
fn pre_syntax_highlighting(
_cache: &mut CommonMarkCache,
_options: &CommonMarkOptions,
ui: &mut Ui,
) {
ui.style_mut().visuals.extreme_bg_color = ui.visuals().extreme_bg_color;
}
fn syntax_highlighting(
&self,
_cache: &mut CommonMarkCache,
_options: &CommonMarkOptions,
extension: &str,
ui: &Ui,
text: &str,
) -> egui::text::LayoutJob {
simple_highlighting(ui, text, extension)
}
}
#[cfg(feature = "better_syntax_highlighting")]
impl CodeBlock {
fn pre_syntax_highlighting(
cache: &mut CommonMarkCache,
options: &CommonMarkOptions,
ui: &mut Ui,
) {
let curr_theme = cache.curr_theme(ui, options);
let style = ui.style_mut();
style.visuals.extreme_bg_color = curr_theme
.settings
.background
.map(syntect_color_to_egui)
.unwrap_or(style.visuals.extreme_bg_color);
if let Some(color) = curr_theme.settings.selection_foreground {
style.visuals.selection.bg_fill = syntect_color_to_egui(color);
}
}
fn syntax_highlighting(
&self,
cache: &CommonMarkCache,
options: &CommonMarkOptions,
extension: &str,
ui: &Ui,
text: &str,
) -> egui::text::LayoutJob {
if let Some(syntax) = cache.ps.find_syntax_by_extension(extension) {
let mut job = egui::text::LayoutJob::default();
let mut h = HighlightLines::new(syntax, cache.curr_theme(ui, options));
for line in LinesWithEndings::from(text) {
let ranges = h.highlight_line(line, &cache.ps).unwrap();
for v in ranges {
let front = v.0.foreground;
job.append(
v.1,
0.0,
egui::TextFormat::simple(
TextStyle::Monospace.resolve(ui.style()),
syntect_color_to_egui(front),
),
);
}
}
job
} else {
simple_highlighting(ui, text, extension)
}
}
}
fn simple_highlighting(ui: &Ui, text: &str, extension: &str) -> egui::text::LayoutJob {
egui_extras::syntax_highlighting::highlight(
ui.ctx(),
ui.style(),
&egui_extras::syntax_highlighting::CodeTheme::from_style(ui.style()),
text,
extension,
)
}
fn plain_highlighting(ui: &Ui, text: &str) -> egui::text::LayoutJob {
let mut job = egui::text::LayoutJob::default();
job.append(
text,
0.0,
egui::TextFormat::simple(
TextStyle::Monospace.resolve(ui.style()),
ui.style().visuals.text_color(),
),
);
job
}
#[cfg(feature = "better_syntax_highlighting")]
fn syntect_color_to_egui(color: syntect::highlighting::Color) -> egui::Color32 {
egui::Color32::from_rgb(color.r, color.g, color.b)
}
#[cfg(feature = "better_syntax_highlighting")]
fn default_theme(ui: &Ui) -> &str {
if ui.style().visuals.dark_mode {
DEFAULT_THEME_DARK
} else {
DEFAULT_THEME_LIGHT
}
}
#[derive(Debug)]
pub struct CommonMarkCache {
#[cfg(feature = "better_syntax_highlighting")]
ps: SyntaxSet,
#[cfg(feature = "better_syntax_highlighting")]
ts: ThemeSet,
link_hooks: HashMap<String, bool>,
scroll: HashMap<egui::Id, ScrollableCache>,
pub(self) has_installed_loaders: bool,
}
#[allow(clippy::derivable_impls)]
impl Default for CommonMarkCache {
fn default() -> Self {
Self {
#[cfg(feature = "better_syntax_highlighting")]
ps: SyntaxSet::load_defaults_newlines(),
#[cfg(feature = "better_syntax_highlighting")]
ts: ThemeSet::load_defaults(),
link_hooks: HashMap::new(),
scroll: Default::default(),
has_installed_loaders: false,
}
}
}
impl CommonMarkCache {
#[cfg(feature = "better_syntax_highlighting")]
pub fn add_syntax_from_folder(&mut self, path: &str) {
let mut builder = self.ps.clone().into_builder();
let _ = builder.add_from_folder(path, true);
self.ps = builder.build();
}
#[cfg(feature = "better_syntax_highlighting")]
pub fn add_syntax_from_str(&mut self, s: &str, fallback_name: Option<&str>) {
let mut builder = self.ps.clone().into_builder();
let _ = SyntaxDefinition::load_from_str(s, true, fallback_name).map(|d| builder.add(d));
self.ps = builder.build();
}
#[cfg(feature = "better_syntax_highlighting")]
pub fn add_syntax_themes_from_folder(
&mut self,
path: impl AsRef<std::path::Path>,
) -> Result<(), syntect::LoadingError> {
self.ts.add_from_folder(path)
}
#[cfg(feature = "better_syntax_highlighting")]
pub fn add_syntax_theme_from_bytes(
&mut self,
name: impl Into<String>,
bytes: &[u8],
) -> Result<(), syntect::LoadingError> {
let mut cursor = std::io::Cursor::new(bytes);
self.ts
.themes
.insert(name.into(), ThemeSet::load_from_reader(&mut cursor)?);
Ok(())
}
pub fn clear_scrollable(&mut self) {
self.scroll.clear();
}
pub fn clear_scrollable_with_id(&mut self, source_id: impl std::hash::Hash) -> bool {
self.scroll.remove(&egui::Id::new(source_id)).is_some()
}
#[allow(rustdoc::broken_intra_doc_links)]
pub fn add_link_hook<S: Into<String>>(&mut self, name: S) {
self.link_hooks.insert(name.into(), false);
}
pub fn remove_link_hook(&mut self, name: &str) -> Option<bool> {
self.link_hooks.remove(name)
}
pub fn get_link_hook(&self, name: &str) -> Option<bool> {
self.link_hooks.get(name).copied()
}
pub fn link_hooks_clear(&mut self) {
self.link_hooks.clear();
}
pub fn link_hooks(&self) -> &HashMap<String, bool> {
&self.link_hooks
}
pub fn link_hooks_mut(&mut self) -> &mut HashMap<String, bool> {
&mut self.link_hooks
}
fn deactivate_link_hooks(&mut self) {
for v in self.link_hooks.values_mut() {
*v = false;
}
}
#[cfg(feature = "better_syntax_highlighting")]
fn curr_theme(&self, ui: &Ui, options: &CommonMarkOptions) -> &Theme {
self.ts
.themes
.get(options.curr_theme(ui))
.unwrap_or_else(|| &self.ts.themes[default_theme(ui)])
}
}
pub fn scroll_cache<'a>(cache: &'a mut CommonMarkCache, id: &egui::Id) -> &'a mut ScrollableCache {
if !cache.scroll.contains_key(id) {
cache.scroll.insert(*id, Default::default());
}
cache.scroll.get_mut(id).unwrap()
}
pub fn prepare_show(cache: &mut CommonMarkCache, ctx: &egui::Context) {
if !cache.has_installed_loaders {
#[cfg(feature = "embedded_image")]
crate::data_url_loader::install_loader(ctx);
egui_extras::install_image_loaders(ctx);
cache.has_installed_loaders = true;
}
cache.deactivate_link_hooks();
}