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 buginput "github.com/MichaelMure/git-bug/commands/bug/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 := buginput.BugCreateEditorInput(ui.cache, "", "")
191
192 if err != nil && err != buginput.ErrEmptyTitle {
193 return err
194 }
195
196 var b *cache.BugCache
197 if err == buginput.ErrEmptyTitle {
198 ui.msgPopup.Activate(msgPopupErrorTitle, "Empty title, aborting.")
199 initGui(nil)
200
201 return errTerminateMainloop
202 } else {
203 b, _, err = repo.Bugs().New(
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 := buginput.BugCommentEditorInput(ui.cache, "")
234 if err != nil && err != buginput.ErrEmptyMessage {
235 return err
236 }
237 if err == buginput.ErrEmptyMessage {
238 ui.msgPopup.Activate(msgPopupErrorTitle, "Empty message, aborting.")
239 } else {
240 _, _, err := bug.AddComment(text.Cleanup(message))
241 if err != nil {
242 return err
243 }
244 }
245
246 initGui(nil)
247
248 return errTerminateMainloop
249}
250
251func editCommentWithEditor(bug *cache.BugCache, target entity.CombinedId, preMessage string) error {
252 // This is somewhat hacky.
253 // As there is no way to pause gocui, run the editor and restart gocui,
254 // we have to stop it entirely and start a new one later.
255 //
256 // - an error channel is used to route the returned error of this new
257 // instance into the original launch function
258 // - a custom error (errTerminateMainloop) is used to terminate the original
259 // instance's mainLoop. This error is then filtered.
260
261 ui.g.Close()
262 ui.g = nil
263
264 message, err := buginput.BugCommentEditorInput(ui.cache, preMessage)
265 if err != nil && err != buginput.ErrEmptyMessage {
266 return err
267 }
268
269 if err == buginput.ErrEmptyMessage {
270 // TODO: Allow comments to be deleted?
271 ui.msgPopup.Activate(msgPopupErrorTitle, "Empty message, aborting.")
272 } else if message == preMessage {
273 ui.msgPopup.Activate(msgPopupErrorTitle, "No changes found, aborting.")
274 } else {
275 _, err := bug.EditComment(target, text.Cleanup(message))
276 if err != nil {
277 return err
278 }
279 }
280
281 initGui(nil)
282
283 return errTerminateMainloop
284}
285
286func setTitleWithEditor(bug *cache.BugCache) error {
287 // This is somewhat hacky.
288 // As there is no way to pause gocui, run the editor and restart gocui,
289 // we have to stop it entirely and start a new one later.
290 //
291 // - an error channel is used to route the returned error of this new
292 // instance into the original launch function
293 // - a custom error (errTerminateMainloop) is used to terminate the original
294 // instance's mainLoop. This error is then filtered.
295
296 ui.g.Close()
297 ui.g = nil
298
299 snap := bug.Compile()
300
301 title, err := buginput.BugTitleEditorInput(ui.cache, snap.Title)
302
303 if err != nil && err != buginput.ErrEmptyTitle {
304 return err
305 }
306
307 if err == buginput.ErrEmptyTitle {
308 ui.msgPopup.Activate(msgPopupErrorTitle, "Empty title, aborting.")
309 } else if title == snap.Title {
310 ui.msgPopup.Activate(msgPopupErrorTitle, "No change, aborting.")
311 } else {
312 _, err := bug.SetTitle(text.CleanupOneLine(title))
313 if err != nil {
314 return err
315 }
316 }
317
318 initGui(nil)
319
320 return errTerminateMainloop
321}
322
323func editQueryWithEditor(bt *bugTable) error {
324 // This is somewhat hacky.
325 // As there is no way to pause gocui, run the editor and restart gocui,
326 // we have to stop it entirely and start a new one later.
327 //
328 // - an error channel is used to route the returned error of this new
329 // instance into the original launch function
330 // - a custom error (errTerminateMainloop) is used to terminate the original
331 // instance's mainLoop. This error is then filtered.
332
333 ui.g.Close()
334 ui.g = nil
335
336 queryStr, err := buginput.QueryEditorInput(bt.repo, bt.queryStr)
337
338 if err != nil {
339 return err
340 }
341
342 bt.queryStr = queryStr
343
344 q, err := query.Parse(queryStr)
345
346 if err != nil {
347 ui.msgPopup.Activate(msgPopupErrorTitle, err.Error())
348 } else {
349 bt.query = q
350 }
351
352 initGui(nil)
353
354 return errTerminateMainloop
355}
356
357func maxInt(a, b int) int {
358 if a > b {
359 return a
360 }
361 return b
362}
363
364func minInt(a, b int) int {
365 if a > b {
366 return b
367 }
368 return a
369}