diff --git a/README.md b/README.md index 29d82e5..6fb6298 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,20 @@ 命令行交互辅助工具 -v1.3 +**v2.0** + +1. 代码重构,使用方式变更 + +**v1.3** 1. 菜单的空白字符可以自定义了 -v1.2 +**v1.2** 1. 增加显示版本号 2. 禁用获取菜单选项时的退出值 -v1.1 +**v1.1** 1. 增加注释 2. 其他改动 diff --git a/clihelper.py b/clihelper.py index 4e642d4..0d469b7 100644 --- a/clihelper.py +++ b/clihelper.py @@ -12,9 +12,9 @@ ####################################################### from __future__ import annotations -from typing import Callable +from typing import Callable, TypedDict -version = (1, 3, 20230705) +version = (2, 0, 20231219) def request_input(prompt_msg: str, @@ -24,7 +24,7 @@ def request_input(prompt_msg: str, default_val: str = "", has_quit_val: bool = True, quit_val: str = "q", - check_func: Callable[[str], bool] | None = None, + check_func: Callable[[str], bool] = None, ask_again: bool = True, need_confirm: bool = False) -> tuple[str, bool]: """ @@ -79,158 +79,159 @@ def request_input(prompt_msg: str, return input_val, True -class CliHelper(object): - """命令行辅助工具""" +class MenuConfig(TypedDict, total=False): + left_padding: int + right_padding: int + top_padding: int + bottom_padding: int + edge_char: str + wall_char: str + serial_marker: str + draw_menu_again: bool - class _OptionNode(object): - """选项节点""" - def __init__(self, - title: str = "Untitled Option", - exec_func: Callable = None, - args: tuple | None = None, - kwargs: dict | None = None): - """ - :param title: 选项标题 - :param exec_func: 选择该选项后要执行的函数;对于用户设定的节点,该执行函数必须返回 None - :param args: 执行函数的位置参数 - :param kwargs: 执行函数的命名参数 - """ - self.title = title - self.exec_func = exec_func if exec_func is not None else self._default_func - self.args = args if args is not None else () - self.kwargs = kwargs if kwargs is not None else {} - self.children = [] # type: list[CliHelper._OptionNode] +DEFAULT_MENU_CONFIG: MenuConfig = { + "left_padding": 2, + "right_padding": 10, + "top_padding": 1, + "bottom_padding": 1, + "edge_char": "#", + "wall_char": " ", + "serial_marker": ". ", + "draw_menu_again": False, +} - @staticmethod - def _default_func(): - """用户没有指定执行函数时执行的默认函数""" - print("No function") + +class _OptionNode(object): + """选项节点""" # 返回上级菜单的执行函数的返回值 _RETURN_CODE = 0xbabe # 进入下级菜单的执行函数的返回值 _ENTER_CODE = 0xbeef + # 退出整个工具的返回值 + _QUIT_CODE = 0xcafe - def __init__(self, *, - left_padding: int = 2, - right_padding: int = 10, - top_padding: int = 1, - bottom_padding: int = 1, - border_char: str = "#", - wall_char: str = " ", - serial_marker: str = ". ", - draw_menu_again: bool = False, - show_version: bool = True): - - self._root = self._OptionNode("Main Menu") - - self._left_padding = left_padding - self._right_padding = right_padding - self._top_padding = top_padding - self._bottom_padding = bottom_padding - self._border_char = border_char - self._wall_char = wall_char - self._serial_marker = serial_marker - self._draw_menu_again = draw_menu_again - - if show_version: - print(f"CliHelper v{version[0]}.{version[1]} ({version[-1]})\n") + def __init__(self, + title: str = "Untitled Option", + exec_func: Callable = None, + args: tuple = None, + kwargs: dict = None, + menu_config: MenuConfig = None, + _is_root: bool = False): + """ + :param title: 选项标题 + :param exec_func: 选择该选项后要执行的函数;对于用户设定的节点,该执行函数必须返回 None + :param args: 执行函数的位置参数 + :param kwargs: 执行函数的命名参数 + :param menu_config: 绘制子选项菜单的参数;只在根选项设置,子选项继承 + :param _is_root: 标记当前节点是否是根节点;内部使用 + """ + self.title = title + self.exec_func = exec_func if exec_func is not None else self._default_func + self.args = args if args is not None else () + self.kwargs = kwargs if kwargs is not None else {} + self.menu_config = menu_config if menu_config is not None else DEFAULT_MENU_CONFIG + self.children = [] # type: list[_OptionNode] + self._is_root = _is_root @staticmethod - def _get_max_length_of_option_titles(p_option: _OptionNode) -> int: + def _default_func(): + """用户没有指定执行函数时执行的默认函数""" + print("No function") + + def _get_max_length_of_option_titles(self) -> int: """ 获取菜单选项中最长的选项标题的长度 - :param p_option: 菜单选项的父选项节点 :return: 菜单选项中最长的选项标题的长度 """ - return max([len(c.title) for c in p_option.children]) + return max([len(c.title) for c in self.children]) - def _draw_menu(self, p_option: _OptionNode): + def _draw_menu(self): """ 绘制菜单选项 - :param p_option: 要绘制的菜单选项的父选项节点 :return: None """ - left = self._left_padding - right = self._right_padding - top = self._top_padding - bottom = self._bottom_padding - bc = self._border_char - wc = self._wall_char - sm = self._serial_marker + left = self.menu_config["left_padding"] + right = self.menu_config["right_padding"] + top = self.menu_config["top_padding"] + bottom = self.menu_config["bottom_padding"] + ec = self.menu_config["edge_char"] + wc = self.menu_config["wall_char"] + sm = self.menu_config["serial_marker"] - max_len_option = self._get_max_length_of_option_titles(p_option) + max_len_option = self._get_max_length_of_option_titles() # 获取最大选项序号的位数 - max_len_serial = len(str(len(p_option.children))) + max_len_serial = len(str(len(self.children))) # 一个选项的总宽度 option_width = max_len_serial + len(sm) + max_len_option # 菜单的宽度(加上左右两边的空白,但不包括边界) menu_width = left + option_width + right - # 上下边界 - top_bottom_border = bc * (menu_width + len(bc) * 2) - # 墙是包括边界和空白 - top_bottom_wall = f"{bc}{wc * menu_width}{bc}" - left_wall = f"{bc}{wc * left}" - right_wall = f"{wc * right}{bc}" + # 上下边界字符 + top_bottom_edge = ec * (menu_width + len(ec) * 2) + # 墙是包括边界和空白(单行) + top_bottom_wall = f"{ec}{wc * menu_width}{ec}" + left_wall = f"{ec}{wc * left}" + right_wall = f"{wc * right}{ec}" - print("\n".join([top_bottom_border] + [top_bottom_wall] * top)) - for i, opt in enumerate(p_option.children): + print("\n".join([top_bottom_edge] + [top_bottom_wall] * top)) + for i, opt in enumerate(self.children): # 序号右对齐,选项标题左对齐 print(f"{left_wall}{i + 1:>{max_len_serial}}{sm}{opt.title:<{max_len_option}}{right_wall}") - print("\n".join([top_bottom_wall] * bottom + [top_bottom_border])) + print("\n".join([top_bottom_wall] * bottom + [top_bottom_edge])) - @staticmethod - def _request_option(p_option: _OptionNode) -> _OptionNode | None: + def _request_option(self) -> _OptionNode | None: """ 请求用户输入选项序号 - :param p_option: 当前菜单选项的父选项节点 :return: 用户选择的选项节点或 None """ choice, state = request_input( "\nYour option", "No such option.", has_default_val=False, has_quit_val=False, - check_func=lambda x: x.isdigit() and 1 <= int(x) <= len(p_option.children), + check_func=lambda x: x.isdigit() and 1 <= int(x) <= len(self.children), ask_again=False, need_confirm=False ) if state is False: return None - return p_option.children[int(choice) - 1] + return self.children[int(choice) - 1] - def _enter_next_level(self, p_option: _OptionNode) -> int: + def _enter_next_level(self) -> int: """ 进入下一级菜单(执行函数) - :param p_option: 下一级菜单的入口选项节点 :return: 状态码 """ state = self._ENTER_CODE - while state != self._RETURN_CODE: - # 如果上次执行不是返回上级菜单,就继续在当前菜单循环 + while state not in (self._RETURN_CODE, self._QUIT_CODE): + # 如果上次执行不是返回上级菜单或退出,就继续在当前菜单循环 # 用户指定是否需要重新绘制菜单, # 或者如果上次执行是进入下级菜单,则一定要绘制菜单 # 这里 state == self._ENTER_CODE 有两种情况, # 一种是第一次进入 while 循环的时候,此时表示用户选择进入下级菜单 # 另一种是用户选择返回上级菜单,结束了 while 循环, # 此函数返回 self._ENTER_CODE,被上层的该函数的 while 循环捕获到 - if self._draw_menu_again or state == self._ENTER_CODE: - self._draw_menu(p_option) + if self.menu_config["draw_menu_again"] or state == self._ENTER_CODE: + self._draw_menu() # 此处设置为 None,是为了防止用户输入不符合要求的序号时, # 重新循环 state 还是 self._ENTER_CODE 导致又绘制了一遍菜单 state = None - opt = self._request_option(p_option) + opt = self._request_option() if opt is None: continue state = opt.exec_func(*opt.args, **opt.kwargs) - return self._ENTER_CODE + # 如果是退出信号,就一路退回到根节点 + if state == self._QUIT_CODE: + return self._QUIT_CODE + else: + return self._ENTER_CODE def _return_previous_level(self) -> int: """ @@ -240,8 +241,15 @@ class CliHelper(object): """ return self._RETURN_CODE + def _quiting_level(self) -> int: + """ + 退出该工具(执行函数) + + :return: 状态码 + """ + return self._QUIT_CODE + def add_option(self, - parent: _OptionNode | None = None, title: str = "Untitled Option", exec_func: Callable = None, args: tuple = None, @@ -249,50 +257,54 @@ class CliHelper(object): """ 添加选项 - :param parent: 要添加的选项的父选项节点,若为 None 则为根节点(显示在主菜单) :param title: 选项标题 :param exec_func: 选择该选项后要执行的函数;对于用户设定的节点,该执行函数必须返回 None :param args: 执行函数的位置参数 :param kwargs: 执行函数的命名参数 :return: 选项节点对象 """ - if parent is None: - parent = self._root # 如果在此父选项下创建了新的选项,该父选项会成为下一级的菜单入口 # 此处进行后缀标记 - if not parent.title.endswith(" [d]"): - parent.title = f"{parent.title} [d]" + if not self.title.endswith(" [d]"): + self.title = f"{self.title} [d]" - option = self._OptionNode(title, exec_func, args, kwargs) - parent.children.append(option) + option = _OptionNode(title, exec_func, args, kwargs, self.menu_config, _is_root=False) + self.children.append(option) # 父选项成为菜单入口之后,其执行函数只能为“进入下一级菜单” - parent.exec_func = self._enter_next_level - # “进入下一级菜单”这个执行函数的参数就是其选项本身 - parent.args = (parent, ) - + self.exec_func = self._enter_next_level return option - def add_return_option(self, p_option: _OptionNode): + def add_return_option(self): """ 添加返回上级菜单的选项 - :param p_option: 要添加该选项的父选项节点 :return: None """ - self.add_option(p_option, title="[Back]", exec_func=self._return_previous_level) + self.add_option(title="[Back]", exec_func=self._return_previous_level) - def add_exit_option(self, p_option: _OptionNode | None = None): + def add_exit_option(self): """ 添加退出菜单选项 - :param p_option: 要添加该选项的父选项节点 :return: None """ - if p_option is None: - p_option = self._root - self.add_option(p_option, title="[Exit]", exec_func=exit, args=(0,)) + self.add_option(title="[Exit]", exec_func=self._quiting_level) def start_loop(self): - """开启主循环""" - self._enter_next_level(self._root) + """开启循环,只在根选项调用""" + if self._is_root: + self._enter_next_level() + print("Exit CliHelper. Bye.") + else: + print("Current option is not the root option. Exit.") + + +class CliHelper(_OptionNode): + """命令行辅助工具""" + + def __init__(self, menu_config: MenuConfig = None, show_version: bool = True): + super().__init__("Main Menu", menu_config=menu_config, _is_root=True) + + if show_version: + print(f"CliHelper v{version[0]}.{version[1]} ({version[-1]})\n") diff --git a/test.py b/test.py index 01aa1ab..49612d9 100644 --- a/test.py +++ b/test.py @@ -11,18 +11,31 @@ def new_folder(name): def test_helper(): - c = CliHelper(right_padding=20, draw_menu_again=False) - n = c.add_option(title="New") - c.add_option(title="Open File", exec_func=open_file) - c.add_option(title="Save") - c.add_exit_option() + # c = CliHelper(right_padding=20, draw_menu_again=False) + # n = c.add_option(title="New") + # c.add_option(title="Open File", exec_func=open_file) + # c.add_option(title="Save") + # c.add_exit_option() + # + # c.add_return_option(n) + # c.add_option(n, "New File") + # c.add_option(n, "New Folder", new_folder, ("Temp",)) + # c.add_exit_option(n) + # + # c.start_loop() + ch = CliHelper() + n = ch.add_option("New") + n.add_return_option() + n.add_option("New File") + n.add_option("New Folder", new_folder, ("Temp", )) + n.add_exit_option() - c.add_return_option(n) - c.add_option(n, "New File") - c.add_option(n, "New Folder", new_folder, ("Temp",)) - c.add_exit_option(n) + ch.add_option("Open File", open_file) + ch.add_option("Save") + ch.add_exit_option() - c.start_loop() + ch.start_loop() + # n.start_loop() def test_req():