@swiftbrowser/code
v0.29.2
Published
A code editor for SwiftBrowser apps: line numbers, long files, syntax highlighting, on a text system shaped after TextKit 2 that drives the page's own view and, natively, NSTextLayoutManager.
Readme
@swiftbrowser/code
A code editor for SwiftBrowser apps: line numbers, long files and syntax
highlighting, on a text system shaped after TextKit 2
(docs/notes/textkit.md in the repository) so that one JavaScript surface
drives the page's own view and, natively, NSTextLayoutManager.
It is optional, as @swiftbrowser/shader and @swiftbrowser/dsp are: an app that does not import it pays nothing for it.
import { CodeEditor } from '@swiftbrowser/code';
<CodeEditor text={source} onChange={setSource} language="swift" theme="github-dark" frame={{ maxHeight: Infinity }} />The app gives the editor its text and hears each edit (onChange(text,
changes), the changes as offsets into the text before); the view keeps the
text between, so a long file never crosses the bridge twice. lineNumbers
(on), wrap (off: long lines scroll), readOnly, fontSize, indentUnit
(two spaces), language, theme and colors are the options.
marks tints ranges of the text behind it (a search's matches, the
statement an error is in): { from, to, color }, offsets into the text as
onChange's are and a CSS color, a later mark over an earlier. They take
no focus and leave the selection alone. focused is a binding, as
TextEditor's is: [isFocused, setFocused] gives the editor the focus or
takes it away (a tap elsewhere putting the keyboard down) when the value
changes, and hears the viewer's own taps through the setter.
<CodeEditor
text={source}
onChange={setSource}
marks={[{ from: error.start, to: error.end, color: 'rgba(255, 69, 58, 0.25)' }]}
focused={[editing, setEditing]}
/>completions offers suggestions in a list under the caret: an array of
items, when they never depend on where the caret is, or a function of the
word being typed, (context) => items, sync or async, which the view asks
once per word (as the word begins, at one of completionTriggers, . say,
which opens the list with an empty word, or when the user presses
Ctrl-Space) with the whole text, the caret, the word so far (prefix, from
from), the line it is on and an AbortSignal that a newer request
aborts. Filtering and ranking as the user goes on typing are the view's,
so the function answers for the word, not for each keystroke; it may
answer { items, incomplete: true } to be asked again at each one. An item
is a label, shown and matched, with an insert that goes into the text
in its place (the label when absent), a caret within it (adsr(|) is the
name's length plus one), a detail for the second column (a signature, a
type), a kind (keyword, function, variable, type…) for its icon,
a filter matched instead of the label, a from to replace from
instead of the word's start, and documentation, the item in full (a
function's doc, its parameters' types and defaults; plain text, line breaks
kept), shown in a pane beside the list while its row is selected. Arrow Up and Down pick an item, Return or Tab
takes it, Escape puts the list away; the item goes in as an ordinary edit,
which the app hears through onChange and the user undoes as any other,
and onCompletionAccept(item) hears the choice after it.
<CodeEditor
text={source}
onChange={setSource}
completionTriggers={['.']}
completions={({ prefix, trigger }) => (trigger === '.' ? members : names).filter((name) => name.startsWith(prefix[0] ?? '')).map((name) => ({ label: name, kind: 'function', detail: signatures[name], insert: `${name}()`, caret: name.length + 1 }))}
onCompletionAccept={(item) => recent.add(item.label)}
/>service hands the editor to a language service
(docs/notes/language-service.md in the repository): a language's analysis
in a worker, answering completions that know the scope, with
documentation, and pushing diagnostics. A service is a class in its
language's package (the .dsp language's is DSPLanguageService in
@swiftbrowser/dsp); the app puts it in a worker entry that calls
serveLanguage(self, new DSPLanguageService()) and gives the editor a
LanguageClient over that worker (languageWorker starts it the way the
highlighter's starts, inline runs the service on the page where no
worker answers), all from @swiftbrowser/code/language.
The editor opens its text with the service when it mounts (and again when
the app sets a new text, named by fileName if given), sends it each
edit, closes it on unmount, and asks the service for the list in place of
completions (the service wins when both are given; completionTriggers
still names the trigger characters). The service's diagnostics, { from,
to, severity, message }, are drawn as wavy underlines under their text,
red for an error, yellow for a warning, blue for information and the line
numbers' color for a hint (colors' diagnosticError,
diagnosticWarning, diagnosticInfo and diagnosticHint change them),
with the message on hover. They arrive a moment after each edit, so in
between they may sit a few characters off.
Signature help: as ( or , is typed (or Ctrl-Shift-Space is pressed),
the editor asks for the call around the caret, and again at each edit or
caret move while the help shows; the service answers with the call's
signatures, the one that applies and the parameter the caret is on
(signatureHelp(doc, at), { signatures, active, activeParameter }, each
parameter's label as it appears in the signature's), and the editor shows
it in a small tooltip over the caret's line, the active parameter in bold
with its documentation under it. A null answer (the caret is not in a
call) closes it, as do Escape, the caret leaving the line and the focus
going; the completions list covers it while it shows.
Hover: when the mouse rests over a word for a moment, the editor asks what
is there (hover(doc, at), the offset of the character under the
pointer); the service answers { from, to, contents }, plain text with
paragraphs separated by blank lines, or null for nothing, and the editor
shows the contents in a tooltip over the word until the mouse leaves it,
the editor scrolls or the text is edited (a touch screen has no hover).
Both are answered only by a service; without one nothing shows.
// dsp-language.worker.ts
import { serveLanguage, type PortLike } from '@swiftbrowser/code/language';
import { DSPLanguageService } from '@swiftbrowser/dsp';
serveLanguage(self as unknown as PortLike, new DSPLanguageService());
// the app
import { LanguageClient, languageWorker } from '@swiftbrowser/code/language';
import { DSPLanguageService } from '@swiftbrowser/dsp';
const dsp = new LanguageClient({
port: languageWorker('dsp-language', () => new Worker(new URL('./dsp-language.worker.ts', import.meta.url), { type: 'module' })),
inline: () => new DSPLanguageService(),
});
<CodeEditor text={program} onChange={setProgram} service={dsp} fileName="synth.dsp" completionTriggers={['.']} />Highlighting
language names one of shiki's grammars: swift, typescript, tsx,
javascript, json, css, html, markdown, python, rust, go,
c, cpp, objective-c, java, kotlin, ruby, php, shellscript,
yaml, toml, sql, glsl, wgsl, diff… and their usual aliases
(ts, js, md, py, sh); registerLanguage(name, () =>
import('@shikijs/langs/haskell')) adds any other, or an app's own TextMate
grammar. theme is one of shiki's: github-light and github-dark (the
defaults, following the page's color scheme), one-dark-pro,
vitesse-light, solarized-dark, catppuccin-mocha, nord, dracula…;
registerTheme adds one. On a page each built-in grammar is a chunk of
its own, loaded when a document first asks for it; in a native app that
bundles the highlighter worker, the worker holds every built-in grammar and
theme and the app's own script none of them, so the app launches without
parsing them. The theme colors the editor's chrome too (its
background, the gutter, the selection, the caret), and colors overrides
any of those.
The tokenizer runs in a worker: shiki's TextMate engine over its JavaScript
regex engine, each line's rule stack kept so an edit re-tokenizes only
until the lines after it are as they were. Grammars and themes load on the
page, lazily, and go to the worker once; the text goes once and each edit
after, as paragraphs replaced; the viewport's lines come back as tokens.
configureHighlighter({ engine: 'oniguruma' }) before the first editor
runs Oniguruma, the engine the grammars are written for, as WebAssembly
(600 KB more) instead, matching every pattern as an editor does.
A page bundles the worker from the package's entry (new Worker(new
URL('./highlighter.worker.js', import.meta.url)), which Vite and the other
bundlers understand). A native app bundles it by name:
swiftbrowser-native build --worker highlighter=@swiftbrowser/code/highlighter.worker
(or highlighter-oniguruma.worker, for Oniguruma), and the editor runs it
in a WKWebView, where WebAssembly and the regexes have a JIT. Without a
worker, the engine runs inline.
Through a host
An app drawn by a host that registers the editor's view and runs the
highlighter itself (an app in NoyaOS's sandbox, drawn by the host's
RemoteTree) imports @swiftbrowser/code/react: the CodeEditor
component and its types alone, with no grammar, theme, worker or text
system behind it, so a small bundle. The host imports @swiftbrowser/code
once, which registers the page's view. (Natively, that entry also installs
the highlighting the native view needs; through /react alone the native
view draws plain text.)
On a page
The view lays out only the lines on screen (and a margin beyond), the rest
at estimated heights, so a hundred thousand lines open at once. The lines
sit in a contenteditable, so the browser supplies the caret, selection,
keyboard and IME, on a phone included; the editor's own commands (words and
lines by Option and Command on a Mac, Control elsewhere; Tab and Shift-Tab
to indent; Return keeps the indentation; undo and redo) are the
controller's, EditorController, written once for both platforms. The
completions list is an element over the lines, beside the caret, and the
keys reach it before the controller's while it is open; the selected
item's documentation is a pane beside it, to its right where there is the
room and under it where there is not. Diagnostics are the lines' own spans,
cut where one begins and ends, underlined, with the message as the span's
title.
Natively
SwiftBrowserCode (Native/Packages/SwiftBrowserCode in the repository)
registers the same view: a text view on TextKit 2 (UITextView on iOS,
NSTextView on the Mac) with the gutter, editing its text with the
platform's keyboard, selection, undo and input methods and reporting each
edit as a delta. The highlighter runs in the app's JavaScript, in the
worker the app bundles beside its app.js
(swiftbrowser-native build --worker highlighter=@swiftbrowser/code/highlighter.worker),
which the host runs in a WKWebView; the tokens go to the view as colors.
swiftbrowser native init --with code (or native add code) links the
package and installs it (SwiftBrowserCode.install()). The completions list is the
view's own, drawn over the text view at the caret: the hardware keyboard's
arrows, Return, Tab and Escape drive it, and on a phone without one the
user taps an item.
