Source code for x84.bbs.editor
""" Editor package for x/84. """
# TODO(jquast): upstream, the blessed project has a ticket to determine
# the current cursor position, https://github.com/jquast/blessed/issues/19
# once implemented, LineEditor can be used with absolute positioning.
# std imports
#
# TODO(jquast): Surely somebody has written a readline-like implementation
# for python that we could use instead. We cannot use readline directly
# due to its C and Unix dependency.
import warnings
# local
from x84.bbs.ansiwin import AnsiWindow, GLYPHSETS
from x84.bbs.session import getterminal
from x84.bbs.output import echo
#: default command-key mapping.
PC_KEYSET = {'refresh': [unichr(12), ],
'backspace': [unichr(8), unichr(127), ],
'backword': [unichr(23), ],
'enter': [u'\r', ],
'exit': [unichr(27), ], }
[docs]class LineEditor(object):
"""
This unicode line editor is unaware of its (y, x) position.
It is great for prompting a quick phrase on any terminal,
such as a ``login:`` prompt.
"""
_hidden = False
_width = 0
# pylint: disable=R0913
# Too many arguments (7/5) (col 4)
def __init__(self, width=None, content=u'', hidden=False,
colors=None, glyphs=None, keyset=None):
"""
Class initializer.
:param int width: the maximum input length.
:param str content: given default content.
:param str hidden: When non-False, a single 'mask' character for output.
:param dict colors: optional dictionary containing key 'highlight'.
:param dict glyphs: optional dictionary of window border characters.
:param dict keyset: optional dictionary of line editing values.
"""
self._term = getterminal()
self.content = content or u''
self.hidden = hidden
self._width = width
self._input_length = self._term.length(content)
self._quit = False
self._carriage_returned = False
self.init_keystrokes(keyset=keyset or PC_KEYSET.copy())
self.init_theme(colors=colors, glyphs=glyphs)
[docs] def init_theme(self, colors=None, glyphs=None, hidden=False):
""" Set color, bordering glyphs, and hidden attribute theme. """
# set defaults,
self.colors = {'highlight': self._term.reverse}
self.glyphs = GLYPHSETS['thin'].copy()
# allow user override
if colors is not None:
self.colors.update(colors)
if glyphs is not None:
self.glyphs.update(glyphs)
if hidden:
self.hidden = hidden
[docs] def init_keystrokes(self, keyset):
""" Sets keyboard keys for various editing keystrokes. """
self.keyset = keyset
self.keyset['refresh'].append(self._term.KEY_REFRESH)
self.keyset['backspace'].append(self._term.KEY_BACKSPACE)
self.keyset['backspace'].append(self._term.KEY_DELETE)
self.keyset['enter'].append(self._term.KEY_ENTER)
self.keyset['exit'].append(self._term.KEY_ESCAPE)
@property
def quit(self):
""" Whether a 'quit' character has been handled, such as escape. """
return self._quit
@property
def carriage_returned(self):
""" Whether the carriage return character has been handled. """
return self._carriage_returned
@property
def hidden(self):
"""
When non-False, a single 'mask' character for hiding input.
Used by password prompts.
"""
return self._hidden
@hidden.setter
def hidden(self, value):
# pylint: disable=C0111
# Missing docstring
assert value is False or 1 == len(value)
self._hidden = value
@property
def width(self):
""" Limit of characters to receive on input. """
return self._width
@width.setter
def width(self, value):
# pylint: disable=C0111
# Missing docstring
self._width = value
[docs] def refresh(self):
"""
Return string sequence suitable for refreshing editor.
No movement or positional sequences are returned.
"""
disp_lightbar = u''.join((
self._term.normal,
self.colors.get('highlight', u''),
' ' * self.width,
'\b' * self.width))
content = self.content
if self.hidden:
content = self.hidden * self._term.length(self.content)
return u''.join((disp_lightbar, content, self._term.cursor_visible))
[docs] def process_keystroke(self, keystroke):
"""
Process the keystroke and return string to refresh.
:param blessed.keyboard.Keystroke keystroke: input from ``inkey()``.
:rtype: str
:returns: string sequence suitable for refresh.
"""
self._quit = False
keystroke = hasattr(keystroke, 'code') and keystroke.code or keystroke
if keystroke in self.keyset['refresh']:
return u'\b' * self._term.length(self.content) + self.refresh()
elif keystroke in self.keyset['backspace']:
if len(self.content) != 0:
len_toss = self._term.length(self.content[-1])
self.content = self.content[:-1]
return u''.join((
u'\b' * len_toss,
u' ' * len_toss,
u'\b',))
elif keystroke in self.keyset['backword']:
if len(self.content) != 0:
ridx = self.content.rstrip().rfind(' ') + 1
toss = self._term.length(self.content[ridx:])
move = len(self.content[ridx:])
self.content = self.content[:ridx]
return u''.join((
u'\b' * toss,
u' ' * move,
u'\b' * move,))
elif keystroke in self.keyset['enter']:
self._carriage_returned = True
elif keystroke in self.keyset['exit']:
self._quit = True
elif isinstance(keystroke, int):
return u''
elif (ord(keystroke) >= ord(' ') and
(self._term.length(self.content) < self.width
or self.width is None)):
self.content += keystroke
return keystroke if not self.hidden else self.hidden
return u''
[docs] def read(self):
"""
Reads input until the ENTER or ESCAPE key is pressed (Blocking).
Allows backspacing. Returns unicode text, or None when canceled.
"""
self._carriage_returned = False
self._quit = False
echo(self.refresh())
term = getterminal()
while not (self.quit or self.carriage_returned):
inp = term.inkey()
echo(self.process_keystroke(inp))
echo(self._term.normal)
if not self.quit:
return self.content
return None
[docs]class ScrollingEditor(AnsiWindow):
"""
A single line Editor, requires absolute (yloc, xloc) position.
Infinite horizontal scrolling is enabled or limited using max_length.
"""
# pylint: disable=R0902,R0904
# Too many instance attributes (14/7)
# Too many public methods (33/20)
def __init__(self, *args, **kwargs):
"""
Class initializer.
:param int width: width of window.
:param int yloc: y-location of window.
:param int xloc: x-location of window.
:param int max_length: maximum length of input (even when scrolled).
:param dict colors: color theme.
:param dict glyphs: bordering window character glyphs.
:param dict keyset: command keys, global ``PC_KEYSET`` is default.
"""
self._term = getterminal()
self._horiz_shift = 0
self._horiz_pos = 0
# self._enable_scrolling = False
self._horiz_lastshift = 0
self._scroll_pct = kwargs.pop('scroll_pct', 25.0)
self._margin_pct = kwargs.pop('margin_pct', 10.0)
self._carriage_returned = False
self._max_length = kwargs.pop('max_length', 0)
self._quit = False
self._bell = False
self.content = kwargs.pop('content', u'')
self._input_length = self._term.length(self.content)
# there are some flaws about how a 'height' of a window must be
# '3', even though we only want 1; we must also offset (y, x) by
# 1 and width by 2: issue #161.
kwargs['height'] = 3
self.init_keystrokes(keyset=kwargs.pop('keyset', PC_KEYSET.copy()))
AnsiWindow.__init__(self, *args, **kwargs)
[docs] def init_theme(self, colors=None, glyphs=None):
""" Set color and bordering glyphs theme. """
AnsiWindow.init_theme(self, colors, glyphs)
if 'highlight' not in self.colors:
self.colors['highlight'] = self._term.yellow_reverse
if 'strip' not in self.glyphs:
self.glyphs['strip'] = u'$ '
[docs] def init_keystrokes(self, keyset):
""" Sets keyboard keys for various editing keystrokes. """
self.keyset = keyset
self.keyset['refresh'].append(self._term.KEY_REFRESH)
self.keyset['backspace'].append(self._term.KEY_BACKSPACE)
self.keyset['backspace'].append(self._term.KEY_DELETE)
self.keyset['enter'].append(self._term.KEY_ENTER)
self.keyset['exit'].append(self._term.KEY_ESCAPE)
@property
def position(self):
""" Tuple of shift amount and column position of line editor. """
return (self._horiz_shift, self._horiz_pos)
@property
def eol(self):
""" Whether more input may be accepted (end of line reached). """
return self._input_length >= self.max_length
@property
def bell(self):
""" Whether the user has neared the margin. """
margin = int(float(self.visible_width) *
(float(self.scroll_pct) * .01))
return bool(self._input_length >= self.visible_width - margin)
@bell.setter
def bell(self, value):
# pylint: disable=C0111
# Missing docstring
self._bell = value
@property
def carriage_returned(self):
""" Whether the carriage return character has been handled. """
return self._carriage_returned
@property
def quit(self):
""" Whether a 'quit' character has been handled, such as escape. """
return self._quit
@property
def is_scrolled(self):
""" Whether the horizontal editor is in a scrolled state. """
return bool(self._horiz_shift)
@property
def scroll_amt(self):
"""
Number of columns from-end until horizontal editor will scroll
Calculated by scroll_pct.
"""
return int(float(self.visible_width) * (float(self.scroll_pct) * .01))
@property
def margin_amt(self):
"""
Absolute number of columns from margin until bell is signaled.
Indicating that the end is near and the carriage should be soon
returned.
"""
return int(float(self.visible_width) * (float(self.margin_pct) * .01))
@property
def scroll_pct(self):
"""
Percentage of visible width from-end until scrolling occurs.
Number of columns, as a percentage of its total visible width, that
will be scrolled when a user reaches the margin by percent. Default
is 25.
"""
return self._scroll_pct
@scroll_pct.setter
def scroll_pct(self, value):
# pylint: disable=C0111
# Missing docstring
self._scroll_pct = float(value)
assert value < 50, ("Bugs with values greater than 50 ...")
@property
def margin_pct(self):
"""
Percentage of visible width from-end until bell is signaled.
Number of columns away from input length limit, as a percentage of its
total visible width, that will alarm the bell. This simulates the bell
of a typewriter as a signaling mechanism. Default is 10.
Unofficially intended for a faked multi-line editor: by using the bell
as a wrap signal to instantiate another line editor and
'return the carriage'.
"""
return self._margin_pct
@margin_pct.setter
def margin_pct(self, value):
# pylint: disable=C0111
# Missing docstring
self._margin_pct = float(value)
@property
def max_length(self):
"""
Maximum line length.
This also limits infinite scrolling when enable_scrolling is True.
When unset, the maximum length is infinite!
"""
if not self._max_length:
warnings.warn("maximum length of ScrollingEditor is infinite!")
return self._max_length or float('inf')
@max_length.setter
def max_length(self, value):
# pylint: disable=C0111
# Missing docstring
self._max_length = value
@property
def content(self):
""" The contents of the editor. """
return self._content
@content.setter
def content(self, value):
# pylint: disable=C0111
# Missing docstring
self._content = value
self._input_length = self._term.length(value)
[docs] def process_keystroke(self, keystroke):
"""
Process the keystroke and return string to refresh.
:param blessed.keyboard.Keystroke keystroke: input from ``inkey()``.
:rtype: str
:returns: string sequence suitable for refresh.
"""
self._quit = False
rstr = u''
if (keystroke in self.keyset['refresh'] or
keystroke.code in self.keyset['refresh']):
rstr = self.refresh()
elif (keystroke in self.keyset['backspace'] or
keystroke.code in self.keyset['backspace']):
rstr = self.backspace()
elif (keystroke in self.keyset['backword'] or
keystroke.code in self.keyset['backword']):
rstr = self.backword()
elif (keystroke in self.keyset['enter'] or
keystroke.code in self.keyset['enter']):
self._carriage_returned = True
rstr = u''
elif (keystroke in self.keyset['exit'] or
keystroke.code in self.keyset['exit']):
self._quit = True
rstr = u''
elif keystroke.is_sequence:
# could beep also, (error)
rstr = u''
else:
if ord(keystroke) >= 0x20:
rstr = self.add(keystroke)
return rstr
[docs] def read(self):
"""
Reads input until the ENTER or ESCAPE key is pressed (Blocking).
Allows backspacing. Returns unicode text, or None when canceled.
"""
echo(self.refresh())
self._quit = False
self._carriage_returned = False
term = getterminal()
while not (self.quit or self.carriage_returned):
inp = term.inkey()
echo(self.process_keystroke(inp))
if not self.quit:
return self.content
return None
[docs] def fixate(self, x_adjust=0):
"""
Return string sequence suitable for "fixating" cursor position.
Set x_adjust to -1 to position cursor 'on' the last character,
or 0 for 'after' (default).
"""
xpos = self._xpadding + self._horiz_pos + x_adjust
return self.pos(1, xpos) + self._term.cursor_visible
[docs] def refresh(self):
"""
Return string sequence suitable for refreshing editor.
A strange by-product; if scrolling was not previously enabled, it is if
wrapping must occur; this can happen if a non-scrolling editor was
provided a very large .content buffer, then later .refresh()'d. --
essentially enabling infinite scrolling.
"""
# reset position and detect new position
self._horiz_lastshift = self._horiz_shift
self._horiz_shift = 0
self._horiz_pos = 0
# (self._term.length(self.content))
for _ in range(self._input_length):
if (self._horiz_pos >
(self.visible_width - self.scroll_amt)):
self._horiz_shift += self.scroll_amt
self._horiz_pos -= self.scroll_amt
self._horiz_pos += 1
if self._horiz_shift > 0:
self._horiz_shift += len(self.glyphs['strip'])
prnt = u''.join((
self.glyphs['strip'],
self.content[self._horiz_shift:],))
else:
prnt = self.content
return u''.join((
self.pos(self.ypadding, self.xpadding),
self._term.normal,
self.colors.get('highlight', u''),
self.align(prnt),
self.fixate(),))
[docs] def backword(self):
"""
Delete word behind cursor, using ' ' as boundary.
In gnu-readline this is unix-word-rubout (C-w).
"""
if 0 == len(self.content):
return u''
ridx = self.content.rstrip().rfind(' ') + 1
self.content = self.content[:ridx]
return self.refresh()
[docs] def backspace(self):
""" Remove character from end of buffer, scroll as necessary. """
if 0 == len(self.content):
return u''
rstr = u''
# measured backspace erases over double-wide (wcwidth)
len_toss = self._term.length(self.content[-1])
len_move = 1
self.content = self.content[:-1]
if self.is_scrolled and (self._horiz_pos < self.scroll_amt):
# shift left,
self._horiz_shift -= self.scroll_amt
self._horiz_pos += self.scroll_amt
rstr += self.refresh()
else:
rstr += u''.join((
self.fixate(0),
u'\b' * len_toss,
u' ' * len_move,
u'\b' * len_move,))
self._horiz_pos -= 1
return rstr
[docs] def update(self, ucs=u''):
"""
Replace or reset content.
Resets properties ``carriage_returned`` and ``quit`` to False.
"""
self._horiz_shift = 0
self._horiz_pos = 0
self.content = ucs
self._carriage_returned = False
self._quit = False
assert unichr(27) not in ucs, ('Editor is not ESC sequence-safe')
[docs] def add(self, u_chr):
"""
Return output sequence of changes after adding a character to editor.
An empty string is returned if no data could be inserted. Sequences for
re-displaying the full input line are returned when the character
addition caused the window to scroll horizontally.
Otherwise, the input is simply returned to be displayed.
"""
if self.eol:
# cannot input, at end of line!
return u''
# append input to content directly to the backend variable,
# so that we adjust the length only by the most recently-added
# character.
self._content += u_chr
self._input_length += self._term.length(u_chr)
# return character appended as output, ensure .fixate() is used first!
self._horiz_pos += 1
if self._horiz_pos >= (self.visible_width - self.margin_amt):
# scrolling is required,
return self.refresh()
return self._term.normal + self.colors['highlight'] + u_chr