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
122func layout(g *gocui.Gui) error {
123 g.Cursor = false
124
125 if err := ui.activeWindow.layout(g); err != nil {
126 return err
127 }
128
129 if err := ui.msgPopup.layout(g); err != nil {
130 return err
131 }
132
133 if err := ui.inputPopup.layout(g); err != nil {
134 return err
135 }
136
137 return nil
138}
139
140func keybindings(g *gocui.Gui) error {
141 // Quit
142 if err := g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil {
143 return err
144 }
145
146 if err := ui.bugTable.keybindings(g); err != nil {
147 return err
148 }
149
150 if err := ui.showBug.keybindings(g); err != nil {
151 return err
152 }
153
154 if err := ui.labelSelect.keybindings(g); err != nil {
155 return err
156 }
157
158 if err := ui.msgPopup.keybindings(g); err != nil {
159 return err
160 }
161
162 if err := ui.inputPopup.keybindings(g); err != nil {
163 return err
164 }
165
166 return nil
167}
168
169func quit(g *gocui.Gui, v *gocui.View) error {
170 return gocui.ErrQuit
171}
172
173func newBugWithEditor(repo *cache.RepoCache) error {
174 // This is somewhat hacky.
175 // As there is no way to pause gocui, run the editor and restart gocui,
176 // we have to stop it entirely and start a new one later.
177 //
178 // - an error channel is used to route the returned error of this new
179 // instance into the original launch function
180 // - a custom error (errTerminateMainloop) is used to terminate the original
181 // instance's mainLoop. This error is then filtered.
182
183 ui.g.Close()
184 ui.g = nil
185
186 title, message, err := input.BugCreateEditorInput(ui.cache, "", "")
187
188 if err != nil && err != input.ErrEmptyTitle {
189 return err
190 }
191
192 var b *cache.BugCache
193 if err == input.ErrEmptyTitle {
194 ui.msgPopup.Activate(msgPopupErrorTitle, "Empty title, aborting.")
195 initGui(nil)
196
197 return errTerminateMainloop
198 } else {
199 b, _, err = repo.NewBug(title, message)
200 if err != nil {
201 return err
202 }
203
204 initGui(func(ui *termUI) error {
205 ui.showBug.SetBug(b)
206 return ui.activateWindow(ui.showBug)
207 })
208
209 return errTerminateMainloop
210 }
211}
212
213func addCommentWithEditor(bug *cache.BugCache) error {
214 // This is somewhat hacky.
215 // As there is no way to pause gocui, run the editor and restart gocui,
216 // we have to stop it entirely and start a new one later.
217 //
218 // - an error channel is used to route the returned error of this new
219 // instance into the original launch function
220 // - a custom error (errTerminateMainloop) is used to terminate the original
221 // instance's mainLoop. This error is then filtered.
222
223 ui.g.Close()
224 ui.g = nil
225
226 message, err := input.BugCommentEditorInput(ui.cache, "")
227
228 if err != nil && err != input.ErrEmptyMessage {
229 return err
230 }
231
232 if err == input.ErrEmptyMessage {
233 ui.msgPopup.Activate(msgPopupErrorTitle, "Empty message, aborting.")
234 } else {
235 _, err := bug.AddComment(message)
236 if err != nil {
237 return err
238 }
239 }
240
241 initGui(nil)
242
243 return errTerminateMainloop
244}
245
246func editCommentWithEditor(bug *cache.BugCache, target entity.Id, preMessage string) error {
247 // This is somewhat hacky.
248 // As there is no way to pause gocui, run the editor and restart gocui,
249 // we have to stop it entirely and start a new one later.
250 //
251 // - an error channel is used to route the returned error of this new
252 // instance into the original launch function
253 // - a custom error (errTerminateMainloop) is used to terminate the original
254 // instance's mainLoop. This error is then filtered.
255
256 ui.g.Close()
257 ui.g = nil
258
259 message, err := input.BugCommentEditorInput(ui.cache, preMessage)
260 if err != nil && err != input.ErrEmptyMessage {
261 return err
262 }
263
264 if err == input.ErrEmptyMessage {
265 // TODO: Allow comments to be deleted?
266 ui.msgPopup.Activate(msgPopupErrorTitle, "Empty message, aborting.")
267 } else if message == preMessage {
268 ui.msgPopup.Activate(msgPopupErrorTitle, "No changes found, aborting.")
269 } else {
270 _, err := bug.EditComment(target, message)
271 if err != nil {
272 return err
273 }
274 }
275
276 initGui(nil)
277
278 return errTerminateMainloop
279}
280
281func setTitleWithEditor(bug *cache.BugCache) error {
282 // This is somewhat hacky.
283 // As there is no way to pause gocui, run the editor and restart gocui,
284 // we have to stop it entirely and start a new one later.
285 //
286 // - an error channel is used to route the returned error of this new
287 // instance into the original launch function
288 // - a custom error (errTerminateMainloop) is used to terminate the original
289 // instance's mainLoop. This error is then filtered.
290
291 ui.g.Close()
292 ui.g = nil
293
294 snap := bug.Snapshot()
295
296 title, err := input.BugTitleEditorInput(ui.cache, snap.Title)
297
298 if err != nil && err != input.ErrEmptyTitle {
299 return err
300 }
301
302 if err == input.ErrEmptyTitle {
303 ui.msgPopup.Activate(msgPopupErrorTitle, "Empty title, aborting.")
304 } else if title == snap.Title {
305 ui.msgPopup.Activate(msgPopupErrorTitle, "No change, aborting.")
306 } else {
307 _, err := bug.SetTitle(title)
308 if err != nil {
309 return err
310 }
311 }
312
313 initGui(nil)
314
315 return errTerminateMainloop
316}
317
318func editQueryWithEditor(bt *bugTable) error {
319 // This is somewhat hacky.
320 // As there is no way to pause gocui, run the editor and restart gocui,
321 // we have to stop it entirely and start a new one later.
322 //
323 // - an error channel is used to route the returned error of this new
324 // instance into the original launch function
325 // - a custom error (errTerminateMainloop) is used to terminate the original
326 // instance's mainLoop. This error is then filtered.
327
328 ui.g.Close()
329 ui.g = nil
330
331 queryStr, err := input.QueryEditorInput(bt.repo, bt.queryStr)
332
333 if err != nil {
334 return err
335 }
336
337 bt.queryStr = queryStr
338
339 query, err := cache.ParseQuery(queryStr)
340
341 if err != nil {
342 ui.msgPopup.Activate(msgPopupErrorTitle, err.Error())
343 } else {
344 bt.query = query
345 }
346
347 initGui(nil)
348
349 return errTerminateMainloop
350}
351
352func maxInt(a, b int) int {
353 if a > b {
354 return a
355 }
356 return b
357}
358
359func minInt(a, b int) int {
360 if a > b {
361 return b
362 }
363 return a
364}