Source code for latch_cli.menus

import os
import sys
import termios
import tty
from typing import Any, Callable, Generic, List, Optional, Tuple, TypeVar

from typing_extensions import TypedDict

from latch_cli.click_utils import AnsiCodes


[docs]def buffered_print() -> Tuple[Callable, Callable]: buffer = [] def __print(*args): for arg in args: buffer.append(arg) def __show(): nonlocal buffer print("".join(buffer), flush=True, end="") buffer = [] return __print, __show
# Allows for exactly one print per render, removing any weird flashing # behavior and also speeding things up considerably _print, _show = buffered_print()
[docs]def clear(k: int): """ Clear `k` lines below the cursor, returning the cursor to its original position """ _print(f"\x1b[2K\x1b[1E" * (k) + f"\x1b[{k}F")
[docs]def draw_box( ul_corner_pos: Tuple[int, int], height: int, width: int, color: Optional[str] = None, ): if height <= 0 or width <= 0: return move_cursor(ul_corner_pos) draw_horizontal_line(width, make_corner=True, color=color) draw_vertical_line(height, make_corner=True, color=color) draw_horizontal_line(width, left=True, make_corner=True, color=color) draw_vertical_line(height, up=True, make_corner=True, color=color)
[docs]def clear_screen(): _print("\x1b[2J")
[docs]def remove_cursor(): _print("\x1b[?25l")
[docs]def reveal_cursor(): _print("\x1b[?25h")
[docs]def move_cursor(pos: Tuple[int, int]): """ Move the cursor to a given (x, y) coordinate """ x, y = pos if x < 0 or y < 0: return _print(f"\x1b[{y};{x}H")
[docs]def move_cursor_up(n: int): if n <= 0: return _print(f"\x1b[{n}A")
[docs]def line_up(n: int): """Moves to the start of the destination line""" if n <= 0: return _print(f"\x1b[{n}F")
[docs]def move_cursor_down(n: int): if n <= 0: return _print(f"\x1b[{n}B")
[docs]def line_down(n: int): """Moves to the start of the destination line""" if n <= 0: return _print(f"\x1b[{n}E")
[docs]def move_cursor_right(n: int): if n <= 0: return _print(f"\x1b[{n}C")
[docs]def move_cursor_left(n: int): if n <= 0: return _print(f"\x1b[{n}D")
[docs]def current_cursor_position() -> Tuple[int, int]: res = b"" sys.stdout.write("\x1b[6n") sys.stdout.flush() while not res.endswith(b"R"): res += sys.stdin.buffer.read(1) y, x = res.strip(b"\x1b[R").split(b";") return int(x), int(y)
[docs]def draw_vertical_line( height: int, up: bool = False, make_corner: bool = False, color: Optional[str] = None, ): """ Draws a vertical line with given `height`, going upwards if `up` is True and downwards otherwise. """ if height <= 0: return if color is not None: _print(color) sep = "\x1b[1A" if up else "\x1b[1B" for i in range(height): if i == 0 and make_corner: corner = "\u2514" if up else "\u2510" _print(f"{corner}\x1b[1D{sep}") else: _print(f"\u2502\x1b[1D{sep}") if color is not None: _print("\x1b[0m")
[docs]def draw_horizontal_line( width: int, left: bool = False, make_corner: bool = False, color: Optional[str] = None, ): """ Draws a horizontal line with given `width`, going to the left if `left` is True and to the right otherwise. """ if width <= 0: return if color is not None: _print(color) sep = "\x1b[2D" if left else "" for i in range(width): if i == 0 and make_corner: corner = "\u2518" if left else "\u250c" _print(f"{corner}{sep}") else: _print(f"\u2500{sep}") if color is not None: _print("\x1b[0m")
[docs]def read_next_byte() -> bytes: b = sys.stdin.buffer.read(1) if b in ( b"\x03", # CTRL C b"\x04", # CTRL D b"q", b"Q", ): raise KeyboardInterrupt return b
[docs]def read_bytes(num_bytes: int) -> bytes: if num_bytes < 0: raise ValueError(f"cannot read {num_bytes} bytes") result = b"" for _ in range(num_bytes): result += read_next_byte() return result
T = TypeVar("T")
[docs]class SelectOption(TypedDict, Generic[T]): display_name: str value: T
[docs]def select_tui( title: str, options: List[SelectOption[T]], clear_terminal: bool = True ) -> Optional[T]: """ Renders a terminal UI that allows users to select one of the options listed in `options` Args: title: The title of the selection window. options: A list of names for each of the options. clear_terminal: Whether or not to clear the entire terminal window before displaying - default False """ if len(options) == 0: raise ValueError("No options given") def render( curr_selected: int, start_index: int = 0, max_per_page: int = 10, indent: str = " ", ) -> int: if curr_selected < 0 or curr_selected >= len(options): curr_selected = 0 _print(title) line_down(2) num_lines_rendered = 4 # 4 "extra" lines for header + footer for i in range(start_index, start_index + max_per_page): if i >= len(options): break name = options[i]["display_name"] if i == curr_selected: color = AnsiCodes.color bold = AnsiCodes.bold reset = AnsiCodes.full_reset prefix = indent[:-2] + "> " _print(f"{color}{bold}{prefix}{name}{reset}\x1b[1E") else: _print(f"{indent}{name}\x1b[1E") num_lines_rendered += 1 line_down(1) control_str = "[ARROW-KEYS] Navigate\t[ENTER] Select\t[Q] Quit" _print(control_str) line_up(num_lines_rendered - 1) _show() return num_lines_rendered old_settings = termios.tcgetattr(sys.stdin.fileno()) tty.setraw(sys.stdin.fileno()) curr_selected = 0 start_index = 0 _, term_height = os.get_terminal_size() remove_cursor() max_per_page = min(len(options), term_height - 4) if clear_terminal: clear_screen() move_cursor((0, 0)) else: print("\n" * (max_per_page + 3)) move_cursor_up(max_per_page + 4) num_lines_rendered = render( curr_selected, start_index=start_index, max_per_page=max_per_page, ) try: while True: b = read_bytes(1) if b == b"\r": return options[curr_selected]["value"] elif b == b"\x1b": b = read_bytes(2) if b == b"[A": # Up Arrow curr_selected = max(curr_selected - 1, 0) if ( curr_selected - start_index < max_per_page // 2 and start_index > 0 ): start_index -= 1 elif b == b"[B": # Down Arrow curr_selected = min(curr_selected + 1, len(options) - 1) if ( curr_selected - start_index > max_per_page // 2 and start_index < len(options) - max_per_page ): start_index += 1 else: continue clear(num_lines_rendered) num_lines_rendered = render( curr_selected, start_index=start_index, max_per_page=max_per_page, ) except KeyboardInterrupt: ... finally: clear(num_lines_rendered) reveal_cursor() _show() termios.tcsetattr(sys.stdin.fileno(), termios.TCSANOW, old_settings)