From 11328fa76ca9611d2c228650065c218c006456b5 Mon Sep 17 00:00:00 2001 From: Brian Picciano Date: Sat, 4 Nov 2017 15:29:15 -0600 Subject: [PATCH] drawing edges in gim, and split out some parts into their own packages --- gim/box.go | 157 +++++++++++++++++++++++++ gim/box_test.go | 28 +++++ gim/geo/geo.go | 87 ++++++++++++++ gim/line.go | 178 ++++++++++++++++++++++++++++ gim/main.go | 247 +++++++-------------------------------- gim/terminal/terminal.go | 146 +++++++++++++++++++++++ 6 files changed, 635 insertions(+), 208 deletions(-) create mode 100644 gim/box.go create mode 100644 gim/box_test.go create mode 100644 gim/geo/geo.go create mode 100644 gim/line.go create mode 100644 gim/terminal/terminal.go diff --git a/gim/box.go b/gim/box.go new file mode 100644 index 0000000..342c944 --- /dev/null +++ b/gim/box.go @@ -0,0 +1,157 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/mediocregopher/ginger/gim/geo" + "github.com/mediocregopher/ginger/gim/terminal" +) + +const ( + boxBorderHoriz = iota + boxBorderVert + boxBorderTL + boxBorderTR + boxBorderBL + boxBorderBR +) + +var boxDefault = []string{ + "─", + "│", + "┌", + "┐", + "└", + "┘", +} + +type box struct { + pos geo.XY + size geo.XY // if unset, auto-determined + body string + + transparent bool +} + +func (b box) lines() []string { + lines := strings.Split(b.body, "\n") + // if the last line is empty don't include it, it means there was a trailing + // newline (or the whole string is empty) + if lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + return lines +} + +func (b box) innerSize() geo.XY { + if b.size != (geo.XY{}) { + return b.size + } + var size geo.XY + for _, line := range b.lines() { + size[1]++ + if l := len(line); l > size[0] { + size[0] = l + } + } + return size +} + +func (b box) rectSize() geo.XY { + return b.innerSize().Add(geo.XY{2, 2}) +} + +// edge returns the coordinate of the edge indicated by the given direction (Up, +// Down, Left, or Right). The coordinate will be for the axis applicable to the +// direction, so for Left/Right it will be the x coordinate and for Up/Down the +// y. +func (b box) rectEdge(dir geo.XY) int { + size := b.rectSize() + switch dir { + case geo.Up: + return b.pos[1] + case geo.Down: + return b.pos[1] + size[1] + case geo.Left: + return b.pos[0] + case geo.Right: + return b.pos[0] + size[0] + default: + panic(fmt.Sprintf("unsupported direction: %#v", dir)) + } +} + +func (b box) rectCorner(xDir, yDir geo.XY) geo.XY { + switch { + case xDir == geo.Left && yDir == geo.Up: + return b.pos + case xDir == geo.Right && yDir == geo.Up: + size := b.rectSize() + return b.pos.Add(size.Mul(geo.Right)).Add(geo.XY{-1, 0}) + case xDir == geo.Left && yDir == geo.Down: + size := b.rectSize() + return b.pos.Add(size.Mul(geo.Down)).Add(geo.XY{0, -1}) + case xDir == geo.Right && yDir == geo.Down: + size := b.rectSize() + return b.pos.Add(size).Add(geo.XY{-1, -1}) + default: + panic(fmt.Sprintf("unsupported rectCorner args: %v, %v", xDir, yDir)) + } +} + +func (b box) draw(term *terminal.Terminal) { + chars := boxDefault + pos := b.pos + size := b.innerSize() + w, h := size[0], size[1] + + // draw top line + term.MoveCursorTo(pos) + term.Printf(chars[boxBorderTL]) + for i := 0; i < w; i++ { + term.Printf(chars[boxBorderHoriz]) + } + term.Printf(chars[boxBorderTR]) + + drawLine := func(line string) { + pos[1]++ + term.MoveCursorTo(pos) + term.Printf(chars[boxBorderVert]) + if len(line) > w { + line = line[:w] + } + term.Printf(line) + if b.transparent { + term.MoveCursor(geo.XY{w + 1, 0}) + } else { + term.Printf(strings.Repeat(" ", w-len(line))) + } + term.Printf(chars[boxBorderVert]) + } + + // truncate lines if necessary + lines := b.lines() + if len(lines) > h { + lines = lines[:h] + } + + // draw body + for _, line := range lines { + drawLine(line) + } + + // draw empty lines + for i := 0; i < h-len(lines); i++ { + drawLine("") + } + + // draw bottom line + pos[1]++ + term.MoveCursorTo(pos) + term.Printf(chars[boxBorderBL]) + for i := 0; i < w; i++ { + term.Printf(chars[boxBorderHoriz]) + } + term.Printf(chars[boxBorderBR]) +} diff --git a/gim/box_test.go b/gim/box_test.go new file mode 100644 index 0000000..5533bf6 --- /dev/null +++ b/gim/box_test.go @@ -0,0 +1,28 @@ +package main + +import ( + . "testing" + + "github.com/mediocregopher/ginger/gim/geo" + "github.com/stretchr/testify/assert" +) + +func TestBox(t *T) { + b := box{ + pos: geo.XY{1, 2}, + size: geo.XY{10, 11}, + } + + assert.Equal(t, geo.XY{10, 11}, b.innerSize()) + assert.Equal(t, geo.XY{12, 13}, b.rectSize()) + + assert.Equal(t, 2, b.rectEdge(geo.Up)) + assert.Equal(t, 15, b.rectEdge(geo.Down)) + assert.Equal(t, 1, b.rectEdge(geo.Left)) + assert.Equal(t, 13, b.rectEdge(geo.Right)) + + assert.Equal(t, geo.XY{1, 2}, b.rectCorner(geo.Left, geo.Up)) + assert.Equal(t, geo.XY{1, 14}, b.rectCorner(geo.Left, geo.Down)) + assert.Equal(t, geo.XY{12, 2}, b.rectCorner(geo.Right, geo.Up)) + assert.Equal(t, geo.XY{12, 14}, b.rectCorner(geo.Right, geo.Down)) +} diff --git a/gim/geo/geo.go b/gim/geo/geo.go new file mode 100644 index 0000000..a42fd7b --- /dev/null +++ b/gim/geo/geo.go @@ -0,0 +1,87 @@ +// Package geo implements basic geometric concepts used by gim +package geo + +import "math" + +// XY describes a 2-dimensional position or vector. The origin of the +// 2-dimensional space is a 0,0, with the x-axis going to the left and the +// y-axis going down. +type XY [2]int + +// Zero is the zero point, or a zero vector, depending on what you're doing +var Zero = XY{0, 0} + +// Unit vectors +var ( + Up = XY{0, -1} + Down = XY{0, 1} + Left = XY{-1, 0} + Right = XY{1, 0} +) + +// Add returns the result of adding the two XYs' fields individually +func (xy XY) Add(xy2 XY) XY { + xy[0] += xy2[0] + xy[1] += xy2[1] + return xy +} + +// Mul returns the result of multiplying the two XYs' fields individually +func (xy XY) Mul(xy2 XY) XY { + xy[0] *= xy2[0] + xy[1] *= xy2[1] + return xy +} + +// Scale returns the result of multiplying both of the XY's fields by the scalar +func (xy XY) Scale(scalar int) XY { + return xy.Mul(XY{scalar, scalar}) +} + +// Inv inverses the XY, a shortcut for xy.Scale(-1) +func (xy XY) Inv() XY { + return xy.Scale(-1) +} + +// Sub subtracts xy2 from xy and returns the result. A shortcut for +// xy.Add(xy2.Inv()) +func (xy XY) Sub(xy2 XY) XY { + return xy.Add(xy2.Inv()) +} + +func round(f float64, r int) int { + switch { + case r < 0: + f = math.Floor(f) + case r == 0: + if f < 0 { + f = math.Ceil(f - 0.5) + } + f = math.Floor(f + 0.5) + case r > 0: + f = math.Ceil(f) + } + return int(f) +} + +func (xy XY) toF64() [2]float64 { + return [2]float64{ + float64(xy[0]), + float64(xy[1]), + } +} + +// Midpoint returns the midpoint between the two XYs. The rounder indicates what +// to do about non-whole values when they're come across: +// - rounder < 0 : floor +// - rounder = 0 : round +// - rounder > 0 : ceil +func (xy XY) Midpoint(xy2 XY, rounder int) XY { + xyf, xy2f := xy.toF64(), xy2.toF64() + xf := xyf[0] + ((xy2f[0] - xyf[0]) / 2) + yf := xyf[1] + ((xy2f[1] - xyf[1]) / 2) + return XY{ + round(xf, rounder), + round(yf, rounder), + } +} diff --git a/gim/line.go b/gim/line.go new file mode 100644 index 0000000..f8490ea --- /dev/null +++ b/gim/line.go @@ -0,0 +1,178 @@ +package main + +import ( + "fmt" + + "github.com/mediocregopher/ginger/gim/geo" + "github.com/mediocregopher/ginger/gim/terminal" +) + +// boxEdgeAdj returns the midpoint of a box's edge, using the given direction +// (single-dimension unit-vector) to know which edge to look at. +func boxEdgeAdj(box box, dir geo.XY) geo.XY { + var a, b geo.XY + switch dir { + case geo.Up: + a, b = box.rectCorner(geo.Left, geo.Up), box.rectCorner(geo.Right, geo.Up) + case geo.Down: + a, b = box.rectCorner(geo.Left, geo.Down), box.rectCorner(geo.Right, geo.Down) + case geo.Left: + a, b = box.rectCorner(geo.Left, geo.Up), box.rectCorner(geo.Left, geo.Down) + case geo.Right: + a, b = box.rectCorner(geo.Right, geo.Up), box.rectCorner(geo.Right, geo.Down) + default: + panic(fmt.Sprintf("unsupported direction: %#v", dir)) + } + + mid := a.Midpoint(b, 0) + return mid +} + +var dirs = []geo.XY{ + geo.Up, + geo.Down, + geo.Left, + geo.Right, +} + +// boxesRelDir returns the "best" direction between from and to. Returns +// geo.Zero if they overlap. It also returns the secondary direction. E.g. Down +// and Left. The secondary direction will never be zero if primary is given, +// even if the two boxes are in-line +func boxesRelDir(from, to box) (geo.XY, geo.XY) { + rels := make([]int, len(dirs)) + for i, dir := range dirs { + rels[i] = to.rectEdge(dir.Inv()) - from.rectEdge(dir) + if dir == geo.Up || dir == geo.Left { + rels[i] *= -1 + } + } + + // find primary + var primary geo.XY + var primaryMax int + for i, rel := range rels { + if rel < 0 { + continue + } else if rel > primaryMax || i == 0 { + primary = dirs[i] + primaryMax = rel + } + } + + // if all rels were negative the boxes are overlapping, return zeros + if primary == geo.Zero { + return geo.Zero, geo.Zero + } + + // now find secondary, which must be perpendicular to primary + var secondary geo.XY + var secondaryMax int + var secondarySet bool + for i, rel := range rels { + if dirs[i] == primary { + continue + } else if dirs[i][0] == 0 && primary[0] == 0 { + continue + } else if dirs[i][1] == 0 && primary[1] == 0 { + continue + } else if !secondarySet || rel > secondaryMax { + secondary = dirs[i] + secondaryMax = rel + secondarySet = true + } + } + + return primary, secondary +} + +// liner will draw a line from one box to another +type liner func(*terminal.Terminal, box, box) + +var lineSegments = func() map[[2]geo.XY]string { + m := map[[2]geo.XY]string{ + {{-1, 0}, {1, 0}}: "─", + {{0, 1}, {0, -1}}: "│", + {{1, 0}, {0, 1}}: "┌", + {{-1, 0}, {0, 1}}: "┐", + {{1, 0}, {0, -1}}: "└", + {{-1, 0}, {0, -1}}: "┘", + } + + // the inverse segments use the same characters + for seg, str := range m { + seg[0], seg[1] = seg[1], seg[0] + m[seg] = str + } + return m +}() + +var edgeSegments = map[geo.XY]string{ + geo.Up: "┴", + geo.Down: "┬", + geo.Left: "┤", + geo.Right: "├", +} + +// actual unicode arrows were fucking up my terminal, and they didn't even +// connect properly with the line segments anyway +var arrows = map[geo.XY]string{ + geo.Up: "^", + geo.Down: "v", + geo.Left: "<", + geo.Right: ">", +} + +func basicLine(term *terminal.Terminal, from, to box) { + dir, dirSec := boxesRelDir(from, to) + + // if the boxes overlap then don't draw anything + if dir == geo.Zero { + return + } + + dirInv := dir.Inv() + start := boxEdgeAdj(from, dir) + end := boxEdgeAdj(to, dirInv) + mid := start.Midpoint(end, 0) + + along := func(xy, dir geo.XY) int { + if dir[0] != 0 { + return xy[0] + } + return xy[1] + } + + var pts []geo.XY + midPrim := along(mid, dir) + endSec := along(end, dirSec) + for curr := start; curr != end; { + pts = append(pts, curr) + if prim := along(curr, dir); prim == midPrim { + if sec := along(curr, dirSec); sec != endSec { + curr = curr.Add(dirSec) + continue + } + } + curr = curr.Add(dir) + } + + for i, pt := range pts { + var str string + switch { + case i == 0: + str = edgeSegments[dir] + case i == len(pts)-1: + str = arrows[dir] + default: + prev, next := pts[i-1], pts[i+1] + seg := [2]geo.XY{ + prev.Sub(pt), + next.Sub(pt), + } + str = lineSegments[seg] + } + term.MoveCursorTo(pt) + term.Printf(str) + } +} diff --git a/gim/main.go b/gim/main.go index 6b61378..a309c32 100644 --- a/gim/main.go +++ b/gim/main.go @@ -4,217 +4,34 @@ import ( "fmt" "math/rand" "os" - "os/signal" "strings" "time" - "github.com/buger/goterm" + "github.com/mediocregopher/ginger/gim/geo" + "github.com/mediocregopher/ginger/gim/terminal" ) const ( - // Reset all custom styles - ansiReset = "\033[0m" - - // Reset to default color - ansiResetColor = "\033[32m" - - // Return curor to start of line and clean it - ansiResetLine = "\r\033[K" -) - -// List of possible colors -const ( - black = iota - red - green - yellow - blue - magenta - cyan - white -) - -func getFgColor(code int) string { - return fmt.Sprintf("\033[3%dm", code) -} - -func getBgColor(code int) string { - return fmt.Sprintf("\033[4%dm", code) -} - -func fgColor(str string, color int) string { - return fmt.Sprintf("%s%s%s", getFgColor(color), str, ansiReset) -} - -func bgColor(str string, color int) string { - return fmt.Sprintf("%s%s%s", getBgColor(color), str, ansiReset) -} - -type xy [2]int - -func (p xy) x() int { - return p[0] -} - -func (p xy) y() int { - return p[1] -} - -func (p xy) add(p2 xy) xy { - p[0] += p2[0] - p[1] += p2[1] - return p -} - -//////////////////////////////////////////////////////////////////////////////// - -type terminal struct { - cursorPos xy -} - -func (t *terminal) moveAbs(to xy) { - t.cursorPos = to - goterm.MoveCursor(to.x()+1, to.y()+1) -} - -func (t *terminal) size() xy { - return xy{goterm.Width(), goterm.Height()} -} - -//////////////////////////////////////////////////////////////////////////////// - -const ( - boxBorderHoriz = iota - boxBorderVert - boxBorderTL - boxBorderTR - boxBorderBL - boxBorderBR -) - -var boxDefault = []string{ - "─", - "│", - "┌", - "┐", - "└", - "┘", -} - -type box struct { - pos xy - size xy // if unset, auto-determined - body string - - transparent bool -} - -func (b box) lines() []string { - lines := strings.Split(b.body, "\n") - // if the last line is empty don't include it, it means there was a trailing - // newline (or the whole string is empty) - if lines[len(lines)-1] == "" { - lines = lines[:len(lines)-1] - } - return lines -} - -func (b box) getSize() xy { - if b.size != (xy{}) { - return b.size - } - var size xy - for _, line := range b.lines() { - size[1]++ - if l := len(line); l > size[0] { - size[0] = l - } - } - return size -} - -func (b box) draw(term *terminal) { - chars := boxDefault - pos := b.pos - size := b.getSize() - w, h := size.x(), size.y() - - // draw top line - term.moveAbs(pos) - goterm.Print(chars[boxBorderTL]) - for i := 0; i < w; i++ { - goterm.Print(chars[boxBorderHoriz]) - } - goterm.Print(chars[boxBorderTR]) - - drawLine := func(line string) { - pos[1]++ - term.moveAbs(pos) - goterm.Print(chars[boxBorderVert]) - if len(line) > w { - line = line[:w] - } - goterm.Print(line) - if b.transparent { - term.moveAbs(pos.add(xy{w + 1, 0})) - } else { - goterm.Print(strings.Repeat(" ", w-len(line))) - } - goterm.Print(chars[boxBorderVert]) - } - - // truncate lines if necessary - lines := b.lines() - if len(lines) > h { - lines = lines[:h] - } - - // draw body - for _, line := range lines { - drawLine(line) - } - - // draw empty lines - for i := 0; i < h-len(lines); i++ { - drawLine("") - } - - // draw bottom line - pos[1]++ - term.moveAbs(pos) - goterm.Print(chars[boxBorderBL]) - for i := 0; i < w; i++ { - goterm.Print(chars[boxBorderHoriz]) - } - goterm.Print(chars[boxBorderBR]) -} - -//////////////////////////////////////////////////////////////////////////////// - -const ( - framerate = 30 + framerate = 10 frameperiod = time.Second / time.Duration(framerate) ) func debugf(str string, args ...interface{}) { + if !strings.HasSuffix(str, "\n") { + str += "\n" + } fmt.Fprintf(os.Stderr, str, args...) } +// TODO +// * Use actual gg graphs and not fake "boxes" +// - This will involve wrapping the vertices in some way, to preserve position +// * Once gg graphs are used we can use that birds-eye-view to make better +// decisions about edge placement + func main() { rand.Seed(time.Now().UnixNano()) - { // exit signal handling, cause ctrl-c doesn't work with goterm otherwise - c := make(chan os.Signal, 1) - signal.Notify(c, os.Interrupt) - go func() { - <-c - goterm.Clear() - goterm.Flush() - os.Stdout.Sync() - os.Exit(0) - }() - } - - term := new(terminal) + term := terminal.New() type movingBox struct { box @@ -223,10 +40,11 @@ func main() { } randBox := func() movingBox { - tsize := term.size() + tsize := term.WindowSize() return movingBox{ box: box{ - pos: xy{rand.Intn(tsize[0]), rand.Intn(tsize[1])}, + pos: geo.XY{rand.Intn(tsize[0]), rand.Intn(tsize[1])}, + size: geo.XY{30, 2}, }, xRight: rand.Intn(1) == 0, yDown: rand.Intn(1) == 0, @@ -242,23 +60,25 @@ func main() { } for range time.Tick(frameperiod) { - goterm.Clear() - termSize := term.size() - now := time.Now() + // update phase + termSize := term.WindowSize() for i := range boxes { b := &boxes[i] - b.body = fmt.Sprintf("%d\n%s", now.Unix(), now.String()) + b.body = fmt.Sprintf("%d) %v", i, b.rectCorner(geo.Left, geo.Up)) + b.body += fmt.Sprintf(" | %v\n", b.rectCorner(geo.Right, geo.Up)) + b.body += fmt.Sprintf(" %v", b.rectCorner(geo.Left, geo.Down)) + b.body += fmt.Sprintf(" | %v", b.rectCorner(geo.Right, geo.Down)) - size := b.getSize() + size := b.rectSize() if b.pos[0] <= 0 { b.xRight = true - } else if b.pos[0]+size[0]+2 > termSize[0] { + } else if b.pos[0]+size[0] >= termSize[0] { b.xRight = false } if b.pos[1] <= 0 { b.yDown = true - } else if b.pos[1]+size[1]+2 > termSize[1] { + } else if b.pos[1]+size[1] >= termSize[1] { b.yDown = false } @@ -272,9 +92,20 @@ func main() { } else { b.pos[1]-- } - - b.draw(term) } - goterm.Flush() + + // draw phase + term.Reset() + for i := range boxes { + boxes[i].draw(term) + } + term.Flush() + for i := range boxes { + if i == 0 { + continue + } + basicLine(term, boxes[i-1].box, boxes[i].box) + } + term.Flush() } } diff --git a/gim/terminal/terminal.go b/gim/terminal/terminal.go new file mode 100644 index 0000000..081dea7 --- /dev/null +++ b/gim/terminal/terminal.go @@ -0,0 +1,146 @@ +// Package terminal implements functionality related to interacting with a +// terminal. Using this package takes the place of using stdout directly +package terminal + +import ( + "bytes" + "fmt" + "io" + "os" + "syscall" + "unicode/utf8" + "unsafe" + + "github.com/mediocregopher/ginger/gim/geo" +) + +// Terminal provides an interface to a terminal which allows for "drawing" +// rather than just writing. Note that all operations on a Terminal aren't +// actually drawn to the screen until Flush is called. +// +// The coordinate system described by Terminal looks like this: +// +// 0,0 ------------------> x +// | +// | +// | +// | +// | +// | +// | +// | +// v +// y +// +type Terminal struct { + buf *bytes.Buffer + pos geo.XY + + // When initialized this will be set to os.Stdout, but can be set to + // anything + Out io.Writer +} + +// New initializes and returns a usable Terminal +func New() *Terminal { + return &Terminal{ + buf: new(bytes.Buffer), + Out: os.Stdout, + } +} + +// WindowSize returns the size of the terminal window (width/height) +// TODO this doesn't support winblows +func (t *Terminal) WindowSize() geo.XY { + var sz struct { + rows uint16 + cols uint16 + xpixels uint16 + ypixels uint16 + } + _, _, err := syscall.Syscall( + syscall.SYS_IOCTL, + uintptr(syscall.Stdin), + uintptr(syscall.TIOCGWINSZ), + uintptr(unsafe.Pointer(&sz)), + ) + if err != 0 { + panic(err.Error()) + } + return geo.XY{int(sz.cols), int(sz.rows)} +} + +// MoveCursorTo moves the cursor to the given position +func (t *Terminal) MoveCursorTo(to geo.XY) { + // actual terminal uses 1,1 as top-left, because 1-indexing is a great idea + fmt.Fprintf(t.buf, "\033[%d;%dH", to[1]+1, to[0]+1) + t.pos = to +} + +// MoveCursor moves the cursor relative to its current position by the given +// vector +func (t *Terminal) MoveCursor(by geo.XY) { + t.MoveCursorTo(t.pos.Add(by)) +} + +// Reset completely clears all drawn characters on the screen and returns the +// cursor to the origin +func (t *Terminal) Reset() { + fmt.Fprintf(t.buf, "\033[2J") +} + +// Printf prints the given formatted string to the terminal, updating the +// internal cursor position accordingly +func (t *Terminal) Printf(format string, args ...interface{}) { + str := fmt.Sprintf(format, args...) + t.buf.WriteString(str) + t.pos[0] += utf8.RuneCountInString(str) +} + +// Flush writes all buffered changes to the screen +func (t *Terminal) Flush() { + if _, err := io.Copy(t.Out, t.buf); err != nil { + panic(err) + } +} + +// TODO deal with these + +const ( + // Reset all custom styles + ansiReset = "\033[0m" + + // Reset to default color + ansiResetColor = "\033[32m" + + // Return curor to start of line and clean it + ansiResetLine = "\r\033[K" +) + +// List of possible colors +const ( + black = iota + red + green + yellow + blue + magenta + cyan + white +) + +func getFgColor(code int) string { + return fmt.Sprintf("\033[3%dm", code) +} + +func getBgColor(code int) string { + return fmt.Sprintf("\033[4%dm", code) +} + +func fgColor(str string, color int) string { + return fmt.Sprintf("%s%s%s", getFgColor(color), str, ansiReset) +} + +func bgColor(str string, color int) string { + return fmt.Sprintf("%s%s%s", getBgColor(color), str, ansiReset) +}