The MicroPython binding to surfer. The same module runs on every port: the desktop (SDL), the ESP32-P4, the web and iOS.
Arguments are positional. The name=default forms below show what
you may leave off; only Node.tween also accepts keywords.
A method on the wrong kind of node is usually silently ignored
(set_color on a label does nothing). The ones that raise are the
transform, opacity, fade, tween, hitbox and set_image calls.
make mpy # builds MicroPython (MPY_DIR ?= ~/micropython) with the surfer module
~/micropython/ports/unix/build-standard/micropython bindings/surfer/repl.pyrepl.py boots an on-screen REPL with surfer and screen in scope, so
everything below can be typed live.
import surfer
surfer.init(1024, 600) # w, h, single=False. Open the display, first.
# Calling it again rebuilds the scene (and resizes)
surfer.tick() # input, animation, compose, present
# -> False when the window is closed
surfer.screen() # the root Node
surfer.rgb(r, g, b) # 0-255 each -> a packed RGB565 colour int
surfer.frame_rate(fps) # lock tick() to a divisor of the refresh; 0 uncaps.
# -> the rate actually locked
surfer.cpu() # busy percent per core since the last callThe app owns the loop:
while surfer.tick():
for kind, text, shift, ctrl in surfer.keys():
...Callbacks fire from inside surfer.tick(), on the same thread.
On the web a blocking loop would freeze the tab, so the browser
drives one frame per requestAnimationFrame and an app registers its
per-frame work as a hook:
import sys, repl
if sys.platform == "webassembly":
repl.app_frame = my_step # called once per frame; return False to unhook
else:
while surfer.tick():
if my_step() is False:
breakframe_rate(fps) is for games: it holds tick() to a steady rate the
panel can divide into, so motion doesn't wobble with load. The panel's
refresh differs by board (60.4 Hz on the P4X, 69.7 Hz on the v1.x EV
board), so scale speeds by the value it returns.
Nodes are the scene: pooled, retained, cheap. Add one to a parent and the compositor repaints only what changes.
| factory | |
|---|---|
surfer.group(x, y) |
a container; draws nothing |
surfer.rect(x, y, w, h, color=grey) |
a solid, opaque rectangle |
surfer.label(text, x, y, color=white, font="ui12") |
proportional text |
surfer.textgrid(cols, rows, fg, bg, font="mono16") |
a grid of character cells (needs a monospace font) |
surfer.textinput(x, y, w, color=white, font="ui12") |
one editable line |
surfer.textarea(x, y, w, rows, color=white, font="ui12") |
editable, wrapped, rows lines tall |
surfer.scrollview(x, y, w, h) |
a clipped viewport; drag and flick scroll its children |
surfer.sprite(img, x, y) |
an image on screen |
surfer.filmstrip(img, frame_w, frame_h, x, y) |
an animation |
surfer.layer(img, x, y, view_w) |
a wrapping, scrolling window onto a wide image |
A font is a name ("ui28", "mono16"; surfer.fonts() lists them), a
Font object, or a registry index. The FONT_UI16, FONT_UI28 and
FONT_MONO16 constants are those names.
Every node:
n.x_pos, n.y_pos # position in the parent: integers, read/write
n.w, n.h # size, read only
n.hidden = True # hide the subtree (write only)
n.add(child) # a node or a widget
n.detach() # out of the tree, all state kept
n.destroy() # detach and free the subtree
n.damage() # force a repaint (after you drew into its image)
n.hits(other) # collision, see below
n.on_touch = fn # fn(phase, x, y), screen coordinates; phase TOUCH_DOWN/MOVE/UPdetach() and add() round-trip losslessly, which is how a host swaps
whole screens.
Per type:
label.set_text("new text") # also textinput / textarea
label.set_wrap(120) # wrap width in pixels (0 = one line)
label.set_align(surfer.ALIGN_CENTER) # ALIGN_LEFT / CENTER / RIGHT
rect.set_color(surfer.rgb(230, 150, 60))
group.set_clip(w, h) # give a group a size: it clips, and can be tapped
grid.set_row(row, "text") # a whole row, space-padded
grid.set_cell(col, row, "A", fg=0xffff, bg=0) # one cell; a character or a codepoint
grid.set_cells(col, row, "a run", fg=0xffff, bg=0)
grid.set_colors(fg, bg) # the default colours
grid.grid_scroll(rows) # +n scrolls content up
grid.scrollback(mult) # keep mult screens of history -> bool
grid.view([n]), grid.history() # how far back the view is / how much there is
sv.scroll_to(x, y) # clamped
sv.scroll_offset() # -> (x, y)
layer.set_offset(px) # float pixels; wraps at the image width
n.fast_scroll(True) # layer, sprite, scrollview, textgrid: see LayersA handler goes on the topmost node you tap, then walks up its parents. A handler on a background rect never sees a tap that lands on a label drawn over it; put it on a group containing both. A scrollview steals a child's gesture after 8 px of travel, so act on TOUCH_UP near the TOUCH_DOWN point for a tap.
Collision: a.hits(b) compares the two boxes, then (for sprites and
filmstrips) the pixels: transparent pixels don't collide. Hidden or
detached nodes never hit. If a has hitboxes, the answer is
a tuple of which of a's boxes hit (empty means no).
img = surfer.image(open("ship.png", "rb").read()) # decode PNG bytes
img = surfer.image(png_bytes, True) # ...as an A8 mask (alpha only)
img = surfer.image_new(w, h) # blank RGB565, black
img = surfer.image_new(w, h, True) # blank ARGB, transparent
img = surfer.image_new(w, h, surfer.A8) # blank mask
img = surfer.image_new(w, h, fmt, True) # ...in fast internal RAM if there is some
img.w, img.h, img.format, img.stride
img.tint = surfer.rgb(255, 40, 200) # an A8 image's colour
img.destroy() # after every sprite using it is gone
surfer.image_scale(dst, src) # scale src to fill dst -> True if hardware did itDecode images at load time, not per frame. An image is not freed when
Python drops it: destroy() it, and only after the nodes showing it are
gone.
s = surfer.sprite(img, x, y)
screen.add(s)
s.x_pos = 300 # the compositor repaints whatever it uncovers
s.scale = 1.5 # 1/16 .. 16
s.rot = 90 # degrees, multiples of 90 only
s.mirror_x = True # mirrors apply before rotation
s.mirror_y = True
s.w, s.h # the transformed footprint
s.set_src(x, y, w, h) # show one window of a big image (a sprite sheet cell, or a camera)
s.set_image(other) # show a different picture on the same nodescale, rot and the mirrors raise on anything but a sprite or
filmstrip. set_image resets the window to the whole new picture and
keeps the node's scale, rotation, mirror and hitboxes.
A8 masks are a one-colour silhouette. Change img.tint and
damage() the sprite: every sprite showing that image recolours, and it
costs one repaint (on the P4 the tint is applied in hardware).
An animation is one image of frames, left to right (and row by row for a grid):
strip = surfer.image(open("walk.png", "rb").read())
f = surfer.filmstrip(strip, 64, 48, x, y) # frame w, frame h
f.w, f.h # one frame
f.frame = 2 # pick a frame
f.fps = 12.0 # play at 12 fps; 0 means you set .frame yourself
f.play([fps]) # start (optionally at a new speed)
f.stop() # freeze, keeping fps
f.playingLate frames are dropped, not replayed. set_image keeps the frame size
and recounts the frames.
At load time, not per frame. A paint is a colour, (colour, alpha),
or a gradient ((x0, y0, c0[, a0]), (x1, y1, c1[, a1])).
g = surfer.image_new(512, 300)
g.fill(color) # the whole image, opaque
g.fill(color, x, y, w, h) # a rectangle of it
g.poly([(0, 300), (256, 20), (512, 300)], paint) # filled, anti-aliased
g.line(x0, y0, x1, y1, paint, width=1) # round caps
g.lines([(0, 0), (40, 80), (90, 20)], paint, width=1)
g.circle(cx, cy, r, paint, width=0) # 0: filled
g.ellipse(cx, cy, rx, ry, paint, width=0)
g.bezier([p0, c, p1], paint, width=2) # 3 points: quadratic
g.bezier([p0, c0, c1, p1], paint, width=2) # 4 points: cubic
g.blit(src, x, y, rot=0) # composite src; rot in quarter turnsAn image supports the buffer protocol:
mv = memoryview(img) # img.stride bytes a row; format 0 RGB565, 1 ARGB, 2 A8
... # write pixels
img.flush() # publish them to the hardware (a no-op where the CPU draws)
sprite.damage() # and repaint what shows themForget flush() and the desktop is perfect while the P4 tears.
open("out.png", "wb").write(surfer.write_png(img))
shot = surfer.fb_image() # the screen as an Image (a copy)
shot = surfer.fb_image(0, 0, 320, 240) # ...a region
surfer.fb_read(x, y, w, h) # a region as RGB888 bytes
surfer.screenshot(path) # a PPM file (desktop and web only)write_png(fb_image()) is a PNG screenshot on every port. An A8 image
saves as white with its alpha.
For scrolling backgrounds, bake the tiles into one wide image at load time and scroll it as a layer: one blit per frame instead of one per tile.
strip = surfer.image_new(2048, 128)
strip.fill(surfer.rgb(92, 148, 218))
tile = surfer.image(open("grass.png", "rb").read())
for x in range(0, 2048, 64):
strip.blit(tile, x, 0)
tile.destroy()
l = surfer.layer(strip, 0, y, 1024)
screen.add(l)
l.fast_scroll(True)
l.set_offset(px) # wraps at the image widthWith fast_scroll(True) (an opaque image) each frame's motion is one
hardware band shift plus a thin repaint. Fast layers must not overlap
each other, and anything drawn over one should be a later sibling. When
sprites move over a fast layer, call set_offset first, then move the
sprites.
sprite.fast_scroll(True) does the same for a sprite whose set_src
window pans across a big opaque image (a game camera over a baked
world).
n.opacity = 0.4 # 0..1; sprite, filmstrip, layer, label
n.fade_out(ms=250)
n.fade_in(ms=250)
n.fade_to(0.4, ms=250)
n.fade_cancel()
n.fading
n.tween("x_pos", 300, 1000, ease="out") # prop, to, ms=250, ease=None, start=None
n.tween_cancel("x_pos") # or no argument: all
n.tweening("x_pos") # or no argument: anyTweenable: "x_pos", "y_pos", "scale", "opacity". Eases:
"linear" (default), "in", "out", "in_out". Several tweens can run
on one node; setting a property directly cancels only that property's
tween. A group, rect or textgrid cannot fade and raises. Opacity is
visual only: taps and hits() ignore it.
A sprite or filmstrip can carry up to 32 collision boxes, which follow its scale, mirror and rotation.
hb = s.hitboxes.add(0, 8, 8, 8) # x, y, w, h in the unrotated picture -> a Hitbox
len(s.hitboxes); s.hitboxes[0]; s.hitboxes.clear()
hb.x, hb.y, hb.w, hb.h # read/write
hb.index # what hits() reports it as
hb.visible = True # draw its outline (for debugging)
hb.visible_color = surfer.rgb(0, 255, 0)
hb.remove() # later boxes shift downWhile a node has hitboxes they are its collision shape; the other node's pixels still count.
surfer.fonts() # every face's name
surfer.fonts(True) # the monospace ones
f = surfer.font("mono16") # a name, or TTF-baked bytes
f.cell_w, f.cell_h # a mono face's cell
f.codepoints() # every character it has
surfer.widget_font("ui12") # the face widgets are built with, from now on
surfer.widget_font() # -> its name
surfer.emoji("fire") # the character: it goes in any label or grid
surfer.emoji() # every name
img = surfer.text_image(s, color, font="ui12", wrap_w=0) # text baked into an ARGB imageA label can't scale; text_image on a sprite can. Emoji are a fallback
face: any codepoint the text face lacks is looked up in the colour emoji
set. An emoji takes two cells in a textgrid (set_row allows for that;
set_cells doesn't).
A textinput or textarea draws the text and a caret, nothing else, and has no keyboard of its own: feed it keys.
ti = surfer.textinput(x, y, 240)
ti.text, ti.caret # read/write
ti.focus(True)
ti.mask = "*" # a password field; None shows the text
for k in surfer.keys():
if not ti.key(k): # False when it didn't use the key
... # Enter (in a one-line field) and hotkeys fall through
ti.insert(s); ti.backspace(); ti.delete(); ti.move(delta, extend=False)
ti.index_from_x(x)
ta = surfer.textarea(x, y, 400, 5)
ta.rows, ta.lines # lines shown / lines the text takes
ta.scroll_y # read/writeA tap places the caret and a drag selects. key() refuses Tab.
Prebuilt controls. They report through .callback and hold .value;
setting .value from code does not fire the callback. A widget must
be added to a node before it draws.
| factory | .value |
callback gets |
|---|---|---|
slider(x, y, w=48, h=330) |
0.0-1.0 | float |
knob(x, y, size=64) |
0.0-1.0 | float |
checkbox(x, y) |
bool | bool |
dropdown(x, y, w, ["a", "b"]) |
index | int |
button(x, y, w, h, label="") |
None | True, on release |
selector(x, y, positions) |
index | int |
radio(x, y, labels, vertical=True) |
index | int |
tabs(x, y, w, h, labels, tab_h=36, face, dim, text, text_active) |
index | int |
colorpicker(x, y, size) |
colour | int |
scrollbar(x, y, len, vertical=True) |
position | int |
led(x, y, color=red) |
brightness 0..1 (or bool) | never called |
- slider: wider than tall is horizontal. Under 30 px across it uses compact art.
- knob: vertical drag.
sizepicks between a 40 px knob (size < 52) and a 64 px one.kn.on_tap = fn(where)handles a tap, 0..1 down the knob. - selector: a knob with N detents; a tap advances one.
- scrollbar:
sb.set_range(total, visible, pos=0)in your own unit; it hides itself when there is nothing to scroll. - tabs:
hincludes the strip.t.page(i)is a group to fill; the widget shows one page at a time.t.set_label(i, s),t.set_face(i, c),t.set_dim(i, c). Givefaceyour page's background colour so the tab and page read as one. - radio:
Falsefor a row.
Every widget:
w.value; w.callback = fn
w.node # its root Node, for tree operations
w.x_pos, w.y_pos, w.w, w.h
w.hidden = True
w.detach()
w.color = c # led, knob, selector, slider cap, tabs
b.label = "new" # button (write only)Inside a scrollview, slider and knob drags always win; taps on the
others yield to scrolling after 8 px. Capitalised aliases exist for
Group, TextInput, Slider, Knob, Checkbox, Dropdown, Button,
Led, Selector, ColorPicker.
for kind, text, shift, ctrl in surfer.keys(): # drain every frame
...
surfer.keys_held() # ((kind, text), ...): keys down now, up to 8, for games
surfer.wheel() # [(x, y, dx, dy), ...]: wheel moves no scrollview took
surfer.touches() # ((id, x, y), ...): every finger, ids stable while held
surfer.has_touch()
surfer.screen_keyboard([show]) # the on-screen keyboard where there is one; None elsewherekind is KEY_TEXT (then text holds the characters; Tab is "\t")
or one of KEY_LEFT RIGHT UP DOWN PGUP PGDN HOME END BACKSPACE DELETE ENTER ESC.
ctrl+letter arrives as its control character in KEY_TEXT (^S is
"\x13") with ctrl False. ^C is the interrupt and never arrives;
^A and ^E arrive as Home and End. The ctrl flag is for keys with no
control character: ctrl+arrows, ctrl+Home/End, ctrl+PgUp/PgDn,
ctrl+Delete.
Use keys() for typing and keys_held() for steering. wheel() is the
desktop's second finger: a trackpad's two-finger gesture arrives as a
wheel, never as touches.
surfer.pad(n) is one normalised controller, whatever feeds it. Slots
0..3.
pad = surfer.pad(0)
pad.up, pad.down, pad.left, pad.right
pad.a, pad.b, pad.x, pad.y, pad.l, pad.r, pad.start, pad.select
pad.lx, pad.ly, pad.rx, pad.ry # sticks, -1.0..1.0
pad.dpad, pad.buttons # bitmasks
surfer.pad_keys(0) # the slot the keyboard drives; -1: noneThe keyboard map: arrows or WASD are the d-pad, space or Z is A, X is B, C is X, V is Y, Q and E are L and R. A gamepad and the keyboard feed the same slot and merge, so a game works with either.
A driver, an on-screen pad or a test writes the pad:
pad.set_dpad(surfer.DPAD_UP | surfer.DPAD_LEFT)
pad.set_buttons(surfer.BTN_A | surfer.BTN_R)
pad.set_stick(0, 0.2, -0.9) # stick 0 or 1, x, y
pad.reset()m = surfer.mesh(glb_bytes, tex_png=None, textured=False)
m.render(img, rx, ry, rz, size, cx=img.w/2, cy=img.h/2, cull=None)
m.tris
m.destroy()Renders a low-poly glTF (.glb) into an RGB565 or ARGB image with a
z-buffer and flat shading: size is the model's radius in pixels,
rx/ry/rz are degrees. Render small and scale the sprite up. render
doesn't flush: call img.flush() and damage the sprite. textured=True
samples the texture per pixel, for models with painted detail.
surfer.KEY_TEXT ... surfer.KEY_ESC
surfer.TOUCH_DOWN surfer.TOUCH_MOVE surfer.TOUCH_UP
surfer.ALIGN_LEFT surfer.ALIGN_CENTER surfer.ALIGN_RIGHT
surfer.A8
surfer.DPAD_UP DPAD_DOWN DPAD_LEFT DPAD_RIGHT
surfer.BTN_A BTN_B BTN_X BTN_Y BTN_L BTN_R BTN_START BTN_SELECT
surfer.FONT_UI16 FONT_UI28 FONT_MONO16 # the names "ui16", "ui28", "mono16"surfer._touch(x, y, phase, id=0) # a synthetic touch
surfer._key(kind, text="", shift=False, ctrl=False)
surfer._wheel(x, y, dx, dy)They go through the same paths real input does.
bindings/surfer/repl.py is a REPL shell on top:
UIScreen:screen.add(el, x=None, y=None)positions and parents in one call and returnsel;screen.remove(el);screen.hide()/screen.present().Console: text with wrap and scroll on a textgrid.Repl: line editing, history, block continuation, tracebacks on screen.repl.feed("code\n")scripts input.
import surfer
surfer.init(1024, 600)
root = surfer.screen()
panel = surfer.group(20, 20)
root.add(panel)
panel.add(surfer.label("mixer", 0, 0, surfer.rgb(240, 242, 248), "ui28"))
for i, name in enumerate(["cutoff", "res", "env", "lfo"]):
s = surfer.slider(i * 110, 60)
s.callback = lambda v, n=name: print(n, "=", v)
panel.add(s)
while surfer.tick():
passMore: examples/space.py (sprites), examples/parallax.py (layers), examples/gamma9001.py (widgets in a scrollview).