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