Skip to content

Integr/serial on newapi - #51

Open
Vadim1987 wants to merge 29 commits into
aldum:devfrom
Vadim1987:integr/serial-on-newapi
Open

Integr/serial on newapi#51
Vadim1987 wants to merge 29 commits into
aldum:devfrom
Vadim1987:integr/serial-on-newapi

Conversation

@Vadim1987

Copy link
Copy Markdown

Supersedes and includes #45 (editor overhaul) and #50 (new input API) — this branch sits on top of both.
On top of them: a platform USB-serial API (src/model/serial), compy.serial in both environments, the port pumped once per frame, and two example projects — microbit (help, echo, send, exec) and terminal.
Verified on device: the tools load, a board connects, files are sent and run, the terminal types to the board and comes back

dsent and others added 29 commits September 4, 2026 10:29
The Keys tables in README.md and doc/EDITOR.md were stale and mutually
inconsistent. Correct them to the behavior verified against the shipped
editor build 1b86c90: toggle edit/run = Ctrl+T (was F8/F9), stop project
= Ctrl+S (was Ctrl+Shift+S), quit project = Ctrl+Q (EDITOR had
Ctrl+Shift+Q), leave editor = Shift+Esc / Ctrl+Shift+S, drop the stale
editor Esc/Ctrl+S/Ctrl+Y rows, and add the input clipboard/selection
keys.

These tables document commit 1b86c90. The editor-stage1 (2eed100) keymap
update follows as a separate change.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_0177PwS4Xws9YFAGqJMADY5c
…work spec

The editor's implicit `edit` flag becomes two first-class
modes: `nav`, where the arrows walk the file, and `edit`,
where an open block sits in the input strip. Transitions
are declared in one table in set_mode: nav reaches edit,
reorder and search; the special modes and edit only return
to nav. A freshly opened buffer always starts in nav.

Keys, per spec 2.2/2.3:
- Enter on an empty input opens the selected block; Enter
  while editing accepts it
- bare Esc does nothing (it also defuses the device's
  RMB->Esc binding silently replacing typed input)
- Shift+Esc discards the edit; on an empty input it leaves
  the editor. Ctrl+W no longer leaves a block
- Ctrl+S is reserved for the checkpoint: saving is
  automatic on accept, so bare Ctrl+S does nothing
- Ctrl+Delete drops a block in navigation only; while
  editing it stays the widget's delete-next-word
- typing straight into a fresh file inserts a new block
  and never replaces the first one (replace needs a block
  deliberately opened)

Files open at the first block, the view following the
selection; the cursor position is remembered per file for
the session and restored on reopen.

The statusline receives the real mode. A mode tag was
tried and dropped: the mode is evident from the input
strip itself, and the tag collided with the left label.

Co-Authored-By: Claude <[email protected]>
The buffer model gains an active line under the block
selection: an absolute source line, always inside the
selected block, clamped on every selection mutation, with
move_line stepping across block boundaries. The view
draws it and follows it on scroll.

Keys, per spec 2.2/2.7:
- bare Up/Down move the active line; PageUp/PageDown
  move it by a viewport page; Home/End go to the file's
  first and last line; opening a block lands the cursor
  on the active line, not on line 1
- Ctrl+Up/Down jump block-wise with the paragraph
  convention: Down to the next block's first line, Up
  first to the current block's first line
- Alt is the peek: Alt+Up/Down scroll a line, Alt+
  PageUp/PageDown a page (Alt+Left/Right too — PgUp/PgDn
  is a four-key chord on the device keyboard), Alt+Home/
  End to the file's edges, the selection staying put in
  both modes; typing after a peek returns the view.
  Ctrl+Alt keeps working as a synonym. Alt does not move
  blocks: that is the reorder mode's job (Ctrl+M)
- inside the input widget bare Home/End are line-scoped
  and Ctrl+Home/End reach the whole block (dsent)
- Ctrl+J follows the require under the selection; Ctrl+O
  is left free for a conventional "open file"
- returning from a followed require restores the view to
  the stored position, as opening a file does

The textinput guard widens to any Ctrl/Alt chord: device
chords leak glyphs, only Shift composes.

Co-Authored-By: Claude <[email protected]>
Co-Authored-By: dsent <[email protected]>
…ith a knock

One path out of an open block, shared by Enter, the arrow
edge crossing, Ctrl+Up/Down from editing and the mouse:
dirty-check, validate, auto-format, re-chunk, size check,
write. Acceptance in place keeps the block (2.4.4); the
arrow transition opens the neighbor — downward on its
first line, upward on its last (2.4.1). An untouched
block leaves freely and writes nothing; an invalid one
refuses and stays.

- opening a Lua block runs it through the pretty-printer
  (9.4): a sloppy block is reshaped in the input, dirty
  from birth, while the file stays untouched until
  acceptance
- the block size limit is the input strip's height (14
  lines); the refusal names how many lines to remove and
  the way out, per 1.4
- an eval refusal moves the cursor to the first error's
  line and shows the parser's message until the first
  fixing keystroke (2.5); the refusing keypress is blocked
  from the widget so it does not clear its own message
- every refusal knocks (2.4.3): a rejected block, a jump
  or page move at the file's edge, a click away from an
  invalid block, Ctrl+J with nothing to follow — one
  neutral sound, since walking off the end of a file is
  not a mistake
- typing in navigation opens the block at the active line
  with a blank line to type into (2.1); on a blank line it
  composes a new block. Ctrl+Enter opens a fresh block
  below (Ctrl+Shift+Enter above) and accepts while editing
- the acceptance pipeline lives in controller methods
  (accept_block and friends), reachable from any input,
  and reports its verdict so a refusal blocks the widget
- writes are durable: the accept path fsyncs the file, and
  a failed write refuses with a message instead of reading
  as accepted (dsent, after real data loss on the device's
  exfat SD card)
- typing right after deleting the last block anchors the
  input properly (dsent)

Co-Authored-By: Claude <[email protected]>
Co-Authored-By: dsent <[email protected]>
…) in the REPL

Ctrl+K copies the file to {name}.~save; Ctrl+Shift+K copies
it back and reloads the open buffer. Both raise a dialog
when there is something to lose — an existing checkpoint,
a file about to be overwritten — showing the timestamps of
the checkpoint and the file (FS.getInfo). While editing,
Ctrl+K accepts the open block first, so the checkpoint
reflects the screen; a refused block aborts it.

The REPL gains revert(name) (default main.lua): restore
without a prompt, false when no checkpoint exists.

Co-Authored-By: Claude <[email protected]>
…double click opens

BufferView:line_at(y) maps a pixel row through the scroll
offset and the wrap to a source line; BufferModel:
block_at_line finds its block. In navigation a click
selects the block and line; a double click opens it, the
mouse counterpart of Enter. While editing, a click inside
the open block places the input cursor on that line, and
a click outside goes through the acceptance gate exactly
as an arrow transition does: an untouched block leaves,
a changed one is accepted and written, an invalid one
refuses and keeps the editor.

Buffer-area clicks route to the editor through the
console; input-strip clicks keep going to the widget.

Co-Authored-By: Claude <[email protected]>
Text level, inside an open block: EditHistory keeps a
32-step ring of {text, cursor} snapshots taken before each
mutation. Consecutive same-kind edits at the expected
cursor coalesce and a typed whitespace starts a new step,
so Ctrl+Z removes the word just typed — no wall-clock
timers, deterministic and testable. The history is born
with the block (set_text resets it) and never leaks across
blocks or into the file.

Block level, in navigation: a 32-step ring on the buffer
records every file write as a trimmed diff — the common
prefix and suffix are cut, leaving exactly the affected
line range. One recording wrapper (record_write) serves
every write site: acceptance, insertion, deletion, the
reorder move. Applying a step is a splice plus the usual
re-chunk and save; selection restores to the step's
remembered side. The history lives on the buffer, so it
survives a follow-require round trip and dies with the
file; a checkpoint restore rebuilds the buffer, which is
the boundary. A new write kills the redo tail.

Keys: Ctrl+Z / Ctrl+Y in both modes; bare Delete drops a
block in navigation now that it is undoable (2.7), without
touching the clipboard — cutting is Ctrl+X alone, since on
the device every clipboard write pops the share overlay.

Dialogs are repeat-proof by construction, as agreed after
the device pass: the confirming key differs from the
invoking one. Discard (Shift+Esc on a changed block),
checkpoint overwrite and restore all confirm on Enter or
Space and cancel on anything else — the invoking chord and
Esc included — so a held key oscillates ask/cancel and
never fires. One executor runs the confirmed action; the
handlers only raise. A parseable draft discarded with
confirmation leaves a recoverable pair in the block
history: one Ctrl+Z puts it in the file, another takes it
out. A plain error message closes on Enter, Esc or
Shift+Esc without re-submitting, matching the REPL.

Co-Authored-By: Claude <[email protected]>
…editing flag

UserInputModel:backspace_word eats the word before the
cursor together with the whitespace in front of it, as
readline does, on Ctrl+Backspace and Ctrl+W (spec 2.7).

UserInputModel is the whole platform's input — the
console, the project inputs the games read, and search
all build on it — so the editor's extras must not leak.
An explicit `editing` flag on the model (off by default;
only the editor model opts in) scopes them: word deletion
and the refusal frame gate on it, the edit history
records nothing without it, and the widget's Ctrl+Y
delete-line stays for the console (in the editor the
controller takes Ctrl+Y for redo first). Regression specs
pin the contract from the console's side: a flagless
model records nothing, Ctrl+Backspace deletes one
character, Ctrl+W does nothing, Ctrl+Y deletes the line.

Co-Authored-By: Claude <[email protected]>
README.md and doc/EDITOR.md describe the final layout:
line-wise navigation with Ctrl-arrow block jumps, Home/End
to the file's edges, the Alt peek, Ctrl+Enter blocks,
undo/redo, bare Delete, word deletion, the dialog keys,
checkpoints and Ctrl+J. The input widget's Home/End rows
follow the line-scoped fix.

Co-Authored-By: Claude <[email protected]>
Co-Authored-By: dsent <[email protected]>
…gnore rules

Update the top-level README's editor walkthrough to the key shortcuts after the
editor overhaul this branch builds on: Enter opens a block for editing and Enter
sends it back, Shift+Esc leaves the editor, and the deprecated Ctrl+Shift+S is
called out as leaving without the acceptance step. Also marks the doc/mermaid
diagram sources as historical design sketches and updates .gitignore. Docs only;
no behaviour change.
The project-lifecycle tests need to stub the filesystem methods invoked while a
project run tears down; this exposes them as seams so the suite can drive exit
without touching the real filesystem. Isolated to src/util/filesystem.lua.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds doc/input_api.md -- the project-facing guide to compy.input (the precise
surface), the simple compy.ask/on_answer/on_key surface, the four lifecycle
flags, shortcuts and hooks, and a migration table from the removed legacy
globals -- and the CHANGELOG.md CURRENT_SCOPE at user-facing altitude, including
the two breaking changes.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
The developer documentation corpus under doc/development: the internals notes,
the conventions, the decisions ledger (decisions/input.md), the technical-debt
register (technical_debt/*), and the overview, drawing-system, smoke-checklist
and tests docs. Reference material for maintainers; no shipped code.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
The busted specs for the widget surface and its callbacks/lifecycle, the input
routing chain, global shortcuts and clicks, the simple surface, console
fall-through, editor keys, cursor/text handling, combo serialisation, config-key
agreement and the memory NFR, plus shared helpers and fixtures and updates to the
editor, history, model, view and mock specs. Green suite: 1174 / 0 / 0 / 9.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
The routing spine: controller.lua dispatch, consoleController, the new
projectInputController, and userInputController. Events walk a chain of
responsibility -- shortcuts, then hooks, then the shown widget -- with the
project run owning its route and the console taking only what nothing consumed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
The input widget's model and view: userInputModel (content, cursor, history and
the submit/cancel lifecycle behind the four flags), userInputView rendering, and
the consoleModel touchpoints. Activation no longer destroys content; get_text
reads it back; clearing and hiding are flag-driven at each verb.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Editor key handling sited at route level (editorController), and the editorModel
and searchModel touchpoints, reconciled to the imported editor rework. NOTE: part
of this diff is the editor-rework baseline rather than new work -- see the PR
slices manifest's base-drift caveat.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…mony

main.lua wiring (the widget built at the run seam, key-repeat, before_exit),
src/util/key.lua (the combo and modifier vocabulary), types.lua, the evaluator's
move to line callbacks and validators, and the harmony screenshot-test init.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
The in-tree example projects move off the removed polling globals onto
compy.input and compy.ask: turtle, tixy, sapper, guess, repl, valid, clock,
paint and pong, with a held-key fix in pong and README updates. The nested
example repositories keep their own PRs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Carries the serial work from integr/serial-on-input77 onto
the current head of the input branch as one change, leaving
the debugging history behind. What comes across:

- model/serial: the facade (onConnect / onDisconnect /
  onBytes / onLine / send / isConnected), the dispatcher that
  delivers to the console and program environments, the line
  reader, and three backends — Android over JNI, a fake for
  tests, a null one for the desktop.
- util/jni: the LuaJIT FFI bridge to the JNI environment that
  SDL hands out, with the call helpers the backend uses.
- The platform port is created once in prepare_env: Android
  gets the USB backend, everything else the null one. Both
  environments see it as compy.serial, held as an upvalue in
  get_compy_namespace like compy.input, for the same reason:
  the env clone is deep and a copied table would collect
  handlers the dispatcher never reads.
- The port is pumped once per frame from love.update, and a
  fault it reports is printed with the environment it came
  from.
- Outgoing bytes leave one per poll: the board's Lua REPL is
  an interactive terminal and drops the tail of anything
  written faster than about a byte every 2 ms (measured over
  pyserial, no Compy in the path). A frame is some 16 ms.
- tests/serial, 38 specs, green here alongside the branch's
  own.

Left behind on purpose: the robot transport and its probe
(a separate line of work), and serial_probe, the console
diagnostic that the terminal project's echo() replaces.
stop_project_run tears down the program's input widget and
its love handlers; its serial handlers stayed. Nothing
called Serial:programEnded, although the facade had it from
the start. Now it is called next to clear_user_handlers, so
a stopped project's onBytes / onLine no longer fire into the
console, and the next run starts with an empty program
environment — the same invariant the input widget keeps.

The console's own handlers are untouched: they were set from
the console and belong to it.
The serial-terminal demo, renamed and rewritten against the
new input API: on_text_entered now takes the text flat, and
after_submit clears the field while the widget stays open,
so there is no re-showing between lines.

The board runs the Lua REPL firmware. It ends its lines with
CR — with CR CR LF after one it has echoed — and it repeats
what it receives, so the terminal shows its echo followed by
its answer. Whole lines are printed as they arrive; a prompt
or the head of a long answer comes without a line ending,
waits a fifth of a second in case the rest is still on the
way, and then goes out through io.write, which leaves the
line open for what follows. The typed text lands on that
open line, where a terminal would put it, and sending closes
it so the board's answer starts on a line of its own.

Nothing checks what is typed: what is valid Lua is the
board's business, and the field would only get in the way.
echo() in the console: on, and what arrives from the board
is printed; echo(false), and it is not. The reading was in
the micro:bit project until now, and it read badly — a board
answers a character at a time, so a chunk is rarely a whole
line, and printing each chunk as it lands spells the answer
out letter by letter.

model/serial/echo holds the two things that fixes. Whole
lines go out through print, which the terminal draws at
once. What is left over — a prompt, the head of a long
answer — has no terminator to wait for and no sign that the
stream has ended, so it goes out once the board has been
quiet for a fifth of a second, through io.write, which
leaves the line open for the rest of it. That wait is why
this belongs here rather than in a project: the frame is
where the time is, and Serial:update now takes dt and ticks
the echo with it.

Bytes that are not UTF-8 are dropped. A board greets a fresh
connection with a few stray ones, and the terminal takes
UTF-8 only — it raises on anything else, which took the
whole output down. Defending io.write is the platform's to
do (technical debt, registered); until then the port does
not hand it anything it cannot draw.

io.write and print come in through the constructor, so the
specs watch what was said and what was written without a
terminal anywhere near them.
The commands around the terminal, to Dani's specification.
main.lua only prints how to start; the tools are loaded into
the console with require("tools") and become commands there:

  help()             this list
  echo(on)           what the board says, in the console
  send(filename)     a project file to the board, as typed
  exec(filename)     a project file run on the board as one
                     chunk

echo is the console's own — it needs a frame to time the
tail of an answer by, and a project has no say in that. The
tools switch it on when they load, which is the moment the
board becomes worth listening to.

Typing to the board is the terminal project; this is the
work around it. Files are written with edit(), which the
console already has, and help() says so.

exec has to hold back the board's echo of the file while it
is in flight, but send queues bytes and returns at once —
they leave one per frame. So its handler stays silent until
the board has echoed the closing marker of the wrapper, then
hands the rest back to echo, or leaves it off if it was off
to begin with. The board echoes exactly what it is sent, so
the marker is a reliable end.
A program that shows what the board says has its own
handlers, and the console's echo kept its own alongside —
so every answer was printed twice, prompts included, and a
line came out as "> > print(1 + 1)".

The dispatcher already had suspend_env for the mirror case,
where a paused program stops being read. programStarted uses
it on the console side, called where the run begins;
programEnded gives the hearing back.

Nothing else changes: the console's handlers are left in
place, they just go unread for as long as a program runs.
programStarted mutes the console for as long as a program
speaks for the board, and stop_project_run was the only
place that gave the hearing back. A project whose top-level
code simply finishes never goes through a stop: it lands in
project_open straight from the run, and the console stayed
deaf until Compy was restarted. The micro:bit project is
exactly that shape — main.lua prints a line and returns —
so run() silenced echo for good.

Two places reach project_open without stopping, and both
now say so. A run that falls idle calls programIdle: the
program is not stopped, so the handlers it set stay, but it
is no longer the one speaking. A run that raised calls
programEnded, which clears them — nothing survives the
project that installed it, the same invariant the widget and
the love handlers already follow there.
The console prepares serial support when it is built
(consoleController.lua), which calls love.system.getOS() to choose a
backend. The shared input fixture's mock love has no `system`, so the
tests/input specs that build a console through it errored ("attempt to
index field 'system'"), taking the suite to 841/0/16/0. Stub
love.system (desktop OS -> null backend), matching the per-spec stubs
already in editor_spec / input_editor_keys_spec.

SUITE: 841/0/16/0 -> 1222/0/0/9 (the 16 errors clear; the serial specs
now run).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AfmgSmapP9mpAYzcPcQhj2
io.write of a byte that is not valid UTF-8 took the whole console
output down: terminal_print's `for i, p in utf8.codes(res_string)`
(terminal.lua) raises `invalid UTF-8 code` on a malformed sequence,
and terminal_print is the single sink for io.write
(redirect.lua -> CanvasModel:write -> terminal:print). One stray byte
aborted the write, lost the valid tail, and propagated up so the
console stopped printing. Reported by Vadim; more reachable now that a
serial board can echo arbitrary bytes.

Defend at the boundary: utf8_clean drops invalid bytes (utf8.len gives
the first bad position) before the codes loop, so no caller can crash
the terminal. This is the policy model/serial/echo.lua already applies
to its own path (and now names as covered here); echo's as_text stays
as belt-and-suspenders and for its CR/LF normalisation.

Breaking test: tests/lib/terminal_spec.lua -- print("A\255B") must not
raise and must queue A and B.

SUITE: 1222/0/0/9 -> 1224/0/0/9 (+2, the new spec).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AfmgSmapP9mpAYzcPcQhj2
…hows

Terminal scrolling off-by-one: the last printable character sent via
io.write should always be visible, but numbering lines 1-16 hid "16"
until a newline was sent.

Cause: CanvasModel built the terminal from the full view height
(cfg.view.h) while the visible console region is
get_drawable_height = h - statusline - input line (util/view.lua). The
terminal thus had ~2 extra rows that render under the input line; a
final un-terminated line landing there was off-screen until a newline
scrolled the buffer up. The intended value was present but commented
out at canvasModel.lua. The terminal's own scroll (roll_up/
wrap_if_bottom) is correct -- the last line stays in the buffer; it was
simply not on screen. Pre-existing platform bug, not introduced by the
input API.

Fix: size only the terminal to get_drawable_height; leave the program
drawing canvas at full size so canvas/turtle programs are unaffected
(this is why the fix is not a wholesale uncomment of that line). Kept
full height in sizedebug mode.

Not unit-testable headless (a rendering/occlusion fact, no LÖVE
compositor in busted); a desktop/device smoke is recommended.
SUITE: 1224/0/0/9 unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AfmgSmapP9mpAYzcPcQhj2
@Vadim1987
Vadim1987 force-pushed the integr/serial-on-newapi branch from e26d0c8 to 663eb17 Compare September 11, 2026 10:00
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.

3 participants