diff --git a/difflet_test.go b/difflet_test.go index c8790fb..ceb3414 100644 --- a/difflet_test.go +++ b/difflet_test.go @@ -72,6 +72,52 @@ func TestDiffletStylesOnlyDiffSigns(t *testing.T) { } } +func TestDiffletAnimationDoesNotChangeThreadCommentRows(t *testing.T) { + if err := applyTheme("dark"); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = applyTheme("dark") }) + + app := NewAppWithSettings( + &recordingService{}, "owner", "repository", false, 10, 10, + AppSettings{Mascot: true, MascotAnimated: true}, + ) + app.screen = threadScreen + app.loading = false + app.details = PRDetails{ + PullRequest: PullRequest{ + Owner: "owner", Repository: "repository", + RepoWithOwner: "owner/repository", Number: 42, + Title: "Stable highlighted comments", + }, + Threads: []ReviewThread{{ + ID: "thread", Path: "main.go", + Comments: []ReviewComment{{ + ID: "comment", Author: "reviewer", + Body: "```go\nfunc main() {\n\tprintln(\"stable\")\n}\n```", + }}, + }}, + } + updated, _ := app.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + app = updated.(App) + before := strings.Split(app.View(), "\n") + + generation := app.difflet.generation + updated, _ = app.Update(diffletTickMsg{generation: generation}) + app = updated.(App) + after := strings.Split(app.View(), "\n") + + if len(before) != len(after) { + t.Fatalf("animation changed frame height from %d to %d", len(before), len(after)) + } + for row := diffletHeight; row < len(before); row++ { + if before[row] != after[row] { + t.Fatalf("animation changed non-mascot row %d:\nbefore %q\nafter %q", + row, before[row], after[row]) + } + } +} + func TestDiffletEnabledSitsRightOfWrappingHeader(t *testing.T) { app := NewAppWithSettings( &recordingService{}, "owner", "repository", false, 10, 10, diff --git a/markdown.go b/markdown.go index 94f4a6a..55ed312 100644 --- a/markdown.go +++ b/markdown.go @@ -97,6 +97,7 @@ func commentMarkdownRenderer(width int) (*glamour.TermRenderer, error) { style.Code.Suffix = "" renderer, err := glamour.NewTermRenderer( glamour.WithStyles(style), + glamour.WithChromaFormatter("terminal16m"), glamour.WithWordWrap(width), glamour.WithTableWrap(true), glamour.WithPreservedNewLines(), diff --git a/terminal_cursor.go b/terminal_cursor.go index 19eda45..66f4f3a 100644 --- a/terminal_cursor.go +++ b/terminal_cursor.go @@ -49,6 +49,21 @@ func (o *terminalCursorOutput) Write(value []byte) (int, error) { o.mu.Lock() defer o.mu.Unlock() + // Bubble Tea v1 can expose intermediate rows from an animated partial + // repaint. This is especially visible when unchanged Markdown code blocks + // below the changed rows contain dense ANSI styling. Terminals that support + // synchronized output hold the completed frame until the reset sequence; + // terminals that do not support it safely ignore both sequences. + if !bytes.Equal(value, []byte(ansi.ShowCursor)) && + !bytes.Equal(value, []byte(ansi.HideCursor)) { + if _, err := io.WriteString(o.file, ansi.SetSynchronizedOutputMode); err != nil { + return 0, err + } + defer func() { + _, _ = io.WriteString(o.file, ansi.ResetSynchronizedOutputMode) + }() + } + written, err := o.file.Write(value) if err != nil || written != len(value) { return written, err diff --git a/terminal_cursor_test.go b/terminal_cursor_test.go index 3acd408..941a14a 100644 --- a/terminal_cursor_test.go +++ b/terminal_cursor_test.go @@ -25,7 +25,7 @@ func TestTerminalCursorOutputPositionsHardwareBarAfterFrame(t *testing.T) { t.Fatal(err) } wantSuffix := ansi.SetCursorStyle(5) + ansi.CursorPosition(7, 4) + ansi.ShowCursor - if !strings.HasSuffix(string(content), wantSuffix) { + if !strings.HasSuffix(string(content), wantSuffix+ansi.ResetSynchronizedOutputMode) { t.Fatalf("cursor output = %q, want suffix %q", content, wantSuffix) } } @@ -46,7 +46,33 @@ func TestTerminalCursorOutputHidesCursorOutsideInsertMode(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.HasSuffix(string(content), ansi.HideCursor) { + if !strings.HasSuffix( + string(content), + ansi.HideCursor+ansi.ResetSynchronizedOutputMode, + ) { t.Fatalf("cursor output did not hide cursor: %q", content) } } + +func TestTerminalCursorOutputSynchronizesCompletedFrames(t *testing.T) { + file, err := os.CreateTemp(t.TempDir(), "cursor-output") + if err != nil { + t.Fatal(err) + } + defer file.Close() + + output := newTerminalCursorOutput(file) + output.SetCursor(false, 0, 0) + if _, err := output.Write([]byte("animated frame")); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(file.Name()) + if err != nil { + t.Fatal(err) + } + want := ansi.SetSynchronizedOutputMode + "animated frame" + + ansi.HideCursor + ansi.ResetSynchronizedOutputMode + if string(content) != want { + t.Fatalf("synchronized frame output = %q, want %q", content, want) + } +} diff --git a/version.go b/version.go index 1081732..74ea36a 100644 --- a/version.go +++ b/version.go @@ -1,3 +1,3 @@ package main -const dipleVersion = "0.1.1" +const dipleVersion = "0.1.2"