diff --git a/widgets/text/scroll.go b/widgets/text/scroll.go new file mode 100644 index 0000000..2f21514 --- /dev/null +++ b/widgets/text/scroll.go @@ -0,0 +1,133 @@ +package text + +// scroll.go contains code that tracks the current scrolling position. + +import "math" + +// scrollTracker tracks the current scrolling position for the Text widget. +// This is not thread safe. +type scrollTracker struct { + // scroll stores user requests to scroll up (negative) or down (positive). + // E.g. -1 means up by one line and 2 means down by two lines. + scroll int + + // scrollPage stores user requests to scroll up (negative) or down + // (positive) by a page of content. E.g. -1 means up by one page and 2 + // means down by two pages. + scrollPage int + + // first tracks the first line that will be printed. + first int + + // state is the state of the scrolling FSM. + state rollState +} + +// newScrollTracker returns a new scroll tracker. +func newScrollTracker(opts *options) *scrollTracker { + if opts.rollContent { + return &scrollTracker{state: rollToEnd} + } + return &scrollTracker{state: rollingDisabled} +} + +// upOneLine processes a user request to scroll up by one line. +func (st *scrollTracker) upOneLine() { + st.scroll-- +} + +// downOneLine processes a user request to scroll down by one line. +func (st *scrollTracker) downOneLine() { + st.scroll++ +} + +// upOnePage processes a user request to scroll up by one page. +func (st *scrollTracker) upOnePage() { + st.scrollPage-- +} + +// downOnePage processes a user request to scroll down by one page. +func (st *scrollTracker) downOnePage() { + st.scrollPage++ +} + +// doScroll processes any outstanding scroll requests and calculates the +// resulting first line. +func (st *scrollTracker) doScroll(lines, height int) int { + first := st.first + st.scroll + st.scrollPage*height + st.scroll = 0 + st.scrollPage = 0 + return normalizeScroll(first, lines, height) +} + +// firstLine returns the number of the first line that should be drawn on a +// canvas of the specified height if there is the provided number of lines of +// text. +func (st *scrollTracker) firstLine(lines, height int) int { + // Execute the scrolling FSM. + st.state = st.state(st, lines, height) + return st.first +} + +// rollState is a state in the scrolling FSM. +type rollState func(st *scrollTracker, lines, height int) rollState + +// rollingDisabled is a state where content rolling was disabled by the +// configuration of the Text widget. +func rollingDisabled(st *scrollTracker, lines, height int) rollState { + st.first = st.doScroll(lines, height) + return rollingDisabled +} + +// rollToEnd is a state in which the last line of the content is always +// visible. When new content arrives, it is rolled upwards. +func rollToEnd(st *scrollTracker, lines, height int) rollState { + // If the user didn't scroll, just roll the content so that the last line + // is visible. + if st.scroll == 0 && st.scrollPage == 0 { + st.first = normalizeScroll(math.MaxUint32, lines, height) + return rollToEnd + } + + st.first = st.doScroll(lines, height) + if lastLineVisible(st.first, lines, height) { + return rollToEnd + } + return rollingPaused +} + +// rollingPaused is a state in which the user scrolled up and made the last +// line scroll out of the view, so the content rolling is paused. +func rollingPaused(st *scrollTracker, lines, height int) rollState { + st.first = st.doScroll(lines, height) + if lastLineVisible(st.first, lines, height) { + return rollToEnd + } + return rollingPaused +} + +// lastLineVisible returns true if the last line is visible given drawing that +// starts from the first line, the number of lines and the height of the +// canvas. +func lastLineVisible(first, lines, height int) bool { + return lines-first <= height +} + +// normalizeScroll returns normalized position of the first line that should be +// drawn when drawing the specified number of lines on a canvas with the +// provided height. +func normalizeScroll(first, lines, height int) int { + if first < 0 || lines <= 0 || height <= 0 { + return 0 + } + + if lines <= height { + return 0 // Scrolling not necessary if the content fits. + } + + max := lines - height + if first > max { + return max + } + return first +} diff --git a/widgets/text/scroll_test.go b/widgets/text/scroll_test.go new file mode 100644 index 0000000..a3113ba --- /dev/null +++ b/widgets/text/scroll_test.go @@ -0,0 +1,318 @@ +package text + +import ( + "testing" +) + +func TestScrollTrackerNoContentRolling(t *testing.T) { + tests := []struct { + desc string + lines int + height int + events func(*scrollTracker) + want int + }{ + { + desc: "starts from the first line", + lines: 2, + height: 1, + want: 0, + }, + { + desc: "user can scroll down by a line", + lines: 2, + height: 1, + events: func(st *scrollTracker) { + st.downOneLine() + }, + want: 1, + }, + { + desc: "scroll down capped at the last line", + lines: 2, + height: 1, + events: func(st *scrollTracker) { + st.downOneLine() + st.downOneLine() + }, + want: 1, + }, + { + desc: "larger terminal, scroll down capped at the last line", + lines: 4, + height: 2, + events: func(st *scrollTracker) { + st.downOneLine() + st.downOneLine() + st.downOneLine() + st.downOneLine() + st.downOneLine() + }, + want: 2, + }, + { + desc: "scroll up capped at the first line", + lines: 2, + height: 1, + events: func(st *scrollTracker) { + st.upOneLine() + st.upOneLine() + }, + want: 0, + }, + { + desc: "processes multiple scroll events", + lines: 4, + height: 2, + events: func(st *scrollTracker) { + st.downOneLine() + st.downOneLine() + st.upOneLine() + }, + want: 1, + }, + { + desc: "scrolling down ignored when all content fits", + lines: 2, + height: 4, + events: func(st *scrollTracker) { + st.downOneLine() + st.downOneLine() + }, + want: 0, + }, + { + desc: "scrolls down by a page", + lines: 6, + height: 2, + events: func(st *scrollTracker) { + st.downOnePage() + st.downOnePage() + }, + want: 4, + }, + { + desc: "scrolling down by a page capped at the last line", + lines: 6, + height: 2, + events: func(st *scrollTracker) { + st.downOnePage() + st.downOnePage() + st.downOnePage() + st.downOnePage() + }, + want: 4, + }, + { + desc: "scrolling up by a page capped at the first line", + lines: 6, + height: 2, + events: func(st *scrollTracker) { + st.downOnePage() + st.upOnePage() + st.upOnePage() + st.upOnePage() + st.upOnePage() + }, + want: 0, + }, + { + desc: "scrolling by lines and pages can be combined", + lines: 8, + height: 2, + events: func(st *scrollTracker) { + st.downOnePage() // first == 2 + st.upOneLine() // first = 1 + st.downOneLine() // first = 2 + st.downOneLine() // first = 3 + st.downOneLine() // first = 4 + st.downOneLine() // first = 5 + st.upOnePage() // first == 3 + }, + want: 3, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + st := newScrollTracker(&options{}) + if tc.events != nil { + tc.events(st) + } + got := st.firstLine(tc.lines, tc.height) + if got != tc.want { + t.Errorf("firstLine => got %d, want %d", got, tc.want) + } + }) + } +} + +func TestScrollTrackerContentRolling(t *testing.T) { + st := newScrollTracker(&options{rollContent: true}) + // All of these test cases act on the same instance of the scroll tracker. + tests := []struct { + desc string + lines int + height int + events func() + want int + }{ + { + desc: "all content fits, draws from the first line", + lines: 2, + height: 2, + want: 0, + }, + { + desc: "content doesn't fit, draws up to the last line", + lines: 4, + height: 2, + want: 2, + }, + { + desc: "draws up to the last line when height decreases", + lines: 4, + height: 1, + want: 3, + }, + { + desc: "draws up to the last line when height increases", + lines: 4, + height: 2, + want: 2, + }, + { + desc: "user scrolling breaks away from the last line", + lines: 4, + height: 2, + events: func() { + st.upOneLine() + }, + want: 1, + }, + { + desc: "keeps scrolled to position when new content arrives", + lines: 5, + height: 2, + want: 1, + }, + { + desc: "scrolling down to the last line displays the latest line", + lines: 5, + height: 2, + events: func() { + st.downOneLine() + st.downOneLine() + st.downOneLine() + }, + want: 3, + }, + { + desc: "rolling of new content resumes", + lines: 6, + height: 2, + want: 4, + }, + { + desc: "scroll up breaks away from the last line again", + lines: 6, + height: 2, + events: func() { + st.upOneLine() + }, + want: 3, + }, + { + desc: "keeps scrolled to position when new content arrives again", + lines: 7, + height: 2, + want: 3, + }, + { + desc: "resize so that the last line becomes visible", + lines: 7, + height: 7, + want: 0, + }, + { + desc: "rolls content after the resize", + lines: 8, + height: 7, + want: 1, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + if tc.events != nil { + tc.events() + } + got := st.firstLine(tc.lines, tc.height) + if got != tc.want { + t.Errorf("firstLine => got %d, want %d", got, tc.want) + } + }) + } +} + +func TestNormalizeScroll(t *testing.T) { + tests := []struct { + desc string + first int + lines int + height int + want int + }{ + { + desc: "first line is negative", + first: -1, + lines: 3, + height: 1, + want: 0, + }, + { + desc: "no lines to be printed", + first: 0, + lines: 0, + height: 1, + want: 0, + }, + { + desc: "zero height", + first: 0, + lines: 1, + height: 0, + want: 0, + }, + { + desc: "first line is greater than the number of lines", + first: 4, + lines: 3, + height: 2, + want: 1, + }, + { + desc: "first line reset to start if the full content fits", + first: 1, + lines: 3, + height: 3, + want: 0, + }, + { + desc: "valid first line", + first: 2, + lines: 4, + height: 2, + want: 2, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got := normalizeScroll(tc.first, tc.lines, tc.height) + if got != tc.want { + t.Errorf("normalizeScroll => got %d, want %d", got, tc.want) + } + }) + } +}