Skip to content
WM KeyboardWM Keyboard
Accessibility

Writing your first plugin

Build the Cipher Tool — a two-tab plugin panel — 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. No Lua experience assumed. If you have written any programming language at all, you already know enough.

You need a text editor and a way to make a ZIP file. That is the whole toolchain.

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

plugin.json — 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": 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; most do not.

main.lua — what it does:

main.lua
function render()
return ui.label { text = "Hello from a plugin" }
end

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

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

Nothing else to it. Put cipher.wmplugin on your phone, open it from a file manager, and confirm the install. Turn plugins on first:

WM KeyboardToolsPlugins

Open the Plugins tool on the keyboard, tap Cipher Tool, and you should see your greeting.

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 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 rule is simply 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:

main.lua
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" },
}
end

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

Rebuild, reinstall, 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.lua
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 },
}
end

Tap the box and the keyboard starts typing into it instead of into your app; there is a Paste button 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))
end

Reading 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:

main.lua
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 },
}
end

ui.row puts things side by side. ui.output is a result block — and it is the important one: it comes with an Insert button 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, reinstall, type something, tap Encode, tap Insert.

The finished demo has a second cipher on its own tab:

main.lua
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",
-- ...
},
}
end

Pages 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. Read it here:

WM KeyboardToolsPluginsyour plugin

If the script fails, the panel shows the error and the plugin stays loaded — fix it, rebuild, reinstall.

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 — every widget on screen at once, with a live log of the events they produce.