From 28e418abd6863624654b55f50c64bdeb9d8bb623 Mon Sep 17 00:00:00 2001 From: Pablu Date: Wed, 29 Jul 2026 08:33:37 +0200 Subject: [PATCH] add mascot --- README.md | 15 ++ config.go | 42 +++--- config_test.go | 4 + difflet.go | 328 ++++++++++++++++++++++++++++++++++++++++++ difflet_test.go | 370 ++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 3 + pr_editor.go | 2 +- tui.go | 294 ++++++++++++++++++++++++++++++++------ 8 files changed, 994 insertions(+), 64 deletions(-) create mode 100644 difflet.go create mode 100644 difflet_test.go diff --git a/README.md b/README.md index a1b6705..701c825 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,9 @@ repository = "" # optional "owner/repository" show_all = false # requires repository limit = 50 # 1-1000 endpoint = "https://api.github.com/graphql" +mascot = false # show the optional Difflet terminal mascot +mascot_expressive = false # allow emotional Difflet expressions +mascot_animated = false # allow brief state-driven motion [display] fold_resolved = true @@ -301,6 +304,18 @@ 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. +`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. +Disabling the mascot preserves the normal header layout. + Thread categories are: - `unresolved`: current unresolved threads; diff --git a/config.go b/config.go index 5be1b3e..54c13ea 100644 --- a/config.go +++ b/config.go @@ -25,20 +25,23 @@ func (d *configDuration) UnmarshalText(text []byte) error { } type Config struct { - Theme string `toml:"theme"` - RefreshInterval configDuration `toml:"refresh_interval"` - Repository string `toml:"repository"` - ShowAll bool `toml:"show_all"` - Limit int `toml:"limit"` - Endpoint string `toml:"endpoint"` - Display DisplayConfig `toml:"display"` - Paths PathConfig `toml:"paths"` - Threads ThreadConfig `toml:"threads"` - Cache CacheConfig `toml:"cache"` - Editing EditingConfig `toml:"editing"` - CustomTheme CustomThemeConfig `toml:"custom_theme"` - KeyBindings KeyBindings `toml:"keybindings"` - AI AIConfig `toml:"ai"` + Theme string `toml:"theme"` + RefreshInterval configDuration `toml:"refresh_interval"` + Repository string `toml:"repository"` + ShowAll bool `toml:"show_all"` + Limit int `toml:"limit"` + Endpoint string `toml:"endpoint"` + Mascot bool `toml:"mascot"` + MascotExpressive bool `toml:"mascot_expressive"` + MascotAnimated bool `toml:"mascot_animated"` + Display DisplayConfig `toml:"display"` + Paths PathConfig `toml:"paths"` + Threads ThreadConfig `toml:"threads"` + Cache CacheConfig `toml:"cache"` + Editing EditingConfig `toml:"editing"` + CustomTheme CustomThemeConfig `toml:"custom_theme"` + KeyBindings KeyBindings `toml:"keybindings"` + AI AIConfig `toml:"ai"` } type CustomThemeConfig struct { @@ -97,10 +100,13 @@ type EditingConfig struct { func defaultConfig() Config { return Config{ - Theme: "dark", - RefreshInterval: configDuration{10 * time.Second}, - Limit: 50, - Endpoint: "https://api.github.com/graphql", + Theme: "dark", + RefreshInterval: configDuration{10 * time.Second}, + Limit: 50, + Endpoint: "https://api.github.com/graphql", + Mascot: false, + MascotExpressive: false, + MascotAnimated: false, Display: DisplayConfig{ FoldResolved: true, ThreadListWidthPercent: 33, diff --git a/config_test.go b/config_test.go index f003193..5f5b185 100644 --- a/config_test.go +++ b/config_test.go @@ -34,6 +34,9 @@ repository = "owner/repo" show_all = true limit = 75 endpoint = "https://github.example.com/api/graphql" +mascot = true +mascot_expressive = true +mascot_animated = true [display] fold_resolved = false @@ -74,6 +77,7 @@ up = ["ctrl+k"] } if got.Theme != "light" || got.RefreshInterval.Duration != 25*time.Second || got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 || + !got.Mascot || !got.MascotExpressive || !got.MascotAnimated || got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 || got.Display.DashboardMode != "hotkey" || got.Display.CompactReviews || got.Display.ViewerLabel != "you" || diff --git a/difflet.go b/difflet.go new file mode 100644 index 0000000..be4b50e --- /dev/null +++ b/difflet.go @@ -0,0 +1,328 @@ +package main + +import ( + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ( + diffletWidth = 9 + diffletHeight = 4 + diffletGap = 2 + diffletMinimumHeaderWidth = 10 + diffletFallbackRightPadding = 1 +) + +type DiffletExpression string + +const ( + DiffletIdle DiffletExpression = "idle" + DiffletFocused DiffletExpression = "focused" + DiffletHappy DiffletExpression = "happy" + DiffletApproved DiffletExpression = "approved" + DiffletSurprised DiffletExpression = "surprised" + DiffletConcerned DiffletExpression = "concerned" + DiffletConfused DiffletExpression = "confused" + DiffletSad DiffletExpression = "sad" + DiffletAnnoyed DiffletExpression = "annoyed" + DiffletError DiffletExpression = "error" + DiffletSleeping DiffletExpression = "sleeping" + DiffletCurious DiffletExpression = "curious" +) + +var diffletFaces = map[DiffletExpression]string{ + DiffletIdle: "•_•", + DiffletFocused: "-_-", + DiffletHappy: "^_^", + DiffletApproved: "^o^", + DiffletSurprised: "•o•", + DiffletConcerned: "•^•", + DiffletConfused: "•~•", + DiffletSad: ";_;", + DiffletAnnoyed: ">_<", + DiffletError: "x_x", + DiffletSleeping: "u_u", + DiffletCurious: "o_o", +} + +type DiffletFrame struct { + Expression DiffletExpression + Feet string +} + +func (frame DiffletFrame) lines() []string { + feet := frame.Feet + if feet == "" { + feet = " ▝ ▘" + } + return []string{ + "▄███████▄", + "█+ " + diffletFaces[frame.Expression] + " -█", + "▀███████▀", + feet + strings.Repeat(" ", max(0, diffletWidth-lipgloss.Width(feet))), + } +} + +func (frame DiffletFrame) render() []string { + lines := frame.lines() + lines[1] = "█" + okStyle.Render("+") + " " + diffletFaces[frame.Expression] + + " " + badStyle.Render("-") + "█" + return lines +} + +type diffletAnimation struct { + frames []DiffletFrame + interval time.Duration + loop bool + settle DiffletExpression +} + +type diffletState int + +const ( + diffletIdle diffletState = iota + diffletLoading + diffletFocused + diffletConcerned + diffletHappy + diffletApproved + diffletSleeping + diffletNewComment + diffletSuccess + diffletRecoverableError + diffletFatalError + diffletSad +) + +type diffletTickMsg struct { + generation uint64 +} + +type diffletModel struct { + enabled bool + visible bool + expressive bool + animated bool + state diffletState + expression DiffletExpression + animation diffletAnimation + frame int + generation uint64 + blinking bool + blinkCycle uint64 +} + +func newDifflet(enabled, expressive, animated bool) diffletModel { + model := diffletModel{ + enabled: enabled, visible: true, expressive: expressive, animated: animated, + state: diffletLoading, expression: DiffletIdle, + } + _ = model.setState(diffletLoading) + return model +} + +func (d *diffletModel) start() tea.Cmd { + if !d.enabled || !d.visible || !d.animated { + return nil + } + if d.state == diffletIdle { + return d.tick(d.blinkDelay()) + } + if len(d.animation.frames) > 0 { + return d.tick(d.animation.interval) + } + return nil +} + +func (d *diffletModel) setState(state diffletState) tea.Cmd { + d.state = state + d.generation++ + d.frame = 0 + d.blinking = false + d.animation = diffletAnimation{} + d.expression = d.staticExpression(state) + if !d.enabled || !d.visible || !d.animated { + return nil + } + + switch state { + case diffletIdle: + return d.tick(d.blinkDelay()) + case diffletLoading: + d.animation = diffletAnimation{ + frames: []DiffletFrame{ + {Expression: DiffletIdle, Feet: " ▝ ▘"}, + {Expression: DiffletIdle, Feet: " ▝ ▘"}, + {Expression: DiffletIdle, Feet: " ▝ ▘"}, + {Expression: DiffletIdle, Feet: " ▝ ▘"}, + }, + interval: 180 * time.Millisecond, + loop: true, + settle: DiffletIdle, + } + case diffletNewComment: + d.animation = diffletAnimation{ + frames: []DiffletFrame{ + {Expression: DiffletIdle}, + {Expression: DiffletSurprised}, + {Expression: DiffletConcerned}, + }, + interval: 200 * time.Millisecond, + settle: DiffletConcerned, + } + case diffletSuccess: + d.animation = diffletAnimation{ + frames: []DiffletFrame{ + {Expression: DiffletIdle}, + {Expression: DiffletHappy}, + {Expression: DiffletApproved}, + {Expression: DiffletHappy}, + }, + interval: 175 * time.Millisecond, + settle: DiffletHappy, + } + case diffletRecoverableError: + if d.expressive { + d.animation = diffletAnimation{ + frames: []DiffletFrame{ + {Expression: DiffletIdle}, + {Expression: DiffletAnnoyed}, + {Expression: DiffletSad}, + }, + interval: 220 * time.Millisecond, + settle: DiffletSad, + } + } + case diffletFatalError: + if d.expressive { + d.animation = diffletAnimation{ + frames: []DiffletFrame{ + {Expression: DiffletIdle}, + {Expression: DiffletAnnoyed}, + {Expression: DiffletError}, + }, + interval: 220 * time.Millisecond, + settle: DiffletError, + } + } + } + if len(d.animation.frames) == 0 { + return nil + } + d.expression = d.animation.frames[0].Expression + return d.tick(d.animation.interval) +} + +func (d *diffletModel) setVisible(visible bool) tea.Cmd { + if d.visible == visible { + return nil + } + d.visible = visible + if !visible { + d.generation++ + d.animation = diffletAnimation{} + d.blinking = false + return nil + } + return d.setState(d.state) +} + +func (d *diffletModel) staticExpression(state diffletState) DiffletExpression { + switch state { + case diffletFocused: + return DiffletFocused + case diffletConcerned, diffletNewComment: + return DiffletConcerned + case diffletHappy, diffletSuccess: + return DiffletHappy + case diffletApproved: + return DiffletApproved + case diffletSleeping: + return DiffletSleeping + case diffletRecoverableError: + if d.expressive { + return DiffletAnnoyed + } + return DiffletConcerned + case diffletFatalError: + return DiffletError + case diffletSad: + if d.expressive { + return DiffletSad + } + return DiffletConcerned + default: + return DiffletIdle + } +} + +func (d *diffletModel) update(msg diffletTickMsg) tea.Cmd { + if !d.enabled || !d.visible || !d.animated || msg.generation != d.generation { + return nil + } + if d.state == diffletIdle { + if !d.blinking { + d.blinking = true + d.expression = DiffletFocused + return d.tick(125 * time.Millisecond) + } + d.blinking = false + d.expression = DiffletIdle + d.blinkCycle++ + return d.tick(d.blinkDelay()) + } + if len(d.animation.frames) == 0 { + return nil + } + next := d.frame + 1 + if next >= len(d.animation.frames) { + if !d.animation.loop { + settle := d.animation.settle + d.animation = diffletAnimation{} + d.expression = settle + return nil + } + next = 0 + } + d.frame = next + d.expression = d.animation.frames[next].Expression + return d.tick(d.animation.interval) +} + +func (d diffletModel) frameLines() []string { + if !d.enabled || !d.visible { + return nil + } + if len(d.animation.frames) > 0 && d.frame < len(d.animation.frames) { + frame := d.animation.frames[d.frame] + frame.Expression = d.expression + return frame.render() + } + return (DiffletFrame{Expression: d.expression}).render() +} + +func diffletHeaderWidth(width int) int { + centeredLeft := max(0, (width-diffletWidth)/2) + if centeredLeft-diffletGap >= diffletMinimumHeaderWidth { + return centeredLeft - diffletGap + } + rightPadding := 0 + if width-diffletWidth-diffletGap > 1 { + rightPadding = diffletFallbackRightPadding + } + return max(1, width-diffletWidth-diffletGap-rightPadding) +} + +func (d diffletModel) tick(after time.Duration) tea.Cmd { + generation := d.generation + return tea.Tick(after, func(time.Time) tea.Msg { + return diffletTickMsg{generation: generation} + }) +} + +func (d diffletModel) blinkDelay() time.Duration { + return 5*time.Second + time.Duration((d.generation+d.blinkCycle*3)%6)*650*time.Millisecond +} diff --git a/difflet_test.go b/difflet_test.go new file mode 100644 index 0000000..0243fe6 --- /dev/null +++ b/difflet_test.go @@ -0,0 +1,370 @@ +package main + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/muesli/termenv" +) + +func TestDiffletExpressionsAndFramesHaveStableCellDimensions(t *testing.T) { + expressions := []DiffletExpression{ + DiffletIdle, DiffletFocused, DiffletHappy, DiffletApproved, + DiffletSurprised, DiffletConcerned, DiffletConfused, DiffletSad, + DiffletAnnoyed, DiffletError, DiffletSleeping, DiffletCurious, + } + for _, expression := range expressions { + face := diffletFaces[expression] + if width := lipgloss.Width(face); width != 3 { + t.Errorf("%s face width = %d, want 3: %q", expression, width, face) + } + lines := (DiffletFrame{Expression: expression}).lines() + if len(lines) != diffletHeight { + t.Fatalf("%s has %d lines, want %d", expression, len(lines), diffletHeight) + } + for row, line := range lines { + if width := lipgloss.Width(line); width != diffletWidth { + t.Errorf("%s row %d width = %d, want %d: %q", + expression, row, width, diffletWidth, line) + } + } + } +} + +func TestDiffletCanonicalFramePreservesGlyphsAndSpacing(t *testing.T) { + got := (DiffletFrame{Expression: DiffletIdle}).lines() + want := []string{ + "▄███████▄", + "█+ •_• -█", + "▀███████▀", + " ▝ ▘ ", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("canonical frame:\n%q\nwant:\n%q", got, want) + } +} + +func TestDiffletStylesOnlyDiffSigns(t *testing.T) { + previousProfile := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(previousProfile) }) + previousOK, previousBad := okStyle, badStyle + okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00ff00")) + badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")) + t.Cleanup(func() { okStyle, badStyle = previousOK, previousBad }) + + rendered := (DiffletFrame{Expression: DiffletIdle}).render() + wantMiddle := "█" + okStyle.Render("+") + " •_• " + badStyle.Render("-") + "█" + if rendered[1] != wantMiddle { + t.Fatalf("middle row = %q, want %q", rendered[1], wantMiddle) + } + if ansi.Strip(strings.Join(rendered, "\n")) != + strings.Join((DiffletFrame{Expression: DiffletIdle}).lines(), "\n") { + t.Fatal("styling changed Difflet glyphs or spacing") + } + for _, row := range []int{0, 2, 3} { + if strings.Contains(rendered[row], "\x1b[") { + t.Fatalf("row %d unexpectedly styled: %q", row, rendered[row]) + } + } +} + +func TestDiffletEnabledSitsRightOfWrappingHeader(t *testing.T) { + app := NewAppWithSettings( + &recordingService{}, "owner", "repository", false, 10, 10, + AppSettings{Mascot: true}, + ) + updated, _ := app.Update(tea.WindowSizeMsg{Width: 24, Height: 12}) + app = updated.(App) + rendered := strings.Split(app.View(), "\n") + if len(rendered) < diffletHeight+2 { + t.Fatalf("rendered rows = %d, want at least %d", len(rendered), diffletHeight+2) + } + + headerWidth := diffletHeaderWidth(app.width) + content := app + content.headerWidth = headerWidth + _, _, ok := splitHeader(content.viewContent()) + if !ok { + t.Fatal("test view did not expose a header") + } + wantMascotLeft := headerWidth + diffletGap + for row, mascotLine := range (DiffletFrame{Expression: DiffletIdle}).lines() { + plain := ansi.Strip(rendered[row]) + if !strings.Contains(plain, strings.TrimRight(mascotLine, " ")) { + t.Fatalf("mascot row %d is not visible: %q", row, plain) + } + left := strings.Index(plain, strings.TrimRight(mascotLine, " ")) + if left != wantMascotLeft { + t.Fatalf("mascot row %d starts at %d, want adjacent position %d", + row, left, wantMascotLeft) + } + } + blankRow := -1 + var headerText strings.Builder + for row := 0; row < len(rendered); row++ { + line := rendered[row] + plain := ansi.Strip(line) + if strings.TrimSpace(plain) == "" { + blankRow = row + break + } + headerText.WriteString(strings.TrimSpace( + ansi.Cut(plain, 0, wantMascotLeft-diffletGap), + )) + } + normalizedHeader := strings.ReplaceAll(headerText.String(), " ", "") + for _, information := range []string{"diple", "owner/repository", "assignedtoyou"} { + if !strings.Contains(normalizedHeader, information) { + t.Fatalf("wrapped header does not contain %q: %s", information, normalizedHeader) + } + } + if blankRow < diffletHeight { + t.Fatalf("blank row = %d, want at or below mascot row %d", + blankRow, diffletHeight) + } +} + +func TestDiffletIsCenteredAtNormalWidths(t *testing.T) { + mascot := (DiffletFrame{Expression: DiffletIdle}).lines() + const width = 40 + headerWidth := diffletHeaderWidth(width) + rendered := strings.Split( + renderHeaderWithDifflet([]string{"diple"}, mascot, width, headerWidth, diffletGap), + "\n", + ) + for row, line := range rendered[:diffletHeight] { + mascotText := strings.TrimRight(mascot[row], " ") + wantLeft := (width - diffletWidth) / 2 + if left := strings.Index(ansi.Strip(line), mascotText); left != wantLeft { + t.Fatalf("row %d mascot starts at %d, want %d: %q", + row, left, wantLeft, line) + } + } +} + +func TestDiffletLongHeaderKeepsMascotCentered(t *testing.T) { + const width = 80 + headerWidth := diffletHeaderWidth(width) + if headerWidth != 33 { + t.Fatalf("header width = %d, want 33", headerWidth) + } + mascot := (DiffletFrame{Expression: DiffletIdle}).lines() + rendered := strings.Split( + renderHeaderWithDifflet( + []string{strings.Repeat("x", headerWidth)}, + mascot, + width, + headerWidth, + diffletGap, + ), + "\n", + ) + mascotLeft := strings.Index(ansi.Strip(rendered[0]), strings.TrimRight(mascot[0], " ")) + if mascotLeft != headerWidth+diffletGap { + t.Fatalf("mascot starts at %d, want %d", mascotLeft, headerWidth+diffletGap) + } + if mascotLeft != (width-diffletWidth)/2 { + t.Fatalf("mascot starts at %d, want centered position %d", + mascotLeft, (width-diffletWidth)/2) + } +} + +func TestDiffletFallsBackToSmallRightPadding(t *testing.T) { + const width = 24 + headerWidth := diffletHeaderWidth(width) + mascotLeft := headerWidth + diffletGap + if rightPadding := width - mascotLeft - diffletWidth; rightPadding != 1 { + t.Fatalf("fallback right padding = %d, want 1", rightPadding) + } +} + +func TestDiffletDisabledPreservesViewExactly(t *testing.T) { + app := NewAppWithSettings( + &recordingService{}, "", "", false, 10, 10, + AppSettings{MascotExpressive: true, MascotAnimated: true}, + ) + updated, _ := app.Update(tea.WindowSizeMsg{Width: 20, Height: 12}) + app = updated.(App) + got := app.View() + want := app.viewContent() + if got != want { + t.Fatalf("disabled mascot changed rendered view:\ngot:\n%q\nwant:\n%q", got, want) + } +} + +func TestDiffletHeaderMeasurementMatchesRenderedHeader(t *testing.T) { + tests := []struct { + name string + screen screen + }{ + {name: "picker", screen: prScreen}, + {name: "dashboard", screen: dashboardScreen}, + {name: "threads", screen: threadScreen}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + app := NewAppWithSettings( + &recordingService{}, "owner", "repository", false, 10, 10, + AppSettings{Mascot: true}, + ) + app.screen = test.screen + app.details = PRDetails{PullRequest: PullRequest{ + Owner: "owner", Repository: "repository", + RepoWithOwner: "owner/repository", Number: 42, + Title: "A pull request with a useful title", + }} + updated, _ := app.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + app = updated.(App) + app.headerWidth = diffletHeaderWidth(app.width) + + measured, ok := app.diffletHeaderLineCount() + if !ok { + t.Fatal("normal screen did not expose measurable header lines") + } + renderedHeader, _, ok := splitHeader(app.viewContent()) + if !ok { + t.Fatal("rendered normal screen did not expose a header") + } + if measured != len(renderedHeader) { + t.Fatalf("measured header lines = %d, rendered = %d", + measured, len(renderedHeader)) + } + }) + } +} + +func TestDiffletKeepsCompleteFrameAtNarrowPhysicalMinimum(t *testing.T) { + app := NewAppWithSettings( + &recordingService{}, "", "", false, 10, 10, + AppSettings{Mascot: true}, + ) + updated, _ := app.Update(tea.WindowSizeMsg{Width: diffletWidth, Height: 12}) + app = updated.(App) + rendered := strings.Split(app.View(), "\n") + for row, mascotLine := range (DiffletFrame{Expression: DiffletIdle}).lines() { + if got := ansi.Strip(rendered[row]); got != mascotLine { + t.Fatalf("mascot row %d = %q, want %q", row, got, mascotLine) + } + } + headerBelowMascot := ansi.Strip(strings.Join(rendered[diffletHeight:], "\n")) + normalizedHeader := strings.NewReplacer("\n", "", " ", "").Replace(headerBelowMascot) + if !strings.Contains(normalizedHeader, "diple") { + t.Fatalf("header did not wrap below complete mascot: %q", headerBelowMascot) + } +} + +func TestDiffletStopsTicksBelowPhysicalMinimum(t *testing.T) { + app := NewAppWithSettings( + &recordingService{}, "", "", false, 10, 10, + AppSettings{Mascot: true, MascotAnimated: true}, + ) + if command := app.difflet.start(); command != nil { + t.Fatal("hidden startup mascot scheduled a tick") + } + updated, command := app.Update(tea.WindowSizeMsg{Width: 12, Height: 12}) + app = updated.(App) + if command == nil { + t.Fatal("visible animated mascot did not schedule a tick") + } + generation := app.difflet.generation + updated, command = app.Update(tea.WindowSizeMsg{Width: 8, Height: 12}) + app = updated.(App) + if command != nil { + t.Fatal("mascot below its nine-cell minimum scheduled a tick") + } + if app.difflet.visible { + t.Fatal("mascot below its nine-cell minimum remained visible") + } + updated, command = app.Update(diffletTickMsg{generation: generation}) + app = updated.(App) + if command != nil || app.difflet.visible { + t.Fatal("stale tick restarted hidden mascot") + } +} + +func TestDiffletStaticOptionsSelectFinalSafeExpression(t *testing.T) { + model := newDifflet(true, false, false) + if command := model.setState(diffletSuccess); command != nil { + t.Fatal("animation-disabled Difflet scheduled a tick") + } + if model.expression != DiffletHappy { + t.Fatalf("static success = %s, want %s", model.expression, DiffletHappy) + } + if command := model.setState(diffletRecoverableError); command != nil { + t.Fatal("non-expressive Difflet scheduled an error animation") + } + if model.expression != DiffletConcerned { + t.Fatalf("non-expressive error = %s, want %s", model.expression, DiffletConcerned) + } + model.setState(diffletFatalError) + if model.expression != DiffletError { + t.Fatalf("non-expressive fatal error = %s, want %s", model.expression, DiffletError) + } +} + +func TestDiffletFocusedStateDoesNotLoop(t *testing.T) { + model := newDifflet(true, true, true) + if command := model.setState(diffletFocused); command != nil { + t.Fatal("focused state scheduled continuous animation") + } + if model.expression != DiffletFocused || len(model.animation.frames) != 0 { + t.Fatalf("focused state = expression %s, animation %#v", + model.expression, model.animation) + } +} + +func TestDiffletLoadingFootShuffleSequence(t *testing.T) { + model := newDifflet(true, false, true) + if command := model.setState(diffletLoading); command == nil { + t.Fatal("loading animation did not schedule a tick") + } + want := []string{" ▝ ▘ ", " ▝ ▘ ", " ▝ ▘ ", " ▝ ▘ "} + for index, feet := range want { + if got := model.frameLines()[3]; got != feet { + t.Fatalf("loading frame %d feet = %q, want %q", index, got, feet) + } + if index < len(want)-1 { + if command := model.update(diffletTickMsg{generation: model.generation}); command == nil { + t.Fatalf("loading frame %d stopped looping", index) + } + } + } +} + +func TestDiffletOneShotStopsAndSettles(t *testing.T) { + model := newDifflet(true, true, true) + model.setState(diffletSuccess) + for index := 0; index < 3; index++ { + if command := model.update(diffletTickMsg{generation: model.generation}); command == nil { + t.Fatalf("success animation stopped at frame %d", index) + } + } + if command := model.update(diffletTickMsg{generation: model.generation}); command != nil { + t.Fatal("completed success animation scheduled another tick") + } + if len(model.animation.frames) != 0 || model.expression != DiffletHappy { + t.Fatalf("success settled with animation=%#v expression=%s", + model.animation, model.expression) + } +} + +func TestDiffletLoopStopsOnStateChangeAndRejectsStaleTick(t *testing.T) { + model := newDifflet(true, true, true) + model.setState(diffletLoading) + oldGeneration := model.generation + if command := model.update(diffletTickMsg{generation: oldGeneration}); command == nil { + t.Fatal("loading animation did not continue") + } + model.setState(diffletConcerned) + if command := model.update(diffletTickMsg{generation: oldGeneration}); command != nil { + t.Fatal("stale tick scheduled another tick") + } + if model.expression != DiffletConcerned || model.frame != 0 { + t.Fatalf("stale tick changed newer state: expression=%s frame=%d", + model.expression, model.frame) + } +} diff --git a/main.go b/main.go index 6b9417d..9cf46c3 100644 --- a/main.go +++ b/main.go @@ -191,6 +191,9 @@ func main() { KeyBindings: config.KeyBindings, AI: aiController, AIStore: aiStore, + Mascot: config.Mascot, + MascotExpressive: config.MascotExpressive, + MascotAnimated: config.MascotAnimated, }, ) cursorOutput := newTerminalCursorOutput(os.Stdout) diff --git a/pr_editor.go b/pr_editor.go index 4007e8d..eff3431 100644 --- a/pr_editor.go +++ b/pr_editor.go @@ -292,7 +292,7 @@ func (m App) positionPREditHardwareCursor(scroll, viewportHeight int) { _, column := editorCursorVisualPosition(editor, m.prEditEditorWidth()) // Rows and columns are one-based. Each editor row has a two-cell "│ " // context rail before its text. - m.cursorOutput.SetCursor(true, column+3, screenRow+1) + m.cursorOutput.SetCursor(true, column+3, m.contentTop+screenRow+1) } func (m App) submitPREdit() tea.Cmd { diff --git a/tui.go b/tui.go index edf6367..ad2dad9 100644 --- a/tui.go +++ b/tui.go @@ -137,6 +137,8 @@ type App struct { listHidden bool scroll int width, height int + contentTop int + headerWidth int loading bool secondaryLoading bool err error @@ -201,6 +203,7 @@ type App struct { aiProgress AIRunProgress aiSpinner int aiEvents <-chan tea.Msg + difflet diffletModel } type AppSettings struct { @@ -219,6 +222,9 @@ type AppSettings struct { Drafts *draftStore AI *AIController AIStore *AIStore + Mascot bool + MascotExpressive bool + MascotAnimated bool } func defaultAppSettings() AppSettings { @@ -252,6 +258,8 @@ func NewAppWithSettings( if state == nil { state = &readStateStore{Data: make(map[string]readPRState)} } + difflet := newDifflet(settings.Mascot, settings.MascotExpressive, settings.MascotAnimated) + difflet.visible = false return App{ service: service, owner: owner, repo: repo, showAll: showAll, limit: limit, poll: poll, folded: make(map[string]bool), loading: true, @@ -273,6 +281,7 @@ func NewAppWithSettings( unreadComments: make(map[string]bool), newThreads: make(map[string]bool), updatedThreads: make(map[string]bool), ai: settings.AI, aiStore: settings.AIStore, + difflet: difflet, } } @@ -281,6 +290,9 @@ func (m App) Init() tea.Cmd { if m.pathScroll { commands = append(commands, m.nextPathTick()) } + if command := m.difflet.start(); command != nil { + commands = append(commands, command) + } return tea.Batch(commands...) } @@ -599,7 +611,7 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) { switch k { case "y": m.writeMode = writeReplyBusy - return m, m.submitReply() + return m, tea.Batch(m.submitReply(), m.difflet.setState(diffletLoading)) case "n", "esc": m.writeMode = writeReply m.scroll = m.detailMaxScroll() @@ -608,7 +620,7 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) { switch k { case "y": m.writeMode = writeResolveBusy - return m, m.submitResolution() + return m, tea.Batch(m.submitResolution(), m.difflet.setState(diffletLoading)) case "n", "esc": m.writeMode, m.writeThreadID = writeNone, "" } @@ -616,7 +628,7 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) { switch k { case "y": m.writeMode = writeAutoMergeBusy - return m, m.submitAutoMerge() + return m, tea.Batch(m.submitAutoMerge(), m.difflet.setState(diffletLoading)) case "n", "esc": m.writeMode = writeNone } @@ -624,7 +636,7 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) { switch k { case "y": m.writeMode = writeMergeNowBusy - return m, m.submitMergeNow() + return m, tea.Batch(m.submitMergeNow(), m.difflet.setState(diffletLoading)) case "n", "esc": m.writeMode = writeNone } @@ -688,6 +700,9 @@ func (m App) submitMergeNow() tea.Cmd { } func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if tick, ok := msg.(diffletTickMsg); ok { + return m, m.difflet.update(tick) + } if m.aiMode != aiNone { if updated, command, handled := m.updateAI(msg); handled { return updated, command @@ -696,17 +711,22 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height + return m, m.difflet.setVisible( + m.width >= diffletWidth && + m.height >= diffletHeight+4, + ) case tickMsg: if !m.loading { m.loading = true + diffletCommand := m.difflet.setState(diffletLoading) targetScreen := m.screen if targetScreen == healthScreen { targetScreen = m.healthReturn } if (targetScreen == dashboardScreen || targetScreen == threadScreen) && m.details.Number != 0 { - return m, tea.Batch(m.loadDetails(m.details.PullRequest, false), m.nextTick()) + return m, tea.Batch(m.loadDetails(m.details.PullRequest, false), m.nextTick(), diffletCommand) } - return m, tea.Batch(m.loadPRs(false), m.nextTick()) + return m, tea.Batch(m.loadPRs(false), m.nextTick(), diffletCommand) } return m, m.nextTick() case pathTickMsg: @@ -734,8 +754,9 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.err = msg.err + command := m.difflet.setState(diffletSad) m.recordHealth("pull request list", healthError, msg.err.Error()) - return m, nil + return m, command } selected := "" if len(m.prs) > 0 && m.prIndex < len(m.prs) { @@ -756,6 +777,10 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.lastRefresh = time.Now() } + if len(m.prs) == 0 { + return m, m.difflet.setState(diffletSleeping) + } + return m, m.difflet.setState(diffletIdle) case detailsLoadedMsg: if m.requests != nil && !m.requests.current(msg.requestID) { return m, nil @@ -781,8 +806,9 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.err = msg.err + command := m.difflet.setState(diffletSad) m.recordHealth("PR refresh", healthError, msg.err.Error()) - return m, nil + return m, command } selected := "" anchor := "" @@ -801,6 +827,11 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.trackThreadUpdates(msg.details) sortReviewThreads(msg.details.Threads, m.threadStatusOrder, m.threadWithinStatus) m.details = msg.details + m.err = nil + diffletCommand := m.difflet.setState(m.restingDiffletState()) + if len(m.updatedThreads) > 0 { + diffletCommand = m.difflet.setState(diffletNewComment) + } for _, issue := range msg.details.DataIssues { m.recordHealth(issue.Component, healthWarning, issue.Message) } @@ -832,9 +863,11 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.lastRefresh = time.Now() if _, ok := m.service.(GitHubEnrichmentService); ok { m.secondaryLoading = true - return m, m.loadDetailsEnrichment(msg.details) + diffletCommand = m.difflet.setState(diffletLoading) + return m, tea.Batch(m.loadDetailsEnrichment(msg.details), diffletCommand) } } + return m, diffletCommand case detailsEnrichedMsg: if m.requests != nil && !m.requests.current(msg.requestID) { return m, nil @@ -866,6 +899,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.recordHealth(issue.Component, healthWarning, issue.Message) } + return m, m.difflet.setState(m.restingDiffletState()) case draftFlushMsg: if msg.err != nil { m.recordHealth("draft persistence", healthWarning, msg.err.Error()) @@ -907,7 +941,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.err != nil { m.err = fmt.Errorf("change thread resolution: %w", msg.err) m.recordHealth("thread resolution", healthError, msg.err.Error()) - return m, nil + return m, m.difflet.setState(diffletRecoverableError) } selected := "" if m.threadIndex >= 0 && m.threadIndex < len(m.details.Threads) { @@ -931,13 +965,14 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.writeThreadID = "" m.err = nil m.lastRefresh = time.Now() + return m, m.difflet.setState(diffletSuccess) case threadRepliedMsg: if msg.err != nil { m.writeMode = writeReply m.err = fmt.Errorf("reply to review thread: %w", msg.err) m.recordHealth("thread reply", healthError, msg.err.Error()) m.scroll = m.detailMaxScroll() - return m, nil + return m, m.difflet.setState(diffletRecoverableError) } m.writeMode = writeNone for index := range m.details.Threads { @@ -957,12 +992,13 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.replyDraft, m.writeThreadID = "", "" m.err = nil m.lastRefresh = time.Now() + return m, m.difflet.setState(diffletSuccess) case autoMergeUpdatedMsg: m.writeMode = writeNone if msg.err != nil { m.err = fmt.Errorf("change auto-merge: %w", msg.err) m.recordHealth("auto-merge", healthError, msg.err.Error()) - return m, nil + return m, m.difflet.setState(diffletRecoverableError) } m.err = nil if msg.enabled { @@ -975,12 +1011,13 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.details.Permissions.CanDisableMerge = false } m.lastRefresh = time.Now() + return m, m.difflet.setState(diffletSuccess) case pullRequestMergedMsg: m.writeMode = writeNone if msg.err != nil { m.err = fmt.Errorf("merge pull request: %w", msg.err) m.recordHealth("merge pull request", healthError, msg.err.Error()) - return m, nil + return m, m.difflet.setState(diffletRecoverableError) } m.err = nil m.details.Merged = msg.result.Merged @@ -988,6 +1025,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.details.State = "MERGED" m.details.AutoMerge = nil m.lastRefresh = time.Now() + return m, m.difflet.setState(diffletSuccess) case pullRequestUpdatedMsg: if msg.err != nil { m.writeMode = writePREdit @@ -999,7 +1037,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = fmt.Errorf("update pull request: %w", msg.err) m.recordHealth("PR metadata update", healthError, msg.err.Error()) m.scroll = 0 - return m, nil + return m, m.difflet.setState(diffletRecoverableError) } m.writeMode = writeNone m.details.Title = msg.metadata.Title @@ -1031,7 +1069,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = nil m.lastRefresh = time.Now() m.loading = true - return m, m.loadDetails(m.details.PullRequest, false) + return m, tea.Batch(m.loadDetails(m.details.PullRequest, false), m.difflet.setState(diffletLoading)) } key, ok := msg.(tea.KeyMsg) @@ -1172,14 +1210,15 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.loading = true + diffletCommand := m.difflet.setState(diffletLoading) targetScreen := m.screen if targetScreen == healthScreen { targetScreen = m.healthReturn } if targetScreen == dashboardScreen || targetScreen == threadScreen { - return m, m.loadDetails(m.details.PullRequest, false) + return m, tea.Batch(m.loadDetails(m.details.PullRequest, false), diffletCommand) } - return m, m.loadPRs(false) + return m, tea.Batch(m.loadPRs(false), diffletCommand) case "j", "down": if m.screen == dashboardScreen { m.scrollDashboard(1) @@ -1282,14 +1321,14 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.screen, m.err, m.loading = prScreen, nil, true - return m, m.loadPRs(false) + return m, tea.Batch(m.loadPRs(false), m.difflet.setState(diffletLoading)) } if m.screen == dashboardScreen { m.screen, m.scroll, m.err = m.dashboardReturn, 0, nil if m.screen == prScreen { m.loading = true m.searching, m.searchQuery = false, "" - return m, m.loadPRs(false) + return m, tea.Batch(m.loadPRs(false), m.difflet.setState(diffletLoading)) } return m, nil } @@ -1382,7 +1421,7 @@ func (m *App) openSelectedPR(target screen) tea.Cmd { m.threadIndex, m.scroll, m.focus, m.listHidden, m.loading, m.err = 0, 0, threadListPane, false, true, nil m.searching, m.searchQuery = false, "" m.pathScrollStep = 0 - return m.loadDetails(m.details.PullRequest, true) + return tea.Batch(m.loadDetails(m.details.PullRequest, true), m.difflet.setState(diffletLoading)) } func (m *App) trackThreadUpdates(details PRDetails) { @@ -1849,11 +1888,8 @@ func (m App) detailPaneSize() (int, int) { } func (m App) threadTopLineCount() int { - topLines := 3 - if m.details.FromCache { - topLines++ - } - if m.details.ThreadsTruncated { + topLines := len(m.threadTopLines()) + if m.headerWidth > 0 { topLines++ } return topLines @@ -1884,6 +1920,117 @@ func (m App) View() string { if m.width == 0 { return "Loading…" } + mascot := m.difflet.frameLines() + if len(mascot) != diffletHeight { + return m.viewContent() + } + gap := diffletGap + headerWidth := diffletHeaderWidth(m.width) + if headerWidth < 1 { + headerWidth = m.width + } + content := m + content.headerWidth = headerWidth + headerLineCount, hasHeader := content.diffletHeaderLineCount() + if !hasHeader { + content.height = max(1, m.height-diffletHeight-1) + content.contentTop = diffletHeight + 1 + return lipgloss.NewStyle().Width(m.width).Height(m.height).Render( + strings.Join(mascot, "\n") + "\n\n" + content.viewContent(), + ) + } + bandHeight := max(diffletHeight, headerLineCount) + addedRows := bandHeight - headerLineCount + if m.width <= diffletWidth { + addedRows = diffletHeight + } + content.height = max(1, m.height-addedRows) + content.contentTop = addedRows + rendered := content.viewContent() + header, body, ok := splitHeader(rendered) + if !ok { + return lipgloss.NewStyle().Width(m.width).Height(m.height).Render( + strings.Join(mascot, "\n") + "\n\n" + rendered, + ) + } + headerBand := renderHeaderWithDifflet(header, mascot, m.width, headerWidth, gap) + return lipgloss.NewStyle().Width(m.width).Height(m.height).Render( + headerBand + "\n\n" + body, + ) +} + +func (m App) diffletHeaderLineCount() (int, bool) { + if m.helpVisible || + (m.aiMode != aiNone && m.aiMode != aiDiscussion) || + (m.writeMode != writeNone && m.writeMode != writeReply && m.writeMode != writePREdit) || + m.screen == healthScreen { + return 0, false + } + switch m.screen { + case prScreen: + return len(m.prHeaderLines()), true + case dashboardScreen: + 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 + default: + return 0, false + } +} + +func splitHeader(view string) (header []string, body string, ok bool) { + lines := strings.Split(view, "\n") + for index, line := range lines { + if strings.TrimSpace(ansi.Strip(line)) == "" { + if index == 0 { + return nil, view, false + } + return lines[:index], strings.Join(lines[index+1:], "\n"), true + } + } + return nil, view, false +} + +func renderHeaderWithDifflet( + header, mascot []string, + width, headerWidth, gap int, +) string { + if width <= diffletWidth { + lines := append([]string(nil), mascot...) + lines = append(lines, header...) + return strings.Join(lines, "\n") + } + height := max(len(header), len(mascot)) + headerText := strings.Join(header, "\n") + left := lipgloss.NewStyle().Width(headerWidth).Height(height).Render(headerText) + right := lipgloss.NewStyle().Width(diffletWidth).Height(height).Render(strings.Join(mascot, "\n")) + return lipgloss.JoinHorizontal( + lipgloss.Top, + left, + strings.Repeat(" ", gap), + right, + ) +} + +func (m App) wrapHeaderLines(lines []string) []string { + if m.headerWidth < 1 { + return lines + } + var wrapped []string + for _, line := range lines { + value := ansi.Hardwrap(ansi.Wordwrap(line, m.headerWidth, ""), m.headerWidth, false) + wrapped = append(wrapped, strings.Split(value, "\n")...) + } + return wrapped +} + +func (m App) viewContent() string { if m.helpVisible { return m.viewHelp() } @@ -2326,16 +2473,7 @@ func paneStyle(active bool) lipgloss.Style { } func (m App) viewPRs() string { - header := titleStyle.Render("diple") - if m.owner != "" { - header += " " + m.owner + "/" + m.repo - } - if m.showAll { - header += dimStyle.Render(" all open pull requests") - } else { - header += dimStyle.Render(" assigned to you") - } - lines := []string{header, ""} + lines := append(m.prHeaderLines(), "") if m.loading && len(m.prs) == 0 { lines = append(lines, "Loading open pull requests…") } else if len(m.prs) == 0 && m.err == nil { @@ -2384,6 +2522,19 @@ func (m App) viewPRs() string { return m.frame(lines, footer) } +func (m App) prHeaderLines() []string { + header := titleStyle.Render("diple") + if m.owner != "" { + header += " " + m.owner + "/" + m.repo + } + if m.showAll { + header += dimStyle.Render(" all open pull requests") + } else { + header += dimStyle.Render(" assigned to you") + } + return m.wrapHeaderLines([]string{header}) +} + type prListRow struct { repository string prIndex int @@ -2669,10 +2820,7 @@ func (m App) healthLines() []string { return lines } -func (m App) dashboardLines() []string { - if m.writeMode == writePREdit { - return m.dashboardEditLines() - } +func (m App) dashboardHeaderLines() []string { pr := m.details width := max(10, m.width-2) draft := "" @@ -2684,7 +2832,9 @@ func (m App) dashboardLines() []string { titleStyle.Render(truncate(pr.Title, width)), } if pr.FromCache { - lines = append(lines, warnStyle.Render("OFFLINE CACHE • saved "+pr.CachedAt.Local().Format("2006-01-02 15:04"))) + lines = append(lines, warnStyle.Render( + "OFFLINE CACHE • saved "+pr.CachedAt.Local().Format("2006-01-02 15:04"), + )) } if len(pr.DataIssues) > 0 { lines = append(lines, warnStyle.Render(fmt.Sprintf( @@ -2692,6 +2842,16 @@ func (m App) dashboardLines() []string { len(pr.DataIssues), primaryKeyLabel(m.keybindings.Views.Health), ))) } + return m.wrapHeaderLines(lines) +} + +func (m App) dashboardLines() []string { + if m.writeMode == writePREdit { + return m.dashboardEditLines() + } + pr := m.details + width := max(10, m.width-2) + lines := m.dashboardHeaderLines() if m.loading && pr.BaseRef == "" { return append(lines, "", "Loading pull request details…") } @@ -3100,9 +3260,13 @@ func viewerPermissionsText(permissions ViewerPermissions) string { return strings.Join(items, ", ") } -func (m App) viewThreads() string { +func (m App) threadTopLines() []string { pr := m.details - header := titleStyle.Render(fmt.Sprintf("%s #%d %s", pr.RepoWithOwner, pr.Number, truncate(pr.Title, max(10, m.width-len(pr.RepoWithOwner)-12)))) + width := m.width + if m.headerWidth > 0 { + width = m.headerWidth + } + header := titleStyle.Render(fmt.Sprintf("%s #%d %s", pr.RepoWithOwner, pr.Number, truncate(pr.Title, max(10, width-len(pr.RepoWithOwner)-12)))) meta := fmt.Sprintf("%s → %s checks: %s %s", pr.HeadRef, pr.BaseRef, coloredState(pr.CheckState), reviewAndMergeState(pr)) people := "assignees: " + m.handlesText(pr.Assignees) + " reviewers: " + m.reviewersText(pr.Reviewers) top := []string{header, meta, people} @@ -3115,8 +3279,16 @@ func (m App) viewThreads() string { if pr.ThreadsTruncated { top = append(top, warnStyle.Render("Showing the first 100 review threads.")) } + return m.wrapHeaderLines(top) +} - contentHeight := max(3, m.height-len(top)-1) +func (m App) viewThreads() string { + top := m.threadTopLines() + topLineCount := len(top) + if m.headerWidth > 0 { + topLineCount++ + } + contentHeight := max(3, m.height-topLineCount-1) var body string if m.width < 70 { if m.focus == threadListPane { @@ -3167,6 +3339,9 @@ func (m App) viewThreads() string { ) } m.positionThreadInputHardwareCursor() + if m.headerWidth > 0 { + top = append(top, "") + } view := m.frame(append(top, body), help) if m.cursorOutput != nil { view += m.cursorOutput.FrameMarker() @@ -3571,7 +3746,7 @@ func (m App) positionThreadInputHardwareCursor() { m.cursorOutput.SetCursor( true, 2+ansi.StringWidth("Filter: ")+ansi.StringWidth(query), - topLines+3, + m.contentTop+topLines+3, ) return } @@ -3607,7 +3782,7 @@ func (m App) positionThreadInputHardwareCursor() { m.cursorOutput.SetCursor( true, paneStart+2+ansi.StringWidth(line.rail)+ansi.StringWidth(line.fixed+line.text), - topLines+2+screenLine, + m.contentTop+topLines+2+screenLine, ) } @@ -3902,6 +4077,35 @@ func (m App) frame(lines []string, help string) string { return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(strings.Join(bodyLines, "\n")) } +func (m App) restingDiffletState() diffletState { + if m.err != nil { + return diffletSad + } + if m.loading || m.secondaryLoading { + return diffletLoading + } + if m.details.Number == 0 { + if !m.lastRefresh.IsZero() && len(m.prs) == 0 { + return diffletSleeping + } + return diffletIdle + } + if m.details.ReviewDecision == "APPROVED" { + return diffletApproved + } + open, outdated, resolved := threadStatusCounts(m.details.Threads) + if open+outdated > 0 { + return diffletConcerned + } + if resolved > 0 { + return diffletHappy + } + if m.screen == threadScreen { + return diffletFocused + } + return diffletIdle +} + func renderPane(lines []string, width, height int, active bool) string { innerWidth := max(1, width-2) innerHeight := max(1, height-2)