Skip to content

The rest of the work: shapes, layers, an eraser, and two bugs the emulator found - #13

Merged
MAXAWER merged 6 commits into
mainfrom
fix/stop-and-rotation
Aug 27, 2026
Merged

MAXAWER merged 6 commits into
mainfrom
fix/stop-and-rotation

Conversation

@MAXAWER

@MAXAWER MAXAWER commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Six commits that never reached main. PR #11 was merged while its branch head
was 9d6486a, and everything pushed after that stayed behind — about 1,600
lines. The missing thirty tests are what gave it away: the suite runs 207 on the
branch and 177 on main.

What is in here

Shapes and text from the command line, with nothing to prepare —
mthread shape heart, mthread shape star --points 7 --rotate 20,
mthread text "hello" --font arial.ttf. Placement moved into the library at the
same time, so the command line places a shape with the code the window places a
photograph with, and flipping arrived with it.

Layers, an eraser, flip buttons, and a screenshot when capture will not.
Several pictures arranged against each other, each with its own placement and
tracer settings; drag across strokes to take them out; a still picture standing
in for the live view when ADB capture fails — which also made placement work
with no phone attached at all.

Two bugs the emulator found, neither of which could be noticed on one device.
getevent output never arrived because adb shell allocates no pty when its own
stdin is not a terminal, so a short recording sat entirely inside a four-kilobyte
libc buffer and appeared empty. One -t is not enough — adb declines and says so
on stderr; two forces it, as ssh -tt does. And the recorder listened to the
node Android calls the touchscreen, which on this emulator is event12 while
touches arrive on event2; it listens to all of them now and lets the decoder
ignore what is not a touch.

The command line now keeps the promise the README makes. record wrote the
old raw format and play replayed it through /dev/input; the README had
already started saying recordings travel. Recording writes the portable format;
replay scales it to the screen it is given; a version 1 file is refused with the
reason and a --raw escape hatch.

The description, checked line by line. Three statements had been overtaken by
the code — rotation "not handled", drawing failing where sendevent is refused,
and two open ends that were already closed — and one real limit was missing:
replay carries a fixed second or two of injector startup, so the strokes are
faithful and the total duration is not.

Deleted: examples/castle.png and tools/make_sample.py. A synthetic
drawing and the script that made it, from when the demo needed art the repository
owned; the demo has used four CC0 photographs for a while and nothing referenced
the castle but its own credit line.

Also tools/make_avd.py, which creates an emulator without the SDK's package
manager — the current Android CLI crashes with a stack buffer overrun on this
machine and the deprecated sdkmanager forwards to it. An AVD is two ini files.

Verified on the emulator, not asserted

  • A photograph drawn on a canvas: 64 strokes, 561 points, recognisable.
  • Two layers arranged and partly erased: 41 strokes taken out where the cursor
    went, 124 of 165 drawn.
  • Shapes and text drawn with no image prepared.
  • Landscape: wm size still says 1080x2280, the frame says 520x246, the drawing
    comes out upright rather than sideways.
  • Record and replay: a drag recorded at kernel level replays within one pixel of
    where it was made, on a device whose digitizer range is 0..32767 against a
    1080x2280 display.
  • Stop: 10 strokes of 465 before it took effect, 2.1 s — not the "immediate" an
    earlier commit message claimed, because the look-ahead budget is counted in
    nominal pacing milliseconds and a slow device stretches them.

207 tests pass.

🤖 Generated with Claude Code

MAXAWER and others added 6 commits August 27, 2026 14:35
Seven shapes and any text, from the command line, with nothing to prepare:

    mthread shape heart --scale 0.6
    mthread shape star --points 7 --rotate 20
    mthread text "hello" --font arial.ttf --y 0.35

The shapes are parametric curves evaluated straight into points, so they are
smooth at any size and cost nothing to produce. The wave needed one correction
worth recording: its x span was fixed at one while its height was two, and _fit
normalises by whichever side is longer, so three cycles came out as a narrow
zigzag rather than a wave.

Text takes a different route on purpose. Rather than carry a stroke font
around, it renders the words with Pillow and hands the result to the ordinary
tracer: every font on the machine becomes available and the letters look like
that font. The cost, and it is unavoidable, is that letters arrive as outlines -
a filled glyph is a shape with an inside and an outside, and this draws with one
finger.

**Placement moves into the library**, because placing a drawing is part of
drawing it rather than part of any one interface. mthread_draw.geometry
re-exports it so existing callers are unaffected, and the command line now
places shapes with exactly the code the window places a photograph with.

**Flipping is added** while the file was open: --flip-x and --flip-y, and
Placement.mirrored, which flips rather than sets, so flipping twice is not
flipping.

Sixteen tests, and two of them are the interesting ones: that every shape fits
the unit square without being stretched, and that a drawing turned to any angle
still lands inside the margin - the rotated bounding box being what has to fit,
not the upright one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**Layers.** Several pictures, arranged against each other before anything is
drawn, each with its own placement and its own tracer settings. Hide one,
reorder them, remove one; what gets drawn is every visible layer in order. Each
keeps the paths it traced to, so changing one does not re-trace the others.

**An eraser.** Drag across the live view with it on and the strokes under the
cursor come out. Which strokes count as near enough is decided after placement,
because the cursor is over a picture of the phone and not over the tracer's
coordinate space; the reach is a fraction of the screen so it means the same
thing whatever size the window has been dragged to. Erasures are recorded as
indices and forgotten when a layer is re-traced, because stroke five of one
tracing is not stroke five of the next - keeping them would silently take out
whatever happened to land in that position.

Erasing is one request at a time. A drag reports faster than an overlay can be
re-rendered, and queueing every report would leave the eraser running behind
the mouse.

**Flipping**, and buttons for the transforms that are not gestures: Flip,
Fit and Undo erase under the phone. Placement.mirrored flips rather than sets,
so flipping twice is not flipping.

**A screenshot taken by hand can stand in for ADB capture**, which fails on some
devices and in some emulators. A still picture does not update, but its
proportions are the ones placement needs. Placement now works with no phone
attached at all, which falls out of the same change and is useful on its own: a
drawing can be arranged before the cable is anywhere near.

The window answers every layer operation by redrawing from the whole state the
engine returns rather than from a delta. It is a few hundred bytes and it
removes the class of bug where the list and the engine disagree about which
layer is selected.

Fourteen engine tests, driven the way a front end drives it and with no device
attached: layers keep their own placements, a hidden layer is not drawn,
erasing takes out what is under the point and nothing else, undo brings it back,
and re-tracing forgets erasures. They use small generated fixtures rather than
the photographs in examples/ - tracing a real photograph a dozen times over cost
the suite most of a minute and told it nothing extra.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three steps still described loading one picture and dragging it, which was
true when there was nothing else to do to a drawing. There is now a table of
what can be done before anything is drawn - move, resize, turn, flip, layers,
the eraser, re-tracing without reopening the file, and a screenshot taken by
hand when capture will not work - and the command line section covers shapes and
text.

The claim that recordings do not travel between phones is removed, that having
been the point of the format change: they hold fractions of a screen now, and a
file from an older version says so rather than misfiring.

Both halves, English and Russian.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Neither of these had a chance of being noticed without a second device to try.

**getevent's output never arrived.** `adb shell` allocates no pty when its own
stdin is not a terminal, and without one getevent's stdout on the device is a
pipe, so its libc buffering holds output back in four-kilobyte blocks. A short
recording ends up entirely inside that buffer and appears only when the process
exits - by which time recording has been stopped and the file looks empty. A
minute of touching worked; five seconds of it did not.

One `-t` is not enough to fix it: adb refuses the pty and says so on stderr
rather than failing. Two forces it, the way `ssh -tt` does. The mirror needs the
exact opposite, `-T`, because a pty would corrupt the JPEG it carries; both
reasons are now written down where the flags are.

**The recorder listened to the wrong device.** It defaulted to the node Android
reports as the touchscreen, which on this emulator is `event12` while touches
actually arrive on `event2` - there are a dozen identical virtio multi-touch
devices and Android nominates one that stays silent. The result was a recording
with nothing in it and no error to explain why. It now listens to all of them
and lets the gesture decoder ignore what is not a touch, which costs a few bytes
and removes the whole failure.

Verified end to end against the emulator, with events injected through the
emulator console so they arrive at kernel level the way a finger would: a drag
from (300, 700) to (700, 1420) records, decodes to one stroke of nine points,
and normalises to fractions that map back to (300, 700) on the screen it was
made on and to (373, 918) on a 1344x2992 one. That is the portability claim,
tested rather than asserted.

Also verified there: touches land where they are aimed on a device whose
digitizer range is 0..32767 while its display is 1080x2280 - the rescaling path
that the Pixel, whose ranges match its display exactly, never exercised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two corrections to tools/test_canvas.py, both found by running it against an
emulator rather than reasoning about it.

The margin was a twelfth of the height on every side. Above the canvas sits a
browser address bar and the page's own header, which on a 1080x2280 emulator
came to about 280 pixels - more than that twelfth - so the rectangle's top edge
was drawn on the toolbar and did not appear. Below it there is only a gesture
bar. The margins are asymmetric now, twenty per cent above and ten below, and
--top-margin adjusts the one that varies between browsers.

The centre cross was drawn at the centre of the screen while the rectangle's
diagonals meet at the centre of the rectangle. With symmetric margins those are
the same point and nobody notices; with asymmetric ones the cross sits above the
crossing, which looks exactly like a calibration error and is not. It is drawn
at the rectangle's centre now.

Both matter because this tool exists to answer "are the coordinates arriving
where they were aimed", and it was answering "no" for its own reasons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`mthread record` still wrote the old raw-event format and `mthread play` still
replayed it through /dev/input - the format that holds digitizer coordinates and
that every recent phone refuses. Meanwhile the README had started saying
recordings travel between phones. Both statements were true of different code.

Recording now writes the portable format and says so; replaying scales it to
whatever screen it is given and reports which phone it came from. A version 1
file is refused with the reason and an escape hatch: --raw replays it the old way
if this is the phone that made it and that phone allows raw input.

Verified on the emulator: a recorded drag writes a version 2 file naming the
device, replays at 2x scaled to 1080x2280, and an older file produces the
explanation rather than a traceback.

**The description, checked line by line.** Three claims had been overtaken by the
code:

Rotation was listed as not handled. Drawing follows the orientation now - read
from the captured frame, since wm size does not change when a phone is turned -
so the limit is narrower than it was and belongs to recordings, which hold
fractions of the screen they were made on. Said that way instead.

"Some devices expose no touchscreen usable by sendevent, drawing then fails" has
not been true since the injector arrived, and "an automatic fallback is an open
task" was that same fallback. Both gone, replaced by the limit that is real and
was missing: replay carries a fixed second or two of injector startup, so the
strokes are faithful and the total duration is not.

Two open ends were closed and still listed - the input fallback and
skeletonising edges instead of halving contours. Replaced with two that are
real: rotation in recordings, and shedding OpenCV, five functions of which
account for half the download.

Numbers spot-checked against the code rather than trusted: 57 strokes and 478
points for the guitar at the demo settings, 294 for the page's, both as printed.

**Deleted.** examples/castle.png and tools/make_sample.py, a synthetic drawing
and the script that made it. They existed because the demo once needed art the
repository owned outright; the demo has used four CC0 photographs for some time
and nothing referenced the castle but its own credit line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@MAXAWER
MAXAWER merged commit facf1af into main Aug 27, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant