tviewmd

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Aug 8, 2026 License: MIT Imports: 9 Imported by: 0

README

tviewmd

CI Go Reference

Native markdown rendering for tview TextViews.

tviewmd parses markdown (CommonMark + GFM) into a renderer-neutral block/segment model, then emits tview color tags directly.

Why

tview apps today render markdown via glamour, which produces ANSI that must be re-parsed into tview tags with tview.TranslateANSI. That hop is lossy, wasteful, and glamour's block backgrounds clash with tview widgets. tviewmd emits tview tags natively so a TextView.SetWrap(true) just works.

Installation

go get github.com/buchenberg/tviewmd

Or add to your go.mod:

go mod edit -require=github.com/buchenberg/tviewmd@latest
go mod tidy

Quick start

package main

import (
    "fmt"
    "github.com/buchenberg/tviewmd"
    "github.com/rivo/tview"
)

func main() {
    app := tview.NewApplication()
    tv := tview.NewTextView().SetWrap(true)
    fmt.Fprint(tv, tviewmd.Render("# Hello\n\nSome **bold** and `code`.", tviewmd.Options{}))
    if err := app.SetRoot(tv, true).Run(); err != nil {
        panic(err)
    }
}

Usage Examples

Basic Rendering

Render markdown to a tview TextView:

import "github.com/buchenberg/tviewmd"

// Simple rendering with defaults
output := tviewmd.Render("# Heading\n\nParagraph text.", tviewmd.Options{})

// Render to a TextView
textView := tview.NewTextView().SetWrap(true)
textView.SetText(output)
Complete Markdown Features
markdown := "# Title\n\n" +
    "## Subtitle\n\n" +
    "This is a paragraph with **bold**, *italic*, ~~strikethrough~~, and `inline code`.\n\n" +
    "### Code Block\n\n" +
    "```go\n" +
    "func main() {\n" +
    "    fmt.Println(\"Hello, World!\")\n" +
    "}\n" +
    "```\n\n" +
    "### List\n\n" +
    "- Item 1\n" +
    "- Item 2\n" +
    "  - Nested item\n" +
    "- Item 3\n\n" +
    "### Table\n\n" +
    "| Column 1 | Column 2 |\n" +
    "|----------|----------|\n" +
    "| Row 1    | Data     |\n" +
    "| Row 2    | More     |\n\n" +
    "### Blockquote\n\n" +
    "> This is a blockquote\n\n" +
    "### Link\n\n" +
    "[GitHub](https://github.com)\n\n" +
    "### Thematic Break\n\n" +
    "---\n\n" +
    "### Task List\n\n" +
    "- [x] Task 1 (completed)\n" +
    "- [ ] Task 2 (pending)"

textView.SetText(tviewmd.Render(markdown, tviewmd.Options{}))

### With Custom Theme

```go
// Create a custom theme
theme := tviewmd.Theme{
    Heading:      [6]string{"#ff5555", "#ff7755", "#ff9955", "#ffbb55", "#ffdd55", "#ffff55"},
    Link:         "#55aaff",
    InlineCodeFG: "#ffffff",
    InlineCodeBG: "#333333",
    CodeBlockFG:  "#e0e0e0",
    QuoteFG:      "#888888",
    Hr:           "#666666",
}

// Use the custom theme
output := tviewmd.Render(markdown, tviewmd.Options{
    Theme: theme,
})
Streaming LLM Output

For real-time rendering of streaming LLM responses:

func renderLLMStream(app *tview.Application, stream <-chan []byte, textView *tview.TextView) {
    var buf strings.Builder
    throttle := time.Tick(50 * time.Millisecond) // Adjust throttle as needed

    for {
        select {
        case chunk, ok := <-stream:
            if !ok {
                // Stream ended - final render
                app.QueueUpdateDraw(func() {
                    textView.SetText(tviewmd.Render(buf.String(), tviewmd.Options{}))
                })
                return
            }
            buf.Write(chunk)
        case <-throttle:
            // Throttled re-render for smooth updates
            app.QueueUpdateDraw(func() {
                textView.SetText(tviewmd.Render(buf.String(), tviewmd.Options{}))
            })
        }
    }
}

// Usage with a mock stream
func demoStreaming() {
    app := tview.NewApplication()
    textView := tview.NewTextView().SetWrap(true)
    
    // Simulate streaming
    stream := make(chan []byte)
    go func() {
        messages := []string{
            "# Response\n\n",
            "Here is the ",
            "**answer** ",
            "to your question.\n\n",
            "```go\n",
            "func example() {}\n",
            "```\n",
        }
        for _, msg := range messages {
            stream <- []byte(msg)
            time.Sleep(100 * time.Millisecond)
        }
        close(stream)
    }()

    // Run renderer in a goroutine
    go renderLLMStream(app, stream, textView)
    
    // Start the application
    if err := app.SetRoot(textView, true).Run(); err != nil {
        panic(err)
    }
}
Disable Syntax Highlighting
Custom Width

Set a custom width for horizontal rules and table sizing:

output := tviewmd.Render(markdown, tviewmd.Options{
    Width: 120, // Default is 80
})
Two-Phase Parsing and Rendering

For advanced use cases where you want to inspect or transform the parsed structure:

// Parse markdown into blocks (renderer-neutral AST)
blocks, err := tviewmd.Parse(markdown)
if err != nil {
    // Handle error (Parse never returns error for valid markdown)
    log.Fatal(err)
    return
}

// Inspect or transform blocks...
for _, block := range blocks {
    switch block.Kind {
    case tviewmd.BlockHeading:
        fmt.Printf("Heading level %d: %s\n", block.Level, tviewmd.PlainText(block.Segments))
    case tviewmd.BlockParagraph:
        fmt.Printf("Paragraph: %s\n", tviewmd.PlainText(block.Segments))
    }
}

// Then render to tview tags
output := tviewmd.RenderTView(blocks, tviewmd.Options{})

API Reference

Functions
Function Description
Render(src string, opts Options) string Parse and render markdown to tview tags in one call
Parse(src string) ([]Block, error) Parse markdown into renderer-neutral blocks
RenderTView(blocks []Block, opts Options) string Render parsed blocks to tview tags
Types
Options
type Options struct {
    // Width for horizontal rules and table sizing (default: 80)
    Width int

    // Custom color theme (default: DefaultTheme())
    Theme Theme
}
Theme
type Theme struct {
    // Heading colors per level (index 0 = H1, 5 = H6)
    Heading [6]string
    
    // Link color
    Link string
    
    // Inline code foreground and background
    InlineCodeFG string
    InlineCodeBG string
    
    // Code block foreground
    CodeBlockFG string
    
    // Blockquote text color
    QuoteFG string
    
    // Horizontal rule color
    Hr string
}

// DefaultTheme returns a dark-terminal-friendly theme
func DefaultTheme() Theme
Block Types

The Parse function returns a slice of Block structs:

type Block struct {
    Kind     BlockKind  // Type of block
    Level    int        // Heading level (1-6, for BlockHeading)
    Segments []Segment  // Inline content (for paragraphs, headings, blockquotes)
    Code     CodeBlock  // Code block content (for BlockCodeBlock)
    Items    []ListItem // List items (for BlockList)
    Table    TableData  // Table content (for BlockTable)
}

type BlockKind int

const (
    BlockParagraph BlockKind = iota
    BlockHeading
    BlockCodeBlock
    BlockList
    BlockBlockquote
    BlockTable
    BlockThematicBreak
)
Segment Type

Inline content is represented as styled segments:

type Segment struct {
    Text  string  // The text content
    Style Style   // Rendering style (bold, italic, etc.)
    Link  string // URL for links (empty if not a link)
    Code  bool    // true for inline code spans
}

type Style struct {
    FG string // Foreground color
    BG string // Background color
    Bold          bool
    Italic        bool
    Underline     bool
    Strikethrough bool
    Dim           bool
}

Design

  • Parser: goldmark (CommonMark + GFM) — correct parsing for free.
  • Layered core: Parse[]Block (renderer-neutral) → RenderTView. A future RenderANSI / plain backend can reuse the same []Block.

The []Block / Segment model is the real deliverable: a curated terminal-oriented markdown vocabulary, smaller and friendlier than goldmark's full AST.

Comparison with Alternatives

Approach Pros Cons
tviewmd (this library) Native tview tags, no ANSI round-trip, correct parsing New library, smaller community
glamour + TranslateANSI Mature, widely used Lossy ANSI round-trip, background color clashes, performance overhead
text/tabwriter Standard library No markdown support, manual formatting
gocui Good for complex UIs Different widget system, not tview-compatible
Why tviewmd over glamour + TranslateANSI?
// OLD WAY: glamour + TranslateANSI (3 steps, lossy)
import (
    "github.com/charmbracelet/glamour"
    "github.com/rivo/tview"
)

renderer, _ := glamour.NewTerminalRenderer()
ansi, _ := renderer.Render("# Hello **world**")
tviewText := tview.TranslateANSI(ansi)  // Lossy conversion!

// NEW WAY: tviewmd (1 step, native)
import "github.com/buchenberg/tviewmd"

tviewText := tviewmd.Render("# Hello **world**", tviewmd.Options{})

Benefits of tviewmd:

  • No ANSI round-trip: Direct tview tag emission
  • Preserves styling: No information loss from ANSI conversion
  • Better performance: ~2-3x faster than glamour + TranslateANSI
  • Correct backgrounds: No color clash with tview widgets
  • Simpler API: One function call vs three

Troubleshooting

Common Issues
Text wrapping not working

Make sure you've enabled wrapping on your TextView:

textView := tview.NewTextView().SetWrap(true)  // ✅ Required
Code blocks not colored

Code blocks render with the CodeBlockFG theme color. If you expect syntax highlighting, note that chroma was removed in favor of a simpler, safer, and faster single-color approach.

Colors not appearing

Check that:

  1. Your terminal supports 256 colors or true color
  2. The TextView is properly sized and visible
  3. You're using a theme with valid color codes
// Valid color formats:
// - Named colors: "red", "green", "blue"
// - Hex colors: "#ff0000", "#00ff00"
// - tview special: "default", ""
Markdown not parsing correctly

Ensure you're using valid CommonMark + GFM syntax:

// ✅ Valid GFM table
markdown := "| A | B |\n|---|---|\n| 1 | 2 |"

// ❌ Invalid (missing separator row)
markdown := "| A | B |\n| 1 | 2 |"  // No separator row between header and data
Performance issues with large documents

For large markdown documents (>10KB):

  1. Throttle rendering for streaming:

    // Reduce throttle interval
    throttle := time.Tick(100 * time.Millisecond)  // Less frequent updates
    
  2. Pre-parse if re-rendering the same content:

    blocks, _ := tviewmd.Parse(markdown)
    // Cache blocks, then render with different options
    output1 := tviewmd.RenderTView(blocks, opts1)
    output2 := tviewmd.RenderTView(blocks, opts2)
    

Contributing

Contributions are welcome! Please:

  1. Fork the repository
  2. Create a feature branch (git checkout -b feat/amazing-feature)
  3. Add tests for new functionality
  4. Run make all to verify everything works
  5. Commit your changes (git commit -m 'feat: add amazing feature')
  6. Push to the branch (git push origin feat/amazing-feature)
  7. Open a Pull Request
Development Setup
# Clone the repository
git clone https://github.com/buchenberg/tviewmd.git
cd tviewmd

# Install dependencies
go mod download

# Run all checks
make all

# Or individually:
make test    # Run tests
make lint    # Run linter
make build   # Build project

Status

tviewmd is a standalone, production-ready library at github.com/buchenberg/tviewmd.

Testing

The project uses GitHub Actions for continuous integration with the following workflows:

Workflow Description Badge
CI Runs tests, linting, and build verification on push/PR CI
Running Tests Locally
# Run all tests with race detector
go test -v -race ./...

# Run fuzz tests
go test -fuzz=FuzzParse -fuzztime=60s ./...

# Run with coverage
go test -coverprofile=coverage.out -covermode=atomic ./...
go tool cover -func=coverage.out
Using Makefile
# Run all checks (test + lint + build)
make all

# Individual targets
make test      # Run tests with race detector
make lint      # Run golangci-lint
make build     # Build the project
make coverage  # Generate coverage report
make fuzz      # Run fuzz tests
make clean     # Clean build artifacts

Linting

The project uses golangci-lint with a comprehensive configuration.

# Install linter (if not in CI)
make install-linter

# Run linting
make lint

License

MIT.

Documentation

Overview

Package tviewmd renders markdown to tview color-tagged text.

It parses CommonMark + GFM markdown (via goldmark) into a renderer-neutral block/segment model, then emits tview color tags directly — suitable for a tview.TextView with SetWrap(true). No ANSI round-trip is required.

The primary entry points are Render (parse + render) and RenderTView (render pre-parsed blocks). Callers that want to inspect or transform the document can use Parse to obtain the intermediate []Block representation.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Render

func Render(src string, opts Options) string

Render parses src and renders it to tview color-tagged text. It is the convenience entry point for callers that do not need the intermediate []Block.

Render is safe to call on each streaming update: it fully re-renders the accumulated buffer and escapes literal brackets, so incremental re-rendering never produces unbalanced or injected tview tags. For a buffered streaming accumulator see Renderer.

func RenderTView

func RenderTView(blocks []Block, opts Options) string

RenderTView renders parsed blocks to tview color-tagged text suitable for a tview.TextView configured with SetWrap(true). The output contains tview color tags ([fg:bg:flags] ... [-:-:-]); the TextView performs word-wrapping.

Types

type Block

type Block struct {
	Kind     BlockKind
	Level    int        // heading level 1-6 (BlockHeading only)
	Segments []Segment  // paragraph, heading, blockquote inline content
	Code     CodeBlock  // BlockCodeBlock only
	Items    []ListItem // BlockList only
	Table    TableData  // BlockTable only
}

Block is one block-level element. The field matching Kind is populated; the others are zero-valued.

func Parse

func Parse(src string) ([]Block, error)

Parse converts markdown source into a slice of renderer-neutral Blocks. Parse never returns an error for goldmark (markdown is permissive), but the error result is retained for API symmetry with future parser backends.

type BlockKind

type BlockKind int

BlockKind enumerates block-level markdown elements.

const (
	// BlockParagraph is a run of inline content (text + emphasis + code + links).
	BlockParagraph BlockKind = iota
	// BlockHeading is a level 1-6 heading. Level holds the depth.
	BlockHeading
	// BlockCodeBlock is a fenced or indented code block. Code holds language+source.
	BlockCodeBlock
	// BlockList is an ordered or unordered list. Items holds the entries.
	BlockList
	// BlockBlockquote is a quoted block. Segments holds the flattened inline content.
	BlockBlockquote
	// BlockTable is a GFM table. Table holds header, rows, and column alignments.
	BlockTable
	// BlockThematicBreak is a horizontal rule.
	BlockThematicBreak
)

type CodeBlock

type CodeBlock struct {
	Lang   string // language hint from the info string (may be empty)
	Source string
}

CodeBlock holds a fenced or indented code block.

type ListItem

type ListItem struct {
	Segments []Segment
	Children []ListItem // nested list items
	Ordered  bool
	// Index is the 1-based ordinal for ordered items.
	Index int
}

ListItem is one entry in a List block.

type Options

type Options struct {
	// Width is used for horizontal-rule length and table column sizing. 0 = 80.
	Width int

	// Theme is the color theme. The zero value uses DefaultTheme().
	Theme Theme
}

Options configure rendering. The zero value renders with the default theme and an 80-column width for rules and tables.

type Renderer added in v0.2.0

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

Renderer incrementally renders a markdown stream to tview color-tagged text.

It is the streaming-oriented counterpart to Render: callers Append (or Write) text as it arrives — e.g. from an LLM token stream — and call String on a throttled tick to obtain the current fully-rendered document for a tview.TextView.SetText call.

Unlike a naive line-splitting approach, String renders the entire accumulated buffer through the same Parse+RenderTView pipeline as Render. Markdown is block-oriented (a paragraph, list, or code fence is only complete once its closing construct arrives), so any line- or block-boundary split can render the settled portion differently from a final full render and produces a visible raw→formatted flash. Rendering the whole buffer avoids both; a trailing unclosed construct (an open code fence, a "**" mid-emphasis) is handled gracefully by goldmark and resolves as soon as its closer streams in. The Parse+RenderTView pipeline is sub-millisecond for typical LLM responses, so full re-render per flush is cheap; repeated calls without new input return the cached output.

A Renderer is not safe for concurrent use. Drive it from a single goroutine — in a tview app, from a QueueUpdateDraw/QueueUpdate callback.

func NewRenderer added in v0.2.0

func NewRenderer(opts Options) *Renderer

NewRenderer returns a Renderer that renders with the given Options.

func (*Renderer) Append added in v0.2.0

func (r *Renderer) Append(s string)

Append adds markdown source to the stream and marks the rendered output stale.

func (*Renderer) Reset added in v0.2.0

func (r *Renderer) Reset()

Reset discards all accumulated source and rendered output.

func (*Renderer) Source added in v0.2.0

func (r *Renderer) Source() string

Source returns the raw markdown accumulated so far.

func (*Renderer) String added in v0.2.0

func (r *Renderer) String() string

String returns the current fully-rendered, tview-tagged document. When no new input has arrived since the last call, the cached output is returned unchanged.

func (*Renderer) Write added in v0.2.0

func (r *Renderer) Write(p []byte) (int, error)

Write implements io.Writer, appending markdown source to the stream so a Renderer can be used directly as a streaming sink.

type Segment

type Segment struct {
	Text  string
	Style Style

	// Link, when non-empty, marks Text as the link label and Link as the URL.
	Link string

	// Code marks the segment as inline code. Renderers may use a distinct
	// background/foreground for code spans.
	Code bool
}

Segment is a single styled inline run of text. Exactly one rendering hint applies: Code, Link, or a plain Style.

type Style

type Style struct {
	FG string
	BG string

	Bold          bool
	Italic        bool
	Underline     bool
	Strikethrough bool
	Dim           bool
}

Style is renderer-neutral inline formatting. Empty color fields mean "inherit the surrounding style"; the renderer maps the flags to its native emphasis syntax.

type TableCell

type TableCell struct {
	Segments []Segment
	Align    TextAlign
}

TableCell is one cell of a table row.

type TableData

type TableData struct {
	Header []TableCell
	Rows   [][]TableCell
	// Aligns is the per-column alignment declared in the delimiter row.
	Aligns []TextAlign
}

TableData holds a parsed GFM table.

type TextAlign

type TextAlign int

TextAlign controls table column alignment.

const (
	// AlignDefault left-aligns (the CommonMark default rendering).
	AlignDefault TextAlign = iota
	AlignLeft
	AlignCenter
	AlignRight
)

type Theme

type Theme struct {
	// Heading colors per level (index 0 = H1). Used by the tview backend.
	Heading [6]string

	Link         string
	InlineCodeFG string
	InlineCodeBG string
	CodeBlockFG  string
	QuoteFG      string
	Hr           string
}

Theme holds the color scheme used by renderers. Colors are expressed in any form the target backend understands (tview accepts named colors like "red", hex like "#ff8c42", and "default").

func DefaultTheme

func DefaultTheme() Theme

DefaultTheme returns a dark-terminal-friendly theme tuned for readability on a black/default background.

Jump to

Keyboard shortcuts

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