Browse Source

Initial commit

master
Noah Petherbridge 2 years ago
commit
a92ceac058
  1. 39
      render/canvas/canvas.go
  2. 77
      render/canvas/draw.go
  3. 86
      render/canvas/engine.go
  4. 227
      render/canvas/events.go
  5. 88
      render/canvas/text.go
  6. 91
      render/canvas/texture.go
  7. 274
      render/color.go
  8. 64
      render/ellipse.go
  9. 108
      render/functions.go
  10. 196
      render/interface.go
  11. 109
      render/point.go
  12. 60
      render/point_test.go
  13. 81
      render/rect_test.go
  14. 57
      render/sdl/canvas.go
  15. 171
      render/sdl/events.go
  16. 120
      render/sdl/sdl.go
  17. 199
      render/sdl/text.go
  18. 84
      render/sdl/texture.go
  19. 26
      render/sdl/utils.go
  20. 105
      render/shapes.go

39
render/canvas/canvas.go

@ -0,0 +1,39 @@
package canvas
import (
"syscall/js"
)
// Canvas represents an HTML5 Canvas object.
type Canvas struct {
Value js.Value
ctx2d js.Value
}
// GetCanvas gets an HTML5 Canvas object from the DOM.
func GetCanvas(id string) Canvas {
canvasEl := js.Global().Get("document").Call("getElementById", id)
canvas2d := canvasEl.Call("getContext", "2d")
c := Canvas{
Value: canvasEl,
ctx2d: canvas2d,
}
canvasEl.Set("width", c.ClientW())
canvasEl.Set("height", c.ClientH())
return c
}
// ClientW returns the client width.
func (c Canvas) ClientW() int {
return js.Global().Get("window").Get("innerWidth").Int()
// return c.Value.Get("clientWidth").Int()
}
// ClientH returns the client height.
func (c Canvas) ClientH() int {
return js.Global().Get("window").Get("innerHeight").Int()
// return c.Value.Get("clientHeight").Int()
}

77
render/canvas/draw.go

@ -0,0 +1,77 @@
package canvas
import (
"fmt"
"syscall/js"
"git.kirsle.net/apps/doodle/lib/render"
)
// Methods here implement the drawing functions of the render.Engine
// RGBA turns a color into CSS RGBA string.
func RGBA(c render.Color) string {
return fmt.Sprintf("rgba(%d,%d,%d,%f)",
c.Red,
c.Green,
c.Blue,
float64(c.Alpha)/255,
)
}
// Clear the canvas to a certain color.
func (e *Engine) Clear(color render.Color) {
e.canvas.ctx2d.Set("fillStyle", RGBA(color))
e.canvas.ctx2d.Call("fillRect", 0, 0, e.width, e.height)
}
// SetTitle sets the window title.
func (e *Engine) SetTitle(title string) {
js.Global().Get("document").Set("title", title)
}
// DrawPoint draws a pixel.
func (e *Engine) DrawPoint(color render.Color, point render.Point) {
e.canvas.ctx2d.Set("fillStyle", RGBA(color))
e.canvas.ctx2d.Call("fillRect",
int(point.X),
int(point.Y),
1,
1,
)
}
// DrawLine draws a line between two points.
func (e *Engine) DrawLine(color render.Color, a, b render.Point) {
e.canvas.ctx2d.Set("fillStyle", RGBA(color))
for pt := range render.IterLine(a, b) {
e.canvas.ctx2d.Call("fillRect",
int(pt.X),
int(pt.Y),
1,
1,
)
}
}
// DrawRect draws a rectangle.
func (e *Engine) DrawRect(color render.Color, rect render.Rect) {
e.canvas.ctx2d.Set("strokeStyle", RGBA(color))
e.canvas.ctx2d.Call("strokeRect",
int(rect.X),
int(rect.Y),
int(rect.W),
int(rect.H),
)
}
// DrawBox draws a filled rectangle.
func (e *Engine) DrawBox(color render.Color, rect render.Rect) {
e.canvas.ctx2d.Set("fillStyle", RGBA(color))
e.canvas.ctx2d.Call("fillRect",
int(rect.X),
int(rect.Y),
int(rect.W),
int(rect.H),
)
}

86
render/canvas/engine.go

@ -0,0 +1,86 @@
package canvas
import (
"syscall/js"
"time"
"git.kirsle.net/apps/doodle/lib/events"
)
// Engine implements a rendering engine targeting an HTML canvas for
// WebAssembly targets.
type Engine struct {
canvas Canvas
startTime time.Time
width int
height int
ticks uint32
// Private fields.
events *events.State
running bool
textures map[string]*Texture // cached texture PNG images
// Event channel. WASM subscribes to events asynchronously using the
// JavaScript APIs, whereas SDL2 polls the event queue which orders them
// all up for processing. This channel will order and queue the events.
queue chan Event
}
// New creates the Canvas Engine.
func New(canvasID string) (*Engine, error) {
canvas := GetCanvas(canvasID)
engine := &Engine{
canvas: canvas,
startTime: time.Now(),
events: events.New(),
width: canvas.ClientW(),
height: canvas.ClientH(),
queue: make(chan Event, 1024),
textures: map[string]*Texture{},
}
return engine, nil
}
// WindowSize returns the size of the canvas window.
func (e *Engine) WindowSize() (w, h int) {
// Good time to recompute it first?
var (
window = js.Global().Get("window")
width = window.Get("innerWidth").Int()
height = window.Get("innerHeight").Int()
)
e.canvas.Value.Set("width", width)
e.canvas.Value.Set("height", height)
return e.canvas.ClientW(), e.canvas.ClientH()
}
// GetTicks returns the number of milliseconds since the engine started.
func (e *Engine) GetTicks() uint32 {
ms := time.Since(e.startTime) * time.Millisecond
return uint32(ms)
}
// TO BE IMPLEMENTED...
func (e *Engine) Setup() error {
return nil
}
func (e *Engine) Present() error {
return nil
}
// Delay for a moment.
func (e *Engine) Delay(delay uint32) {
time.Sleep(time.Duration(delay) * time.Millisecond)
}
// Teardown tasks.
func (e *Engine) Teardown() {}
func (e *Engine) Loop() error {
return nil
}

227
render/canvas/events.go

@ -0,0 +1,227 @@
package canvas
import (
"syscall/js"
"git.kirsle.net/apps/doodle/lib/events"
)
// EventClass to categorize JavaScript events.
type EventClass int
// EventClass values.
const (
MouseEvent EventClass = iota
ClickEvent
KeyEvent
ResizeEvent
WindowEvent
)
// Event object queues up asynchronous JavaScript events to be processed linearly.
type Event struct {
Name string // mouseup, keydown, etc.
Class EventClass
// Mouse events.
X int
Y int
LeftClick bool
RightClick bool
// Key events.
KeyName string
State bool
Repeat bool
}
// AddEventListeners sets up bindings to collect events from the browser.
func (e *Engine) AddEventListeners() {
// Window resize.
js.Global().Get("window").Call(
"addEventListener",
"resize",
js.FuncOf(func(this js.Value, args []js.Value) interface{} {
e.queue <- Event{
Name: "resize",
Class: WindowEvent,
}
return nil
}),
)
// Mouse movement.
e.canvas.Value.Call(
"addEventListener",
"mousemove",
js.FuncOf(func(this js.Value, args []js.Value) interface{} {
var (
x = args[0].Get("pageX").Int()
y = args[0].Get("pageY").Int()
)
e.queue <- Event{
Name: "mousemove",
Class: MouseEvent,
X: x,
Y: y,
}
return nil
}),
)
// Mouse clicks.
for _, ev := range []string{"mouseup", "mousedown"} {
ev := ev
e.canvas.Value.Call(
"addEventListener",
ev,
js.FuncOf(func(this js.Value, args []js.Value) interface{} {
var (
x = args[0].Get("pageX").Int()
y = args[0].Get("pageY").Int()
which = args[0].Get("which").Int()
)
// Is a mouse button pressed down?
checkDown := func(number int) bool {
if which == number {
return ev == "mousedown"
}
return false
}
e.queue <- Event{
Name: ev,
Class: ClickEvent,
X: x,
Y: y,
LeftClick: checkDown(1),
RightClick: checkDown(3),
}
return false
}),
)
}
// Supress context menu.
e.canvas.Value.Call(
"addEventListener",
"contextmenu",
js.FuncOf(func(this js.Value, args []js.Value) interface{} {
args[0].Call("preventDefault")
return false
}),
)
// Keyboard keys
for _, ev := range []string{"keydown", "keyup"} {
ev := ev
js.Global().Get("document").Call(
"addEventListener",
ev,
js.FuncOf(func(this js.Value, args []js.Value) interface{} {
var (
event = args[0]
key = event.Get("key").String()
repeat = event.Get("repeat").Bool()
pressed = ev == "keydown"
)
if key == "F3" {
args[0].Call("preventDefault")
}
e.queue <- Event{
Name: ev,
Class: KeyEvent,
KeyName: key,
Repeat: repeat,
State: pressed,
}
return nil
}),
)
}
}
// PollEvent returns the next event in the queue, or null.
func (e *Engine) PollEvent() *Event {
select {
case event := <-e.queue:
return &event
default:
return nil
}
return nil
}
// Poll for events.
func (e *Engine) Poll() (*events.State, error) {
s := e.events
for event := e.PollEvent(); event != nil; event = e.PollEvent() {
switch event.Class {
case WindowEvent:
s.Resized.Push(true)
case MouseEvent:
s.CursorX.Push(int32(event.X))
s.CursorY.Push(int32(event.Y))
case ClickEvent:
s.CursorX.Push(int32(event.X))
s.CursorY.Push(int32(event.Y))
s.Button1.Push(event.LeftClick)
s.Button2.Push(event.RightClick)
case KeyEvent:
switch event.KeyName {
case "Escape":
if event.Repeat {
continue
}
if event.State {
s.EscapeKey.Push(true)
}
case "Enter":
if event.Repeat {
continue
}
if event.State {
s.EnterKey.Push(true)
}
case "F3":
if event.State {
s.KeyName.Push("F3")
}
case "ArrowUp":
s.Up.Push(event.State)
case "ArrowLeft":
s.Left.Push(event.State)
case "ArrowRight":
s.Right.Push(event.State)
case "ArrowDown":
s.Down.Push(event.State)
case "Shift":
s.ShiftActive.Push(event.State)
continue
case "Alt":
case "Control":
continue
case "Backspace":
if event.State {
s.KeyName.Push(`\b`)
}
default:
if event.State {
s.KeyName.Push(event.KeyName)
} else {
s.KeyName.Push("")
}
}
}
}
return e.events, nil
}

88
render/canvas/text.go

@ -0,0 +1,88 @@
package canvas
// Text rendering functions using the HTML 5 canvas.
import (
"fmt"
"path/filepath"
"strings"
"git.kirsle.net/apps/doodle/lib/render"
)
// FontFilenameToName converts a FontFilename to its CSS font name.
//
// The CSS font name is set to the base of the filename, without the .ttf
// file extension. For example, "fonts/DejaVuSans.ttf" uses the CSS font
// family name "DejaVuSans" and that's what this function returns.
//
// Fonts must be defined in the index.html style sheet when serving the
// wasm build of Doodle.
//
// If filename is "", returns "serif" as a sensible default.
func FontFilenameToName(filename string) string {
if filename == "" {
return "DejaVuSans,serif"
}
return strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))
}
// DrawText draws text on the canvas.
func (e *Engine) DrawText(text render.Text, point render.Point) error {
font := FontFilenameToName(text.FontFilename)
e.canvas.ctx2d.Set("font",
fmt.Sprintf("%dpx %s,serif", text.Size, font),
)
e.canvas.ctx2d.Set("textBaseline", "top")
write := func(dx, dy int, color render.Color) {
e.canvas.ctx2d.Set("fillStyle", color.ToHex())
e.canvas.ctx2d.Call("fillText",
text.Text,
int(point.X)+dx,
int(point.Y)+dy,
)
}
// Does the text have a stroke around it?
if text.Stroke != render.Invisible {
e.canvas.ctx2d.Set("fillStyle", text.Stroke.ToHex())
write(-1, -1, text.Stroke)
write(-1, 0, text.Stroke)
write(-1, 1, text.Stroke)
write(1, -1, text.Stroke)
write(1, 0, text.Stroke)
write(1, 1, text.Stroke)
write(0, -1, text.Stroke)
write(0, 1, text.Stroke)
}
// Does it have a drop shadow?
if text.Shadow != render.Invisible {
write(1, 1, text.Shadow)
}
// Draw the text itself.
write(0, 0, text.Color)
return nil
}
// ComputeTextRect computes and returns a Rect for how large the text would
// appear if rendered.
func (e *Engine) ComputeTextRect(text render.Text) (render.Rect, error) {
font := FontFilenameToName(text.FontFilename)
e.canvas.ctx2d.Set("font",
fmt.Sprintf("%dpx %s,serif", text.Size, font),
)
measure := e.canvas.ctx2d.Call("measureText", text.Text)
rect := render.Rect{
// TODO: the only TextMetrics widely supported in browsers is
// the width. For height, use the text size for now.
W: int32(measure.Get("width").Int()),
H: int32(text.Size),
}
return rect, nil
}

91
render/canvas/texture.go

@ -0,0 +1,91 @@
package canvas
import (
"bytes"
"encoding/base64"
"errors"
"image"
"image/png"
"syscall/js"
"git.kirsle.net/apps/doodle/lib/render"
)
// Texture can hold on to cached image textures.
type Texture struct {
data string // data:image/png URI
image js.Value // DOM image element
canvas js.Value // Warmed up canvas element
ctx2d js.Value // 2D drawing context for the canvas.
width int
height int
}
// StoreTexture caches a texture from a bitmap.
func (e *Engine) StoreTexture(name string, img image.Image) (render.Texturer, error) {
var (
fh = bytes.NewBuffer([]byte{})
imageSize = img.Bounds().Size()
width = imageSize.X
height = imageSize.Y
)
// Encode to PNG format.
if err := png.Encode(fh, img); err != nil {
return nil, err
}
var dataURI = "data:image/png;base64," + base64.StdEncoding.EncodeToString(fh.Bytes())
tex := &Texture{
data: dataURI,
width: width,
height: height,
}
// Preheat a cached Canvas object.
canvas := js.Global().Get("document").Call("createElement", "canvas")
canvas.Set("width", width)
canvas.Set("height", height)
tex.canvas = canvas
ctx2d := canvas.Call("getContext", "2d")
tex.ctx2d = ctx2d
// Load as a JS Image object.
image := js.Global().Call("eval", "new Image()")
image.Call("addEventListener", "load", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
ctx2d.Call("drawImage", image, 0, 0)
return nil
}))
image.Set("src", tex.data)
tex.image = image
// Cache the texture in memory.
e.textures[name] = tex
return tex, nil
}
// Size returns the dimensions of the texture.
func (t *Texture) Size() render.Rect {
return render.NewRect(int32(t.width), int32(t.height))
}
// LoadTexture recalls a cached texture image.
func (e *Engine) LoadTexture(name string) (render.Texturer, error) {
if tex, ok := e.textures[name]; ok {
return tex, nil
}
return nil, errors.New("no bitmap data stored for " + name)
}
// Copy a texturer bitmap onto the canvas.
func (e *Engine) Copy(t render.Texturer, src, dist render.Rect) {
tex := t.(*Texture)
// e.canvas.ctx2d.Call("drawImage", tex.image, dist.X, dist.Y)
e.canvas.ctx2d.Call("drawImage", tex.canvas, dist.X, dist.Y)
}

274
render/color.go

@ -0,0 +1,274 @@
package render
import (
"encoding/json"
"errors"
"fmt"
"image/color"
"regexp"
"strconv"
"github.com/vmihailenco/msgpack"
)
var (
// Regexps to parse hex color codes. Three formats are supported:
// * reHexColor3 uses only 3 hex characters, like #F90
// * reHexColor6 uses standard 6 characters, like #FF9900
// * reHexColor8 is the standard 6 plus alpha channel, like #FF9900FF
reHexColor3 = regexp.MustCompile(`^([A-Fa-f0-9])([A-Fa-f0-9])([A-Fa-f0-9])$`)
reHexColor6 = regexp.MustCompile(`^([A-Fa-f0-9]{2})([A-Fa-f0-9]{2})([A-Fa-f0-9]{2})$`)
reHexColor8 = regexp.MustCompile(`^([A-Fa-f0-9]{2})([A-Fa-f0-9]{2})([A-Fa-f0-9]{2})([A-Fa-f0-9]{2})$`)
)
// Color holds an RGBA color value.
type Color struct {
Red uint8
Green uint8
Blue uint8
Alpha uint8
}
// RGBA creates a new Color.
func RGBA(r, g, b, a uint8) Color {
return Color{
Red: r,
Green: g,
Blue: b,
Alpha: a,
}
}
// FromColor creates a render.Color from a Go color.Color
func FromColor(from color.Color) Color {
// downscale a 16-bit color value to 8-bit. input range 0x0000..0xffff
downscale := func(in uint32) uint8 {
var scale = float64(in) / 0xffff
return uint8(scale * 0xff)
}
r, g, b, a := from.RGBA()
return RGBA(
downscale(r),
downscale(g),
downscale(b),
downscale(a),
)
}
// MustHexColor parses a color from hex code or panics.
func MustHexColor(hex string) Color {
color, err := HexColor(hex)
if err != nil {
panic(err)
}
return color
}
// HexColor parses a color from hexadecimal code.
func HexColor(hex string) (Color, error) {
c := Black // default color
if len(hex) > 0 && hex[0] == '#' {
hex = hex[1:]
}
var m []string
if len(hex) == 3 {
m = reHexColor3.FindStringSubmatch(hex)
} else if len(hex) == 6 {
m = reHexColor6.FindStringSubmatch(hex)
} else if len(hex) == 8 {
m = reHexColor8.FindStringSubmatch(hex)
} else {
return c, errors.New("not a valid length for color code; only 3, 6 and 8 supported")
}
// Any luck?
if m == nil {
return c, errors.New("not a valid hex color code")
}
// Parse the color values. 16=base, 8=bit size
red, _ := strconv.ParseUint(m[1], 16, 8)
green, _ := strconv.ParseUint(m[2], 16, 8)
blue, _ := strconv.ParseUint(m[3], 16, 8)
// Alpha channel available?
var alpha uint64 = 255
if len(m) == 5 {
alpha, _ = strconv.ParseUint(m[4], 16, 8)
}
c.Red = uint8(red)
c.Green = uint8(green)
c.Blue = uint8(blue)
c.Alpha = uint8(alpha)
return c, nil
}
func (c Color) String() string {
return fmt.Sprintf(
"Color<#%02x%02x%02x+%02x>",
c.Red, c.Green, c.Blue, c.Alpha,
)
}
// ToHex converts a render.Color to standard #RRGGBB hexadecimal format.
func (c Color) ToHex() string {
return fmt.Sprintf(
"#%02x%02x%02x",
c.Red, c.Green, c.Blue,
)
}
// ToColor converts a render.Color into a Go standard color.Color
func (c Color) ToColor() color.RGBA {
return color.RGBA{
R: c.Red,
G: c.Green,
B: c.Blue,
A: c.Alpha,
}
}
// Transparent returns whether the alpha channel is zeroed out and the pixel
// won't appear as anything when rendered.
func (c Color) Transparent() bool {
return c.Alpha == 0x00
}
// MarshalJSON serializes the Color for JSON.
func (c Color) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf(
`"#%02x%02x%02x"`,
c.Red, c.Green, c.Blue,
)), nil
}
// UnmarshalJSON reloads the Color from JSON.
func (c *Color) UnmarshalJSON(b []byte) error {
var hex string
err := json.Unmarshal(b, &hex)
if err != nil {
return err
}
parsed, err := HexColor(hex)
if err != nil {
return err
}
c.Red = parsed.Red
c.Blue = parsed.Blue
c.Green = parsed.Green
c.Alpha = parsed.Alpha
return nil
}
func (c Color) EncodeMsgpack(enc *msgpack.Encoder) error {
return enc.EncodeString(fmt.Sprintf(
`"#%02x%02x%02x"`,
c.Red, c.Green, c.Blue,
))
}
func (c Color) DecodeMsgpack(dec *msgpack.Decoder) error {
hex, err := dec.DecodeString()
if err != nil {
return fmt.Errorf("Color.DecodeMsgpack: %s", err)
}
parsed, err := HexColor(hex)
if err != nil {
return fmt.Errorf("Color.DecodeMsgpack: HexColor: %s", err)
}
c.Red = parsed.Red
c.Blue = parsed.Blue
c.Green = parsed.Green
c.Alpha = parsed.Alpha
return nil
}
// // MarshalMsgpack serializes the Color for msgpack.
// func (c Color) MarshalMsgpack() ([]byte, error) {
// data := []uint8{
// c.Red, c.Green, c.Blue, c.Alpha,
// }
// return msgpack.Marshal(data)
// }
//
// // UnmarshalMsgpack decodes a Color from msgpack format.
// func (c *Color) UnmarshalMsgpack(b []byte) error {
// var data []uint8
// if err := msgpack.Unmarshal(data, b); err != nil {
// return err
// }
// c.Red = 255
// c.Green = data[1]
// c.Blue = data[2]
// c.Alpha = data[3]
// return nil
// }
// IsZero returns if the color is all zeroes (invisible).
func (c Color) IsZero() bool {
return c.Red+c.Green+c.Blue+c.Alpha == 0
}
// Add a relative color value to the color.
func (c Color) Add(r, g, b, a int) Color {
var (
R = int(c.Red) + r
G = int(c.Green) + g
B = int(c.Blue) + b
A = int(c.Alpha) + a
)
cap8 := func(v int) uint8 {
if v > 255 {
v = 255
} else if v < 0 {
v = 0
}
return uint8(v)
}
return Color{
Red: cap8(R),
Green: cap8(G),
Blue: cap8(B),
Alpha: cap8(A),
}
}
// AddColor adds another Color to your Color.
func (c Color) AddColor(other Color) Color {
return c.Add(
int(other.Red),
int(other.Green),
int(other.Blue),
int(other.Alpha),
)
}
// Lighten a color value.
func (c Color) Lighten(v int) Color {
return c.Add(v, v, v, 0)
}
// Darken a color value.
func (c Color) Darken(v int) Color {
return c.Add(-v, -v, -v, 0)
}
// Transparentize adjusts the alpha value.
func (c Color) Transparentize(v int) Color {
return c.Add(0, 0, 0, v)
}
// SetAlpha sets the alpha value to a specific setting.
func (c Color) SetAlpha(v uint8) Color {
c.Alpha = v
return c
}

64
render/ellipse.go

@ -0,0 +1,64 @@
package render
// MidpointEllipse implements an ellipse plotting algorithm.
func MidpointEllipse(center, radius Point) chan Point {
yield := make(chan Point)
go func() {
var (
pos = NewPoint(radius.X, 0)
delta = NewPoint(
2*radius.Y*radius.Y*pos.X,
2*radius.X*radius.X*pos.Y,
)
err = radius.X*radius.X -
radius.Y*radius.Y*radius.X +
(radius.Y*radius.Y)/4
)
for delta.Y < delta.X {
yield <- NewPoint(center.X+pos.X, center.Y+pos.Y)
yield <- NewPoint(center.X+pos.X, center.Y-pos.Y)
yield <- NewPoint(center.X-pos.X, center.Y+pos.Y)
yield <- NewPoint(center.X-pos.X, center.Y-pos.Y)
pos.Y++
if err < 0 {
delta.Y += 2 * radius.X * radius.X
err += delta.Y + radius.X*radius.X
} else {
pos.X--
delta.Y += 2 * radius.X * radius.X
delta.X -= 2 * radius.Y * radius.Y
err += delta.Y - delta.X + radius.X*radius.X
}
}
err = radius.X*radius.X*(pos.Y*pos.Y+pos.Y) +
radius.Y*radius.Y*(pos.X-1)*(pos.X-1) -
radius.Y*radius.Y*radius.X*radius.X
for pos.X >= 0 {
yield <- NewPoint(center.X+pos.X, center.Y+pos.Y)
yield <- NewPoint(center.X+pos.X, center.Y-pos.Y)
yield <- NewPoint(center.X-pos.X, center.Y+pos.Y)
yield <- NewPoint(center.X-pos.X, center.Y-pos.Y)
pos.X--
if err > 0 {
delta.X -= 2 * radius.Y * radius.Y
err += radius.Y*radius.Y - delta.X
} else {
pos.Y++
delta.Y += 2 * radius.X * radius.X
delta.X -= 2 * radius.Y * radius.Y
err += delta.Y - delta.X + radius.Y*radius.Y
}
}
close(yield)
}()
return yield
}

108
render/functions.go

@ -0,0 +1,108 @@
package render
import (
"fmt"
"regexp"
"strconv"
)
var regexpResolution = regexp.MustCompile(`^(\d+)x(\d+)$`)
// ParseResolution turns a resolution string like "1024x768" and returns the
// width and height values.
func ParseResolution(resi string) (int, int, error) {
m := regexpResolution.FindStringSubmatch(resi)
if m == nil {
return 0, 0, fmt.Errorf("invalid resolution format, should be %s",
regexpResolution.String(),
)
}
width, err := strconv.Atoi(m[1])
if err != nil {
return 0, 0, err
}
height, err := strconv.Atoi(m[2])
if err != nil {
return 0, 0, err
}
return width, height, nil
}
// TrimBox helps with Engine.Copy() to trim a destination box so that it
// won't overflow with the parent container.
func TrimBox(src, dst *Rect, p Point, S Rect, thickness int32) {
// Constrain source width to not bigger than Canvas width.
if src.W > S.W {
src.W = S.W
}
if src.H > S.H {
src.H = S.H
}
// If the destination width will cause it to overflow the widget
// box, trim off the right edge of the destination rect.
//
// Keep in mind we're dealing with chunks here, and a chunk is
// a small part of the image. Example:
// - Canvas is 800x600 (S.W=800 S.H=600)
// - Chunk wants to render at 790,0 width 100,100 or whatever
// dst={790, 0, 100, 100}
// - Chunk box would exceed 800px width (X=790 + W=100 == 890)
// - Find the delta how much it exceeds as negative (800 - 890 == -90)
// - Lower the Source and Dest rects by that delta size so they
// stay proportional and don't scale or anything dumb.
if dst.X+src.W > p.X+S.W {
// NOTE: delta is a negative number,
// so it will subtract from the width.
delta := (p.X + S.W - thickness) - (dst.W + dst.X)
src.W += delta
dst.W += delta
}
if dst.Y+src.H > p.Y+S.H {
// NOTE: delta is a negative number
delta := (p.Y + S.H - thickness) - (dst.H + dst.Y)
src.H += delta
dst.H += delta
}
// The same for the top left edge, so the drawings don't overlap
// menu bars or left side toolbars.
// - Canvas was placed 80px from the left of the screen.
// Canvas.MoveTo(80, 0)
// - A texture wants to draw at 60, 0 which would cause it to
// overlap 20 pixels into the left toolbar. It needs to be cropped.
// - The delta is: p.X=80 - dst.X=60 == 20
// - Set destination X to p.X to constrain it there: 20
// - Subtract the delta from destination W so we don't scale it.
// - Add 20 to X of the source: the left edge of source is not visible
if dst.X < p.X {
// NOTE: delta is a positive number,
// so it will add to the destination coordinates.
delta := p.X - dst.X
dst.X = p.X + thickness
dst.W -= delta
src.X += delta
}
if dst.Y < p.Y {
delta := p.Y - dst.Y
dst.Y = p.Y + thickness
dst.H -= delta
src.Y += delta
}
// Trim the destination width so it doesn't overlap the Canvas border.
if dst.W >= S.W-thickness {
dst.W = S.W - thickness
}
}
// AbsInt32 returns the absolute value of an int32.
func AbsInt32(v int32) int32 {
if v < 0 {
return -v
}
return v
}

196
render/interface.go

@ -0,0 +1,196 @@
package render
import (
"fmt"
"image"
"git.kirsle.net/apps/doodle/lib/events"
)
// Engine is the interface for the rendering engine, keeping SDL-specific stuff
// far away from the core of Doodle.
type Engine interface {
Setup() error
// Poll for events like keypresses and mouse clicks.
Poll() (*events.State, error)
GetTicks() uint32
WindowSize() (w, h int)
// Present presents the current state to the screen.
Present() error
// Clear the full canvas and set this color.
Clear(Color)
SetTitle(string)
DrawPoint(Color, Point)
DrawLine(Color, Point, Point)
DrawRect(Color, Rect)
DrawBox(Color, Rect)
DrawText(Text, Point) error
ComputeTextRect(Text) (Rect, error)
// Texture caching.
StoreTexture(name string, img image.Image) (Texturer, error)
LoadTexture(name string) (Texturer, error)
Copy(t Texturer, src, dst Rect)
// Delay for a moment using the render engine's delay method,
// implemented by sdl.Delay(uint32)
Delay(uint32)
// Tasks that the Setup function should defer until tear-down.
Teardown()
Loop() error // maybe?
}
// Texturer is a stored image texture used by the rendering engine while
// abstracting away its inner workings.
type Texturer interface {
Size() Rect
}
// Rect has a coordinate and a width and height.
type Rect struct {
X int32
Y int32
W int32
H int32
}
// NewRect creates a rectangle of size `width` and `height`. The X,Y values
// are initialized to zero.
func NewRect(width, height int32) Rect {
return Rect{
W: width,
H: height,
}
}
func (r Rect) String() string {
return fmt.Sprintf("Rect<%d,%d,%d,%d>",
r.X, r.Y, r.W, r.H,
)
}
// Point returns the rectangle's X,Y values as a Point.
func (r Rect) Point() Point {
return Point{
X: r.X,
Y: r.Y,
}
}
// Bigger returns if the given rect is larger than the current one.
func (r Rect) Bigger(other Rect) bool {
// TODO: don't know why this is !
return !(other.X < r.X || // Lefter
other.Y < r.Y || // Higher
other.W > r.W || // Wider
other.H > r.H) // Taller
}
// Intersects with the other rectangle in any way.
func (r Rect) Intersects(other Rect) bool {
// Do a bidirectional compare.
compare := func(a, b Rect) bool {
var corners = []Point{
NewPoint(b.X, b.Y),
NewPoint(b.X, b.Y+b.H),
NewPoint(b.X+b.W, b.Y),
NewPoint(b.X+b.W, b.Y+b.H),
}
for _, pt := range corners {
if pt.Inside(a) {
return true
}
}
return false
}
return compare(r, other) || compare(other, r) || false
}
// IsZero returns if the Rect is uninitialized.
func (r Rect) IsZero() bool {
return r.X == 0 && r.Y == 0 && r.W == 0 && r.H == 0
}
// Add another rect.
func (r Rect) Add(other Rect) Rect {
return Rect{
X: r.X + other.X,
Y: r.Y + other.Y,
W: r.W + other.W,
H: r.H + other.H,
}
}
// Add a point to move the rect.
func (r Rect) AddPoint(other Point) Rect {
return Rect{
X: r.X + other.X,
Y: r.Y + other.Y,
W: r.W,
H: r.H,
}
}
// SubtractPoint is the inverse of AddPoint. Use this only if you need to invert
// the Point being added.
//
// This does r.X - other.X, r.Y - other.Y and keeps the width/height the same.
func (r Rect) SubtractPoint(other Point) Rect {
return Rect{
X: r.X - other.X,
Y: r.Y - other.Y,
W: r.W,
H: r.H,
}
}
// Text holds information for drawing text.
type Text struct {
Text string
Size int
Color Color
Padding int32
PadX int32
PadY int32
Stroke Color // Stroke color (if not zero)
Shadow Color // Drop shadow color (if not zero)
FontFilename string // Path to *.ttf file on disk
}
func (t Text) String() string {
return fmt.Sprintf(`Text<"%s" %dpx %s>`, t.Text, t.Size, t.Color)
}
// IsZero returns if the Text is the zero value.
func (t Text) IsZero() bool {
return t.Text == "" && t.Size == 0 && t.Color == Invisible && t.Padding == 0 && t.Stroke == Invisible && t.Shadow == Invisible
}
// Common color names.
var (
Invisible = Color{}
White = RGBA(255, 255, 255, 255)
Grey = RGBA(153, 153, 153, 255)
Black = RGBA(0, 0, 0, 255)
SkyBlue = RGBA(0, 153, 255, 255)
Blue = RGBA(0, 0, 255, 255)
DarkBlue = RGBA(0, 0, 153, 255)
Red = RGBA(255, 0, 0, 255)
DarkRed = RGBA(153, 0, 0, 255)
Green = RGBA(0, 255, 0, 255)
DarkGreen = RGBA(0, 153, 0, 255)
Cyan = RGBA(0, 255, 255, 255)
DarkCyan = RGBA(0, 153, 153, 255)
Yellow = RGBA(255, 255, 0, 255)
Orange = RGBA(255, 153, 0, 255)
DarkYellow = RGBA(153, 153, 0, 255)
Magenta = RGBA(255, 0, 255, 255)
Purple = RGBA(153, 0, 153, 255)
Pink = RGBA(255, 153, 255, 255)
)

109
render/point.go

@ -0,0 +1,109 @@
package render
import (
"fmt"
"strconv"
"strings"
)
// Point holds an X,Y coordinate value.
type Point struct {
X int32
Y int32
}
// Common points.
var (
Origin Point
)
// NewPoint makes a new Point at an X,Y coordinate.
func NewPoint(x, y int32) Point {
return Point{
X: x,
Y: y,
}
}
func (p Point) String() string {
return fmt.Sprintf("%d,%d", p.X, p.Y)
}
// ParsePoint to parse a point from its string representation.
func ParsePoint(v string) (Point, error) {
halves := strings.Split(v, ",")
if len(halves) != 2 {
return Point{}, fmt.Errorf("'%s': not a valid coordinate string", v)
}
x, errX := strconv.Atoi(halves[0])
y, errY := strconv.Atoi(halves[1])
if errX != nil || errY != nil {
return Point{}, fmt.Errorf("invalid coordinate string (X: %v; Y: %v)",
errX,
errY,
)
}
return Point{
X: int32(x),
Y: int32(y),
}, nil
}
// IsZero returns if the point is the zero value.
func (p Point) IsZero() bool {
return p.X == 0 && p.Y == 0
}
// Inside returns whether the Point falls inside the rect.
//
// NOTICE: the W and H are zero-relative, so a 100x100 box at coordinate
// X,Y would still have W,H of 100.
func (p Point) Inside(r Rect) bool {
var (
x1 = r.X
y1 = r.Y
x2 = r.X + r.W
y2 = r.Y + r.H
)
return ((p.X >= x1 && p.X <= x2) &&
(p.Y >= y1 && p.Y <= y2))
}
// Add (or subtract) the other point to your current point.
func (p *Point) Add(other Point) {
p.X += other.X
p.Y += other.Y
}
// Subtract the other point from your current point.
func (p *Point) Subtract(other Point) {
p.X -= other.X
p.Y -= other.Y
}
// MarshalText to convert the point into text so that a render.Point may be used
// as a map key and serialized to JSON.
func (p *Point) MarshalText() ([]byte, error) {
return []byte(fmt.Sprintf("%d,%d", p.X, p.Y)), nil
}
// UnmarshalText to restore it from text.
func (p *Point) UnmarshalText(b []byte) error {
halves := strings.Split(strings.Trim(string(b), `"`), ",")
if len(halves) != 2 {
return fmt.Errorf("'%s': not a valid coordinate string", b)
}
x, errX := strconv.Atoi(halves[0])
y, errY := strconv.Atoi(halves[1])
if errX != nil || errY != nil {
return fmt.Errorf("Point.UnmarshalJSON: Atoi errors (X=%s Y=%s)",
errX,
errY,
)
}
p.X = int32(x)
p.Y = int32(y)
return nil
}

60
render/point_test.go

@ -0,0 +1,60 @@
package render_test
import (
"strconv"
"testing"
"git.kirsle.net/apps/doodle/lib/render"
)
func TestPointInside(t *testing.T) {
type testCase struct {
rect render.Rect
p render.Point
shouldPass bool
}
tests := []testCase{
testCase{
rect: render.Rect{
X: 0,
Y: 0,
W: 500,
H: 500,
},
p: render.NewPoint(128, 256),
shouldPass: true,
},
testCase{
rect: render.Rect{
X: 100,
Y: 80,
W: 40,
H: 60,
},
p: render.NewPoint(128, 256),
shouldPass: false,
},
testCase{
// true values when debugging why Doodads weren't
// considered inside the viewport.
rect: render.Rect{
X: 0,
Y: -232,
H: 874,
W: 490,
},
p: render.NewPoint(509, 260),
shouldPass: false,
},
}
for _, test := range tests {
if test.p.Inside(test.rect) != test.shouldPass {
t.Errorf("Failed: %s inside %s should be %s",
test.p,
test.rect,
strconv.FormatBool(test.shouldPass),
)
}
}
}

81
render/rect_test.go

@ -0,0 +1,81 @@
package render_test
import (
"strconv"
"testing"
"git.kirsle.net/apps/doodle/lib/render"
)
func TestIntersection(t *testing.T) {
newRect := func(x, y, w, h int) render.Rect {
return render.Rect{
X: int32(x),
Y: int32(y),
W: int32(w),
H: int32(h),
}
}
type TestCase struct {
A render.Rect
B render.Rect
Expect bool
}
var tests = []TestCase{
{
A: newRect(0, 0, 1000, 1000),
B: newRect(200, 200, 100, 100),
Expect: true,
},
{
A: newRect(200, 200, 100, 100),
B: newRect(0, 0, 1000, 1000),
Expect: true,
},
{
A: newRect(0, 0, 100, 100),
B: newRect(100, 0, 100, 100),
Expect: true,
},
{
A: newRect(0, 0, 99, 99),
B: newRect(100, 0, 99, 99),
Expect: false,
},
{
// Real coords of a test doodad!
A: newRect(183, 256, 283, 356),
B: newRect(0, -232, 874, 490),
Expect: true,
},
{
A: newRect(183, 256, 283, 356),
B: newRect(0, -240, 874, 490),
Expect: false, // XXX: must be true
},
{
A: newRect(0, 30, 9, 62),
B: newRect(16, 0, 32, 64),
Expect: false,
},
{
A: newRect(0, 30, 11, 62),
B: newRect(7, 4, 17, 28),
Expect: false,
},
}
for _, test := range tests {
actual := test.A.Intersects(test.B)
if actual != test.Expect {
t.Errorf(
"%s collision with %s: expected %s, got %s",
test.A,
test.B,
strconv.FormatBool(test.Expect),
strconv.FormatBool(actual),
)
}
}
}

57
render/sdl/canvas.go

@ -0,0 +1,57 @@
// Package sdl provides an SDL2 renderer for Doodle.
package sdl
import (
"git.kirsle.net/apps/doodle/lib/render"
"github.com/veandco/go-sdl2/sdl"
)
// Clear the canvas and set this color.
func (r *Renderer) Clear(color render.Color) {
if color != r.lastColor {
r.renderer.SetDrawColor(color.Red, color.Green, color.Blue, color.Alpha)
}
r.renderer.Clear()
}
// DrawPoint puts a color at a pixel.
func (r *Renderer) DrawPoint(color render.Color, point render.Point) {
if color != r.lastColor {
r.renderer.SetDrawColor(color.Red, color.Green, color.Blue, color.Alpha)
}
r.renderer.DrawPoint(point.X, point.Y)
}
// DrawLine draws a line between two points.
func (r *Renderer) DrawLine(color render.Color, a, b render.Point) {
if color != r.lastColor {
r.renderer.SetDrawColor(color.Red, color.Green, color.Blue, color.Alpha)
}
r.renderer.DrawLine(a.X, a.Y, b.X, b.Y)
}
// DrawRect draws a rectangle.
func (r *Renderer) DrawRect(color render.Color, rect render.Rect) {
if color != r.lastColor {
r.renderer.SetDrawColor(color.Red, color.Green, color.Blue, color.Alpha)
}
r.renderer.DrawRect(&sdl.Rect{
X: rect.X,
Y: rect.Y,
W: rect.W,
H: rect.H,
})
}
// DrawBox draws a filled rectangle.
func (r *Renderer) DrawBox(color render.Color, rect render.Rect) {
if color != r.lastColor {
r.renderer.SetDrawColor(color.Red, color.Green, color.Blue, color.Alpha)
}
r.renderer.FillRect(&sdl.Rect{
X: rect.X,
Y: rect.Y,
W: rect.W,
H: rect.H,
})
}

171
render/sdl/events.go

@ -0,0 +1,171 @@
package sdl
import (
"errors"
"fmt"
"git.kirsle.net/apps/doodle/lib/events"
"github.com/veandco/go-sdl2/sdl"
)
// Debug certain SDL events
var (
DebugWindowEvents = false
DebugMouseEvents = false
DebugClickEvents = false
DebugKeyEvents = false
)
// Poll for events.
func (r *Renderer) Poll() (*events.State, error) {
s := r.events
// helper function to push keyboard key names on keyDown events only.
pushKey := func(name string, state uint8) {
if state == 1 {
s.KeyName.Push(name)
}
}
for event := sdl.PollEvent(); event != nil; event = sdl.PollEvent() {
switch t := event.(type) {
case *sdl.QuitEvent:
return s, errors.New("quit")
case *sdl.WindowEvent:
if DebugWindowEvents {
if t.Event == sdl.WINDOWEVENT_RESIZED {
fmt.Printf("[%d ms] tick:%d Window Resized to %dx%d",
t.Timestamp,
r.ticks,
t.Data1,
t.Data2,
)
}
}
s.Resized.Push(true)
case *sdl.MouseMotionEvent:
if DebugMouseEvents {
fmt.Printf("[%d ms] tick:%d MouseMotion type:%d id:%d x:%d y:%d xrel:%d yrel:%d",
t.Timestamp, r.ticks, t.Type, t.Which, t.X, t.Y, t.XRel, t.YRel,
)
}
// Push the cursor position.
s.CursorX.Push(t.X)
s.CursorY.Push(t.Y)
s.Button1.Push(t.State ==