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.
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.
Existing terminal Markdown viewers are good, but three things kept coming up:
- Headings never look like headings. Every viewer renders
# Titlein a color or a background bar. None of them make the text larger, so document structure is hard to scan. - 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. - 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.
- 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 diagrams —
flowchartandsequenceDiagramdrawn with box characters, laid out by rank. Anything else goes throughmmdcand 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.
# mise.toml
[tools]
"github:tokuhirom/mdroll" = "latest"$ mise install$ cargo install --locked --git https://github.com/tokuhirom/mdroll$ git clone https://github.com/tokuhirom/mdroll
$ cd mdroll
$ cargo build --releasePrebuilt 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.
$ mdroll README.md
$ cat README.md | mdroll
$ mdroll # browse *.md from the current directory
$ mdroll README.md | less -R # piped output renders once and exitsWhen 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.
| 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.
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 | 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 |
| Key | Action |
|---|---|
w |
Toggle wrap / no-wrap |
s |
Toggle rendered / source view |
t |
Cycle theme |
z |
Toggle big headings |
i |
Toggle inline images |
| 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 |
| 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 |
| 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.
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 |
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"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 = trueIts 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.
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 # onceremote_images = false # alwaysNothing 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.
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-shellWith 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-shellOn 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.
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.
source text
│
▼ comrak (CommonMark + GFM)
AST ── sourcepos ──┐
│ │
▼ │
Vec<Block> ◄────────┘ intermediate representation
│
▼ layout(&[Block], Viewport, Mode) -> Vec<Line> ← pure function
Vec<Line>
│
▼ crossterm
screen
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.
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 | |
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.
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.
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.
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 = trueLine 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.
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.
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.
# 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.
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 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![]() |
terminal (default)![]() |
nord![]() |
gruvbox![]() |
solarized-dark![]() |
solarized-light![]() |
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.
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,h5andh6inherit from the deepest heading level that was given, so settingh1..h3styles all six sensibly rather than leaving three of them unstyled.foregroundandbackgroundleft unset mean inherit, which is whatterminaldoes 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, footnoteTruecolor is assumed; 256-color terminals get a nearest-color downgrade.
# ~/.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-.
- comrak parsing into
Vec<Block>withsource_range - Pure
layout()with reflow - Vertical scrolling,
Screen/Viewportsplit - Absolute-position bottom row, DECAWM handling
- Toast on mode change
- Headings, paragraphs, lists, blockquotes, code blocks (no highlighting)
- 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
- 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
- UAX #14 line breaking
- Kinsoku rules
- Configurable East Asian Ambiguous width
- OSC 8 hyperlink emission
- Link picker (
F) - Open under cursor (
o)
- Block cursor (
Tab/Shift-Tab) -
y/Y/yc/yp - Line selection mode (
V) -
arboardwith OSC 52 fallback
- Theme loading, bundled themes,
--themeand--list-themes - syntect code block highlighting
- Config file
- Persistent status line as an option
- DECDHL double-height headings
- Capability detection and graceful fallback
- Layout accounting for halved column count and doubled row cost
-
flowchart/graphrendered with box drawings, laid out by rank -
sequenceDiagramrendered with lifelines and arrows - Image rendering through
mmdcwhere the terminal has graphics - Fall back to a highlighted code block when neither can draw it
- Incremental search with
/,?,n,N - Section breadcrumb, shown in the status line
- Table of contents pane (
T)
- Inline images via the Kitty graphics protocol
- Optional mouse capture (
--mouse) with rectangle hit-testing - File browser when invoked with no arguments
-
rto reload -
--watchfor live reload
- 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)
-
: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
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_docdoes. Ctrl-clicking an entry therefore opens an unrelated URL.oandFtake 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.
-
yon its own does nothing: the yank needsyy, and the key afteryis swallowed. Both this README and the help pane document a barey. - 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-Nthis way. - Horizontal scrolling has no right-hand bound, so holding
lin no-wrap mode runs off the end of the content and into empty screens. -
mmdcis never found on Windows, where it ismmdc.cmd: thePATHsearch consults neitherPATHEXTnor 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.
- YAML front matter drawn as a table, the way GitHub draws it
- A parsed subset, falling back to source for anything beyond it
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
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 texta<b は cinside an HTML block reach it. -
flowchart RLandBTreverse every edge instead of the layout, soA --> Bis drawn as an arrow pointing atA. 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;andB --> C;draw four boxes,BandB;among them. - A node whose id starts with
end,subgraph, orclassDefdeclines the whole diagram —endpoint[X] --> Brenders as source. A sequence diagram does the same for a participant calledloopbackoroptional. - 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 LRan 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 inA->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 LRdrops every label it is given —A -->|yes| Bdraws noyesanywhere — and so does any edge that skips a rank in aTDchart. - 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 aTDchart, and two└under each other in anLRone, 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| CbesideB -->|from b| Creadsfrom 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 —
AandBboth toCandD— 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 LRhas 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, andLRhas 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.
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.
-
--themetakes a name and nothing else, sotheme::load_pathcannot 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/*.tomlare 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 bothdeny_unknown_fieldsand say so, but the keys between them are map entries, solnik = { … }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
h1andh2with 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..h6inherit from the deepest level given.
And two found while reading the above:
- The Headings section says
# Headingis emitted double-height, butheading_scaledoes it for every heading up to level 2.##is drawn large and the document does not say so. -
borderandbarare 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.
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 --> Cdrew the picture of all four edges, and so did the same three withB --> Din place ofB --> C, and so didA --> C,A --> D,B --> D: three different graphs, one drawing.flowchart LRdid the same, andZ --> C,Z --> D,A --> D,B --> Coffered 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 --> DthenB --> C, whereCandDwere 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 LRthe 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 --> CbesideB --> Zdrew│ C │◀──│ Z │─┘, joining two boxes nobody joined. Both kinds of routed edge want the same road, and asking for it movesCto the end of its rank the same way.
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-Cis 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 pressingCtrl-Cwants out, and should be told aboutq— 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 shownqonce knows it for the whole session. - The same signal-shaped key completed a yank.
Ctrl-Carrives asChar('c')and the second half of a two-key sequence was matched on the character alone, soyfollowed 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.
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.
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.
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 implementsDECALNand no otherESC #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_IDis what says a pane is one, the wayTMUXdoes; ruling it out also puts headings back on the rasterized path, which herdr draws onceexperimental.kitty_graphicsis on.
- Editing.
mdrollis 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
#123and 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#123is 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.
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.
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.
$ cargo test
$ cargo clippy --all-targets -- -D warnings
$ cargo fmt --allGit 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 installtools/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 draculaNote that it drives kitty, which does not implement DECDHL, so double-height headings never appear in screenshots taken this way.
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.






