Source code for zelfred.render

# -*- coding: utf-8 -*-

"""
This module implements the render logic of the terminal UI.
"""

import typing as T
import dataclasses
import sys

from blessed import Terminal

from .item import T_ITEM
from .line_editor import LineEditor
from .dropdown import Dropdown
from .exc import TerminalTooSmallError


[docs]@dataclasses.dataclass class Render: """ Generic UI render. It can print string, print line, clear line, and move cursor up and down. :param terminal: blessed terminal object. :param line_number: store the current line number of the cursor. if 0, means it is at the first line. if 1, means it is at the second line. :param n_lines: store the total number of printed lines in the terminal. """ terminal: Terminal = dataclasses.field(default_factory=Terminal) line_number: int = dataclasses.field(default=0) n_lines: int = dataclasses.field(default=0) def __post_init__(self): pass def _debug(self): # pragma: no cover print(f"line_number = {self.line_number}") print(f"n_lines = {self.n_lines}")
[docs] def print_str( self, str_tpl: str, new_line=False, **kwargs, ) -> str: """ 打印一个字符串, 可以选择是否换行. 并且自动更新 line_number 的值. :param str_tpl: string template, 一个字符串模板. :param new_line: 如果是 True, 那么会自动换行, 否则不会. 默认不会. :param kwargs: 额外的传递给 str_tpl 的参数. """ if new_line: self.line_number += 1 self.n_lines += 1 content = str_tpl.format(**kwargs) print(content, end="\n" if new_line else "") sys.stdout.flush() return content
[docs] def print_line( self, str_tpl: str, new_line: bool = True, **kwargs, ) -> str: """ 打印一行, 默认自动换行. :param str_tpl: string template, 一个字符串模板. :param new_line: 如果是 True, 那么会自动换行, 否则不会. 默认自动换行. :param kwargs: 额外的传递给 str_tpl 的参数. """ return self.print_str( str_tpl + self.terminal.clear_eol(), new_line=new_line, **kwargs, )
def _force_initial_column(self) -> str: """ 用回车符 (注意! 不是换行符) 把光标移动到本行初始位置. """ return self.print_str("\r")
[docs] def move_to_start(self) -> int: # pragma: no cover """ 把光标移动到初始位置. 本质上是把光标向上回退移动到第一行, 然后再用回车符把光标移动到本行初始位置. """ n = self.line_number if n: print(n * self.terminal.move_up, end="") print("\r", end="") sys.stdout.flush() self.line_number = 0 return n
[docs] def move_up(self, n: int): # pragma: no cover """ 把光标移动到初始位置. 本质上是把光标向上回退移动到第一行, 然后再用回车符把光标移动到本行初始位置. """ print(n * self.terminal.move_up, end="") sys.stdout.flush() self.line_number -= n
[docs] def move_down(self, n: int): # pragma: no cover """ 把光标移动到初始位置. 本质上是把光标向上回退移动到第一行, 然后再用回车符把光标移动到本行初始位置. """ print(n * self.terminal.move_down, end="") sys.stdout.flush() self.line_number += n
[docs] def clear_n_lines(self, n: int): # pragma: no cover """ 把光标以上的 n 行清空, 并把光标移动到行首. 常用于清除掉已经打印过的内容. """ if n > self.line_number: raise ValueError for _ in range(n): print(self.terminal.move_up, end="") print(self.terminal.clear_eol(), end="") print(self.terminal.clear_bol(), end="") self._force_initial_column() self.line_number -= n self.n_lines -= n
[docs] def clear_all(self): """ 清除所有内容, 并把光标移动到行首. Clear """ self.clear_n_lines(n=self.line_number)
@property def width(self): return self.terminal.width or 80 @property def height(self): return self.terminal.width or 24 # -------------------------------------------------------------------------- # Format important text # --------------------------------------------------------------------------
[docs] def format_shortcut(self, key: str) -> str: """ Add terminal color to a zelfred keyboard shortcut key. The default color is magenta. You can override this method to use other colors. Example: Tap :magenta:`Enter` to open url """ return f"{self.terminal.magenta}{key}{self.terminal.normal}"
@property def TAB(self) -> str: """ colored text :magenta:`Tab` """ return self.format_shortcut("Tab") @property def ENTER(self) -> str: """ colored text :magenta:`Enter` """ return self.format_shortcut("Enter") @property def CTRL_A(self) -> str: """ colored text :magenta:`Ctrl A` """ return self.format_shortcut("Ctrl A") @property def CTRL_W(self) -> str: """ colored text :magenta:`Ctrl W` """ return self.format_shortcut("Ctrl W") @property def CTRL_U(self) -> str: """ colored text :magenta:`Ctrl U` """ return self.format_shortcut("Ctrl U") @property def CTRL_P(self) -> str: """ colored text :magenta:`Ctrl P` """ return self.format_shortcut("Ctrl P") @property def F1(self) -> str: """ colored text :magenta:`F1` """ return self.format_shortcut("F1")
[docs] def format_highlight(self, text: str) -> str: """ Add terminal color to text you want to highlight. The default color is magenta. You can override this method to use other colors. Example: this is a very :cyan:`Important message`! """ return f"{self.terminal.cyan}{text}{self.terminal.normal}"
[docs] def format_key(self, key: str) -> str: """ Add terminal color to key in a key value pair. The default color is cyan. You can override this method to use other colors. """ return f"{self.terminal.cyan}{key}{self.terminal.normal}"
[docs] def format_value(self, value: str) -> str: """ Add terminal color to value in a key value pair. The default color is yellow. You can override this method to use other colors. """ return f"{self.terminal.yellow}{str(value)}{self.terminal.normal}"
[docs] def format_key_value(self, key: str, value: str) -> str: """ Add terminal color to a key value pair. Example: tag :cyan:`environment` = :yellow:`production` """ return f"{self.format_key(key)} = {self.format_value(value)}"
[docs]@dataclasses.dataclass class UIRender(Render): """ The Terminal UI Render. It extends the :class:`Render` class and highly optimized for the ``zelfred`` terminal UI layout. See below example, ``|`` represents the cursor: .. code-block:: (Query): user query here| [x] item 1 title here item 1 subtitle here [ ] item 2 title here item 2 subtitle here [ ] item 3 title here item 3 subtitle here The first line ``(Query): user query here|`` is the user input box, it always starts with ``(Query): ``, and user can enter any text input after that. The cursor cannot go beyond the ``: `` part. User can use ``Left``, ``Right``, ``Backspace`` and ``Delete`` keys to edit the user input box. Below the first line is the items drop down menu. Each item has two lines. The first line is the title, it always starts with ``[x] `` or ``[ ] ``. ``[x] `` means the item is selected, ``[ ] `` means the item is not selected. You can only select one item at a time, and by default the first item is selected. There always be one item selected. The second line is the subtitle, it has 2 indent spaces comparing to the title. User can use the ``UP`` and ``DOWN`` keys to navigate the items drop down menu. The dropdown menu can show up to 10 items at a time, if the dropdown menu has more than 10 items, user can scroll down to see the rest of the items using the ``DOWN`` key. The ``CTRL + UP`` and ``CTRL + DOWN`` key can scroll up and down 10 items at a time. """ prompt: str = dataclasses.field(default="(Query)") checked_mark: str = dataclasses.field(default="[x]") not_checked_mark: str = dataclasses.field(default="[ ]") subtitle_pad: str = dataclasses.field(default=" " * 6) def __post_init__(self): super().__post_init__() # -------------------------------------------------------------------------- # line editor # --------------------------------------------------------------------------
[docs] def print_line_editor( self, line_editor: LineEditor, ) -> str: # pragma: no cover """ Render the line editor, the ``(Query): user query here|`` part. And move the cursor to the beginning of the next line, so it's ready to print the dropdown menu. It assumes that there is nothing in the terminal UI before running this. :param line_editor: the new :class:`~zelfred.line_editor.LineEditor` object. :return: text of the line editor. """ return self.print_line( "{t.bold}{t.cyan}{prompt}: {t.normal}{line_editor.line}", prompt=self.prompt, line_editor=line_editor, t=self.terminal, )
[docs] def clear_line_editor(self): # pragma: no cover """ Clear the line editor (the query input box at the first line). It doesn't require to move the cursor to the beginning of the line editor before calling this function, it will handle that automatically. This function will move the cursor to the beginning of the line editor at the end. Before:: (Query): user query here| [x] item 1 title here item 1 subtitle here [ ] item 2 title here item 2 subtitle here [ ] item 3 title here item 3 subtitle here Before:: | # <- this line is empty [x] item 1 title here item 1 subtitle here [ ] item 2 title here item 2 subtitle here [ ] item 3 title here item 3 subtitle here """ self.move_to_start() self.print_str(self.terminal.clear_eol(), end="")
[docs] def update_line_editor( self, line_editor: LineEditor, ) -> str: # pragma: no cover """ Replace the user input with the new line editor, and move the cursor to the beginning of the next line, so it's ready to print the dropdown menu. :param line_editor: the new :class:`~zelfred.line_editor.LineEditor` object. :return: text of the line editor. """ self.clear_line_editor() return self.print_line_editor(line_editor)
# -------------------------------------------------------------------------- # dropdown # --------------------------------------------------------------------------
[docs] def process_title( self, title: str, line_width: int, ): # pragma: no cover """ Make sure the title fix the width of the terminal UI. :param title: the item title. :param line_width: the max width of the terminal UI, it will truncate the title and subtitle if they are too long. """ space = line_width - 4 - 1 if len(title) > space: half = (space - 3) // 2 return title[:half] + "..." + title[-half:] else: return title
[docs] def process_subtitle( self, subtitle: str, line_width: int, ): # pragma: no cover """ Make sure the subtitle fix the width of the terminal UI. :param subtitle: the item subtitle. :param line_width: the max width of the terminal UI, it will truncate the title and subtitle if they are too long. """ space = line_width - 6 - 1 if len(subtitle) > space: half = (space - 3) // 2 return subtitle[:half] + "..." + subtitle[-half:] else: return subtitle
[docs] def print_item( self, item: T_ITEM, selected: bool, line_width: int, ): # pragma: no cover """ Render one item in the dropdown menu. It looks like:: [x] item 1 title here - item 1 subtitle here :param item: the :class:`~zelfred.item.Item` object to render. :param line_width: the max width of the terminal UI, it will truncate the title and subtitle if they are too long. """ if selected: color = self.terminal.cyan symbol = self.checked_mark else: color = self.terminal.normal symbol = self.not_checked_mark self.print_line( "{t.bold}{color}{symbol} {color}{title}{t.normal}", color=color, symbol=symbol, title=self.process_title(item.title_text, line_width), t=self.terminal, ) self.print_line( "{pad}{t.normal}{subtitle}", pad=self.subtitle_pad, subtitle=self.process_subtitle(item.subtitle_text, line_width), t=self.terminal, )
[docs] def print_dropdown( self, dropdown: Dropdown, line_width: int, ) -> int: # pragma: no cover """ Render all items in the dropdown menu, it looks like:: [x] item 1 title here item 1 subtitle here [ ] item 2 title here item 2 subtitle here [ ] item 3 title here item 3 subtitle here It assumes that the terminal UI is showing the query line editor and there's no item in the dropdown menu in the terminal UI before running this. :param dropdown: the :class:`~zelfred.dropdown.Dropdown` object to render. :param line_width: the max width of the terminal UI, it will truncate the title and subtitle if they are too long. :return: number of items rendered. """ # the current terminal height may not be able to fit all items, # so we may need to update the ``self.dropdown._show_items_limit`` # to fit the terminal height. terminal_height = self.terminal.height if terminal_height <= 9: raise TerminalTooSmallError( "Terminal height is too small to render the UI! " "It has to have at least 8 lines." ) terminal_items_limit = (terminal_height - 3) // 2 dropdown.show_items_limit = min( dropdown.show_items_limit, terminal_items_limit, ) # raise error if the terminal width is too small terminal_width = self.terminal.width final_line_width = min(terminal_width, line_width) if terminal_width < 80: raise TerminalTooSmallError( "Terminal width is too small to render the UI! " "It has to have at least 80 ascii character wide." ) # if the cursor is at the first line, move to beginning of the second line # before printing if self.line_number == 0: print(self.terminal.move_down, end="") print("\r", end="") sys.stdout.flush() # print the dropdown menu menu = dropdown.menu for item, selected in dropdown.menu: self.print_item(item, selected=selected, line_width=final_line_width) n_item = len(menu) return n_item
[docs] def clear_dropdown(self): # pragma: no cover """ Before:: (Query): user query here| [x] item 1 title here item 1 subtitle here [ ] item 2 title here item 2 subtitle here [ ] item 3 title here item 3 subtitle here Before:: (Query): user query here | """ self.move_to_end() # self._debug() if self.n_lines > 1: self.clear_n_lines(n=self.line_number - 1)
[docs] def update_dropdown( self, dropdown: Dropdown, line_width: int, ) -> int: # pragma: no cover """ Replace the user input with the new line editor, and move the cursor to the beginning of the next line, so it's ready to print the dropdown menu. :param dropdown: the :class:`~zelfred.dropdown.Dropdown` object to render. :param line_width: the max width of the terminal UI, it will truncate the title and subtitle if they are too long. :return: number of items rendered. """ self.clear_dropdown() return self.print_dropdown(dropdown, line_width)
[docs] def move_cursor_to_line_editor( self, line_editor: LineEditor, ) -> T.Tuple[int, int]: # pragma: no cover """ After the :meth:`Dropdown.print_dropdown` is called, the cursor is at the end of the UI. This method moves the cursor back to the user input box, so user can keep typing. Here's an example:: (Query): user query here| <- want to move to here [x] item 1 title here item 1 subtitle here [ ] item 2 title here item 2 subtitle here [ ] item 3 title here item 3 subtitle here | <- cursor is currently here :return: n_vertical is the number of line to move up, n_horizontal is the number of character to move right. """ n_vertical = self.move_to_start() n_horizontal = len(self.prompt) + 2 + line_editor.cursor_position self.print_str(self.terminal.move_right(n_horizontal), end="") return n_vertical, n_horizontal
[docs] def print_ui( self, line_editor: LineEditor, dropdown: Dropdown, ) -> int: # pragma: no cover """ Render the entire UI, and move the cursor to the right position. It assumes the terminal has nothing. """ self.print_line_editor(line_editor) n_items = self.print_dropdown(dropdown, line_width=self.terminal.width) self.move_cursor_to_line_editor(line_editor) return n_items
[docs] def move_to_end(self) -> int: # pragma: no cover """ Move the cursor to the end, this method will be used before exit. Here's an example:: (Query): user query here| <- cursor is currently here [x] item 1 title here item 1 subtitle here [ ] item 2 title here item 2 subtitle here [ ] item 3 title here item 3 subtitle here | <- want to move to here :param n_items: number of items in the dropdown menu. """ if self.n_lines == 1 and self.line_number == 0: move_down_n_lines = 1 else: move_down_n_lines = self.n_lines - self.line_number # print(f"n_lines = {self.n_lines}, line_number = {self.line_number}, move_down_n_lines = {move_down_n_lines}") # DEBUG ONLY if move_down_n_lines: self.print_str(move_down_n_lines * self.terminal.move_down, end="") self.line_number += move_down_n_lines return move_down_n_lines
[docs] def clear_ui(self): # pragma: no cover """ Clear the entire UI, and move the cursor to the right position. """ self.clear_dropdown() self.clear_line_editor()
T_UI_RENDER = T.TypeVar("T_UI_RENDER", bound=UIRender)