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