Plugins
Table of Contents
- 1. The model
- 2. The API
- 3. The host
- 4. What a plugin can add
- 5. Inspection and the repository
- 6. Viewers as components
- 7. When a plugin serves a file
- 8. Viewers of flowing text
- 9. Versions of the plugin API
- 10. Commands, keys and events
- 11. The document
- 12. Settings, files and the network
- 13. What a plugin shows
Plugins are WebAssembly components, and language plugins, which are
declarative (Language plugins). Two kinds of component run today:
viewers of files that are not text, Kalem's own viewers of pictures,
PDF files and workbooks among them, and extension plugins, which add
commands, keys, event handlers, edits of the document, settings and
panels. kalem plugin finds, installs, builds and develops them. The
design is decided (D25, D28, D29) and written out in section 11 of
the design document. Completers (API 0.2.11) and layers over a
mode (0.2.10) are there too; what it names beyond these (modes, block
renderers, link types, export back-ends as plugins) is planned, each
listed with its task in crates/kalem-plugin/coverage.toml.
1. The model
- A plugin is a WebAssembly component, written in Rust against the
kalem-plugincrate, which is generated from the API's definition in WIT. It sees only what the API grants: no file system, network, clock or threads of its own; its own memory, 64 MB by default; a time budget, 100 ms for a synchronous call (a viewer's are 1 GB and 10 s, below). A plugin error never crashes Kalem, and a viewer that stops three times is turned off until it is updated. - One contract. The trait a viewer implements is the one Kalem's own viewers implement, so a plugin's unit tests run natively in its crate while Kalem runs it as a component, its own viewers included. Kalem ships no runtime for other languages and no scripting engine.
- Permissions are declared in the manifest, approved when the plugin
is installed and recorded in
plugins.toml:fs:read:workspace,fs:write:workspace,fs:read:all,net:fetch:DOMAIN, and, with a separate warning,subprocess(a language plugin's servers) andsubprocess:PROGRAM(one program a component runs). - Out of process for what cannot be a component: language servers
talk JSON-RPC over standard input and output, and the programs a
component runs (
process) are run with their arguments, their output read back. From phase 4, a power user may compile plugins into their own Kalem binary, with native speed and no sandbox, by their own choice.
2. The API
The API is defined in WIT, in crates/kalem-plugin/wit/ (D6): the one
source both sides are generated from. A plugin builds against the
kalem-plugin crate (wit-bindgen); Kalem's host generates its side from
the same files (wasmtime). It holds eight worlds, three kinds:
document-viewer(§11.13): a plugin opening files that are not text. It exportsviewer:describe,detectandopen, and adocumentresource with the viewers' contract:structure,size,render(a bitmap the host paints),text,text-rects,text-at,info,search,links, and the editor half (edits,apply,modified,save);password, for a file that needs one;formats, the kinds it saves as and new files; andannotations, comments and tracked changes. It importsfiles, thefileresource of the one file the host opened for it, with its name, its size and the bytes the plugin asks for, never a path;clock; anddiagnostics.spreadsheet-viewer: the same, and thegridinterface of sheets of cells (below);flow-viewer, the same with theflowinterfaces of flowing text (the Word plugin's); andfull-viewer, both.extension: a plugin that adds to the editor rather than opening files. It importskalem,editor,ui,settings,diagnostics,clockandclock-2,layers,documents,styled-documents,decorations,problems,graph-view,canvas-view, andfs(withfs-2),netandprocesswhen its permissions grant them, and exportsplugin(below).extension-layer,extension-completer,extension-layer-2,extension-layer-3andextension-formatterare the same world exporting more: a layer, then a completer, then a layer's lines under lines, then lines that show a file's text, then a formatter, each with those before it.
crates/kalem-plugin/coverage.toml lists every extension point of the
design (§11.10 to §11.13) with the WIT interface that defines it or the
open task that will; a test keeps it in step with the design document
and the task list.
3. The host
kalem-script runs components in wasmtime with its Cranelift compiler,
the engine chosen by measurement (D28):
- A component is compiled the first time it loads and kept in the state directory, named by its hash and the engine's version and settings; later starts read it in a fraction of a millisecond. A file another engine wrote, or a damaged one, is compiled again.
- A plugin reaches only what Kalem grants it. Its imports are resolved against what the host defines for it and nothing else; one asking for more is refused, and the refusal names the import.
- Each call has a time budget, 100 ms by default, counted by a clock that ticks every 10 ms; a plugin past it is stopped. Each instance has a memory limit, 64 MB by default; a plugin asking for more gets an allocation failure, and Kalem tells the two apart from the plugin's own failures.
- A loaded plugin is shared by threads, each with an instance of its own, so parsers, renderers and completers run in parallel.
- Plugins are built for
wasm32-unknown-unknownand wrapped as components, so they import nothing the API does not name; built forwasm32-wasip2a Rust component imports fifteen WASI interfaces it never uses.
A plugin is a folder with a manifest, plugin.json (its ID, the
component, the API version, activation events, permissions, limits,
and the menus, buttons, layers, completers and settings it
contributes; its commands and keys it registers when it starts),
installed under
plugins/ID/ in the settings directory and loaded on its activation
events. Command IDs follow the registry's rule, dotted parts of
letters and digits each starting with a lower case letter, so an ID
has no hyphens. Plugin settings go in settings.toml under
[plugins."ID"], and bindings for plugin commands in keymap.json
like any other.
4. What a plugin can add
Commands and key bindings; link types; renderers for special blocks
and source languages; decorations; completers and hover text; input
rules; highlighters and document modes; views (a board, a timeline);
panels, as a JSON widget tree both editors draw; diagnostics with
fixes; export back-ends and filters; importers and paste handlers;
Babel executors; table functions; dynamic blocks; agenda views and
capture templates; command-line subcommands; themes. Each appears in
both editors and, where it makes sense, on the command line (kalem
run, kalem check, kalem export --to NAME). Plugins cannot change
the parser's grammar, draw outside the widget tree and SVG, or hold
the editor's thread beyond the budget.
A mode is the largest of these: detect, parse (a flat tree of
kinds and byte ranges, incremental), grid for table-like formats,
edit for Enter, Tab and toggles, the outline, formatting,
completion and diagnostics hooks, and an optional export. It returns
ranges, never text, so it cannot break the byte-exact round trip; it
has two levels, declarative (a syntax definition and a mapping to the
kinds, no code) and programmatic (a parser in Rust); and a
conformance suite every mode runs. The completer and mode contracts
as they exist today are in Completers, modes and highlighters.
5. Inspection and the repository
Kalem has no REPL. Of a live runtime it has one part: a plugin
installed, updated or removed while Kalem runs is taken up without a
restart, which kalem plugin dev uses. An inspection panel (the loaded
plugins with their permissions, budgets and errors, the command
registry, timings) and a local debug socket, off by default, are
planned (group 3.2).
Plugins beyond the core live in getkalem/plugins: one Cargo
workspace with a crate per plugin (the viewers of pictures, PDF files,
workbooks and Word documents, git, note graphs, plain text accounting,
and the Elixir and Rust language plugins today), a template that
kalem plugin new copies, and an index Kalem reads as a static file.
Components are never committed: CI builds every plugin against the
Kalem revision its Cargo.lock pins, and a tag (pdf-viewer-v0.0.2) builds, signs and
publishes its .wasm. kalem plugin install, list and remove work
for declarative plugins and components alike: a component released in
the index is downloaded, its SHA-256 checked against the index, and its
manifest read from its folder at the release's tag, which must name the
same plugin and version; for one not released yet Kalem says to build
it with kalem plugin build and install that folder. Two commands start and build a component:
kalem plugin new NAMEstarts a plugin. Inside a checkout ofgetkalem/pluginsit copiestemplate/toplugins/NAMEand renames it; anywhere else it writes a crate of its own inNAME/that builds alone.kalem plugin build [DIR]runs Cargo forwasm32-unknown-unknown(rustup target add wasm32-unknown-unknownonce) and wraps the module as the componentmainnames inplugin.json, withwasm-tools(cargo install --locked wasm-toolsonce; Kalem calls the tool rather than carrying its 2.3 MB). It prints what the component imports and exports: a plugin imports only what its WIT world names.kalem plugin dev [DIR]builds the plugin with its functions' names kept, installs it, and builds and installs it again whenever its sources change, until stopped. A Kalem running opens files with the new build from then on (one started later registers the plugin), and a trap in its log reads as a backtrace of the plugin's functions. The plugin's own unit tests run natively in its crate, withcargo test.
kalem plugin verify is planned.
6. Viewers as components
A viewer written against the Rust contract of kalem-viewer builds
as a component with one line,
kalem_plugin::export_viewer_of!(PdfViewer) under
#[cfg(target_arch = "wasm32")]: kalem-plugin's adapter writes the
document-viewer world over the trait, and the plugin reads the file
the host gives it through the same FileHandle API, piece by piece.
The PDF and image viewers of getkalem/plugins build this way.
kalem plugin install DIR installs a viewer whose component is built
(kalem plugin build DIR); it says which files it opens before asking.
At start Kalem registers the installed component viewers (in a build
of one's own with viewers built in, in place of the built-in one of the
same name), and a thread compiles them, or reads them from the cache
in the state directory, so the first file they open does not wait.
Each document a component opens gets an instance of its own. One that
fails, runs past its time or out of its memory stops: the editor closes
its document at once and says which plugin stopped and why, and that
its unsaved changes, which nothing can save any more, are lost; the
editor itself goes on, and the log keeps the plugin's version and what
went wrong. A plugin that stops three times is turned off until it is
updated, and its files go at once to what opens them next: a newer copy
installed, else a copy built into Kalem when a build of one's own has
one, else nothing, and the file is offered to the system's application. kalem plugin list says so, and kalem plugin enable ID
turns it on again. Kalem need not be started again for any of this: a
viewer installed, updated or removed while Kalem runs, from the plugin
list or with kalem plugin install in a terminal, opens its files (or
gives them back) within a second or two, and the status bar says which
copy opens them now. A viewer's limits are 1 GB and 10 s a call, more
than §11.6's defaults, since a viewer holds a whole file; a manifest
may set others, limits.memory_mb and limits.time_ms.
The viewers of pictures, PDF files and workbooks are such components,
released by getkalem/plugins and installed like any other plugin:
none is built into Kalem. A file that is not text and that no
installed plugin opens names the released plugins of the indexes that
serve it, to install, after which the file opens; and the system's
application (Find a Plugin for the File, plugin.forFile).
7. When a plugin serves a file
Every plugin, whatever its kind, says in one declaration when it serves
a file, and Kalem tests that declaration alike for every kind of plugin
(kalem_core::applies), both to hand a file to an installed plugin and
to name a plugin of the index for a file. So the plugin named for a file
is the one that serves it once installed, and a plugin serves the files
it declares and no other.
The declaration is a list of rules, and a plugin serves a file one of
its rules matches. A rule says what the file is, by any one of the
things it lists: its extension (.xlsx, .html.eex, the longest
counting), its whole name (mix.lock), its first bytes (magic, pairs
of hexadecimal digits, ?? for any byte: "25 50 44 46 2D" is
%PDF-), the interpreter of its #! line (elixir); and where it is,
by any one of its markers, files or folders that must be in the file's
folder or one above it (logseq/config.edn for a Logseq graph,
.obsidian for an Obsidian vault). A rule that lists both asks both: a
site's plugin may serve .md files under a folder holding hugo.toml,
and no other Markdown file. The rules are what the manifest already
says, and what it adds:
- the extensions a viewer
opens; - the extensions, whole names (
filenames) and interpreters (shebangs) of thelanguagesit serves; - the
markersof each of itslayers; - its own
applies, a rule or a list of them, withextensions,filenames,magic,shebangsandmarkers:{"magic": ["25 50 44 46 2D"]},{"markers": [".git"]}.
A viewer's rules hold for files that are not text only: Kalem opens a
text file in a mode, even one a viewer could show (an SVG drawing is
XML). The index copies these parts of each manifest as they are
(tools/build-index.py), and Kalem reads them from the index and from
an installed manifest by the same code. Where several plugins serve a
file, the surest match is used: a whole name, then an extension (a
longer one before a shorter), then first bytes, which formats share
(every Office file is a ZIP archive), then a #! line, then a marker
alone; a rule whose marker is found as well before one with none; the
first installed (the later, for languages) among equals. Kalem uses the
declaration everywhere:
- a file that is not text opens in the installed viewer that serves it;
the viewer's
detectis not asked; - a text file's language plugin is the one that serves it, the language
then chosen among its own (by the whole name, the extension, the
#!line; its first when the plugin serves the file by a marker); - an extension plugin whose manifest names no
activationstarts with the first file opened that it serves (onFile), or with Kalem when it declares none; - a file Kalem cannot open (not text, no installed viewer serves it) names the released viewers of the indexes that serve it, to install, then the file opened, and the system's application;
- a text file it opens (a source file, a Markdown page of a graph) names
the first released plugin that serves it and is not installed, said
once in the status bar and never again for that plugin, which Install
Suggested Plugin (
plugin.installSuggested) installs;plugins.suggest = falsesays none. The index is read when Kalem starts, once a day at most, and kept in the state folder (plugin-index.json), so a file opened offline is named too.
The feature
components of a build of one's own still builds the released ones in,
pinned by crates/kalem-components/components.toml by their release
and SHA-256, downloaded (or read from the folder KALEM_COMPONENTS_DIR
names), checked and kept compressed, inflated when first used; the
tests take them from there. kalem plugin list marks such a copy
built in, and an installed copy of one is used only when it is newer. Their manifests
set their limits as an installed one's do. Through a component a viewer
took one to two times the time of the same code built into Kalem
natively, as it was until 2026-10-05; a test measures each against a
ceiling; a workbook needs about 256 MB inside the component for a
million cells.
8. Viewers of flowing text
A word processing document, an e-book, a web page or an e-mail is made of
paragraphs that flow: no page or cell of their own until something lays
them out. A viewer of such files exports the flow interface (API 0.2.7,
kalem-plugin's feature flow, the flow-viewer world), and Kalem lays
the paragraphs out itself, in both editors, as it lays out an Org
document: the plugin never draws. The interface says what a unit holds in
the words every such format has, and nothing of one format: paragraphs
with a role (body, title, heading, list item, quote, code, caption), a
level, a style's name for the user, a list label, an alignment, indents
and spacing; runs of text with their marks (bold, italic, underline,
strikethrough, raised or lowered, capitals, hidden, a color, a highlight,
a size, a typeface), a link, and the annotations they are in; tables
whose rows hold cells of paragraphs; asides (headers, footers, notes,
text boxes) and rules (breaks). A unit's flow is a list of items, the
starts and ends of tables and asides among the paragraphs, since WIT has
no type that holds itself, and the host fetches it a range at a time, so
that a book is never sent whole. A unit is a flow when flow.layout
answers one, as a sheet is a grid when grid.layout does; viewer's
unit kinds are frozen.
Kalem shows a flow unit as a document of the editor, of the mode
flow: its text is the paragraphs' edit texts, a paragraph a line (a
table's row of one-paragraph cells a line, its cells apart by tabs), so
that the cursor, selection, search, Vim's motions and both frontends'
drawing work on it unchanged, and each line shows the look the plugin
gave its runs, sizes in proportion to the body text's. An edit of that
text is not applied to it: Kalem turns it into the plugin's edits
(replace for typing and deleting in a paragraph, split for a line
feed typed, join for one deleted, delete across paragraphs), one step
of the plugin's history, and reads the paragraphs again. The plugin
writes them in its format's own vocabulary or refuses them, saying why
(deleted text of a tracked change, a field's result, a note's mark), and
the editor shows the reason. Undo and redo are the plugin's; saving
writes the bytes the plugin gives. After an edit Kalem asks what changed
since the version it holds (flow-3's changes, API 0.2.9): the items
from one on, so many of them then, are so many now, and the paragraphs
after them are numbered on by so much; it fetches those items and lays
out their lines again, and nothing else, so that a keystroke in a
document of twenty thousand paragraphs takes a millisecond or two. A
plugin without it, or one that cannot tell, has its flow read whole. A paragraph's edit text counts a
tab, a line break (U+000B), a page break (U+000C), a column break
(U+000E) and an object (U+FFFC, a picture, a note's mark) as one
character each. A run that is a picture names it and gives its size in
points; Kalem asks the plugin for its pixels (render-picture, no more
than it shows them, in powers of two so that a zoom seldom asks again),
keeps them, and draws the picture in its line, in proportion to the body
text as in the document; one the plugin cannot draw shows its text.
The look of the text is changed as a word processor changes it, through
set-marks (bold, italic, underline, strikethrough, raised or lowered,
a color, a highlight, a size, a typeface, or back to the style's look)
on a range of places, and set-style on a range of paragraphs, from
the styles styles offers. The commands of the Format category do it
on the selection, or the word at the cursor when nothing is selected:
Bold (Ctrl+B), Italic (Ctrl+I) and Underline (Ctrl+U) turn a mark on,
or off where the whole selection has it, as Strike Through,
Superscript and Subscript do; Font, Font Size,
Text Color, Highlight Color and Paragraph Style offer a list (the
document's typefaces and common ones; common sizes; a word processor's
colors; the document's styles, the default first, then the title's and
the headings') and take a value typed; Clear Formatting (Ctrl+Space)
gives the text back its style's look. They are in the Format menu,
the Review menu has the annotations' commands, and in the graphical
editor the toolbar shows the paragraph's style, the typeface and the
size at the cursor, each opening its list, and the marks, pressed where
the text there has them.
Paragraphs' look and lists are flow-2's set-paragraphs (API 0.2.8),
on the selection's paragraphs or the cursor's: Align Left (Ctrl+L),
Center (Ctrl+E), Align Right (Ctrl+R) and Justify (Ctrl+J); Bullets
(Ctrl+Shift+L) and Numbering, which make the paragraphs a list's items
or, when all are already, take them out of it, and Numbering Style
(1., a., A., i., I.); Increase and Decrease Indent, a list item a level
deeper or shallower and another paragraph's indent a half-inch step,
each paragraph its own and all one step; Line Spacing, Space Before and
Space After, from a list or typed; Clear Paragraph Formatting. The
toolbar has the alignments, the lists, the indents and the line
spacing, pressed as the cursor's paragraph has them.
What people wrote about a document rather than in it is the
annotations interface, apart from flow because other viewers have
it too, and every viewer world exports it (a viewer without any answers
none):
- A comment is a remark someone made on part of the document, with
its author, date and text; it may be answered (an annotation whose
parentis the comment) and marked resolved. Word's and OpenDocument's comments, a PDF's notes and highlights, a workbook's notes and threaded comments, a slide's comments are comments. - A revision is a tracked change: an insertion, a deletion, a change of formatting, a move from or to a place, with its author and date; it is accepted (it becomes the document's) or rejected (the document is as before it). Word's and OpenDocument's tracked changes are revisions.
An annotation is anchored to what it is about: text of a flow (from a
paragraph's place to another's), bytes of a page's text, a cell of a
grid, an area of a unit, or a whole unit; a run of a flow names the
annotations it is in by ID, so the host draws them without knowing the
format. The host shows inserted text underlined and deleted text struck
through in their colors, and text a comment is on highlighted; the
status bar says the comment or the change at the cursor. The commands of
the Review category (and menu) add, answer, edit, resolve and delete comments, go to the
next and previous comment or change, accept and reject a change or all
of them, and turn Track Changes on and off, while it is on the plugin
writing the user's edits as tracked changes. The user's name, which
comments and changes carry, is the setting user.name, else the system
account's.
9. Versions of the plugin API
The plugin API is the WIT package kalem:plugin
(crates/kalem-plugin/wit), version 0.2.16; a component's manifest
names the version it was built against ("api": "^0.2.1"), and Kalem
tries it when its own API is of the same 0.MINOR and no earlier. A
component built against a later version is not installed, and one
installed already does not start: Kalem says to update Kalem, naming
both versions, where one built against an earlier 0.MINOR is to be
updated itself. The rules, so that a plugin built once keeps working:
- A released interface never changes: no function, field, case or type
of it is added, removed or altered. The released ones (
viewer,files,clock,grid) are copied inwit-frozen/0.2.0/, and a test (tests/frozen.rs) fails a change to them. - What is new goes into a new interface (
grid-2, in a file of its own) released with the next patch version; the worlds, kept inworlds.wit, export it. Kalem binds the interfaces a component has, so one built against 0.2.0 still runs on a Kalem of 0.2.1, without what it does not export. An interface the plugin imports is granted by every later Kalem, so a component built against 0.2.1 needs a Kalem of 0.2.1 or later, which its manifest says. - 0.2.1 added the import
diagnostics: a component built as Rust builds it stops at a panic with a trap that has no message, so the panic hookkalem-plugin's adapters install says it first, and Kalem's log and the stop's error carry the panic's message and where it happened, above the backtrace of the plugin's functions. - 0.2.2 added the export
password: a viewer whoseopenanswers "This file is protected by a password" (ViewerError::needs_password) opens the file with the one the user types (open-with-password,Viewer::open_with_password); both editors ask for it, hidden, and keep it for the session.kalem-plugin's adapter exports it from the contract; a viewer written against the WIT world itself implements it (opening asopendoes when it has no such files). - 0.2.3 added the export
formats:save-aswrites a document as another of the formats its plugin writes (a workbook as.ods, a macro-enabled workbook as a plain one;ViewerDocument::save_as), andnew-filemakes a new file from sheets of entries as typed (Viewer::new_file: a blank workbook, a text file's rows). So the host holds no code of a plugin's formats: Save As, Open as Workbook and New Workbook go through the plugin.kalem-plugin's adapter exports it from the contract, whose defaults refuse. - 0.2.4 added the import
processand theextensionworld's exporton-process: an extension plugin runs the programs its manifest names (subprocess:git), its runs answered later as its fetches are (kalem_plugin::process). The git plugin ofgetkalem/pluginsis the first to use it. Theextensionworld was not released before, so itsplugininterface gained the export;processis frozen with the first plugin released against it. - 0.2.5 added the imports
documentsanddecorations: an extension plugin shows documents of its own, which it writes (kalem_plugin::documents, below), and marks the lines of files (kalem_plugin::decorations), for the git plugin's status and its marks beside the changed lines. The git plugin was released against 0.2.4, soprocessis frozen (wit-frozen/0.2.4), and theextensionworld'splugininterface no longer gains exports: a component built against 0.2.4 binds it as it is. A plugin learns that the user closed its document from its next write, which is refused. - 0.2.6 added the import
styled-documents: a plugin's document with colors and emphasis on stretches of its text, written with the text (kalem_plugin::documents::open_styled, below). The git plugin was released against 0.2.5, sodocumentsanddecorationsare frozen (wit-frozen/0.2.5), and styles came as an interface of their own rather than as an argument ofdocuments.set. - 0.2.7 added the exports
flowandannotations(above, Viewers of flowing text):flowin the newflow-viewerworld (and infull-viewer, whichkalem-pluginbuilds with both its featuresgridandflow),annotationsin every viewer world. A viewer written against the WIT world itself implementsannotations, answering none;kalem-plugin's adapter exports both from the contract, whose defaults have no flow and no annotations.styled.witof 0.2.6 is frozen (wit-frozen/0.2.6). - 0.2.8 added the export
flow-2to theflow-viewerandfull-viewerworlds:set-paragraphschanges paragraphs' look and lists (alignment, indents, spacing, line spacing, a list of bullets or of numbers in a numbering style named as CSS'slist-style-typenames it, a list's level, the look given directly cleared), a functionflowdid not have and, released, cannot get.kalem-plugin's adapter exports it from the contract (flow_set_paragraphs, which refuses by default); a component built against 0.2.7 has none, and Kalem says so when its paragraphs are formatted.flow.witandannotations.witof 0.2.7 are frozen (wit-frozen/0.2.7). - 0.2.9 added the export
flow-3to theflow-viewerandfull-viewerworlds:changessays what changed in a unit's flow since a version, as a run of items replaced and the paragraphs after it numbered on, so that after an edit the host fetches the items that changed rather than the whole flow, which in a long document took most of a keystroke's time.kalem-plugin's adapter exports it from the contract (flow_changes, which answers none by default: the host then reads the flow whole, as it does for a component built against 0.2.8).flow-2.witof 0.2.8 is frozen (wit-frozen/0.2.8). - 0.2.10 added, for extension plugins, the import
clock(the time, the time zone, random bits, granted without a permission as the viewers have it), the importlayersand the exportlayerin the worldextension-layer(kalem-plugin's featurelayer): a plugin's layers over a mode's view, described in Completers, modes and highlighters. The host bindspluginalone andlayerwhen a component exports it, so an extension of an earlier 0.2.x loads as before.flow-3.witof 0.2.9 is frozen (wit-frozen/0.2.9). - 0.2.11 added, for extension plugins, the export
completerin the worldextension-completer(kalem-plugin's featurecompleter, which exportslayertoo): a plugin's completers, declared in its manifest and described in Completers, modes and highlighters; and the importclock-2, the offset of the user's time zone at an instant, whichclockgave only as the zone's name. The host bindscompleterwhen a component exports it, aslayer; an export of an earlier 0.2.x (layer@0.2.10) is found by the later name.layer.witof 0.2.10 is frozen (wit-frozen/0.2.10). - 0.2.12 added, for extension plugins, the imports
graph-view(a document drawn as a graph, below) andfs-2(files moved and put in the trash underfs:write:workspace).completer.witandclock-2.witof 0.2.11 are frozen (wit-frozen/0.2.11). - 0.2.13 added, for extension plugins, the export
layer-2in the worldextension-layer-2(kalem-plugin's featurelayer-2, which exportslayerandcompletertoo): a layer's overlays with lines drawn under lines (an embedded block's children, a query's results), described in Completers, modes and highlighters. The host askslayer-2in place oflayerwhen a component exports it; one built against an earlier 0.2.x keeps itslayer.graph-view.witandfs-2.witof 0.2.12 are frozen (wit-frozen/0.2.12). - 0.2.14 added, for extension plugins, the import
problems: what a plugin's tool finds wrong in files, drawn as a language server's diagnostics are (below).layer-2.witof 0.2.13 is frozen (wit-frozen/0.2.13). - 0.2.15 added, for extension plugins, the export
layer-3in the worldextension-layer-3(kalem-plugin's featurelayer-3, which exportslayer-2too): lines under lines that show the text of a file, typed into where they are drawn and written back to it, described in Completers, modes and highlighters. The host askslayer-3in place oflayer-2when a component exports it.problems.witof 0.2.14 is frozen (wit-frozen/0.2.14). - 0.2.16 added, for extension plugins, the import
canvas-view: a document drawn as a canvas of shapes where the plugin places them (below); and the exportformatterin the worldextension-formatter(kalem-plugin's featureformatter, which exportslayer-3and those before it too): a plugin's formatters, declared in its manifest by the text types they format, which Format Document asks (below). The host bindsformatterwhen a component exports it, ascompleter.layer-3.witof 0.2.15 is frozen (wit-frozen/0.2.15). - Something removed stays as a function that refuses, until a new minor version (0.3) is cut for everything at once.
- An addition is general, or it waits. It names what several plugins
would do with it, not what one plugin needs (
processruns any program a manifest grants,documentsshows any view a plugin writes,styled-documentscolors any of them by the theme's names), and it comes when a plugin needs it, not before: an extension point no plugin uses is a contract kept for nobody. A plugin's need that no general form covers is reached through what exists (kalem.runof an editor command, a panel, a manifest's declarations) until a second plugin shows the shape it should take.
Components from before 0.2.0 (the manifests that say ^0.1, built while
the API had no version and changed with every spreadsheet feature) are
not tried: Kalem says the plugin needs an update, and its files do not
open until then (a build of one's own with viewers built in opens them
with its built-in copy). A component that cannot be bound for another
reason is treated the same way (in the status bar, and on kalem view's
standard error).
kalem plugin check starts every installed component viewer once, as
opening a file would, and loads every installed extension plugin as
starting it would, without activating it; it exits 1 when one cannot
run. The Released plugins workflow runs it on the index's plugins
every day and when the WIT files change, so a contract change shows
which plugins need a new release.
A viewer of sheets of cells, a workbook's or a database's, builds
the same way with kalem-plugin's feature grid: its component
exports the grid interface beside viewer (the spreadsheet-viewer
world), the grid half of the contract typed in WIT (cells and their
styles, edits, conditional formats, validations, sorting and filters,
charts, pivot tables, undo, macros), and Kalem uses it as it used its
native one. A macro's questions cross as answers given so far: the
plugin stops at a question it has no answer to, Kalem asks the user and
runs it again with the answer. Every function of the contract is
carried to components; a test fails when one is added and not carried.
Viewers get one more interface, clock: the time, the user's time zone
and random bits. A workbook's NOW(), TODAY() and RAND() need them
to compute as Excel does, and they tell the plugin nothing of the user's
files; extension plugins have it too (0.2.10), and clock-2 (0.2.11). The Excel plugin's formula engine,
IronCalc, comes from getkalem/ironcalc, a fork whose only change lets
it read that clock on WebAssembly instead of a browser's JavaScript;
built so, the plugin opens, edits, recalculates and saves workbooks
exactly as its native build does, which a test compared workbook by
workbook, page by page and picture by picture, to the pixel, while Kalem
still had native copies.
Built for speed (opt-level = 3, from the crate's release profile)
and with WebAssembly's SIMD, which kalem plugin build adds, the PDF
viewer renders a page within a fifth of its native speed: 39 ms against 33 ms for a page at a Retina
display's size. Built for size it was twelve times slower and no
smaller.
10. Commands, keys and events
The extension world is a plugin that adds to the editor rather than
opening files. It imports the kalem interface, §11.4's namespace, and
exports plugin: activate, deactivate, run-command and
on-event. In Rust a plugin implements kalem_plugin::kalem::Plugin
and registers its commands with the functions that run them:
use kalem_plugin::kalem::{self, EventKind, Plugin, Reply, Scope};
struct WordCount;
impl Plugin for WordCount {
fn activate() -> Result<(), String> {
let mut spec = kalem::spec("wordcount.show", "Word Count", Scope::only(&["org"]));
spec.keys = vec!["ctrl+alt+w".into()];
kalem::command(spec, |_args| Ok("null".into()))?;
kalem::keymap("space n w", "wordcount.show", None)?;
kalem::on(EventKind::DocumentBeforeSave, |_event| Reply::Proceed)?;
Ok(())
}
}
kalem_plugin::export_plugin!(WordCount);
A command's scope is part of its description, so none is registered
without one; the host refuses a scope naming no type, and an ID that is
not the plugin's own (wordcount.). Arguments and results cross as
JSON. kalem::run runs another command, built in or another plugin's,
once the plugin's call returns: right after the plugin's command, in
its context (a document's edit included), or at the editor's next tick
when an event's handler asked; it is refused at once when there is no
such command, and its result does not come back. A plugin calls its own
commands as functions, since a component is not entered again while it
is inside a call. A handle a registration returns may
be disposed of; dropped, it keeps the registration, and the host takes
back everything a plugin registered when it is deactivated. A vetoable
event's first veto is the answer. Each call has the plugin's time
budget; a command that loops is stopped.
Kalem loads the extension plugins installed: a component that opens
no files. kalem plugin install DIR installs one whose component is
built, and at start a thread loads each (compiled once and cached, as
a viewer is) and activates it when its manifest's activation says:
onStartup, onEvent:NAME at the first event of that name
(onEvent:document:open), or onFile at the first file opened that it
serves (see 7). With none, a plugin that
declares files it serves starts with the first of them, any other with
Kalem. A plugin is known by its ID's last part,
org.kalem.wordcount as wordcount, and its commands are
wordcount.*. Its commands join the palette, the menus' rules and the
keymap in both editors as it registers them, its key bindings go in
before the profile's and the user's (so that both override them), its
subscriptions hear the
editors' events, and its notifications show in the status line. A
plugin that traps or runs past its budget is stopped, what it
registered is taken back, and the user is told.
Neither does an extension plugin need Kalem started again. One
installed while Kalem runs (Install Suggested Plugin, Browse Plugins,
or kalem plugin install in a terminal) is compiled on a thread and
started within a second or two as its activation says, as if it had
started with Kalem: it is told of the documents open, each as a
document:open, then of app:ready; its layer is drawn on them, and
a notes graph's folder it serves is offered as a project then. One
removed stops, what it registered taken back; one updated stops, and
its new version starts. A plugin that starts after the editors opened
their first documents (the first start after an install compiles it)
is told of them the same way.
A manifest may add menus to the menu bar (the system's on macOS, the
window's own elsewhere, and the list F10 shows in both editors) under
"menus": each a title, a when-clause on the document, and its items,
the plugin's own commands (titled without their category's prefix) and
- between groups. The when-clause sees the document's keys
(editorMode, textType, fileKind, editorLanguage) and vcs, the
version control holding the file (git, hg, jj, =svn=…, by the
nearest folder above it with its marker), or for a document without a
file (a new one, a plugin's) the first project's folder, where the
plugin's programs run for it; so the git plugin's Git menu shows in a
repository only:
"menus": [
{ "title": "Git", "when": "vcs == git || textType == git-status",
"items": ["git.status", "-", "git.commit", "git.push", "git.pull"] }
]
A manifest may add buttons too, under "buttons": each a title, one of
the plugin's own commands and a when-clause as a menu's. Where the
clause holds, a button stands beside the File Manager and Projects
buttons, on the window's toolbar and in the terminal's list of open
files, and it is pressed while one of the plugin's documents is shown.
The git plugin's Git opens its status, as SPC g g does:
"buttons": [
{ "title": "Git", "command": "git.status",
"when": "vcs == git || textType == git-status" }
]
A plugin's command runs from the command line too, without a window,
as the editors run it: kalem run PLUGIN COMMAND [FILE...] starts the
plugin named (its ID or its commands' prefix) whatever its activation,
and runs the command against each file, a folder as its listing, or
once without one. What the command asks of the editor follows as in a
window: the document it reads and edits, the commands it runs, a save;
the programs it runs and the requests it makes are waited for, each
within its time limit, and what their answers show or run follows too.
The documents it writes are printed, with --format json as a list of
their titles, kinds and texts; its notices go to standard error and
an error among them makes the exit code 1. Its questions are answered
as a script would leave them: a prompt takes the text it offers, else
it is cancelled, a choice is left with nothing chosen and a
confirmation answered no. A file the command changed is saved, as
kalem fmt saves. The folders named, or a file's own, are the
plugin's workspace with the projects for that run, so that a plugin
with fs:read:workspace reads them. --args gives the command's
arguments as JSON. The graph plugin prints a Logseq graph's pages so:
kalem run graph graph.pages ~/notes
Two of the editor's commands take what a plugin knows and the editor
does not: Search in Folder (search.folder) takes ignore, patterns
in .gitignore's syntax left out besides the folder's own (a graph
leaves out /logseq/), and Show in System File Manager (file.reveal)
takes a path, a file Kalem does not open, shown in its folder for
its application to open.
11. The document
A plugin's command reads and edits the document it runs in through
kalem_plugin::editor: its text and selection, its headlines (level,
title, TODO keyword, priority, tags, properties, planning, parent and
children), the headline at a place or with an ID, those with a tag,
a keyword or a property, a headline's body, the TODO keywords and the
#+KEY: lines, the element at a place, and its tables with their rows
and formulas.
use kalem_plugin::editor::{self, Query};
for h in editor::find(&Query { tag: Some("work".into()), todo: None, property: None }) {
editor::set_todo(h.start, Some("DONE"))?;
}
editor::transact("Close the work items");
A headline or a table is named by where it starts. The plugin reads the
document as its command found it; its edits (insert, replace, the TODO
keyword through Org's own change with its logging, the title, tags and
properties, promoting, demoting and moving a subtree, a table's field
and its recalculation, an ID made on demand, saving) wait until the
command returns, then apply in order, each place moved by the edits
before it, and one undo takes them all back. An edit naming a place
where no headline or table starts is refused at once. Only a plugin's
own command reaches the document: its event handlers and panels do not.
12. Settings, files and the network
A plugin reads Kalem's settings (kalem_plugin::settings::get("editor.font_size"))
and keeps its own in the user's settings.toml, under its ID:
[plugins."org.kalem.wordcount"] goal = 1000
settings::own reads them, settings::set writes them (the editors
read the settings again), and settings::watch hears a change, whether
the user edited the file or the plugin set it.
A manifest describes its settings under "settings", as VS Code's
extensions describe theirs, so that the settings panel lists them on
the plugin's page and changes them without typing where it can:
"settings": {
"goal": { "type": "integer", "minimum": 0, "maximum": 100000,
"default": 1000, "description": "Words a day" },
"show": { "type": "boolean", "default": true },
"style": { "type": "string", "enum": ["plain", "bars"], "default": "plain" },
"folder": { "type": "string", "examples": ["notes", "journal"] },
"kinds": { "type": "array", "items": { "enum": ["org", "markdown"] } },
"skip": { "type": "array", "default": [] }
}
A boolean is a switch, an integer steps in its range, a string
with an enum steps through its choices and one with examples
through them (anything may be typed), an array with an enum in
items is a list of choices each in or out, another array a list of
texts, and any other type is typed as JSON. A dotted key is in tables
within the plugin's: a language plugin describes the settings its
server is sent as settings.SECTION.NAME (settings.elixirLS.mixEnv).
A key the user's table holds that the manifest does not describe is
typed as JSON. For a language plugin the page also has its server and
each server's program and environment (servers.KEY.command,
servers.KEY.env).
Files, the network and programs are not a plugin's unless its manifest asks for them, with the permissions of §11.6:
| Permission | Reaches |
|---|---|
fs:read:workspace |
reading files in the projects' folders |
fs:write:workspace |
reading and writing there, moving files and putting them in the trash (fs-2, API 0.2.12) |
fs:read:all |
reading any file |
net:fetch:DOMAIN |
HTTP to that domain and its subdomains; * for any |
subprocess:PROGRAM |
running the program PROGRAM (a bare name: git) in a project's folder |
Without one, the plugin is not given the fs, net or process
interface at all, and a component that imports it is refused, the
interface named (D28). Paths are absolute, .. is refused, and links
are followed before the check, so one cannot lead out of a project. A
fetch does not make the editor wait: net::fetch returns at once, and
the response reaches a closure later, fetched on a thread of its own.
process::run (API 0.2.4) is the same for programs: it returns the
run's number at once, and how the program ended (its status, its two
outputs, each kept to 16 MB) reaches a closure later. The program is
the one the permission names, found as a language server is, on the
PATH, or where the user's setting programs.PROGRAM of the plugin
says; it runs without a shell (its arguments are a list, never quoted)
and without a terminal, in a project's folder (the first project's
when none is given), at most 16 at a time for a plugin, killed after
ten minutes, with process::kill, or when the plugin stops. The
installer's question names it: "Runs programs on this computer: git".
use kalem_plugin::process::{self, Command};
let status = Command::new("git").args(["status", "--porcelain=v2", "-z"]).cwd(root);
process::run(&status, |result| match result {
Ok(exit) if exit.status == Some(0) => { /* read exit.stdout */ }
Ok(exit) => { /* exit.stderr says why */ }
Err(e) => { /* the program could not start: e */ }
})?;
13. What a plugin shows
The same world imports ui, §11.4's kalem.ui: notify, the three
questions prompt, confirm and quick-pick, status bar items, and
panels. A question never makes the editor wait: the call returns at
once, and the answer comes later, a closure in Rust:
use kalem_plugin::ui::{self, Level};
ui::confirm("Archive the done tasks?", |yes| {
if yes {
ui::notify("Archived", Level::Info);
}
});
A panel is a tree of widgets (texts, buttons, inputs, checkboxes, list
and tree entries, progress bars, rows and columns) that the window and
the terminal each draw with their own; there is no webview (D11). A
plugin builds the tree with ui::Tree, adding each widget under one
already there, and replaces the panel's content with ui::set_panel;
what the user does to a widget reaches the panel's handler with the
widget's key. The host checks every tree before an editor sees it.
Both editors show them with their own means. A question is one of the
editors' own: a line is asked for in the palette (the terminal's
minibuffer), a yes or no and a choice from a list are offered as a list
in the palette, and the answer reaches the plugin through the command
plugin.answer. A status item stands in the status line, a click
running its command in the window. A panel opens with View > Plugin
Panel (view.pluginPanel, offered while a plugin has one), beside the
text or under it; in the window a click on a button, an entry, a
checkbox or an input reaches the plugin, and in both editors Panel
Actions (view.panelActions) lists the widgets one acts on, so a panel
is used from the keyboard too; an input's new text is asked for in the
palette. Cancelling a question tells the plugin nothing yet, a list
offers one choice even when the plugin allows several, and a password
is not hidden as it is typed.
A plugin may also write documents of its own (documents, API 0.2.5):
read-only text that the editor shows as a document, in a tab and the
list of open files as any other, as it shows the file manager's
listing, so that the cursor, search, selection, Vim's motions and
scrolling work on it unchanged. The plugin gives it an ID and a key
(the same ID and key show the same document again), a title, a
highlighter (diff) and a kind, its own ID, a dash and a name
(git-status): the text type that its commands' scopes and its keys
name, so that s or Tab do what the plugin says there and nowhere
else. It writes the text anew whenever it changes; only the part that
differs is replaced, the view keeps its place, and the cursor stays on
its line unless the plugin puts it elsewhere. There is nothing to undo
and no file to save. A command run in it finds the cursor with
editor::selected, and which document it is with documents::current.
Once the user closes it, the plugin's next write is refused, and the
plugin forgets it. Kalem shows the output of the project's run and
test commands (SPC p R, SPC p T) in such a document of its own,
kind kalem-output, and stops the command when it is closed.
Its keys there are bound with a when-clause naming the kind:
textType = git-status && (vimCommand || !vimActive)= applies in Vim's
command mode and in the Word-like profile, and such keys win over the
profile's and Vim's in the plugin's documents only. So do a plugin's
keys in the files of a language its manifest declares, bound with a
when-clause naming their text type (textType = journal=), as a major
mode's keys in Emacs: the plain text accounting plugin's Enter aligns a
posting's amount, as ledger-mode's does, before the new line. In Vim's
insert mode the Vim layer takes Enter in a plain text file before any
binding. A document a plugin
writes has no line numbers and no indentation guides: the plugin lays
it out.
use kalem_plugin::documents::{self, Spec};
let spec = Spec::new("git.status", root, "Git: org", "git-status").language("diff");
let doc = documents::open(&spec, &text, None)?;
// Later, as the repository changes:
if documents::set(doc, &text, None).is_err() { /* closed: forget it */ }
A plugin also marks the lines of files (decorations, API 0.2.5): the
added, changed and removed lines, which both editors draw beside the
lines, a colored bar (in the terminal ▎, and ▁ under a line after
which lines were removed); an added or changed line is also tinted under
its text, green or orange, so that the change shows where it is
(editor.highlight_changes turns the tint off). A plugin sets a file's marks whole, as the
file is on disk; the editor moves them with the edits made since, until
the plugin sets them again (the git plugin does on each save). Next
Change and Previous Change (edit.nextChange, edit.previousChange;
Doom's SPC g ] and SPC g [) go from one change to the next.
use kalem_plugin::decorations::{self, Mark};
decorations::set_gutter("/home/a/org/src/lib.rs", &[(12, Mark::Changed), (40, Mark::Added)])?;
A plugin also tells what its tool finds wrong in files (problems, API
0.2.14): errors and warnings, each at a line, a column and a length in
characters (column 0 for the whole line from its first non-blank,
length 0 for the rest of the line), with a severity, a message and the
tool's name. Both editors draw them as a language server's diagnostics:
the wavy line under the text, the line number's color, the problem on
the cursor's line in the status bar, the hover card and List Problems
(code.problems), beside the server's own when the file's language has
one, alone when it has none. They are drawn in the files of the
languages plugins declare. A plugin sets a file's problems whole, as
the file is on disk, and sets them again when they change, after a save;
they stay at their lines meanwhile. They ask for no permission: they
are drawn, never written.
use kalem_plugin::problems::{self, Problem, Severity};
problems::set("/home/a/books/2026.journal", &[Problem {
line: 12, column: 0, length: 0, severity: Severity::Error,
message: "This transaction is unbalanced".into(), source: Some("hledger".into()),
}])?;
A plugin also formats files (formatter, API 0.2.16). Its manifest
declares each formatter, an id and the text types it formats
("formatters": [{"id": "journal", "types": ["ledger", "hledger"]}]),
and Format Document and editor.format_on_save ask it for the whole
text of a file of those types, with the file's path and the type: it
answers with the formatted text, or refuses with a message and, when it
says, the byte of the text the cursor is put at. It comes after a
language server's formatting and a language plugin's commands.format,
and before the language's usual program; the formatters setting of
the type comes first, and its off turns it off too. Kalem replaces the
lines that changed, as one step to undo. A plugin busy with another
call is not waited for: the status bar says to format again. It asks
for no permission: the text is the editor's, and what it answers goes
into the document, which the user saves.
use kalem_plugin::formatter::{self, Refusal, Request};
fn format(id: &str, request: &Request) -> Result<String, Refusal> {
match id {
"journal" => Ok(align(&request.text)),
_ => Err(formatter::refusal("No such formatter", None)),
}
}
A plugin's document may be styled (styled-documents, API 0.2.6):
stretches of its text, in bytes, each with a color and bold, italic or
underlined, written with the text so that the two always agree. A color
is named, not given (red, green, yellow, blue, magenta,
cyan, muted, accent): each theme has its own shade of it, so the
document reads in the light and the dark theme; a terminal shows the
theme's shade when it shows colors in full, its own named colors
otherwise, and bold and underlines without colors. A span inside
another wins over it. The git plugin's status colors its files as
lazygit does.
use kalem_plugin::documents::{self, Color, StyledSpan, TextStyle};
let styles = [StyledSpan { start: 1, end: 3, style: TextStyle::color(Color::Red) }];
documents::open_styled(&spec, " M README.md\n", None, &styles)?;
A plugin's document may be drawn as a graph (graph-view, API
0.2.12): the plugin gives the nodes, each an id, a label, a group (the
nodes of a group share a color) and a weight (how large it is drawn),
the edges between them by their ids, a node to keep in the middle if
any, and the command run with {"node": id} when the user opens one.
The editor lays the nodes out (they push each other away, edges pull
their ends together), a few steps a frame until the layout settles, and
draws them: in the window as colored dots joined by lines, the labels
of the heavier ones and of those near the mouse written beside them; in
the terminal with Braille dots. Dragging pans, the wheel (or + and
-) zooms, a node dragged moves, a click or Tab chooses one, a double
click or Enter opens it. The document's text, written with the graph,
is what Show Source, search and a terminal show; written again, the
nodes still there keep their places. The graph plugin's Graph View and
Local Graph are such documents.
use kalem_plugin::documents::Spec;
use kalem_plugin::graph_view::{self, Edge, Graph};
let g = Graph {
nodes: vec![graph_view::node("a", "A", "pages", 3), graph_view::node("b", "B", "pages", 1)],
edges: vec![Edge { from: "a".into(), to: "b".into() }],
focus: None,
command: "notes.openNode".into(),
};
graph_view::open(&Spec::new("notes.graph", "", "Graph", "notes-graph"), &g, "A\nB\n")?;
A plugin's document may be drawn as a canvas instead (canvas-view,
API 0.2.16), its shapes where the plugin places them, for a whiteboard,
a canvas or a diagram with a layout of its own: each shape an id, a
kind (a box with rounded corners, an ellipse, text alone, a frame
around others, a picture of an image file in Kalem's projects, a pen's
line through points), its place and size, its text, its fill and line
(a color's name or #rrggbb, else the theme's), and where opening it
leads; connectors between shapes by their ids (from the side of one
facing the other) or between points, with a label, arrows and a color.
The window draws them with lines, fills, pictures and their text
wrapped in them, in a font that grows with the zoom; the terminal draws
the outlines, ellipses, lines and arrows with Braille dots and writes
the text in the cells inside each shape, a picture's name in place of
it. Dragging pans, the wheel (or + and -) zooms, 0 fits, a click
or Tab chooses a shape (Tab goes through those that lead somewhere, in
reading order), a double click or Enter opens it: the canvas's command
runs with {"shape": target}. The document's text, written with the
canvas, is what Show Source, search and copying show; written again,
the view stays where it looks. The graph plugin draws Logseq's
whiteboards and Obsidian's canvases so.
use kalem_plugin::canvas_view::{self, Canvas, ShapeKind};
use kalem_plugin::documents::Spec;
let mut page = canvas_view::shape("a", ShapeKind::Box(8.0), [0.0, 0.0, 240.0, 100.0], "A page");
page.target = Some("page:A page".into());
let c = Canvas {
shapes: vec![page],
connectors: Vec::new(),
command: "notes.openShape".into(),
};
canvas_view::open(&Spec::new("notes.board", "", "Board", "notes-board"), &c, "A page\n")?;
The first plugins planned:
word count, Pomodoro, an export filter, a table function, a jira:
link type, a Mermaid renderer, the kanban board, and modes for
todo.txt, TaskPaper, Fountain, gemtext, Djot and more, with the
language packs for JSON, YAML, TOML, XML, diffs, ledgers and gettext.