Status: canonical. The widget lives at
py/src/bbsengine6/listbox.py.handbook/listbox.mdis the older user-facing reference (withOpinstead ofListboxResult,keyhandlerinstead ofcustom_keys); this spec is the single source of truth for the live API.
bbsengine6.listbox is a paginated, curses-style TUI listbox with
keyboard navigation, item highlighting, idle callbacks, and
per-item key handlers. It is the building block for every
interactive BBS prompt (member pickers, blurb lists, channel
browser, etc.).
run() lifecycleonkey() semantics| Class | Purpose |
|---|---|
ListboxItem |
Single row in the list. Carries content, pk, data, disabled, optional onkey callback |
ListboxResult |
NamedTuple(status, item=None, data=None). status is one of "selected", "cancelled", "noitems", "redraw", "custom" |
Listbox |
Main widget. Holds items, currentitem, currentindex, curpage, pos, the key_handlers dict, and the prompt string |
ListboxCursor |
Helper class (see py/src/bbsengine6/listboxcursor.py) for cursor positioning math |
Listbox(
args: Any,
title: str = "",
itemsperpage: int = 20,
itemheight: int = 1,
items: Optional[List[ListboxItem]] = None,
idle: Optional[Callable[[], Optional[ListboxResult] | bool]] = None,
custom_keys: Optional[Dict[str, Callable[[], Optional[ListboxResult]]]] = None,
**kwargs,
) -> None
args is the application's argparse.Namespace (used for args.debug
gates). The standard handlers are populated first; custom_keys
are merged in via key_handlers.update(custom_keys), so callers
can override standard keys ("KEY_UP", "KEY_DOWN", …) by
passing the same key name.
| Member | Type | Notes |
|---|---|---|
items |
List[ListboxItem] |
Full list passed in __init__ |
currentitem |
property → Optional[ListboxItem] |
Currently selected item |
currentindex |
property → int |
Index into the current page (0-based) |
curpage |
property → int |
Current page (0-based) |
pos |
int |
Cursor position within the current page |
prompt |
str |
Set inside run(prompt) |
key_handlers |
Dict[str, Callable[[], bool | Optional[ListboxResult]]] |
Effective key → handler mapping |
fetchitems() -> List[ListboxItem] |
method | Returns items for the current page; override in subclass |
_highlight_item(idx) |
method | Repaints item at idx as highlighted |
onkey(ch) -> Optional[ListboxResult] | bool |
method | Dispatch a keypress |
run(prompt) -> ListboxResult |
method | Drive the widget loop |
| Field | Type | Default | Description |
|---|---|---|---|
itemsperpage |
int |
20 | Number of items per page (disabled or otherwise) |
itemheight |
int |
1 | Uniform height of each item in rows |
GETCH_TIMEOUT |
float |
0.25 | Key input timeout in seconds |
BOTTOM_BORDER_HEIGHT |
int |
1 | Height of the bottom border line |
CONTENT_PADDING |
int |
4 | Horizontal padding for item content (borders + spacing) |
BORDER_WIDTH_LEFT |
int |
3 | Left border width |
BORDER_WIDTH_RIGHT |
int |
3 | Right border width |
BORDER_CORNER_WIDTH |
int |
2 | Width reduction for border lines (space + vline each side) |
title |
str |
"" |
Title displayed at top of widget |
idle |
callable | None |
Called on idle keypress; see onkey() semantics |
custom_keys |
Dict[str, Callable[[], Optional[ListboxResult]]] |
None |
Merged into key_handlers after the standard handlers |
note: listbox_next is a UI widget component, not a bbsengine6
module. It is exempt from the standard lifecycle functions
(detect, draw, init, buildargs, access, main).
| Key | Method | Action |
|---|---|---|
KEY_ESC |
_handle_key_esc |
Returns ListboxResult("cancelled") |
KEY_ENTER |
_handle_key_enter |
Returns ListboxResult("selected", currentitem); False if disabled |
KEY_UP |
_handle_key_up |
Previous enabled item (skip disabled; wrap to previous page) |
KEY_DOWN |
_handle_key_down |
Next enabled item (skip disabled; wrap to next page) |
KEY_PAGEUP |
_handle_key_pageup |
Previous page |
KEY_PAGEDOWN |
_handle_key_pagedown |
Next page |
KEY_HOME |
_handle_key_home |
First enabled item on the current page |
KEY_END |
_handle_key_end |
Last enabled item on the current page |
custom_keys entries are merged on top of the standard handlers,
so a caller can override "KEY_UP" / "KEY_DOWN" by passing the
same key in custom_keys.
The itemcolors dict determines per-item color:
itemcolors = {
"disabled": "{bggray}",
"highlighted": "{bgwhite}{black}",
"normal": "{normalcolor}",
}
Render logic:
itemcolors["disabled"]; io.setvar("cic", itemcolors["disabled"]).itemcolors["highlighted"]; io.setvar("cic", itemcolors["highlighted"]).itemcolors["normal"]; io.setvar("cic", itemcolors["normal"]).terminalwidth = terminal.width()
contentwidth = terminalwidth - BORDER_WIDTH_LEFT - BORDER_WIDTH_RIGHT
totalwidth = contentwidth + BORDER_WIDTH_LEFT + BORDER_WIDTH_RIGHT
hline = "{hline:" + str(contentwidth - BORDER_CORNER_WIDTH) + "}"
Left border: " {vline} " (BORDER_WIDTH_LEFT chars). Right border:
same shape, BORDER_WIDTH_RIGHT chars. The asymmetric border widths
let a caller intentionally bias left vs right content.
Border layout:
title is set): 4 lines — top border, blank,
centered title, blank._display_middle_border() — "{rtee}{hline}{ltee}"._display_top_border() —
"{ulcorner}{hline}{urcorner}".itemsperpage lines, each item rendered with
itemheight rows. {cic} is the color; {/all} resets before
the right border. Fewer items than itemsperpage → padded with
blank lines._display_bottom_border() — "{llcorner}{hline}{lrcorner}".Content height (excluding borders) = itemsperpage × itemheight.
run() lifecycledef run(self, prompt: str) -> ListboxResult:
if not self.items:
return ListboxResult("noitems")
self.prompt = prompt
self._display()
io.echo(f"{{savecursor}} {promptcolor}{prompt}{{cha}}", end="", flush=True)
while True:
result = self.onkey(io.getch(GETCH_TIMEOUT))
if isinstance(result, ListboxResult):
if result.status == "redraw":
# full redraw (title + content + borders + prompt)
...
continue
return result
if result is True:
io.echo("{restorecursor}", end="", flush=True)
elif result is False:
io.echo("{BEL}", end="", flush=True)
ListboxResult("redraw") is a request from a key handler for a
complete refresh (title box, content, borders, prompt). The current
item selection is preserved across a redraw.
onkey() semanticsonkey(ch) -> Optional[ListboxResult] | bool
True — key was handled; loop continues.False — key was not handled; caller rings the bell.ListboxResult — selection made or cancelled; loop exits.None — only returned when ch is None and no idle callback
is set; loop continues.If ch is None and idle is callable:
idle() returns ListboxResult → returned as the loop result.idle() returns False → onkey returns False (bell rings).onkey returns True (continue).For non-standard keys, onkey delegates to
currentitem.onkey(currentitem, key). The callback returns True
(key handled) or False (key not handled → bell). Common uses:
'e' for edit, 'd' for delete, 'r' for refresh.
bbsengine6/listbox_feature_multicolumn.md describes a multi-column
layout extension:
max(rendered_length(item)) + 1 for that columnKEY_DOWN at the bottom of a column → next column's item 0KEY_RIGHT / KEY_LEFT move to the column to the right/left
(with wrap); row is preservedKEY_END → last item of current columnKEY_HOME → first item of current columnThe multi-column layout is not yet implemented in
bbsengine6.listbox. The single-column widget above is the only
live surface.