Skip to content

Commit 3d9f7b3

Browse files
authored
Merge pull request #1547 from dgageot/tui-refactor
TUI refactor
2 parents d241b03 + c4643a2 commit 3d9f7b3

35 files changed

Lines changed: 962 additions & 403 deletions

File tree

pkg/tui/animation/subscription.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package animation
2+
3+
import tea "charm.land/bubbletea/v2"
4+
5+
// Subscription represents a component's subscription to animation ticks.
6+
// It encapsulates the registration/unregistration lifecycle, making it
7+
// easier to manage animation state correctly.
8+
//
9+
// Usage:
10+
//
11+
// type MyComponent struct {
12+
// animSub animation.Subscription
13+
// }
14+
//
15+
// func (m *MyComponent) Init() tea.Cmd {
16+
// return m.animSub.Start()
17+
// }
18+
//
19+
// func (m *MyComponent) Cleanup() {
20+
// m.animSub.Stop()
21+
// }
22+
type Subscription struct {
23+
active bool
24+
}
25+
26+
// Start activates the subscription if not already active.
27+
// Returns a command to start the tick if this is the first subscription.
28+
// Safe to call multiple times - only the first call registers.
29+
func (s *Subscription) Start() tea.Cmd {
30+
if s.active {
31+
return nil
32+
}
33+
s.active = true
34+
return StartTickIfFirst()
35+
}
36+
37+
// Stop deactivates the subscription if currently active.
38+
// Safe to call multiple times - only the first call unregisters.
39+
func (s *Subscription) Stop() {
40+
if !s.active {
41+
return
42+
}
43+
s.active = false
44+
Unregister()
45+
}
46+
47+
// IsActive returns whether the subscription is currently active.
48+
func (s *Subscription) IsActive() bool {
49+
return s.active
50+
}
51+
52+
// Reset returns a new inactive subscription.
53+
// Useful when recreating a component that needs fresh animation state.
54+
func (s *Subscription) Reset() Subscription {
55+
s.Stop()
56+
return Subscription{}
57+
}

pkg/tui/cmdbatch/batch.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Package cmdbatch provides a fluent builder for batching tea.Cmd values.
2+
//
3+
// This reduces boilerplate when building command slices in Update functions,
4+
// following the Elm Architecture pattern of accumulating commands.
5+
//
6+
// Usage:
7+
//
8+
// return m, cmdbatch.New().
9+
// Add(p.sidebar.Update(msg)).
10+
// AddIf(p.working, p.spinner.Init()).
11+
// Add(p.messages.Update(msg)).
12+
// Batch()
13+
package cmdbatch
14+
15+
import tea "charm.land/bubbletea/v2"
16+
17+
// Builder accumulates commands for batching.
18+
type Builder struct {
19+
cmds []tea.Cmd
20+
}
21+
22+
// New creates a new command builder.
23+
func New() *Builder {
24+
return &Builder{}
25+
}
26+
27+
// Add appends a command to the batch (nil commands are ignored).
28+
func (b *Builder) Add(cmd tea.Cmd) *Builder {
29+
if cmd != nil {
30+
b.cmds = append(b.cmds, cmd)
31+
}
32+
return b
33+
}
34+
35+
// AddIf conditionally appends a command to the batch.
36+
func (b *Builder) AddIf(condition bool, cmd tea.Cmd) *Builder {
37+
if condition && cmd != nil {
38+
b.cmds = append(b.cmds, cmd)
39+
}
40+
return b
41+
}
42+
43+
// AddAll appends multiple commands to the batch.
44+
func (b *Builder) AddAll(cmds ...tea.Cmd) *Builder {
45+
for _, cmd := range cmds {
46+
if cmd != nil {
47+
b.cmds = append(b.cmds, cmd)
48+
}
49+
}
50+
return b
51+
}
52+
53+
// Batch returns a batched command, or nil if no commands were added.
54+
func (b *Builder) Batch() tea.Cmd {
55+
switch len(b.cmds) {
56+
case 0:
57+
return nil
58+
case 1:
59+
return b.cmds[0]
60+
default:
61+
return tea.Batch(b.cmds...)
62+
}
63+
}
64+
65+
// Len returns the number of commands in the batch.
66+
func (b *Builder) Len() int {
67+
return len(b.cmds)
68+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package sidebar
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"charm.land/lipgloss/v2"
8+
9+
"github.com/docker/cagent/pkg/tui/styles"
10+
)
11+
12+
// CollapsedViewModel holds the computed layout decisions for collapsed mode.
13+
// This is a pure data structure - rendering is handled by separate view functions.
14+
// Computing this once avoids duplicating the layout logic between CollapsedHeight and collapsedView.
15+
type CollapsedViewModel struct {
16+
TitleWithStar string
17+
WorkingIndicator string
18+
WorkingDir string
19+
UsageSummary string
20+
21+
// Layout decisions computed from the data
22+
TitleAndIndicatorOnOneLine bool
23+
WdAndUsageOnOneLine bool
24+
ContentWidth int
25+
}
26+
27+
// LineCount returns the number of lines needed to render this layout.
28+
func (vm CollapsedViewModel) LineCount() int {
29+
lines := 1 // divider
30+
31+
switch {
32+
case vm.TitleAndIndicatorOnOneLine:
33+
lines++
34+
case vm.WorkingIndicator == "":
35+
// No working indicator but title wraps
36+
lines += linesNeeded(lipgloss.Width(vm.TitleWithStar), vm.ContentWidth)
37+
default:
38+
// Title and working indicator on separate lines, each may wrap
39+
lines += linesNeeded(lipgloss.Width(vm.TitleWithStar), vm.ContentWidth)
40+
lines += linesNeeded(lipgloss.Width(vm.WorkingIndicator), vm.ContentWidth)
41+
}
42+
43+
if vm.WdAndUsageOnOneLine {
44+
lines++
45+
} else {
46+
lines += linesNeeded(lipgloss.Width(vm.WorkingDir), vm.ContentWidth)
47+
if vm.UsageSummary != "" {
48+
lines += linesNeeded(lipgloss.Width(vm.UsageSummary), vm.ContentWidth)
49+
}
50+
}
51+
52+
return lines
53+
}
54+
55+
// RenderCollapsedView renders the collapsed sidebar from a CollapsedViewModel.
56+
// This is a pure function that takes data and returns a string.
57+
func RenderCollapsedView(vm CollapsedViewModel) string {
58+
var lines []string
59+
60+
// Title line(s)
61+
switch {
62+
case vm.TitleAndIndicatorOnOneLine:
63+
if vm.WorkingIndicator == "" {
64+
lines = append(lines, vm.TitleWithStar)
65+
} else {
66+
gap := vm.ContentWidth - lipgloss.Width(vm.TitleWithStar) - lipgloss.Width(vm.WorkingIndicator)
67+
lines = append(lines, fmt.Sprintf("%s%*s%s", vm.TitleWithStar, gap, "", vm.WorkingIndicator))
68+
}
69+
case vm.WorkingIndicator == "":
70+
// No working indicator but title wraps - just output title (lipgloss will wrap)
71+
lines = append(lines, vm.TitleWithStar)
72+
default:
73+
// Title and working indicator on separate lines
74+
lines = append(lines, vm.TitleWithStar, vm.WorkingIndicator)
75+
}
76+
77+
// Working directory + usage line(s)
78+
if vm.WdAndUsageOnOneLine {
79+
gap := vm.ContentWidth - lipgloss.Width(vm.WorkingDir) - lipgloss.Width(vm.UsageSummary)
80+
lines = append(lines, fmt.Sprintf("%s%*s%s", styles.MutedStyle.Render(vm.WorkingDir), gap, "", vm.UsageSummary))
81+
} else {
82+
lines = append(lines, styles.MutedStyle.Render(vm.WorkingDir))
83+
if vm.UsageSummary != "" {
84+
lines = append(lines, vm.UsageSummary)
85+
}
86+
}
87+
88+
return strings.Join(lines, "\n")
89+
}
90+
91+
// linesNeeded calculates how many lines are needed to display text of given width
92+
// within a container of contentWidth. Returns at least 1 line.
93+
func linesNeeded(textWidth, contentWidth int) int {
94+
if contentWidth <= 0 || textWidth <= 0 {
95+
return 1
96+
}
97+
return max(1, (textWidth+contentWidth-1)/contentWidth)
98+
}

0 commit comments

Comments
 (0)