Skip to content

Commit a8d1f1b

Browse files
authored
Merge pull request #1457 from rumpl/fix-style-restore
Fix restoring style after style inline text
2 parents 7e631ef + 9a9dd8e commit a8d1f1b

3 files changed

Lines changed: 558 additions & 30 deletions

File tree

pkg/tui/components/markdown/fast_renderer.go

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ type cachedStyles struct {
7575
ansiCode ansiStyle
7676
ansiLink ansiStyle
7777
ansiLinkText ansiStyle
78+
ansiText ansiStyle // base document text style
7879

7980
styleTaskTicked string
8081
styleTaskUntick string
@@ -95,6 +96,8 @@ func getGlobalStyles() *cachedStyles {
9596
styleBold := buildStylePrimitive(mdStyle.Strong)
9697
styleItalic := buildStylePrimitive(mdStyle.Emph)
9798

99+
textStyle := buildStylePrimitive(mdStyle.Document.StylePrimitive)
100+
98101
globalStyles = &cachedStyles{
99102
headingStyles: [6]lipgloss.Style{
100103
buildStylePrimitive(mdStyle.H1.StylePrimitive),
@@ -115,6 +118,7 @@ func getGlobalStyles() *cachedStyles {
115118
ansiCode: buildAnsiStyle(buildStylePrimitive(mdStyle.Code.StylePrimitive)),
116119
ansiLink: buildAnsiStyle(buildStylePrimitive(mdStyle.Link)),
117120
ansiLinkText: buildAnsiStyle(buildStylePrimitive(mdStyle.LinkText)),
121+
ansiText: buildAnsiStyle(textStyle),
118122
styleTaskTicked: mdStyle.Task.Ticked,
119123
styleTaskUntick: mdStyle.Task.Unticked,
120124
listIndent: int(mdStyle.List.LevelIndent),
@@ -690,6 +694,7 @@ func (p *parser) renderInline(text string) string {
690694
out.Grow(len(text) + 64) // Pre-allocate with extra space for ANSI codes
691695
i := 0
692696
n := len(text)
697+
needsStyleRestore := false // track if we need to restore text style after styled content
693698

694699
for i < n {
695700
// Check for escaped characters
@@ -706,6 +711,7 @@ func (p *parser) renderInline(text string) string {
706711
code := text[i+1 : i+1+end]
707712
out.WriteString(p.styles.ansiCode.render(code))
708713
i = i + 1 + end + 1
714+
needsStyleRestore = true
709715
continue
710716
}
711717
}
@@ -724,6 +730,7 @@ func (p *parser) renderInline(text string) string {
724730
out.WriteString(p.styles.ansiBold.render(p.renderInline(inner)))
725731
}
726732
i = i + 2 + end + 2
733+
needsStyleRestore = true
727734
continue
728735
}
729736
}
@@ -746,6 +753,7 @@ func (p *parser) renderInline(text string) string {
746753
inner := text[i+1 : end]
747754
out.WriteString(p.styles.ansiItalic.render(p.renderInline(inner)))
748755
i = end + 1
756+
needsStyleRestore = true
749757
continue
750758
}
751759
}
@@ -757,6 +765,7 @@ func (p *parser) renderInline(text string) string {
757765
inner := text[i+2 : i+2+end]
758766
out.WriteString(p.styles.ansiStrike.render(p.renderInline(inner)))
759767
i = i + 2 + end + 2
768+
needsStyleRestore = true
760769
continue
761770
}
762771
}
@@ -778,14 +787,28 @@ func (p *parser) renderInline(text string) string {
778787
out.WriteString(p.styles.ansiLink.render(linkText))
779788
}
780789
i = i + closeBracket + 2 + closeParen + 1
790+
needsStyleRestore = true
781791
continue
782792
}
783793
}
784794
fallthrough
785795
default:
786-
// Regular character
787-
out.WriteByte(text[i])
788-
i++
796+
// Regular character - collect consecutive plain text
797+
start := i
798+
for i < n && !isInlineMarker(text[i]) {
799+
i++
800+
}
801+
// If we didn't advance (started on an unmatched marker), consume it as literal
802+
if i == start {
803+
i++
804+
}
805+
// Only apply text style if we need to restore after styled content
806+
if needsStyleRestore {
807+
p.styles.ansiText.renderTo(&out, text[start:i])
808+
needsStyleRestore = false
809+
} else {
810+
out.WriteString(text[start:i])
811+
}
789812
}
790813
}
791814

@@ -816,14 +839,21 @@ func isWord(b byte) bool {
816839
// This allows a fast path to skip processing plain text.
817840
func hasInlineMarkdown(text string) bool {
818841
for i := range len(text) {
819-
switch text[i] {
820-
case '\\', '`', '*', '_', '~', '[':
842+
if isInlineMarker(text[i]) {
821843
return true
822844
}
823845
}
824846
return false
825847
}
826848

849+
func isInlineMarker(b byte) bool {
850+
switch b {
851+
case '\\', '`', '*', '_', '~', '[':
852+
return true
853+
}
854+
return false
855+
}
856+
827857
// renderCodeBlock renders a fenced code block with syntax highlighting.
828858
func (p *parser) renderCodeBlock(code, lang string) {
829859
if code == "" {

pkg/tui/components/markdown/fast_renderer_test.go

Lines changed: 116 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package markdown
22

33
import (
4+
_ "embed"
45
"regexp"
56
"strings"
67
"testing"
78

9+
"github.com/charmbracelet/x/ansi"
810
runewidth "github.com/mattn/go-runewidth"
911
"github.com/stretchr/testify/assert"
1012
"github.com/stretchr/testify/require"
@@ -507,52 +509,46 @@ The fast renderer should handle all of these efficiently.
507509

508510
func BenchmarkFastRenderer(b *testing.B) {
509511
r := NewFastRenderer(80)
510-
b.ResetTimer()
511-
for range b.N {
512+
for b.Loop() {
512513
_, _ = r.Render(benchmarkInput)
513514
}
514515
}
515516

516517
func BenchmarkGlamourRenderer(b *testing.B) {
517518
r := NewGlamourRenderer(80)
518-
b.ResetTimer()
519-
for range b.N {
519+
for b.Loop() {
520520
_, _ = r.Render(benchmarkInput)
521521
}
522522
}
523523

524524
func BenchmarkFastRendererSmall(b *testing.B) {
525525
r := NewFastRenderer(80)
526526
input := "Hello **world**, this is a *test*."
527-
b.ResetTimer()
528-
for range b.N {
527+
for b.Loop() {
529528
_, _ = r.Render(input)
530529
}
531530
}
532531

533532
func BenchmarkGlamourRendererSmall(b *testing.B) {
534533
r := NewGlamourRenderer(80)
535534
input := "Hello **world**, this is a *test*."
536-
b.ResetTimer()
537-
for range b.N {
535+
for b.Loop() {
538536
_, _ = r.Render(input)
539537
}
540538
}
541539

542540
func BenchmarkFastRendererCodeBlock(b *testing.B) {
543541
r := NewFastRenderer(80)
544542
input := "```go\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n```"
545-
b.ResetTimer()
546-
for range b.N {
543+
for b.Loop() {
547544
_, _ = r.Render(input)
548545
}
549546
}
550547

551548
func BenchmarkGlamourRendererCodeBlock(b *testing.B) {
552549
r := NewGlamourRenderer(80)
553-
input := "```go\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n```"
554-
b.ResetTimer()
555-
for range b.N {
550+
input := "```go\nfunc main() {\n\tfmt.Println(\"hello`\")\n}\n```"
551+
for b.Loop() {
556552
_, _ = r.Render(input)
557553
}
558554
}
@@ -566,48 +562,42 @@ var benchmarkTableInput = `| Name | Age | City | Country | Occupation |
566562

567563
func BenchmarkFastRendererTable(b *testing.B) {
568564
r := NewFastRenderer(80)
569-
b.ResetTimer()
570-
for range b.N {
565+
for b.Loop() {
571566
_, _ = r.Render(benchmarkTableInput)
572567
}
573568
}
574569

575570
func BenchmarkGlamourRendererTable(b *testing.B) {
576571
r := NewGlamourRenderer(80)
577-
b.ResetTimer()
578-
for range b.N {
572+
for b.Loop() {
579573
_, _ = r.Render(benchmarkTableInput)
580574
}
581575
}
582576

583577
func BenchmarkFastRendererTableWidth20(b *testing.B) {
584578
r := NewFastRenderer(20)
585-
b.ResetTimer()
586-
for range b.N {
579+
for b.Loop() {
587580
_, _ = r.Render(benchmarkTableInput)
588581
}
589582
}
590583

591584
func BenchmarkGlamourRendererTableWidth20(b *testing.B) {
592585
r := NewGlamourRenderer(20)
593-
b.ResetTimer()
594-
for range b.N {
586+
for b.Loop() {
595587
_, _ = r.Render(benchmarkTableInput)
596588
}
597589
}
598590

599591
func BenchmarkFastRendererTableWidth200(b *testing.B) {
600592
r := NewFastRenderer(200)
601-
b.ResetTimer()
602-
for range b.N {
593+
for b.Loop() {
603594
_, _ = r.Render(benchmarkTableInput)
604595
}
605596
}
606597

607598
func BenchmarkGlamourRendererTableWidth200(b *testing.B) {
608599
r := NewGlamourRenderer(200)
609-
b.ResetTimer()
610-
for range b.N {
600+
for b.Loop() {
611601
_, _ = r.Render(benchmarkTableInput)
612602
}
613603
}
@@ -764,3 +754,104 @@ This is a paragraph.
764754
})
765755
}
766756
}
757+
758+
func TestInlineCodeRestoresBaseStyle(t *testing.T) {
759+
t.Parallel()
760+
761+
// This test verifies that text after inline code has the document's base style restored,
762+
// not just a reset to terminal default.
763+
// Bug: "Hello `there` beautiful" - "beautiful" was appearing with terminal default
764+
// instead of the document's text color.
765+
766+
r := NewFastRenderer(80)
767+
result, err := r.Render("Hello `there` beautiful")
768+
require.NoError(t, err)
769+
770+
seqs := ansiRegex.FindAllString(result, -1)
771+
772+
// We should have:
773+
// 1. Code style (foreground + background for inline code)
774+
// 2. Reset
775+
// 3. Base text style restoration (document foreground color)
776+
require.GreaterOrEqual(t, len(seqs), 3, "Should have at least 3 ANSI sequences")
777+
778+
// First sequence should be the code style (has RGB foreground and background)
779+
assert.Contains(t, seqs[0], "38;2;", "Code style should have RGB foreground")
780+
assert.Contains(t, seqs[0], "48;2;", "Code style should have RGB background")
781+
782+
// Second sequence should be reset
783+
assert.Equal(t, "\x1b[m", seqs[1], "Second sequence should be reset")
784+
785+
// Third sequence should be the base text style (document color 252)
786+
assert.Contains(t, seqs[2], "38;5;252", "Third sequence should restore document text color")
787+
}
788+
789+
func TestInlineCodeTextContent(t *testing.T) {
790+
t.Parallel()
791+
792+
r := NewFastRenderer(80)
793+
result, err := r.Render("Hello `there` beautiful")
794+
require.NoError(t, err)
795+
796+
plain := ansi.Strip(result)
797+
require.Contains(t, plain, "Hello there beautiful")
798+
}
799+
800+
//go:embed testdata/streaming_benchmark.md
801+
var streamingBenchmarkContent string
802+
803+
// splitIntoStreamingChunks splits content into chunks that simulate LLM streaming.
804+
// LLM tokens are typically 3-4 characters, so we use small varying chunk sizes.
805+
func splitIntoStreamingChunks(content string) []string {
806+
var chunks []string
807+
i := 0
808+
chunkSizes := []int{3, 4, 3, 5, 2, 4, 3, 4, 5, 3, 2, 4, 3, 4, 3, 5}
809+
sizeIdx := 0
810+
811+
for i < len(content) {
812+
chunkSize := chunkSizes[sizeIdx%len(chunkSizes)]
813+
end := i + chunkSize
814+
if end > len(content) {
815+
end = len(content)
816+
}
817+
chunks = append(chunks, content[i:end])
818+
i = end
819+
sizeIdx++
820+
}
821+
return chunks
822+
}
823+
824+
// BenchmarkStreamingFastRenderer benchmarks rendering progressively growing markdown.
825+
// This simulates the streaming use case where content arrives in chunks and
826+
// the entire accumulated content is re-rendered on each update.
827+
func BenchmarkStreamingFastRenderer(b *testing.B) {
828+
chunks := splitIntoStreamingChunks(streamingBenchmarkContent)
829+
r := NewFastRenderer(80)
830+
831+
b.ResetTimer()
832+
for b.Loop() {
833+
var accumulated strings.Builder
834+
for _, chunk := range chunks {
835+
accumulated.WriteString(chunk)
836+
_, _ = r.Render(accumulated.String())
837+
}
838+
}
839+
}
840+
841+
// BenchmarkStreamingGlamourRenderer benchmarks glamour with progressively growing markdown.
842+
// Note: glamour's TermRenderer has internal state issues when reused many times,
843+
// so we create a fresh renderer for each benchmark iteration. This adds overhead
844+
// but is necessary to avoid panics in glamour's internal ANSI parser.
845+
func BenchmarkStreamingGlamourRenderer(b *testing.B) {
846+
chunks := splitIntoStreamingChunks(streamingBenchmarkContent)
847+
848+
b.ResetTimer()
849+
for b.Loop() {
850+
r := NewGlamourRenderer(80)
851+
var accumulated strings.Builder
852+
for _, chunk := range chunks {
853+
accumulated.WriteString(chunk)
854+
_, _ = r.Render(accumulated.String())
855+
}
856+
}
857+
}

0 commit comments

Comments
 (0)