diff --git a/README.md b/README.md index 701c825..3f7b089 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/difflet_test.go b/difflet_test.go index 0243fe6..c8790fb 100644 --- a/difflet_test.go +++ b/difflet_test.go @@ -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 diff --git a/tui.go b/tui.go index ad2dad9..45b78ea 100644 --- a/tui.go +++ b/tui.go @@ -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()