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.
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
right next to the active view's naturally sized header, separated by a
small gap. On normal terminal widths Difflet is centered horizontally and the
header uses the space to its left. When centering would make the header too
narrow, Difflet falls back to a small right-edge inset. Header information
wraps when the combined header and mascot do not fit. The layout adds only the
vertical rows required to display the four-line mascot.
Difflet is disabled by default. Set `mascot = true` to show it on the pull
request picker, dashboard, and thread screens. On the dashboard it is centered
beside the first metadata rows so it does not add whitespace below the pull
request title. Editor and popup views hide it to preserve their full usable
height. On other supported screens Difflet sits to the right of the naturally
sized header. Header information wraps when the combined header and mascot do
not fit.
`mascot_animated` controls brief loading, blink, success, and error motion
independently from `mascot_expressive`, which permits stronger emotional
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) {
tests := []struct {
name string

91
tui.go
View File

@@ -1874,7 +1874,7 @@ func (m App) dashboardViewportHeight() 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) {
@@ -1924,6 +1924,12 @@ func (m App) View() string {
if len(mascot) != diffletHeight {
return m.viewContent()
}
if m.diffletHiddenForCurrentView() {
return m.viewContent()
}
if m.screen == dashboardScreen {
return m.viewDashboardWithLines(m.dashboardLinesWithDifflet(mascot))
}
gap := diffletGap
headerWidth := diffletHeaderWidth(m.width)
if headerWidth < 1 {
@@ -1959,11 +1965,15 @@ func (m App) View() string {
)
}
func (m App) diffletHeaderLineCount() (int, bool) {
if m.helpVisible ||
func (m App) diffletHiddenForCurrentView() bool {
return m.helpVisible ||
(m.aiMode != aiNone && m.aiMode != aiDiscussion) ||
(m.writeMode != writeNone && m.writeMode != writeReply && m.writeMode != writePREdit) ||
m.screen == healthScreen {
(m.writeMode != writeNone && m.writeMode != writeReply) ||
m.screen == healthScreen
}
func (m App) diffletHeaderLineCount() (int, bool) {
if m.diffletHiddenForCurrentView() {
return 0, false
}
switch m.screen {
@@ -1973,9 +1983,6 @@ func (m App) diffletHeaderLineCount() (int, bool) {
if m.scroll > 0 {
return 0, false
}
if m.writeMode == writePREdit {
return 1, true
}
return len(m.dashboardHeaderLines()), true
case threadScreen:
return len(m.threadTopLines()), true
@@ -2563,7 +2570,10 @@ func groupedPRRows(prs []PullRequest, selected int) ([]prListRow, int) {
}
func (m App) viewDashboard() string {
lines := m.dashboardLines()
return m.viewDashboardWithLines(m.dashboardLines())
}
func (m App) viewDashboardWithLines(lines []string) string {
viewportHeight := m.dashboardViewportHeight()
maxScroll := max(0, len(lines)-viewportHeight)
scroll := min(m.scroll, maxScroll)
@@ -2594,6 +2604,69 @@ func (m App) viewDashboard() string {
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 {
lines := m.healthLines()
viewportHeight := m.healthViewportHeight()