Normal mode mappings are owned by Sources/flash/App/NormalMode.swift and the
default mapping list in Sources/flash/Config/Config.swift.
Defaults map keys to high-level actions, never to raw key chords: each action resolves in the focused app’s context (see source actions), and an app without the action gets nothing. Other tab, pane, archive, find-match, document-URL and mark commands remain available for explicit mappings.
h / l scroll left/right. ctrl+e / ctrl+y send a mouse-wheel scroll
down/up by 3 lines; ctrl+d / ctrl+u send 20 lines down/up. Configure these
amounts with [mode] scroll_step_lines and scroll_page_lines, and set
scroll_smooth_ms to spread each scroll over that many milliseconds.gg / G go to the top/bottom.u undoes and ctrl+r redoes. Bare d, j and k are unbound.y copies immediately and p pastes.
Prefix either key with " and a register name to use a named buffer, for
example "ay and "ap. Names a–z and 0–9 hold text in the running
Flash process; uppercase names append when yanking. A bare yank also saves
its text in "0, while a named yank leaves "0 alone. Yanks update the
unnamed register, which Flash shares with the system clipboard; "+ and
"* select that clipboard explicitly. "_y discards text without changing
any register. The prefix also works for HOLD copy and paste. The command
equivalents accept --register=<name>./ opens Find (app_find, Cmd-F) and enters INSERT, so typing goes to the
find field.x closes the current tab (tab_close); X reopens the last closed one
(tab_reopen). Inside tmux x first asks Close this tmux window? in the
alert, with [y]es / [n]o and the keys in the hint colour: the tmux
plugin declares the kill destructive (action_confirmations), so y
closes the window and any other key — n, Escape, a click — closes only
the question. Wrap any command the same way with
confirm --prompt=… --command=…. X acts in browsers and editors; tmux,
terminals and Finder keep no closed tabs, so there it does nothing.r reloads (app_reload); R hard-reloads (app_reload --force).
Browsers use their own chords (Safari’s hard reload is Cmd-Option-R) and
tmux refreshes its client. Other apps, terminals included, do nothing:
Cmd-R replies in Mail and runs in Xcode.[a / ]a cycle previous/next app in MRU order. Quitting the focused
app (:qa, :wq, app_quit, or the app’s own quit) focuses that
previous app, from command mode and from every other mode.[t / ]t switch to the previous/next tab (tab_previous / tab_next);
repeat the final t to keep switching. Inside tmux they switch tmux windows;
elsewhere the app’s own chord (Control-(Shift-)Tab in Messages), else
Cmd-Shift-[ / Cmd-Shift-], which WhatsApp binds to its previous/next chat.t opens a tab (tab_new): a tmux window inside tmux, Cmd-N in editors
whose Cmd-T searches symbols, nothing in Notes, TextEdit, Pages and Mail,
whose Cmd-T opens the Fonts panel, and Cmd-T elsewhere, terminals included.
Once a tab opened, Flash enters INSERT so typing goes to its address bar or
shell; where nothing opened, NORMAL stays.g1–g9 select a tab by position (tab_select): a tmux window by ordinal,
a browser tab through the browsers plugin, a native tab strip through
Accessibility, else Cmd-1…Cmd-9. These actions preserve NORMAL.g0 / g^ go to the first tab and g$ to the last. tmux and plugin
sources select their own first and last windows; otherwise the first tab is
Cmd-1, and the last is the chord an app’s plugin declares (Cmd-9 in
browsers).[m / ]m move the current tab left/right; repeat m to continue moving it.
Firefox and Chromium browsers use their native Control-Shift-Page Up/Down
(checked in Chrome; a Chromium browser that drops the chord does nothing).
Safari, which has no shortcut, moves
the neighbouring tab across the current one by AppleScript; tmux reorders
its window.ctrl+o / ctrl+i traverse Flash’s movement history.H / L go back/forward in the focused app’s own history, as in Vimium:
Cmd-[ / Cmd-] in browsers, Finder and most apps. Editors where Cmd-[
outdents use their own chord (Control-Minus in VS Code, Cursor, Zed and
Sublime Text; Control-Command-Arrow in Xcode). Terminals and apps without
history (Notes, TextEdit, Pages, Mail) do nothing.m (move),
d (double) and c (context, the right-click). Finishers are f (a
discovered target), F (a screen position through the grid) and s (one
visible letter). c is the operator Vim and Vimium leave free; r reloads
and s is the search finisher. The pointer hides while labels are up, even
with another app in front, and returns when it moves; the labels going away
leave it to that move. Pointer mode and a cursor-following grid keep it
visible. So df double-clicks an
element, dF double-clicks a grid position, and ds double-clicks a letter.
cf, cF and cs are the right-click of each.
A system dialog in front of the focused app, such as the Gatekeeper prompt,
is the surface f and s walk even when its process does not become
frontmost. A window’s close button is a hint when the window has one, and
so are a dialog’s default, cancel, and help buttons.s, then one letter, then a two-character hint jumps to that
letter in the focused window’s visible text (EasyMotion). The same prefixes
apply: cs right-clicks, ds double-clicks, ms moves the pointer. s
starts no other mapping, so the jump fires at once.
Nothing is drawn for the search letter itself: the hints are the only
surface, sitting on each match.
The search character matches either case, so v and V find the same
glyphs, and the other character of that US key, so 3 and # do too,
everywhere in the focused window and on the status bar of that window’s
screen (the surfaces f reaches). A letter on a status-bar link or popup
does what f does there; a letter on plain bar text moves the pointer onto
it. The click and the move land one point left of the glyph’s centre, still
on the letter, so a later selection can start on that glyph’s leading side;
in HOLD they land one point right of it, so the selection a held button
draws ends on that letter.
Hint labels are uppercase and accept either case. Backspace
edits the letter, Escape cancels, and no match stays silent. Holding the
hyper key, or any other key that enters a mode, ends the jump and enters
that mode instead of typing into it. mouse_bigram --move
moves the pointer without clicking. The text comes from Accessibility.
Each visible line is measured once, so a common letter costs the same as a
rare one. Each match then asks Accessibility where its glyph is drawn, so a
label sits on its letter even when the text is narrower than its element;
an app that does not answer keeps an even spread of the characters across
the line. Characters truncated behind an ellipsis get no label. The click
re-reads the glyph’s bounds.
A terminal whose tree has no text uses the tmux grid of the client attached
to that window: pane text, the tmux status line, and each pane-border title,
measured on the same window f and cf use. Those two label the same
targets, including pane titles and the status bar. On the status bar,
cf right-clicks, which pins the popup. s labels the letters
on that grid. Flash never reads screen pixels.sequence_timeout_ms before it fires. Triple click therefore ships
unbound, because tf would stall the bare t (new tab). To bind tf /
tF, remove the default t with "t" = false in the same table; tf
then fires on its second key with no timeout. A pending sequence is dropped
when the focused app or the effective mappings change, and its timeout
never fires a mapping from the previous table.f, F or s is a finger count, as on a trackpad. 2f
secondary-clicks the pick, 2df smart-zooms there and 3f looks up the
word there (mouse_target --gesture=look_up|smart_zoom). The gestures move
the pointer like mf and keep NORMAL. flash gesture performs the other
trackpad gestures; see commands.terminal_emulators) is an input target, so a primary click on a hint
there — tmux pane and window hints included — enters INSERT. A terminal
link hint is navigation and stays in NORMAL. A hint click moves the pointer to the target and leaves it there;
m (move) moves it without clicking. With [hints] restore_pointer = true,
every committed click, drag or selection — hint or grid, and mouse_repeat
— puts the pointer back where it was; m and mouse_pointer still move it.
NORMAL’s vertical scroll keys act at the pointer, so with the option on they
keep scrolling where the pointer was, not in the area you just clicked; use
mf or scroll_target to move it there.f opens the link through the terminal.
The hover and click carry the same modifiers.hints.magic_modifiers,
and Shift always): f then Shift-<hint> is a Shift-click, on targets and on
the grid alike. Link text repeated in a terminal pane resolves to the copy
under the hint.Tab restarts the same click on the mouse grid and
Shift-Tab widens it to every app on the screen, or narrows it back.
Backspace with nothing typed returns to the surface the session came from.
A chord on Tab cancels, like any other unknown chord.mouse_target --path picks two labels and clicks every target between them
in reading order, from the first pick to the second; the first pick is
marked until the second lands. --sticky keeps the same labels up after each
click, with no new walk, until Escape or Return; --multi walks again.
--words labels the visible words of the window’s text instead of its
controls, landing on each word’s leading edge; with --select it highlights
the text between two words, with --drag it drags one. Nothing of this
reads the screen: the words come from the text Accessibility exposes, the
same walk s uses.[hints] label_order decides which target gets the easiest label: the first
in reading order, the one nearest the centre of the window, or, with
zones, the first key of every label names the keyboard-shaped zone of the
window the target sits in. [hints] key_priority lists the keys handed out
first. See configuration.mouse_screens labels every display, mouse_warp jumps the pointer to an
edge or the centre of the focused window or its screen, and mouse_position
saves, labels, cycles and forgets pointer positions. None of them ships
bound; see commands.cF, dF and mF use the mouse grid for a right-click, a
double click and a move.? opens the browser mapping reference for the focused app. In the command
line, :mappings does the same. The command line has no default entry
shortcut; run flash enter_command_mode or bind one in your configuration.
The reference shows effective plugin-merged and configured mappings,
including expanded leader bindings and argv mappings. Each row names where
the binding comes from:
a built-in default, a config file, or a plugin.:help opens the browser help homepage; :help <topic> opens a feature guide.mouse_grid (F) splits the screen like the left half of your keyboard: 4
rows × 5 columns, each cell labelled with the key at the same position in the
hints.keys layout’s number, top, home and bottom rows. On QWERTY:
+---+---+---+---+---+
| 1 | 2 | 3 | 4 | 5 |
+---+---+---+---+---+
| q | w | e | r | t |
+---+---+---+---+---+
| a | s | d | f | g |
+---+---+---+---+---+
| z | x | c | v | b |
+---+---+---+---+---+
Each cell also shows, faintly, the grid of the step that follows it, every
sub-cell labelled with the two keys that reach it (qw is q, then w), so
the next key can be read before the first is pressed; the step’s own labels are
drawn on top and stay the first thing to find. The clicking step and cells too
small for a two-letter label show no preview. hints.mouse_grid_preview =
false turns it off. The clicking step’s labels are see-through, so the exact
point each one clicks shows under it.
Colemak uses 12345 / qwfpg / arstd / zxcvb and Dvorak 12345 /
',.py / aoeui / ;qjkx; a literal hints.keys gets the QWERTY block, and
hints.mouse_grid_keys sets any other matrix. Press the key where you want to
go: the grid zooms into that cell and tiles it with the same keys, so every
step uses the same muscle memory. The last of hints.mouse_grid_steps (default
3) clicks, as does any step whose cells reach 18 points. When the clicking
step’s cells are smaller than a label, they are drawn as one glued cluster
centred on (and covering) the chosen cell; each click lands on its label.
The grid starts on the display of the focused window and covers Flash’s status bar, or on the pointer’s display when no window is focused. It still stops at the Dock, and at the menu bar when the Flash bar is hidden.
| Key | Action |
|---|---|
| a grid key | Zoom into that cell; on the last step, click its centre |
space |
Zoom into the centre; on the last step, click the centre |
return |
Click the centre of the current region now |
backspace |
Undo the last grid key (zoom, move, display switch); with nothing left, back to the labels the grid was opened from |
cmd-backspace / alt-backspace |
Start over on the whole display |
| arrows | Slide the region by its own size, stopping at the display edge |
shift-arrows |
Put the pointer on that edge of the region, so a drag or a held button can start on a grid line |
tab / shift-tab |
Move to the next / previous display |
` |
Toggle cursor-follow (hints.mouse_grid_cursor_follow) |
escape |
Cancel; a pointer moved by cursor-follow goes back |
| any other key | Cancel |
Modifiers held on the key that clicks ride the click (hints.magic_modifiers,
and Shift always), so Shift-1 Shift-clicks the 1 cell. Command, Control or
Option chords outside the magic modifiers cancel. Every click flag works with
the grid: --drag and --select pick a first point, then restart on the
whole display for the second; Backspace from there returns to the first
point’s step. --multi restarts on the same display after each click.
mouse_grid --bisect halves the region instead: h / j / k / l keep
the left, bottom, top or right half, and y / u / b / n (drawn as
quadrants) keep the top-left, top-right, bottom-left or bottom-right quarter.
It ignores the step count and clicks once the kept region is at most 18 points
on both sides, or on return; the other grid keys work as above.
mouse_grid --zoom-to-depth=N starts N steps deep on the cell under the
pointer, on the pointer’s display, stopping while one selection remains;
Backspace walks back out.
HOLD drags something with a mouse button held down. enter_hold_mode grabs
what is under the pointer with the left button (--secondary or --middle
for another). ⌘⌃H toggles HOLD from NORMAL or INSERT. Then:
h j k l nudge it 16 points, H J K L 96 points.w / b jump to the next or previous word; e reaches a word’s end;
0 / $ reach a line’s start or end. With text under the pointer, each
extends a selection while the button stays held.f, F and s pick where it goes: a target, a grid cell or a letter.
s lands just past the letter, so a text selection takes it.ctrl+e / ctrl+y and ctrl+d / ctrl+u scroll the page; a text selection
follows the text that moves under the pointer. gg / G reach the first /
last line.y releases the button, copies selected text and clears the highlight after
the copy succeeds. d / x cut, c cuts and enters INSERT, and p pastes
over the selected text. These operators end HOLD.The badge is yellow and shows mode.labels.hold (default HOLD). The keys
live in [mode.hold.mappings], read before NORMAL’s. An
all-mode chord enters HOLD from INSERT as well, and the drop returns to
NORMAL.
[mode.all.mappings]
"cmd+ctrl+h" = ["flash", "enter_hold_mode"]
HOLD is a mouse button Flash holds down (mouse_button;
enter_hold_mode is its toggle). Every pointer move Flash makes while a
button is held is a drag of it. The press, drag and release do not inherit modifiers
from the shortcut that started the hold, so a Control shortcut does not turn
the press into a context click. The mode follows the hold exactly: whichever
path presses the button from NORMAL or INSERT enters HOLD,
whichever releases it leaves, and leaving the mode any other way
(enter_normal_mode, INSERT, the command line, leave_mode, quitting
Flash, a committed click) releases the button. mouse_pointer’s v holds a
drag the same way. A half-typed NORMAL sequence is dropped when the button
goes down or up, since the tables differ. After a scroll Flash re-posts the
held button’s drag where the pointer is, once after the scroll and once after
the app’s own smooth scroll settles, so the app sees the pointer over what
scrolled under it.
ctrl+o and ctrl+i move backward and forward across apps and source locations,
including browser tabs and tmux windows. App focus and location-catalog changes
feed the same history. Repeated observations of the same location coalesce;
returning to a location after visiting another remains a chronological stop.
Choosing a new destination after moving backward discards the forward branch.
Locations restore through their owning source. A browser tab restores through
its flash-browser://tab?pid=…&url=… route: the browser process and the tab’s
URL, or its title when the tab exposes no URL, so a title change keeps the
destination and a restarted browser’s stops fail rather than land elsewhere.
Chromium browsers and Safari select the first tab with that URL by
AppleScript; Firefox re-walks its tab strip and presses the match through
Accessibility.
Tmux keeps stable window IDs so reordering a window does not change its history
destination. Apps without a more precise source restore application focus.
The stack does not capture page scroll positions, editor cursor positions, or
terminal scrollback offsets. History is in memory and bounded to 20 stops in
each direction.
Every verb that asks the focused app to do something is a high-level source
action: tabs, panes, reload, archive, back/forward, gg / G, and the app’s
generic commands — app_undo, app_redo, app_find, app_save,
app_print, document_open, window_new, window_close,
clipboard_copy, clipboard_cut and clipboard_paste, which back u,
ctrl+r, /, :w, :print, :e, :new, :q, :copy, :cut, :paste,
the yank fallback when no Accessibility selection is exposed, and the paste
that delivers a flashlight answer. No default or bundled mapping sends a raw
chord instead (send_key remains an explicit escape hatch). Each action runs
one policy that knows no app and no chord:
tab_select;window_close, the focused window’s close button (Flash’s own step);action_bindings, see plugin protocol): a chord, a
chord sequence, a menu-bar item pressed through Accessibility, or false
for an app without the action;resource_next / resource_previous
(wheel lines) and scroll_top / scroll_bottom (the focused-window
scroller);The macOS conventions — Cmd-Shift-[ / Cmd-Shift-] switch tabs, Cmd-T opens
one, Cmd-W closes it, Cmd-1…Cmd-9 select one, Cmd-[ / Cmd-] go back/forward,
Cmd-Z / Cmd-Shift-Z, Cmd-F, Cmd-S, Cmd-P, Cmd-O, Cmd-N, Cmd-C / Cmd-X /
Cmd-V — are "" bindings of the bundled defaults plugin, which also
carries the apps that differ (editors, Messages, Xcode, Notes and friends).
Browsers, VS Code and terminals override them in their own plugins. With
[plugins] disabled = ["defaults"] those actions do nothing. Reload, reopen,
the last tab, tab moves, panes and archiving have no shared binding: their
chords mean different things across apps, so they act only where a source or
an app-specific binding knows the app. The inspector’s Mappings page lists how
each action resolves in the focused app.
A source that claims an action and fails reports .failed, and no binding
follows it; a menu item that is missing or disabled fails the same way. App
knowledge lives in manifest data (the browsers, defaults, terminals and
vscode plugins), never in host conditionals. A terminal treats a Command
chord a plugin binds for that emulator as bound, like the chords every
emulator binds; any other Command chord is refused (see below), while a menu
press is always allowed.
leave_mode, enter_insert_mode, enter_command_mode, enter_terminal_mode,
and focus_input ship without default mappings in every scope except
TERMINAL, whose Command-W is leave_mode. Bare a, A, i, I, o, O,
and gi do not enter INSERT. Users explicitly choose their shortcuts,
including bindings that prefill the command line with :flashlight.
NORMAL is hermetic: every unmapped key and modifier chord is swallowed, and
only explicit mappings act. Map a key to the high-level action it stands for;
for a chord no action covers, send_key is the explicit escape hatch, or the
user enters INSERT first. The release of a swallowed key is swallowed with it,
because a terminal running the Kitty keyboard protocol encodes key releases to
its pty. A held key’s repeats follow its press: a key whose press entered
INSERT repeats nothing into the app, and a key the app received keeps
repeating there.
A synthesized chord reaches an iOS app (Mac Catalyst or iPad) with its modifiers pressed and released around the key, as a keyboard sends it: UIKit matches key commands against the modifier state it tracks from those presses and ignores a lone key event carrying modifier flags.
Hermeticity also bounds what NORMAL synthesizes. A terminal emulator does not
ignore a Command chord it has no binding for: its encoder falls through to the
plain-text path and writes the chord’s base character, so an unbound cmd+g
types a literal g into the shell, hardware or synthetic. In a terminal
Flash therefore refuses to synthesize any Command chord outside the set every
emulator binds (copy, paste, close, new tab, new window, quit, find, the tab
digits, and Shift-bracket tab traversal); a chord a plugin binds for that
emulator specifically, such as the bare brackets of the emulators whose splits
live on them, is added. A plugin-wide binding for every app never is, so
app_save does nothing in a terminal rather than type an s. A refused mapping does
nothing and NORMAL stays.
The four vertical scroll bindings synthesize line-based mouse-wheel events
in every app, including terminals, at the current pointer position. They use
no app-specific scrolling action or Accessibility scroll fallback. The receiving
app handles the event just as it handles a physical wheel. With
[mode] scroll_smooth_ms set, one keypress becomes several smaller line
events spread over that duration (at most 300 ms): the first moves at once,
the rest follow at least a frame apart on the click queue, and they add up to
the same lines, so terminals scroll exactly as far. A new scroll, including
gg / G and h / l, drops what the previous one has left. [mode] scroll_step
continues to set horizontal movement in pixels. gg and G retain their
source-aware edge behavior. Inside tmux they follow tmux’s own wheel routing:
a pane in a mode scrolls that mode (history-top, cancel); a program that
tracks the mouse, such as a full-screen CLI, owns its scrolling and receives
1,000 wheel reports; any other pane enters copy-mode at the top of its history
and is already at its live bottom. Another terminal scrolls 1,000 lines as a
line wheel, like the four vertical bindings.
NORMAL is persistent by default; enter_normal_mode takes no persistence
option. Opening Find (/) and a new tab (t) are the two actions that ask to
type next: once the action reached the app (a source performed it or its chord
was sent) Flash enters INSERT, once however large the count. A claiming source
that failed, an app without the action, a missing app or a chord a terminal
would refuse leave NORMAL in place. Switching tabs and focus_input preserve
NORMAL even when the app focuses an editable field. Accessibility focus
notifications never change the mode. A primary f hint click enters INSERT
only when its selected target is an input; other hint targets keep NORMAL.
F follows the same rule: the grid point is hit-tested before the click, and
only a primary, double or triple click on a text input enters INSERT. Physical
app clicks and an explicit enter_insert_mode mapping also hand typing to the
app. A text input is a text-field, text-area, combo-box or search-field role,
or any element with the search-field subrole. In an iOS app (Mac Catalyst or
iPad, such as Messages and WhatsApp) it must also accept keyboard focus: UIKit
reports read-only text such as a message bubble with a text-area role.
The status pill resolves its mode label and palette from the current mode
together. Background status evaluations preserve the live #{flash.mode}
reference, so plugin updates and queued renders cannot repaint an old label
with the new mode’s colors. Mode labels update without a fade.
Bind ["flash", "leave_mode"] in [mode.all.mappings] to enable advanced
mode with one exit shortcut. It returns INSERT to
NORMAL, closes command-line, finder, and terminal surfaces using their recorded
return mode, and does nothing in idle NORMAL or with advanced mode disabled.
enter_normal_mode remains the explicit action for selecting NORMAL even when
a command panel was opened with --restore-mode.
Advanced-mode eligibility follows the base mode through command, finder, and
terminal surfaces. Opening a surface while disabled cannot enable NORMAL.
Changing the enabling binding while a surface is open updates its return mode:
enabling returns to NORMAL, and disabling returns to the disabled base mode.
A context menu Flash saw open keeps the keyboard until it closes: a recapture
a system event asks for — an app launching, a Space change, the panel resigning
key — never ends it, while its close notification, an explicit mode entry or
another app coming forward does. A surface that posts no menu notifications
keeps the bounded window that stood in for a close signal.
While a context menu is open its app’s NORMAL mappings still fire, so f
labels the menu’s items rather than the window behind it: the walk starts at
the menus the app reported opening (submenus included; a browser’s context
menu is reachable no other way) and keeps targets inside their frames. A
submenu closing leaves the menu it hangs from in charge of the keyboard. The hint session takes the keyboard back from the menu while it runs,
so labels and Escape reach Flash, and gives it back when it ends. This needs
the session tap: Flash’s key-window fallback would close the menu by becoming
key, so under secure input f walks the window as before.
A label or configuration refresh preserves native-menu capture suspension; only an explicit
mode entry resets that interaction context. Reentrant events wait until the
current mode effect batch finishes, preserving transition order.
Modified bindings in [mode.normal.mappings], [mode.insert.mappings], and
[mode.command.mappings] override the same physical chord from the all-mode
map. The command map is empty by default, so the shared exit works without
duplicating it per mode.
Command surfaces try the configured mapping matcher before native editing or
finder navigation. Their local performKeyEquivalent path uses the same
precedence as Carbon. The global tap still passes command typing.
The all-mode and scoped Carbon registries share an event dispatcher. Their hotkey identities must be distinct, and a handler must decline events owned by the other registry. Reused identities or consuming unowned events can break command shortcuts while NORMAL/INSERT still work through the keyboard tap.
NORMAL and hint input normally arrives through KeyboardCaptureTap, so the
overlay can remain non-key and the focused application keeps its active window
appearance. Command-line and modal surfaces still use the panel’s key-window
path, as does the fallback when macOS refuses the Accessibility-backed tap.
Startup resolves tap availability before entering NORMAL or a capturing surface.
Starting the tap after NORMAL renders would activate Flash through the fallback
path and leave the previous app inactive until another app switch.
Each hint or grid session fixes its capture path when it starts
(KeyboardCaptureTap.sessionCapture). Under secure input (a focused password
field) macOS hides keys from every tap, so that session takes the key window
instead and typed labels reach Flash, never the password field. A commit hands
focus back by raising its target; a cancel yields activation to the covered
app. #{flash.secure_input} and flash status show the state. The tap
refreshes it when a key still reaches Flash. While the flag is on the tap
receives nothing, so core:secure-input polls it (low priority, every 2 s)
for the life of the resident; there is no banner. A focused secure field, and
a text field in the app that holds the flag, are left alone. Anything else is
a stuck hold, and Flash asks that app to release it once: a Chromium browser
blurs its focused element and, if the flag stays on, is sent to its address
bar; Firefox and Safari are sent to the address bar; any other holding app is
activated and the app you were in is restored. The first browser blur can ask
for Automation. Flash does not lock the screen and does not show a dialog.
One attempt per time the flag turns on. A password field that is focused when
the attempt would start is left alone; one that appears after the attempt has
started does not abort a release already in progress.
Interpreters read keys through [app] keyboard_layout’s reference table
(KeyCharacters.read, one lookup per key): labels, the grid, pointer and
adjustment keys and NORMAL mappings match by key position under a non-Latin
input source, while --search keeps the typed text. Which characters those
positions spell comes from [keyboard] layout, not from this input-source
table. The table is rebuilt on input-source changes and config loads, never
per key, and the swallow decision never reads it.
Handing activation back needs the cooperative API: NSApp.yieldActivation(to:)
then app.activate(from: .current). NSApp.deactivate() and a bare
activate(options:) are ignored on current macOS, and they leave Flash active
with no key window: the next command-line open cannot make the panel key, so
AppKit draws no caret. Closing the command line without running anything hands
activation back this way; a submit that opens an app hands it over through that
app’s own activation. The command line’s caret is AppKit’s insertion point, and
it is live only while the panel is key with the field editor as first responder.
An earlier INSERT handoff may finish after a rapid command open and take key
status back; the panel’s key-resign event schedules a guarded recapture while
the command field is still visible and no other Flash window owns focus.
The tap source, Carbon callbacks, AX observer sources, and mode coordinator all share the main run loop. Treat that loop as the input latency budget:
CGWindowListCopyWindowInfo, subprocesses, filesystem I/O,
sleeps, and full overlay layout belong off this path. The INSERT branch tests
raw flags and the O(1) mapping table before anything else, and the frontmost
reconcile
does no work when the event’s target pid already matches the observed
frontmost app (reconcileFrontmostApplication(forKeyTargetingPID:)).HyperKeyMatcher: the
atom’s physical chord and the characters that can spell it). A key that
cannot be it is settled from its key code or typed character before any
key atom is built, and the tap drops every other swallowed release without
an NSEvent or a main-thread hop. The HYPER hold watch reads that prepared
chord; the leader atom is resolved once with it. Entering or leaving HYPER
paints the border’s cached frame instead of reading the window list.CGEventSources (SyntheticEventSources); creating one per event
was a WindowServer round trip.recaptureNormalModeKeyboardInput(). With a live
tap this restores .normal routing and stops; only the no-tap fallback needs
key-window retries. Recapture must not rebuild the status bar or active-window
border.resetCommandLineState().normalModeDispatchContext(), which avoids an
exact AX or WindowServer geometry lookup for an identity-only action.MappingsCoordinator.apply(scope:). All-scope
Carbon registrations stay installed and resolve the current mode’s winning
action at dispatch; mode-specific registrations are reconciled by chord, so
only the chords that differ between the two scopes are unregistered or
registered. Each ModeMapping parses its native chord once at construction.
Reconcile the registry only when the effective mappings change.CGWindowListCopyWindowInfo synchronizes with the
process’s pending Core Animation transaction while holding the WindowServer
connection lock, so from another queue it deadlocks against a main-thread
commit that carries WindowServer actions until SkyLight’s 500 ms timeout,
freezing the main thread with it. Every window-list read therefore goes
through WindowSnapshot.windowList, which runs it on main (background
callers hop with DispatchQueue.main.sync, so main must never wait
synchronously on a queue that reads the window list); a guardrail rejects
any other caller. AX work, LaunchServices lookups, catalog gathering and
plugin event encoding stay off main: tab_select, y and :q press or
read through AX on background queues, minimized-window restores follow an
activation instead of preceding it, app-name resolution and the running-app
refresh run on their own queues, and plugin events are encoded on the plugin
manager’s event queue only when a plugin listens. Scroll verbs resolve
the wheel target frame on the AX queue and do not re-render the mode surface
afterwards. configureModeBadge is memoized on its inputs
(ModeBadgeLayoutStamp), so re-applying an unchanged mode surface skips the
per-screen relayout.AXObserverThread, not the main loop. A burst of
notifications (a browser render storm) costs main one drain
(AppMonitor.drainAXEvents) that applies every event’s dirty-token bump in
order; nothing about the prepared-model contract changes.scheduleConfigReload, 150 ms), a reload whose file bytes are unchanged is
skipped, and AutoLaunch.reconcile runs only when app.autostart changes.MainRunLoopStallObserver records a main_busy warning, at any log level,
for every main run-loop busy stretch over 250 ms. It needs no timer: the run
loop reports each wake and each return to sleep. The warning carries
last_activity_ms_ago: the most recent coarse main-thread units of work
(tap_key, mode_effects, mode_overlay, effective_mappings,
activation, hint_commit, config_reload) with their age, so a stall names
what main was doing. Call MainThreadActivity.note at the top of any new
coarse main-thread unit of work. A
timeout-disabled event tap also logs before being re-enabled; either message is
evidence of main-thread work that needs moving or narrowing.
Two debug-level probes measure the path itself: [latency] tap_to_route is the
delay from the HID timestamp to the main-thread turn that routes a swallowed
key, and [latency] normal_dispatch is the synchronous cost of one normal-mode
action. At info, [latency] hints_visible times each hint activation from
its trigger to the Core Animation commit that shows it; see
performance. Scripts/measure-footprint.sh samples the resident and its children
(CPU, idle wakeups, memory, descriptors) and summarises stalls and log volume
for a before/after comparison.
ActivationLifecycle distinguishes discovery, a pending commit, and an active
gesture. A newer hint request replaces discovery or cancels a commit before its
input starts. Once a gesture starts, it finishes its mouse release before the
latest queued activation runs. Cancellation suppresses the old gesture’s mode
and UI outcome; an old completion cannot clear a newer operation’s state. Dock
and scroll-area discovery use the same ownership tokens as ordinary hints.
HintSession owns the selected action and target data. ActionDispatcher
owns the one button mouse_button or mouse_pointer’s v holds: a pure
MouseButtonHold decides which press and release events each transition
posts, so a press is released exactly once. While it is held, every pointer
move Flash makes posts that button’s dragged event; a session reset or
replacement keeps it, so mouse_button --state=down then mf drags to a
hint. Cancelling a Flash overlay, leave_mode, application shutdown and a
committed click, drag or selection release it. Shutdown also waits for finite
synthetic gestures to post their release events. Click repetition
records a pending click only when its generation is still current and input
actually starts.
CandidateFinderSession owns warm snapshots, query answers, live results, and
their scoring caches. CandidateFinderCoordinator manages the prompt and
publication lifecycle. Live source results stay separate from the warm catalog:
each exact source/query has a token checked before background preparation and
again at publication. Changing or leaving that query clears its rows and rejects
both stale replies and timeout results. Initial snapshot publication never
overwrites the current live-query result.
Holding mode.normal.hyper enters HYPER until that key is released. The
default is <leader>, which follows mode.normal.leader. When the key is
<leader> and the leader is not set (leader = false), HYPER stays off.
Any other single key works in its place (a, <space>, cmd+space). The
status pill is purple and shows mode.labels.hyper (default HYPER). Keys
pressed while it is held are read from [mode.hyper.mappings], not from
NORMAL. The defaults are the Apple function row on 1–9, 0, -, and
=: brightness, Mission Control, Spotlight, Dictation, Focus, media, mute,
and volume. Shift plus the same key sends F1–F12. Arrows sit on the
physical keys where QWERTY has e s d f (up, left, down, right),
spelled as the letters those keys type in [keyboard] layout: QWERTY
e s d f, Colemak f r s t, Dvorak . o e u, Bépo
p u i e. F is the home-row index-finger mark, and it is right.
Shift posts a shifted arrow on that same letter, so Colemak down is
shift+s. A punctuation key uses the character Shift types there, so
Dvorak up is >. A mapping or false on one of those keys wins.
The arrow is posted into the focused app’s key window, downstream of the
session tap. postToPid carries no window, so a terminal drops it while
the hyper key is still down. Volume is a system event and does not take
this path. F-keys use the same window posting.
Pointer movement and bisect stay on the hjkl columns.
While the hyper key is the leader, a key with no hyper mapping can still
complete a NORMAL <leader> sequence when that key continues one. Unmapped
keys are swallowed.
A hold enters HYPER right after a repeatable mapping too: ]t arms a repeat
of t, which any other key drops, the hyper key included. Only a mapping
whose final key is the hyper key keeps that press as its repeat. Entering
HYPER drops whatever NORMAL had pending, with its timeout.
Releasing the key returns to NORMAL. The session tap can drop that key-up
after it swallowed the press and a synthetic event (a volume key, a posted
chord) lands in between; HYPER would otherwise stay up until the key is
pressed again. While HYPER is latched, core:hyper-hold reads the key from
the HID state table (CGEventSource.keyState(.hidSystemState), and every
modifier in a chord such as cmd+space) and leaves once the key has been up
for two ticks. Combined session state is the wrong table: a swallowed press
can look up there. The next key sees the same reading, leaves first, and is
routed as a NORMAL key. A delivered key-up still leaves immediately. The
watch is registered only while the mode is HYPER. Leaving HYPER any other
way — leave_mode, opening the command line, a focus change — ends the hold
and drops its pending keys the same way: the mode transition is the one
reset point, so no exit can leave a latched hold behind.
If nothing else was pressed and the hyper key is the leader, that tap arms
the <leader> prefix: press it,
release it, then the next key, and the sequence runs as before. Holding it
again enters HYPER while that prefix is armed. A mapped <leader><leader>
sequence still takes the second press. Any other pending sequence stays in
NORMAL. INSERT, the command line, a popup, and an open hint session keep
their own keys. See configuration.
Every popup is a terminal. TERMINAL sits beside NORMAL, INSERT, COMMAND, and
the momentary HYPER layer: enter_terminal_mode [--name=<popup>] enters it by showing the
popup standalone (the built-in terminal popup without a name), and clicking a
popup’s body or pinning it from the status bar enters it too. Either way the
popup’s local terminal view gets focus. leave_mode leaves it, closing the
popup. Entering the popup already focused is a no-op; entering another replaces
it. The overlay owns no keyboard input in this mode: the
existing global tap passes keys through, every Carbon registration is suspended,
and only [mode.terminal.mappings] can intercept keys in the popup. The label is
configured with mode.labels.terminal and defaults to TERMINAL.
Terminal mappings inherit only the effective INSERT-active bindings whose
winning action is enter_normal_mode or leave_mode. Scope and plugin precedence are resolved
before this inheritance; an explicit terminal mapping overrides an inherited
binding with the same canonical key. Other all, normal, and insert bindings are
inactive. Plugins may contribute terminal mappings using the same priority rules.
The local sequence recognizer accepts the shared key syntax, including modified
chords and explicit sequences, but has no implicit Escape behavior, counts,
register prefixes, or <leader>. Only known sequence prefixes wait for
mode.sequence_timeout_ms. An exact mapping that also starts a longer sequence
waits for that timeout; a mismatch resolves the longest completed mapping and
reprocesses the remaining keys. Unmatched events retain their original modifiers
and are replayed exactly once to the terminal that received them; a key press
replayed to a fresh popup whose process has ended closes the popup instead
(lifecycles). Focus and
configuration changes flush unresolved events without dispatching a pending
command. repeat = true retains the explicit final-key repetition behavior.
Local mappings run before native copy/paste and terminal key encoding. Text
popups use the same focus mode for selection, copying, and scrolling. Leaving via
enter_normal_mode dismisses the popup and activates the captured external app
before NORMAL recapture. Losing popup focus restores the prior base mode without
activating a different app. The app captured on entry is part of the TERMINAL
mode value and is forgotten with it: the next popup captures its own, and no
other exit can activate an app a popup once covered. A popup’s lifecycle, not its focus or visibility,
decides how long its process lives; see popups.
leave_mode provides one configured exit across surfaces. It dismisses a popup,
text or terminal alike, and restores its prior base mode/app (the default
[mode.terminal.mappings] bind it to Command-W), restores the saved mode from command or
finder input, and leaves INSERT for NORMAL. In NORMAL or disabled mode it only
dismisses active hints and is otherwise a no-op. An all-scope binding to either
enter_normal_mode or leave_mode enables advanced mode. For a shifted bracket,
use "cmd+shift+[" = ["flash", "leave_mode"]; the key matcher handles the {
character produced by Shift.
Unknown commands and unsupported subcommands do nothing visible: no toast, only a warning log naming the invocation and pointing to the mapping or configuration. Invalid mapping arrays report their source location during configuration loading. Malformed built-in commands cannot silently become plugin calls. Plugin execution failures still raise the error toast.
The CLI accepts --key=value and boolean --flag arguments, rejects stray
positional subcommands, and returns status 2 when parsing or resident dispatch
rejects an invocation. Successful dispatch does not imply an asynchronous plugin
operation completed successfully; later failures appear in the toast and logs.
An empty command prompt remains quiet.
The default mapping set has no i, I, a, A, o, or O insert aliases.
INSERT is entered by:
enter_insert_mode mapping;f and F): a text input, or anything in a declared terminal emulator.
A terminal link hint is navigation, so f on one stays in NORMAL;app_find (/) or tab_new (t) once it reached the app.Other normal commands, focus changes and app activation preserve NORMAL.
Moving the pointer, dragging, or selecting with the grid does not request INSERT.
A multi-click session ends when its click enters INSERT. Otherwise
mouse_target --multi discovers the app’s targets again after each click, so
controls the click revealed get hints and removed ones lose theirs; a target
that is still there keeps its label. The previous labels stay drawn until the
new set replaces them, and an app left with no targets ends the session.
Use leave_mode / enter_normal_mode to return from INSERT to NORMAL.
[mode.all.mappings]
"cmd+ctrl+i" = ["flash", "enter_insert_mode"]
"cmd+ctrl+[" = ["flash", "enter_normal_mode"]
"alt+space" = ["flash", "enter_terminal_mode"]
Closing a popup or command surface may restore its saved INSERT mode.
INSERT’s keys belong to the focused app, so Flash never stays the active app in INSERT. Whenever Flash shows INSERT or becomes active while in it, it hands activation back to the app it tracks as focused. Otherwise every key the tap passes, Command chords included, would reach Flash instead. About, a hint session and a physical click that is still activating its own app keep it.