Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Markdown

Group: Content

Code

/// Markdown source shared by both variants — deliberately includes
/// `snake_case` identifiers and a whitespace-flanked `*` so the
/// flanking guard is exercised visually: `foo_bar` and `a * b` render
/// upright, while `*italic*`, `_also_`, `**bold**` and `` `code` `` get
/// their styling.
const DOC: &str = "\
Markdown adapter demo
# Headings scale up
Body text with **bold**, *italic*, _also italic_, and `inline_code`.
Identifiers like foo_bar and baz_qux stay upright (no intraword emphasis).
Arithmetic like a * b * c is left alone too.

- A short bullet item.
- A longer bullet item that wraps gracefully when the viewport is narrow,
  with styling like **bold** still preserved.

```rust
fn main() {
    println!(\"fenced code is never wrapped\");
}
```";

/// Variant 0 state: a [`RichTextPopup`] over the unwrapped adapter
/// output. `font_role` toggles between the UI font and the editor's
/// monospace font (`f` key) — visual confirmation that
/// `Backend::draw_rich_text_popup_with_font_role` reaches a real
/// second paint path, not just the plain one.
struct PopupState {
    scroll_top: usize,
    font_role: FontRole,
}

impl PopupState {
    fn new() -> Self {
        Self {
            scroll_top: 0,
            font_role: FontRole::Chrome,
        }
    }
}

/// Variant 1 state: a word-wrapped [`ListView`] over the adapter's
/// wrapping entry point.
struct WrapState {
    scroll_offset: usize,
    selected_idx: usize,
}

impl WrapState {
    fn new() -> Self {
        Self {
            scroll_offset: 0,
            selected_idx: 0,
        }
    }
}

pub struct MarkdownDemo {
    popup: PopupState,
    wrap: WrapState,
}

impl MarkdownDemo {
    pub fn new() -> Self {
        Self {
            popup: PopupState::new(),
            wrap: WrapState::new(),
        }
    }

    fn render_popup(&self, backend: &mut dyn Backend, area: Rect) {
        let theme = Theme::default();
        let rendered = render_markdown_to_styled(DOC, &theme);

        let col_w = backend.char_width();
        let row_h = backend.line_height();
        let widest = rendered
            .line_text
            .iter()
            .map(|l| l.chars().count())
            .max()
            .unwrap_or(0);
        let content_w = (widest as f32 + 1.0) * col_w;
        // Grows with the pane instead of a fixed row count, so a tall
        // viewport actually shows more of the document —
        // `RichTextPopup::layout` still clamps this down to whatever the
        // viewport has room for, this just stops it from being
        // needlessly short on a tall one.
        let max_visible_rows = ((area.height / row_h).floor() as usize).max(4);

        let popup = RichTextPopup {
            id: WidgetId::new("gallery:markdown:popup"),
            lines: rendered.lines,
            line_text: rendered.line_text,
            line_scales: rendered.line_scales,
            scroll_top: self.popup.scroll_top,
            max_visible_rows,
            has_focus: true,
            selection: None,
            links: Vec::new(),
            focused_link: None,
            placement: PopupPlacement::Below,
            padding: 1.0,
            fg: None,
            bg: None,
        };

        let anchor_x = area.x + area.width * 0.05;
        let anchor_y = area.y + row_h;
        let measure =
            RichTextPopupMeasure::new(content_w, row_h).with_scale_rows(backend.scales_text_rows());
        let layout = popup.layout(anchor_x, anchor_y, area, measure, |_, start, end| {
            (end - start) as f32 * col_w
        });
        match self.popup.font_role {
            FontRole::Chrome => backend.draw_rich_text_popup(&popup, &layout),
            FontRole::Editor => {
                backend.draw_rich_text_popup_with_font_role(&popup, &layout, FontRole::Editor)
            }
        }
    }

    fn content_cols(area: Rect, backend: &dyn Backend) -> usize {
        let cw = backend.char_width();
        let total_cols = (area.width / cw).floor() as usize;
        total_cols.saturating_sub(2)
    }

    /// How many word-wrapped rows [`Self::render_wrap`] will build for
    /// `area`'s current width — used by `handle` to clamp
    /// `WrapState::scroll_offset`/`selected_idx` to the list's real
    /// bounds rather than relying solely on `ListView`'s own internal
    /// clamp (`primitives/list.rs`), so the demo's own state never
    /// drifts arbitrarily far past what's actually on screen.
    fn wrap_row_count(area: Rect, backend: &dyn Backend) -> usize {
        let width = Self::content_cols(area, backend).max(10);
        render_markdown_to_styled_wrapped(DOC, &Theme::default(), width)
            .lines
            .len()
    }

    fn render_wrap(&self, backend: &mut dyn Backend, area: Rect) {
        let theme = Theme::default();
        let width = Self::content_cols(area, backend);
        let rendered = render_markdown_to_styled_wrapped(DOC, &theme, width.max(10));

        let items: Vec<ListItem> = rendered
            .lines
            .into_iter()
            .map(|styled| ListItem {
                text: styled,
                detail: None,
                icon: None,
                decoration: Decoration::Normal,
            })
            .collect();
        let n = items.len();

        let list = ListView {
            id: WidgetId::new("gallery:markdown:wrap"),
            title: Some(StyledText::plain(" word-wrapped ")),
            items,
            selected_idx: self.wrap.selected_idx.min(n.saturating_sub(1)),
            scroll_offset: self.wrap.scroll_offset,
            has_focus: true,
            bordered: false,
            h_scroll: 0,
            max_content_width: None,
            show_v_scrollbar: true,
        };
        backend.draw_list(area, &list);
    }
}

impl Default for MarkdownDemo {
    fn default() -> Self {
        Self::new()
    }
}

impl Demo for MarkdownDemo {
    fn name(&self) -> &'static str {
        "Markdown"
    }

    fn group(&self) -> &'static str {
        "Content"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Popup (unwrapped)", "List (word-wrap)"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        match variant {
            1 => self.render_wrap(backend, area),
            _ => self.render_popup(backend, area),
        }
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        match variant {
            1 => match event {
                UiEvent::KeyPressed { key, .. } => match key {
                    Key::Char('j') | Key::Named(NamedKey::Down) => {
                        let last = Self::wrap_row_count(area, backend).saturating_sub(1);
                        self.wrap.scroll_offset =
                            self.wrap.scroll_offset.saturating_add(1).min(last);
                        self.wrap.selected_idx = self.wrap.selected_idx.saturating_add(1).min(last);
                        Reaction::Redraw
                    }
                    Key::Char('k') | Key::Named(NamedKey::Up) => {
                        self.wrap.scroll_offset = self.wrap.scroll_offset.saturating_sub(1);
                        self.wrap.selected_idx = self.wrap.selected_idx.saturating_sub(1);
                        Reaction::Redraw
                    }
                    _ => Reaction::Continue,
                },
                _ => Reaction::Continue,
            },
            _ => match event {
                UiEvent::KeyPressed { key, .. } => match key {
                    Key::Named(NamedKey::Down) => {
                        self.popup.scroll_top = self.popup.scroll_top.saturating_add(1);
                        Reaction::Redraw
                    }
                    Key::Named(NamedKey::Up) => {
                        self.popup.scroll_top = self.popup.scroll_top.saturating_sub(1);
                        Reaction::Redraw
                    }
                    Key::Char('f') => {
                        self.popup.font_role = match self.popup.font_role {
                            FontRole::Chrome => FontRole::Editor,
                            FontRole::Editor => FontRole::Chrome,
                        };
                        Reaction::Redraw
                    }
                    _ => Reaction::Continue,
                },
                _ => Reaction::Continue,
            },
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        match variant {
            1 => serde_json::json!({
                "scroll_offset": self.wrap.scroll_offset,
                "selected_idx": self.wrap.selected_idx,
            }),
            _ => serde_json::json!({
                "scroll_top": self.popup.scroll_top,
                "font_role": match self.popup.font_role {
                    FontRole::Chrome => "Chrome",
                    FontRole::Editor => "Editor",
                },
            }),
        }
    }

    fn caps_note(&self, variant: usize, caps: &BackendCaps) -> Option<String> {
        if variant == 0 && !caps.generic_font_families {
            Some(
                "This backend has a fixed cell grid — the 'f' font-role toggle has no \
                 visible effect here."
                    .to_string(),
            )
        } else {
            None
        }
    }
}

Backends

VariantTUIGTKmacOSWindows
Popup (unwrapped)
List (word-wrap)