flash

Configuration and command boundaries

config.default.toml is the canonical configuration reference. Loading applies the bundled default file, then the selected user file, then supported environment overrides. FLASH_CONFIG selects a file; otherwise Flash uses $XDG_CONFIG_HOME/flash/flash.toml or ~/.config/flash/flash.toml. The resident watches these configuration files for changes. CLI arguments invoke verbs; they do not configure the resident’s startup.

The inspector runtime page shows the resolved configuration as JSON and edits the selected file. Values that still come from the bundled default use the base text colour. A value a later layer wrote uses the override style and names that layer: config with the filename, or env with the FLASH_ variable. Save writes the editor after the text parses as TOML. Purge defaults removes assignments that equal the bundled file and leaves the result in the editor; it does not write until Save. The request body is limited to 256KB.

Every layer uses the same TOML schema, diagnostics and validators. Invalid values are reported with their source and preserve the previous valid value according to that field’s validation contract. Derivation runs after all layers are applied. Authored settings remain intact: for example, hints.magic_modifiers retains Shift while effectiveMagicModifiers excludes it for non-letter alphabets. Likewise hints.mouse_grid_keys defaults to [], and resolvedMouseGridKeys derives the grid’s matrix after every layer: the left-hand 4×5 block of the resolved hints.keys layout (QWERTY for a literal alphabet). A written-out default would pin QWERTY in the bundled layer over a user’s <colemak>. An explicit matrix needs at least 2×2 keys, rows of equal length, and unique characters (compared lowercased) without whitespace or the reserved `; a malformed one is reported and keeps the previous value. Resolving configuration again is idempotent and does not accumulate warnings. Tables named by the user — [plugin.<id>], [popup.<name>] (see popups), [statusbar.sources.<name>] and [widgets.<name>] (see desktop widgets) — are validated key by key.

statusbar.template, text popups and widget templates share one status format language, including Flash’s meters and sparklines. Each is written inline; there are no @option tables. The configurations under docs/examples are loaded over the bundled defaults by the test suite and must produce no diagnostic; flash config_check applies the same check to your own file.

Keyboard layout

Hint labels, grid keys and NORMAL mappings are characters, matched against the key you press. [app] keyboard_layout picks the layout that match reads keys on:

The table is rebuilt when the selected input source changes and on every config load, never on a keypress; the keyboard tap’s swallow decision does not read it. --search still matches the text you actually type, since it filters by visible text. flash status shows the selected source and the reference layout.

Logical layout

[keyboard] layout names where keys sit for hint labels, the mouse grid, bisect, pointer movement and HYPER arrows. It defaults to qwerty and also accepts colemak, dvorak and bepo. This is not the input source: [app] keyboard_layout only decides how a keypress is read.

When hints.keys is still the bundled default and the layout is not QWERTY, hint labels use that layout’s letters — home row, top row and bottom row — the same alphabet as keys = "<colemak>". QWERTY keeps the bundled home-row and top-row alphabet. A hints.keys token in your file wins, including an explicit QWERTY token or a narrower selector such as <colemak_homerow+colemak_toprow>. The mouse grid uses the left-hand block of the resolved hint layout.

Two-key labels alternate hands while the alphabet can spell them that way. One-key labels stay in front only while the keys held back as prefixes can cover the rest with those alternations. Among those labels, a hand change ranks first, then less travel between keyboard rows (a same-row pair, then a one-row jump, then a two-row jump), then key ease. Key ease is the physical row and column, one formula for every layout: home row above bottom row above top row, and on a row the index finger, then middle, the inward index reach, ring, and pinky. The left key of a mirrored pair ranks one point higher.

Bisect halves use the home-row columns where QWERTY has h j k l (left, down, up, right): Colemak h n e i, Dvorak d h t n, Bépo c t s r. Quadrants use the keys at QWERTY y u and b n. Bépo’s top-left quadrant is the dead key ^, so one press may not type it.

HYPER posts arrows from the physical QWERTY ESDF positions (up, left, down, right): 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: Colemak’s D position types s, and its shifted arrow is shift+s. A punctuation key uses the character Shift types there, so Dvorak up is >. A mapping or false on one of the keys wins. Pointer movement and bisect stay on the hjkl columns. The bindings are derived, so config.default.toml does not list them.

Hint labels

[hints] label_order says which target gets the easiest label. reading (the default) hands the one-key labels out in reading order, top to bottom and left to right. center gives them to the targets nearest the centre of the window, or of the screen for --scope=screen, so the thing you are most likely looking at takes one key. zones makes the first key of every label predictable before the labels show: the window splits into bands and columns laid out like the alphabet’s keys on the keyboard (two bands of ten columns with the default alphabet, q top-left, p top-right, a to l below), each target’s first key is its zone, and the rest of the label is handed out inside the zone as usual. Every label is then at least two keys long.

[hints] key_priority is a string of hint keys, best first. The listed keys outrank the layout’s ease, in the order written, and every other key keeps its place behind them, so "fjdk" puts the index and middle fingers first whatever the layout says. A key that is not in hints.keys is reported in flash config_check and ignored.

[hints] adjust_digits picks what 1–9 do in mouse_target --adjust: tenths of the target’s width (width, the default), or a numeric keypad over the target (grid, 7 8 9 along the top).

mouse_position keeps position_history_size saved pointer positions, one list for every app, or one per app with position_history_scope = "app". pointer_follows_focus = true centres the pointer on a newly focused app’s window when it was outside it. pointer_step, pointer_acceleration and pointer_max_step set mouse_pointer’s movement in points.

Environment overrides

Supported names are the uppercased field path with dots replaced by underscores, prefixed by FLASH_. The inventory is ConfigEnvironment.environmentFields:

For example, FLASH_DEBUG_SHOW_HINTS_BOUNDS=true enables bounds, and FLASH_HINTS_MAGIC_MODIFIERS='["cmd", "ctrl"]' replaces the modifier array. Booleans are true or false; arrays use TOML array syntax. Numbers use the same ranges as the file. Malformed overrides produce diagnostics instead of bypassing the schema or silently coercing values.

Executables and opaque arguments

Only an executable in argv position zero receives configuration-relative path resolution. A bare executable is found through PATH; ./bin/tool resolves from the file defining that command. Remaining arguments retain their exact strings during loading, including URLs, --option=relative/path, and shell programs. Runtime home/environment expansion follows the command’s launch contract.

Named sources (working_directory) and terminal popups (cwd) can set "." when their arguments refer to files beside the defining TOML file. A working directory is an explicit path field and resolves against that file, including ., .., and hidden directories such as .cache. It does not depend on Flash’s launch directory. Use an explicit shell argv for shell syntax, such as ["/bin/sh", "-c", "cat /tmp/value"].

Verbs and mappings

CLI and mapping verb arguments share CommandArguments: --flag or --name=value, with hyphens normalized to underscores. Positional entries, empty names, duplicate names and malformed argument keys are rejected. Each VerbDefinition declares parameter names/types and supplies its parser. Unknown parameters and conflicting actions are rejected. A recognized builtin with invalid arguments cannot fall through to a plugin of the same name.

CLI usage and popup-command completion syntax derive from those definitions. Colon-command descriptions live beside their command specifications. When adding a command, update its definition, canonical configuration examples and contract tests together rather than adding a second parser or help inventory.

Mode-entry shortcuts are opt-in. No default a/A/i/I/o/O/gi binding enters INSERT; users can explicitly bind enter_insert_mode when desired. NORMAL persists across commands and focus changes; focus_input only focuses an input without changing mode.

Vertical NORMAL scrolling sends mouse-wheel events at the pointer in every app. [mode] scroll_step_lines = 3 controls Ctrl-E/Y and scroll_page_lines = 20 controls Ctrl-D/U; both accept integers from 1 to 1000. scroll_step = 60 continues to control horizontal scroll distance in pixels. [mode] scroll_smooth_ms = 0 (0 to 300) spreads each of those vertical scrolls over that many milliseconds as several smaller line events, the larger shares first and at least a frame apart, so the content glides instead of jumping. They stay line events, so terminals scroll the same distance. The steps run on the click queue without blocking it, and any new scroll, gg / G included, drops what the previous one has left; gg and G stay instant.

[hints] restore_pointer = false leaves the pointer on the target after a committed click, as a real click does. true puts it back where it was after every committed hint or grid click, drag and selection and after mouse_repeat, with a tagged move so hover follows; --move and mouse_pointer still move it. Scrolling then stays where the pointer was.

Overlay appearance

[overlay] hint_placement = "corner" places each target’s chip on the target’s top-left corner, centred on a target barely larger than the chip. "center" centres it, "above" puts it on the target’s top edge and "below" under its bottom edge; the last two align with the target’s leading edge, or centre on a narrow target. Every chip stays on its target’s display. Only the chip moves: the click aims at the same point whatever the placement. Grid cells and status-bar chips keep their own geometry.

[overlay.dark] takes the colour keys of [overlay] (hint_fg, hint_bg_top, hint_bg_bottom, hint_border and their important_hint_* counterparts). While macOS is in dark mode each non-empty key replaces its [overlay] colour, one key at a time; an empty key, or an empty table, keeps the [overlay] colour. Flash follows the appearance through key-value observation of the app’s effective appearance, including the automatic schedule, and uses it at the next draw; nothing polls.

[overlay] click_feedback = true draws a short ring (about 220 ms) where each committed click lands: hint and grid clicks, mouse_pointer clicks and mouse_repeat. The ring is the overlay’s one deliberate animation. It is explicit, lives on a layer of its own and starts after the click is queued, so it never delays the click or the next key. It suits demos and screencasts.

[overlay] screen_capture = "hide" asks macOS to keep the overlay, the status bar, its click windows and popups out of screenshots, recordings and screen sharing (NSWindow.sharingType = .none). It is best effort: capture through ScreenCaptureKit on recent macOS may still include them. The window server keeps a window hidden once asked, so switching back to "show" fully applies after Flash restarts.

Mapping examples

[hints]
keys = "<qwerty_homerow+qwerty_toprow>"
min_length = 1

[plugins]
disabled = []
third_party = []

[flashlight]
suggestion_count = 10

[mode.normal]
leader = "\\"

[mode.normal.mappings]
"<leader>space" = ["flash", "enter_command_mode", "--input=:flashlight "]
"[a" = { command = ["flash", "app_previous"], repeat = true }
"f" = ["flash", "mouse_target"]
"F" = ["flash", "mouse_grid"]
"s" = ["flash", "mouse_bigram"]
"cf" = ["flash", "mouse_target", "--secondary"]
"cF" = ["flash", "mouse_grid", "--secondary"]
"cs" = ["flash", "mouse_bigram", "--secondary"]
"df" = ["flash", "mouse_target", "--double"]
"dF" = ["flash", "mouse_grid", "--double"]

Mapping keys are characters, chords (ctrl+e, cmd+shift+<lbracket>) or sequences of them. A key that types nothing is written <name>: <escape> or <esc>, <return> or <enter> (the keypad’s Enter too), <tab>, <space>, <delete> or <backspace>, <delete_forward>, <left> / <right> / <up> / <down>, <home>, <end>, <pageup>, <pagedown>; each pair of aliases is the same key, so a mapping never misses its key by spelling. Mapping values are argv arrays, or inline tables with a command argv array and optional repeat metadata. Resolved configuration JSON reports that same argv, in canonical form. repeat = true repeats a completed normal-mode sequence whenever its final key is pressed again. Arrays beginning with "flash" dispatch in-process; any other executable launches directly. Arguments receive home/environment expansion, with no implicit shell. A plugin command is two words (["flash", "caffeinate", "toggle"]). A one-word plugin name is rejected. The CLI queries status, doctor and config_check are not verbs and cannot be mapped. A built-in verb with invalid arguments, or a name no manifest declares, is rejected.

Entries extend the defaults layer by layer: reusing a key replaces its mapping, and false removes it.

[mode.normal.mappings]
"t" = false                                   # drop the default new tab
"tf" = ["flash", "mouse_target", "--triple"]  # now fires without a timeout

Layers apply in order and the last one to mention a key wins: a removal takes out a key an earlier layer mapped, and a mapping in a later layer restores it. A removal applies to its own table: "t" = false under [mode.normal.mappings] leaves an [mode.all.mappings] entry for t in place. It also drops plugin mappings on that key in that table, including a chord spelled another way (cmd+shift+] and cmd+shift+}). true and { command = false } are rejected.

NORMAL persists across commands and focus changes. Its unmapped keys are swallowed. Holding mode.normal.hyper enters HYPER until the key is released. It defaults to <leader>, which follows mode.normal.leader. leader = false clears the leader; HYPER then stays off until hyper is a real key. Any other single key remaps the hold ("a", "<space>", "cmd+space"). The status pill turns purple and shows mode.labels.hyper (default HYPER). Keys pressed during the hold come from [mode.hyper.mappings]. While the hyper key is the leader, a key with no hyper mapping can still complete a NORMAL <leader> sequence. A tap that presses nothing else arms that prefix on release, so <leader> sequences keep working. + is written <plus>.

HOLD (enter_hold_mode, ⌘⌃H by default) holds a mouse button for dragging or text selection. It reads [mode.hold.mappings] before NORMAL’s and shows mode.labels.hold. The mode guide describes its keys.

[mode.normal]
hyper = "<leader>"

[mode.hyper.mappings]
"1" = ["flash", "system_key", "--key=brightness_down"]
"-" = ["flash", "system_key", "--key=volume_down"]
"=" = ["flash", "system_key", "--key=volume_up"]

The built-in row is brightness, Mission Control, Spotlight, Dictation, Focus, media, mute, and volume on 1–9, 0, -, and =. Shift plus the same key sends F1–F12. A later mapping replaces one key.

A plugin command is the same words as the command bar, and both are required: flash caffeinate toggle, flash media volumedown, flash media output USB Audio. Words after the subcommand are the command’s arguments. A one-word plugin name, or a command with no subcommand, is rejected at plugin load. A name that matches a Flash verb, a reserved query (status, doctor, config_check), or another plugin’s command is an error: every plugin that claims it fails to load. Map keys to high-level actions such as tab_new, app_undo or app_reload: each resolves in the focused app’s context, through a source or the binding a plugin declares for that app (action_bindings; the bundled defaults plugin carries the macOS conventions), and does nothing where the app lacks it (see source actions). To change what an app does for an action, declare a binding in a plugin rather than a send_key mapping (see the plugin cookbook). No default maps a key to send_key; it remains an escape hatch for a chord no action covers. INSERT entry rules and complete defaults live in normal mode.

App-specific mappings

Add an exact bundle identifier under mode.apps to override a mapping only while that app is focused:

[mode.apps."com.apple.Safari".normal.mappings]
"f" = ["flash", "mouse_target", "--search"]
"r" = false

[mode.apps."com.apple.Safari".all.mappings]
"cmd+alt+w" = ["flash", "window_focus", "--direction=right"]

App-specific entries take precedence over plugin and global mappings in the same scope. false suppresses an inherited mapping for that app; other apps keep their existing bindings. Bundle IDs match exactly and are case-sensitive. The available scopes are all, normal, insert, command, terminal, hyper and hold; COMMAND mappings still require a single modified key. Flash updates the active mapping table when focus changes, so no config reload is needed.

Mouse verbs accept --modifiers=cmd+ctrl+alt+shift; presets combine with configured magic modifiers held on the final hint key. The complete set reaches every target. Terminal links additionally require Shift, so f is a plain current-context click (Shift-click for terminal links). F is the keyboard-shaped mouse grid; see normal mode.

Existing hotkey tools

Native mappings are the simplest option, but any launcher that can execute a command can trigger flash:

-- Hammerspoon
hs.hotkey.bind({"ctrl", "alt"}, "f", function()
  hs.execute("flash mouse_target")
end)
# skhd
ctrl + alt - f : flash mouse_target

Karabiner-Elements users can call flash mouse_target from a shell_command manipulator.