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 switch msg := msg.(type) {
75 case tea.KeyMsg:
76 switch msg.String() {
77 case "q", "ctrl+c":
78 return b, tea.Quit
79 case "tab", "shift+tab":
80 b.activeBox = (b.activeBox + 1) % 2
81 case "h", "left":
82 if b.activeBox > 0 {
83 b.activeBox--
84 }
85 case "l", "right":
86 if b.activeBox < len(b.boxes)-1 {
87 b.activeBox++
88 }
89 }
90 case errMsg:
91 b.error = msg.Error()
92 b.state = errorState
93 return b, nil
94 case tea.WindowSizeMsg:
95 b.width = msg.Width
96 b.height = msg.Height
97 if b.state == loadedState {
98 for i, bx := range b.boxes {
99 m, cmd := bx.Update(msg)
100 b.boxes[i] = m
101 if cmd != nil {
102 cmds = append(cmds, cmd)
103 }
104 }
105 }
106 case selection.SelectedMsg:
107 b.activeBox = 1
108 rb := b.repoMenu[msg.Index].bubble
109 rb.GotoTop()
110 b.boxes[1] = rb
111 case selection.ActiveMsg:
112 rb := b.repoMenu[msg.Index].bubble
113 rb.GotoTop()
114 b.boxes[1] = b.repoMenu[msg.Index].bubble
115 }
116 if b.state == loadedState {
117 ab, cmd := b.boxes[b.activeBox].Update(msg)
118 b.boxes[b.activeBox] = ab
119 if cmd != nil {
120 cmds = append(cmds, cmd)
121 }
122 }
123 return b, tea.Batch(cmds...)
124}
125
126func (b *Bubble) viewForBox(i int) string {
127 isActive := i == b.activeBox
128 switch box := b.boxes[i].(type) {
129 case *selection.Bubble:
130 // Menu
131 var s lipgloss.Style
132 s = b.styles.Menu
133 if isActive {
134 s = s.Copy().BorderForeground(b.styles.ActiveBorderColor)
135 }
136 return s.Render(box.View())
137 case *repo.Bubble:
138 // Repo details
139 box.Active = isActive
140 return box.View()
141 default:
142 panic(fmt.Sprintf("unknown box type %T", box))
143 }
144}
145
146func (b Bubble) headerView() string {
147 w := b.width - b.styles.App.GetHorizontalFrameSize()
148 name := ""
149 if b.config != nil {
150 name = b.config.Name
151 }
152 return b.styles.Header.Copy().Width(w).Render(name)
153}
154
155func (b Bubble) footerView() string {
156 w := &strings.Builder{}
157 var h []helpEntry
158 switch b.state {
159 case errorState:
160 h = []helpEntry{{"q", "quit"}}
161 default:
162 h = []helpEntry{
163 {"tab", "section"},
164 {"↑/↓", "navigate"},
165 {"q", "quit"},
166 }
167 if _, ok := b.boxes[b.activeBox].(*repo.Bubble); ok {
168 h = append(h[:2], helpEntry{"f/b", "pgup/pgdown"}, h[2])
169 }
170 }
171 for i, v := range h {
172 fmt.Fprint(w, v.Render(b.styles))
173 if i != len(h)-1 {
174 fmt.Fprint(w, b.styles.HelpDivider)
175 }
176 }
177 return b.styles.Footer.Copy().Width(b.width).Render(w.String())
178}
179
180func (b Bubble) errorView() string {
181 s := b.styles
182 str := lipgloss.JoinHorizontal(
183 lipgloss.Top,
184 s.ErrorTitle.Render("Bummer"),
185 s.ErrorBody.Render(b.error),
186 )
187 h := b.height -
188 s.App.GetVerticalFrameSize() -
189 lipgloss.Height(b.headerView()) -
190 lipgloss.Height(b.footerView()) -
191 s.RepoBody.GetVerticalFrameSize() +
192 3 // TODO: this is repo header height -- get it dynamically
193 return s.Error.Copy().Height(h).Render(str)
194}
195
196func (b Bubble) View() string {
197 s := strings.Builder{}
198 s.WriteString(b.headerView())
199 s.WriteRune('\n')
200 switch b.state {
201 case loadedState:
202 lb := b.viewForBox(0)
203 rb := b.viewForBox(1)
204 s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, lb, rb))
205 case errorState:
206 s.WriteString(b.errorView())
207 }
208 s.WriteRune('\n')
209 s.WriteString(b.footerView())
210 return b.styles.App.Render(s.String())
211}
212
213type helpEntry struct {
214 key string
215 val string
216}
217
218func (h helpEntry) Render(s *style.Styles) string {
219 return fmt.Sprintf("%s %s", s.HelpKey.Render(h.key), s.HelpValue.Render(h.val))
220}