359 lines
10 KiB
Python
359 lines
10 KiB
Python
import os
|
|
import time
|
|
import threading
|
|
import curses
|
|
from curses import textpad, ascii
|
|
from contextlib import contextmanager
|
|
|
|
from .docs import HELP
|
|
from .helpers import strip_textpad, clean
|
|
from .exceptions import EscapeInterrupt
|
|
|
|
__all__ = ['ESCAPE', 'UARROW', 'DARROW', 'BULLET', 'show_notification',
|
|
'show_help', 'LoadScreen', 'Color', 'text_input', 'curses_session',
|
|
'prompt_input', 'add_line', 'get_arrow']
|
|
|
|
ESCAPE = 27
|
|
|
|
# Curses does define constants for these (e.g. curses.ACS_BULLET)
|
|
# However, they rely on using the curses.addch() function, which has been
|
|
# found to be buggy and a PITA to work with. By defining them as unicode
|
|
# points they can be added via the more reliable curses.addstr().
|
|
# http://bugs.python.org/issue21088
|
|
UARROW = u'\u25b2'
|
|
DARROW = u'\u25bc'
|
|
BULLET = u'\u2022'
|
|
GOLD = u'\u272A'
|
|
|
|
def get_arrow(likes):
|
|
"""
|
|
Return the vote symbol to display, based on the `likes` paramater.
|
|
"""
|
|
|
|
if likes is None:
|
|
symbol, attr = BULLET, curses.A_BOLD
|
|
elif likes:
|
|
symbol, attr = UARROW, curses.A_BOLD | Color.GREEN
|
|
else:
|
|
symbol, attr = DARROW, curses.A_BOLD | Color.RED
|
|
return symbol, attr
|
|
|
|
|
|
def add_line(window, text, row=None, col=None, attr=None):
|
|
"""
|
|
Unicode aware version of curses's built-in addnstr method.
|
|
|
|
Safely draws a line of text on the window starting at position (row, col).
|
|
Checks the boundaries of the window and cuts off the text if it exceeds
|
|
the length of the window.
|
|
"""
|
|
|
|
# The following arg combinations must be supported to conform with addnstr
|
|
# (window, text)
|
|
# (window, text, attr)
|
|
# (window, text, row, col)
|
|
# (window, text, row, col, attr)
|
|
|
|
cursor_row, cursor_col = window.getyx()
|
|
row = row if row is not None else cursor_row
|
|
col = col if col is not None else cursor_col
|
|
|
|
max_rows, max_cols = window.getmaxyx()
|
|
n_cols = max_cols - col - 1
|
|
if n_cols <= 0:
|
|
# Trying to draw outside of the screen bounds
|
|
return
|
|
|
|
text = clean(text, n_cols)
|
|
params = [] if attr is None else [attr]
|
|
window.addstr(row, col, text, *params)
|
|
|
|
|
|
def show_notification(stdscr, message):
|
|
"""
|
|
Overlay a message box on the center of the screen and wait for user input.
|
|
|
|
Params:
|
|
message (list): List of strings, one per line.
|
|
"""
|
|
|
|
n_rows, n_cols = stdscr.getmaxyx()
|
|
|
|
box_width = max(map(len, message)) + 2
|
|
box_height = len(message) + 2
|
|
|
|
# Cut off the lines of the message that don't fit on the screen
|
|
box_width = min(box_width, n_cols)
|
|
box_height = min(box_height, n_rows)
|
|
message = message[:box_height-2]
|
|
|
|
s_row = (n_rows - box_height) // 2
|
|
s_col = (n_cols - box_width) // 2
|
|
|
|
window = stdscr.derwin(box_height, box_width, s_row, s_col)
|
|
window.erase()
|
|
window.border()
|
|
|
|
for index, line in enumerate(message, start=1):
|
|
add_line(window, line, index, 1)
|
|
window.refresh()
|
|
ch = stdscr.getch()
|
|
|
|
window.clear()
|
|
window = None
|
|
stdscr.refresh()
|
|
|
|
return ch
|
|
|
|
|
|
def show_help(stdscr):
|
|
"""
|
|
Overlay a message box with the help screen.
|
|
"""
|
|
|
|
show_notification(stdscr, HELP.splitlines())
|
|
|
|
|
|
class LoadScreen(object):
|
|
|
|
"""
|
|
Display a loading dialog while waiting for a blocking action to complete.
|
|
|
|
This class spins off a seperate thread to animate the loading screen in the
|
|
background.
|
|
|
|
Usage:
|
|
#>>> loader = LoadScreen(stdscr)
|
|
#>>> with loader(...):
|
|
#>>> blocking_request(...)
|
|
"""
|
|
|
|
def __init__(self, stdscr):
|
|
|
|
self._stdscr = stdscr
|
|
|
|
self._args = None
|
|
self._animator = None
|
|
self._is_running = None
|
|
|
|
def __call__(
|
|
self,
|
|
delay=0.5,
|
|
interval=0.4,
|
|
message='Downloading',
|
|
trail='...'):
|
|
"""
|
|
Params:
|
|
delay (float): Length of time that the loader will wait before
|
|
printing on the screen. Used to prevent flicker on pages that
|
|
load very fast.
|
|
interval (float): Length of time between each animation frame.
|
|
message (str): Message to display
|
|
trail (str): Trail of characters that will be animated by the
|
|
loading screen.
|
|
"""
|
|
|
|
self._args = (delay, interval, message, trail)
|
|
return self
|
|
|
|
def __enter__(self):
|
|
|
|
self._animator = threading.Thread(target=self.animate, args=self._args)
|
|
self._animator.daemon = True
|
|
|
|
self._is_running = True
|
|
self._animator.start()
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
|
|
self._is_running = False
|
|
self._animator.join()
|
|
|
|
def animate(self, delay, interval, message, trail):
|
|
|
|
start = time.time()
|
|
while (time.time() - start) < delay:
|
|
if not self._is_running:
|
|
return
|
|
|
|
message_len = len(message) + len(trail)
|
|
n_rows, n_cols = self._stdscr.getmaxyx()
|
|
s_row = (n_rows - 3) // 2
|
|
s_col = (n_cols - message_len - 1) // 2
|
|
window = self._stdscr.derwin(3, message_len + 2, s_row, s_col)
|
|
|
|
while True:
|
|
for i in range(len(trail) + 1):
|
|
|
|
if not self._is_running:
|
|
window.clear()
|
|
window = None
|
|
self._stdscr.refresh()
|
|
return
|
|
|
|
window.erase()
|
|
window.border()
|
|
window.addstr(1, 1, message + trail[:i])
|
|
window.refresh()
|
|
time.sleep(interval)
|
|
|
|
|
|
class Color(object):
|
|
|
|
"""
|
|
Color attributes for curses.
|
|
"""
|
|
|
|
_colors = {
|
|
'RED': (curses.COLOR_RED, -1),
|
|
'GREEN': (curses.COLOR_GREEN, -1),
|
|
'YELLOW': (curses.COLOR_YELLOW, -1),
|
|
'BLUE': (curses.COLOR_BLUE, -1),
|
|
'MAGENTA': (curses.COLOR_MAGENTA, -1),
|
|
'CYAN': (curses.COLOR_CYAN, -1),
|
|
'WHITE': (curses.COLOR_WHITE, -1),
|
|
}
|
|
|
|
@classmethod
|
|
def init(cls):
|
|
"""
|
|
Initialize color pairs inside of curses using the default background.
|
|
|
|
This should be called once during the curses initial setup. Afterwards,
|
|
curses color pairs can be accessed directly through class attributes.
|
|
"""
|
|
|
|
# Assign the terminal's default (background) color to code -1
|
|
curses.use_default_colors()
|
|
|
|
for index, (attr, code) in enumerate(cls._colors.items(), start=1):
|
|
curses.init_pair(index, code[0], code[1])
|
|
setattr(cls, attr, curses.color_pair(index))
|
|
|
|
@classmethod
|
|
def get_level(cls, level):
|
|
|
|
levels = [cls.MAGENTA, cls.CYAN, cls.GREEN, cls.YELLOW]
|
|
return levels[level % len(levels)]
|
|
|
|
|
|
def text_input(window, allow_resize=True):
|
|
"""
|
|
Transform a window into a text box that will accept user input and loop
|
|
until an escape sequence is entered.
|
|
|
|
If enter is pressed, return the input text as a string.
|
|
If escape is pressed, return None.
|
|
"""
|
|
|
|
window.clear()
|
|
|
|
# Set cursor mode to 1 because 2 doesn't display on some terminals
|
|
curses.curs_set(1)
|
|
|
|
# Turn insert_mode off to avoid the recursion error described here
|
|
# http://bugs.python.org/issue13051
|
|
textbox = textpad.Textbox(window, insert_mode=False)
|
|
textbox.stripspaces = 0
|
|
|
|
def validate(ch):
|
|
"Filters characters for special key sequences"
|
|
if ch == ESCAPE:
|
|
raise EscapeInterrupt
|
|
if (not allow_resize) and (ch == curses.KEY_RESIZE):
|
|
raise EscapeInterrupt
|
|
# Fix backspace for iterm
|
|
if ch == ascii.DEL:
|
|
ch = curses.KEY_BACKSPACE
|
|
return ch
|
|
|
|
# Wrapping in an exception block so that we can distinguish when the user
|
|
# hits the return character from when the user tries to back out of the
|
|
# input.
|
|
try:
|
|
out = textbox.edit(validate=validate)
|
|
except EscapeInterrupt:
|
|
out = None
|
|
|
|
curses.curs_set(0)
|
|
return strip_textpad(out)
|
|
|
|
|
|
def prompt_input(window, prompt, hide=False):
|
|
"""
|
|
Display a prompt where the user can enter text at the bottom of the screen
|
|
|
|
Set hide to True to make the input text invisible.
|
|
"""
|
|
|
|
attr = curses.A_BOLD | Color.CYAN
|
|
n_rows, n_cols = window.getmaxyx()
|
|
|
|
if hide:
|
|
prompt += ' ' * (n_cols - len(prompt) - 1)
|
|
window.addstr(n_rows-1, 0, prompt, attr)
|
|
out = window.getstr(n_rows-1, 1)
|
|
else:
|
|
window.addstr(n_rows - 1, 0, prompt, attr)
|
|
window.refresh()
|
|
subwin = window.derwin(1, n_cols - len(prompt),
|
|
n_rows - 1, len(prompt))
|
|
subwin.attrset(attr)
|
|
out = text_input(subwin)
|
|
|
|
return out
|
|
|
|
|
|
@contextmanager
|
|
def curses_session():
|
|
"""
|
|
Setup terminal and initialize curses.
|
|
"""
|
|
|
|
try:
|
|
# Curses must wait for some time after the Escape key is pressed to
|
|
# check if it is the beginning of an escape sequence indicating a
|
|
# special key. The default wait time is 1 second, which means that
|
|
# getch() will not return the escape key (27) until a full second
|
|
# after it has been pressed.
|
|
# Turn this down to 25 ms, which is close to what VIM uses.
|
|
# http://stackoverflow.com/questions/27372068
|
|
os.environ['ESCDELAY'] = '25'
|
|
|
|
# Initialize curses
|
|
stdscr = curses.initscr()
|
|
|
|
# Turn off echoing of keys, and enter cbreak mode,
|
|
# where no buffering is performed on keyboard input
|
|
curses.noecho()
|
|
curses.cbreak()
|
|
|
|
# In keypad mode, escape sequences for special keys
|
|
# (like the cursor keys) will be interpreted and
|
|
# a special value like curses.KEY_LEFT will be returned
|
|
stdscr.keypad(1)
|
|
|
|
# Start color, too. Harmless if the terminal doesn't have
|
|
# color; user can test with has_color() later on. The try/catch
|
|
# works around a minor bit of over-conscientiousness in the curses
|
|
# module -- the error return from C start_color() is ignorable.
|
|
try:
|
|
curses.start_color()
|
|
except:
|
|
pass
|
|
|
|
Color.init()
|
|
|
|
# Hide blinking cursor
|
|
curses.curs_set(0)
|
|
|
|
yield stdscr
|
|
|
|
finally:
|
|
|
|
if stdscr is not None:
|
|
stdscr.keypad(0)
|
|
curses.echo()
|
|
curses.nocbreak()
|
|
curses.endwin()
|