A tour of the code
Inkshelf is about 34,000 lines of Go in some fifty packages. Reading them in alphabetical order would teach you little. This tour follows the parts of the site: read a part's chapters first, then come here and open its files in the editor, in order. Each stop says what to look at and gives a command to run (from the root of the repository), so you see the code work rather than only read it.
Three threads cross the parts. Follow one end to end and you will have touched most of the code:
- Start-up: from
mainto the first screen. - A page turn in an EPUB: from a tap to the panel refreshing.
- A download: from a tap on a book to
.pg4files on the reader.
Before you start
You need Go, and nothing else for the first stops. Run the app once, so you know what the code is doing:
make emu-fake # the app against a fake Komga, at http://localhost:8740
make test # every test, on your computer
Two habits help along the way:
- Read the package's doc first. Most packages begin with a
doc.gothat teaches their domain.go doc ./internal/shelfprints it without opening an editor. - Run one test with
-v. A test is the smallest program that uses a function:go test ./internal/naturalorder -run TestNaturalCompare -v.
Stop 0: the map
Read The layer cake, then the domain map in CLAUDE.md at the root of
the repository. It lists every package with one line on what it does. Keep it open: whenever a
stop names a package, find it on the map.
Then check the rule that holds it together yourself:
go list -deps ./internal/app/... ./internal/shelf | grep -E 'servers/(komga|mealie|calibreweb)'
It prints nothing: the app and the shelf never import a specific server.
Part 1: start-up (thread 1)
Open the two programs. They are short, and everything else hangs from them.
cmd/inkshelf/main.go · mainLogging, profiling, then app.New and pocketbook.Run. The registry is handed in here.internal/app/app.go · New, InitThe application: its screens, and the three goroutines Init starts (session.Load, the preloader, the cover loader).internal/device/pocketbook/app.go · App, Run, runThe six methods every app implements, and the select loop that calls them.cmd/emulator/main.go · run, prepareAppDirThe same app on your computer: fake servers, an emulated reader, a web page or a script.
Notice: run in pocketbook/app.go is the heart of the program. One goroutine, one
select: repaint requests call app.Draw, a ticker polls the firmware, which ends in
app.Key, app.Pointer or app.System. Everything the app does starts from one of these
calls.
go doc ./internal/device/pocketbook
go test ./internal/device/pocketbook -run TestEventLoop -v
Ask yourself: cmd/inkshelf and cmd/emulator both call app.New. What is different
about what they pass it?
Part 2: the screen
Chapters: How e-ink works to The emulator. Follow the event loop down to the panel, then come back up through the images.
internal/device/pocketbook/event_loop.go · eventLoop.pollOne tick: replies, firmware messages, the foreground, input, held keys, long presses.internal/device/pocketbook/touch.go · handleTouchRaw touches become Down, Long and Up for app.Pointer.internal/device/pocketbook/canvas.go · Canvas, ClearScreenThe gray image every screen draws into.internal/device/pocketbook/eink_refresh.go · Refresh, FlashRefresh, mxcfbUpdateAsking the panel to refresh a rectangle, with a waveform.internal/device/pocketbook/framebuffer_linux_arm.go · readerFirmware.flushWait for an overlapping refresh, copy, ioctl. Builds only for the reader.internal/device/pocketbook/framebuffer_copy.go · copyRotatedHOT PATH: the canvas copied, rotated, into the framebuffer.internal/device/pocketbook/emulated_firmware.go · Emulator.flushThe same flush, into memory, for the emulator.internal/graphics/pageimage/prepare.go · PrepareDecode, fit, tone, dither to 16 grays, pack into .pg4.
Notice: files ending in _linux_arm.go build only for the reader; elsewhere, _other.go
stubs and the emulator stand in. And notice what flush logs: the time spent waiting for the
previous refresh, copying and sending. Page turns on the reader are timed from those lines in
inkshelf.log.
go test ./internal/device/pocketbook -run 'TestGestures|TestCopyRotated' -v
go test ./internal/graphics/pageimage -run 'TestFit|TestGray4RoundTrip' -v
GOOS=linux GOARCH=arm go vet ./internal/device/pocketbook # the reader-only files too
Ask yourself: why is copyRotated a hot path, and Prepare not?
(From a scan to 16 grays has the answer.)
Part 3: books (thread 2, a page turn)
Chapters: What is an EPUB? to Pages and positions. This is the largest part of the code. Read it in the order the data flows: a zip, a document, fonts, lines, pages, then the engine that holds them.
internal/ebook/epub/container · OpenThe zip, the OPF, the spine, the navigation, fonts and DRM detection.internal/ebook/epub/epub.go · OpenAn EPUB opened for reading.internal/ebook/epub/chapter.go · Book.ChapterA chapter of XHTML and CSS becomes a document (through the build_*.go files).internal/typesetting/document/document.go · DocumentThe model: blocks of styled runs.internal/graphics/fonts/draw.go · DrawSpaced, blendMeasuring and drawing text in gray.internal/typesetting/layout/line_breaking.go · breakLinesWords onto lines, greedily, with hyphenation.internal/typesetting/layout/paginate.go · PaginateLines onto pages.internal/typesetting/layout/draw.go · Page.DrawA laid-out page drawn into an image.internal/reading/textbook.go · Open, TextBook.TurnThe reading engine: positions, page turns across chapters.internal/reading/renderer.go · renderer.pagesLockedLays out a chapter on first use and keeps its pages.
Now follow thread 2, a page turn, from the tap to the panel. Open each function in turn:
reader.Screen.Command(internal/app/screens/reader/reader.go) receives "next page".openbook.TurnPage(internal/app/openbook/openbook.go) changes the state insideapp.Update.TextBook.Turn(internal/reading/textbook.go) moves the position, asking the renderer for the chapter's pages.renderer.pagesLockedcallsBook.Chapterthenlayout.Paginate, once per chapter.- The repaint reaches
app.Draw, which calls the reader'sDrawAndRefreshLocked: the page is drawn withPage.Draw, thenpocketbook.Refreshupdates the panel. - Back in
TurnPage,Syncer.Schedulewill send the progress a few seconds later.
go doc ./internal/typesetting/layout
go test ./internal/ebook/epub -run 'TestOpen|TestChapter' -v
go test ./internal/typesetting/layout -run 'TestHyphenatedWord|TestLongWord' -v
go test ./internal/reading -run TestTurnPages -v
make emu-fake EMUFLAGS="-fake-type calibreweb" # open an EPUB and turn pages
The showcase EPUB of the fake Calibre-Web has Arabic, Hebrew, Chinese and emphasis marks:
make shots SCRIPT=emulator/scripts/calibreweb-showcase.txt EMUFLAGS="-fake-type calibreweb"
saves the screenshots in build/shots.
Ask yourself: where does a chapter's layout run the first time you open the book, and where on the next page turns?
Part 4: the app (thread 3, a download)
Chapters: Life of a tap to Syncing reading progress.
internal/app/input.go · Pointer, touchTargetLockedA tap becomes a target, then a command for the screen.internal/app/state/state.go · State, Update, ShowLockedThe one mutex, and how other goroutines change the state and ask for a repaint.internal/app/screens.go · ScreenWhat a screen is: DrawLocked and Command.internal/servers/server.go · ServerWhat the app needs from any server.internal/servers/registry/registry.go · Opener, KindOfThe one switch on the kind of server.internal/progress/syncer.go · Syncer.Schedule, PushNowProgress sent back, debounced.internal/progress/progress.go · MergeThe reader’s progress against the server’s.
Then follow thread 3, a download:
- A tap on a book not on the reader calls
downloadqueue.EnqueueLocked(internal/app/downloadqueue/downloadqueue.go). If the queue was idle, it startsdownloaderwithapp.Go. downloadercallsStore.Download(internal/shelf/download.go), which branches on the book'sFormat.- For page images, fetchers (
fetchLoop) ask the server for pages, a few at a time, and preparers (prepareLoop, thenpreparePage) callpageimage.Prepareand write each.pg4withwriteFileAtomic. - For an EPUB,
downloadReflowable(internal/shelf/prepare_epub.go) fetches the file whole, builds its index and its cover. book.jsonis written last;downloaderrecords the result inside anUpdateand takes the next book.
go doc ./internal/shelf
go test ./internal/shelf -run TestDownloadPreparesPages -v
go test ./internal/progress -run 'TestMerge|TestSyncerSchedule' -v
go test ./internal/servers -run TestCompareTitles -v
make shots SCRIPT=emulator/scripts/mealie.txt EMUFLAGS="-fake-type mealie"
Ask yourself: downloader runs on its own goroutine. Find every place where it touches the
state, and check that each is inside app.Update (Threads and locks says why).
Part 5: a first change
You have now read the code of each thread. A good first change is a small one that runs the whole loop of Making a change:
- Add a case to a test. Add a title to
TestCompareTitles(internal/servers/sorting_test.go), such as a French title with an article, and run it. - Write an emulator script. Copy
emulator/scripts/smoke.txt, take ashotof a screen you have read the code of, and run it withmake shots SCRIPT=…. - Profile a page turn.
make profile SCRIPT=emulator/scripts/calibreweb-epub.txt, then open the CPU profile and findPaginateandblendin it (Finding what is slow).
When the change is real, make verify runs everything: the build, the linter, the tests, every
script, and the comparison of screenshots with the baseline.