-
Notifications
You must be signed in to change notification settings - Fork 4
Writing your first plugin
By the end of this you will have built the Cipher Tool: a keyboard panel with two tabs that encodes and decodes text. No Lua experience assumed. If you have written any programming language at all, you already know enough.
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.
Skip the toolchain entirely
WM Keyboard also ships an in-app plugin editor with live preview and starter templates, including one that builds this exact Cipher Tool. Its Install button validates the manifest and installs the plugin directly. No ZIP file is needed. This tutorial still walks through the manual path below, since it's the clearest way to see how the pieces fit together.
Tools / Plugins / Write a plugin
- cipher/
- plugin.json who your plugin is
- main.lua what it does
plugin.json says who your plugin is:
{
"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": 1,
"entry": "main.lua",
"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.lua says what it does:
function render()
return ui.label { text = "Hello from a plugin" }
endA .wmplugin is a ZIP with those two files at the top level:
cd cipher && zip ../cipher.wmplugin plugin.json main.luaNothing else to it. Plugins stay off until you turn them on, so start there and switch Allow plugins on:
Tools / Plugins / Manage 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.
Keep the file manager open
You will replace the ZIP and reinstall it as you go. Each round takes about ten seconds.
Two functions. That is the entire model.
function render() -- describe what should be on screen right now
function on_event(e) -- react to the user doing somethingThe 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 tables. The ui.* helpers just build them for you:
ui.button { id = "go", text = "Go" }
-- is exactly
{ type = "button", id = "go", text = "Go" }Replace main.lua with:
local clicks = 0
function on_event(e)
if e.type == "click" and e.id == "go" then
clicks = clicks + 1
end
end
function render()
return ui.column {
ui.label { text = "Clicked " .. clicks .. " times" },
ui.button { id = "go", text = "Click me", style = "primary" },
}
endui.column stacks things vertically. .. joins strings in Lua. Every control
needs an id, which is what comes back to you in e.id.
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:
local message = ""
function on_event(e)
if e.type == "input_changed" and e.id == "message" then
message = e.value
end
end
function render()
return ui.column {
ui.input { id = "message", label = "Message", placeholder = "Type here" },
ui.label { text = "You wrote: " .. message },
}
endTap 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. In Lua:
local function caesar(text, by)
by = by % 26
return (text:gsub("%a", function(c)
local base = c:match("%u") and 65 or 97
return string.char((c:byte() - base + by) % 26 + base)
end))
endReading that: gsub replaces every match of a pattern. %a means "any letter",
%u means "an uppercase letter". base is 65 for uppercase and 97 for
lowercase (the character codes for A and a), so the arithmetic wraps within
the right case. The outer brackets around text:gsub(...) throw away the second
value gsub returns. Lua functions can return several, and here we only want
the string.
Now wire it up:
local message = ""
local shift = "3"
local output = ""
local function caesar(text, by)
by = by % 26
return (text:gsub("%a", function(c)
local base = c:match("%u") and 65 or 97
return string.char((c:byte() - base + by) % 26 + base)
end))
end
function on_event(e)
if e.type == "input_changed" then
if e.id == "message" then message = e.value end
if e.id == "shift" then shift = e.value end
elseif e.type == "click" then
local by = tonumber(shift) or 0
if e.id == "encode" then output = caesar(message, by) end
if e.id == "decode" then output = caesar(message, -by) end
end
end
function render()
return ui.column {
ui.input { id = "message", label = "Message", placeholder = "Type or paste" },
ui.input { id = "shift", label = "Shift", placeholder = "3" },
ui.row {
ui.button { id = "encode", text = "Encode", style = "primary" },
ui.button { id = "decode", text = "Decode" },
},
ui.output { id = "result", text = output, mono = true },
}
endui.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:
function render()
return ui.tabs {
id = "cipher",
ui.page {
title = "Caesar",
ui.input { id = "message", label = "Message" },
-- ...the rest of the Caesar page
},
ui.page {
title = "Vigenere",
-- ...
},
}
endPages go in the array part of the table, which is why they have no = in front
of them. The full source, including Vigenere, is
plugins-src/cipher-tool/main.lua
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:
Tools / Plugins / Manage plugins / your plugin
If the script fails, the panel shows the error and the plugin stays loaded. Fix the script. Rebuild the ZIP. Reinstall it.
Two limits worth knowing before you hit them
Your code gets 20 million instructions and two seconds per event, and a tighter 4 million and half a second for each render. An accidental infinite loop is stopped and reported rather than left to hang the keyboard.
Running out of instructions or time counts a strike, and so does going unresponsive for long enough that the watchdog gives up. Two strikes and the plugin is switched off until you turn it back on. You can stop that from happening: turn Switch off a plugin that hangs off under Tools / Plugins / Manage plugins, where it is on by default. The strikes are still counted and still shown, so you keep the evidence and give up only the automatic switch-off. That is the trade you want while you are writing plugins, or when a slow device is the real cause.
Anyone can install a .wmplugin file directly. To list it in an addon
repository, add an entry pointing at the file:
{
"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.
- API reference Every widget, event and function.
- Permissions How to ask for storage, and why that is the only thing to ask for.
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.
- Home
- Getting started
- Typing
- Languages
- Suggestions & correction
- Emoji & expression
-
Tools
- Clipboard manager
- Voice typing
- Offline voice (Whisper)
- Handwriting
- Scanner (OCR, QR, documents)
- Camera tool
- Translate
- Search, Wikipedia & dictionary
- Media controls
- AI chat
- AI tools
- Utility tools
- Snippets & text expansion
- Text editing & cursor tools
- Instruments
- Trackpad
- Calendar
- App launcher
- Learn from text
- Vocabulary
- Resize the keyboard
- The toolbar
- Themes & appearance
- Addons
- Plugins
- Privacy & security
- Accessibility
-
Reference
- Gesture cheat sheet
- Typing
- Hardware shortcuts
- Deep links & launcher shortcuts
- Key press
- File formats
- Dictionaries & words
- Importing from other keyboards
- Languages
- Importing from Espanso
- Appearance
- Keyboard themes
- Troubleshooting
- Glossary
- Keyboard font
- Easter eggs
- Icons
- Layout & size
- Key layouts
- Rows & bars
- Keyboard modes
- Emoji
- Phone number formats
- Tools
- Addons & plugins
- Reference - Accessibility
- Fingerprint lock
- Reference - Data saver
- Reference - Permissions
- Privacy
- Reference - Selection actions
- Servers
- Reference - Backup & restore
- About & diagnostics
- Statistics
- Settings A–Z
- Development