hard-won lessons from building Bubble Tea UIs. Read this BEFORE implementing new models or components to avoid repeating mistakes...
This document captures hard-won lessons from building Bubble Tea UIs. Read this BEFORE implementing new models or components to avoid repeating mistakes.
TRUST LIPGLOSS. DON'T SECOND-GUESS IT.
If you find yourself with "empirical adjustment" constants to fix widths, you have a bug in your understanding, not in lipgloss. Stop, step back, and find the root cause.
Most Common Mistakes:
SetSize() ā Use natural widthsKey Facts:
Width() in lipgloss includes padding but excludes borderWidth(37 - 2) = Width(35)Width() set) determine their own sizeWidth() set) use allocated spacešØ TRUST LIPGLOSS - DON'T SECOND-GUESS IT šØ
You will be tempted to manually calculate widths by subtracting padding and border. DON'T. Lipgloss handles spacing internally. Your job is to:
Key fact: Width() in lipgloss includes padding but excludes border.
style := lipgloss.NewStyle().
Width(100). // Total pane width (includes padding, excludes border)
PaddingLeft(1). // 1 char inside the width
BorderStyle(RoundedBorder()) // 4 chars OUTSIDE the width (2 per side)
Visual breakdown:
Border(1) | Pad(1) | Content | Pad(1) | Border(1)
|<------- Width(100) ------->|
|<------- Total Visual Width = 102 -------->|
ā ļø CRITICAL: Width() includes padding but excludes border!
If you want total rendered width of 37:
const borderWidth = 2 // 1 left + 1 right
totalWidth := 37
widthForLipgloss := totalWidth - borderWidth // 35
style := lipgloss.NewStyle().
Width(widthForLipgloss). // 35 (includes padding)
PaddingLeft(1).
PaddingRight(2).
BorderStyle(lipgloss.RoundedBorder())
// Result: 35 + 2 (border) = 37 total rendered width ā
Approach 1: Auto-sizing (no Width() set)
// Lipgloss auto-sizes to content
style := lipgloss.NewStyle().
PaddingLeft(1).
PaddingRight(1).
BorderStyle(lipgloss.RoundedBorder())
// Visual width = content + padding + border (all auto-calculated)
Use for: Dynamic content like trees where you don't know the width in advance.
Approach 2: Constrained sizing (Width() set)
// You specify the width, lipgloss fits content inside
style := lipgloss.NewStyle().
Width(totalWidth). // You control this
PaddingLeft(1).
BorderStyle(lipgloss.RoundedBorder())
// Lipgloss ensures final visual width = totalWidth + border
Use for: Fixed-width panes like code viewers.
You still need TWO width values, but for a different reason than you think:
Width() (what the pane occupies)SetSize() (what they can render)Track both in your model:
type MyModel struct {
paneContentWidth int // For viewport.SetSize()
paneTotalWidth int // For lipgloss Width()
}
But DON'T try to derive one from the other by subtracting constants!
func (m MyModel) calculateLayout() MyModel {
// Calculate total width based on available space
halfTotal := remainingWidth / 2
m.paneTotalWidth = halfTotal // This is what we allocate
// For content width: TRUST LIPGLOSS
// Just use the same base value - lipgloss handles the rest
m.paneContentWidth = halfTotal // Same value!
return m
}
Why are they the same? Because lipgloss handles padding and border internally when you call Width(). You don't need to account for it manually.
Critical understanding: The parent model applies borders, not child components.
// Parent model (e.g., CommitReviewModel)
func (m MyModel) View() string {
childContent := m.childModel.View() // Child returns raw content
// Parent wraps with border
style := lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder())
return style.Render(childContent)
}
This means:
SetSize()ā ļø CRITICAL: Border width is 1 character per side, not 2!
Single borders (RoundedBorder, NormalBorder, etc.):
const borderWidth = 2 for single bordersDouble borders (DoubleBorder):
const borderWidth = 2Common mistake: Thinking borders are 2 chars per side because they "look thick". They're single Unicode characters!
Common mistake: Forgetting to account for ALL padding.
// Tree pane with padding on both sides
treeStyle := lipgloss.NewStyle().
PaddingLeft(1). // 1 char
PaddingRight(1) // 1 char
// Total padding width = 2
// Code pane with padding on one side only
codeStyle := lipgloss.NewStyle().
PaddingLeft(1) // 1 char
// Total padding width = 1
Track padding separately per pane type:
const (
treePaddingWidth = 2 // Left + Right
codePaddingWidth = 1 // Left only
)
Methods should update struct fields and return the model:
// ā
CORRECT - Bubble Tea pattern
func (m MyModel) calculateLayout() MyModel {
m.contentWidth = /* calculate */
m.totalWidth = /* calculate */
return m
}
// ā WRONG - Leads to verbose code and errors
func (m MyModel) calculateLayout() (contentWidth, totalWidth int) {
return /* calculate */, /* calculate */
}
// ā
CORRECT
m = m.calculateLayout()
// Values available as m.contentWidth, m.totalWidth
// ā WRONG
contentWidth, totalWidth := m.calculateLayout()
// Now you have to track these variables separately
Problem: When splitting remaining width between two panes, odd numbers create unequal panes.
ā WRONG SOLUTION: Modify content width artificially
// DON'T DO THIS!
if remainingWidth % 2 == 1 {
treeContentWidth++ // Lying to the child model about its size
treeTotalWidth++
remainingWidth--
}
Why this is wrong:
ā CORRECT SOLUTION: Use dynamic padding to consume odd columns
// In your model struct
type MyModel struct {
treeRightPadding int // 1 or 2, depending on odd/even
}
// In calculateLayout()
func (m MyModel) calculateLayout() MyModel {
m.treeRightPadding = 1 // Default
// Calculate what remaining width WOULD be
normalTreeTotal := treeContentWidth + 1 + 1 + 4 // left pad + right pad + border
potentialRemaining := terminalWidth - normalTreeTotal
// If both panes visible and remaining would be odd, add extra padding
if bothPanesVisible && potentialRemaining % 2 == 1 {
m.treeRightPadding = 2 // Extra column as padding
}
// Now calculate actual tree total with dynamic padding
m.treeTotalWidth = treeContentWidth + 1 + m.treeRightPadding + 4
remainingWidth = terminalWidth - m.treeTotalWidth
// remainingWidth is now guaranteed even!
return m
}
// In View()
func (m MyModel) View() string {
treeStyle := lipgloss.NewStyle().
PaddingLeft(1).
PaddingRight(m.treeRightPadding). // Dynamic!
BorderStyle(lipgloss.RoundedBorder())
return treeStyle.Render(m.treePane.View())
}
Why this is correct:
LayoutWidth())When debugging width issues, verify empirically:
01234567890123456789012345678901234567890...
Take screenshot showing exact cutoff point
Count characters to measure actual vs expected
Trust the compiler over gopls - go build is source of truth
This helps you find the ROOT CAUSE.
ā RED FLAG: Constants like this:
const (
paneTotalAdjustment = 3 // "Empirically determined"
paneContentAdjustment = 1 // "Empirically determined"
)
// Used like:
m.paneTotalWidth = halfTotal + paneTotalAdjustment
m.paneContentWidth = halfTotal + paneContentAdjustment
Why this is a problem:
What to do instead:
The goal: All width adjustments should be 0. If they're not, you have a bug in your understanding or implementation.
Correct order when mixing auto-sized and constrained panes:
LayoutWidth())// 1. Auto-sized content width
treeContentWidth = tree.LayoutWidth()
// 2. Dynamic padding to handle odd widths
treeRightPadding = 1 // Default
normalTreeTotal := treeContentWidth + 1 + 1 + 4
potentialRemaining := terminalWidth - normalTreeTotal
if potentialRemaining % 2 == 1 {
treeRightPadding = 2 // Consume odd column
}
// 3. Auto-sized total with dynamic padding
treeTotalWidth = treeContentWidth + 1 + treeRightPadding + 4
// 4. Remaining for constrained panes
remainingWidth = terminalWidth - treeTotalWidth
// 5. Distribute (now guaranteed even)
halfWidth := remainingWidth / 2
pane2TotalWidth = halfWidth
pane3TotalWidth = halfWidth
pane2ContentWidth = halfWidth // Trust lipgloss
pane3ContentWidth = halfWidth // Trust lipgloss
Key principles:
Width() set) determine their own sizeWidth() set) use allocated spaceRecalculate on:
tea.WindowSizeMsg - Terminal resizedStore results in model fields:
case tea.WindowSizeMsg:
m.terminalWidth = msg.Width
m.terminalHeight = msg.Height
m = m.recalculateLayout() // Recalc and update viewports
You need to overlay one Bubble Tea component on top of another (modal dialogs, dropdowns, tooltips). Standard string concatenation doesn't work because:
len(str) counts ANSI codes, breaking positioningUse ANSI-aware string operations from github.com/charmbracelet/x/ansi:
import "github.com/charmbracelet/x/ansi"
// ā
CORRECT - Visual width
width := ansi.StringWidth(styledText)
// ā WRONG - Byte length (includes ANSI codes)
width := len(styledText)
Proven pattern from bubbledd and bubblemodal:
// OverlayDropdown overlays foreground view on background view at specified position.
// Uses ANSI-aware string operations to correctly handle styled text.
//
// Parameters:
// - background: The base view (fully rendered string with ANSI codes)
// - foreground: The overlay view (fully rendered string with ANSI codes)
// - row: Line number in background where foreground row 0 should appear (0-indexed)
// - col: Display column in background where foreground col 0 should appear (0-indexed)
//
// Returns:
// - Composited view with foreground overlaid on background
func OverlayDropdown(background, foreground string, row, col int) string {
var result strings.Builder
bgLines := strings.Split(background, "\n")
fgLines := strings.Split(foreground, "\n")
for i, bgLine := range bgLines {
fgRow := i - row
// This line has no foreground overlay
if fgRow < 0 || fgRow >= len(fgLines) {
result.WriteString(bgLine)
result.WriteString("\n")
continue
}
// Overlay foreground line onto background line
fgLine := fgLines[fgRow]
composited := overlayLine(bgLine, fgLine, col)
result.WriteString(composited)
result.WriteString("\n")
}
// Remove trailing newline
output := result.String()
if len(output) > 0 && output[len(output)-1] == '\n' {
output = output[:len(output)-1]
}
return output
}
Per-line overlay (the critical piece):
// overlayLine overlays foreground onto background at column position (ANSI-aware).
// Pattern: left part of background + foreground + right part of background
//
// The key insight: Standard Go string operations (len, slicing) count ANSI escape
// codes as characters, which breaks positioning. We use ansi.StringWidth() for
// visual width and ansi.Truncate/TruncateLeft for ANSI-safe string cutting.
func overlayLine(background, foreground string, col int) string {
if col < 0 {
col = 0
}
bgWidth := ansi.StringWidth(background)
fgWidth := ansi.StringWidth(foreground)
var result strings.Builder
// Left part: truncate background to col width
if col > 0 {
if col <= bgWidth {
left := ansi.Truncate(background, col, "")
result.WriteString(left)
} else {
// Need padding beyond background width
result.WriteString(background)
result.WriteString(strings.Repeat(" ", col-bgWidth))
}
}
// Middle part: foreground content (the overlay)
result.WriteString(foreground)
// Right part: remainder of background after foreground
endCol := col + fgWidth
if endCol < bgWidth {
// TruncateLeft(s, n) skips the first n display columns
remaining := ansi.TruncateLeft(background, endCol, "")
result.WriteString(remaining)
}
return result.String()
}
From github.com/charmbracelet/x/ansi:
| Operation | Description |
|---|---|
ansi.StringWidth(s) |
Visual width (what you see on screen) |
ansi.Truncate(s, width, tail) |
Keep first N visual columns (cut from right) |
ansi.TruncateLeft(s, width, tail) |
Skip first N visual columns (cut from left) |
Example:
styled := lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Render("Hello")
// styled contains ANSI escape codes
len(styled) // ā 18 (includes escape codes)
ansi.StringWidth(styled) // ā
5 (visual width)
// Keep first 3 visual characters
truncated := ansi.Truncate(styled, 3, "") // "Hel" (still styled!)
// Skip first 2 visual characters
remaining := ansi.TruncateLeft(styled, 2, "") // "llo" (still styled!)
In your View() method:
func (m MyModel) View() string {
// Render main content
mainView := m.mainContent.View()
// If modal/dropdown is active, overlay it
if m.showModal {
modalView := m.modal.View()
// Calculate position (typically centered)
row := (m.height - m.modal.Height()) / 2
col := (m.width - m.modal.Width()) / 2
// Overlay modal on main view
return OverlayModal(mainView, modalView, row, col)
}
return mainView
}
Center overlay on screen:
row := (screenHeight - overlayHeight) / 2
col := (screenWidth - overlayWidth) / 2
Position below trigger element:
row := triggerRow + 1
col := triggerCol
Right-align overlay:
row := /* desired row */
col := screenWidth - overlayWidth
Both use identical overlay logic. The difference is in usage context, not implementation.
š„ CRITICAL LESSON: Debug first, fix second. Never make "confident" claims without measuring actual values! š„
You calculate widths mathematically, it looks perfect on paper, but the UI has a gap. You try fixes based on assumptions. Nothing works. User gets frustrated.
Why: You're debugging your mental model, not the actual code.
Step 1: Add debug logging IMMEDIATELY
Don't guess. Don't assume. Measure actual runtime values:
// In calculateLayout()
m.Logger.Info("WIDTH DEBUG calculateLayout",
"terminalWidth", m.terminalWidth,
"treeContentWidth", m.treeContentWidth,
"treeTotalWidth", m.treeTotalWidth,
"splitTotalWidth", m.splitTotalWidth,
"splitContentWidth", m.splitContentWidth,
)
// In View() - measure what actually renders
treeRendered := treeStyle.Render(m.treePane.View())
treeActualWidth := 0
if lines := strings.Split(treeRendered, "\n"); len(lines) > 0 {
treeActualWidth = ansi.StringWidth(lines[0])
}
m.Logger.Info("WIDTH DEBUG View",
"treeCalculated", m.treeTotalWidth,
"treeActual", treeActualWidth,
"gap", m.treeTotalWidth - treeActualWidth,
)
Step 2: Run the app, capture logs
cd gommod && ../cmd/gomion/gomion commit 2>&1 | tee /tmp/debug.log
# Then: grep "WIDTH DEBUG" /tmp/debug.log
Step 3: Compare calculated vs actual
Look for discrepancies:
treeCalculated: 37 but treeActual: 34 ā Tree is 3 chars too narrow!splitCalculated: 283 and splitActual: 283 ā Split pane is correct!Step 4: Find the root cause
In the example above:
Width(contentWidth) to Width(totalWidth - borderWidth)| Symptom | Likely Cause |
|---|---|
| Actual = Calculated - 2 | Forgot to account for borders (1 per side) |
| Actual = Calculated - 3 | Width() excludes border, you didn't subtract it |
| Actual < Calculated | Content is narrower than you think, not being padded |
| Actual > Calculated | You're adding padding/border twice |
Don't just measure the final rendered output. Measure intermediate values:
// Measure tree content BEFORE styling
treeContent := m.treePane.View()
treeContentActualWidth := 0
if lines := strings.Split(treeContent, "\n"); len(lines) > 0 {
treeContentActualWidth = ansi.StringWidth(lines[0])
}
m.Logger.Info("WIDTH DEBUG tree details",
"treeContentWidth", m.treeContentWidth, // What you calculated
"treeContentActualWidth", treeContentActualWidth, // What it really is
"treeTotalWidth", m.treeTotalWidth, // What you expect total
"treeRenderedWidth", treeActualWidth, // What actually rendered
)
This reveals:
Never make a "fix" without first understanding the discrepancy.
ā "I'll add 3 to the width, that should fix it" ā "Let me try subtracting the border here" ā "Maybe if I adjust the padding..."
ā "Logs show actual is 34 but calculated is 37. That's a 3-char gap. Let me investigate why." ā "Tree content is 17 chars but I'm setting Width(32). Is lipgloss padding it correctly?" ā "Width() might include padding. Let me verify with documentation."
Leave the debug logging in place! Comment it out if needed, but don't delete it. Next time layout breaks, you'll be glad you can uncomment and immediately see the values.
// DEBUG: Uncomment to debug width calculations
// m.Logger.Info("WIDTH DEBUG calculateLayout", ...)
Before declaring "width calculations are done":
calculateLayout() return updated model (not multiple ints)?SetSize()Width() set)Width() set correctly)Before implementing overlay compositing:
ansi.StringWidth() instead of len()?ansi.Truncate() / ansi.TruncateLeft() for string cutting?See gommod/gomtui/commit_review_model.go for complete reference:
calculateLayout() populates cache and returns model (lines ~480-530)View() uses total widths directly (lines ~221-270)recalculateLayout() uses content widths for viewports (lines ~558-573)See:
gommod/bubbledd/overlay_dropdown.go - Complete overlay implementationgommod/bubblemodal/overlay_modal.go - Same pattern, different contextWhen in doubt, refer to this document. When you discover a new pattern, ADD IT HERE.