Skip to content
WM KeyboardWM Keyboard
Accessibility

Plugin API reference

Everything a Python plugin can use: manifest, hooks, widgets, events, wm.*, modules, limits.

Everything a plugin can use. If it is not here, it does not exist.

Plugins are written in Python, a subset of it that matches PyMini, the small interpreter Clink uses. WM Keyboard runs it on its own Rust port of PyMini, the same one on the phone and in the web editor’s live preview. The Python you get lists what that subset has.

plugin.json, at the top level of the archive.

FieldRequiredMeaning
formatyesAlways "wmkeyboard-plugin". The one tag that identifies the file.
versionnoContainer format version. 1.
idyesUnique, lowercase, 3–64 characters of a-z 0-9 . _ -, starting alphanumeric. Becomes the plugin’s folder on the device. Reverse-DNS by convention.
nameyesShown everywhere. Trimmed to 40 characters, control and direction-override characters removed.
pluginVersionyesYour version, e.g. "1.2.0".
authornoShown on the install screen.
descriptionnoOne or two sentences.
apiVersionyes4. A higher number than the app knows is refused. Left out, it counts as 1, which a manifest without language cannot be; see Lua is refused.
languageno"python", the only language there is. Leave it out or set it; nothing else is accepted.
entrynoThe script inside the archive. Defaults to main.py.
permissionsnoSee Permissions. Usually [].
actionsnoButtons on the bar over selected text. See Selection actions. Needs the selection permission.
panelnofalse for a plugin made of actions alone: it has no render() and is left out of the keyboard’s plugin list. Defaults to true.

Plugins were written in Lua before 0.6.6. A manifest that says "language": "lua", or that leaves language out and gives an apiVersion of 3 or lower, is a Lua plugin, and the install is refused with this reason:

written in Lua, which WM Keyboard no longer runs; look for its Python version.

A manifest that says "language": "python" with a lower apiVersion still installs. That is how an action plugin made from a Clink action before 0.6.6 looks; a Clink action converted today gets "apiVersion": 4. What happened to Lua plugins already on a phone is under Lua plugins were retired.

def render(): # a widget, or a list of widgets
...
def on_event(e): # optional
...
def transform(text, action): # only for selection actions
...

render() is called after loading and after every event. on_event(e) is called when the user does something. Change your variables in on_event, describe the result in render().

All three are plain module-level functions. There is no registration step.

State lives in module globals, the variables at the top level of your script. They keep their values for as long as the panel is open. A function that assigns to one has to say so with global first, the way Python always asks:

count = 0
def on_event(e):
global count
count += 1

Leave the global line out and the assignment makes a local variable instead, with no error to tell you.

wm and ui are globals too. Neither needs an import.

A plugin can add its own buttons to the bar that appears over selected text. List them in the manifest, ask for the selection permission, and write transform:

plugin.json (excerpt)
"apiVersion": 4,
"permissions": ["selection"],
"actions": [
{ "id": "rot13", "name": "ROT13", "summary": "Shift each letter 13 places", "icon": "lock" },
{ "id": "quote", "name": "Wrap in quotes" }
]
def transform(text, action): # a string, or None to leave the text alone
if action == "rot13":
return rot13(text)
if action == "quote":
return "“" + text + "”"

A plugin with a single action can take one parameter, transform(text).

When the user selects text and taps one of your buttons, the keyboard loads your script in a fresh interpreter, calls transform with the selected text and the action’s id, and puts the string you return in place of the selection. Undo on the bar takes it back. Nothing is kept between taps except what you write to wm.storage: a global you set during one call is gone by the next.

Action fieldMeaning
idUnique within the plugin, up to 32 characters of a-z 0-9 _ -.
nameThe button’s label. Trimmed to 24 characters.
summaryOne line for the settings list. Optional.
iconA key icon name, the same names a layout key takes (lock, translate, link). Optional; a plugin glyph otherwise.
replacesfalse keeps the selection and adds your result after it. Defaults to true.
input"selection", the default, or "none" to be called with an empty string.

Up to 16 actions. A call gets the same wm as the panel, without wm.ui.set_input, since there is no panel for it to write into. It has the same step and time allowance as loading your script, because every tap loads it. The user can switch each action off in WM Keyboard  Advanced  Selection actions, where actions from plugins come after the built-in ones. They also appear in the WM Keyboard entry of other apps’ selection menus.

A plugin can have a panel and actions at once, the way the Selection actions template keeps the quote style its action uses. One with actions and no panel sets "panel": false. A Clink action installs as exactly that: one action, no panel.

Built by ui.*, which return plain dicts. You can write the dicts yourself if you prefer: every builder below gives a dict with a type key and one key per parameter, named as the parameter is. column and row keep their children under children, tabs its pages under pages, and a page is {"title": ..., "children": [...]}.

ui.column(child, child, ...) # stacked vertically
ui.row(child, child, ...) # side by side, equal widths
ui.spacer(height=8) # vertical gap, 0-64
ui.divider() # a horizontal line

column and row also take one list, which is the easy way to build children in a loop:

ui.column([ui.label(item) for item in items])
ui.label(text, style=None) # style: "title", "body" or "caption"

body is the default. Labels are not interactive and have no id.

ui.output(
text,
id=None,
mono=False, # monospace, for anything with alignment
insertable=True, # show the Insert button
copyable=True, # show the Copy button
)

ui.output is how your results reach the user’s text. The keyboard draws Insert and Copy buttons under it. A tap on Insert puts the text where they are writing. There is no function that types for them.

ui.button(id, text, style=None, enabled=True)
ui.toggle(id, label, checked=False)
ui.input(id, label=None, placeholder=None)

style="primary" highlights a button. Anything else is plain. enabled=False grays one out.

ui.input carries no value. The keyboard owns what is in the box: a tap points the keys at it, and you are told the contents through an input_changed event. Use wm.ui.set_input(id, text) to write to one. This is why a plugin never sees a keystroke. It is handed the whole contents of its own box after each one, and nothing else.

ui.tabs("modes",
ui.page("First", child, child),
ui.page("Second", child),
)

ui.tabs(id, *pages) takes its pages after the id, and ui.page(title, *children) its children after the title. Top level only, up to 8 pages. Which page is showing is the keyboard’s business. You get a tab_selected event if you care.

ui.date(
id,
label=None,
value=None, # seconds since 1970, as time.time() or wm.timestamp() give; None for no date yet
time=False, # pick a time of day too
step=15, # minutes the smaller time buttons move, 1-60
)

The keyboard draws the date between a back and a forward arrow, the time (with time=True) between buttons that move it by step minutes and by an hour, and Today and Clear under them. With no value it shows Set date, which picks today, or the next whole hour when time is on.

Like ui.input, the widget keeps nothing. Each press sends a date_changed event with the new value, and the next render draws whatever you kept. Without time the value is the start of a day. Days move on the calendar in the phone’s time zone, so 9:00 stays 9:00 across a clock change.

ui.progress() # an indeterminate bar

on_event(e) receives a dict. It always has type and id; some events carry more. Read them as e.type or e["type"], whichever you like.

e.typeExtraFired when
"click"noneA button was tapped.
"toggle"e.value (True or False)A toggle was flipped.
"input_changed"e.value (a string)The contents of one of your boxes changed.
"tab_selected"e.index (an int, from 0)A tab was picked.
"date_changed"e.value (seconds, or None)A date widget moved, or was cleared.

input_changed fires per keystroke into your own input widget. That is how a live preview works. It is bounded to your panel: you are told what is in your box, never what is typed anywhere else.

The complete host API.

wm.api_version # 4
wm.plugin_id # your manifest id
wm.plugin_version # your manifest pluginVersion
wm.log(*values) # a line in your log, readable in Settings

print writes to the same log. A line is cut at 512 characters.

wm.ui.set_input(id, text) # write to one of your own input widgets

Applied after your handler returns. Capped at 8 KB. Panel only: a selection action has no boxes to write to.

wm.timestamp(year, month, day, hour=12, minute=0, second=0)

That moment in the phone’s time zone, in seconds since 1970. A field past its range rolls over, so day=d + 1 on the last of the month is the first of the next, and month=13 is January of the following year. This is what a date widget and a reminder take.

wm.storage.get(key, default=None) # the string, or default
wm.storage.set(key, value) # strings only
wm.storage.remove(key)
wm.storage.keys() # a list of strings

Strings only, so use json.dumps and json.loads for anything structured. Local to your plugin, local to the device, deleted when the user uninstalls you. set raises ValueError when you are over quota. Catch it and say something rather than losing the data silently:

try:
wm.storage.set("items", json.dumps(items))
except ValueError as err:
status = "Not saved: " + str(err)

If the manifest did not declare storage, wm.storage is None.

wm.reminders.set(id, seconds, text)
wm.reminders.cancel(id) # True when there was one
wm.reminders.list() # [{"id": ..., "time": ..., "text": ...}, ...]

At seconds, a time as wm.timestamp() or time.time() gives it, the app shows text in a notification with your plugin’s name as its title. Setting an id that is already set replaces it. list gives every reminder not yet shown, soonest first; a reminder leaves the list once it is shown.

Your script does not run to show it. The app keeps the id, the time and the text, and posts the notification itself, so a reminder fires with the keyboard closed. It does not fire while plugins, or your plugin, are switched off; a reminder that comes due then is dropped.

set raises ValueError for a time already past, for empty text, or past 64 reminders. Text is cut to 200 characters, with control and direction-override characters removed.

If the manifest did not declare reminders, wm.reminders is None. A plugin can check for that and work without it.

wm.text, wm.clipboard, wm.http, wm.net, wm.files. None of these exists. There is no API for reading what the user types, reading the field, reading the clipboard, or reaching the network. See Security.

A subset of Python 3 that matches PyMini.

The language: functions with default and *args parameters, lambda, if/elif/else, for and while with break, continue and else, try/except/finally and raise, global, del, list, dict and set comprehensions, f-strings, slicing, tuple unpacking, chained comparisons and import.

Not there: class, with, yield, async and await, match, decorators, nonlocal, assert and **kwargs. A script that uses one is refused with a message that names it, not a vague syntax error.

Built-in functions: abs, all, any, bin, bool, callable, chr, dict, divmod, enumerate, filter, float, format, hex, int, isinstance, len, list, map, max, min, oct, ord, pow, print, range, repr, reversed, round, set, sorted, str, sum, tuple, type and zip. There is no open, eval, exec, compile or __import__.

Strings count characters as you see them, so an emoji with a skin tone or a letter with a combining accent is one character to len, indexing and for. Two strings that look the same compare equal even when one spells an accent as a separate mark.

Dicts answer attribute reads. d.name is d["name"] when the key is there, and otherwise a dict method such as d.get. That is why e.type works on an event. Writing still takes brackets: d["name"] = value.

Modules: import reaches these six and nothing else.

ModuleWhat it has
mathsqrt, floor, ceil, trunc, fabs, pow, exp, log, log2, log10, the trigonometric functions, degrees, radians, hypot, gcd, lcm, factorial, isnan, isinf, isfinite, copysign, fmod, comb, prod, and pi, e, tau, inf, nan
jsondumps and loads. Bad JSON raises an error.
research, match, fullmatch, findall, finditer, sub, subn, split, escape, and the flags I, M, S (also spelled out). Matching spends steps like any loop.
timetime() and monotonic() in seconds; parts() or parts(seconds), a dict of year, month, day, hour, minute, second, weekday (Monday is 0) and yearday in the phone’s time zone; format(pattern) or format(pattern, seconds) with a Unicode date pattern such as "EEE d MMM yyyy", which writes day and month names in English. Left out, seconds is now.
randomrandom, randint, randrange, choice, choices, shuffle, sample, uniform, seed
sysversion

time.format takes the patterns Android and Java use (yyyy, MM, d, HH, mm, EEE, MMMM and so on), not C’s %Y-%m-%d.

Exceeding one of these is something you can see: either an error, or a note on the panel saying what had to be dropped. Two are quiet, and the table says which.

Script size256 KB
Archive size1 MB, 16 entries
Steps3M on load, 2M per event, 400k per render, 3M per selection action
Time3 s on load, 2 s per event, 0.5 s per render, 3 s per selection action
Lists, dicts and sets10,000 items each
Strings100,000 characters
Recursion300 calls deep
Selection action result64K characters
Widgets256 nodes, 12 deep, 8 tabs (dropped, and reported)
Widget text2 KB per node, 64 KB per tree (truncated, and reported)
Storage128 keys, 64 chars per key, 8 KB per value, 64 KB total
Reminders64 pending, 64 chars per id, 200 chars of text, up to ten years ahead
Log200 lines of 512 characters (truncated quietly)
Input box8 KB (truncated quietly)

A step is one statement, one turn of a loop, or one step of a regular expression’s matching. Going past the container, string or recursion limit stops the call with an error that try cannot catch.

A plugin that runs out of steps or time is stopped, the user is told, and it takes a strike. So does one the watchdog has to abandon for going unresponsive. Two strikes and the plugin is switched off until they turn it back on, which also forgives the strikes. That last part is a setting: Switch off a plugin that hangs, on the Plugins screen, on by default. With it off the strikes are still counted and still shown, and only the automatic switch-off goes away.