Drawing App

Freehand drawing with variable-width strokes, drawn as filled polygons on a Canvas. The stroke outline comes from a port of the perfect-freehand algorithm included in the file; no dependency.

What it shows

  • Canvas with on_pan_start, on_pan_update, on_pan_end
  • get_stroke(), a 110-line port of perfect-freehand, turning the pointer path into an outline
  • nib.draw.Polygon with fill for each stroke
  • canvas.draw() with the finished strokes plus the one in progress
  • A toolbar of Buttons for colour and stroke size

app_drawing

import math

import nib

# Stroke outlines: a port of perfect-freehand by Steve Ruiz (MIT),
# https://github.com/steveruizok/perfect-freehand. Round caps, no tapers.
FIXED_PI = math.pi + 0.0001
PRESSURE_RATE = 0.275


def _lerp(a, b, t):
    return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t)


def _rotate(p, c, r):
    s, co = math.sin(r), math.cos(r)
    x, y = p[0] - c[0], p[1] - c[1]
    return (x * co - y * s + c[0], x * s + y * co + c[1])


def _stroke_points(points, size, streamline, last):
    """Streamlined input points with direction, step and running length."""
    t = 0.15 + (1 - streamline) * 0.85
    pts = list(points)
    if len(pts) == 2:
        pts = [pts[0]] + [_lerp(pts[0], pts[1], i / 4) for i in range(1, 5)]
    if len(pts) == 1:
        pts.append((pts[0][0] + 1, pts[0][1] + 1))
    out = [{"p": tuple(pts[0]), "v": (1.0, 1.0), "d": 0.0, "len": 0.0}]
    prev, total, reached = out[0], 0.0, False
    for i in range(1, len(pts)):
        p = tuple(pts[i]) if last and i == len(pts) - 1 else _lerp(prev["p"], pts[i], t)
        if p == prev["p"]:
            continue
        d = math.dist(p, prev["p"])
        total += d
        if i < len(pts) - 1 and not reached:
            if total < size:
                continue
            reached = True
        v = ((prev["p"][0] - p[0]) / d, (prev["p"][1] - p[1]) / d)
        prev = {"p": p, "v": v, "d": d, "len": total}
        out.append(prev)
    out[0]["v"] = out[1]["v"] if len(out) > 1 else (0.0, 0.0)
    return out


def get_stroke(points, size=8.0, thinning=0.5, smoothing=0.5, streamline=0.5, last=False):
    """Closed outline of a stroke. Width follows speed: slow is wide, fast is thin."""
    if not points or size <= 0:
        return []
    pts = _stroke_points(points, size, streamline, last)
    total = pts[-1]["len"]
    min_dist = (size * smoothing) ** 2

    def radius_for(pressure):
        return size * (0.5 - thinning * (0.5 - pressure))

    def simulate(prev_pressure, step):
        sp = min(1.0, step / size)
        return min(1.0, prev_pressure + (min(1.0, 1 - sp) - prev_pressure) * sp * PRESSURE_RATE)

    prev_pressure = 0.25
    for q in pts[:10]:
        prev_pressure = (prev_pressure + simulate(prev_pressure, q["d"])) / 2

    radius = radius_for(0.5)
    first_radius = None
    prev_v = pts[0]["v"]
    pl = pr = pts[0]["p"]
    left, right = [], []
    prev_sharp = False
    n = len(pts)
    for i, q in enumerate(pts):
        p, v, step, run = q["p"], q["v"], q["d"], q["len"]
        if i < n - 1 and total - run < 3:
            continue
        pressure = simulate(prev_pressure, step) if thinning else 0.5
        radius = max(0.01, radius_for(pressure) if thinning else size / 2)
        if first_radius is None:
            first_radius = radius
        next_v = pts[i + 1]["v"] if i < n - 1 else v
        next_dpr = v[0] * next_v[0] + v[1] * next_v[1] if i < n - 1 else 1.0
        prev_dpr = v[0] * prev_v[0] + v[1] * prev_v[1]
        sharp = prev_dpr < 0 and not prev_sharp
        next_sharp = next_dpr < 0
        if sharp or next_sharp:
            ox, oy = prev_v[1] * radius, -prev_v[0] * radius
            for j in range(14):
                a = FIXED_PI * j / 13
                pl = _rotate((p[0] - ox, p[1] - oy), p, a)
                pr = _rotate((p[0] + ox, p[1] + oy), p, -a)
                left.append(pl)
                right.append(pr)
            prev_sharp = next_sharp
            continue
        prev_sharp = False
        if i == n - 1:
            ox, oy = v[1] * radius, -v[0] * radius
            left.append((p[0] - ox, p[1] - oy))
            right.append((p[0] + ox, p[1] + oy))
            continue
        mx, my = _lerp(next_v, v, next_dpr)
        ox, oy = my * radius, -mx * radius
        tl = (p[0] - ox, p[1] - oy)
        if i <= 1 or math.dist(pl, tl) ** 2 > min_dist:
            left.append(tl)
            pl = tl
        tr = (p[0] + ox, p[1] + oy)
        if i <= 1 or math.dist(pr, tr) ** 2 > min_dist:
            right.append(tr)
            pr = tr
        prev_pressure, prev_v = pressure, v

    first, last_p = pts[0]["p"], pts[-1]["p"]
    if first_radius is None:
        first_radius = radius
    if n == 1:
        dx, dy = first[0] - (first[0] + 1), first[1] - (first[1] + 1)
        d = math.hypot(dx, dy)
        start = (first[0] - dy / d * -first_radius, first[1] + dx / d * -first_radius)
        return [_rotate(start, first, FIXED_PI * 2 * j / 13) for j in range(1, 14)]
    start_cap = [_rotate(right[0], first, FIXED_PI * j / 13) for j in range(1, 14)]
    lv = pts[-1]["v"]
    dx, dy = -lv[1], lv[0]
    start = (last_p[0] + dx * radius, last_p[1] + dy * radius)
    end_cap = [_rotate(start, last_p, FIXED_PI * 3 * j / 29) for j in range(1, 30)]
    return left + end_cap + right[::-1] + start_cap


def main(app: nib.App):
    app.title = "Draw"
    app.icon = nib.SFSymbol("pencil.tip")
    app.width = 560
    app.height = 380

    canvas = nib.Canvas(
        width=540,
        height=300,
        background_color="#FFFFFF",
        enable_gestures=True,
    )

    color = "#000000"
    size = 8
    strokes = []  # finished strokes, one Polygon each
    points = []  # the stroke being drawn

    def outline(pts, last=False):
        return nib.draw.Polygon(
            points=get_stroke(pts, size=size, thinning=0.6, last=last),
            fill=color,
        )

    def redraw():
        current = [outline(points)] if len(points) > 1 else []
        canvas.draw(strokes + current)

    def on_pan_start(e: nib.PanEvent):
        points.clear()
        points.append((e.x, e.y))

    def on_pan_update(e: nib.PanEvent):
        points.append((e.x, e.y))
        redraw()

    def on_pan_end(e: nib.PanEvent):
        if len(points) > 1:
            strokes.append(outline(points, last=True))
        points.clear()
        redraw()

    canvas.on_pan_start = on_pan_start
    canvas.on_pan_update = on_pan_update
    canvas.on_pan_end = on_pan_end

    def clear_canvas():
        strokes.clear()
        points.clear()
        canvas.clear()

    def set_color(value):
        def apply():
            nonlocal color
            color = value

        return apply

    def set_size(value):
        def apply():
            nonlocal size
            size = value

        return apply

    # A sample stroke so the canvas is not empty at launch
    sample = [(40 + i * 4.6, 150 + 60 * math.sin(i / 12)) for i in range(100)]
    strokes.append(outline(sample, last=True))
    redraw()

    app.build(
        nib.VStack(
            controls=[
                nib.HStack(
                    controls=[
                        nib.Button("Clear", action=clear_canvas),
                        nib.Spacer(),
                        nib.Text("Color:", font=nib.Font.CAPTION),
                        nib.Button("Black", action=set_color("#000000")),
                        nib.Button("Red", action=set_color("#e74c3c")),
                        nib.Button("Blue", action=set_color("#3498db")),
                        nib.Button("Green", action=set_color("#2ecc71")),
                        nib.Spacer(),
                        nib.Text("Size:", font=nib.Font.CAPTION),
                        nib.Button("Thin", action=set_size(4)),
                        nib.Button("Med", action=set_size(8)),
                        nib.Button("Thick", action=set_size(14)),
                    ],
                    spacing=4,
                ),
                nib.VStack(
                    controls=[canvas],
                    background=nib.Rectangle(
                        corner_radius=5,
                        stroke="#cccccc",
                        stroke_width=1,
                    ),
                ),
            ],
            spacing=10,
            padding=10,
        )
    )


nib.run(main)

Walkthrough

Canvas

canvas = nib.Canvas(
    width=540,
    height=300,
    background_color="#FFFFFF",
    enable_gestures=True,
)

enable_gestures=True makes the canvas emit pan events. PanEvent carries x and y in canvas coordinates; there is no pressure value, so the stroke width is simulated from speed.

Gestures

def on_pan_start(e: nib.PanEvent):
    points.clear()
    points.append((e.x, e.y))

def on_pan_update(e: nib.PanEvent):
    points.append((e.x, e.y))
    redraw()

def on_pan_end(e: nib.PanEvent):
    if len(points) > 1:
        strokes.append(outline(points, last=True))
    points.clear()
    redraw()

points holds the stroke being drawn. On release it becomes one entry in strokes and the list is reset.

From points to an outline

def outline(pts, last=False):
    return nib.draw.Polygon(
        points=get_stroke(pts, size=size, thinning=0.6, last=last),
        fill=color,
    )

get_stroke() returns the closed outline of the stroke as a list of (x, y) points, so a filled Polygon renders it. It is a port of perfect-freehand by Steve Ruiz, MIT, kept in the example file. Two passes:

  1. _stroke_points() streamlines the input: each new point moves only part of the way from the previous one, which removes jitter. It also records direction, step length and running length per point.
  2. get_stroke() walks those points and offsets left and right by a radius. The radius follows simulated pressure: a short step means slow movement and a wider stroke. Sharp corners get a 14-point arc, the ends get round caps, and the left side, end cap, reversed right side and start cap join into one polygon.

Parameters:

Parameter Effect
size Base width in points
thinning How much speed changes the width. 0 is constant width
smoothing Minimum distance between outline points, as a fraction of size
streamline How much the input path is smoothed. 0 follows the pointer exactly
last True once the stroke is complete. The final point is then used as is

size and color are read when outline() runs, so a finished stroke keeps the settings it was drawn with.

Redraw

def redraw():
    current = [outline(points)] if len(points) > 1 else []
    canvas.draw(strokes + current)

The outline of the current stroke changes with every new point, so the demo redraws the whole canvas rather than appending. canvas.draw() replaces all commands. With a few hundred strokes this is still well under a frame.

Toolbar

def set_color(value):
    def apply():
        nonlocal color
        color = value
    return apply

nib.Button("Red", action=set_color("#e74c3c"))

Each button gets a closure that sets color or size. Clear empties strokes and calls canvas.clear().

Sample stroke

A sine wave is drawn at launch so the popover is not empty. Delete the three sample lines in your own app.