Press-and-hold accents for Omarchy, the way macOS does them: hold a vowel, a popup opens at the text cursor with every accented form, and a digit or an arrow picks one.
The digit sits under the character rather than beside it, the way macOS draws it.
Nine accent sets — Português, Español, Français, Deutsch, Italiano, Polski,
Türkçe, Nordisk and English — picked from the panel. They differ in order,
not in reach: each one puts the accents that language actually uses on 1 and
2. Portuguese leads with á ã â à; French leads with à â á ä. The popup is
themed from the current Omarchy palette.
A small fcitx5 addon written in C++, plus a bar widget to configure it.
The addon watches key events at EventWatcherPhase::PreInputMethod, so it sees
them before any input method engine and can consume them outright. Per input
context it runs a three-state machine:
| State | Event | What happens |
|---|---|---|
| idle | press of an accent-bearing key | consume it and start a timer — nothing is shown yet |
| pending | release before the timer | hand the key back to the application — it was a tap |
| pending | a different key | hand the first key back, then handle the new one normally |
| pending | timer fires | put the base letter in the preedit and show the candidates |
| picking | 1–9 |
commit that accent |
| picking | ← → Tab |
move the selection |
| picking | Enter / Space |
commit the highlighted one |
| picking | Esc / Backspace |
commit the plain letter |
| picking | release of the held key | nothing — the popup outlives the release, as on macOS |
| pending or picking | focus leaves the field | drop it — nothing is committed, the popup closes |
Three properties fall out of that design, and all three are the point:
The hold time is its own timer. Not your key repeat. fcitx5's built-in long
press has no timer at all — it fires on the first auto-repeat event, which
makes the popup delay and input:repeat_delay the same number. This addon
reads neither and writes neither.
The base letter is never committed until you decide. Once the popup is up
it lives in the preedit. So nothing ever has to be deleted afterwards, and
deleteSurroundingText — which Chromium silently drops on Wayland — is never
called. That is why accents work in Brave on native Wayland here, with no
XWayland, no wrapper script and no browser flags.
Ordinary typing is ordinary typing. A tap is handed back to the application as the key itself, so to the application it is indistinguishable from a key this addon does not handle; the preedit only starts if the hold survives the timer. Two earlier versions got this wrong in different ways.
The first opened a composition on every keypress and withdrew it on release,
which is invisible in a terminal but made Google Docs walk its caret backwards
and scramble letters — it re-renders on every composition cycle. Measured in a
contenteditable once that was fixed: typing a sentence fires zero
compositionstart and zero delete* input events, and a single held key fires
exactly one.
The second committed each tap as text. That is invisible almost everywhere, and wrong exactly where the keydown itself means something: a selected Google Sheets cell starts editing on a real keydown, and a commit arrives without one, so the first vowel typed into a cell vanished (#10).
Because it lives inside fcitx5, it inherits every frontend fcitx5 already has:
Wayland (text-input-v3), XIM, GTK, Qt and D-Bus. Verified working in GTK4 on
Wayland, in Brave on native Wayland, and in an XWayland client.
omarchy plugin add https://github.com/heroesofcode/omacento --enableThat is the whole thing. The widget's first run compiles the addon, installs it
under ~/.local, and restarts fcitx5 so it gets picked up — about ten seconds,
once. Nothing needs root and nothing outside your home directory is touched.
The only requirement is a C++ compiler: base-devel, which Omarchy already
installs. If it is missing, omacento-doctor says so instead of failing
quietly.
Everything else the scripts reach for ships with Omarchy already: python3
(reading and writing the config and theme files), hyprctl (listing window
classes for the blocklist), systemctl (the fcitx5 user service), plus awk,
sed and grep. Nothing is fetched from the network at any point.
Nine sets ship: Português, Español, Français, Deutsch, Italiano, Polski, Türkçe, Nordisk and English (international). Pick one in the panel.
They differ in order, not in reach — every set can still get to the same
characters, further down the row. Order is what you actually feel, because the
first two land on 1 and 2:
Português á ã â à ä å ā æ
Français à â á ä ã æ å
Deutsch ä à á â ã å æ
Only the lowercase half of a set is written down. The uppercase half is
derived, so adding a language cannot leave Shift half-working. That derivation
is not as simple as it looks: Latin Extended-A stores case pairs with the
uppercase on the even codepoint for most of the block and on the odd one for
two stretches of it, so a single parity rule handles ć and š correctly
while quietly leaving ł, ź, ż and ž alone — Polish breaks and nothing
else does. The ranges are spelled out in addon/src/presets.cpp, and the test
suite checks the ones that flip.
To go beyond the presets, set Table in
~/.config/fcitx5/conf/omacento.conf — one entry per key, the base character
then its variants, space separated, lowercase only:
[Table]
0=a á ã â à
1=ç ć čA base character does not have to be ASCII: the lookup is on the character the
key produces, not on its keysym, so a layout whose key already yields ç can
hang more variants off it.
omarchy plugin update io.github.heroesofcode.omacento
omarchy-restart-shellThe second line is not optional, and it is not specific to this plugin.
omarchy plugin update calls rescanPlugins, which does not re-read a
plugin's QML — measured by changing a label in both Panel.qml and Model.js
and watching the panel keep the old text until the shell was restarted. A
first-time install needs no restart, because there is no previously compiled
copy to go stale.
Note omarchy-restart-shell, not omarchy-refresh-shell: the latter resets
shell.json to Omarchy's defaults and takes every plugin off your bar.
The addon half looks after itself. omacento-build recompiles whenever the
addon's source differs from the source the installed build came from, the panel
runs it when it loads after that restart, and omacento-apply restarts fcitx5
when a new build goes in — so the shell restart is the only step.
It does not appear in your input method list and does not change your keyboard layout. It is a module addon: it sits beside the engine you already use and only reacts to a held key.
| Setting | Default | Notes |
|---|---|---|
| Enabled | on | right-clicking the bar icon toggles it |
| Accent set | Português | nine languages; changes the order, not the reach |
| Hold before the popup | 250 ms | its own timer; macOS sits near 500 |
| Stack candidates vertically | off | one row by default |
| Popup text size | 14 | fcitx5's stock 10 is small next to a terminal |
| Popup font family | inherit | empty means the terminal's family |
| Follow the Omarchy theme | on | regenerates on theme and font changes |
| Turned off in | — | window classes where a held key just repeats |
Hold a vowel. Pick with 1–9, or walk the list with ← / → / Tab and
press Enter. Esc keeps the plain letter. Tapping the key normally types the
plain letter, as always.
The bar icon opens the panel; right-clicking it toggles the whole thing off.
omacento-apply '{"holdTime":400}' # change one setting
omacento-apply --print # show what would be written
omacento-doctor # check every layer, name the broken one
omacento-build # rebuild the addon if fcitx5 changed
omacento-build --check # report only
omacento-apps # window classes, for the blocklistEverything goes through omacento-apply, which is the only writer, so the
panel and the CLI cannot disagree.
| Path | What |
|---|---|
~/.config/fcitx5/conf/omacento.conf |
hold time, blocklist, accent table |
~/.config/fcitx5/conf/classicui.conf |
popup font, theme, orientation |
~/.local/share/fcitx5/themes/omacento/ |
the generated theme |
~/.local/lib/fcitx5/libomacento.so |
the compiled addon |
~/.local/share/fcitx5/addon/omacento.conf |
addon metadata |
~/.local/share/fcitx5/addon/omacento.source |
which source that build came from |
~/.local/state/omacento/appearance.json |
so the theme hooks can run alone |
~/.config/omarchy/hooks/{theme-set,font-set}.d/50-omacento |
theme sync |
~/.config/systemd/user/omarchy-fcitx5.service.d/10-omacento-addon.conf |
addon search path and rebuild |
It deliberately writes nothing under ~/.config/hypr.
make -C addon testThis loads the freshly built libomacento.so into a real fcitx5 Instance
alongside fcitx5's own TestFrontend, sends key events, and checks what comes
back out. Eighteen cases: tap, hold-and-pick, Esc, flush by a second key, the
uppercase table, Ctrl+A staying untouched, the blocklist, arrow-and-Enter, a
digit past the end of the list, the off switch, four about languages and custom
tables, and four about what can change between a key going down and coming up
— Shift let go first, Shift held throughout, and focus leaving with the popup
open or in the middle of a tap.
Two independent checks run at once. pushCommitExpectation aborts at the exact
commit that goes wrong, which is where the useful stack trace is; and the test
records every commit and every forwarded key itself, in one sequence, and
compares the whole of it at the end, because the first check cannot see a
commit that never happens — an addon that committed nothing would otherwise
pass with every expectation still queued. A tap shows up in that sequence as
[a], the key, not a, the letter; a test expecting the letter is testing the
bug from #10.
test/omacento-build.shchecks the other half: that a change to the addon's source is compiled on the
next run, and that a build with no record of its source is rebuilt. It works on
a copy of the plugin in a temporary directory with OMACENTO_PREFIX pointed
into it, so nothing under ~/.local is touched. Both run on every pull request.
The waits are against the addon's real timer, so the margins are deliberately wide (a 20 ms tap against a 300 ms hold). An earlier 3x margin failed about one run in five on a busy machine: the tap slipped past the threshold, became a hold, and threw every later expectation off by one.
Versions are not edited by hand. release-please watches main, reads the
commit messages, and keeps a release pull request open with the next version
number and a generated CHANGELOG.md. Merging that PR tags the release and
publishes it.
So commit messages decide the version, and they follow Conventional Commits:
| Prefix | Effect |
|---|---|
fix: |
patch bump — 1.0.0 to 1.0.1 |
feat: |
minor bump — 1.0.0 to 1.1.0 |
feat!: or a BREAKING CHANGE: footer |
major bump — 1.0.0 to 2.0.0 |
refactor: perf: |
patch bump, shown in the changelog |
docs: ci: test: chore: |
no bump, not in the changelog |
manifest.json is bumped by release-please through the extra-files rule in
release-please-config.json; .release-please-manifest.json is where it
remembers the current version. Neither is meant to be edited by hand.
One thing to watch, with a caveat. GitHub documents that a pull request opened
with the default GITHUB_TOKEN does not trigger other workflows, which would
leave the release PR without the test check that main requires. That did
not happen here: on the 1.1.0 release PR, opened by github-actions with no
PAT configured, test ran four seconds later and the PR was mergeable. So do
not go looking for the problem — but if a release PR ever does show up with no
check, close and reopen it once and the check runs, because that is a human
action. A fine-grained PAT in a RELEASE_PLEASE_TOKEN secret also avoids it;
the workflow prefers that secret when present.
Pull requests are merged with rebase, so every commit message lands on
main verbatim and every one of them is parsed. A stray wip commit in a
branch becomes a stray wip commit in the history — squash locally first, or
switch the repository to squash-merge so only the PR title counts.
fcitx5 only searches /usr/lib/fcitx5 for addons. StandardPathsType::Addon
has no user directory at all, unlike every other path type. The escape hatch is
FCITX_ADDON_DIRS — but it replaces the default list rather than extending
it, so the system directory has to be named in it too, or xim, waylandim
and classicui vanish along with it. That is the whole reason for the systemd
drop-in.
An addon is tied to the fcitx5 it was built against. The Version= field in
omacento.conf must match, or fcitx5 refuses to load it — silently, from the
user's point of view. omacento-build runs as ExecStartPre of
omarchy-fcitx5.service, so an upgrade heals on the next start instead of
turning into a bug report.
An update used to compile nothing. omarchy plugin update pulls new C++
and leaves the installed .so exactly where it was, and the old check — is
there a build, and was it made for this fcitx5? — said yes. So an update that
changed the addon was pulled, reported as current by omacento-doctor too, and
never compiled: the fix for
#10 would have reached
nobody who already had the plugin. The hash of the source a build came from now
sits next to it in omacento.source, and a build with no record at all is
rebuilt once.
The blocklist matches the window class, not the process name. fcitx5
reports Brave as brave-browser, not brave. omacento-apps reads Hyprland's
initialClass for exactly this reason.
A committed string is not a keystroke. To most text fields the two are
indistinguishable, which is why committing a tap looked fine for as long as it
did. Google Sheets is where they part: a selected cell has no visible text
field, starts editing on a real keydown, and treats a commit that arrives
without one as nothing — the letter vanished and the cell stopped responding
until another was selected. Taps now go back through forwardKey, which on
Wayland travels exactly the path of a key this addon never touched: fcitx5
sends every key it does not filter through the same virtual keyboard.
A forwarded key on Wayland takes the modifiers held now. fcitx5's
wayland_v2 frontend replays a forwarded key through its virtual keyboard,
whose modifier state follows the physical keys; the modifiers inside the
forwarded key are ignored. The D-Bus and XIM frontends use them. So a capital
typed the ordinary way, Shift let go a moment before the letter, would be
forwarded as lowercase. When the case has changed between press and release,
the tap is committed as text instead — the old behaviour, correct letter, and
the Sheets bug back for that one keystroke only.
Forwarding a press on Wayland releases it too. The wayland_v2 frontend
follows every forwarded press with a release of its own; D-Bus and XIM forward
exactly the one event they are given. So only the press is forwarded and the
real release is let through behind it, which is right on D-Bus and XIM and
leaves Wayland clients with one release too many — a release for a key they
never saw go down, which fcitx5 already sends after every key whenever
PreferKeyEvent is off.
The keysym is not stable across a press. Let go of Shift before the letter
and a key that went down as A comes back up as a. Matching the release by
keysym missed it, and the popup opened on a key nobody was holding any more.
The held key is matched by keycode.
On Wayland one input context serves every field. The wayland_v2 frontend
keeps a single input context per seat, and a focus change is a deactivate and
an activate on that same context. So whatever this addon was holding followed
the focus: a popup still open when the field lost focus handed its letter to
the first key typed in the next one. A focus change now cancels whatever is
held.
Chromium drops delete_surrounding_text on Wayland. Measured through the
DevTools protocol: the call arrives and nothing happens, no error. Any design
that commits a letter and then removes it produces aá in Brave. This one
never commits early, so it never finds out.
A terminal is useless as a diagnostic instrument. A pty does not process
deletion, so cat > file records what was emitted and never shows a retraction.
wtype types through the virtual-keyboard protocol and can hold a key with
-P key -s 700 -p key, which is enough to drive the timer — but only a real
text field that logs every buffer change shows what actually happened.
Build dependencies are deliberately just base-devel. A single shared
object does not need cmake, ninja and extra-cmake-modules; a plain Makefile
with pkg-config keeps three packages out of the way of anyone installing
this.
omarchy plugin disable io.github.heroesofcode.omacento
make -C ~/.config/omarchy/plugins/io.github.heroesofcode.omacento/addon uninstall
rm -f ~/.config/fcitx5/conf/omacento.conf
rm -f ~/.config/omarchy/hooks/theme-set.d/50-omacento
rm -f ~/.config/omarchy/hooks/font-set.d/50-omacento
rm -rf ~/.local/share/fcitx5/themes/omacento
rm -rf ~/.local/state/omacento
rm -f ~/.config/systemd/user/omarchy-fcitx5.service.d/10-omacento-addon.conf
systemctl --user daemon-reload
systemctl --user restart omarchy-fcitx5~/.config/fcitx5/conf/classicui.conf is left alone: fcitx5 owns that file and
Omacento only edits the theme, font and orientation lines in it.

