whynot is a Go library for showing Markdown documents inside games and GUI apps. Think of patch notes, an in-game journal, help screens or a credits scroll: real formatted text, with headings, lists, tables, images and links, but without bringing in a whole UI toolkit. You give whynot some Markdown, and it lays the document out, draws it, and lets the user scroll through it and click its links. It draws through a small interface rather than a particular graphics library, and it comes with backends for Ebitengine and Gio.
Here is this README, displayed by whynot's own document viewer:
- Broad Markdown coverage. Headings, emphasis, lists, tables, blockquotes, code blocks, images (including animated GIFs) and links are all supported. Anything whynot doesn't support yet is shown as flagged text rather than breaking the document.
- Built for long documents. whynot only lays out the part of the document that's on screen, so scrolling and resizing are just as fast for ten thousand lines as for ten.
- Easy to drop in. A
whynot.Panelshows a document in any rectangle of your window and handles its input, including a scrollbar, touch scrolling and flings. - Styled your way. Colors, text styles, margins and scrollbars come from a stylesheet; light and dark ones are ready-made. Text can use the bundled Go fonts, your own font files, or the fonts installed on the system.
- Extensible code blocks. Plugins can add syntax highlighting to code blocks, and to inline code, or turn code blocks into pictures, such as Mermaid diagrams.
- Independent of any graphics library. whynot draws through a small
canvasinterface and receives input as plain values, so supporting another framework only takes a thin adapter.
There are two standalone document viewers built on the library, one for each backend. They behave the same way: they open Markdown files and web pages, follow links, keep a history, and support zoom and light and dark themes. They're the quickest way to see what whynot can do, and both also run in a web browser.
| Viewer | Install | In your browser |
|---|---|---|
whynot (Ebitengine) |
brew install arnodel/tap/whynot, a release binary, or go install github.com/arnodel/whynot/browser/cmd/whynot@latest |
Try it |
giowhynot (Gio) |
go install github.com/arnodel/whynot/browser/cmd/giowhynot@latest |
Try it |
Start a viewer with the path or URL of a Markdown document to open it, or with nothing to see its welcome page. For example, the screenshot above was taken with:
whynot https://raw.githubusercontent.com/arnodel/whynot/refs/heads/main/README.mdEach viewer's own README describes its keys and options, and how its web version works.
Add the library to your module with:
go get github.com/arnodel/whynotThe simplest way to show a document is a whynot.Panel. A Panel displays a document in a
rectangle of your window and reacts to the mouse, the wheel and touch. Every frame, your
game gives it that frame's input and the current time, and the Panel tells you what
happened, such as the user clicking a link. Here is a complete program:
package main
import (
"image"
"log"
"time"
"github.com/hajimehoshi/ebiten/v2"
"github.com/arnodel/whynot"
"github.com/arnodel/whynot/backends/ebitenbackend"
)
const source = "# Hello\n\nThis is **whynot**, showing [a link](https://example.com)."
type game struct {
panel *whynot.Panel
renderer *ebitenbackend.Renderer // draws onto Ebitengine images
input ebitenbackend.Input // reads Ebitengine's mouse, wheel and touch
start time.Time // the start of the panel's clock
}
func (g *game) Update() error {
// Pass this tick's input to the panel, which scrolls, highlights links
// and so on, and returns what the app may want to react to.
events := g.panel.Frame(g.input.Events(), time.Since(g.start))
for _, e := range events {
if link, ok := e.(whynot.LinkClick); ok {
log.Println("clicked a link to", link.Destination)
}
}
return nil
}
func (g *game) Draw(screen *ebiten.Image) {
// Draw the panel onto the screen, as it is at this time (animated
// images and fading scrollbars depend on it).
g.panel.Draw(g.renderer.NewCanvas(screen), time.Since(g.start))
}
func (g *game) Layout(outsideWidth, outsideHeight int) (int, int) {
// Draw at the screen's real resolution, so text stays sharp on a
// high-density display.
scale := ebiten.Monitor().DeviceScaleFactor()
w, h := int(float64(outsideWidth)*scale), int(float64(outsideHeight)*scale)
g.panel.SetBounds(image.Rect(0, 0, w, h)) // fill the window, which may have been resized
g.panel.SetScale(scale) // size text and margins for that resolution
return w, h
}
func main() {
doc := whynot.Parse([]byte(source))
// A View draws a document, by default in the bundled Go fonts and a dark theme.
view := whynot.NewView(doc)
// The panel's bounds are set in Layout, once the window size is known.
panel := whynot.NewPanel(view, image.Rectangle{})
panel.SetScrollbar(true)
g := &game{panel: panel, renderer: ebitenbackend.New(), start: time.Now()}
if err := ebiten.RunGame(g); err != nil {
log.Fatal(err)
}
}When the user clicks a link to a heading in the same document (a #heading link), the
Panel scrolls there by itself. Any other link is reported to your program as a
whynot.LinkClick, and it's up to you what to do with it, because only your program knows
where documents come from. examples/panel shows a Panel that covers
only part of a game's window.
The Gio backend lives in its own module, so that programs which don't use Gio don't depend on it. Add it with:
go get github.com/arnodel/whynot/backends/giobackendA Panel works the same way in a Gio window: giobackend.Input provides the input, and
giobackend.Renderer provides the canvas to draw on. The one difference is that Gio only
draws a new frame when something happens, so while the Panel is animating (a fling
coasting to a stop, say, or a scrollbar fading out) your program must ask Gio for the next
frame. The package documentation
shows the whole frame loop, and examples/gio is a
program you can run.
A backend for another framework should provide two things: a canvas.Canvas that draws text,
images and rectangles with the framework, and an input reader that turns the framework's
input into whynot's input events. Your program then runs the same frame loop as above.
backends/README.md explains what each part has to do, and the two
existing backends are short enough to serve as examples.
A Panel is made of two parts, which you can also use directly when you need more control than a Panel gives you, for example to fit whynot into a structure of your own:
- a
Viewlays out and draws a document, scrolled to a position; - a
Controllerturns input into what it does to a View: scrolling, highlighting the link under the pointer, and reporting clicks.
view := whynot.NewView(doc)
controller := whynot.NewController(view)
// Then, every frame:
view.SetBounds(rect) // the rectangle of the screen the view occupies
events := controller.Frame(in, now) // apply this frame's input; get back what happened
view.Draw(canvas, now) // draw the view within its rectangleYour own code can also move a View directly, without any input: ScrollBy scrolls by a
distance, ScrollToAnchor jumps to a heading, ScrollToRatio jumps to a point in the
document, and ScrollToEnd shows its end, as a chat or a log wants. To draw a scrollbar of your own, VisibleRange tells you which part of the
document is on screen, and HoveredLink tells you which link is under the pointer, for a
status bar. To arrange Views yourself, stacked one above the other say, DocumentBounds
tells you exactly where a View's whole document lies, and ScrollWithin scrolls it as far
as it goes, reporting what's left to carry on to the next one. examples/view shows a View on its own, scrolled with the
mouse wheel. All positions are measured in the canvas's pixels; the
Coordinates section of
the documentation explains this in detail.
A View's appearance comes from its stylesheet. The
styles/simpletheme package makes stylesheets from a set of plain
fields: you can use one of its ready-made stylesheets, or start from one of its themes and
change what you need, then make a stylesheet from it. A View uses the dark theme unless you
give it another stylesheet, with whynot.WithStyleSheet or later with SetStyleSheet:
theme := simpletheme.Dark() // or simpletheme.Light()
theme.LinkColor = myColor
theme.HeadingTextStyles[0] = simpletheme.TextStyle{Size: 48, Weight: simpletheme.WeightBlack} // top-level headings
view.SetStyleSheet(theme.StyleSheet())You can change a View's stylesheet at any time, which is all it takes to switch between a light and a dark theme.
The stylesheet decides the size, weight and family of each piece of text, and a font selector decides which font actually draws it. whynot comes with three:
fonts.NewGoSelector()uses the Go fonts, which are bundled with whynot. A View uses them unless you give it another selector, withwhynot.WithFaceSelector.fonts.NewCustomSelector()uses font files you provide, for whichever families, weights and styles you choose, and the Go fonts for the rest. Seeexamples/customfont.- The
fonts/systemfontpackage uses fonts installed on the computer, either by name or by picking the platform's usual interface font. Seeexamples/systemfont.
selector := systemfont.New()
// The platform's usual interface font. On an error, such as the font not
// being installed, the bundled Go fonts are used instead.
if err := selector.RegisterPreferredFont(fonts.Proportional); err != nil {
log.Print(err)
}
// A font installed on the system, by name.
if err := selector.RegisterSystemFont(fonts.Monospace, "Menlo"); err != nil {
log.Print(err)
}
view := whynot.NewView(doc, whynot.WithFaceSelector(selector))By default, a code block is shown as plain text in a single color. Code-block plugins
change that for the languages they handle: a plugin can split the code into colored
tokens, for syntax highlighting, or replace the block with an image, such as a rendered
diagram. Plugins are passed to Parse, and they're tried in the order given. That order
matters when a plugin produces an image: while the image loads, or if it fails to, the
next plugin's version of the block is shown, such as the diagram's source, highlighted.
doc := whynot.Parse(source,
whynot.WithCodeBlockPlugin(kroki.Plugin{}), // draws Mermaid diagrams, using kroki.io
whynot.WithCodeBlockPlugin(chromahighlight.Plugin{}), // highlights code, using the chroma library
)Markdown doesn't say what language inline code is in, so it's shown plain. If you know a document's inline code is all in one language, say so, and the plugins highlight it as they would a code block in that language:
doc := whynot.Parse(source,
whynot.WithCodeBlockPlugin(chromahighlight.Plugin{}),
whynot.WithInlineCodeLanguage("lua"), // inline code is Lua
)The codeblocks package defines what a plugin is, so you can write your
own. examples/chromahighlight shows syntax highlighting.
Images are loaded in the background, so a slow image never holds up scrolling or drawing.
For safety, nothing is fetched unless you allow it: a document parsed without an image
registry shows each image's alternative text instead. A registry from the
fetch package says which URL schemes images can come from, and you give it to
whynot.Parse with the URL that relative image sources are relative to:
root, err := os.OpenRoot("docs") // local images are only read from beneath this directory
if err != nil {
log.Fatal(err)
}
registry := fetch.NewRegistry(fetch.FileResolver{Root: root}, fetch.HTTPResolver{})
doc := whynot.Parse(markdown, whynot.WithBaseURL(location), whynot.WithImageRegistry(registry))fetch.HTTPResolver fetches https images only, unless you set its AllowHTTP field.
You can also write a resolver of your own for another URL scheme.
A document may start with front matter, YAML between two --- lines, as used by Jekyll,
Hugo and many READMEs. whynot doesn't show it, and doesn't read it: doc.RawFrontMatter()
returns it as written, for your program to decode with the YAML library of its choice:
var meta struct {
Title string
Tags []string
}
err := yaml.Unmarshal([]byte(doc.RawFrontMatter()), &meta)A Document can grow as its Markdown arrives: a download over a slow network, a reply being
generated, or a game's narration revealed a word at a time. A whynot.Parser makes such a
Document with a writer for it:
doc, w := whynot.NewParser().Stream()
view := whynot.NewView(doc)
go func() {
io.Copy(w, response.Body) // the view shows the document as it arrives
w.Close()
}()Each time the View draws, it takes what was written since, laying out only what's new and
keeping its scroll position. With a framework that draws only when something happens, such
as Gio, ask for a frame each time the Document changes, with doc.Updates().
examples/typewriter reveals a story a character at a time.
Each of these is a small program you can run, for example with go run ./examples/panel:
| Example | What it shows |
|---|---|
examples/panel |
A Panel covering part of an Ebitengine game's window |
examples/view |
A View used on its own, scrolled with the mouse wheel |
examples/chromahighlight |
Syntax highlighting in code blocks |
examples/customfont |
Text in a font file of your choice |
examples/systemfont |
Text in the fonts installed on the system |
examples/typewriter |
A document revealed a character at a time, as it's written |
examples/wasm |
whynot running in a web page, compiled to WebAssembly |
backends/giobackend/examples/gio |
A Panel in a Gio window |
The two viewers, whynot and giowhynot,
are complete applications built the same way.
First, whynot.Parse turns the Markdown into a Document, with the help of any code-block
plugins, and whynot.NewView makes a View to show it. A stylesheet and fonts decide how it
looks; without them, the View uses a dark theme and the bundled Go fonts:
flowchart TB
md[/"Markdown text"/]
plugins["Code-block plugins<br/><code>chromahighlight.Plugin</code>, …"]
parse[["<code>whynot.Parse</code>"]]
doc["<code>whynot.Document</code>"]
sheet["Stylesheet<br/><code>simpletheme.DarkStyleSheet</code>"]
fonts["Fonts<br/><code>fonts.GoSelector</code>"]
newview[["<code>whynot.NewView</code>"]]
view["<code>whynot.View</code>"]
md --> parse
plugins --> parse
parse --> doc
doc --> newview
sheet -. optional .-> newview
fonts -. optional .-> newview
newview --> view
Then, every frame, your program runs a loop between its graphics framework and whynot.
Here it is with Ebitengine; with Gio, the same roles are played by giobackend.Input and
giobackend.Canvas:
flowchart TB
fw["Ebitengine<br/>(or another framework, such as Gio)"]
subgraph program["Your game, every frame"]
input["<code>ebitenbackend.Input</code>"]
subgraph panel["<code>whynot.Panel</code>"]
ctrl["<code>whynot.Controller</code>"]
view["<code>whynot.View</code>"]
end
yours["Your code"]
canvas["<code>ebitenbackend.Canvas</code>"]
end
fw -- "mouse, wheel and touch" --> input
input -- "<code>input.Event</code> values" --> ctrl
ctrl -- "<code>whynot.Event</code> values,<br/>such as <code>whynot.LinkClick</code>" --> yours
ctrl -- "calls to scroll it<br/>and highlight links" --> view
view -- "drawing calls" --> canvas
canvas -- "draws on the screen" --> fw
ebitenbackend.Inputreads what the user did, and turns it into whynot's own input events (input.Eventvalues).- A
whynot.Controllerdecides what those events do to the View: scrolling it, highlighting the link under the pointer, and so on. It returns anything your program may want to react to, such as a click on a link, aswhynot.Eventvalues. - The
whynot.Viewlays out the part of the document that's on screen, and draws it. - An
ebitenbackend.Canvascarries out the View's drawing on the screen image.
As the diagram shows, a whynot.Panel holds a Controller and a View together, so with a
Panel your program deals with a single object, as in the quick start above. You only need
the Controller and the View separately for finer control. whynot.Parse uses
goldmark to read the Markdown.
The library is split into packages along these lines:
| Packages | What they're for |
|---|---|
whynot |
Parsing, and the View, Controller and Panel |
canvas, input |
What a backend implements: drawing, and input events |
fonts, fetch, codeblocks |
What a document and a View can be given: fonts, fetching images, code-block plugins |
styles/simpletheme |
Stylesheets and their ready-made themes |
fonts/systemfont, codeblocks/chromahighlight, codeblocks/kroki |
Ready-made fonts and plugins |
backends/ebitenbackend, backends/giobackend |
The two backends |
internal/… |
The Markdown compiler, the layout engine and the image cache: not part of the API |
ARCHITECTURE.md explains the design in depth: the layout model, how layout stays lazy, how the scroll position survives a resize, hit-testing, and image loading.
Text
- Headings, paragraphs, emphasis, strong text, strikethrough and inline code
- Typographic punctuation: smart quotes, dashes and ellipses
- Front matter, which isn't shown, and is available to your program as written
- Hard line breaks, a backslash or two spaces at the end of a line, as in poems
Blocks
- Fenced and indented code blocks, which scroll sideways when they're wider than the view
- Syntax highlighting and diagrams in code blocks, through plugins, and highlighting of inline code in a language the program gives
- Blockquotes, including nested ones
- Ordered and unordered lists, tight or loose, nested to any depth, and task lists
- Tables (GitHub style), with column alignment and column widths fitted to their content, which scroll sideways when they're wider than the view
- Thematic breaks (horizontal rules)
- Footnotes and definition lists, which are shown as plain text for now
- Raw HTML, which is shown as flagged source text rather than rendered
Images
- PNG, JPEG and GIF images, including animated GIFs
- Background loading, starting ahead of the scroll position, with a placeholder of the right size while an image loads, and its alternative text if it can't be loaded
Links
- Links, including automatic and reference-style links, highlighted under the pointer
- Links to headings within the document (
#heading)
Viewing and interaction
- Layout and drawing whose cost depends on what's on screen, not on the document's length
- Documents shown as they arrive, from a slow source or written a piece at a time, laying out only what's new
- Smooth scrolling, with the scroll position kept when the window is resized or the style changes
- Scrolling with the mouse wheel, by touch (with flings), or by keyboard commands (a step, a page, or sideways)
- A built-in scrollbar styled by the stylesheet, or your own, built with
VisibleRange - Zoom
- Anything not supported yet is shown as flagged text, with a warning in the log, rather than breaking the document
Styling
- Colors, text styles, line height, margins and scrollbars set by a stylesheet, which can be changed at any time; light and dark themes included
- The bundled Go fonts, your own font files, or the system's fonts
whynot follows semantic versioning. From version 1.0.0, the API of
every public package in the main module is stable. Packages under internal/, and the
programs in cmd/ and examples/, aren't part of that promise. The Gio backend is a
separate module, and it stays at version 0 for as long as Gio does, because Gio's own
types are part of its API.
Known issues are listed in ARCHITECTURE.md. Ideas for the future are tracked as GitHub issues.
Contributions are welcome: CONTRIBUTING.md explains how the project is worked on.
whynot is released under the Apache License 2.0.
