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