← XeFM crftwr/xefm on GitHub · craftware

XeFM Dialog System

Overview

XeFM’s dialogs are the modal (and near-modal) overlays the app raises for input, selection, search, and information display: creating a file, renaming, picking a favorite, searching a tree, viewing help, batch-renaming, and so on.

Since the PuiKit port, every dialog is an ordinary PuiKit widget pushed onto the Panel as a layer. There is no bespoke dialog engine, no curses drawing, and no per-frame redraw bookkeeping. A dialog is a Widget (usually also a FocusContainer) that is composed from PuiKit primitives — TextEdit, ListView, MarkdownView, Checkbox — and relies on the Panel’s layer stack for modality, focus, event routing, and drawing.

The dialog classes

Class Module Factory Role
InputDialog xefm/input_dialog.py show_input Single-line text prompt (create, rename, jump-to-path, archive name, password)
FilterListDialog xefm/filter_list_dialog.py show_filter_list Filterable list picker (favorites, drives, history, external programs)
ProgressiveSearchDialog xefm/progressive_search_dialog.py show_progressive_search Live search-as-you-type over the filesystem (filename / content)
TextDialog xefm/text_dialog.py show_text Read-only scrollable plain-text viewer
MarkdownDialog xefm/text_dialog.py show_markdown Read-only scrollable rich-text viewer (help, file details)
BatchRenameDialog xefm/batch_rename_dialog.py show_batch_rename Regex batch rename with a live preview
CompareSelectDialog xefm/compare_dialog.py show_compare_select Keyboard-first criteria picker for compare-and-select
ISearchBar / ViewerISearch xefm/isearch_bar.py (constructed directly) Incremental search input pinned in a pane / viewer footer
CandidateListOverlay xefm/candidate_list.py (driven by InputDialog) TAB-completion popup

Two supporting pieces are not dialogs themselves:

The JumpDialog and the drives/favorites pickers survive as features but are implemented on this framework (jump has its own module; drives/favorites are FilterListDialog call sites). See the cross-links at the bottom.

The shared pattern

Every dialog class follows the same shape, so once you have read one you can read them all.

1. It is a Panel layer

show_xxx(panel, ...) constructs the widget, computes a size from the backend’s size_units, and pushes it:

panel.push_layer(dialog, z=z, hints={"shadow": True, "w": w, "h": h})
animate_open(panel, dialog)

The default layer z is 70. The shadow hint gives the modal its drop shadow; animate_open plays the shared entrance transition (below). Sizing is a fraction of the window clamped to sensible min/max (e.g. InputDialog is max(36, min(sw*0.6, 64)) wide), so dialogs stay consistent regardless of item count.

2. The top interactive layer owns everything (modality)

Panel.dispatch_event delivers events exclusively to the top-most interactive layer (_top_interactive_slot). While a dialog is that layer it receives every key and mouse event, and its handle_event returns True for everything it doesn’t otherwise use — that trailing return True is what makes it modal (nothing leaks to the panes beneath). Being the top layer also makes the dialog the focus root, which is what engages the backend’s text-input / IME system for its TextEdit field and blinks a caret.

3. Focus resolves to a real text widget

Each dialog subclasses FocusContainer and implements focus_children() (and sometimes get_focused()), returning the field(s) so the Panel’s focus leaf lands on a TextEdit/Checkbox. Without this the focus would stop at the dialog (not a text widget) and the IME would never turn on. Class attributes focusable = True and focus_stop_when_empty = True keep the dialog a focus stop even when its list is momentarily empty.

4. Drawing uses a DrawContext, not curses

def draw(self, ctx) -> None:
    theme = ctx.theme
    surface_bg = theme.popup_bg if theme else None
    ctx.draw_box(0, 0, *ctx.size_units, box_style, hints={"fill": True})
    y = draw_title_bar(ctx, self.title, surface_bg=surface_bg, border=border, y=1.0)
    ctx.draw_child(self.edit, field_x, y, field_w, 1.0, hints={"focused": True})

Drawing is done through ctx (a PuiKit DrawContext): draw_box, draw_text, draw_child, round_rect, fill_rect, draw_scrollbar, measure_text, line_height, plus ctx.theme, ctx.size_units, and ctx.vector_shapes (True on GUI/vector backends, False on a character grid). Colors come from the theme (popup_bg, popup_border, text, muted_text, selection_active_bg, …) via Style, never from curses color pairs. Sub-widgets (the field, the list) are drawn with ctx.draw_child(...), and their rects are captured for mouse hit-testing. The same widget code runs unchanged on the curses, macOS, and Windows backends.

5. Events: keys, IME, mouse

handle_event(event) handles three event families and swallows the rest:

6. Outcome via callbacks, dismissal via pop_layer

Dialogs report results through callbacks passed to the factory — on_accept, on_cancel, on_change, on_done, on_result, on_close — not return values. A dialog closes itself with a guarded panel.pop_layer():

def _close(self) -> None:
    panel = self._panel
    if panel is not None and panel.has_layers and panel._layers[-1].widget is self:
        panel.pop_layer()

Rendering model (what replaced the old optimization)

There is no content_changed flag, needs_redraw() method, or “77.8% rendering optimization” any more — that machinery belonged to the removed curses render loop. Under PuiKit, redraws are on demand: a handler mutates state and calls self._panel.render() (or the app calls panel.render() after an action). A dialog with live, asynchronous content drives its own repaints through panel.request_animation_ticks(callback) — see ProgressiveSearchDialog below — falling back to a synchronous settle on a still backend (chiefly tests). The entrance animation is handled once by animate_open.

Geometry and shared chrome (xefm/dialog_geometry.py)

This module is the single home for a modal’s size and chrome, so every dialog looks and opens the same way.

The dialogs in detail

InputDialog — show_input

A small centered modal with a title, a prompt label, and one TextEdit. Typing edits; Enter accepts (text passed to on_accept); Esc or an outside click cancels; on_change fires live on every keystroke (used for incremental prompts).

Call sites: New Directory, New File, Rename, Create Archive, Jump to Path, and the archive password prompt.

FilterListDialog — show_filter_list

The canonical searchable-list picker, built from a TextEdit filter field over a ListView. Typing filters the list (substring, case-insensitive); up/down/pageup/pagedown drive the selection even while the field holds focus; Enter accepts the selected value via on_accept; a click selects/activates a row.

Call sites: favorites, drives, directory history, external programs.

A single modal combining a query field with a streaming results list; typing re-runs a filesystem search on every keystroke. Tab switches between filename and content mode in place. Enter accepts the selected row via on_accept(mode, value).

Threading model (mirrors the port’s async pane listing):

A result_cap (default 1000) bounds growth on a huge tree.

TextDialog / MarkdownDialog — show_text / show_markdown

The read-only counterparts (the InfoDialog replacement). Both share a _ScrollModal base that owns the box, title bar, a hint row, and event routing; they differ only in the body widget:

Both forward up/down/pageup/pagedown/home/end to the body and close on Enter / Esc / outside-click. keys_markdown(rows, intro=...) is a helper that builds a two-column | Key(s) | Action | table source for key-help overlays. Call sites: file Details and the Help/About overlays.

BatchRenameDialog — show_batch_rename

Rename many files with a regex search pattern and a replace pattern, with a live original → new preview before anything touches disk. Two TextEdit fields (Tab switches) sit above a ListView preview.

CompareSelectDialog — show_compare_select

A compact, keyboard-first criteria picker (no Tab, no buttons) that assembles a CompareCriteria for the compare-and-select action. It has three ConditionRows (Size / Modified / Content) — each a real Checkbox plus a segmented relation picker — and a “Preserve current selection” toggle.

ISearchBar / ViewerISearch — xefm/isearch_bar.py

Incremental search is the exception to the centered-modal rule: it must sit exactly on the active pane’s (or a viewer’s) footer bar while the list above stays visible and its cursor keeps moving as you type. So ISearchBar is pushed as a thin overlay layer positioned at the footer’s captured rect (with a "status" surface hint), rather than a centered box.

It is one row: a bold prompt on the left, an editable TextEdit pattern field across the rest, and a position/total match counter pinned to the right. Up/Down walk the match set, Enter stops at the current match, Esc (or an outside click) cancels. The bar owns no search logic — the host supplies callbacks (on_change, on_navigate, on_submit, on_cancel, get_status). See XeFMApp.enter_isearch for the main-window wiring. ViewerISearch is a small driver that gives the full-window text/diff viewers the same footer-anchored incremental search without each re-implementing the plumbing.

CandidateListOverlay — xefm/candidate_list.py

The TAB-completion popup for InputDialog. It is a small presentational widget pushed as its own non-interactive layer directly below/above the field being completed (above the dialog in z-order so it hugs the field, but non-interactive so keyboard focus stays with the dialog beneath). The host syncs it with set_state(candidates, focused_index) from a CompletionController and positions it with overlay_geometry(...), which places the popup below the field (or above when there is no room), left-anchored so the candidate text lines up with the token, sized to the longest candidate and capped at MAX_ROWS (8) rows with a scrollbar. Because it is not the event-owning layer, the host forwards clicks that fall inside it to handle_event, which reports the chosen row through on_activate.

Integration with XeFMApp

The app holds a single self.panel (a PuiKit Panel). An action handler simply calls a factory and renders:

def show_favorites(self) -> None:
    show_filter_list(
        self.panel, favorites, title="Go to Favorite",
        to_label=lambda fav: f"{fav['name']}{fav['path']}",
        on_accept=self._jump_to_favorite,
        region=self._active_pane_region(),
        elide_where="middle",
    )
    self.panel.render()

There is no dialog dispatch table, no handle_input(key) fan-out, and no _draw_dialogs_if_needed() in the main loop — the Panel routes events to the top layer and draws all layers itself. Incremental search is the one case the app manages more directly, because it constructs ISearchBar and pins it to a captured footer rect rather than centering it (XeFMApp.enter_isearch and the _isearch_* callbacks).

Testing

Dialog behavior is covered by unit tests that drive the widgets against PuiKit’s headless backend (no live TUI). Representative files:

Run them with the project’s standard invocation (python -m pytest test/<file> -v).