← XeFM crftwr/xefm on GitHub · craftware

TAB Completion — Implementation

User-facing behaviour: TAB_COMPLETION_FEATURE.md.

TAB completion is built as a reusable component, decoupled from any single dialog, so the same engine can drive completion anywhere a single-line field is edited.

Pieces

Layer File Role
Logic xefm/completion.py LCP helper, Completer protocol, FilepathCompleter, CompletionController
Widget xefm/candidate_list.py CandidateListOverlay + compute_overlay_rect
Host xefm/input_dialog.py owns the overlay layer’s lifecycle + event routing
Callers xefm/app.py pass a FilepathCompleter to the five prompts

xefm/completion.py (UI-agnostic — no PuiKit draw code)

Threaded candidate listing (issue #246)

With threaded=True, on_tab() / on_text_changed() no longer run the completer inline: each spawns a daemon worker (_spawn_fetch) that calls completer.get_candidates off-thread and posts (gen, kind, text, cursor, candidates) to a thread-safe queue. The UI side applies results with:

The worker touches only its snapshot arguments and the queue; all controller state stays UI-thread-owned. dismiss() also invalidates any in-flight fetch (generation bump), which is what makes closing the dialog mid-fetch safe.

xefm/candidate_list.py (presentational)

CandidateListOverlay(Widget) draws the popup with no heavy frame: rows sit on a distinct popup_bg surface (all a terminal needs to separate them), the highlighted row filled with selection_active_bg, and PuiKit’s standard scrollbar (ctx.draw_scrollbar) past MAX_ROWS = 8. A GUI backend adds a hairline round_rect outline and inherits the layer’s drop shadow. Row pitch is ctx.line_height, so it matches the standard list look. Rows are drawn directly rather than via ListView so the “no row highlighted” state is faithful. It holds no logic: the host calls set_state(candidates, focused_index) and, for forwarded clicks, handle_event, which reports the row through on_activate. overlay_geometry(...) returns the rect: directly below the field, or above when there’s no room (Req 2.2/2.3), left-anchored at the token column, sized to the longest (measured) candidate — no border rows reserved.

Overlay as a non-interactive TOP layer (key design decision)

The candidate list is its own layer, above the dialog (z = dialog_z + 1), so it visually hugs the field — but it must not steal the keyboard from the field. That required a small PuiKit layer-system extension (puikit/panel.py): a layer can be pushed non-interactive (push_layer(..., interactive=False)).

The dialog drives the overlay programmatically (holds the reference; the overlay never receives events itself). panel.remove(overlay) tears it down.

Because the overlay is now higher-z, it draws after the dialog in the same render pass — so the dialog positions it (in its own draw, from measured field geometry) before it draws, and it lands correctly the first frame. This is what removed the one-frame position jump on GUI.

InputDialog wiring (xefm/input_dialog.py)

show_input(..., completer=...) enables completion. InputDialog:

Reusing it elsewhere

Attach a CompletionController to any widget that owns a TextEdit: forward TAB to on_tab(), arrows to move_focus(), Enter to accept(), Esc to dismiss(), and call on_text_changed() after edits; render the candidates from controller.candidates / focused_index (reuse CandidateListOverlay, or draw your own). Supply any CompleterFilepathCompleter is one implementation.

Tests

test/test_completion.py (34 tests, python -m pytest): the LCP helper, FilepathCompleter against a temp tree (prefix/sep/sorted/case/~/absolute/ missing-dir, hidden-file filtering incl. the explicit-dot override), and CompletionController on a real TextEdit (LCP insert, single full-completion, narrow/hide, focus wrap, accept consumed-vs-not, apply/dismiss). Threaded mode is tested with a gate-blocked completer (GatedCompleter): apply-on-pump, a stale snapshot dropped after further typing, dismiss invalidating an in-flight fetch, and a live refresh cycle. The overlay layer lifecycle and event routing were verified end-to-end through a MemoryBackend panel (including that focused_leaf stays on the field, so IME survives, while the popup is on top). The PuiKit non-interactive-layer primitive has its own test in puikit/tests/test_panel.py.