Why the pieces are shaped the way they are. This file exists so the source can stay short: a module header says what a module is, and links here for why. Dated decisions with a "we considered X and rejected it" shape live in DECISIONS.md instead; this file is the standing explanation.
See also PLAN.md for the product-level design and PERMISSIONS.md for what each platform demands of the user.
- Settings UI: why a separate process
- Smart commands: why text triggers, not hotkeys
- Hotkeys with no key in them
- Choosing a layout backend on Linux
- The correction path
- Key gate: holding keystrokes back
- Plug-ins
Subsystems not listed here keep their reasoning in the module header, because it is short enough to live next to the code. Add a section when a header starts growing a second argument — that is the signal it has outgrown the source.
The tray (tao::EventLoop + tray-icon) and iced both want to own
the platform's main thread on macOS — NSApplication is a singleton
and the tray already binds it. Rather than choreograph a thread swap,
the Settings UI ships as a CLI subcommand, poltertype --settings,
which the tray spawns as a child process.
The two share nothing at runtime: the UI reads and writes
config.toml on disk, and when it exits the tray sees
SettingsReloaded and refreshes its caches. The named pane
(--setup, --plugins) is the entire protocol between the two
processes.
The subprocess boundary is not just a macOS workaround. A crash in the UI cannot take down the engine or the keyboard hook; all three platforms behave identically with no per-platform thread juggling; and the UI can be run on its own, in a debugger, or from a test driver.
Four consequences worth knowing before editing settings_ui:
- The i18n catalog must be loaded before the first widget exists.
tris called from the view function, which runs every frame. Finding no catalog is not an error — every call site is written in English and simply stays that way. It can be replaced while the window runs (i18n::reload, what the language picker calls), which costs nothing here becauseviewis rebuilt from scratch anyway — but anything the window cached at load time, a plug-in's translated manifest above all, has to be re-read by whoever changed the language. exit_on_close_request(false)is load-bearing. The window intercepts the close request so an unsaved Wordlists edit is flushed to disk first. Without it, a user who typed a word and used the window's close button instead of Save lost the edit silently.- A pane the window opens on never fires its selection handler.
Controls that have to ask a plug-in are kicked off from
startup_task()for exactly that reason, or the pane sits at "Asking the plug-in…" for ever. - A gesture needs every subscription it is made of. Capturing a
hotkey means
on_key_pressandon_key_release; with only the first, a modifier tap updates the capture state and the gesture never completes — which is exactly how modifier-only binding shipped broken the first time. Nothing in the test suite can drive an iced subscription, so logic that has to be checked belongs in a pure function the subscription calls (mod_capture_step), not in the handler.
Smart commands are text-trigger expansions in the manner of
TextExpander, Espanso and AutoHotkey hotstrings: the user types a short
token like anrl , the engine recognises it on the word boundary,
deletes the token and the boundary, and runs an action.
[hotkeys] already spends the user's two global key combinations
(pause, switch-last). More global hotkeys would be a different
feature with worse properties: they collide with system-wide bindings,
they are invisible where a typed trigger is right there in the text,
and the OS caps how many can be registered. Text triggers have none of
those limits — they live inside the same word-boundary pipeline that
already does layout correction, so a user can have hundreds at no
cost.
Ordering is significant. Trigger lookup runs on every word boundary before the structural-boundary, disabled-app and identifier filters:
- User types
anrl<space>. - Word boundary fires.
- Trigger lookup:
anrlmatches → dispatch the action, backspace the token and the boundary, re-emit whatever text the action leaves behind, return. - Otherwise the normal layout-correction pipeline runs.
Running before the filters is what makes a snippet like => work
inside an IDE. Those filters exist to veto automatic switching;
expansion is what the user actively asked for, so they do not apply.
The action surface is deliberately small. Each variant maps to one
OS primitive already known to be safe: TypeText → send_text,
SwitchLayout → switch_to, OpenPath → opener::open.
RunShell (0.10.0) is the one with a threat model rather than a
one-line description — off unless [commands].allow_run_shell, never
run through a shell, never handed anything the user typed as an
argument. commands/shell.rs holds the reasoning; read it before
changing any of that.
Multi-token triggers also landed in 0.10.0. The word buffer still
resets at every boundary, so WordHistory holds the last few
completed words alongside it — bounded by length, by the idle timeout
and by the focused application, because that history is the only place
the engine keeps more of the user's text than the word being typed.
Two things are intentionally absent. Case-insensitive matching:
triggers are matched exactly, so users can pick tokens that do not
collide with prose — a case-insensitive best regards would fire on
an ordinary sign-off. Placeholders that substitute typed text into
an action: for run_shell that is an argument-injection channel, and
elsewhere nobody has asked for it.
Either global hotkey can be bound to modifiers alone — Shift+Shift,
or two held together like Ctrl+Shift (issue #32). Two properties of
that shape decide the design.
It cannot be an OS hotkey. HotKey::new needs a key code and there
is none, so a modifier chord is matched off the raw key stream instead
— the same stream the engine already reads on every platform, which is
why SetKeystreamHotkeys goes out unconditionally while os_grab()
covers only the Key bindings. It also has to be armed before the
event loop starts: on a session where GTK's start-up blocks, the tray
took 25 seconds to get there, and a hotkey that only works once the
tray is up is one the user reports as broken.
Nothing fires on press. The chord is judged when the last modifier
comes back up, and only if the set held was exactly the chord's, no
other key went down in between, and the hold was shorter than
MOD_TAP_MAX. That is what lets Shift+Shift live beside Ctrl+C,
Ctrl+Shift+V and typing capitals — each of those either dirties the
gesture or fails the exact-set test — and the tap deadline is what
keeps a Shift held while reaching for a capital from counting.
Two shapes are refused, and for reasons that live outside the matcher.
A single modifier is out because mouse buttons reach the engine on
Linux only (SC_POINTER_BUTTON): on Windows and macOS a bare Shift
binding would fire on every Shift+click, and a gesture that holds on
one platform and misfires on the others is worse than no gesture.
Caps Lock is out because it is observed rather than consumed —
binding it would flip the lock as well as fire.
Modifier chords are never consumed either: the keys still reach the focused application. For a bare modifier that is harmless, and it is why this is the one chord shape that behaves identically everywhere.
Eight backends are probed in priority order (linux/mod.rs), and two
rules decide which one takes the session. A bug wrote each of them.
A backend must be able to name a layout. Ubuntu installs fcitx5
with language support and autostarts it, so fcitx5-remote -t 1 exits
0 on a desktop where fcitx owns no input method at all, and -n then
answers with an empty line. Before the rule, that backend won on GNOME,
Xfce, MATE, LXQt, Budgie, sway, labwc and every bare WM, reported one
layout whose id was the empty string, loaded zero layouts, and logged
layout switcher ready on an app that could no longer correct
anything. A backend that initialises but names nothing now stands down
for the next one (names_a_layout).
A backend must be the mechanism the session actually uses.
org.gnome.desktop.input-sources lives in dconf — a file in the
user's home directory, which outlives the session that wrote it — so a
machine that once ran GNOME keeps a populated schema for ever. In i3,
fluxbox, icewm, LXQt, openbox or Xfce/X11 the gsettings backend would
then win, write a key nothing in that session reads, and decline every
correction with "the desktop put the layout back before we could type".
Finding the schema populated is therefore not enough: the session has
to be one whose own daemon acts on it (session_reads_this_schema).
The wlroots process check stays alongside that, because labwc reports
itself as Budgie.
Both rules are the same lesson as #26: probe by what a desktop does, never by what it ships or by what it calls itself. The X11 XKB backend is probed last for a related reason — where a desktop drives the session, locking the xkb group underneath it leaves that desktop's own indicator lying.
A correction is three phases with a hard ordering, all in
engine/switcher/correction.rs: absorb → emit (delete, replay) →
flush. Every part of it exists because a keystroke the user lands
mid-correction is ordered against our emitted events by the
compositor, and no after-the-fact counting can undo that.
Switch the layout first. Flipping the layout does not touch existing text, so a failed switch aborts with the user's word intact. The old order — backspaces, then switch — destroyed the word every time the switch failed. Switching first also overlaps the compositor's xkb propagation with the backspace burst, and means keystrokes landing mid-correction already produce glyphs in the layout the user intended.
Absorb. Before deleting anything, watch the key stream. While
presses keep arriving, fold them into the plan — they are the start of
the user's next word, already on screen in the layout just switched
to. Emission starts only after the stream has come back empty three
times running (~60 ms at the default replay_speed, which scales the
gap between probes; a fast typist's inter-key gap is 45–60 ms plus
listener lag, so two probes can land inside a single gap). A
correction fired by a chord also waits for that chord to come up: our
replay reaches the application the way the user's keys do, so typing
under a held Ctrl produces shortcuts and nothing lands.
If a boundary arrives while absorbing, the user finished their next word too — include it and re-process it afterwards so it gets its own decision. If a submission (Enter/Tab) or anything murkier (Backspace, navigation, a shortcut) arrives, abort the whole correction: nothing has been emitted, so the text is untouched. The layout stays switched and the buffer is tainted.
Verify, where that means anything. Between absorbing and emitting,
a switch that reported success is read back — three times across the
window the deletion would occupy, because MATE's settings daemon
restores its own group milliseconds after ours lands. A backend with
no reading independent of its own write answers None and is asked
once: sampling silence three times told us nothing and cost 80 ms of
every manual switch on KDE, Hyprland, IBus and Fcitx (issue #71).
Emit. Erase the on-screen characters, then retype the corrected
word plus everything typed while preparing. Concretely, зтзь ш
came out as ipnpm when the i reached the app between deletion and
replay, and as pinpm / pnpmi when it reached it mid-replay.
Replay prefers the original scancodes against the freshly switched
layout — the only path that works in Wayland-native and terminal apps.
Backends with a real Unicode-emit API (KEYEVENTF_UNICODE,
CGEventKeyboardSetUnicodeString) answer Unsupported and fall back
to send_text.
The boundary key is the exception, and has to be. Re-reading the
word under the new layout is the entire correction; re-reading the
key that closed it is a second, unasked-for edit. Shift+0x35 is
, in uk-UA and ? in en-US, so replaying it as pressed rewrote the
user's punctuation. The separator is therefore looked up by character
in the target layout and replayed on whichever key produces it there
(boundary_key_for), falling back to the key as typed when the target
cannot produce that character at all — a few layouts reach some
punctuation through AltGr, which PolterType does not track.
Intrusion probe, for when the gate is unavailable: anything on the wire after the replay was pressed during it, so it is on screen somewhere inside the text just typed. The position is unknown but the character count is not, so erasing that many plus the intruders and retyping restores typed order. That repair is itself a burst, so it waits for a pause first — and if no pause comes, the screen is left as-is and the engine stops vouching for it. A scrambled word the user can fix beats a correction chasing their fingers across the line.
Echo drain. consume_echo matches by scancode, so while the echo
queue is non-empty a real user press of a scancode we just replayed
would be swallowed as an echo — very possible, since the next word
often starts with the same letters. Draining while the user is still
inside the verified pause empties the queue in one keyd round-trip.
Bounded, because backends that tag their echoes injected (Windows,
macOS) never send them back through the listener and would otherwise
wait for ever.
The gate (poltertype-input::KeyGate) holds the user's keys back for
exactly the length of the burst. Held keys still reach us; they queue
instead of landing in the text, and we type them out once the
correction is down. Platform status, defaults and the remapper
interaction are in PERMISSIONS.md and the project
README — do not restate them in source comments, they go stale.
The invariant that matters when editing: held-and-dropped is
strictly worse than never held. It is the one way this feature can
lose typing rather than merely scramble it. send_keys replays raw
scancodes, which is what the gate was written against on evdev;
Windows and macOS answer Unsupported on purpose, because their
Unicode-emit API is more robust there and replaying scancodes would
race the layout switch. Every flush path must therefore fall back to
send_text rather than give up — a gate that swallows keystrokes from
the application and then fails to give them back is a regression, not
a degradation. Keystrokes that are not characters (Backspace, arrows,
Esc) translate to nothing in any layout and are dropped; that loss is
bounded by one burst.
Two kinds of thing are called a plug-in, and the manifest has to say which before anything is installed. A pack is data — layouts, wordlists, translations — and cannot execute at all; the installer's allow-list is what guarantees that, and DATA_LAYOUT.md is its contract. An extension ships a program. The kind is declared rather than inferred, so a pack cannot quietly become executable by gaining a field.
The rest of this section is about extensions. An extension is a separate process, never code loaded into this one. That is what keeps the process holding the global keyboard hook out of the blast radius of third-party code: a plug-in that panics, deadlocks or is outright malicious costs the user that plug-in, not their keyboard. Two kinds of process run — a service, started and stopped with PolterType, and a command, a one-shot invocation behind a menu entry or a button.
Four things the supervisor deliberately does not do:
- No restart loop. A service that dies stays dead until the user asks again. Automatic restart turns a plug-in that crashes on startup into a fork bomb that also fills the log, and hides exactly the failure the user needs to see.
- No shell. Arguments come from the manifest as a list and are passed as a list. There is no string to quote, so nothing to quote wrongly.
- No inherited standard input. A plug-in gets a null stdin and can never sit waiting on a terminal a tray app does not have.
- No filtering of a plug-in's output. It goes to
logs/plugin-<id>.log, truncated at every start, because a tray app launched from a desktop entry has no terminal to inherit and the one line explaining a death would go nowhere. PolterType's rule about never logging typed text binds PolterType; a plug-in that reads keystrokes was trusted with them by being installed at all.
Stopping is a declared stop command rather than a signal, on every
platform. SIGTERM still requires the plug-in to install a handler, and
Windows' console control event was measured and refused — addressed to
the child's process group it returned success and did nothing;
addressed to the whole console it killed the sender. See
DECISIONS.md.
Every call into a plug-in is bounded. The state read runs on the UI thread while a menu is drawn, so its deadline is short; a report runs off it and gets longer. A pane that says "it did not answer" is honest, one that never renders is not.
The same rule one storey down: a plug-in supplies text, PolterType
draws the menu. A manifest can name its entries in advance, but not a
queue — the things that arrived while nobody was looking — so a
tray_list names a command whose rows are produced while the menu
is being opened, in the same tab-separated form the pane's tick-box
lists use. Each row becomes a submenu of its own: the label is what
fits on one line, and the detail is one hover away, which is the only
place in a tray menu detail can live at all.
What a row can do comes from the manifest and nowhere else. The
plug-in supplies the row's text, never its actions, and {id} is
substituted as a whole argument rather than into one — so a row's own
text can never become a second flag.
The icon is shared, and stays PolterType's. A plug-in names the state key that counts, and a value above zero puts a mark in the icon's top-right corner — top, because the bottom-right is the pause indicator and a paused PolterType with work waiting has to be able to say both. The count itself goes in the tooltip. A plug-in gets to raise that mark; it never gets to replace the icon, draw on it, or choose what it looks like.
A plug-in describes its settings; PolterType draws them. That is the
whole isolation story on the UI side — a plug-in cannot render a pixel,
so it cannot imitate a system prompt, PolterType's own dialogs or
another plug-in. What it may declare is a small closed set of controls
(toggle, choice, suggest, text, number, decimal, strings, button,
report, list, records, section) bound to dotted keys in its own
config file. Edits go through toml_edit, so the prose in that file —
where a plug-in explains what each switch costs — survives. A control
kind this PolterType does not know loads as Unknown rather than
failing the parse, because serde's refusal would take the whole
manifest, and the plug-in with it, over one word from a newer version.
Seven decisions worth keeping:
- A decimal is not a number. TOML's integer and float are different
types to the program reading the file back, and a plug-in expecting
0.35cannot parse1. Sodecimalis its own kind and always writes a float, even for a round value. - Typing does not write. A value settles when the user moves on,
and at the latest when the window closes. Saving on every keystroke —
which this pane used to do — walks a threshold from
0.9to0.95through0, and for the length of that keystroke a gate in a running plug-in is wide open. - Sections are navigation, not decoration. A capable plug-in has
around a hundred settings, so PolterType lists its sections beside
the window's own nav and shows one at a time. A section nobody is
looking at is also unasked: a
listwhose rows come from the plug-in is not spawned until its section is on screen, which is what keeps opening this pane from waking every chat client on the machine. Exactly one region of the pane scrolls, so a wheel never lands on an ambiguous boundary. - An option that has to be explained stops being a drop-down. A
choicebetweenask,autoandoffis three words. A choice between nine language models has to be compared, and a drop-down shows one at a time with nowhere to put the sentence saying what each is for — so an option may carry adetailand alink, and a choice with any described option is drawn as a column of radio rows instead. The link ishttpsonly, refused at manifest load and checked again at the click, and its visible text is the address: a plug-in supplying a destination is a third party deciding where PolterType sends somebody, and a friendly label over an arbitrary URL is exactly the shape the draw-it-ourselves rule exists to prevent. recordsis for a setting that is a list of composite things — entries of several fields each, where astringslist gives one unstructured line per entry and numbered slots cap at whatever number somebody guessed. The control names an array of tables and declares what one row holds; PolterType draws a card per entry with Add and Remove. Row fields are single names, not dotted paths, and cannot themselves be sections, buttons, reports or more records — a pane that nests is a config editor, and this is not one. A new row is written empty, so the plug-in's own defaults cover every field left out rather than a value nobody chose.- A
suggestis a text box that knows the answers, not a choice with an escape hatch. Its candidates are whatever the plug-in can see, but the set is not closed: what somebody wants may live in a client that is not running, and achoicegives no way to say so. So it is free text with the candidates beside it, narrowing as it is typed into, fed by the manifest'soptions, by acommand(the same tab-separated rows alistticks), or by both. The row's id is what gets written, never its label — what is picked has to be what is stored, and a name that reads well but identifies nobody is a message sent to nobody. Until the plug-in answers, the box stays a plain text box rather than a dead drop-down. Matches are drawn inline and bounded — a handful under the box, the rest counted — because iced's own combo box sizes its overlay to its options, and ninety-five of them covered the whole form. - A row of a
recordsgroup can be acted on. A group may declareactionsand anid_field; each action is a button on that row's card, running a declared command with{id}substituted as one whole argument, never pasted into one — exactly as a tray list's per-row action works. Without it a pane can describe "send this at nine on Tuesday" and offer no way to find out before Tuesday whether it works, which for the one feature that writes to another person unattended is the wrong trade. A row whose naming field is empty gets the button visibly disabled rather than hidden: a command run against a nameless row is a command run against nothing. The button waits for its command and shows what it printed, because "did it go?" is the only question it is pressed to answer and a detached run answers it nowhere. It runs off the UI thread with a deadline long enough for an action that opens another application and types (ACTION_TIMEOUT, 90 s, against a report's 6), then refreshes the reports it just invalidated — those only, since re-asking a list would wake a client for a press that had nothing to do with it. One at a time: these steal focus.