Skip to main content

The event loop and touch

One goroutine wakes every 20 ms, reads what the firmware posted, and calls the app; it alone calls the app, and it builds long presses, drags and key repeats out of the firmware's bare downs, moves and ups.

The previous chapter showed the mailboxes. Something has to check them, turn their bytes into events, and call the app. That is the event loopEvent loop: The one goroutine that polls for input and firmware messages every 20 ms and calls the app. It is the only one that draws., in internal/device/pocketbook. It is small, but every touch you make goes through it.

A night watchman doing rounds: every 20 ms he walks past the same doors in the same order, looks in each mailbox, and deals with what he finds. Between rounds, he sits by a doorbell that colleagues can ring if they need him at once.

Why poll every 20 ms?​

The obvious design is to sleep until a message arrives. The firmware's queues do not allow it: the task queue is a System V queue, which is not a file and cannot be watched like one, and /hwevent is opened non-blocking and read without waiting. As the code says, neither queue can wake the app. So the loop does what libinkview does: it wakes on a ticker every 20 ms (pollInterval) and looks.

20 ms is short enough that you cannot feel it (the panel itself takes hundreds of milliseconds to change), and long enough that looking costs nothing.

One tick, in order​

At each tick, poll does the same steps, in this order:

  1. readReplies, only while a request waits: collect the firmware's replies.
  2. readTaskMessages: answer sync events, pass news to App.System, stop on exit.
  3. followForeground: read visible(). In the background, stop here: forget held buttons and touches. Back in front, call Draw, since the panel shows another app's picture.
  4. readInput: read every waiting /hwevent message, and call App.Key or App.Pointer.
  5. repeatHeldKey: if a button is held long enough, send a repeat.
  6. sendLongPress: if a finger is held still long enough, send a long press.

The last two run at every tick, even with no input: a finger held still sends nothing, so only the clock can say "it has been 500 ms".

The doorbell: channels​

A Go channel is a pipe that goroutines pass values through, and a select waits on several channels at once and takes whichever is ready first. The loop is a select on the ticker and a few channels:

internal/device/pocketbook/app.go
// trimmed: logging and the conversion to seconds left out
select {
case <-repaints: app.Draw()
case <-exits: return app.Close()
case duration := <-sleepRequests: loop.request(taskBanSuspend, seconds(duration), time.Now())
case <-signals: return app.Close()
case now := <-ticker.C: if loop.poll(now) { return app.Close() }
}

The rule that keeps the app simple: only this goroutine calls the app. A download or a cover loader running on another goroutineGoroutine: Go’s lightweight thread. Inkshelf starts them only from the main thread, with state.Go. never draws. It calls Repaint(), Exit() or PreventSleepFor(), which only drop a request into a channel that holds one. A second Repaint sent before the first is read is dropped: one Draw does both. Channels can wake a select, so a repaint does not wait for the next tick.

InteractiveThe 20 ms heartbeat, slowed down
-100 ms-80 ms-60 ms-40 ms-20 ms0 ms20 msnow
● touch in /hwevent · ● firmware event in the task queue · ● Go channel · ■ handled. The bar is the wait.
poll(now), at every tick
1. readReplies2. readTaskMessages3. followForeground4. readInput5. repeatHeldKey6. sendLongPress
The loop sleeps between ticks. Press a button: watch what waits for the next tick, and what does not.
Try this: Press Touch just after a tick and watch it wait. Then press Repaint(): no wait. Press Repaint() ×2 and read the log.

From raw touches to gestures​

The firmware sends a touch as down, move, move…, up, with raw coordinates. Everything else, the app's long presses, holds and drags, the loop makes itself, as libinkview did.

Turning the coordinates. The touch panel is mounted turned, like the display. touchPoint converts: screen x = raw y, screen y = 1871 − raw x, clamped to the screen. The InkPad 3 has no calibration file, so this fixed rule is all there is.

Only the first finger. A message with a touch id other than 0 is a second finger: ignored.

The dead zone. A finger always trembles. A touch counts as still while it stays within a squared distance of 200 of where it went down (a radius of about 14 pixels, about 1.2 mm on a 300 dpi screen). Past it, every move is also sent as a PointerDrag, and that touch gets no long press.

Long press and hold. A still touch held for 500 ms becomes PointerLong, once. Then, every 500 ms while it is held, PointerHold.

InteractiveA touch, as the app receives it
500 ms0DownLongHoldUp
0 ms · PointerDown (680, 955)500 ms · PointerLong1000 ms · PointerHold1200 ms · PointerUp
The firmware sent the down as raw (916, 680); touchPoint made it (680, 955): x = raw y, y = 1871 − raw x.
Try this: Tap quickly. Then press and hold past the ring. Then press and drag out of the dashed circle before 500 ms.
internal/device/pocketbook/touch.go
case eventPointerMove:
touch.last = point
loop.app.Pointer(PointerEvent{Point: point, State: PointerMove})
if !touch.dragging && squaredDistance(point, touch.start) > dragDeadZone {
touch.dragging = true
}
if touch.dragging {
loop.app.Pointer(PointerEvent{Point: point, State: PointerDrag})
}

Key repeats​

Buttons work the same way. The firmware sends a press and a release; it also sends repeats of its own (event 28), but libinkview ignored them and timed repeats itself, and so does Inkshelf. A held button repeats after 600 ms, then every 300 ms, checked at each tick. The reader turns page after page while you hold Next.

InteractiveHolding a page button
hold it down · 4 repeats
600 ms0Press60090012001500Release
First repeat at 600 ms, then every 300 ms. Hold it under 600 ms: only a press and a release.
Try this: Hold the button for about two seconds. Then just tap it.
Going deeper
  • Timestamps are tick times. Input waits in /hwevent until the next tick, and the loop stamps it with the tick's time. That is why the times above land on multiples of 20 ms, and why a long press arrives at the first tick at or after 500 ms.
  • Background resets. followForeground clears the held button and the active touch when the app leaves the front: their releases will never be seen.
  • Waiting for nothing. request sends and returns; the reply is picked up by a later tick. The loop never blocks on the firmware, so a slow firmware cannot freeze the app.
  • Signals. SIGINT, SIGTERM, SIGHUP and SIGQUIT go through the same select, so Close runs on the loop's goroutine like everything else.

Where it lives in the code​

The full story: E-ink and the firmware, and for what the app does with these events, App threading.

Next: all of this can run on your computer, with no reader at all. That is the emulator.

Check yourself​

Why does the loop wake every 20 ms instead of sleeping until a message comes?
A download goroutine calls Repaint() twice in a row. How many times is Draw called?
You press, move 10 pixels to the right, and hold for a second. What does the app get?
The touch panel reports raw (1000, 300). Where on the screen is that?