Skip to content
WM KeyboardWM Keyboard
Accessibility

Writing your first plugin

Build the Cipher Tool in Python: a plugin panel with two tabs, made with nothing but a text editor.

By the end of this you will have built the Cipher Tool: a keyboard panel with two tabs that encodes and decodes text. Plugins are written in Python. If you have written any programming language at all, you already know enough; the Python here is the plain kind, functions, if and for.

To follow this walkthrough by hand, you need a text editor and a way to make a ZIP file. That is the whole toolchain for it.

  • Directorycipher/
    • plugin.json who your plugin is
    • main.py what it does

plugin.json says who your plugin is:

plugin.json
{
"format": "wmkeyboard-plugin",
"version": 1,
"id": "com.yourname.cipher",
"name": "Cipher Tool",
"pluginVersion": "1.0.0",
"author": "Your Name",
"description": "Caesar and Vigenere ciphers.",
"apiVersion": 4,
"entry": "main.py",
"permissions": []
}

The id has to be unique, lowercase, and look like a reverse domain name. It becomes the folder your plugin lives in on the device. permissions is empty because this plugin does not need anything, and most do not.

main.py says what it does:

main.py
def render():
return ui.label("Hello from a plugin")

ui is already there. So is wm, the rest of what the keyboard offers a plugin. Neither needs an import.

A .wmplugin is a ZIP with those two files at the top level:

Terminal window
cd cipher && zip ../cipher.wmplugin plugin.json main.py

Nothing else to it. Plugins stay off until you turn them on, so start there and switch Allow plugins on:

WM Keyboard  Tools  Plugins  Manage plugins

The first time, the keyboard downloads the library that runs plugins. Copy cipher.wmplugin to your phone. Open it from a file manager. Confirm the install. Then open the Plugins tool on the keyboard and tap Cipher Tool. Your greeting appears.

Two functions. That is the entire model.

def render(): # describe what should be on screen right now
...
def on_event(e): # react to the user doing something
...

The keyboard calls render(), draws what it returns, and waits. When the user taps something, it calls on_event(e), then calls render() again and redraws.

So you never update the screen yourself. You change a variable and describe the result. If you have used React, this will feel familiar. If you have not, the whole rule is render() says what things look like, on_event says what changes.

render() returns dicts. The ui helpers build them for you:

ui.button("go", "Go")
# is exactly
{"type": "button", "id": "go", "text": "Go"}

Replace main.py with:

main.py
clicks = 0
def on_event(e):
global clicks
if e.type == "click" and e.id == "go":
clicks += 1
def render():
return ui.column(
ui.label(f"Clicked {clicks} times"),
ui.button("go", "Click me", style="primary"),
)

ui.column stacks things vertically. Every control needs an id, which is what comes back to you in e.id.

Two Python details are doing work here. clicks lives at the top of the file, so it keeps its value between events; your plugin’s state is just its module globals. And on_event says global clicks before it changes it. Without that line, clicks += 1 would put its result in a new local variable that vanishes when the function returns, and the count on screen would never move. No error tells you so, which makes it the easiest mistake to make here. Any function that assigns to a global needs the global line. One that only reads it, like render(), does not.

e is a dict, so e["type"] works too. Attribute reads are a shortcut the keyboard’s Python allows on every dict.

Rebuild the ZIP. Reinstall it. Tap the button a few times.

A plugin cannot read what you are typing in your messaging app. There is no API for it, on purpose. Instead you draw your own box, and the user types or pastes into it:

main.py
message = ""
def on_event(e):
global message
if e.type == "input_changed" and e.id == "message":
message = e.value
def render():
return ui.column(
ui.input("message", label="Message", placeholder="Type here"),
ui.label("You wrote: " + message),
)

Tap the box and the keyboard types into it instead of into your app. A Paste button sits beside it for text you already have. You get an input_changed event with the new contents each time it changes.

A Caesar cipher shifts every letter along the alphabet:

LOWER = "abcdefghijklmnopqrstuvwxyz"
UPPER = LOWER.upper()
def caesar(text, by):
out = ""
for c in text:
if c in LOWER:
out += LOWER[(LOWER.index(c) + by) % 26]
elif c in UPPER:
out += UPPER[(UPPER.index(c) + by) % 26]
else:
out += c
return out

Reading that: the loop walks the text one character at a time. A lowercase letter is looked up in LOWER, moved by places, and wrapped around with % 26, so z moved by one is a. Uppercase works the same against UPPER. Anything else, a space or a digit or an accented letter, passes through unchanged. Python’s % always gives a result from 0 to 25 here, even when by is negative, which is what lets decoding be the same function with the shift turned around.

Now wire it up:

main.py
LOWER = "abcdefghijklmnopqrstuvwxyz"
UPPER = LOWER.upper()
message = ""
shift = "3"
output = ""
def caesar(text, by):
out = ""
for c in text:
if c in LOWER:
out += LOWER[(LOWER.index(c) + by) % 26]
elif c in UPPER:
out += UPPER[(UPPER.index(c) + by) % 26]
else:
out += c
return out
def shift_amount():
try:
return int(shift)
except ValueError:
return 0
def on_event(e):
global message, shift, output
if e.type == "input_changed":
if e.id == "message":
message = e.value
elif e.id == "shift":
shift = e.value
elif e.type == "click":
if e.id == "encode":
output = caesar(message, shift_amount())
elif e.id == "decode":
output = caesar(message, -shift_amount())
def render():
return ui.column(
ui.input("message", label="Message", placeholder="Type or paste"),
ui.input("shift", label="Shift", placeholder="3"),
ui.row(
ui.button("encode", "Encode", style="primary"),
ui.button("decode", "Decode"),
),
ui.output(output, id="result", mono=True),
)

int(shift) raises a ValueError when the box holds something that is not a number, so shift_amount catches that and uses 0. An uncaught error would stop the event and show it on the panel; the plugin would keep running, but the tap would do nothing.

ui.row puts things side by side. ui.output is the one to know: it is a result block, and the keyboard draws an Insert button under it that puts the text into whatever the user is writing in. That is the only way a plugin’s output reaches their text. A plugin cannot type on its own. The user taps Insert.

Rebuild the ZIP. Reinstall it. Type something. Tap Encode, then Insert.

The finished demo has a second cipher on its own tab. Vigenère is Caesar with the shift taken from a repeating keyword, one letter of the key per letter of the message:

def vigenere(text, key, direction):
shifts = [LOWER.index(c) for c in key.lower() if c in LOWER]
if not shifts:
return text
out = ""
i = 0
for c in text:
if c in LOWER or c in UPPER:
out += caesar(c, direction * shifts[i % len(shifts)])
i += 1
else:
out += c
return out

ui.tabs takes an id and then one ui.page per tab. A page takes a title and then its children, the same way ui.column does:

main.py
def result():
if output == "":
return ui.label("Type something and pick a button.", style="caption")
return ui.output(output, id="result", mono=True)
def render():
return ui.tabs("cipher",
ui.page("Caesar",
ui.input("message", label="Message", placeholder="Type or paste"),
ui.input("shift", label="Shift", placeholder="3"),
ui.row(
ui.button("caesar_encode", "Encode", style="primary"),
ui.button("caesar_decode", "Decode"),
),
result(),
),
ui.page("Vigenere",
ui.input("message", label="Message", placeholder="Type or paste"),
ui.input("keyword", label="Keyword", placeholder="lemon"),
ui.row(
ui.button("vigenere_encode", "Encode", style="primary"),
ui.button("vigenere_decode", "Decode"),
),
result(),
),
)

Both pages draw a box with the id message, so the text stays when you switch tabs. The buttons now have one id per cipher, and on_event grows to match:

def on_event(e):
global message, shift, keyword, output
if e.type == "input_changed":
if e.id == "message":
message = e.value
elif e.id == "shift":
shift = e.value
elif e.id == "keyword":
keyword = e.value
elif e.type == "click":
if e.id == "caesar_encode":
output = caesar(message, shift_amount())
elif e.id == "caesar_decode":
output = caesar(message, -shift_amount())
elif e.id == "vigenere_encode":
output = vigenere(message, keyword, 1)
elif e.id == "vigenere_decode":
output = vigenere(message, keyword, -1)
elif e.type == "tab_selected":
# A result from the other cipher would only confuse.
output = ""

Add keyword = "" next to the other globals at the top. The full source is plugins-src/cipher-tool/main.py in the addon repository.

print() and wm.log() both write to your plugin’s log. That is your only window into a running plugin, so use it freely. Tap your plugin in the installed list and read the last 40 lines there:

WM Keyboard  Tools  Plugins  Manage plugins  your plugin

If the script fails, the panel shows the error with its line number and the plugin stays loaded. Fix the script. Rebuild the ZIP. Reinstall it.

The keyboard’s Python is a small one, the same subset Clink runs. Functions, lambdas, comprehensions, f-strings, slicing and try are all there; class, with, yield and decorators are not. A script that uses one is refused with a message that names it. The API reference has the whole list.

Anyone can install a .wmplugin file directly. To list it in an addon repository, add an entry pointing at the file:

wmkeyboard-repo.json (excerpt)
{
"id": "cipher-tool",
"type": "plugin",
"name": "Cipher Tool",
"version": "1.0.0",
"author": "Your Name",
"description": "Caesar and Vigenere ciphers.",
"path": "plugins/cipher-tool.wmplugin",
"sha256": "…",
"sizeBytes": 1693,
"license": "MIT"
}

The sha256 is required for plugins, unlike every other addon type. The app will not install code it cannot verify. tools/build_index.py in the sample repository fills it in for you, and tools/validate.py checks it.

Also worth a look: the UI Kitchen Sink demo. It puts every widget on screen at once, with a live log of the events they produce.