Initial commit

This commit is contained in:
Hugo Mårdbrink 2025-11-30 15:44:22 +01:00
commit a6272848f9
379 changed files with 74829 additions and 0 deletions

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Eli Adelhult
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,34 @@
# Paint
**Make 2D drawings, animations, and games using Gleam and the HTML Canvas!**
[![Package Version](https://img.shields.io/hexpm/v/paint)](https://hex.pm/packages/paint)
[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/paint/)
Paint is a tiny embedded domain specific language (inspired by [Gloss](https://hackage.haskell.org/package/gloss)).
Make pictures out of basic shapes then style, transform, and combine them using the provided functions.
![Frame 3(2)](https://github.com/user-attachments/assets/a8b83b58-990a-432a-9034-deebc4d210a6)
```gleam
import paint as p
import paint/canvas
fn main() {
let my_picture = p.combine([
p.circle(30.0),
p.circle(20.0) |> p.fill(p.colour_rgb(0, 200, 200)),
p.rectangle(50.0, 30.0) |> p.rotate(p.angle_deg(30.0)),
p.text("Hello world", 10) |> p.translate_y(-35.0),
])
canvas.display(fn(_: canvas.Config) { my_picture }, "#canvas_id")
}
```
**Want to learn more? Read the [docs](https://hexdocs.pm/paint) or browse the [visual examples](https://adelhult.github.io/paint/).**
## Logo
Lucy is borrowed from the [Gleam branding page](https://gleam.run/branding/) and the brush is made by [Delapouite (game icons)](https://game-icons.net/1x1/delapouite/paint-brush.html).
## Changelog
API additions and breaking changes can be found in the file `CHANGELOG.md`.

View file

@ -0,0 +1,23 @@
name = "paint"
version = "1.0.0"
target = "javascript"
# Fill out these fields if you intend to generate HTML documentation or publish
# your project to the Hex package manager.
#
description = "Make 2D drawings, animations, and games (HTML Canvas)"
licences = ["MIT"]
repository = { type = "github", user = "adelhult", repo = "paint" }
links = [
{ title = "Visual examples", href = "https://adelhult.github.io/paint/" },
]
dev-dependencies = { gleeunit = ">= 1.6.1 and < 2.0.0" }
#
# For a full reference of all the available options, you can have a look at
# https://gleam.run/writing-gleam/gleam-toml/.
[dependencies]
gleam_stdlib = ">= 0.58.0 and < 2.0.0"
gleam_community_colour = ">= 2.0.0 and < 3.0.0"
gleam_json = ">= 3.0.2 and < 4.0.0"

View file

@ -0,0 +1,271 @@
import { Ok, Error } from "./gleam.mjs";
class PaintCanvas extends HTMLElement {
// Open an issue if you are in need of any other attributes :)
static observedAttributes = ["width", "height", "style", "picture"];
constructor() {
super();
// Create a canvas
this.canvas = document.createElement("canvas");
const style = document.createElement("style");
style.textContent = `
:host {
display: inline-block;
}
`;
this.shadow = this.attachShadow({ mode: "open" });
this.shadow.appendChild(style);
this.shadow.appendChild(this.canvas);
this.ctx = this.canvas.getContext("2d");
}
attributeChangedCallback(name, _oldValue, newValue) {
if (name === "picture") {
this.picture = newValue;
return;
} else if (name === "width") {
this.width = newValue;
} else if (name === "height") {
this.height = newValue;
}
}
drawPicture() {
if (!this.pictureString) {
return;
}
this.ctx.reset();
const display =
window.PAINT_STATE[
"display_on_rendering_context_with_default_drawing_state"
];
display(this.pictureString, this.ctx);
}
set picture(value) {
this.pictureString = value;
this.drawPicture();
}
set width(value) {
this.canvas.width = value;
this.drawPicture();
}
set height(value) {
this.canvas.height = value;
this.drawPicture();
}
get width() {
return this.canvas.width;
}
get height() {
return this.canvas.height;
}
}
export function define_web_component() {
window.customElements.define("paint-canvas", PaintCanvas);
}
export function get_rendering_context(selector) {
// TODO: Handle the case where the canvas element is not found.
return document.querySelector(selector).getContext("2d");
}
export function setup_request_animation_frame(callback) {
window.requestAnimationFrame((time) => {
callback(time);
});
}
export function setup_input_handler(event_name, callback) {
window.addEventListener(event_name, callback);
}
export function get_key_code(event) {
return event.keyCode;
}
export function set_global(state, id) {
if (typeof window.PAINT_STATE == "undefined") {
window.PAINT_STATE = {};
}
window.PAINT_STATE[id] = state;
}
export function get_global(id) {
if (!window.PAINT_STATE) {
return new Error(undefined);
}
if (!(id in window.PAINT_STATE)) {
return new Error(undefined);
}
return new Ok(window.PAINT_STATE[id]);
}
export function get_width(ctx) {
return ctx.canvas.clientWidth;
}
export function get_height(ctx) {
return ctx.canvas.clientHeight;
}
// Based on https://stackoverflow.com/questions/17130395/real-mouse-position-in-canvas
export function mouse_pos(ctx, event) {
// Calculate the scaling of the canvas vs its content
const rect = ctx.canvas.getBoundingClientRect();
const scaleX = ctx.canvas.width / rect.width;
const scaleY = ctx.canvas.height / rect.height;
return [
(event.clientX - rect.left) * scaleX,
(event.clientY - rect.top) * scaleY,
];
}
// if check_pressed is true, the function will return true if the button was pressed
// if check_pressed is false, the function will return true if the button was released
export function check_mouse_button(
event,
previous_event,
button_index,
check_pressed,
) {
let previous_buttons = previous_event?.buttons ?? 0;
let current_buttons = event.buttons;
// ~001 &&
// 011
// -----
// 010 found the newly pressed!
//
// 011 &&
// ~001
// -----
// 010 found the newly released!
if (check_pressed) {
previous_buttons = ~previous_buttons;
} else {
current_buttons = ~current_buttons;
}
let button = previous_buttons & current_buttons & (1 << button_index);
return !!button;
}
export function reset(ctx) {
ctx.reset();
}
export function arc(ctx, radius, start, end, fill, stroke) {
ctx.beginPath();
ctx.arc(0, 0, radius, start, end);
if (fill) {
ctx.fill();
}
if (stroke) {
ctx.stroke();
}
}
export function polygon(ctx, points, closed, fill, stroke) {
ctx.beginPath();
ctx.moveTo(0, 0);
let started = false;
for (const point of points) {
let x = point[0];
let y = point[1];
if (started) {
ctx.lineTo(x, y);
} else {
ctx.moveTo(x, y);
started = true;
}
}
if (closed) {
ctx.closePath();
}
if (fill && closed) {
ctx.fill();
}
if (stroke) {
ctx.stroke();
}
}
export function text(ctx, text, style) {
ctx.font = style;
ctx.fillText(text, 0, 0);
}
export function save(ctx) {
ctx.save();
}
export function restore(ctx) {
ctx.restore();
}
export function set_fill_colour(ctx, css_colour) {
ctx.fillStyle = css_colour;
}
export function set_stroke_color(ctx, css_color) {
ctx.strokeStyle = css_color;
}
export function set_line_width(ctx, width) {
ctx.lineWidth = width;
}
export function translate(ctx, x, y) {
ctx.translate(x, y);
}
export function scale(ctx, x, y) {
ctx.scale(x, y);
}
export function rotate(ctx, radians) {
ctx.rotate(radians);
}
export function reset_transform(ctx) {
ctx.resetTransform();
}
export function draw_image(ctx, image, width_px, height_px) {
ctx.drawImage(image, 0, 0, width_px, height_px);
}
export function image_from_query(selector) {
return document.querySelector(selector);
}
export function image_from_src(src) {
const image = new Image();
image.src = src;
return image;
}
export function on_image_load(image, callback) {
if (image.complete) {
callback();
} else {
image.addEventListener("load", callback);
}
}
export function set_image_smoothing_enabled(ctx, value) {
ctx.imageSmoothingEnabled = value;
}

View file

@ -0,0 +1,3 @@
export function pi() {
return Math.PI;
}

View file

@ -0,0 +1,201 @@
//// This module contains the main `Picture` type as well as the
//// function you can use to construct, modify and combine pictures.
import gleam/result
import gleam_community/colour
import paint/internal/types as internal_implementation
/// A 2D picture. This is the type which this entire library revolves around.
///
///> [!NOTE]
///> Unless you intend to author a new backend you should **consider this type opaque and never use any of its constructors**.
///> Instead, make use of the many utility functions defined in this module (`circle`, `combine`, `fill`, etc.)
pub type Picture =
internal_implementation.Picture
/// A reference to an image (i.e. a texture), not to be confused with the `Picture` type.
/// To create an image, see the image functions in the `canvas` back-end.
pub type Image =
internal_implementation.Image
/// An angle in clock-wise direction.
/// See: `angle_rad` and `angle_deg`.
pub type Angle =
internal_implementation.Angle
/// Create an angle expressed in radians
pub fn angle_rad(radians: Float) -> Angle {
internal_implementation.Radians(radians)
}
/// Create an angle expressed in degrees
pub fn angle_deg(degrees: Float) -> Angle {
internal_implementation.Radians(degrees *. pi() /. 180.0)
}
/// A rexport of the Colour type from [gleam_community/colour](https://hexdocs.pm/gleam_community_colour/).
/// Paint also includes the functions `colour_hex` and `colour_rgb` to
/// easily construct Colours, but feel free to import the `gleam_community/colour` module
/// and use the many utility that are provided from there.
pub type Colour =
colour.Colour
/// A utility around [colour.from_rgb_hex_string](https://hexdocs.pm/gleam_community_colour/gleam_community/colour.html#from_rgb_hex_string)
/// (from `gleam_community/colour`) that **panics** on an invalid hex code.
pub fn colour_hex(string: String) -> Colour {
result.lazy_unwrap(colour.from_rgb_hex_string(string), fn() {
panic as "Failed to parse hex code"
})
}
/// A utility around [colour.from_rgb255](https://hexdocs.pm/gleam_community_colour/gleam_community/colour.html#from_rgb255)
/// (from `gleam_community/colour`) that **panics** if the values are outside of the allowed range.
pub fn colour_rgb(red: Int, green: Int, blue: Int) -> Colour {
result.lazy_unwrap(colour.from_rgb255(red, green, blue), fn() {
panic as "The value was not inside of the valid range [0-255]"
})
}
pub type Vec2 =
#(Float, Float)
/// A blank picture
pub fn blank() -> Picture {
internal_implementation.Blank
}
/// A circle with some given radius
pub fn circle(radius: Float) -> Picture {
internal_implementation.Arc(
radius,
start: internal_implementation.Radians(0.0),
end: internal_implementation.Radians(2.0 *. pi()),
)
}
/// An arc with some radius going from some
/// starting angle to some other angle in clock-wise direction
pub fn arc(radius: Float, start: Angle, end: Angle) -> Picture {
internal_implementation.Arc(radius, start: start, end: end)
}
/// A polygon consisting of a list of 2d points
pub fn polygon(points: List(#(Float, Float))) -> Picture {
internal_implementation.Polygon(points, True)
}
/// Lines (same as a polygon but not a closed shape)
pub fn lines(points: List(#(Float, Float))) -> Picture {
internal_implementation.Polygon(points, False)
}
/// A rectangle with some given width and height
pub fn rectangle(width: Float, height: Float) -> Picture {
polygon([#(0.0, 0.0), #(width, 0.0), #(width, height), #(0.0, height)])
}
/// A square
pub fn square(length: Float) -> Picture {
rectangle(length, length)
}
/// Draw an image such as a PNG, JPEG or an SVG. See the `canvas` back-end for more details on how to load images.
pub fn image(image: Image, width_px width_px, height_px height_px) -> Picture {
// TODO: add a function that allows us to draw only part of an image, flip, and if we want smooth scaling or not
internal_implementation.ImageRef(image, width_px:, height_px:)
}
/// Set image scaling to be smooth (this is the default behaviour)
pub fn image_scaling_smooth(picture: Picture) -> Picture {
internal_implementation.ImageScalingBehaviour(
picture,
internal_implementation.ScalingSmooth,
)
}
/// Disable smooth image scaling, suitable for pixel art.
pub fn image_scaling_pixelated(picture: Picture) -> Picture {
internal_implementation.ImageScalingBehaviour(
picture,
internal_implementation.ScalingPixelated,
)
}
/// Text with some given font size
pub fn text(text: String, px font_size: Int) -> Picture {
internal_implementation.Text(
text,
style: internal_implementation.FontProperties(font_size, "sans-serif"),
)
// TODO: expose more styling options (font and text alignment)
}
/// Translate a picture in horizontal and vertical direction
pub fn translate_xy(picture: Picture, x: Float, y: Float) -> Picture {
internal_implementation.Translate(picture, #(x, y))
}
/// Translate a picture in the horizontal direction
pub fn translate_x(picture: Picture, x: Float) -> Picture {
translate_xy(picture, x, 0.0)
}
/// Translate a picture in the vertical direction
pub fn translate_y(picture: Picture, y: Float) -> Picture {
translate_xy(picture, 0.0, y)
}
/// Scale the picture in the horizontal direction
pub fn scale_x(picture: Picture, factor: Float) -> Picture {
internal_implementation.Scale(picture, #(factor, 1.0))
}
/// Scale the picture in the vertical direction
pub fn scale_y(picture: Picture, factor: Float) -> Picture {
internal_implementation.Scale(picture, #(1.0, factor))
}
/// Scale the picture uniformly in horizontal and vertical direction
pub fn scale_uniform(picture: Picture, factor: Float) -> Picture {
internal_implementation.Scale(picture, #(factor, factor))
}
/// Rotate the picture in a clock-wise direction
pub fn rotate(picture: Picture, angle: Angle) -> Picture {
internal_implementation.Rotate(picture, angle)
}
/// Fill a picture with some given colour, see `Colour`.
pub fn fill(picture: Picture, colour: Colour) -> Picture {
internal_implementation.Fill(picture, colour)
}
/// Set a solid stroke with some given colour and width
pub fn stroke(picture: Picture, colour: Colour, width width: Float) -> Picture {
internal_implementation.Stroke(
picture,
internal_implementation.SolidStroke(colour, width),
)
}
/// Remove the stroke of the given picture
pub fn stroke_none(picture: Picture) -> Picture {
internal_implementation.Stroke(picture, internal_implementation.NoStroke)
}
/// Concatenate two pictures
pub fn concat(picture: Picture, another_picture: Picture) -> Picture {
combine([picture, another_picture])
}
/// Combine multiple pictures into one
pub fn combine(pictures: List(Picture)) -> Picture {
internal_implementation.Combine(pictures)
}
// Internal utility function to get Pi π
@external(erlang, "math", "pi")
@external(javascript, "./numbers_ffi.mjs", "pi")
fn pi() -> Float {
3.1415926
}

View file

@ -0,0 +1,461 @@
//// A HTML canvas backend that can be used for displaying
//// your `Picture`s. There are three different ways of doing so:
//// - `display` (provide a picture and a CSS selector to some canvas element)
//// - `define_web_component` (an alternative to `display` using custom web components, useful if you are using a web framework like Lustre)
//// - `interact` (allows you to make animations and interactive programs)
import gleam/int
import gleam/option.{type Option, None, Some}
import gleam_community/colour
import paint.{translate_xy}
import paint/encode
import paint/event.{type Event}
import paint/internal/impl_canvas
import paint/internal/types.{
type Image, type Picture, Arc, Blank, Combine, Fill, FontProperties, Image,
NoStroke, Polygon, Radians, Rotate, Scale, SolidStroke, Stroke, Text,
Translate,
}
/// The configuration of the "canvas"
pub type Config {
Config(width: Float, height: Float)
}
/// Create a reference to an image using a CSS query selector. For example:
/// ```
/// fn kitten() {
/// canvas.image_from_query("#kitten")
/// }
/// // In the HTML file:
/// // <img
/// // style="display: none"
/// // src="https://upload.wikimedia.org/wikipedia/commons/4/4d/Cat_November_2010-1a.jpg"
/// // id="kitten"
/// // />
/// ```
///
/// > [!WARNING]
/// > **Important**: Make sure the image has loaded before trying to draw a pictures referencing it.
/// > You can do this using `canvas.wait_until_loaded` function.
pub fn image_from_query(selector: String) -> Image {
let id = "image-selector-" <> selector
case impl_canvas.get_global(id) {
// Re-use the cached image if we can
Ok(_) -> {
Nil
}
Error(Nil) -> {
let image = impl_canvas.image_from_query(selector)
impl_canvas.set_global(image, id)
}
}
Image(id)
}
/// Create a reference to an image using a source path.
/// ```
/// fn my_logo_image() {
/// canvas.image_from_src("./priv/static/logo.svg")
/// }
/// ```
///
/// > [!WARNING]
/// > **Important**: Make sure the image has loaded before trying to draw a pictures referencing it.
/// > You can do this using `canvas.wait_until_loaded` function.
pub fn image_from_src(src: String) -> Image {
let id = "image-src-" <> src
case impl_canvas.get_global(id) {
// Re-use the cached image if we can
Ok(_) -> {
Nil
}
Error(Nil) -> {
let image = impl_canvas.image_from_src(src)
impl_canvas.set_global(image, id)
}
}
Image(id)
}
/// Wait until a list of images have all been loaded, for example:
/// ```
/// fn lucy() {
/// canvas.image_from_query("#lucy")
/// }
///
/// fn cat() {
/// canvas.image_from_src("./path/to/kitten.png")
/// }
///
/// pub fn main() {
/// use <- canvas.wait_until_loaded([lucy(), kitten()])
/// // It is now safe to draw Pictures containing the images lucy and kitten :)
/// }
/// ```
pub fn wait_until_loaded(images: List(Image), on_loaded: fn() -> Nil) -> Nil {
case images {
[] -> on_loaded()
[image, ..rest] -> {
let Image(id:) = image
let assert Ok(js_image) = impl_canvas.get_global(id)
impl_canvas.on_image_load(js_image, fn() {
wait_until_loaded(rest, on_loaded)
})
}
}
}
/// Display a picture on a HTML canvas element
/// (specified by some [CSS Selector](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_selectors)).
/// ```
/// canvas.display(fn (_: canvas.Config) { circle(50.0) }, "#mycanvas")
/// ```
pub fn display(init: fn(Config) -> Picture, selector: String) {
let ctx = impl_canvas.get_rendering_context(selector)
impl_canvas.reset(ctx)
let picture =
init(Config(impl_canvas.get_width(ctx), impl_canvas.get_height(ctx)))
display_on_rendering_context(picture, ctx, default_drawing_state)
}
/// Additional state used when drawing
/// (note that the fill and stroke color as well as the stroke width
/// is stored inside of the context)
type DrawingState {
DrawingState(fill: Bool, stroke: Bool)
}
const default_drawing_state = DrawingState(fill: False, stroke: True)
fn display_on_rendering_context(
picture: Picture,
ctx: impl_canvas.RenderingContext2D,
state: DrawingState,
) {
case picture {
Blank -> Nil
Text(text, properties) -> {
let FontProperties(size_px, font_family) = properties
impl_canvas.save(ctx)
impl_canvas.text(
ctx,
text,
int.to_string(size_px) <> "px " <> font_family,
)
impl_canvas.restore(ctx)
}
Polygon(points, closed) -> {
impl_canvas.polygon(ctx, points, closed, state.fill, state.stroke)
}
Arc(radius, start, end) -> {
let Radians(start_radians) = start
let Radians(end_radians) = end
impl_canvas.arc(
ctx,
radius,
start_radians,
end_radians,
state.fill,
state.stroke,
)
}
Fill(p, colour) -> {
impl_canvas.save(ctx)
impl_canvas.set_fill_colour(ctx, colour.to_css_rgba_string(colour))
display_on_rendering_context(p, ctx, DrawingState(..state, fill: True))
impl_canvas.restore(ctx)
}
Stroke(p, stroke) -> {
case stroke {
NoStroke ->
display_on_rendering_context(
p,
ctx,
DrawingState(..state, stroke: False),
)
SolidStroke(color, width) -> {
impl_canvas.save(ctx)
impl_canvas.set_stroke_color(ctx, colour.to_css_rgba_string(color))
impl_canvas.set_line_width(ctx, width)
display_on_rendering_context(
p,
ctx,
DrawingState(..state, stroke: True),
)
impl_canvas.restore(ctx)
}
}
}
Translate(p, vec) -> {
let #(x, y) = vec
impl_canvas.save(ctx)
impl_canvas.translate(ctx, x, y)
display_on_rendering_context(p, ctx, state)
impl_canvas.restore(ctx)
}
Scale(p, vec) -> {
let #(x, y) = vec
impl_canvas.save(ctx)
impl_canvas.scale(ctx, x, y)
display_on_rendering_context(p, ctx, state)
impl_canvas.restore(ctx)
}
Rotate(p, angle) -> {
let Radians(rad) = angle
impl_canvas.save(ctx)
impl_canvas.rotate(ctx, rad)
display_on_rendering_context(p, ctx, state)
impl_canvas.restore(ctx)
}
Combine(pictures) -> {
case pictures {
[] -> Nil
[p, ..ps] -> {
display_on_rendering_context(p, ctx, state)
display_on_rendering_context(Combine(ps), ctx, state)
}
}
}
types.ImageRef(Image(id:), width_px:, height_px:) -> {
// TODO: log an error if this fails?
let assert Ok(image) = impl_canvas.get_global(id)
impl_canvas.draw_image(ctx, image, width_px, height_px)
}
types.ImageScalingBehaviour(p, behaviour) -> {
impl_canvas.save(ctx)
impl_canvas.set_image_smoothing_enabled(ctx, case behaviour {
types.ScalingPixelated -> False
types.ScalingSmooth -> True
})
display_on_rendering_context(p, ctx, state)
impl_canvas.restore(ctx)
}
}
}
/// Animations, interactive applications and tiny games can be built using the
/// `interact` function. It roughly follows the [Elm architecture](https://guide.elm-lang.org/architecture/).
/// Here is a short example:
/// ```
/// type State =
/// Int
///
/// fn init(_: canvas.Config) -> State {
/// 0
/// }
///
/// fn update(state: State, event: event.Event) -> State {
/// case event {
/// event.Tick(_) -> state + 1
/// _ -> state
/// }
/// }
///
/// fn view(state: State) -> Picture {
/// paint.circle(int.to_float(state))
/// }
///
/// fn main() {
/// interact(init, update, view, "#mycanvas")
/// }
/// ```
pub fn interact(
init: fn(Config) -> state,
update: fn(state, Event) -> state,
view: fn(state) -> Picture,
selector: String,
) {
let ctx = impl_canvas.get_rendering_context(selector)
let initial_state =
init(Config(impl_canvas.get_width(ctx), impl_canvas.get_height(ctx)))
impl_canvas.set_global(initial_state, selector)
// Handle keyboard input
let create_key_handler = fn(event_name, constructor) {
impl_canvas.setup_input_handler(
event_name,
fn(event: impl_canvas.KeyboardEvent) {
let key = parse_key_code(impl_canvas.get_key_code(event))
case key {
Some(key) -> {
let assert Ok(old_state) = impl_canvas.get_global(selector)
let new_state = update(old_state, constructor(key))
impl_canvas.set_global(new_state, selector)
}
None -> Nil
}
},
)
}
create_key_handler("keydown", event.KeyboardPressed)
create_key_handler("keyup", event.KeyboardRelased)
// Handle mouse movement
impl_canvas.setup_input_handler(
"mousemove",
fn(event: impl_canvas.MouseEvent) {
let #(x, y) = impl_canvas.mouse_pos(ctx, event)
let assert Ok(old_state) = impl_canvas.get_global(selector)
let new_state = update(old_state, event.MouseMoved(x, y))
impl_canvas.set_global(new_state, selector)
Nil
},
)
// Handle mouse buttons
let create_mouse_button_handler = fn(event_name, constructor, check_pressed) {
impl_canvas.setup_input_handler(
event_name,
fn(event: impl_canvas.MouseEvent) {
// Read the previous state of the mouse
let previous_event_id = "PAINT_PREVIOUS_MOUSE_INPUT_FOR_" <> selector
let previous_event = impl_canvas.get_global(previous_event_id)
// Save this state
impl_canvas.set_global(event, previous_event_id)
// A utility to check which buttons was just pressed/released
let check_button = fn(i) {
impl_canvas.check_mouse_button(
event,
previous_event,
i,
check_pressed,
)
}
let trigger_update = fn(button) {
let assert Ok(old_state) = impl_canvas.get_global(selector)
let new_state = update(old_state, constructor(button))
impl_canvas.set_global(new_state, selector)
}
// Note: it is rather rare, but it seems that multiple buttons
// can be pressed in the very same MouseEvent, so we may need to
// trigger multiple events at once.
case check_button(0) {
True -> trigger_update(event.MouseButtonLeft)
False -> Nil
}
case check_button(1) {
True -> trigger_update(event.MouseButtonRight)
False -> Nil
}
case check_button(2) {
True -> trigger_update(event.MouseButtonMiddle)
False -> Nil
}
Nil
},
)
}
create_mouse_button_handler("mousedown", event.MousePressed, True)
create_mouse_button_handler("mouseup", event.MouseReleased, False)
impl_canvas.setup_request_animation_frame(get_tick_func(
ctx,
view,
update,
selector,
))
}
fn parse_key_code(key_code: Int) -> Option(event.Key) {
case key_code {
32 -> Some(event.KeySpace)
37 -> Some(event.KeyLeftArrow)
38 -> Some(event.KeyUpArrow)
39 -> Some(event.KeyRightArrow)
40 -> Some(event.KeyDownArrow)
87 -> Some(event.KeyW)
65 -> Some(event.KeyA)
83 -> Some(event.KeyS)
68 -> Some(event.KeyD)
90 -> Some(event.KeyZ)
88 -> Some(event.KeyX)
67 -> Some(event.KeyC)
18 -> Some(event.KeyEnter)
27 -> Some(event.KeyEscape)
8 -> Some(event.KeyBackspace)
_ -> None
}
}
// Gleam does not have recursive let bindings, so I need
// to do this workaround...
fn get_tick_func(ctx, view, update, selector) {
fn(time) {
let assert Ok(current_state) = impl_canvas.get_global(selector)
// Trigger a tick event before drawing
let new_state = update(current_state, event.Tick(time))
impl_canvas.set_global(new_state, selector)
// Create the picture
let picture = view(new_state)
// Render the picture on the canvas
impl_canvas.reset(ctx)
display_on_rendering_context(picture, ctx, default_drawing_state)
impl_canvas.setup_request_animation_frame(
// call myself
get_tick_func(ctx, view, update, selector),
)
}
}
/// If you are using [Lustre](https://github.com/lustre-labs/lustre) or some other framework to build
/// your web application you may prefer to use the [web components](https://developer.mozilla.org/en-US/docs/Web/API/Web_components) API
/// and the `define_web_component` function.
/// ```
/// // Call this function once to register a custom HTML element <paint-canvas>
/// canvas.define_web_component()
/// // You can then display your picture by setting the "picture"
/// // property or attribute on the element.
///
/// // In Lustre it would look something like this:
/// fn canvas(picture: paint.Picture, attributes: List(attribute.Attribute(a))) {
/// element.element(
/// "paint-canvas",
/// [attribute.attribute("picture", encode.to_string(picture)), ..attributes],
/// [],
/// )
///}
/// ```
/// A more detailed example for using this API can be found in the `demos/with_lustre` directory.
pub fn define_web_component() -> Nil {
impl_canvas.define_web_component()
// somewhat of an ugly hack, but the setter for the web component will need to call this
// when the picture property changes. Therefore we
// bind this function to the window object so we can access it from the JS side of things.
//
// However, we should be careful of changing this. It is not part of the public API but it seems like
// [Tiramisu](https://hexdocs.pm/tiramisu/index.html) might makes use of it.
impl_canvas.set_global(
fn(encoded_picture, ctx) {
let assert Ok(picture) = encode.from_string(encoded_picture)
as "Invalid picture provided to web component"
display_on_rendering_context(picture, ctx, default_drawing_state)
},
"display_on_rendering_context_with_default_drawing_state",
)
}
/// Utility to set the origin in the center of the canvas
pub fn center(picture: Picture) -> fn(Config) -> Picture {
fn(config) {
let Config(width, height) = config
picture |> translate_xy(width *. 0.5, height *. 0.5)
}
}

View file

@ -0,0 +1,260 @@
import gleam/dynamic/decode.{type Decoder}
import gleam/json.{type Json}
import gleam_community/colour
import paint.{type Picture}
import paint/internal/types.{
type Angle, type FontProperties, type StrokeProperties, FontProperties,
NoStroke, Radians, SolidStroke,
}
/// Serialize a `Picture` to a string.
///
/// Note, serializing an `Image` texture will only store an ID referencing the image. This means that if you deserialize a Picture containing
/// references to images, you are responsible for making sure all images are loaded before drawing the picture.
/// More advanced APIs to support use cases such as these are planned for a future release.
///
/// Also, if you wish to store the serialized data, remember that the library currently makes no stability guarantee that
/// the data can be deserialized by *future* versions of the library.
pub fn to_string(picture: Picture) -> String {
let version = "paint:unstable"
json.object([
#("version", json.string(version)),
#("picture", picture_to_json(picture)),
])
|> json.to_string
}
/// Attempt to deserialize a `Picture`
pub fn from_string(string: String) {
let decoder = {
use picture <- decode.field("picture", decode_picture())
decode.success(picture)
}
json.parse(string, decoder)
}
fn decode_angle() {
use radians <- decode.field("radians", decode.float)
decode.success(Radians(radians))
}
fn decode_picture() -> Decoder(Picture) {
use <- decode.recursive
use ty <- decode.field("type", decode.string)
case ty {
"arc" -> {
use radius <- decode.field("radius", decode.float)
use start <- decode.field("start", decode_angle())
use end <- decode.field("end", decode_angle())
decode.success(types.Arc(radius, start:, end:))
}
"blank" -> decode.success(types.Blank)
"combine" -> {
use pictures <- decode.field(
"pictures",
decode.list(of: decode_picture()),
)
decode.success(types.Combine(pictures))
}
"fill" -> {
use picture <- decode.field("picture", decode_picture())
use colour <- decode.field("colour", colour.decoder())
decode.success(types.Fill(picture, colour))
}
"polygon" -> {
use points <- decode.field("points", decode.list(of: decode_vec2()))
use closed <- decode.field("closed", decode.bool)
decode.success(types.Polygon(points, closed))
}
"rotate" -> {
use angle <- decode.field("angle", decode_angle())
use picture <- decode.field("picture", decode_picture())
decode.success(types.Rotate(picture, angle))
}
"scale" -> {
use x <- decode.field("x", decode.float)
use y <- decode.field("y", decode.float)
use picture <- decode.field("picture", decode_picture())
decode.success(types.Scale(picture, #(x, y)))
}
"stroke" -> {
use stroke <- decode.field("stroke", decode_stroke())
use picture <- decode.field("picture", decode_picture())
decode.success(types.Stroke(picture, stroke))
}
"text" -> {
use text <- decode.field("text", decode.string)
use style <- decode.field("style", decode_font())
decode.success(types.Text(text, style))
}
"translate" -> {
use x <- decode.field("x", decode.float)
use y <- decode.field("y", decode.float)
use picture <- decode.field("picture", decode_picture())
decode.success(types.Translate(picture, #(x, y)))
}
"image" -> {
use id <- decode.field("id", decode.string)
use width_px <- decode.field("width_px", decode.int)
use height_px <- decode.field("height_px", decode.int)
decode.success(types.ImageRef(types.Image(id:), width_px:, height_px:))
}
"image_scaling_behaviour" -> {
use behaviour <- decode.field("behaviour", decode.string)
use picture <- decode.field("picture", decode_picture())
case behaviour {
"smooth" ->
decode.success(types.ImageScalingBehaviour(
picture,
types.ScalingSmooth,
))
"pixelated" ->
decode.success(types.ImageScalingBehaviour(
picture,
types.ScalingPixelated,
))
_ -> decode.failure(types.Blank, "Picture")
}
}
_ -> decode.failure(types.Blank, "Picture")
}
}
fn decode_font() -> Decoder(FontProperties) {
use size_px <- decode.field("sizePx", decode.int)
use font_family <- decode.field("fontFamily", decode.string)
decode.success(FontProperties(size_px:, font_family:))
}
fn decode_stroke() -> Decoder(StrokeProperties) {
use stroke_type <- decode.field("type", decode.string)
case stroke_type {
"noStroke" -> decode.success(NoStroke)
"solidStroke" -> {
use colour <- decode.field("colour", colour.decoder())
use thickness <- decode.field("thickness", decode.float)
decode.success(SolidStroke(colour, thickness))
}
_ -> decode.failure(NoStroke, "StrokeProperties")
}
}
fn decode_vec2() -> Decoder(#(Float, Float)) {
use x <- decode.field("x", decode.float)
use y <- decode.field("y", decode.float)
decode.success(#(x, y))
}
fn picture_to_json(picture: Picture) -> Json {
case picture {
types.Arc(radius:, start:, end:) ->
json.object([
#("type", json.string("arc")),
#("radius", json.float(radius)),
#("start", angle_to_json(start)),
#("end", angle_to_json(end)),
])
types.Blank -> json.object([#("type", json.string("blank"))])
types.Combine(from) ->
json.object([
#("type", json.string("combine")),
#("pictures", json.array(from:, of: picture_to_json)),
])
types.Fill(picture, colour) ->
json.object([
#("type", json.string("fill")),
#("colour", colour.encode(colour)),
#("picture", picture_to_json(picture)),
])
types.Polygon(points, closed:) ->
json.object([
#("type", json.string("polygon")),
#(
"points",
json.array(from: points, of: fn(point) {
let #(x, y) = point
json.object([#("x", json.float(x)), #("y", json.float(y))])
}),
),
#("closed", json.bool(closed)),
])
types.Rotate(picture, angle) ->
json.object([
#("type", json.string("rotate")),
#("angle", angle_to_json(angle)),
#("picture", picture_to_json(picture)),
])
types.Scale(picture, #(x, y)) ->
json.object([
#("type", json.string("scale")),
#("x", json.float(x)),
#("y", json.float(y)),
#("picture", picture_to_json(picture)),
])
types.Stroke(picture, stroke) ->
json.object([
#("type", json.string("stroke")),
#("stroke", stroke_to_json(stroke)),
#("picture", picture_to_json(picture)),
])
types.Text(text:, style:) ->
json.object([
#("type", json.string("text")),
#("text", json.string(text)),
#("style", font_to_json(style)),
])
types.Translate(picture, #(x, y)) ->
json.object([
#("type", json.string("translate")),
#("x", json.float(x)),
#("y", json.float(y)),
#("picture", picture_to_json(picture)),
])
types.ImageRef(types.Image(id:), width_px:, height_px:) -> {
json.object([
#("type", json.string("image")),
#("id", json.string(id)),
#("width_px", json.int(width_px)),
#("height_px", json.int(height_px)),
])
}
types.ImageScalingBehaviour(picture, behaviour) ->
json.object([
#("type", json.string("image_scaling_behaviour")),
#(
"behaviour",
json.string(case behaviour {
types.ScalingPixelated -> "pixelated"
types.ScalingSmooth -> "smooth"
}),
),
#("picture", picture_to_json(picture)),
])
}
}
fn font_to_json(font: FontProperties) -> Json {
let FontProperties(size_px:, font_family:) = font
json.object([
#("sizePx", json.int(size_px)),
#("fontFamily", json.string(font_family)),
])
}
fn stroke_to_json(stroke: StrokeProperties) -> Json {
case stroke {
NoStroke -> json.object([#("type", json.string("noStroke"))])
SolidStroke(colour, thickness) ->
json.object([
#("type", json.string("solidStroke")),
#("colour", colour.encode(colour)),
#("thickness", json.float(thickness)),
])
}
}
fn angle_to_json(angle: Angle) -> Json {
let Radians(rad) = angle
json.object([#("radians", json.float(rad))])
}

View file

@ -0,0 +1,50 @@
//// This module contains events that can be triggered when
//// building interactive applications.
////
//// See `paint/canvas` and the `canvas.interact` function for a
//// practical example of how this is used.
pub type Event {
/// Triggered before drawing. Contains the number of milliseconds elapsed.
Tick(Float)
/// Triggered when a key is pressed
KeyboardPressed(Key)
/// Triggered when a key is released
KeyboardRelased(Key)
/// Triggered when the mouse is moved. Contains
/// the `x` and `y` value for the mouse position.
MouseMoved(Float, Float)
/// Triggered when a mouse button is pressed
MousePressed(MouseButton)
/// Triggered when a mouse button is released.
///
/// Note, on the web you might encounter issues where the
/// release event for the right mouse button is not triggered
/// because of the context menu.
MouseReleased(MouseButton)
}
pub type Key {
KeyLeftArrow
KeyRightArrow
KeyUpArrow
KeyDownArrow
KeySpace
KeyW
KeyA
KeyS
KeyD
KeyZ
KeyX
KeyC
KeyEnter
KeyEscape
KeyBackspace
}
pub type MouseButton {
MouseButtonLeft
MouseButtonRight
/// The scroll wheel button
MouseButtonMiddle
}

View file

@ -0,0 +1,118 @@
pub type RenderingContext2D
@external(javascript, "./../../impl_canvas_bindings.mjs", "define_web_component")
pub fn define_web_component() -> Nil
// TODO: forward the timestamp from the callback
@external(javascript, "./../../impl_canvas_bindings.mjs", "setup_request_animation_frame")
pub fn setup_request_animation_frame(callback: fn(Float) -> Nil) -> Nil
@external(javascript, "./../../impl_canvas_bindings.mjs", "get_rendering_context")
pub fn get_rendering_context(selector: String) -> RenderingContext2D
@external(javascript, "../../impl_canvas_bindings.mjs", "setup_input_handler")
pub fn setup_input_handler(event: String, callback: fn(event) -> Nil) -> Nil
pub type KeyboardEvent
@external(javascript, "./../../impl_canvas_bindings.mjs", "get_key_code")
pub fn get_key_code(event: KeyboardEvent) -> Int
pub type MouseEvent
@external(javascript, "./../../impl_canvas_bindings.mjs", "mouse_pos")
pub fn mouse_pos(ctx: RenderingContext2D, event: MouseEvent) -> #(Float, Float)
@external(javascript, "./../../impl_canvas_bindings.mjs", "check_mouse_button")
pub fn check_mouse_button(
event: MouseEvent,
previous_event: Result(MouseEvent, Nil),
button_index: Int,
check_pressed check_pressed: Bool,
) -> Bool
@external(javascript, "../../impl_canvas_bindings.mjs", "get_width")
pub fn get_width(ctx: RenderingContext2D) -> Float
@external(javascript, "../../impl_canvas_bindings.mjs", "get_height")
pub fn get_height(ctx: RenderingContext2D) -> Float
@external(javascript, "../../impl_canvas_bindings.mjs", "set_global")
pub fn set_global(state: state, id: String) -> Nil
@external(javascript, "../../impl_canvas_bindings.mjs", "get_global")
pub fn get_global(id: String) -> Result(state, Nil)
@external(javascript, "../../impl_canvas_bindings.mjs", "reset")
pub fn reset(ctx: RenderingContext2D) -> Nil
@external(javascript, "../../impl_canvas_bindings.mjs", "save")
pub fn save(ctx: RenderingContext2D) -> Nil
@external(javascript, "../../impl_canvas_bindings.mjs", "restore")
pub fn restore(ctx: RenderingContext2D) -> Nil
@external(javascript, "../../impl_canvas_bindings.mjs", "translate")
pub fn translate(ctx: RenderingContext2D, x: Float, y: Float) -> Nil
@external(javascript, "../../impl_canvas_bindings.mjs", "scale")
pub fn scale(ctx: RenderingContext2D, x: Float, y: Float) -> Nil
@external(javascript, "../../impl_canvas_bindings.mjs", "rotate")
pub fn rotate(ctx: RenderingContext2D, radians: Float) -> Nil
@external(javascript, "../../impl_canvas_bindings.mjs", "reset_transform")
pub fn reset_transform(ctx: RenderingContext2D) -> Nil
@external(javascript, "../../impl_canvas_bindings.mjs", "set_fill_colour")
pub fn set_fill_colour(ctx: RenderingContext2D, css_colour: String) -> Nil
@external(javascript, "../../impl_canvas_bindings.mjs", "set_stroke_color")
pub fn set_stroke_color(ctx: RenderingContext2D, css_color: String) -> Nil
@external(javascript, "../../impl_canvas_bindings.mjs", "set_line_width")
pub fn set_line_width(ctx: RenderingContext2D, width: Float) -> Nil
@external(javascript, "../../impl_canvas_bindings.mjs", "set_image_smoothing_enabled")
pub fn set_image_smoothing_enabled(ctx: RenderingContext2D, value: Bool) -> Nil
@external(javascript, "../../impl_canvas_bindings.mjs", "arc")
pub fn arc(
ctx: RenderingContext2D,
radius: Float,
start: Float,
end: Float,
fill: Bool,
stroke: Bool,
) -> Nil
@external(javascript, "../../impl_canvas_bindings.mjs", "polygon")
pub fn polygon(
ctx: RenderingContext2D,
points: List(#(Float, Float)),
closed: Bool,
fill: Bool,
stroke: Bool,
) -> Nil
@external(javascript, "../../impl_canvas_bindings.mjs", "text")
pub fn text(ctx: RenderingContext2D, text: String, style: String) -> Nil
pub type JsImage
@external(javascript, "../../impl_canvas_bindings.mjs", "draw_image")
pub fn draw_image(
ctx: RenderingContext2D,
image: JsImage,
width_px: Int,
height_px: Int,
) -> Nil
@external(javascript, "../../impl_canvas_bindings.mjs", "image_from_query")
pub fn image_from_query(selector: String) -> JsImage
@external(javascript, "../../impl_canvas_bindings.mjs", "image_from_src")
pub fn image_from_src(src: String) -> JsImage
@external(javascript, "../../impl_canvas_bindings.mjs", "on_image_load")
pub fn on_image_load(image: JsImage, callback: fn() -> Nil) -> Nil

View file

@ -0,0 +1,48 @@
import gleam_community/colour.{type Colour}
pub type Picture {
// Shapes
Blank
Polygon(List(Vec2), closed: Bool)
Arc(radius: Float, start: Angle, end: Angle)
Text(text: String, style: FontProperties)
ImageRef(Image, width_px: Int, height_px: Int)
// Styling
// TODO: font
Fill(Picture, Colour)
Stroke(Picture, StrokeProperties)
ImageScalingBehaviour(Picture, ImageScalingBehaviour)
// Transform
Translate(Picture, Vec2)
Scale(Picture, Vec2)
Rotate(Picture, Angle)
// Combine
Combine(List(Picture))
}
// The ID for an image
// Invariant: the image object is assumed to already be created and stored somewhere (like the PAINT_STATE for the canvas backend)
pub type Image {
Image(id: String)
}
pub type ImageScalingBehaviour {
ScalingSmooth
ScalingPixelated
}
pub type StrokeProperties {
NoStroke
SolidStroke(Colour, Float)
}
pub type FontProperties {
FontProperties(size_px: Int, font_family: String)
}
pub type Angle {
Radians(Float)
}
pub type Vec2 =
#(Float, Float)