1package tui
2
3import (
4 "fmt"
5 "strings"
6
7 tea "github.com/charmbracelet/bubbletea"
8 "github.com/charmbracelet/lipgloss"
9 "github.com/charmbracelet/soft/config"
10 "github.com/charmbracelet/soft/git"
11 "github.com/charmbracelet/soft/tui/bubbles/repo"
12 "github.com/charmbracelet/soft/tui/bubbles/selection"
13 "github.com/charmbracelet/soft/tui/style"
14)
15
16type sessionState int
17
18const (
19 startState sessionState = iota
20 errorState
21 loadedState
22 quittingState
23 quitState
24)
25
26type SessionConfig struct {
27 Width int
28 Height int
29 InitialRepo string
30}
31
32type MenuEntry struct {
33 Name string `json:"name"`
34 Note string `json:"note"`
35 Repo string `json:"repo"`
36 bubble *repo.Bubble
37}
38
39type Bubble struct {
40 config *config.Config
41 styles *style.Styles
42 state sessionState
43 error string
44 width int
45 height int
46 initialRepo string
47 repoMenu []MenuEntry
48 repos []*git.Repo
49 boxes []tea.Model
50 activeBox int
51 repoSelect *selection.Bubble
52}
53
54func NewBubble(cfg *config.Config, sCfg *SessionConfig) *Bubble {
55 b := &Bubble{
56 config: cfg,
57 styles: style.DefaultStyles(),
58 width: sCfg.Width,
59 height: sCfg.Height,
60 repoMenu: make([]MenuEntry, 0),
61 boxes: make([]tea.Model, 2),
62 initialRepo: sCfg.InitialRepo,
63 }
64 b.state = startState
65 return b
66}
67
68func (b *Bubble) Init() tea.Cmd {
69 return b.setupCmd
70}
71
72func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
73 cmds := make([]tea.Cmd, 0)
74 // Always allow state, error, info, window resize and quit messages
75 switch msg := msg.(type) {
76 case tea.KeyMsg:
77 switch msg.String() {
78 case "q", "ctrl+c":
79 return b, tea.Quit
80 case "tab", "shift+tab":
81 b.activeBox = (b.activeBox + 1) % 2
82 case "h", "left":
83 if b.activeBox > 0 {
84 b.activeBox--
85 }
86 case "l", "right":
87 if b.activeBox < len(b.boxes)-1 {
88 b.activeBox++
89 }
90 }
91 case errMsg:
92 b.error = msg.Error()
93 b.state = errorState
94 return b, nil
95 case tea.WindowSizeMsg:
96 b.width = msg.Width
97 b.height = msg.Height
98 if b.state == loadedState {
99 for i, bx := range b.boxes {
100 m, cmd := bx.Update(msg)
101 b.boxes[i] = m
102 if cmd != nil {
103 cmds = append(cmds, cmd)
104 }
105 }
106 }
107 case selection.SelectedMsg:
108 b.activeBox = 1
109 rb := b.repoMenu[msg.Index].bubble
110 rb.GotoTop()
111 b.boxes[1] = rb
112 case selection.ActiveMsg:
113 rb := b.repoMenu[msg.Index].bubble
114 rb.GotoTop()
115 b.boxes[1] = b.repoMenu[msg.Index].bubble
116 }
117 if b.state == loadedState {
118 ab, cmd := b.boxes[b.activeBox].Update(msg)
119 b.boxes[b.activeBox] = ab
120 if cmd != nil {
121 cmds = append(cmds, cmd)
122 }
123 }
124 return b, tea.Batch(cmds...)
125}
126
127func (b *Bubble) viewForBox(i int) string {
128 isActive := i == b.activeBox
129 switch box := b.boxes[i].(type) {
130 case *selection.Bubble:
131 // Menu
132 var s lipgloss.Style
133 s = b.styles.Menu
134 if isActive {
135 s = s.Copy().BorderForeground(b.styles.ActiveBorderColor)
136 }
137 return s.Render(box.View())
138 case *repo.Bubble:
139 // Repo details
140 box.Active = isActive
141 return box.View()
142 default:
143 panic(fmt.Sprintf("unknown box type %T", box))
144 }
145}
146
147func (b Bubble) headerView() string {
148 w := b.width - b.styles.App.GetHorizontalFrameSize()
149 name := ""
150 if b.config != nil {
151 name = b.config.Name
152 }
153 return b.styles.Header.Copy().Width(w).Render(name)
154}
155
156func (b Bubble) footerView() string {
157 w := &strings.Builder{}
158 var h []helpEntry
159 switch b.state {
160 case errorState:
161 h = []helpEntry{{"q", "quit"}}
162 default:
163 h = []helpEntry{
164 {"tab", "section"},
165 {"↑/↓", "navigate"},
166 {"q", "quit"},
167 }
168 if _, ok := b.boxes[b.activeBox].(*repo.Bubble); ok {
169 h = append(h[:2], helpEntry{"f/b", "pgup/pgdown"}, h[2])
170 }
171 }
172 for i, v := range h {
173 fmt.Fprint(w, v.Render(b.styles))
174 if i != len(h)-1 {
175 fmt.Fprint(w, b.styles.HelpDivider)
176 }
177 }
178 return b.styles.Footer.Copy().Width(b.width).Render(w.String())
179}
180
181func (b Bubble) errorView() string {
182 s := b.styles
183 str := lipgloss.JoinHorizontal(
184 lipgloss.Top,
185 s.ErrorTitle.Render("Bummer"),
186 s.ErrorBody.Render(b.error),
187 )
188 h := b.height -
189 s.App.GetVerticalFrameSize() -
190 lipgloss.Height(b.headerView()) -
191 lipgloss.Height(b.footerView()) -
192 s.RepoBody.GetVerticalFrameSize() +
193 3 // TODO: this is repo header height -- get it dynamically
194 return s.Error.Copy().Height(h).Render(str)
195}
196
197func (b Bubble) View() string {
198 s := strings.Builder{}
199 s.WriteString(b.headerView())
200 s.WriteRune('\n')
201 switch b.state {
202 case loadedState:
203 lb := b.viewForBox(0)
204 rb := b.viewForBox(1)
205 s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, lb, rb))
206 case errorState:
207 s.WriteString(b.errorView())
208 }
209 s.WriteRune('\n')
210 s.WriteString(b.footerView())
211 return b.styles.App.Render(s.String())
212}
213
214type helpEntry struct {
215 key string
216 val string
217}
218
219func (h helpEntry) Render(s *style.Styles) string {
220 return fmt.Sprintf("%s %s", s.HelpKey.Render(h.key), s.HelpValue.Render(h.val))
221}