1// Package termui contains the interactive terminal UI
2package termui
3
4import (
5 "fmt"
6
7 "github.com/awesome-gocui/gocui"
8 "github.com/pkg/errors"
9
10 "github.com/MichaelMure/git-bug/cache"
11 "github.com/MichaelMure/git-bug/entity"
12 "github.com/MichaelMure/git-bug/input"
13 "github.com/MichaelMure/git-bug/query"
14)
15
16var errTerminateMainloop = errors.New("terminate gocui mainloop")
17
18type termUI struct {
19 g *gocui.Gui
20 gError chan error
21 cache *cache.RepoCache
22
23 activeWindow window
24
25 bugTable *bugTable
26 showBug *showBug
27 labelSelect *labelSelect
28 msgPopup *msgPopup
29 inputPopup *inputPopup
30}
31
32func (tui *termUI) activateWindow(window window) error {
33 if err := tui.activeWindow.disable(tui.g); err != nil {
34 return err
35 }
36
37 tui.activeWindow = window
38
39 return nil
40}
41
42var ui *termUI
43
44type window interface {
45 keybindings(g *gocui.Gui) error
46 layout(g *gocui.Gui) error
47 disable(g *gocui.Gui) error
48}
49
50// Run will launch the termUI in the terminal
51func Run(cache *cache.RepoCache) error {
52 ui = &termUI{
53 gError: make(chan error, 1),
54 cache: cache,
55 bugTable: newBugTable(cache),
56 showBug: newShowBug(cache),
57 labelSelect: newLabelSelect(),
58 msgPopup: newMsgPopup(),
59 inputPopup: newInputPopup(),
60 }
61
62 ui.activeWindow = ui.bugTable
63
64 initGui(nil)
65
66 err := <-ui.gError
67
68 type errorStack interface {
69 ErrorStack() string
70 }
71
72 if err != nil && err != gocui.ErrQuit {
73 if e, ok := err.(errorStack); ok {
74 fmt.Println(e.ErrorStack())
75 }
76 return err
77 }
78
79 return nil
80}
81
82func initGui(action func(ui *termUI) error) {
83 g, err := gocui.NewGui(gocui.Output256, false)
84
85 if err != nil {
86 ui.gError <- err
87 return
88 }
89
90 ui.g = g
91
92 ui.g.SetManagerFunc(layout)
93
94 ui.g.InputEsc = true
95
96 err = keybindings(ui.g)
97
98 if err != nil {
99 ui.g.Close()
100 ui.g = nil
101 ui.gError <- err
102 return
103 }
104
105 if action != nil {
106 err = action(ui)
107 if err != nil {
108 ui.g.Close()
109 ui.g = nil
110 ui.gError <- err
111 return
112 }
113 }
114
115 err = g.MainLoop()
116
117 if err != nil && err != errTerminateMainloop {
118 if ui.g != nil {
119 ui.g.Close()
120 }
121 ui.gError <- err
122 }
123}
124
125func layout(g *gocui.Gui) error {
126 g.Cursor = false
127
128 if err := ui.activeWindow.layout(g); err != nil {
129 return err
130 }
131
132 if err := ui.msgPopup.layout(g); err != nil {
133 return err
134 }
135
136 if err := ui.inputPopup.layout(g); err != nil {
137 return err
138 }
139
140 return nil
141}
142
143func keybindings(g *gocui.Gui) error {
144 // Quit
145 if err := g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil {
146 return err
147 }
148
149 if err := ui.bugTable.keybindings(g); err != nil {
150 return err
151 }
152
153 if err := ui.showBug.keybindings(g); err != nil {
154 return err
155 }
156
157 if err := ui.labelSelect.keybindings(g); err != nil {
158 return err
159 }
160
161 if err := ui.msgPopup.keybindings(g); err != nil {
162 return err
163 }
164
165 if err := ui.inputPopup.keybindings(g); err != nil {
166 return err
167 }
168
169 return nil
170}
171
172func quit(g *gocui.Gui, v *gocui.View) error {
173 return gocui.ErrQuit
174}
175
176func newBugWithEditor(repo *cache.RepoCache) error {
177 // This is somewhat hacky.
178 // As there is no way to pause gocui, run the editor and restart gocui,
179 // we have to stop it entirely and start a new one later.
180 //
181 // - an error channel is used to route the returned error of this new
182 // instance into the original launch function
183 // - a custom error (errTerminateMainloop) is used to terminate the original
184 // instance's mainLoop. This error is then filtered.
185
186 ui.g.Close()
187 ui.g = nil
188
189 title, message, err := input.BugCreateEditorInput(ui.cache, "", "")
190
191 if err != nil && err != input.ErrEmptyTitle {
192 return err
193 }
194
195 var b *cache.BugCache
196 if err == input.ErrEmptyTitle {
197 ui.msgPopup.Activate(msgPopupErrorTitle, "Empty title, aborting.")
198 initGui(nil)
199
200 return errTerminateMainloop
201 } else {
202 b, _, err = repo.NewBug(title, message)
203 if err != nil {
204 return err
205 }
206
207 initGui(func(ui *termUI) error {
208 ui.showBug.SetBug(b)
209 return ui.activateWindow(ui.showBug)
210 })
211
212 return errTerminateMainloop
213 }
214}
215
216func addCommentWithEditor(bug *cache.BugCache) error {
217 // This is somewhat hacky.
218 // As there is no way to pause gocui, run the editor and restart gocui,
219 // we have to stop it entirely and start a new one later.
220 //
221 // - an error channel is used to route the returned error of this new
222 // instance into the original launch function
223 // - a custom error (errTerminateMainloop) is used to terminate the original
224 // instance's mainLoop. This error is then filtered.
225
226 ui.g.Close()
227 ui.g = nil
228
229 message, err := input.BugCommentEditorInput(ui.cache, "")
230
231 if err != nil && err != input.ErrEmptyMessage {
232 return err
233 }
234
235 if err == input.ErrEmptyMessage {
236 ui.msgPopup.Activate(msgPopupErrorTitle, "Empty message, aborting.")
237 } else {
238 _, err := bug.AddComment(message)
239 if err != nil {
240 return err
241 }
242 }
243
244 initGui(nil)
245
246 return errTerminateMainloop
247}
248
249func editCommentWithEditor(bug *cache.BugCache, target entity.Id, preMessage string) error {
250 // This is somewhat hacky.
251 // As there is no way to pause gocui, run the editor and restart gocui,
252 // we have to stop it entirely and start a new one later.
253 //
254 // - an error channel is used to route the returned error of this new
255 // instance into the original launch function
256 // - a custom error (errTerminateMainloop) is used to terminate the original
257 // instance's mainLoop. This error is then filtered.
258
259 ui.g.Close()
260 ui.g = nil
261
262 message, err := input.BugCommentEditorInput(ui.cache, preMessage)
263 if err != nil && err != input.ErrEmptyMessage {
264 return err
265 }
266
267 if err == input.ErrEmptyMessage {
268 // TODO: Allow comments to be deleted?
269 ui.msgPopup.Activate(msgPopupErrorTitle, "Empty message, aborting.")
270 } else if message == preMessage {
271 ui.msgPopup.Activate(msgPopupErrorTitle, "No changes found, aborting.")
272 } else {
273 _, err := bug.EditComment(target, message)
274 if err != nil {
275 return err
276 }
277 }
278
279 initGui(nil)
280
281 return errTerminateMainloop
282}
283
284func setTitleWithEditor(bug *cache.BugCache) error {
285 // This is somewhat hacky.
286 // As there is no way to pause gocui, run the editor and restart gocui,
287 // we have to stop it entirely and start a new one later.
288 //
289 // - an error channel is used to route the returned error of this new
290 // instance into the original launch function
291 // - a custom error (errTerminateMainloop) is used to terminate the original
292 // instance's mainLoop. This error is then filtered.
293
294 ui.g.Close()
295 ui.g = nil
296
297 snap := bug.Snapshot()
298
299 title, err := input.BugTitleEditorInput(ui.cache, snap.Title)
300
301 if err != nil && err != input.ErrEmptyTitle {
302 return err
303 }
304
305 if err == input.ErrEmptyTitle {
306 ui.msgPopup.Activate(msgPopupErrorTitle, "Empty title, aborting.")
307 } else if title == snap.Title {
308 ui.msgPopup.Activate(msgPopupErrorTitle, "No change, aborting.")
309 } else {
310 _, err := bug.SetTitle(title)
311 if err != nil {
312 return err
313 }
314 }
315
316 initGui(nil)
317
318 return errTerminateMainloop
319}
320
321func editQueryWithEditor(bt *bugTable) error {
322 // This is somewhat hacky.
323 // As there is no way to pause gocui, run the editor and restart gocui,
324 // we have to stop it entirely and start a new one later.
325 //
326 // - an error channel is used to route the returned error of this new
327 // instance into the original launch function
328 // - a custom error (errTerminateMainloop) is used to terminate the original
329 // instance's mainLoop. This error is then filtered.
330
331 ui.g.Close()
332 ui.g = nil
333
334 queryStr, err := input.QueryEditorInput(bt.repo, bt.queryStr)
335
336 if err != nil {
337 return err
338 }
339
340 bt.queryStr = queryStr
341
342 q, err := query.Parse(queryStr)
343
344 if err != nil {
345 ui.msgPopup.Activate(msgPopupErrorTitle, err.Error())
346 } else {
347 bt.query = q
348 }
349
350 initGui(nil)
351
352 return errTerminateMainloop
353}
354
355func maxInt(a, b int) int {
356 if a > b {
357 return a
358 }
359 return b
360}
361
362func minInt(a, b int) int {
363 if a > b {
364 return b
365 }
366 return a
367}