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.
The manifest
Section titled “The manifest”plugin.json, at the top level of the archive.
| Field | Required | Meaning |
|---|---|---|
format | yes | Always "wmkeyboard-plugin". The one tag that identifies the file. |
version | no | Container format version. 1. |
id | yes | Unique, lowercase, 3–64 characters of a-z 0-9 . _ -, starting alphanumeric. Becomes the plugin’s folder on the device. Reverse-DNS by convention. |
name | yes | Shown everywhere. Trimmed to 40 characters, control and direction-override characters removed. |
pluginVersion | yes | Your version, e.g. "1.2.0". |
author | no | Shown on the install screen. |
description | no | One or two sentences. |
apiVersion | yes | 4. 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. |
language | no | "python", the only language there is. Leave it out or set it; nothing else is accepted. |
entry | no | The script inside the archive. Defaults to main.py. |
permissions | no | See Permissions. Usually []. |
actions | no | Buttons on the bar over selected text. See Selection actions. Needs the selection permission. |
panel | no | false for a plugin made of actions alone: it has no render() and is left out of the keyboard’s plugin list. Defaults to true. |
Lua is refused
Section titled “Lua is refused”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.
Your functions
Section titled “Your functions”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 += 1Leave 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.
Selection actions
Section titled “Selection actions”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:
"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 field | Meaning |
|---|---|
id | Unique within the plugin, up to 32 characters of a-z 0-9 _ -. |
name | The button’s label. Trimmed to 24 characters. |
summary | One line for the settings list. Optional. |
icon | A key icon name, the same names a layout key takes (lock, translate, link). Optional; a plugin glyph otherwise. |
replaces | false 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.
Widgets
Section titled “Widgets”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": [...]}.
Layout
Section titled “Layout”ui.column(child, child, ...) # stacked verticallyui.row(child, child, ...) # side by side, equal widthsui.spacer(height=8) # vertical gap, 0-64ui.divider() # a horizontal linecolumn 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.
Controls
Section titled “Controls”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.
Progress
Section titled “Progress”ui.progress() # an indeterminate barEvents
Section titled “Events”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.type | Extra | Fired when |
|---|---|---|
"click" | none | A 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.
Always available
Section titled “Always available”wm.api_version # 4wm.plugin_id # your manifest idwm.plugin_version # your manifest pluginVersionwm.log(*values) # a line in your log, readable in Settingsprint 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 widgetsApplied 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.
With the storage permission
Section titled “With the storage permission”wm.storage.get(key, default=None) # the string, or defaultwm.storage.set(key, value) # strings onlywm.storage.remove(key)wm.storage.keys() # a list of stringsStrings 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.
With the reminders permission
Section titled “With the reminders permission”wm.reminders.set(id, seconds, text)wm.reminders.cancel(id) # True when there was onewm.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.
Not present, and never will be
Section titled “Not present, and never will be”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.
The Python you get
Section titled “The Python you get”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.
| Module | What it has |
|---|---|
math | sqrt, 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 |
json | dumps and loads. Bad JSON raises an error. |
re | search, match, fullmatch, findall, finditer, sub, subn, split, escape, and the flags I, M, S (also spelled out). Matching spends steps like any loop. |
time | time() 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. |
random | random, randint, randrange, choice, choices, shuffle, sample, uniform, seed |
sys | version |
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.
Limits
Section titled “Limits”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 size | 256 KB |
| Archive size | 1 MB, 16 entries |
| Steps | 3M on load, 2M per event, 400k per render, 3M per selection action |
| Time | 3 s on load, 2 s per event, 0.5 s per render, 3 s per selection action |
| Lists, dicts and sets | 10,000 items each |
| Strings | 100,000 characters |
| Recursion | 300 calls deep |
| Selection action result | 64K characters |
| Widgets | 256 nodes, 12 deep, 8 tabs (dropped, and reported) |
| Widget text | 2 KB per node, 64 KB per tree (truncated, and reported) |
| Storage | 128 keys, 64 chars per key, 8 KB per value, 64 KB total |
| Reminders | 64 pending, 64 chars per id, 200 chars of text, up to ten years ahead |
| Log | 200 lines of 512 characters (truncated quietly) |
| Input box | 8 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.
