All notable changes to PolterType are recorded here. The format is loosely based on Keep a Changelog, and the project follows Semantic Versioning.
-
Force-by-selection now follows the text, not the current layout (#68).
converted()trustedlayout_switcher.current()for the source layout. If the layout had moved on since the word was typed — exactly what the very next word does when it triggers auto-correction — the guess named the layout the caret is in now,transliterate_to's own guard refused it, and the force-switch silently did nothing. Both directions are now tried, current-first; the guard rejects the wrong one on its own, so nothing is forced through blind. -
The manual switch-last hotkey no longer stalls on Windows (#69).
GetAsyncKeyStateinsideWH_KEYBOARD_LLreports the keyboard as it was before the event being delivered has taken effect. A Ctrl release therefore arrived reading "Ctrl held", and with nothing typed afterwards that snapshot was the last one the engine got — it believed the force-switch chord was still down and waited for a release it had already been handed, stalling every manual switch 1.5–5 s until the next keystroke. For a modifier the event is about, the event itself is now the truth: a newModifiers::after_transition()applies the delivered key's own press/release, reading the other side of the same modifier live so that releasing one Shift while the other is held keeps Shift. Selection conversion, which ran the same wait, now works on the first press.
-
The manual switch-last hotkey no longer converts a word you have moved away from (#65). Selecting a phrase with
Ctrl+Shift+←and pressing the hotkey rewrote the previous word instead of the selection. The stash the hotkey acts on is deliberately kept across shortcuts — the hotkey is itself a shortcut — but a shortcut built on an arrow,Home,EndorAis one whose effect we can read: the caret moved, so a correction that backspaces from it would land in the wrong place. Those now drop the stash, exactly as the same keys do when pressed without a modifier, which is why selecting with plainShift+←always worked.The same change closes a worse case nobody had reported yet. With everything selected by
Ctrl+A, the first Backspace of a correction deletes the whole selection — so the hotkey pressed after a select-all would erase the document and type one word in its place.
-
[suggestions] caret_anchor— whether PolterType may ask the accessibility bus where your text cursor is (#66). The suggestion tooltip is anchored at the caret, and on Linux the only way to know where that is goes through AT-SPI. Joining that bus is not a private act: it raisesorg.a11y.Status.IsEnabledfor the whole session, which is the signal Qt applications take as "a screen reader is running" — Telegram Desktop says so on screen, every session, whether or not a single suggestion was ever shown.It stays on by default, and the switch is in Settings → Suggestions (Place the tooltip at the text cursor). Off, the tooltip anchors to the window and PolterType never touches the bus at all on Hyprland and X11. On GNOME and KDE Wayland the same bus is the only answer to "which application has focus", which
[exceptions]needs, so there the connection stays and only the caret subscription goes —docs/PERMISSIONS.mdsays which is which.Turning the tooltip itself off now also stops the watcher, which it never did: with
[suggestions] enabled = falsethere was nothing in the program that wanted a caret, and it connected anyway.
-
The Linux listener no longer wakes five hundred times a second to ask an idle keyboard whether anything happened (#63). Both loops ran on a 2 ms timer: the evdev one polled every open device and slept, the X11 one did the same on its connection. Measured on the 0.33.1 AppImage with the engine paused and nothing typed, that was 0.55 % of a core and 502 context switches a second, all of it in the
poltertype-inputthread. Both now wait on the descriptors themselves withpoll(2); the same measurement reads 0.05 % and 38. On a laptop the bill was never really the half percent — it was the idle states the CPU could not reach.Nothing about correction timing was traded away for it. The key gate, which holds your keystrokes back while a correction is on the wire, now wakes the thread through an
eventfdthe instant it is asked rather than waiting out a poll round, so a hold is taken sooner than before. The Caps Lock latch is read on the way into the loop instead of on the way out, so a lock toggled by something that sends no key event — KDE InputActions,xdotool key Caps_Lock, an on-screen keyboard — is still reconciled before the first word it would otherwise have mis-cased. -
A translation that will not load can now say why (#64). One line the TOML parser refuses used to cost the whole catalog, and the interface simply came up in English: the file was right there, the app said nothing, and the only account of what happened was a log line a translator has no reason to read. The commonest way to write such a line is a Windows path —
"C:\path"is not a valid escape,"C:\\path"is. A file that will not parse whole is now read a line at a time: the line that is wrong is dropped, everything else loads, and the Settings window names the file and the line under the language picker. A language that was asked for and produced no catalog at all says that in the same place.A second road to the same silence is closed with it. The Settings window skipped loading translations altogether when it could not locate the directory PolterType keeps its own catalogs in — taking the user's own catalog, which lives somewhere else entirely and is the whole translation for every language we do not ship, down with something it had nothing to do with.
Entries left empty are still exactly what the format says they are — "not translated yet" — and are not reported as anything: a catalog being filled in a line at a time is a normal catalog.
-
Nothing in the program changed; the tag did. v0.33.0 was cut from a commit carrying three
clippy::expect_usedviolations in a test file — a lint this workspace denies everywhere, tests included. Test code is never compiled into an installer, so the 0.33.0 binaries were correct and this release is byte-for-byte the same program. What was wrong is thatgit checkout v0.33.0followed bycargo clippy --workspace --all-targets -- -D warningsfailed, so the tag was no use to anyone building or bisecting from it. This one is cut from the commit that fixed them.It reached a tag because the pre-commit hook had not been running:
core.hooksPathin the maintainer's clone named a directory that no longer existed after the checkout moved, and git skips a missing hooks directory without a word. CI caught it on all three platforms, which is what CI is for; the hook is pointed at.githooksagain so the next one is caught a step earlier.
-
A plug-in's settings page can now offer a search box. The new
querypane control draws a line to type a question into, a button to ask it, and the plug-in's answer underneath — where areportshows an answer to a question nobody asked, this one shows the answer to yours. Nothing runs until the box is submitted, so opening the page costs no process, and what is typed is never written to the plug-in's config file: a question is asked, answered and forgotten, not stored.What is typed reaches the plug-in as one whole argument, replacing a
{query}placeholder the manifest puts in its command — the same substitution a per-row action uses for{id}, and never string interpolation, so a question cannot become a second flag. A question beginning with-is refused outright rather than passed on. -
A guide the window links to can now open in your language. The Setup pane's button led to an English page from a window that was Ukrainian, German, Spanish or French, and there was no way to point it anywhere else (issue #61). A translated guide is now a file beside the English one —
docs/PERMISSIONS.uk.md— plus one line in the catalog that translates the window:"docs.permissions" = "uk".The value is a language tag rather than an address, and the rest of the link is assembled in the program: a catalog can send that button to another translation of our guide and to nothing else. Nothing probes for the file either — PolterType makes exactly one network call and it is the updater — so the catalog line is the statement that a translation exists, which is why it travels in the same pull request as the page.
docs/TRANSLATING_THE_UI.mdhas the details. No guide has been translated yet; the way to add one is now open.
-
The examples under "Add command" now translate. The three greyed hints — the signature, the layout id, the path — were written into the program in English and stayed English in every language (issue #62). They are catalog entries now, and a translator is free to make the layout example
uk-UArather thanen-US, which is the more useful hint in a Ukrainian window.The snippet example lost its
\nalong the way: nothing between that box and the keystrokes expands one, so an example containing it was promising an escape the engine does not have. -
All four shipped languages get a feature at the same time. The plug-in query box above reached the Ukrainian catalog and none of the others. A test now fails the build when the shipped catalogs stop agreeing on which keys they carry.
-
The Setup pane's own rows now translate. Every step on that pane — "Read the keyboard", "Type the correction", the paragraph under each, and on macOS the notes about Accessibility, signing and notifications — stayed English in a window that was otherwise Ukrainian, German, Spanish or French. The pane's frame translated and its content did not, which made the one screen a confused user is sent to the one screen their language did not reach (issue #60).
The cause is a dependency, not an oversight in any catalog: the text is written in
poltertype-input, where the per-OS probe has to live, andtrlives inpoltertype-core, which depends on that crate. Nothing there could look a translation up. A step now carries the catalog key beside its English and the Settings window resolves it as it draws — the same "a key and the English at the call site" contract the rest of the interface already had. All four shipped catalogs carry the 24 new keys; a catalog of your own picks them up from<config-dir>/poltertype/i18n/the same way.
-
Hovering the tray icon says something on Linux too — where the system's tray library is new enough. The tooltip was written on every state change and thrown away every time:
set_tooltipis an empty function intray-icon's Linux backend, in the version we pin and in the five released since. PolterType now drives the indicator itself and fills in theToolTipproperty a panel reads on hover, with the same sentence Windows and macOS have shown all along: the current layout, whether switching is paused, whether the keyboard hook is missing, and how many drafts are waiting.The API it needs arrived in libayatana-appindicator 0.6.0. Debian 13, Ubuntu and Debian sid all still ship 0.5.94, which has no tooltip functions at all — and the AppImage bundles the copy it was built against, so it is on 0.5.94 too. There, and on the pre-Ayatana
libappindicator3, the symbol is looked up once, missed, and the tray behaves exactly as it did before. Measured both ways on KDE Plasma: the tooltip drawn on screen against 0.6.0, and the tray unchanged against 0.5.94.
-
The tray item says who it is. It used to register as
tray-icon tray app— the id every application built on that crate shares — and carried no title, so a panel listing hidden items fell back to the lower-cased process name. It is nowpoltertype, titledPolterType. A panel that remembers per-item whether an icon was shown may treat it as a new item once. -
The guide to translating the interface no longer invites a PR. The set of languages the build ships is closed for now; a catalog of your own lives in
<config-dir>/poltertype/i18n/, where it loads the same way, wins over the shipped files key by key, survives an update and is a single file to pass to anyone who wants it.
-
A plug-in can ship its own translations. Its settings pane is drawn by PolterType but worded by its manifest, so the words now come from a catalog the plug-in carries:
<plugin>/i18n/<lang>.toml, keys derived from the manifest's own structure, printed ready to translate bypoltertype --plugin-strings <id>. The catalog is confined toplugin.<id>.— an extension can retitle its own pane and nothing else — and it is independent of the interface's own languages, so a plug-in can offer Polish while the window around it is English.Values are never translated: an option's
value, a control's key and a command's id still reach the plug-in's config exactly as its author wrote them, and a drop-down now shows the label while writing the value. What a plug-in prints — report text, the rows of a list — only it can translate, so every plug-in process is handedPOLTERTYPE_LOCALE. -
A language picker, in General → Appearance. It lists what is actually on disk — the shipped catalogs, one a plug-in brought, one you dropped into
<config-dir>/poltertype/i18n/yourself — so a translation can be tried without hand-editingconfig.toml, and a language PolterType has no name for is offered under its code rather than hidden. The window changes language as you pick it, and the tray menu follows when the settings are saved — neither waits for a restart, and the tray's own words are rewritten where they stand. -
The whole window and the tray menu are translated, not just the panes. The side navigation, every pane's footnotes and form labels, the save and status lines, the Setup pane's badges and notes, the tray's own entries, its tooltip and the "switched", "word added" and "update ready" notifications all go through the catalog now, with Ukrainian shipped for every one of them. A plug-in's tray entries are translated from its own catalog, exactly as its settings pane is.
Every pane's subtitle goes through the catalog too — the sentence under each heading was the last thing still drawn from a raw literal — and so does the suggestion popup's "Add to dictionary", the one piece of interface the engine shows while you type.
Three things stay English by design: error notifications, which carry the operating system's own message; the Setup pane's step titles, which come from the permission probe in a crate that does not depend on the translation loader; and the plug-in supervisor's failures, which name a plug-in's ids beside an OS error.
-
The Plug-ins pane speaks the interface's language. Everything PolterType draws around a plug-in — Add, Remove, Refresh, Select all, "Asking the plug-in…", the placeholders and the save line — goes through the catalog like the rest of the window, with the Ukrainian translation shipped.
-
Three more interface languages: German, Spanish and French. Each is a complete catalog — every string the window, the tray menu, the notifications and the suggestion popup draw — so the picker in General → Appearance now offers Deutsch, Español, Français and Українська beside the system default. English needs no catalog: it is compiled into every call site.
- The file-organization rules became a gate rather than a
convention.
cargo xtask stylereads the tree and fails on whatCONTRIBUTING.mdhad only ever asked for — one kind of thing per file, unit tests in a siblingtests.rs, a platformcfgonly on the declaration that picks a per-OS module — and it runs in the pre-commit hook and in CI. The tree was brought under it in the same breath:poltertype-appandpoltertype-corenow hold no platformcfgat all, and the files that had grown into bags were split. Nothing a user can see changed.
<config-dir>/poltertype/i18n/is read at last. The loader only ever looked at the shipped catalog, so the edit-and-reload loopdocs/TRANSLATING_THE_UI.mdpromises translators did not work and a language PolterType does not ship could not be tried without editing the source tree. Catalogs are now layered — shipped, then plug-ins, then yours — and the last one to name a key wins.
-
The tray icon can be the layout's flag.
[general] tray_icontakes a fourth value,flag, and the General pane a fourth chip: the icon becomes the country of the layout in force — Ukraine, Spain, Japan — instead of its two-letter code. The flags are drawn by the same arithmetic that draws the lettered badge, so nothing new is downloaded, embedded or rasterised by a font.Thirty-nine countries have a drawing. A layout whose country has none — or that names no country at all, like Windows' opaque layout ids — keeps the lettered
colorbadge, so the icon never stops saying which layout is in force. Countries whose flags differ only by an emblem too small to draw at this size (Slovakia, Slovenia, Serbia, Croatia, Mexico) are deliberately left to the letters rather than drawn as a flag that would be wrong.Pausing dims the flag rather than flattening it, so the bands, cross or disc still name the country while the icon reads as inactive.
-
Selection conversion works on macOS, and the toggle is no longer refused there. The emitter can send a chord now — modifiers travel as flags on the key events, the way macOS matches menu shortcuts, with an SC-1 → Apple keycode table that refuses what it cannot name — so
Cmd+CandCmd+Vreach the focused application and theNoCopyChordgap closes. Contributed on #55, measured on an M1 Pro. -
Updates can keep their macOS permissions.
[updates] local_signing_identitynames a codesign identity in the login keychain; the installer re-signs the swapped bundle with it, and TCC then keys Accessibility and Input Monitoring to certificate plus identifier instead of to the hash of the bundle's own bytes — which is what made every previous update look like new software. A Setup step creates the identity in one click. Left empty, updates stay ad-hoc as before, and the installer clears the two stale TCC records after a successful swap so the Ask buttons can raise the system prompt again. Groundwork for #42; the real fix is still a Developer ID. -
[engine] hold_keysexposes the key gate as a setting. Off by default, which is unchanged behaviour and the project's documented latency stance; on, a keystroke landing mid-correction is held and replayed after it rather than racing the burst.
-
The manual hotkey answers again after the caret moves (#57, #56). Press an arrow key, or click away from a window and back, then type a word and press the hotkey: nothing happened, and nothing kept happening until a space was typed.
One cause under both. Interrupting a word marks the buffer, so the half-seen word is never corrected from the tail we watched — but the same interruption also clears the buffer, so that mark could only ever land on the next word, one seen from its first key at a caret nothing had moved since. The mark now says the narrower thing it always meant for this gesture: a word we stopped recording is still on screen at the caret. A shortcut and an idle gap leave exactly that and are still refused; an arrow key and a click move the caret off it and are not. Automatic switching is unchanged — a word typed after an arrow key mid-word is still left alone.
-
Selection conversion works on Linux (#51). It never had, on any Linux session without a clipboard manager, and two separate faults were each enough on their own.
X11 and Wayland both serve the clipboard from the process that owns it, and the handle PolterType wrote through was closed as soon as the write returned — so the converted text was destroyed before the paste could ask for it, and the restore afterwards wiped the user's own clipboard instead of putting it back. KDE hid this because it ships a clipboard manager, which adopts an orphaned selection; Cinnamon and the bare sessions ship none. The writing handle is now held for the life of the process, which is the lifetime the protocols ask for.
And the copy chord went out while the hotkey was still held, where an X11 passive grab has become an active one and everything the app emits goes to the client holding the grab rather than to the focused window. Every other force-switch path already waited for the key to come up; this one now does too.
-
A wrong-direction press no longer mangles punctuation. Converting a selection whose text holds no letter of the layout it would be read from is refused outright:
проверяюconverted toghjdthz.and pressed again put the.— which exists on the Russian layout, on its own key — through to/, and nothing later brought the letter back. Contributed on #55. -
macOS: an update no longer relaunches the version it replaced. The Settings window is a subprocess of the same executable and outlived the swap, so
openfound the app already running and merely activated the old window. Child windows now come along on the update hand-off and on Quit, and the installer swapsContentsinside the bundle instead of moving the.appaside — which also retires thepoltertype.app.newthat Spotlight used to surface mid-install. Reported on #42.
-
The tray icon is drawn four times larger, and
monohas lost its tile (#54). The icon was a 16×16 bitmap, which is what every panel above scale 1 then had to enlarge — the fuzzy, pixelated badge in the report's screenshot. It is now handed over at 64×64, so the panel scales it down, which every toolkit does with filtering.monoalso drops the slate background it shipped with in 0.26.0. A panel's other icons are flat glyphs on the panel itself, and a filled tile was what made ours the one foreign object in the row;monois now the two letters and nothing else, drawn light or dark according to the desktop's own preference. A panel is free to disagree with that preference, so each letter carries a thin halo in the other polarity — a wrong guess costs contrast rather than the whole icon.coloris unchanged apart from the resolution.
- "Settings…" no longer opens a second window (#53). The window is a subprocess and the tray had no memory of it, so every click started another one — each with its own copy of your settings, and the last one saved winning. The menu item now does nothing while a window is already open. It does not raise that window: the two are separate processes, and raising one from the other is not something a Wayland session offers.
-
The manual hotkey now converts a separator, when there is no word to convert (#52).
№isShift+3on the Russian and Ukrainian layouts and#on the US one — and it is not a letter in any of them, so it never joined a word, never reached the stash the force-switch acts on, and pressing the hotkey did nothing at all. The key that produced it was known the whole time.One character, and only the one immediately left of the caret: a run of separators is as likely to be a divider line as a mistake. Two cases are deliberately left alone — a key that reads the same under both layouts (every space is a space, and retyping it would move the caret for nothing), and
EnterorTab, whose replay would submit the line or move focus instead of typing a character. A word still wins where there is one, so nothing about the existing gesture changes.Measured on Cinnamon X11 with the default
Ctrl+Shift+Backspace: the separator alone, the separator typed after a word, and a space left exactly where it was.
- A separator typed after a pause no longer mangles the line. Found
while measuring the above, and older than it. The word the hotkey
acts on outlives a typing pause on purpose — that is what keeps the
gesture working when you stop to look at what you typed — but the
buffer that counts characters does not, and anything typed in between
left the two disagreeing. Type a word, pause a couple of seconds,
type
№, press the hotkey: the correction was spliced one character too far right andпривет №came back asпghbdtn. The word behind a separator is now left alone, and the separator itself is what the press converts.
-
Manual-only conversion, named as a mode (#51). The General pane opens on a Conversion choice — Automatic or Manual only. Manual only keeps watching what you type and corrects nothing on its own; the last word is converted when you press the manual hotkey, which is what a Punto Switcher user means by manual mode.
It writes the same
[general] pausedthe tray's Pause auto-switch has always written, and the two names now sit next to each other in the window that explains them, because the request that asked for this mode came from someone who had found the pause and read it as the app being switched off. Nothing about the behaviour changed: the engine has stashed every completed word while paused since 0.22.0, precisely so the hotkey has something to convert.Measured on Cinnamon X11 — the session the report came from — with the default
Ctrl+Shift+Backspace: paused by the tray chord and paused from the config file, on a finished word, on a word still being typed, and on the second word of a line. All four convert, and nothing converts on its own. The probe ismanual-mode-probe.pyin the desktop-matrix rig.
-
The tray icon can be neutral, or absent (#50).
[general] tray_icon, and the second row of the General pane's Appearance card, now takes three values.coloris what it has always done: a hue per layout, so the eye learns which colour means which language before the two letters on it are legible.monokeeps the letters and drops the hue — one slate badge, whatever the layout — for panels the hue clashed with.hiddenremoves the icon.Hiding it removes the tray menu with it, and that menu is the whole of this app's UI: Pause, Settings, Quit. What is left is
poltertype --settings, which is what the report proposed; the window says so where the choice is made. On Linux, hiding asks the desktop to make the icon passive — a panel is allowed to draw a passive item anyway, so this is a request rather than a guarantee. Checked on Cinnamon, which is where it was asked for.
-
The logo in the Settings window draws again (#49). The mark beside "PolterType" in the sidebar came out as a fragment, and the larger one on the About card as nothing at all. Not a font and not a theme: the mark was drawn as vectors on a
canvas, and the pure-CPU renderer this window uses applies a canvas's clip in the wrong coordinate space — so the further the mark sits from the window's top-left corner, the more of it is masked away.It is now drawn from the same rasteriser that produces the window's own icon and the icon your desktop shows, which is why that one was correct in the reporter's screenshot while the one inside the window was not. Same artwork, one copy of it, no clip to get wrong.
Every build since 0.23.0 had it — the published 0.23.0 was run to check. Nothing in the test suite could see it: a renderer bug is only visible on screen, which is now a step in the release checklist rather than a habit.
-
A hand-installed AppImage no longer leaves the menu pointing at the file you replaced (#48). Put a new AppImage where the old one was, under its own version-stamped name, and start it: the menu entry PolterType keeps up to date read the new path, while clicking it still ran the old one — "Unable to find a program".
A desktop's menu cache is keyed on the modification time of the directory the entry sits in, not on the entry itself, and rewriting a file in place does not move that. Measured on Plasma Wayland: the directory's timestamp stayed put and the session's menu cache went on holding the old
Execfor as long as it was watched. The entry is now written beside itself and renamed into place, which is what moves that timestamp — the same session then picks the new path up within seconds, with nothing asked of it.Only ever visible to an install that changes the file name by hand. The in-app updater replaces the AppImage under its existing name, on purpose — every dock pin and shell alias points at that path.
-
The force-switch stops chiming when you turn the chime off (#47). "Play a soft chime on correction" silenced the automatic pass and left the manual one ringing. Every other path that corrects a word reads that setting; the force-switch built its correction with the sound flag set to a literal
true, and had done since the gesture existed.The test harness now records what would have been played, which it never did before — sound was the one thing nothing asserted on, which is how a hardcoded flag sat there unnoticed.
One bug, and it took the reporter naming the exact key to find. Clearing what you had typed with a keyboard shortcut left PolterType unable to vouch for the line — and it went on not vouching for the word you typed next, which it had watched from its first keystroke.
-
Clearing the line no longer kills the hotkey (#44). Clear a line with
Ctrl+Backspace— or withCtrl+Aand then Backspace — and the word you typed next could not be force-switched. Nothing was said about it: no sound, no message, and a debug log that reported it the same way as a hotkey pressed with nothing to switch. It stayed that way until a space went by.A shortcut can edit text arbitrarily, so PolterType marks the word it was tracking as one it can no longer account for — a correction deletes a counted number of characters, and it must not count characters it cannot see. But that mark was lifted only at the next word boundary, so it covered the word typed after the shortcut as well, and that word is watched from its first keystroke like any other. A backwards word-delete is the one shortcut whose effect on the text is known: it erases exactly what the mark exists to protect. It now lifts the mark instead of setting it, and so does backspacing past everything we are tracking, which is what
Ctrl+Aand Backspace comes to.The automatic pass had the same hole for the same reason, and this closes it too: the first word after a cleared line went uncorrected, which is the harder half to notice because there is no gesture to blame. Both halves are regression tests now, and both were measured across the desktop matrix.
A refusal like this also gets its own line in the debug log now. The one it used to share said "fired with no word to switch", which is what an empty buffer says too — the two readings are indistinguishable in a log, and this report needed two rounds because of it.
-
The missed-word menu says where the word goes (#38). It reads "Add a missed word to the dictionary…" now, on the reporter's observation that nothing in the menu said what picking a row does. The entry stays where it is while the list is empty, showing the row 0.25.2 added: a menu that vanishes when it holds nothing is a menu nobody finds in the first place.
Three more from the same reporter, and two of them are the same shape: PolterType knew something and the file did not, or the file knew something and PolterType did not. The third is a menu that could not say it was empty.
It also closes what was left of #34, which was closed too early at 0.21.0: the reporter said then that swapping the two chords still needed a restart, and that was true.
-
A rebound hotkey works the moment you save it (#45). It used to work only after a restart. The chords were put in force at exactly one moment — when the Settings window closed — which is not when anyone finds out whether a rebind worked: you press Save, the banner says it saved, you try the new chord with the window still open, and nothing happens. A chord typed straight into
config.tomlnever arrived either, short of clicking "Reload Settings".config.tomlis now watched. Whatever writes it — the Settings window, your text editor, a file synced from another machine — the running app re-reads it within a second and re-arms the chords from it. Verified on this machine against 0.25.1: rewriting the pause chord under the running app left it on the old key indefinitely; it now moves inside a second. -
Auto-switch left off stays off (#46). Pausing from the tray or the hotkey lasted until you quit, and the next launch was correcting words again for somebody who had deliberately switched that off. The state now lives in
[general].paused, written whenever you pause or resume, read at startup — and, since the file is watched, applied live: setting it by hand pauses or resumes a running app. The tray icon and the menu entry come up matching it, so a PolterType that starts paused says so before you go looking.One consequence worth knowing: pausing rewrites
config.toml, and that rewrite does not keep comments you added by hand. Saving from the Settings window has always worked that way; the pause hotkey does now too. -
"Add a missed word…" says when there is nothing in it (#38). The entry sat there greyed out until a tooltip offer had actually been missed, and a menu entry that answers a click with nothing reads as a menu that does not work. It now opens on a single row saying so.
The mechanism itself was measured while looking into this, on the KDE Plasma Wayland session it was reported from: the missed word does reach the tray. Read off the tray's own D-Bus menu rather than off a screenshot, so it is the rows the desktop is holding, not the ones we think we sent.
Two follow-ups from the same reporter on the release meant to settle them, and a Linux updater that had been losing updates for five of them.
-
"Restart to update" installs the update on Linux. On any desktop that runs PolterType as a systemd user service — which includes the unit the app's own run at login toggle writes — it did not. The swap lived in a helper the app started and then quit for, and that helper shared the app's control group: systemd killed it at the exact moment it was waiting for, before it had swapped anything. The app quit, the update was lost, and nothing said so. One maintainer's logs record it happening on five of eight attempts across five releases. The app now puts the new AppImage in place itself, before it starts anything, which is atomic and needs nothing to outlive it; only the relaunch is handed off, and an update that installs but cannot restart the app now says so and leaves the app running instead of quitting into nothing. Verified end to end in a unit of the same shape.
-
Leaning on the force-switch key no longer leaves it answering nothing (#44). Tap it and the word came back; hold it a few seconds and the gesture went dead — for that word and everything typed after it, until the app was restarted.
0.25.0 had it wait two seconds for your fingers to lift and then type anyway. That last part cannot work. Before typing, PolterType releases the modifiers you are still holding, because a replay under a held Ctrl produces shortcuts instead of text — and on Wayland that release changes nothing, because it comes from a virtual keyboard that never pressed the key. Measured on KDE Plasma Wayland: the Ctrl in your hand stayed down and the correction arrived in the window as seven
^Hand five control codes, where a word should have been. On X11 it is different and no better — the shortcut you are holding keeps the keyboard to itself, so the same burst reached nothing at all.A correction can only happen once the key is up, so now nothing happens until it is: no layout switch, no deletion, no typing. The wait is five seconds, and past it the word is simply left as you typed it — the one outcome that cannot make things worse — with the gesture still there to press again. Measured on every session in our desktop matrix that can switch layouts — eleven of them, Wayland and X11 — with the key tapped, held for one, three and six seconds, and pressed again after each; and with the capture checked for control characters, which is the only reading that tells "nothing was typed" from "something illegible was".
-
The Hotkeys pane recognises Caps Lock once you have neutralised it (#41). Binding Caps Lock needs the lock taken off the key first — the pane says so — and that is exactly what leaves the key with no name for the pane to match on. So Rebind saw it before you did what it asked, and not after. It now recognises the key by its position, which no keyboard layout can move.
-
A hotkey with no modifier on it works more than once per word. Its own keypress reached the word buffer like any other, and the classifier reads a bare function key as the cursor moving — so the press that had just switched a word threw that word away on its way out, and the next press found nothing to act on. Found while fixing the two above; it applies to any bare binding, Caps Lock included.
Five reports in a day, from two people on two platforms. Four are fixed; the fifth is fixed and waiting to be confirmed on the machine that sees it.
-
Holding the force-switch key no longer breaks the correction it asked for (#39). Tap the key and the word came back; hold it a moment longer and the word was left in the wrong layout, or lost. Two causes, both about the key still being down. Your keyboard reports a held key as a stream of presses, and PolterType read those as you typing a shortcut mid-correction, which is a thing it refuses to type over — so it abandoned the correction untouched. And on X11 the shortcut you are holding keeps the keyboard to itself until you let go, so anything PolterType typed went nowhere at all. It now recognises its own key repeating, and waits — up to two seconds — for your fingers to lift before it types. Measured with the chord held for a full second on every session in our desktop matrix that can switch layouts at all: ten of them, Wayland and X11.
-
The force-switch key keeps working on the words you type next (#40). Pressing it on a word you have not finished typing told the internal model that it no longer knew where the cursor was — which is a state it deliberately refuses to act in. So the key answered once and then went quiet for everything typed afterwards, until a space happened to clear it: press it on a new word and you got the old word's letters, or nothing. It now marks only what it needs to (this word has been placed by hand, leave it alone at the boundary) and keeps track of the cursor, which it never actually lost. Same ten sessions, same sweep — including the one where this had been an unexplained hole in our own notes since 0.20.0.
-
ui_theme = "system"follows the system again on Windows and macOS (#43). It has meant "light" on both since 0.23.0: the toolkit upgrade replaced the call that used to answer this with one that answers nothing, and on Linux the desktop portal had always been the real authority, so nothing looked wrong here. The reporter sees it on both platforms — macOS withAppleInterfaceStyle = Dark, Windows 11 with the dark app theme — which is what says the two probes are the right two. Both are compiled and their parsers tested; neither has been run on the platform it is for, because there is no Mac or Windows box in this release's test loop. -
Punctuation keys work as hotkeys on the backends that match chords themselves — Wayland and evdev. Every key of the main block that is not a letter, a digit or a function key — backtick, brackets, semicolon, quote, comma, period, slash, backslash — was missing from an internal scancode table, so binding one there produced a hotkey that answered to nothing at all. Found while looking into #43, whose reporter turned out to be hitting something else entirely: another layout switcher was swallowing the chord before PolterType saw it.
-
Rebinding to a key your layout renders as something exotic no longer fails silently. A capture reads the character the key produces, so rebinding to a letter with a Cyrillic layout active wrote a combination our own reader rejects — and a rejected binding is quietly replaced by the default. It now falls back to the key's physical position, which always has a name.
-
Caps Lock can be the force-switch key (#41) — the Punto Switcher gesture, asked for by name. Two things to know, and the Hotkeys pane says both. PolterType watches keys and never swallows them, so Caps Lock still latches the lock unless you take it off the key first (the
caps:nonekeyboard option, or whatever your remapper calls it) — and a latched lock makes the corrected word come back in capitals. Once it is neutralised, no operating-system shortcut registry can find the key any more, so PolterType reads it off the key stream instead, on every platform. Bare Caps Lock only:Shift+Caps Lock is left alone, which is the escape hatch for latching the lock when you actually want it. -
macOS says what an update will cost, instead of offering a button that cannot work (#42). Our builds are not signed with an Apple Developer ID, so macOS ties Accessibility and Input Monitoring to the exact copy of the app — and every self-update replaces it. The switches stay on, the app is denied anyway, and because macOS has an answer on record it will not show its permission dialog again, which is why Ask macOS now did nothing. The Setup pane now recognises that state, says to remove PolterType from the list and add it back, and opens the right pane; the Updates card says so before you update rather than after. Compiled, not run — the reporter's M1 Pro reproduces the problem every time and is the machine that can confirm the fix.
The last item of #32, and the one that had been "when convenient" since April. Off by default.
-
The force-switch key can convert a selected passage. Tick Also convert selected text on the Hotkeys pane (
[selection] enabled) and the same key that fixes the word you just typed will also fix a passage already on screen — a whole sentence in the wrong layout, or a word the detector deliberately left alone. Select it, press the key.It looks at the selection only when there is no just-typed word to fix, so it costs nothing the rest of the time. Pressing copy into your editor on every force-switch, on the chance you had selected something, would be a price everybody paid for a rare case.
-
It is off until you ask for it, and that is the design. Reading a selection means copying it, which is a longer reach than the rest of the app has — the word buffer never leaves memory and nothing else touches your clipboard. Nobody should acquire that by upgrading. Your clipboard is put back afterwards.
-
The toggle knows where it cannot work, and says so. It greys itself out with the reason in place of the setting, from a real probe of your session rather than a list of desktop names. Measured on all fifteen desktops in our matrix: it works on KDE Plasma, sway, labwc, Budgie, Xfce and every X11 session — and not on GNOME or Cinnamon's Wayland sessions, which give a background app no way to read the clipboard without stealing keyboard focus, which PolterType will not do. Not on macOS either, where it cannot yet press the copy shortcut at all.
Two things it cannot promise even where it works, both in
docs/KNOWN-GAPS.md: a clipboard holding an image or files is not preserved, and nothing distinguishes a password field from any other.
Two long-standing reports closed at their source, and the toolkit underneath the Settings window moved a major version to do it.
-
Words the tooltip offered and lost now wait in the tray (#38). The "Add to dictionary" row appears on a tooltip that the next keystroke dismisses — so the faster you type, the less likely you are ever to reach it. The reporter never once managed to. PolterType now keeps the last eight such words and hangs them off a tray submenu, Add a missed word…; picking one adds it exactly as the tooltip row would.
The list is deliberately small and deliberately forgetful. It lives in memory only, is never written to a file and never reaches a log — it is the one place the app holds words you typed beyond the single word the engine is working on, so it stays a menu rather than becoming a history.
-
The second, empty "winit window" beside Settings on KDE Wayland (#35). It was never ours to draw: the toolkit created a throwaway window to hand its renderer a handle while starting up, asked for it to be invisible and never destroyed it. Wayland has no "invisible", so a desktop that lists toplevels rather than mapped surfaces showed it. The Settings window now runs on iced 0.14, which has no such window at all.
-
The Settings window no longer dies on a sudden resize. A quick drag to a small size could trip an assertion inside the old toolkit's renderer and take the window with it — development builds only, which is what people running from source use every day. Gone with the upgrade, and re-measured: 300×300, 1400×1000, 320×340 and 900×700 in sequence all survive.
- Nothing you can see in the Settings window. The toolkit upgrade touched every pane, so all of them were re-checked on screen rather than assumed: the sidebar, the brand mark, the panes and the footer render as before.
-
#33 has two new regression tests rather than a fix. A hyphenated English compound typed in English is not switched, and ALL-CAPS text is left alone even with a hyphen through it — which between them leave exactly one mechanism for the reported
auto-switch→ФГЕЩ-ЫЦШЕСР: a Caps Lock that was on while PolterType believed it off. That hole was closed in 0.22.0; whether it was the reported cause still needs the reporter'sapplying correction … caps=line. -
Selection conversion — the remaining half of #32 — is not in this release. It needs clipboard access on three platforms and the ability to synthesise a
Ctrl+Cchord on two more than currently can, and it is its own release rather than a rider on a toolkit upgrade.
0.21.0 gave the force-switch a gesture people already had in their hands. This release is about what happens when you make that gesture twice, which until now was nothing — reported within a day by the same user, in #36 and #37.
-
The force-switch hotkey can be pressed more than once. It used to work exactly once per word, so a press made in error could not be taken back and a word could not be walked through your layouts to find the right one. Press it again and the word moves on to the next active layout, wrapping — with two layouts that is simply "and back again", with three it reaches the third.
The first press after a correction of ours still undoes it and learns the word. Nothing after that learns anything: taking back a press of your own says nothing about the word, and a user tapping through the renderings to see them would otherwise have switched that word off for good.
-
Pausing auto-switch also disabled the manual switch (#36). While paused the engine stopped tracking the word being typed altogether, and the manual hotkey acts on what that tracking stashes — so it had nothing to work on. Pause now stops PolterType deciding, not PolterType watching: the word is still followed and still reachable by the hotkey, and nothing is corrected without you asking. This matters most to the people who turn auto-switch off precisely because they would rather fix wrong-layout words by hand.
-
A hotkey whose key also ends words stopped working at the first correction. Chords fire once per press and are latched until the key comes back up — but the release of the Space that closed a word was swallowed by the correction that word triggered, so the latch never lifted.
Ctrl+Shift+Space, the default pause chord, was dead for the rest of the session; the force-switch had the milder form of the same fault and answered every other press. Found while checking the two issues above, on KDE Plasma Wayland. -
The Caps Lock state could be wrong for a whole session on Linux. It was re-read only when the Caps Lock key moved, and that key is not the only way the lock moves — a compositor-level remapper (KDE's InputActions), an on-screen keyboard or
xdotool key Caps_Lockall change it silently. Believing the lock is off while it is on is what turns a corrected word into capitals in the other layout, since the ALL-CAPS filter then does not recognise all-caps text and the application applies the real lock to the keystrokes we replay. The lock is now re-checked five times a second while you type. Whether this is behind the remaining reports in #33 is not established; theapplying correction … caps=line in a debug log settles it either way. Windows and macOS read the lock per keystroke and were never affected.
The gesture people arrive with from Punto Switcher and Caramba: modifiers alone, no third key. Asked for in #32 by three people independently, and the one item in that thread that is felt every day rather than occasionally.
-
Either hotkey can now be modifiers alone —
Shift+Shift(tap it twice) or two held together (Ctrl+Shift,Alt+Shift, …). Bind them the same way as anything else: click Rebind and make the gesture.They fire on release, and only if nothing else was pressed while the modifiers were down, which is what leaves
Ctrl+C,Ctrl+Shift+Vand typing capitals alone. A tap also has to be a tap: hold longer than half a second and nothing fires.There is no key code to register, so these are matched off the key stream on every platform rather than through an OS-level grab — the first hotkey shape that takes exactly the same path on Windows, macOS, X11 and Wayland alike.
-
A single lone modifier is deliberately not offered. Mouse buttons are invisible to PolterType on Windows and macOS, so a bare
Shiftbinding would fire on every Shift+click. Caps Lock stays unavailable for the same kind of reason: we observe it rather than consume it, so binding it would flip the lock as well as fire.
-
A machine that had ever run GNOME could stop correcting in every other desktop on it. GNOME's
input-sourcessetting lives indconf, a file in your home directory, and it outlives the session that wrote it. PolterType took a populated setting as "this desktop drives the layout through gsettings" — so on the same machine, i3, fluxbox, icewm, LXQt, openbox and Xfce/X11 all picked that backend, wrote the setting, watched their own session put the layout straight back, and declined every correction. Six sessions in the desktop matrix, each of which corrects fine with that setting empty. The backend now claims a session only when the desktop is one that acts on the setting; everything else falls through to the mechanism that really drives it. -
Hotkeys were dead for the first half-minute after launch on some sessions. They were armed after the tray's event loop was built, and building it can block: 25 seconds on sway, measured — with corrections already working the whole time, which is what makes it look like the hotkey is broken rather than late. Chords read off the key stream are now armed before that step.
-
The Settings window and the suggestion tooltip drew their text in whatever font the machine happened to answer with — on Ubuntu, in one with no letters in it. Both asked for "Fira Sans": that is the literal family name behind iced's default font, and the one
cosmic-textresolves "sans-serif" to. Neither carries the font, so on a machine without it the request fell through to whichever of the hundreds of installed faces the font database returned first. On a stock Ubuntu 26.04 desktop that face had no text glyphs, and the Settings window rendered its headers and its layout ids and nothing else: no menu, no buttons, no checkbox labels. Both now ask the desktop what its sans-serif actually is (fontconfig on Linux, Segoe UI on Windows, Helvetica Neue on macOS). -
The Hotkeys pane no longer asks for a restart it does not need. It had been telling users to quit and relaunch the tray for a rebind to take effect since before 0.20.1 made the rebind live on window close.
Found by re-reading the hotkey path end to end after 0.20.0 closed #34, and every one of them was measured rather than reasoned. They share a shape: the binding is refused somewhere the user cannot see, and the default quietly takes its place.
-
Rebinding to the Windows/Super key never worked. The Settings pane wrote
Metafor that modifier, and the reader accepts onlySuperandCmd— so every Super binding was rejected on load and replaced by the default, while the pane went on showing what the user had pressed. -
Rebinding to a letter while a non-Latin layout was active never worked either. A key is captured as the character it produced, so the pane offered
Ctrl+Shift+Фand the reader refused it. Both are now refused where the user can see it: the capture is checked against the same reader the tray uses, and says what to press instead. -
A
Superchord could never fire on Wayland or Linux/evdev. Building the hotkey normalises Super to one modifier bit and the key-stream matcher tested the other, so the flag was always false and the chord matched nothing. -
The manual switch could aim at a layout the OS cannot switch to. "The other layout" came out of a hash map, in a different order every run. Harmless with exactly two layouts loaded — and not harmless when the OS layout list cannot be read at all, which loads all fifteen bundled ones and leaves the force-switch aiming at whichever came first, to be refused by a pre-flight check that tells the user nothing. It now prefers what the OS reports as switchable, and settles ties by name.
Windows PolterType has been offering updates it was structurally incapable of installing since the day the updater shipped. Found by reading the event log of a machine it had failed on five times.
-
"Restart to update" now installs the update. It never once did on Windows. The installer was started with
DETACHED_PROCESS, which leaves a process with no console at all, and Windows PowerShell 5.1 cannot start without one: it recorded that it was starting up and died before the first line of the script. From inside the app that looked exactly like success — the tray quit, nothing installed, PolterType did not come back, and after three such clicks the verified download was deleted as un-installable. -
The app no longer leaves on trust. Every installer script now says it is alive before it does anything that can fail, and the app waits to hear that before quitting. No greeting, no hand-off: PolterType stays running, says what happened, and the attempt is not counted against the download — a spawn the OS never ran is not evidence that a file is bad.
-
A refused install still gives you your app back, on all three platforms. The relaunch used to sit inside the success branch, so an installer the OS turned down ended with nothing running at all. The old binary is untouched in that case and perfectly able to start.
-
An install that fails now says so. Both output streams of the installer go to
installer.login the logs folder (Tray → "Open Logs Folder…"), msiexec keeps a verbose log of its own, and the exit code it left behind is read back and shown the next time PolterType starts. Before this, a failed update left no trace anywhere on the machine. -
macOS keeps a working app if the swap goes wrong: the installed bundle is moved aside rather than deleted, so a half-finished replace can put back something that runs.
-
The daily check actually happens daily. It was counting monotonic time, which stops while a laptop is suspended — so a machine that sleeps every night never accumulated twenty-four hours of it and the check fired once, at boot, and never again. (#3)
-
A hotkey changed in Settings takes effect immediately. The two chords were resolved once, before the event loop, and never again: changing either one wrote
config.toml, reloaded everything else, and left the hotkeys as they were until the app was restarted. (#34) -
The manual switch no longer eats text when it is pressed a moment late. A correction backspaces from the caret, and the word the hotkey reads was the last finished one — so pressing it after the next word had started sent that word's backspace count several characters too far right and left the line in pieces. It now acts on the word the caret is actually in, and where the caret cannot be accounted for at all it does nothing rather than guess. Present since the hotkey existed, and a plausible half of the "sometimes random symbols" in #33.
-
The manual switch works on the word you are still typing. It only ever acted on a word already closed by a space, so the gesture people arrive with from Punto Switcher and Caramba — type, see the wrong layout, press the key — did nothing at all, and said so only in a debug log nobody reads. Measured on KDE Plasma Wayland. (#34, #32)
-
Apple Silicon, by a contributor: first launch and permissions, detection and corrections, the force-switch hotkey, and the self-update from 0.18.1 to 0.19.0 on an M1 Pro — the
.app-bundle swap thatdocs/KNOWN-GAPS.mdhad called written-from-the-docs and never run. (#3) -
The "winit window" beside Settings on KDE Plasma Wayland is not ours, and is not fixed here. It is a throwaway window
iced_winit0.13 creates to boot its renderer, asks to be kept invisible, and never destroys — and Wayland has no invisible, so a compositor that lists toplevels shows it. Cosmetic, unfocusable, and gone when Settings closes. iced 0.14 has no such window; that upgrade is its own change.docs/KNOWN-GAPS.mdhas the protocol trace. (#35) -
KDE Plasma Wayland, in the desktop-matrix VM: the layout backend (
linux-kde-qdbus), the evdev listener, and — measured there for the first time — the manual switch-last hotkey, both on a closed word and on one still being typed.
Found by running PolterType on seventeen desktop sessions in a virtual machine. Ten correct a word end to end; the four that cannot now say so instead of eating the word.
-
GNOME works again. On GNOME 49 every settings key PolterType could write had stopped doing anything, so each correction deleted the word and retyped it unchanged. It now reads which source the shell is on, and switches with the desktop's own shortcut — sent only when the ordinary route fails.
-
sway is supported, through its own IPC. It keeps its keyboard configuration to itself, so there was previously no way to switch a layout there at all.
-
A word is never deleted and retyped unchanged. If the layout did not really move, the correction is abandoned before a keystroke and the log says which desktop and why.
-
MATE, labwc, Budgie's and Xfce's Wayland sessions report that they are unsupported instead of appearing to work.
docs/KNOWN-GAPS.mdhas what each lacks.
-
A correction no longer comes back in the wrong case. With Caps Lock on, PolterType retyped the word pressing Shift for every capital the lock had produced — and the lock is still on when it does, so the letters came back lower-case and every digit or punctuation mark came back as its shifted symbol (
1as!). The replay now reproduces the Shift the fingers actually pressed and leaves the lock to the system, which is what types the capitals. (#33) -
…and it no longer thinks the lock is on when it is not. On Linux PolterType counted presses of the Caps Lock key. Give that key another job —
caps:escape,caps:ctrl_modifier, or the popular "Caps Lock switches layout" (grp:caps_toggle) — and it latches nothing at all, while every press still flipped an internal flag that then stayed wrong for the rest of the session. The lock is now read from the keyboard itself (the Caps Lock LED the kernel keeps), so a key that locks nothing changes nothing. Windows and macOS read it from the OS the same way. -
A hotkey works with Caps Lock on. The same conflated flag told the chord matcher that Shift was held whenever the lock was, so a shortcut without Shift stopped matching and one with Shift matched without it. Corrections also stopped waiting out an absorb window that could never come quiet, and no longer release a Shift the user was not holding.
-
A digit no longer ends a word early under Caps Lock.
abc1read asABC!, and!is a separator.
- On most Linux desktops, an input method that was merely installed took over the keyboard layout — and then owned none. Ubuntu installs fcitx5 alongside language support and starts it at login, so it answers "yes, I am running" while managing nothing. PolterType believed it, loaded zero keyboard layouts, and went quiet: no corrections, and a log line saying the layout switcher was ready. Measured on GNOME, Xfce, MATE, LXQt, Budgie, sway, labwc, i3, openbox, fluxbox and icewm — everything except KDE and Cinnamon, which are asked first. A backend now has to name at least one layout before PolterType will use it, and the log says which one stood down and why.
- Setting
[general].log_level = "debug"now actually raises the log level. The key has been written into every config file since the beginning and read by nothing: the only way to get a detailed log was to relaunch from a terminal withRUST_LOGset, which is no help at all when the app starts from a menu entry or at login. The setting applies to PolterType's own crates, so a debug log stays readable rather than filling with someone else's toolkit.RUST_LOGstill overrides it.
Three reports from one afternoon of ordinary typing, all of them the same shape: a word PolterType had no business touching, or one it would not touch at all.
техcame back asnt[. On a US keyboard those three Ukrainian keys spellntand an opening bracket — andntis an entry in the bulk English wordlist PolterType ships, so the dictionary claimed the word with near-certainty and the bracket landed in the text. A rendering carrying punctuation in the middle of a word is not something anybody meant to type, so it can no longer speak for a layout. The rule is exactly the one a word's own layout has had since 0.6.3 (ma;ananever vetoedmañana); it just never applied to the other side.
command --wslcame back ascommand --цід. A hyphen belongs inside a word —well-knownhas to survive as one token — so a flag reaches the engine with no separator to give it away, and the identifier guard looks for underscores, digits and camel case, none of which--wslhas. Nothing that opens with a hyphen is prose, and PolterType now leaves it alone.wslalso joins the terminal vocabulary that is never auto-switched, so it is safe on its own too.
- Delete a line with Backspace, retype it wrong-layout, and nothing happened. Once the deletions ran past everything typed since the last space, PolterType marked what came next as unreadable and declined to correct any of it until a fresh separator appeared — which, on a line being retyped from its start, could be several words later. Deleting text PolterType never saw means it cannot vouch for what sits to the left of the caret; it says nothing about the word typed afterwards, which is watched from its first keystroke. The suggestion tooltip, which is the part that genuinely needs that context, still stands down.
The first release cut on a Windows machine. Most of what follows was found by running the app rather than reading it, and one of the fixes is the reason nobody had been able to run it that way before.
- Keystrokes that arrive from another program are no longer thrown
away. Typing through a software KM switch from another machine
(Deskflow, Synergy, Barrier), the on-screen keyboard, voice typing,
or any remapper that re-injects made PolterType go completely quiet
— on Windows, and nowhere else, with nothing in the log to say so.
The engine drops keystrokes it synthesised itself, which it must;
the Windows listener was marking everything synthetic as ours.
Only our own corrections are marked now, which is what macOS has
always done. See
docs/DECISIONS.md, 2026-08-23.
-
The tooltip appears at the text caret in applications that keep a real one, instead of always hanging above the bottom edge of the window. Applications that draw their own caret — most browsers, most terminals — still get the window position, the same way an app without an accessibility bridge does on Linux.
-
The window is sized and placed in the same pixels. PolterType had never told Windows it understood display scaling, so the tooltip measured its size against the monitor's real DPI and its position against coordinates Windows had scaled down to match a 96-DPI screen. The two only agree at 100% scale. They agree everywhere now.
- A busy Windows Installer is waited for, not treated as a failure. Windows allows one MSI transaction at a time, so a vendor's support agent or Windows Update running at the wrong moment made "Restart to update" quit the app, install nothing, and come back on the old version with no explanation anywhere. PolterType now comes back for the installer for five minutes, and if it still cannot have it, writes the exit code where the update was staged.
- A language with several keyboards installed is listed once.
Windows has three different Bulgarian keyboards under one language
id; all three appeared as identical
bg-BGrows, two of which the engine was already ignoring. The list now matches what the engine actually loads.
-
A caret reported by one application can no longer place the tooltip in another. Every app on the desktop feeds the same accessibility caret stream, and PolterType kept only the freshest position without recording whose it was — so a chat window updating in the background, or the editor you left a moment ago, decided where the tooltip went in the terminal you are typing in now. Anything that landed inside the focused window was accepted. Each caret is now tied to the process and window that reported it, and used only there.
-
VS Code and other web-based editors get a real caret. They answer no glyph rectangle for any position, so the tooltip used to fall back to the bottom-centre of the window — a long way from a caret near the top of a file. It now follows the caret itself.
-
The tooltip can no longer land on the wrong monitor, and no longer slides by the height of a panel. Placement is measured against the screen the anchor is actually on, as the compositor reports it.
-
A word that starts with
:/\@=#&is left alone, the way one that ends with them already was./tmpcame back as/еьз. The guard that keeps PolterType out of URLs, paths and email addresses only ever looked at the character that finished a word — and a path segment finishes with an ordinary space, sotmpreached the detectors as if it were a word in a sentence. The same test now runs on the separator the word began after, which covers/tmp,@nickname,C:\Usersand--flag=valuealike. Sentence punctuation (.,,) is still deliberately not on that list, and the manual switch-last hotkey still ignores every filter. -
Shell and toolchain vocabulary is in the bundled English dictionary.
tmp,mkdir,stderr,rustc,systemctl,localhostand ~30 more are missing from every general-purpose word list, and most of them have no vowels — the exact shape the engine reads as wrong-layout noise, which is howmkdirbecameпнасиwith no slash anywhere in sight. Each entry was checked against every other bundled dictionary first, so none of them costs another language a correction.
-
A real word of the layout you typed in now outranks any overlay entry on the other side. The overlay-priority sweep ran across both layouts before the current one's own dictionary was consulted at all. One stray entry therefore beat a genuine word — every time, at 0.95 confidence, silently and for good. With
ghbdsnin a user's English overlay, a correctly typed UkrainianПривітwas rewritten toGhbdsnon every attempt. Overlay priority still wins where it was designed to: a coincidental skeleton match on a render that carries stray punctuation. -
Undoing a correction the engine got right no longer teaches its wrong-layout twin as a word. The manual switch-last hotkey doubles as "you were wrong, learn this" — the auto-correction path's only escape hatch — and it used to learn unconditionally. Undo a correct correction, whether to try the gesture out or to want the other rendering just this once, and the engine wrote the gibberish it had just fixed into the dictionary as a word:
привіт, correctly switched away from en-US, came back as the English wordghbdsn;taskscame back as Ukrainianефілі. Each then broke the real word through the path above. An undo now teaches only when it carries evidence — the target layout already knows the word, or the correction rested on word shape rather than on a dictionary hit.Entries already learned this way stay until removed. They live one per line in the user wordlists (
<config>/poltertype/wordlists/); anything there that reads as the other language typed on the wrong keyboard can be deleted, and takes effect on Reload Settings. -
A word added to a user wordlist is written in the shape the loader reads back. The file took the token as typed while the running dictionary took it stripped to letters, so an entry could sit on disk as
just-code.netagainstjustcodenetin the engine. Nothing behaved differently — the loader normalised on the way in either way — but the file is the thing people open and edit.
-
poltertypeis now in the bundled English dictionary. It is a coined name no general-purpose word list carries, so typing it on a Cyrillic layout produced a candidate the engine had no reason to believe in — the one word every user of this app is guaranteed to type was the one it would not fix. A corpus test against the real shipped dictionaries now holds it. -
One stray token left the list with it.
ghjcrehby— a Cyrillic surname typed in the wrong layout — had been pasted intoen_us-extras.txtby accident and was being taught to the engine as valid English, which is exactly the gibberish the app exists to correct.
- "Run at login", ticked from an AppImage, wrote an
Exec=nothing could launch. The entry took its path fromcurrent_exe(), which inside a running AppImage points into the temporary mount (/tmp/.mount_XXXXXX/usr/bin/poltertype) — gone the moment the app exits, and long gone by the time the next session reads the entry. It now prefers$APPIMAGE, the way the desktop entry and the updater already did. Nothing to redo by hand: the entry is rewritten on the next launch.
-
~/.config/autostartis a desktop-environment mechanism, and half our Linux users do not run one. GNOME, KDE and Xfce read that directory; a bare Hyprland, Sway or river session has nothing that does, so the entry the toggle wrote sat there being read by nobody. Where systemd'sxdg-desktop-autostart.targetbridges the gap it is also the wrong shape — it fires as early as the user manager can reach it, which is before a compositor has published the environment we need.PolterType now installs
~/.config/systemd/user/dev.opensource.poltertype.service, wanted bygraphical-session.target: started by whatever brings the session up, and therefore after the same thing has imported the session's environment. The old.desktopentry is removed when the unit goes in — two mechanisms would start two copies, and the second one loses to the instance lock with a message that reads like a fault. A machine with no systemd user manager still gets the XDG entry.A bare compositor still has to reach
graphical-session.targetonce, which is a five-line one-time wiring; PolterType logs a warning when it installs the unit into a session that never gets there, instead of leaving the toggle to be disproved at the next login.docs/PERMISSIONS.mdhas the recipe.
global-hotkeywas being built on a backend that never uses it. On Wayland/evdev the chords are read off the key stream, and nothing is ever registered with the OS manager — but creating it still startsglobal-hotkey's X11 thread, which opens a display and uses the handle without checking it. With no display that isXDefaultRootWindow(NULL): a SIGSEGV inside libX11, under our name, three log lines into startup. It is now built only on the path that registers something, and only after waiting up to 15 s for an X display to answer — the same window, and the same reasoning, as the layout backend. Past it the app starts without OS-level hotkeys rather than not at all.
- PolterType stayed dead for the whole session over a backend that was 200 ms late. With no switcher it aborted at startup, which on an autostarted process meant a tray icon that never appeared and an exit code in a journal nobody reads. It now starts anyway with switching turned off, puts ⚠ Layout switching unavailable — Setup… at the top of the tray menu, and the Setup pane explains the rest — the same shape as a missing keyboard hook, which has worked that way since 0.17.3.
-
The stash the hotkey works on was being cleared by the idle timeout.
[engine].idle_timeout_ms(2 s) abandons the word still being typed, which is right — after a pause the caret may be anywhere. It was also dropping the last completed word, and the first key event after the pause is the hotkey's ownCtrl. So the sequence a person actually performs — type a word, notice the layout was wrong, reach for the chord — cleared the stash on the way to reading it, and the hotkey did nothing at all. Reported on Arch/Hyprland, where it looked like the chord was not reaching the app; it was, four times over, measured on the key stream.The stash now outlives idle by a window of its own (
LAST_WORD_TTL, 60 s). Nothing else changes: a click, a nav key, deleting text we never saw, or the next completed word all still drop it through their own paths — this only bounds how long an untouched machine keeps one word in RAM. An engine test pins the whole sequence, and fails on the old code.
-
Autostart looked broken on Hyprland because PolterType exited 1 at login. The XDG entry was correct, systemd's generator turned it into a unit, the unit ran — and the app aborted with "no layout switcher backend", having probed all seven. A Hyprland session imports its own environment into the systemd user manager from its config, and
xdg-desktop-autostart.targetcan win that race, so the process that autostart launched could not see the compositor that launched it.Two changes, either of which would have covered this one, and both of which are right on their own:
- The Hyprland probe no longer depends on
HYPRLAND_INSTANCE_SIGNATURE. It resolves the instance from the live socket directory when the variable is missing — which is exactly the autostarted case — in the layout switcher and in the focus tracker alike. - Startup keeps probing for a backend for 15 s before giving up instead of aborting on the first miss. Every backend is probed against something a session brings up asynchronously (a compositor socket, a D-Bus name, a gsettings schema); being 200 ms early is not the same as being unsupported.
- The Hyprland probe no longer depends on
-
scripts/setup-linux.shsets NixOS up declaratively instead of failing at it. It could never have worked imperatively there:/etc/udev/rules.dis a read-only symlink into the Nix store, so the rule write failed andset -eended the script mid-way — afterusermodhad added the account toinput, which the next rebuild silently reverts, becauseusers.users.<name>.extraGroupsis what decides membership. Reported from a fresh NixOS install where PolterType would not start at all afterwards.It now writes
/etc/nixos/poltertype.nix—hardware.uinput.enable, the two groups, andprograms.appimage.binfmt, without which NixOS cannot exec a generic AppImage at all — and adds one line to theimportslist inconfiguration.nix, keeping the original asconfiguration.nix.poltertype-backup. Both files are checked withnix-instantiate --parsebefore they are left in place; an edit that does not parse is rolled back rather than handed to your next rebuild. On a flake-built system the module is also staged withgit add— a flake evaluates the git tree, so an untracked module is invisible to the rebuild that imports it, which is how the first real run of this ended.nixos-rebuild switchstays yours to run: it can fail on things that have nothing to do with PolterType, and you want to be able to tell the two apart. So does a configuration the script does not understand — noconfiguration.nix, noimportslist, or no sign of the account being declared under/etc/nixos— where it prints the block to paste and changes nothing.docs/PERMISSIONS.mdhas both. -
That verification now checks
/dev/uinputinstead of noting that it exists. "✓ /dev/uinput exists" was a pass on a machine where the node belongs to a group the account is not in — PolterType reads the typing and cannot type the correction back, which is how NixOS is set up out of the box. It now names the owning group and checks both membership and the group write bit.
Fixed — Linux/Wayland: force-switch looked broken because Settings named the wrong key (#31)
-
The Hotkeys pane now shows the chord the tray is really listening for. On the Wayland/evdev backend a hotkey is observed rather than consumed by the OS, so
Ctrl+Shift+Backspacewould also reach the app being typed in — whereCtrl+Backspacedeletes the very word the force-switch is about to fix. PolterType has therefore substitutedCtrl+Shift+F9there since the backend existed, said so in the log, and gone on displayingCtrl+Shift+Backspacein Settings. Anyone who trusted the window pressed a key nothing was listening for and concluded force-switch was broken. It was not:Ctrl+Shift+F9fires it, verified on this machine end to end.The substitution is now resolved by one function that both the tray and the Settings window call, so they cannot drift apart again, and the pane prints a line under the row saying which chord was replaced and why. Nothing is written back to
config.toml— a config file still means the same thing on every machine, which is the whole reason the substitution is a runtime decision.The same lie was live on macOS, where the default pause chord is replaced because the system already owns
Ctrl+Shift+Spacefor switching input sources. Same fix, same pane, unverified on hardware like everything else macOS here. -
The pane no longer claims hotkeys are "registered with the OS at startup". On the evdev backend they are read off the key stream instead — which is exactly what makes the substitution necessary, so the sentence was contradicting the behaviour it sat above.
Fixed — Linux: told to run a script they had just run twice (#31)
-
The keyboard-access failure now names which of five things went wrong. Every Wayland machine that could not read a key got the same sentence — "no readable keyboard devices in /dev/input/* — run scripts/setup-linux.sh to grant access" — and for four of the five causes that script is a no-op. The reporter of #31 ran it twice.
The listener now reports what it found: how many
event*nodes exist, how many opened, how many are keyboards, and the errno anduid/gid/modeof the first one that refused. The advice follows from that. A session already carrying theinputgroup is told the udev rule never reached the existing devices, and what the kernel says they are owned by. A session that predates its own group membership is told to log out — and thatnewgrp inputgrants the group to one shell, so an app started from the desktop never sees it. Only genuine non-membership is told to run the script. A container with no input devices, and a machine whose devices all open but hold no keyboard, each get their own answer. -
scripts/setup-linux.shverifies its own work. It re-reads the group database andstats every/dev/input/event*afterwards, and exits non-zero instead of printing "Done" if the user is not in the group, if any device is still not group-readable, or if/dev/uinputis missing. It also catches being run as root with noSUDO_USER, where it isrootthat gets added to theinputgroup rather than the account that will run PolterType. Its closing advice now says which of log-out andnewgrpapplies to the shell it is standing in. -
The AppImage carries the setup script it tells you to run. It never did, so "run
bash scripts/setup-linux.sh" meant "go clone the repository to get one file" for everyone who downloaded a single self-contained binary. It now ships atusr/share/poltertype/scripts/, and both the error and the Setup pane's copy button name the copy the running binary actually has. -
A device set with no keyboard in it is a failure, not a quiet start. Opening only a mouse used to count as success: clicks tell the engine to forget its buffer and nothing else, so the app ran and could never correct anything, with no line in the log to say why.
The KDE half of #31 is confirmed fixed on the reporter's Plasma 6 session — the layout list parses and both layouts load. The layout switch itself still has not run on a real KDE machine; see
docs/KNOWN-GAPS.md.
-
cqrs-clientstayscqrs-client. Typed under en-US with a Cyrillic layout loaded, it was being replaced withсйкы-сдшуте. Read as one string the token really does look like noise — six consonants in a row and a 0.20 vowel ratio, scoring 0.00 for en-US against 0.75 for the Cyrillic reading. Read ascqrs+clientit is obvious: the second half is a plain English word, and only the acronym welded to its front made the whole thing look wrong.Both detectors now judge a hyphen- or dot-joined token segment by segment. The same fix covers the whole family —
grpc-server,api-gateway,redis-cache,client.cqrs— and, as it turns out, ordinary hyphenated English that had the same exposure:well-known,read-only,up-to-date,cross-platform.Hyphenated words in other languages are untouched, which is the hard half.
по-перше,будь-ласка,все-таки,интернет-магазин,кое-чтоare still corrected: a segment only speaks for its token when it reads better in the layout that is already active than the switch would make it. A first attempt that merely asked "does some segment read well here?" passed every unit test and silently stopped correcting a fifth of a real Russian corpus; a new corpus test runs both directions against the real bundled dictionaries so that cannot come back. Seedocs/DECISIONS.md, 2026-08-20. -
A handful of lowercase acronyms joined the bundled English dictionary —
cqrs,csrf,xss,webrtc,psql. Typed alone they have too few vowels for shape scoring to defend, and none of them collides with a real Ukrainian or Russian word on the same keys.
Fixed — Linux: KDE, and an AppImage that could not start (#31)
-
PolterType no longer aborts when the tray library is missing. The library is
dlopened by name, and when no version of it is installed — the default on Arch and its derivatives — the process died with a SIGABRT and a dlopen dump in the system language that named four.sofiles and no package. It now says what is missing and which package provides it on Arch, Debian/Ubuntu, Fedora and openSUSE, and exits cleanly. -
The AppImage now carries that library. Because the load is a
dlopen, the packaging tool's dependency scan never saw it, so every AppImage released so far shipped without it. This is the half that means KDE users need install nothing. -
The KDE layout backend works against Plasma as it has been since 5.23. Two bugs:
getLayoutsListreturns a type plainqdbuscannot print, and it reports that on standard output with a success exit code — so the error sentence itself was being read back as the name of a keyboard layout, every bundled layout was then skipped as "not active", and the engine came up with zero layouts and no way to correct anything. Underneath that,getLayoutandsetLayouthave addressed layouts by index since Plasma 5.23 (2021) while we were passing xkb names likeus.Both are fixed against KWin's own interface definition, and the backend now refuses to activate at all unless Plasma answers with a layout list it can actually read — falling through to another backend beats poisoning the engine with a layout that does not exist. Verified against upstream source, not against a running Plasma session — nobody here has one; see
docs/KNOWN-GAPS.md.
-
REQUIRE_SIGNATUREistrue. Since v0.7.0 the release manifest has carried a detached ed25519 signature, made with a key that has never been in CI and checked against a public key compiled into the binary before any URL in the manifest is read. Until now a wrong signature was refused and a missing one only warned — a two-stage rollout, because the users of the release that introduced signing would otherwise have been checking manifests published before anyone signed one.Stage two was due at v0.9.0 and arrives here. Every release from v0.7.0 to v0.17.1 was in fact signed, verified live through the
releases/latest/download/latest.jsonredirector, so the condition had held eighteen times before the constant moved. What it buys: whoever can publish a GitHub release can no longer publish an update, because the checksums in a manifest are no defence when they ship in the same release as the file they describe.Builds older than v0.17.2 are unaffected — each carries its own copy of the constant, still
false. And note what is still not signed: the installers themselves. That needs certificates we do not hold (docs/CODE_SIGNING.md), so a first launch still meets an OS warning.
-
An option that explains itself. A choice between
ask,autoandoffis three words and a drop-down is right; a choice between nine language models is not, because they have to be compared and a picker shows one at a time with nowhere to put the sentence saying what each is for. An entry inoptionsmay now be a table carryingdetailandlinkbeside its value, mixed freely with plain strings, and a choice with any described option is drawn as a column of radio rows. A plug-in supplying a link is a third party deciding where PolterType sends somebody, so:httpsonly, refused at manifest load and again at the click, and the visible text is the address. -
A setting that is a list of composite things. Scheduled messages — each an application, a conversation, a time and a text — had no shape: a
stringslist gives one line per entry with no structure. Arecordsgroup names an array of tables and declares what one row holds; the pane draws a card per entry with Add and Remove, and every comment in the file survives the edit, including the ones inside rows nobody touched. A row's fields are single names rather than paths and cannot nest — a pane that nests is a config editor. -
A box that suggests, and a card you can act on. Every conversation name used to be typed by hand off another window, and a name one character wrong is a message that never goes out. A
suggestfield is free text with the answers offered beneath it, narrowing as it is typed into, filled from the manifest'soptions, from a command, or both — and what gets stored is the row's id, never its label. It is deliberately not achoice: the conversation somebody wants may be in a client that is not running, and a drop-down offers no way to say so. Arecordsgroup may declareactionsand anid_field, giving each card a button that runs a declared command with{id}replaced by that field — because a pane that can describe "send this at nine on Tuesday" and offers no way to try it before Tuesday is the wrong trade for the one thing here that writes to another person unattended. -
The suggestion list is drawn inline and bounded, at most eight rows with the remainder counted rather than scrolled: iced's overlay sized itself to ninety-five conversations and covered the whole form, and a second scrollbar beside the pane's only one is a wheel that moves whichever region the pointer happened to be over. Each row carries the detail line the plug-in sent with it. Six muted words beside the label say that answers are behind the box and which state it is in — "asking the plug-in" and "the plug-in offered nothing" look identical in an empty list.
-
The Settings window now wears the mark on Linux too. 0.17.1 gave it an icon; on a Wayland session that icon never arrived. Two things were in the way, and neither was the icon itself — the AppImage has shipped
poltertype.pngall along.The window declared no application id.
icedpasses one to winit whether or not you set it, so ours went out as the empty string: an empty Waylandapp_id, and an empty X11WM_CLASS— which is worse than omitting it, because winit's fallback to the binary's own name runs only when nothing is passed at all. Measured on Hyprland,hyprctl clientsreportedclass: "". Pinning, grouping and every "which app is this window?" question had nothing to go on.And on Wayland the app id is the only route an icon has: the protocol has no window icon, so the compositor looks the app up in the installed
.desktopentries instead — which is why winit implements setting one there as an empty function. The window now declarespoltertype, matching the entry every Linux package installs. -
A downloaded AppImage installs its own desktop entry. Nothing in the un-packaged path ever put a file where the desktop looks, so a user who did what the site says — download,
chmod +x, run — had no menu entry and no icon anywhere. PolterType now writespoltertype.desktopand the mark at five sizes into$XDG_DATA_HOMEat startup, rendered from the same geometry as the Windows resource and the macOS.icns. It steps aside when a distribution package already installed one, rewrites only when the entry is missing or stamped with an older version, and pointsExecat the AppImage the user downloaded rather than at the temporary mount it is running from. -
The autostart entry and desktop notifications name an icon. Both drew a placeholder for the same reason — there was no installed icon to name. There is now.
-
A row's button waits, and says what happened. It ran detached and its output went nowhere, so "did it go?" — the only question "Send now" is pressed to answer — was answered nowhere, and the report below told you only if somebody thought to press Refresh. It now runs off the UI thread with a deadline of its own (90 s: this is not a query, it opens a chat client and types at human speed), shows the plug-in's own sentence as the status, and re-asks the reports it just invalidated. While one runs, every such button is dead — they steal focus, and two at once would type into each other's window.
-
A failed row action no longer stacks three names in front of the one worth reading. «Ранкове» could not be run: row action: Ранкове: … — the plug-in's own sentence now comes through unprefixed when there is one to quote, and the pane does not add a row's name to a message that already carries it.
-
The button beside a suggestion box says
list, not↓. The arrow is in the bundled Fira Sans — itscmapsays so — and still drew an empty box, because what the renderer resolvesFont::DEFAULTto is not the file the crate ships. Four letters nobody has to guess at, andhidewhile the list is open, which also says which way the press goes.
- macOS was checked and needed nothing: the
.appcarriesAppIcon.icnsat ten sizes,CFBundleIconFilenames it, andLSUIElementkeeps the app out of the Dock and the app switcher on purpose, so the surfaces that showed a placeholder on Windows do not exist there.
-
poltertype.exenow carries its own icon. Installed from the MSI, PolterType sat in the Start menu wearing the shell's placeholder. The binary had zero icon resources: the only icon we ever produced was the installer's, andARPPRODUCTICONreaches exactly one screen — Add/Remove Programs. The Start-menu shortcut is authored to take its icon from the file it points at, and the file had nothing to give, so Explorer, Alt-Tab, the taskbar and every pinned entry showed the same placeholder.The executable is now built with the PolterType mark embedded at six sizes (16 through 256), rendered from the same geometry the installers use rather than a checked-in image, so the two cannot drift apart. Nothing in the installer changed — the shortcut simply has something to inherit now.
-
The Settings window wears the mark too. It had no icon of its own, so its title bar and its Alt-Tab card drew the placeholder as well. Same geometry again, rendered at 64 px when the window opens.
-
The Details tab is no longer blank. The exe had no
VERSIONINFOblock either, so Windows could not say what version it was, who published it or what it does — and neither could anyone deciding whether to trust an unsigned binary. It now names the product, the version, the company and the licence.
- Release CI renders the Windows icon with
cargo xtask assets icon-icoinstead of converting a PNG with ImageMagick. Every size comes straight off the vector mark rather than being box-filtered down from one 1024 px master, and the workflow stops assumingmagickis preinstalled.
-
A plug-in may fill a menu with rows it produces on the spot. The tray could show a plug-in's settings and its state; it could not show the things that arrived while nobody was looking and are waiting on an answer, because a manifest written months earlier cannot name them. A manifest now declares a list whose rows come from a command run while the menu is being opened — printed in the same tab-separated form the settings pane's tick-box lists already use. Each row becomes a submenu of its own: the label is what fits on one line, and everything the row actually holds is one hover away, which is the only place in a tray menu detail can live at all.
A plug-in supplies text, never markup, and never actions. What a row can do was declared before that row existed, 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 same boundary as the settings pane, one storey down: a third party that can draw can imitate PolterType's own dialogs. -
The icon can say that something is waiting. A plug-in names the state key that counts, and a value above zero puts a mark in the tray icon's top-right corner, with the count in the tooltip. Top-right because the bottom-right is the pause indicator, and a paused PolterType with work waiting has to be able to say both at once. A plug-in gets to raise that mark; it never gets to replace the icon, draw on it, or choose what it looks like.
-
"Select all" and "Clear", beside the Refresh a list already had. A plug-in can offer sixty conversations, and saying "all of them" by clicking sixty boxes is the work a settings window exists to spare somebody. Only a list carries the two buttons — a report has nothing to tick.
Both act on the rows currently on screen and nothing else: a list can hold names the plug-in did not offer this time (a conversation in a client that is not running, one typed in by hand), and silently clearing what cannot be seen is the worse surprise of the two. It is also one edit of the file rather than one per row, so the program that owns that file cannot catch it half-written, and its comments are read past once instead of sixty times.
-
The separator that closes a word survives the correction. Typing a word and then
,under uk-UA could come back asPhotos?— the word retyped and the comma replaced. The word is deliberately re-emitted as scancodes so it re-reads under the new layout; the key that closed it went out the same way and picked up the new layout's glyph too.Shift+0x35is,in uk-UA and?in en-US, so the punctuation the user typed was rewritten by a correction that had no business touching it. The boundary is now looked up by character in the target layout and replayed on whichever key produces it there (,moves to a bare0x33under en-US). Where the target cannot produce the character at all — a few layouts reach some punctuation through AltGr, which PolterType does not track — the key is replayed as typed, exactly as before. -
Switching the layout by hand no longer invites a correction. Type
Photosin en-US, switch to uk-UA, then press the key that closes the word: the engine read a word it had watched being typed in English as though it were Ukrainian, found gibberish, and "fixed" it — retyping text that was already right and pulling the layout back off the one just chosen. The buffer holds scancodes, so a word only means anything against the layout that was active while it was typed; each word is now stamped with that layout at its first key, and a word that ends under a different one is left alone. The manual switch-last hotkey still works on it.
-
A plug-in's settings are navigable instead of endless. The pane was one flat column of labels with boxes after them, which was fine for the five controls a plug-in declared and unusable for the entire config surface of one. A plug-in can now declare
sectionheadings, and PolterType turns them into a navigation list beside the window's own, showing one page at a time. Two new control kinds come with it:decimal, which always writes a float because TOML's two number types are not interchangeable to the program reading the file back — a plug-in expecting0.35refuses to start on1— andstrings, a list edited as one comma-separated line for the sets nobody can offer rows for.As before, a plug-in never draws anything. Every control is rendered by PolterType from a static declaration, which is what stops a third-party pane imitating a system prompt or another plug-in's settings.
-
Typing no longer writes on every keystroke. A value settles when you move on, and at the latest when the window closes. The old behaviour put every prefix of what you were typing into a file a running plug-in was reading: a threshold on its way from
0.9to0.95passes through0, and for the length of a keystroke a gate in that plug-in was wide open. -
A section nobody is looking at is never asked. Controls whose rows come from the plug-in do not spawn it until their page is on screen, and two controls sharing one command now ask once — so opening this pane no longer reads every chat client's sidebar twice over.
-
The Settings window no longer re-reads a plug-in's config once per list row. Deciding whether a row's box was ticked meant reading the plug-in's whole config file and running a format-preserving TOML parse — for every row, every time the window rebuilt. A plug-in showing two lists of 34 entries read 1.2 MB and ran 68 parses on every click, against a file the click itself had just written. Membership is now held and refreshed where the file can actually have changed: on load, on every write the pane makes, and on reaching a section. Measured on that pane, 1216 KB per interaction became 34 KB.
-
Exactly one region of the Plug-ins pane scrolls. Reports and row lists grew their own scrollbars a few pixels from the page's own, and a wheel over the boundary moved whichever one the pointer happened to be inside. They now grow to their content and the page scrolls once. The pane also drops its card frame and its own padding, which had it sitting further from the window edge than every other page.
- Dependencies are optimised in debug builds (
[profile.dev.package ."*"] opt-level = 3). This is a contributor-facing change with a user-visible reason: the Settings window is rendered on the CPU, so unoptimisedtiny-skiaandcosmic-textare the whole frame budget. The same scroll costs 97% of a core in a stock debug build and 20% in release. Our own crates stay unoptimised, so stepping and backtraces still work where the bugs are; the cost is one slow build after pulling this.
-
The spelling-suggestion tooltip now renders on macOS. It was the last platform where the feature existed engine-side and nothing drew it: suggestions were computed, the accept chord worked, and there was no list to look at. There is now — a borderless, non-activating
NSPanelthat cannot take the keyboard away from what you are typing in, the same guarantee the Windows and Linux overlays make and the reason this feature has its own window type on every platform. Click a row to accept it, hover to highlight, ignore it and it hides itself. The accept chord is shown the way macOS writes shortcuts (⌃⇧) rather than in the Windows spelling the config uses.Contributed by @shohart and verified on real hardware (Intel, macOS 15.7) — #29.
-
PolterType can see which application has focus on macOS. The tracker was a stub there, which meant three features silently did nothing on that platform however you configured them:
[exceptions].disabled_apps, per-app wordlist profiles, andapps = [...]scoping on smart commands. They work now. If you carried any of those settings on a Mac, they start taking effect with this release — they were never wrong, just inert.The tooltip uses the same tracker to find the caret, so on macOS it sits at the text rather than at the bottom of the window. Where an application reports a caret that isn't where the text is — Chrome and Terminal both do — the answer is rejected and the tooltip falls back to the focused field. This uses the Accessibility permission the app already required: it reads where the caret and window are, never what is in them.
-
Corrections on macOS no longer drop or duplicate a character. Key events posted back-to-back arrive inside a single run-loop turn in the receiving application, which coalesces them — so a backspace could go missing at the seam between deleting the mistyped word and typing the replacement, leaving its first letter on screen, or land one too many and eat the space before it. The burst is now paced, as it already was on X11.
-
Typed words could reach the log through a dependency.
cosmic-textandfontdblog the text they are shaping at debug level, and the suggestion tooltip shapes the words being suggested. Both are now capped atwarnwhateverRUST_LOGsays. This affected every platform, not just macOS, and only at debug level — but "no typed text in logs, ever" does not have a level qualifier on it.
-
PolterType now works on Intel Macs. The universal binary we shipped had its arm64 half signed and its x86_64 half not, and macOS runs the x86_64 half on an Intel Mac. Accessibility permission cannot attach to unsigned code, so the event tap installed, reported success, and received nothing: the toggle in System Settings read ON, the log showed a healthy listener, and no layout was ever switched. Apple Silicon was unaffected throughout.
Nobody removed a signature — nothing ever added one. On an Apple Silicon build machine the linker ad-hoc-signs the arm64 binary because arm64 macOS refuses to run unsigned code at all; the cross-compiled x86_64 binary needs no such favour and does not get one.
lipothen merges the pair verbatim, and the release never rancodesignover the result. The build now signs the finished.appand fails the release if either slice comes out unsigned, which is the check whose absence let this ship.This does not make the app notarised: Gatekeeper still asks for right-click → Open on first launch, exactly as before. One new wrinkle — because the signature is ad-hoc, macOS identifies the app by the hash of its bytes, so Accessibility must be granted again after an update. A Developer ID would fix that too, and is still on the list. Reported by @shohart, with the
codesignoutput that made it diagnosable.
-
Windows keyboard mappings are now read from the OS instead of assumed. Windows identifies a layout by its language, so every Bulgarian keyboard arrives as
bg-BG— and Windows ships three that are genuinely different. PolterType bundled one mapping per language and handed it to whoever asked, which meant a user on Bulgarian (Phonetic) got a table wrong on 45 of its 48 keys. Nothing errored and nothing appeared in the interface: corrections were simply built from a keyboard they do not own, and the detector was reasoning about a word they never typed. PolterType now asks Windows what each installed keyboard actually produces and uses that in place of the bundled table. This fixes every variant at once — including the ones we never bundled, and custom layouts we have never heard of — rather than the handful somebody remembered to describe. Turkish Q vs F and Ukrainian standard vs Enhanced carried the same risk and are covered by the same change. -
Ukrainian
ґis on the right key on Windows. The bundled mapping puts it where xkb does; Windows puts it on the extra key next to the left Shift and gives the old position to\. One TOML cannot be right for both, so Windows users had one key wrong in a layout that was otherwise exact. They now get the Windows answer while Linux keeps the xkb one, with no change to the data. The Ukrainian apostrophe and the hryvnia sign — absent from the bundled table entirely — came along with it. -
Accepting a suggestion picks the same key every time. Where a layout carries one character on two keys, the reverse lookup iterated a hash map and could pick either, run to run. It now takes the lower scancode, which is also the key that exists on keyboards that don't have the extra ISO one.
Nothing changes for Linux or macOS: the bundled TOMLs are untouched
and a backend that cannot describe its keyboards simply keeps using
them. A layout TOML in <config-dir>/poltertype/layouts/ still
outranks everything, including the OS.
-
word_whitelistnow actually blocks auto-correction. The setting is documented as "words that should never be auto-corrected" and was read in exactly one place: the suggestion tooltip. So a word listed there stopped being flagged and went on being corrected — the one thing the setting exists to prevent. It is now the first of the pre-decision filters, ahead of every heuristic, because it is the only one of them that is a statement of intent rather than a guess. Entries are matched on letters only and case-insensitively, soJust-Code.netin the config answers for the buffer'sjustcodenet. -
The correction path has a way into the dictionary at last. "Add to dictionary" lives on the suggestion tooltip, and the tooltip only appears for words the engine keeps — so for the words it wrongly corrected, the ones that actually cost you something, there was no way to teach it anything.
Ctrl+Shift+Backspaceon a word PolterType just corrected now undoes that correction and adds the word to your dictionary. It used to re-apply the same correction: same keys, same target layout, the word deleted and retyped identically — the gesture you reach for when a correction is wrong did visibly nothing. The word joining the dictionary is announced with a notification (when you have those on), because unlike clicking a button labelled "Add to dictionary", it is a side effect of asking for something else. -
One word, not twelve. A word added to the dictionary now also answers for its other grammatical forms: teach it
деплойandдеплою,деплоїмо,деплоїтиstop being flagged too. In an inflected language a single piece of jargon otherwise costs a dozen trips through the tooltip — measured against one real user's wordlist, 11 of 75 entries were forms of a word already in it. The rule is deliberately narrow (a shared five-character opening, at most four characters of ending on either side) and applies to the suggestion tooltip only: detection still runs on exact membership, because being lenient there would mean corrections silently not happening.
PolterType could stop correcting anything at all, with no error in the
log, and stay that way until it was restarted. The trigger reported
was a Cinnamon layout-switch shortcut bound to a bare modifier
(Alt_L → first layout, Alt_R → second), found by the same reporter
as #26.
The X11 backend tracks modifiers by watching press and release edges,
because XInput2 raw events carry no modifier state of their own. That
is only sound while we see every edge — and we do not. Any client
holding an active keyboard grab stops raw events reaching everyone
else for as long as it holds one: measured on X.org, three key taps
produced nine raw events with no grab and zero during one, with no
error and no disconnect. A desktop takes exactly such a grab to
service a keybinding, so a modifier pressed just before it and
released inside it left us latched with Alt held forever. From then on
Modifiers::is_command() was true for every keystroke, the engine
read each one as a shortcut, and the word buffer was abandoned every
time.
The listener now reconciles its modifier state against XQueryKeymap,
which answers from the server's own device state rather than from
event delivery and — measured the same way — keeps working through a
foreign grab. It runs only on idle rounds and only while some modifier
is believed held, so an idle keyboard never asks at all, and a stuck
modifier clears within 200 ms.
Cinnamon is the reliable trigger, not the only one: a lock screen, a
screenshot tool or any window-manager chord can swallow the same edge.
PolterType made the Cinnamon case frequent by switching the layout
itself, so the user's shortcut often targeted the layout they were
already in — which is the path in Cinnamon's activateInputSource
that returns before releasing the grab.
-
A hostname typed in its own layout is no longer "corrected" into Cyrillic. Typing
games.just-code.netunder en-US rewrote it asпфьуіюогіе-сщвуютуе, and the next prose word switched the layout straight back — so a sentence with an address in it switched twice, which is what the report "ввід доменів працює дуже погано, перемикає по декілька разів" describes.The
.key is a letter in the Cyrillic layouts (scancode0x34isю), so the word buffer correctly keeps a whole host together as one token — but that made the two renderings incomparable. The Cyrillic one is a clean run of letters, while the en-US one keeps its literal dots and paid the word-plausibility detector's stray-punctuation penalty twice over, scoring 0.00 for its own layout against 0.75 for Cyrillic. The correctly-typed domain looked like the most obvious wrong-layout word the engine had ever seen.Dot-separated compounds are now scored one segment at a time and take their worst segment's score, so a host reads as plausible exactly when every one of its parts does. A genuine wrong-layout word whose rendering happens to carry a dot is still corrected —
союзcomes out ascj.p, whose segments read as nothing — and a dot sitting next to other stray punctuation (любов→k.,jd) still takes the ordinary path. Full URLs were never affected::and/are structural boundaries the engine already stays out of.
Cinnamon now has a backend of its own, and it is probed ahead of
gsettings. On Cinnamon 6.6 and newer it asks the shell —
org.Cinnamon.GetInputSources / ActivateInputSourceIndex, the same
entry point the keyboard applet uses, so the tray indicator follows.
On 6.4 and older (Linux Mint 22.x) there is no such API, and layouts
there are ordinary XKB groups: the applet drives
XAppKbdLayoutController → libgnomekbd → XkbLockGroup, and it
listens for group changes, so locking a group both switches the
keyboard and updates the indicator. Which of the two applies is
settled by calling the method and seeing whether it answers, not by
parsing a version.
PolterType picked the gsettings backend on Cinnamon, wrote
org.gnome.desktop.input-sources current, and nothing happened. The
word was still retyped, so it looked half-working — and then it
stopped working at all, because the next current() read back the
value we had just written and the app believed it was already in the
other layout. Reported from Linux Mint 22
(#26).
Cinnamon ships org.gnome.desktop.input-sources (it comes with the
shared GTK stack) and populates it, and never reads it. It keeps a
fork of the schema — org.cinnamon.desktop.input-sources — of which
only sources is live; the current source is in-memory state of its
InputSourceManager, reachable through the shell, not through dconf.
So the write landed in dconf and stopped there. The gsettings probe
now requires more than "the schema is installed and populated", which
was never the same question as "somebody reads it".
IBus is not the culprit here despite GTK_IM_MODULE=ibus: Cinnamon
does activate an xkb:… IBus engine on every switch, but only so XIM
clients keep working, and — in the words of the comment above that
call in Cinnamon's own source — those engines "simply 'echo' back
symbols, despite their naming implying differently". Driving ibus engine on Cinnamon would have been a second write that changes no
layout.
An escape hatch for the input stacks we will inevitably still guess
wrong about: POLTERTYPE_LAYOUT_BACKEND=cinnamon (also gnome,
kde, hyprland, ibus, fcitx, x11, or auto) pins the backend
and skips the probe. An unknown name, or a backend that cannot start
here, is a startup error rather than a silent fall back to probing —
the whole point of the variable is to be told when the choice did not
happen. Pinning gnome also skips the "this desktop ignores the
schema" check, so a user whose gsettings switching demonstrably works
is never argued with by a list of desktop names.
Nothing here changes the shipped application. It is all developer
experience, and it is in main — a git pull is the whole upgrade.
cargo clippyrecompiled the entire workspace on every run, changed file or not.poltertype-core's build script declaredcargo:rerun-if-changedon five wordlist paths per language, and four of them do not exist for nearly any language — the bulk lists ship as<stem>.txt.gz, so a plain.txtnever exists,-extrasexists only for en_us and-weakonly for uk_ua. Cargo treats a build script naming a missing file as stale always, and every crate here depends on that one. It now declares only what exists and watches the containing directory, so a wordlist added later is still picked up.- The hooks were paying that three times over, because clippy with
default features, clippy with
--all-featuresand the pre-pushcargo buildare three configurations sharing onetarget/, each invalidating the last. They now usetarget/lint,target/lint-allandtarget/. Check-only artefacts are cheap: about 900 MB each, against the 69 GB the real build already occupies.
Measured end to end on one changed file: pre-commit 261 s → 4 s, pre-push 130 s → 2 s. On an unchanged tree, 128 s → 0 s.
#[allow(clippy::too_many_arguments)] came off three functions and
#![allow(unused_imports)] off four test modules, by changing the code
rather than the attribute. SwitcherEngine::new and apply_correction
take parameter structs (EngineDeps, Correction) instead of ten
positional arguments each — seven of new's were Arc<dyn …>, so any
two could be transposed and still compile. apply_suggestion_replacement
split into a planning half and an emitting half. The four blanket
import allows were hiding fourteen genuinely unused imports.
Behaviour is unchanged; the audit also found and fixed a per-call leak
in LayoutDictionary::from_overlay_only, which built and leaked a fresh
empty FST on every construction — including at runtime, on every
settings reload, for any language with user overlay files.
With POLTERTYPE_HOLD_KEYS=1, PolterType on macOS now holds your
keystrokes back while a correction types, and replays them behind it
— the race that used to scramble зтзь ш into ipnpm is closed on
a third platform. The event tap moves from listen-only to active
when the gate is on; our own emissions bypass the hold via the
emitter stamp; a tap the OS disables for overrunning its callback
budget is re-enabled instead of going deaf. Validated on Intel
hardware: a 4-key burst fired mid-correction lands exactly once, in
order, in the freshly switched layout.
Off by default for the same reason as Windows: the flush delays held
keys until the burst ends, which reads as the caret lagging after
every correction. Turn it on if you type fast enough to hit the
race — see docs/PERMISSIONS.md.
Two findings rode along:
core-graphics0.24's tap trampoline mapped a callback'sNoneback to the original event, so an "active" tap swallowed nothing — the reason the gate now requires 0.25 (CallbackResult::Drop).- The final post-release sweep sent held keystrokes through
send_keys, which isUnsupportedon macOS and Windows — they are now emitted via the samesend_textfallback as the main flush, closing a narrow window where a fast typist could lose characters outright.
- PolterType no longer blocks display / system sleep when the sound
output is HDMI (or DisplayPort). The audio worker cached its
CoreAudio
OutputStreamfor the whole life of the process once a sound had played, and an open output stream on an HDMI device keeps coreaudiod's power assertion alive — macOS then refuses to turn the screen off or sleep, as if audio were playing forever. The worker now releases the stream after 30 s without a sound command (the existingSTREAM_IDLE_REFRESHwindow) and reopens it lazily on the next play; the ~20-50 ms reopen cost is hidden under the synth tone's lead silence.
-
The AI subsystem now ships inside the official installers. The
aicargo feature has existed since 0.8.0 and no published release ever enabled it — which quietly turned "configure your own model inconfig.toml" into "recompile the app yourself". That was never the deal: the promise is an integration you switch on, not a feature flag you must build. All four installers are now built with--features ai,poltertype-ai/remote, and main CI lints and tests that feature set so a release configuration can no longer break unseen.Nothing about the defaults moves an inch.
[ai].enabledis off; no model, no vendor SDK and no default endpoint ships; an[[ai.plugins]]entry naming neither anendpointnor aproviderpreset is refused; a non-loopback endpoint additionally needs[ai].allow_remote = true; API keys live in the OS keychain only. With nothing configured — the default — the subsystem builds no detectors and opens no socket.One claim in our own docs had to move, and honesty says name it: the shipped binary now links a second HTTP client (
reqwest+rustls, insidepoltertype-ai) beside the updater'sureq, so "the updater is the only reason a TLS stack is linked in" is retired from the README and the site. What remains true, and checkable withcargo tree: a stock source build still contains neither the feature nor the client, and a configured endpoint is the only thing either client ever talks to.
-
A plug-in whose service dies is now noticed within seconds instead of never. The supervisor's reaping was documented as running on the tray's heartbeat and in fact ran only when the user clicked something in the menu, so a service that exited went unreported — and stayed a zombie process — until the next click. It was found the hard way: a capture plug-in here died one second after startup and nobody knew for ten and a half hours, while the tray kept reporting its mode correctly and uselessly, because a plug-in's state comes from a one-shot command that answers the same whether the service behind it is alive or dead.
Reaping now runs on the existing 15-second heartbeat, before the menu is refreshed, and the heartbeat is armed for any supervised service rather than only for plug-ins that report state. A service that goes raises a notification naming it — on the path that is not gated by
[general].show_notifications, because awarn!line in a file nobody knows about is not a user interface.A service also gets somewhere to say why it went: its output now goes to
logs/plugin-<id>.log, truncated at every start, instead of being inherited from a tray app that on most desktops has no terminal at all. The last line of that file is what the notification quotes. Still no automatic restart — a plug-in that crashes on startup would become a fork bomb, and the failure would go back to being invisible.
Two blocks, and they meet in one place: the plug-in system landed in 0.10.0 without anyone having run it on Windows, and running it there is what turned up most of what follows.
PolterType has claimed to work on Windows since 0.1.0. It did. But no release had been exercised on it, and a week on a real machine found things that no amount of review had.
-
The tray app no longer opens a console window. Nothing set
windows_subsystem, so the binary linked as a console image and Windows allocated a black window for it every time it was started by anything that was not already a console — the Start Menu shortcut, the autostart entry, Explorer. It sat behind the tray icon for the life of the process, and the settings window brought a second one. -
Suggestions are now drawn on Windows. The tooltip existed only as a keyboard chord there: the engine ranked the candidates and nothing ever showed them, so you had to already know what you were accepting. There is now a layered, always-on-top, never-activated window, which cannot take the keyboard away from what you are typing into because the window style forbids it. It appears above the focused window; caret-accurate placement needs a caret source Windows does not have yet.
-
The keystroke hold-back was losing what it held. Off by default and never run on hardware,
POLTERTYPE_HOLD_KEYS=1turned out to swallow your keystrokes and then fail to give them back — and after that was fixed, to still drop the spacebar, which is the boundary that triggers most corrections. It works now, and it stays off by default for a new reason: holding costs a delay after every correction that you can feel. Both fixes are shared with macOS, which had the same two holes. -
Uninstalling takes the autostart entry with it. It did not, so removing PolterType left Windows trying to start a deleted program at every login, with nothing in the interface to explain it.
-
All fifteen keyboard layouts were checked against Windows' own keymaps — including the nine added in 0.9.0 that had never been typed on. They match at the plain and shift levels, with four exceptions in the whole set where Windows and xkb genuinely disagree and one file cannot satisfy both. What the audit did turn up is larger and is not fixed here: a language is not a keyboard, and PolterType currently treats it as one (#20).
-
тоis a Ukrainian word again. Two-letter tokens are judged against a curated list rather than the dictionary, and that list hadоbut notто— so a single letter could switch your layout while one of the commonest words in the language could not. -
The release workflow's rehearsal mode could not build anything. It passed the branch name where a version was expected, and all four installers failed on it, each in its own dialect. The one mode meant for testing a release without publishing one had never worked.
-
Extensions can run on Windows at all. A manifest names its program without an extension so that one manifest describes a plug-in everywhere; resolution took that name literally, and every toolchain on Windows writes
foo.exe. No extension had ever resolved there. -
No more console windows from plug-ins. A tray app owns no console, so every plug-in process was handed one of its own — once at startup for a service, and again every single time the tray menu was drawn, because the menu asks each plug-in for its state.
-
A plug-in can be asked to stop, on every platform. Services were killed 400 ms after PolterType decided to quit, and on Windows they were never asked at all. A plug-in may now declare a command with the reserved id
stop, run before the grace period — its own program, deciding for itself what leaving cleanly means. The per-OS route was tried first and measured: seedocs/DECISIONS.md, 2026-08-04, for why a console control event is not it. -
The supervisor's tests run off Unix. They drove
/bin/sh, so three failed on Windows — and, worse, four passed for the wrong reason, because most of that suite asserts a process is not running, which is trivially true when it never started.
[0.10.0] — you bring the model, the interface speaks your language, and the roadmap runs out of features
-
The AI subsystem is a socket you plug your own model into. Both shipped backends were stubs that returned no opinion; they are gone. What replaced them is one detector that speaks three common HTTP shapes —
openai-chat,anthropic-messages,ollama-generate— and asks a model exactly one question.PolterType ships no model, no vendor SDK and no default endpoint. What answers is an Ollama on your own machine, an API you hold the key to, or a gateway of your own, named by you in
[[ai.plugins]]. Configure nothing — the default — and there is no AI in PolterType at all. An entry with neither anendpointnor aproviderpreset is refused with a message saying exactly that: picking one for you would be choosing a vendor on your behalf.Two properties the implementation exists to hold up.
It cannot slow your typing down.
judgeruns between you finishing a word and the word being fixed, so the default mode never waits: it answers from a cache of already-decided words and queues a miss for next time. The first time you type a word the model contributes nothing — exactly what the stubs did for every word — and everything after it is free, because people retype the same few thousand words all day.mode = "blocking"puts the call inline if you want it, capped at 250 ms and refused at startup with the reason above that, rather than silently clamped into lag you would have to diagnose.Local is not remote.
[ai].allow_remoteexists to gate typed words leaving your machine, and a request to127.0.0.1does not leave it — so a model you run yourself needs no network permission. Requiring one would make people enable access they are not using. The distinction is decided in one place, resolves no DNS (a resolver answer can change between the check and the request), and treats anything unparseable as remote.What goes on the wire is one word's candidate readings and a fixed instruction. Not the sentence, not the document, not the focused application, and not the layout ids — those would reveal which languages you have installed. Keys stay in the OS keychain; a literal secret in
config.tomlis refused, not used. Without theremotecargo feature no HTTP client is compiled in at all, whichcargo treewill confirm. -
PolterType knows which application you are in on GNOME and KDE.
focused_exe()returnedNoneon every Wayland session but Hyprland, so[exceptions].disabled_apps, per-app wordlist profiles andapps = [...]scoping were quietly inert on the two largest desktops.The plan was a KWin script plus a GNOME Shell extension — two out-of-tree artifacts, in two languages, that you would have to install. It turned out to be unnecessary: AT-SPI events arrive over the accessibility bus from the application's own connection, so the bus itself can be asked whose it is. One backend, nothing to install, and it works on any compositor with an a11y bridge.
Read the limit before relying on it. Only applications with a live accessibility bridge are visible — GTK, Qt and Electron answer; most terminals do not, and a terminal is where developers type. An app that never emits also never un-focuses the previous one, so observations carry an age and anything older than five minutes counts as no answer. This is an improvement on nothing, not an equivalent of a compositor query.
-
The settings window speaks other languages, starting with Ukrainian. An app whose whole subject is other people's languages had an English-only interface.
Translations are data —
data/i18n/<lang>.toml, one flat table — and a file in<config-dir>/poltertype/i18n/wins over the shipped one, so a translator can edit and reopen the window without rebuilding anything. English is compiled into every call site rather than loaded, so a catalog that fails to parse, a key nobody translated, or a file a packager forgot degrades to readable English instead of a blank button.[general].ui_languagepicks;"system"and"auto"both follow the environment. Adding a language is one file — see docs/TRANSLATING_THE_UI.md. -
Smart-command triggers can be more than one word.
best regardsnow works. The word buffer still resets at every boundary, so the engine keeps the last four completed words alongside it — bounded by the same idle timeout that already abandons the buffer, and cleared when you change application, because half a trigger typed in one window must not complete in another. It is the one place the engine holds more of your text than the word you are typing, and it is sized accordingly. -
run_shellsmart commands, off by default and deliberately awkward to misuse. PolterType already reads every keystroke; adding "and can run a program" turns a shared or stolenconfig.tomlinto code that fires the next time you type an ordinary word. So it needs[commands].allow_run_shell = true, runs no shell — a program and an argument vector, executed directly, so a metacharacter is just a character — and never puts anything you typed into an argument. A timeout, an output cap, no stdin, and dispatch off the correction path. Inserted output is truncated on a character boundary, stripped of control characters (a newline typed into a chat window sends it), and not inserted at all when the command failed. -
Language packs have a supported way in. The loader has read
<data_dir>/plugins/<id>/since v0.1, but getting a pack there meant copying directories by hand with no validation.installtakes a directory already on your disk — there is no download, and that is the point. Fetching third-party content into a process that reads every keystroke is a far wider channel than the updater's signed, no-payload manifest fetch; a pack you downloaded yourself is a trust decision you made where you could see it. It also means no archive, so no zip-slip and no decompression bomb.Installation copies only what a data-only pack may contain, reports everything it left behind, refuses symlinks rather than following them, and replaces atomically — an interrupted install leaves the old pack or none, never half of a new one.
-
Wayland can type without the setup script — on GNOME and KDE, in theory.
uinputneedsinput-group membership plus a udev rule, which is the onesudostanding between installing PolterType and it doing anything. TheRemoteDesktopportal is the standard, permissioned way to ask a compositor to synthesise input, so it is now tried when and only whenuinputcannot be opened — nobody who already ranscripts/setup-linux.shwill ever see a consent dialog.This has never run. There is no RemoteDesktop backend on the machine it was written on, so it is written from the specification and executed by nobody — the same standing as the macOS paths, and it is labelled that way in the code. If it misbehaves on a real GNOME or KDE session, assume PolterType is wrong before the compositor.
It takes the portal's
NotifyKeyboardKeycoderather thanlibeideliberately: that method does exactly what a correction needs, and going throughConnectToEISand the libei protocol would have meant a new protocol implementation and a heavyweight dependency to send twenty keystrokes — while still needing the same session negotiation. A restore token is stored so later launches are silent.
- A
-1from a model was read as "the first candidate". Every model that means "none of these" and writes it as a negative number would have had a word retyped as something the user did not ask for.
- There will be no AT-SPI keystroke listener, and this is now a
decision with measurements rather than an open plan item. Registering
one returns false on wlroots and delivers nothing even with keys
injected through
uinput, becauseat-spi2-registrydhas no keyboard of its own — on Wayland it relays what the compositor hands it, and only mutter does. Where it would work (X11) the existing listener already needs no permissions. Wayland still needsscripts/setup-linux.shonce; anyone wanting a zero-permission session has X11 today. See docs/DECISIONS.md, 2026-08-01.
-
Nine more languages: Polish, Czech, Greek, Hebrew, Turkish, Bulgarian, Italian, and Portuguese in both its orthographies. PolterType now bundles fifteen layouts instead of six. Each one is a layout TOML plus a full dictionary, so the same detection that makes uk-UA ↔ en-US reliable applies to cs-CZ, tr-TR, it-IT and the rest. Nothing loads that your OS doesn't have enabled — the active-layout filter still means a two-keyboard user pays for two.
Layout mappings were generated from
xkeyboard-configrather than transcribed from keyboard pictures, and then reviewed; the trick is written up in docs/ADDING_A_LANGUAGE.md for the next person. Closes #2.Two of them carry a caveat worth reading before you expect magic:
- Polish maps to exactly the same characters as US English. The standard Polish layout is the "programmer's" one — QWERTY with every diacritic on AltGr, which PolterType doesn't track — so there is no pl-PL ↔ en-US mistake to correct, and none is possible. The Polish wordlist still does real work: it stops Polish prose being dragged toward whichever other layout you have active.
- Hebrew ships dictionary stems without affix expansion. Its Hunspell table encodes the clitic prefixes as 3335 prefix rules, which expand to 60.6 M forms — a 141 MB wordlist and a far bigger FST in every installer. Hebrew shares its script with nothing else bundled, so plausibility already separates it and the dictionary is a refinement. See data/wordlists/CREDITS.md.
Installers grow by about 57 MB, most of it Turkish — an agglutinative language expands to 5.8 M forms and two 15 MB FSTs. That is the price of the detection quality; nothing about it is loaded at runtime unless you have a Turkish keyboard enabled.
-
Polish and Greek dictionaries would have shipped as mojibake, and the French one hadn't been refreshed in a year. Three separate faults in
cargo xtask wordlists fetch, all of which failed quietly:- Hunspell declares a dictionary's encoding once, in the
.aff— the.dichas noSETline of its own. We looked for one in each file separately and fell back to Latin-1 when there wasn't any, so Polish (ISO-8859-2) and Greek (ISO-8859-7) decoded into plausible nonsense:słowobecames³owo, which neither matches a lookup nor trips a check. German survived only because German really is Latin-1. The.aff's declared encoding is now what decodes both halves, ISO-8859-2 and ISO-8859-7 have real tables, and an unrecognised or absentSETis an error instead of a guess. - The French source moved upstream (
fr_FR/→fr_FR/dictionaries/) and the old URL had been 404ing. The fetch printed one stderr line and exited 0, so the stale wordlist just stayed. Fixed, and the command now exits non-zero when any source fails. The refreshed French list gains 11,701 forms and loses 26, all of them elision artifacts liked'pick-up. FLAG numdictionaries (comma-separated numeric affix flags) were rejected outright. Turkish needs them — its affix table runs to six figures of distinct flags. Now supported.
- Hunspell declares a dictionary's encoding once, in the
derive_vowelslearned the vowel sets of the new languages, which the plausibility detector counts. Two are not what a script default would give you:ъis a full vowel in Bulgarian, and Turkish's dotlessıis a vowel that the bare Latin set scores as a consonant.data/wordlists/CREDITS.mdnow states each dictionary's licence per-language instead of hedging. The old blanket "most often GPL-2-or-later or LGPL/MPL" was wrong about at least Russian, which is BSD — and Hebrew's Hspell is AGPL-3.0-or-later, the strictest thing in the tree and worth knowing before redistributing a build.
-
Windows can hold your keystrokes back during a correction — opt-in, and unverified. Until now only Linux/evdev could stop a keystroke landing inside a correction; on Windows a fast typist could still get a mangled word right after one. The low-level hook now swallows the user's keys for the length of a correction burst and the engine replays them behind it, the same contract the evdev gate has.
Off by default. Set
POLTERTYPE_HOLD_KEYS=1to switch it on. A feature that can leave someone unable to type does not get enabled for strangers by someone who has never run it — and nobody has run this on Windows. If you try it, #7 is where to say what happened.Three things make it safe to try. Our own synthesised keys are recognised by a marker stamped into
dwExtraInfoand are never swallowed, so a correction cannot block itself — and, unlike theLLKHF_INJECTEDflag, that marker distinguishes our events from any other automation tool's. Every hold carries a deadline that the next keystroke enforces, so a caller that dies mid-correction costs one keystroke of latency rather than a dead keyboard. And Windows itself removes a hook whose callback stops answering, which means a hung process gives the keyboard back without our help — the failure mode that made the evdev gate dangerous does not exist here. -
The AI subsystem is connected to the engine.
poltertype-aihas compiled since v0.1 with nothing ever constructing it, and[ai].enabledwas a setting no code read. Now[[ai.plugins]]entries inconfig.tomlare turned into detectors and appended to the pipeline — appended, never substituted, so an AI voice is added to the decision and the offline detectors keep working exactly as before.What this does not do is make PolterType smarter yet. Both shipped backends are still stubs that return no opinion: the local one loads no model, the remote one makes no request, and no build makes a network call. What changed is that the seam is real — a model can now be dropped in without touching the app.
The gates, in order: the
aicargo feature, then[ai].enabled, then the entry building at all, and for remote plug-ins[ai].allow_remoteon top — checked per judgement, so switching it on needs no config edit. An entry that cannot be built is logged with its id and skipped; the others still load. Anapi_key_refthat is not akeyring:reference is refused outright, because a key inconfig.tomlis a key in backups, dotfile repos and pasted bug reports.
- The installed app finally wears its own face. The icon the
installers shipped was a stand-in from before the rename — the
letters
kbon an indigo square — so every Start menu, Dock and application launcher has been showing a logo for a product that no longer exists. It is now the PolterType mark: the ghost on its keycap, the same one the site and its favicon use. Nothing else changed; the tray icon still shows the live layout code (EN,UK, …), which is information, not branding. The mark stays procedural —cargo xtask assets icon-pngdraws it from the geometry inxtask/src/assets/, transcribed fromfavicon.svgand rendered at whatever size the installer asks for, so the repo still carries no binary asset. The catch that comes with that: the two have to be edited together, and nothing checks that they still match.
- Windows: our own synthetic keystrokes are now identified by a
marker, not by a flag that means "somebody injected this". The
emitter stamps
dwExtraInfo; the listener reads it back. The oldLLKHF_INJECTEDcheck stays as a fallback, so nothing about existing echo-filtering changes.
[0.7.0] — updates get signed, ARM64 Linux gets a build, and the setup guide starts checking your machine
macOS users, before you update. This release changes the macOS input path: modifier presses now reach the engine (they never did), and a correction releases the modifiers you are holding before it types. Together they fix a correction fired under a held ⌘ going out as ⌘⌫ — "delete to start of line". The code is reviewed, unit-tested where the logic is portable, and compiled by CI, but it has not been run on a Mac by anyone. If typing looks wrong afterwards, please say so in #3 — that issue is how this gets confirmed.
- The release manifest is signed, and the updater checks it. Until
now the only thing standing between a user and a hostile update was
a SHA-256 that shipped in the same GitHub release as the installer —
so whoever could publish one could publish both.
latest.jsonnow carries a detached ed25519 signature, verified against a public key compiled into the binary, the moment the manifest is parsed and before any URL in it is read. The private key is not a CI secret and never touches a runner: signing is a manual step the maintainer performs on the draft release (cargo xtask manifest sign), which is the only version of this that a compromised GitHub account cannot forge. Not yet mandatory. A wrong signature is refused from this release on, but a missing one is still accepted — otherwise every user would be stranded on the last unsigned manifest. Enforcement is a one-constant flip in a later release, and only then does anything user-facing get to say "signed updates".
- A Setup pane that checks this machine instead of linking to a
document. When the keyboard hooks fail to start, the tray alert now
opens the Settings window on a new Setup pane rather than a
markdown file in a browser. It probes the running system and says
what is actually missing, per OS: on Wayland, whether key events can
be read and whether corrections can be typed, as two separate
answers, because they are two separate permissions and the half-
granted case (detection works, nothing gets fixed) is the confusing
one. On macOS, Accessibility and Input Monitoring separately, with
buttons that ask the system for each and deep links into the right
System Settings pane. On X11 and Windows it says there is nothing to
grant — most people arrive expecting the worst. Check again
re-probes, and answers even when nothing changed.
It also catches the trap that wastes an evening:
usermod -aG inputupdates the group database and cannot touch a login session that already exists, so everything looks configured and nothing works. That state gets its own answer — log out, don't re-run the script. Nothing on the pane changes the system. The Linux script needssudo, so the button copies the command for the user to read and run themselves. - An honest banner when layout switching is unavailable. Hooks working and no switcher backend is its own failure: PolterType spots the wrong-layout word and rewrites it into the same wrong layout, so it looks like the correction is broken rather than missing.
- The suggestion tooltip anchors to the caret on GNOME and KDE
Wayland. Those sessions have no compositor-agnostic active-window
query, so the focus tracker was a plain no-op there and the tooltip
fell back to the bottom of the screen. But AT-SPI is a session-bus
service and answers on any compositor — the caret watcher had simply
never been built on that path. It is now:
focused_exe()still returnsNone(nothing keyed off the focused app starts guessing), while the tooltip gets the best anchor in the chain instead of the worst.
-
Packaging manifests for AUR, winget and Homebrew, staged in
packaging/with the publish step for each written down. Nothing is live yet — and the README install table stays silent until each one is.packaging/bump.sh <version>re-points all three at a published release by hashing the bytes GitHub actually serves. Two decisions worth knowing: the AUR packages install the udev rule but will not add anyone to theinputgroup (that is the user's account, not ours), and the Homebrew cask does not strip macOS quarantine — removing that check silently for an unsigned app that reads every keystroke is not a convenience we get to hand out. -
The tooltip was never broken on KDE. KWin has implemented
zwlr_layer_shell_v1for years; verified against KWin 6.7.3, where the surface configures and maps exactly as on Hyprland. GNOME Wayland is not a no-op either — Mutter has no layer-shell, but the X11 override-redirect fallback maps through XWayland. Five places claimed otherwise, including the error message users would see. The backend has always probed rather than matched desktop names; the prose was a hand-maintained list that went stale silently. The real remaining gap is a Wayland session with neither layer-shell nor XWayland, plus macOS and Windows. -
No Flatpak, decided with evidence rather than left open. The emitter writes to
/dev/uinput, which no Flatpak permission grants short of--device=all—device=inputdeliberately excludes it — and there is no portal. Layout switching additionally needs host binaries a sandbox does not have. Reasoning, sources and the conditions for revisiting are indocs/DECISIONS.md; the README says so plainly so nobody has to ask twice. -
aarch64 Linux builds.
poltertype-<ver>-aarch64.AppImageships alongside the x86_64 one, built natively on an ARM64 runner rather than cross-compiled. Raspberry Pi 5, Asahi and ARM laptops/servers had nothing to download and nothing for the in-app updater to offer them; both now work. Deliberately the only architecture added — every installer is a support surface, so armv7 and ARM Windows stay out until there is hardware and demand behind them.
- macOS: a suggestion accepted with its chord still held no longer
retypes the word under those modifiers.
release_modifierswas a default no-op on macOS, and — worse — every event we posted inherited the live hardware modifier flags from itsHIDSystemStatesource, so with ⌘ down our backspaces went out as ⌘⌫ ("delete to start of line"). The emitter now clears the flags on everything it posts and sends aFlagsChangedrelease for each modifier the engine believes is down. Caps Lock is deliberately left alone — it is a latch, not a held key. - Windows: the same no-op, the same bug.
release_modifiersnow sends key-ups for both sides of each held modifier. - macOS: modifier presses reach the engine at all. The event tap
subscribed to
KeyDown/KeyUponly, but macOS reports a modifier moving asFlagsChanged— so the modifier arms of the keycode table had been unreachable since they were written, and the engine only learned what was held when an ordinary key arrived. It now sees the same discrete modifier stream as the Windows and Linux backends, which is what makes the fix above fire at the right moment. Keys with no SC Set-1 equivalent (Fn, media) are dropped rather than falling through the identity mapping into the word buffer's "navigation — end the word" range.
- The macOS backend is a directory rather than one file, and the part
most likely to be wrong — the Apple→SC Set-1 keycode table and the
FlagsChangeddirection rules — carries no Apple dependency, so its tests run on Linux and Windows CI too. Everything Mac-only stays compile-checked by CI'smacos-latestjob.
- Typed words no longer appear in logs — at any level, in any
build. The decision diagnostics embedded the word they judged
("current `…` is a dictionary word",
original=… corrected=…), and the correction summary logged both words at INFO — the default level — so a release build with default settings wrote typed words into the on-disk log, contradicting the README's privacy promise. Every such site now renders words as<N chars>. Developers can see the words in a debug build only by settingPOLTERTYPE_UNSAFE_LOG_WORDS=1; release builds redact unconditionally, at compile time.
- Linux: the key gate can no longer freeze the whole session's input. The gate's "is our emitter proxied by a remapper?" probe ran once, at startup — racing keyd's own asynchronous grab of the freshly created device. Winning that race armed the gate on a stack where it must stand down, and the first correction then grabbed the remapper's virtual keyboard: every input path — the user's keys and our own corrections alike — funnelled into PolterType, and the session's input died until a reboot. The gate now re-verifies the emitter before every hold and shuts itself off for the rest of the run the moment the emitter turns busy. The emitter also records its device node at creation, so the never-grab-our-own-device exclusion works by identity rather than name comparison.
- Wrong-layout words containing a cross-layout letter now get
corrected. Typing
mañanaorespañolwith the US layout active renders asma;ana/espa;ol(ñsits on the US;key) — and both detectors used to freeze exactly this case: the plausibility scorer ignored the;entirely (espa;olscored a perfect en-US fit and vetoed the switch), and the dictionary detector looked up the letters-only skeleton, where over-inclusive bulk entries likemaana/seorproduced a phantom "current is a real word" veto. Interior punctuation (apostrophes and hyphens exempt) now crushes a rendering's plausibility and demotes skeleton dictionary hits from a veto to a tiebreaker, so the es–en pair the landing page demos works end-to-end. - Hyprland: the "Spanish" keymap now resolves to
es-ES. The pretty-name table covered every bundled language except Spanish, so the moment a correction switched the system to the Spanish layout the engine stopped recognising its own current layout — renders came back empty and every subsequent word got a phantom re-correction. - Hyprland: the "Russian" keymap no longer resolves to
en-US. Theusshorthand in the same table matched as a substring, and "Russian" (as well as "Belarusian") contains it — so a user switching to the Russian layout had the engine convinced they were typing English. Found by the new all-bundled-languages round-trip test.
PolterType runs on a Mac for the first time, and the "Start automatically when I sign in" checkbox does something for the first time — on every platform.
The macOS backend shipped in v0.5.0 had only ever been compiled by CI. The fixes below come from an outside contributor running it on real hardware (macOS 15, Intel), where it turned out to crash on launch and never process a keystroke.
- The app wouldn't launch from Finder at all. The single-instance lock was created under the process working directory, which is the read-only system volume for GUI launches; startup aborted with "Read-only file system". The lock now lives in the per-user config directory.
- SIGILL seconds after launch. HIToolbox asserts the main dispatch queue inside Text Input Services on modern macOS; calling TIS from the layout-poller / engine threads killed the process. All TIS calls are now routed through the main dispatch queue.
- The keyboard tap delivered nothing. The tap thread ran its
CFRunLoop in
kCFRunLoopCommonModesas the run mode; the tap source never fired. It now runs in the default mode. - Every second word was skipped. Emitted backspaces / retyped text echoed back through the event tap untagged and poisoned the word buffer after each correction. All posted events are now stamped so the listener recognises them as injected.
- Shift / Caps Lock state was invisible to the engine.
CGEventFlagAlphaShiftnow folds into the shift bit, matching the X11 backend; the keycode table gained full modifier / navigation / F-row mappings so caret-moving keys end a word the way they do on Windows and Linux. - Russian / Ukrainian layouts weren't detected on systems with the
PC ("Win") input-source variants —
RussianWin/UkrainianWin(andABC, the modern US id) are now mapped, and layout switching matches sources by their mapped BCP-47 id. - The app icon no longer lingers in the Dock. tao applies the
Regular activation policy by default, overriding
LSUIElement; the tray app now runs as an Accessory process. - The pause hotkey no longer steals the system layout switcher.
Ctrl+Shift+Spaceis macOS's own "select previous input source", so the default there is nowCtrl+Shift+P. Applied only while you are on the default; an explicit binding is honoured as written.
- "Start automatically when I sign in" now does something. The
setting has existed since the first release, defaulting to on,
while no code ever read it — the app had never started at login on
any platform. It now registers a per-user LaunchAgent on macOS, an
HKCUrun-key value on Windows and an XDG autostart entry on Linux. Unticking it removes the entry. - The Accessibility permission prompt. When the event tap can't attach, macOS is now asked to show the system prompt instead of the app failing silently into a dead tray icon. Note macOS also requires Input Monitoring for key delivery; it prompts for that when the tap is created.
- Settings UI shows the platform's modifier glyphs (⌃⌥⇧⌘) on macOS.
- A correction could be declined on a busy machine. The intrusion probe bounded itself by wall-clock while being driven by its own sleeps, so under load the deadline expired before the run of silence that authorises a repair could accumulate — and the engine left the text alone when it should have fixed it. It now counts samples instead, which is the same bound without the race. This also fixes an intermittent CI failure on macOS.
auto-launch, which had been declared since the first commit and used by nothing.
Two bugs put the tooltip somewhere other than the text being corrected.
- The tooltip followed the mouse, not the caret. When the focused app exposed no AT-SPI caret, the anchor fell back to the pointer position, on the theory that the user had just clicked into the text they were editing. Nothing checked that the pointer was still at that click, so an idle mouse parked mid-screen pulled the tooltip to the middle of the display while the caret sat in a chat box at the bottom edge. The pointer anchor is gone: without a caret the tooltip now hangs above the bottom edge of the focused window, which is the neighbourhood of the chat inputs and shell prompts this feature is for.
- The first tooltip of every session was placed blind. On Wayland the popup thread parks on its command channel between popups and reads nothing from the compositor, so the outputs' names, sizes and scales — which arrive as events, not with the globals — had not been received when the first popup was built. That popup got no screen bounds to clamp against and no named output, leaving the choice of monitor to the compositor while the coordinates had been computed for a different one. The popup thread now refreshes its output state before every show, which also picks up hotplugs and mode changes that happened while it was parked.
Every start printed libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib in newly written code. to stderr. The
notice is aimed at whoever links the library — tray-icon, through
libappindicator — and there is nothing a user can do about it. It now
goes to PolterType's own log at debug level instead of the journal, so
it stays available to us without being noise for you. New
poltertype-tray crate, which exists so the binary crate can keep
holding no platform-conditional code at all.
Typing зтзь (i.e. pnpm on the wrong layout) and carrying straight
on with the next word could leave ipnpm on screen — the i in front
of the correction rather than behind it, and pinpm / pnpmi when it
landed further in. The keystroke was reaching the application inside
the correction's own burst, where the compositor interleaves it with our
injected keys and no amount of counting afterwards can place it.
- The blind settle sleep before a replay is gone. Both Linux emitters paused 30 ms right before typing the corrected word, to let the compositor finish propagating the new keyboard layout. That pause sat between our last look at the key stream and our first emitted key — precisely the window a keystroke slipped into. The engine owns that wait now, measured from the actual layout switch and taken before the deletion, where it costs nothing (the absorb gate has usually covered it several times over already).
- The engine looks at the key stream later, and once more. The probe that catches keys racing the deletion now allows for the trip from device to listener thread, so keys pressed during the burst are seen while they can still be placed.
- A keystroke that gets in anyway is repaired. After emitting, the engine checks whether anything landed inside its own burst and, if the user has since paused, erases what it typed — intruder included — and retypes it all in the order it was typed. The repair waits for that pause on purpose and is budgeted: a correction must never end up chasing a still-typing user down the line, so if no pause comes it leaves the text alone and stops vouching for the screen instead.
Repairing a scrambled correction is treating the symptom. On
Linux/Wayland PolterType now holds the keyboard back for the length
of a correction burst (EVIOCGRAB) and types out whatever you pressed
meanwhile itself, in the order you pressed it. Typing a whole command
straight through in the wrong layout — зтзь ш кгт at a real typing
cadence — went from 4 wrong results in 6 to none.
- It stands down where it would do harm. Behind an input remapper
(keyd and friends) the only grabbable source of your keystrokes also
carries PolterType's own, so grabbing it would block the correction
itself. PolterType detects that at startup and quietly keeps the
detect-and-repair behaviour instead —
docs/PERMISSIONS.mdhas the keyd one-liner if you want the stronger path.POLTERTYPE_HOLD_KEYS=0turns it off entirely. - It cannot leave your keyboard dead. The thread that owns the devices drops the hold after 1.2 s no matter what the engine is doing, and a crashed process releases it by construction. Backspace, arrows and Esc pressed during a burst are typed out too rather than swallowed.
Ctrl+Meta+<digit> (and the default Ctrl+Shift+<digit>) did nothing
visible. The accept itself was working the whole time — the replacement
was simply typed while the chord's own modifiers were still held, so
every key of it arrived at the application as a shortcut rather than a
character. Corrections now wait for the chord to come up before typing,
and ask the emitter to release what is held; the manual switch-last
hotkey had the same flaw and is fixed by the same change.
The digit itself is erased along with the word now, too. Chords are matched off the key stream rather than grabbed — registering nine global hotkeys would take those combinations away from every application — so the digit reaches the document on its way past, and was being left behind in the replaced text.
The device rescan that picks up hot-plugged keyboards re-opened every
node under /dev/input and read its capabilities — 70–140 ms on the
same thread that reads your keystrokes, every 2 seconds. Events piled
up in kernel buffers and arrived late in a burst, right where the
correction logic is at its most timing-sensitive. It now opens only
devices that are genuinely new. A keyboard unplugged and plugged back
into the same port is also picked up again, which the first version of
this fix would have missed.
Until now PolterType only helped when the layout was wrong. Typos
typed in the right layout — слоао, hwllo — got nothing. Now,
when a completed word isn't a dictionary word (and isn't something
the engine would auto-correct), a small tooltip appears near the
focused window with up to 5 nearby dictionary words. Click one, or
press Ctrl+Shift+<digit>, and the word is replaced in place —
including any separators and next-word keystrokes you'd already
typed.
The details that make it behave:
- Candidates come from the bundled dictionaries via a new
surface-form FST per language, so
п'ятьis suggested with its apostrophe. Ranking is keyboard-aware: substituting a key with its physical neighbour ranks higher than a random edit, and adjacent transpositions count as a single slip. - Low-confidence layout verdicts join the list. When the detector saw a cross-layout word but stayed below the confidence threshold, that candidate now leads the tooltip (badged with the layout) instead of being dropped — you make the call the engine wasn't sure enough to make.
- The tooltip appears next to the text you're typing. It anchors
to the real caret via accessibility (AT-SPI) in apps that expose
it, falling back to the pointer, then the focused window.
PolterType raises the session's
org.a11y.Status.IsEnabledflag so application a11y bridges wake up — apps already running before PolterType's first launch join in after they restart. Placement picks whichever side of the caret has room (above first, then below/right/left) and never covers the line being typed. Clicks on the tooltip are carefully disambiguated from clicks that move the caret — mid-text insertions replace exactly the mistyped word and leave the surrounding text alone. - The tooltip never takes keyboard focus (Wayland layer-shell on Hyprland/Sway, override-redirect on X11) and hides itself after 30 seconds, on Esc / click elsewhere / caret movement, or the moment it can no longer act on the word. GNOME/KDE Wayland, macOS and Windows have no overlay backend yet — the feature quietly stays engine-side there.
- "Add to dictionary" lives in the tooltip. The last row adds the
flagged word to your wordlist overlay
(
<config-dir>/poltertype/wordlists/<stem>.txt) with one click or digit — jargon, names and project vocabulary stop being flagged immediately and permanently. No tooltip appears at all for words typed right after a click / arrow keys / Esc: the typed keys may be a fragment of a longer word on screen, and a suggestion computed on a fragment would corrupt it if accepted. - Local, silent, off-switchable. No network, nothing typed ever
reaches a log, and
[suggestions] enabled = false(or the new Settings → Suggestions pane) turns the whole thing off. Defaults: on, 5 suggestions, 30 s,Ctrl+Shift+ digit (the modifier half is configurable — e.g.accept_modifiers = "Ctrl+Meta").
- The Windows MSI never shipped
uk_ua-weak.txt, so the weak-word deferral (theтуче→nextcase from 0.4.x) silently didn't work on Windows installs. The WiX manifest lists each data file explicitly and the weak list was forgotten; it is packaged now.
[exceptions].disabled_apps shipped with a ~50-entry default skip-list
— VS Code, Cursor, the JetBrains IDEs, Sublime, Zed, kitty, alacritty,
konsole, PowerShell, tmux and more. On Linux it had never done anything:
focused_exe() returned None there, so the list could not match. The
Hyprland/X11 focus tracker added in 0.3.0 made it real, and PolterType
abruptly went silent in exactly the windows developers type in — no
error, no notification, nothing above DEBUG in the log. It reads, from
the outside, as "layout switching is broken".
The default list is now empty. PolterType corrects everywhere until
you tell it not to. What keeps the corrector out of your code is
unchanged and never depended on knowing which app has focus: the
identifier guard (engine.suppress_in_identifiers), plausibility-keep,
min_word_length, and the dictionary confidence threshold.
The skip-list itself still works and is still honoured — it is now
opt-in. Add apps in config.toml or on the Settings → Exceptions pane.
Existing installs are migrated on first launch. Shipping an empty
default in the binary would have fixed nothing for anyone who already
ran an older build: those 69 entries are written into their
config.toml, and the app reads the file, not the default. So this
release clears the list out of the file — but only when it is still
the shipped default, entry for entry. Take one app out of the list, or
add one of your own, and PolterType treats it as yours and never
touches it. The migration is logged at INFO when it fires.
The MSI shipped as poltertype-v0.4.0-x86_64-pc-windows-msvc.msi while
the AppImage and the DMG dropped the tag's v
(poltertype-0.4.0-…). The build script was passing the raw git tag
into the file name instead of the stripped version — long-standing, and
harmless to the updater (it matches artifacts by pattern), but it made
the three downloads on a release page look like they came from
different projects, and it is a trap for anyone scripting a download by
filename. The README already documented the v-less form; now the
build agrees with it.
Pre-release tags keep their suffix in the file name
(poltertype-0.5.0-rc.1-…msi), so a release candidate can never
collide with the final release it precedes.
Until now, updating meant noticing that a release had happened and re-running an installer by hand. Since the installers are unsigned and there is no store to push through, that meant most people simply stayed on whichever build they first installed — including for security fixes.
PolterType now updates itself. Once a day it fetches a small manifest from GitHub Releases, and when a newer version is out it downloads the installer for your platform in the background and verifies its SHA-256 against the manifest. Then it stops and waits.
Nothing is ever installed while you're typing. The app holds a global keyboard hook; swapping its binary mid-sentence is the one thing it must not do. The staged update installs when you quit the app, or when you click the new ⟳ Restart to update — v0.4.0 entry in the tray menu. A notification tells you once when a version is ready; the same tray entry doubles as a manual Check for updates… when nothing is staged.
All three platforms self-update: the MSI is reinstalled per-user (no
UAC), the AppImage is swapped in place, and the macOS .app bundle is
replaced from the DMG. An install that isn't ours — a distro package,
a cargo build binary — is never overwritten; you get a notification
pointing at the Releases page instead.
This is a real change to what PolterType is, so it is stated plainly rather than buried: previous versions never opened a socket, and this one does.
The update check is a plain GET of a static JSON file on github.com.
There is no request body, no query string, no account and no identifier.
GitHub learns what any web server learns — your IP, and a User-Agent
naming the version you're running. Nothing about you, your layouts, your
configuration or a single character you type is transmitted, ever. This
is not telemetry, and PolterType still has none of any kind.
If you want a build that never touches the network at all, that is one checkbox — General → Updates in the Settings window, or:
[updates]
enabled = false
check_interval_hours = 24 # clamped to a 1-hour floorExisting config.toml files don't need editing: the section defaults in.
- The download is verified, not signed. The SHA-256 comes from the
same release as the installer, so it catches a corrupted download or a
tampered CDN — but not a compromised GitHub account. Signing the
manifest with a key held off GitHub is the real fix and is planned;
the manifest already carries a reserved
signaturefield. - The macOS updater has not been run on a Mac. macOS is a CI-only
target for this project. The
.app-swap path follows Apple's docs and the Windows and Linux paths are exercised, but treat macOS self-updating as unproven for now.
0.3.1 — the Settings window wears the brand, in light and dark
The Settings window used to render in iced's stock theme — grey, unbranded, and unrelated to how the product presents itself anywhere else. It now shares a visual language with poltertype.com: the same design tokens (brand indigo, ink/muted text, "ecto" green for success, "garble" pink for danger), the GhostMark keycap-ghost logo in the sidebar (drawn as vectors — no image assets, no SVG renderer dependency), pane content grouped into hairline cards, and hotkeys rendered as physical keycap chips, the same way the site draws hotkey chords.
Both light and dark variants ship. The default follows the OS
setting; a new Appearance picker on the General pane pins it to
Light or Dark explicitly, persisted as [general].ui_theme in
config.toml ("system" / "light" / "dark"; unknown values
fall back to "system").
Smaller UX fixes in the same pass: the About links (site, repo, issue tracker) are real buttons that open the browser instead of non-clickable text, the About pane shows the resolved config path, and per-pane status banners use the shared success/danger colours instead of hardcoded RGB values.
Two things had to be fixed for the above to hold up. Following the OS appearance now works beyond GNOME/KDE: the detection iced ships mis-parses the XDG desktop portal's reply and falls back to "light" on Hyprland-class desktops, so the window now asks the portal (and the GNOME gsettings key) itself. And switching themes at runtime exposed rendering bugs in iced 0.13's CPU compositor — the window blinked between the new palette and a stale old-theme frame while the mouse moved — which the window now sidesteps by forcing a full repaint on every UI change (imperceptible; it only redraws on input events anyway).
0.3.0 — per-app features land on Linux, and the tray admits hook failures
Per-app features stop being Windows-only. The focus tracker — the
component that answers "which app is the user typing into?" — now has
two Linux backends: Hyprland (over the compositor's IPC socket, the
same transport the layout switcher uses) and X11 (EWMH
_NET_ACTIVE_WINDOW). Both report the focused process's executable
basename, exactly like the Windows tracker, so [exceptions].disabled_apps,
per-app wordlist profiles, and apps = [...] scoping on smart
commands now work on those setups with the same config values you'd
write on Windows (minus the .exe). GNOME and KDE on Wayland still
have no active-window query — the tracker stays a no-op there.
Previously, when the keyboard listener failed to start — most commonly
a Wayland session without input-group access — the tray came up
looking perfectly healthy while the app silently did nothing. Now the
failure is surfaced three ways: a "⚠ Keyboard hooks unavailable —
Setup Guide…" entry at the top of the tray menu (opens the permissions
guide in your browser), a warning suffix on the tray tooltip, and a
one-time system notification at startup explaining what happened and
where the fix is.
The Languages panel's helper text was still spelling the product name in lowercase.
0.2.2 — the name is spelled "PolterType" everywhere it is shown
Everywhere the app wrote its own name for a human to read, it used the
spelling Poltertype. The brand is PolterType. The settings window
title, the tray tooltip, the system notifications, the About entry in
the tray menu, --version and --help, the README files seeded into
the user's layouts and wordlists folders, and the installer metadata —
the Linux .desktop entry, the macOS bundle name, and the Windows
product name shown in Add/Remove Programs — all agree on it now.
This is a display-only change: nothing moves on disk and no setting is
lost. The app id stays dev.opensource.poltertype and the config and
data directories stay poltertype, because those are identifiers, not
the brand. The Windows installer's product folder and registry key are
derived from the product name and so change case with it, which is
harmless — both the filesystem and the registry are case-insensitive
there, and the upgrade is keyed on the MSI upgrade code regardless.
0.2.1 — "Settings…" survives an in-place update
Replacing the binary while the tray kept running — an in-place package
upgrade, or a cargo build during development — made the Settings…
tray entry a silent no-op, permanently, until the app was restarted.
The cause is how Linux reports a running process's own path: once the
binary behind /proc/self/exe is unlinked, the kernel keeps resolving
the link but appends a literal (deleted) to it, and
std::env::current_exe() hands that string back verbatim. The tray
spawns the Settings GUI as a copy of itself, so it was trying to
execute a file called poltertype (deleted), getting ENOENT, and
giving up with nothing but a warn! line in a log the user has no
reason to look at.
The tray now recognises that path shape and launches the binary that actually sits there — the freshly installed one. When there is nothing left to launch (the app was uninstalled or the build directory wiped), it says so with a system notification instead of failing silently.
0.2.0 — PolterType rename, Linux X11 support, Hyprland layout fix
The rename lands in full — binary, crates, config directory and
data-dir env var all become PolterType, with an existing kb-switcher
configuration adopted automatically on first launch — together with
X11 support and a Hyprland fix for corrections that fired in one
direction only on input-remapper setups.
X11 sessions are now fully supported, and unlike Wayland they need
no setup at all: no input group, no udev rule, no sudo, no
setup-linux.sh. Everything the app needs is available to any client
that can open the display.
- Listener —
XInput2raw key events selected on the root window. - Emitter —
XTestFakeInput, both for replaying the corrected word as scancodes and (for smart-commands) for typing arbitrary Unicode by temporarily binding a keysym to a spare keycode. - Layout switching — XKB group locking (
XkbLatchLockState), for bare window managers (i3, openbox, a hand-rolled.xinitrc) where no desktop environment owns the layout. On an X11 session that does run GNOME / KDE / IBus / Fcitx, those backends still win, so their tray indicator stays in sync with the keyboard.
Session detection also stopped relying on XDG_SESSION_TYPE alone —
plenty of bare-WM setups never set it, which is exactly the crowd this
backend is for. It now falls back to the display sockets, and correctly
picks the Wayland path under XWayland, where the compositor owns input.
The org.gnome.desktop.input-sources schema ships with GTK, so it is
installed on many machines running no GNOME-family desktop at all.
The probe accepted it on the strength of the schema alone, then read
back an empty input-source list — leaving a switcher with nothing to
switch between, and shadowing the backend that would have worked. It
now requires the schema to list at least one input source.
The working title kb-switcher is retired. Everything brand-visible
moves to the new name: the binary (poltertype), the crates
(poltertype-*), the app id (dev.opensource.poltertype), the macOS
bundle id (org.poltertype.app), the config directory
(~/.config/poltertype/ and OS equivalents), the data-dir override
env var (POLTERTYPE_DATA_DIR, was KB_SWITCHER_DATA_DIR), and the
installer/product names. On first launch the app adopts an existing
kb-switcher config directory automatically: config.toml plus the
wordlist / layout overlays are copied into the new location (nothing
in the new directory is ever overwritten, and the old directory is
left in place as a backup).
crates/poltertype-core/build.rs (and xtask) resolved the repo
root with the compile-time env!("CARGO_MANIFEST_DIR") macro, which
freezes the absolute path of the checkout that compiled them. After
moving or renaming the working copy, the cached build script kept
reading wordlists and layout mappings from the old path — silently
producing empty dictionaries and stale mappings in
target/dist/data, which disabled layout detection entirely in dev
builds. Both now read CARGO_MANIFEST_DIR from the environment at
run time.
One direction of correction could silently die while the other kept
working (typically "uk→en fires, en→uk never does"). Root cause:
current() read the active keymap of the keyboard Hyprland flags
main: — but Hyprland re-elects main when devices appear, and
right after our uinput emitter registers, the emitter itself is
often promoted. Its keymap only tracks our own switchxkblayout all
calls, never the user's per-device Alt+Shift toggle (which lands on
the keyd/remapper virtual keyboard the physical keystream flows
through). After the first correction plus one manual toggle, the
engine's idea of "current layout" was permanently wrong for one
direction: a Ukrainian word typed under en-US mapped to "already
valid Ukrainian" and was vetoed. The guard that was supposed to skip
the emitter compared the raw device name (poltertype virtual keyboard) against Hyprland's dash-normalised output
(poltertype-virtual-keyboard) and never matched. current() now
normalises names before comparing, never considers the emitter, and
prefers an input-remapper virtual keyboard (keyd / kanata / kmonad)
over main: — when a remapper is present, its device is the one
whose keymap reflects what the user is actually typing.
0.1.1 — ALL-CAPS suppression + trailing-space fix on Wayland
Two follow-up fixes for the most common "the corrector glitched on me" reports against 0.1.0 — both Linux/Wayland symptoms, both pure-Rust core / listener changes.
Typing a word entirely in uppercase (URL, HTTP, API, ССЫЛКА,
…) by holding Shift or via Caps Lock is almost always deliberate —
an abbreviation or a shouted word — not someone "in the wrong
layout". The auto-switch detector would occasionally take the bait
on these tokens (an ALL-CAPS string often happens to render as
something letter-like in the other layout) and replace the
abbreviation with gibberish. The engine now skips auto-switching for
buffers where every cased letter is uppercase and there are at least
two of them. Mixed-case (Hello, iPhone, IPv4) and single
capital letters (sentence starts, I / Я) are unaffected; the
manual switch-last hotkey (Ctrl+Shift+Backspace) still works on
ALL-CAPS buffers for the rare case where the user really did want to
flip layouts. Controlled by [engine].suppress_for_all_caps
(default: on).
On Linux/Wayland the listener folds Caps Lock into the effective shift bit, so both held-Shift and Caps-Lock-on variants are caught. On Windows / macOS only the held-Shift variant is caught for now — folding Caps Lock into the modifier on those backends is a separate per-OS listener change.
The long-standing report "corrected words run together — the space gets cut" turned out to be a held-key bug, not a coalescing one. The boundary key (almost always Space) that triggers the correction is still physically held down when our uinput replay reaches it: the user just pressed Space, the engine reacted within ~10 ms, but human fingers don't release that fast. Injecting a press for an already- down key is a no-op at the compositor — global key state is already "down", so no character is produced. The replay now emits a release for the boundary scancode before its press, clearing the held state regardless of whether the user is still holding the key (a harmless no-op if they already let go). The following press is then a real down-edge and reliably produces the trailing space / newline.
0.1.0 — First stable
First stable release — drops the -beta pre-release suffix. No new
features beyond the fixes below; this version marks the Linux/Wayland
path as working well enough on the maintainer's daily-driver setup
(Hyprland + keyd) to leave beta.
Auto-correction re-emits the boundary key after the corrected word.
When that boundary was Enter, the correction pressed Enter a second
time — in a terminal that ran a spurious command (e.g. typing
podman start --all, hitting Enter, and having a stray і typed and
executed at the next prompt); in a chat app it would send a message.
The engine now treats Enter / Return / Tab as submission boundaries
and never auto-corrects on them. The manual switch-last hotkey is
unaffected.
Pasting text with Ctrl+V (or Ctrl+Shift+V / Shift+Insert) could
trigger an auto-correction of the pasted word. A paste isn't typing
and must never be retyped into another layout, but on Wayland the
compositor / input remapper (keyd & friends) can replay the inserted
text through a virtual keyboard, where it is indistinguishable from
human keystrokes. The engine now opens a short window after any paste
shortcut during which it declines to auto-correct, so pasted content
is left exactly as-is. The next genuinely-typed word is unaffected.
On the Wayland/evdev backend the OS-level global-hotkey grab never
sees native input — it can only bind through Xwayland, which Hyprland
and friends don't route real keystrokes into. So the pause and
switch-last hotkeys silently did nothing on a pure Wayland session.
The evdev listener already observes every key, so the engine now
matches the hotkey chords straight off that stream instead. Detection
is rising-edge (one fire per physical press, autorepeat ignored) and
requires an exact modifier match, so Ctrl+Shift+Space never fires on
Ctrl+Shift+Alt+Space. The two paths are mutually exclusive per
backend, so there's no double-fire on Windows/X11.
The default switch-last binding (Ctrl+Shift+Backspace) is also
rebound to a safe key (Ctrl+Shift+F9) on the keystream path: there
the Backspace also reaches the focused app, where Ctrl+Backspace
means "delete the previous word" and would corrupt the very text being
corrected. An explicit custom binding is always honoured as-is.
Powering off a Bluetooth keyboard (or unplugging a USB one) left its
evdev fd returning ENODEV on every poll, and the listener re-polled
it hundreds of times a second — warning on each, flooding the log
forever. A disconnected device is now dropped from the poll set on the
first ENODEV. The listener also re-enumerates /dev/input every two
seconds, so a reconnected keyboard is picked back up automatically
instead of staying dead until the app restarts.
The auto-switch + corrector pipeline did not actually work on a
Wayland session running Hyprland with keyd (a common tiling-WM
setup): the tray icon appeared but no layout was detected, nothing
was corrected, and early attempts spiralled into a backspace/space
loop that locked typing for seconds. Several distinct bugs:
- evdev listener deadlocked.
Device::fetch_eventsis blocking by default; the single-thread fan-in loop stalled on the first quiet device and never reached the keyboardkeydactually emits through. The evdev FDs are now set non-blocking. - Layout switch hit the wrong device.
hyprctl switchxkblayout main-keyboardonly flips one keyboard; withkeydthe real input flows through its virtual keyboard, which kept the old layout and re-typed the original Latin glyphs. We now switchalldevices. - Active-layout query read the wrong device.
current()took the firstactive keymapline (a stale power/sleep button), so the engine misjudged the active layout and the tray ignored manual Alt+Shift switches. It now reads the keyboard Hyprland flagsmain, skipping our own uinput emitter. - Corrector typed Unicode escape codes. The Wayland emitter drove
the GTK
Ctrl+Shift+U <hex>compose sequence, which most terminals / Wayland-native apps render literally. The corrector now replays the original scancodes after the layout flip (a newKeyEmitter::send_keys), so the compositor's xkb mapping produces the right glyphs. Windows/macOS keep their native Unicode path. - Self-correction feedback loop. Replayed events come back through
the listener without an
injectedmarker (the remapper strips it), so the engine re-corrected its own output indefinitely. A short post-correction lockout window suppresses the echo. - Dropped keystrokes in replays. Packing press+release into one uinput frame let libinput coalesce it into a zero-duration tap (most visibly the trailing space between corrected words). Events are now emitted one per frame with a small inter-event delay.
- Shift / Caps state was ignored. The evdev listener left modifiers empty, so corrections always came out lowercase. It now tracks Shift/Ctrl/Alt/Super/CapsLock from the event stream.
scripts/setup-linux.sh also re-triggers udev with --action=change
and force-fixes /dev/uinput ownership so the permissions apply
without a reboot.
Hunspell expands every Ukrainian stem into all of its grammatical
surface forms — including ones modern speakers basically never type
standalone, like vocative-case nouns ("туче!" — "O cloud!" from
туча). When such a form happened to also be the cross-layout
rendering of a common English word, the dict detector saw a real
Ukrainian word in the buffer and refused to switch — leaving the
user stuck on gibberish. The motivating case: typing next under
uk-UA produced туче, which is technically valid → Keep → no
correction.
New per-layout <stem>-weak.txt data file marks these "valid but
basically never the intent" entries. The DictionaryDetector now
treats a current-side weak hit as a deferred signal: it walks the
alt-layout renderings first and switches to any of them that's
itself a strong dict hit. If no alt is in dict, the weak word still
keeps (the weak list never blocks a switch by itself, only opens
the door to one). Strong (non-weak) entries are unaffected — they
continue to win outright.
- New file:
data/wordlists/uk_ua-weak.txt, seeded withтуче. Conservative on purpose — adding a common word here would auto-switch users typing it intentionally. - Same loader contract as the existing
<stem>-stop.txt/<stem>-extras.txtfiles: bundled list at compile time, optional user overlay at<config-dir>/poltertype/wordlists/<stem>-weak.txtpicked up by "Reload Settings" without a rebuild. DictionaryDetector::is_weak()exposed for diagnostic UI / future detectors.
Two-letter English acronyms (AI, ML, UI, UX, DB, QA, CD,
CI, MD, …) typed under uk-UA used to render as Cyrillic-uppercase
noise (ФШ, ЬД, ГШ, …) and stay there — neither detector had any
signal to switch on:
DictionaryDetectordeliberately skips the embedded FST for ≤2-letter buffers (the bulkdwyl/english-wordscorpus ships short noise likews,ax,oethat would block legitimate Cyrillic switches), so a curated 2-letter acronym sitting only in the FST was invisible.WordPlausibilityDetectorignores buffers shorter than 3 letters by design.
build.rs now mirrors the ≤2-letter slice of <stem>-extras.txt into
the dist <stem>-stop.txt at compile time. Extras is our own curated
list — no noise — so its short subset is safe to trust in the short
regime. Existing user-side <stem>-stop.txt overlays still merge in
on top, and the dwyl short noise is unchanged (still FST-only, still
invisible to the short-token lookup). For en-US this lights up ai,
ml, ui, ux, db, qa, cd, ci, md, fe, fp, gz,
qr, mp, bz, xz, ks, ln, rc, ay.
The Wordlists pane used to ship its own Save and Reload buttons below the editor, separate from the footer Save and Reload that covered the rest of the settings. Two pairs of nearly-identical buttons made the UI confusing — users (reasonably) expected the more prominent footer Save to write everything, including the wordlist edit in front of them, and were surprised when it didn't.
Both per-pane buttons are now removed. The footer pair now covers everything:
- Footer Save — writes
config.tomlAND flushes any unsaved wordlist content (using the sameflush_wordlist_to_diskhelper as the auto-save-on-switch path). - Footer Reload — re-reads
config.tomlAND re-reads the currently-displayed wordlist file from disk, discarding any unsaved editor content (intentional — same semantics as the old per-pane Reload).
The Wordlists pane keeps its dirty indicator ("● unsaved changes") and per-pane status banner so the user still sees "auto-saved unsaved edit to ..." messages from layout / profile / kind switches. Just one click target for the save itself.
Bumped from 720×540 to 820×640 so the Commands and Wordlists panes render their full forms (and lists, where applicable) without scrolling on a stock 1080p screen. Still small enough to feel like a settings dialog, not a main window.
When the engine auto-corrects (changes the OS layout and re-types
the last word) it can now show a brief system notification —
"poltertype: Switched to English (United States)" — that auto-
dismisses after ~2 seconds. Off by default (preserves the existing
"quiet by default" contract); toggle on the General pane in the
Settings window. The body text uses the layout's friendly name
field (from data/layout-mappings/<stem>.toml) when known, and
falls back to the raw BCP-47 id.
Implementation notes:
- Cross-platform via
notify-rust— Windows 10+ Toast, NSUserNotification on macOS, Desktop Notifications spec via DBus on Linux. Matches platforms supported elsewhere in PolterType. - Fired only on
SwitcherEvent::Corrected— auto-switch and manual switch-last hotkey both produce that event, so the user sees notifications for both. NOT fired onLayoutChanged(which also covers external layout changes like Win+Space; those are already explicit user actions and don't need a notification of their own). - Spawned on a dedicated thread so the platform's notification call (DBus round-trip on Linux, Toast XML on Windows) never adds latency to the tray's event loop.
- Notification text never contains the typed word — only the destination layout's name. Matches the project's hard rule in the project's own rules about not logging user-typed text.
- Failures (no notification daemon, Focus Assist suppressing toasts, sandbox quirks) are logged at warn level and swallowed; the auto-switch itself already happened, so the notification is best-effort UX sugar on top.
Three related ways the Wordlists pane could lose a typed-but-not- saved edit, all fixed:
- Footer "Save" didn't save the wordlist. The bottom-right
primary-styled "Save" button only wrote
config.toml— wordlist content lived in a separatetext_editor::Contentbuffer that the per-pane Save (smaller, in the pane footer) was responsible for flushing. A user clicking the more prominent footer button and then closing the window would lose their edit. Footer Save now also flushes any dirty wordlist content before writing config.toml. - Switching layout / profile / kind dropped unsaved content. Clicking a different layout / profile / kind button used to unconditionally re-read the file for the new selection and overwrite the editor buffer — silently discarding anything the user had typed. The selectors now auto-flush first, with a separate "Auto-saved unsaved edit to ..." banner so the user understands the side effect.
- Closing the window dropped unsaved content. The window's
X button (or Alt+F4 / Cmd+W) used to take the buffer to the
grave. Iced's
exit_on_close_request(false)plus aiced::window::close_requests()subscription let us intercept the close, flush, then close manually.
The actual save logic is now a single flush_wordlist_to_disk
helper called by all four paths (per-pane Save, footer Save,
selector switch, window close), so adding new triggers in the
future stays consistent. WordlistFlushOutcome carries enough
detail (Nothing / NoLayout / Saved(path) / Failed(msg)) for each
caller to pick banner phrasing that matches what actually
happened — silent for no-op auto-saves, explicit for user-clicked
saves.
Saving a word in the Wordlists pane previously took effect only
after a tray restart, even though the pane's banner said "Saved.
Close this window to apply". The settings-waiter (the worker that
runs when the GUI subprocess exits) reloaded config.toml for
the schema parts ([[commands]], [hotkeys], exceptions, profile
defs) but left the engine's dictionary set untouched.
Fix: the close handler now performs three reload steps in sequence:
config.tomlreload — picks up schema edits (existing).- Global wordlist reload — re-reads
<config-dir>/poltertype/wordlists/<stem>.txtand atomically swaps the engine's dictionary set, same primitive the tray "Reload Settings" entry uses. - Per-profile cache rebuild + watcher force-reapply — the
profile cache built at startup is replaced from disk, and a
new
force_reapplyflag tells the focus-watcher to re-apply the currently active profile on its next ~250 ms tick. Without this, a user editing a profile's wordlist while focused on a matching app would have to alt-tab away and back to see the change.
Refactor in crates/poltertype-app/src/main.rs: profile_dict_cache now
lives behind Arc<RwLock<...>> so the close-handler can rebuild
it without restarting the watcher thread; spawn_profile_watcher
takes the cache + force-flag and re-reads on every tick. The
Wordlists pane banner / pane-intro text were updated from
"Restart PolterType to apply" to "Close this window to apply" so
the wording matches reality.
Pressing Ctrl+Shift+Backspace (the manual switch-last hotkey)
right after an auto-correction caused an infinite loop: text
accumulating to wow wow wow… and the correction sound playing
on a loop until the app was killed.
Root cause: when apply_correction sends BACKSPACE keystrokes
via SendInput to delete the typed word, those Backspaces are
flagged INJECTED so the engine itself ignores them. But Win32
RegisterHotKey (the primitive global-hotkey uses) sees the
combination of our injected Backspace + the user's
still-held Ctrl+Shift modifiers as a fresh Ctrl+Shift+Backspace
press and fires the hotkey again — running force_switch_last
recursively. Same effect from key auto-repeat if the user holds
the chord.
Fix: EngineCommand::SwitchLastForcefully now takes the
stashed last_word atomically (write().take()) instead of
cloning it (read().clone()). The first fire processes; every
subsequent fire from the same physical hotkey press (or its
echo) finds None and exits silently. To re-trigger, the user
must complete another word and let the engine re-stash a new
last_word. Pinned by a regression test
(engine::last_word_consume_tests).
Inspired by classic text expanders (TextExpander, Espanso,
AutoHotkey hotstrings): the user types a short token like
anrl (acronym + space), the engine recognises it on the word
boundary, deletes the token + boundary, and runs an action —
typically expanding to a longer phrase.
config.toml accepts [[commands]] entries:
[[commands]]
id = "anrl"
name = "Anatomical reference list"
trigger = "anrl"
action = { type = "type_text", text = "Anatomical Reference List" }
[[commands]]
id = "to-english"
trigger = "((en))"
action = { type = "switch_layout", layout = "en-US" }
[[commands]]
id = ";cfg"
trigger = ";cfg"
action = { type = "open_path", path = "%LOCALAPPDATA%/poltertype/config.toml" }Three v1 actions:
type_text— backspace trigger + boundary, emit the literal text, re-emit the boundary. Soanrl<space>→<expansion><space>, the user's flow continues naturally.switch_layout— backspace trigger + boundary, switch the OS layout to the given BCP-47 id. Samelist_activepre-flight as the corrector — unreachable layouts are rejected loudly.open_path— backspace trigger + boundary, hand the path toopener::open(default handler / browser).
Optional apps = [...] filter scopes a command to specific
foreground applications using the same case-insensitive basename
match [exceptions].disabled_apps already uses.
The trigger lookup runs BEFORE the structural-boundary /
disabled-app / identifier filters: text expansion is direct user
intent, not a guess, so those auto-switch filters don't apply.
That's what makes => snippets work inside an IDE.
A new Commands pane in the Settings UI lets users add and
remove commands. Form fields: name, trigger (text input), action
kind (TypeText / SwitchLayout / OpenPath), action param, optional
apps filter. Auto-generates kebab-case ids from the display name;
collisions append -2, -3, … deterministically.
What v1 deliberately doesn't include:
run_shell— arbitrary command execution. The blast radius (a malicious config could mass-exfiltrate) makes this a separate security review, queued for later.- Multi-token triggers (
best regards→…). The buffer resets at every word boundary; matching across boundaries needs a sliding window we don't have today. - Case-insensitive / case-preserving expansion. v1 matches exactly — pick triggers that don't collide with prose.
Adds [wordlists] to config.toml:
[wordlists]
default_profile = ""
[[wordlists.profiles]]
id = "code"
name = "Programming"
apps = ["Code.exe", "Cursor.exe", "idea64.exe"]
[[wordlists.profiles]]
id = "writing"
name = "Long-form prose"
apps = ["WINWORD.EXE", "obsidian.exe"]Each profile points at its own subdirectory under
<config-dir>/poltertype/wordlists/profiles/<id>/<stem>.txt (and
<stem>-stop.txt). A new background watcher polls
FocusTracker::focused_exe() every ~250 ms and atomically swaps
the active dictionary set when the focused app changes — using
the same DictionaryDetector::replace_dicts primitive the
"Reload Settings" path already uses.
The Settings UI's Wordlists pane now shows a Profile row
above the existing Layout / Kind pickers (only when at least one
profile is configured) — pick "Global" or any of your profiles to
edit that profile's overlay files. Profile list management
(add / delete profiles, edit apps lists) is queued for a follow-up;
v1 expects users to declare profiles in config.toml once, then
edit their wordlists from the GUI.
What v1 deliberately doesn't include:
- Profile inheritance — each profile is its own overlay set, no merging. Adds load-time complexity ("which profile wins?") without a clear UX win.
- Hot reload — same constraint as the global overlay; profile edits apply on tray restart.
New helper to bump the workspace version in lock-step across
Cargo.toml, CHANGELOG.md (the ## [Unreleased] — <ver>
heading), and Cargo.lock. Surface:
cargo xtask version # print current
cargo xtask version bump # auto-bump (pre-release counter or patch)
cargo xtask version set X.Y.Z # exact value
cargo xtask version <subcmd> --dry-runHand-rolled parser, no semver / regex deps. Surgical
Cargo.toml edit anchored on [workspace.package].version so
dep-pin version = "..." entries elsewhere in the file are left
alone. Refuses to write if the file shapes drift — see
docs/RELEASING.md for the full release flow.
0.1.0-alpha.0 → 0.1.0-beta.6 — pre-release iterations
Pre-release tags up through v0.1.0-beta.6 (one per merged
batch of work) shipped against this single rolling block while
the project bootstrapped. Per-tag notes weren't kept — the
git log is the authoritative record for which commit landed in
which tag. From v0.1.0-beta.7 onward, each release gets its
own dated section above.
The initial scaffolding lands across Phases 0–8 documented in docs/PLAN.md. Highlights:
- Cargo workspace with seven crates:
poltertype-app,poltertype-core,poltertype-input,poltertype-layout,poltertype-detect,poltertype-ai,poltertype-types. - Pure-Rust runtime:
taoevent loop +tray-icon+global-hotkeysingle-instance. No WebView, no Node.
- SwitcherEngine: scancode-buffer → per-layout render → detector
pipeline → corrector. Skips events synthesised by our own
KeyEmitter(avoids feedback loops). WordPlausibilityDetector— vowel-ratio + consonant-cluster heuristic. Catches the canonical "wrong-layout" cases for EN ↔ UK.- Layout mappings in
data/layout-mappings/*.toml, embedded viainclude_str!. EN-US + UK-UA in v0.1. - Settings stored as TOML at the OS-canonical config path; reload from tray notifies the engine without restart.
- File logs via
tracing-appender(daily rotation) under the OS data dir. - Tray menu: Open Settings (config.toml in default editor) / Open Logs Folder / Reload Settings / Pause / About / Quit.
- Global hotkeys:
Ctrl+Shift+Space(pause),Ctrl+Shift+Backspace(force-switch the last word). - AI subsystem scaffold (
poltertype-ai, gated byfeature = "ai"):Detector+WordRewriterplug-in shape, key storage viakeyring, declarative[[ai.detectors]]config schema. Concrete ONNX/LLM implementations are stubs in v0.1; v0.1.x fills them in.
| Platform | Listener | Layout switcher | Emitter |
|---|---|---|---|
| Windows 10 / 11 | WH_KEYBOARD_LL (working) |
LoadKeyboardLayout + WM_INPUTLANGCHANGEREQUEST (working) |
SendInput + KEYEVENTF_UNICODE (working) |
| macOS 14+ | CGEventTap (best-effort, validated on CI) |
Carbon TIS (best-effort) | CGEventPost + Unicode string (best-effort) |
| Linux Wayland | evdev (best-effort, requires setup-linux.sh) |
Hyprland / KDE / GSettings (GNOME, Ubuntu Unity, Cinnamon, Budgie, Pantheon, MATE) / IBus / Fcitx5 — probed in that order | uinput + Ctrl+Shift+U (best-effort) |
| Linux X11 | stub (v0.1.x) | KDE / GSettings / IBus / Fcitx5 work the same on X11; raw XkbLockGroup fallback in v0.1.x |
stub (v0.1.x) |
docs/PLAN.md— architecture, roadmap, decisions log.docs/DECISIONS.md— non-obvious technical choices with reasoning.docs/PERMISSIONS.md— per-OS access requirements.docs/AI.md— AI subsystem privacy + plug-in API.
Detection now consults proper per-language dictionaries instead of a
hand-curated 280-word list. Sources (see data/wordlists/CREDITS.md):
- EN:
dwyl/english-words— Public Domain — ~370k entries. - UK / RU / DE / ES / FR: LibreOffice Hunspell dictionaries
(
*.dic+*.aff) — MPL / GPL / etc., per-language.
xtask/src/hunspell.rs parses each language's .aff rules and
expands every <stem>/<flags> entry in the .dic into the full
set of inflected surface forms. Coverage per language:
| Lang | Stems | Surface forms |
|---|---|---|
| en | — | 370 105 |
| uk | 350656 | 3 486 848 |
| ru | 146269 | 1 436 553 |
| de | 258202 | 789 398 |
| es | 58221 | 652 463 |
| fr | 84139 | 2 139 550 |
Storage is a BurntSushi FST Set built at
compile time from data/wordlists/<id>.txt and embedded via
include_bytes!. The FST encoding keeps lookup at O(len(word))
with no per-word allocation; the on-disk size grows roughly
linearly with the form count.
User overlay: drop additional words into
<config-dir>/poltertype/wordlists/<id>.txt to extend any
dictionary with project-specific vocabulary at startup.
Refresh upstream: cargo xtask wordlists fetch re-downloads .dic
.afffor each language, re-runs the expander, and writes a freshdata/wordlists/<id>.txt.
Auto-switching skips:
- the foreground app is on
[exceptions].disabled_apps— defaults cover VS Code / Cursor, every JetBrains IDE, Sublime, Zed, Neovide, Windows Terminal, alacritty / kitty / wezterm, PowerShell / cmd, and friends; case-insensitive basename match. - the just-finished token looks like a code identifier
(
snake_case,camelCase,letter+digit, or contains code punctuation). Acronyms and ordinary capitalised prose are not flagged.
Both filters apply to automatic decisions only — the manual switch
hotkey Ctrl+Shift+Backspace always works, so devs can fix
wrong-layout identifiers or write multi-language comments by
explicitly asking the engine to act.
Pushing a v* tag triggers .github/workflows/release.yml, which
builds three platform-native installers in parallel and attaches
them to a draft GitHub Release:
- Windows — per-user
.msivia WiX Toolset 3 (no admin needed, no UAC prompt). Start menu shortcut, clean uninstall. - macOS — universal-binary
.dmg(Intel + Apple Silicon merged withlipo) containing a tray-onlypoltertype.app(LSUIElementset; no Dock icon). - Linux —
.AppImage(x86_64) built withlinuxdeploy. Single file, no system install.
Beta builds are unsigned — Gatekeeper / SmartScreen will warn on first launch; the release notes call out the per-OS workaround. Code signing comes in a later phase.
The packaging scripts under installers/ are also runnable locally;
see CONTRIBUTING.md §Releasing.
Layout TOMLs and FST wordlists no longer ride inside the binary.
crates/poltertype-core/build.rs writes them to target/dist/data/; each
installer copies that tree into the runtime's expected location:
| Platform | Data lives at |
|---|---|
| Windows MSI | <exe_dir>\data\ |
| macOS .dmg | poltertype.app/Contents/Resources/data/ |
| Linux AppImage | <mount>/usr/share/poltertype/data/ |
dev (cargo run) |
target/dist/data/ |
poltertype_core::data_dir::resolve() finds the live tree at startup. The
app then queries LayoutSwitcher::list_active() and loads only the
layouts the OS actually has — a user with en-US / uk-UA / ru-RU
saves the FST RAM for the three other bundled languages they'd
never query, and the detector physically can't pick an unreachable
layout (the root cause of the original http bug).
Foundation for the future plug-in / language-pack marketplace —
<data_dir>/plugins/<pack-id>/ is reserved with the contract
specified in docs/DATA_LAYOUT.md. v1's
plug-in surface will be data-only (TOMLs + FSTs); native-code or
network-enabled plug-ins are explicitly out of scope until the
security model has been reviewed.
Tray menu "Settings…" entry opens a real GUI (iced 0.13 with
the lightweight tiny-skia renderer). Six panes:
- Languages — checkbox UI over OS-active layouts. Renders the effective state, so the default (empty allow-list = "use every OS layout") shows every box ticked. Un-ticking a box from that state materialises the allow-list as "everything except this one", preserving the user's intent across save.
- Hotkeys — current pause / switch-last bindings + a Rebind
button per row. Click → "Press a combination…" → the next
<modifier>+<key>combo is captured and written. Lone modifier presses are filtered, single-letter combos refused,Esccancels. Round-trip throughglobal-hotkey::HotKey::from_stris unit-tested so the GUI can never produce a combo the next tray launch silently drops.crates/poltertype-appnow reads bindings from[hotkeys]in settings (used to be hardcoded). - Wordlists — multiline editor over the per-layout user-overlay
files in
<config-dir>/poltertype/wordlists/<stem>.txt(Extras) and<stem>-stop.txt(Stop list). Pick a layout button, pick a kind, edit, hit Save — the file is written with a trailing newline (matches the bundled convention) and the resolved path is shown above the editor so users can verify where the bytes land. Changes apply on next tray restart since wordlist FSTs are loaded at engine start, not hot-reloaded; the pane spells this out so users don't expect live reload. - General — autostart, sound on correction, suppress-in- identifiers, idle timeout, plus shortcut buttons to the various config / log / wordlist / layout folders.
- Exceptions — list-edit for
[exceptions].disabled_apps. One row per entry with a delete×, plus an Add field at the bottom (Enter or Add-button). Case-insensitive dedup matches the engine's runtime comparison. - About — version, repo links, "Reset to defaults" + "Reload from disk" escape hatches.
Implementation note: the GUI runs as a child process
(poltertype --settings) so the tray's tao::EventLoop and
iced's winit event loop don't fight over the macOS main thread.
<data_dir>/plugins/<pack-id>/ is now enumerated at LayoutDb
load. Pack shape: manifest.toml + layout-mappings/*.toml +
wordlists/<stem>.fst[+ -stop.txt]. Precedence chain
bundled ← plug-ins ← user-overlay — a user can still override
a plug-in by dropping a TOML with the same id in their config dir.
v1 surface is data-only — no native code, no network calls, no settings injection (see docs/DATA_LAYOUT.md § "What plug-ins won't be"). The loader is ~80 LOC, every error path warns and skips, four unit tests cover happy-path / missing-manifest / invalid-manifest / user-override.
- Linux X11 listener / emitter / layout switcher are stubs.
- macOS / Linux backends are written from documentation and only
validated by
cargo checkon CI; runtime tuning will land as contributors with the right hardware report issues. - Beta builds are unsigned (no Apple Developer ID, no Windows EV / OV cert yet) — code signing tracked for a later phase.
- Hotkey capture on Wayland — works inside the focused Settings window, but Wayland's security model means we don't see global key presses while another app has focus. Acceptable for v1 (you'd rebind from inside the window anyway), revisited if a use case surfaces.
- Plug-in marketplace UX — install / sign / update flow is a separate phase. The loader is ready; the network + UI plumbing has its own security review queued.