termui.go

  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}