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