Help us shape the future of Go! Take the 2026 Go Developer Survey

whynot

package module
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 22, 2026 License: Apache-2.0 Imports: 38 Imported by: 0

README

Why Not?

A Markdown document viewer for ebiten games, written in Go - for anything that needs to show real formatted text (patch notes, an in-game journal, help screens, a credits scroll) without pulling in a full UI toolkit. Point it at a []byte of Markdown and it lays out and draws the document straight onto an ebiten.Image, using goldmark to parse.

  • Broad Markdown coverage - tables, nested lists, images (including animated GIFs), links, blockquotes, code blocks, and more - degrading gracefully instead of crashing on anything not yet supported
  • Built for long documents - layout and drawing are lazy, anchored at the current scroll position, so a resize or scroll costs the same whether the document is 10 lines or 10,000
  • Fully customizable styling - colors, margins, and text styles all resolve through one StyleSheet interface (dark and light themes built in), swappable at runtime
  • Drop-in embedding - ebitenrenderer.Panel adds a scrollable, zoomable Markdown view to part of a larger game window in a few lines, with resizing, hover/click, and an optional scrollbar all handled for you

Try the standalone viewer

cmd/whynot is a small standalone app built entirely on the library - the easiest way to see what whynot can do.

Try it right now in your browser - no install, running entirely client-side via Ebitengine's own js/wasm backend (see cmd/whynot/web). Local file opening, paste-to-open, and following a link to a real webpage aren't wired up there yet - everything else, including opening a Markdown document by URL (?doc=<url>, or just follow a link), works the same as installed.

Otherwise, on macOS or Linux:

brew install arnodel/tap/whynot

Otherwise, grab a binary for your platform from the latest release, or build it yourself:

go install github.com/arnodel/whynot/cmd/whynot@latest

Run it with no argument and it opens a built-in welcome page explaining how to use it. Pass a Markdown file path (or URL) as an argument to view that file. Here is a screenshot from:

whynot https://raw.githubusercontent.com/arnodel/whynot/refs/heads/main/README.md

whynot showing this README

Status

Pre-1.0 - the API may still change. See Features below for what's implemented. Architecture, internal layout model, and the reasoning behind some of the trickier bits (lazy layout, scroll anchoring) are documented in ARCHITECTURE.md.

Embed a Markdown viewer in your ebiten game

The library (root package whynot) has no rendering backend dependency - it only depends on goldmark for parsing. ebitenrenderer implements whynot.Canvas on top of ebiten. Two ways to wire the two together, depending on how much control you want.

The turnkey way: ebitenrenderer.Panel

Panel wraps a View with input handling already done for you - scrolling, link hover/click, and an optional draggable scrollbar (WithScrollbar()) - all scoped to whatever rectangle you give it, so it's safe to embed as part of a larger game window without stepping on whatever else is there. It's what cmd/whynot itself is built on.

examples/panel inset a Panel into part of a window that fills the rest with plain green - other game content standing in - to prove it: nothing whynot draws ever leaks past its own bounds.

A Panel embedded with margin on every side, other content visible around it

package main

import (
	"fmt"
	"image"
	"image/color"
	"log"
	"strings"

	"github.com/hajimehoshi/ebiten/v2"

	"github.com/arnodel/whynot"
	"github.com/arnodel/whynot/ebitenrenderer"
)

const (
	windowWidth  = 800
	windowHeight = 600
	panelMargin  = 60 // inset on all sides, so the background shows around the panel
)

var backgroundColor = color.RGBA{0x20, 0x60, 0x20, 0xFF} // green, to make it distinct from the panel contents

func exampleDoc() string {
	var b strings.Builder
	b.WriteString("# Panel example\n\n")
	b.WriteString("This document exists to prove that `ebitenrenderer.Panel` clips its drawing to\n")
	b.WriteString("its own bounds even when embedded inside a larger window that draws other\n")
	b.WriteString("content around it.\n\n")
	for i := 1; i <= 8; i++ {
		fmt.Fprintf(&b, "## Section %d\n\n", i)
		b.WriteString("Some text, quite a bit of it actually, more than one line's worth, so the\n")
		b.WriteString("document is tall enough to need scrolling within its panel.\n\n")
	}
	return b.String()
}

func main() {
	view := whynot.NewView([]byte(exampleDoc()), whynot.NewGoFontFaceSelector(72), whynot.WithStyleSheet(whynot.NewDarkStyleSheet()))
	bounds := image.Rect(panelMargin, panelMargin, windowWidth-panelMargin, windowHeight-panelMargin)
	panel := ebitenrenderer.NewPanel(view, ebitenrenderer.New(), bounds, ebitenrenderer.WithScrollbar())

	ebiten.SetWindowSize(windowWidth, windowHeight)
	ebiten.SetWindowTitle("whynot panel example")
	if err := ebiten.RunGame(&game{panel: panel}); err != nil {
		log.Fatal(err)
	}
}

type game struct {
	panel *ebitenrenderer.Panel
}

func (g *game) Update() error {
	g.panel.Update()
	return nil
}

func (g *game) Draw(screen *ebiten.Image) {
	screen.Fill(backgroundColor)
	g.panel.Draw(screen)
}

func (g *game) Layout(outsideWidth, outsideHeight int) (int, int) {
	return windowWidth, windowHeight
}

Run it yourself: go run ./examples/panel.

Finer control: whynot.View directly

Drop to View (plus ebitenrenderer.Canvas) yourself for full control over input handling, or to fit whynot into an Update/Draw structure that doesn't match what Panel assumes - the same building blocks Panel itself is built on:

A View filling the whole window, no toolbar or scrollbar

package main

import (
	"fmt"
	"log"
	"strings"
	"time"

	"github.com/hajimehoshi/ebiten/v2"

	"github.com/arnodel/whynot"
	"github.com/arnodel/whynot/ebitenrenderer"
)

func exampleDoc() string {
	var b strings.Builder
	b.WriteString("# View example\n\n")
	b.WriteString("This document is rendered by wiring `whynot.View` up directly - full control\n")
	b.WriteString("over input handling, at the cost of doing it yourself (see `ebitenrenderer.Panel`\n")
	b.WriteString("for the turnkey alternative).\n\n")
	for i := 1; i <= 8; i++ {
		fmt.Fprintf(&b, "## Section %d\n\n", i)
		b.WriteString("Some text, quite a bit of it actually, more than one line's worth, so the\n")
		b.WriteString("document is tall enough to need scrolling.\n\n")
	}
	return b.String()
}

type game struct {
	view     *whynot.View
	renderer *ebitenrenderer.Renderer
	start    time.Time
}

func (g *game) Update() error {
	_, dy := ebiten.Wheel()
	g.view.Scroll(dy * 2)
	return nil
}

func (g *game) Draw(screen *ebiten.Image) {
	// View.Draw fills its own background (from the View's StyleSheet) -
	// no separate clear step needed here.
	g.view.Draw(g.renderer.NewCanvas(screen), 0, 0)
}

func (g *game) Layout(outsideWidth, outsideHeight int) (int, int) {
	// The last argument is elapsed time since rendering started - only
	// animated images actually need it (see whynot.RenderingContext.Time).
	g.view.Layout(outsideWidth, outsideHeight, 1, time.Since(g.start))
	return outsideWidth, outsideHeight
}

func main() {
	g := &game{
		view:     whynot.NewView([]byte(exampleDoc()), whynot.NewGoFontFaceSelector(72)),
		renderer: ebitenrenderer.New(),
		start:    time.Now(),
	}
	ebiten.SetWindowSize(800, 600)
	ebiten.SetWindowTitle("whynot view example")
	if err := ebiten.RunGame(g); err != nil {
		log.Fatal(err)
	}
}

Run it yourself: go run ./examples/view.

cmd/whynot is a fuller example built the Panel way (see above), handling display scale, zoom, and a toolbar too.

Styling

Appearance - colors, margins, text sizes/weights, and a handful of dimensional constants (table/blockquote geometry, rule and strikethrough thickness) - is entirely driven by the StyleSheet interface (stylesheet.go), resolved per node when the layout tree is built rather than hardcoded anywhere. DefaultStyleSheet is the configurable built-in implementation; NewDarkStyleSheet()/NewLightStyleSheet() (what cmd/whynot's theme toggle switches between) are both just different field values on the same struct - tweak one (s := whynot.NewDarkStyleSheet(); s.LinkColor = myColor) and pass it via whynot.WithStyleSheet(s), or embed DefaultStyleSheet in your own type and override individual methods for full control over one aspect (e.g. per-node margins) without reimplementing the rest. Swapping a View's StyleSheet at runtime (View.SetStyleSheet) re-lays-out the document immediately, which is all cmd/whynot's theme toggle button does.

Fonts

NewView's second argument is a FaceSelector (textstyle.go) - SelectFace(TextStyle) (font.Face, error) plus SetDPI(float64) - the interface every example above passes whynot.NewGoFontFaceSelector(72) to. It's decoupled from StyleSheet: StyleSheet decides which size/weight/style/family a piece of text gets, FaceSelector decides what font file actually renders that combination.

  • GoFontFaceSelector serves the Go fonts embedded in golang.org/x/image/font/gofont

    • what every example above uses.
  • CustomFontFaceSelector loads your own TTF/OTF font bytes (AddFont) or files (AddFontFile), per (family, weight, style) slot - both take a trailing subfont index (almost always 0; only matters for a .ttc/.otc collection) - falling back to a NewGoFontFaceSelector for any slot you don't register, so you only need to supply the fonts you actually want to override (or whynot.WithFallback(...) a different one, nil included, at construction):

    selector := whynot.NewCustomFontFaceSelector(72)
    selector.AddFontFile(whynot.Proportional, font.WeightNormal, font.StyleNormal, "myfont.ttf", 0)
    view := whynot.NewView(source, selector)
    

    AddFontCollection/AddFontCollectionFile register every subfont a .ttc/.otc contains that can be confidently classified from its own name-table data, instead of picking one slot by index yourself - the primitive systemfont (below) builds on.

    See examples/customfont for a runnable version - go run ./examples/customfont uses a bundled font (Pacifico, SIL Open Font License) by default, or pass -font path/to/font.ttf to try your own.

  • systemfont.SystemFontFaceSelector (a separate package, github.com/arnodel/whynot/systemfont, to keep its adrg/sysfont dependency out of the core library) resolves fonts by name from whatever's installed on the host machine, instead of requiring font bytes/files up front:

    selector := systemfont.NewSystemFontFaceSelector(72)
    selector.RegisterSystemFont(whynot.Proportional, "Arial")
    view := whynot.NewView(source, selector)
    

    RegisterSystemFont finds the best-matching installed font, then uses CustomFontFaceSelector.AddFontCollection to read every subfont in its file (a plain font, or every named variant in a .ttc/.otc) and register the ones it can confidently classify into a (weight, style) slot - anything it can't find or classify falls back to the bundled Go fonts.

    Don't want to name a specific font at all? RegisterPreferredFont picks this platform's most likely UI font for you, from a small curated, GOOS-aware candidate list (e.g. Helvetica Neue/Helvetica on macOS, Segoe UI on Windows, DejaVu Sans on Linux) - trying each in order until one actually resolves:

    selector := systemfont.NewSystemFontFaceSelector(72)
    selector.RegisterPreferredFont(whynot.Proportional)
    selector.RegisterPreferredFont(whynot.Monospace)
    view := whynot.NewView(source, selector)
    

    There's no portable way to ask the OS directly for its actual configured UI font without new platform-specific work (cgo on macOS, a registry/API call on Windows, fontconfig on Linux), so this is a best-effort guess, not a guarantee - it logs exactly what it tried and what (if anything) resolved, the same as RegisterSystemFont does. See examples/systemfont for a runnable version - with no flags at all, it uses RegisterPreferredFont for both families.

Syntax highlighting

By default, a fenced or indented code block renders in one flat, neutral color (StyleSheet's CodeBlockColor) - inline `code` spans use a separate, more eye-catching accent color instead (CodeSpanColor), since a small isolated word in prose reads fine as an accent while a whole block of it would fight with any syntax-highlighted spans inside it. Passing a whynot.Highlighter - Highlight(language, code string) []HighlightSpan, classifying the block's source into consecutive typed spans - colors it token-by-token instead, via whynot.WithHighlighter (or whynot.WithSyntaxHighlighter if you call whynot.Parse directly rather than NewView):

view := whynot.NewView(source, selector, whynot.WithHighlighter(chromahighlight.Highlighter{}))

chromahighlight (github.com/arnodel/whynot/chromahighlight, a separate package to keep github.com/alecthomas/chroma/v2's ~200 embedded language lexers out of the core library's dependency graph) implements Highlighter on top of chroma, picking a lexer from the fence's own language string (falling back to unhighlighted, flat-color rendering for a language it doesn't recognize, or for an indented block, which has no fence to name one). Colors come from StyleSheet's own SyntaxColors (keyword/type/ function/string/number/comment), tuned separately for the dark and light themes - not from chroma's own named styles. A builtin type (e.g. Go's int) and a declared custom type/class name share the type color, deliberately, and likewise for function - though a lexer can only ever recognize a declaration this way (and, for some languages' lexers, a function call too), not necessarily every later usage, since that needs real type/binding information a lexer doesn't have (see chromahighlight's classify for the specifics).

See examples/chromahighlight for a runnable version.

cmd/whynot: a standalone viewer

go run ./cmd/whynot path/to/some.md

Run with no argument and it opens a built-in welcome page (embedded in the binary, cmd/whynot/assets/welcome.md) explaining how to use it - including pasting a file path or http(s) URL (Cmd/Ctrl+V) to open it, and pasting the word "welcome" to come back. Beyond scrolling and resizing, it demonstrates what a caller can build on top of the library:

  • Document text uses this platform's own fonts (systemfont.SystemFontFaceSelector, via RegisterPreferredFont - see above) when it can find them, falling back to the bundled Go fonts for anything it can't - watch stderr for exactly what got resolved.
  • -light switches to the light theme at startup; the toolbar button toggles between light and dark while running.
  • Dragging the scrollbar jumps to a position (View.ScrollToRatio) - it self-corrects toward wherever the mouse currently is as not-yet-resolved parts of the document get resolved during the drag, rather than drifting away from the cursor. ↓/↑ nudge the scroll position a bit at a time, repeating while held.
  • Hovering a link highlights it (View.Hover).
  • Clicking a link follows it - a relative path loads another local file, an http(s) URL fetches it (opening it in the system's default browser instead if its Content-Type turns out to be HTML rather than Markdown/plain text - a real webpage, not a .md file), and a URL fragment (#some-heading) scrolls to that heading (View.ScrollToAnchor), even on a document just navigated to. Either way it's resolved against the current document's own location (net/url.URL.ResolveReference), so a relative link works the same whether that document came from disk or from a fetch.
  • Backspace goes back to wherever a link was followed from, exact scroll position included - whether that was a different document or just an in-page anchor jump.
  • Images resolve and load the same way - a relative src is resolved against the document's own location, an http(s) one is fetched, regardless of whether the document itself came from disk or a fetch. Fetching and decoding happen in the background, never blocking rendering; a missing or undecodable image falls back to its alt text (or title, or a generic message) instead of a silent gap.
  • -debug-hit outlines whatever View.HitTest resolves under the cursor, for debugging.
  • -debug-stats (togglable at runtime with F) shows FPS/TPS and per-frame Update/Draw timing.
  • A Markdown construct whynot doesn't understand (e.g. raw HTML) shows in a distinct color with a warning logged, instead of crashing the whole document - see Features.

None of the link-following/history logic lives in the library itself - whynot only exposes the primitives (View.Hover, LinkAt, ScrollToAnchor, ScrollPosition/RestoreScrollPosition); loading files, fetching URLs, and keeping a history stack are all cmd/whynot's own (load.go, navigate.go). Image loading follows the same split, one layer further in: the library defines ImageSource (defaulting to a plain local file open) and owns caching the result (ImageCache - an image is resolved, fetched, and decoded at most once, however many times it's asked for, however many rendering backends ask for it); cmd/whynot supplies the file-or-http(s), resolved-against-the-document's-location ImageSource, via WithImageSource.

Features

Checked items are implemented; unchecked ones aren't yet. Grouped by theme rather than by implementation order now that most of the list is done.

Text and inline formatting

  • Headings (all 6 levels), paragraphs
  • Emphasis, strong, and both together (*x*, **x**, ***x***)
  • Strikethrough (~~x~~)
  • Inline code
  • Typographer (smart quotes/dashes, "x" -> "x", unspaced --/--- -> en/em dash, ... -> ellipsis) - goldmark's extension.TypographerParser, enabled by default. Was blocked on a real gap in the inline model (goldmark emits the substitution as a separate Text node with no whitespace from its neighbor, e.g. Alice's -> "Alice", "'", "s " as three siblings) until word-adjacency tracking between sibling Inlines landed

Block structures

  • Fenced and indented code blocks
  • Syntax highlighting for code blocks (opt-in, via whynot.Highlighter - chromahighlight provides a chroma-backed implementation)
  • Blockquotes, including nested ones
  • Thematic breaks (---)
  • Ordered and unordered lists, tight or loose, nested to any depth, including task lists (- [ ])
  • Tables (GFM), including column alignment and negotiated column widths - needed a genuine 2D layout primitive (TableBox, row height = max of that row's cells), not an extension of the existing 1D StackBox/ContainerBox
  • Footnotes, definition lists - goldmark extensions for both exist but aren't enabled, so the syntax ([^1], term/: definition) isn't recognized at all yet, rendering as plain literal text

Images

  • PNG/JPEG/GIF, including animated GIFs (disposal-correct compositing, always looping) - resolved and loaded via a pluggable ImageSource, fetched and decoded at most once per image regardless of how many times it's asked for; alt text is captured from arbitrary inline content, per CommonMark
  • Loading never blocks rendering - a still-pending image shows a placeholder at its final size once known (or a "loading" fallback before that); a missing or undecodable image falls back to alt text, then title, then a generic message
  • Prefetch images ahead of the scroll position - a standalone image's own load starts well before its containing slot is actually resolved, in both scroll directions (View.Layout's preLayoutNearby/prefetchImageSources), not only once it's scrolled right up to it

Links and navigation

  • Links and autolinks, including reference-style ([text][ref]) - highlighted on hover (View.Hover), destination resolvable at a point (View.LinkAt)
  • Heading anchors: goldmark's auto-generated heading ids, scrollable to via View.ScrollToAnchor - what a link's #fragment targets
  • Raw inline/block HTML rendered as HTML - currently shown as flagged unsupported text instead, not a crash, but not styled/laid out as real HTML would be either

Rendering and performance

  • Scrolling, window resizing with reflow and scroll-position anchoring, and viewport culling - all handled by whynot.View
  • Smooth scrolling - View.Scroll's position is a float64, accumulating fractional wheel deltas exactly rather than rounding each call, so repeated small scrolls end up exactly where one large one would
  • View.DocumentBounds/VisibleViewBounds expose real per-slot pixel-height geometry (each top-level slot's height, once resolved; extrapolated from the average of what's known for the rest, refined as more of the document is visited) for a caller to build its own scrollbar, or feed to any other UI it wants to drive from scroll position. View.ScrollToRatio is the other direction - a caller driving a scrollbar thumb drag recomputes the ratio from the mouse's current position every frame rather than a target captured once, so a jump into not-yet-resolved territory only ever corrects toward the cursor, never drifts
  • Large documents: layout and drawing are lazy, built outward from the current scroll position rather than the whole document, so cost tracks what's on screen, not the document's total size - a resize deep into a ~1000-line document costs microseconds, not tens of milliseconds. See ARCHITECTURE.md for how
  • Graceful degradation: a Markdown construct whynot doesn't recognize (e.g. raw HTML) logs a warning and renders as flagged, distinctly colored text/code showing its source, rather than crashing - a reference-style link's own [ref]: url definition line and an HTML comment are recognized as intentionally invisible rather than unsupported, since no Markdown renderer ever shows them either

Embedding

  • ebitenrenderer.Panel embeds a scrollable document into part of a larger ebiten.Game's own window - coordinate translation, hover/click, wheel scroll gated on its own bounds, and an optional draggable scrollbar (WithScrollbar), all bounds-aware so a panel never affects anything outside its own rectangle. See above

Styling

  • Fully customizable via the StyleSheet interface (see above) - colors, margins, text styles/sizes, and dimensional constants all resolve through it; override one field on DefaultStyleSheet, or embed it in a custom StyleSheet for full control. Swappable at runtime (View.SetStyleSheet) - cmd/whynot's light/dark toggle is just two DefaultStyleSheet instances
  • Custom font files via CustomFontFaceSelector (see above) - register your own TTF/OTF bytes per weight/style slot, falling back to the bundled Go fonts for anything not overridden
  • System-installed fonts by name via systemfont.SystemFontFaceSelector (see above) - resolves an installed font (e.g. "Arial") and registers every style variant it can confidently classify, same fallback story. Don't know a name to ask for? RegisterPreferredFont guesses this platform's likely UI font from a short curated candidate list instead

cmd/whynot, the standalone viewer

  • Built-in welcome page, shown by default, explaining how to use the app
  • Paste a file path or http(s) URL to open it; paste "welcome" to return here
  • A link to a webpage opens in the system browser instead of failing; a link to Markdown opens in whynot itself (see above)
  • Back/forward history, light/dark theme, zoom
  • Renders document text in this platform's own fonts when it can find them (systemfont.RegisterPreferredFont), falling back to the bundled Go fonts
  • Scrollbar - via ebitenrenderer.Panel's WithScrollbar() (not the library itself: View.DocumentBounds/VisibleViewBounds expose the geometry an embedder needs to build its own, whether that's Panel's version, a native scrollbar widget, or something else entirely). Draggable (View.ScrollToRatio), with hover/drag color feedback and a theme-aware color (a light thumb on the dark theme's near-black background would be invisible against the light theme's white one, and vice versa)
  • A real app icon instead of the generic terminal one when launched as a bundled macOS/Windows/Linux app

Known issues

See ARCHITECTURE.md.

Ideas

Floated but not scoped or started:

  • A different rendering backend than Ebitengine - Canvas (see above) is already the seam for this, the documented boundary between layout and actual drawing, so a second backend wouldn't touch layout or parsing at all
  • A different Markdown parser, or a different input format entirely (e.g. reStructuredText) - Parse() is the only goldmark-specific code in the library; everything downstream just consumes a Block/ASTNode tree with no idea where it came from
  • Video - a static poster image with a play button, opening an external player rather than playing in-app
  • Image zoom/lightbox - a click-to-open overlay for one image, independent of the document's own zoom level

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func DrawBlockLayout

func DrawBlockLayout(box BlockLayout, dst Canvas, x, y int, now time.Duration)

DrawBlockLayout is the sole entry point for drawing a BlockLayout: it skips drawContents entirely when box's bounds don't overlap dst, so every BlockLayout gets that for free regardless of who's calling it or where it sits in the tree.

Types

type ASTNode

type ASTNode struct {
	Tag    ASTTag
	Parent *ASTNode

	// Destination is the URL a TagLink node points to. Empty for every
	// other tag.
	Destination string

	// ID is a heading's anchor id (goldmark's auto-generated slug, e.g.
	// "my-heading" for "## My Heading") - what a link's URL fragment
	// (the part after '#') targets. Empty for every non-heading tag.
	ID string
	// contains filtered or unexported fields
}

ASTNode is a node in the semantic tree built alongside the Block/Inline tree during compilation - structural only (what kind of thing this is, and where it sits), with no rendering or style baked in. Public because it's the input a StyleSheet queries, and potentially useful beyond styling later (document navigation, folding).

func (*ASTNode) AddChild

func (n *ASTNode) AddChild(tag ASTTag) *ASTNode

AddChild creates a new ASTNode tagged with tag, parented to n, and returns it - the usual way to extend the tree during compilation. Works with a nil n (a fresh root node): the result only stores n as its Parent rather than dereferencing it.

func (*ASTNode) AncestorTag

func (n *ASTNode) AncestorTag(tag ASTTag) *ASTNode

AncestorTag returns n itself, or the nearest ancestor, tagged with tag - or nil if neither n nor any ancestor is. The pointer-returning counterpart to HasAncestorTag, for callers that need the node itself (e.g. to compare identity against), not just whether one exists.

func (*ASTNode) HasAncestor

func (n *ASTNode) HasAncestor(target *ASTNode) bool

HasAncestor reports whether n itself, or any of its ancestors, is target - matching a specific node's identity (e.g. "is this part of the currently hovered link") rather than a tag. A multi-word link's words don't all share one ASTNode when styled sub-spans are involved (e.g. a bold word inside a link gets its own child node), so matching by identity alone isn't enough - this walk is what makes matching the link's own node still catch every word inside it.

func (*ASTNode) HasAncestorTag

func (n *ASTNode) HasAncestorTag(tag ASTTag) bool

HasAncestorTag reports whether n itself, or any of its ancestors, is tagged with tag - for style decisions that are a structural yes/no (e.g. "is this text struck through") rather than a value to resolve.

func (*ASTNode) Path

func (n *ASTNode) Path() ASTPath

Path returns the tag ancestry from the root down to and including n - a convenience for StyleSheet implementations that want ancestry without walking Parent themselves. Computed once and cached.

slices.Concat, not append(parentPath, n.Tag) - append can leave two siblings aliasing the same backing-array slot.

type ASTPath

type ASTPath []ASTTag

ASTPath is a tag ancestry, root-to-leaf.

type ASTTag

type ASTTag int

ASTTag identifies the semantic role of an ASTNode - which part of a StyleSheet a value tagged with it should resolve its appearance against. Exported because StyleSheet implementations switch on it.

const (
	TagParagraph ASTTag = iota
	TagHeading1
	TagHeading2
	TagHeading3
	TagHeading4
	TagHeading5
	TagHeading6
	TagList
	TagListItem
	TagCodeBlock
	TagThematicBreak
	TagBlockquote
	TagTable
	TagTableCell
	TagLink
	TagCodeSpan
	TagEmphasis
	TagStrong
	TagStrikethrough
	TagImage
	TagUnsupported

	// TagCodeKeyword..TagCodeFunction classify a syntax-highlighted code
	// token - see Highlighter. Appended after TagUnsupported deliberately:
	// headingTag() relies on TagHeading1..TagHeading6 staying consecutive,
	// so nothing may be inserted before them.
	TagCodeKeyword
	TagCodeString
	TagCodeNumber
	TagCodeComment
	TagCodeType
	TagCodeFunction
)

type AnimatedImage

type AnimatedImage struct {
	// contains filtered or unexported fields
}

AnimatedImage holds every frame of a decoded GIF, already composited to full-canvas, disposal-correct frames (see decodeAnimatedGIF). CurrentFrame is a pure function of now (elapsed time since rendering started - RenderingContext.Time) and the animation's own total duration - there's no per-animation "start" to track: now is already "time since start", so a looping animation just always shows whichever frame now % total currently lands in, as if it had been playing since time zero. Deterministic and easy to test without any real waiting, and no clock-skew concerns between decode time and draw time to reason about, since nothing is stamped at decode time. Always loops forever.

func (*AnimatedImage) Bounds

func (a *AnimatedImage) Bounds() image.Rectangle

Bounds is every frame's shared size - GIF frames may individually be smaller (see decodeAnimatedGIF), but every composited frame here is full-canvas.

func (*AnimatedImage) CurrentFrame

func (a *AnimatedImage) CurrentFrame(now time.Duration) image.Image

CurrentFrame returns whichever frame now lands in, looping forever.

type Block

type Block interface {
	Source
	GetBlockLayout(ctx RenderingContext, width int) BlockLayout
	Margins(ctx RenderingContext) Margins
}

func Parse

func Parse(source []byte, opts ...ParseOption) Block

Parse compiles Markdown source into a Block tree ready for layout via Block.GetBlockLayout. All appearance (fonts, colors, margins, ...) is resolved later, from RenderingContext.StyleSheet against each Block/Inline's own ASTNode - Parse itself only builds structure.

type BlockLayout

type BlockLayout interface {
	Bounds() image.Rectangle
	Source() Source

	HitTest(p image.Point) (hit Hit, offset image.Point)
	// PendingImages returns the resolved image srcs this box - or
	// anything nested inside it - is still waiting to settle (still
	// fetching, or failed but eligible for retry). Empty once nothing
	// nested is still unsettled - such a box never needs revisiting due
	// to image state, only a genuine structural change (width, scale,
	// StyleSheet), same as any other content. Not memoized: only called
	// from View.invalidateChangedImages, itself only reached when
	// ImageCache reports something actually changed - not a per-frame
	// cost - and a resolved top-level slot's subtree never mutates in
	// place (see StackBox.boxAt), so there's nothing to go stale.
	PendingImages() []string
	// contains filtered or unexported methods
}

BlockLayout is drawn via the package-level DrawBlockLayout, not by calling drawContents directly, so that every BlockLayout gets the same off-screen skip for free regardless of where it sits in the tree. drawContents holds only the type-specific drawing logic.

HitTest identifies what's at p, a point in this BlockLayout's own Bounds() frame - callers must check p is in Bounds() first, since an implementation never re-checks. A nil hit means no match (dead space, e.g. a margin or padding); offset is where hit.Bounds() should be placed to land in the same frame p arrived in - a composite BlockLayout recursing into a child must add its own offset to the child's.

type BlockquoteBlock

type BlockquoteBlock struct {
	WithoutMargins
	// contains filtered or unexported fields
}

BlockquoteBlock is a quoted group of ordinary blocks (`> ...`). Unlike list-item indentation, which relies on the parent StackBlock's generic left-margin wrapping, a blockquote positions its own content and draws its own left-edge bar - self-contained, so nested blockquotes (each level's bar drawn independently) just work without the parent needing to know anything about it. inner is the quoted content as a single Block - already a StackBlock if there was more than one, resolved once at compile time rather than rebuilt on every GetBlockLayout call.

func (*BlockquoteBlock) GetBlockLayout

func (b *BlockquoteBlock) GetBlockLayout(ctx RenderingContext, width int) BlockLayout

func (*BlockquoteBlock) Node

func (b *BlockquoteBlock) Node() *ASTNode

type BlockquoteBox

type BlockquoteBox struct {
	// contains filtered or unexported fields
}

BlockquoteBox draws a vertical bar down the left edge and positions its inner content (the quote's own blocks, already laid out at the reduced width) to the right of it - the visual marker for a `>` blockquote.

func (*BlockquoteBox) Bounds

func (b *BlockquoteBox) Bounds() image.Rectangle

func (*BlockquoteBox) HitTest

func (b *BlockquoteBox) HitTest(p image.Point) (Hit, image.Point)

HitTest treats the whole indent strip (bar plus any padding before inner starts) as the blockquote's own, and falls back to it too whenever inner declines to match (content narrower than the indented width, or a gap between quoted paragraphs) - every HitTest implementation degrades gracefully on an out-of-bounds point, so there's no need to bounds-check before delegating.

func (*BlockquoteBox) PendingImages

func (b *BlockquoteBox) PendingImages() []string

func (*BlockquoteBox) Source

func (b *BlockquoteBox) Source() Source

type BlockquoteGeometry

type BlockquoteGeometry struct {
	Indent   float64
	BarWidth float64
}

BlockquoteGeometry is the dimensional constants a blockquote's own bar and indent need - grouped since a blockquote's GetBlockLayout always reads both together.

type Canvas

type Canvas interface {
	Bounds() image.Rectangle
	DrawText(s string, face font.Face, x, y int, clr color.Color)
	DrawImage(img image.Image, x, y, width, height int)
	DrawRect(x, y, w, h int, clr color.Color)
}

Canvas is the draw destination for a BlockLayout/InlineLayout tree - the boundary between layout (which never depends on a rendering backend) and actual drawing (which necessarily does). DrawImage receives an already-decoded, backend-agnostic image.Image - whynot.ImageCache owns resolving, fetching, and decoding (see InlineImage.GetInlineLayout), so a Canvas implementation never fetches anything itself; its own job is purely whatever backend-specific conversion drawing it needs (e.g. uploading it as a GPU texture), which it's free to cache itself, keyed by img's own identity - the same resolved image comes back from the cache every time it's asked for. width/height are the size to draw it at - already scaled by RenderingContext.Scale, and not generally img's own pixel size - so an implementation must scale what it draws to fit, not blit it 1:1.

type CheckboxBox added in v0.3.0

type CheckboxBox struct {
	// contains filtered or unexported fields
}

CheckboxBox is a GFM task list item's checkbox marker, drawn with plain filled rects (Canvas.DrawRect) rather than a font glyph - TaskCheckbox's fallback for a face that doesn't have the ballot-box glyphs (☐/☑; most fonts, including the bundled Go fonts, don't). An outlined square for unchecked, the same outline with a filled square inside for checked - deliberately not a baked icon image: this way it needs no embedded asset, scales naturally from the resolved face's own metrics (already DPI/zoom-scaled, like every face SelectFace returns - so it tracks font size and zoom together without a separate StyleSheet dimensional constant), and always matches the resolved text color instead of being a fixed-color raster that could clash with a custom StyleSheet.

func (*CheckboxBox) Bounds added in v0.3.0

func (b *CheckboxBox) Bounds() image.Rectangle

func (*CheckboxBox) BoundsAndAdvance added in v0.3.0

func (b *CheckboxBox) BoundsAndAdvance() (image.Rectangle, int)

BoundsAndAdvance places the box entirely above the baseline (Min.Y = -size, Max.Y = 0, matching how a same-sized glyph like □ itself sits), so it lines up with surrounding text the same way TaskCheckbox's glyph path does.

func (*CheckboxBox) DrawInline added in v0.3.0

func (b *CheckboxBox) DrawInline(dst Canvas, x, y int, now time.Duration) int

func (*CheckboxBox) Glued added in v0.3.1

func (b *CheckboxBox) Glued() bool

Glued is always false - a CheckboxBox only ever appears as a ListItemMarkerBox's Marker (a list item's task checkbox), so like ListItemMarkerBox itself it's never queried by the gap/break logic.

func (*CheckboxBox) HitTest added in v0.3.0

func (b *CheckboxBox) HitTest(p image.Point, x, y int) (Hit, image.Point, int)

func (*CheckboxBox) PendingImages added in v0.3.0

func (b *CheckboxBox) PendingImages() []string

func (*CheckboxBox) Source added in v0.3.0

func (b *CheckboxBox) Source() Source

func (*CheckboxBox) SpaceWidth added in v0.3.0

func (b *CheckboxBox) SpaceWidth() int

type CodeBlock

type CodeBlock struct {
	WithoutMargins
	// contains filtered or unexported fields
}

func (*CodeBlock) GetBlockLayout

func (b *CodeBlock) GetBlockLayout(ctx RenderingContext, width int) BlockLayout

func (*CodeBlock) Node

func (b *CodeBlock) Node() *ASTNode

type ContainerBox

type ContainerBox struct {
	// contains filtered or unexported fields
}

func NewContainerBox

func NewContainerBox(inner BlockLayout, w, h, x, y int) *ContainerBox

func (*ContainerBox) Bounds

func (b *ContainerBox) Bounds() image.Rectangle

func (*ContainerBox) HitTest

func (b *ContainerBox) HitTest(p image.Point) (Hit, image.Point)

HitTest has no source of its own - a pure positioning wrapper (for indentation, or cell alignment in a table) - so a miss on inner (e.g. content narrower than the space it was given) reports no match rather than falling back to anything.

func (*ContainerBox) PendingImages

func (b *ContainerBox) PendingImages() []string

func (*ContainerBox) Source

func (b *ContainerBox) Source() Source

Source delegates to inner: a pure positioning wrapper (for indentation, or cell alignment in a table) has no identity of its own, but always wraps exactly one thing, unlike StackBox.

type CustomFontFaceSelector added in v0.3.0

type CustomFontFaceSelector struct {
	// contains filtered or unexported fields
}

CustomFontFaceSelector is a FaceSelector backed by caller-supplied TTF/OTF font bytes, registered per (FontFamily, weight-bucket, style) slot via AddFont. Any (family, weight, style) combination with nothing registered is delegated to a fallback FaceSelector - NewGoFontFaceSelector by default, overridable via WithFallback - so callers only need to supply the fonts they actually care about overriding. WithFallback(nil) means "no fallback": SelectFace returns an error for any unfilled slot instead of delegating.

func NewCustomFontFaceSelector added in v0.3.0

func NewCustomFontFaceSelector(dpi float64, opts ...CustomFontFaceSelectorOption) *CustomFontFaceSelector

NewCustomFontFaceSelector returns a CustomFontFaceSelector with nothing registered yet - every SelectFace call delegates to its fallback FaceSelector (NewGoFontFaceSelector(dpi) unless overridden via WithFallback) until AddFont/AddFontFile is called.

func (*CustomFontFaceSelector) AddFont added in v0.3.0

func (s *CustomFontFaceSelector) AddFont(family FontFamily, weight font.Weight, style font.Style, data []byte, index int) error

AddFont registers data (TTF/OTF bytes, or a .ttc/.otc collection) for exactly the (family, weight, style) slot given, replacing whatever was registered for that slot before. index picks which subfont within data to use - 0 for an ordinary single-font file (opentype.ParseCollection treats it as a 1-font collection), or a specific subfont's index within a known .ttc/.otc. weight and style are bucketed exactly like a resolved TextStyle is in SelectFace (see normalizedFontKey).

data is parsed immediately, so a malformed font or an out-of-range index is reported here, at registration time, rather than surfacing later from SelectFace. On error nothing is registered and any already-cached faces are left untouched.

Use AddFontCollection instead to register every subfont a collection contains that can be confidently classified, rather than picking one by index yourself.

func (*CustomFontFaceSelector) AddFontCollection added in v0.3.0

func (s *CustomFontFaceSelector) AddFontCollection(family FontFamily, data []byte, matchFamily string) (registered int, err error)

AddFontCollection registers every subfont in data (a .ttc/.otc collection, or a plain single-font TTF/OTF) that classifies cleanly into a (weight, style) slot for family, via each subfont's own name-table subfamily string (see classifySubfamily in fontclassify.go). If matchFamily is non-empty, a subfont is also required to have its own name-table family reasonably match it (case/space/punctuation-insensitive, either direction as a substring - see subfontBelongsToFamily) - pass "" to register every classifiable subfont regardless of its own family name, e.g. when data is already known to hold only the one family wanted.

Anything unrecognized (Light/Thin/Black/SemiBold/Condensed/... - see classifySubfamily's doc comment for why these are skipped rather than guessed) or family-mismatched is simply not registered, not an error - those slots are served by the fallback FaceSelector instead. The only error this returns is a failure to parse data at all; it returns how many subfonts were registered otherwise.

func (*CustomFontFaceSelector) AddFontCollectionFile added in v0.3.0

func (s *CustomFontFaceSelector) AddFontCollectionFile(family FontFamily, path string, matchFamily string) (registered int, err error)

AddFontCollectionFile is AddFontCollection, reading data from path instead of taking it directly.

func (*CustomFontFaceSelector) AddFontFile added in v0.3.0

func (s *CustomFontFaceSelector) AddFontFile(family FontFamily, weight font.Weight, style font.Style, path string, index int) error

AddFontFile is AddFont, reading data from path instead of taking it directly. A convenience for the common case of loading a font straight from disk; font bytes coming from anywhere else (embed.FS, a network fetch) still go through AddFont directly, the same way NewView itself only ever takes []byte.

func (*CustomFontFaceSelector) SelectFace added in v0.3.0

func (s *CustomFontFaceSelector) SelectFace(style TextStyle) (font.Face, error)

func (*CustomFontFaceSelector) SetDPI added in v0.3.0

func (s *CustomFontFaceSelector) SetDPI(dpi float64)

type CustomFontFaceSelectorOption added in v0.3.0

type CustomFontFaceSelectorOption func(*CustomFontFaceSelector)

CustomFontFaceSelectorOption customizes a CustomFontFaceSelector at construction, via NewCustomFontFaceSelector's opts parameter.

func WithFallback added in v0.3.0

func WithFallback(fallback FaceSelector) CustomFontFaceSelectorOption

WithFallback overrides the FaceSelector NewCustomFontFaceSelector otherwise defaults to (NewGoFontFaceSelector) for any (family, weight, style) slot nothing was registered for. Pass nil to disable fallback entirely - SelectFace then returns an error for an unfilled slot instead.

type DefaultStyleSheet

type DefaultStyleSheet struct {
	ParagraphMargins   Margins
	ParagraphTextStyle PartialTextStyle

	HeadingMargins    [6]Margins
	HeadingTextStyles [6]PartialTextStyle

	ListMargins Margins

	ListItemMargins   Margins
	ListItemTextStyle PartialTextStyle

	CodeBlockMargins   Margins
	CodeBlockTextStyle PartialTextStyle
	// CodeBlockColor is deliberately a neutral gray rather than an accent
	// color: a whole block of code in a bright color reads as garish and
	// fights with syntax-highlighted spans inside it (see SyntaxColors) -
	// unlike CodeSpanColor, a single inline `code` word stays fine as an
	// accent since it's a small, isolated highlight within prose.
	CodeBlockColor color.Color

	// UnsupportedColor is the text color for a Markdown construct
	// whynot doesn't understand - rendered as a code block (see
	// CodeBlockMargins/CodeBlockTextStyle, shared with TagCodeBlock) but
	// in this distinct color so it reads as an error, not as normal code.
	UnsupportedColor color.Color

	// CodeSpanTextStyle, EmphasisTextStyle, StrongTextStyle: inline spans
	// have no margins of their own to bundle alongside, unlike the
	// block-level tags above.
	CodeSpanTextStyle PartialTextStyle
	// CodeSpanColor is an accent color, unlike CodeBlockColor - see its
	// own doc comment.
	CodeSpanColor     color.Color
	EmphasisTextStyle PartialTextStyle
	StrongTextStyle   PartialTextStyle

	ThematicBreakMargins Margins
	ThematicBreakColor   color.Color

	LinkColor color.Color

	BlockquoteMargins  Margins
	BlockquoteBarColor color.Color

	TableCellTextStyle PartialTextStyle
	TableMargins       Margins
	TableFrameColor    color.Color

	// ImagePlaceholderColor is the rect InlineImage.GetInlineLayout draws
	// in place of an image that's still pending but already knows its
	// final size.
	ImagePlaceholderColor color.Color

	// TextColor is the root-level fallback color for tags with no color
	// of their own. BaseTextStyle is TextStyle's equivalent - unlike the
	// per-tag PartialTextStyle fields above, it's a plain TextStyle,
	// not a PartialTextStyle: being the ultimate fallback means it
	// always claims every field by definition (see TextStyle below), so
	// there's no meaningful subset for a Set mask to express.
	TextColor     color.Color
	BaseTextStyle TextStyle

	// Background is the whole viewport's fill color - see BackgroundColor.
	// Named without the "Color" suffix only to avoid colliding with the
	// BackgroundColor method (color.Color already makes the field's own
	// meaning unambiguous).
	Background color.Color

	// ViewMargin is the whole viewport's own inset - see ViewMargins. Named
	// in the singular only to avoid colliding with the plural ViewMargins
	// method (Margins is a struct - like the field, not a color.Color -
	// so unlike Background/BackgroundColor there's no type-derived word
	// to hang the distinction on instead).
	ViewMargin Margins

	// Highlight is the text color for whatever's under
	// RenderingContext.HighlightNode - see HighlightColor. Named without
	// the "Color" suffix for the same reason Background is.
	Highlight color.Color

	// Scrollbar is the thumb's three interaction-state colors - see
	// ScrollbarColor. Unlike ThematicBreakColor/BlockquoteBarColor/
	// TableFrameColor (whynot's own content decorations), a scrollbar is
	// drawn outside whynot entirely, by whatever embeds a View - this
	// field only supplies the color that embedder asks for. Named
	// without the "Color" suffix for the same reason Background/
	// Highlight are.
	Scrollbar ScrollbarColors

	// Syntax is the palette a Highlighter's classified code tokens draw
	// from - see SyntaxColors.
	Syntax SyntaxColors
	// contains filtered or unexported fields
}

DefaultStyleSheet is the concrete, configurable StyleSheet implementation whynot's built-in themes (NewDarkStyleSheet, NewLightStyleSheet) are both built from - the two differ only in the field values their constructors fill in, not in shape. A caller who wants to change one thing can start from either constructor and mutate a field (ParagraphMargins, LinkColor, ParagraphTextStyle.Size, ...), or embed it in a custom StyleSheet and override individual methods for full control. The PartialTextStyle fields (as opposed to a plain TextStyle) mean a tag's contribution can be extended, not just adjusted - e.g. style.EmphasisTextStyle.Weight = font.WeightBold; style.EmphasisTextStyle.Set |= FieldWeight makes emphasis bold as well as italic, which a plain TextStyle field couldn't express: the fields it doesn't set wouldn't be distinguishable from fields deliberately set to their zero value.

func NewDarkStyleSheet

func NewDarkStyleSheet() *DefaultStyleSheet

NewDarkStyleSheet returns whynot's built-in dark theme - light text on a dark background - and is what NewView uses when no StyleSheet is given.

func NewLightStyleSheet

func NewLightStyleSheet() *DefaultStyleSheet

NewLightStyleSheet returns whynot's built-in light theme - dark text on a light background. Everything but color is identical to NewDarkStyleSheet (margins, sizes, weights, dimensional constants), so it's built from it rather than repeating them: ThematicBreakColor, BlockquoteBarColor, TableFrameColor, and ImagePlaceholderColor are also left as NewDarkStyleSheet's mid-grey, which reads fine against either a light or dark background, unlike TextColor/Background/LinkColor/CodeBlockColor/CodeSpanColor/Scrollbar, which need real light-appropriate values.

func (*DefaultStyleSheet) BackgroundColor

func (s *DefaultStyleSheet) BackgroundColor() color.Color

func (*DefaultStyleSheet) BlockquoteGeometry

func (s *DefaultStyleSheet) BlockquoteGeometry(node *ASTNode) BlockquoteGeometry

func (*DefaultStyleSheet) BorderColor

func (s *DefaultStyleSheet) BorderColor(node *ASTNode) color.Color

func (*DefaultStyleSheet) Color

func (s *DefaultStyleSheet) Color(node *ASTNode) color.Color

Color returns node's own contribution to the cascading text color - nil for any tag with no opinion, so ResolvedColor's ancestry walk passes through it to an outer contributor (or the TextColor default at the root). Only Link, the two code tags, and Unsupported have an opinion; block-level decoration colors live on BorderColor instead, since e.g. a blockquote's bar color must never leak into its inner text color.

func (*DefaultStyleSheet) HighlightColor

func (s *DefaultStyleSheet) HighlightColor() color.Color

func (*DefaultStyleSheet) LineHeight added in v0.3.0

func (s *DefaultStyleSheet) LineHeight(node *ASTNode) float64

func (*DefaultStyleSheet) Margins

func (s *DefaultStyleSheet) Margins(node *ASTNode) Margins

func (*DefaultStyleSheet) ScrollbarColor added in v0.2.0

func (s *DefaultStyleSheet) ScrollbarColor(hover, pressed bool) color.Color

func (*DefaultStyleSheet) StrikeThickness

func (s *DefaultStyleSheet) StrikeThickness(node *ASTNode) float64

StrikeThickness returns 0 unless node (or an ancestor) is tagged TagStrikethrough - encoding "should this be struck" and "how thick" in one value, the same way TextStyle's Weight being font.WeightNormal means "not bold".

func (*DefaultStyleSheet) TableGeometry

func (s *DefaultStyleSheet) TableGeometry(node *ASTNode) TableGeometry

func (*DefaultStyleSheet) TextStyle

func (s *DefaultStyleSheet) TextStyle(node *ASTNode) PartialTextStyle

TextStyle returns each tag's own contribution - only the fields Set flags are meaningful; ResolvedTextStyle merges these across a node's ancestry, so e.g. Strong nested inside Emphasis picks up both. At the root (node == nil) it returns BaseTextStyle claiming every field, the fallback ResolvedTextStyle reaches if no ancestor ever set some field - guarantees e.g. Size is never silently left at 0.

func (*DefaultStyleSheet) ThematicBreakThickness

func (s *DefaultStyleSheet) ThematicBreakThickness(node *ASTNode) float64

func (*DefaultStyleSheet) ViewMargins

func (s *DefaultStyleSheet) ViewMargins() Margins

type EmptyBox

type EmptyBox struct {
	// contains filtered or unexported fields
}

func NewEmptyBox

func NewEmptyBox(w, h int) *EmptyBox

func (*EmptyBox) Bounds

func (b *EmptyBox) Bounds() image.Rectangle

func (*EmptyBox) HitTest

func (b *EmptyBox) HitTest(p image.Point) (Hit, image.Point)

HitTest always misses: an EmptyBox is pure spacing (a margin/gap between blocks or lines), never content.

func (*EmptyBox) PendingImages

func (b *EmptyBox) PendingImages() []string

func (*EmptyBox) Source

func (b *EmptyBox) Source() Source

type FaceSelector

type FaceSelector interface {
	SelectFace(TextStyle) (font.Face, error)
	// SetDPI updates the DPI used to rasterize faces going forward,
	// invalidating any cached font.Face built at the old DPI. Called every
	// View.Layout frame (see view.go), so implementations must treat a
	// call with an unchanged dpi as a cheap no-op.
	SetDPI(float64)
}

type FileImageSource

type FileImageSource struct{}

FileImageSource is the default ImageSource: src is opened exactly as written (via the OS's own working-directory-relative resolution), with no further resolution - today's pre-existing behavior, used unless an embedder opts into WithImageSource.

func (FileImageSource) Open

func (FileImageSource) Open(resolved string) (io.ReadCloser, error)

func (FileImageSource) Resolve

func (FileImageSource) Resolve(src string) (string, error)

type FontFamily

type FontFamily int
const (
	Proportional FontFamily = iota
	Monospace
	SmallCaps
)

func (FontFamily) String added in v0.3.0

func (f FontFamily) String() string

String names f for logging/debugging - e.g. a FaceSelector reporting which family a font-resolution decision was made for.

type GoFontFaceSelector

type GoFontFaceSelector struct {
	// contains filtered or unexported fields
}

func NewGoFontFaceSelector

func NewGoFontFaceSelector(dpi float64) *GoFontFaceSelector

func (*GoFontFaceSelector) SelectFace

func (s *GoFontFaceSelector) SelectFace(style TextStyle) (font.Face, error)

func (*GoFontFaceSelector) SetDPI

func (s *GoFontFaceSelector) SetDPI(dpi float64)

type HighlightSpan added in v0.3.0

type HighlightSpan struct {
	Text  string
	Class TokenClass
}

HighlightSpan is one classified run of a Highlighter's output.

type Highlighter added in v0.3.0

type Highlighter interface {
	// Highlight splits code (a whole fenced/indented block's source,
	// potentially multiple lines) into consecutive spans whose Text
	// concatenates back to code exactly. language is the fence's own
	// info string (e.g. "go"), "" if there was none (including every
	// indented code block, which has no fence to carry one).
	// Implementations are free to return a single TokenPlain span
	// covering all of code (e.g. an unrecognized language) - equivalent
	// to no highlighting for that block.
	Highlight(language, code string) []HighlightSpan
}

Highlighter classifies a code block's source into consecutive typed spans, used to color it token-by-token instead of as one flat run. Parse/NewView have no highlighter by default - every code block then renders in one flat color (StyleSheet's existing TagCodeBlock case), exactly as before this existed.

type Hit

type Hit interface {
	Bounds() image.Rectangle
	Source() Source
}

Hit is what HitTest returns for an actual match: something that can report its own Bounds() and Source() - every BlockLayout and InlineLayout qualifies, so HitTest can return either kind of leaf uniformly. A composite's Source() is nil when it aggregates children with no single identity of their own (see StackBox.Source), the same way StackBlock.Node does for the semantic tree.

type ImageBox

type ImageBox struct {
	// contains filtered or unexported fields
}

func (*ImageBox) Bounds

func (b *ImageBox) Bounds() image.Rectangle

func (*ImageBox) BoundsAndAdvance

func (b *ImageBox) BoundsAndAdvance() (image.Rectangle, int)

func (*ImageBox) DrawInline

func (b *ImageBox) DrawInline(dst Canvas, x, y int, now time.Duration) int

func (*ImageBox) Glued added in v0.3.1

func (b *ImageBox) Glued() bool

func (*ImageBox) HitTest

func (b *ImageBox) HitTest(p image.Point, x, y int) (Hit, image.Point, int)

func (*ImageBox) PendingImages

func (b *ImageBox) PendingImages() []string

func (*ImageBox) Source

func (b *ImageBox) Source() Source

func (*ImageBox) SpaceWidth

func (b *ImageBox) SpaceWidth() int

type ImageCache

type ImageCache struct {
	// contains filtered or unexported fields
}

ImageCache wraps an ImageSource with the "management of image data" the library owns: an image is resolved, fetched, and decoded at most once, however many times Load is called for it afterwards - every layout rebuild asks again. A Canvas implementation never fetches its own copy either - ImageBox carries the already-decoded image.Image forward from here, straight into Canvas.DrawImage.

Load never blocks: a cache miss (or a failed entry whose retry delay has elapsed) returns ImagePending immediately and starts a fetch on a background goroutine, which is why this type is safe for concurrent use (Load from the layout goroutine, the goroutine's own writes as a fetch progresses) - unlike everything else in this codebase, which is only ever touched from ebiten's single game-loop goroutine and has no need to be.

func NewImageCache

func NewImageCache(source ImageSource) *ImageCache

func (*ImageCache) ChangedSince

func (c *ImageCache) ChangedSince(mark uint64) (changed []ImageChange, newMark uint64)

ChangedSince returns every change since a previous ChangedSince (or an initial call with mark 0) returned mark, plus a new mark to pass next time. Cheap: each entry just remembers the cache's own version counter at the moment it last changed, so this is a scan of however many distinct images exist, not a growing log.

func (*ImageCache) Load

func (c *ImageCache) Load(src string) (resolved string, result ImageResult)

Load resolves src and returns its current state, starting a fetch in the background on a genuine miss, or on a failed entry old enough to retry - never blocking on the fetch itself. resolved is still returned when Resolve itself failed - InlineImage's fallback text names it in a missing- or broken-image message.

type ImageChange

type ImageChange struct {
	Src string
	// BoundsRevealed is true if, since the mark passed to
	// ChangedSince, this src's bounds became known for the first time
	// - the one transition that can change a slot's height
	// unpredictably (everything else - becoming ready, a retry's
	// outcome - happens at an already-known, already-laid-out size).
	// See View.invalidateChangedImages.
	BoundsRevealed bool
}

ImageChange is one entry ImageCache.ChangedSince reports.

type ImageResult

type ImageResult struct {
	Status ImageStatus
	// Bounds is the image's native pixel size - known once Status ==
	// ImageReady, but may already be known earlier, while Status is
	// still ImagePending (see ImageCache's header-peek). Zero while
	// genuinely unknown.
	Bounds image.Rectangle
	// Image is the decoded image - valid only when Status == ImageReady
	// and this isn't an animated GIF (Animation is set instead; exactly
	// one of the two is set on a ready result).
	Image image.Image
	// Animation is set instead of Image when the decoded image is an
	// animated GIF - see AnimatedImage.
	Animation *AnimatedImage
	// Err says why the most recent attempt failed - valid only when
	// Status == ImageFailed.
	Err error
}

ImageResult is what ImageCache.Load returns for one src.

type ImageSource

type ImageSource interface {
	// Resolve turns src into a stable, absolute identifier - the cache
	// key, and what a missing- or broken-image's fallback message
	// names. Pure and cheap: no I/O, just interpreting src against
	// whatever base the ImageSource knows about.
	Resolve(src string) (string, error)
	// Open fetches the bytes at a value Resolve returned.
	Open(resolved string) (io.ReadCloser, error)
}

ImageSource resolves and fetches an image's raw bytes - the embedder-supplied policy for wherever an InlineImage's src actually points (relative to a document's own location, over http(s), from an archive, whatever it needs). Kept out of the library's own hands the same way document loading and link resolution already are (see cmd/whynot/main.go's loadDocument/resolveLink) - only the seam lives here. Always used through an ImageCache (see NewImageCache), never called directly by GetInlineLayout, so resolving and fetching an image only ever happens once no matter how many times it's asked for - and always from a background goroutine, since Open can block (a slow disk, a slow or hanging server) and ImageCache.Load must never block its caller.

type ImageStatus

type ImageStatus int

ImageStatus is an image's state within an ImageCache.

const (
	// ImagePending means a fetch is in flight (or about to start) - not
	// yet ready, not (yet, or any longer) failed. Bounds may already be
	// known even in this state - see ImageCache's own doc comment.
	ImagePending ImageStatus = iota
	// ImageReady means Image holds the fully decoded result.
	ImageReady
	// ImageFailed means the most recent attempt failed - Err says how.
	// Not necessarily permanent: see ImageCache's retry behavior.
	ImageFailed
)

type Inline

type Inline interface {
	Source
	// GetInlineLayout builds this Inline's layout, width the same
	// effectively-unbounded-or-real wrap width a Block's own
	// GetBlockLayout receives (see naturalWidthMeasure) - only
	// InlineImage uses it (see fitWidth), every other implementation
	// ignores it.
	GetInlineLayout(ctx RenderingContext, width int) InlineLayout
}

type InlineImage

type InlineImage struct {
	// contains filtered or unexported fields
}

func (*InlineImage) GetInlineLayout

func (i *InlineImage) GetInlineLayout(ctx RenderingContext, width int) InlineLayout

GetInlineLayout resolves, fetches, and decodes src via ctx.ImageCache (letting the embedder decide the resolution/fetch policy - relative to a document's location, over http(s), from an archive, whatever it needs; falls back to i.ownCache, lazily created from FileImageSource, if ctx was built as a bare RenderingContext{} with no ImageCache set, the same default NewView itself uses - so direct RenderingContext callers that never touch an image, like most of this package's own tests, don't need to know ImageCache exists) - never blocking, since ImageCache.Load never does. There are three outcomes:

  • Ready: the decoded image, scaled by ctx.Scale like every other sized quantity in the layout system (RenderingContext.ScaledMargins and friends), so images grow and shrink along with zoom/DPI instead of staying pixel-locked - then capped to width, preserving aspect ratio, if that scaled size would still be wider (see fitWidth) - the inline layer's equivalent of CSS's max-width: 100%, so an image dropped into a document at its own native resolution doesn't overflow the page.
  • Pending with bounds already known (the header-peek in ImageCache.fetchAndDecode beat the caller here): an ImageBox at the correct, final (scaled and width-capped) size, but with no image yet - DrawInline draws a placeholder instead, and nothing needs to reflow once the real pixels arrive later.
  • Pending with no bounds yet, or Failed (unreadable, undecodable, or mid-retry-cooldown): falls back to text instead of a silent zero-size gap - reusing InlineText's own GetInlineLayout, so it renders exactly like any other construct whynot can't handle (see appendUnsupportedInline). Either way the resulting TextBox is stamped with which src it's standing in for, so View.invalidateChangedImages knows to revisit it once that changes.

func (*InlineImage) Node

func (i *InlineImage) Node() *ASTNode

type InlineLayout

type InlineLayout interface {
	BoundsAndAdvance() (image.Rectangle, int)
	Bounds() image.Rectangle
	Source() Source
	SpaceWidth() int
	// Glued reports whether this item directly abuts the previous one in
	// its line with no source whitespace between them - e.g. a word right
	// next to a code span, or a non-breaking space's own neighbors. A
	// glued boundary gets no rendered gap (splitBoxes, LineBox's
	// BoundsAndAdvance/HitTest/drawContents) and is never a line-break
	// point (splitBoxes). Meaningless for an item that's never anything
	// but a line's first part (ListItemMarkerBox, CheckboxBox) - never
	// queried there.
	Glued() bool
	// DrawInline's now - see BlockLayout.drawContents's identical parameter.
	DrawInline(dst Canvas, x, y int, now time.Duration) int
	HitTest(p image.Point, x, y int) (hit Hit, offset image.Point, nextX int)
	// PendingImages - see BlockLayout's identical method.
	PendingImages() []string
}

InlineLayout's HitTest mirrors DrawInline's own calling convention exactly - same (x, y), same "next x" return - so LineBox.HitTest can drive the identical accumulation loop drawContents does, just calling HitTest instead of DrawInline at each step.

type InlineText

type InlineText struct {
	// contains filtered or unexported fields
}

func (*InlineText) GetInlineLayout

func (t *InlineText) GetInlineLayout(ctx RenderingContext, width int) InlineLayout

func (*InlineText) Node

func (t *InlineText) Node() *ASTNode

type LineBox

type LineBox struct {

	// Glue, when true, abuts parts directly with no inter-part gap -
	// for a line whose parts are already-contiguous substrings of one
	// original string (e.g. CodeBlock's classified token spans, each a
	// verbatim slice of the source line, any whitespace between tokens
	// already included in a plain-class span's own text) rather than
	// separately word-split text (TextBlock's parts, built by
	// appendString's strings.Fields, which discards whitespace and so
	// needs a real inter-word gap reconstructed from SpaceWidth()).
	Glue bool
	// contains filtered or unexported fields
}

func (*LineBox) Bounds

func (b *LineBox) Bounds() image.Rectangle

Bounds only compensates for a negative bounds.Min.X (an overshooting left bearing), not a positive one: a code line's leading indentation has real advance but no ink, so it's occupied space starting at this box's own x=0, not excess to crop away. Cropping it under-reported indented lines' width and broke hit-testing on their right-hand side.

func (*LineBox) BoundsAndAdvance

func (b *LineBox) BoundsAndAdvance() (image.Rectangle, int)

Same reasoning as TextBox: parts are fixed at construction and a LineBox is never reused across a re-layout, so this is safe to compute once and reuse for the instance's whole life. Inter-word spacing here must match drawContents/splitBoxes exactly (each word's own SpaceWidth(), not a fixed value) - this is what a caller measures via Bounds() to decide how much room the line actually needs, so any mismatch with what actually gets drawn silently under- or over-reports it.

func (*LineBox) HitTest

func (b *LineBox) HitTest(p image.Point) (Hit, image.Point)

HitTest mirrors drawContents' own accumulation loop, calling each part's HitTest instead of DrawInline at each step, so the two can't drift apart. p needs no shift: y is the same internal y drawContents computes for external y=0, which already lands a part's footprint in Bounds()'s frame - the same frame p arrives in.

func (*LineBox) PendingImages

func (b *LineBox) PendingImages() []string

func (*LineBox) Source

func (b *LineBox) Source() Source

Source is always nil: a line can mix parts with different identities (e.g. plain text next to emphasized text), so there's no single node to report at the line level - HitTest already returns the specific part that matched instead.

type ListItemHeadBlock

type ListItemHeadBlock struct {
	WithoutMargins
	// contains filtered or unexported fields
}

ListItemHeadBlock is a list item's own paragraph text, flowed with the marker hanging off the first line - see GetBlockLayout. It always reports zero margins: a list item's indentation and item-to-item spacing belong to the StackBlock CompileListItem wraps it in (along with any trailing content, e.g. a nested list), not to the head on its own - it has no business claiming indentation whether or not there's a trailing part.

func (*ListItemHeadBlock) GetBlockLayout

func (b *ListItemHeadBlock) GetBlockLayout(ctx RenderingContext, width int) BlockLayout

func (*ListItemHeadBlock) Node

func (b *ListItemHeadBlock) Node() *ASTNode

type ListItemMarkerBox

type ListItemMarkerBox struct {
	Marker InlineLayout
}

func (*ListItemMarkerBox) Bounds

func (b *ListItemMarkerBox) Bounds() image.Rectangle

func (*ListItemMarkerBox) BoundsAndAdvance

func (b *ListItemMarkerBox) BoundsAndAdvance() (image.Rectangle, int)

func (*ListItemMarkerBox) DrawInline

func (b *ListItemMarkerBox) DrawInline(dst Canvas, x, y int, now time.Duration) int

func (*ListItemMarkerBox) Glued added in v0.3.1

func (b *ListItemMarkerBox) Glued() bool

Glued is always false - a ListItemMarkerBox is only ever a line's first part (ListItemHeadBlock.GetBlockLayout), so it's never queried by the gap/break logic that Glued exists for.

func (*ListItemMarkerBox) HitTest

func (b *ListItemMarkerBox) HitTest(p image.Point, x, y int) (Hit, image.Point, int)

HitTest mirrors DrawInline exactly: the marker is drawn hanging off to the left of x (DrawInline's x-advance-space), not at x itself, so a naive check against BoundsAndAdvance's own (zero-width) bounds would never match a click on the visible marker glyph.

func (*ListItemMarkerBox) PendingImages

func (b *ListItemMarkerBox) PendingImages() []string

func (*ListItemMarkerBox) Source

func (b *ListItemMarkerBox) Source() Source

func (*ListItemMarkerBox) SpaceWidth

func (b *ListItemMarkerBox) SpaceWidth() int

type MarginBlock

type MarginBlock struct {
	Block
	// contains filtered or unexported fields
}

MarginBlock adds margins to an existing Block, collapsing Top/Bottom with whatever it already reports. Left/Right are just MarginBlock's own value: nothing it wraps ever reports a nonzero Left/Right to collapse with - StackBlock, the only Block with any derived margins, only ever derives Top/Bottom from its children.

Its own margins are resolved from ctx.StyleSheet via node - unscaled, matching Block.Margins' convention (StackBlock.GetBlockLayout, the one real caller, scales the result via ctx.ScaledMargins).

func (*MarginBlock) Margins

func (b *MarginBlock) Margins(ctx RenderingContext) Margins

func (*MarginBlock) Node

func (b *MarginBlock) Node() *ASTNode

Node delegates to the wrapped Block rather than returning b.node: a loose list item's head wraps a ListItemHeadBlock in a MarginBlock keyed to a synthetic TagParagraph node (purely so its margins resolve like a paragraph's) - the wrapped content's TagListItem is the more correct identity for hit-testing.

type Marginer

type Marginer interface {
	Margins(ctx RenderingContext) Margins
}

Marginer is anything that reports its own logical (unscaled) Margins - every Block satisfies it, but it's kept narrow so RenderingContext. ScaledMargins doesn't need the rest of the Block interface.

type Margins

type Margins struct {
	Top, Bottom, Left, Right float64
}

type MarkdownCompiler

type MarkdownCompiler struct {
	// contains filtered or unexported fields
}

MarkdownCompiler compiles Markdown source into a Block/ASTNode tree - structure only, no appearance. See Parse.

func (*MarkdownCompiler) AppendInlineNode

func (c *MarkdownCompiler) AppendInlineNode(items []Inline, node gmast.Node, astNode *ASTNode) []Inline

AppendInlineNode walks an inline subtree, appending each leaf as an Inline. astNode is node's parent in the ASTNode tree - updated only by the constructs that get their own ASTTag (Emphasis, Strong, Link, CodeSpan, Strikethrough) via AddChild, and threaded straight through everywhere else. Appearance (font, color, strike) is never resolved here - each produced Inline just carries the ASTNode it was created under, resolved later by RenderingContext against a StyleSheet.

func (*MarkdownCompiler) CompileBlock

func (c *MarkdownCompiler) CompileBlock(node gmast.Node, parent *ASTNode) Block

func (*MarkdownCompiler) CompileDocument

func (c *MarkdownCompiler) CompileDocument(node gmast.Node) Block

CompileDocument compiles the document root. Always the top of the tree - gmast.KindDocument is never nested - so unlike CompileBlock/CompileListItem it needs no parent *ASTNode to thread through.

func (*MarkdownCompiler) CompileListItem

func (c *MarkdownCompiler) CompileListItem(node gmast.Node, index int, marker byte, tight bool, parent *ASTNode) Block

func (*MarkdownCompiler) CompileNode

func (c *MarkdownCompiler) CompileNode(node gmast.Node, parent *ASTNode) Block

func (*MarkdownCompiler) CompileTable

func (c *MarkdownCompiler) CompileTable(node gmast.Node, parent *ASTNode) Block

CompileTable compiles a Table node. The header is mandatory (GFM requires it); the body is not - a table can legitimately have zero data rows, in which case Table has no TableBody child at all.

func (*MarkdownCompiler) CompileTableRow

func (c *MarkdownCompiler) CompileTableRow(node gmast.Node, parent *ASTNode) []tableCell

CompileTableRow compiles the cells of a TableHeader or a TableRow - both have TableCell children directly, no intermediate node, so one method handles both despite the different AST kinds.

type ParseOption added in v0.3.0

type ParseOption func(*MarkdownCompiler)

ParseOption customizes Parse - see WithSyntaxHighlighter.

func WithSyntaxHighlighter added in v0.3.0

func WithSyntaxHighlighter(h Highlighter) ParseOption

WithSyntaxHighlighter sets the Highlighter Parse uses to color code blocks token-by-token. Unset, code blocks render in one flat color, exactly as before this option existed. See WithHighlighter for the equivalent NewView option.

type PartialTextStyle

type PartialTextStyle struct {
	TextStyle
	Set TextStyleField
}

PartialTextStyle is what a single node's own tag contributes to TextStyle - only the fields flagged in Set are meaningful; every other field is inherited from further up the node's ancestry (see RenderingContext.ResolvedTextStyle).

type RenderingContext

type RenderingContext struct {
	Scale float64
	FaceSelector
	StyleSheet StyleSheet

	// HighlightNode is the ASTNode currently under the mouse (e.g. a
	// hovered link), or nil - see ResolvedColor. Set by View.Hover, which
	// rebuilds the layout tree when it changes.
	HighlightNode *ASTNode

	// ImageCache resolves, fetches, and decodes an InlineImage's src,
	// caching the result - see ImageCache's own doc comment. Defaults
	// to NewImageCache(FileImageSource{}) (NewView), overridable via
	// WithImageSource.
	ImageCache *ImageCache

	// Time is elapsed time since the embedder started rendering (its
	// own reference point - only ever used relative to itself, never
	// compared against a wall-clock timestamp), set every View.Layout
	// call. The one consumer today is AnimatedImage.CurrentFrame,
	// reached via DrawInline - kept here rather than read from a direct
	// time.Now() call so frame selection stays a pure, deterministic
	// function of its inputs, easy to test without any real waiting.
	Time time.Duration
}

func (RenderingContext) ResolvedColor

func (c RenderingContext) ResolvedColor(node *ASTNode) color.Color

ResolvedColor walks node's ancestry (node itself first) for the nearest non-nil Color contribution - mirrors CSS's `color`, which inherits down from the nearest ancestor that sets it.

func (RenderingContext) ResolvedTextStyle

func (c RenderingContext) ResolvedTextStyle(node *ASTNode) TextStyle

ResolvedTextStyle merges node's ancestry's TextStyle contributions into one TextStyle - the nearest ancestor (node itself first) to set a given field wins, so e.g. Strong nested inside Emphasis picks up both a bold weight (from Strong) and an italic style (from Emphasis). Walks one step past the root (node == nil) so StyleSheet's baseline contribution can fill in any field nothing along the way ever claimed.

func (RenderingContext) ScaledBlockquoteGeometry

func (c RenderingContext) ScaledBlockquoteGeometry(node *ASTNode) BlockquoteGeometry

func (RenderingContext) ScaledMargins

func (c RenderingContext) ScaledMargins(m Marginer) Margins

func (RenderingContext) ScaledStrikeThickness

func (c RenderingContext) ScaledStrikeThickness(node *ASTNode) float64

func (RenderingContext) ScaledTableGeometry

func (c RenderingContext) ScaledTableGeometry(node *ASTNode) TableGeometry

func (RenderingContext) ScaledThematicBreakThickness

func (c RenderingContext) ScaledThematicBreakThickness(node *ASTNode) float64

func (RenderingContext) ScaledViewMargins

func (c RenderingContext) ScaledViewMargins() Margins

type RuleBox

type RuleBox struct {
	// contains filtered or unexported fields
}

RuleBox is a single filled horizontal bar - the box for a thematic break (`---`). Its own height is just the bar's thickness; the visual spacing above and below comes from ThematicBreakBlock's Margins, same as any other block.

func (*RuleBox) Bounds

func (b *RuleBox) Bounds() image.Rectangle

func (*RuleBox) HitTest

func (b *RuleBox) HitTest(p image.Point) (Hit, image.Point)

func (*RuleBox) PendingImages

func (b *RuleBox) PendingImages() []string

func (*RuleBox) Source

func (b *RuleBox) Source() Source

type ScrollPosition

type ScrollPosition struct {
	// contains filtered or unexported fields
}

ScrollPosition is an opaque snapshot of a View's scroll position, captured by ScrollPosition and restored by RestoreScrollPosition.

type ScrollbarColors added in v0.2.0

type ScrollbarColors struct {
	Idle, Hover, Pressed color.Color
}

ScrollbarColors is the three colors DefaultStyleSheet.ScrollbarColor picks between - idle, hovered, and actively-dragged - kept as one struct (like BlockquoteGeometry/TableGeometry) since overriding one almost always means reconsidering all three together, as one "gets more prominent as you interact" progression.

type ScrollbarStyleSheet added in v0.2.0

type ScrollbarStyleSheet interface {
	// ScrollbarColor returns the color to draw the scrollbar thumb in.
	// hover is true whenever the thumb is highlighted (including while
	// dragging); pressed is true only while actively being dragged.
	ScrollbarColor(hover, pressed bool) color.Color
}

ScrollbarStyleSheet is an optional StyleSheet capability: a StyleSheet that also knows how to color a scrollbar thumb. It's optional, not part of StyleSheet itself, because a scrollbar is drawn by whatever embeds a View (cmd/whynot today, a future ebitenrenderer.Panel later) - View itself never draws one - so a StyleSheet that's never paired with a scrollbar (e.g. a test StyleSheet) isn't forced to implement it. A caller that wants a scrollbar type-asserts its StyleSheet against this interface and falls back to a reasonable default color when it isn't implemented.

type Source

type Source interface {
	Node() *ASTNode
}

Source is the common ground between Block and Inline: something with a semantic identity in the compiled ASTNode tree. Leaf boxes expose it so a hit-test result traces back to its origin - and since the concrete value is the real Block/Inline, a caller can type-assert further for anything beyond the node itself.

type StackBlock

type StackBlock struct {
	// contains filtered or unexported fields
}

func (*StackBlock) GetBlockLayout

func (b *StackBlock) GetBlockLayout(ctx RenderingContext, width int) BlockLayout

GetBlockLayout builds the slot skeleton only - gap sizes from Margins(), which is cheap (no text measurement) - deferring each block's own GetBlockLayout call to StackBox.boxAt, on first access to that slot. A resize only needs to re-derive gap sizes and widths up front; the expensive part (actually laying out each block's content) only happens for slots something later asks for, e.g. those near a scroll anchor.

func (*StackBlock) Margins

func (b *StackBlock) Margins(ctx RenderingContext) Margins

Margins reports Top/Bottom as its first/last child's own margin - the same collapsing GetBlockLayout applies between siblings, extended to its own edges. Left/Right are zero: as a stack of blocks arranged vertically, StackBlock has no notion of a horizontal edge to derive from a child - only whatever wraps it (see MarginBlock) has a real Left/Right.

func (*StackBlock) Node

func (b *StackBlock) Node() *ASTNode

Node always returns nil: a StackBlock aggregates other Blocks, each with their own identity, so it has none of its own - hit-testing that reaches a bare StackBlock should already have recursed into whichever child slot actually matched.

type StackBox

type StackBox struct {
	// contains filtered or unexported fields
}

func (*StackBox) Bounds

func (b *StackBox) Bounds() image.Rectangle

func (*StackBox) DrawFrom

func (b *StackBox) DrawFrom(dst Canvas, c stackCursor, x, y int, now time.Duration)

DrawFrom draws starting at c, so that c's position lands at (x, y) on dst - unlike DrawBlockLayout, it never calls Bounds() on the whole tree first, and never resolves or draws slots before c.index. Intended for View to call at the scroll cursor.

func (*StackBox) HitTest

func (b *StackBox) HitTest(p image.Point) (Hit, image.Point)

HitTest walks slots from the start looking for the one p.Y falls in - a plain linear scan, unlike normalizeCursor's cursor-relative walk, since resolving this way would force-build every slot up to the match regardless. That's fine for a StackBox reached via nested structure (a list's items, a blockquote's paragraphs - typically small), but would defeat the point for the top-level, potentially huge document StackBox - View.HitTest handles that one specially, starting from the current scroll cursor instead of slot 0.

A miss on the matched slot (outside its bounds, or its own HitTest declines - e.g. clicking past the end of a paragraph's short last line) falls back to this StackBox itself when it has a Source - the same fallback-to-self BlockquoteBox/TableBox already do. When it doesn't (an aggregate of unrelated blocks - see Source), it's a genuine miss instead, same as before Source existed.

func (*StackBox) PendingImages

func (b *StackBox) PendingImages() []string

PendingImages aggregates only over already-resolved slots (slot.box != nil) - an unresolved slot hasn't been looked at yet, so there's nothing pending to report about it until something actually asks for it (see boxAt). Not memoized (see the BlockLayout interface's own doc comment) - in particular, the top-level document StackBox's own slots keep resolving lazily over time, so caching this would go stale; View.invalidateChangedImages only ever calls this on individual already-resolved slots' boxes, never on that top-level aggregate itself.

func (*StackBox) Source

func (b *StackBox) Source() Source

type StyleSheet

type StyleSheet interface {
	// Margins returns the margins for node's own tag.
	Margins(node *ASTNode) Margins
	// TextStyle returns node's own tag's contribution to TextStyle -
	// combined with its ancestors' contributions by
	// RenderingContext.ResolvedTextStyle, since e.g. Strong nested inside
	// Emphasis needs both a bold and an italic contribution to survive.
	TextStyle(node *ASTNode) PartialTextStyle
	// Color returns node's own tag's contribution to the cascading text
	// color, or nil if it has no opinion (inherits from an ancestor, or
	// the document default at the root - see
	// RenderingContext.ResolvedColor). Mirrors CSS's `color`, which
	// inherits by default.
	Color(node *ASTNode) color.Color
	// BorderColor returns node's own single, non-inherited decoration
	// color - a thematic break's rule, a blockquote's bar, a table's
	// frame. Unlike Color, this is always resolved directly against one
	// node, never by walking ancestry - mirrors CSS's border-color,
	// which doesn't inherit.
	BorderColor(node *ASTNode) color.Color
	// BackgroundColor returns the color the document's viewport is filled
	// with before anything else draws. Unlike every other method here,
	// it takes no node: it isn't a property of any particular tag, just
	// painted once, for the whole view, before any content-specific
	// resolution happens - a node parameter would have nothing real to
	// mean.
	BackgroundColor() color.Color
	// ViewMargins returns the space between the document's viewport edge
	// and its content, on all four sides - the same shape as a tag's own
	// Margins, but for the whole view rather than one node, the same way
	// BackgroundColor is a whole-view counterpart to Color.
	ViewMargins() Margins
	// HighlightColor returns the text color for whatever's under
	// RenderingContext.HighlightNode (e.g. a hovered link) - takes no
	// node for the same reason BackgroundColor doesn't: it isn't a
	// property of any one tag, it's a transient interaction state
	// RenderingContext.ResolvedColor substitutes in ahead of the normal
	// per-node cascade.
	HighlightColor() color.Color

	// StrikeThickness returns the thickness for a strikethrough line at
	// node, or 0 if node isn't (nor is any ancestor) struck through -
	// this is both "should this be struck" and "how thick", the same way
	// TextStyle's Weight being font.WeightNormal means "not bold".
	StrikeThickness(node *ASTNode) float64
	ThematicBreakThickness(node *ASTNode) float64
	BlockquoteGeometry(node *ASTNode) BlockquoteGeometry
	TableGeometry(node *ASTNode) TableGeometry

	// LineHeight returns the multiplier applied to a line's natural
	// Ascent+Descent to get the vertical space reserved for it - CSS's
	// line-height, as a unitless ratio (e.g. 1.2 means 120% of the font's
	// own single-line height). A font's own metrics don't reliably encode
	// comfortable reading spacing (many real fonts report a zero or
	// near-zero line gap), so this is what actually separates wrapped
	// lines within a paragraph - distinct from Margins, which only
	// separates one block from the next. 1.0 means no extra space beyond
	// the font's bare ascent/descent.
	LineHeight(node *ASTNode) float64
}

StyleSheet resolves an ASTNode's semantic role to concrete appearance - margins, text styling, color, and a few dimensional constants. Every method takes the node, uniformly, even where nothing in whynot itself varies a value by node today (there's only one table's worth of column gap, for instance) - an implementation is free to ignore it, but the shape leaves room for one that doesn't want to. See DefaultStyleSheet for whynot's built-in appearance.

type SyntaxColors added in v0.3.0

type SyntaxColors struct {
	Keyword color.Color
	// Type is shared by a builtin primitive type and a declared
	// custom type/class name - see TokenType's own doc comment.
	Type color.Color
	// Function is a function/method name - see TokenFunction's own doc
	// comment.
	Function color.Color
	String   color.Color
	Number   color.Color
	Comment  color.Color
}

SyntaxColors is the palette a Highlighter's classified tokens draw from (see TagCodeKeyword..TagCodeFunction) - grouped like BlockquoteGeometry/ TableGeometry/ScrollbarColors, since picking a new palette usually means reconsidering the whole set together, not one color at a time.

type TableBlock

type TableBlock struct {
	WithoutMargins
	// contains filtered or unexported fields
}

TableBlock is a GFM table. header and each row in rows hold one tableCell per column.

func (*TableBlock) GetBlockLayout

func (b *TableBlock) GetBlockLayout(ctx RenderingContext, width int) BlockLayout

func (*TableBlock) Node

func (b *TableBlock) Node() *ASTNode

type TableBox

type TableBox struct {
	// contains filtered or unexported fields
}

TableBox draws a GFM table: a frame around the whole thing, a rule under the header row, a rule between each pair of columns, and each cell positioned at its resolved (column, row) offset. columnOffsets/ rowOffsets have one more entry than there are columns/rows - the last entry is the table's own right/bottom edge, so Bounds() doesn't need separate width/height fields, and the header rule's position is just rowOffsets[1] (see TableBlock.GetBlockLayout for why that boundary is exactly where the rule belongs). Each column rule is centered in the columnGap between adjacent columns' content, at columnOffsets[c] - columnGap/2.

func (*TableBox) Bounds

func (b *TableBox) Bounds() image.Rectangle

func (*TableBox) HitTest

func (b *TableBox) HitTest(p image.Point) (Hit, image.Point)

HitTest finds which cell (row, col) p falls in via columnOffsets/ rowOffsets, and falls back to the table's own source whenever there isn't one to recurse into (the frame, a gap between cells/rows) or the cell itself declines (its content is narrower/shorter than the cell's allotted space).

func (*TableBox) PendingImages

func (b *TableBox) PendingImages() []string

func (*TableBox) Source

func (b *TableBox) Source() Source

type TableGeometry

type TableGeometry struct {
	FrameThickness      float64
	ColumnGap           float64
	RowGap              float64
	HeaderGap           float64
	ColumnRuleThickness float64
}

TableGeometry is the dimensional constants a table's layout needs - grouped since TableBlock.GetBlockLayout always reads all five together.

type TaskCheckbox added in v0.3.0

type TaskCheckbox struct {
	// contains filtered or unexported fields
}

TaskCheckbox is a GFM task list item's checkbox marker (- [ ]/- [x]). Resolved at layout time, not compile time: whether to draw the real ballot-box glyph or a procedurally-drawn box (see CheckboxBox) depends on whether the active FaceSelector/StyleSheet's resolved font actually has that glyph, which can change across a re-layout (zoom, theme, a caller registering a different font) even though the semantic tree itself never does - most fonts, including the bundled Go fonts, don't have it.

func (*TaskCheckbox) GetInlineLayout added in v0.3.0

func (c *TaskCheckbox) GetInlineLayout(ctx RenderingContext, width int) InlineLayout

func (*TaskCheckbox) Node added in v0.3.0

func (c *TaskCheckbox) Node() *ASTNode

type TextBlock

type TextBlock struct {
	WithoutMargins
	// contains filtered or unexported fields
}

func (*TextBlock) GetBlockLayout

func (b *TextBlock) GetBlockLayout(ctx RenderingContext, width int) BlockLayout

func (*TextBlock) Node

func (b *TextBlock) Node() *ASTNode

type TextBox

type TextBox struct {
	Text  string
	Face  font.Face
	Color color.Color

	// StrikeThickness is the strikethrough line's thickness in pixels; 0
	// means no strikethrough.
	StrikeThickness int

	// LineHeight is the multiplier applied to Face's natural Ascent+Descent
	// to get this box's vertical extent (StyleSheet.LineHeight) - the
	// font's own metrics don't reliably encode comfortable line spacing
	// (see BoundsAndAdvance), so this is what actually separates
	// consecutive wrapped lines within a paragraph. The zero value (an
	// unset TextBox, e.g. built directly by a test rather than through
	// InlineText.GetInlineLayout) falls back to the pre-LineHeight
	// behavior - bare Ascent+Descent, no extra space.
	LineHeight float64
	// contains filtered or unexported fields
}

func (*TextBox) Bounds

func (b *TextBox) Bounds() image.Rectangle

func (*TextBox) BoundsAndAdvance

func (b *TextBox) BoundsAndAdvance() (image.Rectangle, int)

Text/Face never change after construction, and a TextBox is always rebuilt from scratch (never mutated) whenever the source Block tree is re-laid out, so this measurement is valid for the entire lifetime of the instance: compute it once, on first use.

func (*TextBox) DrawInline

func (b *TextBox) DrawInline(dst Canvas, x, y int, now time.Duration) int

func (*TextBox) Glued added in v0.3.1

func (b *TextBox) Glued() bool

func (*TextBox) HitTest

func (b *TextBox) HitTest(p image.Point, x, y int) (Hit, image.Point, int)

func (*TextBox) PendingImages

func (b *TextBox) PendingImages() []string

func (*TextBox) Source

func (b *TextBox) Source() Source

func (*TextBox) SpaceWidth

func (b *TextBox) SpaceWidth() int

type TextStyle

type TextStyle struct {
	Size   float64
	Style  font.Style
	Weight font.Weight
	Family FontFamily
}

type TextStyleField

type TextStyleField uint8

TextStyleField identifies one field of TextStyle, so a PartialTextStyle can say which fields it actually sets - needed because TextStyle's own zero values (font.WeightNormal, font.StyleNormal, Proportional) are real, meaningful values, not "unset" sentinels.

const (
	FieldSize TextStyleField = 1 << iota
	FieldStyle
	FieldWeight
	FieldFamily
)

type ThematicBreakBlock

type ThematicBreakBlock struct {
	WithoutMargins
	// contains filtered or unexported fields
}

ThematicBreakBlock is a horizontal rule (`---`). Unlike the other Block types it has no inline content to lay out - just a color; its vertical spacing comes from whatever MarginBlock wraps it.

func (*ThematicBreakBlock) GetBlockLayout

func (b *ThematicBreakBlock) GetBlockLayout(ctx RenderingContext, width int) BlockLayout

func (*ThematicBreakBlock) Node

func (b *ThematicBreakBlock) Node() *ASTNode

type TokenClass added in v0.3.0

type TokenClass int

TokenClass is a small, whynot-level classification of a syntax token - deliberately much coarser than a real highlighting library's own token taxonomy (chroma alone has ~50 TokenTypes): StyleSheet only needs enough categories to assign a handful of distinct colors, not to reproduce a library's full type system.

const (
	TokenPlain TokenClass = iota // no special color - inherits the block's own CodeBlockColor
	TokenKeyword
	// TokenType is a type name - a builtin primitive type and a declared
	// custom type/class name share this one class, so e.g. int and a
	// user-defined struct read as the same kind of thing. A Highlighter
	// isn't expected to recognize every usage of a type, only what it
	// can tell from syntax (typically its declaration).
	TokenType
	// TokenFunction is a function/method name - same expectation as
	// TokenType: recognize what's clear from syntax (a declaration, and
	// often a call), not necessarily every usage.
	TokenFunction
	TokenString
	TokenNumber
	TokenComment
)

type View

type View struct {
	// contains filtered or unexported fields
}

View renders a parsed Markdown document onto a Canvas. It owns the layout cache (rebuilt only when width or scale change, not every frame), viewport culling, and scroll-position anchoring across resizes - the parts of rendering a reflowing document that are easy to get wrong and not specific to any one game, so a caller doesn't have to reimplement them.

A View doesn't read input itself: call Scroll with deltas from whatever input source is appropriate for the embedding game, and call Layout whenever the available width or the display scale changes (typically from the embedding game's own layout/resize callback).

func NewView

func NewView(source []byte, faceSelector FaceSelector, opts ...ViewOption) *View

NewView parses source and returns a View ready to render it once Layout has been called at least once to establish a width.

func (*View) DocumentBounds

func (v *View) DocumentBounds() image.Rectangle

DocumentBounds returns the document's estimated extent, origin at (0, 0): width is the last Layout width; height is the current best estimate of the total (see refreshSlotHeights), exact once every slot has been resolved. A caller scales the ratio between this and VisibleViewBounds to build its own scrollbar.

func (*View) Draw

func (v *View) Draw(dst Canvas, x, y int)

Draw renders the document onto dst with its top-left corner at (x, y), at the current scroll position. Content above the current cursor, and content outside dst's bounds, is never resolved or drawn - Draw's cost tracks what's visible, not the document's total size or how far into it the scroll position is.

Draw fills dst's whole bounds with the StyleSheet's background color first - a caller doesn't need its own clear/fill step (or to know the StyleSheet's background color itself) before calling Draw. The ViewMargins' top/bottom is already baked into the scroll position (see rebuild); only Left needs applying here.

func (*View) HitTest

func (v *View) HitTest(x, y int) (hit Hit, offset image.Point)

HitTest identifies what's at document position (x, y) - the same coordinate space Draw's (x, y) places content's origin into. hit is nil if (x, y) doesn't land on any content - past the end of the document, or in a margin/gap. Otherwise offset is where hit.Bounds() should be placed to get its absolute position in that same space, e.g. for drawing an outline around it.

y is resolved via normalizeCursor, the same cursor-relative walk Scroll/Layout use, so a click far from the current scroll position doesn't force-build every slot in between.

Known imprecision: normalizeCursor clamps a position above the very start of the document to the same value as the start itself, so clicking well above the rendered content resolves as if it landed on the first slot rather than missing. Not worth fixing since callers only ever pass points within their own rendered viewport.

func (*View) Hover

func (v *View) Hover(x, y int) (destination string, ok bool)

Hover updates the currently-highlighted link, given the mouse position in the same coordinate space HitTest/Draw use - call every frame from the embedding game's own input handling. Highlighting only ever changes color, never layout, so at most two slots (the one left, the one entered) are surgically invalidated rather than triggering a full rebuild.

Hover also reports the link under (x, y), same as LinkAt, so a caller handling a click at the same position doesn't need a second HitTest.

func (*View) Layout

func (v *View) Layout(width, height int, scale float64, now time.Duration)

Layout sets the pixel width and display scale to render at (DPI = scale * 72, matching main.go's convention), and now - elapsed time since rendering started, the embedder's own reference point (see RenderingContext.Time) - which it stores unconditionally, even when nothing else changed, since an animated image needs fresh time every call to actually animate. Cheap to call every frame otherwise: the layout tree only rebuilds when width or scale actually change.

height is the viewport's own height - not used for wrapping (only width affects that), only so pre-layout/prefetch below know where the visible area ends. Not stored - used transiently within this call.

func (*View) LinkAt

func (v *View) LinkAt(x, y int) (destination string, ok bool)

LinkAt reports the destination URL of the link at document position (x, y) - the same coordinate space HitTest/Hover use. ok is false if (x, y) doesn't land on a link. Unlike Hover, this never changes the highlight - use it to query a position other than the current hover (e.g. from a test, or a separate input source).

func (*View) RestoreScrollPosition

func (v *View) RestoreScrollPosition(p ScrollPosition)

RestoreScrollPosition restores a position captured earlier by ScrollPosition - only meaningful on the same View it was taken from.

func (*View) ScaledViewMargins added in v0.2.0

func (v *View) ScaledViewMargins() Margins

ScaledViewMargins returns the View's current effective margin between its viewport edge and its content, in real screen pixels (already multiplied by the zoom/DPI scale most recently passed to Layout) - the same value Draw and HitTest use internally to place content, exposed so an embedder positioning something else relative to the View (e.g. cmd/whynot's own scrollbar, drawn beside it rather than by it) can stay in exact agreement with it.

func (*View) Scroll

func (v *View) Scroll(dy float64)

Scroll adjusts the vertical scroll position by dy pixels: negative dy moves the cursor forward through the document, revealing later content ("scrolling down"); positive moves back toward the start. This matches ebiten.Wheel()'s dy passed straight through, so callers don't need to negate it.

func (*View) ScrollPosition

func (v *View) ScrollPosition() ScrollPosition

ScrollPosition captures the View's current scroll position.

func (*View) ScrollToAnchor

func (v *View) ScrollToAnchor(id string) bool

ScrollToAnchor scrolls to put the heading with the given anchor id (see ASTNode.ID) at the top of the viewport, e.g. after following a link with a URL fragment. ok is false, and the scroll position unchanged, if no heading has that id or nothing has been laid out yet (see Layout).

Only top-level headings are found - a heading nested inside a blockquote or list won't be. Cheap regardless of document size: a slot's own Node() is known without resolving its content into a box (see StackBox.boxAt), so this never lays out anything beyond what's already built.

func (*View) ScrollToRatio

func (v *View) ScrollToRatio(ratio float64)

ScrollToRatio sets the scroll position to ratio (clamped to [0, 1]) through the document's current best-estimated height - e.g. for a caller implementing scrollbar-thumb dragging, where DocumentBounds/ VisibleViewBounds already give the inverse ratio back. Recomputes from the live estimate every call rather than a target captured once, so a jump into not-yet-resolved territory only ever corrects toward whatever ratio the caller asks for next, never drifts from it.

func (*View) SetStyleSheet

func (v *View) SetStyleSheet(s StyleSheet)

SetStyleSheet swaps the View's StyleSheet and takes effect immediately - GetBlockLayout/GetInlineLayout resolve and bake in concrete style values (colors, margins collapsed to gaps, ...) when the layout tree is built, so without an explicit rebuild here the change wouldn't be visible until whatever next happened to resize the view. Rebuilding also re-anchors the scroll position by ratio through the current slot, the same way a resize does - a new StyleSheet can change content heights (different margins, font sizes, ...) just as reflowing at a new width can.

func (*View) Title

func (v *View) Title() (string, bool)

Title returns the document's own title - the text of its first heading, at any level - or ok=false if it has none. A caller (e.g. for a window/tab title) decides its own fallback; a document with no heading at all is a normal, unremarkable case, not an error.

Only a top-level heading is found, the same limitation ScrollToAnchor has and for the same reason: one nested inside a blockquote or list isn't reachable this way.

func (*View) VisibleViewBounds

func (v *View) VisibleViewBounds(viewportSize image.Point) image.Rectangle

VisibleViewBounds returns the sub-rectangle of DocumentBounds currently visible for a viewport of viewportSize (the same size passed to Draw's dst). The bottom edge is exactly top+viewportSize.Y, clamped to the document's total - deliberately not "wherever the last slot DrawFrom would draw happens to end": a slot's own bottom can overshoot the viewport by a lot (a large image or table), and which slot ends up last shifts discretely while scrolling, so pinning the result to it made the thumb's size visibly jump - a quantization artifact independent of estimation accuracy. The forward walk below still force-resolves the same slots DrawFrom would draw this frame, as a side effect: no laziness benefit in skipping it, since Draw resolves them anyway, and doing so keeps the document total as accurate as possible near the viewport.

type ViewOption

type ViewOption func(*View)

ViewOption customizes a View at construction, via NewView's opts parameter.

func WithHighlighter added in v0.3.0

func WithHighlighter(h Highlighter) ViewOption

WithHighlighter sets the Highlighter used to color code blocks token-by-token - the NewView-level equivalent of Parse's own WithSyntaxHighlighter (named differently since it's a different option type, ViewOption vs ParseOption; Go has no function overloading). Unset, code blocks render in one flat color, same as before this option existed.

func WithImageSource

func WithImageSource(s ImageSource) ViewOption

WithImageSource overrides the ImageSource NewView otherwise defaults to (FileImageSource) - e.g. for an embedder that wants images resolved relative to a document's own location, or fetched over http(s), the way cmd/whynot does. Wrapped in a cache automatically (see ImageCache), so resolving/fetching a given image only happens once for the life of the resulting View, however many times it's asked for (every layout rebuild - resize, zoom, theme change, even hovering a different link - asks again).

func WithStyleSheet

func WithStyleSheet(s StyleSheet) ViewOption

WithStyleSheet overrides the StyleSheet NewView otherwise defaults to (NewDarkStyleSheet) - e.g. NewView(source, faceSelector, WithStyleSheet(NewLightStyleSheet())).

type WithoutMargins

type WithoutMargins struct{}

WithoutMargins satisfies Block's Margins() with a zero value, for content that never gets margins of its own (a list item's head, a table cell's content).

func (WithoutMargins) Margins

Directories

Path Synopsis
backends
giobackend module
Package chromahighlight implements whynot.Highlighter on top of github.com/alecthomas/chroma/v2.
Package chromahighlight implements whynot.Highlighter on top of github.com/alecthomas/chroma/v2.
cmd
whynot command
Package ebitenrenderer implements whynot.Canvas on top of ebiten.
Package ebitenrenderer implements whynot.Canvas on top of ebiten.
examples
chromahighlight command
Command chromahighlight is a runnable example of whynot.WithHighlighter backed by chromahighlight.Highlighter (github.com/alecthomas/chroma/v2).
Command chromahighlight is a runnable example of whynot.WithHighlighter backed by chromahighlight.Highlighter (github.com/alecthomas/chroma/v2).
customfont command
Command customfont is a runnable example of whynot.CustomFontFaceSelector.
Command customfont is a runnable example of whynot.CustomFontFaceSelector.
panel command
Command panel is a runnable example of ebitenrenderer.Panel embedded in a larger game window: it fills the whole window with a solid color standing in for "other game content," then draws a Panel into an inset rectangle, proving Panel.Draw's SubImage clipping holds on all four edges - something cmd/whynot never has to demonstrate, since its View occupies basically the whole window.
Command panel is a runnable example of ebitenrenderer.Panel embedded in a larger game window: it fills the whole window with a solid color standing in for "other game content," then draws a Panel into an inset rectangle, proving Panel.Draw's SubImage clipping holds on all four edges - something cmd/whynot never has to demonstrate, since its View occupies basically the whole window.
systemfont command
Command systemfont is a runnable example of systemfont.SystemFontFaceSelector.
Command systemfont is a runnable example of systemfont.SystemFontFaceSelector.
view command
Command view is a runnable example of whynot.View wired up by hand - full control over input handling, at the cost of doing it yourself.
Command view is a runnable example of whynot.View wired up by hand - full control over input handling, at the cost of doing it yourself.
wasm command
Command wasm is a runnable example of whynot compiled to WebAssembly and run in a browser, via Ebitengine's own js/wasm backend - see README.md in this directory for how to build and serve it.
Command wasm is a runnable example of whynot compiled to WebAssembly and run in a browser, via Ebitengine's own js/wasm backend - see README.md in this directory for how to build and serve it.
Package systemfont resolves fonts by name from whatever's installed on the host machine (e.g.
Package systemfont resolves fonts by name from whatever's installed on the host machine (e.g.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL