git-bug/termui/show_bug.go
Zdenek Crha 999e61224c Fix 'no description' readability in terminal with bright background
The rendering of color for 'No description provided' text is broken on
bright terminals - it sets black background which together with default
black forground color renders opaque rectangle.

The GreyBold color alias is broken too - name suggests bold gray
forground color, but actually sets bold default fg color with black
bacground.

First make color alias consistent. Rename it to BlackBold and have it
set bold black fg color (same as similar *Bold aliases).

Second, update all places which use it to render text to also use white
background to prevent it from disappering in terminals with black
background color.
2020-09-27 09:22:34 +02:00

670 lines
14 KiB
Go

package termui
import (
"bytes"
"fmt"
"strings"
"github.com/MichaelMure/go-term-text"
"github.com/awesome-gocui/gocui"
"github.com/MichaelMure/git-bug/bug"
"github.com/MichaelMure/git-bug/cache"
"github.com/MichaelMure/git-bug/entity"
"github.com/MichaelMure/git-bug/util/colors"
)
const showBugView = "showBugView"
const showBugSidebarView = "showBugSidebarView"
const showBugInstructionView = "showBugInstructionView"
const showBugHeaderView = "showBugHeaderView"
const timeLayout = "Jan 2 2006"
var showBugHelp = helpBar{
{"q", "Save and return"},
{"←↓↑→,hjkl", "Navigation"},
{"o", "Toggle open/close"},
{"e", "Edit"},
{"c", "Comment"},
{"t", "Change title"},
}
type showBug struct {
cache *cache.RepoCache
bug *cache.BugCache
childViews []string
mainSelectableView []string
sideSelectableView []string
selected string
isOnSide bool
scroll int
}
func newShowBug(cache *cache.RepoCache) *showBug {
return &showBug{
cache: cache,
}
}
func (sb *showBug) SetBug(bug *cache.BugCache) {
sb.bug = bug
sb.scroll = 0
sb.selected = ""
sb.isOnSide = false
}
func (sb *showBug) layout(g *gocui.Gui) error {
maxX, maxY := g.Size()
sb.childViews = nil
v, err := g.SetView(showBugView, 0, 0, maxX*2/3, maxY-2, 0)
if err != nil {
if !gocui.IsUnknownView(err) {
return err
}
sb.childViews = append(sb.childViews, showBugView)
v.Frame = false
}
v.Clear()
err = sb.renderMain(g, v)
if err != nil {
return err
}
v, err = g.SetView(showBugSidebarView, maxX*2/3+1, 0, maxX-1, maxY-2, 0)
if err != nil {
if !gocui.IsUnknownView(err) {
return err
}
sb.childViews = append(sb.childViews, showBugSidebarView)
v.Frame = false
}
v.Clear()
err = sb.renderSidebar(g, v)
if err != nil {
return err
}
v, err = g.SetView(showBugInstructionView, -1, maxY-2, maxX, maxY, 0)
if err != nil {
if !gocui.IsUnknownView(err) {
return err
}
sb.childViews = append(sb.childViews, showBugInstructionView)
v.Frame = false
v.FgColor = gocui.ColorWhite
}
v.Clear()
_, _ = fmt.Fprint(v, showBugHelp.Render(maxX))
_, err = g.SetViewOnTop(showBugInstructionView)
if err != nil {
return err
}
_, err = g.SetCurrentView(showBugView)
return err
}
func (sb *showBug) keybindings(g *gocui.Gui) error {
// Return
if err := g.SetKeybinding(showBugView, 'q', gocui.ModNone, sb.saveAndBack); err != nil {
return err
}
// Scrolling
if err := g.SetKeybinding(showBugView, gocui.KeyPgup, gocui.ModNone,
sb.scrollUp); err != nil {
return err
}
if err := g.SetKeybinding(showBugView, gocui.KeyPgdn, gocui.ModNone,
sb.scrollDown); err != nil {
return err
}
// Down
if err := g.SetKeybinding(showBugView, 'j', gocui.ModNone,
sb.selectNext); err != nil {
return err
}
if err := g.SetKeybinding(showBugView, gocui.KeyArrowDown, gocui.ModNone,
sb.selectNext); err != nil {
return err
}
// Up
if err := g.SetKeybinding(showBugView, 'k', gocui.ModNone,
sb.selectPrevious); err != nil {
return err
}
if err := g.SetKeybinding(showBugView, gocui.KeyArrowUp, gocui.ModNone,
sb.selectPrevious); err != nil {
return err
}
// Left
if err := g.SetKeybinding(showBugView, 'h', gocui.ModNone,
sb.left); err != nil {
return err
}
if err := g.SetKeybinding(showBugView, gocui.KeyArrowLeft, gocui.ModNone,
sb.left); err != nil {
return err
}
// Right
if err := g.SetKeybinding(showBugView, 'l', gocui.ModNone,
sb.right); err != nil {
return err
}
if err := g.SetKeybinding(showBugView, gocui.KeyArrowRight, gocui.ModNone,
sb.right); err != nil {
return err
}
// Comment
if err := g.SetKeybinding(showBugView, 'c', gocui.ModNone,
sb.comment); err != nil {
return err
}
// Open/close
if err := g.SetKeybinding(showBugView, 'o', gocui.ModNone,
sb.toggleOpenClose); err != nil {
return err
}
// Title
if err := g.SetKeybinding(showBugView, 't', gocui.ModNone,
sb.setTitle); err != nil {
return err
}
// Edit
if err := g.SetKeybinding(showBugView, 'e', gocui.ModNone,
sb.edit); err != nil {
return err
}
return nil
}
func (sb *showBug) disable(g *gocui.Gui) error {
for _, view := range sb.childViews {
if err := g.DeleteView(view); err != nil && !gocui.IsUnknownView(err) {
return err
}
}
return nil
}
func (sb *showBug) renderMain(g *gocui.Gui, mainView *gocui.View) error {
maxX, _ := mainView.Size()
x0, y0, _, _, _ := g.ViewPosition(mainView.Name())
y0 -= sb.scroll
snap := sb.bug.Snapshot()
sb.mainSelectableView = nil
createTimelineItem := snap.Timeline[0].(*bug.CreateTimelineItem)
edited := ""
if createTimelineItem.Edited() {
edited = " (edited)"
}
bugHeader := fmt.Sprintf("[%s] %s\n\n[%s] %s opened this bug on %s%s",
colors.Cyan(snap.Id().Human()),
colors.Bold(snap.Title),
colors.Yellow(snap.Status),
colors.Magenta(snap.Author.DisplayName()),
snap.CreateTime.Format(timeLayout),
edited,
)
bugHeader, lines := text.Wrap(bugHeader, maxX, text.WrapIndent(" "))
v, err := sb.createOpView(g, showBugHeaderView, x0, y0, maxX+1, lines, false)
if err != nil {
return err
}
_, _ = fmt.Fprint(v, bugHeader)
y0 += lines + 1
for _, op := range snap.Timeline {
viewName := op.Id().String()
// TODO: me might skip the rendering of blocks that are outside of the view
// but to do that we need to rework how sb.mainSelectableView is maintained
switch op := op.(type) {
case *bug.CreateTimelineItem:
var content string
var lines int
if op.MessageIsEmpty() {
content, lines = text.WrapLeftPadded(emptyMessagePlaceholder(), maxX-1, 4)
} else {
content, lines = text.WrapLeftPadded(op.Message, maxX-1, 4)
}
v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
if err != nil {
return err
}
_, _ = fmt.Fprint(v, content)
y0 += lines + 2
case *bug.AddCommentTimelineItem:
edited := ""
if op.Edited() {
edited = " (edited)"
}
var message string
if op.MessageIsEmpty() {
message, _ = text.WrapLeftPadded(emptyMessagePlaceholder(), maxX-1, 4)
} else {
message, _ = text.WrapLeftPadded(op.Message, maxX-1, 4)
}
content := fmt.Sprintf("%s commented on %s%s\n\n%s",
colors.Magenta(op.Author.DisplayName()),
op.CreatedAt.Time().Format(timeLayout),
edited,
message,
)
content, lines = text.Wrap(content, maxX)
v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
if err != nil {
return err
}
_, _ = fmt.Fprint(v, content)
y0 += lines + 2
case *bug.SetTitleTimelineItem:
content := fmt.Sprintf("%s changed the title to %s on %s",
colors.Magenta(op.Author.DisplayName()),
colors.Bold(op.Title),
op.UnixTime.Time().Format(timeLayout),
)
content, lines := text.Wrap(content, maxX)
v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
if err != nil {
return err
}
_, _ = fmt.Fprint(v, content)
y0 += lines + 2
case *bug.SetStatusTimelineItem:
content := fmt.Sprintf("%s %s the bug on %s",
colors.Magenta(op.Author.DisplayName()),
colors.Bold(op.Status.Action()),
op.UnixTime.Time().Format(timeLayout),
)
content, lines := text.Wrap(content, maxX)
v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
if err != nil {
return err
}
_, _ = fmt.Fprint(v, content)
y0 += lines + 2
case *bug.LabelChangeTimelineItem:
var added []string
for _, label := range op.Added {
added = append(added, colors.Bold("\""+label+"\""))
}
var removed []string
for _, label := range op.Removed {
removed = append(removed, colors.Bold("\""+label+"\""))
}
var action bytes.Buffer
if len(added) > 0 {
action.WriteString("added ")
action.WriteString(strings.Join(added, ", "))
if len(removed) > 0 {
action.WriteString(" and ")
}
}
if len(removed) > 0 {
action.WriteString("removed ")
action.WriteString(strings.Join(removed, ", "))
}
if len(added)+len(removed) > 1 {
action.WriteString(" labels")
} else {
action.WriteString(" label")
}
content := fmt.Sprintf("%s %s on %s",
colors.Magenta(op.Author.DisplayName()),
action.String(),
op.UnixTime.Time().Format(timeLayout),
)
content, lines := text.Wrap(content, maxX)
v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
if err != nil {
return err
}
_, _ = fmt.Fprint(v, content)
y0 += lines + 2
}
}
return nil
}
// emptyMessagePlaceholder return a formatted placeholder for an empty message
func emptyMessagePlaceholder() string {
return colors.BlackBold(colors.WhiteBg("No description provided."))
}
func (sb *showBug) createOpView(g *gocui.Gui, name string, x0 int, y0 int, maxX int, height int, selectable bool) (*gocui.View, error) {
v, err := g.SetView(name, x0, y0, maxX, y0+height+1, 0)
if err != nil && !gocui.IsUnknownView(err) {
return nil, err
}
sb.childViews = append(sb.childViews, name)
if selectable {
sb.mainSelectableView = append(sb.mainSelectableView, name)
}
v.Frame = sb.selected == name
v.Clear()
return v, nil
}
func (sb *showBug) createSideView(g *gocui.Gui, name string, x0 int, y0 int, maxX int, height int) (*gocui.View, error) {
v, err := g.SetView(name, x0, y0, maxX, y0+height+1, 0)
if err != nil && !gocui.IsUnknownView(err) {
return nil, err
}
sb.childViews = append(sb.childViews, name)
sb.sideSelectableView = append(sb.sideSelectableView, name)
v.Frame = sb.selected == name
v.Clear()
return v, nil
}
func (sb *showBug) renderSidebar(g *gocui.Gui, sideView *gocui.View) error {
maxX, _ := sideView.Size()
x0, y0, _, _, _ := g.ViewPosition(sideView.Name())
maxX += x0
snap := sb.bug.Snapshot()
sb.sideSelectableView = nil
labelStr := make([]string, len(snap.Labels))
for i, l := range snap.Labels {
lc := l.Color()
lc256 := lc.Term256()
labelStr[i] = lc256.Escape() + "◼ " + lc256.Unescape() + l.String()
}
labels := strings.Join(labelStr, "\n")
labels, lines := text.WrapLeftPadded(labels, maxX, 2)
content := fmt.Sprintf("%s\n\n%s", colors.Bold(" Labels"), labels)
v, err := sb.createSideView(g, "sideLabels", x0, y0, maxX, lines+2)
if err != nil {
return err
}
_, _ = fmt.Fprint(v, content)
return nil
}
func (sb *showBug) saveAndBack(g *gocui.Gui, v *gocui.View) error {
err := sb.bug.CommitAsNeeded()
if err != nil {
return err
}
err = ui.activateWindow(ui.bugTable)
if err != nil {
return err
}
return nil
}
func (sb *showBug) scrollUp(g *gocui.Gui, v *gocui.View) error {
mainView, err := g.View(showBugView)
if err != nil {
return err
}
_, maxY := mainView.Size()
sb.scroll -= maxY / 2
sb.scroll = maxInt(sb.scroll, 0)
return nil
}
func (sb *showBug) scrollDown(g *gocui.Gui, v *gocui.View) error {
_, maxY := v.Size()
lastViewName := sb.mainSelectableView[len(sb.mainSelectableView)-1]
lastView, err := g.View(lastViewName)
if err != nil {
return err
}
_, vMaxY := lastView.Size()
_, vy0, _, _, err := g.ViewPosition(lastViewName)
if err != nil {
return err
}
maxScroll := vy0 + sb.scroll + vMaxY - maxY
sb.scroll += maxY / 2
sb.scroll = minInt(sb.scroll, maxScroll)
return nil
}
func (sb *showBug) selectPrevious(g *gocui.Gui, v *gocui.View) error {
var selectable []string
if sb.isOnSide {
selectable = sb.sideSelectableView
} else {
selectable = sb.mainSelectableView
}
for i, name := range selectable {
if name == sb.selected {
// special case to scroll up to the top
if i == 0 {
sb.scroll = 0
}
sb.selected = selectable[maxInt(i-1, 0)]
return sb.focusView(g)
}
}
if sb.selected == "" && len(selectable) > 0 {
sb.selected = selectable[0]
}
return sb.focusView(g)
}
func (sb *showBug) selectNext(g *gocui.Gui, v *gocui.View) error {
var selectable []string
if sb.isOnSide {
selectable = sb.sideSelectableView
} else {
selectable = sb.mainSelectableView
}
for i, name := range selectable {
if name == sb.selected {
sb.selected = selectable[minInt(i+1, len(selectable)-1)]
return sb.focusView(g)
}
}
if sb.selected == "" && len(selectable) > 0 {
sb.selected = selectable[0]
}
return sb.focusView(g)
}
func (sb *showBug) left(g *gocui.Gui, v *gocui.View) error {
if sb.isOnSide {
sb.isOnSide = false
sb.selected = ""
return sb.selectNext(g, v)
}
if sb.selected == "" {
return sb.selectNext(g, v)
}
return nil
}
func (sb *showBug) right(g *gocui.Gui, v *gocui.View) error {
if !sb.isOnSide {
sb.isOnSide = true
sb.selected = ""
return sb.selectNext(g, v)
}
if sb.selected == "" {
return sb.selectNext(g, v)
}
return nil
}
func (sb *showBug) focusView(g *gocui.Gui) error {
mainView, err := g.View(showBugView)
if err != nil {
return err
}
_, maxY := mainView.Size()
_, vy0, _, _, err := g.ViewPosition(sb.selected)
if err != nil {
return err
}
v, err := g.View(sb.selected)
if err != nil {
return err
}
_, vMaxY := v.Size()
vy1 := vy0 + vMaxY
if vy0 < 0 {
sb.scroll += vy0
return nil
}
if vy1 > maxY {
sb.scroll -= maxY - vy1
}
return nil
}
func (sb *showBug) comment(g *gocui.Gui, v *gocui.View) error {
return addCommentWithEditor(sb.bug)
}
func (sb *showBug) setTitle(g *gocui.Gui, v *gocui.View) error {
return setTitleWithEditor(sb.bug)
}
func (sb *showBug) toggleOpenClose(g *gocui.Gui, v *gocui.View) error {
switch sb.bug.Snapshot().Status {
case bug.OpenStatus:
_, err := sb.bug.Close()
return err
case bug.ClosedStatus:
_, err := sb.bug.Open()
return err
default:
return nil
}
}
func (sb *showBug) edit(g *gocui.Gui, v *gocui.View) error {
snap := sb.bug.Snapshot()
if sb.isOnSide {
return sb.editLabels(g, snap)
}
if sb.selected == "" {
return nil
}
op, err := snap.SearchTimelineItem(entity.Id(sb.selected))
if err != nil {
return err
}
switch op := op.(type) {
case *bug.AddCommentTimelineItem:
return editCommentWithEditor(sb.bug, op.Id(), op.Message)
case *bug.CreateTimelineItem:
return editCommentWithEditor(sb.bug, op.Id(), op.Message)
case *bug.LabelChangeTimelineItem:
return sb.editLabels(g, snap)
}
ui.msgPopup.Activate(msgPopupErrorTitle, "Selected field is not editable.")
return nil
}
func (sb *showBug) editLabels(g *gocui.Gui, snap *bug.Snapshot) error {
ui.labelSelect.SetBug(sb.cache, sb.bug)
return ui.activateWindow(ui.labelSelect)
}