egui_markdown 0.1.0

A markdown parser and renderer for egui
Documentation
use std::collections::VecDeque;
use std::time::Instant;

use eframe::egui::{
  self, text::LayoutJob, Color32, FontDefinitions, FontFamily, FontId, Rect, Response, RichText, TextFormat, Ui, Vec2,
};
use egui_markdown::{LinkHandler, LinkStyle, MarkdownLabel, MarkdownStyle};

fn main() -> eframe::Result {
  env_logger::init();

  let options = eframe::NativeOptions {
    viewport: egui::ViewportBuilder::default().with_inner_size([1000.0, 700.0]).with_title("egui_markdown - advanced"),
    ..Default::default()
  };

  eframe::run_native("egui_markdown advanced", options, Box::new(|cc| Ok(Box::new(AdvancedApp::new(cc)))))
}

struct AdvancedApp {
  markdown_input: String,
  show_editor: bool,
  show_style_editor: bool,
  selectable: bool,
  interactable: bool,
  render_times: VecDeque<f64>,
  markdown_style: MarkdownStyle,
  simulate_stream: bool,
  stream_pos: usize,
  stream_source: String,
  last_stream_tick: Instant,
}

impl AdvancedApp {
  fn new(cc: &eframe::CreationContext<'_>) -> Self {
    install_bold_font(&cc.egui_ctx);

    Self {
      markdown_input: DEFAULT_MARKDOWN.to_string(),
      show_editor: true,
      show_style_editor: false,
      selectable: true,
      interactable: true,
      render_times: VecDeque::with_capacity(120),
      markdown_style: {
        let mut s = MarkdownStyle::default();
        s.code_font_size = 12.0;
        s
      },
      simulate_stream: false,
      stream_pos: 0,
      stream_source: DEFAULT_MARKDOWN.to_string(),
      last_stream_tick: Instant::now(),
    }
  }
}

fn install_bold_font(ctx: &egui::Context) {
  let mut fonts = FontDefinitions::default();
  // Use the built-in proportional font data as "bold" (real apps would load an actual bold font)
  if let Some(font_data) = fonts.font_data.get("Ubuntu-Light").cloned() {
    fonts.font_data.insert("Bold".to_owned(), font_data);
    fonts.families.insert(FontFamily::Name("bold".into()), vec!["Bold".to_owned()]);
  }
  ctx.set_fonts(fonts);
}

struct DemoLinkHandler;

impl LinkHandler for DemoLinkHandler {
  fn link_style(&self, href: &str) -> Option<LinkStyle> {
    if href.starts_with("custom://") {
      Some(LinkStyle { color: Some(Color32::from_rgb(255, 100, 100)), underline: true })
    } else if href.starts_with("styled://") {
      Some(LinkStyle { color: Some(Color32::from_rgb(255, 150, 50)), underline: true })
    } else {
      None
    }
  }

  fn click(&self, _text: &str, href: &str, _ui: &mut Ui) -> bool {
    if href.starts_with("custom://") || href.starts_with("badge://") {
      eprintln!("Custom link clicked: {href}");
      true // handled
    } else {
      false // fall through to default (open in browser)
    }
  }

  fn inline_widget_size(&self, href: &str, font: &FontId) -> Option<Vec2> {
    if href.starts_with("badge://") {
      Some(Vec2::new(0.0, font.size + 6.0))
    } else {
      None
    }
  }

  fn layout_link(&self, text: &str, href: &str, job: &mut LayoutJob, font: &FontId, _color: Color32) -> bool {
    if href.starts_with("styled://") {
      job.append("\u{26A1} ", 0.0, TextFormat { font_id: font.clone(), color: Color32::YELLOW, ..Default::default() });
      job.append(
        text,
        0.0,
        TextFormat { font_id: font.clone(), color: Color32::from_rgb(255, 150, 50), ..Default::default() },
      );
      true
    } else if href.starts_with("badge://") {
      let placeholder = "\u{00A0}".repeat(text.len() + 1);
      let format = TextFormat { font_id: FontId::monospace(font.size), color: _color, ..Default::default() };
      job.append(&placeholder, 0.0, format);
      true
    } else {
      false
    }
  }

  fn paint_inline_widget(&self, ui: &mut Ui, text: &str, href: &str, rect: Rect) {
    if !href.starts_with("badge://") {
      return;
    }
    let fill = Color32::from_rgb(80, 160, 80);
    let rounding = rect.height() * 0.5;
    ui.painter().rect_filled(rect, rounding, fill);
    ui.painter().text(
      rect.center(),
      egui::Align2::CENTER_CENTER,
      text,
      FontId::proportional(rect.height() - 6.0),
      Color32::WHITE,
    );
  }

  fn is_block_widget(&self, href: &str) -> bool {
    href.starts_with("widget://")
  }

  fn block_widget(&self, ui: &mut Ui, text: &str, href: &str) -> Option<Response> {
    let response = egui::Frame::NONE
      .fill(ui.visuals().widgets.inactive.bg_fill)
      .corner_radius(4.0)
      .inner_margin(egui::Margin::symmetric(6, 2))
      .show(ui, |ui| {
        ui.horizontal(|ui| {
          ui.label(RichText::new("\u{1F517}").small());
          ui.label(text);
        });
      })
      .response;
    if response.clicked() {
      eprintln!("Widget link clicked: {href}");
    }
    Some(response)
  }
}

fn code_block_header(ui: &mut Ui, code: &str, lang: &str) {
  let bg = if ui.visuals().dark_mode { Color32::from_gray(90) } else { Color32::from_gray(210) };
  let button = egui::Button::new("Copy").fill(bg);
  if ui.add(button).clicked() {
    ui.ctx().copy_text(code.to_string());
  }
  if !lang.is_empty() {
    ui.label(egui::RichText::new(lang).small().color(bg));
  }
}

impl eframe::App for AdvancedApp {
  fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
    let link_handler = DemoLinkHandler;

    egui::TopBottomPanel::top("top").show(ctx, |ui| {
      ui.horizontal(|ui| {
        ui.heading("egui_markdown advanced");
        ui.separator();
        ui.toggle_value(&mut self.show_editor, "Editor");
        ui.toggle_value(&mut self.show_style_editor, "Style");
        ui.toggle_value(&mut self.selectable, "Selectable");
        ui.toggle_value(&mut self.interactable, "Interactable");
        ui.separator();
        if ui.button("Reset").clicked() {
          self.markdown_input = DEFAULT_MARKDOWN.to_string();
          self.simulate_stream = false;
          self.stream_pos = 0;
        }
        if ui.toggle_value(&mut self.simulate_stream, "Simulate Stream").changed() {
          if self.simulate_stream {
            self.stream_source = self.markdown_input.clone();
            self.stream_pos = 0;
            self.markdown_input.clear();
            self.last_stream_tick = Instant::now();
          } else {
            self.markdown_input = self.stream_source.clone();
          }
        }
        ui.separator();
        let avg_render = if self.render_times.is_empty() {
          0.0
        } else {
          self.render_times.iter().sum::<f64>() / self.render_times.len() as f64
        };
        ui.label(format!("render: {avg_render:.2}ms"));
      });
    });

    if self.show_editor {
      egui::SidePanel::left("editor").default_width(400.0).show(ctx, |ui| {
        ui.heading("Markdown Source");
        egui::ScrollArea::vertical().show(ui, |ui| {
          ui.add(egui::TextEdit::multiline(&mut self.markdown_input).desired_width(f32::INFINITY).code_editor());
        });
      });
    }

    if self.show_style_editor {
      egui::SidePanel::right("style_editor").default_width(280.0).show(ctx, |ui| {
        ui.heading("Style");
        ui.separator();
        egui::ScrollArea::vertical().show(ui, |ui| {
          self.markdown_style.ui(ui);
        });
      });
    }

    if self.simulate_stream && self.stream_pos < self.stream_source.len() {
      let now = Instant::now();
      let elapsed_ms = now.duration_since(self.last_stream_tick).as_millis();
      if elapsed_ms >= 8 {
        let chars_to_add = 2;
        let mut end = self.stream_pos;
        for _ in 0..chars_to_add {
          if end >= self.stream_source.len() {
            break;
          }
          let next = end + 1;
          // Find next char boundary
          let mut boundary = next;
          while boundary < self.stream_source.len() && !self.stream_source.is_char_boundary(boundary) {
            boundary += 1;
          }
          end = boundary;
        }
        self.stream_pos = end;
        self.markdown_input = self.stream_source[..self.stream_pos].to_string();
        self.last_stream_tick = now;
      }
      ctx.request_repaint();
    }

    egui::CentralPanel::default().show(ctx, |ui| {
      ui.heading("Rendered Output");
      ui.separator();
      ui.style_mut().url_in_tooltip = true;
      egui::ScrollArea::vertical().show(ui, |ui| {
        let render_start = Instant::now();
        MarkdownLabel::new(ui.id().with("md"), &self.markdown_input)
          .font(FontId::proportional(14.0))
          .selectable(self.selectable)
          .interactable(self.interactable)
          .link_handler(&link_handler)
          .code_block_buttons(&code_block_header)
          .scroll_code_blocks(true)
          .style(&self.markdown_style)
          .heal(self.simulate_stream)
          .show(ui);
        let render_elapsed = render_start.elapsed();

        if self.render_times.len() >= 120 {
          self.render_times.pop_front();
        }
        self.render_times.push_back(render_elapsed.as_secs_f64() * 1000.0);
      });
    });
  }
}

const DEFAULT_MARKDOWN: &str = r#"# egui_markdown - Advanced Demo

This demo shows **all features** including custom link handlers, code block buttons, and configurable options.

## Custom Links

A [normal link](https://github.com/emilk/egui) opens in the browser.

A [custom protocol link](custom://action/do-something) is handled by the `LinkHandler` trait.

## Custom Link Rendering

A [styled link](styled://path/to/thing) with custom colors inline.

An inline widget: [passing](badge://ci/passing) rendered as a painted badge over placeholder text.

A [widget link](widget://gref/myprogram/state) rendered as a block-level widget.

Regular [normal link](https://example.com) with default styling.

## Link Titles

Hover over this: [egui](https://github.com/emilk/egui "The egui UI library").

## Text Formatting

Regular, **bold**, *italic*, ***bold italic***, ~~strikethrough~~, and `inline code`.

## Headings

# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6

## Lists

- Unordered item
  - Nested item
    - Deep nested
- Another item

1. First
2. Second
3. Third

## Task Lists

- [x] Implement parser
- [x] Add caching
- [ ] Publish to crates.io

## Code Blocks

```rust
use egui_markdown::MarkdownLabel;

fn render(ui: &mut egui::Ui) {
    MarkdownLabel::new(ui.id().with("md"), "**Hello**")
        .selectable(true)
        .show(ui);
}
```

```javascript
// Try clicking the Copy button
const msg = "Hello from egui_markdown!";
console.log(msg);
```

```rust
// This line is intentionally very long to demonstrate horizontal scrolling in code blocks - it should scroll rather than wrap when scroll_code_blocks is enabled on the MarkdownLabel widget
fn example() { println!("scroll me!"); }
```

## Blockquotes

> Simple blockquote with **bold** and `code`.
>
> > Nested blockquote.
> >
> > > Triple nested.

## Tables

| Feature | Status | Description |
|:--------|:------:|------------:|
| Parsing | Done | pulldown-cmark based |
| Rendering | Done | Full egui integration |
| Caching | Done | FrameCache pattern |
| Links | Done | Custom handlers |
| Tables | Done | With alignment |
| [Link in table](https://example.com) | Done | Clickable |

## Horizontal Rules

Content above.

---

Content below.

## Footnotes

This sentence has a footnote[^1] and another[^note].

[^1]: First footnote content.
[^note]: Named footnote content.

## Mixed Content

> A blockquote with a list and formatting:
> - **Bold item** with `code`
> - *Italic item* with ~~strikethrough~~
> - A [link](https://example.com) in a list in a blockquote
"#;