plesty.sdk.tui ============== .. py:module:: plesty.sdk.tui .. autoapi-nested-parse:: A checkable list for the terminal, with no dependency and no assumptions. ``plesty init field-test`` asks a developer to choose which of a device's operations are safe to run unsupervised. Typing comma-separated numbers works and reads badly: the choices scroll away above the prompt, there is no feedback until the whole answer is submitted, and a mistyped digit selects something the developer never looked at — on a question whose wrong answer moves a stage. So the list is checkable. Arrow keys move, space toggles, Enter confirms, and what is selected is visible the whole time. Three constraints shaped this: **No new dependency.** A TUI library for one command is a large amount of surface for a small amount of interaction, and every dependency is another thing gate 10 audits and someone upgrades. **Every platform a module is developed or run on.** The instrument PC is often Windows; development and CI are Linux; some benches are macOS. Console key reading is the one part that genuinely differs — Windows reads through ``msvcrt``, Linux and macOS put the terminal into raw mode through ``termios`` — so it is isolated in :func:`read_key` and nothing else has to care. **A pipe is not a terminal.** Scripted runs, CI, and anything reading from a file have no keyboard. :func:`interactive` says so and the caller falls back to a prompt, rather than this blocking forever on a read that never returns. Colours follow the PLESTY palette: cyan for what is being asked, mint for what is chosen, amber for a consequence, dim for detail. Attributes ---------- .. autoapisummary:: plesty.sdk.tui.ASK plesty.sdk.tui.CHOSEN plesty.sdk.tui.WARN plesty.sdk.tui._LEGEND plesty.sdk.tui._SEQUENCE_WAIT Classes ------- .. autoapisummary:: plesty.sdk.tui.Choice Functions --------- .. autoapisummary:: plesty.sdk.tui.panel plesty.sdk.tui.interactive plesty.sdk.tui.read_key plesty.sdk.tui._read_key_windows plesty.sdk.tui._read_key_posix plesty.sdk.tui._decode_escape plesty.sdk.tui._next_byte plesty.sdk.tui._name_key plesty.sdk.tui.checkbox plesty.sdk.tui._toggle plesty.sdk.tui._draw plesty.sdk.tui._clear Module Contents --------------- .. py:data:: ASK :value: 'bright_cyan' .. py:data:: CHOSEN :value: 'bright_green' .. py:data:: WARN :value: 'yellow' .. py:data:: _LEGEND :value: '↑↓ move space select a all n none enter confirm' .. py:data:: _SEQUENCE_WAIT :value: 0.05 .. py:class:: Choice One selectable line. :ivar name: The value returned when chosen. :ivar summary: What it is, shown under the name in dim text. Choosing from bare names is how the wrong operation gets picked. .. py:attribute:: name :type: str .. py:attribute:: summary :type: str :value: '' .. py:attribute:: selected :type: bool :value: False .. py:function:: panel(title: str, body: str, colour: str = ASK) -> str Frame *body* under a bold *title*, so it is read rather than scrolled past. The interview ends with the only text that tells a developer what to actually do next, and it arrived as one more paragraph after twenty prompts. A frame is the cheapest way to say "this part is not more interview". Prose is wrapped to the frame; an indented line is left alone, because those are commands to copy and breaking one makes it not run. The frame widens for them instead, up to the terminal. :param title: Shown bold in the top rule. :param body: The text to frame. :param colour: Colour of the frame and title. :returns: The framed block, ready to echo. .. py:function:: interactive() -> bool Return ``True`` when there is a keyboard and a screen to draw on. A scripted run pipes its answers in, and a list that waits for a keypress would hang there forever. Callers use this to fall back to a prompt. .. py:function:: read_key() -> str Block for one keypress and name it. :returns: ``up``, ``down``, ``space``, ``enter``, ``escape`` for a lone Escape, ``other`` for any other control sequence, or the character typed, lowercased. Ctrl-C raises :class:`KeyboardInterrupt` as it would at a normal prompt — a developer who wants out of a question about laser operations should not have to hunt for the exit. :raises KeyboardInterrupt: On Ctrl-C. .. py:function:: _read_key_windows() -> str Read one key through the Windows console API. Arrow keys arrive as two reads: a ``0x00`` or ``0xe0`` prefix, then a code. The body sits under a ``sys.platform`` test rather than starting with a bare import. ``msvcrt`` exists only on Windows, so a type checker running anywhere else reports every one of its attributes as missing; this is the form mypy narrows on, which keeps the module checkable on the machine it is written on without silencing the checker on the machine it runs on. A genuine mistake in one of these calls is still reported there. :raises RuntimeError: If called off Windows, which only a caller bypassing :func:`read_key` could arrange. .. py:function:: _read_key_posix() -> str Read one key from a POSIX terminal put briefly into raw mode. Arrow keys arrive as an escape sequence, ``ESC [ A``, and as ``ESC O A`` from a terminal in application-cursor mode. The bytes are taken straight from the file descriptor, not through ``sys.stdin``: the text wrapper would pull the whole sequence into its own buffer on the first read, so a ``select`` on the descriptor would then see nothing waiting and report a lone Escape — which is how one arrow press used to answer the question (sdk#32). A lone Escape is still told apart by whether more input follows within a few milliseconds. .. py:function:: _decode_escape(descriptor: int) -> str Name the sequence that follows an Escape already read. Every byte of a control sequence is consumed, whether or not it names a key this list uses. Leaving a tail behind is worse than misreading one press: the ``A`` closing ``ESC [ 1 ; 5 A`` would be read next as the select-all key. :param descriptor: The terminal, in raw mode. :returns: ``up``, ``down``, ``escape`` for a lone Escape, or ``other``. .. py:function:: _next_byte(descriptor: int, timeout: float | None = None) -> str Read one byte from *descriptor*, or ``""`` if none arrives in *timeout*. :param descriptor: The terminal, in raw mode. :param timeout: Seconds to wait, or ``None`` to block. .. py:function:: _name_key(char: str) -> str Name a plain character key. :param char: The character read. :raises KeyboardInterrupt: When it is Ctrl-C. .. py:function:: checkbox(choices: collections.abc.Sequence[Choice], limit: int | None = None, single: bool = False, when_empty: str = 'nothing selected') -> list[str] Draw a checkable list and return what was selected. Choices marked ``selected`` start ticked, and Enter confirms whatever is ticked when it is pressed. The cheapest keypress therefore gives the most conservative answer, which is the whole safety property: this asks which operations may be run unsupervised, and a list arriving pre-ticked would invite confirming rather than deciding. ``a`` selects everything, but it has to be typed. :param choices: The lines to offer. :param limit: Most items selectable, or ``None`` for no limit. Selecting past the limit drops the oldest choice rather than refusing the new one, so the list never becomes unresponsive. :param single: Radio behaviour — one selection at a time. :param when_empty: What an empty selection *means for this question*, shown while it is empty. Empty is restrictive on one question and permissive on another — no operation is called, against every setting is written back — and a developer cannot be expected to remember which is which. :returns: The chosen names, in the order they appear in *choices*. .. py:function:: _toggle(selected: list[int], cursor: int, limit: int | None, single: bool) -> None Select or deselect the item under the cursor. :param selected: Indices currently selected, mutated in place. :param cursor: Index under the cursor. :param limit: Most items selectable. :param single: Radio behaviour. .. py:function:: _draw(choices: collections.abc.Sequence[Choice], selected: collections.abc.Sequence[int], cursor: int, previous: int, limit: int | None, single: bool, when_empty: str = 'nothing selected') -> int Redraw the list in place and return how many lines it occupies. :param choices: The lines offered. :param selected: Indices currently selected. :param cursor: Index under the cursor. :param previous: Lines drawn last time, erased before redrawing. :param limit: Most items selectable, shown in the counter. :param single: Radio behaviour, which changes the counter's wording. :param when_empty: What an empty selection means for this question. :returns: The number of lines drawn. .. py:function:: _clear(lines: int) -> None Erase *lines* previously drawn, so the list redraws in place. :param lines: How many lines to erase.