2021-03-11 19:16:15 +01:00
|
|
|
"""Integrated command-line implementation."""
|
2021-03-13 16:31:11 +01:00
|
|
|
|
2021-02-12 23:29:51 +01:00
|
|
|
import curses
|
2021-03-13 16:31:11 +01:00
|
|
|
import curses.ascii
|
2021-02-12 23:29:51 +01:00
|
|
|
import curses.textpad
|
2021-03-13 16:31:11 +01:00
|
|
|
|
|
|
|
from bebop.links import Links
|
2021-02-12 23:29:51 +01:00
|
|
|
|
|
|
|
|
|
|
|
class CommandLine:
|
2021-03-13 16:31:11 +01:00
|
|
|
"""Basic and flaky command-line à la Vim, using curses module's Textbox."""
|
2021-02-12 23:29:51 +01:00
|
|
|
|
|
|
|
def __init__(self, window):
|
|
|
|
self.window = window
|
|
|
|
self.textbox = None
|
|
|
|
|
|
|
|
def clear(self):
|
2021-03-13 16:31:11 +01:00
|
|
|
"""Clear command-line contents."""
|
2021-02-12 23:29:51 +01:00
|
|
|
self.window.clear()
|
|
|
|
self.window.refresh()
|
|
|
|
|
|
|
|
def focus(self, command_char, validator=None, prefix=""):
|
|
|
|
"""Give user focus to the command bar.
|
|
|
|
|
|
|
|
Show the command char and give focus to the command textbox. The
|
|
|
|
validator function is passed to the textbox.
|
|
|
|
|
|
|
|
Arguments:
|
2021-03-13 16:31:11 +01:00
|
|
|
- command_char: char to display before the command line; it must be an
|
|
|
|
str of length 1, else the return value of `gather` might be wrong.
|
|
|
|
- validator: function to use to validate the input chars; if omitted,
|
|
|
|
`validate_common_input` is used.
|
2021-02-12 23:29:51 +01:00
|
|
|
- prefix: string to insert before the cursor in the command line.
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
User input as string. The string will be empty if the validator raised
|
|
|
|
an EscapeInterrupt.
|
|
|
|
"""
|
|
|
|
self.window.clear()
|
|
|
|
self.window.refresh()
|
|
|
|
self.textbox = curses.textpad.Textbox(self.window)
|
|
|
|
self.window.addstr(command_char + prefix)
|
|
|
|
curses.curs_set(1)
|
|
|
|
try:
|
2021-03-13 16:31:11 +01:00
|
|
|
command = self.textbox.edit(validator or self.validate_common_input)
|
2021-02-12 23:29:51 +01:00
|
|
|
except EscapeCommandInterrupt:
|
|
|
|
command = ""
|
|
|
|
except TerminateCommandInterrupt as exc:
|
|
|
|
command = exc.command
|
2021-03-14 00:04:55 +01:00
|
|
|
else:
|
|
|
|
command = command[1:].rstrip()
|
2021-02-12 23:29:51 +01:00
|
|
|
curses.curs_set(0)
|
2021-03-13 16:31:11 +01:00
|
|
|
self.clear()
|
2021-02-12 23:29:51 +01:00
|
|
|
return command
|
|
|
|
|
|
|
|
def gather(self):
|
|
|
|
"""Return the string currently written by the user in command line."""
|
|
|
|
return self.textbox.gather()[1:].rstrip()
|
|
|
|
|
2021-03-13 16:31:11 +01:00
|
|
|
def validate_common_input(self, ch: int):
|
|
|
|
"""Generic input validator, handles a few more cases than default.
|
|
|
|
|
|
|
|
This validator can be used as a default validator as it handles, on top
|
|
|
|
of the Textbox defaults:
|
|
|
|
- Erasing the first command char, i.e. clearing the line, cancels the
|
|
|
|
command input.
|
|
|
|
- Pressing ESC also cancels the input.
|
|
|
|
|
|
|
|
This validator can be safely called at the beginning of other validators
|
|
|
|
to handle the keys above.
|
|
|
|
"""
|
|
|
|
if ch == curses.KEY_BACKSPACE: # Cancel input if all line is cleaned.
|
|
|
|
text = self.gather()
|
|
|
|
if len(text) == 0:
|
|
|
|
raise EscapeCommandInterrupt()
|
|
|
|
elif ch == curses.ascii.ESC: # Could be ESC or ALT
|
|
|
|
self.window.nodelay(True)
|
|
|
|
ch = self.window.getch()
|
|
|
|
if ch == -1:
|
|
|
|
raise EscapeCommandInterrupt()
|
|
|
|
self.window.nodelay(False)
|
|
|
|
return ch
|
|
|
|
|
|
|
|
def focus_for_link_navigation(self, init_char: int, links: Links):
|
|
|
|
"""Handle a initial digit input by the user.
|
|
|
|
|
|
|
|
When a digit key is pressed, the user intents to visit a link (or
|
|
|
|
dropped something on the numpad). To reduce the number of key types
|
|
|
|
needed, Bebop uses the following algorithm:
|
|
|
|
- If the current user input identifies a link without ambiguity, it is
|
|
|
|
used directly.
|
|
|
|
- If it is ambiguous, the user either inputs as many digits required
|
|
|
|
to disambiguate the link ID, or press enter to validate her input.
|
|
|
|
|
|
|
|
Examples:
|
|
|
|
- I have 3 links. Pressing "2" takes me to link 2.
|
|
|
|
- I have 15 links. Pressing "3" takes me to link 3 (no ambiguity).
|
|
|
|
- I have 15 links. Pressing "1" and "2" takes me to link 12.
|
|
|
|
- I have 456 links. Pressing "1", "2" and Enter takes me to link 12.
|
|
|
|
- I have 456 links. Pressing "1", "2" and "6" takes me to link 126.
|
|
|
|
|
|
|
|
Arguments:
|
|
|
|
- init_char: the first char (code) being pressed.
|
|
|
|
- links: accessible Links.
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
The tuple (error, value); if error is 0, value is the link ID to use; if
|
|
|
|
error is 1, discard value and do nothing; if error is 2, value is an
|
|
|
|
error than can be showed to the user.
|
|
|
|
"""
|
|
|
|
digit = init_char & 0xf
|
|
|
|
num_links = len(links)
|
|
|
|
# If there are less than 10 links, just open it now.
|
|
|
|
if num_links < 10:
|
|
|
|
return 0, digit
|
|
|
|
# Else check if the digit alone is sufficient.
|
|
|
|
digit = chr(init_char)
|
|
|
|
max_digits = 0
|
|
|
|
while num_links:
|
|
|
|
max_digits += 1
|
|
|
|
num_links //= 10
|
|
|
|
candidates = links.disambiguate(digit, max_digits)
|
|
|
|
if len(candidates) == 1:
|
|
|
|
return 0, candidates[0]
|
|
|
|
# Else, focus the command line to let the user input more digits.
|
|
|
|
validator = lambda ch: self.validate_link_digit(ch, links, max_digits)
|
|
|
|
link_input = self.focus("&", validator, digit)
|
|
|
|
if not link_input:
|
|
|
|
return 1, None
|
|
|
|
try:
|
|
|
|
link_id = int(link_input)
|
2021-03-13 20:38:19 +01:00
|
|
|
except ValueError:
|
2021-03-13 16:31:11 +01:00
|
|
|
return 2, f"Invalid link ID {link_input}."
|
|
|
|
return 0, link_id
|
|
|
|
|
|
|
|
def validate_link_digit(self, ch: int, links: Links, max_digits: int):
|
|
|
|
"""Handle input chars to be used as link ID."""
|
|
|
|
# Handle common chars.
|
|
|
|
ch = self.validate_common_input(ch)
|
|
|
|
# Only accept digits. If we reach the amount of required digits, open
|
|
|
|
# link now and leave command line. Else just process it.
|
|
|
|
if curses.ascii.isdigit(ch):
|
|
|
|
digits = self.gather() + chr(ch)
|
|
|
|
candidates = links.disambiguate(digits, max_digits)
|
|
|
|
if len(candidates) == 1:
|
2021-03-13 18:57:37 +01:00
|
|
|
raise TerminateCommandInterrupt(candidates[0])
|
2021-03-13 16:31:11 +01:00
|
|
|
return ch
|
|
|
|
# If not a digit but a printable character, ignore it.
|
|
|
|
if curses.ascii.isprint(ch):
|
|
|
|
return 0
|
|
|
|
# Everything else could be a control character and should be processed.
|
|
|
|
return ch
|
|
|
|
|
2021-02-12 23:29:51 +01:00
|
|
|
|
|
|
|
class EscapeCommandInterrupt(Exception):
|
|
|
|
"""Signal that ESC has been pressed during command line."""
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
class TerminateCommandInterrupt(Exception):
|
2021-02-13 23:34:45 +01:00
|
|
|
"""Signal that validation ended command line input early.
|
2021-02-12 23:29:51 +01:00
|
|
|
|
2021-02-13 23:34:45 +01:00
|
|
|
The value to use is stored in the command attribute. This value can be of
|
|
|
|
any type: str for common commands but also int for ID input, etc.
|
|
|
|
"""
|
|
|
|
|
|
|
|
def __init__(self, command, *args, **kwargs):
|
2021-02-12 23:29:51 +01:00
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
self.command = command
|