Skip to content
 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

151 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

mdroll

A terminal Markdown viewer with big headings and real GitHub Flavored Markdown support.

mdroll renders Markdown in the terminal the way GitHub does — tables, task lists, footnotes, alerts, and mermaid diagrams included — with a horizontal-scroll mode for when you don't want reflow, and a source view you can toggle into at any time. Headings are actually big, not just a different color.

The name is md + roll, after 絵巻 (emaki), the horizontal picture scrolls you read by unrolling sideways.

mdroll rendering a document with an inline image, a mermaid flowchart, a table, and an alert

Rendering doc/demo.md with the Dracula theme, captured in kitty. The headings — including the Japanese one — are bitmaps rather than DECDHL, because kitty has graphics but no double-height lines. See Terminal support.


Why

Existing terminal Markdown viewers are good, but three things kept coming up:

  1. Headings never look like headings. Every viewer renders # Title in a color or a background bar. None of them make the text larger, so document structure is hard to scan.
  2. GitHub's Markdown is the Markdown people actually write. Task lists, footnotes, > [!NOTE] alerts, and mermaid diagrams appear in every README, and most viewers render them as raw text or drop them entirely.
  3. You can't get the text back out. Once content is rendered, copying the original Markdown means opening the file in an editor.

mdroll targets these directly. It is built for WezTerm first; other terminals work, but features that depend on terminal capabilities degrade gracefully rather than being the design center.


Features

  • Everything GitHub renders — tables, task lists, strikethrough, autolinks, footnotes, > [!NOTE] alerts, :rocket: emoji, and definition lists. Not just the five extensions the GFM spec defines, and nothing GitHub does not have — see What counts as Markdown here.
  • The HTML in your README — centred logos, badge rows, <details> sections, <kbd> keys, HTML tables and lists, all rendered rather than dumped as tags.
  • The pictures in your README — logos and badge rows drawn as pictures, including the SVG ones, and including the ones behind an https:// URL.
  • Mermaid diagramsflowchart and sequenceDiagram drawn with box characters, laid out by rank. Anything else goes through mmdc and renders as a picture, if you have it installed.
  • Two rendering modes — rendered view, and raw source view showing #, **, and friends as written.
  • Two layout modes — reflow to terminal width, or no-wrap with horizontal scrolling.
  • Big headings — DECDHL double-height lines where they work, and text rasterized to a bitmap where the terminal has graphics instead.
  • Correct CJK line breaking via UAX #14, with kinsoku rules applied.
  • Block yank — move a cursor over blocks and copy either the original Markdown source or the rendered plain text. Works over SSH via OSC 52.
  • Clickable links via OSC 8 hyperlinks, with no mouse capture required, so your terminal's native text selection keeps working.
  • Bundled themes — Dracula, Solarized Dark/Light, Nord, Gruvbox, selectable from the command line.
  • Single binary, installable with mise.

Installation

mise

# mise.toml
[tools]
"github:tokuhirom/mdroll" = "latest"
$ mise install

cargo

$ cargo install --locked --git https://github.com/tokuhirom/mdroll

From source

$ git clone https://github.com/tokuhirom/mdroll
$ cd mdroll
$ cargo build --release

Prebuilt binaries are attached to each release: macOS on Apple silicon, Linux on x86-64 and arm64, and Windows on x86-64. mise picks the right one for your machine automatically. Intel Macs build from source.


Usage

$ mdroll README.md
$ cat README.md | mdroll
$ mdroll                      # browse *.md from the current directory
$ mdroll README.md | less -R  # piped output renders once and exits

When stdout is not a terminal, mdroll renders the whole document to stdout and exits instead of trying to page it — the same thing a pager does when it is not on a terminal.

Options

Option Description
--theme <NAME|PATH> Color theme, by name or by path to a .toml file. Default: terminal (uses your terminal's own colors).
--list-themes Print available theme names and exit.
--dump-theme <NAME|PATH> Write a theme out as TOML and exit. A starting point for your own.
--wrap / --no-wrap Start in reflow or horizontal-scroll mode.
--source Start in source view instead of rendered view.
--width <N> Cap the reflow width. 0 means full terminal width.
--margin <N> Blank columns to keep on each side. Default 2.
--status / --no-status Show a persistent status line instead of transient toasts.
--mouse Enable mouse capture (needed for image click actions). Off by default.
--no-images Disable inline image rendering.
--no-remote-images Never fetch images over the network; show their alt text instead.
--graphics <MODE> auto, kitty, or none. Default auto, which asks the terminal.
--no-color Plain output, no ANSI styling.
--mermaid <MODE> auto, text, or image. Default auto.
--watch Reload automatically when the file changes on disk.
-z, --no-big-headings Never draw headings at double size, by either method.
--ambiguous-wide Treat East Asian Ambiguous characters as two columns.
--config <PATH> Use an alternate config file.

mdroll --man > mdroll.1 writes a man page, generated from the same definition the argument parser uses so it cannot drift.

Environment variables: MDROLL_THEME, MDROLL_CONFIG, NO_COLOR, and VISUAL or EDITOR for the v key.

Precedence is command line → environment → config file → built-in default.


Modes

mdroll has two independent, orthogonal mode axes:

Wrap No-wrap
Render Reflowed, styled output. The default. Styled output, horizontal scrolling. Good for wide tables.
Source Raw Markdown, reflowed. Raw Markdown, one logical line per row. Best for copying.

Toggling into source view automatically switches to no-wrap, and restores your previous wrap setting on the way back. This matters: dragging a selection across reflowed text inserts hard line breaks into whatever you copy. In no-wrap source view, one logical line stays on one row, so your terminal's native selection gives you the text exactly as written.


Key bindings

Navigation

Key Action
j / Scroll down one line
k / Scroll up one line
d Half page down
u Half page up
f / Space / PgDn Page down
b / PgUp Page up
g Jump to top
G Jump to bottom
h / Scroll left (no-wrap mode)
l / Scroll right (no-wrap mode)
0 Reset horizontal scroll

Modes

Key Action
w Toggle wrap / no-wrap
s Toggle rendered / source view
t Cycle theme
z Toggle big headings
i Toggle inline images

Copying

Key Action
Tab Move block cursor forward
Shift-Tab Move block cursor backward
yy Yank block as Markdown source
Y Yank block as rendered plain text
yc Yank code block contents only, without the fences
V Line selection mode — extend with j/k, confirm with y
yp Yank the file path

Links and search

Key Action
F Link picker — label every link, jump by keystroke
o Open the link under the block cursor
/ Search forward
? Search backward
n / N Next / previous match

Other

Key Action
r Reload the file
v Open $EDITOR at the line on screen, then reload
T Table of contents
H Show help
q / Esc Quit

Links are also emitted as OSC 8 hyperlinks, so Cmd-click (macOS) or Ctrl-click opens them directly through the terminal without mdroll capturing the mouse.

T marks the entry you are already inside, the way the block cursor marks the block you are on, and opens on that part of itself rather than on its own first line. Above the first heading there is no entry to mark and none is marked.

T and H put a pane over the document, and closing one comes back to the row you were reading rather than to the top. The contents pane is where a reader goes precisely when they have not decided to move yet, so deciding against it has to cost nothing. Picking an entry is a decision and goes where it says.

? is backward search, the pager meaning, because the whole of the navigation here is the pager's — j, k, d, u, f, b, g, G, /, n, N — and giving ? to the help would leave / without its opposite. But ? is what almost everything else opens help with, so the prompt it opens says help: H until the first character of a query is typed. The key it names is the one that is bound, not the one shipped, so rebinding help renames the hint too.

A key bound to nothing says so — x does nothing — H for help — rather than being swallowed. Silence is what a viewer that has stopped responding looks like, and it is not an answer to the reader who has just discovered that a key they guessed at is not the key. Ctrl-C is answered with q to quit instead: raw mode means it arrives as a key rather than as a signal, and the person pressing it wants the way out, not the list of keys. It does not quit — that would be a binding the keymap does not know about — it says where the exit is.


Terminal support

WezTerm is the reference. Everything works everywhere; the features below are the ones that depend on what the terminal can actually do.

Feature Works on Elsewhere
Inline images WezTerm, kitty, ghostty Alt text, dimmed
Big headings, via DECDHL WezTerm, xterm, foot see below
Big headings, rasterized kitty, ghostty Colour and weight only
Heading borders and bars, free kitty, ghostty Drawn as text; the rule costs a row
Clickable links (OSC 8) Most modern terminals Use F or o instead
Clipboard over SSH (OSC 52) Most modern terminals Enable it in your terminal
Truecolor COLORTERM=truecolor Nearest 256-color match

Over ssh

Graphics work over ssh. The escape sequences travel down the connection like any other output, and the terminal drawing them is the one in front of you.

What does not travel is the environment. WEZTERM_PANE, KITTY_WINDOW_ID and friends are set by a terminal on the machine it runs on, so a viewer on the far end that goes looking for them concludes there is nothing there. mdroll therefore asks the terminal instead, at startup, with a graphics query followed by a Device Attributes request — every terminal answers the second one, and replies come back in order, so a DA reply with no graphics reply ahead of it means no. The same round trip asks for the cell size, which TIOCGWINSZ also cannot report across a connection.

If a terminal answers neither, --graphics kitty says to draw anyway:

graphics = "kitty"

Multiplexers

tmux rewrites both graphics escapes and line attributes, so mdroll turns inline images and double-height headings off when TMUX is set rather than emitting sequences that would arrive mangled.

herdr passes the Kitty graphics protocol through, but only when you opt in. Add this to your herdr config, or images will fall back to alt text:

[experimental]
kitty_graphics = true

Its panes are drawn by ghostty's terminal, which implements no ESC # sequence but DECALN, so double-height headings are dropped there rather than mangled — and the pane reports TERM=xterm-256color, which the DECDHL allowlist would otherwise take at its word. HERDR_PANE_ID is what says a pane is one, so big headings inside herdr take the rasterized path, which needs the graphics opt-in above.

Images

A paragraph that holds nothing but pictures is a figure: one logo on its own, or a whole row of badges laid out side by side and wrapped when the row fills up. A lone figure keeps its alt text as a caption; a badge row does not, since the badges say it themselves. <img width> and <img height> are honoured, which is what stops a logo authored at 1300 pixels wide from filling the terminal. A picture wrapped in a link opens the link — o on a badge goes to the build, not to a PNG of a build's state.

SVG is rasterized through resvg at the size it will be displayed at, so a logo is as sharp as the terminal's cells allow. Badge text needs fonts, which are taken from the system.

Images behind an http(s) URL are fetched on worker threads and cached under ~/.cache/mdroll/images, keyed by URL. The document is drawn immediately with alt text where the pictures will go, and each one replaces its text as it lands; a second look at the same document is instant and works offline.

Entries are dropped a week after they were written, and fetched again the next time they are wanted. Time since writing, not since last use: a build badge that never expired would show the same state forever, and being a week out of date is the failure this is meant to bound.

The cache is a record of which documents have been opened and what they pointed at, so on Unix it is kept to its owner: ~/.cache/mdroll and everything under it are 0700, and downloads land as 0600. A directory left open by an older version is narrowed on the next run.

Opening a document means talking to whichever hosts it points at, so:

$ mdroll --no-remote-images README.md   # once
remote_images = false                   # always

Nothing is fetched when stdout is not a terminal, so mdroll README.md | head never touches the network. data: and file: URLs are not fetched at all.

Mermaid

Box drawings handle flowchart and sequenceDiagram. Everything else — pie charts, Gantt charts, state diagrams, subgraphs, anything cyclic — needs a real renderer, which means mermaid-cli:

One kind of flowchart is declined too. Every edge crossing a rank boundary turns in the same place — the bus a parent's children hang off in a top-down chart, the column a connector turns in sideways — and two of those that overlap are joined rather than drawn over each other, because two edges arriving at one box do meet there. A run with two boxes hanging off one side and two off the other therefore offers all four connections, whichever of them were written:

A --> C      ┌───┐   ┌───┐
A --> D      │ A │   │ B │
B --> C      └───┘   └───┘
               │       │
               ├───────┤
               ▼       ▼
             ┌───┐   ┌───┐
             │ C │   │ D │
             └───┘   └───┘

Those three edges and the same three with B --> D in place of B --> C draw the identical picture, so a chart whose run would say more than the document does is declined rather than drawn. Where every connection the run offers was written — one parent fanning out, several parents merging into one child, a diamond, or all four of the edges above — the picture says exactly what it means and is drawn as before.

Two runs can also be threaded through each other for no reason the graph gives, because the order of a rank is the order its nodes were written in. Where that is what stops a chart being drawn, the ranks are reordered until it can be. The order in the document is preferred and kept whenever it works, so a diagram that came out one way yesterday comes out the same way today.

Where no order helps — two parents meeting at a child they share, as above — an edge is taken off the band and routed round the outside instead, on the same lane an edge that skips a rank uses. The band is then left with edges its run can say, and the routed edge has a run nothing else is on, which is also somewhere its label unambiguously belongs:

A -->|ac| C     ┌───┐   ┌───┐
A -->|ad| D     │ A │   │ B │
B -->|bc| C     └───┘   └───┘─┐
               ad │        ac │
                  ├───────┐   │bc
                  ▼       ▼   │
                ┌───┐   ┌───┐ │
                │ D │   │ C │◀┘
                └───┘   └───┘

A label wants that as much as a run does. It hangs beside whichever of its edge's two ends the edge has to itself — the parent's, where the parent has one child; the child's, where the child has one parent — and where neither is, every line of the band carries two edges and a label beside any of them reads as either's. A and B both to C and D, all four labelled, is the case: the four used to be stacked on rows of their own, which kept one from being written over another and said nothing about which was which. Edges come off the band until what is left can say which is which, the same repair for the same kind of ambiguity:

A -->|ac| C      ┌───┐   ┌───┐
A -->|ad| D      │ A │   │ B │
B -->|bc| C      └───┘   └───┘──────┐
B -->|bd| D        ├───────│───┐    │
                   │       │   │    │bc
                ad │       │bd │ac  │
                   └───────┤   │    │
                    ┌──────│───│────┘
                    ├──────│───┘
                    ▼      ▼
                 ┌───┐   ┌───┐
                 │ C │   │ D │
                 └───┘   └───┘

ad and bd are beside columns that carry one edge each, and ac and bc beside lanes that carry one each. An unlabelled band is nothing to repair, however its ends are shared, so A --> C and the three like it are drawn as before: the ambiguity is in the label, not in the lines.

A lane runs past the far end of every rank, so a run reaching it passes behind whatever boxes stand between, and comes out of hiding beside the box next door reading as that box's edge. Every edge routed round the outside is therefore asked for a road that is clear — both of its ends the last boxes of their ranks — whether it was taken off a band or skips a rank of its own accord. Hence C above being moved to the end of its rank.

Only one box of a rank can be the last of it, so an order does not always exist: two edges skipping a rank out of the two boxes of one rank and into the two of another want all four ends last. The end that cannot have the short road takes a long one instead. It leaves the box on the side facing the band and travels a row of the band that is its own, which is clear of every box by construction — a band is what lies between the ranks — and it crosses the band's connectors on the way, which is now something a drawing can say. The band is deepened by the row it gives away, and only the band that gives one:

A --> X --> C   ┌───┐   ┌───┐
B --> Y --> D   │ A │   │ B │
A --> C         └───┘   └───┘───┐
B --> D           ├───────│───┐ │
                  │       │   │ │
                  │       │   │ │
                  ▼       ▼   │ │
                ┌───┐   ┌───┐ │ │
                │ X │   │ Y │ │ │
                └───┘   └───┘ │ │
                  │       │   │ │
                  │       │   │ │
                  │       │   │ │
                  │┌──────│───┘ │
                  ▼▼      ▼     │
                ┌───┐   ┌───┐   │
                │ C │   │ D │◀──┘
                └───┘   └───┘

B --> D has the clear road out of B and back into D, both being the last of their ranks. A --> C has neither, so it leaves the underside of A — where its own connector to X already leaves, which is why the two are joined there — and comes back over C one column across from the connector X --> C arrives in, so that the two arrowheads are not one.

Two lanes belong to two edges, so where one crosses the other they do not meet. Box drawing has no character for one line passing over another, so the vertical is drawn and the horizontal is left with a one-cell gap where it runs behind — a gap in a long run is read straight across, whereas a is read as a junction, which is the one thing the crossing is not. A corner is a different matter: two lanes leaving the same box share the row they leave on, and there they are joined.

$ mise use -g npm:@mermaid-js/mermaid-cli
$ npx puppeteer browsers install chrome-headless-shell

With mmdc on PATH and a terminal that has graphics, those diagrams render as pictures. Without it they show as source. --mermaid image forces the picture path even for diagrams box drawings could handle; --mermaid text never runs mmdc at all.

When a document had a diagram that only mmdc could have drawn and mmdc is not installed, mdroll says so on stderr as it exits:

mdroll: 1 diagram could not be drawn as a picture — mmdc is not installed.
To draw it:
  mise use -g npm:@mermaid-js/mermaid-cli
  npx puppeteer browsers install chrome-headless-shell

On the way out rather than in a toast, because it is a fact about the machine and not about the document, and a reader who has decided against mmdc should not have it in front of them all session. Nothing is said when the box drawings covered every diagram, when the terminal has no graphics, or when --mermaid text or --no-images already said no picture was wanted — in all of those, installing anything would change nothing. 2>/dev/null silences it for good.

Rendering happens on a worker thread, because starting a browser takes long enough to feel, and results are cached under ~/.cache/mdroll/mermaid keyed by the diagram's content. The box drawings or the source appear immediately and the picture replaces them when it arrives.

Cell geometry

Sizing an image in rows needs to know how many pixels a character cell is. mdroll asks the terminal, and falls back to 8×16 if it will not say — which distorts the aspect ratio slightly but never breaks the layout.


Design

Pipeline

source text
   │
   ▼  comrak (CommonMark + GFM)
  AST ── sourcepos ──┐
   │                 │
   ▼                 │
Vec<Block>  ◄────────┘   intermediate representation
   │
   ▼  layout(&[Block], Viewport, Mode) -> Vec<Line>     ← pure function
Vec<Line>
   │
   ▼  crossterm
 screen

Intermediate representation

struct Span {
    text: String,
    style: Style,
    link: Option<LinkId>,
}

struct Block {
    source_range: Range<usize>,   // line range in the original file
    kind: BlockKind,              // Heading(u8) | Para | Code | Quote | List | Table | Images
    spans: Vec<Span>,
}

struct Line {
    source_line: usize,
    scale: Scale,                 // Normal | DoubleHeight
    spans: Vec<Span>,
    hits: Vec<Hit>,
}

struct Hit {
    rect: Rect,
    target: HitTarget,            // Link(LinkId) | Image(ImageId)
}

Two things carry the whole design:

source_range on every block. comrak attaches sourcepos to each AST node, giving the start and end position in the original file. Propagating that range means yanking a block is a slice of the original source, not a reconstruction from the rendered form. It also lets mode switches preserve your reading position — the current screen row maps to a source line, and the source line maps back into whatever the new layout produced. Skipping this early is expensive to retrofit.

layout() is a pure function. Wrap/no-wrap toggling, source/render toggling, and terminal resize are all handled by discarding the layout and recomputing it. No incremental state, no invalidation logic, no drift.

What counts as Markdown here

The rule is what GitHub renders, which is not the same as the GFM spec.

The spec covers five extensions — tables, task lists, strikethrough, autolinks, and disallowed raw HTML. GitHub renders considerably more than that: > [!NOTE] alerts, footnotes, :rocket: emoji shortcodes, $...$ math, and YAML front matter are all GitHub features that no spec mentions. Since the point of this viewer is to show you the file the way GitHub will, the line is drawn at GitHub's behaviour rather than at the spec, and those are all on.

The rule cuts the other way too, which matters more. The Markdown parser underneath, comrak, offers extensions GitHub does not have, and four of them are deliberately off because turning them on changes what an ordinary document means:

Extension Syntax With it on On GitHub
underline __text__ underlined bold
subscript ~text~ subscript struck through
superscript ^text^ superscript literal ^text^
spoiler ||text|| hidden literal ||text||

The first two are the reason this is a rule and not a preference. __bold__ appears in most READMEs, and GFM defines strikethrough as one or two tildes — so subscript does not merely diverge from GitHub, it breaks the spec the project claims to implement. Superscript and subscript are still available the way they are on GitHub, through <sup> and <sub>.

Each of the four has a test pinning the GitHub behaviour, so re-enabling one fails the suite rather than quietly changing every document.

HTML

READMEs are full of HTML that Markdown has no syntax for: centred logos, badge rows, <details> sections, <sub> captions. GitHub renders all of it, so printing the tags is the wrong answer.

mdroll parses the subset that appears in hand-written documents — well-nested tags, quoted or bare attributes, void elements, comments, and the entities people actually type — and maps it onto the same intermediate representation Markdown produces. align="center" and text-align become block alignment, which Markdown itself cannot express. A logo wrapped in a link inside a <picture> inside a centred paragraph is recognised as what it is, a figure, and drawn as one.

Two deliberate departures from a browser. A run of <br> tags collapses to a single blank line, because six of them is a spacing hack that would cost six rows of a terminal. And <details> cannot fold, so the summary is shown as a heading and the contents follow it.

An HTML block that produces nothing renderable falls back to showing its source, which is still better than showing nothing.

Front matter

GitHub draws YAML front matter as a table, and that is what makes an ADR readable in a terminal: status and date are the first things you want, and a block of key: value is a poor way to read them.

The parser covers the shapes that appear at the top of a document — scalars, quoted scalars, both spellings of a sequence, comments — and flattens each key to one row. What it does not cover, it declines: a nested mapping, a block scalar, an anchor, and the front matter falls back to its own source. That is the same bargain the HTML subset makes, and it is cheap here because front matter is only ever displayed. The cost of not understanding something is that you see it as written.

A trailing # is left alone rather than treated as a comment, because title: C# in 2026 is a value and there is no way to tell from the outside.

Text measurement

Display width comes from unicode-width. The East Asian Ambiguous class is configurable, because whether or occupies one column or two depends on the terminal's own setting. For WezTerm, match it to your config:

-- ~/.wezterm.lua
treat_east_asian_ambiguous_width_as_wide = true

Line breaking uses unicode-linebreak (UAX #14) plus kinsoku adjustments: no line may begin with 。、)」』ー and none may end with 「(『. Text without spaces has to break somewhere, and these rules are what keep the result from looking wrong.

Horizontal scrolling

The horizontal offset is stored in display columns, never in bytes or char counts. Slicing a line walks it accumulating width; when a full-width character straddles the boundary, it is replaced with a single space. Getting this rule wrong produces a one-column drift that compounds across the document.

Screen layout and the status line

The terminal height is decremented in exactly one place, and the result has its own type so it cannot be confused with the full screen:

struct Screen { rows: u16, cols: u16 }

impl Screen {
    fn viewport(&self) -> Viewport {
        Viewport { rows: self.rows.saturating_sub(1), cols: self.cols }
    }
    fn status_row(&self) -> u16 { self.rows.saturating_sub(1) }
}

layout() and all scroll arithmetic take a Viewport. Only the top-level draw function sees a Screen.

The status line is drawn last, at an absolute position, after the content:

clear → draw content → MoveTo(0, status_row) → draw status

Because it overwrites whatever is there, a content region that miscounts by a row cannot hide it. Autowrap (DECAWM) is disabled while the status line is written, so writing into the bottom-right cell cannot trigger a scroll.

Rendering is a full redraw every frame. A viewer updates rarely, and differential updates are the usual source of "the status line vanishes sometimes" bugs.

By default, mode changes surface as a transient toast on the bottom row for about 1.5 seconds rather than a permanent status line — it costs no rows at all. --status switches to a persistent line.

Headings

# and ## are drawn large; ### and below get colour and weight only. Two levels is where GitHub stops setting a heading apart structurally as well — it is the same pair it draws a bottom border under — and a terminal has few rows to spend, so a document whose every level is double-height reads as no hierarchy at all.

On terminals supporting DECDHL, such a heading is emitted as double-height text:

\e#3Heading      ← top half
\e#4Heading      ← bottom half

This consumes two physical rows for one logical line, and halves the usable column count for that row, so the layout pass must account for both.

Support is narrower than it looks. kitty does not implement DECDHL — it reports ESC # 3 as a parse error and then draws the line a second time, so a heading appears twice. Because a terminal that ignores the sequence produces visibly broken output rather than a graceful no-op, detection is an allowlist rather than a denylist: WezTerm, xterm, and foot get double-height headings, and everything else gets colour and weight. -z forces them off anywhere.

Where DECDHL is unavailable but graphics are not — kitty and ghostty, exactly — the heading is instead rendered to a bitmap at twice the cell height and placed with the Kitty graphics protocol over the two rows the layout already reserved. The layout does not need to know which path was taken: a double-height line occupies two rows and half the columns either way. The bitmap is transparent behind the glyphs, so the terminal's own background keeps showing through — except under a span the theme gives a background of its own, such as a code span inside a heading, which gets a block across both rows the way the terminal would paint the cell.

The font comes from fc-match sans-serif:bold, falling back to a short list of usual locations. If nothing is found, big headings are simply not offered.

A bitmap can also be decorated, which text cannot. GitHub gives h1 and h2 a bottom border, and here a rule under the heading costs nothing: the text uses 0.78 of two rows the layout has already reserved, so the line goes in the space below it rather than on a row of its own. A bar down the left goes in the blank the margin leaves, and is dropped rather than drawn over the first letter when there is no blank to put it in.

[heading]
h1 = { fg = "#bd93f9", bold = true, border = true, bar = true }
h2 = { fg = "#8be9fd", bold = true, border = "#6272a4" }   # explicit colour
h3 = { fg = "#50fa7b", bold = true }

Every level can carry either. Where the heading becomes a bitmap the two are painted into it and cost nothing; everywhere else they are drawn as text, which is what makes them work below the cutoff, on terminals with no graphics, and under -z.

true takes the heading's own colour at 55%, which is the default for the two levels drawn large. Deriving it rather than requiring it written down is the point: every theme that predates the feature, including any you already have, shows a border without being edited. Dimming scales the channels rather than blending towards the background, because terminal has no background colour to blend with.

Unlike colour, decoration does not inherit down the levels. A theme naming only h1..h3 gets h3's colour on h4..h6, and asking for a bar on h3 does not put one on every level beneath it.

The text form differs in what it costs rather than in whether it appears. A bar is a gutter, the same mechanism as the one down the side of a blockquote, so it takes a column and the heading reflows around it. A rule has nowhere to go but a row of its own, because a line of text fills its row from top to bottom and a terminal cannot underscore it with anything thinner than a character. So the same document is a row taller per h1 and h2 outside kitty and ghostty, where the bitmap has 0.22 of two already-reserved rows to spend and spends it.

Clipboard

Local sessions use arboard. When SSH_CONNECTION is set, or when arboard fails, mdroll falls back to OSC 52, which carries text and therefore works for every yank operation over SSH. Image data cannot be transported this way; on remote sessions an image yank copies the path instead.

Themes

Themes are TOML, embedded at build time with include_str!. Additional themes are loaded from ~/.config/mdroll/themes/*.toml.

name = "dracula"

[code]
syntect_theme = "Dracula"

[heading]
h1 = { fg = "#bd93f9", bold = true }
h2 = { fg = "#8be9fd", bold = true }
h3 = { fg = "#50fa7b" }

[inline]
link = { fg = "#8be9fd", underline = true }
code = { fg = "#ff79c6", bg = "#44475a" }

Two color systems are in play: the UI palette above, and syntect's .tmTheme files for code block highlighting. syntect ships Solarized and the base16 family but not Dracula, so Dracula.tmTheme is vendored and referenced by name.

The default theme is terminal, which sets no background color and inherits your terminal's palette. This avoids fighting WezTerm's transparency and background image settings. Named themes paint backgrounds only when explicitly selected.

The bundled six, all rendering the same document:

dracula
mdroll with the dracula theme
terminal (default)
mdroll with the terminal theme
nord
mdroll with the nord theme
gruvbox
mdroll with the gruvbox theme
solarized-dark
mdroll with the solarized-dark theme
solarized-light
mdroll with the solarized-light theme

Captured in kitty, so the headings are bitmaps: dracula is the one theme that uses both decorations, and the rest carry the default border on h1 and h2.

Writing one

Start from an existing theme rather than a blank file:

$ mdroll --dump-theme dracula > mine.toml
$ mdroll --theme ./mine.toml README.md
$ mv mine.toml ~/.config/mdroll/themes/     # once you like it

--dump-theme writes the resolved theme: every key the parser reads, including the ones this theme left at their default. That is the reference — a key list written out by hand here would be wrong the first time a key was added and nobody noticed, and a round-trip test asserts that dumping a theme and reading it back gives the same theme, so the output cannot drift from the code.

--theme takes a path as well as a name, so the file can be rendered where it is being edited instead of being installed after every change. A name and a path never collide: a name is a file stem under ~/.config/mdroll/themes, so it carries neither a separator nor a .toml extension. A user theme whose stem matches a bundled one replaces it, and --list-themes shows both.

Every key is optional and absent ones fall back, so a theme can be four lines long. Two fallbacks are worth knowing:

  • h4, h5 and h6 inherit from the deepest heading level that was given, so setting h1..h3 styles all six sensibly rather than leaving three of them unstyled.
  • foreground and background left unset mean inherit, which is what terminal does deliberately. Setting them is what makes a theme paint over your terminal's own palette.

Colors are #rrggbb, #rgb, a 0-255 palette index, or a name: red, brightred, and so on through the sixteen, plus reset for the terminal's default. Note that white is the dim one — the bright one is brightwhite.

Attributes are bold, italic, underline, strikethrough, dim and reverse. They only ever go on: a style is merged over the default rather than replacing it, so writing bold = false against a key that defaults to bold does nothing. Nothing in a theme can turn an attribute off, which is why a dump writes only the ones that are set.

A misspelled section ([inlnie]), a misspelled key (lnik = { … }) and a misspelled attribute ({ blod = true }) are all errors, and the message for a key names the ones that section does have:

$ mdroll --theme ./mine.toml README.md
mdroll: in ./mine.toml: unknown key "lnik" in [inline]; valid keys: link, code,
emph, strong, strikethrough, footnote

Truecolor is assumed; 256-color terminals get a nearest-color downgrade.

Configuration

# ~/.config/mdroll/config.toml

theme = "dracula"
default_mode = "render"        # "render" | "source"
default_wrap = true
width = 100                    # 0 = full terminal width
margin = 2                     # blank columns on each side
status = false                 # false = toast, true = persistent line
double_height_headings = true
images = true
remote_images = true           # fetch images behind an http(s) URL
graphics = "auto"              # "auto" | "kitty" | "none"
mouse = false
east_asian_ambiguous_wide = true
watch = false
mermaid = "auto"                # "auto" | "text" | "image"

[keys]
# Naming an action replaces all of its default bindings. An empty list
# unbinds it.
quit = ["q", "Esc"]
toggle_wrap = ["w"]
half_page_down = ["Ctrl-d"]
contents = []

Action names are quit, scroll_down, scroll_up, half_page_down, half_page_up, page_down, page_up, top, bottom, scroll_left, scroll_right, reset_scroll, toggle_wrap, toggle_source, cycle_theme, toggle_big_headings, toggle_images, cursor_next, cursor_prev, yank, yank_rendered, select_lines, link_pick, open, search_forward, search_backward, next_match, prev_match, reload, edit, contents, and help. Key specs are a single character, a name such as Esc, Space, Tab, Shift-Tab, PgDn, Home, or an arrow, optionally prefixed with Ctrl- or Alt-.


Roadmap

v0.1 — Walking skeleton

  • comrak parsing into Vec<Block> with source_range
  • Pure layout() with reflow
  • Vertical scrolling, Screen/Viewport split
  • Absolute-position bottom row, DECAWM handling
  • Toast on mode change
  • Headings, paragraphs, lists, blockquotes, code blocks (no highlighting)

v0.2 — Modes

  • Source view toggle
  • No-wrap mode with horizontal scrolling
  • Auto no-wrap when entering source view, restore on exit
  • Full-width character handling at slice boundaries

v0.3 — GitHub Flavored Markdown

  • Tables, with column widths measured in display columns
  • Task lists and strikethrough
  • Autolinks
  • Footnotes, with a references section at the end
  • > [!NOTE] / [!TIP] / [!IMPORTANT] / [!WARNING] / [!CAUTION] alerts

v0.4 — Text quality

  • UAX #14 line breaking
  • Kinsoku rules
  • Configurable East Asian Ambiguous width

v0.5 — Links

  • OSC 8 hyperlink emission
  • Link picker (F)
  • Open under cursor (o)

v0.6 — Copying

  • Block cursor (Tab / Shift-Tab)
  • y / Y / yc / yp
  • Line selection mode (V)
  • arboard with OSC 52 fallback

v0.7 — Presentation

  • Theme loading, bundled themes, --theme and --list-themes
  • syntect code block highlighting
  • Config file
  • Persistent status line as an option

v0.8 — Big headings

  • DECDHL double-height headings
  • Capability detection and graceful fallback
  • Layout accounting for halved column count and doubled row cost

v0.9 — Mermaid

  • flowchart / graph rendered with box drawings, laid out by rank
  • sequenceDiagram rendered with lifelines and arrows
  • Image rendering through mmdc where the terminal has graphics
  • Fall back to a highlighted code block when neither can draw it

v0.10 — Finding things

  • Incremental search with /, ?, n, N
  • Section breadcrumb, shown in the status line
  • Table of contents pane (T)

v0.11 — Images and files

  • Inline images via the Kitty graphics protocol
  • Optional mouse capture (--mouse) with rectangle hit-testing
  • File browser when invoked with no arguments
  • r to reload
  • --watch for live reload

v1.0

  • Key remapping through config
  • Rasterized heading fallback for non-DECDHL terminals
  • Release automation, binaries for macOS/Linux/Windows
  • Documentation and man page (mdroll --man > mdroll.1)

v1.1 — GitHub parity

  • :rocket: emoji shortcodes, with unknown codes left as written
  • Definition lists
  • Math shown as its LaTeX source, since a terminal cannot typeset it
  • comrak extensions GitHub does not have turned off, with tests pinning __bold__ and single-tilde ~strikethrough~ to GitHub's reading

v1.2 — Corrections

Known defects, found by reading the code against this document. Each one is small on its own; they are collected here so the list is somewhere other than an issue tracker nobody reads.

  • The contents pane draws its links against the main document's link table, because the draw path checks only for the help pane and not for the contents pane the way active_doc does. Ctrl-clicking an entry therefore opens an unrelated URL. o and F take the other path and are correct.
  • Every link-picker label is placed at the column of the first link on its row, so a badge row gets its labels stacked in one spot and only the last one drawn is visible. The hit rectangles already carry the right column.
  • Only the first 26 links can be labelled, and the ones past that are not mentioned, which reads as the picker having missed them.
  • y on its own does nothing: the yank needs yy, and the key after y is swallowed. Both this README and the help pane document a bare y.
  • In-document anchor links — [Terminal support](#terminal-support), which this file itself uses — are handed to the system opener instead of jumping to the heading. The contents pane already maps #line-N this way.
  • Horizontal scrolling has no right-hand bound, so holding l in no-wrap mode runs off the end of the content and into empty screens.
  • mmdc is never found on Windows, where it is mmdc.cmd: the PATH search consults neither PATHEXT nor the executable bit.
  • Nothing ever expires from ~/.cache/mdroll, so a badge whose image changes stays pinned to the first version fetched, with no way to refresh it short of deleting the directory by hand.
  • A $$...$$ block leaves a blank row above and below it.

v1.3 — Front matter

  • YAML front matter drawn as a table, the way GitHub draws it
  • A parsed subset, falling back to source for anything beyond it

v1.4 — Measurement

Defects in the parts this document makes its strongest claims about, found by reading them against those claims. Each was invisible to the existing tests because the tests used text that happened to avoid the case.

  • Kinsoku applied to forced breaks, not only to chosen ones: text with no UAX #14 opportunity anywhere could still open a line with
  • Tab stops counted in display columns, so a tab after lands where the terminal puts it
  • A table keeps its list marker wherever it sits in the document, rather than only when it opens the file
  • A double-height heading scrolls horizontally at the speed of the text under it, rather than twice as fast

v1.5 — HTML, diagrams, and pictures

Defects in html.rs, mermaid.rs, and graphics.rs, found by reading them against this document. The first two are crashes on documents nobody would think twice about writing; the mermaid ones all break the module's own rule that declining beats drawing something subtly wrong.

  • The entity decoder looks for the ; in the twelve bytes after an & by slicing at byte twelve, which panics when a character straddles it. QuickCheck & 日本語 in an HTML block takes the whole viewer down.
  • Attribute parsing steps over a byte it cannot make sense of, which lands inside a multi-byte character and panics on the next name it reads. Both <p 幅="3"> and the text a<b は c inside an HTML block reach it.
  • flowchart RL and BT reverse every edge instead of the layout, so A --> B is drawn as an arrow pointing at A. The one thing a diagram says is which way its arrows point.
  • A statement ending in ;, which mermaid's own documentation writes, makes a second node: A --> B; and B --> C; draw four boxes, B and B; among them.
  • A node whose id starts with end, subgraph, or classDef declines the whole diagram — endpoint[X] --> B renders as source. A sequence diagram does the same for a participant called loopback or optional.
  • An edge label is drawn on the row the parent's connector occupies, so it erases the line it belongs to, and the canvas is never widened for it, so anything long is silently cut off at the right edge.
  • In flowchart LR an edge that skips a rank is drawn straight at the box in between; since the line is drawn only where the canvas is still blank, it vanishes entirely and the diagram is missing an edge.
  • A self-edge — A --> A — is dropped without a word, rather than drawn or declined.
  • A sequence message whose text contains -->, as in A->B: use --> this, is split at the arrow inside the text and the whole diagram is declined.
  • Retiring a placement clears the upload cache by image id without checking that the id being deleted is the one cached. Resize the window once and every later frame re-reads the file, rescales it, re-encodes a PNG, and re-transmits it — for as long as the image stays on screen.

Four more, found while fixing those rather than by reading:

  • Edge labels are drawn only by the fan that hangs a parent's children off one bus, which is the only place that has them. A flowchart LR drops every label it is given — A -->|yes| B draws no yes anywhere — and so does any edge that skips a rank in a TD chart.
  • Where two parents meet at one child, each draws the junction under that child for itself, so the second overwrites the first: └─────┌────┘, with a corner where the column that carries on downwards wants a .
  • The same, one layer out: an edge that skips a rank is routed to a lane of its own, and a second lane leaving the same box has to cross the first one's corner to reach it. It stopped dead there and started again on the far side — └───┘─┐─┐ in a TD chart, and two under each other in an LR one, the upper one a line arriving from nowhere.
  • Two labelled edges that meet at one node write their labels in the same place, so the second is drawn over the first: A -->|from a| C beside B -->|from b| C reads from bm a. A label goes to the end of its edge where a fan's edges are each on a row of their own, and at a join it is the other end that is.

And one left over from that last one:

  • A label hangs off whichever end of its edge that edge has to itself, which is the parent where several meet at a child and the child where one parent has several. Where two parents each have two labelled children — A and B both to C and D — every column and every row is shared by two edges and there is nowhere left to put the fourth label. Two of the four are drawn over.

And one found while fixing it:

  • flowchart LR has its own version of that collision, and a worse one. The same four edges lose two labels outright and show the surviving two against the wrong edges, because a sideways label is written at one end of its own connector and several connectors arriving at one box share that row. The fix above does not reach it: that one stacks rows above the bus a fan hangs its children from, and LR has no bus to stack above.
  • Neither fix makes a label say which edge it belongs to where two of them end up side by side; it only stops one being drawn over the other. Two edges arriving at one box genuinely share the row they arrive on, and short of routing them apart there is nowhere a label could sit that belongs to one and not the other. Routing them apart is what it took, and the road round the outside is what made it affordable: a band whose labels cannot be told apart gives up edges to lanes, the fewest that leave every remaining label beside a line one edge is on, and every one taken off carries its own beside its lane. A band with nothing written on it is left alone — the ambiguity is in the label, not in the lines.

v1.6 — Themes

A theme can already say what colour a heading is, and a user can already write one. What is missing is everything around that: there is no way to point at a theme file that is not yet installed, no way to find out which keys exist without the repository checked out, and no way for a theme to say anything about a heading beyond its colour.

  • --theme takes a name and nothing else, so theme::load_path cannot be reached from the command line and a theme being written has to be copied into the config directory before it can be looked at.
  • No way to see a theme's keys from an installed binary. themes/*.toml are in the repository, not in the build. --dump-theme <name> writing the resolved theme back out as TOML answers it, and is a starting point for a new theme as well as a reference that cannot drift from the code.
  • The Themes section names a handful of keys by example and leaves the rest to be guessed at. It should say how a theme is written, where it goes, and what is derived when a key is absent.
  • A misspelled key inside a theme section is carried and never looked at. [inline] and { blod = true } are both deny_unknown_fields and say so, but the keys between them are map entries, so lnik = { … } is accepted in silence and the link stays unstyled. Being told beats wondering why the colour did not take.
  • A heading can be coloured but not decorated. GitHub draws h1 and h2 with a bottom border, which is the same pair this renderer draws at double height; a left bar is the other decoration worth having. Drawn into the bitmap they cost no rows, because the text occupies 0.78 of the two the layout already reserved.
  • Decoration colours have to be derived rather than enumerated. A theme written before the feature existed — including every theme a user already has — would otherwise show none of it, which is the same reason h4..h6 inherit from the deepest level given.

And two found while reading the above:

  • The Headings section says # Heading is emitted double-height, but heading_scale does it for every heading up to level 2. ## is drawn large and the document does not say so.
  • border and bar are accepted on every heading level and can only be drawn on the two that get a bitmap, because that is the only path with anywhere to put them. h3 = { bar = true } parses, resolves, and draws nothing. Either the levels below the cutoff grow a text decoration that costs a row, or the key is refused where it cannot be honoured.
  • Rasterizing flattens a heading to one string and one colour, taken from the first span that has one, so inline styling inside a heading is lost on exactly the terminals that get the bitmap. # See `config.toml` renders the code span in its own colour under DECDHL and in the heading's colour under kitty.
  • A span's background still does not survive rasterizing. A code span in a heading has one — code = { bg = "#44475a" } in Dracula — and the bitmap draws foregrounds onto a transparent canvas, so under DECDHL the span sits in a box and under kitty it does not. Smaller than the colour was, and the same shape of problem.

v1.7 — What a diagram says

Found by taking the last item of v1.5 at its word. Asking whether a label could be made to say which edge it belongs to turned up something larger sitting underneath it: where two labels cannot be told apart, quite often neither can the edges, and sometimes there is an edge in the picture that nobody wrote.

  • A band's shared run offers every connection between the boxes on either side of it. A --> C, A --> D, B --> C drew the picture of all four edges, and so did the same three with B --> D in place of B --> C, and so did A --> C, A --> D, B --> D: three different graphs, one drawing. flowchart LR did the same, and Z --> C, Z --> D, A --> D, B --> C offered six connections for four edges. Declining a chart whose run would say more than the document does is the smallest honest answer, and it is what the renderer already does with everything else it cannot model.
  • Declining is what this renderer can do about it, not what it should do. A band that will not fit one run would fit two, given each parent a run of its own and columns chosen so that no parent's connector has to cross another's run — which is layout proper, rather than centring each rank and putting every bend in one place. That is also the only thing that would let a label belong to one edge and not the other, which is the v1.5 item above. What it took in the end was not a second run in the band but a run outside it: the lane an edge that skips a rank already uses, which is the one place on the canvas that belongs to one edge and to nothing else, and where its label therefore belongs to it too. The v1.5 case is not all of the way home — where every connection was written the run is honest and stays shared, and the labels on it are still side by side.
  • The road out is only clear when both ends of the routed edge are the last boxes of their ranks, because a run reaching the lane passes behind whatever stands between and comes out of hiding as the next box's edge. A band with three parents over two children has no such edge and is still declined. Widening the canvas to give the run a column of its own, rather than borrowing the space behind the boxes, would reach those too. What it took was a line of its own rather than a column: an end with no clear road leaves the box into the band and travels a row of it that nothing else is on, a band being the one part of the canvas no box is in. The band is deepened by the row, and the short road is still preferred — an order that gives every routed edge one is taken over an order that does not.
  • Two lanes crossing are joined into a , which says the two edges meet. They do not, and this has been true since lanes were added; it is the one place left where a line is drawn through another and claims to touch it.
  • Ordering within a rank is the order the edges were written in, so two runs can interleave when nothing about the graph makes them. A --> D then B --> C, where C and D were already introduced in the other order, is declined for a crossing that swapping the two would remove. A sweep that orders each rank to reduce crossings would give some of these charts back — as a repair for a chart that will not otherwise draw, not as a policy applied to every chart, so that a diagram that came out one way yesterday comes out the same way today.
  • In flowchart LR the corner where a connector turns is drawn hard, so where two turn in the same column the second erases the first's. In a diamond, A's connector turns at where belongs, and the run carrying its other edge stops half a cell short of it. A child level with its parent has it worse: its run is refused the cell outright and stops dead at the corner. The top-down bus states its junctions as directions and joins them; the sideways one has never been made to.

And one found while reading the road out again:

  • The road is asked for only where an edge was taken off a band. An edge that skips a rank of its own accord was let out unasked, on the grounds that it hides behind boxes belonging to ranks it has nothing to do with — but the boxes standing in its way belong to its own rank, and A --> C beside B --> Z drew │ C │◀──│ Z │─┘, joining two boxes nobody joined. Both kinds of routed edge want the same road, and asking for it moves C to the end of its rank the same way.

v1.8 — Finding the keys

Everything in this document is discoverable by reading this document. Inside the viewer there is one key that tells you the rest, and nothing anywhere says which key it is: a reader who never reads the README is not told twice, they are not told once.

  • ?, which opens help nearly everywhere else, opens a backward search prompt showing a bare ? and no explanation. The keys are the pager's on purpose and ? should stay backward search, but the prompt is the exact moment someone is asking for help and is the place to answer.
  • An unbound key does nothing at all — no toast, no bell — so pressing one is indistinguishable from a viewer that has stopped responding, and the reader learns nothing about how to find out what would have worked.
  • Ctrl-C is bound to nothing, and raw mode means it arrives as a key rather than as a signal, so the one key a reader reaches for when they believe a terminal program has hung is the one that proves them right. It now reports itself like any other unbound key, which says the viewer is alive but answers a question nobody asked: someone pressing Ctrl-C wants out, and should be told about q — or simply be let out. Told, in the end. Quitting on it would put a second exit in the program that the keymap cannot see, cannot rebind and does not list, and a reader who is shown q once knows it for the whole session.
  • The same signal-shaped key completed a yank. Ctrl-C arrives as Char('c') and the second half of a two-key sequence was matched on the character alone, so y followed by an attempt to interrupt the program put the code block on the clipboard — a keystroke meant to stop everything instead wrote to the one place outside the process the viewer can reach. Only an unmodified key continues a sequence now; Shift is not counted, because the case of the character carries it there the same way it does in the keymap.

v1.9 — Where you were

A sidebar was considered for the contents and turned down: a pane down one side costs columns on every row of every document, and where a # heading is drawn with DECDHL the sequence is a property of the whole terminal line, so anything sharing those rows is doubled with it. What the contents pane was missing was never the columns. It was that it did not say where in the document the reader already was, and that visiting it moved them.

  • Opening the contents or the help and closing it again put the document back at the top. The pane is a look at something else, and the contents in particular is where a reader goes when they have not decided to move at all — so the one gesture that should have cost nothing cost them their place, and in a long document there was no way back to it.
  • The contents pane lists every heading and marks none of them, so a reader who opens it to find out where they are has to work it out from the headings around the one they came from. The status line already computes the enclosing section for its breadcrumb, and the pane is drawn by the same machinery that highlights a search match. Marking it is also what makes the pane worth opening in a document with thirty headings, where the entry is off the first screenful and the pane used to open at its own top: the mark is what it scrolls to.

v1.10 — What the screen is holding

An upload is work already done and can always be done again. A placement is not: it is a picture on the reader's screen, and the record of what was placed last frame is the only thing in the program that can take one back. Losing that record loses the picture — permanently, because nothing else remembers it.

  • Cycling the theme left every picture that was on screen there for the rest of the session. A theme change reparses the document, and the reparse forgets the uploads, which is right: the ids belonged to the old document. It forgot what was placed along with them. The next frame sent the same image up again under a fresh id and drew it over the top, and the first placement, which no longer appeared in any record, was never deleted — so it stayed where it was and scrolling slid the document out from under it.

v1.11 — Whose terminal is it

A multiplexer's pane is a terminal in its own right, and the TERM it hands the process names the compatibility it offers rather than the emulator drawing it. An allowlist that reads that string is really asking the wrong terminal.

  • Big headings did nothing inside herdr. Its panes report TERM=xterm-256color, which the DECDHL allowlist reads as xterm, while the terminal behind them is ghostty's, which implements DECALN and no other ESC # sequence. A double-height row is written as two rows carrying the same text, so the heading arrived not as one big line but as two ordinary copies of itself. HERDR_PANE_ID is what says a pane is one, the way TMUX does; ruling it out also puts headings back on the rasterized path, which herdr draws once experimental.kitty_graphics is on.

Non-goals

  • Editing. mdroll is a viewer. Use your editor.
  • HTML or PDF export. Use pandoc.
  • A browser. Link following opens your system handler; it does not render remote pages.
  • Repository autolinks. GitHub turns #123 and a commit SHA into links because it already knows which repository you are looking at. A viewer opening a file from disk does not: the file may be in no repository, or in one that is not the repository the text refers to, and picking a remote to guess with would turn plain text into a link to the wrong project. Being told #123 is better than being sent somewhere unrelated.
  • Universal terminal support. WezTerm is the reference. Features degrade elsewhere rather than being designed down to the lowest common denominator.

Prior art

mdroll owes ideas to glow, mdcat, and md-tui. Each solves a different part of this problem well; none of them combines large headings, mermaid rendering, and in-viewer copying, which is the gap this fills.


Contributing

Issues and pull requests are welcome. For anything involving text measurement or line breaking, please include a test case with the actual string — width bugs are almost impossible to reason about from a description alone.

Development

$ cargo test
$ cargo clippy --all-targets -- -D warnings
$ cargo fmt --all

Git hooks are managed with lefthook. pre-commit runs the formatter check and clippy; pre-push runs the tests:

$ mise use -g lefthook   # or: brew install lefthook
$ lefthook install

tools/screenshot.sh renders a document in a real terminal on a headless X server and saves a PNG, which is how the screenshot above is produced:

$ cargo build --release
$ tools/screenshot.sh tests/fixtures/kitchen-sink.md shot.png --theme dracula

Note that it drives kitty, which does not implement DECDHL, so double-height headings never appear in screenshots taken this way.


License

MIT License

Copyright (c) 2026 Tokuhiro Matsuno

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Bundled theme definitions are derived from Dracula, Solarized, Nord, and Gruvbox, each MIT-licensed. See THIRDPARTY.md for full attribution.

About

A terminal Markdown viewer with big headings and real GFM support

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages