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