use eframe::egui;
use egui_commonmark::{CommonMarkCache, CommonMarkViewer};
use std::path::PathBuf;
use crate::core::mermaid::preprocess_mermaid_for_egui;
use crate::core::toc::{self, TocEntry};
use crate::core::watcher::Watch;
const UI_FONT_FAMILIES: &[&str] = &[
"SF Pro Text",
"SF Pro Display",
".AppleSystemUIFont",
"Helvetica Neue",
"Segoe UI Variable Text",
"Segoe UI",
"Cantarell",
"Ubuntu",
"Noto Sans",
"DejaVu Sans",
];
const MONO_FONT_FAMILIES: &[&str] = &[
"SF Mono",
"SFMono-Regular",
"Menlo",
"Cascadia Mono",
"Consolas",
"Noto Sans Mono",
"DejaVu Sans Mono",
"Liberation Mono",
];
fn preference(preferences: &[&str], name: &str) -> Option<usize> {
preferences
.iter()
.position(|candidate| candidate.eq_ignore_ascii_case(name))
}
const FALLBACK_FONT_FAMILIES: &[&str] = &[
"PingFang SC",
"Hiragino Sans",
"Hiragino Sans GB",
"Apple SD Gothic Neo",
"Microsoft YaHei",
"Yu Gothic",
"Malgun Gothic",
"Noto Sans CJK SC",
"Noto Sans CJK JP",
"Noto Sans CJK KR",
];
struct FontBudget {
bytes: u64,
faces: usize,
}
const FONT_BUDGET: FontBudget = FontBudget {
bytes: 64 * 1024 * 1024,
faces: 8,
};
struct Candidate {
rank: usize,
family: String,
path: std::path::PathBuf,
index: u32,
}
fn load_system_fonts(ctx: &egui::Context) {
let mut db = fontdb::Database::new();
db.load_system_fonts();
let mut ui: Vec<Candidate> = Vec::new();
let mut mono: Vec<Candidate> = Vec::new();
let mut fallbacks: Vec<Candidate> = Vec::new();
for face in db.faces() {
let source = match &face.source {
fontdb::Source::Binary(_) => continue,
fontdb::Source::File(path) | fontdb::Source::SharedFile(path, _) => path,
};
if face.weight != fontdb::Weight::NORMAL
|| face.style != fontdb::Style::Normal
|| face.stretch != fontdb::Stretch::Normal
{
continue;
}
let Some((family, _)) = face.families.first() else {
continue;
};
let candidate = |rank: usize| Candidate {
rank,
family: family.clone(),
path: source.clone(),
index: face.index,
};
let offer = |list: &mut Vec<Candidate>, preferences: &[&str]| {
if let Some(rank) = preference(preferences, family) {
match list.iter().position(|c| c.family == *family) {
Some(i) if rank < list[i].rank => list[i] = candidate(rank),
Some(_) => {}
None => list.push(candidate(rank)),
}
}
};
offer(&mut ui, UI_FONT_FAMILIES);
offer(&mut mono, MONO_FONT_FAMILIES);
offer(&mut fallbacks, FALLBACK_FONT_FAMILIES);
}
for list in [&mut ui, &mut mono, &mut fallbacks] {
list.sort_by_key(|c| c.rank);
}
let mut ordered: Vec<(&Candidate, Role)> = Vec::new();
ordered.extend(ui.iter().map(|c| (c, Role::Ui)));
ordered.extend(mono.iter().map(|c| (c, Role::Mono)));
ordered.extend(fallbacks.iter().map(|c| (c, Role::Fallback)));
ctx.set_fonts(build_font_definitions(&ordered, &FONT_BUDGET));
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Role {
Ui,
Mono,
Fallback,
}
fn build_font_definitions(
candidates: &[(&Candidate, Role)],
budget: &FontBudget,
) -> egui::FontDefinitions {
let mut fonts = egui::FontDefinitions::default();
let mut spent = 0_u64;
let mut loaded: Vec<String> = Vec::new();
let mut ui_key = None;
let mut mono_key = None;
for (candidate, role) in candidates {
let filled = match role {
Role::Ui => ui_key.is_some(),
Role::Mono => mono_key.is_some(),
Role::Fallback => false,
};
if filled {
continue;
}
let key = format!("{}#{}", candidate.path.display(), candidate.index);
if !fonts.font_data.contains_key(&key) {
if loaded.len() >= budget.faces {
continue;
}
let Some(data) = read_within(&candidate.path, budget.bytes - spent) else {
continue;
};
spent += data.len() as u64;
let mut font_data = egui::FontData::from_owned(data);
font_data.index = candidate.index;
fonts.font_data.insert(key.clone(), font_data.into());
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
fonts.families.entry(family).or_default().push(key.clone());
}
loaded.push(candidate.family.clone());
}
match role {
Role::Ui => ui_key = Some(key),
Role::Mono => mono_key = Some(key),
Role::Fallback => {}
}
}
crate::vlog!(
"fonts: {:.1} MB of a {:.0} MB budget for {} of at most {} system face(s): {}",
spent as f64 / 1_048_576.0,
budget.bytes as f64 / 1_048_576.0,
loaded.len(),
budget.faces,
loaded.join(", ")
);
for (family, chosen) in [
(egui::FontFamily::Proportional, ui_key),
(egui::FontFamily::Monospace, mono_key),
] {
if let Some(key) = chosen
&& let Some(list) = fonts.families.get_mut(&family)
{
list.retain(|existing| existing != &key);
list.insert(0, key);
}
}
fonts
}
fn read_within(path: &std::path::Path, limit: u64) -> Option<Vec<u8>> {
use std::io::Read as _;
let file = std::fs::File::open(path).ok()?;
let len = file.metadata().ok()?.len();
if len > limit {
return None;
}
let mut data = Vec::new();
file.take(len + 1).read_to_end(&mut data).ok()?;
(data.len() as u64 <= len).then_some(data)
}
fn render_simple_html(markdown: &str, base_dir: &std::path::Path) -> String {
use comrak::nodes::NodeValue;
use comrak::{Arena, Options, parse_document};
let arena = Arena::new();
let mut options = Options::default();
options.extension.table = true;
options.extension.strikethrough = true;
options.extension.autolink = true;
options.extension.tasklist = true;
options.extension.footnotes = true;
options.extension.front_matter_delimiter = Some("---".to_owned());
let root = parse_document(&arena, markdown, &options);
let mut replacements: Vec<(usize, usize, String)> = Vec::new();
for node in root.children() {
let data = node.data.borrow();
if let NodeValue::HtmlBlock(block) = &data.value {
let converted = html_to_markdown(&block.literal, base_dir);
if !converted.trim().is_empty() {
replacements.push((
data.sourcepos.start.line,
data.sourcepos.end.line,
converted,
));
}
}
}
if replacements.is_empty() {
return markdown.to_string();
}
replacements.sort_by_key(|(start, _, _)| *start);
let lines: Vec<&str> = markdown.lines().collect();
let mut out = String::with_capacity(markdown.len());
let mut line_no = 1usize;
let mut next = replacements.into_iter().peekable();
while line_no <= lines.len() {
match next.peek() {
Some((start, end, _)) if *start == line_no => {
let (_, end, converted) = next.next().expect("peeked");
out.push_str(converted.trim_end());
out.push('\n');
line_no = end + 1;
}
_ => {
out.push_str(lines[line_no - 1]);
out.push('\n');
line_no += 1;
}
}
}
out
}
fn html_to_markdown(html: &str, base_dir: &std::path::Path) -> String {
let mut out = String::new();
let mut text = String::new();
let mut heading: Option<usize> = None;
let flush = |out: &mut String, text: &mut String, heading: &mut Option<usize>| {
let body = decode_entities(&text.split_whitespace().collect::<Vec<_>>().join(" "));
text.clear();
if body.is_empty() {
return;
}
if let Some(level) = heading.take() {
out.push_str(&"#".repeat(level));
out.push(' ');
}
out.push_str(&escape_markdown(&body));
out.push_str("\n\n");
};
let bytes: Vec<char> = html.chars().collect();
let mut i = 0usize;
while i < bytes.len() {
if bytes[i] != '<' {
text.push(bytes[i]);
i += 1;
continue;
}
let Some((tag, consumed)) = parse_tag(&bytes[i..]) else {
text.push('<');
i += 1;
continue;
};
i += consumed;
let (name, closing) = (tag.name.as_str(), tag.closing);
match name {
"img" if !closing => {
flush(&mut out, &mut text, &mut heading);
if let Some(src) = tag.attribute("src") {
let alt = tag.attribute("alt").unwrap_or_default();
let width = tag
.attribute("width")
.and_then(|w| w.trim().parse::<f32>().ok());
let alt = escape_alt(&alt);
let original = format!("");
let resolved = rewrite_image_sized(
&alt,
&src,
&original,
base_dir,
width.filter(|w| *w > 0.0),
&crate::core::net::remote_image_data_uri,
);
out.push_str(&resolved);
out.push_str("\n\n");
}
}
"br" => text.push(' '),
"h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
flush(&mut out, &mut text, &mut heading);
if !closing {
heading = name[1..].parse::<usize>().ok();
}
}
"p" | "div" => flush(&mut out, &mut text, &mut heading),
_ => {}
}
}
flush(&mut out, &mut text, &mut heading);
out
}
struct Tag {
name: String,
closing: bool,
attributes: Vec<(String, String)>,
}
impl Tag {
fn attribute(&self, name: &str) -> Option<String> {
self.attributes
.iter()
.find(|(key, _)| key == name)
.map(|(_, value)| value.clone())
}
}
fn parse_tag(chars: &[char]) -> Option<(Tag, usize)> {
let mut i = 1usize; let closing = chars.get(i) == Some(&'/');
if closing {
i += 1;
}
let start = i;
while chars
.get(i)
.is_some_and(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
{
i += 1;
}
if i == start {
return None;
}
let name: String = chars[start..i]
.iter()
.collect::<String>()
.to_ascii_lowercase();
let mut attributes = Vec::new();
loop {
while chars.get(i).is_some_and(|c| c.is_whitespace()) {
i += 1;
}
match chars.get(i) {
None => return None, Some('>') => {
return Some((
Tag {
name,
closing,
attributes,
},
i + 1,
));
}
Some('/') => {
i += 1;
continue;
}
Some(_) => {}
}
let key_start = i;
while chars
.get(i)
.is_some_and(|c| !c.is_whitespace() && *c != '=' && *c != '>')
{
i += 1;
}
if i == key_start {
i += 1;
continue;
}
let key: String = chars[key_start..i]
.iter()
.collect::<String>()
.to_ascii_lowercase();
while chars.get(i).is_some_and(|c| c.is_whitespace()) {
i += 1;
}
if chars.get(i) != Some(&'=') {
attributes.push((key, String::new()));
continue;
}
i += 1;
while chars.get(i).is_some_and(|c| c.is_whitespace()) {
i += 1;
}
let Some(quote) = chars.get(i).copied().filter(|c| *c == '"' || *c == '\'') else {
while chars
.get(i)
.is_some_and(|c| !c.is_whitespace() && *c != '>')
{
i += 1;
}
continue;
};
i += 1;
let value_start = i;
while chars.get(i).is_some_and(|c| *c != quote) {
i += 1;
}
chars.get(i)?;
let value: String = chars[value_start..i].iter().collect();
i += 1;
attributes.push((key, decode_entities(&value)));
}
}
fn decode_entities(text: &str) -> String {
text.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("&", "&")
}
fn refused_image(alt: &str, src: &str) -> String {
let what = if alt.trim().is_empty() {
escape_markdown(src)
} else {
alt.to_string()
};
format!("\\[âš image not shown: {what}\\]")
}
fn escape_alt(text: &str) -> String {
escape_markdown(text)
}
fn escape_markdown(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let digits = text.chars().take_while(char::is_ascii_digit).count();
for (i, c) in text.chars().enumerate() {
let opens_a_block = (i == 0 && matches!(c, '>' | '-' | '+' | '=' | '|'))
|| (digits > 0 && i == digits && matches!(c, '.' | ')'));
if opens_a_block
|| matches!(
c,
'\\' | '`' | '*' | '_' | '[' | ']' | '(' | ')' | '#' | '!'
)
{
out.push('\\');
}
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
_ => out.push(c),
}
}
out
}
struct MarkdownTable {
header: Vec<Cell>,
rows: Vec<Vec<Cell>>,
alignments: Vec<comrak::nodes::TableAlignment>,
columns: usize,
}
type Cell = Vec<CellPiece>;
#[derive(Clone, Default)]
struct CellPiece {
text: String,
code: bool,
strong: bool,
emph: bool,
strikethrough: bool,
link: Option<String>,
}
impl MarkdownTable {
fn from_node<'a>(
node: &'a comrak::arena_tree::Node<'a, std::cell::RefCell<comrak::nodes::Ast>>,
) -> Option<Self> {
use comrak::nodes::NodeValue;
let NodeValue::Table(table) = &node.data.borrow().value else {
return None;
};
let (alignments, columns) = (table.alignments.clone(), table.num_columns);
let mut header = Vec::new();
let mut rows = Vec::new();
for row in node.children() {
let NodeValue::TableRow(is_header) = row.data.borrow().value else {
continue;
};
let cells: Vec<Cell> = row
.children()
.map(|cell| {
let mut pieces = Vec::new();
collect_inline(cell, &CellPiece::default(), &mut pieces);
pieces
})
.collect();
if is_header {
header = cells;
} else {
rows.push(cells);
}
}
Some(Self {
header,
rows,
alignments,
columns,
})
}
}
fn collect_inline<'a>(
node: &'a comrak::arena_tree::Node<'a, std::cell::RefCell<comrak::nodes::Ast>>,
inherited: &CellPiece,
out: &mut Vec<CellPiece>,
) {
use comrak::nodes::NodeValue;
let value = &node.data.borrow().value;
let mut style = inherited.clone();
match value {
NodeValue::Text(text) => {
out.push(CellPiece {
text: text.to_string(),
..style
});
return;
}
NodeValue::Code(code) => {
out.push(CellPiece {
text: code.literal.clone(),
code: true,
..style
});
return;
}
NodeValue::SoftBreak | NodeValue::LineBreak => {
out.push(CellPiece {
text: " ".to_string(),
..style
});
return;
}
NodeValue::Image(_) => {}
NodeValue::Strong => style.strong = true,
NodeValue::Emph => style.emph = true,
NodeValue::Strikethrough => style.strikethrough = true,
NodeValue::Link(link) => style.link = Some(link.url.clone()),
_ => {}
}
for child in node.children() {
collect_inline(child, &style, out);
}
}
fn cell_layout(
cell: &[CellPiece],
header: bool,
palette: &crate::core::style::Palette,
) -> egui::text::LayoutJob {
use crate::core::style::{BASE_FONT_SIZE, CODE_FONT_SCALE};
use egui::{FontFamily, FontId, TextFormat};
let colour = |c: crate::core::style::Rgb| egui::Color32::from_rgb(c[0], c[1], c[2]);
let body = FontId::new(BASE_FONT_SIZE, FontFamily::Proportional);
let mono = FontId::new(BASE_FONT_SIZE * CODE_FONT_SCALE, FontFamily::Monospace);
let plain = colour(if header { palette.strong } else { palette.fg });
let mut job = egui::text::LayoutJob::default();
for piece in cell {
let colour_for = if piece.link.is_some() {
colour(palette.link)
} else if piece.strong {
colour(palette.strong)
} else {
plain
};
job.append(
&piece.text,
0.0,
TextFormat {
font_id: if piece.code {
mono.clone()
} else {
body.clone()
},
color: colour_for,
background: if piece.code {
colour(palette.inline_code_bg)
} else {
egui::Color32::TRANSPARENT
},
italics: piece.emph,
underline: if piece.link.is_some() {
egui::Stroke::new(1.0, colour(palette.link))
} else {
egui::Stroke::NONE
},
strikethrough: if piece.strikethrough {
egui::Stroke::new(1.0, colour_for)
} else {
egui::Stroke::NONE
},
..Default::default()
},
);
}
job
}
const CELL_PADDING_X: f32 = 13.0;
const CELL_PADDING_Y: f32 = 6.0;
fn cell_width(ui: &egui::Ui, cell: &[CellPiece], palette: &crate::core::style::Palette) -> f32 {
let galley = ui.fonts_mut(|f| f.layout_job(cell_layout(cell, false, palette)));
galley.size().x + 2.0 * CELL_PADDING_X
}
fn show_table(ui: &mut egui::Ui, table: &MarkdownTable) {
use comrak::nodes::TableAlignment;
let palette = if ui.visuals().dark_mode {
&crate::core::style::DARK
} else {
&crate::core::style::LIGHT
};
let colour = |c: crate::core::style::Rgb| egui::Color32::from_rgb(c[0], c[1], c[2]);
let columns = table.columns;
if columns == 0 {
return;
}
fn cell_at(row: &[Cell], column: usize) -> &[CellPiece] {
row.get(column).map_or(&[][..], Vec::as_slice)
}
let mut widths: Vec<f32> = (0..columns)
.map(|column| {
std::iter::once(cell_at(&table.header, column))
.chain(table.rows.iter().map(|row| cell_at(row, column)))
.map(|cell| cell_width(ui, cell, palette))
.fold(0.0_f32, f32::max)
})
.collect();
let floor = 2.0 * CELL_PADDING_X + 1.0;
let total: f32 = widths.iter().sum();
let available = ui.available_width();
if total > available && total > 0.0 {
let ratio = available / total;
for width in &mut widths {
*width = (*width * ratio).max(floor);
}
}
let stroke = egui::Stroke::new(1.0, colour(palette.border));
let row_ui = |ui: &mut egui::Ui, row: &[Cell], header: bool| {
let height = (0..columns)
.map(|column| {
let job = cell_layout(cell_at(row, column), header, palette);
let width = widths[column] - 2.0 * CELL_PADDING_X;
let galley = ui.fonts_mut(|f| {
let mut job = job;
job.wrap.max_width = width;
f.layout_job(job)
});
galley.size().y
})
.fold(0.0_f32, f32::max);
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
for (column, width) in widths.iter().enumerate() {
let mut frame = egui::Frame::new()
.inner_margin(egui::Margin::symmetric(
CELL_PADDING_X as i8,
CELL_PADDING_Y as i8,
))
.stroke(stroke);
if header {
frame = frame.fill(colour(palette.code_bg));
}
frame.show(ui, |ui| {
ui.set_width(width - 2.0 * CELL_PADDING_X);
ui.set_min_height(height);
let align = match table.alignments.get(column) {
Some(TableAlignment::Center) => egui::Align::Center,
Some(TableAlignment::Right) => egui::Align::Max,
_ => egui::Align::Min,
};
ui.with_layout(egui::Layout::top_down(align), |ui| {
ui.add(egui::Label::new(cell_layout(
cell_at(row, column),
header,
palette,
)));
});
});
}
});
};
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 0.0;
row_ui(ui, &table.header, true);
for row in &table.rows {
row_ui(ui, row, false);
}
});
}
fn split_tables(section: &str) -> Vec<Segment<'_>> {
use comrak::nodes::NodeValue;
use comrak::{Arena, Options, parse_document};
let arena = Arena::new();
let mut options = Options::default();
options.extension.table = true;
options.extension.strikethrough = true;
options.extension.autolink = true;
options.extension.tasklist = true;
options.extension.footnotes = true;
let root = parse_document(&arena, section, &options);
let mut ranges: Vec<(usize, usize, MarkdownTable)> = Vec::new();
for node in root.children() {
let data = node.data.borrow();
if matches!(data.value, NodeValue::Table(_)) {
drop(data);
if let Some(table) = MarkdownTable::from_node(node) {
let data = node.data.borrow();
ranges.push((data.sourcepos.start.line, data.sourcepos.end.line, table));
}
}
}
if ranges.is_empty() {
return vec![Segment::Markdown(section)];
}
let mut starts = Vec::with_capacity(section.lines().count() + 1);
let mut at = 0usize;
for line in section.split_inclusive('\n') {
starts.push(at);
at += line.len();
}
starts.push(section.len());
let line_start = |line: usize| starts.get(line - 1).copied().unwrap_or(section.len());
let line_end = |line: usize| starts.get(line).copied().unwrap_or(section.len());
let mut segments = Vec::new();
let mut cursor = 0usize;
for (start, end, table) in ranges {
let from = line_start(start);
let to = line_end(end);
if from > cursor && !section[cursor..from].trim().is_empty() {
segments.push(Segment::Markdown(§ion[cursor..from]));
}
segments.push(Segment::Table(table));
cursor = to;
}
if cursor < section.len() && !section[cursor..].trim().is_empty() {
segments.push(Segment::Markdown(§ion[cursor..]));
}
segments
}
enum Segment<'a> {
Markdown(&'a str),
Table(MarkdownTable),
}
const CONTENT_WIDTH: f32 = 900.0;
fn viewer<'a>() -> CommonMarkViewer<'a> {
CommonMarkViewer::new()
.syntax_theme_dark("base16-ocean.dark")
.syntax_theme_light("InspiredGitHub")
}
fn underlined_heading(section: &str) -> Option<(&str, &str)> {
use comrak::nodes::NodeValue;
use comrak::{Arena, Options, parse_document};
let arena = Arena::new();
let root = parse_document(&arena, section, &Options::default());
let first = root.first_child()?;
let data = first.data.borrow();
let NodeValue::Heading(heading) = data.value else {
return None;
};
if heading.level > 2 {
return None;
}
let end_line = data.sourcepos.end.line;
let mut offset = 0usize;
for (n, line) in section.split_inclusive('\n').enumerate() {
offset += line.len();
if n + 1 == end_line {
let head = section[..offset].trim_end_matches('\n');
return Some((head, §ion[offset..]));
}
}
None
}
fn toggle_theme(ctx: &egui::Context) {
ctx.set_theme(match ctx.theme() {
egui::Theme::Dark => egui::ThemePreference::Light,
egui::Theme::Light => egui::ThemePreference::Dark,
});
}
fn apply_theme_preference(ctx: &egui::Context, setting: crate::core::Theme) {
ctx.set_theme(match setting {
crate::core::Theme::Dark => egui::ThemePreference::Dark,
crate::core::Theme::Light => egui::ThemePreference::Light,
crate::core::Theme::Auto => egui::ThemePreference::System,
});
}
fn apply_style(ctx: &egui::Context) {
use crate::core::style::{self, BASE_FONT_SIZE, CODE_FONT_SCALE};
use egui::{FontFamily, FontId, TextStyle};
let colour = |c: style::Rgb| egui::Color32::from_rgb(c[0], c[1], c[2]);
ctx.all_styles_mut(|s| {
s.text_styles = [
(
TextStyle::Small,
FontId::new(BASE_FONT_SIZE * 0.875, FontFamily::Proportional),
),
(
TextStyle::Body,
FontId::new(BASE_FONT_SIZE, FontFamily::Proportional),
),
(
TextStyle::Button,
FontId::new(BASE_FONT_SIZE, FontFamily::Proportional),
),
(
TextStyle::Heading,
FontId::new(style::heading_size(1), FontFamily::Proportional),
),
(
TextStyle::Monospace,
FontId::new(BASE_FONT_SIZE * CODE_FONT_SCALE, FontFamily::Monospace),
),
]
.into();
});
apply_theme_preference(ctx, crate::core::theme());
for (theme, palette) in [
(egui::Theme::Dark, &style::DARK),
(egui::Theme::Light, &style::LIGHT),
] {
ctx.style_mut_of(theme, |s| {
let v = &mut s.visuals;
v.panel_fill = colour(palette.bg);
v.window_fill = colour(palette.bg);
v.extreme_bg_color = colour(palette.code_bg);
v.code_bg_color = colour(palette.inline_code_bg);
v.hyperlink_color = colour(palette.link);
v.widgets.noninteractive.fg_stroke.color = colour(palette.fg);
v.widgets.inactive.fg_stroke.color = colour(palette.fg);
v.widgets.hovered.fg_stroke.color = colour(palette.strong);
v.widgets.active.fg_stroke.color = colour(palette.strong);
v.widgets.noninteractive.bg_stroke.color = colour(palette.border);
v.weak_text_color = Some(colour(palette.muted));
});
}
}
pub fn run(file_path: PathBuf) -> Result<(), Box<dyn std::error::Error>> {
let canonical_file = std::fs::canonicalize(&file_path).unwrap_or_else(|_| {
std::env::current_dir().map_or_else(|_| file_path.clone(), |cwd| cwd.join(&file_path))
});
let base_dir = crate::core::document_base_dir(&canonical_file);
let raw_markdown = std::fs::read_to_string(&file_path)
.unwrap_or_else(|e| format!("# Error\nCould not read `{}`: {}", file_path.display(), e));
let markdown = preprocess_mermaid_for_egui(&raw_markdown);
let markdown = render_simple_html(&markdown, &base_dir);
let markdown = resolve_local_image_paths(&markdown, &base_dir);
let toc_entries = toc::extract_toc(&markdown);
let (has_preamble, sections) = split_by_headings(&markdown);
let watch = crate::core::watcher::watch_file(&file_path)?;
let (icon_rgba, icon_w, icon_h) = crate::core::icon::load_icon_rgba();
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([1100.0, 900.0])
.with_title(format!("mdr - {}", file_path.display()))
.with_icon(egui::IconData {
rgba: icon_rgba,
width: icon_w,
height: icon_h,
}),
..Default::default()
};
eframe::run_native(
"mdr",
options,
Box::new(move |cc| {
load_system_fonts(&cc.egui_ctx);
apply_style(&cc.egui_ctx);
Ok(Box::new(MdrApp {
markdown,
sections,
has_preamble,
caches: Vec::new(),
file_path,
base_dir,
watch,
toc_entries,
scroll_to_section: None,
search_active: false,
search_query: String::new(),
search_section_matches: Vec::new(),
current_match: 0,
toc_visible: true,
focus_search: false,
}))
}),
)
.map_err(|e| e.to_string().into())
}
fn heading_start_lines(markdown: &str) -> Vec<usize> {
use comrak::nodes::NodeValue;
use comrak::{Arena, Options, parse_document};
let arena = Arena::new();
let mut options = Options::default();
options.extension.strikethrough = true;
options.extension.table = true;
options.extension.autolink = true;
options.extension.tasklist = true;
options.extension.footnotes = true;
options.extension.front_matter_delimiter = Some("---".to_string());
let root = parse_document(&arena, markdown, &options);
let mut lines = Vec::new();
for node in root.descendants() {
let data = node.data.borrow();
if matches!(data.value, NodeValue::Heading(_)) {
lines.push(data.sourcepos.start.line);
}
}
lines.sort_unstable();
lines
}
fn split_by_headings(markdown: &str) -> (bool, Vec<String>) {
let starts = heading_start_lines(markdown);
let mut next_start = starts.iter().copied().peekable();
let mut sections: Vec<String> = Vec::new();
let mut current = String::new();
for (index, line) in markdown.lines().enumerate() {
let lineno = index + 1;
if next_start.peek() == Some(&lineno) {
next_start.next();
if !current.is_empty() {
sections.push(std::mem::take(&mut current));
}
}
current.push_str(line);
current.push('\n');
}
if !current.is_empty() {
sections.push(current);
}
let has_preamble = sections.len() > starts.len();
(has_preamble, sections)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Action {
Quit,
ToggleToc,
ToggleTheme,
OpenSearch,
CloseSearch,
ScrollUp,
ScrollDown,
PageUp,
PageDown,
GoTop,
GoBottom,
}
const SCROLL_STEP: f32 = 64.0;
const PAGE_STEP: f32 = 600.0;
const SCROLL_TO_END: f32 = 1.0e9;
fn key_action(key: egui::Key, modifiers: egui::Modifiers, search_open: bool) -> Option<Action> {
use egui::Key;
if modifiers.command {
return match key {
Key::Q | Key::W => Some(Action::Quit),
Key::F => Some(if search_open {
Action::CloseSearch
} else {
Action::OpenSearch
}),
_ => None,
};
}
if modifiers.alt || modifiers.ctrl || modifiers.mac_cmd {
return None;
}
if key == Key::Escape {
return Some(if search_open {
Action::CloseSearch
} else {
Action::Quit
});
}
if key == Key::F10 {
return Some(Action::ToggleToc);
}
if search_open {
return None;
}
if modifiers.shift {
return (key == Key::G).then_some(Action::GoBottom);
}
match key {
Key::Q => Some(Action::Quit),
Key::T => Some(Action::ToggleTheme),
Key::ArrowDown | Key::J => Some(Action::ScrollDown),
Key::ArrowUp | Key::K => Some(Action::ScrollUp),
Key::PageDown | Key::Space => Some(Action::PageDown),
Key::PageUp => Some(Action::PageUp),
Key::Home | Key::G => Some(Action::GoTop),
Key::End => Some(Action::GoBottom),
_ => None,
}
}
fn frame_actions(ctx: &egui::Context, search_open: bool) -> Vec<Action> {
ctx.input(|i| {
i.events
.iter()
.filter_map(|event| match event {
egui::Event::Key {
key,
pressed: true,
modifiers,
..
} => key_action(*key, *modifiers, search_open),
_ => None,
})
.collect()
})
}
struct MdrApp {
markdown: String,
sections: Vec<String>,
has_preamble: bool,
caches: Vec<CommonMarkCache>,
file_path: PathBuf,
base_dir: PathBuf,
watch: Watch,
toc_entries: Vec<TocEntry>,
scroll_to_section: Option<usize>,
search_active: bool,
search_query: String,
search_section_matches: Vec<usize>,
current_match: usize,
toc_visible: bool,
focus_search: bool,
}
impl eframe::App for MdrApp {
fn ui(&mut self, root_ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = root_ui.ctx().clone();
ctx.global_style_mut(|s| s.interaction.selectable_labels = true);
if self.watch.changes().try_recv().is_ok() {
while self.watch.changes().try_recv().is_ok() {}
if let Ok(content) = std::fs::read_to_string(&self.file_path) {
self.markdown = preprocess_mermaid_for_egui(&content);
self.markdown = render_simple_html(&self.markdown, &self.base_dir);
self.markdown = resolve_local_image_paths(&self.markdown, &self.base_dir);
self.toc_entries = toc::extract_toc(&self.markdown);
let (has_preamble, sections) = split_by_headings(&self.markdown);
self.has_preamble = has_preamble;
self.sections = sections;
self.caches.clear();
}
}
while self.caches.len() < self.sections.len() {
self.caches.push(CommonMarkCache::default());
}
let search_open = self.search_active || ctx.text_edit_focused();
let mut scroll_delta = 0.0_f32;
let mut scroll_to_offset: Option<f32> = None;
for action in frame_actions(&ctx, search_open) {
match action {
Action::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
Action::ToggleToc => self.toc_visible = !self.toc_visible,
Action::ToggleTheme => toggle_theme(&ctx),
Action::OpenSearch => {
self.search_active = true;
self.focus_search = true;
}
Action::CloseSearch => {
self.search_active = false;
self.focus_search = false;
self.search_query.clear();
self.search_section_matches.clear();
}
Action::ScrollDown => scroll_delta -= SCROLL_STEP,
Action::ScrollUp => scroll_delta += SCROLL_STEP,
Action::PageDown => scroll_delta -= PAGE_STEP,
Action::PageUp => scroll_delta += PAGE_STEP,
Action::GoTop => scroll_to_offset = Some(0.0),
Action::GoBottom => scroll_to_offset = Some(SCROLL_TO_END),
}
}
if self.search_active {
egui::Panel::top("search_bar").show(root_ui, |ui| {
ui.horizontal(|ui| {
ui.label("Search:");
let response = ui.text_edit_singleline(&mut self.search_query);
if response.changed() {
self.search_section_matches.clear();
self.current_match = 0;
if !self.search_query.is_empty() {
let query_lower = self.search_query.to_lowercase();
for (i, section) in self.sections.iter().enumerate() {
if section.to_lowercase().contains(&query_lower) {
self.search_section_matches.push(i);
}
}
if !self.search_section_matches.is_empty() {
self.scroll_to_section = Some(self.search_section_matches[0]);
}
}
}
if self.focus_search {
self.focus_search = false;
response.request_focus();
}
let match_text = if self.search_section_matches.is_empty() {
if self.search_query.is_empty() {
String::new()
} else {
"No matches".to_string()
}
} else {
format!(
"{}/{}",
self.current_match + 1,
self.search_section_matches.len()
)
};
ui.label(&match_text);
if (ui.button("\u{25B2}").clicked()
|| (ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.shift)
&& self.search_active))
&& !self.search_section_matches.is_empty()
{
self.current_match = if self.current_match == 0 {
self.search_section_matches.len() - 1
} else {
self.current_match - 1
};
self.scroll_to_section =
Some(self.search_section_matches[self.current_match]);
}
if (ui.button("\u{25BC}").clicked()
|| (ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift)
&& self.search_active))
&& !self.search_section_matches.is_empty()
{
self.current_match =
(self.current_match + 1) % self.search_section_matches.len();
self.scroll_to_section =
Some(self.search_section_matches[self.current_match]);
}
if ui
.button(if self.toc_visible {
"Hide TOC"
} else {
"Show TOC"
})
.clicked()
{
self.toc_visible = !self.toc_visible;
}
if ui.button("\u{2715}").clicked() {
self.search_active = false;
self.search_query.clear();
self.search_section_matches.clear();
}
});
});
}
let has_preamble = self.has_preamble;
let scroll_target = &mut self.scroll_to_section;
if self.toc_visible {
egui::Panel::left("toc_panel")
.default_size(220.0)
.resizable(true)
.show(root_ui, |ui| {
use crate::core::style::{self, BASE_FONT_SIZE};
let palette = if ui.visuals().dark_mode {
&style::DARK
} else {
&style::LIGHT
};
let colour = |c: style::Rgb| egui::Color32::from_rgb(c[0], c[1], c[2]);
let muted = colour(palette.muted);
let fg = colour(palette.fg);
ui.add_space(4.0);
ui.label(
egui::RichText::new("TABLE OF CONTENTS")
.size(BASE_FONT_SIZE * 0.75)
.color(muted),
);
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
for (i, entry) in self.toc_entries.iter().enumerate() {
let indent = ((f32::from(entry.level) - 1.0) * 12.0).max(0.0);
ui.horizontal_top(|ui| {
ui.add_space(indent);
let text = egui::RichText::new(&entry.text);
let text = match entry.level {
1 => text.color(fg),
2 | 3 => text.size(BASE_FONT_SIZE * 0.875).color(fg),
_ => text.size(BASE_FONT_SIZE * 0.8125).color(muted),
};
if ui.link(text).clicked() {
let section_idx = if has_preamble { i + 1 } else { i };
*scroll_target = Some(section_idx);
}
});
ui.add_space(2.0);
}
});
});
}
let scroll_to = self.scroll_to_section.take();
egui::CentralPanel::default().show(root_ui, |ui| {
let mut area = egui::ScrollArea::vertical();
if let Some(offset) = scroll_to_offset {
area = area.vertical_scroll_offset(offset);
}
area.show(ui, |ui| {
if scroll_delta != 0.0 {
ui.scroll_with_delta(egui::vec2(0.0, scroll_delta));
}
ui.set_max_width(CONTENT_WIDTH.min(ui.available_width()));
for (i, section) in self.sections.iter().enumerate() {
let response = ui.allocate_response(egui::vec2(0.0, 0.0), egui::Sense::hover());
if scroll_to == Some(i) {
response.scroll_to_me(Some(egui::Align::TOP));
}
let anchor_id = ui.id().with(format!("section_{i}"));
ui.push_id(anchor_id, |ui| {
let cache = &mut self.caches[i];
let body = match underlined_heading(section) {
Some((heading, body)) => {
viewer().show(ui, cache, heading);
ui.add_space(2.0);
ui.separator();
ui.add_space(2.0);
body
}
None => section,
};
for segment in split_tables(body) {
match segment {
Segment::Markdown(text) => {
viewer().show(ui, cache, text);
}
Segment::Table(table) => show_table(ui, &table),
}
}
});
}
});
});
ctx.request_repaint_after(std::time::Duration::from_millis(500));
}
}
fn resolve_local_image_paths(markdown: &str, base_dir: &std::path::Path) -> String {
use std::sync::OnceLock;
static RE: OnceLock<regex::Regex> = OnceLock::new();
let re = RE.get_or_init(|| regex::Regex::new(r"!\[([^\]]*)\]\(([^)]+)\)").unwrap());
re.replace_all(markdown, |caps: ®ex::Captures| {
rewrite_image(
&caps[1],
&caps[2],
&caps[0],
base_dir,
&crate::core::net::remote_image_data_uri,
)
})
.to_string()
}
fn rewrite_image(
alt: &str,
src: &str,
original: &str,
base_dir: &std::path::Path,
fetch_remote: &dyn Fn(&str) -> Option<String>,
) -> String {
rewrite_image_sized(alt, src, original, base_dir, None, fetch_remote)
}
fn raster_data_uri(data_uri: &str, width: Option<f32>) -> String {
use base64::Engine;
let Some(rest) = data_uri.strip_prefix("data:image/svg+xml") else {
return data_uri.to_string();
};
let Some(payload) = rest.strip_prefix(";base64,") else {
return data_uri.to_string();
};
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(payload) else {
return data_uri.to_string();
};
let Ok(svg) = String::from_utf8(bytes) else {
return data_uri.to_string();
};
rasterize_svg_data(&svg, width).unwrap_or_else(|_| data_uri.to_string())
}
fn rewrite_image_sized(
alt: &str,
src: &str,
original: &str,
base_dir: &std::path::Path,
width: Option<f32>,
fetch_remote: &dyn Fn(&str) -> Option<String>,
) -> String {
if crate::core::net::is_remote_url(src) {
return match fetch_remote(src) {
Some(data_uri) => format!("", raster_data_uri(&data_uri, width)),
None => original.to_string(),
};
}
if src.starts_with("data:") {
return format!("", raster_data_uri(src, width));
}
if src.starts_with("file://") {
return original.to_string();
}
let abs_path = base_dir.join(src);
if !crate::core::paths::is_within_image_root(&abs_path, base_dir) {
return refused_image(alt, src);
}
if !abs_path.exists() {
return refused_image(alt, src);
}
if let Err(e) = crate::core::image_validation::validate_image_file(&abs_path) {
return format!(
"[⚠Invalid image: {} — {}]",
abs_path.file_name().unwrap_or_default().to_string_lossy(),
e
);
}
let is_svg = abs_path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("svg"));
if is_svg {
if let Ok(data_uri) = rasterize_svg_at(&abs_path, width) {
return format!("");
}
return refused_image(alt, src);
}
match file_to_data_uri(&abs_path) {
Ok(data_uri) => format!(""),
Err(_) => original.to_string(),
}
}
const MAX_IMAGE_FILE_SIZE: u64 = 100 * 1024 * 1024;
fn file_to_data_uri(path: &std::path::Path) -> Result<String, Box<dyn std::error::Error>> {
use base64::Engine;
let metadata = std::fs::metadata(path)?;
if metadata.len() > MAX_IMAGE_FILE_SIZE {
return Err(format!(
"image file too large ({} bytes, max {})",
metadata.len(),
MAX_IMAGE_FILE_SIZE
)
.into());
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let mime = match ext.to_lowercase().as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"bmp" => "image/bmp",
"ico" => "image/x-icon",
_ => "application/octet-stream",
};
let data = std::fs::read(path)?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
Ok(format!("data:{mime};base64,{b64}"))
}
fn rasterize_svg_at(
path: &std::path::Path,
target_width: Option<f32>,
) -> Result<String, Box<dyn std::error::Error>> {
rasterize_svg_data(&std::fs::read_to_string(path)?, target_width)
}
fn rasterize_svg_data(
svg_data: &str,
target_width: Option<f32>,
) -> Result<String, Box<dyn std::error::Error>> {
use base64::Engine;
const MAX_DIM: f32 = 8192.0;
let trimmed = svg_data.trim_start();
if (!trimmed.starts_with('<')
|| trimmed.starts_with("<!DOCTYPE html")
|| trimmed.starts_with("<html"))
&& !trimmed.contains("<svg")
{
return Err("File is not a valid SVG (possibly an HTML page)".into());
}
let options = crate::core::svg::options();
let tree = usvg::Tree::from_str(svg_data, &options)?;
let size = tree.size();
let svg_w = size.width();
let svg_h = size.height();
if svg_w <= 0.0 || svg_h <= 0.0 {
return Err("SVG has zero dimensions".into());
}
let ideal_scale = target_width.map_or(2.0_f32, |w| w / svg_w);
let max_scale_w = MAX_DIM / svg_w;
let max_scale_h = MAX_DIM / svg_h;
let scale = ideal_scale.min(max_scale_w).min(max_scale_h);
let width = (svg_w * scale) as u32;
let height = (svg_h * scale) as u32;
if width == 0 || height == 0 {
return Err("SVG too small after scaling".into());
}
let mut pixmap = tiny_skia::Pixmap::new(width, height).ok_or("Failed to create pixmap")?;
let transform = tiny_skia::Transform::from_scale(scale, scale);
resvg::render(&tree, transform, &mut pixmap.as_mut());
let png_data = pixmap.encode_png()?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&png_data);
Ok(format!("data:image/png;base64,{b64}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn table_of(section: &str) -> MarkdownTable {
for segment in split_tables(section) {
if let Segment::Table(table) = segment {
return table;
}
}
panic!("no table in {section:?}");
}
fn cell_text(cell: &[CellPiece]) -> String {
cell.iter().map(|p| p.text.as_str()).collect()
}
#[test]
fn ordinary_text_in_a_cell_is_left_alone() {
let table = table_of("| a | b |\n|---|---|\n| foo_bar | \\*literal\\* |\n");
assert_eq!(cell_text(&table.rows[0][0]), "foo_bar");
assert_eq!(cell_text(&table.rows[0][1]), "*literal*");
assert!(
!table.rows[0][1].iter().any(|p| p.emph),
"an escaped asterisk is not emphasis"
);
}
#[test]
fn a_link_keeps_its_label_and_its_destination() {
let table = table_of("| a |\n|---|\n| [guide](docs/a(b).md) |\n");
let cell = &table.rows[0][0];
assert_eq!(cell_text(cell), "guide");
assert_eq!(
cell.iter().find_map(|p| p.link.clone()),
Some("docs/a(b).md".to_string())
);
}
#[test]
fn an_empty_cell_at_the_edge_of_a_row_is_kept() {
let table = table_of("| a | b | c |\n|---|---|---|\n| | x | |\n");
assert_eq!(table.columns, 3);
assert_eq!(cell_text(&table.rows[0][0]), "");
assert_eq!(cell_text(&table.rows[0][1]), "x");
}
#[test]
fn the_column_count_comes_from_the_header() {
let table = table_of("| a | b |\n|---|---|\n| 1 | 2 | 3 |\n| 4 |\n");
assert_eq!(table.columns, 2);
}
#[test]
fn the_delimiter_row_alignments_are_kept() {
use comrak::nodes::TableAlignment;
let table = table_of("| a | b | c |\n|:--|:-:|--:|\n| 1 | 2 | 3 |\n");
assert_eq!(
table.alignments,
vec![
TableAlignment::Left,
TableAlignment::Center,
TableAlignment::Right
]
);
}
#[test]
fn a_cell_keeps_its_strikethrough() {
let table = table_of("| a |\n|---|\n| ~~not~~ supported |\n");
let cell = &table.rows[0][0];
assert_eq!(cell_text(cell), "not supported");
assert!(
cell.iter().any(|p| p.strikethrough && p.text == "not"),
"only the struck run should be struck: {:?}",
cell.iter()
.map(|p| (&p.text, p.strikethrough))
.collect::<Vec<_>>()
);
}
#[test]
fn a_cell_keeps_its_inline_code_and_emphasis() {
let table = table_of("| a |\n|---|\n| **`gui`** (default) |\n");
let cell = &table.rows[0][0];
assert_eq!(cell_text(cell), "gui (default)");
assert!(
cell.iter().any(|p| p.code && p.strong),
"the code span is inside the bold: {:?}",
cell.iter()
.map(|p| (&p.text, p.code, p.strong))
.collect::<Vec<_>>()
);
}
#[test]
fn a_pipe_inside_a_code_block_is_not_a_table() {
let section = "Text\n\n```sh\n| a | b |\n|---|---|\n```\n";
let segments = split_tables(section);
assert_eq!(segments.len(), 1);
assert!(matches!(segments[0], Segment::Markdown(_)));
}
#[test]
fn a_table_is_split_out_of_the_prose_around_it() {
let section = "Before\n\n| a | b |\n|---|---|\n| c | d |\n\nAfter\n";
let kinds: Vec<&str> = split_tables(section)
.iter()
.map(|s| match s {
Segment::Markdown(_) => "markdown",
Segment::Table(_) => "table",
})
.collect();
assert_eq!(kinds, ["markdown", "table", "markdown"]);
}
fn html(markdown: &str) -> String {
render_simple_html(markdown, std::path::Path::new("/nonexistent"))
}
fn with_one_image() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("b.png"),
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00],
)
.unwrap();
dir
}
#[test]
fn both_spellings_of_a_heading_get_their_rule() {
assert_eq!(
underlined_heading("# Title\n\nBody\n"),
Some(("# Title", "\nBody\n"))
);
assert_eq!(
underlined_heading("## Title\n\nBody\n"),
Some(("## Title", "\nBody\n"))
);
assert_eq!(
underlined_heading("Title\n=====\n\nBody\n"),
Some(("Title\n=====", "\nBody\n"))
);
assert_eq!(
underlined_heading("Title\n-----\n\nBody\n"),
Some(("Title\n-----", "\nBody\n"))
);
}
#[test]
fn a_fence_that_looks_like_a_setext_heading_is_not_one() {
let section = "```text\n---\ncontent\n```\n";
assert_eq!(underlined_heading(section), None);
let section = "A long\ntitle\n======\n\nBody\n";
let (head, body) = underlined_heading(section).expect("a Setext heading");
assert_eq!(head, "A long\ntitle\n======");
assert_eq!(body, "\nBody\n");
}
#[test]
fn a_deeper_heading_and_a_preamble_get_no_rule() {
assert_eq!(underlined_heading("### Title\n\nBody\n"), None);
assert_eq!(underlined_heading("Just prose\n\nmore\n"), None);
assert_eq!(underlined_heading("#nothashtag\n\nBody\n"), None);
}
#[test]
fn an_html_heading_becomes_a_markdown_heading() {
let out = html("<h1 align=\"center\">mdr — Markdown Reader</h1>\n");
assert_eq!(out.trim(), "# mdr — Markdown Reader");
}
#[test]
fn an_html_image_becomes_a_markdown_image() {
let out = render_simple_html(
"<p align=\"center\">\n <img src=\"b.png\" alt=\"mdr logo\"/>\n</p>\n",
with_one_image().path(),
);
assert!(out.starts_with(", "got {out}");
}
#[test]
fn an_html_paragraph_keeps_its_text() {
let out = html("<p align=\"center\">\n A fast Markdown viewer.\n</p>\n");
assert_eq!(out.trim(), "A fast Markdown viewer.");
}
#[test]
fn html_inside_a_code_block_is_left_alone() {
let source = "Before\n\n```html\n<h1 align=\"center\">Not a heading</h1>\n```\n\nAfter\n";
assert_eq!(html(source).trim(), source.trim());
}
#[test]
fn an_unhandled_tag_keeps_what_it_wrapped() {
let out = html("<div><span class=\"x\">kept</span></div>\n");
assert_eq!(out.trim(), "kept");
}
fn rendered_text(markdown: &str) -> String {
use comrak::nodes::NodeValue;
use comrak::{Arena, Options, parse_document};
let arena = Arena::new();
let root = parse_document(&arena, &html(markdown), &Options::default());
let mut out = String::new();
for node in root.descendants() {
match &node.data.borrow().value {
NodeValue::Text(text) => out.push_str(text),
NodeValue::Code(code) => out.push_str(&code.literal),
_ => {}
}
}
out
}
fn rendered_headings(markdown: &str) -> Vec<u8> {
use comrak::nodes::NodeValue;
use comrak::{Arena, Options, parse_document};
let arena = Arena::new();
let root = parse_document(&arena, &html(markdown), &Options::default());
root.descendants()
.filter_map(|n| match &n.data.borrow().value {
NodeValue::Heading(h) => Some(h.level),
_ => None,
})
.collect()
}
#[test]
fn markdown_characters_in_html_text_stay_literal() {
assert_eq!(
rendered_text("<p>*not emphasis* and _not either_</p>\n"),
"*not emphasis* and _not either_"
);
assert_eq!(
rendered_text("<p>[not a link](nowhere)</p>\n"),
"[not a link](nowhere)"
);
}
#[test]
fn a_hash_in_html_text_does_not_become_a_heading() {
assert_eq!(rendered_text("<p># not a heading</p>\n"), "# not a heading");
assert!(rendered_headings("<p># not a heading</p>\n").is_empty());
assert_eq!(rendered_headings("<h2>a heading</h2>\n"), vec![2]);
}
#[test]
fn an_html_entity_is_decoded_exactly_once() {
assert_eq!(rendered_text("<p>&lt;</p>\n"), "<");
assert_eq!(rendered_text("<p>Tom & Jerry</p>\n"), "Tom & Jerry");
}
#[test]
fn a_number_in_html_text_does_not_open_a_list() {
assert_eq!(rendered_text("<p>1. not a list</p>\n"), "1. not a list");
assert_eq!(
rendered_text("<p>12) not a list either</p>\n"),
"12) not a list either"
);
assert_eq!(rendered_text("<p>version 1. done</p>\n"), "version 1. done");
}
#[test]
fn an_svg_data_uri_becomes_a_raster_one() {
use base64::Engine;
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8" fill="red"/></svg>"#;
let encoded = base64::engine::general_purpose::STANDARD.encode(svg);
let out = raster_data_uri(&format!("data:image/svg+xml;base64,{encoded}"), None);
assert!(out.starts_with("data:image/png;base64,"), "got {out}");
}
#[test]
fn a_raster_data_uri_is_left_as_it_is() {
let png = "data:image/png;base64,iVBORw0KGgo=";
assert_eq!(raster_data_uri(png, None), png);
assert_eq!(
raster_data_uri("https://example.com/a.svg", None),
"https://example.com/a.svg"
);
}
#[test]
fn a_declared_width_resizes_the_drawing() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("logo.svg"),
r#"<svg xmlns="http://www.w3.org/2000/svg" width="600" height="600"><rect width="600" height="600" fill="red"/></svg>"#,
)
.unwrap();
fn width_of(markdown: &str) -> u32 {
use base64::Engine;
let start = markdown.find("base64,").expect("a data URI") + "base64,".len();
let end = markdown[start..].find(')').expect("a closing paren") + start;
let bytes = base64::engine::general_purpose::STANDARD
.decode(&markdown[start..end])
.expect("valid base64");
image::load_from_memory(&bytes).expect("a PNG").width()
}
let sized = render_simple_html(
r#"<p><img src="logo.svg" alt="l" width="60"/></p>"#,
dir.path(),
);
let natural = render_simple_html(r#"<p><img src="logo.svg" alt="l"/></p>"#, dir.path());
assert_eq!(
width_of(&sized),
60,
"the declared width should be honoured"
);
assert_eq!(width_of(&natural), 1200);
}
#[test]
fn an_image_in_html_goes_through_the_ordinary_resolver() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("not-really.svg"), b"<html>nope</html>").unwrap();
let out = render_simple_html(
r#"<p><img src="not-really.svg" alt="x" width="180"/></p>"#,
dir.path(),
);
assert!(
!out.contains("data:image"),
"a file that is not the image it claims must not be embedded: {out}"
);
}
#[test]
fn an_alt_keeps_the_author_s_words() {
let dir = with_one_image();
let out = render_simple_html(r#"<p><img src="b.png" alt="a [b] c"/></p>"#, dir.path());
assert!(out.starts_with(r"![a \[b\] c]"), "got {out}");
}
#[test]
fn a_refused_image_shows_its_alt_once_decoded() {
let out = render_simple_html(
r#"<p><img src="missing.png" alt="Tom & Jerry"/></p>"#,
std::path::Path::new("/nonexistent"),
);
assert!(out.contains("image not shown"), "got {out}");
assert_eq!(
rendered_text(r#"<p><img src="missing.png" alt="Tom & Jerry"/></p>"#),
"[âš image not shown: Tom & Jerry]"
);
}
#[test]
fn html_text_cannot_reopen_a_block() {
assert_eq!(rendered_text("<p>> not a quote</p>\n"), "> not a quote");
assert_eq!(rendered_text("<p>- not a list</p>\n"), "- not a list");
}
#[test]
fn a_custom_element_is_not_mistaken_for_a_known_one() {
let chars: Vec<char> = "<h1-title>".chars().collect();
let (tag, _) = parse_tag(&chars).expect("a tag");
assert_eq!(tag.name, "h1-title");
let source = "<div><h1-title>not a heading</h1-title></div>\n";
assert_eq!(rendered_headings(source), Vec::<u8>::new());
assert_eq!(rendered_text(source), "not a heading");
assert_eq!(rendered_headings("<h1>a heading</h1>\n"), vec![1]);
}
#[test]
fn an_attribute_is_read_as_an_attribute_not_searched_for() {
let dir = with_one_image();
let out = render_simple_html(
r#"<p><img title="old src='a.png'" src="b.png" alt="x"/></p>"#,
dir.path(),
);
assert!(out.contains("data:image/png"), "got {out}");
}
#[test]
fn a_greater_than_inside_a_value_does_not_end_the_tag() {
let dir = with_one_image();
let out = render_simple_html(r#"<p><img alt="a > b" src="b.png"/></p>"#, dir.path());
assert!(
out.contains("data:image/png"),
"the tag should have been read whole: {out}"
);
assert!(
out.starts_with("![a > b]"),
"the alt should be intact: {out}"
);
}
#[test]
fn an_html_block_inside_a_quote_is_left_alone() {
let source = "> <div>quoted</div>\n";
assert_eq!(html(source), source);
}
#[test]
fn a_document_without_html_is_returned_unchanged() {
let source = "# Title\n\nSome *text* and `code`.\n";
assert_eq!(html(source), source);
}
fn fixture(dir: &std::path::Path, name: &str, bytes: usize, index: u32) -> Candidate {
let path = dir.join(name);
std::fs::write(&path, vec![0_u8; bytes]).unwrap();
Candidate {
rank: 0,
family: name.to_string(),
path,
index,
}
}
#[test]
fn the_font_budget_stops_reading_once_the_byte_ceiling_is_reached() {
let dir = tempfile::tempdir().unwrap();
let small = fixture(dir.path(), "small", 1_000, 0);
let huge = fixture(dir.path(), "huge", 10_000, 0);
let budget = FontBudget {
bytes: 5_000,
faces: 8,
};
let fonts = build_font_definitions(&[(&small, Role::Ui), (&huge, Role::Fallback)], &budget);
let keys: Vec<&String> = fonts.font_data.keys().collect();
assert!(
keys.iter().any(|k| k.contains("small")),
"the face that fits must be loaded: {keys:?}"
);
assert!(
!keys.iter().any(|k| k.contains("huge")),
"a face over the remaining budget must be skipped: {keys:?}"
);
}
#[test]
fn a_face_over_budget_does_not_block_the_ones_behind_it() {
let dir = tempfile::tempdir().unwrap();
let huge = fixture(dir.path(), "huge", 10_000, 0);
let small = fixture(dir.path(), "small", 1_000, 0);
let budget = FontBudget {
bytes: 5_000,
faces: 8,
};
let fonts = build_font_definitions(
&[(&huge, Role::Fallback), (&small, Role::Fallback)],
&budget,
);
assert!(
fonts.font_data.keys().any(|k| k.contains("small")),
"the loader should carry on past a face it cannot afford"
);
}
#[test]
fn the_font_budget_caps_how_many_faces_are_added() {
let dir = tempfile::tempdir().unwrap();
let faces: Vec<Candidate> = (0..5)
.map(|i| fixture(dir.path(), &format!("face{i}"), 10, 0))
.collect();
let budget = FontBudget {
bytes: 1_000_000,
faces: 2,
};
let candidates: Vec<(&Candidate, Role)> =
faces.iter().map(|c| (c, Role::Fallback)).collect();
let fonts = build_font_definitions(&candidates, &budget);
let added = fonts
.font_data
.keys()
.filter(|k| k.contains("face"))
.count();
assert_eq!(added, 2, "the face count is a ceiling, not a suggestion");
}
#[test]
fn nothing_is_read_when_the_budget_admits_nothing() {
let dir = tempfile::tempdir().unwrap();
let face = fixture(dir.path(), "face", 10, 0);
let budget = FontBudget { bytes: 0, faces: 0 };
let fonts = build_font_definitions(&[(&face, Role::Ui)], &budget);
assert!(
!fonts.font_data.keys().any(|k| k.contains("face")),
"no system face should have been read"
);
assert!(
!fonts.font_data.is_empty(),
"egui's own fonts must survive, or there is nothing left to draw with"
);
}
#[test]
fn a_face_inside_a_collection_keeps_its_index() {
let dir = tempfile::tempdir().unwrap();
let face = fixture(dir.path(), "collection.ttc", 100, 3);
let budget = FontBudget {
bytes: 1_000,
faces: 8,
};
let fonts = build_font_definitions(&[(&face, Role::Ui)], &budget);
let (_, data) = fonts
.font_data
.iter()
.find(|(k, _)| k.contains("collection"))
.expect("the face should have been loaded");
assert_eq!(
data.index, 3,
"the face index inside the collection is lost"
);
}
#[test]
fn two_faces_of_one_family_do_not_overwrite_each_other() {
let dir = tempfile::tempdir().unwrap();
let mut regular = fixture(dir.path(), "Regular.ttf", 10, 0);
let mut bold = fixture(dir.path(), "Bold.ttf", 10, 0);
regular.family = "Shared".to_string();
bold.family = "Shared".to_string();
let budget = FontBudget {
bytes: 1_000,
faces: 8,
};
let fonts =
build_font_definitions(&[(®ular, Role::Ui), (&bold, Role::Fallback)], &budget);
assert_eq!(
fonts.font_data.len(),
egui::FontDefinitions::default().font_data.len() + 2,
"both faces of the family must survive"
);
}
#[test]
fn the_chosen_faces_lead_their_family_and_fall_back_on_egui() {
let dir = tempfile::tempdir().unwrap();
let ui = fixture(dir.path(), "ui", 10, 0);
let mono = fixture(dir.path(), "mono", 10, 0);
let budget = FontBudget {
bytes: 1_000,
faces: 8,
};
let fonts = build_font_definitions(&[(&ui, Role::Ui), (&mono, Role::Mono)], &budget);
let defaults = egui::FontDefinitions::default();
for (family, leader) in [
(egui::FontFamily::Proportional, "ui"),
(egui::FontFamily::Monospace, "mono"),
] {
let chain = &fonts.families[&family];
assert!(
chain[0].contains(leader),
"the {leader} font must lead the {family:?} chain: {chain:?}"
);
let embedded = &defaults.families[&family];
let kept: Vec<&String> = chain.iter().filter(|k| embedded.contains(k)).collect();
assert_eq!(
kept,
embedded.iter().collect::<Vec<_>>(),
"egui's embedded fallbacks must all survive, in order: {chain:?}"
);
}
}
#[test]
fn a_primary_that_does_not_fit_falls_through_to_the_next_choice() {
let dir = tempfile::tempdir().unwrap();
let first = fixture(dir.path(), "first-choice", 10_000, 0);
let second = fixture(dir.path(), "second-choice", 100, 0);
let budget = FontBudget {
bytes: 5_000,
faces: 8,
};
let fonts = build_font_definitions(&[(&first, Role::Ui), (&second, Role::Ui)], &budget);
let leader = &fonts.families[&egui::FontFamily::Proportional][0];
assert!(
leader.contains("second-choice"),
"the next choice should have taken the role: {leader}"
);
}
#[test]
fn one_face_serving_two_roles_is_read_once() {
let dir = tempfile::tempdir().unwrap();
let shared = fixture(dir.path(), "shared", 100, 0);
let budget = FontBudget {
bytes: 1_000,
faces: 1,
};
let fonts = build_font_definitions(&[(&shared, Role::Ui), (&shared, Role::Mono)], &budget);
let proportional = &fonts.families[&egui::FontFamily::Proportional];
let monospace = &fonts.families[&egui::FontFamily::Monospace];
assert!(
proportional[0].contains("shared") && monospace[0].contains("shared"),
"the one face should lead both chains: {proportional:?} / {monospace:?}"
);
assert_eq!(
proportional.iter().filter(|k| k.contains("shared")).count(),
1,
"it should appear once in the chain, not once per role: {proportional:?}"
);
assert_eq!(
fonts
.font_data
.keys()
.filter(|k| k.contains("shared"))
.count(),
1
);
}
fn set_system_theme(ctx: &egui::Context, theme: egui::Theme) {
let input = egui::RawInput {
system_theme: Some(theme),
..Default::default()
};
ctx.options_mut(|o| o.begin_pass(&input));
}
#[test]
fn a_forced_theme_outranks_the_system_one() {
for (setting, expected) in [
(crate::core::Theme::Light, egui::Theme::Light),
(crate::core::Theme::Dark, egui::Theme::Dark),
] {
for system in [egui::Theme::Dark, egui::Theme::Light] {
let ctx = egui::Context::default();
apply_theme_preference(&ctx, setting);
set_system_theme(&ctx, system);
assert_eq!(
ctx.theme(),
expected,
"{setting:?} must hold whatever the desktop says ({system:?})"
);
}
}
}
#[test]
fn auto_hands_the_choice_back_to_the_system() {
for system in [egui::Theme::Dark, egui::Theme::Light] {
let ctx = egui::Context::default();
ctx.set_theme(egui::ThemePreference::Dark);
apply_theme_preference(&ctx, crate::core::Theme::Auto);
set_system_theme(&ctx, system);
assert_eq!(
ctx.theme(),
system,
"a stored preference must not outlive a run that asked for auto"
);
}
}
#[test]
fn the_style_carries_the_shared_palette_into_both_themes() {
use crate::core::style;
let ctx = egui::Context::default();
apply_style(&ctx);
for (theme, palette, name) in [
(egui::Theme::Dark, &style::DARK, "dark"),
(egui::Theme::Light, &style::LIGHT, "light"),
] {
let style = ctx.style_of(theme);
let expect = |c: style::Rgb| egui::Color32::from_rgb(c[0], c[1], c[2]);
assert_eq!(
style.visuals.panel_fill,
expect(palette.bg),
"{name}: the page background must come from the shared palette"
);
assert_eq!(
style.visuals.hyperlink_color,
expect(palette.link),
"{name}: links must come from the shared palette"
);
assert_eq!(
style.visuals.code_bg_color,
expect(palette.inline_code_bg),
"{name}: inline code must use the chip background, not the block one"
);
assert_eq!(
style.visuals.weak_text_color(),
expect(palette.muted),
"{name}: blockquotes read weak_text_color, so it has to be set"
);
assert_eq!(
style.visuals.widgets.hovered.fg_stroke.color,
expect(palette.strong),
"{name}: egui draws bold by colour alone, so strong must differ"
);
assert_ne!(
style.visuals.widgets.hovered.fg_stroke.color,
style.visuals.widgets.inactive.fg_stroke.color,
"{name}: bold text must not be the same colour as body text"
);
}
}
#[test]
fn the_style_sets_the_shared_type_scale() {
use crate::core::style::{BASE_FONT_SIZE, CODE_FONT_SCALE, heading_size};
use egui::TextStyle;
let ctx = egui::Context::default();
apply_style(&ctx);
let style = ctx.style_of(egui::Theme::Dark);
assert_eq!(
style.text_styles[&TextStyle::Body].size,
BASE_FONT_SIZE,
"the body must be the shared size, not egui's 13 pt default"
);
assert_eq!(
style.text_styles[&TextStyle::Heading].size,
heading_size(1),
"the heading style is the h1 end of the scale"
);
assert_eq!(
style.text_styles[&TextStyle::Monospace].size,
BASE_FONT_SIZE * CODE_FONT_SCALE,
"code is set at a fraction of the prose around it"
);
assert!(
style.text_styles[&TextStyle::Heading].size > style.text_styles[&TextStyle::Body].size,
"a heading that is not larger than the body is the bug this fixed"
);
}
#[test]
fn split_by_headings_single_heading() {
let md = "# Title\nSome content\n";
let (has_preamble, sections) = split_by_headings(md);
assert!(!has_preamble);
assert_eq!(sections.len(), 1);
assert!(sections[0].contains("# Title"));
assert!(sections[0].contains("Some content"));
}
#[test]
fn split_by_headings_multiple_headings() {
let md = "# First\nContent 1\n## Second\nContent 2\n### Third\nContent 3\n";
let (has_preamble, sections) = split_by_headings(md);
assert!(!has_preamble);
assert_eq!(sections.len(), 3);
assert!(sections[0].contains("# First"));
assert!(sections[1].contains("## Second"));
assert!(sections[2].contains("### Third"));
}
#[test]
fn split_by_headings_with_preamble() {
let md = "Some introductory text.\n\n# First Heading\nContent here.\n";
let (has_preamble, sections) = split_by_headings(md);
assert!(has_preamble);
assert_eq!(sections.len(), 2);
assert!(sections[0].contains("Some introductory text."));
assert!(sections[1].contains("# First Heading"));
}
#[test]
fn split_by_headings_no_headings() {
let md = "Just some text.\nNo headings here.\n";
let (has_preamble, sections) = split_by_headings(md);
assert!(has_preamble);
assert_eq!(sections.len(), 1);
assert!(sections[0].contains("Just some text."));
}
#[test]
fn split_by_headings_empty_input() {
let (has_preamble, sections) = split_by_headings("");
assert!(!has_preamble);
assert!(sections.is_empty());
}
#[test]
fn split_by_headings_hash_in_code_block_not_split() {
let md = "# Title\n#!/bin/bash\necho hello\n";
let (has_preamble, sections) = split_by_headings(md);
assert!(!has_preamble);
assert_eq!(sections.len(), 1);
assert!(sections[0].contains("#!/bin/bash"));
}
#[test]
fn split_by_headings_fenced_code_hash_not_split() {
let md = "# Title\n\n```bash\n$>cat file\n# Comment in code rendered as title\n```\n";
let (has_preamble, sections) = split_by_headings(md);
assert!(!has_preamble);
assert_eq!(sections.len(), 1);
assert!(sections[0].contains("# Comment in code rendered as title"));
}
#[test]
fn split_by_headings_shebang_as_first_line() {
let md = "#!/bin/bash\n# Title\nContent\n";
let (has_preamble, sections) = split_by_headings(md);
assert!(has_preamble);
assert_eq!(sections.len(), 2);
}
#[test]
fn split_by_headings_consecutive_headings() {
let md = "# H1\n## H2\n## H3\n";
let (has_preamble, sections) = split_by_headings(md);
assert!(!has_preamble);
assert_eq!(sections.len(), 3);
}
#[test]
fn split_by_headings_heading_without_space_not_treated_as_heading() {
let md = "# Real Heading\n#notaheading\ntext\n";
let (has_preamble, sections) = split_by_headings(md);
assert!(!has_preamble);
assert_eq!(sections.len(), 1);
assert!(sections[0].contains("#notaheading"));
}
#[test]
fn split_by_headings_matches_toc_for_setext_headings() {
let md = "Intro text.\n\nFirst\n=====\n\nBody one.\n\nSecond\n------\n\nBody two.\n\n## Third\n\nBody three.\n";
let toc = toc::extract_toc(md);
let (has_preamble, sections) = split_by_headings(md);
assert_eq!(toc.len(), 3, "{toc:?}");
assert!(has_preamble);
assert_eq!(sections.len(), toc.len() + 1, "sections: {sections:?}");
for (i, entry) in toc.iter().enumerate() {
let section = §ions[i + 1];
assert!(
section.lines().next().unwrap_or("").contains(&entry.text),
"section {} = {:?} should start with {:?}",
i + 1,
section,
entry.text
);
}
}
#[test]
fn split_by_headings_preserves_content_within_sections() {
let md = "# Title\nLine 1\nLine 2\n\n## Next\nLine 3\n";
let (_, sections) = split_by_headings(md);
assert!(sections[0].contains("Line 1"));
assert!(sections[0].contains("Line 2"));
assert!(sections[1].contains("Line 3"));
}
#[test]
fn split_by_headings_treats_front_matter_as_preamble() {
let md = "---\ntitle: hello\n---\n\n# Title\n\nBody.\n";
let toc = toc::extract_toc(md);
let (has_preamble, sections) = split_by_headings(md);
assert_eq!(toc.len(), 1);
assert!(has_preamble);
assert_eq!(sections.len(), 2, "sections: {sections:?}");
assert!(sections[0].contains("title: hello"));
assert!(sections[1].starts_with("# Title"));
}
#[test]
fn cmd_or_ctrl_f_opens_and_closes_the_search() {
assert_eq!(
key_action(egui::Key::F, egui::Modifiers::COMMAND, false),
Some(Action::OpenSearch)
);
assert_eq!(
key_action(egui::Key::F, egui::Modifiers::COMMAND, true),
Some(Action::CloseSearch)
);
}
#[test]
fn a_bare_control_key_on_macos_does_not_open_the_search() {
assert_eq!(key_action(egui::Key::F, egui::Modifiers::CTRL, false), None);
}
#[test]
fn cmd_q_and_cmd_w_quit_even_while_searching() {
for key in [egui::Key::Q, egui::Key::W] {
assert_eq!(
key_action(key, egui::Modifiers::COMMAND, false),
Some(Action::Quit)
);
assert_eq!(
key_action(key, egui::Modifiers::COMMAND, true),
Some(Action::Quit)
);
}
}
#[test]
fn bare_q_quits_only_when_the_search_is_closed() {
assert_eq!(
key_action(egui::Key::Q, egui::Modifiers::NONE, false),
Some(Action::Quit)
);
assert_eq!(key_action(egui::Key::Q, egui::Modifiers::NONE, true), None);
}
#[test]
fn escape_closes_the_search_before_it_closes_the_window() {
assert_eq!(
key_action(egui::Key::Escape, egui::Modifiers::NONE, true),
Some(Action::CloseSearch)
);
assert_eq!(
key_action(egui::Key::Escape, egui::Modifiers::NONE, false),
Some(Action::Quit)
);
}
#[test]
fn t_toggles_the_theme_but_not_while_typing() {
assert_eq!(
key_action(egui::Key::T, egui::Modifiers::NONE, false),
Some(Action::ToggleTheme)
);
assert_eq!(key_action(egui::Key::T, egui::Modifiers::NONE, true), None);
}
#[test]
fn the_toggle_flips_whatever_the_window_is_showing() {
for forced in [crate::core::Theme::Light, crate::core::Theme::Dark] {
let ctx = egui::Context::default();
apply_theme_preference(&ctx, forced);
let before = ctx.theme();
toggle_theme(&ctx);
assert_ne!(ctx.theme(), before, "{forced:?} should have flipped");
toggle_theme(&ctx);
assert_eq!(ctx.theme(), before, "a second press should come back");
}
}
#[test]
fn f10_toggles_the_toc_even_while_searching() {
assert_eq!(
key_action(egui::Key::F10, egui::Modifiers::NONE, true),
Some(Action::ToggleToc)
);
}
#[test]
fn scrolling_keys_match_the_other_backends() {
let none = egui::Modifiers::NONE;
assert_eq!(
key_action(egui::Key::ArrowDown, none, false),
Some(Action::ScrollDown)
);
assert_eq!(
key_action(egui::Key::J, none, false),
Some(Action::ScrollDown)
);
assert_eq!(
key_action(egui::Key::ArrowUp, none, false),
Some(Action::ScrollUp)
);
assert_eq!(
key_action(egui::Key::K, none, false),
Some(Action::ScrollUp)
);
assert_eq!(
key_action(egui::Key::PageDown, none, false),
Some(Action::PageDown)
);
assert_eq!(
key_action(egui::Key::Space, none, false),
Some(Action::PageDown)
);
assert_eq!(
key_action(egui::Key::PageUp, none, false),
Some(Action::PageUp)
);
assert_eq!(
key_action(egui::Key::Home, none, false),
Some(Action::GoTop)
);
assert_eq!(
key_action(egui::Key::End, none, false),
Some(Action::GoBottom)
);
assert_eq!(key_action(egui::Key::G, none, false), Some(Action::GoTop));
assert_eq!(
key_action(egui::Key::G, egui::Modifiers::SHIFT, false),
Some(Action::GoBottom)
);
}
#[test]
fn no_bare_key_fires_while_typing_in_the_search_field() {
let none = egui::Modifiers::NONE;
for key in [
egui::Key::J,
egui::Key::K,
egui::Key::G,
egui::Key::Space,
egui::Key::ArrowDown,
egui::Key::ArrowUp,
egui::Key::PageDown,
egui::Key::PageUp,
egui::Key::Home,
egui::Key::End,
] {
assert_eq!(key_action(key, none, true), None, "{key:?} fired");
}
assert_eq!(key_action(egui::Key::G, egui::Modifiers::SHIFT, true), None);
}
#[test]
fn unhandled_keys_produce_no_action() {
assert_eq!(key_action(egui::Key::Z, egui::Modifiers::NONE, false), None);
assert_eq!(key_action(egui::Key::J, egui::Modifiers::ALT, false), None);
}
const PNG: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00];
fn project() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
let proj = tmp.path().join("proj");
std::fs::create_dir_all(proj.join(".git")).unwrap();
std::fs::create_dir_all(proj.join("docs")).unwrap();
std::fs::create_dir_all(proj.join("images")).unwrap();
std::fs::write(proj.join("images/logo.png"), PNG).unwrap();
std::fs::write(tmp.path().join("secret.png"), PNG).unwrap();
tmp
}
fn never_fetch(_: &str) -> Option<String> {
panic!("the tests must never touch the network");
}
#[test]
fn an_image_in_a_sibling_directory_of_the_project_is_embedded() {
let tmp = project();
let docs = tmp.path().join("proj/docs");
let out = rewrite_image(
"logo",
"../images/logo.png",
"",
&docs,
&never_fetch,
);
assert!(
out.starts_with(",
"got {out}"
);
}
#[test]
fn an_image_outside_the_project_is_still_refused() {
let tmp = project();
let docs = tmp.path().join("proj/docs");
let original = "";
let out = rewrite_image("x", "../../secret.png", original, &docs, &never_fetch);
assert!(!out.contains("]("), "still a link: {out}");
assert!(!out.starts_with("!["), "still an image: {out}");
assert!(
!out.contains("secret.png"),
"the path is still there: {out}"
);
assert!(out.contains("image not shown"), "got {out}");
}
#[test]
fn remote_images_become_data_uris() {
let tmp = project();
let docs = tmp.path().join("proj/docs");
let fetch = |url: &str| {
assert_eq!(url, "https://example.com/badge.png");
Some("data:image/png;base64,YWI=".to_string())
};
assert_eq!(
rewrite_image(
"badge",
"https://example.com/badge.png",
"",
&docs,
&fetch,
),
""
);
}
#[test]
fn an_unfetchable_remote_image_is_left_untouched() {
let tmp = project();
let docs = tmp.path().join("proj/docs");
let original = "";
assert_eq!(
rewrite_image(
"badge",
"https://example.com/badge.png",
original,
&docs,
&|_| None,
),
original
);
}
#[test]
fn data_and_file_uris_are_left_untouched() {
let tmp = project();
let docs = tmp.path().join("proj/docs");
for src in ["data:image/png;base64,YWI=", "file:///tmp/a.png"] {
let original = format!("");
assert_eq!(
rewrite_image("x", src, &original, &docs, &never_fetch),
original
);
}
}
}