termui.go

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