diff --git a/draw/hv_line.go b/draw/hv_line.go new file mode 100644 index 0000000..b243041 --- /dev/null +++ b/draw/hv_line.go @@ -0,0 +1,206 @@ +// Copyright 2018 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 draw + +// hv_line.go contains code that draws horizontal and vertical lines. + +import ( + "fmt" + "image" + + "github.com/mum4k/termdash/canvas" + "github.com/mum4k/termdash/cell" +) + +// HVLineOption is used to provide options to HVLine(). +type HVLineOption interface { + // set sets the provided option. + set(*hVLineOptions) +} + +// hVLineOptions stores the provided options. +type hVLineOptions struct { + cellOpts []cell.Option + lineStyle LineStyle +} + +// newHVLineOptions returns a new hVLineOptions instance. +func newHVLineOptions() *hVLineOptions { + return &hVLineOptions{ + lineStyle: DefaultHVLineStyle, + } +} + +// hVLineOption implements HVLineOption. +type hVLineOption func(*hVLineOptions) + +// set implements HVLineOption.set. +func (o hVLineOption) set(opts *hVLineOptions) { + o(opts) +} + +// DefaultHVLineStyle is the default value for the HVLineStyle option. +const DefaultHVLineStyle = LineStyleLight + +// HVLineStyle sets the style of the line. +// Defaults to DefaultHVLineStyle. +func HVLineStyle(ls LineStyle) HVLineOption { + return hVLineOption(func(opts *hVLineOptions) { + opts.lineStyle = ls + }) +} + +// HVLineCellOpts sets options on the cells that contain the line. +func HVLineCellOpts(cOpts ...cell.Option) HVLineOption { + return hVLineOption(func(opts *hVLineOptions) { + opts.cellOpts = cOpts + }) +} + +// HVLine represents one horizontal or vertical line. +type HVLine struct { + // start is the cell where the line starts. + start image.Point + // end is the cell where the line ends. + end image.Point +} + +// HVLines draws horizontal or vertical lines. Handles drawing of the correct +// characters for locations where any two lines cross (e.g. a corner, a T shape +// or a cross). Each line must be at least two cells long. Both start and end +// must be on the same horizontal (same X coordinate) or same vertical (same Y +// coordinate) line. +func HVLines(c *canvas.Canvas, lines []HVLine, opts ...HVLineOption) error { + opt := newHVLineOptions() + for _, o := range opts { + o.set(opt) + } + + g := newHVLineGraph() + for _, l := range lines { + line, err := newHVLine(c, l.start, l.end, opt) + if err != nil { + return err + } + g.addLine(line) + + switch { + case line.horizontal(): + for curX := line.start.X; ; curX++ { + cur := image.Point{curX, line.start.Y} + if _, err := c.SetCell(cur, line.mainPart, opt.cellOpts...); err != nil { + return err + } + + if curX == line.end.X { + break + } + } + + case line.vertical(): + for curY := line.start.Y; ; curY++ { + cur := image.Point{line.start.X, curY} + if _, err := c.SetCell(cur, line.mainPart, opt.cellOpts...); err != nil { + return err + } + + if curY == line.end.Y { + break + } + } + } + } + + for _, n := range g.multiEdgeNodes() { + r, err := n.rune(opt.lineStyle) + if err != nil { + return err + } + if _, err := c.SetCell(n.p, r, opt.cellOpts...); err != nil { + return err + } + } + + return nil +} + +// hVLine represents a line that will be drawn on the canvas. +type hVLine struct { + // start is the starting point of the line. + start image.Point + + // end is the ending point of the line. + end image.Point + + // mainPart is either parts[vLine] or parts[hLine] depending on whether + // this is horizontal or vertical line. + mainPart rune + + // opts are the options provided in a call to HVLine(). + opts *hVLineOptions +} + +// newHVLine creates a new hVLine instance. +// Swaps start and end if necessary, so that horizontal drawing is always left +// to right and vertical is always top down. +func newHVLine(c *canvas.Canvas, start, end image.Point, opts *hVLineOptions) (*hVLine, error) { + if ar := c.Area(); !start.In(ar) || !end.In(ar) { + return nil, fmt.Errorf("both the start%v and the end%v must be in the canvas area: %v", start, end, ar) + } + + parts, err := lineParts(opts.lineStyle) + if err != nil { + return nil, err + } + + var mainPart rune + switch { + case start.X != end.X && start.Y != end.Y: + return nil, fmt.Errorf("can only draw horizontal (same X coordinates) or vertical (same Y coordinates), got start:%v end:%v", start, end) + + case start.X == end.X && start.Y == end.Y: + return nil, fmt.Errorf("the line must at least one cell long, got start%v, end%v", start, end) + + case start.X == end.X: + mainPart = parts[vLine] + if start.Y > end.Y { + start, end = end, start + } + + case start.Y == end.Y: + mainPart = parts[hLine] + if start.X > end.X { + start, end = end, start + } + + } + + return &hVLine{ + start: start, + end: end, + mainPart: mainPart, + opts: opts, + }, nil +} + +// horizontal determines if this is a horizontal line. +func (hvl *hVLine) horizontal() bool { + return hvl.mainPart == lineStyleChars[hvl.opts.lineStyle][hLine] +} + +// vertical determines if this is a vertical line. +func (hvl *hVLine) vertical() bool { + return hvl.mainPart == lineStyleChars[hvl.opts.lineStyle][vLine] +} diff --git a/draw/hv_line_graph.go b/draw/hv_line_graph.go new file mode 100644 index 0000000..f151b6e --- /dev/null +++ b/draw/hv_line_graph.go @@ -0,0 +1,204 @@ +// Copyright 2018 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 draw + +// hv_line_graph.go helps to keep track of locations where lines cross. + +import ( + "fmt" + "image" +) + +// hVLineEdge is an edge between two points on the graph. +type hVLineEdge struct { + // from is the starting node of this edge. + // From is guaranteed to be less than to. + from image.Point + + // to is the ending point of this edge. + to image.Point +} + +// newHVLineEdge returns a new edge between the two points. +func newHVLineEdge(from, to image.Point) hVLineEdge { + return hVLineEdge{ + from: from, + to: to, + } +} + +// hVLineNode represents one node in the graph. +// I.e. one cell. +type hVLineNode struct { + // p is the point where this node is. + p image.Point + + // edges are the edges between this node and the surrounding nodes. + // The code only supports horizontal and vertical lines so there can only + // ever be edges to nodes on these planes. + edges map[hVLineEdge]bool +} + +// newHVLineNode creates a new newHVLineNode. +func newHVLineNode(p image.Point) *hVLineNode { + return &hVLineNode{ + p: p, + edges: map[hVLineEdge]bool{}, + } +} + +// hasDown determines if this node has an edge to the one below it. +func (n *hVLineNode) hasDown() bool { + target := newHVLineEdge(n.p, image.Point{n.p.X, n.p.Y + 1}) + _, ok := n.edges[target] + return ok +} + +// hasUp determines if this node has an edge to the one above it. +func (n *hVLineNode) hasUp() bool { + target := newHVLineEdge(image.Point{n.p.X, n.p.Y - 1}, n.p) + _, ok := n.edges[target] + return ok +} + +// hasLeft determines if this node has an edge to the next node on the left. +func (n *hVLineNode) hasLeft() bool { + target := newHVLineEdge(image.Point{n.p.X - 1, n.p.Y}, n.p) + _, ok := n.edges[target] + return ok +} + +// hasRight determines if this node has an edge to the next node on the right. +func (n *hVLineNode) hasRight() bool { + target := newHVLineEdge(n.p, image.Point{n.p.X + 1, n.p.Y}) + _, ok := n.edges[target] + return ok +} + +// rune, given the selected line style returns the correct line character to +// represent this node. +// Only handles nodes with two or more edges, as returned by multiEdgeNodes(). +func (n *hVLineNode) rune(ls LineStyle) (rune, error) { + parts, err := lineParts(ls) + if err != nil { + return -1, err + } + + switch len(n.edges) { + case 2: + switch { + case n.hasLeft() && n.hasRight(): + return parts[hLine], nil + case n.hasUp() && n.hasDown(): + return parts[vLine], nil + case n.hasDown() && n.hasRight(): + return parts[topLeftCorner], nil + case n.hasDown() && n.hasLeft(): + return parts[topRightCorner], nil + case n.hasUp() && n.hasRight(): + return parts[bottomLeftCorner], nil + case n.hasUp() && n.hasLeft(): + return parts[bottomRightCorner], nil + default: + return -1, fmt.Errorf("unexpected two edges in node representing point %v: %v", n.p, n.edges) + } + + case 3: + switch { + case n.hasUp() && n.hasLeft() && n.hasRight(): + return parts[hAndUp], nil + case n.hasDown() && n.hasLeft() && n.hasRight(): + return parts[hAndDown], nil + case n.hasUp() && n.hasDown() && n.hasRight(): + return parts[vAndRight], nil + case n.hasUp() && n.hasDown() && n.hasLeft(): + return parts[vAndLeft], nil + + default: + return -1, fmt.Errorf("unexpected three edges in node representing point %v: %v", n.p, n.edges) + } + + case 4: + return parts[vAndH], nil + default: + return -1, fmt.Errorf("unexpected number of edges(%d) in node representing point %v", len(n.edges), n.p) + } +} + +// hVLineGraph represents lines on the canvas as a bidirectional graph of +// nodes. Helps to determine the characters that should be used where multiple +// lines cross. +type hVLineGraph struct { + nodes map[image.Point]*hVLineNode +} + +// newHVLineGraph creates a new hVLineGraph. +func newHVLineGraph() *hVLineGraph { + return &hVLineGraph{ + nodes: make(map[image.Point]*hVLineNode), + } +} + +// getOrCreateNode gets an existing or creates a new node for the point. +func (g *hVLineGraph) getOrCreateNode(p image.Point) *hVLineNode { + if n, ok := g.nodes[p]; ok { + return n + } + n := newHVLineNode(p) + g.nodes[p] = n + return n +} + +// addLine adds a line to the graph. +// This adds edges between all the points on the line. +func (g *hVLineGraph) addLine(line *hVLine) { + switch { + case line.horizontal(): + for curX := line.start.X; curX < line.end.X; curX++ { + from := image.Point{curX, line.start.Y} + to := image.Point{curX + 1, line.start.Y} + n1 := g.getOrCreateNode(from) + n2 := g.getOrCreateNode(to) + edge := newHVLineEdge(from, to) + n1.edges[edge] = true + n2.edges[edge] = true + } + + case line.vertical(): + for curY := line.start.Y; curY < line.end.Y; curY++ { + from := image.Point{line.start.X, curY} + to := image.Point{line.start.X, curY + 1} + n1 := g.getOrCreateNode(from) + n2 := g.getOrCreateNode(to) + edge := newHVLineEdge(from, to) + n1.edges[edge] = true + n2.edges[edge] = true + } + } +} + +// multiEdgeNodes returns all nodes that have more than one edge. These are +// the nodes where we might need to use different line characters to represent +// the crossing of multiple lines. +func (g *hVLineGraph) multiEdgeNodes() []*hVLineNode { + var nodes []*hVLineNode + for _, n := range g.nodes { + if len(n.edges) <= 1 { + continue + } + nodes = append(nodes, n) + } + return nodes +} diff --git a/draw/hv_line_graph_test.go b/draw/hv_line_graph_test.go new file mode 100644 index 0000000..7a40cdf --- /dev/null +++ b/draw/hv_line_graph_test.go @@ -0,0 +1,375 @@ +// Copyright 2018 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 draw + +import ( + "image" + "sort" + "testing" + + "github.com/kylelemons/godebug/pretty" + "github.com/mum4k/termdash/canvas" +) + +func TestMultiEdgeNodes(t *testing.T) { + tests := []struct { + desc string + lines []HVLine + want []*hVLineNode + }{ + { + desc: "no lines added", + }, + { + desc: "single-edge nodes only", + lines: []HVLine{ + { + start: image.Point{0, 0}, + end: image.Point{0, 1}, + }, + { + start: image.Point{1, 0}, + end: image.Point{1, 1}, + }, + }, + }, + { + desc: "lines don't cross", + lines: []HVLine{ + { + start: image.Point{0, 0}, + end: image.Point{0, 2}, + }, + { + start: image.Point{1, 0}, + end: image.Point{1, 2}, + }, + }, + want: []*hVLineNode{ + { + p: image.Point{0, 1}, + edges: map[hVLineEdge]bool{ + newHVLineEdge(image.Point{0, 0}, image.Point{0, 1}): true, + newHVLineEdge(image.Point{0, 1}, image.Point{0, 2}): true, + }, + }, + { + p: image.Point{1, 1}, + edges: map[hVLineEdge]bool{ + newHVLineEdge(image.Point{1, 0}, image.Point{1, 1}): true, + newHVLineEdge(image.Point{1, 1}, image.Point{1, 2}): true, + }, + }, + }, + }, + { + desc: "lines cross, node has two edges", + lines: []HVLine{ + { + start: image.Point{0, 0}, + end: image.Point{0, 1}, + }, + { + start: image.Point{0, 0}, + end: image.Point{1, 0}, + }, + }, + want: []*hVLineNode{ + { + p: image.Point{0, 0}, + edges: map[hVLineEdge]bool{ + newHVLineEdge(image.Point{0, 0}, image.Point{0, 1}): true, + newHVLineEdge(image.Point{0, 0}, image.Point{1, 0}): true, + }, + }, + }, + }, + { + desc: "lines cross, node has three edges", + lines: []HVLine{ + { + start: image.Point{0, 0}, + end: image.Point{0, 2}, + }, + { + start: image.Point{0, 1}, + end: image.Point{1, 1}, + }, + }, + want: []*hVLineNode{ + { + p: image.Point{0, 1}, + edges: map[hVLineEdge]bool{ + newHVLineEdge(image.Point{0, 0}, image.Point{0, 1}): true, + newHVLineEdge(image.Point{0, 1}, image.Point{1, 1}): true, + newHVLineEdge(image.Point{0, 1}, image.Point{0, 2}): true, + }, + }, + }, + }, + { + desc: "lines cross, node has four edges", + lines: []HVLine{ + { + start: image.Point{1, 0}, + end: image.Point{1, 2}, + }, + { + start: image.Point{0, 1}, + end: image.Point{2, 1}, + }, + }, + want: []*hVLineNode{ + { + p: image.Point{1, 1}, + edges: map[hVLineEdge]bool{ + newHVLineEdge(image.Point{1, 0}, image.Point{1, 1}): true, + newHVLineEdge(image.Point{0, 1}, image.Point{1, 1}): true, + newHVLineEdge(image.Point{1, 1}, image.Point{2, 1}): true, + newHVLineEdge(image.Point{1, 1}, image.Point{1, 2}): true, + }, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + c, err := canvas.New(image.Rect(0, 0, 3, 3)) + if err != nil { + t.Fatalf("canvas.New => unexpected error: %v", err) + } + + g := newHVLineGraph() + for i, l := range tc.lines { + line, err := newHVLine(c, l.start, l.end, newHVLineOptions()) + if err != nil { + t.Fatalf("newHVLine[%d] => unexpected error: %v", i, err) + } + g.addLine(line) + } + + got := g.multiEdgeNodes() + + lessFn := func(i, j int) bool { + return got[i].p.X < got[j].p.X || got[i].p.Y < got[j].p.Y + } + sort.Slice(got, lessFn) + sort.Slice(tc.want, lessFn) + if diff := pretty.Compare(tc.want, got); diff != "" { + t.Errorf("multiEdgeNodes => unexpected diff (-want, +got):\n%s", diff) + } + }) + } + +} + +func TestNodeRune(t *testing.T) { + tests := []struct { + desc string + node *hVLineNode + ls LineStyle + want rune + wantErr bool + }{ + { + desc: "fails on node with no edges", + node: &hVLineNode{}, + wantErr: true, + }, + { + desc: "fails on unsupported two edge combination", + node: &hVLineNode{ + edges: map[hVLineEdge]bool{ + newHVLineEdge(image.Point{0, 0}, image.Point{1, 1}): true, + newHVLineEdge(image.Point{1, 1}, image.Point{2, 2}): true, + }, + }, + ls: LineStyleLight, + wantErr: true, + }, + { + desc: "fails on unsupported three edge combination", + node: &hVLineNode{ + edges: map[hVLineEdge]bool{ + newHVLineEdge(image.Point{0, 0}, image.Point{1, 1}): true, + newHVLineEdge(image.Point{0, 0}, image.Point{0, 1}): true, + newHVLineEdge(image.Point{1, 1}, image.Point{2, 2}): true, + }, + }, + ls: LineStyleLight, + wantErr: true, + }, + { + desc: "fails on unsupported line style", + node: &hVLineNode{}, + ls: LineStyle(-1), + wantErr: true, + }, + { + desc: "horizontal line", + node: &hVLineNode{ + p: image.Point{1, 1}, + edges: map[hVLineEdge]bool{ + newHVLineEdge(image.Point{0, 1}, image.Point{1, 1}): true, + newHVLineEdge(image.Point{1, 1}, image.Point{2, 1}): true, + }, + }, + ls: LineStyleLight, + want: lineStyleChars[LineStyleLight][hLine], + }, + { + desc: "vertical line", + node: &hVLineNode{ + p: image.Point{1, 1}, + edges: map[hVLineEdge]bool{ + newHVLineEdge(image.Point{1, 0}, image.Point{1, 1}): true, + newHVLineEdge(image.Point{1, 1}, image.Point{1, 2}): true, + }, + }, + ls: LineStyleLight, + want: lineStyleChars[LineStyleLight][vLine], + }, + { + desc: "top left corner", + node: &hVLineNode{ + p: image.Point{0, 0}, + edges: map[hVLineEdge]bool{ + newHVLineEdge(image.Point{0, 0}, image.Point{1, 0}): true, + newHVLineEdge(image.Point{0, 0}, image.Point{0, 1}): true, + }, + }, + ls: LineStyleLight, + want: lineStyleChars[LineStyleLight][topLeftCorner], + }, + { + desc: "top right corner", + node: &hVLineNode{ + p: image.Point{2, 0}, + edges: map[hVLineEdge]bool{ + newHVLineEdge(image.Point{1, 0}, image.Point{2, 0}): true, + newHVLineEdge(image.Point{2, 0}, image.Point{2, 1}): true, + }, + }, + ls: LineStyleLight, + want: lineStyleChars[LineStyleLight][topRightCorner], + }, + { + desc: "bottom left corner", + node: &hVLineNode{ + p: image.Point{0, 2}, + edges: map[hVLineEdge]bool{ + newHVLineEdge(image.Point{0, 1}, image.Point{0, 2}): true, + newHVLineEdge(image.Point{0, 2}, image.Point{1, 2}): true, + }, + }, + ls: LineStyleLight, + want: lineStyleChars[LineStyleLight][bottomLeftCorner], + }, + { + desc: "bottom right corner", + node: &hVLineNode{ + p: image.Point{2, 2}, + edges: map[hVLineEdge]bool{ + newHVLineEdge(image.Point{1, 2}, image.Point{2, 2}): true, + newHVLineEdge(image.Point{2, 1}, image.Point{2, 2}): true, + }, + }, + ls: LineStyleLight, + want: lineStyleChars[LineStyleLight][bottomRightCorner], + }, + { + desc: "T horizontal and up", + node: &hVLineNode{ + p: image.Point{1, 2}, + edges: map[hVLineEdge]bool{ + newHVLineEdge(image.Point{1, 1}, image.Point{1, 2}): true, + newHVLineEdge(image.Point{0, 2}, image.Point{1, 2}): true, + newHVLineEdge(image.Point{1, 2}, image.Point{2, 2}): true, + }, + }, + ls: LineStyleLight, + want: lineStyleChars[LineStyleLight][hAndUp], + }, + { + desc: "T horizontal and down", + node: &hVLineNode{ + p: image.Point{1, 0}, + edges: map[hVLineEdge]bool{ + newHVLineEdge(image.Point{0, 0}, image.Point{1, 0}): true, + newHVLineEdge(image.Point{1, 0}, image.Point{2, 0}): true, + newHVLineEdge(image.Point{1, 0}, image.Point{1, 1}): true, + }, + }, + ls: LineStyleLight, + want: lineStyleChars[LineStyleLight][hAndDown], + }, + { + desc: "T vertical and right", + node: &hVLineNode{ + p: image.Point{0, 1}, + edges: map[hVLineEdge]bool{ + newHVLineEdge(image.Point{0, 0}, image.Point{0, 1}): true, + newHVLineEdge(image.Point{0, 1}, image.Point{1, 1}): true, + newHVLineEdge(image.Point{0, 1}, image.Point{0, 2}): true, + }, + }, + ls: LineStyleLight, + want: lineStyleChars[LineStyleLight][vAndRight], + }, + { + desc: "T vertical and left", + node: &hVLineNode{ + p: image.Point{2, 1}, + edges: map[hVLineEdge]bool{ + newHVLineEdge(image.Point{2, 0}, image.Point{2, 1}): true, + newHVLineEdge(image.Point{1, 1}, image.Point{2, 1}): true, + newHVLineEdge(image.Point{2, 1}, image.Point{2, 2}): true, + }, + }, + ls: LineStyleLight, + want: lineStyleChars[LineStyleLight][vAndLeft], + }, + { + desc: "cross", + node: &hVLineNode{ + p: image.Point{1, 1}, + edges: map[hVLineEdge]bool{ + newHVLineEdge(image.Point{1, 0}, image.Point{1, 1}): true, + newHVLineEdge(image.Point{0, 1}, image.Point{1, 1}): true, + newHVLineEdge(image.Point{1, 1}, image.Point{2, 1}): true, + newHVLineEdge(image.Point{1, 1}, image.Point{1, 2}): true, + }, + }, + ls: LineStyleLight, + want: lineStyleChars[LineStyleLight][vAndH], + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got, err := tc.node.rune(tc.ls) + if (err != nil) != tc.wantErr { + t.Errorf("rune => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + if got != tc.want { + t.Errorf("rune => got %c, want %c", got, tc.want) + } + }) + } +} diff --git a/draw/hv_line_test.go b/draw/hv_line_test.go new file mode 100644 index 0000000..03565ce --- /dev/null +++ b/draw/hv_line_test.go @@ -0,0 +1,673 @@ +// Copyright 2018 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 draw + +import ( + "image" + "testing" + + "github.com/mum4k/termdash/canvas" + "github.com/mum4k/termdash/canvas/testcanvas" + "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/terminal/faketerm" +) + +func TestHVLines(t *testing.T) { + tests := []struct { + desc string + canvas image.Rectangle // Size of the canvas for the test. + lines []HVLine + opts []HVLineOption + want func(size image.Point) *faketerm.Terminal + wantErr bool + }{ + { + desc: "fails when line isn't horizontal or vertical", + canvas: image.Rect(0, 0, 2, 2), + lines: []HVLine{ + { + start: image.Point{0, 0}, + end: image.Point{1, 1}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + return faketerm.MustNew(size) + }, + wantErr: true, + }, + { + desc: "fails when start isn't in the canvas", + canvas: image.Rect(0, 0, 1, 1), + lines: []HVLine{ + { + start: image.Point{2, 0}, + end: image.Point{0, 0}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + return faketerm.MustNew(size) + }, + wantErr: true, + }, + { + desc: "fails when end isn't in the canvas", + canvas: image.Rect(0, 0, 1, 1), + lines: []HVLine{ + { + start: image.Point{0, 0}, + end: image.Point{0, 2}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + return faketerm.MustNew(size) + }, + wantErr: true, + }, + { + desc: "fails when the line has zero length", + canvas: image.Rect(0, 0, 1, 1), + lines: []HVLine{ + { + start: image.Point{0, 0}, + end: image.Point{0, 0}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + return faketerm.MustNew(size) + }, + wantErr: true, + }, + { + desc: "draws single horizontal line", + canvas: image.Rect(0, 0, 3, 1), + lines: []HVLine{ + { + start: image.Point{0, 0}, + end: image.Point{2, 0}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + parts := lineStyleChars[LineStyleLight] + testcanvas.MustSetCell(c, image.Point{0, 0}, parts[hLine]) + testcanvas.MustSetCell(c, image.Point{1, 0}, parts[hLine]) + testcanvas.MustSetCell(c, image.Point{2, 0}, parts[hLine]) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "respects line style set explicitly", + canvas: image.Rect(0, 0, 3, 1), + lines: []HVLine{ + { + start: image.Point{0, 0}, + end: image.Point{2, 0}, + }, + }, + opts: []HVLineOption{ + HVLineStyle(LineStyleLight), + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + parts := lineStyleChars[LineStyleLight] + testcanvas.MustSetCell(c, image.Point{0, 0}, parts[hLine]) + testcanvas.MustSetCell(c, image.Point{1, 0}, parts[hLine]) + testcanvas.MustSetCell(c, image.Point{2, 0}, parts[hLine]) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "respects cell options", + canvas: image.Rect(0, 0, 3, 1), + lines: []HVLine{ + { + start: image.Point{0, 0}, + end: image.Point{2, 0}, + }, + }, + opts: []HVLineOption{ + HVLineCellOpts( + cell.FgColor(cell.ColorYellow), + cell.BgColor(cell.ColorBlue), + ), + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + parts := lineStyleChars[LineStyleLight] + testcanvas.MustSetCell(c, image.Point{0, 0}, parts[hLine], + cell.FgColor(cell.ColorYellow), + cell.BgColor(cell.ColorBlue), + ) + testcanvas.MustSetCell(c, image.Point{1, 0}, parts[hLine], + cell.FgColor(cell.ColorYellow), + cell.BgColor(cell.ColorBlue), + ) + testcanvas.MustSetCell(c, image.Point{2, 0}, parts[hLine], + cell.FgColor(cell.ColorYellow), + cell.BgColor(cell.ColorBlue), + ) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "draws single horizontal line, supplied in reverse direction", + canvas: image.Rect(0, 0, 3, 1), + lines: []HVLine{ + { + start: image.Point{1, 0}, + end: image.Point{0, 0}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + parts := lineStyleChars[LineStyleLight] + testcanvas.MustSetCell(c, image.Point{0, 0}, parts[hLine]) + testcanvas.MustSetCell(c, image.Point{1, 0}, parts[hLine]) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "draws single vertical line", + canvas: image.Rect(0, 0, 3, 3), + lines: []HVLine{ + { + start: image.Point{1, 0}, + end: image.Point{1, 2}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + parts := lineStyleChars[LineStyleLight] + testcanvas.MustSetCell(c, image.Point{1, 0}, parts[vLine]) + testcanvas.MustSetCell(c, image.Point{1, 1}, parts[vLine]) + testcanvas.MustSetCell(c, image.Point{1, 2}, parts[vLine]) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "draws single vertical line, supplied in reverse direction", + canvas: image.Rect(0, 0, 3, 3), + lines: []HVLine{ + { + start: image.Point{1, 1}, + end: image.Point{1, 0}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + parts := lineStyleChars[LineStyleLight] + testcanvas.MustSetCell(c, image.Point{1, 0}, parts[vLine]) + testcanvas.MustSetCell(c, image.Point{1, 1}, parts[vLine]) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "parallel horizontal lines don't affect each other", + canvas: image.Rect(0, 0, 3, 3), + lines: []HVLine{ + { + start: image.Point{0, 0}, + end: image.Point{2, 0}, + }, + { + start: image.Point{0, 1}, + end: image.Point{2, 1}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + parts := lineStyleChars[LineStyleLight] + testcanvas.MustSetCell(c, image.Point{0, 0}, parts[hLine]) + testcanvas.MustSetCell(c, image.Point{1, 0}, parts[hLine]) + testcanvas.MustSetCell(c, image.Point{2, 0}, parts[hLine]) + + testcanvas.MustSetCell(c, image.Point{0, 1}, parts[hLine]) + testcanvas.MustSetCell(c, image.Point{1, 1}, parts[hLine]) + testcanvas.MustSetCell(c, image.Point{2, 1}, parts[hLine]) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "parallel vertical lines don't affect each other", + canvas: image.Rect(0, 0, 3, 3), + lines: []HVLine{ + { + start: image.Point{0, 0}, + end: image.Point{0, 2}, + }, + { + start: image.Point{1, 0}, + end: image.Point{1, 2}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + parts := lineStyleChars[LineStyleLight] + testcanvas.MustSetCell(c, image.Point{0, 0}, parts[vLine]) + testcanvas.MustSetCell(c, image.Point{0, 1}, parts[vLine]) + testcanvas.MustSetCell(c, image.Point{0, 2}, parts[vLine]) + + testcanvas.MustSetCell(c, image.Point{1, 0}, parts[vLine]) + testcanvas.MustSetCell(c, image.Point{1, 1}, parts[vLine]) + testcanvas.MustSetCell(c, image.Point{1, 2}, parts[vLine]) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "perpendicular lines that don't cross don't affect each other", + canvas: image.Rect(0, 0, 3, 3), + lines: []HVLine{ + { + start: image.Point{0, 0}, + end: image.Point{0, 2}, + }, + { + start: image.Point{1, 1}, + end: image.Point{2, 1}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + parts := lineStyleChars[LineStyleLight] + testcanvas.MustSetCell(c, image.Point{0, 0}, parts[vLine]) + testcanvas.MustSetCell(c, image.Point{0, 1}, parts[vLine]) + testcanvas.MustSetCell(c, image.Point{0, 2}, parts[vLine]) + + testcanvas.MustSetCell(c, image.Point{1, 1}, parts[hLine]) + testcanvas.MustSetCell(c, image.Point{2, 1}, parts[hLine]) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "draws top left corner", + canvas: image.Rect(0, 0, 3, 3), + lines: []HVLine{ + { + start: image.Point{0, 0}, + end: image.Point{0, 2}, + }, + { + start: image.Point{0, 0}, + end: image.Point{2, 0}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + parts := lineStyleChars[LineStyleLight] + testcanvas.MustSetCell(c, image.Point{0, 0}, parts[topLeftCorner]) + testcanvas.MustSetCell(c, image.Point{0, 1}, parts[vLine]) + testcanvas.MustSetCell(c, image.Point{0, 2}, parts[vLine]) + + testcanvas.MustSetCell(c, image.Point{1, 0}, parts[hLine]) + testcanvas.MustSetCell(c, image.Point{2, 0}, parts[hLine]) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "draws top right corner", + canvas: image.Rect(0, 0, 3, 3), + lines: []HVLine{ + { + start: image.Point{2, 0}, + end: image.Point{2, 2}, + }, + { + start: image.Point{0, 0}, + end: image.Point{2, 0}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + parts := lineStyleChars[LineStyleLight] + testcanvas.MustSetCell(c, image.Point{2, 0}, parts[topRightCorner]) + testcanvas.MustSetCell(c, image.Point{2, 1}, parts[vLine]) + testcanvas.MustSetCell(c, image.Point{2, 2}, parts[vLine]) + + testcanvas.MustSetCell(c, image.Point{0, 0}, parts[hLine]) + testcanvas.MustSetCell(c, image.Point{1, 0}, parts[hLine]) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "draws bottom left corner", + canvas: image.Rect(0, 0, 3, 3), + lines: []HVLine{ + { + start: image.Point{0, 0}, + end: image.Point{0, 2}, + }, + { + start: image.Point{0, 2}, + end: image.Point{2, 2}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + parts := lineStyleChars[LineStyleLight] + testcanvas.MustSetCell(c, image.Point{0, 0}, parts[vLine]) + testcanvas.MustSetCell(c, image.Point{0, 1}, parts[vLine]) + testcanvas.MustSetCell(c, image.Point{0, 2}, parts[bottomLeftCorner]) + + testcanvas.MustSetCell(c, image.Point{1, 2}, parts[hLine]) + testcanvas.MustSetCell(c, image.Point{2, 2}, parts[hLine]) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "draws bottom right corner", + canvas: image.Rect(0, 0, 3, 3), + lines: []HVLine{ + { + start: image.Point{2, 0}, + end: image.Point{2, 2}, + }, + { + start: image.Point{0, 2}, + end: image.Point{2, 2}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + parts := lineStyleChars[LineStyleLight] + testcanvas.MustSetCell(c, image.Point{2, 0}, parts[vLine]) + testcanvas.MustSetCell(c, image.Point{2, 1}, parts[vLine]) + testcanvas.MustSetCell(c, image.Point{2, 2}, parts[bottomRightCorner]) + + testcanvas.MustSetCell(c, image.Point{0, 2}, parts[hLine]) + testcanvas.MustSetCell(c, image.Point{1, 2}, parts[hLine]) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "draws T horizontal and up", + canvas: image.Rect(0, 0, 3, 3), + lines: []HVLine{ + { + start: image.Point{0, 2}, + end: image.Point{2, 2}, + }, + { + start: image.Point{1, 0}, + end: image.Point{1, 2}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + parts := lineStyleChars[LineStyleLight] + testcanvas.MustSetCell(c, image.Point{0, 2}, parts[hLine]) + testcanvas.MustSetCell(c, image.Point{1, 2}, parts[hAndUp]) + testcanvas.MustSetCell(c, image.Point{2, 2}, parts[hLine]) + + testcanvas.MustSetCell(c, image.Point{1, 0}, parts[vLine]) + testcanvas.MustSetCell(c, image.Point{1, 1}, parts[vLine]) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "draws T horizontal and down", + canvas: image.Rect(0, 0, 3, 3), + lines: []HVLine{ + { + start: image.Point{0, 0}, + end: image.Point{2, 0}, + }, + { + start: image.Point{1, 0}, + end: image.Point{1, 2}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + parts := lineStyleChars[LineStyleLight] + testcanvas.MustSetCell(c, image.Point{0, 0}, parts[hLine]) + testcanvas.MustSetCell(c, image.Point{1, 0}, parts[hAndDown]) + testcanvas.MustSetCell(c, image.Point{2, 0}, parts[hLine]) + + testcanvas.MustSetCell(c, image.Point{1, 1}, parts[vLine]) + testcanvas.MustSetCell(c, image.Point{1, 2}, parts[vLine]) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "draws T vertical and left", + canvas: image.Rect(0, 0, 3, 3), + lines: []HVLine{ + { + start: image.Point{0, 1}, + end: image.Point{2, 1}, + }, + { + start: image.Point{2, 0}, + end: image.Point{2, 2}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + parts := lineStyleChars[LineStyleLight] + testcanvas.MustSetCell(c, image.Point{0, 1}, parts[hLine]) + testcanvas.MustSetCell(c, image.Point{1, 1}, parts[hLine]) + testcanvas.MustSetCell(c, image.Point{2, 1}, parts[vAndLeft]) + + testcanvas.MustSetCell(c, image.Point{2, 0}, parts[vLine]) + testcanvas.MustSetCell(c, image.Point{2, 2}, parts[vLine]) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "draws T vertical and right", + canvas: image.Rect(0, 0, 3, 3), + lines: []HVLine{ + { + start: image.Point{0, 1}, + end: image.Point{2, 1}, + }, + { + start: image.Point{0, 0}, + end: image.Point{0, 2}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + parts := lineStyleChars[LineStyleLight] + testcanvas.MustSetCell(c, image.Point{0, 1}, parts[vAndRight]) + testcanvas.MustSetCell(c, image.Point{1, 1}, parts[hLine]) + testcanvas.MustSetCell(c, image.Point{2, 1}, parts[hLine]) + + testcanvas.MustSetCell(c, image.Point{0, 0}, parts[vLine]) + testcanvas.MustSetCell(c, image.Point{0, 2}, parts[vLine]) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "draws a cross", + canvas: image.Rect(0, 0, 3, 3), + lines: []HVLine{ + { + start: image.Point{0, 1}, + end: image.Point{2, 1}, + }, + { + start: image.Point{1, 0}, + end: image.Point{1, 2}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + parts := lineStyleChars[LineStyleLight] + testcanvas.MustSetCell(c, image.Point{0, 1}, parts[hLine]) + testcanvas.MustSetCell(c, image.Point{1, 1}, parts[vAndH]) + testcanvas.MustSetCell(c, image.Point{2, 1}, parts[hLine]) + + testcanvas.MustSetCell(c, image.Point{1, 0}, parts[vLine]) + testcanvas.MustSetCell(c, image.Point{1, 2}, parts[vLine]) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "draws multiple crossings", + canvas: image.Rect(0, 0, 3, 3), + lines: []HVLine{ + // Three horizontal lines. + { + start: image.Point{0, 0}, + end: image.Point{2, 0}, + }, + { + start: image.Point{0, 1}, + end: image.Point{2, 1}, + }, + { + start: image.Point{0, 2}, + end: image.Point{2, 2}, + }, + // Three vertical lines. + { + start: image.Point{0, 0}, + end: image.Point{0, 2}, + }, + { + start: image.Point{1, 0}, + end: image.Point{1, 2}, + }, + { + start: image.Point{2, 0}, + end: image.Point{2, 2}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + parts := lineStyleChars[LineStyleLight] + testcanvas.MustSetCell(c, image.Point{0, 0}, parts[topLeftCorner]) + testcanvas.MustSetCell(c, image.Point{1, 0}, parts[hAndDown]) + testcanvas.MustSetCell(c, image.Point{2, 0}, parts[topRightCorner]) + + testcanvas.MustSetCell(c, image.Point{0, 1}, parts[vAndRight]) + testcanvas.MustSetCell(c, image.Point{1, 1}, parts[vAndH]) + testcanvas.MustSetCell(c, image.Point{2, 1}, parts[vAndLeft]) + + testcanvas.MustSetCell(c, image.Point{0, 2}, parts[bottomLeftCorner]) + testcanvas.MustSetCell(c, image.Point{1, 2}, parts[hAndUp]) + testcanvas.MustSetCell(c, image.Point{2, 2}, parts[bottomRightCorner]) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + c, err := canvas.New(tc.canvas) + if err != nil { + t.Fatalf("canvas.New => unexpected error: %v", err) + } + + err = HVLines(c, tc.lines, tc.opts...) + if (err != nil) != tc.wantErr { + t.Errorf("HVLines => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + + got, err := faketerm.New(c.Size()) + if err != nil { + t.Fatalf("faketerm.New => unexpected error: %v", err) + } + + if err := c.Apply(got); err != nil { + t.Fatalf("Apply => unexpected error: %v", err) + } + + if diff := faketerm.Diff(tc.want(c.Size()), got); diff != "" { + t.Errorf("HVLines => %v", diff) + } + }) + } +} diff --git a/draw/line_style.go b/draw/line_style.go index f8dfa48..c849f60 100644 --- a/draw/line_style.go +++ b/draw/line_style.go @@ -14,12 +14,17 @@ package draw -import "fmt" +import ( + "fmt" + + runewidth "github.com/mattn/go-runewidth" +) // line_style.go contains the Unicode characters used for drawing lines of // different styles. // lineStyleChars maps the line styles to the corresponding component characters. +// Source: http://en.wikipedia.org/wiki/Box-drawing_character. var lineStyleChars = map[LineStyle]map[linePart]rune{ LineStyleLight: { hLine: '─', @@ -28,14 +33,31 @@ var lineStyleChars = map[LineStyle]map[linePart]rune{ topRightCorner: '┐', bottomLeftCorner: '└', bottomRightCorner: '┘', + hAndUp: '┴', + hAndDown: '┬', + vAndLeft: '┤', + vAndRight: '├', + vAndH: '┼', }, } +// init verifies that all line parts are half-width runes (occupy only one +// cell). +func init() { + for ls, parts := range lineStyleChars { + for part, r := range parts { + if got := runewidth.RuneWidth(r); got > 1 { + panic(fmt.Errorf("line style %v line part %v is a rune %c with width %v, all parts must be half-width runes (width of one)", ls, part, r, got)) + } + } + } +} + // lineParts returns the line component characters for the provided line style. func lineParts(ls LineStyle) (map[linePart]rune, error) { parts, ok := lineStyleChars[ls] if !ok { - return nil, fmt.Errorf("unsupported line style %v", ls) + return nil, fmt.Errorf("unsupported line style %d", ls) } return parts, nil } @@ -80,6 +102,11 @@ var linePartNames = map[linePart]string{ topRightCorner: "linePartTopRightCorner", bottomLeftCorner: "linePartBottomLeftCorner", bottomRightCorner: "linePartBottomRightCorner", + hAndUp: "linePartHAndUp", + hAndDown: "linePartHAndDown", + vAndLeft: "linePartVAndLeft", + vAndRight: "linePartVAndRight", + vAndH: "linePartVAndH", } const ( @@ -89,4 +116,9 @@ const ( topRightCorner bottomLeftCorner bottomRightCorner + hAndUp + hAndDown + vAndLeft + vAndRight + vAndH ) diff --git a/draw/testdraw/testdraw.go b/draw/testdraw/testdraw.go index b47d490..674c20a 100644 --- a/draw/testdraw/testdraw.go +++ b/draw/testdraw/testdraw.go @@ -43,3 +43,10 @@ func MustRectangle(c *canvas.Canvas, r image.Rectangle, opts ...draw.RectangleOp panic(fmt.Sprintf("draw.Rectangle => unexpected error: %v", err)) } } + +// MustHVLines draws the vertical / horizontal lines or panics. +func MustHVLines(c *canvas.Canvas, lines []draw.HVLine, opts ...draw.HVLineOption) { + if err := draw.HVLines(c, lines, opts...); err != nil { + panic(fmt.Sprintf("draw.HVLines => unexpected error: %v", err)) + } +}