← XeFM crftwr/xefm on GitHub · craftware

Customization — Preview

Preview. Everything on this page is subject to change. The objects your functions receive and the shape of the ACTIONS, EVENT_HOOKS, SORT_KEYS, FILTERS and PATH_SCHEMES variables may change in any release until xefm.user_api.API_VERSION reaches 1 (it is 0 today). XeFM writes one line to the log pane saying so whenever a config uses them. Nothing else in config.py is 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.


Rebinding viewer keys

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'].

Every viewer action

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.

Rebinding a shared key in one place only

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
}

Your own actions: ACTIONS

Define 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.

Replacing or wrapping a built-in

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).

What your function is given

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.

Running an external program from a key

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)

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.

Opening your own list of files

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.


Reacting to events: EVENT_HOOKS

def 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.


Your own sort order: SORT_KEYS

XeFM 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.

What you get, and what you return

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.

Replacing a built-in

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.

The rest of the entry

   
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

If it goes wrong

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.


Your own filters: FILTERS

The 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.

What you get

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.

Where the name shows up

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.

If it goes wrong

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.


Your own picture formats: IMAGE_DECODERS

The 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}

What you get, and what you return

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.)

Registering is also claiming

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.

If it goes wrong

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.


Your own places: PATH_SCHEMES

A 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.


Your own modules: ~/.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')

Things to know

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.


See also