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