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