charmbracelet / charmbracelet/bubbletea

lipgloss styles rendered incorrectly when `.Width()` applied

Open
#1,225 12 comments 0 reactions 0 assignees View on GitHub
Dominant language
Go
Stars
44.9k
Forks
1.3k
PR merge metrics
No merged PRs in 30d

Description

**Describe the bug**
When applying a style dynamically to a string that has a width set to it by a `lipgloss` style, the style is rendered incorrectly. With 3 different styles applied to 3 different substrings there should be "typed" text (default), "untyped text" (default but faint) and cursor (default reversed), when applied to slices of a string, render incorrectly. The beginning of the string (which should be typed) is rendered fainted. Also, the borders and some text outside of the widget appear to be rendered fainted. This effect is temporal, as on first rendered frame everything appears to look fine and then rerenders incorrectly on subsequent renders (see video).

**Setup**
Please complete the following information along with version numbers, if applicable.
- OS Ubuntu
- Shell bash
- Terminal Emulator kitty
- Terminal Multiplexer zellij

**To Reproduce**
Steps to reproduce the behavior:
1. Compile the code
2. Start the program
3. See error

**Source Code**
```go
package main

import (
"fmt"
"strings"
"time"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"golang.org/x/term"

"github.com/charmbracelet/bubbles/stopwatch"
)

func main() {
prompt := strings.Repeat("the end is never ", 100)

p := tea.NewProgram(initialModel(prompt), tea.WithAltScreen())
p.Run()
}

type styles struct {
global_style lipgloss.Style
typed_text_style lipgloss.Style
untyped_text_style lipgloss.Style
cursor_style lipgloss.Style
}

type Model struct {
prompt string
cursor int
styles styles
width int
stopwatch stopwatch.Model
}

func default_styles(width int) styles {
return styles{
global_style: lipgloss.NewStyle().Width(width).Border(lipgloss.NormalBorder()),
typed_text_style: lipgloss.NewStyle(),
untyped_text_style: lipgloss.NewStyle().Faint(true),
cursor_style: lipgloss.NewStyle().Reverse(true),
}
}

func initialModel(prompt string) Model {
w, _, _ := term.GetSize(0)
return Model{
width: w,
prompt: prompt,
cursor: 0,
styles: default_styles(w - 10),
stopwatch: stopwatch.NewWithInterval(time.Second),
}
}

func (m Model) Init() tea.Cmd {
return m.stopwatch.Start()
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyCtrlC, tea.KeyEsc:
return m, tea.Quit
}
case stopwatch.TickMsg:
m.cursor++
}

m.stopwatch, cmd = m.stopwatch.Update(msg)
cmds = append(cmds, cmd)

return m, tea.Batch(cmds...)
}

func (m Model) View() string {
var sb strings.Builder
sb.WriteString(m.styles.typed_text_style.Render(m.prompt[:m.cursor]))
sb.WriteString(m.styles.cursor_style.Render(string(m.prompt[m.cursor])))
sb.WriteString(m.styles.untyped_text_style.Render(m.prompt[m.cursor+1:]))
sb.WriteString(fmt.Sprintf("\ncursor_coord: %d", m.cursor))

res := lipgloss.JoinVertical(lipgloss.Center, m.styles.global_style.Render(sb.String()), m.stopwatch.View())
return lipgloss.PlaceHorizontal(m.width, lipgloss.Center, res)
}
```

**Expected behavior**
Cursor advances, all the text rendered before it is rendered normally, all the text after it is rendered fainted

**Screenshots**

[Screencast from 2024-11-06 23-01-18.webm](https://github.com/user-attachments/assets/0015ddf7-4fa4-445d-a532-21f6883214ff)

**Additional context**
At first glance this appears to be a lipgloss bug, but when I tried to replicate it with a minimal program, like so
```go
package main

import (
"fmt"

"github.com/charmbracelet/lipgloss"
)

// styles are defined here is a similar manner

const prompt = "A quick brown fox jumps over the lazy dog"

func main() {
dstyles := default_styles(10)
cursor_coord := 15
styled_text := ""

styled_text += dstyles.typed_text_style.Render(prompt[:cursor_coord])
styled_text += dstyles.cursor_style.Render(string(prompt[cursor_coord]))
styled_text += dstyles.untyped_text_style.Render(prompt[cursor_coord+1:])

fmt.Println(dstyles.global_style.Render(styled_text))
}
```
The bug did not replicate, everything rendered as expected.

Also, setting the cursor to a static value also didn't work in the above code example also did not replicate the bug. Setting the `global_style` to not add the `.Width()` also did not replicate the bug. So this is somewhere between `lipgloss.Style.Width` and the rendering procedure of `bubbletea`

Contributor guide

Open the contributing guide

Research direction

Start with the provided Bubble Tea reproduction in the issue and compare it with the minimal lipgloss-only example, focusing on the rerender path after the cursor advances. The fix is done when a width-constrained global style preserves the typed, cursor, faint, border, and surrounding text styling across subsequent renders.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
cli
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.