termui.go

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