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