diff --git a/bullet/charDef.py b/bullet/charDef.py index b25ab2d..435ebcb 100644 --- a/bullet/charDef.py +++ b/bullet/charDef.py @@ -32,3 +32,28 @@ BACK_SPACE_CHAR = 8 SPACE_CHAR = ord(' ') INTERRUPT_KEY = 3 + +if sys.platform == "win32": + WIN_CH_BUFFER = [] + WIN_CHAR_MAP = { + b"\xe0H": ARROW_UP_KEY - ARROW_KEY_FLAG, + b"\x00H": ARROW_UP_KEY - ARROW_KEY_FLAG, + b"\xe0P": ARROW_DOWN_KEY - ARROW_KEY_FLAG, + b"\x00P": ARROW_DOWN_KEY - ARROW_KEY_FLAG, + b"\xe0M": ARROW_RIGHT_KEY - ARROW_KEY_FLAG, + b"\x00M": ARROW_RIGHT_KEY - ARROW_KEY_FLAG, + b"\xe0K": ARROW_LEFT_KEY - ARROW_KEY_FLAG, + b"\x00K": ARROW_LEFT_KEY - ARROW_KEY_FLAG, + b"\xe0G": HOME_KEY - MOD_KEY_FLAG, + b"\x00G": HOME_KEY - MOD_KEY_FLAG, + b"\xe0R": INSERT_KEY - MOD_KEY_FLAG, + b"\x00R": INSERT_KEY - MOD_KEY_FLAG, + b"\xe0S": DELETE_KEY - MOD_KEY_FLAG, + b"\x00S": DELETE_KEY - MOD_KEY_FLAG, + b"\xe0O": END_KEY - MOD_KEY_FLAG, + b"\x00O": END_KEY - MOD_KEY_FLAG, + b"\xe0I": PG_UP_KEY - MOD_KEY_FLAG, + b"\x00I": PG_UP_KEY - MOD_KEY_FLAG, + b"\xe0Q": PG_DOWN_KEY - MOD_KEY_FLAG, + b"\x00Q": PG_DOWN_KEY - MOD_KEY_FLAG + } diff --git a/bullet/client.py b/bullet/client.py index be3b061..a797594 100644 --- a/bullet/client.py +++ b/bullet/client.py @@ -4,7 +4,6 @@ from . import utils from . import cursor from . import keyhandler -import readline import re # Reusable private utility class @@ -90,7 +89,7 @@ def input(self): i == TAB_KEY or \ i == UNDEFINED_KEY: return - elif i == BACK_SPACE_KEY: + elif i == BACK_SPACE_KEY or i == BACK_SPACE_CHAR: if self.moveCursor(self.pos - 1): self.deleteChar() elif i == DELETE_KEY: @@ -113,6 +112,7 @@ def __init__( prompt: str = "", choices: list = [], bullet: str = "●", + prompt_color: str = colors.foreground["default"], bullet_color: str = colors.foreground["default"], word_color: str = colors.foreground["default"], word_on_switch: str = colors.REVERSE, @@ -134,6 +134,7 @@ def __init__( raise ValueError("Margin must be > 0!") self.prompt = prompt + self.prompt_color = prompt_color self.choices = choices self.pos = 0 @@ -195,6 +196,28 @@ def moveDown(self): utils.moveCursorDown(1) self.printBullet(self.pos) + @keyhandler.register(HOME_KEY) + def moveTop(self): + utils.clearLine() + old_pos = self.pos + self.pos = 0 + self.printBullet(old_pos) + while old_pos > 0: + utils.moveCursorUp(1) + old_pos -= 1 + self.printBullet(self.pos) + + @keyhandler.register(END_KEY) + def moveBottom(self): + utils.clearLine() + old_pos = self.pos + self.pos = len(self.choices) - 1 + self.printBullet(old_pos) + while old_pos < len(self.choices) - 1: + utils.moveCursorDown(1) + old_pos += 1 + self.printBullet(self.pos) + @keyhandler.register(NEWLINE_KEY) def accept(self): utils.moveCursorDown(len(self.choices) - self.pos) @@ -211,7 +234,7 @@ def interrupt(self): def launch(self, default = None): if self.prompt: - utils.forceWrite(' ' * self.indent + self.prompt + '\n') + utils.forceWrite(' ' * self.indent + self.prompt_color + self.prompt + colors.RESET + '\n') utils.forceWrite('\n' * self.shift) if default is not None: if type(default).__name__ != 'int': @@ -234,6 +257,7 @@ def __init__( prompt: str = "", choices: list = [], check: str = "√", + prompt_color: str = colors.foreground["default"], check_color: str = colors.foreground["default"], check_on_switch: str = colors.REVERSE, word_color: str = colors.foreground["default"], @@ -256,6 +280,7 @@ def __init__( raise ValueError("Margin must be > 0!") self.prompt = prompt + self.prompt_color = prompt_color self.choices = choices self.checked = [False] * len(self.choices) self.pos = 0 @@ -325,6 +350,28 @@ def moveDown(self): utils.moveCursorDown(1) self.printRow(self.pos) + @keyhandler.register(HOME_KEY) + def moveTop(self): + utils.clearLine() + old_pos = self.pos + self.pos = 0 + self.printRow(old_pos) + while old_pos > 0: + utils.moveCursorUp(1) + old_pos -= 1 + self.printRow(self.pos) + + @keyhandler.register(END_KEY) + def moveBottom(self): + utils.clearLine() + old_pos = self.pos + self.pos = len(self.choices) - 1 + self.printRow(old_pos) + while old_pos < len(self.choices) - 1: + utils.moveCursorDown(1) + old_pos += 1 + self.printRow(self.pos) + @keyhandler.register(NEWLINE_KEY) def accept(self): utils.moveCursorDown(len(self.choices) - self.pos) @@ -343,7 +390,7 @@ def interrupt(self): def launch(self, default = None): if self.prompt: - utils.forceWrite(' ' * self.indent + self.prompt + '\n') + utils.forceWrite(' ' * self.indent + self.prompt_color + self.prompt + colors.RESET + '\n') utils.forceWrite('\n' * self.shift) if default is None: default = [] @@ -367,11 +414,12 @@ def launch(self, default = None): class YesNo: def __init__( self, - prompt, - default="y", - indent=0, - word_color=colors.foreground["default"], - prompt_prefix="[y/n] " + prompt: str = "", + default: str = "y", + indent: int = 0, + prompt_color: str = colors.foreground["default"], + word_color: str = colors.foreground["default"], + prompt_prefix: str = "[y/n] " ): self.indent = indent if not prompt: @@ -380,6 +428,7 @@ def __init__( raise ValueError("`default` can only be 'y' or 'n'!") self.default = "[{}]".format(default.lower()) self.prompt = prompt_prefix + prompt + self.prompt_color = prompt_color self.word_color = word_color def valid(self, ans): @@ -388,15 +437,15 @@ def valid(self, ans): ans = ans.lower() if "yes".startswith(ans) or "no".startswith(ans): return True - utils.moveCursorUp(1) - utils.forceWrite(' ' * self.indent + self.prompt + self.default) + utils.moveCursorUp(self.prompt.count("\n") + 1) + utils.forceWrite(' ' * self.indent + self.prompt_color + self.prompt + self.default + colors.RESET) utils.forceWrite(' ' * len(ans)) utils.forceWrite('\b' * len(ans)) return False def launch(self): my_input = myInput(word_color = self.word_color) - utils.forceWrite(' ' * self.indent + self.prompt + self.default) + utils.forceWrite(' ' * self.indent + self.prompt_color + self.prompt + self.default + colors.RESET) while True: ans = my_input.input() if ans == "": @@ -409,25 +458,25 @@ def launch(self): class Input: def __init__( self, - prompt, - default = "", - indent = 0, - word_color = colors.foreground["default"], - strip = False, - pattern = "" + prompt : str = "", + default: str = "", + indent: int = 0, + prompt_color: str = colors.foreground["default"], + word_color: str = colors.foreground["default"], + strip: bool = False, + pattern: str = "" ): self.indent = indent if not prompt: raise ValueError("Prompt can not be empty!") self.default = "[{}]".format(default) if default else "" self.prompt = prompt + self.prompt_color = prompt_color self.word_color = word_color self.strip = strip self.pattern = pattern def valid(self, ans): - if ans is None: - return False if not bool(re.match(self.pattern, ans)): utils.moveCursorUp(1) utils.forceWrite(' ' * self.indent + self.prompt + self.default) @@ -437,7 +486,7 @@ def valid(self, ans): return True def launch(self): - utils.forceWrite(' ' * self.indent + self.prompt + self.default) + utils.forceWrite(' ' * self.indent + self.prompt_color + self.prompt + self.default + colors.RESET) sess = myInput(word_color = self.word_color) if not self.pattern: while True: @@ -447,7 +496,7 @@ def launch(self): return self.default[1:-1] else: utils.moveCursorUp(1) - utils.forceWrite(' ' * self.indent + self.prompt + self.default) + utils.forceWrite(' ' * self.indent + self.prompt_color + self.prompt + self.default + colors.RESET) utils.forceWrite(' ' * len(result)) utils.forceWrite('\b' * len(result)) else: @@ -462,12 +511,14 @@ def launch(self): class Password: def __init__( self, - prompt, - indent = 0, - hidden = '*', - word_color = colors.foreground["default"] + prompt: str = "", + indent: int = 0, + hidden: str = '*', + prompt_color: str = colors.foreground["default"], + word_color: str = colors.foreground["default"] ): self.indent = indent + self.prompt_color = prompt_color if not prompt: raise ValueError("Prompt can not be empty!") self.prompt = prompt @@ -475,21 +526,23 @@ def __init__( self.word_color = word_color def launch(self): - utils.forceWrite(' ' * self.indent + self.prompt) + utils.forceWrite(' ' * self.indent + self.prompt_color + self.prompt + colors.RESET) return myInput(password = True, hidden = self.hidden, word_color = self.word_color).input() class Numbers: def __init__( self, - prompt, - indent = 0, - word_color = colors.foreground["default"], + prompt: str = "", + indent: int = 0, + prompt_color: str = colors.foreground["default"], + word_color: str = colors.foreground["default"], type = float ): self.indent = indent if not prompt: raise ValueError("Prompt can not be empty!") self.prompt = prompt + self.prompt_color = prompt_color self.word_color = word_color self.type = type @@ -499,7 +552,7 @@ def valid(self, ans): return True except: utils.moveCursorUp(1) - utils.forceWrite(' ' * self.indent + self.prompt) + utils.forceWrite(' ' * self.indent + self.prompt_color + self.prompt + colors.RESET) utils.forceWrite(' ' * len(ans)) utils.forceWrite('\b' * len(ans)) return False @@ -511,7 +564,7 @@ def launch(self, default = None): except: raise ValueError("`default` should be a " + str(self.type)) my_input = myInput(word_color = self.word_color) - utils.forceWrite(' ' * self.indent + self.prompt) + utils.forceWrite(' ' * self.indent + self.prompt_color + self.prompt + colors.RESET) while True: ans = my_input.input() if ans == "" and default is not None: @@ -544,6 +597,7 @@ def summarize(self): print(prompt, answer) def launch(self): + self.result = [] for ui in self.components: self.result.append((ui.prompt, ui.launch())) if not self.separator: @@ -561,6 +615,7 @@ def __init__( pointer = "→", up_indicator: str = "↑", down_indicator: str = "↓", + prompt_color: str = colors.foreground["default"], pointer_color: str = colors.foreground["default"], indicator_color: str = colors.foreground["default"], word_color: str = colors.foreground["default"], @@ -584,6 +639,7 @@ def __init__( raise ValueError("Margin must be > 0!") self.prompt = prompt + self.prompt_color = prompt_color self.choices = choices self.pos = 0 # Position of item at current cursor. @@ -615,7 +671,7 @@ def __init__( self.return_index = return_index def renderRows(self): - self.printRow(self.top, indicator = self.up_indicator if self.top != 0 else '') + self.printRow(self.top, indicator = self.up_indicator if self.top != 0 else ' ') utils.forceWrite('\n') i = self.top @@ -645,7 +701,7 @@ def printRow(self, idx, indicator=''): def moveUp(self): if self.pos == self.top: if self.top == 0: - return # Already reached top-most position + return # Already reached top-most position else: utils.clearConsoleDown(self.height) self.pos, self.top = self.pos - 1, self.top - 1 @@ -665,7 +721,7 @@ def moveUp(self): def moveDown(self): if self.pos == self.top + self.height - 1: if self.top + self.height == len(self.choices): - return + return # Already reached bottom-most position else: utils.clearConsoleUp(self.height) utils.moveCursorDown(1) @@ -681,6 +737,67 @@ def moveDown(self): utils.moveCursorDown(1) self.printRow(self.pos) + @keyhandler.register(HOME_KEY) + def moveTop(self): + if self.pos == self.top: + if self.top == 0: + return # Already reached top-most position + else: + pass # Not at top-most position + else: + utils.moveCursorUp(self.pos - self.top) + utils.clearConsoleDown(self.height) + self.pos = self.top = 0 + self.renderRows() + utils.moveCursorUp(self.height) + + @keyhandler.register(END_KEY) + def moveBottom(self): + if self.pos == self.top + self.height - 1: + if self.top + self.height == len(self.choices): + return # Already reached bottom-most position + else: + pass # Not already at bottm-most position + else: + utils.moveCursorDown(self.height - (self.pos - self.top + 1)) + utils.clearConsoleUp(self.height) + utils.moveCursorDown(1) + self.top = len(self.choices) - self.height + self.pos = len(self.choices) - 1 + self.renderRows() + utils.moveCursorUp(1) + + @keyhandler.register(PG_UP_KEY) + def movePgUp(self): + if self.pos == self.top: + if self.top == 0: + return # Already reached top-most position + else: + pass # Not at top-most position + else: + utils.moveCursorUp(self.pos - self.top) + utils.clearConsoleDown(self.height) + self.top = max(0, self.top - self.height) + self.pos = max(0, self.pos - self.height) + self.renderRows() + utils.moveCursorUp(self.height - (self.pos - self.top)) + + @keyhandler.register(PG_DOWN_KEY) + def movePgDown(self): + if self.pos == self.top + self.height - 1: + if self.top + self.height == len(self.choices): + return # Already reached bottom-most position + else: + utils.moveCursorDown(self.height - (self.pos - self.top + 1)) + else: + utils.moveCursorDown(self.height - (self.pos - self.top + 1)) + utils.clearConsoleUp(self.height) + utils.moveCursorDown(1) + self.top = min(len(self.choices) - self.height, self.top + self.height) + self.pos = min(len(self.choices) - 1, self.pos + self.height) + self.renderRows() + utils.moveCursorUp(1 + self.height - (self.pos - self.top + 1)) + @keyhandler.register(NEWLINE_KEY) def accept(self): d = self.top + self.height - self.pos @@ -690,7 +807,7 @@ def accept(self): return ret, self.pos self.pos = 0 return ret - + @keyhandler.register(INTERRUPT_KEY) def interrupt(self): d = self.top + self.height - self.pos @@ -699,7 +816,7 @@ def interrupt(self): def launch(self): if self.prompt: - utils.forceWrite(' ' * self.indent + self.prompt + '\n') + utils.forceWrite(' ' * self.indent + self.prompt_color + self.prompt + colors.RESET + '\n') utils.forceWrite('\n' * self.shift) self.renderRows() utils.moveCursorUp(self.height) diff --git a/bullet/utils.py b/bullet/utils.py index 255df2b..5956334 100644 --- a/bullet/utils.py +++ b/bullet/utils.py @@ -1,6 +1,5 @@ import os import sys -import tty, termios import string import shutil from .charDef import * @@ -10,13 +9,44 @@ def mygetc(): ''' Get raw characters from input. ''' - fd = sys.stdin.fileno() - old_settings = termios.tcgetattr(fd) - try: - tty.setraw(fd) - ch = sys.stdin.read(1) - finally: - termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + if os.name == 'nt': + import msvcrt + encoding = "mbcs" + # Flush the keyboard buffer + while msvcrt.kbhit(): + msvcrt.getwch() + if (len(WIN_CH_BUFFER) == 0): + # Read the keystroke + ch = msvcrt.getwch() + # If it is a prefix char, get second part + if ch.encode(encoding) in (b"\x00", b"\xe0"): + ch2 = ch + msvcrt.getwch() + # Translate actual Win chars to bullet char types + try: + chx = chr(WIN_CHAR_MAP[ch2.encode(encoding)]) + WIN_CH_BUFFER.append(chr(MOD_KEY_INT)) + WIN_CH_BUFFER.append(chx) + if ord(chx) in (INSERT_KEY - MOD_KEY_FLAG, + DELETE_KEY - MOD_KEY_FLAG, + PG_UP_KEY - MOD_KEY_FLAG, + PG_DOWN_KEY - MOD_KEY_FLAG): + WIN_CH_BUFFER.append(chr(MOD_KEY_DUMMY)) + ch = chr(ESC_KEY) + except KeyError: + ch = ch2[1] + else: + pass + else: + ch = WIN_CH_BUFFER.pop(0) + elif os.name == 'posix': + import tty, termios + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + tty.setraw(fd) + ch = sys.stdin.read(1) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) return ch def getchar(): @@ -29,7 +59,7 @@ def getchar(): ord(c) == NEWLINE_KEY: return c - elif ord(c) == BACK_SPACE_KEY: + elif ord(c) == BACK_SPACE_KEY or ord(c) == BACK_SPACE_CHAR: return c elif ord(c) == ESC_KEY: @@ -37,11 +67,15 @@ def getchar(): if ord(combo) == MOD_KEY_INT: key = mygetc() if ord(key) >= MOD_KEY_BEGIN - MOD_KEY_FLAG and ord(key) <= MOD_KEY_END - MOD_KEY_FLAG: - if ord(mygetc()) == MOD_KEY_DUMMY: + if ord(key) in (HOME_KEY - MOD_KEY_FLAG, END_KEY - MOD_KEY_FLAG): return chr(ord(key) + MOD_KEY_FLAG) else: - return UNDEFINED_KEY - elif ord(key) >= ARROW_KEY_BEGIN - ARROW_KEY_FLAG and ord(key) <= ARROW_KEY_END - ARROW_KEY_FLAG: + trail = mygetc() + if ord(trail) == MOD_KEY_DUMMY: + return chr(ord(key) + MOD_KEY_FLAG) + else: + return UNDEFINED_KEY + elif ARROW_KEY_BEGIN - ARROW_KEY_FLAG <= ord(key) <= ARROW_KEY_END - ARROW_KEY_FLAG: return chr(ord(key) + ARROW_KEY_FLAG) else: return UNDEFINED_KEY