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