diff --git a/CHANGELOG.md b/CHANGELOG.md index 879572e..55143db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 "roll" through the linechart. - The LineChart widget now has a method that returns the observed capacity of the LineChart the last time Draw was called. +- The LineChart widget now supports zoom of the content triggered by mouse + events. - The Text widget now has a Write option that atomically replaces the entire text content. diff --git a/README.md b/README.md index 1d6d42d..d001f5d 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,8 @@ go run github.com/mum4k/termdash/widgets/barchart/barchartdemo/barchartdemo.go ### The LineChart -Displays series of values on a line chart. Run the +Displays series of values on a line chart, supports zoom triggered by mouse +events. Run the [linechartdemo](widgets/linechart/linechartdemo/linechartdemo.go). ```go diff --git a/area/area.go b/area/area.go index 4c437cc..616834a 100644 --- a/area/area.go +++ b/area/area.go @@ -96,42 +96,11 @@ func ExcludeBorder(area image.Rectangle) image.Rectangle { ) } -// findGCF finds the greatest common factor of two integers. -func findGCF(a, b int) int { - if a == 0 || b == 0 { - return 0 - } - - // https://en.wikipedia.org/wiki/Euclidean_algorithm - for { - rem := a % b - a = b - b = rem - - if b == 0 { - break - } - } - return a -} - -// simplifyRatio simplifies the given ratio. -func simplifyRatio(ratio image.Point) image.Point { - gcf := findGCF(ratio.X, ratio.Y) - if gcf == 0 { - return image.ZP - } - return image.Point{ - X: ratio.X / gcf, - Y: ratio.Y / gcf, - } -} - // WithRatio returns the largest area that has the requested ratio but is // either equal or smaller than the provided area. Returns zero area if the // area or the ratio are zero, or if there is no such area. func WithRatio(area image.Rectangle, ratio image.Point) image.Rectangle { - ratio = simplifyRatio(ratio) + ratio = numbers.SimplifyRatio(ratio) if area == image.ZR || ratio == image.ZP { return image.ZR } diff --git a/area/area_test.go b/area/area_test.go index c998ed0..27c0475 100644 --- a/area/area_test.go +++ b/area/area_test.go @@ -15,7 +15,6 @@ package area import ( - "fmt" "image" "testing" @@ -321,30 +320,6 @@ func TestExcludeBorder(t *testing.T) { } } -func TestFindGCF(t *testing.T) { - tests := []struct { - a int - b int - want int - }{ - {0, 0, 0}, - {0, 1, 0}, - {1, 0, 0}, - {1, 1, 1}, - {2, 2, 2}, - {50, 35, 5}, - {16, 88, 8}, - } - - for _, tc := range tests { - t.Run(fmt.Sprintf("findGCF(%d,%d)", tc.a, tc.b), func(t *testing.T) { - if got := findGCF(tc.a, tc.b); got != tc.want { - t.Errorf("findGCF(%d,%d) => got %v, want %v", tc.a, tc.b, got, tc.want) - } - }) - } -} - func TestWithRatio(t *testing.T) { tests := []struct { desc string diff --git a/canvas/braille/braille.go b/canvas/braille/braille.go index 196f7e5..ac35648 100644 --- a/canvas/braille/braille.go +++ b/canvas/braille/braille.go @@ -110,6 +110,11 @@ func (c *Canvas) Size() image.Point { return image.Point{s.X * ColMult, s.Y * RowMult} } +// CellArea returns the area of the underlying cell canvas in cells. +func (c *Canvas) CellArea() image.Rectangle { + return c.regular.Area() +} + // Area returns the area of the braille canvas in pixels. // This will be zero-based area that is two times wider and four times taller // than the area used to create the braille canvas. @@ -186,17 +191,57 @@ func (c *Canvas) TogglePixel(p image.Point, opts ...cell.Option) error { if err != nil { return err } - cell, err := c.regular.Cell(cp) + curCell, err := c.regular.Cell(cp) if err != nil { return err } - if isBraille(cell.Rune) && pixelSet(cell.Rune, p) { + if isBraille(curCell.Rune) && pixelSet(curCell.Rune, p) { return c.ClearPixel(p, opts...) } return c.SetPixel(p, opts...) } +// SetCellOpts sets options on the specified cell of the braille canvas without +// modifying the content of the cell. +// Sets the default cell options if no options are provided. +// This method is idempotent. +func (c *Canvas) SetCellOpts(cellPoint image.Point, opts ...cell.Option) error { + curCell, err := c.regular.Cell(cellPoint) + if err != nil { + return err + } + + if len(opts) == 0 { + // Set the default options. + opts = []cell.Option{ + cell.FgColor(cell.ColorDefault), + cell.BgColor(cell.ColorDefault), + } + } + if _, err := c.regular.SetCell(cellPoint, curCell.Rune, opts...); err != nil { + return err + } + return nil +} + +// SetAreaCellOpts is like SetCellOpts, but sets the specified options on all +// the cells within the provided area. +func (c *Canvas) SetAreaCellOpts(cellArea image.Rectangle, opts ...cell.Option) error { + haveArea := c.regular.Area() + if !cellArea.In(haveArea) { + return fmt.Errorf("unable to set cell options in area %v, it must fit inside the available cell area is %v", cellArea, haveArea) + } + for col := cellArea.Min.X; col < cellArea.Max.X; col++ { + for row := cellArea.Min.Y; row < cellArea.Max.Y; row++ { + if err := c.SetCellOpts(image.Point{col, row}, opts...); err != nil { + return err + } + } + } + return nil +} + // Apply applies the canvas to the corresponding area of the terminal. // Guarantees to stay within limits of the area the canvas was created with. func (c *Canvas) Apply(t terminalapi.Terminal) error { diff --git a/canvas/braille/braille_test.go b/canvas/braille/braille_test.go index 38dfd09..b32fe97 100644 --- a/canvas/braille/braille_test.go +++ b/canvas/braille/braille_test.go @@ -74,11 +74,12 @@ func Example_appliedToTerminal() { func TestNew(t *testing.T) { tests := []struct { - desc string - ar image.Rectangle - wantSize image.Point - wantArea image.Rectangle - wantErr bool + desc string + ar image.Rectangle + wantSize image.Point + wantArea image.Rectangle + wantCellArea image.Rectangle + wantErr bool }{ { desc: "fails on a negative area", @@ -86,34 +87,39 @@ func TestNew(t *testing.T) { wantErr: true, }, { - desc: "braille from zero-based single-cell area", - ar: image.Rect(0, 0, 1, 1), - wantSize: image.Point{2, 4}, - wantArea: image.Rect(0, 0, 2, 4), + desc: "braille from zero-based single-cell area", + ar: image.Rect(0, 0, 1, 1), + wantSize: image.Point{2, 4}, + wantArea: image.Rect(0, 0, 2, 4), + wantCellArea: image.Rect(0, 0, 1, 1), }, { - desc: "braille from non-zero-based single-cell area", - ar: image.Rect(3, 3, 4, 4), - wantSize: image.Point{2, 4}, - wantArea: image.Rect(0, 0, 2, 4), + desc: "braille from non-zero-based single-cell area", + ar: image.Rect(3, 3, 4, 4), + wantSize: image.Point{2, 4}, + wantArea: image.Rect(0, 0, 2, 4), + wantCellArea: image.Rect(0, 0, 1, 1), }, { - desc: "braille from zero-based multi-cell area", - ar: image.Rect(0, 0, 3, 3), - wantSize: image.Point{6, 12}, - wantArea: image.Rect(0, 0, 6, 12), + desc: "braille from zero-based multi-cell area", + ar: image.Rect(0, 0, 3, 3), + wantSize: image.Point{6, 12}, + wantArea: image.Rect(0, 0, 6, 12), + wantCellArea: image.Rect(0, 0, 3, 3), }, { - desc: "braille from non-zero-based multi-cell area", - ar: image.Rect(6, 6, 9, 9), - wantSize: image.Point{6, 12}, - wantArea: image.Rect(0, 0, 6, 12), + desc: "braille from non-zero-based multi-cell area", + ar: image.Rect(6, 6, 9, 9), + wantSize: image.Point{6, 12}, + wantArea: image.Rect(0, 0, 6, 12), + wantCellArea: image.Rect(0, 0, 3, 3), }, { - desc: "braille from non-zero-based multi-cell rectangular area", - ar: image.Rect(6, 6, 9, 10), - wantSize: image.Point{6, 16}, - wantArea: image.Rect(0, 0, 6, 16), + desc: "braille from non-zero-based multi-cell rectangular area", + ar: image.Rect(6, 6, 9, 10), + wantSize: image.Point{6, 16}, + wantArea: image.Rect(0, 0, 6, 16), + wantCellArea: image.Rect(0, 0, 3, 4), }, } @@ -136,6 +142,11 @@ func TestNew(t *testing.T) { if diff := pretty.Compare(tc.wantArea, gotArea); diff != "" { t.Errorf("Area => unexpected diff (-want, +got):\n%s", diff) } + + gotCellArea := got.CellArea() + if diff := pretty.Compare(tc.wantCellArea, gotCellArea); diff != "" { + t.Errorf("CellArea => unexpected diff (-want, +got):\n%s", diff) + } }) } } @@ -170,6 +181,162 @@ func TestBraille(t *testing.T) { return faketerm.MustNew(size) }, }, + { + desc: "SetCellOptions fails on a cell outside of the braille canvas", + ar: image.Rect(0, 0, 1, 1), + pixelOps: func(c *Canvas) error { + return c.SetCellOpts(image.Point{0, -1}) + }, + wantErr: true, + want: func(size image.Point) *faketerm.Terminal { + return faketerm.MustNew(size) + }, + }, + { + desc: "SetCellOptions sets options on cell with no options", + ar: image.Rect(0, 0, 1, 1), + pixelOps: func(c *Canvas) error { + return c.SetCellOpts(image.Point{0, 0}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + c := testcanvas.MustCell(cvs, image.Point{0, 0}) + testcanvas.MustSetCell(cvs, image.Point{0, 0}, c.Rune, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)) + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "SetCellOptions preserves the cell rune", + ar: image.Rect(0, 0, 1, 1), + pixelOps: func(c *Canvas) error { + if err := c.SetPixel(image.Point{0, 0}); err != nil { + return err + } + return c.SetCellOpts(image.Point{0, 0}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetCell(cvs, image.Point{0, 0}, '⠁', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)) + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "SetCellOptions overwrites options set previously", + ar: image.Rect(0, 0, 1, 1), + pixelOps: func(c *Canvas) error { + if err := c.SetPixel(image.Point{0, 0}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil { + return err + } + return c.SetCellOpts(image.Point{0, 0}, cell.FgColor(cell.ColorGreen), cell.BgColor(cell.ColorYellow)) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetCell(cvs, image.Point{0, 0}, '⠁', cell.FgColor(cell.ColorGreen), cell.BgColor(cell.ColorYellow)) + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "SetCellOptions sets default options when no options provided", + ar: image.Rect(0, 0, 1, 1), + pixelOps: func(c *Canvas) error { + if err := c.SetPixel(image.Point{0, 0}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil { + return err + } + return c.SetCellOpts(image.Point{0, 0}) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + testcanvas.MustSetCell(cvs, image.Point{0, 0}, '⠁') + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "SetCellOptions is idempotent", + ar: image.Rect(0, 0, 1, 1), + pixelOps: func(c *Canvas) error { + if err := c.SetCellOpts(image.Point{0, 0}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil { + return err + } + return c.SetCellOpts(image.Point{0, 0}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + c := testcanvas.MustCell(cvs, image.Point{0, 0}) + testcanvas.MustSetCell(cvs, image.Point{0, 0}, c.Rune, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)) + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "SetAreaCellOptions fails on area too large", + ar: image.Rect(0, 0, 1, 1), + pixelOps: func(c *Canvas) error { + return c.SetAreaCellOpts(image.Rect(0, 0, 2, 2), cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)) + }, + wantErr: true, + }, + { + desc: "SetCellOptions sets the cell options in full area", + ar: image.Rect(0, 0, 1, 1), + pixelOps: func(c *Canvas) error { + return c.SetAreaCellOpts(image.Rect(0, 0, 1, 1), cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + for _, p := range []image.Point{ + {0, 0}, + } { + c := testcanvas.MustCell(cvs, p) + testcanvas.MustSetCell(cvs, p, c.Rune, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)) + } + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "SetCellOptions sets the cell options in a sub-area", + ar: image.Rect(0, 0, 3, 3), + pixelOps: func(c *Canvas) error { + return c.SetAreaCellOpts(image.Rect(0, 0, 2, 2), cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + for _, p := range []image.Point{ + {0, 0}, + {0, 1}, + {1, 0}, + {1, 1}, + } { + c := testcanvas.MustCell(cvs, p) + testcanvas.MustSetCell(cvs, p, c.Rune, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)) + } + testcanvas.MustApply(cvs, ft) + return ft + }, + }, { desc: "set pixel 0,0", ar: image.Rect(0, 0, 1, 1), diff --git a/canvas/braille/testbraille/testbraille.go b/canvas/braille/testbraille/testbraille.go index fe86b5d..7f3d82a 100644 --- a/canvas/braille/testbraille/testbraille.go +++ b/canvas/braille/testbraille/testbraille.go @@ -61,3 +61,17 @@ func MustCopyTo(bc *braille.Canvas, dst *canvas.Canvas) { panic(fmt.Sprintf("bc.CopyTo => unexpected error: %v", err)) } } + +// MustSetCellOpts sets the cell options or panics. +func MustSetCellOpts(bc *braille.Canvas, cellPoint image.Point, opts ...cell.Option) { + if err := bc.SetCellOpts(cellPoint, opts...); err != nil { + panic(fmt.Sprintf("bc.SetCellOpts => unexpected error: %v", err)) + } +} + +// MustSetAreaCellOpts sets the cell options in the area or panics. +func MustSetAreaCellOpts(bc *braille.Canvas, cellArea image.Rectangle, opts ...cell.Option) { + if err := bc.SetAreaCellOpts(cellArea, opts...); err != nil { + panic(fmt.Sprintf("bc.SetAreaCellOpts => unexpected error: %v", err)) + } +} diff --git a/canvas/testcanvas/testcanvas.go b/canvas/testcanvas/testcanvas.go index 375e066..166fddb 100644 --- a/canvas/testcanvas/testcanvas.go +++ b/canvas/testcanvas/testcanvas.go @@ -51,6 +51,15 @@ func MustSetCell(c *canvas.Canvas, p image.Point, r rune, opts ...cell.Option) i return cells } +// MustCell returns the cell or panics. +func MustCell(c *canvas.Canvas, p image.Point) *cell.Cell { + cell, err := c.Cell(p) + if err != nil { + panic(fmt.Sprintf("canvas.Cell => unexpected error: %v", err)) + } + return cell +} + // MustCopyTo copies the content of the source canvas onto the destination // canvas or panics. func MustCopyTo(src, dst *canvas.Canvas) { diff --git a/images/linechartdemo.gif b/images/linechartdemo.gif index c505762..3cb385a 100644 Binary files a/images/linechartdemo.gif and b/images/linechartdemo.gif differ diff --git a/numbers/numbers.go b/numbers/numbers.go index 2c6199f..f88a6b3 100644 --- a/numbers/numbers.go +++ b/numbers/numbers.go @@ -16,6 +16,7 @@ package numbers import ( + "image" "math" ) @@ -170,3 +171,51 @@ func Abs(x int) int { } return x } + +// findGCF finds the greatest common factor of two integers. +func findGCF(a, b int) int { + if a == 0 || b == 0 { + return 0 + } + a = Abs(a) + b = Abs(b) + + // https://en.wikipedia.org/wiki/Euclidean_algorithm + for { + rem := a % b + a = b + b = rem + + if b == 0 { + break + } + } + return a +} + +// SimplifyRatio simplifies the given ratio. +func SimplifyRatio(ratio image.Point) image.Point { + gcf := findGCF(ratio.X, ratio.Y) + if gcf == 0 { + return image.ZP + } + return image.Point{ + X: ratio.X / gcf, + Y: ratio.Y / gcf, + } +} + +// SplitByRatio splits the provided number by the specified ratio. +func SplitByRatio(n int, ratio image.Point) image.Point { + sr := SimplifyRatio(ratio) + if sr.Eq(image.ZP) { + return image.ZP + } + fn := float64(n) + sum := float64(sr.X + sr.Y) + fact := fn / sum + return image.Point{ + int(Round(fact * float64(sr.X))), + int(Round(fact * float64(sr.Y))), + } +} diff --git a/numbers/numbers_test.go b/numbers/numbers_test.go index 7815e5f..b47cbbf 100644 --- a/numbers/numbers_test.go +++ b/numbers/numbers_test.go @@ -16,8 +16,11 @@ package numbers import ( "fmt" + "image" "math" "testing" + + "github.com/kylelemons/godebug/pretty" ) func TestRoundToNonZeroPlaces(t *testing.T) { @@ -349,3 +352,138 @@ func TestAbs(t *testing.T) { }) } } + +func TestFindGCF(t *testing.T) { + tests := []struct { + a int + b int + want int + }{ + {0, 0, 0}, + {0, 1, 0}, + {1, 0, 0}, + {1, 1, 1}, + {2, 2, 2}, + {50, 35, 5}, + {16, 88, 8}, + {-16, 88, 8}, + {16, -88, 8}, + {-16, -88, 8}, + } + + for _, tc := range tests { + t.Run(fmt.Sprintf("findGCF(%d,%d)", tc.a, tc.b), func(t *testing.T) { + if got := findGCF(tc.a, tc.b); got != tc.want { + t.Errorf("findGCF(%d,%d) => got %v, want %v", tc.a, tc.b, got, tc.want) + } + }) + } +} + +func TestSimplifyRatio(t *testing.T) { + tests := []struct { + desc string + ratio image.Point + want image.Point + }{ + { + desc: "zero ratio", + ratio: image.Point{0, 0}, + want: image.Point{0, 0}, + }, + { + desc: "already simplified", + ratio: image.Point{1, 3}, + want: image.Point{1, 3}, + }, + { + desc: "already simplified and X is negative", + ratio: image.Point{-1, 3}, + want: image.Point{-1, 3}, + }, + { + desc: "already simplified and Y is negative", + ratio: image.Point{1, -3}, + want: image.Point{1, -3}, + }, + { + desc: "already simplified and both are negative", + ratio: image.Point{-1, -3}, + want: image.Point{-1, -3}, + }, + { + desc: "simplifies positive ratio", + ratio: image.Point{27, 42}, + want: image.Point{9, 14}, + }, + { + desc: "simplifies negative ratio", + ratio: image.Point{-30, 50}, + want: image.Point{-3, 5}, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got := SimplifyRatio(tc.ratio) + if diff := pretty.Compare(tc.want, got); diff != "" { + t.Errorf("SimplifyRatio => unexpected diff (-want, +got):\n%s", diff) + } + }) + } +} + +func TestSplitByRatio(t *testing.T) { + tests := []struct { + desc string + number int + ratio image.Point + want image.Point + }{ + { + desc: "zero numerator", + number: 10, + ratio: image.Point{0, 2}, + want: image.ZP, + }, + { + desc: "zero denominator", + number: 10, + ratio: image.Point{2, 0}, + want: image.ZP, + }, + { + desc: "zero number", + number: 0, + ratio: image.Point{1, 2}, + want: image.ZP, + }, + { + desc: "equal ratio", + number: 2, + ratio: image.Point{2, 2}, + want: image.Point{1, 1}, + }, + { + desc: "unequal ratio", + number: 15, + ratio: image.Point{1, 2}, + want: image.Point{5, 10}, + }, + { + desc: "large ratio", + number: 19, + ratio: image.Point{78, 121}, + want: image.Point{7, 12}, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got := SplitByRatio(tc.number, tc.ratio) + if diff := pretty.Compare(tc.want, got); diff != "" { + t.Errorf("SplitByRatio => unexpected diff (-want, +got):\n%s", diff) + } + }) + } +} diff --git a/widgets/linechart/axes/axes.go b/widgets/linechart/axes/axes.go index 1184615..205c0af 100644 --- a/widgets/linechart/axes/axes.go +++ b/widgets/linechart/axes/axes.go @@ -147,6 +147,9 @@ type XDetails struct { // Labels are the labels for values on the X axis in an increasing order. Labels []*Label + + // Properties are the properties that were used on the call to NewXDetails. + Properties *XProperties } // XProperties are the properties of the X axis. @@ -199,10 +202,11 @@ func NewXDetails(cvsAr image.Rectangle, xp *XProperties) (*XDetails, error) { } return &XDetails{ - Start: image.Point{xp.ReqYWidth, cvsAr.Dy() - reqHeight}, // Space for the labels. - End: image.Point{xp.ReqYWidth + graphWidth, cvsAr.Dy() - reqHeight}, - Scale: scale, - Labels: labels, + Start: image.Point{xp.ReqYWidth, cvsAr.Dy() - reqHeight}, // Space for the labels. + End: image.Point{xp.ReqYWidth + graphWidth, cvsAr.Dy() - reqHeight}, + Scale: scale, + Labels: labels, + Properties: xp, }, nil } diff --git a/widgets/linechart/axes/axes_test.go b/widgets/linechart/axes/axes_test.go index 501a1cc..625b53c 100644 --- a/widgets/linechart/axes/axes_test.go +++ b/widgets/linechart/axes/axes_test.go @@ -270,6 +270,11 @@ func TestNewXDetails(t *testing.T) { Pos: image.Point{1, 2}, }, }, + Properties: &XProperties{ + Min: 0, + Max: 0, + ReqYWidth: 0, + }, }, }, { @@ -291,6 +296,12 @@ func TestNewXDetails(t *testing.T) { Pos: image.Point{1, 2}, }, }, + Properties: &XProperties{ + Min: 0, + Max: 0, + ReqYWidth: 0, + LO: LabelOrientationVertical, + }, }, }, { @@ -311,6 +322,11 @@ func TestNewXDetails(t *testing.T) { Pos: image.Point{3, 4}, }, }, + Properties: &XProperties{ + Min: 0, + Max: 0, + ReqYWidth: 2, + }, }, }, { @@ -336,6 +352,12 @@ func TestNewXDetails(t *testing.T) { Pos: image.Point{7, 6}, }, }, + Properties: &XProperties{ + Min: 0, + Max: 1000, + ReqYWidth: 2, + LO: LabelOrientationVertical, + }, }, }, { @@ -361,6 +383,12 @@ func TestNewXDetails(t *testing.T) { Pos: image.Point{7, 7}, }, }, + Properties: &XProperties{ + Min: 0, + Max: 999, + ReqYWidth: 2, + LO: LabelOrientationVertical, + }, }, }, { @@ -390,6 +418,16 @@ func TestNewXDetails(t *testing.T) { Pos: image.Point{19, 5}, }, }, + Properties: &XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 5, + CustomLabels: map[int]string{ + 0: "start", + 1: "end", + }, + LO: LabelOrientationVertical, + }, }, }, } diff --git a/widgets/linechart/linechart.go b/widgets/linechart/linechart.go index 8edf635..de0426a 100644 --- a/widgets/linechart/linechart.go +++ b/widgets/linechart/linechart.go @@ -31,6 +31,7 @@ import ( "github.com/mum4k/termdash/terminalapi" "github.com/mum4k/termdash/widgetapi" "github.com/mum4k/termdash/widgets/linechart/axes" + "github.com/mum4k/termdash/widgets/linechart/zoom" ) // seriesValues represent values stored in the series. @@ -70,6 +71,10 @@ func newSeriesValues(values []float64) *seriesValues { // The Y axis will be sized so that it can conveniently accommodate the largest // value among all the labeled line charts. This determines the used scale. // +// LineChart supports mouse based zoom, zooming is achieved by either +// highlighting an area on the graph (left mouse clicking and dragging) or by +// using the mouse scroll button. +// // Implements widgetapi.Widget. This object is thread-safe. type LineChart struct { // mu protects the LineChart widget. @@ -91,6 +96,9 @@ type LineChart struct { // xLabels that were provided on a call to Series. xLabels map[int]string + + // zoom tracks the zooming of the X axis. + zoom *zoom.Tracker } // New returns a new line chart widget. @@ -339,23 +347,18 @@ func (lc *LineChart) drawAxes(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YD return nil } -// brailleCvs returns a braille canvas sized so that it fits between the axes -// and the canvas borders. -func (lc *LineChart) brailleCvs(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDetails) (*braille.Canvas, error) { - // The area available to the graph. - graphAr := image.Rect(yd.Start.X+1, yd.Start.Y, cvs.Area().Max.X, xd.End.Y) - bc, err := braille.New(graphAr) - if err != nil { - return nil, fmt.Errorf("braille.New => %v", err) - } - return bc, nil +// graphAr returns the area available for the graph itself sized so that it +// fits between the axes and the canvas borders. +func (lc *LineChart) graphAr(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDetails) image.Rectangle { + return image.Rect(yd.Start.X+1, yd.Start.Y, cvs.Area().Max.X, xd.End.Y) } // drawSeries draws the graph representing the stored series. // Returns XDetails that might be adjusted to not start at zero value if some // of the series didn't fit the graphs and XAxisUnscaled was provided. func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDetails) (*axes.XDetails, error) { - bc, err := lc.brailleCvs(cvs, xd, yd) + graphAr := lc.graphAr(cvs, xd, yd) + bc, err := braille.New(graphAr) if err != nil { return nil, err } @@ -365,6 +368,19 @@ func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes. return nil, err } + if lc.zoom == nil { + z, err := zoom.New(xdForCap, cvs.Area(), graphAr, zoom.ScrollStep(lc.opts.zoomStepPercent)) + if err != nil { + return nil, err + } + lc.zoom = z + } else { + if err := lc.zoom.Update(xdForCap, cvs.Area(), graphAr); err != nil { + return nil, err + } + } + + xdZoomed := lc.zoom.Zoom() var names []string for name := range lc.series { names = append(names, name) @@ -383,7 +399,7 @@ func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes. var prev float64 for i := 1; i < len(sv.values); i++ { prev = sv.values[i-1] - if i < int(xdForCap.Scale.Min.Value)+1 || i > int(xdForCap.Scale.Max.Value) { + if i < int(xdZoomed.Scale.Min.Value)+1 || i > int(xdZoomed.Scale.Max.Value) { // Don't draw lines for values that aren't supposed to be visible. // These are either values outside of the current zoom or // values at the beginning of a series that falls before athe @@ -392,13 +408,13 @@ func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes. continue } - startX, err := xdForCap.Scale.ValueToPixel(i - 1) + startX, err := xdZoomed.Scale.ValueToPixel(i - 1) if err != nil { - return nil, fmt.Errorf("failure for series %v[%d], xdForCap.Scale.ValueToPixel => %v", name, i-1, err) + return nil, fmt.Errorf("failure for series %v[%d], xdZoomed.Scale.ValueToPixel => %v", name, i-1, err) } - endX, err := xdForCap.Scale.ValueToPixel(i) + endX, err := xdZoomed.Scale.ValueToPixel(i) if err != nil { - return nil, fmt.Errorf("failure for series %v[%d], xdForCap.Scale.ValueToPixel => %v", name, i, err) + return nil, fmt.Errorf("failure for series %v[%d], xdZoomed.Scale.ValueToPixel => %v", name, i, err) } startY, err := yd.Scale.ValueToPixel(prev) @@ -420,10 +436,24 @@ func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes. } } } + + if highlight, hRange := lc.zoom.Highlight(); highlight { + if err := lc.highlightRange(bc, hRange); err != nil { + return nil, err + } + } + if err := bc.CopyTo(cvs); err != nil { return nil, fmt.Errorf("bc.Apply => %v", err) } - return xdForCap, nil + return xdZoomed, nil +} + +// highlightRange highlights the range of X columns on the braille canvas. +func (lc *LineChart) highlightRange(bc *braille.Canvas, hRange *zoom.Range) error { + cellAr := bc.CellArea() + ar := image.Rect(hRange.Start, cellAr.Min.Y, hRange.End, cellAr.Max.Y) + return bc.SetAreaCellOpts(ar, cell.BgColor(lc.opts.zoomHightlightColor)) } // Keyboard implements widgetapi.Widget.Keyboard. @@ -433,7 +463,10 @@ func (lc *LineChart) Keyboard(k *terminalapi.Keyboard) error { // Mouse implements widgetapi.Widget.Mouse. func (lc *LineChart) Mouse(m *terminalapi.Mouse) error { - return errors.New("the LineChart widget doesn't support mouse events") + if lc.zoom == nil { + return nil + } + return lc.zoom.Mouse(m) } // minSize determines the minimum required size to draw the line chart. @@ -457,6 +490,7 @@ func (lc *LineChart) Options() widgetapi.Options { return widgetapi.Options{ MinimumSize: lc.minSize(), + WantMouse: true, } } diff --git a/widgets/linechart/linechart_test.go b/widgets/linechart/linechart_test.go index 305fbc2..cfee4dd 100644 --- a/widgets/linechart/linechart_test.go +++ b/widgets/linechart/linechart_test.go @@ -26,7 +26,9 @@ import ( "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/draw" "github.com/mum4k/termdash/draw/testdraw" + "github.com/mum4k/termdash/mouse" "github.com/mum4k/termdash/terminal/faketerm" + "github.com/mum4k/termdash/terminalapi" "github.com/mum4k/termdash/widgetapi" ) @@ -42,6 +44,22 @@ func TestLineChartDraws(t *testing.T) { wantWriteErr bool wantDrawErr bool }{ + { + desc: "fails with scroll step too low", + canvas: image.Rect(0, 0, 3, 4), + opts: []Option{ + ZoomStepPercent(0), + }, + wantErr: true, + }, + { + desc: "fails with scroll step too high", + canvas: image.Rect(0, 0, 3, 4), + opts: []Option{ + ZoomStepPercent(101), + }, + wantErr: true, + }, { desc: "fails with custom scale where min is NaN", canvas: image.Rect(0, 0, 3, 4), @@ -1136,6 +1154,155 @@ func TestLineChartDraws(t *testing.T) { return ft }, }, + { + desc: "highlights area for zoom", + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + if err := lc.Series("first", []float64{0, 100}); err != nil { + return err + } + // Draw once so zoom tracker is initialized. + cvs := testcanvas.MustNew(image.Rect(0, 0, 20, 10)) + if err := lc.Draw(cvs); err != nil { + return err + } + return lc.Mouse(&terminalapi.Mouse{ + Position: image.Point{6, 5}, + Button: mouse.ButtonLeft, + }) + }, + wantCapacity: 28, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{5, 0}, End: image.Point{5, 8}}, + {Start: image.Point{5, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{4, 7}) + testdraw.MustText(c, "51.68", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{6, 9}) + testdraw.MustText(c, "1", image.Point{19, 9}) + + // Braille line. + graphAr := image.Rect(6, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{26, 0}) + + // Highlighted area for zoom. + testbraille.MustSetAreaCellOpts(bc, image.Rect(0, 0, 1, 8), cell.BgColor(cell.ColorNumber(235))) + + testbraille.MustCopyTo(bc, c) + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "highlights area for zoom to a custom color", + opts: []Option{ + ZoomHightlightColor(cell.ColorNumber(13)), + }, + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + if err := lc.Series("first", []float64{0, 100}); err != nil { + return err + } + // Draw once so zoom tracker is initialized. + cvs := testcanvas.MustNew(image.Rect(0, 0, 20, 10)) + if err := lc.Draw(cvs); err != nil { + return err + } + return lc.Mouse(&terminalapi.Mouse{ + Position: image.Point{6, 5}, + Button: mouse.ButtonLeft, + }) + }, + wantCapacity: 28, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{5, 0}, End: image.Point{5, 8}}, + {Start: image.Point{5, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{4, 7}) + testdraw.MustText(c, "51.68", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{6, 9}) + testdraw.MustText(c, "1", image.Point{19, 9}) + + // Braille line. + graphAr := image.Rect(6, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{26, 0}) + + // Highlighted area for zoom. + testbraille.MustSetAreaCellOpts(bc, image.Rect(0, 0, 1, 8), cell.BgColor(cell.ColorNumber(13))) + + testbraille.MustCopyTo(bc, c) + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "zooms in on scroll up", + opts: []Option{ + ZoomStepPercent(50), + }, + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + if err := lc.Series("first", []float64{0, 25, 75, 100}); err != nil { + return err + } + // Draw once so zoom tracker is initialized. + cvs := testcanvas.MustNew(image.Rect(0, 0, 20, 10)) + if err := lc.Draw(cvs); err != nil { + return err + } + return lc.Mouse(&terminalapi.Mouse{ + Position: image.Point{8, 5}, + Button: mouse.ButtonWheelUp, + }) + }, + wantCapacity: 28, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{5, 0}, End: image.Point{5, 8}}, + {Start: image.Point{5, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{4, 7}) + testdraw.MustText(c, "51.68", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{6, 9}) + testdraw.MustText(c, "1", image.Point{12, 9}) + testdraw.MustText(c, "2", image.Point{19, 9}) + + // Braille line. + graphAr := image.Rect(6, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{13, 23}) + testdraw.MustBrailleLine(bc, image.Point{13, 23}, image.Point{27, 8}) + + testbraille.MustCopyTo(bc, c) + testcanvas.MustApply(c, ft) + return ft + }, + }, } for _, tc := range tests { @@ -1198,6 +1365,26 @@ func TestLineChartDraws(t *testing.T) { } } +func TestKeyboard(t *testing.T) { + lc, err := New() + if err != nil { + t.Fatalf("New => unexpected error: %v", err) + } + if err := lc.Keyboard(&terminalapi.Keyboard{}); err == nil { + t.Errorf("Keyboard => got nil err, wanted one") + } +} + +func TestMouseDoesNothingWithoutZoomTracker(t *testing.T) { + lc, err := New() + if err != nil { + t.Fatalf("New => unexpected error: %v", err) + } + if err := lc.Mouse(&terminalapi.Mouse{}); err != nil { + t.Errorf("Mouse => unexpected error: %v", err) + } +} + func TestOptions(t *testing.T) { tests := []struct { desc string @@ -1210,6 +1397,7 @@ func TestOptions(t *testing.T) { desc: "reserves space for axis without series", want: widgetapi.Options{ MinimumSize: image.Point{3, 4}, + WantMouse: true, }, }, { @@ -1219,6 +1407,7 @@ func TestOptions(t *testing.T) { }, want: widgetapi.Options{ MinimumSize: image.Point{5, 4}, + WantMouse: true, }, }, { @@ -1228,6 +1417,7 @@ func TestOptions(t *testing.T) { }, want: widgetapi.Options{ MinimumSize: image.Point{6, 4}, + WantMouse: true, }, }, { @@ -1240,6 +1430,7 @@ func TestOptions(t *testing.T) { }, want: widgetapi.Options{ MinimumSize: image.Point{4, 5}, + WantMouse: true, }, }, { @@ -1252,6 +1443,7 @@ func TestOptions(t *testing.T) { }, want: widgetapi.Options{ MinimumSize: image.Point{5, 7}, + WantMouse: true, }, }, } diff --git a/widgets/linechart/options.go b/widgets/linechart/options.go index f631bbe..4f30fba 100644 --- a/widgets/linechart/options.go +++ b/widgets/linechart/options.go @@ -20,6 +20,7 @@ import ( "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/widgets/linechart/axes" + "github.com/mum4k/termdash/widgets/linechart/zoom" ) // options.go contains configurable options for LineChart. @@ -32,33 +33,39 @@ type Option interface { // options stores the provided options. type options struct { - axesCellOpts []cell.Option - xLabelCellOpts []cell.Option - xLabelOrientation axes.LabelOrientation - yLabelCellOpts []cell.Option - xAxisUnscaled bool - yAxisMode axes.YScaleMode - yAxisCustomScale *customScale + axesCellOpts []cell.Option + xLabelCellOpts []cell.Option + xLabelOrientation axes.LabelOrientation + yLabelCellOpts []cell.Option + xAxisUnscaled bool + yAxisMode axes.YScaleMode + yAxisCustomScale *customScale + zoomHightlightColor cell.Color + zoomStepPercent int } // validate validates the provided options. func (o *options) validate() error { - if o.yAxisCustomScale == nil { - return nil + if o.yAxisCustomScale != nil { + if math.IsNaN(o.yAxisCustomScale.min) || math.IsNaN(o.yAxisCustomScale.max) { + return fmt.Errorf("both the min(%v) and the max(%v) provided as custom Y scale must be valid numbers", o.yAxisCustomScale.min, o.yAxisCustomScale.max) + } + if o.yAxisCustomScale.min >= o.yAxisCustomScale.max { + return fmt.Errorf("the min(%v) must be less than the max(%v) provided as custom Y scale", o.yAxisCustomScale.min, o.yAxisCustomScale.max) + } } - - if math.IsNaN(o.yAxisCustomScale.min) || math.IsNaN(o.yAxisCustomScale.max) { - return fmt.Errorf("both the min(%v) and the max(%v) provided as custom Y scale must be valid numbers", o.yAxisCustomScale.min, o.yAxisCustomScale.max) - } - if o.yAxisCustomScale.min >= o.yAxisCustomScale.max { - return fmt.Errorf("the min(%v) must be less than the max(%v) provided as custom Y scale", o.yAxisCustomScale.min, o.yAxisCustomScale.max) + if got, min, max := o.zoomStepPercent, 1, 100; got < min || got > max { + return fmt.Errorf("invalid ZoomStepPercent %d, must be in range %d <= value <= %d", got, min, max) } return nil } // newOptions returns a new options instance. func newOptions(opts ...Option) *options { - opt := &options{} + opt := &options{ + zoomHightlightColor: cell.ColorNumber(235), + zoomStepPercent: zoom.DefaultScrollStep, + } for _, o := range opts { o.set(opt) } @@ -168,3 +175,22 @@ func XAxisUnscaled() Option { opts.xAxisUnscaled = true }) } + +// ZoomHightlightColor sets the background color of the area that is selected +// with mouse in order to zoom the linechart. +// Defaults to color number 235. +func ZoomHightlightColor(c cell.Color) Option { + return option(func(opts *options) { + opts.zoomHightlightColor = c + }) +} + +// ZoomStepPercent sets the zooming step on each mouse scroll event as the +// percentage of the size of the X axis. +// The value must be in range 0 < value <= 100. +// Defaults to zoom.DefaultScrollStep. +func ZoomStepPercent(perc int) Option { + return option(func(opts *options) { + opts.zoomStepPercent = perc + }) +} diff --git a/widgets/linechart/zoom/zoom.go b/widgets/linechart/zoom/zoom.go new file mode 100644 index 0000000..dc54771 --- /dev/null +++ b/widgets/linechart/zoom/zoom.go @@ -0,0 +1,487 @@ +// Copyright 2019 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package zoom contains code that tracks the current zoom level. +package zoom + +import ( + "fmt" + "image" + "reflect" + + "github.com/mum4k/termdash/mouse" + "github.com/mum4k/termdash/mouse/button" + "github.com/mum4k/termdash/numbers" + "github.com/mum4k/termdash/terminalapi" + "github.com/mum4k/termdash/widgets/linechart/axes" +) + +// Option is used to provide options. +type Option interface { + // set sets the provided option. + set(*options) +} + +// options stores the provided options. +type options struct { + scrollStepPerc int +} + +// newOptions creates new options instance and applies the provided options. +func newOptions(opts ...Option) *options { + o := &options{ + scrollStepPerc: DefaultScrollStep, + } + for _, opt := range opts { + opt.set(o) + } + return o +} + +// validate validates the provided options. +func (o *options) validate() error { + if min, max := 1, 100; o.scrollStepPerc < min || o.scrollStepPerc > max { + return fmt.Errorf("invalid ScrollStep %d, must be a value in the range %d <= value <= %d", o.scrollStepPerc, min, max) + } + return nil +} + +// option implements Option. +type option func(*options) + +// set implements Option.set. +func (o option) set(opts *options) { + o(opts) +} + +// DefaultScrollStep is the default value for the ScrollStep option. +const DefaultScrollStep = 10 + +// ScrollStep sets the amount of zoom in or out on a single mouse scroll event. +// This is set as a percentage of the current value size of the X axis. +// Must be a value in range 0 < value <= 100. +// Defaults to DefaultScrollStep. +func ScrollStep(perc int) Option { + return option(func(opts *options) { + opts.scrollStepPerc = perc + }) +} + +// Tracker tracks the state of mouse selection on the linechart and stores +// requests for zoom. +// This object is not thread-safe. +type Tracker struct { + // baseX is the base X axis without any zoom applied. + baseX *axes.XDetails + // zoomX is the zoomed X axis or nil if zoom isn't applied. + zoomX *axes.XDetails + + // cvsAr is the entire canvas available to the linechart widget. + cvsAr image.Rectangle + + // graphAr is a smaller part of the cvsAr that contains the linechart + // itself. I.e. an area between the axis and the borders of cvsAr. + graphAr image.Rectangle + + // fsm is the state machine tracking the state of mouse left button. + fsm *button.FSM + + // highlight is the currently highlighted area. + highlight *Range + + // opts are the provided options. + opts *options +} + +// New returns a new zoom tracker that tracks zoom requests within +// the provided graph area. The cvsAr argument indicates size of the entire +// canvas available to the widget. +func New(baseX *axes.XDetails, cvsAr, graphAr image.Rectangle, opts ...Option) (*Tracker, error) { + o := newOptions(opts...) + if err := o.validate(); err != nil { + return nil, err + } + + t := &Tracker{ + fsm: button.NewFSM(mouse.ButtonLeft, graphAr), + highlight: &Range{}, + opts: o, + } + if err := t.Update(baseX, cvsAr, graphAr); err != nil { + return nil, err + } + return t, nil +} + +// Update is used to inform the zoom tracker about the base X axis and the +// graph area. +// Should be called each time the widget redraws. +func (t *Tracker) Update(baseX *axes.XDetails, cvsAr, graphAr image.Rectangle) error { + if !graphAr.In(cvsAr) { + return fmt.Errorf("the graphAr %v doesn't fit inside the cvsAr %v", graphAr, cvsAr) + } + // If any of these parameters changed, we need to reset the FSM and ensure + // the current zoom is still within the range of the new X axis. + ac, sc := t.axisChanged(baseX), t.sizeChanged(cvsAr, graphAr) + + if sc { + t.highlight.reset() + } + if ac || sc { + if t.zoomX != nil { + // Input data changed and we have an existing zoom in place. + // We need to normalize it again, since it might be outside of the + // currently visible values (e.g. if the terminal size decreased). + zoomMin := int(t.zoomX.Scale.Min.Value) + zoomMax := int(t.zoomX.Scale.Max.Value) + min, max := normalize(baseX.Scale.Min, baseX.Scale.Max, zoomMin, zoomMax) + zoom, err := newZoomedFromBase(min, max, baseX, cvsAr) + if err != nil { + return err + } + t.zoomX = zoom + } + } + + t.baseX = baseX + t.cvsAr = cvsAr + t.graphAr = graphAr + return nil +} + +// sizeChanged asserts whether the physical layout of the terminal changed. +func (t *Tracker) sizeChanged(cvsAr, graphAr image.Rectangle) bool { + return !cvsAr.Eq(t.cvsAr) || !graphAr.Eq(t.graphAr) +} + +// axisChanged asserts whether the axis scale changed. +func (t *Tracker) axisChanged(baseX *axes.XDetails) bool { + return !reflect.DeepEqual(baseX, t.baseX) +} + +// baseForZoom returns the base axis before zooming. +// This is either the base provided to New or Update if no zoom was performed +// yet, or the previously zoomed axis. +func (t *Tracker) baseForZoom() *axes.XDetails { + if t.zoomX == nil { + return t.baseX + } + return t.zoomX +} + +// Mouse is used to forward mouse events to the zoom tracker. +func (t *Tracker) Mouse(m *terminalapi.Mouse) error { + zoom, err := zoomToScroll(m, t.cvsAr, t.graphAr, t.baseForZoom(), t.baseX, t.opts) + if err != nil { + return err + } + if zoom != nil { + t.zoomX = zoom + } + + clicked, bs := t.fsm.Event(m) + switch { + case bs == button.Down: + cellX := m.Position.X - t.graphAr.Min.X + t.highlight.addX(cellX) + + case clicked && bs == button.Up: + zoom, err := zoomToHighlight(t.baseForZoom(), t.highlight, t.cvsAr) + if err != nil { + return err + } + t.zoomX = zoom + t.highlight.reset() + + default: + t.highlight.reset() + } + return nil +} + +// Range represents a range of values. +// The range includes all values x such that Start <= x < End. +type Range struct { + // Start is the start of the range. + Start int + // End is the end of the range. + End int + + // last is the last coordinate that was added to the range. + last int +} + +// empty asserts if the range is empty. +func (r *Range) empty() bool { + return r.Start == r.End +} + +// reset resets the range back to zero. +func (r *Range) reset() { + r.Start, r.End, r.last = 0, 0, 0 +} + +// addX adds the provided X coordinate to the range. +func (r *Range) addX(x int) { + switch { + case r.empty(): + r.Start = x + r.End = x + 1 + + case x < r.Start: + if r.last == r.End-1 { + // Handles fast mouse move to the left across Start. + // If we don't adjust the end, we would extend both ends of the + // range. + r.End = r.Start + 1 + } + r.Start = x + + case x >= r.End: + if r.last == r.Start { + // Handles fast mouse move to the right across End. + // If we don't adjust the start, we would extend both ends of the + // range. + r.Start = r.End - 1 + } + r.End = x + 1 + + case x > r.last: + // Handles change of direction from left to right. + r.Start = x + + case x < r.last: + // Handles change of direction from right to left. + r.End = x + 1 + } + r.last = x +} + +// Highlight returns true if a range on the graph area should be highlighted +// because the user is holding down the left mouse button and dragging mouse +// across the graph area. The returned range indicates the range of X cell +// coordinates within the graph area provided to New or Update. These are the +// columns that should be highlighted. +// Returns false of no area should be highlighted, in which case the state of +// the Range return value is undefined. +func (t *Tracker) Highlight() (bool, *Range) { + if t.highlight.empty() { + return false, nil + } + return true, t.highlight +} + +// Zoom returns an adjusted X axis if zoom is applied, or the same axis as was +// provided to New or Update. +func (t *Tracker) Zoom() *axes.XDetails { + if t.zoomX == nil { + return t.baseX + } + return t.zoomX +} + +// normalize normalizes the zoom range. +// This handles cases where zoom out would happen above the base axis or +// when the base axis itself changes (user provided new values) or when the +// graph areas change (terminal size changed). +func normalize(baseMin, baseMax *axes.Value, min, max int) (int, int) { + bMin := int(baseMin.Value) + bMax := int(baseMax.Value) + var newMin, newMax int + // Don't zoom-out above the base axis. + if min < bMin { + newMin = bMin + } else { + newMin = min + } + + if max > bMax { + newMax = bMax + } else { + newMax = max + } + return newMin, newMax +} + +// newZoomedFromBase returns a new X axis zoomed to the provided min and max. +func newZoomedFromBase(min, max int, base *axes.XDetails, cvsAr image.Rectangle) (*axes.XDetails, error) { + zp := *base.Properties // Shallow copy. + zp.Min = min + zp.Max = max + + zoom, err := axes.NewXDetails(cvsAr, &zp) + if err != nil { + return nil, fmt.Errorf("failed to create zoomed X axis: %v", err) + } + return zoom, nil +} + +// findCellPair given two cells on the base X axis returns the values of the +// closest or the same cells such that the values are distinct. +// Useful while zooming, if the zoom targets a view that would only have one +// value, this function adjusts the view to the closest two cells with distinct +// values. +func findCellPair(base *axes.XDetails, minCell, maxCell int) (*axes.Value, *axes.Value, error) { + minL, err := base.Scale.CellLabel(minCell) + if err != nil { + return nil, nil, fmt.Errorf("unable to determine min label for cell %d: %v", minCell, err) + } + maxL, err := base.Scale.CellLabel(maxCell) + if err != nil { + return nil, nil, fmt.Errorf("unable to determine max label for cell %d: %v", maxCell, err) + } + + diff := maxL.Value - minL.Value + if diff > 1 { + return minL, maxL, nil + } + + // Try above the max. + for cellNum := maxCell; cellNum < base.Scale.GraphWidth; cellNum++ { + l, err := base.Scale.CellLabel(cellNum) + if err != nil { + return nil, nil, err + } + if l.Value > minL.Value { + return minL, l, nil + } + } + + // Try below the min. + for cellNum := minCell; cellNum >= 0; cellNum-- { + l, err := base.Scale.CellLabel(cellNum) + if err != nil { + return nil, nil, err + } + if l.Value < maxL.Value { + return l, maxL, nil + } + } + + // Give up and use the first and the last cells. + firstL, err := base.Scale.CellLabel(0) + if err != nil { + return nil, nil, fmt.Errorf("unable to determine label for the first cell: %v", err) + } + lastL, err := base.Scale.CellLabel(base.Scale.GraphWidth - 1) + if err != nil { + return nil, nil, fmt.Errorf("unable to determine label for the last cell: %v", err) + } + return firstL, lastL, nil +} + +// zoomToHighlight zooms the base X axis according to the highlighted range. +func zoomToHighlight(base *axes.XDetails, hr *Range, cvsAr image.Rectangle) (*axes.XDetails, error) { + minL, maxL, err := findCellPair(base, hr.Start, hr.End-1) + if err != nil { + return nil, err + } + + zoom, err := newZoomedFromBase(int(minL.Value), int(maxL.Value), base, cvsAr) + if err != nil { + return nil, err + } + return zoom, nil +} + +// zoomToScroll zooms the current X axis in or out depending on the direction of +// the scroll. Doesn't zoom out above the base X axis view. +// Doesn't zoom if the scroll button isn't recognized or the event falls +// outside of the graph area. +// Can return nil axis if the mouse event didn't result in zooming. +func zoomToScroll(m *terminalapi.Mouse, cvsAr, graphAr image.Rectangle, curr, base *axes.XDetails, opts *options) (*axes.XDetails, error) { + if !m.Position.In(graphAr) { + // Ignore scroll events outside of the graph area. + return nil, nil + } + + var direction int // Positive on zoom in, negative on zoom out. + switch m.Button { + case mouse.ButtonWheelUp: + direction = 1 + + case mouse.ButtonWheelDown: + direction = -1 + + default: + // Nothing to do for other buttons. + return nil, nil + } + + cellX := m.Position.X - graphAr.Min.X + tgtVal, err := curr.Scale.CellLabel(cellX) + if err != nil { + return nil, fmt.Errorf("unable to determine value at the point where scrolling occurred: %v", err) + } + + currMin := int(curr.Scale.Min.Value) + currMax := int(curr.Scale.Max.Value) + baseMin := int(base.Scale.Min.Value) + baseMax := int(base.Scale.Max.Value) + size := baseMax - baseMin + step := size * opts.scrollStepPerc / 100 + _, left := numbers.MinMaxInts([]int{ + 1, + int(tgtVal.Value) - currMin, + }) + _, right := numbers.MinMaxInts([]int{ + 1, + currMax - int(tgtVal.Value), + }) + + splitStep := numbers.SplitByRatio(step, image.Point{left, right}) + newMin := currMin + (direction * splitStep.X) + newMax := currMax - (direction * splitStep.Y) + + var limits *axes.XDetails + switch m.Button { + case mouse.ButtonWheelUp: + if newMin > currMax { + newMin = currMax + } + if newMax < currMin { + newMax = currMin + } + limits = curr + + case mouse.ButtonWheelDown: + if newMin < baseMin { + newMin = baseMin + } + if newMax > baseMax { + newMax = baseMax + } + limits = base + } + + minCell, err := limits.Scale.ValueToCell(newMin) + if err != nil { + return nil, err + } + maxCell, err := limits.Scale.ValueToCell(newMax) + if err != nil { + return nil, err + } + minL, maxL, err := findCellPair(limits, minCell, maxCell) + if err != nil { + return nil, err + } + + min, max := normalize(limits.Scale.Min, limits.Scale.Max, int(minL.Value), int(maxL.Value)) + zoom, err := newZoomedFromBase(min, max, curr, cvsAr) + if err != nil { + return nil, err + } + return zoom, nil +} diff --git a/widgets/linechart/zoom/zoom_test.go b/widgets/linechart/zoom/zoom_test.go new file mode 100644 index 0000000..483ba20 --- /dev/null +++ b/widgets/linechart/zoom/zoom_test.go @@ -0,0 +1,1696 @@ +// Copyright 2019 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package zoom + +import ( + "image" + "testing" + + "github.com/kylelemons/godebug/pretty" + "github.com/mum4k/termdash/mouse" + "github.com/mum4k/termdash/terminalapi" + "github.com/mum4k/termdash/widgets/linechart/axes" +) + +// mustNewXDetails creates the XDetails or panics. +func mustNewXDetails(cvsAr image.Rectangle, xp *axes.XProperties) *axes.XDetails { + xd, err := axes.NewXDetails(cvsAr, xp) + if err != nil { + panic(err) + } + return xd +} + +func TestTracker(t *testing.T) { + tests := []struct { + desc string + opts []Option + xp *axes.XProperties + cvsAr image.Rectangle + graphAr image.Rectangle + // mutate if not nil, can mutate the state of the tracker. + // I.e. send mouse events or update the X scale or canvas areas. + mutate func(*Tracker) error + wantHighlight bool + wantHighlightRange *Range + wantZoom *axes.XDetails + wantErr bool + wantMutateErr bool + }{ + { + desc: "New fails when graph area doesn't fall inside the canvas", + xp: &axes.XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 10, 10), + graphAr: image.Rect(20, 20, 30, 30), + wantErr: true, + }, + { + desc: "New fails on ScrollStep too low", + opts: []Option{ + ScrollStep(0), + }, + xp: &axes.XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 10, 10), + graphAr: image.Rect(2, 0, 10, 10), + wantErr: true, + }, + { + desc: "New fails on ScrollStep too high", + opts: []Option{ + ScrollStep(101), + }, + xp: &axes.XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 10, 10), + graphAr: image.Rect(2, 0, 10, 10), + wantErr: true, + }, + { + desc: "Update fails when graph area doesn't fall inside the canvas", + xp: &axes.XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 10, 10), + graphAr: image.Rect(1, 1, 9, 9), + mutate: func(tr *Tracker) error { + cvsAr := image.Rect(0, 0, 10, 10) + graphAr := image.Rect(20, 20, 30, 30) + return tr.Update(tr.baseX, cvsAr, graphAr) + }, + wantMutateErr: true, + }, + { + desc: "no highlight or zoom without mouse events", + xp: &axes.XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 10, 10), + graphAr: image.Rect(3, 0, 10, 10), + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 10, 10), + &axes.XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights single row", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 6, 6), + graphAr: image.Rect(2, 0, 6, 6), + mutate: func(tr *Tracker) error { + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonLeft, + }) + }, + wantHighlight: true, + wantHighlightRange: &Range{Start: 0, End: 1, last: 0}, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 6, 6), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights multiple rows to the right of start", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 6, 6), + graphAr: image.Rect(2, 0, 6, 6), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonLeft, + }) + }, + wantHighlight: true, + wantHighlightRange: &Range{Start: 0, End: 3, last: 2}, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 6, 6), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights multiple rows to the right of start then middle", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 6, 6), + graphAr: image.Rect(2, 0, 6, 6), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }) + }, + wantHighlight: true, + wantHighlightRange: &Range{Start: 0, End: 2, last: 1}, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 6, 6), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights multiple rows to the right of start then left of start", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 6, 6), + graphAr: image.Rect(2, 0, 6, 6), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonLeft, + }) + }, + wantHighlight: true, + wantHighlightRange: &Range{Start: 0, End: 2, last: 0}, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 6, 6), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights multiple rows to the left of start", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 6, 6), + graphAr: image.Rect(2, 0, 6, 6), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonLeft, + }) + }, + wantHighlight: true, + wantHighlightRange: &Range{Start: 0, End: 3, last: 0}, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 6, 6), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights multiple rows to the left of start then middle", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 6, 6), + graphAr: image.Rect(2, 0, 6, 6), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }) + }, + wantHighlight: true, + wantHighlightRange: &Range{Start: 1, End: 3, last: 1}, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 6, 6), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights multiple rows to the left of start then right", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 6, 6), + graphAr: image.Rect(2, 0, 6, 6), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{5, 0}, + Button: mouse.ButtonLeft, + }) + }, + wantHighlight: true, + wantHighlightRange: &Range{Start: 2, End: 4, last: 3}, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 6, 6), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights multiple rows in the middle", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 6, 6), + graphAr: image.Rect(2, 0, 6, 6), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{5, 0}, + Button: mouse.ButtonLeft, + }) + }, + wantHighlight: true, + wantHighlightRange: &Range{Start: 1, End: 4, last: 3}, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 6, 6), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "does not highlight for clicks outside of graph area", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 6, 6), + graphAr: image.Rect(2, 0, 6, 6), + mutate: func(tr *Tracker) error { + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{1, 0}, + Button: mouse.ButtonLeft, + }) + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 6, 6), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights and zooms into the X axis once", + xp: &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonRelease, + }) + + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 8, 8), + &axes.XProperties{ + Min: 1, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights and zooms into the X axis twice", + xp: &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + // Zoom into values 1-3. + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{5, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{5, 0}, + Button: mouse.ButtonRelease, + }); err != nil { + return err + } + + // Zoom into values 2-3. + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{5, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonRelease, + }) + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 8, 8), + &axes.XProperties{ + Min: 2, + Max: 3, + ReqYWidth: 2, + }, + ), + }, + { + desc: "doesn't zoom below two values", + xp: &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + // Zoom into values 1-3. + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{5, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{5, 0}, + Button: mouse.ButtonRelease, + }); err != nil { + return err + } + + // Zoom into values 2-3. + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{5, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{5, 0}, + Button: mouse.ButtonRelease, + }); err != nil { + return err + } + + // Doesn't zoom further. + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonRelease, + }) + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 8, 8), + &axes.XProperties{ + Min: 2, + Max: 3, + ReqYWidth: 2, + }, + ), + }, + { + desc: "fails to zoom when X coordinate of click too high", + xp: &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{7, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonRelease, + }) + }, + wantMutateErr: true, + }, + { + desc: "cancels highlight and zooms on unrelated mouse button", + xp: &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonMiddle, + }) + + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 8, 8), + &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + ), + }, + { + desc: "cancels highlight and zooms on button release outside of the graph area", + xp: &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{3, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{0, 0}, + Button: mouse.ButtonRelease, + }) + + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 8, 8), + &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + ), + }, + { + desc: "highlights of single row maximizes zoom", + xp: &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonRelease, + }) + + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 8, 8), + &axes.XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 2, + }, + ), + }, + { + desc: "multiple scroll ups maximize zoom", + opts: []Option{ + ScrollStep(30), + }, + xp: &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelUp, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelUp, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelUp, + }) + + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 8, 8), + &axes.XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 2, + }, + ), + }, + { + desc: "multiple scroll downs minimize zoom", + opts: []Option{ + ScrollStep(30), + }, + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelUp, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelUp, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelUp, + }); err != nil { + return err + } + + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelDown, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelDown, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelDown, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelDown, + }); err != nil { + return err + } + return tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelDown, + }) + + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 8, 8), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + { + desc: "zoom normalized when axis changed (new values)", + xp: &axes.XProperties{ + Min: 0, + Max: 5, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonRelease, + }); err != nil { + return err + } + + newX, err := axes.NewXDetails(image.Rect(0, 0, 8, 8), &axes.XProperties{ + Min: 0, + Max: 0, + ReqYWidth: 2, + }) + if err != nil { + return err + } + return tr.Update( + newX, + image.Rect(0, 0, 8, 8), + image.Rect(2, 0, 8, 8), + ) + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 8, 8), + &axes.XProperties{ + Min: 0, + Max: 0, + ReqYWidth: 2, + }, + ), + }, + { + desc: "zoom normalized when terminal size changed", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonRelease, + }); err != nil { + return err + } + + newX, err := axes.NewXDetails(image.Rect(0, 0, 4, 4), &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }) + if err != nil { + return err + } + return tr.Update( + newX, + image.Rect(0, 0, 4, 4), + image.Rect(2, 0, 4, 4), + ) + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 4, 4), + &axes.XProperties{ + Min: 0, + Max: 1, + ReqYWidth: 2, + }, + ), + }, + { + desc: "cancels highlight when terminal size changed", + xp: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + mutate: func(tr *Tracker) error { + if err := tr.Mouse(&terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonLeft, + }); err != nil { + return err + } + + newX, err := axes.NewXDetails(image.Rect(0, 0, 4, 4), &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }) + if err != nil { + return err + } + return tr.Update( + newX, + image.Rect(0, 0, 4, 4), + image.Rect(2, 0, 4, 4), + ) + }, + wantHighlight: false, + wantZoom: mustNewXDetails( + image.Rect(0, 0, 4, 4), + &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + ), + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + xd, err := axes.NewXDetails(tc.cvsAr, tc.xp) + if err != nil { + t.Fatalf("NewXDetails => unexpected error: %v", err) + } + + tracker, err := New(xd, tc.cvsAr, tc.graphAr, tc.opts...) + if (err != nil) != tc.wantErr { + t.Errorf("New => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + + if tc.mutate != nil { + err := tc.mutate(tracker) + if (err != nil) != tc.wantMutateErr { + t.Errorf("tc.mutate => unexpected error: %v, wantMutateErr: %v", err, tc.wantMutateErr) + } + if err != nil { + return + } + } + + gotHighlight, gotHightlightRange := tracker.Highlight() + if gotHighlight != tc.wantHighlight { + t.Errorf("Hightlight => %v, _, want %v, _", gotHighlight, tc.wantHighlight) + } + if diff := pretty.Compare(tc.wantHighlightRange, gotHightlightRange); diff != "" { + t.Errorf("Hightlight => unexpected range, diff (-want, +got):\n%s", diff) + } + + gotZoom := tracker.Zoom() + if diff := pretty.Compare(tc.wantZoom, gotZoom); diff != "" { + t.Errorf("Zoom => unexpected XDetails, diff (-want, +got):\n%s", diff) + } + }) + } +} + +func TestNormalize(t *testing.T) { + tests := []struct { + desc string + baseMin *axes.Value + baseMax *axes.Value + min int + max int + wantMin int + wantMax int + }{ + { + desc: "min and max within the base axis", + baseMin: axes.NewValue(0, 0), + baseMax: axes.NewValue(3, 0), + min: 1, + max: 2, + wantMin: 1, + wantMax: 2, + }, + { + desc: "min and max on the edges of base", + baseMin: axes.NewValue(0, 0), + baseMax: axes.NewValue(3, 0), + min: 0, + max: 3, + wantMin: 0, + wantMax: 3, + }, + { + desc: "min and max normalized", + baseMin: axes.NewValue(0, 0), + baseMax: axes.NewValue(3, 0), + min: -1, + max: 4, + wantMin: 0, + wantMax: 3, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + gotMin, gotMax := normalize(tc.baseMin, tc.baseMax, tc.min, tc.max) + if gotMin != tc.wantMin || gotMax != tc.wantMax { + t.Errorf("normalize => %v, %v, want %v, %v", gotMin, gotMax, tc.wantMin, tc.wantMax) + } + }) + } +} + +func TestNewZoomedFromBase(t *testing.T) { + tests := []struct { + desc string + min int + max int + baseP *axes.XProperties + cvsAr image.Rectangle + wantP *axes.XProperties + wantErr bool + }{ + { + desc: "returns zoomed axis", + min: 1, + max: 2, + baseP: &axes.XProperties{ + Min: 0, + Max: 3, + ReqYWidth: 2, + CustomLabels: map[int]string{ + 1: "1", + }, + LO: axes.LabelOrientationVertical, + }, + cvsAr: image.Rect(0, 0, 10, 10), + wantP: &axes.XProperties{ + Min: 1, + Max: 2, + ReqYWidth: 2, + CustomLabels: map[int]string{ + 1: "1", + }, + LO: axes.LabelOrientationVertical, + }, + }, + { + desc: "fails on negative max", + min: 1, + max: -2, + baseP: &axes.XProperties{ + Min: 0, + Max: 3, + ReqYWidth: 2, + CustomLabels: map[int]string{ + 1: "1", + }, + LO: axes.LabelOrientationVertical, + }, + cvsAr: image.Rect(0, 0, 10, 10), + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + base, err := axes.NewXDetails(tc.cvsAr, tc.baseP) + if err != nil { + t.Fatalf("NewXDetails => unexpected error: %v", err) + } + + got, err := newZoomedFromBase(tc.min, tc.max, base, tc.cvsAr) + if (err != nil) != tc.wantErr { + t.Errorf("newZoomedFromBase => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + + var want *axes.XDetails + if tc.wantP != nil { + w, err := axes.NewXDetails(tc.cvsAr, tc.wantP) + if err != nil { + t.Fatalf("NewXDetails => unexpected error: %v", err) + + } + want = w + } + + if diff := pretty.Compare(want, got); diff != "" { + t.Errorf("newZoomedFromBase => unexpected diff (-want, +got):\n%s", diff) + } + }) + } +} + +func TestFindCellPair(t *testing.T) { + tests := []struct { + desc string + cvsAr image.Rectangle + baseP *axes.XProperties + minCell int + maxCell int + wantMin *axes.Value + wantMax *axes.Value + wantErr bool + }{ + { + desc: "fails when minCell isn't on the graph", + cvsAr: image.Rect(0, 0, 4, 4), + baseP: &axes.XProperties{ + Min: 0, + Max: 3, + }, + minCell: -1, + maxCell: 3, + wantErr: true, + }, + { + desc: "fails when maxCell isn't on the graph", + cvsAr: image.Rect(0, 0, 4, 4), + baseP: &axes.XProperties{ + Min: 0, + Max: 3, + }, + minCell: 0, + maxCell: 4, + wantErr: true, + }, + { + desc: "nothing to do, cells point at distinct values", + cvsAr: image.Rect(0, 0, 4, 4), + baseP: &axes.XProperties{ + Min: 0, + Max: 2, + }, + minCell: 0, + maxCell: 2, + wantMin: axes.NewValue(0, 2), + wantMax: axes.NewValue(2, 2), + }, + { + desc: "cells point at the same value, distinct found above max", + cvsAr: image.Rect(0, 0, 4, 4), + baseP: &axes.XProperties{ + Min: 0, + Max: 2, + }, + minCell: 1, + maxCell: 2, + wantMin: axes.NewValue(1, 2), + wantMax: axes.NewValue(2, 2), + }, + { + desc: "cells point at the same value, distinct found below min", + cvsAr: image.Rect(0, 0, 4, 4), + baseP: &axes.XProperties{ + Min: 0, + Max: 2, + }, + minCell: 2, + maxCell: 2, + wantMin: axes.NewValue(1, 2), + wantMax: axes.NewValue(2, 2), + }, + { + desc: "cells point at the same value, only distinct are first and last", + cvsAr: image.Rect(0, 0, 4, 4), + baseP: &axes.XProperties{ + Min: 0, + Max: 0, + }, + minCell: 1, + maxCell: 2, + wantMin: axes.NewValue(0, 2), + wantMax: axes.NewValue(0, 2), + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + base, err := axes.NewXDetails(tc.cvsAr, tc.baseP) + if err != nil { + t.Fatalf("NewXDetails => unexpected error: %v", err) + } + + gotMin, gotMax, err := findCellPair(base, tc.minCell, tc.maxCell) + if (err != nil) != tc.wantErr { + t.Errorf("findCellPair => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + + if diff := pretty.Compare(tc.wantMin, gotMin); diff != "" { + t.Errorf("findCellPair => unexpected min, diff (-want, +got):\n%s", diff) + } + if diff := pretty.Compare(tc.wantMax, gotMax); diff != "" { + t.Errorf("findCellPair => unexpected max, diff (-want, +got):\n%s", diff) + } + }) + } +} + +func TestZoomToHighlight(t *testing.T) { + tests := []struct { + desc string + baseP *axes.XProperties + hRange *Range + cvsAr image.Rectangle + wantP *axes.XProperties + wantErr bool + }{ + { + desc: "fails on impossible range", + cvsAr: image.Rect(0, 0, 4, 4), + baseP: &axes.XProperties{ + Min: 0, + Max: 3, + }, + hRange: &Range{Start: -1, End: 2}, + wantErr: true, + }, + { + desc: "zooms to highlighted area", + cvsAr: image.Rect(0, 0, 4, 4), + baseP: &axes.XProperties{ + Min: 0, + Max: 3, + }, + hRange: &Range{Start: 1, End: 2}, + wantP: &axes.XProperties{ + Min: 1, + Max: 2, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + base, err := axes.NewXDetails(tc.cvsAr, tc.baseP) + if err != nil { + t.Fatalf("NewXDetails => unexpected error: %v", err) + } + + got, err := zoomToHighlight(base, tc.hRange, tc.cvsAr) + if (err != nil) != tc.wantErr { + t.Errorf("zoomToHighlight => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + + var want *axes.XDetails + if tc.wantP != nil { + w, err := axes.NewXDetails(tc.cvsAr, tc.wantP) + if err != nil { + t.Fatalf("NewXDetails => unexpected error: %v", err) + } + want = w + } + if diff := pretty.Compare(want, got); diff != "" { + t.Errorf("zoomToHighlight => unexpected diff (-want, +got):\n%s", diff) + } + }) + } +} + +func TestZoomToScroll(t *testing.T) { + tests := []struct { + desc string + mouse *terminalapi.Mouse + cvsAr image.Rectangle + graphAr image.Rectangle + currP *axes.XProperties + baseP *axes.XProperties + opts []Option + wantP *axes.XProperties + wantErr bool + }{ + { + desc: "ignores scroll outside of graphAr", + mouse: &terminalapi.Mouse{ + Position: image.Point{4, 4}, + Button: mouse.ButtonWheelUp, + }, + cvsAr: image.Rect(0, 0, 4, 4), + }, + { + desc: "ignores other (non scroll) buttons", + mouse: &terminalapi.Mouse{ + Position: image.Point{0, 0}, + Button: mouse.ButtonLeft, + }, + cvsAr: image.Rect(0, 0, 4, 4), + graphAr: image.Rect(0, 0, 4, 4), + }, + { + desc: "scroll up in the middle zooms in evenly", + opts: []Option{ + ScrollStep(30), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonWheelUp, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + wantP: &axes.XProperties{ + Min: 1, + Max: 3, + ReqYWidth: 2, + }, + }, + { + desc: "scroll up at the left edge", + opts: []Option{ + ScrollStep(30), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelUp, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + wantP: &axes.XProperties{ + Min: 0, + Max: 3, + ReqYWidth: 2, + }, + }, + { + desc: "scroll up at the right edge", + opts: []Option{ + ScrollStep(30), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonWheelUp, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + wantP: &axes.XProperties{ + Min: 1, + Max: 4, + ReqYWidth: 2, + }, + }, + { + desc: "zoom in when current is already zoomed", + opts: []Option{ + ScrollStep(30), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonWheelUp, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 1, + Max: 3, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + wantP: &axes.XProperties{ + Min: 2, + Max: 3, + ReqYWidth: 2, + }, + }, + { + desc: "zoom in moves min over the current max", + opts: []Option{ + ScrollStep(150), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonWheelUp, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + wantP: &axes.XProperties{ + Min: 2, + Max: 3, + ReqYWidth: 2, + }, + }, + { + desc: "zoom in moves max under the current min", + opts: []Option{ + ScrollStep(150), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelUp, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + wantP: &axes.XProperties{ + Min: 1, + Max: 2, + ReqYWidth: 2, + }, + }, + { + desc: "scroll down in the middle zooms out evenly", + opts: []Option{ + ScrollStep(30), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{4, 0}, + Button: mouse.ButtonWheelDown, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 1, + Max: 3, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + wantP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + }, + { + desc: "scroll down at the left edge", + opts: []Option{ + ScrollStep(30), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelDown, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 1, + Max: 3, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + wantP: &axes.XProperties{ + Min: 1, + Max: 4, + ReqYWidth: 2, + }, + }, + { + desc: "scroll down at the right edge", + opts: []Option{ + ScrollStep(30), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonWheelDown, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 1, + Max: 3, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + wantP: &axes.XProperties{ + Min: 0, + Max: 3, + ReqYWidth: 2, + }, + }, + { + desc: "zoom out moves min below base", + opts: []Option{ + ScrollStep(150), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{6, 0}, + Button: mouse.ButtonWheelDown, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 1, + Max: 3, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + wantP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + }, + { + desc: "zoom out moves max above base", + opts: []Option{ + ScrollStep(150), + }, + mouse: &terminalapi.Mouse{ + Position: image.Point{2, 0}, + Button: mouse.ButtonWheelDown, + }, + cvsAr: image.Rect(0, 0, 8, 8), + graphAr: image.Rect(2, 0, 8, 8), + currP: &axes.XProperties{ + Min: 1, + Max: 3, + ReqYWidth: 2, + }, + baseP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + wantP: &axes.XProperties{ + Min: 0, + Max: 4, + ReqYWidth: 2, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + var curr *axes.XDetails + if tc.currP != nil { + c, err := axes.NewXDetails(tc.cvsAr, tc.currP) + if err != nil { + t.Fatalf("NewXDetails => unexpected error: %v", err) + } + curr = c + } + + var base *axes.XDetails + if tc.baseP != nil { + b, err := axes.NewXDetails(tc.cvsAr, tc.baseP) + if err != nil { + t.Fatalf("NewXDetails => unexpected error: %v", err) + } + base = b + } + + got, err := zoomToScroll(tc.mouse, tc.cvsAr, tc.graphAr, curr, base, newOptions(tc.opts...)) + if (err != nil) != tc.wantErr { + t.Errorf("zoomToScroll => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + + var want *axes.XDetails + if tc.wantP != nil { + w, err := axes.NewXDetails(tc.cvsAr, tc.wantP) + if err != nil { + t.Fatalf("NewXDetails => unexpected error: %v", err) + } + want = w + } + if diff := pretty.Compare(want, got); diff != "" { + t.Errorf("zoomToHighlight => unexpected diff (-want, +got):\n%s", diff) + } + + }) + } +}