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