overlay

package module
v0.0.0-...-738f3c5 Latest Latest
Warning

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

Go to latest
Published: May 1, 2026 License: MIT Imports: 8 Imported by: 0

README

bubble-overlay

Composable modals and overlay stacks for Bubble Tea: v1 (View() string + OverlayView / OverlayStack) and v2 (View() tea.View + overlayv2).

Simple Demo

Requirements

  • Go 1.25+ (this module includes charm.land/bubbletea/v2 alongside Bubble Tea v1.)

Installation

go get github.com/madicen/bubble-overlay

The v2 API lives at import path github.com/madicen/bubble-overlay/v2 (package overlayv2); you still add the root module once.

Which API should I use?

You are on… Use
Bubble Tea v1, View() string Package overlay: OverlayView, OverlayStack, OverlayConfig, FocusTrap.
Bubble Tea v2 (charm.land/bubbletea/v2), View() tea.View Package overlayv2: Stack, CompositeView, shared config from overlay.

Both paths use the same string compositing for the terminal (overlay.OverlayView, grapheme-aware, ANSI/hyperlink-safe). The v2 stack flattens each child tea.View, composites, then wraps with tea.NewView — see docs/ADR-v2-bridge.md.


Bubble Tea v1 — quick start

import overlay "github.com/madicen/bubble-overlay"

func (m model) View() string {
	main := m.renderMain()
	if !m.showModal {
		return main
	}
	modal := lipgloss.NewStyle().Width(40).Render("…")
	return overlay.OverlayView(main, modal, m.width, m.height, row, col)
}

OverlayStack adds nested modals, dimming, Center / RightDrawer / Fixed, Escape and optional click-outside, and FocusTrap so your base model skips keys/mouse while overlays are open. See examples/simple, examples/confirm, examples/stack.


Bubble Tea v2 — quick start

import (
	tea "charm.land/bubbletea/v2"
	bov "github.com/madicen/bubble-overlay"
	"github.com/madicen/bubble-overlay/v2"
)

func (m rootModel) View() tea.View {
	w, h := m.width, m.height
	if w == 0 || h == 0 {
		w, h = 80, 25
	}
	v := m.stack.CompositeView(m.mainView, w, h)
	v.AltScreen = true
	return v
}

Run go run examples/v2simple/main.go. Use overlayv2.FocusTrap like v1 FocusTrap for interactive routing.


Usage

bubble-overlay composites a string modal over a string main view without destroying the background. It uses display-cell width (grapheme-aware, aligned with lipgloss) and handles ANSI correctly: a full SGR reset (and hyperlink reset) is inserted immediately before the modal so the main line’s active pen does not bleed into the dialog, then after the modal the pen that belonged at the right edge of the hole is re-applied so long styled runs (e.g. full-width lipgloss bars) still look correct past the cut.

examples/colors shows two styled rows with the modal cutting through them; toggle with space.

Transparency and mask
  • OverlayViewWithTransparency: ASCII space (' ') in the modal is treated as transparent so the main view shows through at those cells.
  • OverlayViewWithMask: choose a mask rune (e.g. ); only those cells pass through to the main view — see examples/transparency.

Centering. OverlayViewInCenter(main, modal, viewW, viewH) centers the modal in an arbitrary viewport—full terminal, tab strip, or panel—not “full screen only”. Pass the same viewW / viewH you use when compositing that region. When the region’s bounds match the main string’s grid, use ModalCellSize(main) for viewW / viewH, or call OverlayViewInCenterInMain(main, modal) which does that for you.

Centering helpers measure the modal with ModalCellSize (same rules as OverlayView), not lipgloss.Size, so placement and hit-testing stay aligned.

Context menus: use OverlayViewAtPoint(main, modal, viewW, viewH, anchorTop, anchorLeft) (clamp + composite in one step), or ClampOverlayOriginAtPoint / ClampMenuOrigin + OverlayView. Test hits with CellInModal using post-clamp top/left and ModalCellSize(modal).

For “centered but nudged” (e.g. loading line above a label), use OverlayViewInCenterWithOffset (and transparency/mask variants): offsets apply after centering, then OverlayView clamps.

Helpers OverlayViewInCenter*, OverlayViewInCenterInMain, OverlayViewAtPoint*, and OverlayViewInCenterWithOffset* cover common layouts.

Cookbook

Full-screen vs inner panel. Use WindowSizeMsg width/height as viewW / viewH when the main view fills the terminal. When the overlay sits only over a panel whose string is exactly main, use OverlayViewInCenterInMain(main, modal) or ModalCellSize(main) with OverlayViewInCenter so the viewport matches the panel grid.

Menu under cursor (v1 mouse). Coordinates are zero-based. tea.MouseMsg uses X = column (left) and Y = row (top)—same order as OverlayView(..., top, left) arguments only if you pass anchorTop = msg.Y and anchorLeft = msg.X (row first, then column). Example:

out := overlay.OverlayViewAtPoint(base, menu, w, h, msg.Y, msg.X)
t, l := overlay.ClampMenuOrigin(menu, w, h, msg.Y, msg.X)
mw, mh := overlay.ModalCellSize(menu)
inside := overlay.CellInModal(msg.X, msg.Y, t, l, mw, mh)

Stack vs raw compositing. Prefer OverlayStack / Placement when you want dimming, Escape / click-outside, nested modals, and focus routing (FocusTrap). Use OverlayView (and helpers) when you only need a single hole punch or fully custom update routing.


Consumer integration (OverlayView hosts)

Single source of truth. Overflow clamping (when the modal is wider or taller than the viewport) is implemented once as ClampOverlayOrigin and used by OverlayView. Hosts that duplicate placement logic for hit-testing should call ClampOverlayOrigin or Placement.ClampedOrigin with the same modalW, modalH, viewW, and viewH they use for compositing—do not reimplement the algorithm.

Placement. Placement.Origin returns coordinates before that overflow clamp (it only pins negative top/left to zero). Placement.ClampedOrigin matches what OverlayView paints. Use ClampedOrigin (or Origin plus ClampOverlayOrigin) whenever coordinates must align with the compositor.

Hit-testing. If you forward tea.MouseMsg (or v2 mouse messages) and compare against a stored overlay rectangle, that rectangle must use post-clamp top/left; comparing against pre-clamp “desired” placement will be wrong when the modal overflows the viewport.

Coordinates. OverlayView top/left are zero-based row and column offsets from the top-left of the view string. Bubble Tea v1 tea.MouseMsg X and Y use the same zero-based cell indexing, so they align directly with ClampOverlayOrigin / CellInModal. For Bubble Tea v2, use the X / Y from the underlying mouse event the same way once your pipeline uses the same width/height as compositing.

Helpers. ModalCellSize and CellInModal are thin exports over the same helpers used by OverlayStack for modal bounds and inside/outside checks.

Behavioral note (sizing). Modal width/height follow strings.Split(modal, "\n") and max lipgloss.Width per line (matching OverlayView), not a trimmed trailing newline. If you change that measurement in the compositor, update internal/layout.ModalCellSize and release notes accordingly.

Checklist when changing the compositor

Before merging overlay geometry or merge behavior changes: exercise resize, modal larger than the terminal, mouse inside vs outside the modal, and zones vs relative coordinates if your app uses them. Call out breaking behavioral changes in release notes (this repo has no auto-generated changelog—document in your release).


Package reference

Symbol Package Role
OverlayView, OverlayViewWithTransparency, OverlayViewWithMask, DimSurface overlay Hole-punch compositing; dim multiline string.
ClampOverlayOrigin, ClampOverlayOriginAtPoint, ClampMenuOrigin, ModalCellSize, CellInModal overlay Shared geometry for compositor parity and hit-testing.
OverlayViewInCenter*, OverlayViewInCenterInMain, OverlayViewInCenterWithOffset*, OverlayViewAtPoint* overlay Common centered, offset, and anchored layouts.
OverlayConfig, Placement, Placement.ClampedOrigin overlay Per-frame dimming and anchor.
OverlayStack, OverlayOnCloser, FocusTrap, DevStackDepthFooter overlay v1 stack and helpers.
Stack, ViewAdapter, StringPipelineAdapter, ViewString overlayv2 v2 stack + R1 compositor.

Examples

Example Bubble Tea What it shows
examples/simple v1 OverlayStack, center + dim
examples/confirm v1 Yes/no + cmd + Pop
examples/stack v1 Nested overlays, OVERLAY_DEV=1 footer
examples/form v1 OverlayView + text input
examples/spinner v1 OverlayView + spinner
examples/colors v1 Styled lines through the modal cut
examples/transparency v1 Mask rune pass-through
examples/v2simple v2 overlayv2.Stack + CompositeView
go run examples/simple/main.go
go run examples/confirm/main.go
go run examples/form/main.go
go run examples/spinner/main.go
go run examples/colors/main.go
go run examples/transparency/main.go
go run examples/stack/main.go
OVERLAY_DEV=1 go run examples/stack/main.go
go run examples/v2simple/main.go

Confirm Form Spinner Colors Nested stack Transparency
Confirm Form Spinner Colors Stack Transparency

Recording GIFs (VHS)

Use make gifs from the repo root. Tapes Source "vhs/_env.tape", which clears CI / NO_COLOR and sets TERM=xterm-256color and COLORTERM=truecolor so lipgloss/termenv emit color (termenv otherwise assumes no TTY when CI is set).

Each tape runs go mod download inside Hide before go run ./examples/... so download lines do not appear in the GIF.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func CellInModal

func CellInModal(x, y, top, left, mw, mh int) bool

CellInModal reports whether terminal cell coordinates (x, y) fall inside the modal rectangle [left, left+mw) × [top, top+mh). Use coordinates after ClampOverlayOrigin / Placement.ClampedOrigin so hit-testing matches painted overlay geometry.

Bubble Tea v1 tea.MouseMsg uses zero-based X and Y for the terminal cell (column, row), matching top and left passed into OverlayView (also zero-based from the top-left of the view).

func ClampMenuOrigin

func ClampMenuOrigin(modal string, viewW, viewH, anchorTop, anchorLeft int) (int, int)

ClampMenuOrigin is an alias for ClampOverlayOriginAtPoint: same implementation, for call sites that anchor a menu or popover at a cursor cell (typically tea.MouseMsg.Y as top, tea.MouseMsg.X as left).

func ClampOverlayOrigin

func ClampOverlayOrigin(modalW, modalH, viewW, viewH, top, left int) (int, int)

ClampOverlayOrigin applies the same origin adjustment as OverlayView: if the modal rectangle would extend past the viewport edge, top and/or left are shifted so the rectangle fits; then negative coordinates are clamped to zero.

modalW and modalH must match how OverlayView measures that modal string (see ModalCellSize).

func ClampOverlayOriginAtPoint

func ClampOverlayOriginAtPoint(modal string, viewW, viewH, top, left int) (int, int)

ClampOverlayOriginAtPoint runs ModalCellSize(modal) then ClampOverlayOrigin. Use for anchors such as a context menu at (top, left) (e.g. Bubble Tea mouse Y/X as row/column) so hit-testing with CellInModal matches OverlayView(modal, …, top, left).

func DevStackDepthFooter

func DevStackDepthFooter(depth int, dev bool) string

func DimSurface

func DimSurface(s string, opacity float64) string

func ModalCellSize

func ModalCellSize(modal string) (w, h int)

ModalCellSize returns display-cell width (max per line) and line count for a modal string, using the same rules as OverlayView when measuring the modal for placement and clamping.

func OverlayView

func OverlayView(mainView, modalView string, viewWidth, viewHeight, top, left int) string

OverlayView composites modalView on top of mainView. Only the rectangle at (top, left) with the modal's size is replaced; all other cells show the main view. Returns a single string with viewHeight lines, each viewWidth cells wide (padding/truncation as needed).

Main and modal strings may contain ANSI (e.g. from lipgloss); overlay uses display-cell width (grapheme-aware, matching lipgloss) so alignment is correct. After the modal, graphics state that originated under the modal is re-applied so background colors and other SGR attributes still apply to the visible tail of each line. A full SGR reset (and hyperlink reset) is inserted immediately before the modal so the main line’s active pen does not bleed into the first cells of the modal.

func OverlayViewAtPoint

func OverlayViewAtPoint(mainView, modalView string, viewWidth, viewHeight, anchorTop, anchorLeft int) string

OverlayViewAtPoint composites modalView with its top-left anchored at (anchorTop, anchorLeft) after the same overflow clamp as OverlayView. Use Bubble Tea v1 mouse coordinates as anchorTop = msg.Y (row) and anchorLeft = msg.X (column).

func OverlayViewAtPointWithMask

func OverlayViewAtPointWithMask(mainView, modalView string, viewWidth, viewHeight, anchorTop, anchorLeft int, maskRune rune) string

OverlayViewAtPointWithMask is like OverlayViewAtPoint but uses OverlayViewWithMask.

func OverlayViewAtPointWithTransparency

func OverlayViewAtPointWithTransparency(mainView, modalView string, viewWidth, viewHeight, anchorTop, anchorLeft int) string

OverlayViewAtPointWithTransparency is like OverlayViewAtPoint but uses OverlayViewWithTransparency.

func OverlayViewInCenter

func OverlayViewInCenter(mainView, modalView string, viewWidth, viewHeight int) string

OverlayViewInCenter centers modalView in a viewport of viewWidth×viewHeight and composites with OverlayView. The viewport may be the full terminal, a tab/content region, or any rectangle you pass to OverlayView—this is the general “center in viewport” helper, not full-screen-only.

When the background is not the entire terminal, pass the same width and height you use for that region. If the region matches the main view’s cell bounds, use ModalCellSize(mainView) or OverlayViewInCenterInMain.

Centering uses ModalCellSize(modalView), matching OverlayView’s internal modal measurement (not lipgloss.Size).

func OverlayViewInCenterInMain

func OverlayViewInCenterInMain(mainView, modalView string) string

OverlayViewInCenterInMain derives the viewport size from ModalCellSize(mainView) and centers modalView over mainView. Use when the overlay applies to exactly the main string’s cell bounds (e.g. an inner panel or log region).

func OverlayViewInCenterWithMask

func OverlayViewInCenterWithMask(mainView, modalView string, viewWidth, viewHeight int, maskRune rune) string

OverlayViewInCenterWithMask is like OverlayViewInCenter but uses OverlayViewWithMask.

func OverlayViewInCenterWithOffset

func OverlayViewInCenterWithOffset(mainView, modalView string, viewWidth, viewHeight, deltaTop, deltaLeft int) string

OverlayViewInCenterWithOffset centers modalView, adds deltaTop and deltaLeft (e.g. nudge a loading banner upward), then composites. Overflow clamping matches OverlayView (applied inside OverlayView).

func OverlayViewInCenterWithOffsetWithMask

func OverlayViewInCenterWithOffsetWithMask(mainView, modalView string, viewWidth, viewHeight, deltaTop, deltaLeft int, maskRune rune) string

OverlayViewInCenterWithOffsetWithMask is like OverlayViewInCenterWithOffset but uses OverlayViewWithMask.

func OverlayViewInCenterWithOffsetWithTransparency

func OverlayViewInCenterWithOffsetWithTransparency(mainView, modalView string, viewWidth, viewHeight, deltaTop, deltaLeft int) string

OverlayViewInCenterWithOffsetWithTransparency is like OverlayViewInCenterWithOffset but uses OverlayViewWithTransparency.

func OverlayViewInCenterWithTransparency

func OverlayViewInCenterWithTransparency(mainView, modalView string, viewWidth, viewHeight int) string

OverlayViewInCenterWithTransparency is like OverlayViewInCenter but uses OverlayViewWithTransparency.

func OverlayViewWithMask

func OverlayViewWithMask(mainView, modalView string, viewWidth, viewHeight, top, left int, maskRune rune) string

OverlayViewWithMask is like OverlayView but treats any cell whose rune equals maskRune as transparent (pass-through to the main view). Use 0 for maskRune to behave like OverlayView.

func OverlayViewWithTransparency

func OverlayViewWithTransparency(mainView, modalView string, viewWidth, viewHeight, top, left int) string

OverlayViewWithTransparency is like OverlayView but cells in the modal that are ASCII space (' ') are treated as transparent: the main view shows through at those positions. Non-space modal cells (including styled spaces from lipgloss that carry a background) still replace the main view.

Types

type FocusTrap

type FocusTrap struct {
	Stack *OverlayStack
}

func (FocusTrap) InteractiveToBase

func (f FocusTrap) InteractiveToBase(msg tea.Msg) bool

type OverlayConfig

type OverlayConfig struct {
	Placement           Placement
	DimOpacity          float64
	CloseOnEscape       bool
	CloseOnClickOutside bool
}

func DefaultOverlayConfig

func DefaultOverlayConfig() OverlayConfig

type OverlayOnCloser

type OverlayOnCloser interface {
	OnOverlayClose() tea.Cmd
}

type OverlayStack

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

func (*OverlayStack) Depth

func (s *OverlayStack) Depth() int

func (*OverlayStack) MainReceivesKeyMsg

func (s *OverlayStack) MainReceivesKeyMsg() bool

func (*OverlayStack) MainReceivesMouseMsg

func (s *OverlayStack) MainReceivesMouseMsg() bool

func (*OverlayStack) Pop

func (s *OverlayStack) Pop() (popped tea.Model, cmd tea.Cmd)

func (*OverlayStack) Push

func (s *OverlayStack) Push(m tea.Model, cfg OverlayConfig) tea.Cmd

func (*OverlayStack) StackDepth

func (s *OverlayStack) StackDepth() int

func (*OverlayStack) Top

func (s *OverlayStack) Top() tea.Model

func (*OverlayStack) Update

func (s *OverlayStack) Update(msg tea.Msg) tea.Cmd

func (*OverlayStack) View

func (s *OverlayStack) View(baseMain string, viewW, viewH int) string

type Placement

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

func Center

func Center() Placement

func Fixed

func Fixed(top, left int) Placement

func RightDrawer

func RightDrawer() Placement

func (Placement) ClampedOrigin

func (p Placement) ClampedOrigin(modalW, modalH, viewW, viewH int) (top, left int)

ClampedOrigin returns Origin followed by the same overflow clamp as OverlayView. Use this when forwarding tea.MouseMsg or storing overlay bounds so geometry matches the compositor.

func (Placement) Origin

func (p Placement) Origin(modalW, modalH, viewW, viewH int) (top, left int)

Origin returns the placement anchor before OverlayView overflow clamping: negative top/left are pinned to 0, but if the modal is wider or taller than the viewport the origin is not shifted until compositing—use ClampedOrigin or ClampOverlayOrigin for coordinates that must match painting (e.g. mouse hit-testing).

type PlacementKind

type PlacementKind uint8

Directories

Path Synopsis
examples
colors command
confirm command
form command
simple command
spinner command
stack command
transparency command
v2simple command
internal
Package overlayv2 provides OverlayStack for Bubble Tea v2 (charm.land/bubbletea/v2).
Package overlayv2 provides OverlayStack for Bubble Tea v2 (charm.land/bubbletea/v2).

Jump to

Keyboard shortcuts

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