Browse Source

Initial commit

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

+ 39
- 0
render/canvas/canvas.go View File

@@ -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
- 0
render/canvas/draw.go View File

@@ -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
- 0
render/canvas/engine.go View File

@@ -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
- 0
render/canvas/events.go View File

@@ -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
- 0
render/canvas/text.go View File

@@ -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
- 0
render/canvas/texture.go View File

@@ -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
- 0
render/color.go View File

@@ -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
- 0
render/ellipse.go View File

@@ -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
- 0
render/functions.go View File

@@ -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
- 0
render/interface.go View File

@@ -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
- 0
render/point.go View File

@@ -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
- 0
render/point_test.go View File

@@ -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
- 0
render/rect_test.go View File

@@ -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
- 0
render/sdl/canvas.go View File

@@ -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
- 0
render/sdl/events.go View File

@@ -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 == 1)
case *sdl.MouseButtonEvent:
if DebugClickEvents {
fmt.Printf("[%d ms] tick:%d MouseButton type:%d id:%d x:%d y:%d button:%d state:%d",
t.Timestamp, r.ticks, t.Type, t.Which, t.X, t.Y, t.Button, t.State,
)
}

// Push the cursor position.
s.CursorX.Push(t.X)
s.CursorY.Push(t.Y)

// Is a mouse button pressed down?
checkDown := func(number uint8, target *events.BoolTick) bool {
if t.Button == number {
var eventName string
if t.State == 1 && target.Now == false {
eventName = "DOWN"
} else if t.State == 0 && target.Now == true {
eventName = "UP"
}

if eventName != "" {
target.Push(eventName == "DOWN")
}
return true
}
return false
}

if checkDown(1, s.Button1) || checkDown(3, s.Button2) || checkDown(2, s.Button3) {
// Return the event immediately.
return s, nil
}
case *sdl.MouseWheelEvent:
if DebugMouseEvents {
fmt.Printf("[%d ms] tick:%d MouseWheel type:%d id:%d x:%d y:%d",
t.Timestamp, r.ticks, t.Type, t.Which, t.X, t.Y,
)
}
case *sdl.KeyboardEvent:
if DebugKeyEvents {
fmt.Printf("[%d ms] tick:%d Keyboard type:%d sym:%c modifiers:%d state:%d repeat:%d\n",
t.Timestamp, r.ticks, t.Type, t.Keysym.Sym, t.Keysym.Mod, t.State, t.Repeat,
)
}

switch t.Keysym.Scancode {
case sdl.SCANCODE_ESCAPE:
if t.Repeat == 1 {
continue
}
s.EscapeKey.Push(t.State == 1)
case sdl.SCANCODE_RETURN:
if t.Repeat == 1 {
continue
}
s.EnterKey.Push(t.State == 1)
case sdl.SCANCODE_F1:
pushKey("F1", t.State)
case sdl.SCANCODE_F2:
pushKey("F2", t.State)
case sdl.SCANCODE_F3:
pushKey("F3", t.State)
case sdl.SCANCODE_F4:
pushKey("F4", t.State)
case sdl.SCANCODE_F5:
pushKey("F5", t.State)
case sdl.SCANCODE_F6:
pushKey("F6", t.State)
case sdl.SCANCODE_F7:
pushKey("F7", t.State)
case sdl.SCANCODE_F8:
pushKey("F8", t.State)
case sdl.SCANCODE_F9:
pushKey("F9", t.State)
case sdl.SCANCODE_F10:
pushKey("F10", t.State)
case sdl.SCANCODE_F11:
pushKey("F11", t.State)
case sdl.SCANCODE_F12:
pushKey("F12", t.State)
case sdl.SCANCODE_UP:
s.Up.Push(t.State == 1)
case sdl.SCANCODE_LEFT:
s.Left.Push(t.State == 1)
case sdl.SCANCODE_RIGHT:
s.Right.Push(t.State == 1)
case sdl.SCANCODE_DOWN:
s.Down.Push(t.State == 1)
case sdl.SCANCODE_LSHIFT:
case sdl.SCANCODE_RSHIFT:
s.ShiftActive.Push(t.State == 1)
case sdl.SCANCODE_LALT:
case sdl.SCANCODE_RALT:
continue
case sdl.SCANCODE_LCTRL:
s.ControlActive.Push(t.State == 1)
case sdl.SCANCODE_RCTRL:
s.ControlActive.Push(t.State == 1)
case sdl.SCANCODE_BACKSPACE:
// Make it a key event with "\b" as the sequence.
if t.State == 1 || t.Repeat == 1 {
s.KeyName.Push(`\b`)
}
default:
// Push the string value of the key.
if t.State == 1 {
s.KeyName.Push(string(t.Keysym.Sym))
}
}
}
}

return s, nil
}

+ 120
- 0
render/sdl/sdl.go View File

@@ -0,0 +1,120 @@
// Package sdl provides an SDL2 renderer for Doodle.
package sdl

import (
"fmt"
"time"

"git.kirsle.net/apps/doodle/lib/events"
"git.kirsle.net/apps/doodle/lib/render"
"github.com/veandco/go-sdl2/sdl"
"github.com/veandco/go-sdl2/ttf"
)

// Renderer manages the SDL state.
type Renderer struct {
// Configurable fields.
title string
width int32
height int32
startTime time.Time

// Private fields.
events *events.State
window *sdl.Window
renderer *sdl.Renderer
running bool
ticks uint64
textures map[string]*Texture // cached textures

// Optimizations to minimize SDL calls.
lastColor render.Color
}

// New creates the SDL renderer.
func New(title string, width, height int) *Renderer {
return &Renderer{
events: events.New(),
title: title,
width: int32(width),
height: int32(height),
textures: map[string]*Texture{},
}
}

// Teardown tasks when exiting the program.
func (r *Renderer) Teardown() {
r.renderer.Destroy()
r.window.Destroy()
sdl.Quit()
}

// Setup the renderer.
func (r *Renderer) Setup() error {
// Initialize SDL.
if err := sdl.Init(sdl.INIT_EVERYTHING); err != nil {
return fmt.Errorf("sdl.Init: %s", err)
}

// Initialize SDL_TTF.
if err := ttf.Init(); err != nil {
return fmt.Errorf("ttf.Init: %s", err)
}

// Create our window.
window, err := sdl.CreateWindow(
r.title,
sdl.WINDOWPOS_CENTERED,
sdl.WINDOWPOS_CENTERED,
r.width,
r.height,
sdl.WINDOW_SHOWN|sdl.WINDOW_RESIZABLE,
)
if err != nil {
return err
}
r.window = window

// Blank out the window in white.
renderer, err := sdl.CreateRenderer(window, -1, sdl.RENDERER_ACCELERATED)
if err != nil {
panic(err)
}
renderer.SetDrawBlendMode(sdl.BLENDMODE_BLEND)
r.renderer = renderer

return nil
}

// SetTitle sets the SDL window title.
func (r *Renderer) SetTitle(title string) {
r.title = title
r.window.SetTitle(title)
}

// GetTicks gets SDL's current tick count.
func (r *Renderer) GetTicks() uint32 {
return sdl.GetTicks()
}

// WindowSize returns the SDL window size.
func (r *Renderer) WindowSize() (int, int) {
w, h := r.window.GetSize()
return int(w), int(h)
}

// Present the current frame.
func (r *Renderer) Present() error {
r.renderer.Present()
return nil
}

// Delay using sdl.Delay
func (r *Renderer) Delay(time uint32) {
sdl.Delay(time)
}

// Loop is the main loop.
func (r *Renderer) Loop() error {
return nil
}

+ 199
- 0
render/sdl/text.go View File

@@ -0,0 +1,199 @@
package sdl

import (
"fmt"
"strings"
"sync"

"git.kirsle.net/apps/doodle/lib/events"
"git.kirsle.net/apps/doodle/lib/render"
"github.com/veandco/go-sdl2/sdl"
"github.com/veandco/go-sdl2/ttf"
)

// TODO: font filenames
var defaultFontFilename = "DejaVuSans.ttf"

// Font holds cached SDL_TTF structures for loaded fonts. They are created
// automatically when fonts are either preinstalled (InstallFont) or loaded for
// the first time as demanded by the DrawText method.
type Font struct {
Filename string
data []byte // raw binary data of font
ttf *ttf.Font
}

var (
fonts = map[string]*ttf.Font{} // keys like "DejaVuSans@14" by font size
installedFont = map[string][]byte{} // installed font files' binary handles
fontsMu sync.RWMutex
)

// InstallFont preloads the font cache using TTF binary data in memory.
func InstallFont(filename string, binary []byte) {
fontsMu.Lock()
installedFont[filename] = binary
fontsMu.Unlock()
}

// LoadFont loads and caches the font at a given size.
func LoadFont(filename string, size int) (*ttf.Font, error) {
if filename == "" {
filename = defaultFontFilename
}

// Cached font available?
keyName := fmt.Sprintf("%s@%d", filename, size)
if font, ok := fonts[keyName]; ok {
return font, nil
}

// Do we have this font in memory?
var (
font *ttf.Font
err error
)

if binary, ok := installedFont[filename]; ok {
var RWops *sdl.RWops
RWops, err = sdl.RWFromMem(binary)
if err != nil {
return nil, fmt.Errorf("LoadFont(%s): RWFromMem: %s", filename, err)
}

font, err = ttf.OpenFontRW(RWops, 0, size)
} else {
font, err = ttf.OpenFont(filename, size)
}

// Error opening the font?
if err != nil {
return nil, fmt.Errorf("LoadFont(%s): %s", filename, err)
}

// Cache this font name and size.
fonts[keyName] = font

return font, nil
}

// Keysym returns the current key pressed, taking into account the Shift
// key modifier.
func (r *Renderer) Keysym(ev *events.State) string {
if key := ev.KeyName.Read(); key != "" {
if ev.ShiftActive.Pressed() {
if symbol, ok := shiftMap[key]; ok {
return symbol
}
return strings.ToUpper(key)
}
}
return ""
}

// ComputeTextRect computes and returns a Rect for how large the text would
// appear if rendered.
func (r *Renderer) ComputeTextRect(text render.Text) (render.Rect, error) {
var (
rect render.Rect
font *ttf.Font
surface *sdl.Surface
color = ColorToSDL(text.Color)
err error
)

if font, err = LoadFont(text.FontFilename, text.Size); err != nil {
return rect, err
}

if surface, err = font.RenderUTF8Blended(text.Text, color); err != nil {
return rect, err
}
defer surface.Free()

rect.W = surface.W
rect.H = surface.H
return rect, err
}

// DrawText draws text on the canvas.
func (r *Renderer) DrawText(text render.Text, point render.Point) error {
var (
font *ttf.Font
surface *sdl.Surface
tex *sdl.Texture
err error
)

if font, err = LoadFont(text.FontFilename, text.Size); err != nil {
return err
}

write := func(dx, dy int32, color sdl.Color) {
if surface, err = font.RenderUTF8Blended(text.Text, color); err != nil {
return
}
defer surface.Free()

if tex, err = r.renderer.CreateTextureFromSurface(surface); err != nil {
return
}
defer tex.Destroy()

tmp := &sdl.Rect{
X: point.X + dx,
Y: point.Y + dy,
W: surface.W,
H: surface.H,
}
r.renderer.Copy(tex, nil, tmp)
}

// Does the text have a stroke around it?
if text.Stroke != render.Invisible {
color := ColorToSDL(text.Stroke)
write(-1, -1, color)
write(-1, 0, color)
write(-1, 1, color)
write(1, -1, color)
write(1, 0, color)
write(1, 1, color)
write(0, -1, color)
write(0, 1, color)
}

// Does it have a drop shadow?
if text.Shadow != render.Invisible {
write(1, 1, ColorToSDL(text.Shadow))
}

// Draw the text itself.
write(0, 0, ColorToSDL(text.Color))

return err
}

// shiftMap maps keys to their Shift versions.
var shiftMap = map[string]string{
"`": "~",
"1": "!",
"2": "@",
"3": "#",
"4": "$",
"5": "%",
"6": "^",
"7": "&",
"8": "*",
"9": "(",
"0": ")",
"-": "_",
"=": "+",
"[": "{",
"]": "}",
`\`: "|",
";": ":",
`'`: `"`,
",": "<",
".": ">",
"/": "?",
}

+ 84
- 0
render/sdl/texture.go View File

@@ -0,0 +1,84 @@
package sdl

import (
"bytes"
"fmt"
"image"

"git.kirsle.net/apps/doodle/lib/render"
"github.com/veandco/go-sdl2/sdl"
"golang.org/x/image/bmp"
)

// Copy a texture into the renderer.
func (r *Renderer) Copy(t render.Texturer, src, dst render.Rect) {
if tex, ok := t.(*Texture); ok {
var (
a = RectToSDL(src)
b = RectToSDL(dst)
)
r.renderer.Copy(tex.tex, &a, &b)
}
}

// Texture can hold on to SDL textures for caching and optimization.
type Texture struct {
tex *sdl.Texture
width int32
height int32
}

// StoreTexture caches an SDL texture from a bitmap.
func (r *Renderer) StoreTexture(name string, img image.Image) (render.Texturer, error) {
var (
fh = bytes.NewBuffer([]byte{})
)

err := bmp.Encode(fh, img)
if err != nil {
return nil, fmt.Errorf("NewTexture: bmp.Encode: %s", err)
}

// Create an SDL RWOps from the bitmap data in memory.
sdlRW, err := sdl.RWFromMem(fh.Bytes())
if err != nil {
return nil, fmt.Errorf("NewTexture: sdl.RWFromMem: %s", err)
}

surface, err := sdl.LoadBMPRW(sdlRW, true)
if err != nil {
return nil, fmt.Errorf("NewTexture: sdl.LoadBMPRW: %s", err)
}
defer surface.Free()

// TODO: chroma key color hardcoded to white here
key := sdl.MapRGB(surface.Format, 255, 255, 255)
surface.SetColorKey(true, key)

texture, err := r.renderer.CreateTextureFromSurface(surface)
if err != nil {
return nil, fmt.Errorf("NewBitmap: create texture: %s", err)
}

tex := &Texture{
width: surface.W,
height: surface.H,
tex: texture,
}
r.textures[name] = tex

return tex, nil
}

// Size returns the dimensions of the texture.
func (t *Texture) Size() render.Rect {
return render.NewRect(t.width, t.height)
}

// LoadTexture initializes a texture from a bitmap image.
func (r *Renderer) LoadTexture(name string) (render.Texturer, error) {
if tex, ok := r.textures[name]; ok {
return tex, nil
}
return nil, fmt.Errorf("LoadTexture(%s): not found in texture cache", name)
}

+ 26
- 0
render/sdl/utils.go View File

@@ -0,0 +1,26 @@
package sdl

import (
"git.kirsle.net/apps/doodle/lib/render"
"github.com/veandco/go-sdl2/sdl"
)

// ColorToSDL converts Doodle's Color type to an sdl.Color.
func ColorToSDL(c render.Color) sdl.Color {
return sdl.Color{
R: c.Red,
G: c.Green,
B: c.Blue,
A: c.Alpha,
}
}

// RectToSDL converts Doodle's Rect type to an sdl.Rect.
func RectToSDL(r render.Rect) sdl.Rect {
return sdl.Rect{
X: r.X,
Y: r.Y,
W: r.W,
H: r.H,
}
}

+ 105
- 0
render/shapes.go View File

@@ -0,0 +1,105 @@
package render

import (
"math"
)

// IterLine is a generator that returns the X,Y coordinates to draw a line.
// https://en.wikipedia.org/wiki/Digital_differential_analyzer_(graphics_algorithm)
func IterLine(p1 Point, p2 Point) chan Point {
var (
x1 = p1.X
y1 =