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

whynot

package module
v0.1.2 Latest Latest
Warning

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

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

README

Why Not?

A Markdown renderer for ebiten games, written in Go. Point it at a []byte of Markdown and it lays out and draws the document onto the screen - scrolling, resizing, and reflow handled for you.

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

Use the mouse wheel to scroll; resize the window to see it reflow.

Why does this exist?

Games sometimes need to show a chunk of formatted text - patch notes, an in-game journal, help screens, a credits scroll - without pulling in a full UI toolkit. Why Not renders straight onto an ebiten.Image, using goldmark to parse.

Status

Early and incomplete - see Features below for what's done and what isn't. The API may still change. Architecture, internal layout model, and the reasoning behind some of the trickier bits (lazy layout, scroll anchoring) are documented in ARCHITECTURE.md.

Using it as a library

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; a caller wires the two together:

package main

import (
	"image/color"
	"log"
	"os"
	"time"

	"github.com/hajimehoshi/ebiten/v2"

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

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) {
	screen.Fill(color.Black) // whynot never fills its own background
	g.view.Draw(g.renderer.NewCanvas(screen), 0, 0)
}

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

func main() {
	source, err := os.ReadFile("doc.md")
	if err != nil {
		log.Fatal(err)
	}
	g := &game{
		view:     whynot.NewView(source, whynot.NewGoFontFaceSelector(72)),
		renderer: ebitenrenderer.New(),
		start:    time.Now(),
	}
	if err := ebiten.RunGame(g); err != nil {
		log.Fatal(err)
	}
}

cmd/whynot is the fuller version of this, handling display scale 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 L/D keys do.

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:

  • -light switches to the light theme at startup; L/D toggle between light and dark while running.
  • 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.
  • 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) - 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), but appendString word-splits each sibling independently, so adjacent no-space siblings would render as separately-spaced words - needs word-adjacency tracking across sibling Inlines first

Block structures

  • Fenced and indented code blocks
  • 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 - today an image only starts loading once its containing slot is actually resolved (in practice, scrolled near), not when the document is first opened

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
  • View.DocumentBounds/VisibleViewBounds expose scroll-position geometry (in top-level-slot-count units, not pixels - a coarse but free proxy, refined without changing either signature if that ever matters) for a caller to build its own scrollbar, or feed to any other UI it wants to drive from scroll position
  • 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

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

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
  • Scrollbar - drawn by cmd/whynot itself (drawScrollbar, draw.go), not the library: View.DocumentBounds/VisibleViewBounds expose the geometry instead, so an embedder using its own UI framework (or wanting a native scrollbar widget) can build whatever it wants rather than being stuck with the library's own opinion
  • A real app icon instead of the generic terminal one when launched as a bundled macOS/Windows/Linux app

Known issues

See ARCHITECTURE.md.

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
)

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) 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 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 DefaultStyleSheet

type DefaultStyleSheet struct {
	ParagraphMargins   Margins
	ParagraphTextStyle PartialTextStyle

	HeadingMargins    [6]Margins
	HeadingTextStyles [6]PartialTextStyle

	ListMargins Margins

	ListItemMargins   Margins
	ListItemTextStyle PartialTextStyle

	CodeBlockMargins   Margins
	CodeBlockTextStyle PartialTextStyle
	CodeColor          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
	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
	// 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/CodeColor, 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) Margins

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

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(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
)

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 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) 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(RenderingContext) InlineLayout
}

type InlineImage

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

func (*InlineImage) GetInlineLayout

func (i *InlineImage) GetInlineLayout(ctx RenderingContext) 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.
  • Pending with bounds already known (the header-peek in ImageCache.fetchAndDecode beat the caller here): an ImageBox at the correct, final (scaled) 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
	// 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) InlineLayout

func (*InlineText) Node

func (t *InlineText) Node() *ASTNode

type LineBox

type LineBox struct {
	// 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) 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 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 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
}

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 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 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
	// 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) 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 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 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.

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) 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 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
cmd
whynot command
Package ebitenrenderer implements whynot.Canvas on top of ebiten.
Package ebitenrenderer implements whynot.Canvas on top of ebiten.

Jump to

Keyboard shortcuts

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