XeFM’s key bindings system allows you to customize keyboard shortcuts for all actions in the application. The system supports:
The simplest form - just a single character:
'quit': ['Q']
'context_menu_dir': ['?']
'toggle_hidden': ['.']
Important behavior:
'a' and
'A' both mean the A key with no modifier.'Q' does not fire when you press Shift+Q — it is still the unshifted key.
Write 'Shift-Q' when you mean the shifted one, which is why the defaults
spell out 'Shift-F' next to 'F'.? and / are
different keys even though they share one physical key.Examples:
'quit': ['Q'] # the Q key; writing 'q' would mean the same
'context_menu_dir': ['?'] # the '?' glyph, not the '/' key it shares
'isearch': ['F'] # F on its own
'find_files': ['Shift-F'] # Shift+F — a different binding from 'F'
A key with no glyph of its own is written by name:
'move_up': ['UP']
'page_up': ['PAGE_UP']
'confirm': ['ENTER']
'cancel': ['ESCAPE']
'menu': ['F10']
This is the complete list. A name that is not here is not accepted: XeFM
logs Unknown key in expression: ... and the binding never fires.
| Group | Names |
|---|---|
| Navigation | UP, DOWN, LEFT, RIGHT, HOME, END, PAGE_UP (or PAGEUP), PAGE_DOWN (or PAGEDOWN) |
| Editing | ENTER (or RETURN), ESCAPE (or ESC), TAB, BACKSPACE, DELETE (or DEL), INSERT, SPACE |
| Function keys | F1 through F12 |
| Menu key | APPS — the PC keyboard’s context-menu key. Delivered by Windows (desktop app and terminal), a PC keyboard on macOS, and the web backend; a POSIX terminal sends nothing for it. Shift-APPS is a separate binding. |
| Bare Alt tap | ALT on its own — Alt pressed and released with nothing in between. Delivered only by the Windows terminal, where it opens the menu bar. As a prefix (Alt-X) it is the modifier instead. |
Names are case-insensitive: 'ENTER', 'enter' and 'Enter' all work.
Punctuation can be written as its glyph ('-', '[', ';') or by name, which
is easier to read in a config file:
| Name(s) | Key | Shift- gives |
|---|---|---|
MINUS |
- |
_ |
EQUAL, EQUALS |
= |
+ |
LEFT_BRACKET |
[ |
{ |
RIGHT_BRACKET |
] |
} |
BACKSLASH |
\ |
\| |
SEMICOLON |
; |
: |
QUOTE, APOSTROPHE |
' |
" |
COMMA |
, |
< |
PERIOD, DOT |
. |
> |
SLASH |
/ |
? |
GRAVE, BACKTICK, BACKQUOTE |
` |
~ |
Shift- on a punctuation or digit key resolves to the character that key
actually produces on a US layout — 'Shift-EQUAL' is +, 'Shift-1' is !,
'Shift-BACKQUOTE' is ~ — so the two ways of writing it are the same binding:
'go_root': ['BACKSLASH'] # same as ['\\']
'adjust_log_up': ['Shift-LEFT_BRACKET'] # same as ['{']
On a non-US layout, write the glyph your keyboard produces rather than the shifted name.
Add modifiers before the main key, separated by hyphens:
'page_up': ['PAGE_UP', 'Shift-UP']
'page_down': ['PAGE_DOWN', 'Shift-DOWN']
'jump_to_top': ['Command-UP']
'jump_to_bottom': ['Command-DOWN']
'delete_files': ['DELETE', 'Command-Backspace']
Available modifiers:
Shift - Shift keyControl or Ctrl - Control keyAlt or Option - Alt/Option keyCommand or Cmd - Command key (macOS)Modifier rules:
'Shift', 'SHIFT', and 'shift' all work'Command-Shift-X' equals 'Shift-Command-X''Command-Shift-X', 'Control-Alt-Delete'The same XeFM runs as a desktop app and in a terminal, and a terminal cannot send every chord a window can. The defaults are therefore one common table — every key in it reaches XeFM in the macOS app, the Windows app and any terminal — with two per-case additions where a desktop platform owns the gesture outright:
| Case | What is added |
|---|---|
| macOS desktop | Command-ENTER for open_with_os, Command-C for copy_log_selection |
| Windows desktop | Ctrl-ENTER for open_with_os |
| Any terminal | nothing — the common table is the whole keymap |
What a terminal cannot deliver, and why those chords are not in the common table:
| Chord | Why not |
|---|---|
Command-<key> |
No terminal encodes the Command key at all — not Terminal.app, not iTerm2, not VS Code’s. |
Ctrl-Shift-<letter> |
A terminal sends Ctrl+letter as a single control byte with no room for Shift, so 'Ctrl-Shift-C' is 'Ctrl-C' there. (The Windows console is the exception, but a binding that works on one console only is not a default.) |
Shift-<printable> |
The same gap from the other side: a terminal reports the character, and the character Shift+Space produces is a space. select_range takes Shift-Space anyway — the one deliberate exception in the table, because Shift is the range gesture users arrive with and nothing a terminal can carry says “range” at all. In a POSIX terminal it toggles one item (plain Space’s job) until you rebind it. |
Ctrl-ENTER |
Same reason — indistinguishable from plain ENTER. |
Alt-ENTER |
macOS keyboards spend Option on glyphs and IME, and the Windows terminal takes Alt+Enter for fullscreen. |
Ctrl-I, Ctrl-M, Ctrl-J, Ctrl-H, Ctrl-[ |
Those bytes already are Tab, Enter, Backspace and Escape. |
Shift and Ctrl on a named key are fine everywhere — Ctrl-UP, Shift-DOWN,
Ctrl-HOME, Shift-F3 — because a terminal has a sequence for those. That
leaves plain Ctrl-<letter> as the chord family the whole keymap can rely on,
which is what the OS-flavoured actions use. Ctrl-Space belongs to that family
too: a terminal sends it as the NUL byte, which PuiKit decodes, and both consoles
report it natively — which is why the search bar marks a file with it.
On top of that, whatever runs XeFM may claim a chord before XeFM sees it. Nothing is logged when it does — XeFM is never told the key was pressed, so the binding simply does nothing. The ones worth knowing:
Ctrl-Shift-P (command palette), Ctrl-Shift-F
(find), Ctrl-Shift-T/N/D/W (tabs, windows, panes), Ctrl-Shift-A/V/M/K
(select all, paste, mark mode, clear), Ctrl-Shift-<arrow>/HOME/END/PAGE_UP/PAGE_DOWN
(scrollback), Ctrl-Shift-SPACE, Ctrl-Shift-TAB, Ctrl-Shift-<digit>,
Ctrl-Alt-<digit>, Alt-ENTER and F11. Its copy chords (Ctrl-Shift-C,
Ctrl-INSERT, ENTER) only fire while text is selected in the terminal; with
nothing selected they fall through.terminal.integrated.commandsToSkipShell and never sends them on. That
includes the command palette (Cmd-Shift-P / Ctrl-Shift-P) and Quick Open
(Ctrl-P and Ctrl-E on Windows and Linux) — so copy_paths on Ctrl-P is
the one deliberate compromise in the defaults: it works in every terminal
except that one, where Quick Open opens instead. Rebind it there, or use
Edit → Copy Full Path(s).Ctrl-B, Ctrl-A) and whatever is
typed after it.These changed when the keymap became one common table (and help later, to
make room for the context-menu keys), and an existing
~/.xefm/config.py keeps whatever it already says — XeFM fills in a setting your
config is missing, but never rewrites the KEY_BINDINGS you already have. Edit
the lines by hand if your config predates the change:
| Action | Was | Now |
|---|---|---|
open_with_os |
Command-ENTER / Ctrl-ENTER (Windows) |
Ctrl-O, plus the old chord in the desktop app |
reveal_in_os |
Alt-ENTER / Ctrl-Shift-E (Windows) |
Ctrl-R |
copy_names |
Command-Shift-C / Ctrl-Shift-C (Windows) |
Ctrl-N |
copy_paths |
Command-Shift-P / Ctrl-Shift-P (Windows) |
Ctrl-P |
copy_log_selection |
Command-C, Ctrl-C |
Ctrl-C, plus Command-C in the macOS app |
toggle_select_up |
Shift-SPACE |
unbound — the key went to select_range (see below) |
isearch.toggle_select_down |
Shift-DOWN |
Ctrl-SPACE |
isearch.toggle_select_up |
Shift-UP |
unbound |
help |
? |
F1 — ? (Shift-/) went to context_menu_dir, with / for context_menu |
Shift-Space now fills a range (select_range), which is what Shift means in
every other application, and the search bar’s marking moved onto the Space family
to match: Ctrl-Space marks the current match. That was Shift-Down /
Shift-Up, which worked but read as the log pane’s scroll keys — the same chord
one surface away. Marking backwards is what lost its default in both places;
both actions are still registered, so a line in KEY_BINDINGS brings either
back. The search bar has no range key: Enter leaves the search with the cursor on
the match, and Shift-Space does the rest.
For actions without selection requirements, use a list of keys:
KEY_BINDINGS = {
'quit': ['Q'], # the Q key ('q' would mean the same)
'help': ['F1'], # a named key
'context_menu_dir': ['?'], # the '?' glyph
'move_up': ['UP', 'k'], # two keys for one action
'move_down': ['DOWN', 'j'],
}
For actions that require or prohibit file selection, use a dictionary:
KEY_BINDINGS = {
'delete_files': {
'keys': ['K', 'DELETE'],
'selection': 'required' # Only available when files are selected
},
'create_directory': {
'keys': ['M'],
'selection': 'none' # Only available when no files are selected
},
}
The plain list form is just shorthand for 'selection': 'any', so existing
list-style bindings keep working unchanged.
Selection requirements:
'required' - Action only available when files are selected'none' - Action only available when no files are selected'any' - Action always available (default)Default selection-aware actions. Out of the box these file operations use
'required' (they act on the selection, so they’re hidden when nothing is
selected): copy_files (C), move_files (M), delete_files (K / DELETE),
and create_archive (P). create_directory (M) uses 'none' so it shares
the M key with move_files without conflict — XeFM picks whichever action fits
the current selection state. You can add a 'selection' requirement to any other
action the same way.
The keys used inside the text, image, diff and directory-diff viewers are named
actions too, prefixed with the viewer they belong to — text_viewer.page_down,
file_diff.next_block, dir_diff.switch_side. They are not listed in the
default KEY_BINDINGS because they work without an entry; add one only for a key
you want to change:
KEY_BINDINGS = {
'text_viewer.page_down': ['SPACE'],
'file_diff.next_block': ['J'],
}
Because each surface only looks at its own names, a viewer key may share a key
with a file-list key with no conflict. The same prefix also scopes a shared
action — quit, help, isearch, edit_file — to one viewer:
'file_diff.quit': ['X'] changes it there and nowhere else.
The full list of viewer actions and their defaults is in Customization (Preview), along with how to bind a key to a Python function of your own.
The incremental search bar (F) is a surface of its own too, and its keys carry
its name the same way — isearch.next_match, isearch.prev_match,
isearch.toggle_select_down, isearch.toggle_select_up,
isearch.select_matches, isearch.accept, isearch.cancel. They are rebound
exactly like a viewer’s; the defaults and what each one does are in
Customization (Preview).
One rule is specific to this surface: the key must not be one that types a
character. The pattern field is offered every printable key first — that is
what keeps Q, ? and Space typeable into a pattern while quit, context_menu_dir and
toggle_select_down own them in the file list — so an isearch action bound to
N can never fire. XeFM notes it in the log pane at startup rather than leaving
the binding silently dead. Shift-DOWN, Ctrl-N and F2 are all fine. Shift
alone is not enough to make a printable key a command here: Shift-Space types a
space, so the file list’s select_range key could not serve this surface even if
range selection belonged here — which it does not. Press Enter to leave the
search (the cursor stays on the match) and Shift-Space applies again.
Some actions have been given clearer names. Your old names keep working — a config binding one still resolves to the same action — and XeFM notes it once in the log pane at startup. Rename when convenient; nothing breaks if you don’t.
| Old name | Current name |
|---|---|
search |
isearch |
search_dialog |
find_files |
search_content |
find_in_files |
sort_menu |
sort |
drives_dialog |
drives |
rename_file |
rename |
select_file |
toggle_select_down |
select_file_up |
toggle_select_up |
select_all_files |
toggle_select_files |
select_all_items |
toggle_select_items |
image_zoom_in |
image_viewer.zoom_in |
image_zoom_out |
image_viewer.zoom_out |
image_zoom_reset |
image_viewer.zoom_reset |
image_next |
image_viewer.next |
image_prev |
image_viewer.prev |
image_scroll_up |
image_viewer.pan_up |
image_scroll_down |
image_viewer.pan_down |
image_scroll_left |
image_viewer.pan_left |
image_scroll_right |
image_viewer.pan_right |
Three kinds of change are in there. The image viewer’s actions gained their
viewer’s prefix, which leaves the plain names — zoom_in, next — free to
become shared actions if a second viewer ever grows them. The image_scroll_*
group became pan_*, which is what those keys have always done. And the three
unrelated things called “search” got names that say which is which: isearch
jumps to a match as you type, find_files searches for files by name,
find_in_files searches inside them.
The text viewer’s toggle_wrap, toggle_view_mode and change_encoding were
deliberately not prefixed. Those name capabilities rather than one viewer’s
use of them — the diff viewer could grow an encoding picker tomorrow — and an
unprefixed name is what lets a second viewer pick one up with no change to your
config at all.
If a config lists both spellings of an action, the current one wins and the old one is ignored — so migrating a key at a time is safe.
Here’s a complete configuration example:
# ~/.xefm/config.py
class Config:
KEY_BINDINGS = {
# Basic navigation
'quit': ['q'], # Matches both 'q' and 'Q'
'help': ['F1'], # Function key F1
'move_up': ['UP', 'k'], # 'k' matches both 'k' and 'K'
'move_down': ['DOWN', 'j'], # 'j' matches both 'j' and 'J'
'move_left': ['LEFT', 'h'], # 'h' matches both 'h' and 'H'
'move_right': ['RIGHT', 'l'], # 'l' matches both 'l' and 'L'
# Page navigation with modifiers
'page_up': ['PAGE_UP', 'Shift-UP'],
'page_down': ['PAGE_DOWN', 'Shift-DOWN'],
'jump_to_top': ['HOME', 'Command-UP'],
'jump_to_bottom': ['END', 'Command-DOWN'],
# File operations with selection requirements
'delete_files': {
'keys': ['K', 'DELETE'],
'selection': 'required'
},
'copy_files': {
'keys': ['C'], # 'C' and 'c' are equivalent
'selection': 'required'
},
'create_directory': {
'keys': ['M'], # 'M' and 'm' are equivalent
'selection': 'none'
},
# Use Shift modifier for uppercase-specific bindings
'find_files': ['Shift-F'], # Only matches Shift+F
}
If you have an existing configuration, here’s how to migrate:
KEY_BINDINGS = {
'quit': ['q', 'Q'], # Redundant uppercase
'copy_files': ['c', 'C'], # Redundant uppercase
'move_up': ['UP', 'k'],
'page_up': ['PPAGE'], # Old special key name
}
KEY_BINDINGS = {
'quit': ['Q'], # one entry: 'Q' and 'q' are the same binding
'copy_files': ['C'], # likewise
'move_up': ['UP', 'k'], # a named key and a letter, both bound
'page_up': ['PAGE_UP', 'Shift-UP'], # the current name, plus a chord
}
Key changes:
'q' and 'Q' are one and the same
binding, so listing both does nothing.'Shift-F', not
'F' — a capital in the token is not Shift.? and / are different.PPAGE → PAGE_UP, NPAGE → PAGE_DOWN. The old curses names are not
accepted — a config still using them logs Unknown key in expression at
startup and that binding does nothing.Unknown key in expression: ... and never firesisearch.* action, check the key is not a printable one — the search
pattern is offered those first, so such a binding never fires (XeFM says so in
the log pane at startup)'selection': 'required''selection': 'none'