drawing edges in gim, and split out some parts into their own packages

This commit is contained in:
Brian Picciano 2017-11-04 15:29:15 -06:00
parent b4bf8f6c5a
commit 11328fa76c
6 changed files with 635 additions and 208 deletions

157
gim/box.go Normal file
View File

@ -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])
}

28
gim/box_test.go Normal file
View File

@ -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))
}

87
gim/geo/geo.go Normal file
View File

@ -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),
}
}

178
gim/line.go Normal file
View File

@ -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)
}
}

View File

@ -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)
// draw phase
term.Reset()
for i := range boxes {
boxes[i].draw(term)
}
goterm.Flush()
term.Flush()
for i := range boxes {
if i == 0 {
continue
}
basicLine(term, boxes[i-1].box, boxes[i].box)
}
term.Flush()
}
}

146
gim/terminal/terminal.go Normal file
View File

@ -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)
}