Preview. Everything on this page is subject to change. The objects your functions receive and the shape of the
ACTIONS,EVENT_HOOKS,SORT_KEYS,FILTERSandPATH_SCHEMESvariables may change in any release untilxefm.user_api.API_VERSIONreaches1(it is0today). XeFM writes one line to the log pane saying so whenever a config uses them. Nothing else inconfig.pyis affected.
~/.xefm/config.py is real Python that XeFM executes at startup, so it has
always been able to hold logic — if sys.platform == 'win32': blocks, computed
paths, list comprehensions. This feature takes the next step: your config can
define functions, bind them to keys, and run them when things happen.
Three things it lets you do:
Naming every key also meant correcting some names that were already there. Old names keep working — see Renamed actions for the list.
KEY_BINDINGS already listed most of what XeFM does. What it did not list were
the keys inside the viewers: PgDn to scroll the text viewer, n to jump to
the next diff block, Tab to switch sides in the directory diff. Those were
fixed in the code and could not be changed.
Now they are ordinary named actions. They are not listed in your KEY_BINDINGS
by default — they work without an entry — so add one only for a key you want to
change:
KEY_BINDINGS = {
...
'text_viewer.page_down': ['SPACE'], # page with Space in the text viewer
'text_viewer.page_up': ['B'],
'file_diff.next_block': ['J'],
'file_diff.prev_block': ['Shift-J'],
}
The name is prefixed with the viewer it belongs to. Because each surface only
ever looks at its own names, a viewer key can share a key with a file-list key
with no ambiguity — W toggling line wrap in the viewer and comparing panes in
the file list is not a conflict, and never was meant to be. In the example above
SPACE pages the text viewer while still toggling selection in the file list.
An entry replaces the default rather than adding to it, as everywhere else
in KEY_BINDINGS: after that first line, PgDn no longer pages the text viewer.
List both if you want both — ['SPACE', 'PAGE_DOWN'].
The keys they ship with are not listed here — each action declares its own in
xefm/actions.py, and F1 inside a viewer shows what yours are on, which is the
only list that cannot go stale.
| Text viewer | File diff |
|---|---|
text_viewer.scroll_up |
file_diff.scroll_up |
text_viewer.scroll_down |
file_diff.scroll_down |
text_viewer.page_up |
file_diff.page_up |
text_viewer.page_down |
file_diff.page_down |
text_viewer.scroll_top |
file_diff.scroll_top |
text_viewer.scroll_bottom |
file_diff.scroll_bottom |
text_viewer.scroll_left |
file_diff.scroll_left |
text_viewer.scroll_right |
file_diff.scroll_right |
toggle_wrap * † |
file_diff.next_block |
toggle_view_mode * † |
file_diff.prev_block |
change_encoding * † |
| Image viewer | Directory diff |
|---|---|
image_viewer.zoom_in * |
dir_diff.cursor_up |
image_viewer.zoom_out * |
dir_diff.cursor_down |
image_viewer.zoom_reset * |
dir_diff.page_up |
image_viewer.next * |
dir_diff.page_down |
image_viewer.prev * |
dir_diff.cursor_top |
image_viewer.pan_up * |
dir_diff.cursor_bottom |
image_viewer.pan_down * |
dir_diff.expand |
image_viewer.pan_left * |
dir_diff.collapse |
image_viewer.pan_right * |
dir_diff.activate |
image_viewer.first |
dir_diff.switch_side |
image_viewer.last |
dir_diff.next_change |
dir_diff.prev_change |
|
dir_diff.rescan |
|
dir_diff.split_left |
|
dir_diff.split_right |
The search bar (isearch) is a surface of its own, and its keys are named the
same way:
| Action | |
|---|---|
isearch.next_match |
Move to the next match |
isearch.prev_match |
Move to the previous match |
isearch.toggle_select_down |
Mark, then move to the next match |
isearch.toggle_select_up |
Mark, then move to the previous match (no default key) |
isearch.select_matches |
Mark every match (again: clear them) |
isearch.accept |
Stop at the current match |
isearch.cancel |
Cancel, restoring the cursor |
One rule applies here and nowhere else: the key must not be one that types a
character. Everything printable belongs to the pattern you are typing — which
is why marking a file here is Ctrl-Space rather than the file list’s own Space,
whose glyph the search reads as the separator between report 2024 and
report*2024*. Bind an isearch action to N and it can never fire; XeFM says so
in the log pane at startup rather than leaving you to wonder. Shift is not enough
to turn a printable into a command — Shift-Space types a space too, so the file
list’s select_range key does nothing here (and range selection is not one of
this surface’s operations: leave the search with Enter, which keeps the cursor on
the match, and the file list’s own key applies). Everything modified with Ctrl,
and every non-printable key, is free: Ctrl-Y, Shift-DOWN, F2, INSERT (on
Windows and in the terminal — macOS keyboards have no Insert key).
isearch.select_matches is the one default that takes a key the pattern field
would otherwise use (select-all-text). Only the Ctrl form: Cmd-A still selects
the text on macOS, which is where that chord is the habit.
* These are listed as live entries in the default KEY_BINDINGS, so a config
generated from the template already has them. The rest work from the defaults
above with no entry at all.
† Unprefixed on purpose. These three name a capability rather than one
viewer’s use of it, and the diff viewer — which already reads through the same
decoder — is a plausible second home for the encoding picker. Keeping the plain
name means that, if it happens, your existing binding covers both viewers with
no change: a name can live in more than one context, each with its own
behaviour, exactly as copy_files already does in the file list and the
directory diff.
The directory diff also understands the file-list actions it can perform on the
focused node — copy_files, move_files, delete_files, view_file and
edit_file — under those same names, so one rebind moves both.
quit, help, isearch and edit_file mean something on every surface, which
is why rebinding quit changes it in the file list and in every viewer. To
change it in one viewer alone, prefix it with that viewer’s name:
KEY_BINDINGS = {
'quit': ['Q'], # everywhere...
'file_diff.quit': ['X'], # ...except in the file diff viewer
}
ACTIONSDefine a function that takes one argument, then name it in ACTIONS and bind
the name in KEY_BINDINGS. Define the functions above class Config: —
they are ordinary module-level Python.
def select_documents(ctx):
"""Select every Word/PDF document in the active pane."""
n = ctx.pane.select(lambda e: e.suffix.lower() in ('.docx', '.pdf'))
ctx.message(f"Selected {n} document(s)")
class Config:
ACTIONS = {
'select-documents': select_documents,
}
KEY_BINDINGS = {
...
'select-documents': ['Shift-D'],
}
Press Shift-D and the documents are selected. Your actions also appear in the
help dialog (F1), under Your Actions, alongside the built-in ones.
Edit the file and run reload_config and the new version takes effect
immediately — no restart.
A name that is already a built-in action is ignored, with a warning in the log
pane, unless you say you meant it. When you do, the built-in stays reachable
through ctx.invoke(), so you can wrap rather than replace it:
def confirm_then_quit(ctx):
ctx.message("saving session…")
save_session(ctx)
ctx.invoke('quit') # runs the *built-in* quit
class Config:
ACTIONS = {
'quit': {'func': confirm_then_quit, 'override': True},
}
The dict form also takes description (what the help dialog shows).
ctx is the only argument. It offers:
ctx.pane |
the active pane |
ctx.other |
the inactive pane |
ctx.left, ctx.right |
the panes by position |
ctx.invoke(name) |
run another action — built-in or your own |
ctx.message(text) |
write one line to the log pane |
ctx.input(prompt, default, on_accept=fn) |
ask for a line of text |
ctx.choose(title, items, on_result=fn) |
pick from a list (index, or None); type='filter' for a searchable one |
ctx.confirm(prompt, on_result=fn) |
yes / no |
ctx.run_program(command, terminal=False) |
run an external program — see below |
ctx.action_names() |
every name invoke() accepts |
And each pane:
pane.path |
the directory it is showing |
pane.name |
'left' or 'right' |
pane.is_active |
whether the cursor is in it |
pane.activate() |
move the cursor into it |
pane.entries |
everything listed, in the pane’s sort order |
pane.cursor |
the focused row’s index (assignable; clamped) |
pane.focused |
the entry under the cursor, or None |
pane.selected() |
the selected entries |
pane.select(predicate) |
add matches to the selection; returns how many |
pane.unselect(predicate) |
remove matches; no predicate clears it |
pane.cd(path, focus_name=None) |
go somewhere |
pane.refresh() |
re-read the directory |
pane.open_list(paths, title=...) |
open a list of paths in place of a directory — see below |
And each entry: .name, .path, .suffix, .stem, .is_dir, .is_file,
.is_link, .size, .mtime. .path is a pathlib.Path-alike that also
addresses files inside archives and on S3 / SFTP, so entry.path.read_text()
works wherever the pane does.
.size and .mtime read from disk the first time you ask; the rest are free.
A predicate that only looks at names therefore costs no filesystem access at
all, which matters in a large directory.
A PROGRAMS entry is launched from the X picker; to give a program a key
of its own, call it from an action with ctx.run_program():
def massren(ctx):
ctx.run_program(['massren'], terminal=True)
class Config:
ACTIONS = {'massren': massren}
KEY_BINDINGS = {..., 'massren': ['Alt-R']}
command is a list, or a string that is split like a command line. It runs
the way a PROGRAMS entry runs — in the active pane’s directory, with the same
XEFM_* variables in its
environment (add your own with env={...}, or pick a directory with cwd=) —
with one difference: no file names are appended. If the program wants the
selection, pass it yourself:
def open_selection_in_vim(ctx):
files = ctx.pane.selected() or [ctx.pane.focused]
ctx.run_program(['vim'] + [str(e.path) for e in files if e], terminal=True)
terminal=True, the program starts in the background, its output
goes to the log pane, and XeFM carries on.terminal=True, XeFM hands the terminal over to the program and waits
for it — what an editor, a pager, or anything else that draws on the screen
or reads the keyboard needs. When it exits, both panes are re-read and the
exit code is returned. A nonzero exit waits for Enter first, so its error
output stays readable. The desktop window has no terminal to hand over, so
there a terminal=True launch is refused with a line in the log pane.Don’t call subprocess.run() yourself for an interactive program. XeFM owns
the terminal, and a program that draws on it or reads the keyboard behind
XeFM’s back leaves the screen garbled — or both of them waiting for the same
key.
pane.open_list() puts any list of paths in a pane, the way a search’s results
land there: every file operation works on the rows, and go_parent returns to
the directory. It is how a search of your own — or any tool that prints paths —
gets its answer into XeFM:
import subprocess
def modified_in_git(ctx):
out = subprocess.run(['git', 'ls-files', '--modified'],
cwd=str(ctx.pane.path), capture_output=True,
text=True).stdout
ctx.pane.open_list(out.splitlines(), title='git: modified')
class Config:
ACTIONS = {'modified_in_git': modified_in_git}
Paths may be strings, Path objects or entries, absolute or relative to
pane.path, and may be ssh://… or s3://… URIs — one list can mix them.
Which of them
exist is checked on a worker thread; the ones that don’t are left out and
counted in the log pane, and if none do, the pane stays as it was. Rows are
named relative to the deepest directory they all share. The
file list feature describes what the pane then
does.
Producing the list is your code’s work, and it runs on the UI thread like any action: a command that takes seconds freezes the window for those seconds.
EVENT_HOOKSdef log_visit(ctx, pane, old_path, new_path):
with open(Path.home() / '.xefm' / 'visited.log', 'a') as f:
f.write(f"{new_path}\n")
def open_psd_in_gimp(ctx, path):
if path.suffix.lower() != '.psd':
return False
subprocess.Popen(['gimp', str(path)])
return True # claimed — XeFM does nothing further
class Config:
EVENT_HOOKS = {
'directory_changed': [log_visit],
'file_open': [open_psd_in_gimp],
}
| Event | Signature | When |
|---|---|---|
startup |
fn(ctx) |
once the app is up and the panes are listed |
quit |
fn(ctx) |
before XeFM shuts down, panes still live |
directory_changed |
fn(ctx, pane, old_path, new_path) |
a pane moves to a different directory |
file_open |
fn(ctx, path) |
Enter on a file, before XeFM decides what to do with it; return True to claim it |
Each event maps to a list, run in order. A bare function is accepted where you have only one.
file_open is how you route a file type somewhere of your own without touching
FILE_ASSOCIATIONS. It fires for the open action (Enter) — not for
directories, since entering one is navigation rather than opening, and not for
view_file (V), open_with_os or the viewers, which are explicit requests for
a particular way of opening something.
directory_changed fires on an actual change of directory. A refresh, a
re-sort, a reload after a file operation and the filesystem monitor’s own
reloads all leave the pane where it is and stay quiet.
SORT_KEYSXeFM orders names by their character codes, which is not how Explorer or Finder order a directory: they put symbols and digits in a different place and sort kanji by the system’s own rules. Rather than pick one of those, XeFM lets you write the order you want.
A sort key is a function that takes one entry and returns something sortable. XeFM sorts by whatever comes back:
SORT_KEYS = {
"biggest": {"label": "Size, then name", "key": lambda e: (e.size, e.name)},
}
That adds a Size, then name row to the sort dialog (s) and to the Sort By
menu, alongside the four built-in ones.
Your function is handed one entry, with name, path, is_dir, is_link,
size and mtime. Reading size or mtime is free — XeFM already collected
them for the listing, so your key costs nothing extra even in a huge directory on
a network drive.
name is the name as the pane shows it, which is what you want to sort by:
accented and Japanese characters come to you written one way whatever way the
disk happens to store them, and on a search-results pane it is the whole path
under the folder you searched, the same text the row displays. Use path when
you need to reach the file itself.
Return anything that can be compared: a string, a number, or a tuple for a
multi-level order — tuples are compared item by item, so (e.size, e.name) means
“by size, and by name within the same size”. Whatever you return has to be
comparable with what you return for every other entry: if one entry gives a
number where another gives text, the sort cannot be done.
Two things you do not need to handle: directories always come first, and ascending/descending is applied for you. Your key only decides the order inside one group.
Naming one of the four built-in sorts — filename, extension, size,
timestamp, which are the dialog’s own rows in lower case — replaces it. That
needs "override": True, so a typo cannot quietly change what Filename means:
import locale, re
locale.setlocale(locale.LC_COLLATE, "") # your system's own ordering
def by_system_order(entry):
# digit runs as numbers, everything else through the system's ordering
parts = re.split(r"(\d+)", entry.name)
return [int(p) if i % 2 else locale.strxfrm(p) for i, p in enumerate(parts)]
SORT_KEYS = {"filename": {"key": by_system_order, "override": True}}
A replaced sort keeps its row and its label unless you give it new ones.
label |
the row text in the dialog and the menu (defaults to the name you gave it) |
explain |
the example line the dialog shows under the order — say what the order looks like |
hotkey |
a letter that applies the sort directly in the dialog. A new row gets its label’s initial when no other row has claimed it, and otherwise none — the arrow keys always work |
A key that fails, or returns things that cannot be compared with each other, loses the whole sort — the pane falls back to ordering by filename and says so once in the log pane. It never fails the listing, and it never floods the log with one message per file.
Your key may run on a background thread, so it must not do anything that expects to be on the main one. Keep it to arithmetic and string handling over the entry you were given.
FILTERSThe pane filter (;) takes wildcard patterns — one, or several separated by
spaces (*.jpg *.png) — which can only ask about a name. “The images”, “anything I touched today”, “everything over 100 MB”
are questions about the file, and no pattern spells them. So the filter is
yours to write too.
Each entry becomes a fixed row in the Filter dialog, straight under clear filter and above the patterns you have typed there before — so a filter you defined is always in the same place, rather than ageing down a history it was never part of.
The simple kind is patterns, which is the case you were already writing by hand:
FILTERS = {
"images": ["*.jpg", "*.jpeg", "*.png", "*.gif"], # any one of them matches
}
The other kind is a function. It takes one entry and returns whether to show it:
import time
FILTERS = {
"today": {"label": "Modified today",
"match": lambda e: e.mtime >= time.time() - 24 * 3600},
"big": {"label": "Over 100 MB", "match": lambda e: e.size > 100 << 20},
"no-mates": {"label": "Files with no .txt beside them",
"match": lambda e: not e.path.with_suffix(".txt").exists()},
}
label is the row text and defaults to the name you gave it.
The same entry a sort key is handed: name, path, suffix, stem, is_dir,
is_file, is_link, size and mtime. Reading size or mtime is free —
XeFM collected them for the listing already — so a filter over a huge directory
on a network drive costs nothing extra. name is the name as the pane shows
it; use path when you need to reach the file itself.
Directories are always shown, exactly as they are under a typed pattern: a
filter that hid them would take away the folder you were about to open. Your
function therefore only decides which files are visible — and “directories
only” is written "match": lambda e: False.
A filter is remembered by its name, so the status bar reads Filter: Images
rather than the pattern behind it, and a content search (⇧G) started while the
filter is on narrows to exactly the files the pane is showing — your match
function included. A name may not contain *, ? or [: XeFM remembers a typed filter as the pattern itself, and one
that could be read as both would be impossible to tell apart. Put the wildcards
in pattern and give the filter a plain name.
Names you define never enter the ; prompt’s history either — they already have
a row, and recording them would list them twice.
A filter that fails loses the filter, not the listing: the pane shows everything and says so once in the log pane. That direction is deliberate — a filter that quietly hides half a directory is how a file ends up inside an operation nobody could see it join.
Like a sort key, match may run on a background thread, so keep it to
arithmetic, strings and quick filesystem questions.
IMAGE_DECODERSThe image viewer already reads more than XeFM ships a decoder for, because it
asks your machine: on macOS the system decoder handles HEIC, JPEG XL and camera
RAW; on Windows, whichever imaging codecs are installed; in a terminal, Pillow
and any Pillow plugin you added. pip install pillow-heif is the whole of
adding HEIC anywhere it is not already there.
What is left is the formats only a library XeFM does not ship can read. Write a function from the file’s bytes to a picture, and name the suffix:
import io
def open_svg(data):
import cairosvg # pip install cairosvg
from PIL import Image
return Image.open(io.BytesIO(cairosvg.svg2png(bytestring=data)))
class Config:
IMAGE_DECODERS = {'.svg': open_svg}
Your function is handed the file’s bytes — not a path — so one decoder
covers a picture on disk, inside a zip, and on an S3 bucket alike, with nothing
copied to a temporary file. Return a PIL.Image, or None for “I cannot read
this one”. (A puikit.image.RasterImage is also accepted, if you already have
raw pixels and would rather not build a PIL.Image for XeFM to take apart
again.)
XeFM offers the image viewer only for formats something on the machine can
actually decode — so a file that opens in it always shows a picture, never a
“cannot show this” card. Registering a suffix adds it to that set, which is what
makes Enter on a .svg open the picture rather than the text.
Naming a suffix XeFM already reads replaces its decoder with yours. That is
the only way to change how a format is read, so no extra override flag is
asked for — writing the entry is the statement.
A decoder that fails costs that picture and nothing else: the viewer shows the reason on its metadata card and the log pane gets the traceback. Failing to a card is the right direction here — unlike a filter, a picture that cannot be decoded has no honest stand-in.
Like a sort key, a decoder may run on a background thread, so it must not touch the UI. It is also the slowest thing in this document: a RAW decode takes seconds.
More, including the format table per platform, in
doc/IMAGE_VIEWER_FEATURE.md.
PATH_SCHEMESA config can add a browsable location that is not a directory — the Windows
registry, a bookmark database, a device list, anything you can present as
folders and files. Write one class, name it, and reg:// is somewhere a pane
can open, jump_to_path can jump to, and FAVORITE_DIRECTORIES can hold.
import io
from xefm.path_base import ReadOnlyPathImpl, UriStatResult
class NotesPathImpl(ReadOnlyPathImpl):
def exists(self): ...
def is_dir(self): ...
def iterdir(self): ... # yield self._child(name)
def stat(self): ... # return UriStatResult(size=…, mtime=…, is_dir=…)
def open(self, mode='r', buffering=-1, encoding=None,
errors=None, newline=None): ...
class Config:
PATH_SCHEMES = {'notes': NotesPathImpl}
Five methods is the whole requirement — path arithmetic, parents, names,
globbing and refusing writes come with the base class. Full walkthrough,
including a registry browser, in
doc/VIRTUAL_FOLDERS_FEATURE.md.
~/.xefm/extensions/~/.xefm/extensions/ is on Python’s import path, so anything the sections
above describe — a PATH_SCHEMES class, an archive format, a set of actions —
can live in its own file and be imported from config.py:
~/.xefm/
├── config.py
└── extensions/
├── box.py # import box
└── mail/ # import mail.eml_archive
├── __init__.py
└── eml_archive.py
import box
class Config:
PATH_SCHEMES = {'box': box.BoxPathImpl}
XeFM creates the directory at startup if it is missing.
Reloading the config re-imports them. Edit a module, run Tools ▸ Reload Configuration, and the edited code is what runs — no restart.
Python’s own modules win a name clash. The directory is added to the end
of the import path, so an email.py or queue.py here cannot break the
standard library for the rest of XeFM — but it cannot be imported either.
Rename it.
Only extensions/ is on the path. ~/.xefm/ itself is not (its
config.py would be importable as config, a name too common to claim), and
neither is ~/.xefm/tools/, which holds external programs
XeFM runs as separate processes.
Keeping them elsewhere. If you keep your extensions in their own project
directory — under version control, say — add it to the path yourself at the top
of config.py:
import sys
sys.path.append('/Users/me/src/xefm-extensions')
Your code runs on the UI thread, and XeFM waits for it. A slow action
freezes the window until it returns. There is no background-work helper in this
version; keep actions quick, and launch anything long with ctx.run_program().
Prompts do not block. XeFM never stops for a dialog, so ctx.input,
ctx.choose and ctx.confirm return immediately and deliver their answer to a
callback. Put the rest of the action inside it:
def rename_to_lowercase(ctx):
entry = ctx.pane.focused
if entry is None:
return
def go(ok):
if ok:
entry.path.rename(entry.path.with_name(entry.name.lower()))
ctx.pane.refresh()
ctx.confirm(f"Rename {entry.name} to lowercase?", on_result=go)
A long list wants the searchable picker. ctx.choose opens a compact box
where typing jumps the selection and a second of quiet forgets what you typed —
right for a handful of alternatives, wrong for a list you have to search. Pass
type='filter' and you get the dialog the built-in Favorites and History lists
use instead: a filter field over a scrolling list, anchored over the active
pane, taking the same query the file pane’s incremental search takes —
space-separated tokens, wildcards, and Migemo, so typing romaji finds Japanese
labels — and holding it until you erase it.
BOOKMARKS = [("work", "/Users/me/src"), ("写真", "/Users/me/Pictures")]
def go_to_bookmark(ctx):
def jump(index):
if index is not None:
ctx.pane.cd(BOOKMARKS[index][1])
ctx.choose("Bookmarks", [f"{n} — {p}" for n, p in BOOKMARKS],
type="filter", on_result=jump)
pane.cd() and pane.refresh() are asynchronous for the same reason — the
directory is read on a worker thread, so pane.entries is briefly empty right
after you call them. Read the new listing from a later action or from a
directory_changed hook, not from the next line.
A mistake costs you one log line. An exception inside your action or hook is
caught, logged with its traceback, and dropped; XeFM keeps running. A malformed
ACTIONS or EVENT_HOOKS entry is a warning that skips that one entry — the
rest of your config still loads.
One config, both platforms. Nothing in this API mentions a widget or a backend, so the same config behaves identically in the terminal and in the desktop window.
Not in this version: functions bound inside a viewer (viewer keys are rebindable, but the function you bind must be a file-list action), custom viewers and renderers, filter predicates as functions, and any access to the widget tree. All are additive later.
doc/KEY_BINDINGS_FEATURE.md — key expression
syntax, modifiers, selection requirementsdoc/CONFIGURATION_FEATURE.md — everything else in
config.pydoc/EXTERNAL_PROGRAMS_FEATURE.md — running
external programs, the other way to extend XeFMdoc/VIRTUAL_FOLDERS_FEATURE.md — PATH_SCHEMES
in full, with a worked registry browserdoc/dev/CUSTOMIZATION_API_IMPLEMENTATION.md
— how it is built