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)