primopy drawing with draw
▶Open primopy

primopydrawing with draw

draw is the drawing context built into primopy. You write from primopy import draw, call a few of its methods, and the picture appears in the Draw tab of the console. It is made to give you a friendly start into Python: draw something on your first day.

primopy is the module the editor provides; nothing to install, it is simply there when a program runs here. draw is its drawing context, one object that a program imports and then draws through, much as a web page asks its canvas for a context and draws through that. Under the names, draw is the browser's own canvas. Every method and every style on this page is the canvas's, written the Python way, so what you learn here is what a web page draws with, and the canvas documentation on MDN is a second reference for the same object. This page is the full reference, with worked examples. The handbook covers where the Draw tab lives and how it interacts with the debugger.

Contents#

  1. 1Getting started
  2. 2The coordinate system
  3. 3The canvas, mirrored
  4. 4Colours and styles
  5. 5Reference
  6. 6Examples
  7. 7Drawing and the debugger
  8. 8Limits and gotchas

1Getting started#

Nothing to install. A drawing program starts with one import line and then talks to draw:

pythonOpen
from primopy import draw

draw.clear("midnightblue")
draw.fill_style = "gold"
draw.fill_circle(200, 150, 60)
draw.write(20, 20, "Hello!")

Every method is called on draw, and a style such as fill_style is set by assignment: draw.fill_style = "gold", as on the canvas. When you run the program, the console switches to the Draw tab and the picture appears there. Examples 2, 4 and 7 in the examples menu are ready-made starting points, and 16 is a whole game.

2The coordinate system#

  • (0, 0) is the top-left corner.
  • x grows to the right, y grows downward, the opposite of a maths graph, but the same as every screen.
  • Units are drawing units. On an ordinary display one unit is one pixel; on a high-resolution display the canvas is backed by two or three times as many physical pixels, but you keep using the same numbers and everything simply looks sharper.
  • Coordinates may be floats. draw.fill_circle(100.5, 80.25, 30) is fine.

The canvas fills the Draw tab, so its size depends on how big the console is. Always ask rather than guess:

pythonOpen
from primopy import draw

width, height = draw.size()
draw.stroke_circle(width / 2, height / 2, min(width, height) / 3)

Make the console taller and run again and the circle grows with it.

3The canvas, mirrored#

Every web browser has a drawing surface called the canvas, and a page draws on it through an object with a long name, CanvasRenderingContext2D. draw is that object, name for name, with the names spelled the way Python spells them; a page gets it with canvas.getContext("2d"), a program here gets it with from primopy import draw. The rule is mechanical: a name written likeThis in the canvas documentation is written like_this here.

On the canvas (MDN)In draw
ctx.fillRect(x, y, w, h)draw.fill_rect(x, y, w, h)
ctx.fillStyle = "gold"draw.fill_style = "gold"
ctx.beginPath(), ctx.moveTo(), ctx.lineTo()draw.begin_path(), draw.move_to(), draw.line_to()
ctx.arc(x, y, r, 0, Math.PI)draw.arc(x, y, r, 0, 180)
ctx.createLinearGradient(...)draw.create_linear_gradient(...)
ctx.measureText(s).widthdraw.measure_text(s)

So when you wonder what line_join does, or how bezier_curve_to bends, look up lineJoin or bezierCurveTo on MDN: the answer is the same. That is the reason draw is built this way. A library with names of its own would have to explain everything itself, and what you learned would stop at the edge of primopy; the canvas is documented in depth, and it is what you meet the moment you draw on a real web page.

Three things to know before reading the canvas pages:

  • Styles are set by assignment. draw.line_width = 4 and draw.fill_style = "gold", exactly like ctx.lineWidth = 4 on the canvas. There is no set_line_width() function. Reading a style back gives what you last set.
  • Every call that paints says how. The canvas has no rect() that draws; it has fill_rect and stroke_rect, and fill_text and stroke_text, and a general fill() and stroke() for a path you built. Fill means solid, in the fill colour; stroke means outline, in the stroke colour, at the line width. draw keeps that rule for its own additions too: fill_circle and stroke_circle, fill_polygon and stroke_polygon.
  • Angles are in degrees. The canvas wants radians; draw takes degrees everywhere an angle appears (arc, ellipse, rotate, create_conic_gradient) and converts, because 90 reads better than math.pi / 2 on your first day. Angles go clockwise, since y grows downward: 0° points right, 90° points down.

What the canvas does not have, draw adds, and these are the only names you will not find on MDN: size(), clear(), color(), flush(), write(), line(), the six fill_/stroke_ shape shortcuts, the keyboard and the mouse. What the canvas has and draw leaves out is anything to do with images and raw pixels (drawImage, getImageData, patterns); see Limits.

4Colours and styles#

Colours#

Every style or function that takes a colour accepts what a web browser accepts:

FormExamples
Named colour"red", "skyblue", "gold", "midnightblue", "rebeccapurple"
Hex code"#ff0000", "#f00", "#ff000080" (last two digits = transparency)
RGB / RGBA"rgb(255, 0, 0)", "rgba(0, 0, 0, 0.5)"
HSL"hsl(200, 80%, 50%)", handy for rainbows: vary the first number

There are about 150 named colours, listed with swatches on MDN under named colours; that list is the one draw uses. A gradient (see Gradients) is accepted wherever a colour is, for fill_style and stroke_style.

If you pass something that isn't a colour, draw raises a ValueError instead of silently drawing nothing:

ValueError: 'gren' is not a colour. Try a name like 'red', a code like '#ff0000', or 'rgb(255, 0, 0)'.

Two pens#

The canvas has two colours, not one: fill_style paints the inside of shapes and the body of text, stroke_style draws outlines and lines. They are set by assignment, and each stays as it is until you change it:

pythonOpen
from primopy import draw

draw.fill_style = "gold"
draw.stroke_style = "black"
draw.line_width = 3
draw.fill_circle(100, 100, 40)     # gold
draw.stroke_circle(100, 100, 40)   # black ring, 3 units wide

draw.color("gold") sets both at once, which is what a beginner usually wants: one pen, one colour, until the picture needs an outline in a second colour.

The styles#

These are all the styles, with what they start at in each run. Most are the canvas's own starting values; the first seven are set by draw so that a first line draws something visible, and so that text lands where shapes do.

StyleStarts atWhat it does
fill_style"black"Colour (or gradient) for fill* calls and write
stroke_style"black"Colour (or gradient) for stroke* calls and line
line_width2Thickness of lines and outlines, in drawing units
line_cap"round"Line ends: "butt", "round" or "square"
line_join"round"Corners: "round", "bevel" or "miter"
text_baseline"top"Which part of the text sits on y: "top", "middle", "alphabetic", "bottom"
font"16px sans-serif"A CSS font, e.g. "bold 24px serif", for fill_text and stroke_text
text_align"start"Which part of the text sits on x: "start" (the left, in left-to-right text), "end", "left", "center", "right"
letter_spacing"0px"Extra space between letters, as a CSS length
miter_limit10How sharp a "miter" corner may get before it is cut off
line_dash_offset0Where along the dash pattern a stroke starts; see set_line_dash
global_alpha1Transparency of everything drawn afterwards, 0 to 1
global_composite_operation"source-over"How new paint mixes with what is there, e.g. "lighter", "multiply"
shadow_color"rgba(0, 0, 0, 0)"The colour of a drop shadow under everything drawn afterwards; transparent, so no shadow, until set
shadow_blur, shadow_offset_x, shadow_offset_y0How soft the shadow is, and how far it sits to the right and down
filter"none"A CSS filter, e.g. "blur(4px)"
image_smoothing_enabledTrueThe canvas has it, so draw has it; without images it changes nothing

Reading a style back, print(draw.line_width), gives the value in force, which is what you last set or the starting value in the table. A style is remembered by save() and put back by restore(), along with the transform and the clip; see State.

5Reference#

Setup#

draw.size() → (width, height) The drawing area in drawing units. Call it at the start and derive your positions from it.

draw.clear(colour="white") Fill the whole canvas with one colour. draw.clear() gives a white page; draw.clear(None) erases to transparent. Usually the first line of a drawing. It paints the whole area whatever transform is in force, and leaves every style as it was, including fill_style.

draw.color(colour) Set fill_style and stroke_style together.

draw.flush() Shows everything drawn so far, right now. Drawing calls travel to the page in batches; time.sleep(), input(), and every call that needs an answer from the page (the keyboard, the mouse, measure_text, the hit tests) already send what is waiting, so a loop that draws and sleeps needs no flush(). Call it before a long calculation whose frame should already be showing.

Shapes in one call#

These are draw's shortcuts: each builds a path and fills or strokes it, so that a circle is one line rather than three. All of them use the styles in force.

draw.line(x1, y1, x2, y2) Straight line between two points, in stroke_style.

draw.fill_circle(x, y, r) / draw.stroke_circle(x, y, r) Circle centred on (x, y) with radius r.

draw.fill_ellipse(x, y, rx, ry) / draw.stroke_ellipse(x, y, rx, ry) Ellipse centred on (x, y), rx wide and ry tall (those are half-widths, like a radius).

draw.fill_polygon(points) / draw.stroke_polygon(points) A closed shape through a list of (x, y) pairs; the last point is joined back to the first. Needs at least two points.

And the canvas's own one-call rectangles:

draw.fill_rect(x, y, w, h) / draw.stroke_rect(x, y, w, h) Rectangle with its top-left corner at (x, y).

draw.clear_rect(x, y, w, h) Erase a rectangle to transparent.

Paths#

Everything else is drawn the canvas way: start a path, add lines and curves to it, then paint it once, with fill() or stroke() or both. A path is not visible until painted, and it stays the current path until the next begin_path(), which is why a shape can be filled and then stroked without being described twice.

pythonOpen
from primopy import draw

draw.begin_path()
draw.move_to(50, 150)
draw.line_to(150, 30)
draw.line_to(250, 150)
draw.close_path()
draw.fill_style = "tomato"
draw.fill()
draw.stroke()

draw.begin_path() Start a new path, forgetting the old one.

draw.move_to(x, y) Lift the pen and put it down at (x, y): the start of a line.

draw.line_to(x, y) A straight line from the pen to (x, y).

draw.close_path() Join the end back to the start. fill() closes a shape by itself; stroke() does not, so an outline needs this or it has a gap.

draw.arc(x, y, r, start, end, anticlockwise=False) Part of a circle around (x, y), from start to end degrees, clockwise. draw.arc(x, y, r, 0, 180) is the bottom half; 180, 360 is the top half; 0, 360 is the whole circle. If the pen is already down somewhere, a straight line joins it to the start of the arc, which is what makes a pie slice: move_to the centre, arc, close_path, fill.

draw.ellipse(x, y, rx, ry, rotation=0, start=0, end=360, anticlockwise=False) Part of an ellipse, turned by rotation degrees. With the defaults, the whole ellipse.

draw.arc_to(x1, y1, x2, y2, r) A rounded corner of radius r on the way from the pen towards (x1, y1) and on to (x2, y2).

draw.rect(x, y, w, h) / draw.round_rect(x, y, w, h, radii) Add a rectangle to the path, the second with rounded corners: one radius for all four, or a list of them.

draw.quadratic_curve_to(cpx, cpy, x, y) / draw.bezier_curve_to(cp1x, cp1y, cp2x, cp2y, x, y) A curve to (x, y), bent towards one control point or two.

draw.fill() / draw.stroke() Paint the current path, solid in fill_style or as an outline in stroke_style.

draw.clip() From now on, paint only inside the current path. There is no un-clip; wrap it in save() and restore().

draw.set_line_dash(lengths) Stroke with dashes from here on: a list of on and off lengths, [8, 4] for dashes, [2, 6] for dots, [] for solid lines again. line_dash_offset slides the pattern along, which animates a marching outline.

Transforms#

A transform moves the paper under the pen. After translate(100, 100), the point you call (0, 0) is drawn at (100, 100); after rotate(45), everything is drawn turned. Transforms accumulate, so the usual pattern is save(), move and turn, draw, restore(), which puts the paper back where it was.

draw.translate(x, y) Move the origin by (x, y).

draw.rotate(degrees) Turn the drawing clockwise around the origin. To turn a shape around its own centre, translate to the centre first, rotate, and draw the shape around (0, 0).

draw.scale(sx, sy) Stretch by sx across and sy down. draw.scale(1, -1) flips y so that maths coordinates draw the right way up.

draw.transform(a, b, c, d, e, f) / draw.set_transform(a, b, c, d, e, f) / draw.reset_transform() The matrix forms: multiply the current transform by one, replace it, or go back to none.

State#

draw.save() / draw.restore() save() remembers every style, the transform and the clip; restore() puts them all back. Calls nest, so a function that changes the pen can begin with save() and end with restore() and leave no trace on whoever called it.

Gradients#

A gradient is a colour that changes from place to place. Make one, give it colour stops, and assign it to fill_style or stroke_style as you would a colour:

pythonOpen
from primopy import draw

w, h = draw.size()
g = draw.create_linear_gradient(0, 0, 0, h)   # top to bottom
g.add_color_stop(0, "navy")
g.add_color_stop(1, "orange")
draw.fill_style = g
draw.fill_rect(0, 0, w, h)

draw.create_linear_gradient(x0, y0, x1, y1) Along the line from one point to the other; beyond the ends the end colours continue.

draw.create_radial_gradient(x0, y0, r0, x1, y1, r1) From a circle out to a larger one, usually with the same centre: a glow or a shaded ball.

draw.create_conic_gradient(start, x, y) Sweeping around (x, y) like a clock hand, from start degrees: a colour wheel.

gradient.add_color_stop(offset, colour) The colour at offset, from 0 at the start to 1 at the end. Add as many as you like, in any order. Gradient coordinates are drawing units on the canvas, not positions within the shape, so a gradient made for the whole page shades a small rectangle with only the slice of it that the rectangle covers.

The objects these return are of class Gradient, importable as from primopy import Gradient should you ever need the name. A gradient belongs to the run that made it: keep one in a variable across runs and assigning it raises, so make it again.

Text#

draw.write(x, y, text, size=16, font="sans") Write text with its top-left corner at (x, y), in fill_style. size is the height in drawing units. font is "sans", "serif" or "mono", the keys of FONTS (see below), or the name of any font family the page has loaded. Anything you pass is converted with str(), so numbers are fine. This is the beginner's call: size and face in the call, nothing to set first.

draw.fill_text(text, x, y) / draw.stroke_text(text, x, y) The canvas's own, in the font style, positioned by text_align and text_baseline. Note the order: text first, as on the canvas. Use these when you want a bold or italic face (draw.font = "italic bold 32px serif"), an outline, or text centred on a point:

pythonOpen
from primopy import draw

w, h = draw.size()
draw.font = "bold 40px sans-serif"
draw.text_align = "center"
draw.text_baseline = "middle"
draw.fill_text("centred", w / 2, h / 2)

draw.measure_text(text) → width How wide text would be in the current font, in drawing units. For centring, text_align is simpler; measure_text is for when the width itself matters, such as underlining a word, drawing a box around it, or fitting a label to a bar.

FONTS A dictionary mapping the three short names to real font families. It lives in the module rather than on the context, so it takes its own import: from primopy import draw, FONTS. You can add your own entry: FONTS["fancy"] = "Georgia".

Hit tests#

draw.is_point_in_path(x, y) → True / False Whether (x, y) lies inside the current path. Build the path, then ask, before the next begin_path().

draw.is_point_in_stroke(x, y) → True / False Whether (x, y) lies on the outline of the current path, at the current line_width.

The point is in plain drawing units, before any transform you have set, which is exactly the form a mouse position comes in. So a button is a path, and "was it clicked" is one call; see Buttons in the examples.

The keyboard#

A game needs to know what the player pressed while it runs. The page collects every press as it happens; the functions below hand them over. Keys have short names: "a", "left", "right", "up", "down", "space", "enter", "escape", "shift". Letters are always lower case, and the modifier keys never come out of key(): Shift+A is the press "a", and pressed("shift") tells A from a.

draw.key() → "left", "a", … or None The next key that was pressed, or None if nothing was. Presses are queued in order, so a quick double tap is not lost. Holding a key down counts as one press. This is the one to use for turning, jumping or choosing.

draw.pressed(name) → True / False Whether the key called name is held down right now. For steady movement: if draw.pressed("right"): x = x + 3.

draw.keys() → list of names Every key pressed since the last call, oldest first.

draw.keys_down() → set of names Every key held down right now.

draw.wait_key() → a name Waits until a key is pressed and returns it. For "press any key to start".

When a program first asks for a key, the canvas takes the keyboard: the arrows steer instead of scrolling, and Space no longer presses the Run button. Click in the editor to type there again; click the drawing to give the game the keys back.

The mouse#

The same arrangement as the keyboard: the page watches the pointer over the drawing and answers when asked. Positions are in drawing units, the same ones size() reports and shapes are drawn in, so a click can be compared with a shape without any conversion. On a touch screen a finger is the mouse: a finger down is a button held, and a tap is a click.

draw.mouse() → (x, y) Where the pointer last was over the drawing. Before it has moved over the drawing at all, (0, 0).

draw.mouse_down() → True / False Whether a button is held down right now, or a finger is on the screen. For painting and dragging.

draw.click() → (x, y) or None The next click, or None if there was none. A click is counted when the button is let go, at the place it was let go, and clicks are queued in order like key presses, so none is lost between two frames. This is the one to use for choosing and placing.

draw.clicks() → list of (x, y) pairs Every click since the last call, oldest first.

Asking the page a question, whether for keys, the mouse, a measurement or a hit test, needs the same shared memory as input(), which primopy.com has. A copy of the editor served without the two cross-origin headers cannot answer, and there those calls raise an error; colours are then taken on trust instead of being checked.

6Examples#

All of these are complete programs. Paste one into the editor and press Run.

A face#

Two pens at work: the head is filled in one colour and outlined in another, and the smile is an arc built as a path and stroked.

pythonOpen
from primopy import draw

w, h = draw.size()
cx, cy = w / 2, h / 2
r = min(w, h) / 3

draw.clear("skyblue")
draw.stroke_style = "black"
draw.line_width = 3

draw.fill_style = "gold"
draw.fill_circle(cx, cy, r)                        # head
draw.stroke_circle(cx, cy, r)                      # and its outline
draw.fill_style = "black"
draw.fill_circle(cx - r / 3, cy - r / 4, r / 8)    # eyes
draw.fill_circle(cx + r / 3, cy - r / 4, r / 8)

draw.begin_path()
draw.arc(cx, cy + r / 6, r / 2, 20, 160)           # smile: lower part of a circle
draw.stroke()

A grid with loops#

range plus line is the workhorse of most drawings.

pythonOpen
from primopy import draw

w, h = draw.size()
step = 40

draw.clear("white")
draw.color("lightgray")
for x in range(0, w, step):
    draw.line(x, 0, x, h)
for y in range(0, h, step):
    draw.line(0, y, w, y)

draw.color("black")
draw.line(0, h / 2, w, h / 2)   # axes
draw.line(w / 2, 0, w / 2, h)

A bar chart#

Data → rectangles, with labels underneath. Note how the bar height is subtracted from the baseline because y grows downward, and how fill_style is switched between the bars and the text, since write paints with it too.

pythonOpen
from primopy import draw

scores = {"Mon": 3, "Tue": 7, "Wed": 5, "Thu": 9, "Fri": 6}

w, h = draw.size()
margin = 40
base = h - margin
gap = (w - 2 * margin) / len(scores)
tallest = max(scores.values())

draw.clear("white")
draw.stroke_style = "black"
draw.line(margin, base, w - margin, base)

for i, (day, value) in enumerate(scores.items()):
    x = margin + i * gap + gap * 0.15
    bar_h = (base - margin) * value / tallest
    draw.fill_style = "steelblue"
    draw.fill_rect(x, base - bar_h, gap * 0.7, bar_h)
    draw.fill_style = "black"
    draw.write(x, base + 6, day, size=14)
    draw.write(x, base - bar_h - 20, value, size=14)

A graph of a function#

A curve is a path of many short segments: move_to the first point, line_to the rest, stroke once. This maps maths coordinates (x from −2π to 2π, y from −1 to 1) onto the canvas.

pythonOpen
from primopy import draw
import math

w, h = draw.size()
draw.clear("white")

draw.color("gray")
draw.line(0, h / 2, w, h / 2)

draw.color("crimson")
draw.begin_path()
for px in range(0, w + 1, 2):
    x = (px / w) * 4 * math.pi - 2 * math.pi   # canvas → maths
    y = math.sin(x)
    py = h / 2 - y * (h / 2 - 20)              # maths → canvas (flip y)
    if px == 0:
        draw.move_to(px, py)
    else:
        draw.line_to(px, py)
draw.stroke()
draw.write(10, 10, "y = sin(x)", size=18, font="mono")

A regular polygon and a star#

fill_polygon takes any list of points; trigonometry produces them. Angles here are in radians because math.cos/math.sin want radians; only draw itself speaks degrees.

pythonOpen
from primopy import draw
import math

def ring(cx, cy, r, n, start=-90):
    """n points evenly spaced on a circle, first one at `start` degrees."""
    pts = []
    for i in range(n):
        a = math.radians(start + i * 360 / n)
        pts.append((cx + r * math.cos(a), cy + r * math.sin(a)))
    return pts

w, h = draw.size()
draw.clear("midnightblue")
draw.stroke_style = "white"

# hexagon on the left
draw.fill_style = "seagreen"
draw.fill_polygon(ring(w * 0.3, h / 2, 80, 6))
draw.stroke_polygon(ring(w * 0.3, h / 2, 80, 6))

# five-pointed star on the right: alternate outer and inner radius
outer = ring(w * 0.7, h / 2, 90, 5)
inner = ring(w * 0.7, h / 2, 36, 5, start=-90 + 36)
star = [p for pair in zip(outer, inner) for p in pair]
draw.fill_style = "gold"
draw.fill_polygon(star)

A pie chart, in degrees#

Each slice is a path: the centre, an arc, and back. Degrees make the arithmetic plain: a share of the total is a share of 360.

pythonOpen
from primopy import draw

shares = {"rent": 40, "food": 25, "fun": 20, "saved": 15}
colours = ["steelblue", "salmon", "gold", "seagreen"]

w, h = draw.size()
cx, cy, r = w / 2, h / 2, min(w, h) / 3
draw.clear("white")

start = -90                                   # begin at the top
for (name, share), colour in zip(shares.items(), colours):
    end = start + 360 * share / 100
    draw.begin_path()
    draw.move_to(cx, cy)
    draw.arc(cx, cy, r, start, end)
    draw.close_path()
    draw.fill_style = colour
    draw.fill()
    draw.stroke()
    start = end

A rainbow with HSL colours#

Varying the hue in a loop is the easiest way to get many distinct colours. Each band is a half circle, stroked with a thick pen.

pythonOpen
from primopy import draw

w, h = draw.size()
draw.clear("white")
bands = 12
draw.line_width = h / bands / 2
for i in range(bands):
    draw.stroke_style = f"hsl({i * 360 / bands}, 90%, 55%)"
    draw.begin_path()
    draw.arc(w / 2, h, h * (1 - i / bands) - 10, 180, 360)
    draw.stroke()

A sunset with gradients#

A linear gradient for the sky, a radial one for the sun. Both are assigned to fill_style like a colour.

pythonOpen
from primopy import draw

w, h = draw.size()

sky = draw.create_linear_gradient(0, 0, 0, h)
sky.add_color_stop(0, "midnightblue")
sky.add_color_stop(0.6, "orangered")
sky.add_color_stop(1, "gold")
draw.fill_style = sky
draw.fill_rect(0, 0, w, h)

sun = draw.create_radial_gradient(w / 2, h * 0.7, 0, w / 2, h * 0.7, h / 4)
sun.add_color_stop(0, "white")
sun.add_color_stop(0.3, "yellow")
sun.add_color_stop(1, "rgba(255, 200, 0, 0)")   # fades to nothing
draw.fill_style = sun
draw.fill_circle(w / 2, h * 0.7, h / 4)

A clock face with transforms#

Instead of computing where each tick goes, turn the paper: translate to the centre, rotate by the hour, and draw every tick at the same place. save() and restore() undo the turn each time.

pythonOpen
from primopy import draw

w, h = draw.size()
cx, cy, r = w / 2, h / 2, min(w, h) / 2.5

draw.clear("white")
draw.stroke_circle(cx, cy, r)
draw.line_width = 4
for hour in range(12):
    draw.save()
    draw.translate(cx, cy)
    draw.rotate(hour * 30)
    draw.line(0, -r, 0, -r + 15)
    draw.restore()

draw.save()
draw.translate(cx, cy)
draw.rotate(10 * 30 + 15)                  # half past ten
draw.line_width = 6
draw.line(0, 0, 0, -r * 0.55)              # hour hand
draw.restore()
draw.save()
draw.translate(cx, cy)
draw.rotate(180)
draw.line_width = 3
draw.line(0, 0, 0, -r * 0.85)              # minute hand
draw.restore()

A dashed, clipped drawing#

clip() keeps everything after it inside a path, and set_line_dash changes the stroke. Both are undone by restore().

pythonOpen
from primopy import draw

w, h = draw.size()
draw.clear("white")

draw.save()
draw.begin_path()
draw.arc(w / 2, h / 2, min(w, h) / 3, 0, 360)
draw.clip()                                # only inside the circle from here on
for y in range(0, h, 12):
    draw.stroke_style = f"hsl({y * 360 / h}, 80%, 60%)"
    draw.line(0, y, w, y)
draw.restore()

draw.set_line_dash([10, 6])
draw.stroke_style = "black"
draw.stroke_circle(w / 2, h / 2, min(w, h) / 3)
draw.set_line_dash([])

Text placement#

write anchors at the top-left and takes its size in the call. The canvas's fill_text uses the font style and can be centred with text_align, and measure_text says how wide a word will be, so that a box fits it.

pythonOpen
from primopy import draw

w, h = draw.size()
draw.clear("#222")
draw.fill_style = "white"
draw.stroke_style = "gold"

draw.write(10, 10, "top-left, sans 16")
draw.write(10, 34, "serif, 20", size=20, font="serif")

draw.font = "bold 48px monospace"
draw.text_align = "center"
draw.text_baseline = "middle"
draw.fill_text("primopy", w / 2, h / 2)

width = draw.measure_text("primopy")
draw.stroke_rect(w / 2 - width / 2 - 10, h / 2 - 34, width + 20, 68)

A game loop: Snake#

Draw a frame, sleep a little, ask what was pressed, repeat. That is all a game loop is. Example 16 in the examples menu is the whole game; this is its shape.

pythonOpen
from primopy import draw
import time

x, y = 40, 40
dx, dy = 2, 0
TURNS = {"left": (-2, 0), "right": (2, 0), "up": (0, -2), "down": (0, 2)}

draw.fill_style = "limegreen"
while True:
    k = draw.key()                      # None, or a key pressed since last frame
    if k in TURNS:
        dx, dy = TURNS[k]
    if k == "escape":
        break
    x, y = x + dx, y + dy
    draw.clear("black")
    draw.fill_circle(x, y, 8)
    time.sleep(0.03)                    # shows the frame, then waits

Painting with the mouse#

mouse() says where the pointer is and mouse_down() whether it is pressed, so a drag leaves a trail. Nothing is cleared between frames here: the drawing accumulates.

pythonOpen
from primopy import draw
import time

draw.clear("white")
draw.fill_style = "crimson"
while True:
    x, y = draw.mouse()
    if draw.mouse_down():
        draw.fill_circle(x, y, 6)
    k = draw.key()
    if k == "c":
        draw.clear("white")
    if k == "escape":
        break
    time.sleep(0.02)

Buttons: hit tests and the mouse#

A button is a path. Draw it once, and when a click arrives, build the same path again and ask is_point_in_path. The click is in drawing units, and so is the path, so nothing needs converting.

pythonOpen
from primopy import draw
import time

w, h = draw.size()
buttons = {"crimson": 30, "seagreen": 100, "steelblue": 170}   # colour: y

def button(y):
    draw.begin_path()
    draw.round_rect(30, y, 140, 50, 12)

draw.clear("white")
for colour, y in buttons.items():
    button(y)
    draw.fill_style = colour
    draw.fill()
    draw.fill_style = "white"
    draw.fill_text(colour, 45, y + 17)

while True:
    c = draw.click()
    if c is not None:
        for colour, y in buttons.items():
            button(y)
            if draw.is_point_in_path(c[0], c[1]):
                draw.fill_style = colour
                draw.fill_circle(w * 0.7, h / 2, min(w, h) / 5)
    if draw.key() == "escape":
        break
    time.sleep(0.03)

Recursion: a tree#

Drawings make recursion visible. Record this one and scrub the tape: the tree grows branch by branch.

pythonOpen
from primopy import draw
import math

def branch(x, y, length, angle, depth):
    if depth == 0:
        return
    x2 = x + length * math.cos(math.radians(angle))
    y2 = y - length * math.sin(math.radians(angle))
    draw.line_width = depth
    draw.line(x, y, x2, y2)
    branch(x2, y2, length * 0.72, angle + 25, depth - 1)
    branch(x2, y2, length * 0.72, angle - 25, depth - 1)

w, h = draw.size()
draw.clear("white")
draw.stroke_style = "saddlebrown"
branch(w / 2, h - 10, h / 4, 90, 9)

The same tree with transforms, and no trigonometry: each branch turns the paper and draws straight up.

pythonOpen
from primopy import draw

def branch(length, depth):
    if depth == 0:
        return
    draw.line_width = depth
    draw.line(0, 0, 0, -length)
    draw.translate(0, -length)
    for turn in (25, -25):
        draw.save()
        draw.rotate(turn)
        branch(length * 0.72, depth - 1)
        draw.restore()

w, h = draw.size()
draw.clear("white")
draw.stroke_style = "saddlebrown"
draw.translate(w / 2, h - 10)
branch(h / 4, 9)

7Drawing and the debugger#

Drawings are part of the recording. Press Record & step, then move along the tape: the canvas is rebuilt to show exactly what had been drawn at that step, so a loop that draws a bar per iteration shows one more bar each time you step forward, and one fewer each time you step back. A stage is taken when ink lands on the canvas: fill, stroke, the rectangle and text calls and everything built on them. Setting a style, moving the pen or adding to a path changes nothing visible, so it makes no stage of its own. Up to sixty stages of a drawing are kept per recording; a program that draws more than that keeps every second stage, then every fourth, so the whole run stays covered at a coarser spacing. A program that sleeps between drawings is animating, and there a stage is one whole frame: what the canvas showed when the sleep began.

This is the most useful thing about draw for learning: put a drawing call inside the code you don't understand and watch when it happens.

8Limits and gotchas#

  • primopy only exists inside primopy. If you copy a program that uses it into another Python installation, from primopy import draw will fail there; that's expected, not a mistake in your code. To share a drawing program, share the primopy link instead. For drawing elsewhere, look at turtle (bundled with Python), tkinter, or pygame, and for a web page, the canvas itself: the names are the same ones, in camelCase.
  • y is upside down compared to graph paper. To plot maths, flip it: py = h - y, or draw.scale(1, -1) after translating the origin to the bottom.
  • draw uses degrees, math uses radians. Convert with math.radians() / math.degrees().
  • Styles live on draw. draw.fill_style = "gold" changes the pen; a bare fill_style = "gold" makes an ordinary variable of your own and draws nothing. Keep draw. in front.
  • A path is painted, not drawn. move_to, line_to and arc add to a path; nothing appears until fill() or stroke(). If a shape is missing, the paint call probably is.
  • Animation is a loop you write. There is no frame callback: draw, time.sleep(), repeat. Every sleep shows what has been drawn so far, and so does every key and mouse function. A recording (Record & step) stops after 5000 steps, about 20 seconds of Snake, so record a short game and run a long one.
  • No images or raw pixels. The canvas's drawImage, getImageData, putImageData and patterns are not mirrored: images would have to cross from the page into Python and back, and a pixel at a time is slow across that bridge. Draw with shapes; a fractal is fill_rect with a 1×1 size, one call per cell, which is fine for a few thousand cells.
  • Questions need the page's shared memory. The keyboard, the mouse, measure_text and the hit tests wait for an answer, which only a cross-origin-isolated page can give; primopy.com is one. Everything that only draws works anywhere.
  • Size varies. Never hard-code 400 × 300; ask draw.size(). Resizing the console changes the canvas only at the next run.
  • Colour typos raise errors. That is deliberate: a wrong name would otherwise just draw nothing and leave you puzzled. A font string is not checked in the same way, since checking it would mean waiting for an answer on every word; an unknown face silently falls back to sans-serif.
  • clear ignores the transform and the pens; it always paints the whole area, and does not change any style.
  • Styles persist across calls, and so do transforms. A thick outline or a rotated frame that surprises you later was set earlier; save() before and restore() after is the habit that prevents it.