Ultraviolet

repository·main·Indexed 18 days ago

https://github.com/charmbracelet/ultraviolet

A set of primitives for building terminal user interfaces in Go. Ultraviolet provides cell-based rendering, cross-platform input handling, and a diffing renderer without requiring terminfo/termcap databases. It includes a layered architecture of abstractions including Terminal, TerminalScreen, Buffer, and a constraint-based layout solver using the Cassowary algorithm.

Tokens
23.3K
Snippets
99
Records
129
Agent score
62%

What's inside ultraviolet

  1. How Ultraviolet's layered primitives work together

    main

    Ultraviolet is built as a stack of abstractions to decouple application logic from terminal-specific details:

    • Terminal: The top-level manager for the application lifecycle (raw mode, input loop, start/stop). Use uv.DefaultTerminal() or uv.NewTerminal(console, opts).
    • TerminalScreen: The concrete implementation of the screen state. It manages the alternate buffer, cursor, mouse modes, and window titles. Access it via terminal.Screen().
    • Screen (Interface): A minimal interface (Bounds, CellAt, SetCell, WidthMethod) that allows you to write decoupled code. TerminalScreen, Buffer, Window, and ScreenBuffer all implement this.
    • Buffer / Window: Off-screen cell buffers. Buffer is a flat grid; Window adds hierarchical relationships and shared-buffer views. Both implement Screen and Drawable.
    • screen package: Drawing helpers that operate on any Screen. Includes Context for styled text (Print, DrawString) and utilities like Clear and Fill.
    • layout package: A constraint-based layout solver using the Cassowary algorithm. It allows partitioning space using constraints like Len, Min, Max, Percent, Ratio, and Fill.
  2. Run renderer fuzz targets

    main

    Once the environment is set up, you can run the standard test suite or perform targeted fuzzing to find new failing inputs. Use the -fuzz flag to run a specific target for a set duration.

    # Run the fuzz targets over their seed corpus, plus the regular tests
    go test ./...
    
    # Search for new failing inputs for a specific target (e.g., FuzzRenderer)
    go test -run='XXX' -fuzz='FuzzRenderer$' -fuzztime=5m
  3. Start and Stop the Terminal

    main

    To begin the application lifecycle, call t.Start(). This performs several critical actions:

    • Puts the console into raw mode (disabling echoing and line buffering).
    • Initializes the input event loop.
    • Prepares the screen for rendering.

    Always use defer t.Stop() to ensure the terminal is cleaned up properly. Stop() restores the console, exits the alternate screen, and is safe to call multiple times.

    if err := t.Start(); err != nil {
        log.Fatalf("failed to start terminal: %v", err)
    }
    defer t.Stop()
    if err := t.Start(); err != nil {
        log.Fatalf("failed to start terminal: %v", err)
    }
    defer t.Stop()
  4. Set up and run renderer conformance tests

    main

    The conformance module contains fuzz tests that validate the terminal renderer by comparing its output against a real terminal emulator (using libghostty). These tests ensure that the renderer's internal model of the screen stays in sync with what is actually displayed.

    Note: These tests are in a separate module to avoid forcing cgo and specific Go version requirements on users of the main ultraviolet library. They must be run from within the internal/conformance directory, as they are excluded from the root module's test suite.

    # 1. Build libghostty-vt using zig and CMake
    git clone https://github.com/mitchellh/go-libghostty
    cd go-libghostty && make build
    
    # 2. Set the PKG_CONFIG_PATH
    export PKG_CONFIG_PATH="$PWD/build/_deps/ghostty-src/zig-out/share/pkgconfig"
    
    # 3. Run tests from the conformance directory
    go test ./...
  5. Initialize a Terminal in Ultraviolet

    main

    A Terminal is the central manager for the console, the input event loop, and the screen state.

    You can initialize a terminal using the default configuration:

    t := uv.DefaultTerminal()

    Alternatively, you can create a custom terminal by providing a Console (which wraps os.Stdin, os.Stdout, and environment variables) and an Options struct:

    con := uv.NewConsole(os.Stdin, os.Stdout, os.Environ())
    t := uv.NewTerminal(con, &uv.Options{
        Logger: myLogger, // optional, for debugging I/O
    })
    t := uv.DefaultTerminal()
  6. Quick Start with Ultraviolet

    main

    This example demonstrates how to initialize a terminal, manage a screen, handle window resize events, and process keyboard input to exit the application.

    Key steps:

    1. Create a terminal using uv.DefaultTerminal().
    2. Access the screen via t.Screen().
    3. Enter the alternate screen buffer with scr.EnterAltScreen().
    4. Start the terminal loop with t.Start().
    5. Use screen.NewContext(scr) to get drawing helpers.
    6. Listen to t.Events() for uv.WindowSizeEvent and uv.KeyPressEvent.
    7. Call scr.Render() and scr.Flush() to apply changes to the terminal.
    package main
    
    import (
    	"log"
    
    	uv "github.com/charmbracelet/ultraviolet"
    	"github.com/charmbracelet/ultraviolet/screen"
    )
    
    func main() {
    	t := uv.DefaultTerminal()
    	scr := t.Screen()
    
    	scr.EnterAltScreen()
    
    	if err := t.Start(); err != nil {
    		log.Fatalf("failed to start terminal: %v", err)
    	}
    	defer t.Stop()
    
    	ctx := screen.NewContext(scr)
    	text := "Hello, World!"
    	textWidth := scr.StringWidth(text)
    
    	display := func() {
    		screen.Clear(scr)
    		bounds := scr.Bounds()
    		x := (bounds.Dx() - textWidth) / 2
    		y := bounds.Dy() / 2
    		ctx.DrawString(text, x, y)
    		scr.Render()
    		scr.Flush()
    	}
    
    	for ev := range t.Events() {
    		switch ev := ev.(type) {
    		case uv.WindowSizeEvent:
    			scr.Resize(ev.Width, ev.Height)
    			display()
    		case uv.KeyPressEvent:
    			if ev.MatchString("q", "ctrl+c") {
    				return
    			}
    		}
    	}
    }
  7. Manage rectangular areas with Window

    main

    A Window represents a rectangular area on the screen. It can function as a root window (no parent) or a sub-window (with a parent).

    Windows handle text data through a Buffer. A window can either own its own unique buffer or act as a 'view' that shares the buffer of its parent window.

    import "github.com/charmbracelet/ultraviolet"
    
    // Create a root window
    root := uv.NewWindow(80, 24, nil)
    
    // Create a sub-window that owns its own buffer
    subWindow := root.NewWindow(10, 10, 20, 5)
    
    // Create a view that shares the root's buffer
    view := root.NewView(5, 5, 10, 10)
  8. Optimize rendering with RenderBuffer

    main

    A RenderBuffer extends a standard Buffer by tracking which parts of the screen have changed (been "touched"). This allows for efficient rendering by only updating the modified portions of the screen rather than the entire buffer.

    When you use RenderBuffer methods like SetCell, InsertLine, or FillArea, the buffer automatically marks the affected lines as "touched" using TouchLine. You can then query TouchedLines() to determine how much work is needed for the next render cycle.

    // Create a render buffer for efficient updates
    rb := uv.NewRenderBuffer(80, 24)
    
    // Setting a cell automatically marks the line as touched
    rb.SetCell(5, 5, &someCell)
    
    // Check how many lines need re-rendering
    if rb.TouchedLines() > 0 {
        // Perform optimized render logic
    }
  9. Use StyledString for terminal rendering

    main

    A StyledString is a string that can be decomposed into styled lines and cells. It is used to disassemble strings containing ANSI escape codes (including SGR and Hyperlink codes) into a series of cells that can be used within a [Buffer] or Screen.

    Key features include:

    • Wrapping: Control whether the string wraps to the next line.
    • Truncation: If Wrap is false, you can specify a Tail string to be appended when the string is truncated to fit the available area.
    • Decomposition: Convert the string into a slice of Line objects or draw it directly to a Screen.
    // Create a new styled string
    ss := uv.NewStyledString("\x1b[31mHello\x1b[0m World")
    
    // Draw it to a screen at a specific area
    ss.Draw(myScreen, myRectangle)
  10. Initialize a TerminalScreen

    main

    Use NewTerminalScreen to create a new TerminalScreen instance. It requires an io.Writer (typically os.Stdout) and an Environ object to handle environment variables. The constructor automatically detects the terminal's color profile and configures renderer optimizations like tab stops and backspace support based on the terminal state.

    screen := uv.NewTerminalScreen(os.Stdout, env)
  11. Handle key events using the Key struct

    main

    The Key struct represents a key press or release event. It contains the character text, modifier keys, the specific key code, and metadata like whether the key is being repeated.

    There are two recommended patterns for handling key events:

    1. Using the string representation: Use ev.String() for a concise switch statement. This is useful for simple command matching (e.g., "enter", "a").
    2. Using the key type (more foolproof): Access the Key object directly via ev.Key(). This allows you to check key.Code for special keys (like KeyEnter) or key.Text for printable characters. This is safer because Key.Text is only populated for printable characters.

    Note: Key.Text is empty for special keys like KeyEnter or KeyTab, and for non-printable combinations involving modifiers.

    // Pattern 1: Switch on string representation
    switch ev := ev.(type) {
    case KeyPressEvent:
        switch ev.String() {
        case "enter":
            fmt.Println("you pressed enter!")
        case "a":
            fmt.Println("you pressed a!")
        }
    }
    
    // Pattern 2: Switch on key type (more foolproof)
    switch ev := ev.(type) {
    case KeyEvent:
        // catch both KeyPressEvent and KeyReleaseEvent
        switch key := ev.Key(); key.Code {
        case KeyEnter:
            fmt.Println("you pressed enter!")
        default:
            switch key.Text {
            case "a":
                fmt.Println("you pressed a!")
            }
        }
    }