SyncTERM embeds Wren in three isolated scripting hosts. A persistent trusted VM owns the main menu, a second persistent VM owns the file picker, and every connection gets a fresh restricted VM which is torn down at disconnect. Connected scripts hook into keyboard, mouse, inbound bytes, outbound bytes, status text, and a periodic timer; menu scripts implement the dialing directory and settings UI; and picker scripts implement the local-file consent interface. Each host exposes a different set of capabilities.
This document is the reference for that scripting layer: how scripts are discovered, how the embedded scripts can be overridden, and the complete add-on object model.
Why Wren
Wren is a small, class-based, dynamically typed scripting language. Three properties make it a good fit for SyncTERM:
-
It compiles to bytecode in-process; no separate toolchain or external interpreter is required.
-
The VM is a few thousand lines of plain C with no external dependencies, vendored under
src/syncterm/wren/. -
The host program controls every binding the script can reach. No filesystem, networking, or process primitives leak in by default.
The full upstream language reference lives at https://wren.io/. This manual covers only the SyncTERM additions.
Quick Start
The minimal "hello world" hook prints a message to the SyncTERM Wren console (Ctrl+`) the first time you press F1 while connected:
import "syncterm" for Hook, Key
Hook.onKey { |k|
if (k == Key.f1) {
System.print("hello from wren!")
return true // consume the keystroke
}
return false // pass through to SyncTERM
}
Save this as hello.wren in the SyncTERM scripts directory (see
Script Loading), connect to any BBS, and press F1. Open the
console with Ctrl+` to see the output.
Wren Language Reference
A compact reference for the Wren syntax SyncTERM scripts use. Full
upstream documentation at https://wren.io/. Key facts up front:
Wren is single-threaded and cooperative (concurrency via fibers, no
threads); whitespace-significant (newline terminates statements; no
; token); class-based with single inheritance; dynamically typed.
Comments
// Line comment.
/* Block comment, /* nested */ blocks ok. */
Literals
|
Booleans. |
|
The single null value. |
|
Numbers. All numeric values are 64-bit floats; |
|
String literal. Escapes: |
|
String interpolation: |
|
List literal. |
|
Map literal. |
|
Half-open Range (1, 2, 3, 4 — excludes 5). |
|
Closed Range (1, 2, 3, 4, 5 — includes 5). |
Statement Termination
Newlines separate statements. There is no ; token at all. Two
statements on one line is a compile error. This trips up developers
coming from C / JS / Python regularly.
var x = 1 // ok
var x = 1 ; var y = 2 // ERROR: Invalid character ';'
Two consequences of the same rule:
-
Ternaries can’t span lines.
cond ? a : bmust be on one line — the then-branch terminates at a newline before the:. -
elseafter a newline-endedifbody is a syntax error.if (c) bodywhose body sits on the same line as theifends at the newline; anelseon the next line is orphaned. Either brace each branch (soelsefollows}) or put the whole chain on one line.
if (a == "x") foo() else bar() // single-line, ok
if (a == "x") { // braced, ok
foo()
} else {
bar()
}
if (a == "x") foo() // ERROR: 'else' is orphaned
else bar()
Strings and %
The Wren lexer treats % inside a "…" string as the start of an
interpolation %(expr). A % not followed by ( is a lex error —
not a literal %. To put a literal percent sign in a string, escape
it as \%:
"100%" // ERROR: Expect '(' after '%'.
"100\%" // ok — four bytes: 1, 0, 0, %
"got %(x)" // ok — interpolation, inserts x.toString
Every literal % needs a leading \. The pair \% is its own
escape — \\% does not work, because \\ consumes the slash on
its own and leaves a bare %; spell that as \\\%.
Variables
var x = 1 // declare and initialize
x = 2 // reassign
Block-scoped (everything between { and }). Shadowing in inner
scopes is allowed. Top-level var at module scope is a module-level
binding (importable from other modules). Declaration is required
before use; there is no auto-vivification.
Operators
Arithmetic: + - * / % (modulo follows the dividend’s sign), unary
- and +. Comparison: < ⇐ > >= == !=. Logical: && || ! —
short-circuit, return one of their operands (not coerced to Bool).
Bitwise: & | ^ << >> ~ — operate on integers (Wren truncates to
32-bit for the bitwise op). Range: .. and .... Conditional:
cond ? a : b (one line). Type test: obj is Class.
The .. / ... operators bind tighter than method calls, so write
(0...list.count) not 0...list.count when chaining.
Control Flow
if (cond) {
...
} else if (cond2) {
...
} else {
...
}
while (cond) {
...
}
for (item in iterable) {
...
}
break // exit innermost loop
continue // next iteration
return v // return from method/function
for (x in seq) {} works on anything implementing iterate(prev)
and iteratorValue(iter) — Lists, Maps (yields keys), Strings (yields
codepoint substrings), Ranges, and your own classes.
Functions and Closures
Wren has no top-level function keyword. Functions are closures
created with Fn.new:
var add = Fn.new {|a, b| a + b }
var n = add.call(3, 4) // 7
var greet = Fn.new {
System.print("hi")
}
greet.call()
Block syntax { … } after a method name passes a closure as the
last argument:
list.map {|x| x * 2 } // map(_) takes a Fn
Single-line { expr } returns expr implicitly; multi-line bodies
return null unless an explicit return is hit. This applies
uniformly to Fn.new, Fiber.new, getters, methods, and operator
overloads. The inverse is also true: { return expr } on a single
line is a compile error — single-line bodies are expression-mode,
and return is a statement.
foo() { 42 } // ok — implicit return
foo() { // multi-line — last expression NOT returned
var x = 1
x + 1 // discarded; foo() returns null
}
foo() { // ok — explicit return
var x = 1
return x + 1
}
foo() { return 42 } // ERROR: single-line, return not allowed
Classes
Single inheritance, no abstract classes, no interfaces. All fields are private to the declaring class (see "Field scope" below).
class Animal {
construct new(name) {
_name = name
}
name { _name } // getter
name=(s) { _name = s } // setter
speak() { // method
System.print("%(_name) makes a sound")
}
static kingdom { "Animalia" } // static getter
static spawn(n) { Animal.new(n) } // static method
// Operators: + - * / % - (prefix) ! < > <= >= == [_] [_]=(_)
+(other) { _name + other.name }
}
class Dog is Animal {
construct new(name, breed) {
super(name) // call super constructor
_breed = breed
}
speak() { // override
System.print("%(name) barks")
}
describe() {
super.speak() // call super method
System.print("It's a %(_breed)")
}
}
Field scope
Field references (_name, __static) resolve against the class
currently being compiled, not the inheritance chain. A subclass'
_x is a brand-new slot, not the parent’s _x. Cross class
boundaries via getters/setters:
class Widget {
construct new() { _surface = null }
surface { _surface } // expose to subclasses
}
class Pane is Widget {
paint() {
var s = surface // ok — uses getter
var t = _surface // BUG: brand-new field, null
}
}
Symptom of getting it wrong: Null does not implement 'X(,)'
errors from subclass methods reading "the parent’s" field.
Naming
-
_name— instance field. Only legal inside a class body; the parser rejects it elsewhere. -
__name— static field. Same scoping rule. -
name_(trailing underscore) — convention for "class-private" methods. Not enforced by the language; a strong project hint.
Foreign methods and classes
foreign declarations bind to host C code:
class Codepage {
foreign static encodes_(s) // host-implemented static
foreign instance_method(arg) // host-implemented instance
}
foreign class Cell { // host owns instance allocation
foreign ch
foreign ch=(s)
}
A class needs foreign class only when the host allocates instance
data; a plain class with foreign static methods is fine for
namespace-style bindings (see Codepage, Hook).
Type checks
obj is Class returns true if Class is in the object’s class
chain. Compiles to obj.is(Class). You can override is, but
you cannot delegate to the default via super.is(c) — is is a
reserved keyword and super. requires an identifier after it.
Override only if the foreign genuinely implements every method of
the claimed class.
Fibers
Fibers are first-class coroutines. Wren is single-threaded and
cooperative; concurrency comes from yielding fibers, not threads.
There is no await, no Promise, no scheduler — the fiber handle IS
the resumption token.
var f = Fiber.new {
System.print("a")
Fiber.yield() // suspend; control returns to .call() caller
System.print("b")
}
f.call() // prints "a", returns when fiber yields
f.call() // prints "b", fiber finishes
// .yield(v) returns v from the .call() that resumed the fiber:
var g = Fiber.new {
while (true) {
var x = Fiber.yield(42) // yield 42, get next call's arg as x
System.print(x)
}
}
g.call() // returns 42
g.call("hi") // prints "hi", returns 42
g.call("yo") // prints "yo", returns 42
// .try() catches abort:
var h = Fiber.new { Fiber.abort("boom") }
var err = h.try() // err == "boom"; h.error == "boom"
Fiber.yield(v) transfers to the immediate .call() caller — a
child fiber yielding inside a hook body returns control to the hook
body, NOT up to the dispatcher. This is what makes
Fiber.new { … }.call() from inside a hook safe.
For host-driven async: a foreign method captures Fiber.current
from a slot and returns; the host arranges to call .call(_) on
the captured handle later. See Modal Input for the
canonical example.
Modules and Imports
import "module" // load module, no symbols imported
import "module" for Name // import a single symbol
import "module" for A, B, C // multiple
import "module" for Name as Alias // rename on import
Module names are strings; the host resolves them. In SyncTERM,
modules are looked up in (1) embedded scripts (compiled in), (2) the
user script directory. A user file myhelper.wren in the script
dir is importable as import "myhelper".
Common Pitfalls
A checklist of mistakes that catch every new Wren author at least once. Most are consequences of rules above; this section gives them a single place to look up.
-
Semicolons. Wren has no
;. Every separator is a newline. -
Multi-line bodies don’t auto-return. Use explicit
return. -
Single-line
{ return x }is a syntax error. Dropreturn. -
Ternaries on one line only. Split via
if/elseor pre-compute. -
elsemust follow}on the same line, or chain on one line. -
Every literal
%in a string needs\%. Bare%starts interpolation. See Strings and%. -
Subclass
_fieldis NOT the parent’s. Use getters/setters. -
String.countis codepoints;s[i]ands[a...b]are bytes. Mixing them silently truncates UTF-8 strings. For byte iteration uses.bytes.count. -
String
<>⇐>=are not defined.list.sort()on strings aborts; pass an explicit byte-wise comparator. -
Wren modulo follows the dividend’s sign.
-1 % 5is-1, not4. For positive-modulo, write((x % n) + n) % n. -
foreign classis for instance allocation only. Use plainclasswithforeign staticfor namespace bindings. -
No
async/await/ scheduler. Use fibers andFiber.yield.
Wren Standard Library
The built-in classes Wren provides without any host bindings. These
are always available, no import needed. Many are mixin-aware via
the Sequence protocol, so iteration and transformation methods
work on Lists, Maps, Ranges, Strings, and your own classes that
implement iterate / iteratorValue.
System
Static-only namespace for I/O and timing.
|
Print a blank line. |
|
Print |
|
Print each item in |
|
Print |
|
Like |
|
Wall-clock time in seconds since process start, as Num. |
|
Note
|
In SyncTERM, System.print / System.write are routed to the
Wren console log buffer (visible via the Wren Console pane), not to
the process’s stdout. Use Host.print(s) when you need
output to reach the launching shell.
|
Object
Root of every class hierarchy. Every value responds to:
|
Identity by default; classes can override. |
|
Negation of |
|
Hash code, integer. |
|
Type check; same as |
|
Default returns |
|
Returns the object’s class. |
|
Boolean negation. Falsy values are |
Class
The class of all classes. Useful properties:
|
String name of the class. |
|
Parent class, or |
|
The class name. |
Bool
true and false. Has toString (returns "true" / "false")
and the standard ! && || operators (the latter two short-
circuit and return one of the operands, not a coerced Bool).
Null
The single value null. !null is true; everything else
(null.toString, null == null) does what you’d expect.
Num
All numeric values are 64-bit floats. No separate integer type; the language calls something an "integer" when it has no fractional part. Mathematical method-style helpers:
|
π. |
|
+∞. |
|
Max / min finite double. |
|
Range of exact-integer doubles (±2⁵³−1). |
|
Absolute value. |
|
Round-modes. |
|
Square root. |
|
Trig (radians). |
|
Inverse trig. |
|
atan2(self, x). |
|
Natural log, log base 2, eˣ. |
|
nᵖ. |
|
Pairwise min / max. |
|
Clamp into |
|
Fractional part. |
|
-1 / 0 / +1. |
|
Predicates. |
|
Decimal string. |
|
Bitwise (truncated to 32-bit). |
|
Bit shifts (also 32-bit). |
String
Immutable Unicode string, stored as UTF-8. s.count is codepoint
count; s[i] and s[a...b] are byte-indexed. This pair of facts
is the single most insidious Wren string trap — see Common Pitfalls. For byte work, use s.bytes.
|
Codepoint count. |
|
True iff |
|
A |
|
A |
|
Codepoint substring starting at byte index |
|
Byte-ranged substring. Negative indices count from the end. |
|
Concatenation. |
|
Repeat |
|
Byte index of first match, or |
|
Byte index of match at-or-after |
|
Substring presence. |
|
Prefix check. |
|
Suffix check. |
|
All non-overlapping occurrences. |
|
List of substrings, separator removed. |
|
Strip whitespace. |
|
Strip any chars in the supplied string. |
|
|
|
The string itself. |
|
Single-byte string. |
|
Encode a codepoint as UTF-8 string. |
List
Mutable, ordered, dynamic-length, heterogeneous.
|
Empty list. |
|
Pre-fill |
|
Literal. |
|
Length. |
|
True iff empty. |
|
Access / replace; negatives count from the end. |
|
Slice (returns a new List). |
|
Append. |
|
Append every item of |
|
Insert |
|
Remove first occurrence; returns true if removed. |
|
Remove and return element at |
|
Empty the list. |
|
Index of first match, or |
|
Membership. |
|
In-place sort with |
|
In-place sort with a custom comparator. |
|
In-place swap. |
|
Sequence protocol. |
|
|
Map
Mutable hash map. Keys may be any hashable value (Num, String, Bool, Null, Range, or Class — not List, Map, or arbitrary instance). Iteration yields keys.
|
Empty map. |
|
Literal. |
|
Number of pairs. |
|
Empty? |
|
Access / set. Missing key returns |
|
Distinguishes "missing" from "value is null". |
|
Remove and return the old value (or |
|
Empty the map. |
|
|
|
Sequence protocol; iterating yields keys. |
|
|
Range
Created via the .. (half-open) and ... (closed) operators on
Nums. Lazy — values are produced on iteration.
|
Lower bound. |
|
Upper bound. |
|
Bounds normalised regardless of direction. |
|
True for |
|
Number of values. |
|
Iterate. |
Reverse ranges are valid: 5..0 walks 5, 4, 3, 2, 1.
Sequence (mixin)
The base "iterable" protocol. Sequence itself isn’t usually
instantiated; List, Map, Range, String, and your own classes that
implement iterate(prev) and iteratorValue(iter) inherit its
methods. Most are lazy where it makes sense.
|
True iff |
|
True iff any item passes. |
|
Membership via |
|
Eager count. |
|
Items where |
|
Apply |
|
True iff |
|
Concatenate all items' |
|
…with |
|
Lazy mapped sequence. |
|
Lazy filtered sequence. |
|
Lazy. |
|
Fold from first item. |
|
Fold from |
|
Materialise into a List. |
Fiber
First-class coroutines.
|
Create a paused fiber whose body is the closure. |
|
The fiber currently running. |
|
Resume |
|
Like call, but does not record this fiber as the resumer; cannot be returned to via |
|
Resume |
|
Like |
|
True after the body returns or aborts. |
|
The abort message if |
|
Suspend; control returns to the most recent |
|
…passing |
|
Raise |
Fn
A callable closure. Fn.new { … } is the only constructor.
|
Build from a closure literal. |
|
Invoke with no args. |
|
Invoke with args (up to 16). |
|
Declared parameter count. |
Script Loading
Script Directory
User scripts live in a per-platform directory. SyncTERM creates the directory on first launch if it doesn’t already exist.
| Platform | Scripts directory |
|---|---|
Linux / *BSD |
|
macOS |
|
Windows |
|
Haiku |
|
The directory has two roles, distinguished by where files live within it:
| Path | Role |
|---|---|
|
Pure library module. Loaded only when something imports it via
|
|
Auto-loaded entry script. Runs at the moment the framework fires
|
Files anywhere else under scripts/ (e.g. user-organised
subdirectories of pure library code) are reached only via import —
the framework owns scripts/auto/ and ignores everything else for
auto-load purposes.
Module Names
Each script becomes its own Wren module, named after the file
basename without the .wren extension and without any directory
prefix. myscript.wren becomes module myscript, whether it’s at
scripts/myscript.wren or scripts/auto/connected/myscript.wren.
Module names are how scripts cross-reference each other:
import "myscript" for SomeClass
Module names are also how the embedded-script override mechanism works (see below).
Embedded Scripts
SyncTERM ships with a small set of scripts compiled into the binary.
They are not stored as files; the build system runs wren_embed_gen
at compile time, infers each script’s role from its source path
(library vs auto-load, and which event for auto-load), and folds the
sources into a C string table linked alongside wren_host.c.
The currently embedded scripts are:
| Module | Role | Purpose |
|---|---|---|
|
library |
Foundational module: foreign-class declarations and Wren-side
helpers ( |
|
library / menu only |
Trusted main-menu data model: transactional BBS-list loading and
mutable |
|
library / picker only |
Request-scoped file-picker interface. It exposes directory listings, path resolution, metadata, and completion methods, but no file-open capability. Menu and connected scripts cannot import it. |
|
reserved library / picker only |
Host-controlled picker entry and recovery surface. SyncTERM always interprets its embedded copy before loading picker overrides. |
|
auto / picker |
Split-pane file and directory browser. It implements single-file,
directory, save, and cross-directory multi-file selection and exports
|
|
auto / connected |
Connected-session Ctrl+` hook for the shared |
|
library |
Wren REPL UI shared by the connected and menu VMs. Each invocation reads the log belonging to its current VM. |
|
auto / connected |
Alt+L send-login handler. Sends username, password, and sysop password from the directory entry, with the right send-order rules per connection type. |
|
auto / connected |
Default |
|
auto / connected |
|
|
auto / connected |
|
|
auto / connected |
|
|
auto / connected |
|
|
auto / connected |
Default |
|
auto / menu |
The persistent Wren main-menu controller. It loads the menu BBS
model, prompts for an encrypted-directory password when necessary,
and presents the classic dialing directory: the directory and
SyncTERM Settings lists remain visible together, the active list uses
the configured Classic Theme palette, and the selected entry’s comment and
list commands occupy the bottom two rows. The host calls
|
|
auto / menu |
Internal controller used by C connection and startup code for trusted
alerts, confirmations, prompts, choices, and progress overlays. An
override must preserve the |
The syncterm module is loaded on demand the first time something
imports it. Its top level obtains host-created Cache and Download
objects where that VM has them. The auto-load embeds run when their
owning VM is created: once per application for menu and picker, and
once per BBS session for connected.
Overriding Embedded Scripts
A user script whose basename and role match an embedded module
overrides the embedded one. The shared REPL implementation can be
replaced with
~/.local/share/syncterm/scripts/wren_console.wren; all three VMs import
that library when their built-in menu, connected, or picker entry point opens
the console. Replacing
scripts/auto/connected/console.wren changes only the connected
Ctrl+` hook. Replacing scripts/auto/menu/main_menu.wren changes
the menu key binding along with the rest of the main-menu controller.
Replacing scripts/auto/picker/file_picker.wren changes the picker
implementation but not its reserved recovery surface.
The check is an exact match on the bare module name within the same
script role. A root console.wren is a library module distinct from
the connected auto-load module, while a root wren_console.wren
replaces the shared library. Case matters: Wren_Console.wren is a
different module from wren_console.wren.
To opt out of an embedded auto-load script entirely, drop a stub override into the matching directory:
// ~/.local/share/syncterm/scripts/auto/connected/connected.wren
// Disable the default Alt+L handler.
The override is loaded but registers no hook, so Alt+L silently does nothing.
Load Order
-
scripts/auto/<event>/is globbed for the firing event. -
Each embedded script tagged with the firing event runs, unless its module name appears in the user-script set (in which case the user override runs instead).
-
Each user script in
scripts/auto/<event>/runs as its own filename-derived module.
import statements (whether from a script’s top-level or inside its
body) resolve through the lookup chain for the current VM:
-
scripts/<name>.wren— pure library module. -
scripts/auto/<event>/<name>.wren— where<event>ismenu,connected, orpicker; this catches imports of auto-load modules which have not run yet. -
The embedded table — built-in fallback by module name.
The foundational syncterm module lazily loads the first time
anything imports it; subsequent imports hit Wren’s module cache.
A script’s top-level code runs once in the VM which owns its event. Top-level code commonly registers hooks or creates the long-lived menu controller; persistent state should be stored on classes (static fields).
The built-in main_menu module is also the host entry-point contract.
An override must export class MainMenu with these static methods:
-
prepare()loads the directory, performs any password interaction, and returns a Bool indicating whether the model is ready. -
run(currentName, connected)presents the directory.currentNameis a String ornull. Whenconnectedis false, return a current menuBBShandle to start that connection ornullto exit. Connected invocations are edit-only; their return value is ignored. -
offerSave(source)receives a menu-owned transientBBSafter a command-line connection and returns whether it was saved. The host callsprepare()first, then copies the C record into menu storage; no connection-owned pointer or foreign value is passed into Wren.
When the built-in module is loaded, it paints the main-menu background and
title bar immediately. Startup alerts and progress panes therefore appear
over initialized menu chrome before MainMenu.run() constructs the
interactive directory.
The host validates and copies a record returned by run while the menu
VM is still selected, so the Wren foreign object itself never crosses
into the connected VM.
The shipped controller binds Ctrl+` to the menu VM’s REPL. A CP437
‼ in the top-right corner reports unread output from that VM: yellow
for print output and red for errors. Synchronous key and mouse handlers
run through an error boundary; an abort is added to the menu VM’s log,
the indicator is refreshed, and the main menu continues running. This
boundary does not hide layout or painting failures, which still abort
MainMenu.run rather than repeatedly failing during every redraw.
The shipped controller defines the main-menu interaction contract. Tab from
the directory enters the selected entry’s comment field, then advances to the
simultaneously visible settings pane; Backtab traverses the same path in
reverse. Left and Right move directly between the directory and settings.
The first completed left click in an inactive pane transfers focus without
activating a row; a second click activates the row. A wheel event moves the
active list regardless of which pane is under the pointer. A completed left
click on the main-screen background transfers focus to the other pane.
Enter connects (or edits while connected) from the directory and invokes one
settings action from the settings pane. F2/Ctrl-E, Insert, Delete, F5/F6,
Ctrl-S, </>, Ctrl-D, and Alt-B invoke their documented commands. F2 does
nothing on the blank append row; Ctrl-E invokes the row’s add operation. The
comment row is also clickable for direct editing. An override may present the
model differently, but it must provide this user-visible behavior.
The comment is edited only in the main screen’s footer. It is not a field in the full directory-entry editor. F1 and Ctrl-Z in the footer display the directory help. Operation-specific alerts and confirmations display the context help for the operation which opened them.
The footer advertises edit, copy, paste, insert, and delete only while the directory pane owns focus. The SyncTERM Settings pane advertises only Help and Exit, matching the operations available from that pane. While a comment is being edited, the footer shows the hints for the pane that opened it.
Lists which support appending provide a selectable blank final row.
Activating the blank row or pressing Insert invokes the same add operation.
The shipped directory, sort-profile, sort-field, web-list, custom-font, and
short-palette editors expose both forms; the palette row is omitted once all
sixteen colors are present. + aliases Insert, while - and ASCII DEL alias
Delete. Ctrl-Insert aliases F5 Copy, Shift-Insert aliases F6 Paste, and
Shift-Delete invokes Cut where that operation is available. Backspace and a
right click alias Escape after the focused widget declines the event. Delete
and the editor-specific copy, paste, rename, and cut keys are direct list
commands.
Adding a directory entry prompts for its name, connection type, and address,
then writes it immediately and returns to the directory. A single :port
suffix is accepted for network addresses. F2 or Ctrl-E opens the full entry
editor. Editing a read-only entry first offers to create a personal entry
with the same name. F5/F6 prompts for a new name when pasting a personal
entry; pasting a copied read-only entry uses the copied name.
The specialized editors provide direct menu operations. Enter on a sort
profile opens its field list, and Enter on a sort field reverses that
field; [ and ] move between profiles without returning to the profile
list. Enter on a web list edits its URI. Font details are a flat Name plus
four cell-size paths, and selecting a path opens the picker directly.
The encryption screen has distinct Change Password, algorithm, and Decrypt
operations; changing algorithms reuses the password already held by the
host. Palette editing exposes separate Red, Green, and Blue fields, uses
Tab and Backtab between them, cycles the preview foreground with Up and Down,
and uses % to reset all three components to the mode default. Each
component is selected when entered, so typing replaces its current value.
Enter commits the current component; Tab and Backtab move without accepting
uncommitted text.
In safe mode, accepted default-entry and web-list changes update the in-memory model but are not written to configuration files. Encryption conversions are no-ops; Change Password presents its password prompt before returning.
When an action rebuilds a repeating editor’s list, the editor reselects the
same row. This applies to entry logging and palettes, sort profiles and
fields, program settings and their nested lists, web lists, and font
management.
Directory editing commands apply only while the directory pane owns focus.
Directory, pasted-entry, command-line-save, sort-profile, and web-list name
prompts repeat after an invalid or duplicate name. Entry-editor prompts
distinguish empty, reserved, and duplicate names. Directory add and
command-line-save prompts treat an empty edit as cancellation while still
distinguishing reserved and duplicate names; an entry creation failure is
reported separately. Sort-profile field navigation returns to the last
profile visited. New entries are inserted according to the active sort
profile after their connection details are accepted. Quick Connect limits
the directory address field to 64 characters. Current Screen Mode and
Startup Screen Mode use distinct dialog titles and context help.
Directory password and system-password rows are obscured in the editor
list, but selecting one uses ordinary text editing so the existing value can
be inspected and corrected. Personal-directory encryption prompts are
masked.
Rate choices select the first supported rate at or above a configured value,
or Current when no supported rate is high enough.
An OS window-close request sets a sticky process-exit latch. It does not ask
the main menu or connected session for confirmation. An App unwinds nested
widgets through their normal Escape path one modal frame at a time, allowing
each Wren call to return and run its normal cleanup. A widget explicitly
marked atExit remains interactive; the command-line directory-entry save
workflow uses this for its confirmation, prompts, errors, and editor.
Accepted Classic Theme colour changes rebuild the active theme immediately
while Classic Theme is selected. A selected file theme is unaffected.
Renaming a persisted entry also renames its per-entry cache directory after
the directory file is written successfully.
Font Management treats an empty name as cancellation when adding a record.
Otherwise, it accepts the entered name and the file returned by the picker as
the record values. It reports a full custom-font table before prompting for
another name. The preformatted File Locations and Build Options viewers use
the titles File Locations and Build Options. Their maximum dimensions are
78 by 21 and 60 by 21 cells respectively, capped to preserve the standard
modal margins and complete drop shadow. Their Markdown content defines the
label emphasis, indentation, and section layout rendered by the shared Help
viewer.
Palette Insert is exposed only while the palette has room for another color,
and Delete is exposed only while a color can be deleted. Sort-profile Paste
is exposed only after Copy or Cut has supplied a clipboard profile. Font
Details and the outer Font Management list both support Insert, Delete, and
the blank add row.
Adding a web list skips its initial fetch when no cache path is available but
records the configuration; explicit refresh reports the missing cache.
Left and Right move directly between the directory and settings panes.
Leaving the comment field by mouse, pane navigation, or application exit
commits the accepted edit before focus moves. The entry editor reselects its
highlighted field after [ and ] move between entries. Program Settings
summarizes enabled audio backends, and entry rows distinguish automatic
terminal types, unset passwords, and nonzero communication rates.
Web-list edits update the in-memory configuration immediately and are written
when the Web Lists screen closes.
Saving an existing directory entry does not change its Added timestamp.
Clearing the Terminal Type field writes the empty override, so a subsequent
directory reload uses automatic terminal-type selection. In the palette
component editor, % resets all three components of the selected colour from
the mode palette without moving focus to another field.
Creating or copying a personal entry with the same name as a read-only entry
replaces that row immediately. The model stores the read-only entry beneath
the personal override. Deleting the override exposes the read-only entry;
renaming the personal entry exposes the read-only entry under its own name.
Adding an entry may create such an override. Pasting a copied personal entry
instead requires a name unique across all currently visible entries.
The inactive directory comment is centered across its line. While the
directory pane has focus, its selection supplies the OS window title; while
the settings pane has focus, the title is the bare SyncTERM version. Editing
a comment leaves the title from the pane where editing began until focus moves
again. The active editor is left-aligned within the middle
screen width - 4 cells.
Its existing value initially appears selected in the text-input lightbar, so
typing or deleting replaces the complete comment. Cursor movement or mouse
positioning leaves the value unchanged and switches to the normal menu colors
for ordinary editing. Leaving the editor immediately centers the inactive
row, even when its value was unchanged.
The shipped menu keeps context help beside the Wren screen that owns each
control. Directory, sort-profile, entry-editor, settings, web-list, font,
and encryption panes assign Markdown helpText to their panes and nested
dialogs. F1 therefore resolves to the general screen description from the
list and to field-specific help while a choice or prompt is open. Menu
overrides should use the normal Help Markdown contract described under
Help viewer. Help content must be Markdown; legacy help-buffer
markup is unsupported.
The shipped editors distinguish accepting an edit from writing its backing
file. A confirmed entry field immediately
updates the C-owned BBS, and a confirmed program setting is immediately
applied to the running settings. Their files are written when the owning
entry or Program Settings editor closes. Font changes stay in the font
model until Font Management closes. Sort-profile operations update the
working profile model, which is written when Sort Profiles closes; profile
cycling from the main directory writes immediately. None of these screens
exposes the persistence boundary as a selectable Save or Cancel row.
Escape from a nested prompt or choice cancels only that nested operation. Escape from an owning editor closes it without rolling back accepted edits. An owning menu stays on the modal stack and is rendered behind a nested prompt, picker, viewer, or submenu until that child is dismissed.
VM Lifetimes and Trust Boundaries
SyncTERM deliberately uses three Wren VMs. They do not share Wren objects, handles, modules, globals, hooks, timers, result queues, or foreign objects.
-
The menu VM is created once after conio and configuration are initialized. It owns
scripts/auto/menu/, survives connections, is parked while the connected screen is active, and is destroyed at application shutdown. It can edit the BBS list and settings and can request a picker operation. -
A fresh connected VM is created by
wren_host_init(bbs)for each BBS session and freed bywren_host_shutdown()on every exit path. It ownsscripts/auto/connected/. Module statics, hooks, timers, claims, and queued results do not survive disconnect. -
The picker VM is created once after the menu VM, owns
scripts/auto/picker/, and remains parked except while a picker request is active. It cannot importsyncterm_menu, inspect BBS records, change settings, establish connections, or open files. Its request object exposes only directory listings, path resolution and metadata, and one-shot completion methods.
The remote system is considered hostile. A remotely supplied script
may know the active entry’s username, password, and system password;
those values are already meaningful to that remote. It must not gain
the authority to enumerate or edit other BBS entries, change global
settings, start another connection, or mint arbitrary local-file
access. Consequently syncterm_menu is not merely omitted from the
connected documentation: the connected loader explicitly rejects it,
and menu foreign objects cannot cross the VM boundary.
Menu auto-script overrides are trusted application code, not a sandbox for
downloaded scripts. In addition to directory and settings mutation, the
menu VM can invoke picker-mediated local-file operations, open URLs from the
completed session’s scrollback, and update trusted application chrome. Do
not install a remotely supplied script under scripts/auto/menu/; remote or
otherwise untrusted session automation belongs in the per-connection VM and
receives only that VM’s capabilities.
Connected scripts intentionally retain Input.ungetKey,
Input.ungetMouse, physical-key synthesis, and the other synthetic
input bindings. User scripts need them to automate the current remote
session. Isolation is provided by the VM and transition barriers, not
by removing those session-local controls.
At every ownership transition (connect, disconnect, entry to or return
from the menu through Alt+E, and entry to or return from the picker), the
host establishes a new input epoch. It discards pending conio keyboard
and mouse pushback and rejects late input-shaped results tagged with an
older epoch. Input claims remain parked in the VM that owns them and
cannot run while another VM owns the screen. This prevents a connected
script from placing synthetic input in
ungetkey() / ungetmouse() just before the trusted menu or picker
takes control. The same barrier protects the connected VM when control
returns from a trusted UI.
The picker VM is the local-file consent broker. Neither its caller nor
the picker script receives arbitrary pathname-open authority. A
single-file or multi-file completion is converted by C into read-only
File handles. A save completion explicitly reports create or
overwrite consent; C converts it into a one-shot write-only grant without
retesting the pathname. These rights remain attached to the foreign
File handle as documented in Write consent.
The request object is invalidated as soon as the picker returns, so it
cannot retain listing or completion authority between calls.
Picker overrides under scripts/auto/picker/ are trusted local
application code, but the picker VM still receives only the capabilities
needed by the browser. The host always interprets the embedded
picker_bootstrap first. A picker implementation compile or runtime
failure therefore enters a recovery screen instead of terminating
SyncTERM: Ctrl+` opens the picker VM’s REPL, Escape cancels the
request, and Key.quit exits the application. The normal picker shows
that VM’s unread or error indicator at the top right and uses the same
Ctrl+` binding. Only failure to initialize the VM or reserved
bootstrap disables picker operations.
C-owned startup and connection flows also render their dialogs through the persistent menu VM. These calls do not expose a dialog capability to connected scripts. Blocking alerts, confirmations, prompts, and choices save and restore the current screen and establish input barriers both before and after the dialog, so synthetic connected-session input cannot answer a trusted question. Progress overlays deliberately do not clear or claim input: connection setup continues polling conio so the user can abort a stalled operation. Clearing progress restores the exact screen, palette, fonts, video flags, and cursor state saved when the first update was shown.
The embedded menu_host_ui module exports class MenuHostUI with static
methods alert(title, message), confirm(title, message),
prompt(title, message, initial, maxLen, masked),
choice(title, message, options, current), status(title, lines), and
statusClear(). It is a host-controller ABI rather than a public menu
script API: application C code holds the call handles and invokes it.
The progress pane is 74 columns wide when the screen permits, matching the
C formatter and the previous UIFC window. The pane itself is centered, but
all status rows share one padded left edge so preformatted columns remain
aligned between rows and updates. Text wraps within that common content
width on narrower screens.
Menu auto-script overrides are trusted and may replace its presentation,
but must keep those signatures and return Bool, String/null, and
numeric choice indexes as appropriate or host dialogs fail closed.
Every reconnect rebuilds the connected VM from scratch. Connected
scripts needing cross-session state must persist it through an
authorized Cache / Download file or picker token. Menu scripts can
keep ordinary Wren state for the application lifetime, but durable
changes still require the explicit model save methods.
Console entries, cached entry handles, REPL state, and unread markers belong to one VM. Opening the menu through Alt+E therefore shows the menu VM’s log, not output produced by the parked connected VM, and no console object or handle crosses the trust boundary.
The owner thread is captured at init; dispatchers called from any
other thread (background SFTP and SSH writes invoke conn_send from
worker threads) short-circuit to pass-through. Scripts run on the
foreground thread only.
Importing the API
Shared host bindings live in module syncterm. Every script imports
the classes it needs:
import "syncterm" for Screen, Input, Conn, Hook, Key
An import does not grant all methods declared by that module. The menu VM resolves the declarations so shared UI libraries can load, but forbidden methods abort with "this capability is not available in the menu VM" if called. Unknown foreign declarations remain module-load errors. Menu-only data APIs are imported separately:
import "syncterm_menu" for Menu, BBS, Settings, MenuFont, MenuReadStatus, MenuEncryption, MenuFontSlot
Cache is injected into the syncterm module from C as a
module-level Directory object — there is no Wren-callable
constructor. Import it like any other binding:
import "syncterm" for Cache
Hook Events
Hooks are registered as Wren callables (block, function, or method
reference). Each call site walks its hook list in registration
order; the first hook returning true consumes the event and stops
dispatch. Returning false, returning a non-Bool, or throwing
passes through to the next hook and ultimately to SyncTERM’s default
handling.
Every registration returns a HookHandle (or null if the per-event
limit is hit) — see HookHandle. Save it if the script needs to
remove the hook later or read its metrics; toss it if not.
Hooks must run synchronously
Every hook fire is wrapped in a child fiber via Hook.dispatch_.
For C1-contract hooks (see Two return contracts) the dispatcher needs
the hook’s return value before it can decide what to do next; a hook
that yields up to the dispatcher would strand it with no value to
act on. C2-contract hooks ignore returns, but yielding still
breaks the dispatch chain.
If a hook callback yields its own fiber directly (e.g. fires an
async op against Fiber.current and immediately calls
Fiber.yield()), the dispatch wrapper detects the yield and logs:
hook handler must not yield directly; wrap parking work in
Fiber.new { ... }.call()
The hook is then treated as if it returned a non-Bool — the input
passes through to the next hook and to SyncTERM’s default handling.
A Fiber.abort from the hook is caught the same way: logged with a
stack trace, treated as passthrough.
A hook that needs to wait on async results has two options that keep the hook itself synchronous. The first wraps the work in a child fiber and `.call()`s it from the hook body:
Hook.onKey { |k|
if (k == Key.f2) {
Fiber.new { runModalBrowser() }.call()
return true
}
return false
}
.call() runs the child fiber synchronously until it yields (e.g.
on an SFTP op or a Fiber.yield() waiting for a Wake.post).
The child’s yield returns to the hook body — the immediate
.call() caller — not up to the dispatcher. The hook still
returns its Bool; the child resumes later via the framework’s
result-queue drain.
The second option is to fire the async op against a Fiber.new {|r|
… } whose body runs when the result arrives. The hook returns
synchronously without ever yielding; the callback fiber is invoked
later by the drainer:
Hook.onKey { |k|
if (k == Key.f2) {
SFTP.realpath(Fiber.new {|r|
// handle r …
}, ".")
return true
}
return false
}
Two return contracts
Every hook callback follows one of exactly two return-value contracts. Knowing which contract a hook uses tells you what (if anything) your return value does.
| Contract | Behaviour |
|---|---|
|
|
|
The return value is ignored entirely. These hooks fire as a
side-effect notification; the event continues regardless. A |
onKey, onPhysicalKey, and onMouse use C1 but have no meaningful
interpretation for String returns (a 16-bit key code, a
PhysicalKeyEvent, or a MouseEvent isn’t a byte stream), so a String
return from those is silently treated as passthrough. Use onInput if
you need byte-level replacement.
| Method | Argument | Contract |
|---|---|---|
|
|
C1 |
|
Filtered: |
C1 |
|
|
C1. String replacements are capped at 256 bytes — larger ones log a runtime error and the original byte passes through. |
|
Filtered: |
C1 |
|
|
C1 |
|
Filtered: |
C1 |
|
|
C2. An "honor |
|
Same as |
C2 |
|
|
C1 |
|
Filtered: |
C1 |
|
|
C2 |
|
Fires once when the SSH shell channel closes but the session is still alive (e.g. SFTP transfers may still be in flight). Typical use: pop a status / queue UI so the user can watch transfers finish, or signal a worker to wind down. See CTerm.sftpActive for the corresponding "keep the session up" flag. |
C2 |
|
Fires at the end of the disconnect path, after the main loop has fully exited but before the BBS / Wren VM tear down. SFTP is no longer available at this point (sftpc_finish has already run); use this for final local-state flushes (queue persistence, log close, etc.). |
C2 |
Filtered variants share the same per-event registration array as the unfiltered forms, so dispatch order = registration order regardless of which form was used.
Hook.onInput (and Hook.onMatch) fires on every byte off the wire,
before RIPscrip parsing, ZMODEM/OOII detection, or the cterm
emulator. Scripts see the raw stream and can drop bytes that would
otherwise be consumed by those layers, or expand a single byte into
multiple bytes by returning a String — the canonical example is
LF→CRLF normalization for hosts that send only LF. Bytes are
dispatched in bulk right after conn_recv_upto, so there is no
speed-emulation gating — parse_rip already eats RIP escapes in bulk
regardless of the emulated bps rate, so gating only the Wren hook
would be inconsistent.
Hooks run in registration order; the first one that returns Bool
true (drop) or a String (replace) wins, and later hooks don’t see
that byte. When a replacement won’t fit in the post-filter buffer,
the filter pauses on that input byte; the unprocessed wire-side
tail stays parked until the next recv_bytes() call drains
something out and frees room for it.
Hook.every fires from the main-loop deadline check just before the
sleep call. If the loop stalls long enough that more than one
interval has elapsed, the deadline jumps forward to "now" rather
than firing repeatedly to catch up.
The status bar is a separate dispatch path — see Status.
A single render callable (rather than a hook chain) builds the row
into a Surface the host hands it.
Streaming regex hooks
Hook.onMatch(pattern, fn) registers a regex against the inbound
byte stream. Each input byte is fed to a streaming Pike VM (Russ
Cox’s NFA simulation, vendored under re1/); when a match completes,
fn is called with a Wren List:
Hook.onMatch("login:") { |m|
Conn.send("user\r")
return false
}
Hook.onMatch("user (joe|jane|bob)") { |m|
// m[0] = "user joe", m[1] = "joe"
System.print("hi %(m[1])")
return false
}
m[0] is the matched substring; m[1..] are the user-pattern’s
capture groups in registration order. Both onMatch and
onMatchClean are passthrough-only: the callback’s return value is
ignored, and the matched text always reaches the terminal. Matching
completes only after preceding bytes have passed through cterm, so these
hooks cannot drop a complete matched span. Use the byte-granular
Hook.onInput hook when input must be dropped before reaching cterm.
Hook.onMatchClean(pattern, fn) is the escape-aware variant — the
byte stream feeding the regex VM is pre-filtered through SyncTERM’s
shared ANSI parser (ansi_filter in ansi_filter.[ch], the same
state machine ripper.c uses to find ANSI envelopes inside RIP).
The filter strips ESC, CSI sequences (ESC [ … <final>), DCS / OSC /
PM / APC strings (ESC P|]|^|_ … ESC \), and SOS-style strings
(ESC X … ESC \). The cleaned bytes reach the regex VM in the
same per-byte-stream form as onMatch; what the user sees is what
the matcher sees.
Grammar
RE1 implements a deliberately minimal regex dialect. These are all of the supported metacharacters:
| Construct | Meaning |
|---|---|
literal byte |
matches itself. Backslash has no special meaning —
|
|
any byte (including NUL — the streaming VM does not treat NUL as end-of-input) |
|
capturing group |
|
non-capturing group |
|
alternation |
|
greedy: zero-or-more, one-or-more, optional |
|
lazy (non-greedy) variants |
Notably not supported (will be parsed as literal characters or syntax errors):
Patterns are anchored at the current buffer start. "Match
anywhere in the stream" is achieved by the dispatcher: when the VM
returns IMPOSSIBLE (no thread can ever complete from the current
buffer head), the oldest byte is dropped and the survivors are
re-fed. In practice this means a pattern like "hello" matches the
substring "hello" wherever it appears in the stream.
This trick requires that the pattern can produce IMPOSSIBLE — i.e.,
the first byte either advances the NFA or kills every thread.
Patterns whose leading construct can match without consuming a byte
keep threads alive on every input forever, the buffer fills, and
matches start being silently dropped. Hook.onMatch therefore
rejects patterns whose leading construct is *, +, or ? at
registration time with Fiber.abort:
| Allowed (1-byte anchor) | Rejected (variable-width leading) |
|---|---|
|
|
|
|
|
|
|
|
If you want a quantifier inside the pattern, anchor it with a fixed
prefix: bbs (.) ` rather than `(.) bbs.
Match semantics
Pike VM has a leftmost-first-completing tie-breaking rule: as soon
as any thread reaches a Match opcode, the match commits and
remaining threads in the current step are discarded. In a streaming
context this means open-ended quantifiers fire as soon as the
shortest acceptable prefix is seen — a* matches the empty string
at the first byte, a+ matches the first 'a' and stops.
For greedy-feeling behavior, terminate variable-length sub-patterns
with a literal that follows them: user (.) ` (capture text up
to a space) instead of bare `user (.). Newlines must be encoded
as literal \n bytes in the Wren string (“\n” in the source code,
which Wren resolves to a single 0x0A byte).
Limits
-
Buffer is capped at 4 KB per hook. When a partial match grows past the cap, the oldest half is dropped and the VM restarts — patterns that demand more than 4 KB of context will silently miss.
-
Up to 9 capture groups (RE1’s
MAXSUB = 20minus the whole-match pair). -
Pattern compile errors (bad syntax, internal asserts) surface as
Fiber.abortat the registration site, with the RE1 error text attached. The exception trace points at the offendingHook.onMatchcall.
HookHandle
Every successful Hook.on* and Hook.every registration returns a
HookHandle. The class has no Wren-callable constructor, so a
script can’t fabricate one to remove arbitrary hooks — only its
own.
import "syncterm" for Hook
var h = Hook.onKey { |k|
if (k == 0x4200) { // F8
System.print("F8 pressed")
return true
}
return false
}
// ...later...
h.remove() // tombstone: no further dispatch
| Member | Meaning |
|---|---|
|
Tombstone the hook. Future dispatches skip it. Safe to call from
inside the hook’s own callback (the host removes by NULL-ing the
callable; in-flight dispatch finishes on the still-allocated
resources). Returns |
|
Number of times the host has invoked this hook. Counts every
|
|
Cumulative wall-clock seconds spent inside |
|
Smallest / largest single invocation time in seconds. Both read
back as |
HookHandle.remove() is safe from inside the hook’s own callback.
The entry’s fn handle is released immediately (dispatchers skip
past the now-NULL slot on their next iteration), and the entry is
linked onto a cleanup queue. The host drains that queue once per
main-loop iteration — outside any dispatcher — at which point the
entry is removed from its dispatch array, regex resources (compiled
program, match buffer, PikeVM state) are freed, and the entry
struct itself is freed once the script has also dropped the
HookHandle (Wren’s GC fires the foreign-class finalizer).
Metric getters on a removed handle keep working until the script
drops the handle.
Modal Input
The conventional Input.next() / Input.next(ms) / Input.poll()
primitives all return promptly; while a script is calling them in a
loop the doterm() main loop is blocked, and inbound server bytes
queue up in the socket buffer until the script returns.
For modal dialogs that should not block the main loop while the
script idles between events, use Input.pushClaim(fn). The
foreign installs fn as the current claim on a stack of input
claims; whenever a key or mouse event arrives, the C-side
dispatcher walks the stack head-first (newest first) and invokes
the first claim whose owning fiber is still alive. The claim’s
return value is a Bool — true consumes the event (no other claim
or hook sees it), false passes it through to the next claim down
and ultimately to the registered Hook.onKey / Hook.onMouse
chain.
import "syncterm" for Input, Key, Wake, Screen, CTerm
var saved = Screen.save()
CTerm.suspended = true
var me = Fiber.current
var claim
claim = Input.pushClaim(Fn.new {|ev|
// Decide consume / passthrough synchronously here — handlers
// run from the C dispatch frame and must not yield. Anything
// we want to *do* with the event is dispatched by waking the
// worker fiber via Wake.post; the worker yields/resumes on
// its own schedule.
Wake.post(me, ev)
return true
})
while (true) {
var ev = Fiber.yield()
if (ev is KeyEvent && ev.code == Key.escape) break
Screen.window.print("got: %(ev)\n")
}
claim.pop()
Screen.restore(saved)
CTerm.suspended = false
The claim is a per-fiber slot: pushing twice from the same fiber
replaces the existing claim in place (preserving stack position).
Pushing from a different fiber adds a new entry on top, which
becomes the foreground. Auto-prune at dispatch time drops claims
whose owning fiber is isDone (so a script that aborts mid-claim
doesn’t leave a dead handler installed). The returned
ClaimHandle is the explicit way to remove the
claim; pop() is idempotent.
CTerm.suspended is independent of the claim mechanism — any
script can set it to claim the screen, and remote bytes pile up in
the conn buffer behind it. When the TCP receive window fills, the
remote sees its send() calls block or EAGAIN. Clearing the
flag releases the backpressure and the buffered bytes drain
through cterm normally.
The claim handler runs from the C dispatch frame via the same
Hook.dispatch_ wrapper that protects regular hooks (see "Hooks
must run synchronously" above) — so a yield inside the handler is
caught and treated as not-consumed, and the input falls through to
the next claim or hook chain. Heavy work belongs in a separate
fiber that the handler wakes via Wake.post.
A hook callback can install its own claim and let it persist past the hook return — perfect for "F2 opens a modal that captures input until Esc":
Hook.onKey(Key.f2) { |k|
Fiber.new {
var claim = Input.pushClaim(Fn.new {|ev|
Wake.post(Fiber.current, ev)
return true
})
while (true) {
var ev = Fiber.yield()
if (ev is KeyEvent && ev.code == Key.escape) break
// ...
}
claim.pop()
}.call()
return true
}
For more elaborate UIs, the App class (see App) wraps
this pattern and provides synchronous claim decisions based on its
own state (modal stack, focused widget, mouse hit-test) plus a
runChild helper that lets handler code yield safely on async
work without breaking the App’s event loop.
Built-in REPL
Ctrl+` opens the immediate-mode REPL for the VM which owns the current
screen. The shared UI is implemented in scripts/wren_console.wren
and can be overridden with
~/.local/share/syncterm/scripts/wren_console.wren. The connected
entry point is scripts/auto/connected/console.wren; the persistent
menu binds the same key in scripts/auto/menu/main_menu.wren.
While the connected console is open the doterm() main loop is suspended. Connection bytes accumulate in the socket buffer but aren’t drained until you exit the console. This is acceptable for a development tool; not appropriate for "watch a slow BBS scroll" use cases. The menu already uses a synchronous event loop, so opening its console does not change its event-pumping model. When the menu was entered through Alt+E, the connection is already parked for the entire menu visit.
The REPL evaluates input through REPL.eval(module, src). The
input is pre-classified by inspecting its leading non-whitespace
token: source that begins with a Wren statement keyword (var,
class, import, return, break, continue, if, while,
for) is compiled as a statement; everything else is compiled as
an expression. Successful expressions print their result quoted
with C-style escapes (so "7" the string and 7 the number are
visibly distinct); statements print nothing.
Variables and class declarations persist across submissions inside the active module:
> var x = 1 + 2
> x * 10
30
> class Foo { static greet { "hi" } }
> Foo.greet
"hi"
REPL Commands
| Command | Action |
|---|---|
|
Print commands, editing keys, history navigation, scrollback navigation, and exit keys. |
|
Switch the eval target to |
|
List every Wren module currently loaded into the VM, alphabetically
sorted. Includes |
|
Close the REPL and return to the connected session or main menu. |
Modules can plug in their own /<command> entries via
WrenConsole.register(name, help, fn):
import "wren_console" for WrenConsole
WrenConsole.register("greet", "say hello [<name>]") { |args|
if (args == "") {
System.print("hello!")
} else {
System.print("hello, %(args)!")
}
}
The handler is called with the raw argument string — everything
after the command name and its first separating space, or "" if
none. Re-registering an existing name overwrites the previous
binding. Names can’t contain spaces (the dispatcher splits on the
first one). The dispatch wraps the handler in Fiber.new {}.try()
so a runtime abort surfaces as a logged error instead of tearing
the console out from under itself. /? lists every registered
command with its help text alongside the built-ins.
WrenConsole.unregister(name) drops a registration (idempotent —
no-op if not registered); WrenConsole.commands returns the sorted
list of currently-registered names for tooling.
REPL Key Bindings
| Key | Action |
|---|---|
Left / Right |
Move the cursor within the current input line. |
Home / End |
Jump to start / end of the current input line. |
Backspace |
Delete the character before the cursor. |
Delete |
Delete the character at the cursor. |
Up / Down (live mode) |
Walk command history. If the line currently has typed text, that text becomes a prefix anchor — only history entries starting with it are visited. |
PgUp / PgDn |
Page through the scrollback log buffer. PgUp from live mode pins the current top of view; PgDn returns to live once you’ve scrolled past the tail. |
Up / Down (scrollback mode) |
Single-row scroll. Down past the tail rejoins live mode. |
Enter (blank or whitespace-only) |
Advance to a fresh prompt row without evaluating. |
Ctrl+W |
Backward kill-word, ending at the cursor (the tail past the cursor is preserved). |
Ctrl+L |
Clear the screen and the in-memory log. Returns to live mode. |
Middle-click |
Paste from system clipboard at the cursor. Multi-line text is split on LF and each line submitted as if Enter were pressed. |
Alt+drag |
Hand off to the existing select-and-copy gesture. |
Ctrl+` / Esc / |
Close the REPL. |
Object Model
The remainder of this document is a reference for every foreign
class exposed in the syncterm module.
A note on toString: every class with an explicit toString method
returns it strictly for human-readable debug output — the format
that System.print(value) and string interpolation "%(value)"
emit when no other rendering is appropriate. The exact string is
not part of the API contract; it may change between SyncTERM
versions to add or remove fields, reformat, etc. Scripts that need
specific data should call the named property accessors (cell.ch,
event.code, surface.width) — never parse toString output.
Without an explicit toString a foreign would print as something
like [instance of Cell], which is uniformly less useful than even
a minimal Cell(0x41 attr=0x07); that’s the only motivation for
having these methods at all.
Error / ScriptError
Error is the polymorphic base for every recoverable-failure value
type the API hands back: SFTPError,
FileError, WONError,
ConnError, plus any user-defined
ScriptError subclass. Lets a script catch any of
them with one check:
import "syncterm" for Error
var v = something_that_might_fail()
if (v is Error) {
System.print("failed: %(v)") // toString includes the type
return
}
// proceed with the typed result
Every concrete Error subclass guarantees the contract:
| Member | Description |
|---|---|
|
Numeric error code from the type’s enum
(e.g. |
|
Human-readable diagnostic, may be |
|
Format with the subclass name as prefix. |
Type-specific extras (FileError.errno, WONError.offset,
ConnError.bytesSent, SFTPError.serverStatus,
SFTPError.isTransient) live on the subclass.
Error itself is empty by design: foreign subclasses (FileError
etc.) need the parent to be field-free or Wren rejects the
inheritance at module-load time (the
numFields == -1 && superclass→numFields > 0 check at
wren_vm.c:559-563).
ScriptError
Concrete Wren-side base for script-defined error types. Use this when a script wants to surface its own typed error without writing C bindings:
import "syncterm" for ScriptError
class ConfigError is ScriptError {}
if (something_bad) {
return ConfigError.new(42, "missing required field 'host'")
}
ScriptError provides the same code / message / toString
getters every foreign Error subclass exposes, so callers see uniform
behaviour from is Error and the field accesses regardless of
whether the value came from C or Wren. The default toString uses
the runtime class name ("ConfigError(42): missing required field
'host'"); subclasses may override for a different format.
| Member | Description |
|---|---|
|
Construct. |
|
The integer passed to the constructor. |
|
The string passed to the constructor. |
|
|
Screen
Read/write access to the terminal display surface. Screen itself
exposes whole-screen and absolute-coordinate operations; per-window
operations live under Screen.window.
| Member | Description |
|---|---|
|
|
|
Save the entire screen contents. Returns an opaque handle. |
|
Restore a previously saved screen. |
|
Run |
|
Read a rectangle of cells (1-based, inclusive). Returns a
|
|
Write |
|
Blit a |
|
Copy a rectangle to a new position. |
|
Read or set the active text attribute byte. |
|
Hyperlink ID attached to subsequent window writes. |
Screen.supports, Screen.font, Screen.palette, Screen.cursor,
Screen.videoFlags, Screen.color, and Screen.window are getters
that return the helper class so the script can call its static
members.
Screen.supports
Read-only Boolean capability flags reflecting what the current display backend (SDL, X11, Win32, etc.) can do. Scripts that adapt their output to the backend should branch on these.
| Flag | True when the backend can… |
|---|---|
|
Replace the bitmap font in any slot at runtime. False on hardware text-mode backends like curses. |
|
Render the alternate-blink font slot. |
|
Render the alternate-bold font slot. |
|
Render the high-intensity (bright) bit on background colors instead of using it as a blink toggle. |
|
Mutate the active palette at runtime. |
|
Address individual pixels (pixel-aware backends only — needed for RIP, SIXEL, SkyPix). |
|
Render a non-default cursor shape. |
|
Switch fonts via the SyncTERM font-selection UI. |
|
Set the OS window title. |
|
Set the OS window’s class / icon name (X11). |
|
Set the OS window’s icon image. |
|
Address palette indices beyond 16. |
|
Use nearest-neighbour scaling for the pixel-doubled display. |
|
Defer scaling to the OS / window manager. |
|
Inhibit the OS window close button while a BBS session is active. |
Screen.window
Operations scoped to the active text window. Cursor position, character output, line edits, and clearing all act inside the window’s rectangle. Coordinates are window-relative (1, 1 is the top-left of the window, not the screen).
| Member | Description |
|---|---|
|
|
|
|
|
Write one character at the cursor. |
|
Write a string at the cursor. |
|
Clear the window with the current attribute. |
|
Clear from cursor to end of line. |
|
Line editing inside the window. |
Screen.font
Per-slot font management. Five slots (0..4) match SyncTERM’s font slot model.
Screen.font[i] reads the font index in slot i. Screen.font[i] = n
loads font n into slot i. Use the Font class
(see Font) for the named built-in indices.
Screen.palette
24-bit-RGB palette as 0xRRGGBB integers.
Screen.palette[i] and Screen.palette[i]=(c) cover the full
palette range.
Screen.palette.mode and Screen.palette.mode=(list) get/set the
16-color legacy-attribute palette as a List of integers.
|
Note
|
term.c, cterm.c, and ripper.c all do their own palette
manipulations. Concurrent edits from a script and from the terminal
code may not compose cleanly.
|
Screen.cursor — CustomCursor
Cursor geometry: scanline range, blink rate, visibility.
The class supports two usage modes:
-
Static (live state): every static property reads or writes the cursor immediately.
Screen.cursor.blink = falsehides the blink on the next refresh. Each setter under the hood does a full read-modify-write of the renderer’s cursor state — so a single static write IS atomic for that one field, but a chain of static writes is NOT atomic across fields. The renderer can paint between them, briefly showing intermediate states. For multi-field changes, use the instance path. -
Instance (staged edit):
CustomCursor.new()(orCustomCursor.current) snapshots the current values. Modify the instance’s properties locally, thenapply()to commit atomically — one syscall regardless of how many fields changed.
CustomCursor.preserve(fn) is a convenience for the common
"snapshot, change, restore" pattern: snapshots the live cursor,
runs fn, re-applies the snapshot. Same fiber semantics as
Screen.modalRun — no Fiber.try wrapper, an abort in fn skips
the restore.
Properties (both static and instance): startLine, endLine,
range, blink, visible.
Pre-defined snapshots:
| Snapshot | Equivalent legacy code |
|---|---|
|
Mode-default cursor shape (legacy code 2). |
|
Solid block cursor (legacy code 1). |
|
Hidden cursor (legacy code 0). |
Screen.videoFlags — VideoFlags
Boolean video-output flags, with the same static-vs-instance pattern
as CustomCursor — including the chained-static-writes
non-atomicity caveat and the VideoFlags.preserve(fn) snapshot
helper.
| Property | Meaning |
|---|---|
|
Use the alternate (right-half) character set for graphics characters — the EGA/VGA "9th column" expansion. |
|
Suppress the bright-foreground bit; bold text renders as plain. |
|
Treat the high-intensity bit on the background nibble as bright background instead of blink. The terminal’s tradition was blink; most modern terminals bind it to bright bg. |
|
Repurpose the blink attribute bit (bit 7 of the legacy attribute byte) to select the alternate character set instead of triggering blink. When set, "blinking" cells render from the alt-font slot statically; when clear, bit 7 means actual blink. |
|
Disable blink rendering entirely. |
|
Read-only. True when the backend is using 9-pixel-wide cells with the right-edge duplication for the box-drawing range. Flipping it under a live screen would mismatch cell-bitmap layout sized at video- mode init for 8-vs-9 pixel cells, so it’s intentionally read-only. |
|
Apply the right-edge duplication to box- drawing characters in the 0xC0..0xDF range (the "VGA 9th-column" trick). |
Screen.color — Color
Build and inspect 24-bit RGB values. Returned values are raw
0xRRGGBB without the high RGB-mode bit; the bit is added when
writing into a Cell field via fgRgb= / bgRgb=.
| Method | Description |
|---|---|
|
Pack three 8-bit components into a uint32. |
|
Decode an attribute byte into |
|
Encode two palette indices into one attribute byte. |
Input
Unified blocking and nonblocking reader for keyboard and mouse events.
| Member | Description |
|---|---|
|
Block until the next event; return |
|
Wait up to |
|
Return immediately with the next pending event, or |
|
Push an event back to the front of the input queue. Routes by
event type — |
|
Synthesize a physical key edge. |
|
Hand off to SyncTERM’s full-screen drag-select gesture. This uses the
current screen dimensions and does not depend on a connected terminal.
|
|
Show or hide the mouse cursor. Setter only — there is no ciolib query for visibility, and tracking it from Wren would drift if other code shows or hides the cursor. |
|
Read or set the bitmask of |
|
Single-event toggles for the same mask. |
|
Reconfigure ciolib to report exactly the events appropriate for
cterm’s current mouse mode ( |
|
Push |
Once a process-close request has been read, Input.next(), Input.next(ms),
and Input.poll() continue returning Key.quit until SyncTERM exits. UI code
should use App, which reserves the event for call-stack unwinding and
supports the explicit Widget.atExit exception.
For posting a synthetic resume to a parked fiber (the wake
mechanism that Hook.onInput-style callbacks use to nudge a UI
fiber), see Wake.
KeyEvent
Wraps a 16-bit ciolib key code.
| Member | Description |
|---|---|
|
Construct from a raw 16-bit code. Aborts the calling fiber if
|
|
Raw 16-bit ciolib code. |
|
Unicode value of the byte under the current |
|
UTF-8 form of |
Encoding constraint
ciolib transports 16-bit key codes through getch / ungetch as a
low-byte then high-byte sequence, with the convention that the
first byte being 0x00 or 0xE0 means "extended scancode follows."
The corollary: a code with high byte non-zero AND low byte not
0x00 / 0xE0 cannot be represented in that stream — Input.unget
would split it into two separate, unrelated reads.
KeyEvent.new(code) therefore accepts only:
-
High byte
0x00— single-byte ASCII / printable keys (KeyEvent.new(0x001B)for Esc,KeyEvent.new(0x0041)forA). -
Low byte
0x00— extended scancode-only keys (KeyEvent.new(0x3B00)for F1,KeyEvent.new(0xFA00)for an arbitrary synthetic sentinel). -
Low byte
0xE0—E0-prefixed extended keys (KeyEvent.new(0x29E0)for the Wren console hotkey).
Any other combination (KeyEvent.new(0xFB01), KeyEvent.new(0x1234))
aborts the calling fiber with a clear error.
The CIO_KEY_ABORTED scancode (0x01E0, "Esc by scancode") is
normalized to plain Esc (0x001B) inside the constructor, so scripts
only ever see one Esc value.
PhysicalKeyEvent
Represents one physical key press or release edge using the same evdev key
code namespace as CTerm’s physical-key reporting protocol. Returned by
Input.next() / Input.poll(), passed to Input.pushClaim handlers, and
passed to Hook.onPhysicalKey callbacks.
| Member | Description |
|---|---|
|
Construct a synthetic physical-key edge. |
|
evdev key code number. |
|
|
MouseEvent
Mirrors struct mouse_event from ciolib.h. Returned by
Input.next() / Input.poll(), passed to Input.pushClaim
handlers, and passed to Hook.onMouse callbacks — every mouse
surface uses this same shape.
| Member | Description |
|---|---|
|
Construct from raw fields. Five overloads dispatched by arity.
Omitted |
|
Mouse event type code. |
|
Snapshot of the button-state bitmask at the moment the event
fired (the |
|
Keyboard modifier mask. |
|
Click-down coordinates (1-based). |
|
Release coordinates (1-based). |
Mouse event constants
Mouse.button1*, Mouse.button2*, and Mouse.button3* identify left,
middle, and right button events. Each family uses the ciolib order Press,
Release, Click, double/triple/quadruple Click, DragStart, DragMove, and DragEnd;
the Wren module currently names every button-1 event and the button-2 and
button-3 Press, Release, and Click events. Mouse.wheelUpPress,
Mouse.wheelUpClick, Mouse.wheelDownPress, and Mouse.wheelDownClick cover
the backend-dependent wheel forms.
ClaimHandle
Returned from Input.pushClaim. Drop the claim by calling
pop().
| Member | Description |
|---|---|
|
Remove the claim entry from the stack. Idempotent — stale handles (after a same-fiber re-push bumped the entry’s id, or after another path popped it, or after the foreign finalizer ran) no-op silently. The C-side foreign finalizer also pops on GC, so a forgotten handle still cleans up — but explicit pop is cheaper and more predictable. |
Wake
Fiber-resume primitive. Wake.post(fiber, value) queues a
synthetic resume of fiber, delivering value as the return of
its next Fiber.yield(). The wake is enqueued on the same
result queue Timer.trigger and SFTP completions use, so it’s
delivered on the next main-loop drain — never in the middle of the
current foreign call. Safe to call from any foreign context
(hooks, claim handlers, worker fibers). value may be any Wren
object — the deliverer pins it as a WrenHandle until delivery,
then loads it into the fiber’s resumed-value slot. Multiple posts
queue up; each becomes one resumption in arrival order.
| Method | Description |
|---|---|
|
Queue a resume. Returns immediately; the wake is dispatched on
the next main-loop drain. Convention: pass a discriminated value
the fiber’s main-loop demuxer can recognise (a foreign
|
Key
16-bit key codes returned by KeyEvent.code.
Printable ASCII characters (digits, letters, punctuation) come through directly as their ASCII byte value with the high byte zero — e.g. uppercase A is 0x0041 (65) — and don’t need a named constant. The constants below cover non-printable keys, modified keys, function keys, and synthetic markers.
The high byte is the PC scancode (or a synthetic marker > 0x7D when no scancode applies); the low byte is 0 for extended keys. ASCII keys use the low byte for the character and 0 for the high byte.
ASCII keys
| Constant | Code | Description |
|---|---|---|
|
0x001B |
Esc. |
|
0x000D |
Enter / Return (CR). |
|
0x0008 |
Backspace (BS). |
|
0x0009 |
Tab (HT). |
|
0x007F |
ASCII DEL — distinct from |
Cursor and editing keys
| Constant | Code | Description |
|---|---|---|
|
0x4700 |
Home. |
|
0x4F00 |
End. |
|
0x4800 |
Up arrow. |
|
0x5000 |
Down arrow. |
|
0x4B00 |
Left arrow. |
|
0x4D00 |
Right arrow. |
|
0x4900 |
Page Up. |
|
0x5100 |
Page Down. |
|
0x5200 |
Insert (Ins). |
|
0x5300 |
Delete (Del) — the keyboard key, distinct
from |
|
0x0F00 |
Shift+Tab (Back-Tab). |
Modified Insert / Delete
| Constant | Code | Description |
|---|---|---|
|
0x0500 |
Shift+Insert. |
|
0x0700 |
Shift+Delete. |
|
0x0400 |
Ctrl+Insert. |
|
0x0600 |
Ctrl+Delete. |
|
0xA200 |
Alt+Insert. |
|
0xA300 |
Alt+Delete. |
Modified arrow keys and End
| Constant | Code | Description |
|---|---|---|
|
0x91E0 |
Shift+Up. |
|
0x8D00 |
Ctrl+Up. |
|
0x9800 |
Alt+Up. |
|
0x96E0 |
Shift+Down. |
|
0x9100 |
Ctrl+Down. |
|
0xA000 |
Alt+Down. |
|
0x93E0 |
Shift+Left. |
|
0x7300 |
Ctrl+Left. |
|
0x94E0 |
Shift+Right. |
|
0x7400 |
Ctrl+Right. |
|
0x95E0 |
Shift+End. |
|
0x7500 |
Ctrl+End. |
Function keys
f1 through f12 and the modified variants shiftF1..shiftF12,
ctrlF1..ctrlF12, altF1..altF12. Code values are
contiguous within each group, but the absolute values are scancode-
derived and aren’t worth memorising. Compare against the named
constant.
| Key | Plain | Shift | Ctrl | Alt |
|---|---|---|---|---|
F1 |
0x3B00 |
0x5400 |
0x5E00 |
0x6800 |
F2 |
0x3C00 |
0x5500 |
0x5F00 |
0x6900 |
F3 |
0x3D00 |
0x5600 |
0x6000 |
0x6A00 |
F4 |
0x3E00 |
0x5700 |
0x6100 |
0x6B00 |
F5 |
0x3F00 |
0x5800 |
0x6200 |
0x6C00 |
F6 |
0x4000 |
0x5900 |
0x6300 |
0x6D00 |
F7 |
0x4100 |
0x5A00 |
0x6400 |
0x6E00 |
F8 |
0x4200 |
0x5B00 |
0x6500 |
0x6F00 |
F9 |
0x4300 |
0x5C00 |
0x6600 |
0x7000 |
F10 |
0x4400 |
0x5D00 |
0x6700 |
0x7100 |
F11 |
0x8500 |
0x8700 |
0x8900 |
0x8B00 |
F12 |
0x8600 |
0x8800 |
0x8A00 |
0x8C00 |
Synthetic markers
These are SyncTERM-specific codes injected into the key queue rather than scancodes from a real key.
| Constant | Code | Description |
|---|---|---|
|
0x7DE0 |
A mouse event is next in the queue. When
|
|
0x7EE0 |
The user requested quit (window close
button, OS close signal). This is a sticky
host request; |
|
0x29E0 |
Ctrl+` — opens the Wren console. High byte is the `-key scancode (0x29). |
Clipboard
| Member | Description |
|---|---|
|
Read the system clipboard as a string. |
|
Write a string to the system clipboard. |
Codepage
Enum-style class identifying a character-set encoding. Returned by
Font.codepage and Font.codepageOf(i) to tell scripts which
encoding the bytes in a screen cell are in.
A _b suffix on a codepage name means "broken vertical" — the
font variant draws box-drawing pipe characters with one-pixel gaps
between rows. The base codepage and its _b variant cover the same
character set; only the box-drawing glyph shapes differ.
| Constant | Index | Description |
|---|---|---|
|
0 |
IBM PC code page 437 — English plus box-drawing. The default for traditional ANSI BBSes. |
|
1 |
Windows-1251 — Cyrillic (Russian, Bulgarian, Ukrainian, etc.) in a "swiss" font variant. |
|
2 |
Windows-1251 with the broken-vertical glyph variant. |
|
3 |
KOI8-R — Russian Cyrillic. Common on 1990s Russian Linux/Unix systems. |
|
4 |
ISO-8859-2 (Latin-2) — Central European (Polish, Czech, Hungarian, …). |
|
5 |
ISO-8859-4 (Latin-4) — Northern European / older Baltic. 9-bit-mapped wide-glyph variant. |
|
6 |
CP866 — Russian DOS, "(c)" modified variant. |
|
7 |
ISO-8859-9 (Latin-5) — Turkish. |
|
8 |
ISO-8859-8 — Hebrew. |
|
9 |
KOI8-U — Ukrainian Cyrillic. |
|
10 |
ISO-8859-15 (Latin-9) — Western European with the Euro sign. |
|
11 |
ISO-8859-5 — Latin/Cyrillic. |
|
12 |
CP850 — Multilingual Latin-1 (DOS Western European). |
|
13 |
CP850 with the broken-vertical glyph variant. |
|
14 |
CP865 — Nordic DOS (Danish, Norwegian). |
|
15 |
CP865 with the broken-vertical glyph variant. |
|
16 |
ISO-8859-7 — Greek. |
|
17 |
ISO-8859-1 (Latin-1) — Western European, classic Unix default. |
|
18 |
CP866 — Russian DOS, second modified variant. |
|
19 |
CP866-U — Ukrainian variant. |
|
20 |
CP1131 — Belarusian DOS. |
|
21 |
ARMSCII-8 — Armenian. |
|
22 |
HAIK-8 — older Armenian set; used with the ARMSCII-8 screen map. |
|
23 |
ATASCII — Atari 8-bit native character set. |
|
24 |
PETSCII (uppercase/graphics mode) — Commodore 64/128 default. |
|
25 |
PETSCII (lowercase/shifted mode) — Commodore 64/128 after a Shift+Commodore key. |
|
26 |
UK Prestel / Viewdata teletext — block mosaic graphics, double-height, conceal. |
|
27 |
Prestel separated mosaics — block mosaics with gaps between cells. |
|
28 |
Atari ST GEM character set. |
Cell
Wraps a struct vmem_cell: one screen cell’s character, attribute,
and color state. Use cases: read a rectangle through
Screen.readRect, build a list manually, then write it back through
Screen.writeRect.
| Member | Description |
|---|---|
|
Construct an empty cell. |
|
Character as a Unicode string (one codepoint, round-tripped through CP437). |
|
Character as a raw 8-bit byte. |
|
Font slot. |
|
Legacy attribute byte (foreground in low nibble, background in high nibble, plus bright/blink bits). |
|
Bright bit. |
|
Blink bit. |
|
Foreground / background palette indices. |
|
Foreground / background as 24-bit RGB. Setters preserve bits 24..30 of the existing field, so toggling between palette and RGB modes does not lose unrelated state. |
|
Hyperlink ID attached to this cell. |
|
Structural equality of every content field. This is not a |
c |
c.eqContent(template) }`).
+
Returns |
Surface
A width × height grid of Cells backed by a contiguous
vmem_cell buffer. Surface is Sequence, so the linear [i]
subscript, count, and the standard sequence helpers (each,
where, map, all, any, reduce, join, take, skip,
toList, …) all work — handy for "tell me where any 'X' lives"
without switching to 2D indexing. Linear order is row-major:
buf[y * width + x].
Screen.readRect returns a Surface sized to the requested rect;
Screen.putRect blits a Surface to the screen as a single atomic
puttext; Screen.writeRect accepts a Surface as its source. The
UI library uses Surfaces as per-widget backbuffers and composites
them into a screen-sized backbuffer Surface that’s blitted once
per frame.
| Member | Description |
|---|---|
|
Allocate a fresh Surface of the given size. Cells start zeroed (NUL char, attribute 0, slot 0, no hyperlink). |
|
Dimensions in cells. |
|
|
|
Linear-indexed read of the cell at offset |
|
2D-indexed |
|
Sequence protocol — yields each cell linearly in row-major order
(the cell at |
|
Sequence-of-Sequence view, outer-by-y. |
|
Sequence-of-Sequence view, outer-by-x. |
|
Paste a source Surface into this Surface at |
|
Bulk-fill the cells inside |
|
Debug-only string form, e.g. |
Font
A "font" in SyncTERM is one of 257 indexed slots, each a complete
glyph table for a particular character set or display style. Slots
0–45 ship with built-in fonts, covering the codepages enumerated by
Codepage plus several Amiga and 8-bit display styles.
Slots 46+ are reserved for user-loaded fonts (uploaded via cterm
escape sequences or RIP), but Font.count reports however many slots
are actually populated at runtime.
The Font class itself only exposes named constants for the most
useful slots; every slot is reachable numerically through
Screen.font[i] regardless. The full built-in table follows.
| Named constant | Index | Slot description |
|---|---|---|
|
0 |
Codepage 437 English (default). |
1 |
Codepage 1251 Cyrillic, "swiss" variant. |
|
2 |
Russian KOI8-R. |
|
3 |
ISO-8859-2 Central European. |
|
4 |
ISO-8859-4 Baltic wide (VGA 9-bit mapped). |
|
5 |
Codepage 866 (c) Russian. |
|
6 |
ISO-8859-9 Turkish. |
|
7 |
HAIK-8 (used with the ARMSCII-8 screen map). |
|
8 |
ISO-8859-8 Hebrew. |
|
9 |
Ukrainian KOI8-U. |
|
10 |
ISO-8859-15 West European, "thin" variant. |
|
11 |
ISO-8859-4 Baltic (VGA 9-bit mapped). |
|
12 |
Russian KOI8-R, alternate (b) variant. |
|
13 |
ISO-8859-4 Baltic wide. |
|
14 |
ISO-8859-5 Cyrillic. |
|
15 |
ARMSCII-8 (Armenian). |
|
16 |
ISO-8859-15 West European. |
|
17 |
Codepage 850 Multilingual Latin I, "thin". |
|
18 |
Codepage 850 Multilingual Latin I. |
|
19 |
Codepage 865 Norwegian, "thin". |
|
20 |
Codepage 1251 Cyrillic. |
|
21 |
ISO-8859-7 Greek. |
|
22 |
Russian KOI8-R, alternate (c) variant. |
|
23 |
ISO-8859-4 Baltic. |
|
24 |
ISO-8859-1 West European. |
|
25 |
Codepage 866 Russian. |
|
26 |
Codepage 437 English, "thin" variant. |
|
27 |
Codepage 866 (b) Russian. |
|
28 |
Codepage 865 Norwegian. |
|
29 |
Ukrainian CP866-U. |
|
30 |
ISO-8859-1 West European, "thin". |
|
31 |
Codepage 1131 Belarusian, "swiss". |
|
|
32 |
Commodore 64 (uppercase / graphics). |
|
33 |
Commodore 64 (lowercase / shifted). |
|
34 |
Commodore 128 (uppercase / graphics). |
|
35 |
Commodore 128 (lowercase / shifted). |
|
36 |
Atari 8-bit. |
|
37 |
P0T NOoDLE — Amiga BBS classic. |
|
38 |
mO’sOul — Amiga BBS classic. |
|
39 |
MicroKnight Plus — Amiga. |
|
40 |
Topaz Plus — Amiga. |
|
41 |
MicroKnight — Amiga. |
|
42 |
Topaz — Amiga Workbench font. |
|
43 |
Prestel — UK Viewdata mosaic. |
|
44 |
Atari ST GEM. |
|
45 |
RIPterm — RIP graphics terminal font. |
The "thin" variants are the same character set rendered with thinner strokes in the VGA BIOS style. A "swiss" variant uses a Helvetica-style sans rendering. An "(a)" / "(b)" / "(c)" annotation distinguishes bundled font variants that use the same codepage encoding.
| Method | Description |
|---|---|
|
Human-readable font name for slot |
|
Total number of populated font slots. Iteration
bound — |
|
|
|
The |
|
The codepage value for slot |
Hyperlinks
Typed-ID lookup table over the active hyperlink registry. Despite the
[id] / containsKey(id) shape, this is not a Map — there is no
keys / values / count / iteration. IDs flow into a script from
add() returns or from Cell.hyperlinkId on cells under the cursor;
scripts can’t enumerate the ID space. ciolib doesn’t expose an
enumeration primitive either, and no current script needs one. If a
caller turns up that wants enumeration, the table can grow keys /
count / iterate later without breaking the existing surface (see
Console.entries for the pattern).
| Member | Description |
|---|---|
|
URI string for |
|
|
|
Allocate a new hyperlink ID for |
|
Parameter string for |
To attach hyperlinks to text, allocate an ID and assign it to
Screen.hyperlinkId before writing through Screen.window.print.
Console
Read-only access to the current VM’s always-on print/error log buffer. The buffer has 1024 entries of up to 8 KB each; older entries are evicted in FIFO order. Sequence numbers are monotonic across the VM’s lifetime, including across eviction, so an incremental "show only entries newer than X" reader works correctly even after the ring has wrapped.
| Member | Description |
|---|---|
|
Number of valid entries currently in the ring. |
|
Monotonic count of entries ever written. Survives |
|
Entry with monotonic sequence |
|
Drop all entries. |
|
Snapshot the current |
|
Wren iteration protocol — works in |
|
|
e |
e[2] == LogSource.runtimeError
}.toList
----
+
|
LogSource
Enum values for the source field on Console entries.
-
LogSource.print(0) — scriptSystem.print. -
LogSource.compileError(1) — Wren compile diagnostic. -
LogSource.runtimeError(2) — Wren runtime error message. -
LogSource.stackFrame(3) — one frame of a runtime-error stack trace.
REPL
Primitive for the immediate-mode REPL. Most scripts will not use
this directly; the embedded wren_console script wraps it.
| Method | Description |
|---|---|
|
Compile and run |
|
As above, in |
|
|
|
List of every module name currently loaded in the VM (including
|
The [value] wrapper for expressions distinguishes "this was a
statement" from "this was an expression whose value is `null`".
Runtime errors propagate normally. Wrap the call in
Fiber.new {}.try() to catch them.
Conn
Connection-level send and receive.
| Member | Description |
|---|---|
|
Send bytes through the full path: telnet IAC escaping, then on-wire.
Returns |
|
Send bytes raw, bypassing IAC escaping. Same return contract as
|
|
Close the connection. |
|
Request a session end. Sets a flag that doterm() picks up at the
top of its next iteration, runs the (UI-free) cleanup
(cterm shutdown, conn close, mouse/cursor restore), then either
returns to the main menu ( |
|
Paste from the system clipboard onto the wire — codepage-aware, bracketed-paste-aware. No-op when the clipboard is empty. This is the operation bound to Shift+Insert by the default key hooks. |
|
Invoke |
|
Open the upload picker or dialog. This is the operation bound to Alt+U by the default key hooks. No-op outside an active session. Mouse events are refreshed on the way out. |
|
Open the download picker or dialog. This is the operation bound to Alt+D by the default key hooks. No-op outside an active session. Mouse events are refreshed on the way out. |
|
Bool — is the link up? |
|
Monotonic seconds since the connection provider reached the active
connected state, clamped to |
|
|
|
Bytes available to read right now (in the inbuf ring). |
|
Bytes queued to write that haven’t gone out yet. |
|
Look at up to |
|
Read up to |
ConnError
Recoverable wire-side failure returned by Conn.send / Conn.sendRaw.
Returned IN PLACE of null on failure; null still means success.
Mirrors the SFTPError / FileError /
WONError shape.
| Member | Description |
|---|---|
|
|
|
For |
|
Human-readable diagnostic, may be |
|
|
ConnErr constants:
| Constant | Code | When emitted |
|---|---|---|
|
0 |
Sentinel; never appears on a returned ConnError. |
|
1 |
Send attempted while |
|
2 |
Outbuf was full / send partially queued. |
Capture
Streaming-log control for the active session. The Wren-side
CaptureMenu in scripts/auto/connected/capture_menu.wren implements
the user-facing Alt+C flow with these primitives. All methods are
no-ops when no terminal is active.
| Method | Description |
|---|---|
|
Bool — true while a streaming log is open (regardless of pause state). |
|
Bool — true when the active log has been paused via
|
|
Open a streaming log to the path authorized by |
|
Close the active log. No-op when not capturing. Idempotent. |
|
Set the PAUSED bit on the active log. No-op when not capturing or already paused. |
|
Clear the PAUSED bit. No-op when not capturing. |
Use CTerm.saveScreenshot for a one-shot binary screen
snapshot with optional SAUCE metadata. Screen snapshots are terminal
state rather than streaming logs and therefore are not part of
Capture.
CTerm
Read-only window into cterm state — what term.c and ripper.c
read. Useful from Hook.onInput and Status.callable for
emulation-aware logic.
| Group | Members |
|---|---|
Cursor (1-based, on the terminal) |
|
Origin (1-based, on the host screen) |
|
Geometry |
|
Color state |
|
Fonts |
|
Scrollback |
|
Mode flags |
|
Screenshot |
|
Mouse state |
|
Throttle |
|
Bitfield snapshots |
|
| Action | Description |
|---|---|
|
Send |
|
Read or write the wire-pump suspend flag. When set true, the main
loop stops draining bytes from the conn buffer. Bytes pile up in the
conn buffer; the TCP receive window eventually fills and the remote
sees its |
|
SFTP-active flag the SSH driver reads to decide whether to keep
the SSH session alive when the shell channel closes (or wire
goes idle). Set true while a script has SFTP transfers in
flight; set false when the queue drains. |
|
Force a SyncTERM status-bar repaint on the next |
|
Read or toggle the |
|
Read-only enum int from the live |
|
Read-only Bool from the live |
|
Read or write the cterm doorway-mode flag. When true, the input pump passes most keys through verbatim instead of decoding them; used by door programs that want raw scancodes. |
|
Read or write the Operation Overkill ][ tone-emulation mode
( |
|
Current network character-pacing rate in BPS. |
|
Walk |
ExtAttr
Snapshot of cterm’s extended-attribute bitfield. All members are read-only Booleans reflecting the corresponding terminal mode.
| Member | Description |
|---|---|
|
DECAWM — characters past the right margin wrap to the next line. |
|
DECOM — cursor coordinates are relative to the active scroll region rather than the screen. |
|
SIXEL scroll: when a SIXEL image extends past the bottom of the screen, scroll the screen up to make room. When clear, the image is clipped at the bottom row. |
|
DECLRMM — left/right margin mode enables horizontal scroll regions. |
|
DEC mode 2004 — pasted text is bracketed by ESC[200~ / ESC[201~ markers. |
|
DECBKM — Backarrow key sends BS (0x08) instead of DEL (0x7F). |
|
Prestel mosaic graphics character set is active. |
|
Current row is rendered double-height. |
|
Concealed (hidden) text mode. |
|
Separated mosaics — gaps between mosaic blocks. |
|
Hold-graphics mode — control codes render as the most recently emitted mosaic glyph rather than blanking. |
|
DECKPAM — numeric keypad sends application keypad codes instead of digits. |
LastColumnFlag
Snapshot of cterm’s last-column-flag state. Used by emulations that distinguish "cursor at the right margin" from "cursor wrapped to next line" (the so-called phantom-column).
| Member | Description |
|---|---|
|
Bool — the flag is currently set: the cursor sits at the right margin awaiting the next character to wrap it. Cleared by any cursor-positioning sequence. |
|
Bool — last-column-flag tracking is enabled at all. Some emulations disable it. |
|
Bool — tracking is forced on regardless of the
underlying mode (set by SyncTERM’s emulation-bridge
code or the BBS list’s |
StatusDisplay
Referenced by CTerm.statusDisplay. VT320 DECSSDT (Select Status
Display Type) values: which status line, if any, the bottom row is
acting as.
| Constant | Code | Description |
|---|---|---|
|
0 |
No status line — the bottom row is part of the main display area. |
|
1 |
Indicator status line — terminal-managed diagnostic line (e.g. "INSERT", connection state). The host can’t write to it. |
|
2 |
Host-writable status line — the bottom row is a separate addressable region the BBS can direct writes to via DECSASD. |
Connected BBS
Read-only window into the active struct bbslist entry. Useful
from onConnect-style top-level code that wants to behave
differently per BBS.
Most fields are direct getters that return the underlying value. Fields that index into enums are surfaced both as the integer (for direct comparison with the enum class) and through the typed enum classes documented below.
| Group | Fields |
|---|---|
Identity |
|
Network |
|
Auth |
|
Display |
|
Modem / serial |
|
Telnet |
|
RIP / music |
|
File transfer |
|
Logs |
|
Statistics |
|
This BBS class belongs to the shared syncterm module and always
means the one active connection. It is unrelated to the mutable menu
BBS class below; the two classes live in different modules and VMs
and no instance can cross between them.
Menu BBS Model
The menu VM imports Menu, BBS, and MenuReadStatus from
syncterm_menu. Menu owns a C-side transactional view of the
personal, installed system, and web-cache BBS lists. It is not
available in a connected VM.
| Method | Description |
|---|---|
|
Load all lists and replace the current model only after the complete
load succeeds. |
|
Parse a String using SyncTERM’s normal quick-connect URL rules and
return an unsaved transient |
|
Return a detached |
|
Select the completed session’s text mode, restore its mode-specific fonts
and palette, and draw the terminal background. Returns |
|
Return whether the completed session’s terminal layout reserves a status row. The shipped viewer uses the full screen height for Page Up/Page Down and this value to recover the terminal height used by H/L. |
|
Open a detected plain-text URL with the configured local URL handler. Returns whether it opened. This menu-only operation carries local browser authority and is unavailable to connected scripts. |
|
Open an OSC 8 hyperlink from the conio hyperlink registry. Returns whether it opened. The shipped viewer copies the URL to the clipboard when opening fails. |
|
Set the offline viewer’s pointer and status-row URL for a hyperlink ID; pass zero to clear both. This is a presentation operation for the trusted menu viewer, not a connected-session capability. |
|
Return the classic header text, combining the SyncTERM version and active conio output-backend description. This is display data and does not change the OS window title. |
|
Return the current local time in the classic 24-character
|
|
Set the OS window title to the classic name/call-count/last-connected
summary for a current menu |
|
Return the host diagnostic String for a |
|
Set SyncTERM’s process exit latch after trusted menu code has completed any required work. This is available for explicit menu commands; OS window-close requests set the same latch in the host. |
|
Return a new List of host-created menu |
|
Return whether the combined personal and read-only directory has room for another personal entry. |
|
Return the mutable root-section defaults record, or |
|
Test whether a new personal entry name is valid and unused by another
personal entry. Names are 1..30 bytes and the reserved
|
|
Create an unsaved personal entry from |
|
Create an unsaved personal copy of |
|
Reapply the configured BBS-list sort profiles. |
|
Return fresh |
|
Return fresh |
|
Zero-based index of the active profile. |
|
Select a profile in memory, apply its order, and re-sort the loaded
directory. Returns |
|
Insert a profile. Names are 1..19 bytes and case-insensitively
unique. Returns |
|
Replace a profile’s name and field order. Updating the active
profile also re-sorts the loaded directory. Returns |
|
Delete a profile and re-sort with the resulting active profile.
Deleting the last profile restores the built-in profiles. Returns
|
|
Write the working profiles, active profile, and current order to
|
The shipped offline viewer saves the menu screen, calls
prepareOfflineScrollback(), captures the resulting background, and restores
the saved mode, fonts, palette, video flags, cursor, mouse subscriptions, and
screen when it closes. It starts at the newest screen. Up/J and Down/K move
one line, Page Up/Page Down move by the physical screen height, H/L move by
the terminal content height, the mouse wheel moves one line, F1 opens the
Markdown help, and Escape returns to the directory. Clicking opens OSC 8 or
detected plain-text URLs, hovering shows an OSC 8 URL in the status row, and
button-one drag invokes SyncTERM’s line-mode text selection.
MenuReadStatus constants are ok, passwordRequired,
decryptFailed, readFailed, migrationListOpenFailed,
migrationIniOpenFailed, migrationIniReadFailed,
migrationListWriteFailed, and migrationIniWriteFailed.
Menu BBS properties map directly to the real struct bbslist:
| Group | Properties |
|---|---|
Identity and state |
Read-only |
Address and authentication |
Read/write |
Display and emulation |
Read/write |
Paths and logging |
Read/write |
Serial and protocol |
Read/write |
Setters mutate only the in-memory record and set dirty; save() is
the persistence boundary and returns Bool. A rename is likewise
in-memory until save() performs the section rename and field update
together. delete() removes a personal entry from disk before
removing it from the model. System and web-cache records are
read-only: setters abort, while rename, save, and delete return
false. Pass one to Menu.copy to obtain an editable personal copy.
The shipped entry editor invokes those setters, including rename(),
after each nested field editor accepts a changed value. It calls
save() for a dirty entry when the entry editor closes.
The fingerprint has no direct editor row; the shipped F5/F6 clipboard carries
it through the draft so copying an entry preserves an accepted SSH host key.
Download Path and Upload Path remain direct entry rows. Log Configuration
opens the four intended logging controls: Log Filename, File Transfer Log
Level, Telnet Command Log Level, and Append Log File. Accepted values still
update the working BBS before the logging submenu closes.
A successful full load, create, or delete advances the model
generation. Every previously obtained menu BBS handle then becomes
stale; using one aborts with an instruction to reacquire it from
Menu.entries. This makes use-after-delete and references into a
transactionally replaced list deterministic. Sorting and in-place
renaming preserve handles.
Menu Program Settings
Menu.settings returns a C-owned Settings snapshot. Property
setters validate the assigned value, change only that snapshot, and set
dirty. apply() applies the snapshot to the running program without
writing syncterm.ini. Values copied from the running settings are not
revalidated, so values accepted by older versions pass through unchanged.
reload() discards changes not yet applied and repopulates the same handle
from the current running settings.
dirty specifically means that the snapshot differs from the running
settings. A successful apply() clears it. It does not indicate
whether those running settings have subsequently been written to
syncterm.ini; code which separates apply() from save() must track
that persistence obligation itself.
save() preserves unrelated INI sections, writes the existing
syncterm.ini keys, and applies the settings which have immediate effects.
These include scrollback capacity, scaling, mouse-wheel direction, audio
backends, custom-mode dimensions, cursor style, and the Classic Theme
colours used when the built-in theme is active. It returns
false when allocation, encrypted-list rewriting, or INI persistence fails.
An unapplied snapshot is not applied after such a failure; settings already
accepted through apply() remain active. Changing the KDF work factor may
rewrite an encrypted BBS list during apply(). Saving syncterm.ini is
disabled in safe mode.
A successful apply() or save() advances the settings generation.
The object which performed the operation remains valid and clean, but
any other outstanding Settings handles become stale and abort when
used. Reacquire a snapshot from Menu.settings after another component
applies or saves settings.
The shipped Program Settings editor calls apply() after each accepted
setting and calls save() once when the editor closes. Thus settings
with immediate effects change at field-confirmation time while the INI
write remains grouped at the Program Settings screen-exit boundary.
The editor keeps Classic Theme Colours and Custom Screen Mode as nested groups instead
of flattening their fields into Program Settings. A connected invocation
omits Custom Screen Mode. The connected settings list also omits Current
Screen Mode and List Encryption; List Encryption is omitted in every mode
when Menu.encryptionAvailable is false.
| Property | Meaning |
|---|---|
|
Bool program options. |
|
Modem strings and the terminal name exported to local shell
connections. The shipped editor accepts 1024 bytes for the device,
1023 bytes for either modem command, and 30 bytes for |
|
Stored personal-list path or URI. The host resolves it to the active
local or cached list path after a successful apply or save. The shipped
editor limits it to |
|
Catalog-backed display, backend, and audio selections. |
|
Scrollback capacity is 1 through |
|
The exponent in the persisted |
|
Custom text-mode geometry. Rows are 14..255, columns are 40..255,
font height is exactly 8, 14, or 16, and both aspect values are
1 through |
|
Palette indexes used to construct the built-in Classic Theme.
Frame, text, and lightbar foreground use |
The six values are persisted under [ClassicTheme]. On load, each
missing key falls back independently to the same key under the former
[UIFC] section, so a partial new section does not discard old
preferences. The next successful Program Settings save writes all six
[ClassicTheme] values and removes [UIFC]. A failed save leaves the
existing file unchanged.
The host supplies display catalogs rather than requiring scripts to duplicate platform-dependent enum tables:
| Menu member | Result |
|---|---|
|
Lists of display names indexed by the corresponding numeric setting. |
|
Lists of |
|
Return the built-in default port for a connection-type value. |
|
Open the named serial device and return its supported rates as
|
|
Return the |
|
Return |
|
Build-dependent List of |
|
Build-dependent List of |
|
List of |
|
Build-specific |
|
Bool indicating whether this build exposes personal-list encryption. |
|
Foreground and background color-name Lists, including their final
|
|
Current SyncTERM screen-mode enum value. |
|
Apply a concrete screen mode immediately and restore the configured
default cursor. |
|
Map containing |
Menu Themes
Theme storage and file syntax are documented in Themes.adoc.
Menu.themes refreshes the C-owned catalog and returns rows in the form
[filename, name, author, description, version, error]. The built-in
Classic Theme has an empty filename. Optional metadata is null when
absent. An invalid or missing theme has an error String and cannot be
selected, but remains available for display in the browser.
Menu.selectedThemeFile returns the committed filename, or an empty String
for Classic Theme. Menu.previewTheme(filename) installs a temporary
process-wide preview and returns null, or returns an error String without
changing the committed selection. Menu.cancelThemePreview() restores the
committed theme. Both operations advance Host.themeGeneration when they
change the active snapshot.
Menu.selectTheme(filename) validates the catalog entry, writes
[SyncTERM] ThemeFile, and commits the preview as one operation. Selecting
Classic removes the key. It returns null on success or an error String on
failure. In safe mode the running selection changes but the INI file is not
written. A successful selection advances both the theme and settings
generations, so scripts should reacquire outstanding Settings snapshots.
Menu List Encryption
Menu.encryptionAlgorithm, Menu.encryptionKeySize, and
Menu.encryptionName describe the personal BBS list currently in
memory. MenuEncryption defines none, aes, and chacha20.
Menu.setEncryption(algorithm, keySize, password) rewrites the current
personal list and returns Bool. AES accepts 128- or 256-bit keys;
ChaCha20 and unencrypted lists require key size zero. password may
be null to reuse the password already held by the host. Enabling
encryption fails if neither source supplies a non-empty password. A
successful rewrite updates the current algorithm, key size, and held
password; a failure leaves them unchanged. The operation is disabled
in safe mode.
Menu Web Lists
Menu.webLists returns a fresh List of [name, uri] rows. Names must
be unique and non-empty; System List is reserved. Indexes refer to
the order shown by that returned List. Menu.webListsDirty reports
whether the in-memory list has unpersisted edits.
| Method | Description |
|---|---|
|
Fetch the URI into the web-list cache and insert the in-memory entry.
Returns |
|
Change an in-memory entry’s URI. Returns Bool. It does not refresh the cached list. |
|
Remove an in-memory entry. Returns Bool. |
|
Persist the in-memory entries to the |
|
Fetch the current URI into the cache without changing configuration.
Returns |
In safe mode, add, update, and delete still update the running configuration,
and add still performs its initial fetch. saveWebLists() returns false
without writing syncterm.ini, so those changes last only for the current
process. Refresh remains permitted. No web-list method exposes its cache
pathname as a file capability.
Menu Custom Fonts
Custom-font configuration is held in a C-owned model separate from
the loaded conio font slots. Menu.fonts returns a fresh List of
MenuFont handles, or null if the INI file could not be read.
Menu.fontsDirty reports whether that model has unsaved edits.
| Method | Description |
|---|---|
|
Return whether another custom-font record can be added. This includes the build’s available conio slots, safe mode, and model-read status. |
|
Insert a blank custom font at |
|
Replace the |
|
Discard unsaved model edits and reread the INI file transactionally. Returns Bool. |
|
Read/write display and INI-section name, containing at most 50 bytes. The native editor permits an empty or duplicate replacement name, so the binding does as well. |
|
Configured pathname String for display, or |
|
Assign a path from a live, read-authorized |
|
Clear one configured size and return Bool. |
|
Remove the record from the in-memory model and return Bool. Call
|
MenuFontSlot values and their picker masks are:
| Constant | Code | Bytes | Typical mask |
|---|---|---|---|
|
0 |
2048 |
|
|
1 |
3584 |
|
|
2 |
4096 |
|
|
3 |
10240 |
|
Create and delete advance the font-model generation, invalidating all
previously obtained MenuFont handles. Rename, file assignment,
clear, and save preserve handles. Font mutation and persistence are
disabled in safe mode; reload remains available because it only reads
the existing configuration.
Menu BBS Constants
The menu BBS properties use the following numeric constants. Most
are shared with the active-connection BBS API, but the catalogs
returned by Menu should be preferred when constructing a user-facing
choice list.
ConnType
| Constant | Code | Description |
|---|---|---|
|
0 |
Unset / not yet decided. |
|
1 |
RLogin (TCP/513) — classic BBS auth-on-connect protocol. |
|
2 |
RLogin with the handshake byte order reversed (some servers). |
|
3 |
Telnet (TCP/23) — IAC negotiation in NVT mode. |
|
4 |
Raw TCP — no protocol layer; bytes pass straight through. |
|
5 |
SSH-2 — encrypted with password / key authentication. |
|
6 |
SSH-2 with the "none" authentication method (anonymous). |
|
7 |
Phone modem dial-out via AT commands. |
|
8 |
Direct serial port. |
|
9 |
Serial without RTS/CTS hardware flow control. |
|
10 |
Local subprocess (shell command). |
|
11 |
MajorBBS / Worldgroup "GHost" client interface. |
|
12 |
Telnet over TLS (TCP/992). |
Emulation
| Constant | Code | Description |
|---|---|---|
|
0 |
ANSI-BBS — ANSI X3.64 / VT-style escape sequences with PC-style color attributes. The default. |
|
1 |
Commodore 64 / 128 PETSCII control codes. |
|
2 |
Atari 8-bit ATASCII control codes. |
|
3 |
UK Prestel / Viewdata teletext — block mosaic graphics, double-height, conceal. |
|
4 |
BBC Micro Mode 7 teletext. |
|
5 |
Atari ST GEM VT-52 emulation. |
BBSListType
| Constant | Code | Description |
|---|---|---|
|
0 |
User-edited entry from the user’s BBS list. |
|
1 |
System-shipped entry from the SyncTERM-bundled list. Read-only at the UI level. |
ScreenMode
Standard modes encode their dimensions in the name (e.g. c80x25 =
80 columns × 25 rows). Codes are stable enum values.
| Constant | Code | Description |
|---|---|---|
|
0 |
"Don’t change" — keep the current mode. |
|
1 |
80×25 (CGA / VGA standard). |
|
2 |
80×25 with LCD-style cell aspect (SyncTERM-specific). |
|
3 |
80×28 (extended VGA). |
|
4 |
80×30 (extended VGA). |
|
5 |
80×43 (EGA-extended). |
|
6 |
80×50 (VGA 8-pixel-tall cell). |
|
7 |
80×60 (extended VGA). |
|
8 |
132×37 (Super VGA). |
|
9 |
132×52 (Super VGA). |
|
10 |
132×25. |
|
11 |
132×28. |
|
12 |
132×30. |
|
13 |
132×34. |
|
14 |
132×43. |
|
15 |
132×50. |
|
16 |
132×60. |
|
17 |
Commodore 64 (40×25 PETSCII). |
|
18 |
Commodore 128 (40-column mode). |
|
19 |
Commodore 128 (80-column mode). |
|
20 |
Atari 8-bit native (40×24 ATASCII). |
|
21 |
Atari 8-bit with XEP80 80-column expansion box. |
|
22 |
Custom dimensions configured per-BBS. |
|
23 |
EGA 80×25 with hardware-accurate pixel aspect (8×14 cell). |
|
24 |
VGA 80×25 with hardware-accurate pixel aspect (8×16 cell). |
|
25 |
Prestel teletext (40×24). |
|
26 |
BBC Micro Mode 7 (40×25 teletext). |
|
27 |
Atari ST 40×25 (low-res color). |
|
28 |
Atari ST 80×25 (medium-res color). |
|
29 |
Atari ST 80×25 monochrome (high-res). |
AddressFamily
| Constant | Code | Description |
|---|---|---|
|
0 |
Don’t care — let the resolver pick whichever family the host has. |
|
1 |
IPv4 only. |
|
2 |
IPv6 only. |
MusicMode
Controls how SyncTERM interprets ANSI music sequences (CSI … character).
| Constant | Code | Description |
|---|---|---|
|
0 |
SyncTERM’s strict mode — only the CSI MNML sequence triggers music; less-anchored sequences are passed through as text. The default. |
|
1 |
BANSI-style — older BBS music parsing rules. |
|
2 |
Most permissive — all forms accepted. |
RipVersion
| Constant | Code | Description |
|---|---|---|
|
0 |
RIP support disabled for this connection. |
|
1 |
RIPscrip v1.54 compatibility mode. |
|
2 |
SyncTERM’s "idealized" RIP v3 — bug-fixed and extended; not bug-compatible with v1.54. |
Parity
| Constant | Code | Description |
|---|---|---|
|
0 |
No parity bit (8N1 framing). |
|
1 |
Even parity bit. |
|
2 |
Odd parity bit. |
FlowControl
Read-only foreign class — instance returned from BBS.flowControl.
| Member | Description |
|---|---|
|
Bool — RTS/CTS hardware flow control enabled. |
|
Bool — XON/XOFF software flow control enabled. |
LogLevel
Standard syslog severity values (RFC 5424). emergency is the most
severe; debug is the least.
| Constant | Code | Description |
|---|---|---|
|
0 |
System is unusable. |
|
1 |
Action must be taken immediately. |
|
2 |
Critical condition. |
|
3 |
Error condition. |
|
4 |
Warning condition. |
|
5 |
Normal but significant. |
|
6 |
Informational. |
|
7 |
Debug-level diagnostic. |
Host
Module-private bridge for state the host owns but Wren shouldn’t
be able to construct directly. The members below are foreigns;
the Cache / Download module-level vars (see below) are
pre-bound from these at module load.
| Member | Description |
|---|---|
|
Monotonic process-wide generation for the active theme. Preview, cancellation, and committed selection changes advance it. |
|
Return fresh immutable-by-ownership snapshots of the active theme and
compiled fallback respectively. Each result is |
|
Returns a fresh |
|
Returns a fresh |
|
The BBS’s configured |
|
User-consent escape hatch for "upload from anywhere": invokes the
isolated picker VM and returns a |
|
The same consent picker with a caller-supplied title String. The title changes only the picker presentation and grants no additional file authority. |
|
Multi-select counterpart of |
Save-mode picker with path entry and overwrite confirmation. The user
can pick an existing file or type a new filename. The picker reports
create or overwrite consent explicitly, and the caller does not stat the
path again. Returns a
write-consent |
|
|
Re-construct a |
|
Status-bar transfer-indicator arrows (CP437 ↑ / ↓, painted by the
default Status render at the row’s right edge).
Generic — any Wren script that knows it’s transferring something
can light them; the canonical user is the SFTP queue’s worker
fibers, but a Zmodem / HTTP fetcher / etc. could too. Pure
Wren-side state on the |
|
|
|
|
|
Display name of the modifier key that produces Alt keycodes on
this platform. |
|
Write |
|
Full path of the Wren script supplied via the |
|
Wren-console activity since the user last visited the REPL.
|
|
ANSI music mode display strings and help blurb, returned as a
fresh |
|
Throttled-output ladder. |
|
Transfer log threshold ( |
|
|
|
Highest valid OOII mode value (returns |
|
Suspend terminal byte consumption and enter the persistent trusted menu VM over a saved copy of the connected screen. The connected VM is parked, no Wren or foreign object crosses between VMs, and trusted input barriers discard queued synthetic keyboard and mouse input in both directions. On return, the host restores the terminal screen, title, scrollback configuration, and prior suspension state. This is the default Alt+E path. |
|
The first locally-configured SSH public key, as a Map
|
FilePickerOptions
Public option bits for Host.pickFile and Host.pickFiles. The
numeric assignments are stable so scripts may combine or persist them.
| Member | Value | Meaning |
|---|---|---|
|
0 |
No optional behavior. |
|
1 |
Display the mask but do not let the user edit or replace it. |
|
2 |
Match file and directory masks case-sensitively. |
|
4 |
Sort entry names case-sensitively. |
|
8 |
Select a directory rather than a file. |
|
16 |
Reject a typed path which does not exist. |
|
32 |
Reject a typed filename which does not exist. |
|
64 |
Include hidden entries in directory listings. |
|
256 |
Allow the always-visible Path field to receive focus and accept a typed directory or filename. |
|
512 |
Ask before accepting an existing destination. |
|
1024 |
Ask before accepting a destination which does not exist. |
allowEntry, confirmOverwrite, and confirmCreate are invalid
with Host.pickFiles. Host.pickSavePath supplies its save-mode
options itself.
Cache
Module-level Directory injected from C (pre-bound from
Host.cacheDirectory), pointing at SyncTERM’s per-user cache
directory (SYNCTERM_PATH_CACHE). Use this for script-private
state that should survive restarts.
Cache.list returns a Map keyed by entry name; values are File
objects for regular files and Directory objects for subdirectories.
That’s the read-side handle factory — a script reads existing cache
content by indexing into Cache.list, and creates new content with
Cache.create(name):
import "syncterm" for Cache
// Read an existing cache file. Indexing the list returns the
// File object directly, no separate "open by name" step.
if (Cache.contains("greetings.txt")) {
var f = Cache.list["greetings.txt"]
f.open()
System.print(f.read())
f.close()
} else {
// First run: create + write. create() returns null if the file
// already exists or the OS rejects the create.
var f = Cache.create("greetings.txt")
if (f != null) {
f.open()
f.write("Hello, world!")
f.close()
}
}
// Subdirectories appear in the same map. Walk into them by chaining
// `.list` lookups:
// Cache.list["RIP"] → Directory for /RIP
// Cache.list["RIP"].list["icons.dat"] → File inside /RIP
There is no Wren-callable factory for Directory — Cache,
Download, and Cache.list[someSubdir] (and the analogous
Download.list[…]) are the only paths to one.
Download is a module-level Directory binding pre-bound from
Host.downloadDir at import time. It is null when the
configured DownloadPath is empty, set to the user’s $HOME,
or doesn’t exist as a directory on disk; scripts MUST null-check
before use. Its relaxed-name predicate is what makes typical
SFTP filenames ("file with spaces.zip", "doc-v1.0.tar.gz")
usable as local paths in transfer queues.
There is intentionally no Upload counterpart. All uploads must
go through Host.pickFile / Host.pickFiles, which mint a
per-file consent token tied to the file’s content hash. The
script then enqueues uploads by token (File.token); the worker
re-opens the source via Host.openLocalFile(token). Use
Host.uploadPath to feed the configured UploadPath to the
picker as initialDir.
Directory
Opaque handle pointing at a directory on disk.
| Method | Description |
|---|---|
|
|
|
A Map keyed by entry name; values are |
|
Atomically create a new zero-byte file named |
|
Atomically create a new subdirectory named |
|
Remove the entry named |
Handle staleness
File and Directory foreign objects are filesystem snapshots —
the C side caches the absolute path at the moment the handle was
issued (via Directory.list, Directory.create, Directory.createDir,
or Cache). A subsequent Directory.delete invalidates handles
to the removed entry by walking a live-handle registry and setting
a dead flag on each match.
Two layers of protection keep operations from acting on a stale path:
-
Active invalidation.
Directory.deletewalks every live handle and marks dead anything whose path is the removed entry or a descendant of it. Subsequent ops on a dead handle throw. -
Per-call existence check. Every File / Directory method re-checks
fexist(path)/isdir(path)before doing anything. If the path is missing — because something outside the script (another script, another process, the user) deleted it — the handle is marked dead, removed from the registry, and the operation aborts the calling fiber. This catches deletions that bypassedDirectory.delete.
The active layer is fast (one boolean check); the existence layer
costs a stat() per operation but covers the externally-modified
case. Together they ensure no File / Directory operation ever
silently acts on the wrong path.
Open files are exempt
A File handle whose underlying file is currently open (between
File.open() and File.close()) is not marked dead by the
invalidation walk and bypasses the per-call fexist() check.
This matches platform reality:
-
On Unix, an open file descriptor remains valid after the path is unlinked; reads and writes continue to work, and the inode is freed only when the last reference closes.
-
On Windows, deleting a file that’s currently open fails at the OS layer, so the situation never arises.
While the file is open, operations against the fd hit OS-level
errors if the underlying file is genuinely gone (very rare on Unix,
impossible on Windows). File.close() re-runs the existence check
after fclose; if the path is gone at that point, the handle is
marked dead and unregistered, and the next operation throws.
Scripts should drop stale handles when they’re done with them; the handles aren’t hazardous (operations throw rather than corrupt state) but holding them prevents the underlying foreign data from being GC’d.
Filename Policy
Filenames passed to any Directory or File method must satisfy:
-
Length 1..64 characters.
-
Characters from
[A-Za-z0-9._-]only. -
Must not begin with
.or-. -
Must not end with
.. -
Must not contain
... -
Must not match a Windows reserved device name (
CON,PRN,AUX,NUL,COM1..COM9,LPT1..LPT9), case-insensitively and with or without an extension.
Method calls violating the policy fail (return null / no-op);
methods on the resulting File are no-ops if construction failed.
File
Every File carries rights assigned by the host when it is created:
-
Files reached through
CacheorDownloadare read/write. -
Files returned by
Host.pickFile/Host.pickFiles, or reconstructed byHost.openLocalFile, are read-only.open()usesrb; write methods andmtime=()abort the fiber. Read-consuming bindings such asScreen.loadFont()accept these Files. -
Files returned by
Host.pickSavePathare one-shot write-only grants. Read methods, hashes, and read-consuming bindings reject them. See Write consent.
Rights belong to the foreign handle, not the path. Passing a File to
another binding does not broaden its authority.
| Method | Description |
|---|---|
|
Open the file using the mode authorized by its host-assigned rights.
Sandbox Files use |
|
Close the file handle. |
|
Read up to |
|
Read at an absolute offset; do not advance the current offset. |
|
Read the entire file from offset 0. |
|
Write |
|
Replace file contents with |
|
Read from the current offset to the first LF (0x0A) or EOF and
return the bytes read with any trailing LF removed. Advances the
offset past the LF on a hit, or to EOF if none was found. Returns
|
|
Write |
|
Current read/write position. |
|
Length of the file in bytes. |
|
Modification time as POSIX seconds. Setter calls |
|
|
|
Opaque consent token for picker-sourced |
|
Hashes of the file’s full content; raw digest bytes (20 / 16
bytes) returned as a Wren String. Implementation memory-maps the
file; zero-length files are hashed as the empty buffer. Returned
as bytes rather than hex so they compare directly against
|
A File whose backing file has been deleted via Directory.delete
is dead — every subsequent method aborts the calling fiber with
"handle is dead" (a programmer error: the script kept the handle
after explicitly invalidating it). See the
Handle staleness note in the Directory
section for the full mechanism.
A File whose backing path vanished externally (another process
deleted it, the user rm’d the file outside the script’s awareness)
is recoverable — the per-call `fexist() check catches it and
returns a FileError with code == FileErr.vanished
in place of the typed result. Drop or inspect the error and move on;
the fiber is not aborted.
Write consent
Files minted by Host.pickSavePath carry a
write-only consent — a single-shot grant to open the user-picked path
exactly once, in a specific mode determined at pick time:
| Picker outcome | open() mode |
|---|---|
Path didn’t exist at pick time |
|
Path existed and the user confirmed overwrite |
|
The consent is consumed by the first successful open(). After
the matching close(), every subsequent open() / writeBytes /
write / writeLine / etc. aborts the fiber with "write consent
already used". Re-pick the path to write again.
The grant never includes read authority. read, readBytes,
readLine, sha1, md5, and bindings which consume a readable File
reject a save-picker File, both before and while it is open.
File.token is null on write-consent Files: persisting a write
consent across sessions would defeat the single-shot rule. The
existing token mechanism is exclusively for read consent (the
Host.pickFiles / openLocalFile upload-resume flow).
Other bindings consume write consent on the caller’s behalf:
-
Host.pickSavePathmints the consent. -
Capture.start(file, raw)— opens for streaming-log output and transfers the FILE pointer into `cterm’s logfile. -
CTerm.saveScreenshot(file, withSauce)— writes the screen as IBM-CGA / BinaryText with optional SAUCE block.
After any of these calls the File is inert — calling open() on it
yourself is no longer valid.
FileError
Recoverable I/O failures (disk full, mmap failure, externally-deleted
file, OOM mid-op, …) flow through a FileError foreign returned IN
PLACE of the typed result. Mirrors SFTPError in
shape. Most scripts will simply ignore the value — that’s the point:
a failed write returns a FileError the caller can drop on the floor
and continue, rather than aborting the fiber. Scripts that care can
demux with is FileError.
| Member | Description |
|---|---|
|
|
|
Captured C |
|
Human-readable diagnostic text, or |
|
|
FileErr constants (matching enum file_err_code in wren_bind_fs.c):
| Constant | Code | When emitted |
|---|---|---|
|
0 |
Sentinel; never appears on a returned FileError. |
|
1 |
|
|
2 |
|
|
3 |
|
|
4 |
|
|
5 |
Internal |
|
6 |
Backing file/dir gone (race with external |
|
7 |
|
What is NOT a FileError: programmer errors abort the fiber instead.
That covers: using a dead handle (Directory.delete invalidated it),
calling read/write on an unopened file, calling open() twice,
passing a negative offset/count, reading past EOF, passing the wrong
type to a setter. These are all "your code is wrong" — fail loud.
What is also not a FileError: EOF from a short read. File.read
and friends return whatever bytes were actually read (possibly empty),
not a FileError. The script can detect EOF by comparing the
returned byte count to the requested count, just like fread.
Picker consent tokens
Wren scripts must not synthesize File foreigns from arbitrary
paths — that’s the whole point of the Cache / Download
sandboxed roots and the relaxed-name predicate. The escape
hatch is Host.pickFile / Host.pickFiles, where the isolated
picker interaction is the user’s signal of consent.
To let queued picker-sourced uploads survive a disconnect, the
picker mints a capability token at consent time: an
HMAC-SHA256 binding of the absolute path and the file’s SHA-256,
signed by a per-installation key stored in the user’s encrypted
syncterm.lst. Wren can store the token freely (it’s opaque
bytes — exposed via File.token and accepted by
Host.openLocalFile); only the C side can mint or verify one.
The file-content hash routes through DeuceSSH’s
hardware-accelerated digest backend (Intel SHA-NI / ARM crypto
extensions, via OpenSSL or Botan) when the build has it, so
signing/verifying a multi-GB picked file stays in the
sub-second range on commodity hardware. WITHOUT_DEUCESSH
builds fall back to xpdev’s pure-C SHA-256 (src/hash/sha256.c)
— correct, just slower on huge files; signing key generation
in that build seeds from /dev/urandom (or time/PID) via
xp_random rather than the SSH backend’s CSPRNG.
At resume time, Host.openLocalFile(token) verifies:
-
The HMAC matches (token wasn’t tampered with; signing key hasn’t rotated).
-
The file at the encoded path still exists.
-
The file’s current SHA-256 still matches the SHA-256 captured at consent time.
Any failure returns null; the queued upload should be marked
failed and the user prompted to re-pick. The content hash
binding is deliberate: if the file’s content has changed since
consent, the original consent doesn’t authorize uploading the
new content.
The signing key is loaded only from the user’s personal BBS list
(USER_BBSLIST) — never from shared/system or web-fetched
lists. This stops a malicious shared list from injecting a
known key and forging tokens against the local install. Key
rotation is manual: remove the WrenPickerHmacKey line from
syncterm.lst; SyncTERM regenerates on next launch. All
in-flight picker tokens become invalid.
Host.pickFile / Host.pickFiles continue to work even when
the signing key is unavailable (e.g. an unencrypted list, OOM
during generation) — the returned File`s have `null .token,
so picker uploads in such sessions don’t persist across
reconnects but otherwise behave normally.
Hook
The dispatcher registry. See Hook Events for the contract on each method, and HookHandle for the return type.
Hook.onKey(fn), Hook.onPhysicalKey(fn), Hook.onInput(fn),
Hook.onMouse(fn), Hook.every(ms, fn),
plus the filtered variants Hook.onKey(key, fn), Hook.onInput(byte,
fn), Hook.onPhysicalKey(evdev, fn), Hook.onMouse(event, fn), and
Hook.onMatch(pattern, fn).
Each call returns a HookHandle.
Status
Wren-driven status bar. The host invokes a single render callable
on every status-bar update with a width×1 Surface that has been
pre-filled to the default attribute (yellow on blue, spaces). The
callable mutates cells in place and returns; its return value is
ignored.
import "syncterm" for Status, Surface, BBS, CTerm
Status.callable = Fn.new { |surf|
if (!Status.enabled) return
var s = "%(BBS.name) | %(BBS.connTypeName) | %(BBS.bpsRate) bps"
var bytes = s.bytes
for (i in 0...bytes.count) {
if (i + 1 >= surf.width) break
surf[i + 1].chByte = bytes[i]
}
}
The default implementation lives at
scripts/auto/connected/status_default.wren. It uses the layout
name (flags) │ ConnType │ speed │ HH:MM:SS │ ALT-Z menu with
right-pinned indicators for mouse mode (M) and SFTP transfers
(arrows). To override it, place a same-named file in the auto-load
directory, or set Status.callable = … from any later script. The
host releases the previous handle when the callable changes.
| Member | Description |
|---|---|
|
The currently installed render |
|
Install (or clear) the render callable. |
|
|
The recycled Surface is reallocated only when the terminal width
changes, so per-frame allocation churn stays out of the hot path.
Out-of-bounds writes (surf[i] for i >= surf.width) throw —
treat surf.width as authoritative and clip your own loops.
Transfer
Mailbox bridge between SyncTERM’s transfer-window UI (Wren-side) and
the C protocol drivers running on a worker thread. beginSession
spawns the worker; the Wren-side TransferApp then drains log
events, snapshots tick state, and watches the done flag in a tick
loop until the transfer completes or the user aborts.
import "syncterm" for Transfer
Transfer.beginSession("fake") // start worker; throws if active
while (!Transfer.done) {
for (entry in Transfer.drainLog()) System.print(entry[1])
if (Transfer.tickDirty) {
var snap = Transfer.snapshot() // 13-element fixed-shape List
System.print("bytes: %(snap[1])")
}
// ... handle Esc → Transfer.requestAbort() ...
}
Transfer.endSession()
The Stage 2 build only wires "fake" (a 100-KiB synthetic stream
that ticks every 100 ms with periodic log messages, useful for
exercising the UI path). Real protocol kinds ("zmodem-recv",
"ymodem-send", "xmodem-recv", "cet-recv") land in Stage 4.
| Member | Description |
|---|---|
|
Spawn the worker thread and reset the mailbox. |
|
Latch an abort if the worker hasn’t already finished, join the
thread, and clear session state. Idempotent — safe to call after
a natural completion. Returns |
|
Drain the log ring under mutex; return a |
|
Atomic read+clear of the "tick state changed" flag. Returns
|
|
Return a 13-element fixed-shape |
|
|
|
|
|
Latch an abort flag the worker polls between protocol steps.
Returns |
|
Read the abort flag without modifying it. |
Platform
OS identification. No further OS surface (no shell exec, no stdio, no process model) is exposed.
| Method | Description |
|---|---|
|
OS identifier as a String. Returns |
Timer
One-shot fiber resumption after a delay. Registers a fiber to
receive a TimerElapsed event after the requested number of
milliseconds; the event lands on the result queue and the fiber is
resumed by the standard drainer once doterm() reaches it.
For recurring fire-and-forget callbacks (no fiber resume), use
Hook.every(ms, fn) instead.
| Method | Description |
|---|---|
|
Schedule |
|
Monotonic clock reading in seconds (a |
Common idiom — fire and immediately await:
import "syncterm" for Timer
Timer.trigger(Fiber.current, 250)
Fiber.yield()
// 250ms have passed, with the doterm() loop running normally
Multi-fire / event-loop dispatch:
import "syncterm" for Timer, SFTP, SFTPStat, SFTPError
Timer.trigger(Fiber.current, 100) // spinner tick
SFTP.stat(Fiber.current, "/foo") // stat in flight
while (true) {
var x = Fiber.yield()
if (x is TimerElapsed) {
spinner.update()
Timer.trigger(Fiber.current, 100) // re-arm
}
if (x is SFTPStat) break
if (x is SFTPError) break
}
TimerElapsed is a marker class with no fields — the caller already
knows what it scheduled; just dispatch on type.
Format
Number-to-string helpers for human-readable display. Both methods auto-pick a unit based on the magnitude of the input and use one decimal of precision; negative inputs are clamped to zero.
| Method | Description |
|---|---|
|
Format |
|
Format |
Combined idiom — display a transfer rate and ETA:
import "syncterm" for Format, Timer
var startedAt = Timer.now
var startBytes = job.done
// ... later ...
var elapsed = Timer.now - startedAt
var moved = job.done - startBytes
if (elapsed > 0) {
System.print("%(Format.bytes(moved / elapsed))/s")
var remain = job.total - job.done
if (remain > 0) {
System.print("ETA %(Format.duration(remain / (moved / elapsed)))")
}
}
SFTP
SSH-channel side-band file transfer. The full surface follows the fiber-arg async pattern (see Async Ops below): the foreign primitive captures a fiber and queues work on the SSH session’s recv thread; the result lands on the framework’s queue and the standard drainer resumes the fiber.
SFTP.<op>(fiber, args…) returns null when the request was
queued (yield to receive the result), or an SFTPError directly on
synchronous failure (session is gone, OOM at the foreign-method
site).
| Method | Description |
|---|---|
|
|
|
The remote |
|
|
|
Resolve |
|
Stat a remote path. Resumes with |
|
Open a directory for iteration. Resumes with |
|
Read the next batch of entries from an open directory handle.
Resumes with |
|
Open a file. |
|
Read up to |
|
Write |
|
Close a handle returned by |
|
Mutation ops. Each resumes with |
|
Set both atime and mtime of |
|
Fetch the server’s long description for |
FileFlag
OR-able bitmask constants for SFTP.open’s flags argument; each
matches the corresponding `SSH_FXF_* wire constant.
| Constant | Wire value |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
SFTPEntry
One entry from SFTP.readdir.
| Member | Description |
|---|---|
|
Filename relative to the parent directory. |
|
Server-formatted long name (when the
|
|
File size in bytes. |
|
Modification time, POSIX seconds. |
|
|
|
|
|
SHA-1 / MD5 digest bytes from |
SFTPStat
Result of SFTP.stat.
| Member | Description |
|---|---|
|
File size in bytes. |
|
Modification time, POSIX seconds. |
|
Access time, POSIX seconds. |
|
SFTP-wire permission bits (Unix-style on POSIX servers). |
|
Owner user ID. |
|
Owner group ID. |
SFTPHandle
Opaque server file/dir handle. Only SFTP.open / SFTP.opendir
produce one; only SFTP.read / SFTP.write / SFTP.readdir /
SFTP.close consume one. GC’d handles fire SFTP.close
fire-and-forget as a safety net, but scripts should close
explicitly.
SFTPError
Returned in place of the typed result on any failure. Two error
layers — distinguish via code:
| Member | Description |
|---|---|
|
|
|
|
|
Human-readable diagnostic text accumulated by the
library (may be |
|
|
The toString override produces "SFTPError: <name>: <message>"
for System.print / interpolation; useful for log-line debug.
Async Ops
Every SFTP.<op>(fiber, …) and Timer.trigger(fiber, ms) follow
the same fiber-arg pattern. The first argument is the fiber that
will be resumed with the result. The canonical idiom is the
||-yield pattern:
var r = SFTP.realpath(Fiber.current, ".") || Fiber.yield()
// r is String or SFTPError
The || short-circuits when the foreign returned a synchronous
error (no session, OOM): r is the SFTPError immediately, no
yield happens. Otherwise the foreign returned null, the yield
fires, and the fiber resumes with the actual result (success or
async error).
The single-source rule
This idiom is correct iff the calling fiber’s only resume sources
are the async-op completions it’s making. If the fiber has other
things that can wake it (Wake.post from the host, the App’s claim
handler posting key/mouse/timer events to its run-fiber, etc.), the
yield can return a value of the wrong type and the result-type check
on the next line will see garbage.
In practice, every script context that calls SFTP.<op> is already
single-source by construction. The three providers:
-
Hook handler fibers — the host invokes hooks (
onConnect,onKey, etc.) in a fresh fiber; nothing else can wake it.auto/connected/sftp_pubkey.wrenworks this way. -
SftpQueue worker fibers — the queue spawns one worker per direction; each is the long-running parker for its own ops.
sftp_queue.wrenworks this way. -
App.runChild children — an
Apphandler runs in the App’s run-fiber, which IS multi-source. Wrap||-yield work inApp.runChildto get a single-source child fiber while the run-fiber keeps dispatching events:var r = app.runChild(Fn.new { return SFTP.realpath(Fiber.current, ".") || Fiber.yield() })sftp_app.wrenworks this way — everySFTP.<op>call insideSftpAppultimately runs in arunChildfiber.
If you ever find yourself wanting to write SFTP.<op>(Fiber.current,
…) || Fiber.yield() directly inside a key/mouse handler or
other App-handler context, that’s the multi-source-fiber footgun.
Wrap it in runChild.
Callback shape (no yield)
For fire-and-forget — no need for an answer at all, or the result
handler is naturally a closure — pass Fiber.new {|r| … }
instead of Fiber.current. The calling fiber doesn’t yield:
SFTP.realpath(Fiber.new {|r|
// r is String or SFTPError
}, ".")
The framework `.call`s the passed fiber with the result whenever the op completes. Common in hooks that don’t want to block on the result, and for fan-out where each result wants its own closure.
WON
The Wren Object Notation — round-trip serialisation between Wren values
and a textual literal format. Output is a strict subset of valid Wren
syntax (and therefore also pasteable into Wren source). Deserialisation
runs a hardened C parser, not eval, so feeding untrusted text to
WON.deserialize cannot execute code.
Supported types:
| Type | Notes |
|---|---|
|
|
|
|
|
|
|
quoted with Wren-style escapes; |
|
|
|
|
|
coerced to |
|
any other |
Values of any other type abort serialisation in strict mode and are
silently omitted (or replaced with null at the top level) in lossy
mode. Cyclic Lists / Maps abort in both modes.
| Member | Description |
|---|---|
|
Strict compact form. |
|
Strict pretty-printed form. |
|
Compact form that silently omits unsupported items from Lists and Maps;
a top-level unsupported value becomes |
|
Pretty-printed lossy form. |
|
Parse |
WONError
Returned IN PLACE of the parsed value when WON.deserialize fails.
The script demuxes via is WONError. Mirrors the
SFTPError / FileError /
ConnError shape.
| Member | Description |
|---|---|
|
|
|
Byte offset where the error was detected. Useful for flagging a position in the input. |
|
Human-readable diagnostic, may be |
|
|
WONErr constants:
| Constant | Code | When emitted |
|---|---|---|
|
0 |
Sentinel; never appears on a returned WONError. |
|
1 |
Expected token / unexpected character / malformed number. |
|
2 |
Unterminated string, premature end of input. |
|
3 |
Malformed |
|
4 |
A |
|
5 |
Nesting exceeded the parser’s recursion budget (256 levels). |
|
6 |
A complete value was followed by more (non-whitespace) data. |
OOM during parsing aborts the fiber (no recoverable path — the
parser can’t allocate a WONError to return either).
import "syncterm" for WON
var data = {
"name": "syncterm",
"items": [1, 2, [3, 4]],
"flags": {"verbose": true, "depth": 7},
}
System.print(WON.serialize(data))
// {"name":"syncterm","items":[1,2,[3,4]],"flags":{"verbose":true,"depth":7}}
System.print(WON.serialize(data, " "))
// {
// "name": "syncterm",
// "items": [
// 1,
// 2,
// [
// 3,
// 4
// ]
// ],
// "flags": {
// "verbose": true,
// "depth": 7
// }
// }
var roundTripped = WON.deserialize(WON.serialize(data))
System.print(roundTripped["flags"]["depth"]) // 7
// Lossy mode skips unsupported entries instead of aborting.
var mixed = [1, Fiber.new {}, 2, Fiber.new {}, 3]
System.print(WON.serializeLossy(mixed)) // [1,2,3]
Built-in UI Library
SyncTERM ships a pure-Wren widget library on top of the
Modal Input primitive. It draws into Surfaces with
no foreign calls per cell, composites them through a screen-sized
backbuffer, and applies a final Screen.putRect per frame, so the
whole thing runs without flicker even on slow remote BBSes. The
visual style follows SyncTERM’s classic conventions: cyan-on-blue dialog frames,
yellow titles, lightbar selection, drop shadows on modals, and a
distinct cyan inactive palette for any pane that’s currently behind
a modal.
A minimal session looks like:
import "ui" for App, Pane, ListView, Alert, Rect
import "syncterm" for Screen
var snap = Screen.save()
var app = App.new()
var size = Screen.size
var pane = Pane.new()
pane.bounds = Rect.new(2, 2, size[0] - 2, size[1] - 2)
pane.title = "Pick one"
pane.focused = true
pane.onClose = Fn.new { app.quit() }
app.root.add(pane)
var list = ListView.new()
var ib = pane.innerBounds
list.bounds = Rect.new(ib.x, ib.y, ib.w, ib.h)
list.items = ["alpha", "beta", "gamma"]
list.onSelect = Fn.new {|i, item| Alert.show(app, "Picked: %(item)") }
pane.add(list)
app.runSync()
Screen.restore(snap)
App.run() and App.runSync() differ in how they pump events.
run() parks a fiber on Fiber.yield() between events — claim
handler posts each accepted event via Wake.post, the result
queue resumes the fiber. Inbound bytes flow normally,
Hook.every and Timer.trigger keep firing. runSync() blocks
the VM on Input.next() instead; use it from contexts where
doterm can’t dispatch back into the App, e.g. the Wren console
(whose REPL itself runs inside a Hook.onKey).
runSync() dispatches each key or mouse event in a child fiber. A
runtime abort from a synchronous handler therefore does not unwind the
App’s event loop. By default the App writes the error and trace to the
current VM’s console log; set app.onError = Fn.new {|failedFiber| … }
to replace that reporting. Layout and painting remain outside this
boundary so a deterministic redraw failure is not retried forever.
Key.quit is reserved for the host’s sticky process-close request. App
claims it before ordinary widget or global-keymap dispatch. With a modal
open, the App sends that modal an Escape for normal cleanup and then removes
it if it did not close itself; subsequent pump iterations unwind the
remaining Wren call stack. With no modal, the App stops. A widget whose
atExit property is true is the explicit exception: it remains interactive
while the quit latch is set, and modals it opens inherit atExit. Use this
only for work that must finish or be decided during shutdown, such as a save
confirmation. The connected-session host handles a real process-close
request before dispatching to the connected VM, so remotely supplied scripts
cannot use atExit to delay termination.
Importing
Every public class is exported from individual ui_*.wren modules
and re-exported from the convenience aggregator ui:
import "ui" for App, Pane, ListView, TextInput, SelectOnFocusInput, Button,
Checkbox, RadioGroup, SpinBox, MenuBar, StatusBar,
Form, Alert, Confirm, Prompt, Find, Help, PopStatus,
Painter, Style, Theme, Glyphs,
Widget, Container, Rect
If you only need a couple of classes, the per-topic modules cut
load cost: import "ui_pane" for Pane, import "ui_popup" for
Confirm, etc. They’re listed in UI Modules.
App
App is the root of a UI session. It owns:
-
root— aContainerthat hosts the foreground widget tree. -
a modal stack — synchronous dialogs push themselves onto it via
app.modal(widget), which blocks until the widget pops itself (typically from its owndismissWith_(value)helper). -
a global keymap —
app.bind(keyCode, fn)registers a hotkey that fires when no widget consumes the key. F1 is bound by default toshowHelp. -
a Theme initialized from
Theme.current, which decodes the C-owned active theme and refreshes its per-VM cache whenHost.themeGenerationchanges. The menu, picker, and connected VMs therefore use the same selected theme. Assignapp.themeonly when the App intentionally needs a different per-App theme. Assignment invalidates the complete root, transient-status, and modal widget trees so every mounted widget repaints on the next draw. -
a single run-fiber that pumps the event loop.
run()pushes an input claim (see Modal Input) whose handler decides synchronously, from App state alone, whether each event is "for the App" — modal up consumes everything; with a focused widget, keys are claimed and mouse events are claimed when they hit the visible widget tree (or start a drag, which the App owns for rectangular select); otherwise the event falls through to lower claims and then to registeredHook.onKey/Hook.onMouse. Claimed events are delivered to the run-fiber viaWake.post; the nextdrainOnce_iteration picks them up and dispatches into the widget tree. -
a backbuffer
Surfacesized to the term area (Screen.sizeminus one row whenCTerm.statusDisplay > 0, so the App’s blit doesn’t overwrite the SyncTERM status bar) and a one-shot screen capture (Screen.readRect) used as the backdrop behind everything. Areas not covered by widgets show that capture rather than a styled fill.
Methods of interest:
-
run()/runSync()— enter the event loop. Save/restore mouse events andCustomCursorautomatically.tickMs=schedules a periodiconTick_callback (overridable; no-op by default). Only one timer is queued at a time per App: the_tickPendingguard means non-tick events (keys, mouse, external posts) don’t accumulate timers behind the pending one.runSync()contains runtime errors raised by key and mouse handlers as described above. -
onError=(fn)— replacerunSync()’s default reporting for a failed event handler. The callback receives the failed `Fiber, whoseerrorandstackTracedescribe the abort. -
quit()— break out of the loop. Widgets and global handlers call this from their key/mouse handlers. -
modal(widget)— push a modal, drain events until it pops, return the widget so the caller can read itsresult. A modal whoseclosesOnOutsideClick(event)method accepts a mouse event receives an Escape when that event occurs outside its bounds. List-style popups accept button 1 and right-click; Help accepts any ordinary mouse-button click. Text prompts and specialized editors reject outside clicks. -
popStatus(message)— show or clear a transient centered overlay that does not intercept input. Useful for "Working…" status while blocking onInput.nextor a long Wren computation. The overlay sits above the foreground widget tree but below the modal stack, so a dialog the user is actively working with isn’t obscured by an indicator behind it. -
releaseFocus()/restoreFocus()— wrap a picker call, where another VM temporarily owns the screen, so the App’s foreground tree doesn’t also draw as focused. Without this, the picker host’s pre-call screen save captures the App’s panes in their focused colour scheme, and the post-call restore re-paints them that way until the next App-driven repaint — visually, two widgets are focused at once. Operates onmodalTop(so a release while a modal is up defocuses the modal’s leaf, not the root’s) and forces an immediate paint so the unfocused state lands on screen before the host UI saves it. Both are idempotent: a secondreleaseFocusbeforerestoreFocusis a no-op, andrestoreFocuswithout a prior release is a no-op.releaseFocus() var picked = Host.pickFiles(Host.uploadPath, "*", 0) restoreFocus() -
showHelp()— walks from the focused leaf up the parent chain to the first widget withhelpTextset, then opens aHelpdialog with that text. No-op if nothing in the chain has help. -
post()/post(value)— wake the App’s run-fiber from outside the input / timer paths. See "Externally-driven repaint" below. -
onPost=(fn)— handler for posted values that aren’t the no-payload sentinel. See below. -
runChild(fn)— spawnfnin a fresh fiber, pump the App’s event loop from the run-fiber until the child completes, return whateverfnreturned. See "Async work in handlers" below. -
onLayout=(fn)— register a callback receiving(width, height)on the first paint and whenever the effective App area changes size. The root Container is resized before the callback runs, allowing the callback to assign stable responsive bounds to its children. -
onUnhandledMouse=(fn)— register a callback receiving(event, hit)after a non-modal mouse event is declined by the hit widget.hitis the declined Widget, ornullwhen the event is outside the root. Returningtrueconsumes the event; returningfalseleaves normal App fallback handling, including right-click Escape and drag selection, in place. The callback is not invoked while a modal owns input. -
theme=— install a custom Theme (see Theme).
Async work in handlers
runChild is the App-context provider for the single-source fiber
that the ||-yield SFTP idiom (see Async Ops) requires.
A keymap or widget handler that needs to call SFTP.<op> can’t do
it directly: the App’s run-fiber has multiple resume sources (the
claim handler’s Wake.post for keys/mouse, the tickMs timer,
external app.post(value) calls), and any of them might wake the
fiber before the SFTP result arrives. The yield then returns the
wrong type and the calling code sees garbage.
runChild(fn) spawns a child fiber whose only resume sources are
its own SFTP / Wake.post completions:
fetchDescAndShow_(path) {
popStatus("Fetching ...")
var d = runChild(Fn.new {
return SFTP.descs(Fiber.current, path) || Fiber.yield()
})
popStatus(null)
if (d is SFTPError) Alert.show(this, "Failed: " + d.toString)
else Alert.showPreformatted(this, d)
}
The child fiber yields on SFTP.descs; the run-fiber drops into
its own drainOnce_ loop, dispatching keys, mouse, ticks, and
repaints normally while the child is parked. When the SFTP
result wakes the child, its body completes and runChild
returns the value. Modal calls (Alert, Confirm) belong on the
run-fiber side after runChild returns — modals pump
drainOnce_ from whichever fiber called them, and only the
run-fiber receives wakes from the App’s claim handler.
Externally-driven repaint
The async run() parks the run-fiber on Fiber.yield() between
events. A network-driven app (chat client, ticker, log viewer fed
by remote bytes) needs a way for Hook.onInput to nudge the UI
without faking a key press. That’s what post is for:
import "syncterm" for Hook
import "ui_app" for App
var app = App.new()
// ... build widget tree ...
Hook.onInput { |b|
buffer.add(b) // mutate state
someWidget.markDirty() // mark UI as needing repaint
app.post() // wake the App so drainOnce_ runs again
return false
}
app.run()
app.post() calls Wake.post(runFiber, WakeOnly.instance) — a
no-payload sentinel. The next main-loop drain delivers it,
drawAll runs at the top of drainOnce_, and the dirty widget
repaints. The hook returns synchronously — post only
enqueues, satisfying the hooks-must-run-synchronously rule.
app.post(value) carries an arbitrary Wren value. When a non-
sentinel value arrives, onPost (if set) is called with it after
the redraw. Pass anything you can recognise:
app.onPost = Fn.new {|v|
if (v is String && v == "irc.PRIVMSG") chatPane.scrollToBottom()
}
Hook.onInput { |b|
// ...parse, accumulate...
if (gotPrivmsg) app.post("irc.PRIVMSG")
return false
}
post is a no-op when the App isn’t running (no run-fiber).
It’s not supported under runSync — the synchronous variant
blocks on Input.next() at the C level, not on Fiber.yield,
so a wake through the result queue won’t reach it. Use run()
for any app that needs external wake-ups.
App is not itself a Widget but exposes effectiveTheme and
markDirty() so widget tree-walks can terminate at it. Setting a
widget’s parent to an App is supported and is what pushModal
does internally.
Widget
Base class for everything paintable. Holds:
-
bounds— aRectin 1-based screen coords. -
parent— pointer to the containingContainerorApp. -
theme=— a per-widget Theme override; otherwise inherited viaeffectiveThemewalking the parent chain. -
focused,visible,focusable,activitySensitive,dirty— boolean state flags. SettingactivitySensitivetofalsemakes ambient screen chrome use its base theme roles regardless of modal activity; it does not affect focus or input dispatch. -
atExit—falseby default. Whentrue, this widget remains interactive after a sticky process-close request. Modals pushed while it is active inherit the property. Ordinary widgets are unwound through Escape and cannot consumeKey.quitthemselves. -
helpText=— string surfaced byApp.showHelp(F1). -
closesOnOutsideClick(event)— returns whether the App should send Escape to this widget wheneventoccurs outside its modal bounds. The base implementation returnsfalse. -
shadow=— when true, the parent paints a drop shadow on the cells immediately right and below the widget’s bounds. -
surface— the widget’s private Surface backbuffer, allocated lazily and resized whenboundschanges. -
preferredWidth,preferredHeight— auto-layout sizing hints. Defaultnull(no preference, fill whatever bounds the parent gives). Widgets with a natural minimum size (ListViewwith N items, aFormwith measured rows, …) override these so containers that know how to lay out children (Pane.fitContent) can size themselves around the content.
Layer-aware painting: every widget tracks the App’s active layer
state (whether it or an ancestor is the modal top of stack) and
repaints itself when the cached state no longer matches. No
tree-wide dirty pass is needed when modals push or pop — each
widget notices on its next draw() and repaints with the inactive
theme variant. Subclasses override onPaint_() to draw into
surface; the base Widget.draw() calls onPaint_(), clears the
dirty flag, and returns the surface.
Hardware cursor: cursorPos returns [x, y] in 1-based screen
coords (or null); cursorVisible returns whether the cursor
should be shown while focused; cursorShape optionally returns
"normal" or "solid"; and cursorAttr optionally returns the
legacy text attribute whose foreground colors the cursor. App
reads all four off the focused leaf each frame and applies them. A
visible null shape defaults to "normal"; a null attribute leaves
the current conio cursor color unchanged. Containers forward the
focused child’s values.
tryHotkey(ev) is a parent-driven hotkey hook. Container.handle
scans its children with this when a printable key fell through the
focused child, so a typed letter can activate a button advertising
that letter even when focus is elsewhere.
Container
Widget subclass that manages a child list, a focused-child index,
and event dispatch. add(child) / remove(child) mutate the tree;
the first focusable child added becomes focused automatically.
Focus traversal:
-
Tab/Right—focusNext(), ordered ring with wrap. -
BackTab/Left—focusPrev(). -
Up/Down— spatial nearest-focusable scan. Picks the focusable child whose centre is above / below the current focus’s centre and minimises Manhattan distance. Ties (children on the same row) go to the lower-indexed sibling — i.e. tab order.
Spatial Up/Down does not wrap; it falls back to a hotkey scan if
nothing matches. Widgets that need arrows for their own semantics
(TextInput consumes Left/Right, ListView consumes Up/Down) catch
the keys before they bubble.
Mouse: Container.hitTest(px, py) walks children top-to-bottom
(last-added wins) and returns the deepest visible widget covering
the point, or the Container itself when nothing under it does.
focusedIndex is readable as a property and writable as a setter
for explicit focus management — e.g. the App-level
releaseFocus() / restoreFocus() pair clears and restores it
around blocking host UIs. The getter returns null when no
child is focused, otherwise an integer in 0..count - 1. The
setter toggles .focused on the previously- and newly-focused
child; pass null to clear focus. Direct day-to-day focus
changes still go through focusNext / focusPrev etc., not this
setter.
Pane
Container with a frame, optional title, [?] and [X] corner
buttons, and an inset interior. The default is a control-bearing pane:
the stronger double-line frame, title in its own bar row with a horizontal
separator underneath, and both corner buttons enabled.
Configuration knobs:
-
frameKind=—"control"(the default) or"display". A control frame marks a pane that contains choices, buttons, or editable input and uses the stronger double-line family. A display frame marks informational content and uses the single-line family. Assigning any other value aborts the calling fiber. -
titleAsBar=— when true, the title sits inside the frame in its own row with a horizontal separator underneath. When false, the title is embedded in the top border (-| Title |-). Classic list views use the bar form;Popupsubclasses use the embedded form. -
helpable=/closeable=— show or hide the[?]and[X]corner buttons. Help button callsonHelpif set, elseApp.showHelp(). Close button callsonCloseif set. -
onHelp=,onClose=— function callbacks for the corner buttons.
pane.innerBounds returns the drawable interior Rect after the
frame, title row, and separator are accounted for — children should
size themselves against this.
pane.contentBounds is innerBounds inset by one cell on every
side so text never touches the frame.
Popup-style widgets (Alert / Confirm / Prompt / PopStatus /
Help) lay themselves out inside contentBounds; bare Pane use
sites that want the full frame interior (e.g. a ListView filling
its parent pane) keep using innerBounds.
Pane.focused is a visual flag — set it from the App or a parent
container when the pane is the foreground. It does NOT redirect
keyboard focus; that still flows through `Container’s normal
focused-child routing to a leaf widget inside.
Auto-layout
Pane.fitContent() sizes the pane around its single child’s
preferredWidth / preferredHeight, plus whatever the title bar
and corner buttons require. It repeats measurement after assigning
the child’s bounds, so preferences that depend on the resulting
viewport, such as a ListView scrollbar, settle in one call. The
width is the largest of:
-
the child’s
preferredWidth + 2(left + right border), -
the title’s outer-width demand (see below), and
-
the corner buttons' outer-width demand (each visible button contributes 3 cells; total + 2 corners).
The two title modes have different geometry:
-
titleAsBar— title is centered on its own row inside the frame; outer width =title + 4(frame(2) + 1-cell padding on each side so the title never butts up against the side frame). -
frameTitle(titleAsBar = false) — title sits in the top frame border between─ ` and ` ─brackets; outer width =title + 6(corners(2) + brackets(2) + spaces(2)).
Multi-child panes are not auto-sized — fitContent only inspects
the first child. Lay them out manually via bounds / add.
Pane.fitContent(maximumWidth, maximumHeight) performs the same
layout while constraining the complete pane to the supplied maximum
dimensions. Pane.fitContentToScreen() applies the standard modal
limits, leaving room for the two-column right shadow and one-row
bottom shadow, and centers the result. For a pane with a requested
fixed size rather than a child-defined size,
Pane.fitToScreen(width, height) applies those same limits and
centering.
Pane.centerOnScreen() repositions the pane so its bounds are
centered on the current Screen.size, then re-anchors its single
child into the new innerBounds. Use fitContentToScreen() for the
usual constrained and centered modal layout:
var pane = Pane.new()
pane.title = "ANSI Music Setup"
pane.add(list)
pane.fitContentToScreen()
ListView
Scrollable list of items with a selection cursor and an optional
scrollbar. Items can be anything; rendering goes through
formatItem(item, width) which subclasses can override for rich
per-row formatting. Default is item.toString.
Setup:
var list = ListView.new()
list.bounds = Rect.new(x, y, w, h)
list.items = ["alpha", "beta", "gamma"]
list.onChange = Fn.new {|i, item| /* selection moved */ }
list.onSelect = Fn.new {|i, item| /* ... */ }
Navigation: Up, Down, PageUp, PageDown, Home, End move
the selection. Enter fires onSelect with (index, item).
Mouse: a single click on a row both selects it and fires onSelect;
each wheel notch over the list content moves the selection one row using
the same wrapping rules as Up and Down. Over the scrollbar, a wheel notch
moves one page. The scrollbar’s top and bottom arrow cells invoke PageUp
and PageDown; track clicks jump proportionally while keeping the lightbar
on the same screen row. The separator column between content and scrollbar
is inert. Button-1 drags fall through to screen selection.
wrap= controls single-row Up/Down movement and defaults to true:
Up at the first row wraps to the last, and Down at the last wraps to
the first. Set it to false to clamp at the ends. PageUp/PageDown,
Home/End, and direct selected= assignments do not wrap.
onChange is independent of activation. It fires with (index, item)
whenever navigation, assignment, search, or a row click changes the
current row. Replacing items also fires it so a detail pane can
refresh even when the selected numeric index remains zero. An empty
list reports (null, null).
items= preserves the selected index and scrollTop when replacing a
populated list, clamping them only when the replacement is shorter. This
allows a menu to rebuild changed row labels without moving its viewport.
resetItems(list) replaces the collection and explicitly selects its
first row at scrollTop=0; use it when navigating to a different
collection rather than redrawing the current one.
selected is null when no row is selected (empty list, or
explicitly cleared with list.selected = null); otherwise an
integer in 0..count - 1. selectedItem mirrors the same
contract — null when nothing is selected, the item otherwise.
When a selection is assigned before the list has bounds, first layout
centres an offscreen selected row where possible. A selection already in
the initial viewport leaves that viewport at the top. Normal navigation
scrolls only far enough to keep the moving selection visible.
The selected row is highlighted even when the list does not have focus.
Set highlightWhenUnfocused=false in a multi-list control when only the
list receiving keyboard input should display its selection lightbar. This
does not apply the inactive palette to the other list.
Search
Two complementary search modes, both case-insensitive (ASCII fold) and wrapping at the end of the list:
-
Type-to-search — any printable character grows a rolling buffer and jumps to the first item whose search text starts with it. If the new buffer doesn’t match anywhere, falls back to just the new character (fresh prefix from current+1). Buffer resets on any navigation or activation key, so a paused-then-resumed search starts clean.
-
Ctrl-F / Ctrl-G — Ctrl-F opens an inline
Finddialog (see the Popup family) for a substring query, jumps to the first match. Ctrl-G repeats the last query.
The hook searchTextFor_(item) returns the string to match against;
defaults to formatItem(item, 1024) (the normal display string).
Override in subclasses when the displayed row decorates the underlying
text — the SFTP browser’s BrowserListView, for example, returns
item.name so users type bare filenames rather than the chip-prefixed
[==] foo.dat line.
Tag mode
selectionMode= accepts "single" (default) or "tag". In tag mode
each row tracks an independent tag flag, toggled by Space on the
current row; tagged returns the indices that are flagged on. A
1-cell marker column appears at the leftmost content position using
the theme’s tag.on / tag.off glyphs (» / blank by default;
> / blank in ASCII-only mode). Switching modes resets the flags;
so does setting items=.
Scrollbar layout knobs:
-
scrollbarSide=—"right"(default) or"left". -
scrollbarSeparator=— when true, paints a|divider between the scrollbar column and the content area. -
showScroll=— set to false to disable the scrollbar entirely.
The scrollbar is only painted when items.count > bounds.h.
ListView always reserves a 1-cell padding between the frame and
the items on the side that doesn’t have a scrollbar (both sides
when no scrollbar is shown at all), so item text never butts up
against the frame the parent Pane draws. innerWidth is
bounds.w - leftInset - rightInset accordingly.
Auto-layout: preferredWidth returns the longest item’s display
length plus the active insets (left + right padding, or scrollbar
+ padding when overflow is expected); preferredHeight returns
items.count, clamped to at least one row. The parent layout owns
screen and container constraints. Combined with
Pane.fitContentToScreen, a list-in-pane modal sizes itself to its
contents:
import "ui_pane" for Pane
import "ui_list" for ListView
var list = ListView.new()
list.items = Host.musicNames
var pane = Pane.new()
pane.title = "ANSI Music Setup"
pane.add(list)
pane.fitContentToScreen() // sized to list, constrained, and centered
TextInput
Single-line text edit field. Stores the value as a list of codepoint strings so the cursor indexes by codepoint and emoji / multibyte characters take one cell per codepoint.
var t = TextInput.new()
t.bounds = Rect.new(x, y, w, 1)
t.value = "hello"
t.maxLen = 64 // optional cap
t.mask = "*" // optional display-only mask
t.onSubmit = Fn.new {|s| /* Enter */ }
t.onChange = Fn.new {|s| /* per-keystroke */ }
t.selectAll() // optional replace-all entry state
Keys use SyncTERM’s standard line-editor behavior. Left, Right, Home, End,
Backspace, and Delete / DelChar move and delete; Ctrl-B and Ctrl-E
alias Home and End, and Ctrl-Y truncates at the cursor. Insert toggles
insert/overwrite mode. Ctrl-C / Ctrl-Insert copy the whole field,
Ctrl-X / Shift-Delete cut it, and Ctrl-V / Shift-Insert paste at the cursor.
Ctrl-Z opens the focused field’s contextual help when one is available.
Enter fires onSubmit; printable codepoints edit at the cursor. Tab,
BackTab, Up, and Down are NOT consumed so containers can use them for
focus traversal. A left click positions the cursor; middle- and right-click
paste there. An unconsumed right-click elsewhere is Escape. Drag events
fall through to the App’s screen-selection handler.
selectAll() puts a non-empty value into a transient whole-field selection
state, reported by the read-only allSelected getter. The next printable,
keyboard paste, Backspace, Delete, or Ctrl-Y operation replaces the complete
value. Cursor movement and mouse positioning preserve the value and dismiss
the selection before ordinary editing continues; other non-editing key events
also dismiss it without changing the value. Assigning value clears the
state, so callers opt into it explicitly after assignment when needed.
SelectOnFocusInput is the existing-value editing variant used by
SyncTERM prompts:
prompts and inline editors. It calls selectAll() whenever it gains focus,
clears the selection on focus loss, highlights only the selected text, and
uses the surrounding default style after cursor movement begins ordinary
editing. Use TextInput when focus should leave an assigned value ready for
appending; use SelectOnFocusInput when typing should replace that value.
insertMode reports whether printable input inserts (true, the initial
mode) or overwrites existing characters (false). The mode is shared by
all TextInput instances, so toggling it in one field applies to the next
field. cursorShape is "normal" in insert mode and "solid" in overwrite
mode, matching SyncTERM’s insert/overwrite cursor convention.
cursorAttr supplies
the focused input style’s legacy attribute so bitmap backends render the
cursor in the field foreground instead of an unrelated global color. Pasted
control characters are discarded, matching typed-input filtering, and
maxLen applies to pasted text.
mask= accepts null or a non-empty String and uses its first
codepoint when painting every stored character. It does not change
value, cursor movement, length, or callback arguments, making it
suitable for password and system-password fields.
Assigning or resizing bounds recalculates the horizontal viewport.
An initial value wider than the field displays its end followed by a
cell for the insertion cursor, regardless of whether value or
bounds was assigned first. scrollOff reports the first displayed
codepoint index.
cursorVisible returns true and cursorPos reports the cell that
corresponds to the current insertion point in screen coords —
backends that show the hardware cursor track the input as the user
types.
Button
Single-row "[ label ]" widget. Activates on Enter, Space, or
mouse click within bounds. onPress= sets the activation
callback. intrinsicWidth returns label.count + 4 for sizing
parents.
var b = Button.new("OK")
b.bounds = Rect.new(x, y, b.intrinsicWidth, 1)
b.onPress = Fn.new { app.popModal() }
hotkeyIdx= selects which label letter to highlight (default 0,
the first letter). tryHotkey matches that letter case-
insensitively against typed input, so a button labeled "Yes" with
hotkeyIdx = 0 activates on Y from anywhere in the same
container — even when focus is on a sibling.
Checkbox
Single-row "[X] Label" toggle. value is a Bool, label is a
String. Space or Enter flips the value when focused; mouse
click anywhere on the widget toggles too.
var c = Checkbox.new("Show shadows")
c.bounds = Rect.new(x, y, c.intrinsicWidth, 1)
c.value = true
c.onChange = Fn.new {|v| /* ... */ }
onChange fires only when the value actually changes — assigning
the current value is a no-op. Theme roles: checkbox,
checkbox.focused. Glyphs reused: check.on (√ U+221A), check.off
(space).
RadioGroup
Multi-row mutually-exclusive selector. Owns its own item list and selected index — the whole group is a single focusable widget.
var g = RadioGroup.new()
g.bounds = Rect.new(x, y, w, h)
g.items = ["None", "SSL", "SSH"]
g.selected = 0
g.onChange = Fn.new {|i, item| /* ... */ }
Two pointers: cursor (visually highlighted row) and selected
(the committed value). Up/Down move the cursor; Home / End
jump to ends; Space or Enter commits the cursor as the
selection. Mouse click selects directly. onChange fires only
when the selection actually changes. Both pointers are null
when the group is empty (or explicitly cleared by assigning
null); otherwise integers in 0..count - 1. The cursor highlight only
appears while the widget itself has focus — leaving the group
shows just the filled • glyph on the selected row, so a
multi-field Form doesn’t confuse the user with a stale lightbar.
wrap= controls the edge behavior of Up/Down. Default true:
Up at the top wraps to the bottom and vice versa; every press is
consumed. When false, Up at the top and Down at the bottom
return false instead, letting the parent Container move focus to
the previous / next sibling. Form.addField flips this to false
on RadioGroup children so vertical traversal escapes the group at
its edges.
Theme roles: radio.item, radio.item.focused. Glyphs reused:
radio.on (• U+2022), radio.off (○ U+25CB).
SpinBox
Single-row numeric input with up/down step buttons. Layout:
[ 42 ▲▼].
var s = SpinBox.new()
s.bounds = Rect.new(x, y, w, 1)
s.min = 0
s.max = 100
s.step = 1
s.value = 50
s.onChange = Fn.new {|v| /* ... */ }
Keys: Up / + adds step; Down / - subtracts; PageUp /
PageDown move ten steps; Home / End jump to min / max.
Mouse wheel scrolls one step; clicks on the up/down arrow cells
step ±1. value= clamps to [min, max]. Theme roles: spinbox,
spinbox.focused. Glyphs reused: scrollbar.up, scrollbar.down.
MenuBar
Horizontal strip of activatable items, typically at the top of the
screen. Each item is a [label, Fn] pair; activation calls the
Fn. No nested submenus in v1 — pop a Popup from the callback if
you want one.
var bar = MenuBar.new()
bar.bounds = Rect.new(1, 1, sz[0], 1)
bar.items = [
["File", Fn.new { Alert.show(app, "...") }],
["Edit", Fn.new { /* ... */ }],
["Help", Fn.new { app.showHelp() }],
]
Keys: Left / Right move focus between items with wrap; Home /
End jump to ends; Enter / Space activate the focused item; a
typed letter (case-insensitive ASCII) matched against item labels'
first characters activates that item. focusedItem is null
when the bar is empty (or explicitly cleared); otherwise an
integer in 0..count - 1. tryHotkey(ev) is exposed
on the widget so a Container parent can route a letter pressed
elsewhere into the bar’s matcher. Mouse click activates the clicked
item; clicks on inter-item gaps are dropped. Theme roles:
menubar, menubar.item, menubar.item.focused.
StatusBar
Single-row strip used at the bottom (or top) of the screen for status text and key hints. Not focusable.
var s = StatusBar.new()
s.bounds = Rect.new(1, sz[1], sz[0], 1)
s.text = "F1 Help Esc Quit"
// or — multi-segment:
s.segments = [
["F1 Help", "left"],
["Connected", "center"],
["09:42", "right"],
]
text= sets a single left-aligned string. segments= takes a list
of [text, align] pairs where align is "left", "center", or
"right". Left segments stack from the left edge with a 2-cell
gap; right segments flush to the right edge inward; one center
segment is centred in the remaining space. Setting either form
overrides the other. Theme role: statusbar (black on cyan).
Form
Container subclass that lays out (label, widget) pairs in a
vertical stack with right-aligned labels in a column on the left.
Optional OK / Cancel buttons appear on the row below the last field
when onSubmit / onCancel are wired.
var f = Form.new()
f.bounds = pane.innerBounds
f.addField("Name:", nameInput)
f.addField("Port:", portSpin)
f.addFieldH("Encryption:", encRadio, 3) // 3 rows tall
f.addField("", autoCheckbox) // no label
f.onSubmit = Fn.new { /* read field values */ }
f.onCancel = Fn.new { app.quit() }
pane.add(f)
-
addField(label, widget)— queues a one-row field. -
addFieldH(label, widget, h)— queues a multi-row field (e.g. RadioGroup). -
clearFields()— drops every queued field and removes the widgets as children. -
rowGap=— blank rows between fields. Default 0. -
rowH=— default per-field height. Default 1.
The label column width auto-sizes to the longest label; widgets
start one column past the longest label plus a 2-cell gap. Tab /
BackTab traversal hits widgets in declaration order, then OK, then
Cancel. Esc fires onCancel if wired.
onSubmit= / onCancel= are idempotent setters — assigning the
same callback twice doesn’t double-add the OK / Cancel button, and
assigning null removes the button.
Popup family
Modal dialogs built on Pane (control frame, embedded title, drop
shadow) and pushed onto the App’s modal stack. Each show is
synchronous: the App’s modal() pumps drain until the popup pops itself,
then control returns with the result. Help is the informational
exception and selects the display frame; transient PopStatus uses the
display frame as well.
import "ui" for Alert, Confirm, Prompt, LinePrompt
Alert.show(app, "Disk full")
Alert.show(app, "Warning", "Disk full")
Alert.show(app, "Warning", "Disk full", "# Disk Full\n\nFree space first.")
if (Confirm.show(app, "Delete file?", "# Delete\n\nThis cannot be undone.")) {
// ...
}
var name = Prompt.show(app, "Your name?", "anonymous")
if (name != null) {
// user submitted
}
var path = LinePrompt.show(app, "Path", "/tmp")
Alert — single OK button. Enter, Space, O, Esc, or a mouse click
on OK dismisses it; unrelated keys do not. show returns null.
Confirm — Yes / No buttons. Y / N letter shortcuts dismiss
directly; Enter activates the focused button; Tab cycles focus.
show returns true on Yes, false on No or Esc.
The four-argument titled Alert.show(app, title, message, helpText)
and three-argument Confirm.show(app, message, helpText) forms attach
Markdown context help to the popup. F1 and Ctrl-Z display that text
while the popup owns focus.
Prompt — SelectOnFocusInput plus OK / Cancel buttons. A non-empty
initial value starts selected: typing, keyboard paste,
Backspace, or Delete replaces it, while cursor movement or mouse positioning
preserves it and begins ordinary editing. Enter in the input or on OK
submits the value; Esc or Cancel returns null. The read-only input
getter exposes that input so trusted UI code can set maxLen or a display-only
mask before presenting the popup. sizeForInput(inputWidth) and
sizeForInput(inputWidth, minimumWidth) size the field for the requested
capacity and cap the complete popup to the standard screen margins. The
MenuUi.prompt wrappers use the field’s maxLen for this width.
Short fields retain the normal minimum while URI, path, password,
and other long fields use the available screen width.
LinePrompt — compact UIFC-style single-row entry. Its label appears
to the left of the input on the only interior row; there is no OK / Cancel
button row. Enter submits and Esc returns null. The read-only input
getter and sizeForInput methods have the same purpose as Prompt’s. A
distinct optional title may be placed in the top frame, but menu prompts
omit it when it would merely repeat the field label. MenuUi.prompt uses
this form for ordinary menu editing and application filename entry; host
prompts retain Prompt because their explanatory message is separate from
the dialog title. LinePrompt.runStandalone(app, label, initial) is the
standalone equivalent.
Find — compact single-row variant of Prompt. Title sits in the
top frame border, the input fills the entire innerBounds row (no
padding, no buttons, no [X]). Total height is 3 cells. Its previous query
starts selected under the same replacement rules as Prompt. Used by
`ListView’s Ctrl-F.
import "ui" for Find
var q = Find.show(app, "Find", lastQuery)
if (q != null && q.count > 0) {
// ...
}
Layout
All popups use the standard frame interior padding via
Pane.contentBounds — a 1-cell pad on every side so text never
touches the frame. Buttons (Alert OK, Confirm Yes/No, Prompt
OK/Cancel) anchor hard against the bottom frame; the empty row
between the message and the button row falls out of the height
math as a natural visual separator.
Popup.centeredBounds_(message, extraRows, minW) sizes the dialog
around the wrapped message: width is longest-line + 4 (frame
padding), height is 4 + msgRows + extraRows (2 frame + 2 padding
content). Subclasses pass extraRows for whatever they paint
below the message — 1 for Alert/Confirm (button row), 2 for
Prompt (input + button rows).
PopStatus is the non-modal companion: a centered frame with a
message and no buttons, used by App.popStatus(message). It does
not push onto the modal stack — purely decorative — and is
dismissed with app.popStatus(null).
Standalone popups
Confirm.show(app, …) (and the other Popup.show family methods)
expect app to already be running — they call app.modal(p), which
pumps drainOnce_() on the existing run loop’s input claim. When
you need a popup from a context with no enclosing app — a
Hook.onKey handler being the canonical case — use the standalone
variants, which push the popup, wire onDismiss to app.quit(),
and run the App so key events are routed:
var app = App.new()
if (Confirm.runStandalone(app, "Disconnect... Are you sure?")) {
// user clicked Yes
}
Standalone helpers are available as Alert.runStandalone(app, msg),
Alert.runStandalone(app, title, msg),
Confirm.runStandalone(app, msg), and
Prompt.runStandalone(app, msg, initial).
For custom popup drivers, onDismiss=(fn) fires from dismissWith_
after the popup pops itself off the modal stack, with the dismissed
value (Bool / String / null depending on the popup subclass). If you
call app.run() yourself, wire onDismiss to app.quit(); otherwise
app.run() would loop forever on an empty modal stack.
Help viewer
A modal scrollable text viewer for context help. The body is parsed once as a markdown subset (see "Markdown subset" below) and laid out lazily — text wraps to the dialog width and reflows on resize.
import "ui" for Help
Help.show(app, "Help — Editor",
"# Editor\n\n" +
"- `Up`/`Down` scroll\n" +
"- `Enter` close\n")
Keys: Up, Down, PageUp, PageDown, Home, End scroll;
any other key dismisses. Wheel scrolls one line over the text and one
page over the scrollbar. Scrollbar clicks jump proportionally; button-1
drags fall through to screen text selection. An ordinary mouse-button
click also dismisses, including a click outside the dialog. Same scrollbar
knobs as ListView (scrollbarSide=, scrollbarSeparator=). Clicking
the frame’s [X] corner button dismisses (inherited from Pane).
App.showHelp (F1 by default) walks from the focused leaf up the
parent chain looking for a widget with helpText set, then calls
Help.show with the title "Help" and that text. Set helpText
on whichever widget is the most-specific scope you want help to
fall back to.
Prompts and other modal widgets can also carry helpText. Because the
focused input or list is their child, the same parent walk finds the modal’s
field-specific help before any help attached to the underlying screen.
Default placement: Help.centeredBounds_() claims the full cterm
height minus the status row (y=1, h=screenHeight-1) and a
2-cell horizontal margin (w=screenWidth-4). Pass an explicit
Rect via the bounds= setter after construction to override.
Padding: 1-cell margin on every side except the scrollbar column, where text is flush. The scrollbar defaults to the right, and its column plus optional separator span the full inner height (between the top and bottom frame chars); only text rows inset by the top/bottom padding.
Markdown subset
Help body strings (and any helpText) are parsed as a small
CommonMark + Pandoc subset by ui_markdown.wren. Supported syntax:
-
Block:
# / / #headings;- ` or `* ` bullets; blank-line- separated paragraphs; Pandoc-style definition lists (term line followed by one or more `: descriptionlines). -
Inline:
bold,. Backslash escapes a literalcode*,`, or\. -
Hard line break: a line ending in two trailing spaces forces a break at that point inside a paragraph or bullet. Continuation uses the same wrap indent as a soft wrap (so a bullet’s hard- break still hangs under the text).
-
Italics are intentionally omitted — terminal italic support varies and dim grey on dark blue is hard to read.
Each block-level token resolves to a style role (help.heading.1,
help.bold, help.code, help.bullet, etc.) that cascades through
help and finally default, so themes can restyle without touching
the parser. Unknown markup falls through as literal text.
Definition lists pad each term to a common column width — capped at
half the dialog — and wrap descriptions inside the description
column, with continuation lines indented to the description start.
Term and description default to help.bold and help.text
respectively.
Headings render as a single-row reverse-video bar: blue-on-cyan
text with one styled space cell on each end (" Title ").
Layout is reflow-on-width: Markdown.layout(doc, cols) returns a
list of MdLine`s, each carrying pre-resolved style runs ready for
`Painter.text. Help re-runs layout whenever its bounds change
(open, resize, scrollbar appearing/disappearing) and clamps the
scroll position accordingly.
Example combining heading + def-list:
# Capture Type
ASCII
: Plain text, no escape sequences
Raw
: Preserves ANSI escape sequences
Binary
: Saves the current screen as IBM-CGA / BinaryText
Theme, Style, Glyphs
A Theme bundles a role → Style map and a Glyphs (name →
glyph) lookup. Style is a four-field tuple (font, legacyAttr,
fgRgb, bgRgb); any field may be null to inherit from a parent
role. Painting picks one of the two color paths based on what the
backend supports — RGB on capable terminals, legacy attr otherwise.
Role cascade: Theme.style(role) walks dotted role suffixes,
merging partial Styles at each level until every field is
populated. "list.item.focused" falls back to "list.item" falls
back to "default". "default" is the cascade terminator and
must be a complete Style.
Inactive cascade: when a role ends in .inactive, the Theme walks
a parallel chain — X.inactive, then parent(X).inactive, …,
finally default.inactive. Falls through to default if the
theme has no inactive variants at all. Widget.style(role)
auto-suffixes .inactive whenever inActiveLayer is false, so a
single layer-state flag cascades through frame, title, list rows,
input field, button labels, and scrollbar without per-widget
bookkeeping.
A widget lands in the inactive layer for either of two reasons:
-
It isn’t part of the App’s modal-top subtree (a modal is on top, and the widget is in the layer behind it).
-
An ancestor that gates focus visibility has
focused == false.Widget.gatesActiveLayerdefaults tofalse;Paneoverrides it totrue, so an unfocused Pane in a multi-pane layout dims every cell it owns AND every descendant’s surface — frame, title, bg fill, list rows all agree on active-vs-inactive without each subclass having to know.
activitySensitive=false opts a widget out of this style selection.
It continues to report its computed inActiveLayer value, but
style(role) always resolves the base role. This is intended for
ambient screen backgrounds, title bars, and status/help bars which do
not have active and inactive forms. Do not use focusable=false for
this purpose: display-only children such as progress bars still need to
inherit an inactive containing Pane.
Theme.default is the C-owned compiled fallback decoded into Wren and
memoized so
someTheme == Theme.default works as identity equality. Theme builders
use it to populate roles and glyphs they do not override, and an unattached
Widget falls back to it when it has no Theme in its parent chain. It uses a
white-on-blue palette, yellow frame/title, lightbar selection, gray-on-blue
scrollbar, and bright-white-on-cyan inactive palette.
Theme.current decodes Host.themeData and caches the result against
Host.themeGeneration. App.new() initializes from this process-wide
selection. The built-in Classic Theme and file themes use the same host
representation; their storage and complete role/glyph registry are
documented in Themes.adoc. Scripts may still construct a Theme directly
or assign app.theme to replace the program theme for one App. Such a Wren
object does not modify the C-owned selection or any other VM.
Glyphs entries are either a single string or [primary,
fallback] pairs — primary is the rich Unicode glyph, fallback is
an ASCII-safe substitute. Cell storage is CP437, so primary
glyphs MUST be in CP437 too — Cell.ch= substitutes ? for any
codepoint not in the table. As a safety net, Glyphs[name]
auto-promotes to the per-entry fallback when the primary’s first
codepoint doesn’t encode in CP437 (probed via the foreign
Codepage.encodes_(text)). Resolutions are cached per name so
the encoding test runs once. Set theme.glyphs.asciiOnly = true
to force the fallback for every entry that has one — useful when
the user has selected a font that can’t render line-drawing
characters at all.
Glyph families used by the library:
-
frame.display.*— single-line--borders for informational viewers, plustee.left/right,title.left/right, andseparator. -
frame.control.*— double-line==borders for panes containing choices, buttons, or editable input.Pane.frameKindselects between the two frame families. -
scrollbar.track,scrollbar.thumb,scrollbar.up,scrollbar.down,scrollbar.separator— scrollbar pieces.
Painter
Painter is the low-level drawing toolbox. Every primitive takes
a Surface as its first argument and mutates the cells in place;
no calls reach Screen.writeRect from here. Coordinates inside a
Surface are 0-based (0..width-1, 0..height-1); widgets that
work in absolute screen coords convert at composition time.
The most useful entry points:
fill(s, rect, ch, style) |
Bulk-fill a Surface rect with a single character + Style. |
|---|---|
|
Draw |
|
Box around |
|
Frame with a centered `+ |
|
+` in the top border. |
|
Single-row / single-column lines. |
|
Vertical scrollbar with always-visible up/down arrows on the top/bottom rows when |
|
Resolve a click at row |
|
Paint a drop shadow on the cells around the rect: 2 columns wide on the right, 1 row tall on the bottom. Each shadow cell keeps its existing character and dims the attribute (legacy 0x08, RGB fg 0x202020 on bg 0x000000). |
|
Apply a Style to an existing Cell, leaving null fields untouched. Exposed for callers that mutate a cell view directly. |
UI Modules
The library is split across one module per topic, plus the convenience aggregator:
|
Re-exports every public class. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Demo gallery
scripts/ui_demo.wren is a playground for the library; run it from
the Wren console (Ctrl+`):
import "ui_demo" for UiDemo
UiDemo.run()
Top level is a list of widget demos; pick one with Enter to launch
it, Esc returns to the gallery. Each demo runs its own App via
runSync (the console blocks the main loop, so doterm can’t
dispatch through to a parked fiber); nesting is fine because every
App save/restores Screen, mouse events, and CustomCursor on entry
and exit.
Worked Example: Auto-respond to a Prompt
A small script that watches inbound text for known prompts and sends
canned responses. Hook.onMatch is the right tool for this — it
runs a streaming regex against the inbound byte stream and fires the
callback on each match, no manual buffering required:
import "syncterm" for Hook, Conn
Hook.onMatch("Press any key to continue") { |m|
Conn.send("\r")
}
Hook.onMatch("Logon: ") { |m|
Conn.send("myhandle\r")
}
onMatch is passthrough-only, so the matched text always reaches
the terminal — the callback just acts on the side (sending bytes
back to the BBS, updating script state, etc.). When a prompt is
preceded by colour codes, use Hook.onMatchClean instead so the
escapes don’t break the literal match.
onMatch substring-matches anywhere in the stream, not anchored to
line boundaries, so prompts that wait for input mid-line (like
`Logon: `) work the same as prompts followed by a newline. See
Hook Events for the regex grammar accepted, including
which constructs aren’t supported (no character classes, no anchors,
no backslash escapes).
Worked Example: Per-byte Inspection
Hook.onInput is the lower-level primitive — fires once per inbound
byte before the byte reaches the terminal. Use it when you need to
react to individual bytes rather than text patterns. Below: count
BEL (0x07) bytes received over the session.
import "syncterm" for Hook
class Stats {
static bells { __bells }
static bells=(n) { __bells = n }
}
Stats.bells = 0
Hook.onInput { |b|
if (b == 0x07) Stats.bells = Stats.bells + 1
return false
}
Inspect the count from the Wren console (Ctrl+`) — Stats.bells
returns the running total. Returning false lets the byte through
to cterm; returning true would consume it (so the BEL never reaches
the terminal and never beeps). Filtered variants —
Hook.onInput(0x07, fn) — push the byte equality check to the C
side, avoiding a Wren entry on every non-target byte.