How fman finds a plugin, what a plugin can extend, and the contracts each extension point has to keep.
For installing, reloading and removing plugins - and what happens when two
of them ship a package of the same name - see
docs/PLUGINS.md.
Everything here is importable from fman (plus its sibling modules
fman.fs, fman.url, fman.clipboard). Anything under fman.impl is
internal: it changes without notice.
| Directory | What goes there |
|---|---|
<resources>/Plugins |
The shipped Core plugin. Loads first. |
%APPDATA%\fman\Plugins\Third-party |
Installed plugins. |
%APPDATA%\fman\Plugins\User |
Your own. User\Settings always loads last, so its JSON wins. |
On macOS and Linux, %APPDATA%\fman is
~/Library/Application Support/fman and ~/.config/fman. The path is
fman.DATA_DIRECTORY (FMAN_DATA_DIRECTORY overrides it).
Load order is shipped → third-party → user (impl/plugins/discover.py). Each
plugin directory is appended to sys.path, so a plugin can import any other
loaded plugin's packages — including Core's.
%APPDATA%\fman\Plugins\User\My Plugin\
my_plugin\__init__.py <- required: a package
*.ttf <- fonts, loaded automatically
Theme.css / Theme (Windows).css
Key Bindings.json / Key Bindings (Windows).json
File Context Menu.json / Folder Context Menu.json
<anything>.json <- read with load_json('<anything>.json')
Only directories containing __init__.py are imported
(impl/plugins/plugin.py, _load_packages). Loose .py files at the plugin
root are ignored, and so is a nested package that nothing re-exports — see
Registration.
A (Windows) / (Mac) / (Linux) suffix on any JSON or CSS filename makes it
platform-specific; fman.PLATFORM is the same token.
fman does not have a register_*() call. It scans the classes reachable
from each package's __init__.py namespace and registers anything whose
ancestry includes one of these:
| Base class | Gives you |
|---|---|
ApplicationCommand |
A command in the palette, bound to the window |
DirectoryPaneCommand |
A command bound to the pane it ran from |
DirectoryPaneListener |
Callbacks for pane events |
fman.fs.FileSystem |
A new url scheme |
fman.fs.Column |
A new file-list column |
Viewer |
A file viewer shown inside a pane — see Viewers |
Two consequences worth knowing:
- Split your plugin across modules and you must re-export. Only names
reachable from
my_plugin/__init__.pyare scanned. Core does exactly this:core/__init__.pycarriesfrom core.viewers import *so the viewer classes incore/viewers.pyare discovered. - Unloading is automatic. Every registration pushes its own inverse action,
which
fman.unload_plugin()replays in reverse. Nothing you register leaks.
A command's name is its class name in snake_case: CenterWindow →
center_window. That is the name Key Bindings.json and run_command() use.
Commands and listeners run on a worker thread
(impl/plugins/command_registry.py). Any Qt call must be wrapped:
from fman.impl.util.qt.thread import run_in_main_thread
class ShowSomething(DirectoryPaneCommand):
@run_in_main_thread
def __call__(self):
... # safe to touch widgets herefman.impl.util.qt.thread is technically internal, but it is imported by a
dozen Core modules and is the sanctioned way to do this.
Exceptions inside a command are caught and reported rather than crashing fman.
You receive a DirectoryPane; you never construct one. self.pane in a
DirectoryPaneCommand, pane.window.get_panes() for all of them. Panes are
addressed by index into that list — key bindings pass pane_index. The
"other pane" idiom is:
panes = pane.window.get_panes()
other = panes[(panes.index(pane) + 1) % len(panes)]There are two panes by default, but the count comes from the saved session, so never assume exactly two.
Location and selection: get_path(), set_path(url, callback, onerror),
reload(), get_selected_files(), get_file_under_cursor(),
place_cursor_at(url), select() / deselect(), focus(),
get_columns(), get_sort_column() / set_sort_column().
A pane can show any QWidget in place of its file list. This is what the
built-in file viewers are built on, and it is available to plugins directly —
your widget does not have to have a file behind it.
| Method | Does |
|---|---|
pane.mount_widget(view, focus=True) |
Hides the file list, shows view, re-points the pane's focus proxy at it |
pane.unmount_widget() |
Removes and deletes it, restores the file list and its focus |
pane.get_mounted_widget() |
The mounted widget, or None |
pane.get_colors() |
(background, foreground) as hex, from the pane's live palette |
from fman import DirectoryPaneCommand
from fman.impl.util.qt.thread import run_in_main_thread
from PyQt5.QtWidgets import QLabel
class ShowHello(DirectoryPaneCommand):
@run_in_main_thread
def __call__(self):
bg, fg = self.pane.get_colors()
label = QLabel('hello')
label.setStyleSheet(
'QLabel { background-color: %s; color: %s; }' % (bg, fg)
)
self.pane.mount_widget(label)Four things your widget owns once it is mounted:
- Closing. Nothing closes it for you. Handle
Escape/Enter/BackspaceinkeyPressEventand callpane.unmount_widget()— that is the contract users expect from the built-in viewers. - Pane switching.
Tab/Shift+Tabshould runpane.run_command('switch_panes'). The focus proxy is already re-pointed, so tabbing back returns to your widget rather than the hidden file list. - Colors. Use
get_colors(). Hardcoding a palette means your widget is the one thing that ignores the user's theme. - Stylesheets. Set colors with a type-selector rule
(
QLabel { ... }), never*. Core'sTheme.cssapplies an app-wide* { font-size: ...pt; }, and once that touches a widget Qt switches it to the QSS style engine and stops honouring its palette. A local type rule beats the wildcard; a palette alone loses to it.
focus=False mounts without taking keyboard focus — for mounting into the
other pane while the user keeps browsing in this one.
A Viewer is a widget-in-a-pane that fman picks for you, by file, when the
user runs "View file". Subclass it and your viewer joins the built-in text,
image and video ones on equal footing — the viewer palette, next/previous-file
navigation and the per-viewer "advance only for same type" toggle all work
without further wiring.
from fman import Viewer
class MarkdownViewer(Viewer):
name = 'markdown'
def matches(self, url):
return url.lower().endswith('.md')
def show(self, pane, url, focus_view=True):
pane.mount_widget(build_my_widget(url), focus=focus_view)| Member | Contract |
|---|---|
name |
Required, unique, stable. Also the navigation category and the key its settings are stored under (<name>_viewer_advance_same_type in Core Settings.json), so renaming it silently resets those. A viewer without a name is refused, with an error. |
priority |
Higher wins when two viewers match. Default 0. |
matches(url) |
True if you handle this file. Called during directory scans too, so keep it cheap. |
show(pane, url, focus_view=True) |
Build the widget and mount it. Forward focus_view to mount_widget. |
Registration order is plugin load order, and Core loads first. Core's text
viewer sniffs rather than matching an extension, so it says yes to any file
that is not an image, a video or a binary — including your .md. It therefore
sits at priority = -100, below the default, so plugin viewers get first
refusal. Leave priority alone unless you are deliberately overriding another
plugin.
- A viewer whose
matches()raises is skipped, not fatal: the error is reported and the next viewer is tried. One broken plugin cannot take "View file" down. - Directories and non-local urls never reach a viewer — "View file" alerts on
those instead (
core/viewers.py,viewer_for). - Unloading the plugin unregisters the viewer.
fman.find_viewer(url) returns the viewer that would handle a url, or None;
fman.viewer_for_category(name) looks one up by name. You rarely need
either — they exist for code that has to ask the question without opening
anything.
See File viewers for the user-facing behaviour your viewer inherits, and Key bindings for the viewer-scoped binding file.
load_json('My Settings.json', default={}) merges that filename across
every plugin directory in load order — dicts update, lists prepend — so a
user file overrides yours. save_json writes only the difference, into
User\Settings.
Core exposes the one-key convenience both it and its viewers use:
from core.settings import get_setting, save_setting
save_setting('My Settings.json', 'key', value) # value=None clears the keyAny *.ttf in a plugin's root directory is loaded into Qt's font database when
the plugin loads, and unloaded with it. Reference it by the family name in the
font's own name table — not the filename. It also becomes selectable as the UI
font; see Fonts.
show_alert, show_prompt, show_status_message, clear_status_message,
show_file_open_dialog, show_quicksearch (with QuicksearchItem), and
submit_task(Task(...)) for anything long enough to need a progress dialog.
show_quicksearch returns (query, value), or None if the user pressed
Escape. Pass alt_accept=True to also accept on Shift+Enter: the result is
then always a triple, (query, value, alt), with alt telling the two apart.
Every other caller keeps the pair — see
Command palette keywords for what the palettes
do with it.