fix mascot

This commit is contained in:
2026-07-30 11:09:25 +02:00
parent 28e418abd6
commit bcad515698
3 changed files with 256 additions and 16 deletions

View File

@@ -304,13 +304,13 @@ showing every repeated `COMMENTED` event.
`viewer_label = "login"` shows your GitHub username like every other author. `viewer_label = "login"` shows your GitHub username like every other author.
Set it to `"you"` to replace your username with `@you` throughout the UI. Set it to `"you"` to replace your username with `@you` throughout the UI.
Difflet is disabled by default. Set `mascot = true` to keep it visible to the Difflet is disabled by default. Set `mascot = true` to show it on the pull
right next to the active view's naturally sized header, separated by a request picker, dashboard, and thread screens. On the dashboard it is centered
small gap. On normal terminal widths Difflet is centered horizontally and the beside the first metadata rows so it does not add whitespace below the pull
header uses the space to its left. When centering would make the header too request title. Editor and popup views hide it to preserve their full usable
narrow, Difflet falls back to a small right-edge inset. Header information height. On other supported screens Difflet sits to the right of the naturally
wraps when the combined header and mascot do not fit. The layout adds only the sized header. Header information wraps when the combined header and mascot do
vertical rows required to display the four-line mascot. not fit.
`mascot_animated` controls brief loading, blink, success, and error motion `mascot_animated` controls brief loading, blink, success, and error motion
independently from `mascot_expressive`, which permits stronger emotional independently from `mascot_expressive`, which permits stronger emotional
faces. Disabling animation leaves the appropriate final state visible. faces. Disabling animation leaves the appropriate final state visible.

View File

@@ -196,6 +196,173 @@ func TestDiffletDisabledPreservesViewExactly(t *testing.T) {
} }
} }
func TestDiffletDashboardIsCenteredBesideMetadata(t *testing.T) {
app := NewAppWithSettings(
&recordingService{}, "owner", "repository", false, 10, 10,
AppSettings{Mascot: true},
)
app.screen = dashboardScreen
app.loading = false
app.details = PRDetails{
PullRequest: PullRequest{
RepoWithOwner: "owner/repository", Number: 42,
Title: "A useful title", Author: "alice",
},
HeadRef: "feature", BaseRef: "main",
}
updated, _ := app.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
app = updated.(App)
rendered := strings.Split(app.View(), "\n")
headerHeight := len(app.dashboardHeaderLines())
if strings.TrimSpace(ansi.Strip(rendered[headerHeight])) == "" {
t.Fatal("dashboard left a blank row between its title and metadata")
}
for row, mascotLine := range (DiffletFrame{Expression: DiffletIdle}).lines() {
plain := ansi.Strip(rendered[headerHeight+row])
mascotText := strings.TrimRight(mascotLine, " ")
mascotIndex := strings.Index(plain, mascotText)
left := -1
if mascotIndex >= 0 {
left = lipgloss.Width(plain[:mascotIndex])
}
if left != (app.width-diffletWidth)/2 {
t.Fatalf("row %d mascot starts at %d, want centered position %d: %q",
row, left, (app.width-diffletWidth)/2, plain)
}
}
for row, label := range []string{"author", "branches", "review"} {
if !strings.Contains(ansi.Strip(rendered[headerHeight+row]), label) {
t.Fatalf("dashboard row %d does not place %q beside mascot: %q",
row, label, ansi.Strip(rendered[headerHeight+row]))
}
}
}
func TestDiffletIsHiddenInEditorAndPopups(t *testing.T) {
app := NewAppWithSettings(
&recordingService{}, "owner", "repository", false, 10, 10,
AppSettings{Mascot: true},
)
app.screen = dashboardScreen
app.loading = false
app.details = PRDetails{PullRequest: PullRequest{
RepoWithOwner: "owner/repository", Number: 42, Title: "Title",
}}
updated, _ := app.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
app = updated.(App)
for _, mode := range []writeMode{writePREdit, writeReplyConfirm} {
app.writeMode = mode
if got, want := app.View(), app.viewContent(); got != want {
t.Fatalf("write mode %d changed by enabled mascot:\ngot:\n%q\nwant:\n%q",
mode, got, want)
}
if strings.Contains(ansi.Strip(app.View()), "▄███████▄") {
t.Fatalf("write mode %d displayed the mascot", mode)
}
}
app.writeMode = writeNone
app.helpVisible = true
if got, want := app.View(), app.viewContent(); got != want {
t.Fatalf("help popup changed by enabled mascot:\ngot:\n%q\nwant:\n%q", got, want)
}
}
func TestDiffletEnabledEditorCanRevealLastDescriptionRow(t *testing.T) {
app := NewAppWithSettings(
&recordingPRService{}, "owner", "repository", false, 10, 10,
AppSettings{Mascot: true},
)
app.screen = dashboardScreen
app.loading = false
app.width, app.height = 50, 12
app.details = PRDetails{
PullRequest: PullRequest{
ID: "pr", RepoWithOwner: "owner/repository", Number: 42,
Title: "Title",
},
BaseRef: "main",
Body: strings.Repeat("description row\n", 20) + "LAST DESCRIPTION ROW",
Permissions: ViewerPermissions{
CanUpdatePR: true,
},
}
app.startPREdit()
app.prEditEditors[prEditBodyField].Cursor =
len([]rune(app.prEditEditors[prEditBodyField].Text))
app.ensurePREditCursorVisible()
rendered := ansi.Strip(app.View())
if !strings.Contains(rendered, "LAST DESCRIPTION ROW") {
t.Fatalf("last description row is outside the editor viewport:\n%s", rendered)
}
if strings.Contains(rendered, "▄███████▄") {
t.Fatal("editor displayed the mascot instead of using its full height")
}
}
func TestDashboardDiffletRemainsCenteredAtNarrowWidths(t *testing.T) {
mascot := (DiffletFrame{Expression: DiffletIdle}).lines()
metadata := []string{"author", "branches", "review", "checks"}
for width := diffletWidth; width < 20; width++ {
rendered := renderDashboardMetadataWithDifflet(metadata, mascot, width)
for row, mascotLine := range mascot {
mascotText := strings.TrimRight(mascotLine, " ")
var mascotRow string
for _, line := range rendered {
if strings.Contains(line, mascotText) {
mascotRow = line
break
}
}
index := strings.Index(mascotRow, mascotText)
left := -1
if index >= 0 {
left = lipgloss.Width(mascotRow[:index])
}
if want := max(0, (width-diffletWidth)/2); left != want {
t.Fatalf("width %d row %d mascot starts at %d, want %d: %q",
width, row, left, want, mascotRow)
}
}
}
}
func TestDashboardLoadingDiffletIsCentered(t *testing.T) {
app := NewAppWithSettings(
&recordingService{}, "owner", "repository", false, 10, 10,
AppSettings{Mascot: true},
)
app.screen = dashboardScreen
app.loading = true
app.details = PRDetails{PullRequest: PullRequest{
RepoWithOwner: "owner/repository", Number: 42, Title: "Title",
}}
updated, _ := app.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
app = updated.(App)
rendered := strings.Split(app.View(), "\n")
mascotLine := strings.TrimRight((DiffletFrame{Expression: DiffletIdle}).lines()[0], " ")
for row, line := range rendered {
plain := ansi.Strip(line)
index := strings.Index(plain, mascotLine)
if index < 0 {
continue
}
if left := lipgloss.Width(plain[:index]); left != (app.width-diffletWidth)/2 {
t.Fatalf("loading mascot starts at %d, want %d: %q",
left, (app.width-diffletWidth)/2, plain)
}
if row != len(app.dashboardHeaderLines()) {
t.Fatalf("loading mascot begins on row %d, want %d",
row, len(app.dashboardHeaderLines()))
}
return
}
t.Fatal("loading dashboard did not display the mascot")
}
func TestDiffletHeaderMeasurementMatchesRenderedHeader(t *testing.T) { func TestDiffletHeaderMeasurementMatchesRenderedHeader(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

91
tui.go
View File

@@ -1874,7 +1874,7 @@ func (m App) dashboardViewportHeight() int {
} }
func (m App) dashboardMaxScroll() int { func (m App) dashboardMaxScroll() int {
return max(0, len(m.dashboardLines())-m.dashboardViewportHeight()) return max(0, len(m.dashboardDisplayLines())-m.dashboardViewportHeight())
} }
func (m App) detailPaneSize() (int, int) { func (m App) detailPaneSize() (int, int) {
@@ -1924,6 +1924,12 @@ func (m App) View() string {
if len(mascot) != diffletHeight { if len(mascot) != diffletHeight {
return m.viewContent() return m.viewContent()
} }
if m.diffletHiddenForCurrentView() {
return m.viewContent()
}
if m.screen == dashboardScreen {
return m.viewDashboardWithLines(m.dashboardLinesWithDifflet(mascot))
}
gap := diffletGap gap := diffletGap
headerWidth := diffletHeaderWidth(m.width) headerWidth := diffletHeaderWidth(m.width)
if headerWidth < 1 { if headerWidth < 1 {
@@ -1959,11 +1965,15 @@ func (m App) View() string {
) )
} }
func (m App) diffletHeaderLineCount() (int, bool) { func (m App) diffletHiddenForCurrentView() bool {
if m.helpVisible || return m.helpVisible ||
(m.aiMode != aiNone && m.aiMode != aiDiscussion) || (m.aiMode != aiNone && m.aiMode != aiDiscussion) ||
(m.writeMode != writeNone && m.writeMode != writeReply && m.writeMode != writePREdit) || (m.writeMode != writeNone && m.writeMode != writeReply) ||
m.screen == healthScreen { m.screen == healthScreen
}
func (m App) diffletHeaderLineCount() (int, bool) {
if m.diffletHiddenForCurrentView() {
return 0, false return 0, false
} }
switch m.screen { switch m.screen {
@@ -1973,9 +1983,6 @@ func (m App) diffletHeaderLineCount() (int, bool) {
if m.scroll > 0 { if m.scroll > 0 {
return 0, false return 0, false
} }
if m.writeMode == writePREdit {
return 1, true
}
return len(m.dashboardHeaderLines()), true return len(m.dashboardHeaderLines()), true
case threadScreen: case threadScreen:
return len(m.threadTopLines()), true return len(m.threadTopLines()), true
@@ -2563,7 +2570,10 @@ func groupedPRRows(prs []PullRequest, selected int) ([]prListRow, int) {
} }
func (m App) viewDashboard() string { func (m App) viewDashboard() string {
lines := m.dashboardLines() return m.viewDashboardWithLines(m.dashboardLines())
}
func (m App) viewDashboardWithLines(lines []string) string {
viewportHeight := m.dashboardViewportHeight() viewportHeight := m.dashboardViewportHeight()
maxScroll := max(0, len(lines)-viewportHeight) maxScroll := max(0, len(lines)-viewportHeight)
scroll := min(m.scroll, maxScroll) scroll := min(m.scroll, maxScroll)
@@ -2594,6 +2604,69 @@ func (m App) viewDashboard() string {
return view return view
} }
func (m App) dashboardDisplayLines() []string {
mascot := m.difflet.frameLines()
if len(mascot) == diffletHeight && !m.diffletHiddenForCurrentView() {
return m.dashboardLinesWithDifflet(mascot)
}
return m.dashboardLines()
}
func (m App) dashboardLinesWithDifflet(mascot []string) []string {
lines := m.dashboardLines()
headerLines := len(m.dashboardHeaderLines())
metadataStart := headerLines + 1
if headerLines >= len(lines) ||
strings.TrimSpace(ansi.Strip(lines[headerLines])) != "" {
return lines
}
if len(lines) < metadataStart+diffletHeight {
result := append([]string(nil), lines[:headerLines]...)
result = append(result, centeredDiffletLines(mascot, m.width)...)
return append(result, lines[metadataStart:]...)
}
band := renderDashboardMetadataWithDifflet(
lines[metadataStart:metadataStart+diffletHeight],
mascot,
m.width,
)
result := append([]string(nil), lines[:headerLines]...)
result = append(result, band...)
return append(result, lines[metadataStart+diffletHeight:]...)
}
func renderDashboardMetadataWithDifflet(metadata, mascot []string, width int) []string {
mascotLeft := max(0, (width-diffletWidth)/2)
if width <= diffletWidth || mascotLeft < diffletGap {
centered := centeredDiffletLines(mascot, width)
return append(centered, metadata...)
}
metadataWidth := max(1, mascotLeft-diffletGap)
height := max(len(metadata), len(mascot))
rendered := make([]string, 0, height)
for row := range height {
left, right := "", ""
if row < len(metadata) {
left = ansi.Truncate(metadata[row], metadataWidth, "…")
}
if row < len(mascot) {
right = mascot[row]
}
rendered = append(rendered, pad(left, mascotLeft)+right)
}
return rendered
}
func centeredDiffletLines(mascot []string, width int) []string {
centered := make([]string, 0, len(mascot))
for _, line := range mascot {
centered = append(centered, lipgloss.PlaceHorizontal(
width, lipgloss.Center, line,
))
}
return centered
}
func (m App) viewHealth() string { func (m App) viewHealth() string {
lines := m.healthLines() lines := m.healthLines()
viewportHeight := m.healthViewportHeight() viewportHeight := m.healthViewportHeight()