diff --git a/gui.go b/gui.go index ba8d93c..e91735f 100644 --- a/gui.go +++ b/gui.go @@ -743,6 +743,8 @@ func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) error { } } } + + showScrollbar, realScrollbarStart, realScrollbarEnd := calcRealScrollbarStartEnd(v) for y := v.y0 + 1; y < v.y1 && y < g.maxY; y++ { if y < 0 { continue @@ -753,7 +755,9 @@ func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) error { } } if v.x1 > -1 && v.x1 < g.maxX { - if err := g.SetRune(v.x1, y, runeV, fgColor, bgColor); err != nil { + runeToPrint := calcScrollbarRune(showScrollbar, realScrollbarStart, realScrollbarEnd, v.y0+1, v.y1-1, y, runeV) + + if err := g.SetRune(v.x1, y, runeToPrint, fgColor, bgColor); err != nil { return err } } @@ -761,6 +765,44 @@ func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) error { return nil } +func calcScrollbarRune(showScrollbar bool, scrollbarStart int, scrollbarEnd int, rangeStart int, rangeEnd int, position int, runeV rune) rune { + if !showScrollbar { + return runeV + } else if position == rangeStart { + return '▲' + } else if position == rangeEnd { + return '▼' + } else if position > scrollbarStart && position < scrollbarEnd { + return '█' + } else if position > rangeStart && position < rangeEnd { + // keeping this as a separate branch in case we later want to render something different here. + return runeV + } else { + return runeV + } +} + +func calcRealScrollbarStartEnd(v *View) (bool, int, int) { + height := v.InnerHeight() + 1 + fullHeight := v.ViewLinesHeight() + + if v.CanScrollPastBottom { + fullHeight += height - 2 + } + + if height < 2 || height >= fullHeight { + return false, 0, 0 + } + + originY := v.OriginY() + scrollbarStart, scrollbarHeight := calcScrollbar(fullHeight, height, originY, height-1) + top := v.y0 + 1 + realScrollbarStart := top + scrollbarStart + realScrollbarEnd := realScrollbarStart + scrollbarHeight + + return true, realScrollbarStart, realScrollbarEnd +} + func cornerRune(index byte) rune { return []rune{' ', '│', '│', '│', '─', '┘', '┐', '┤', '─', '└', '┌', '├', '├', '┴', '┬', '┼'}[index] } @@ -1014,6 +1056,40 @@ func (g *Gui) draw(v *View) error { if !v.Visible || v.y1 < v.y0 { return nil } + + if g.Cursor { + if curview := g.currentView; curview != nil { + vMaxX, vMaxY := curview.Size() + if curview.cx < 0 { + curview.cx = 0 + } else if curview.cx >= vMaxX { + curview.cx = vMaxX - 1 + } + if curview.cy < 0 { + curview.cy = 0 + } else if curview.cy >= vMaxY { + curview.cy = vMaxY - 1 + } + + gMaxX, gMaxY := g.Size() + cx, cy := curview.x0+curview.cx+1, curview.y0+curview.cy+1 + // This test probably doesn't need to be here. + // tcell is hiding cursor by setting coordinates outside of screen. + // Keeping it here for now, as I'm not 100% sure :) + if cx >= 0 && cx < gMaxX && cy >= 0 && cy < gMaxY { + Screen.ShowCursor(cx, cy) + } else { + Screen.HideCursor() + } + } + } else { + Screen.HideCursor() + } + + if err := v.draw(); err != nil { + return err + } + if v.Frame { var fgColor, bgColor, frameColor Attribute if g.Highlight && v == g.currentView { @@ -1057,38 +1133,6 @@ func (g *Gui) draw(v *View) error { } } - if g.Cursor { - if curview := g.currentView; curview != nil { - vMaxX, vMaxY := curview.Size() - if curview.cx < 0 { - curview.cx = 0 - } else if curview.cx >= vMaxX { - curview.cx = vMaxX - 1 - } - if curview.cy < 0 { - curview.cy = 0 - } else if curview.cy >= vMaxY { - curview.cy = vMaxY - 1 - } - - gMaxX, gMaxY := g.Size() - cx, cy := curview.x0+curview.cx+1, curview.y0+curview.cy+1 - // This test probably doesn't need to be here. - // tcell is hiding cursor by setting coordinates outside of screen. - // Keeping it here for now, as I'm not 100% sure :) - if cx >= 0 && cx < gMaxX && cy >= 0 && cy < gMaxY { - Screen.ShowCursor(cx, cy) - } else { - Screen.HideCursor() - } - } - } else { - Screen.HideCursor() - } - - if err := v.draw(); err != nil { - return err - } return nil } diff --git a/scrollbar.go b/scrollbar.go new file mode 100644 index 0000000..3bdb4a4 --- /dev/null +++ b/scrollbar.go @@ -0,0 +1,33 @@ +package gocui + +import "math" + +// returns start and height of scrollbar +// `max` is the maximum possible value of `position` +func calcScrollbar(listSize int, pageSize int, position int, scrollAreaSize int) (int, int) { + height := calcScrollbarHeight(listSize, pageSize, scrollAreaSize) + // assume we can't scroll past the last item + maxPosition := listSize - pageSize + if maxPosition <= 0 { + return 0, height + } + if position == maxPosition { + return scrollAreaSize - height, height + } + // we only want to show the scrollbar at the top or bottom positions if we're at the end. Hence the .Ceil (for moving the scrollbar once we scroll down) and the -1 (for pretending there's a smaller range than we actually have, with the above condition ensuring we snap to the bottom once we're at the end of the list) + start := int(math.Ceil(((float64(position) / float64(maxPosition)) * float64(scrollAreaSize-height-1)))) + return start, height +} + +func calcScrollbarHeight(listSize int, pageSize int, scrollAreaSize int) int { + if pageSize >= listSize { + return scrollAreaSize + } + height := int((float64(pageSize) / float64(listSize)) * float64(scrollAreaSize)) + minHeight := 2 + if height < minHeight { + return minHeight + } + + return height +} diff --git a/scrollbar_test.go b/scrollbar_test.go new file mode 100644 index 0000000..63db4bb --- /dev/null +++ b/scrollbar_test.go @@ -0,0 +1,114 @@ +package gocui + +import "testing" + +func TestCalcScrollbar(t *testing.T) { + tests := []struct { + testName string + listSize int + pageSize int + position int + scrollAreaSize int + + expectedStart int + expectedHeight int + }{ + { + testName: "page size greater than list size", + listSize: 5, + pageSize: 10, + position: 0, + scrollAreaSize: 20, + + expectedStart: 0, + expectedHeight: 20, + }, + { + testName: "page size matches list size", + listSize: 10, + pageSize: 10, + position: 0, + scrollAreaSize: 20, + + expectedStart: 0, + expectedHeight: 20, + }, + { + testName: "page size half of list size", + listSize: 10, + pageSize: 5, + position: 0, + scrollAreaSize: 20, + + expectedStart: 0, + expectedHeight: 10, + }, + { + testName: "page size half of list size at scroll end", + listSize: 10, + pageSize: 5, + position: 5, + scrollAreaSize: 20, + + expectedStart: 10, + expectedHeight: 10, + }, + { + testName: "page size third of list size having scrolled half the way", + listSize: 15, + // Recall that my max position is listSize - pageSize i.e 15 - 5 i.e. 10. + // So if I've scrolled to position 5 that means I've done one page and I've got + // one page to go which means by scrollbar should take up a third of the available + // space and appear in the centre of the scrollbar area + pageSize: 5, + position: 5, + scrollAreaSize: 21, + + expectedStart: 7, + expectedHeight: 7, + }, + { + testName: "page size third of list size having scrolled the full way", + listSize: 15, + pageSize: 5, + position: 10, + scrollAreaSize: 21, + + expectedStart: 14, + expectedHeight: 7, + }, + { + testName: "page size third of list size having scrolled by one", + listSize: 15, + pageSize: 5, + position: 1, + scrollAreaSize: 21, + + expectedStart: 2, + expectedHeight: 7, + }, + { + testName: "page size third of list size having scrolled up from the bottom by one", + listSize: 15, + pageSize: 5, + position: 9, + scrollAreaSize: 21, + + expectedStart: 12, + expectedHeight: 7, + }, + } + + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + start, height := calcScrollbar(test.listSize, test.pageSize, test.position, test.scrollAreaSize) + if start != test.expectedStart { + t.Errorf("expected start to be %d, got %d", test.expectedStart, start) + } + + if height != test.expectedHeight { + t.Errorf("expected height to be %d, got %d", test.expectedHeight, height) + } + }) + } +} diff --git a/view.go b/view.go index d2c2b75..2d30d87 100644 --- a/view.go +++ b/view.go @@ -158,6 +158,9 @@ type View struct { // something like '1 of 20' for a list view Footer string + + // if true, the user can scroll all the way past the last item until it appears at the top of the view + CanScrollPastBottom bool } // call this in the event of a view resize, or if you want to render new content @@ -1287,3 +1290,64 @@ func (v *View) OverwriteLines(y int, content string) { lines := strings.Replace(content, "\n", "\x1b[K\n", -1) v.writeString(lines) } + +func (v *View) ScrollUp(amount int) { + newOy := v.oy - amount + if newOy < 0 { + newOy = 0 + } + v.oy = newOy +} + +// ensures we don't scroll past the end of the view's content +func (v *View) ScrollDown(amount int) { + adjustedAmount := v.adjustDownwardScrollAmount(amount) + if adjustedAmount > 0 { + v.oy += adjustedAmount + } +} + +func (v *View) ScrollLeft(amount int) { + newOx := v.ox - amount + if newOx < 0 { + newOx = 0 + } + v.ox = newOx +} + +// not applying any limits to this +func (v *View) ScrollRight(amount int) { + v.ox += amount +} + +func (v *View) adjustDownwardScrollAmount(scrollHeight int) int { + _, oy := v.Origin() + y := oy + if !v.CanScrollPastBottom { + _, sy := v.Size() + y += sy + } + scrollableLines := v.ViewLinesHeight() - y + if scrollableLines < 0 { + return 0 + } + + // margin is about how many lines must still appear if you scroll + // all the way down. + margin := 0 + if v.CanScrollPastBottom { + // Setting to 2 because of the newline at the end of the file that we're likely showing. + // If we want to scroll past bottom outside the context of reading a file's contents, + // we should make this into a field on the view to be configured by the client. + // For now we're hardcoding it. + margin = 2 + } + if scrollableLines-margin < scrollHeight { + scrollHeight = scrollableLines - margin + } + if oy+scrollHeight < 0 { + return 0 + } else { + return scrollHeight + } +}