A minimal, runnable Even Hub app that renders a scrollable two-column list on the G2 display — checkboxes, strikethrough on checked rows, a live "N left" tally, and cursor navigation that flows down column 1 and then down column 2.
This is the list screen extracted out of Fushopping List (a shopping list app part of fuscripts.com with a companion app for the G2 glasses), stripped of its auth and networking so the layout technique is all that's left. Mock data, no accounts, no backend — clone it and run it.
swipe move the cursor
press check the item off
double-press exit
There is no column widget, no grid layout, and no flexbox on the G2. A
TextContainerProperty is a positioned box that renders a string, splitting it
on \n at a fixed 27px line height. That's the entire toolbox.
So a two-column list is just two text containers side by side, each holding
nine lines joined by \n:
┌──────────────────────┬──────────────────────┐
│ [ ] Milk │ [ ] Tomato sauce │ container "body0" container "body1"
│ [ ] Eggs │ [ ] Onions │ x = 34 x = 322
│ [ ] Bread │ [ ] Garlic │ w = 244 w = 244
│ … 9 lines total │ … 9 lines total │ h = 9 × 27 = 243
└──────────────────────┴──────────────────────┘
Four things make it work.
Items go down column 0 and then down column 1 — not left-to-right. That one choice is what keeps the code simple: the visible page is just
const slice = items.slice(top, top + 18) // 2 cols × 9 rowsand the renderer chops that slice into columns. Scrolling moves top by one
item, so when the cursor walks off the bottom of column 1 the whole grid pulls
up by one row and the item that was at the top of column 0 disappears. The state
layer never knows columns exist (src/state.ts); the render
layer never knows about scrolling (src/render.ts).
Mapping the cursor to a cell is then two lines:
const visible = state.index - state.top
const col = Math.floor(visible / ITEMS_ROWS)
const row = visible % ITEMS_ROWSTempting to emit one container per item — 18 of them — and position each. Don't. The whole page is re-sent over BLE on every single gesture, and the frame cost scales with container count. Two containers instead of eighteen is the difference between a list that responds instantly and one that visibly crawls.
This one costs an afternoon if you don't know it. If a visible container is
the one with isEventCapture: 1, the firmware scrolls that container itself on a
temple swipe — so the text slides, springs back under your own re-render, and
every gesture flickers.
The fix: exactly one full-screen, transparent container (content is a single
space) is the only isEventCapture: 1 layer, and every visible container is
isEventCapture: 0. Swipes land on the invisible layer, which has nothing to
scroll, and you move the cursor yourself.
new TextContainerProperty({
xPosition: 0, yPosition: 0, width: 576, height: 288,
containerID: 1, containerName: 'evt',
content: ' ',
isEventCapture: 1,
})The G2 font has no combining strike, and the text container API exposes no
per-run styling or brightness — a checked row can't be dimmed or struck
directly. So a second container sits exactly over the text column, offset past
the [x] prefix, holding one run of ─ (U+2500, which connects seam to seam)
per checked row and an empty line for every unchecked one:
const dashes = Math.floor(getTextWidth(name) / getTextWidth('─'))Floor the division — a run wider than its text wraps the line and pushes the whole column out of alignment.
Everything above depends on knowing how wide a string renders.
@evenrealities/pretext's
getTextWidth() measures against the same font the firmware uses, which is what
makes truncation and centering land where you expect:
- truncate every line before sending it — an overflowing line wraps to a second row and silently pushes the rest of the column down
- center by padding with spaces (
' '.repeat(...)), because there is no text-align
9 rows × 27px = 243px, and the canvas is 288px tall. That leaves room for the
header strip or a bottom gesture hint, not both. This demo spends it on a 9th
row and documents the gestures on the phone screen instead. The header also only
renders while top === 0, so it scrolls away with the list rather than sitting
there as a fixed bar — and because the grid geometry doesn't change either way,
crossing that boundary never reflows the items.
npm install
npm run dev # vite on :5173In the simulator (no glasses needed):
npm run sim # points the simulator at localhost:5173On real glasses — sideload the dev server over your LAN:
npm run qr # prints a QR for http://<your-lan-ip>:5173Scan it with the Even app (Developer Mode on). The qr script resolves your LAN
IP with macOS's ipconfig; on other platforms run
npx evenhub qr --url http://<your-ip>:5173 directly. Your phone and computer
have to be on the same network.
Package it into an installable .ehpk:
npm run pack # → two-column-list.ehpkChange package_id in app.json before you publish anything —
it's currently com.example.twocolumnlist.
src/
main.ts bridge lifecycle, gesture routing, frame sending
render.ts ← the interesting file: state → text containers
state.ts cursor movement + the sticky scroll window
demo.ts mock groceries
types.ts
Two more things in main.ts worth stealing for a real app, both learned the hard
way in Fushopping List:
- every bridge call goes through one serialized queue with a timeout — a flaky BLE hop otherwise hangs for ~30 seconds with the glasses frozen mid-frame
- identical frames are never re-sent — rebuilding the same page makes the display visibly redraw, which reads as a flicker on every no-op gesture
MIT — do whatever you like with it.
