# BYOK Script Writer's Guide

How to write a script for the BYOK, for people who write prose rather than code.

---

## What a script is

A script is a text file on the memory card, in the `Scripts` folder, ending in `.lua`. The BYOK finds it the next time it starts.

Scripts do small jobs on the document you are writing. Typing a scene break. Replacing `INT` with something longer. Counting the paragraphs. They cannot change how the BYOK itself behaves — a script cannot alter your settings, your keyboard, or another script.

### The two kinds

**A one-shot** runs when you ask for it. You give it a letter, and `Ctrl+T` followed by that letter runs it. It does its job and stops.

```lua
function main()
  BYOK.doc.insert("\n\n* * *\n\n")
end
```

**A watcher** runs while you type. It has no letter, because you do not summon it — you switch it on, and it stays on until you switch it off.

```lua
function init()
  BYOK.sys.registerEvent("WORD")
end

function listen(event, text, from, to)
  ...
end
```

A watcher has two functions because it has two moments. `init` runs once when you switch it on, and says what it wants to be told about. `listen` runs every time that thing happens.

### What a watcher can be told about

| Event | Happens when | `text` is | `from`–`to` cover | At most |
|---|---|---|---|---|
| `WORD` | you finish a word | the word | the word, **without** the space | 64 bytes |
| `LINE` | you press Enter | the line you just finished | that line, without the newline | 1024 bytes |
| `TAB` | you press Tab | the word before the tab | **the word and the spaces Tab left** | 64 bytes |

`listen` is told which event it is for, because a watcher may ask for several. If yours asks for only one you can ignore the first argument, but it costs nothing to check.

**Past the cap, nothing happens at all.** No event, no error, and your `listen` is simply not called. What you get is never a shortened version: the `from`–`to` would still describe the whole of it, so a script replacing a range it had only seen part of would eat the rest. Silence is the safe answer, and it is the only one.

`LINE` is the big one because it carries a whole **paragraph**. Enter is a paragraph break on the BYOK, not a line break — the screen wraps your text for you — so the line you are handed runs back to the last time you pressed it. Ordinary prose runs several hundred bytes to the paragraph. The other two carry a word, and something past 64 bytes is not a word.

**If a `LINE` watcher never seems to fire, this is the first thing to check.** Plug the BYOK into a computer and read the serial log; it says when a paragraph was too big, and how big it was.

**There are no tab characters in your document.** Pressing Tab types spaces — five of them — and a tab in a file you copy across is turned into spaces when it loads. Nothing you write will ever find a `\t`, because there is never one there.

So the `TAB` range covers the abbreviation *and* the spaces that Tab just left behind. That is what makes expanding a snippet a single `replaceRange`: both vanish and your text takes their place. Do not count the spaces yourself or assume how many there are — use the `from` and `to` you were given, and it stays right if the number ever changes.

`WORD` goes the other way, and the difference matters the first time you write one. The space you typed is what tells the BYOK the word is finished, and that space is *not* part of the range — it is still sitting there after your replacement lands. So a snippet ending in a space of its own gives you two. Leave it off and let the space you typed do the work:

```lua
CHR = "CHARACTER:"     -- not "CHARACTER: "
```

A Tab expander is the opposite, and wants the trailing space that a WORD expander must not have — there is nothing left behind for it to inherit.

**Pressing Tab does not also finish a word.** It sends `TAB` and nothing else, so a snippet watcher on `WORD` and a completion watcher on `TAB` never both fire on the same keypress.

**None of these can stop the key.** By the time your script runs, the word is typed, the line is broken, the spaces are in. What you get is the chance to change what happened — which is why every event tells you exactly which stretch of text to replace.

Wherever your cursor was when the event fired, that is where it stays. If you were three words further on by the time a `LINE` watcher reformatted the line behind you, you are still three words further on.

### Which to write

If you would reach for it deliberately — "expand this", "count that" — write a one-shot. If it should happen without you thinking about it, write a watcher.

Watchers cost more. One is fine. Four is the limit, and the BYOK will refuse the fifth rather than slow your typing down.

Two watchers may both listen for the same event, but **only one of them may change text on it**. The second is refused when you try to switch it on, and told which one holds it. Watchers on *different* events never conflict — a word expander, a line capitaliser and a tab expander all run happily together.

## The header block

Every script starts with a few lines that tell the BYOK what it is. They begin with `--- @`, and they must come before anything else.

```lua
--- @name         Scene break
--- @description  Types a centred scene break at the cursor
--- @type         oneshot
--- @key          b
--- @requires     document
--- @caps         doc.edit
```

| Line | What it does |
|---|---|
| `@name` | What appears in the script list. Required — without it the script is ignored. |
| `@description` | The sentence shown when you open the script's details. Keep it under about fifty characters or the end is cut. |
| `@type` | `oneshot` or `watcher`. Required. |
| `@key` | The letter for `Ctrl+T`. One-shots only; a watcher is switched on instead. |
| `@requires` | `document` if it needs something open, `none` if not. |
| `@caps` | What it is allowed to do. See below. |

Spelling is forgiving: `one-shot` and `oneshot` are the same, and capitals do not matter.

### @caps is the important one

A script can only call what its `@caps` line asks for. Ask for nothing and `BYOK` is not there at all — the script will stop at its first line with an error.

There are ten to choose from:

| Capability | Lets the script |
|---|---|
| `doc.read` | Read the document, and read what you have selected |
| `doc.edit` | Change the document |
| `doc.navigate` | Move you — find a word and take you to it |
| `data.read` | Read its own data file |
| `data.write` | Write its own data file |
| `ui.alert` | Put a message on screen |
| `ui.list` | Ask you to choose from a list. A watcher may only ask from a `TAB` |
| `ui.ask` | Ask you to type a line. A watcher may only ask from a `TAB` |
| `screen.draw` | Take the screen and draw on it. One-shots only |
| `sys.log` | Write to the serial log |

List several with commas:

```lua
--- @caps         doc.read, ui.alert
```

`doc.navigate` is separate from `doc.read` on purpose. A script that scrolls your document away from where you were has done something to you, even though it changed nothing — so it has to say so. `data` is separate from `doc` for a plainer reason: a script's own file and the book you are writing are not the same thing, and a script that only keeps notes for itself should not be asking to touch your manuscript.

`screen.draw` is one capability rather than a dozen. Nobody should have to list every kind of line and box before they can draw one.

Two things worth knowing before they cost you an afternoon:

- A misspelt capability is not reported when the script loads. It simply grants nothing, and the script fails at the moment it tries to use it.
- Asking for more than you use is not an error, but it is a promise you did not need to make. `@caps doc.edit` on a script that only counts words says it may rewrite your book.

### Your letter may not be the one you asked for

If two scripts want `b`, the first one keeps it. The second is left without a letter — it still appears in the list, and you can give it a different one there. You are not asked to resolve it while you are trying to write.

**Check that you got the letter you asked for**, the first time you run a new script. A script with no letter is easy to mistake for one that is not working, when in fact `Ctrl+T` and your letter are quietly running somebody else's script.

`?` and the space bar are never available: they already mean something when `Ctrl+T` is waiting.

## What you can call

Everything the BYOK offers lives under `BYOK`, grouped by what it touches. This list grows as firmware does.

### BYOK.doc — the document you are writing

Three capabilities, because there are three different things a script can do to a document: look at it, change it, and move you around in it.

#### Looking — `doc.read`

**`BYOK.doc.chunks()`**

Walks the whole document in pieces, front to back:

```lua
for chunk in BYOK.doc.chunks() do
  ...
end
```

Each chunk is a few thousand characters and always ends at the end of a line, so a word or a phrase is never split between two of them — and no piece of the document ever arrives twice. The chunks lay end to end and make exactly your file. You get the document as it was **when the script started**. One pass, forwards; you cannot ask for a particular page and cannot go back.

**What you were typing a second ago is included.** The BYOK writes the page out before it runs any script that reads, so a script never sees an older version of your work than the one in front of you. You do not have to save first, and there is no point doing so.

That is what makes it safe to **work something out with `chunks()` and then act on it with `find`**, even though one reads the file and the other works on the page in front of you. They are the same document while your script runs. Count occurrences in one and correct them in the other, and the number you counted still means something.

**`BYOK.doc.selection()`**

The text you have selected, or `nil` if nothing is.

```lua
local text = BYOK.doc.selection()
if not text then
  BYOK.ui.alert("Nothing selected", "Select some text first")
  return
end
```

Select the usual way — hold shift and use the arrows — then run the script. Your selection survives opening the script list, so it does not matter whether you run the script by its letter or pick it from the list.

Very large selections come back as `nil` rather than as a shortened version, so you are never handed half a paragraph believing it is all of it.

**`BYOK.doc.clipboard()`**

What you last copied or cut, or `nil` if you have not copied anything since the BYOK started.

```lua
local clip = BYOK.doc.clipboard()
if clip then
  BYOK.doc.insert("> " .. clip)
end
```

All of it or nothing, like `selection()` — there is no way to ask for the first so many characters, because that would cut in the middle of a word and possibly in the middle of a letter.

**It is not always from the file you have open.** The clipboard survives switching files, so a script may be handed something cut from somewhere else entirely. If that matters to what you are doing, you cannot tell from here.

Reading it does not disturb it. Your clipboard still holds what it held, and paste still works.

#### Changing — `doc.edit`

**`BYOK.doc.insert(text)`**

Types `text` where your cursor is, exactly as if you had typed it. **If something is selected, this replaces it** — which is why there is no separate "replace the selection" call:

```lua
BYOK.doc.insert(BYOK.doc.selection():upper())
```

**It hands back whether the edit was taken, and on anything that matters you should look.**

```lua
if not BYOK.doc.insert(text) then
  -- it did not happen
end
```

`false` almost always means one thing: **your script has a window open.** While a window has the screen, the page will not accept changes — see **Drawing on the screen** for what to do instead. It is also `false` for an empty insert, or text past the size limit.

Worth saying plainly, because it is an easy mistake and a quiet one: a script that counts what it ASKED for rather than what was taken will tell you it fixed six things and have changed nothing at all.

**`BYOK.doc.replaceRange(from, to, text)`**

Replaces the stretch between two positions. This is what a watcher uses, because the event tells it exactly which stretch it means — by the time the script runs, your cursor has usually moved on.

Passing empty text deletes the stretch and puts nothing back, which is how a watcher removes something rather than changing it.

Hands back the same thing as `insert`, and for the same reasons.

**Both of these will insert characters the screen cannot draw.** The BYOK's fonts cover the ordinary keyboard and little else. An em dash, curly quotes, an ellipsis — the kind of punctuation a typographic script reaches for first — go into the file correctly and appear on screen as a filled diamond. Your text is not damaged and the file is right; you simply cannot see what you wrote. Until the fonts grow, write two hyphens rather than an em dash, and straight quotes rather than curly ones.

#### Moving you — `doc.navigate`

**`BYOK.doc.find(term)`**

Searches from your cursor, wraps round the end once, and takes you to the match with it selected. Returns `true` if it found one.

```lua
if not BYOK.doc.find("TK") then
  BYOK.ui.alert("No TK left", "Nothing still to come")
end
```

Run it again to walk to the next one. It is a separate capability from reading because it moves you: a script that scrolls your document away from where you were has done something to you, even though it changed nothing.

It tells you **whether** it found the term, never **where**.

When it says `true`, the cursor really is on the match — not on its way there. That is what lets the next line edit it.

##### It matches letters, not words

Worth reading twice, because the obvious use of it damages prose. `find` looks for your letters **anywhere**, including inside longer words:

```lua
BYOK.doc.find("ot")     -- stops inside lot, not, got, another
```

A script that corrected that would rewrite all four.

**Capitals count too.** `find("teh")` walks straight past `Teh` at the start of a sentence. List both spellings if you want both — and if you are also counting occurrences yourself, count them the same way, or the number you have and the matches you get will not be the same set.

**There is no whole-word option, and you cannot build one.** Since `find` tells you whether it matched and never where, your script cannot look at the letters either side and decide for itself.

What works is putting the spaces into the term, and back into the replacement:

```lua
BYOK.doc.find(" ot ")
```

Be honest with yourself about what that misses. An occurrence at the start of a line, or against a comma or a full stop, has no space on one side and will not be found. Two of the same word separated by a single space only take one pass each time, because the space between them belongs to whichever is found first — run it again for the rest.

Short words are where this is most dangerous and hardest to get right, so a list of corrections is safest when every entry is long enough to be unambiguous on its own.

##### Changing what you found

`find` leaves the match selected, and `insert` replaces a selection. Those two together are how a script fixes a word it went looking for — needs `doc.navigate` and `doc.edit`:

```lua
if BYOK.doc.find("teh") then
  BYOK.doc.insert("the")
end
```

##### Fixing all of them

`find` gives back `false` only when the term is nowhere in the document. Because it wraps, that means it has looked everywhere — so a loop finishes rather than handing you the first match over and over:

```lua
while BYOK.doc.find("teh") do
  BYOK.doc.insert("the")
end
```

Each turn replaces one, so there is one fewer to find, and it always reaches the end.

**Unless your replacement contains the thing you are looking for.** Searching for `hte` and inserting `hte ` never runs out of matches, and the BYOK eventually stops the script for running too long.

**A loop like that cannot let you say no.** It only ends because every turn removes a match; leave one alone and the wrap brings you back to it for ever. If you want to ask before each change, count them first with `chunks()` and go round exactly that many times.

##### What this cannot do

This is a sweep, and sweeping is all of it. `chunks()` and `find` are two separate searches that do not join up: if you read the document and noticed a misspelling in the third paragraph, `find` still takes you to the first one in the file. Changing one particular occurrence and leaving the rest alone is not something a script can say today — see the end of this section.

### BYOK.data — the script's own file

A script can keep a file of its own beside itself on the card. `scene.lua` reads and writes `scene.dat`; `count.lua` would use `count.dat`.

**8 KB is the ceiling, and there is no way round it.** Both reading and writing refuse anything larger, there is no way to read a big file a piece at a time, and a script cannot open a second file — its own `.dat` is the only one it can reach. Eight kilobytes is roughly a thousand short lines: plenty for snippets, settings or a list of names, and nowhere near a dictionary. **If what you are planning does not fit, it does not fit.** Better to know that while you are planning than to meet it as a failed save later on.

**You never name the file.** There is no filename anywhere in these calls, and that is deliberate — a script cannot reach a file that is not its own, because it has no way to say one. It also means you cannot get the name wrong.

**`BYOK.data.read()`** — needs `data.read`

The whole file as one piece of text. When it cannot give you that, it hands back `nil` **and a reason**:

```lua
local text, why = BYOK.data.read()
```

| `why` | What happened |
|---|---|
| `"missing"` | There is no file yet. |
| `"toolarge"` | Over 8 KB. Your file is there, untouched. |
| `"unreadable"` | It exists but could not be opened. |

**Only `"missing"` means it is safe to write.** The other two mean your file is still on the card, and saving over it would destroy something you cannot get back:

```lua
local text, why = BYOK.data.read()
if not text and why ~= "missing" then
  return                     -- something is there; leave it alone
end
```

Any script that saves what it has learned needs that check. Without it a file that has merely grown too big reads as a first run, the script starts again from nothing, and the next save replaces everything that was in it.

Ignore the second value if your script only ever reads.

**`BYOK.data.write(text)`** — needs `data.write`

Replaces the file entirely. Returns `true` if it was written. There is no way to add to the end — read it, change it, write it back.

At most 8 KB, on the way in and on the way out. **Check what `write` gives back**: a refusal is otherwise silent, and a script that keeps adding to a list will one day cross the line and stop saving without saying so.

Refusing rather than shortening is the same rule as a long selection: half a file treated as though it were all of it is worse than none.

**Nothing decides what goes in it but you.** It is plain bytes as far as the BYOK is concerned — a list, some settings, notes to yourself. Whatever you write, your script has to be the thing that understands it.

That makes it the simplest way to let someone change a script without editing the script. Ship a `.dat` alongside, read it if it is there, fall back to something built in if it is not:

```lua
local function load_scenes()
  local text, why = BYOK.data.read()
  if not text then
    if why ~= "missing" then return nil end   -- do not overwrite what is there
    return built_in
  end

  local scenes = {}
  for line in text:gmatch("[^\r\n]+") do
    if not line:match("^%s*$") and not line:match("^%s*#") then
      local name, body = line:match("^(.-)|(.*)$")
      if name then scenes[#scenes + 1] = { name = name, text = body } end
    end
  end

  if #scenes == 0 then return built_in end
  return scenes
end
```

Now the file is editable on a computer in any text editor, and the script still works for someone who never opens it. That is `scene.lua`, and `scene.dat` is on the card next to it.

A file that parses to nothing falls back too. An empty list is a mistake rather than an instruction.

**Falling back is not always the kind thing.** Where the file IS the point of the script — a word list, a set of rules — quietly using something else when the writer's own edit failed to parse is worse than stopping and saying so. They will believe their change took effect. `spelling.lua` keeps no built-in list for exactly this reason.

**Renaming a script leaves its data behind.** `scene.lua` renamed to `slug.lua` will look for `slug.dat` and find nothing. Rename the `.dat` with it.

### BYOK.ui — asking and telling

**`BYOK.ui.alert(title, body)`** — needs `ui.alert`

A box with a title, one line of message, and OK. Both lines are about 26 characters; anything longer is cut.

**`BYOK.ui.list(choices)`** — needs `ui.list`

Puts a list on screen and waits. Returns the number of the choice, counting from one, or `nil` if the writer backed out.

```lua
local dividers = {"* * *", "- - -", "~ ~ ~"}

local choice = BYOK.ui.list(dividers)
if not choice then return end

BYOK.doc.insert(dividers[choice])
```

Your script stops at that line and carries on from it with the answer in hand. Nothing else is needed — no waiting, no second function.

It gives back a **number, not the text**, so it lines up with the table you already have. That matters when the list you show is not the text you insert:

```lua
local names = {}
for i, scene in ipairs(scenes) do names[i] = scene.name end

local choice = BYOK.ui.list(names)
if choice then BYOK.doc.insert(scenes[choice].text) end
```

**Backing out is a real answer, and the commonest one.** Either the return arrow at the foot of the list or the escape key gives you `nil`. Handle it first and say nothing — the writer knows what they did.

At most 32 choices. More than that is refused outright rather than showing the first 32, because a menu quietly missing its last entries is worse than one that never appeared.

**A watcher may only open a list from a `TAB`.** Tab is you asking for something to happen, so a menu is a reasonable answer to it. Finishing a word is not asking, and a list after every word would leave you no way to type at all.

So from `WORD` or `LINE` you get back `nil` and a reason, the same shape as `data.read`:

| Called from | `choice` | `why` |
|---|---|---|
| a one-shot, or a `TAB` watcher | the number chosen | `nil` |
| either, when the writer backs out | `nil` | `nil` |
| a `WORD` or `LINE` watcher | `nil` | `"notab"` |

Nothing appears on screen and your watcher keeps running. That is deliberate: the mistake belongs to whoever wrote the script, and the person who would see a dialog is the one typing — who did nothing wrong, cannot fix it, and would lose a watcher they had chosen to switch on. The reason goes to the serial log instead, where the person who can fix it is looking.

A Tab watcher that offers a list still owes the range a replacement on every path, including the one where you back out. See the completion example among the complete scripts at the end.

**`BYOK.ui.ask(prompt)`** — needs `ui.ask`

Puts your prompt on screen above an empty line and waits. Returns what was typed, or `nil` if the writer backed out.

```lua
local heading = BYOK.ui.ask("Heading")
if not heading then return end

BYOK.doc.insert("\n" .. heading:upper() .. "\n\n")
```

Like a list, your script stops at that line and carries on from it with the answer in hand.

**There is no way to fill the line in advance, and there will not be.** It asks; it does not edit. So whatever comes back is something the writer chose to type, and a script cannot put words in their mouth by offering them pre-written. The cost is real and worth stating: a script that renames something makes them type the whole new name rather than change a letter of the old one.

**An empty answer cannot happen.** The box will not accept a blank line — pressing enter in an empty box does nothing, and neither does OK. So `if answer then` is the whole test, and you never need a second check for empty text.

Backing out is escape, or Cancel. Both give you `nil`, and anything already typed is discarded.

At most 40 characters, and **the line does not scroll** — what fits on screen is what you can type. That is a decision, not a gap: it is one line, for a short answer to a short question. If you need a paragraph from the writer, let them type it on the page and take it with a `LINE` watcher.

**A watcher may only ask from a `TAB`** — the same rule as a list, and it matters more here. A list you can escape; a box that takes typing takes **your keyboard**. A watcher allowed to open one after every word could swallow the next thing you wrote. From `WORD` or `LINE` you get `nil` and `"notab"`, nothing appears on screen, and the watcher keeps running.

### BYOK.screen — a window to draw in

Needs `screen.draw`, one-shots only. A script can take the whole screen for a chart, a diagram or a game, wait for keys, and give it back.

It has a section of its own — see **Drawing on the screen**.

### BYOK.sys — everything else

**`BYOK.sys.log(message)`** — needs `sys.log`

Writes to the serial log. Useful when the BYOK is plugged into a computer and you are working out why something is wrong. **Nobody using the BYOK normally will ever see it**, so never say anything important only in a log.

**`BYOK.sys.registerEvent(name)`** — watchers only, inside `init`

Says what your watcher wants to be told about: `"WORD"`, `"LINE"` or `"TAB"`. What each one gives you, and how much text it can carry, is in the first section. Call it once per event you want; a name that is not one of those three is refused, so a typo cannot leave you with a watcher that quietly never fires.

### The rest of Lua

You have Lua's strings, tables, maths and coroutines. You do not have anything that reaches outside the script — no opening files, no `require`, no loading other code. If you want to count words, `("%S+")` is a word counter and there is no reason for the BYOK to provide one.

### What is not here yet

**Positions.** Nothing gives you a number saying where you are, and nothing takes one to send you somewhere. `find` moves you without either. This is on purpose for now: the document as `chunks()` reads it and the document as the editor holds it are counted separately, and until those agree a number would mean two things.

**Whole-word matching.** `find` matches letters wherever they appear, and cannot be told to want a word. See `doc.find` above for what that costs and what to do instead.

**Putting something ON the clipboard.** A script can read what you copied; it cannot copy for you. Nobody has needed it yet.

**Stopping a key.** A watcher is told what happened, never asked what should happen. See the first section.

If a script seems impossible today, one of those is usually why.

## Drawing on the screen

A script can take the whole screen and draw on it — a chart of something about your writing, a diagram, a game while you think. Needs `screen.draw`, and one-shots only: a watcher runs while you type, and a window appearing over your page because you finished a word is not something to offer.

Nothing here touches your document. That is why a drawing script can say `@requires none` and run on a BYOK with no file open at all.

### Opening one

```lua
function main()
  if not BYOK.screen.open() then return end
  local s = BYOK.screen

  s.clear()
  s.text(4, 12, "Hello")
end
```

`open()` gives back `false` if one is already open — you get one, and asking for a second is refused rather than quietly replacing the first. It also gives back `false` if the window could not be put up at all, which you handle the same way: don't draw.

When it gives back `true`, the window is **ready to measure and draw on**. `width()`, `height()` and `lineHeight()` are real on the very next line.

The window stays after `main` finishes. `s.close()` puts it away, and **escape always closes it** whatever your script thinks — that is the writer's way out and you cannot take it from them.

### While your window is up, the page will not take changes

This is the rule that catches people, including the person who wrote this guide.

Your window sits exactly where the document does, and it has the screen. For as long as that is true, **`doc.insert` and `doc.replaceRange` are refused** — they hand back `false` and nothing happens. Not delayed: refused.

So a script that draws and also edits has to do them in that order, with the window gone before the first change:

```lua
-- 1. Gather, while you have the screen
local answers = {}
while s.isOpen() do
  ...
end

-- 2. The window has gone. NOW edit.
for _, a in ipairs(answers) do
  BYOK.doc.insert(a)
end
```

**Your script keeps running after the window goes**, which is what makes that possible. The moment `key()` or `sleep()` hands back because the window closed — or `close()` returns — the page has the screen again and your edits are taken.

Check what `insert` gives back and you will find out at once if you have this the wrong way round. Ignore it and your script will report work it did not do.

If your script stops with an error, the window goes with it. A script that has failed cannot tidy up after itself, and a half-drawn picture with an error message over it is no use to anybody.

### Where things go

`width()` and `height()` are the size of your window. Use them rather than typing numbers: they are why a script still lays out correctly if the screen ever changes.

Corners are `0, 0` at the top left. Everything is measured from your window's own corner, and anything you draw outside it is simply dropped — you cannot mark the rest of the screen by accident.

**Coordinates must be whole numbers**, and Lua hands you a fraction the moment you divide. That catches nearly every chart, because a bar's length is a proportion of something:

```lua
local width = math.floor(barMax * fraction)   -- not barMax * fraction
```

A fraction is refused rather than rounded, and the message names the argument — *error decoding argument #3*. Refusing is the more useful answer: it points at the line where the arithmetic went fractional, which is where the mistake is, rather than quietly drawing something a pixel off and leaving you to notice.

`//` does the same job as `math.floor` when both sides are already whole: `(s.width() - w) // 2` centres something.

### The drawing calls

| Call | What it does |
|---|---|
| `s.clear()` | Empties the window |
| `s.line(x1, y1, x2, y2)` | A straight line |
| `s.rect(x1, y1, x2, y2)` | An empty box |
| `s.fill(x1, y1, x2, y2, pattern)` | A solid or shaded box |
| `s.circle(x, y, r, filled)` | A circle, hollow or solid |
| `s.pixel(x, y, on)` | One dot |
| `s.invert(x1, y1, x2, y2)` | Flips black and white in a box |
| `s.text(x, y, str)` | Writes text |
| `s.textWidth(str)` | How wide that text will be |
| `s.lineHeight()` | How tall a line of it is |

`pattern` is a name, not a number: `"solid"`, `"dark"`, `"grey"`, `"light"`, `"clear"`. On a black-and-white screen those are how two bars next to each other stay telling apart. `"clear"` is how you rub something out.

### Text sits ON the line you give it

`text(x, y, ...)` treats `y` as the **bottom** of the letters. They grow upward from it.

So text against the bottom edge is `height() - 1`, not `height() - lineHeight()`. If you think of a line as sitting so many pixels down from the top, add `lineHeight()`:

```lua
local function textAt(s, x, top, str)
  s.text(x, top + s.lineHeight(), str)
end
```

Getting this backwards puts your text a line's height away from where you meant, which usually looks like the call being broken rather than the number being wrong.

**Measure rather than guess.** `textWidth` counts the font actually being used, so right-aligning is `width() - s.textWidth(str) - 4` and it stays right whatever the font turns out to be.

The same font limits apply as anywhere else: the BYOK's fonts cover the ordinary keyboard and little else, so an em dash or a curly quote draws as a filled diamond. Stick to plain characters in anything you draw.

### Fonts

**Your window starts in the font the writer chose**, whatever that is — including one they installed on the card. A script that never mentions fonts therefore looks like the rest of their device rather than announcing itself.

**`s.fonts()`** gives back the names you may ask for. **`s.setFont(name)`** takes one, and gives back the name actually in use.

```lua
for _, name in ipairs(s.fonts()) do
  BYOK.sys.log(name)     -- "default", "arial small", ... "mono large"
end
```

Every name in that list works. There is nothing in it you cannot select.

A name is a family, a size, or both, in any order:

```lua
s.setFont("large")        -- bigger, same family
s.setFont("mono")         -- same size, different family
s.setFont("mono large")   -- both
s.setFont("default")      -- back to the writer's font
```

Families are `arial`, `times` and `mono`; sizes are `small`, `medium` and `large`.

**A word it does not recognise changes nothing.** `s.setFont("garamond")` leaves you on whatever you already had, and hands that name back. Being left where you were is easier to predict than being moved somewhere you did not ask for — and it means a typo costs you a font you wanted, not a layout you had.

**Not every pairing exists.** Times has no small. Asking for one you cannot have gives you the nearest size in the family you asked for — the typeface being the part you chose deliberately. That is why `setFont` hands a name back: compare it if you care, ignore it if you do not.

```lua
local got = s.setFont("times small")   -- "times medium"
```

**Changing font changes `lineHeight()`.** What you have already drawn stays where it is, but everything you measure afterwards is in the new size. Set the font first, then lay out.

### Waiting, and reading keys

**`s.key(ms)`** waits for a keypress for up to `ms` milliseconds. It gives back a name — `"up"`, `"down"`, `"left"`, `"right"`, `"enter"`, `"tab"`, `"backspace"`, `"delete"`, `"home"`, `"end"`, `"pgup"`, `"pgdn"` — or the character itself for anything printable, so a space comes back as `" "`. If nobody pressed anything in time, it gives back `nil`.

Escape is not among them. It closes the window instead, which is what `isOpen()` is for.

That timeout is the whole trick to anything that moves:

```lua
while true do
  local k = s.key(140)
  if not s.isOpen() then break end     -- escape was pressed
  if k == "left"  then ... end
  if k == "right" then ... end
  -- runs every 140ms whether or not a key came
end
```

`nil` is not a failure. It is the beat — it is what moves the ball when the player is holding still.

**`s.sleep(ms)`** waits without watching for keys, and gives back `false` if the window closed while it waited. Use it when you want a pause of a known length — drawing a chart bar by bar so it can be watched:

```lua
for i, bar in ipairs(bars) do
  s.fill(10, i * 12, 10 + bar.width, i * 12 + 8, "dark")
  if not s.sleep(120) then return end
end
```

Keys deliberately do not cut a `sleep` short. An animation should not run at a speed that depends on whether anyone happened to touch the keyboard.

**`s.isOpen()`** is `false` once the writer has pressed escape. Check it after anything that waits, and stop when it says to.

**Waiting needs a window.** `sleep()` is the only pause a script has, and it does nothing once the window has gone. So there is no way to slow a loop down after you have closed it — if something needs pacing, do the pacing while you still have the screen.

### Draw what changed, not everything

This is the one thing that decides whether something feels good or feels broken.

Your window keeps what you drew. Nothing repaints it behind your back. So a moving picture should erase the bit that moved and draw the bit that is new — not clear the window and rebuild it:

```lua
-- Once, before the loop
s.clear()
s.rect(0, 0, w, h)

-- Each frame: two small changes, not a rebuild
s.fill(oldX, oldY, oldX + 3, oldY + 3, "clear")
s.fill(newX, newY, newX + 3, newY + 3, "solid")
```

Clearing the whole window every frame makes the screen visibly blink. On a chart that is merely ugly; on anything that moves it is unplayable.

### When something covers your window

The BYOK may need to tell you something while your script is running — that the battery is low, most often. Its message appears over your window, and when it goes it takes that part of your picture with it.

**`s.damaged()`** is `true` once after that has happened, and false again when you have asked. Nothing else can put your picture back, because nothing else knows what was on it:

```lua
if s.damaged() then
  redrawEverything()
end
```

Check it wherever you already wait. Anything meant to stay on screen for a while should — a chart nobody redraws will sit there with a hole in it.

### Stopping

Escape closes the window. If a script has stopped listening — a loop with a mistake in it, most likely — **hold Ctrl and press the full stop key**. That stops the script wherever it is and gives you the screen back. It works for any script, not only one that has drawn something.

You should not need it often. It is there so that a script you are still working on can never take the machine away from you.

### Not there yet

**Fonts from the card, by name.** You get the writer's own font through `"default"`, and the built-in families by name, but a script cannot ask for one of their installed fonts specifically. Reading one in means holding a chunk of memory for as long as the window is open, which is not worth it until somebody wants it.

## When something goes wrong

A script that fails says so on screen, with the file and line it failed at.

```
Script error
scenebreak.lua line 9
This script needs a
capability it lacks
```

Your document is never touched by a script that fails. Nothing half-happens.

### The messages you are most likely to see

| It says | It means |
|---|---|
| This script needs a capability it lacks | The `@caps` line does not ask for something the script calls. Usually a spelling. |
| This script needs a function called main() | A one-shot with no `main`. Check the spelling and that it is not `local`. |
| This script needs a function called listen() | A watcher with no `listen`. |
| init() must say which events to listen for | A watcher whose `init` never called `registerEvent`. |
| That function does not exist | A call the BYOK does not have. Check it against the list of what you can call. |
| The script used too much memory | Usually building one enormous string. Work in pieces. |
| The script ran for too long | A loop that does not end. |

Anything else is Lua's own wording, shortened to fit.

### A watcher that fails switches itself off

A watcher runs on every word you type. If it failed each time, you would get a dialog for every word and no way back except restarting.

So the first failure is the last: you see one message, the watcher switches off, and the list shows it off. Fix the script, copy it across, restart, and switch it on again.

### When a script does nothing at all

Not every refusal is an error, because most of them are not the script's fault.

- **A script you have just copied across is not there yet.** Scripts are looked for once, at startup. Restart the BYOK and it will appear — and the same goes for a change you made to a script already on the card. If your edit seems to have had no effect, this is why.
- **A script that needs a document, when none is open**, does nothing. Same if a menu or a dialog is in front — it acts on the document you are looking at, and if you are not looking at one there is nothing to act on. The script list is the exception: it is a way of running scripts, so opening it does not count as leaving your document, and it does not lose your selection either.
- **A letter with nothing bound to it** does nothing.
- **A watcher whose event is already taken** by another watcher that edits will not switch on. Only one watcher may rewrite text on a given event, because the second would be working from positions the first has already moved. The refusal names nothing you need to fix in your script — switch the other one off instead. Watchers on *different* events never collide.
- **A fifth watcher** will not switch on. Four is the limit.

None of these beep or complain. Nothing started, so nothing is left half-done.

### When it worked but looks wrong

If text appears as a filled diamond, the character is in your file correctly and the screen simply has no shape for it. See the note on fonts under `doc.insert`.

## Complete scripts, line by line

All of these are on the card as examples. Copy one and change it — that is a faster start than an empty file.

**Copy it, then restart the BYOK.** Scripts are looked for once, when it starts up. A script you have just copied across will not appear in the list until then, and neither will a change you made to one already there.

### A scene break

```lua
--- @name         Scene break
--- @description  Types a centred scene break at the cursor
--- @type         oneshot
--- @key          b
--- @requires     document
--- @caps         doc.edit

function main()
  BYOK.doc.insert("\n\n* * *\n\n")
end
```

Six header lines and one line of work. `Ctrl+T b` puts a blank line, three spaced asterisks, and another blank line wherever you are. `Ctrl+Z` takes the whole thing back in one press — the BYOK treats a script's edit as one action, not as nine separate characters.

`\n` is a new line. `"\n\n"` is therefore a blank line between paragraphs.

### A snippet expander

```lua
--- @name         Expand snippets
--- @description  Replaces an abbreviation as you type
--- @type         watcher
--- @requires     document
--- @caps         doc.edit

local snippets = {
  CHR = "CHARACTER:",
  INT = "INTERNAL",
  EXT = "EXTERNAL",
}

function init()
  BYOK.sys.registerEvent("WORD")
end

function listen(event, word, from, to)
  local text = snippets[word]
  if text then BYOK.doc.replaceRange(from, to, text) end
end
```

Three parts.

**The table** lists what turns into what. It is built once, when you switch the watcher on, and kept — which is the real difference between a watcher and a one-shot. Add your own lines to it; that is the only part you need to change.

None of them ends in a space. The space you typed to finish the word is what sent the event, it is not part of what gets replaced, and it is still there afterwards — so a snippet with a space of its own would give you two.

**`init`** asks to be told when a word is finished. Without this nothing ever happens.

**`listen`** runs each time you finish a word. It is given the word and where it was. If the word is in the table, it is replaced; if not, the script does nothing at all, which is the usual case and costs nothing.

`from` and `to` matter. By the time `listen` runs you have typed the space after the word, and possibly more — so the script replaces *where the word was*, not wherever you have got to.

The first argument, `event`, says which event this is. This watcher only asked for one, so it could be ignored — but check it anyway. The day you add a second event, a `listen` that assumed there was only ever one becomes a bug you have to find rather than one you were told about.

### The same idea, on demand

Change two lines and it waits for Tab instead:

```lua
function init()
  BYOK.sys.registerEvent("TAB")
end
```

`from` and `to` now cover the abbreviation *and* the spaces Tab left behind, so the same single `replaceRange` clears both.

The spaces are the one thing to think about. A `WORD` expander inherits the space you typed and must not add one of its own; a Tab expander inherits nothing, so it wants the trailing space back: `CHR = "CHARACTER: "`.

And whatever else it does, **a Tab watcher must always replace the range** — even when it has no expansion to offer, and even when you change your mind. Do nothing and those spaces stay in the prose, where they are invisible and nobody will spot them for a hundred pages:

```lua
function listen(event, word, from, to)
  local text = snippets[word:upper()]
  BYOK.doc.replaceRange(from, to, text or word)   -- `or word` puts it back
end
```

Which you want depends on the abbreviation. Automatic suits marks you always mean — `CHR` is never a word. Tab suits anything that is also ordinary English, where expanding it every time you typed it would be maddening.

### Tab can offer you a choice

Because Tab is you asking for something, a Tab watcher may put a list up — the only event that may. This is `pick.lua`: type `list`, press Tab, choose a divider.

```lua
--- @name         Pick a divider
--- @description  Type list and press Tab to choose one
--- @type         watcher
--- @requires     document
--- @caps         doc.edit, ui.list

local dividers = { "* * *", "- - -", "~ ~ ~", "* * * * *" }

function init()
  BYOK.sys.registerEvent("TAB")
end

function listen(event, word, from, to)
  if word:lower() ~= "list" then
    BYOK.doc.replaceRange(from, to, word)   -- not ours: give the word back
    return
  end

  local choice = BYOK.ui.list(dividers)
  BYOK.doc.replaceRange(from, to, choice and dividers[choice] or word)
end
```

Both endings replace the range, and that is the whole lesson of the script. The last line reads "the divider they chose, or the word back if they chose nothing" — so escaping the list leaves the line exactly as it was, rather than with five invisible spaces on the end.

The word you Tab after is how the watcher knows the list is wanted. Anything else gets handed straight back, which is what lets this sit alongside your ordinary typing without ever surprising you.

### One that asks first

```lua
--- @name         New scene
--- @description  Choose a scene type and drop in its heading
--- @type         oneshot
--- @key          s
--- @requires     document
--- @caps         doc.edit, ui.list, data.read

local scenes = {
  { name = "Interior, day",   text = "INT. LOCATION - DAY\n\n" },
  { name = "Interior, night", text = "INT. LOCATION - NIGHT\n\n" },
  { name = "Exterior, day",   text = "EXT. LOCATION - DAY\n\n" },
  { name = "Flashback",       text = "INT. LOCATION - DAY (FLASHBACK)\n\n" },
}

function main()
  local names = {}
  for i, scene in ipairs(scenes) do
    names[i] = scene.name
  end

  local choice = BYOK.ui.list(names)
  if not choice then return end

  BYOK.doc.insert(scenes[choice].text)
end
```

`Ctrl+T s`, pick a kind of scene, and its heading appears where you were. Type over `LOCATION`.

**Each entry keeps its name and its text together.** Adding a scene type is one line, and the two halves cannot drift apart — which they would if the names and the templates were separate lists that had to stay in the same order.

**The list is given just the names**, built in the loop above. This is why `BYOK.ui.list` takes a table rather than a run of arguments: the list you offer is usually made from something rather than typed out.

**The answer is a number, not the text**, which is what lets `scenes[choice].text` work. Had it handed back the name you would have to search the table for it again.

**`if not choice then return end` comes first.** Backing out is the commonest answer of all, and a script that forgets to check it goes on to use nothing as though it were something.

The copy on the card goes one step further: it reads its scene list from `scene.dat` and falls back to the table above when there is no file. That is why its `@caps` line asks for `data.read`. See **BYOK.data** for how, and edit `scene.dat` rather than the script if all you want is different scenes.

### One that asks you to type

A list is for answers you already know. When you cannot know them — a name, a title, a word that has never been written down — ask instead. This is `ask.lua`.

```lua
--- @name         Insert a heading
--- @description  Asks for a heading and writes it on its own line
--- @type         oneshot
--- @key          i
--- @requires     document
--- @caps         ui.ask, doc.edit

function main()
  local heading = BYOK.ui.ask("Heading")
  if not heading then return end

  BYOK.doc.insert("\n" .. heading:upper() .. "\n\n")
end
```

`Ctrl+T i`, type a heading, press Enter, and it lands in capitals on a line of its own.

**The same first line as the scene picker**, for the same reason: `nil` means they backed out, it is the commonest answer, and it is checked before anything else happens.

**One check, not two.** The box refuses to accept a blank line, so there is no such thing as an empty answer and `if not heading` catches everything. A `heading ~= ""` alongside it would be guarding against something that cannot occur.

**Nothing is waiting in the box when it opens, and nothing can be.** There is no way for a script to fill it in advance. That is a deliberate limit rather than a missing feature: it means every answer is one the writer typed, and no script can offer them words and have those words come back looking like their own. The price is that a script which changes something makes them type the whole of the new version.

### One that watches whole lines

`WORD` and `TAB` hand you a word. `LINE` hands you the whole paragraph you just finished, every time you press Enter. This is `tidyline.lua`, and it takes out the spaces you left hanging on the end:

```lua
--- @name         Tidy line ends
--- @description  Drops spaces left hanging at the end of a line
--- @type         watcher
--- @requires     document
--- @caps         doc.edit

function init()
  BYOK.sys.registerEvent("LINE")
end

function listen(event, text, from, to)
  local tidy = text:gsub("%s+$", "")
  if tidy ~= text then
    BYOK.doc.replaceRange(from, to, tidy)
  end
end
```

**Trailing spaces are the right size of problem for a watcher.** Nobody ever means them, you cannot see them, and fixing one changes nothing you would have wanted kept. Anything you might have written on purpose does not belong in something that runs while you type.

**`if tidy ~= text` is not a tidiness of its own.** A watcher that rewrites every line you finish puts an undo step behind every Enter, so the writer pressing `Ctrl+Z` gets a change they never made instead of the one they were reaching for. Touch the document only when there is something to change.

Remember `LINE` carries a paragraph, and a paragraph past about a thousand bytes does not arrive at all — see the size column in the first section.

### Reading the whole document

Everything so far has acted on one spot. `chunks()` walks the lot, front to back. This is `count.lua`:

```lua
--- @name         Count paragraphs
--- @description  Counts paragraphs and words in the current document
--- @type         oneshot
--- @key          p
--- @requires     document
--- @caps         doc.read, ui.alert

function main()
  local paragraphs, words = 1, 0
  for chunk in BYOK.doc.chunks() do
    for _ in chunk:gmatch("\n%s*\n") do paragraphs = paragraphs + 1 end
    for _ in chunk:gmatch("%S+")     do words = words + 1 end
  end

  BYOK.ui.alert("This document", paragraphs .. " paras, " .. words .. " words")
end
```

**It adds up as it goes and keeps no text.** That is the shape to copy. A script that glued the chunks together into one string to search afterwards would be holding the whole document in memory, which is the one thing chunking exists to avoid — and on a long manuscript it would run out.

**Counting per chunk and summing is safe** because no piece of the document arrives twice. Each chunk ends at the end of a line and the next one starts where it left off, so a word is never split and never repeated.

**There is no word-counting call, and there should not be.** `("%S+")` is a word counter — a run of anything that is not a space. Lua already does this, so the BYOK does not.

### A window that hands something back

The last shape, and the one that catches people. This is `jot.lua`: a blank screen to write a note on, away from the page, which lands at your cursor when you press escape.

```lua
--- @name         Jot
--- @description  Type a line on a blank screen, escape puts it in the page
--- @type         oneshot
--- @key          j
--- @requires     document
--- @caps         doc.edit, screen.draw, sys.log

local s = BYOK.screen

function main()
  if not s.open() then return end

  s.clear()
  s.text(4, s.lineHeight(), "jot -- escape when done")

  -- 1. GATHER. Nothing here touches the document.
  local text = ""
  while s.isOpen() do
    local k = s.key(400)
    if not s.isOpen() then break end
    if k == "backspace" then
      text = text:sub(1, -2)
    elseif k ~= nil and #k == 1 then
      text = text .. k
    end
    showLine(text)        -- redraws one row, not the window
  end

  -- 2. The window has gone. NOW the page will take it.
  if text ~= "" then
    if not BYOK.doc.insert(text) then
      BYOK.sys.log("jot: the editor refused the insert")
    end
  end
end
```

**The order is the whole point.** While your window is up, the page will not accept changes — `doc.insert` hands back `false` for as long as you hold the screen. So this collects into an ordinary Lua string and writes nothing until escape has closed the window.

Doing it the other way round, inserting as each letter arrives, looks perfectly reasonable and **does nothing at all**. Every call is refused, and if you do not check what `insert` gives back, nothing tells you.

**A printable key comes back as the character itself**, so `#k == 1` takes letters, digits and spaces and leaves the named keys alone — `"up"`, `"enter"`, `"backspace"` are all longer than one byte.

**It redraws one row, not the window.** Clearing everything on each keystroke makes the screen blink once per letter, which is unusable to type into. The full version on the card keeps that in a small `showLine` and also handles `damaged()`, for when a notification covers the window.

### The bigger ones on the card

Two are too long to print here and worth reading when you want the shape rather than the syntax.

**`spelling.lua`** offers to fix common misspellings. It counts what is in the document first, asks about each word once, and only then makes the changes — which is what lets it say "3 of 4 times" and lets you decline a word without the loop running for ever.

**`focus.lua`** is `jot.lua` grown up: a whole writing session on a blank screen, written into the page when you are done.

### Switching a watcher on

`Ctrl+T`, wait a moment for the list to appear, move to the script, press the space bar for its details, space again to change On to Off or back, then Enter.
