diff --git a/canvas/canvas.go b/canvas/canvas.go new file mode 100644 index 0000000..c5eb82f --- /dev/null +++ b/canvas/canvas.go @@ -0,0 +1,37 @@ +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 c.Value.Get("clientWidth").Int() +} + +// ClientH returns the client height. +func (c Canvas) ClientH() int { + return c.Value.Get("clientHeight").Int() +} diff --git a/canvas/draw.go b/canvas/draw.go new file mode 100644 index 0000000..fd8d8d2 --- /dev/null +++ b/canvas/draw.go @@ -0,0 +1,66 @@ +package canvas + +import ( + "syscall/js" + + "git.kirsle.net/apps/doodle/lib/render" +) + +// Methods here implement the drawing functions of the render.Engine + +// Clear the canvas to a certain color. +func (e *Engine) Clear(color render.Color) { + e.canvas.ctx2d.Set("fillStyle", color.ToHex()) + 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", color.ToHex()) + 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", color.ToHex()) + for pt := range render.IterLine2(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", color.ToHex()) + 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", color.ToHex()) + e.canvas.ctx2d.Call("fillRect", + int(rect.X), + int(rect.Y), + int(rect.W), + int(rect.H), + ) +} diff --git a/canvas/engine.go b/canvas/engine.go new file mode 100644 index 0000000..f3edd19 --- /dev/null +++ b/canvas/engine.go @@ -0,0 +1,77 @@ +package canvas + +import ( + "time" + + "git.kirsle.net/apps/doodle/lib/events" + "git.kirsle.net/apps/doodle/lib/render" +) + +// 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 +} + +// 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(), + } + + return engine, nil +} + +// WindowSize returns the size of the canvas window. +func (e *Engine) WindowSize() (w, h int) { + return e.canvas.ClientW(), e.canvas.ClientH() +} + +// GetTicks returns the current tick count. +func (e *Engine) GetTicks() uint32 { + return e.ticks +} + +// TO BE IMPLEMENTED... + +func (e *Engine) Setup() error { + return nil +} + +func (e *Engine) Present() error { + return nil +} + +func (e *Engine) NewBitmap(filename string) (render.Texturer, error) { + return nil, nil +} + +func (e *Engine) Copy(t render.Texturer, src, dist render.Rect) { + +} + +// 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 +} diff --git a/canvas/events.go b/canvas/events.go new file mode 100644 index 0000000..6c3b74c --- /dev/null +++ b/canvas/events.go @@ -0,0 +1,102 @@ +package canvas + +import ( + "syscall/js" + + "git.kirsle.net/apps/doodle/lib/events" + "git.kirsle.net/apps/doodle/pkg/log" +) + +// AddEventListeners sets up bindings to collect events from the browser. +func (e *Engine) AddEventListeners() { + s := e.events + + // 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() + ) + + s.CursorX.Push(int32(x)) + s.CursorY.Push(int32(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() + ) + + log.Info("Clicked at %d,%d", x, y) + + s.CursorX.Push(int32(x)) + s.CursorY.Push(int32(y)) + + // Is a mouse button pressed down? + checkDown := func(number int) bool { + if which == number { + return ev == "mousedown" + } + return false + } + + s.Button1.Push(checkDown(1)) + s.Button2.Push(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 + // js.Global().Get("document").Call( + // "addEventListener", + // "keydown", + // js.FuncOf(func(this js.Value, args []js.Value) interface{} { + // log.Info("key: %+v", args) + // var ( + // event = args[0] + // charCode = event.Get("charCode") + // key = event.Get("key").String() + // ) + // + // switch key { + // case "Enter": + // s.EnterKey.Push(true) + // // default: + // // s.KeyName.Push(key) + // } + // + // log.Info("keypress: code=%s key=%s", charCode, key) + // + // return nil + // }), + // ) +} + +// Poll for events. +func (e *Engine) Poll() (*events.State, error) { + return e.events, nil +} diff --git a/canvas/text.go b/canvas/text.go new file mode 100644 index 0000000..a4ce4aa --- /dev/null +++ b/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 +} diff --git a/color.go b/color.go index 699abb9..3ec3ac5 100644 --- a/color.go +++ b/color.go @@ -113,6 +113,14 @@ func (c Color) String() string { ) } +// 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{