summaryrefslogtreecommitdiff
path: root/src/windows/inputs.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/windows/inputs.py')
-rw-r--r--src/windows/inputs.py764
1 files changed, 764 insertions, 0 deletions
diff --git a/src/windows/inputs.py b/src/windows/inputs.py
new file mode 100644
index 00000000..8c2f2e2e
--- /dev/null
+++ b/src/windows/inputs.py
@@ -0,0 +1,764 @@
+"""
+Text inputs.
+"""
+
+import logging
+log = logging.getLogger(__name__)
+
+import curses
+import string
+
+import common
+import poopt
+from . import Win, g_lock
+from . base_wins import format_chars
+from . funcs import find_first_format_char
+from config import config
+from theming import to_curses_attr
+
+
+class Input(Win):
+ """
+ The simplest Input possible, provides just a way to edit a single line
+ of text. It also has a clipboard, common to all Inputs.
+ Doesn't have any history.
+ It doesn't do anything when enter is pressed either.
+ This should be herited for all kinds of Inputs, for example MessageInput
+ or the little inputs in dataforms, etc, adding specific features (completion etc)
+ It features two kinds of completion, but they have to be called from outside (the Tab),
+ passing the list of items that can be used to complete. The completion can be used
+ in a very flexible way.
+ """
+ text_attributes = ['b', 'o', 'u', '1', '2', '3', '4', '5', '6', '7', 't']
+ clipboard = '' # A common clipboard for all the inputs, this makes
+ # it easy cut and paste text between various input
+ def __init__(self):
+ self.key_func = {
+ "KEY_LEFT": self.key_left,
+ "KEY_RIGHT": self.key_right,
+ "KEY_END": self.key_end,
+ "KEY_HOME": self.key_home,
+ "KEY_DC": self.key_dc,
+ '^D': self.key_dc,
+ 'M-b': self.jump_word_left,
+ "M-[1;5D": self.jump_word_left,
+ '^W': self.delete_word,
+ 'M-d': self.delete_next_word,
+ '^K': self.delete_end_of_line,
+ '^U': self.delete_begining_of_line,
+ '^Y': self.paste_clipboard,
+ '^A': self.key_home,
+ '^E': self.key_end,
+ 'M-f': self.jump_word_right,
+ "M-[1;5C": self.jump_word_right,
+ "KEY_BACKSPACE": self.key_backspace,
+ "M-KEY_BACKSPACE": self.delete_word,
+ '^?': self.key_backspace,
+ "M-^?": self.delete_word,
+ # '^J': self.add_line_break,
+ }
+ Win.__init__(self)
+ self.text = ''
+ self.pos = 0 # The position of the “cursor” in the text
+ # (not only in the view)
+ self.view_pos = 0 # The position (in the text) of the
+ # first character displayed on the
+ # screen
+ self.on_input = None # callback called on any key pressed
+ self.color = None # use this color on addstr
+
+ def on_delete(self):
+ """
+ Remove all references kept to a tab, so that the tab
+ can be garbage collected
+ """
+ del self.key_func
+
+ def set_color(self, color):
+ self.color = color
+ self.rewrite_text()
+
+ def is_empty(self):
+ if self.text:
+ return False
+ return True
+
+ def is_cursor_at_end(self):
+ """
+ Whether or not the cursor is at the end of the text.
+ """
+ assert len(self.text) >= self.pos
+ if len(self.text) == self.pos:
+ return True
+ return False
+
+ def jump_word_left(self):
+ """
+ Move the cursor one word to the left
+ """
+ if self.pos == 0:
+ return True
+ separators = string.punctuation+' '
+ while self.pos > 0 and self.text[self.pos-1] in separators:
+ self.key_left()
+ while self.pos > 0 and self.text[self.pos-1] not in separators:
+ self.key_left()
+ return True
+
+ def jump_word_right(self):
+ """
+ Move the cursor one word to the right
+ """
+ if self.is_cursor_at_end():
+ return True
+ separators = string.punctuation+' '
+ while not self.is_cursor_at_end() and self.text[self.pos] in separators:
+ self.key_right()
+ while not self.is_cursor_at_end() and self.text[self.pos] not in separators:
+ self.key_right()
+ return True
+
+ def delete_word(self):
+ """
+ Delete the word just before the cursor
+ """
+ separators = string.punctuation+' '
+ while self.pos > 0 and self.text[self.pos-1] in separators:
+ self.key_backspace()
+ while self.pos > 0 and self.text[self.pos-1] not in separators:
+ self.key_backspace()
+ return True
+
+ def delete_next_word(self):
+ """
+ Delete the word just after the cursor
+ """
+ separators = string.punctuation+' '
+ while not self.is_cursor_at_end() and self.text[self.pos] in separators:
+ self.key_dc()
+ while not self.is_cursor_at_end() and self.text[self.pos] not in separators:
+ self.key_dc()
+ return True
+
+ def delete_end_of_line(self):
+ """
+ Cut the text from cursor to the end of line
+ """
+ if self.is_cursor_at_end():
+ return False
+ Input.clipboard = self.text[self.pos:]
+ self.text = self.text[:self.pos]
+ self.key_end()
+ return True
+
+ def delete_begining_of_line(self):
+ """
+ Cut the text from cursor to the begining of line
+ """
+ if self.pos == 0:
+ return True
+ Input.clipboard = self.text[:self.pos]
+ self.text = self.text[self.pos:]
+ self.key_home()
+ return True
+
+ def paste_clipboard(self):
+ """
+ Insert what is in the clipboard at the cursor position
+ """
+ if not Input.clipboard:
+ return True
+ for letter in Input.clipboard:
+ self.do_command(letter, False)
+ self.rewrite_text()
+ return True
+
+ def key_dc(self):
+ """
+ delete char just after the cursor
+ """
+ self.reset_completion()
+ if self.is_cursor_at_end():
+ return True # end of line, nothing to delete
+ self.text = self.text[:self.pos]+self.text[self.pos+1:]
+ self.rewrite_text()
+ return True
+
+ def key_home(self):
+ """
+ Go to the begining of line
+ """
+ self.reset_completion()
+ self.pos = 0
+ self.rewrite_text()
+ return True
+
+ def key_end(self, reset=False):
+ """
+ Go to the end of line
+ """
+ if reset:
+ self.reset_completion()
+ self.pos = len(self.text)
+ assert self.is_cursor_at_end()
+ self.rewrite_text()
+ return True
+
+ def key_left(self, jump=True, reset=True):
+ """
+ Move the cursor one char to the left
+ """
+ if reset:
+ self.reset_completion()
+ if self.pos == 0:
+ return True
+ self.pos -= 1
+ if reset:
+ self.rewrite_text()
+ return True
+
+ def key_right(self, jump=True, reset=True):
+ """
+ Move the cursor one char to the right
+ """
+ if reset:
+ self.reset_completion()
+ if self.is_cursor_at_end():
+ return True
+ self.pos += 1
+ if reset:
+ self.rewrite_text()
+ return True
+
+ def key_backspace(self, reset=True):
+ """
+ Delete the char just before the cursor
+ """
+ self.reset_completion()
+ if self.pos == 0:
+ return
+ self.key_left()
+ self.key_dc()
+ return True
+
+ def auto_completion(self, word_list, add_after='', quotify=True):
+ """
+ Complete the input, from a list of words
+ if add_after is None, we use the value defined in completion
+ plus a space, after the completion. If it's a string, we use it after the
+ completion (with no additional space)
+ """
+ if quotify:
+ for i, word in enumerate(word_list[:]):
+ word_list[i] = '"' + word + '"'
+ self.normal_completion(word_list, add_after)
+ return True
+
+ def new_completion(self, word_list, argument_position=-1, add_after='', quotify=True, override=False):
+ """
+ Complete the argument at position ``argument_postion`` in the input.
+ If ``quotify`` is ``True``, then the completion will operate on block of words
+ (e.g. "toto titi") whereas if it is ``False``, it will operate on words (e.g
+ "toto", "titi").
+
+ The completions may modify other parts of the input when completing an argument,
+ for example removing useless double quotes around single-words, or setting the
+ space between each argument to only one space.
+
+ The case where we complete the first argument is special, because we complete
+ the command, and we do not want to modify anything else in the input.
+
+ This method is the one that should be used if the command being completed
+ has several arguments.
+ """
+ if argument_position == 0:
+ self._new_completion_first(word_list)
+ else:
+ self._new_completion_args(word_list, argument_position, add_after, quotify, override)
+ self.rewrite_text()
+ return True
+
+ def _new_completion_args(self, word_list, argument_position=-1, add_after='', quoted=True, override=False):
+ """
+ Case for completing arguments with position ≠ 0
+ """
+ if quoted:
+ words = common.shell_split(self.text)
+ else:
+ words = self.text.split()
+ if argument_position >= len(words):
+ current = ''
+ else:
+ current = words[argument_position]
+
+ if quoted:
+ split_words = words[1:]
+ words = [words[0]]
+ for word in split_words:
+ if ' ' in word or '\\' in word:
+ words.append('"' + word + '"')
+ else:
+ words.append(word)
+ current_l = current.lower()
+ if self.last_completion is not None:
+ self.hit_list.append(self.hit_list.pop(0))
+ else:
+ if override:
+ hit_list = word_list
+ else:
+ hit_list = []
+ for word in word_list:
+ if word.lower().startswith(current_l):
+ hit_list.append(word)
+ if not hit_list:
+ return
+ self.hit_list = hit_list
+
+ if argument_position >= len(words):
+ if quoted and ' ' in self.hit_list[0]:
+ words.append('"'+self.hit_list[0]+'"')
+ else:
+ words.append(self.hit_list[0])
+ else:
+ if quoted and ' ' in self.hit_list[0]:
+ words[argument_position] = '"'+self.hit_list[0]+'"'
+ else:
+ words[argument_position] = self.hit_list[0]
+
+ new_pos = -1
+ for i, word in enumerate(words):
+ if argument_position >= i:
+ new_pos += len(word) + 1
+
+ self.last_completion = self.hit_list[0]
+ self.text = words[0] + ' ' + ' '.join(words[1:])
+ self.pos = new_pos
+
+ def _new_completion_first(self, word_list):
+ """
+ Special case of completing the command itself:
+ we don’t want to change anything to the input doing that
+ """
+ space_pos = self.text.find(' ')
+ if space_pos != -1:
+ current, follow = self.text[:space_pos], self.text[space_pos:]
+ else:
+ current, follow = self.text, ''
+
+ if self.last_completion:
+ self.hit_list.append(self.hit_list.pop(0))
+ else:
+ hit_list = []
+ for word in word_list:
+ if word.lower().startswith(current):
+ hit_list.append(word)
+ if not hit_list:
+ return
+ self.hit_list = hit_list
+
+ self.last_completion = self.hit_list[0]
+ self.text = self.hit_list[0] + follow
+ self.pos = len(self.hit_list[0])
+
+ def get_argument_position(self, quoted=True):
+ """
+ Get the argument number at the current position
+ """
+ command_stop = self.text.find(' ')
+ if command_stop == -1 or self.pos <= command_stop:
+ return 0
+ text = self.text[command_stop+1:]
+ pos = self.pos - len(self.text) + len(text) - 1
+ val = common.find_argument(pos, text, quoted=quoted) + 1
+ return val
+
+ def reset_completion(self):
+ """
+ Reset the completion list (called on ALL keys except tab)
+ """
+ self.hit_list = []
+ self.last_completion = None
+
+ def normal_completion(self, word_list, after):
+ """
+ Normal completion
+ """
+ pos = self.pos
+ if pos < len(self.text) and after.endswith(' ') and self.text[pos] == ' ':
+ after = after[:-1] # remove the last space if we are already on a space
+ if not self.last_completion:
+ space_before_cursor = self.text.rfind(' ', 0, pos)
+ if space_before_cursor != -1:
+ begin = self.text[space_before_cursor+1:pos]
+ else:
+ begin = self.text[:pos]
+ hit_list = [] # list of matching hits
+ for word in word_list:
+ if word.lower().startswith(begin.lower()):
+ hit_list.append(word)
+ elif word.startswith('"') and word.lower()[1:].startswith(begin.lower()):
+ hit_list.append(word)
+ if len(hit_list) == 0:
+ return
+ self.hit_list = hit_list
+ end = len(begin)
+ else:
+ begin = self.last_completion
+ end = len(begin) + len(after)
+ self.hit_list.append(self.hit_list.pop(0)) # rotate list
+
+ self.text = self.text[:pos-end] + self.text[pos:]
+ pos -= end
+ hit = self.hit_list[0] # take the first hit
+ self.text = self.text[:pos] + hit + after + self.text[pos:]
+ for _ in range(end):
+ try:
+ self.key_left(reset=False)
+ except:
+ pass
+ for _ in range(len(hit) + len(after)):
+ self.key_right(reset=False)
+
+ self.rewrite_text()
+ self.last_completion = hit
+
+ def do_command(self, key, reset=True, raw=False):
+ if key in self.key_func:
+ res = self.key_func[key]()
+ if not raw and self.on_input:
+ self.on_input(self.get_text())
+ return res
+ if not raw and (not key or len(key) > 1):
+ return False # ignore non-handled keyboard shortcuts
+ if reset:
+ self.reset_completion()
+ # Insert the char at the cursor position
+ self.text = self.text[:self.pos]+key+self.text[self.pos:]
+ self.pos += len(key)
+ if reset:
+ self.rewrite_text()
+ if self.on_input:
+ self.on_input(self.get_text())
+
+ return True
+
+ def add_line_break(self):
+ """
+ Add a (real) \n to the line
+ """
+ self.do_command('\n')
+
+ def get_text(self):
+ """
+ Return the text entered so far
+ """
+ return self.text
+
+ def addstr_colored_lite(self, text, y=None, x=None):
+ """
+ Just like addstr_colored, with the single-char attributes
+ (\x0E to \x19 instead of \x19 + attr). We do not use any }
+ char in this version
+ """
+ chars = format_chars[:]
+ chars.append('\n')
+ if y is not None and x is not None:
+ self.move(y, x)
+ format_char = find_first_format_char(text, chars)
+ while format_char != -1:
+ if text[format_char] == '\n':
+ attr_char = '|'
+ else:
+ attr_char = self.text_attributes[
+ format_chars.index(text[format_char])]
+ self.addstr(text[:format_char])
+ self.addstr(attr_char, curses.A_REVERSE)
+ text = text[format_char+1:]
+ if attr_char == 'o':
+ self._win.attrset(0)
+ elif attr_char == 'u':
+ self._win.attron(curses.A_UNDERLINE)
+ elif attr_char == 'b':
+ self._win.attron(curses.A_BOLD)
+ elif attr_char in string.digits and attr_char != '':
+ self._win.attron(to_curses_attr((int(attr_char), -1)))
+ format_char = find_first_format_char(text, chars)
+ self.addstr(text)
+
+ def rewrite_text(self):
+ """
+ Refresh the line onscreen, but first, always adjust the
+ view_pos. Also, each FORMAT_CHAR+attr_char count only take
+ one screen column (this is done in addstr_colored_lite), we
+ have to do some special calculations to find the correct
+ length of text to display, and the position of the cursor.
+ """
+ self.adjust_view_pos()
+ with g_lock:
+ text = self.text
+ self._win.erase()
+ if self.color:
+ self._win.attron(to_curses_attr(self.color))
+ displayed_text = text[self.view_pos:self.view_pos+self.width-1].replace('\t', '\x18')
+ self._win.attrset(0)
+ self.addstr_colored_lite(displayed_text)
+ # Fill the rest of the line with the input color
+ if self.color:
+ (_, x) = self._win.getyx()
+ size = self.width - x
+ self.addnstr(' ' * size, size, to_curses_attr(self.color))
+ self.addstr(0,
+ poopt.wcswidth(displayed_text[:self.pos-self.view_pos]), '')
+ if self.color:
+ self._win.attroff(to_curses_attr(self.color))
+ curses.curs_set(1)
+ self._refresh()
+
+ def adjust_view_pos(self):
+ """
+ Adjust the position of the View, if needed (for example if the
+ cursor moved and would now be out of the view, we adapt the
+ view_pos so that we can always see our cursor)
+ """
+ # start of the input
+ if self.pos == 0:
+ self.view_pos = 0
+ return
+ # cursor outside of the screen (left)
+ if self.pos < self.view_pos:
+ self.view_pos = self.pos - max(1 * self.width // 3, 1)
+ # cursor outside of the screen (right)
+ elif self.pos >= self.view_pos + self.width - 1:
+ self.view_pos = self.pos - max(2 * self.width // 3, 2)
+
+ if self.view_pos < 0:
+ self.view_pos = 0
+
+
+ assert(self.pos > self.view_pos and
+ self.pos < self.view_pos + max(self.width, 3))
+
+ def refresh(self):
+ log.debug('Refresh: %s', self.__class__.__name__)
+ self.rewrite_text()
+
+ def clear_text(self):
+ self.text = ''
+ self.pos = 0
+ self.rewrite_text()
+
+ def key_enter(self):
+ txt = self.get_text()
+ self.clear_text()
+ return txt
+
+class HistoryInput(Input):
+ """
+ An input with colors and stuff, plus an history
+ ^R allows to search inside the history (as in a shell)
+ """
+ history = list()
+
+ def __init__(self):
+ Input.__init__(self)
+ self.help_message = ''
+ self.current_completed = ''
+ self.key_func['^R'] = self.toggle_search
+ self.search = False
+ if config.get('separate_history', False):
+ self.history = list()
+
+ def toggle_search(self):
+ if self.help_message:
+ return
+ self.search = not self.search
+ self.refresh()
+
+ def update_completed(self):
+ """
+ Find a match for the current text
+ """
+ if not self.text:
+ return
+ for i in self.history:
+ if self.text in i:
+ self.current_completed = i
+ return
+ self.current_completed = ''
+
+ def history_enter(self):
+ """
+ Enter was pressed, set the text to the
+ current completion and disable history
+ search
+ """
+ if self.search:
+ self.search = False
+ if self.current_completed:
+ self.text = self.current_completed
+ self.current_completed = ''
+ self.refresh()
+ return True
+ self.refresh()
+ return False
+
+ def key_up(self):
+ """
+ Get the previous line in the history
+ """
+ self.reset_completion()
+ if self.histo_pos == -1 and self.get_text():
+ if not self.history or self.history[0] != self.get_text():
+ # add the message to history, we do not want to lose it
+ self.history.insert(0, self.get_text())
+ self.histo_pos += 1
+ if self.histo_pos < len(self.history) - 1:
+ self.histo_pos += 1
+ self.text = self.history[self.histo_pos]
+ self.key_end()
+ return True
+
+ def key_down(self):
+ """
+ Get the next line in the history
+ """
+ self.reset_completion()
+ if self.histo_pos > 0:
+ self.histo_pos -= 1
+ self.text = self.history[self.histo_pos]
+ elif self.histo_pos <= 0 and self.get_text():
+ if not self.history or self.history[0] != self.get_text():
+ # add the message to history, we do not want to lose it
+ self.history.insert(0, self.get_text())
+ self.text = ''
+ self.histo_pos = -1
+ self.key_end()
+ return True
+
+class MessageInput(HistoryInput):
+ """
+ The input featuring history and that is being used in
+ Conversation, Muc and Private tabs
+ Also letting the user enter colors or other text markups
+ """
+ history = list() # The history is common to all MessageInput
+
+ def __init__(self):
+ HistoryInput.__init__(self)
+ self.last_completion = None
+ self.histo_pos = -1
+ self.key_func["KEY_UP"] = self.key_up
+ self.key_func["M-A"] = self.key_up
+ self.key_func["KEY_DOWN"] = self.key_down
+ self.key_func["M-B"] = self.key_down
+ self.key_func['^C'] = self.enter_attrib
+
+ def enter_attrib(self):
+ """
+ Read one more char (c), add the corresponding char from formats_char to the text string
+ """
+ attr_char = self.core.read_keyboard()[0]
+ if attr_char in self.text_attributes:
+ char = format_chars[self.text_attributes.index(attr_char)]
+ self.do_command(char, False)
+ self.rewrite_text()
+
+ def key_enter(self):
+ if self.history_enter():
+ return
+
+ txt = self.get_text()
+ if len(txt) != 0:
+ if not self.history or self.history[0] != txt:
+ # add the message to history, but avoid duplicates
+ self.history.insert(0, txt)
+ self.histo_pos = -1
+ self.clear_text()
+ return txt
+
+class CommandInput(HistoryInput):
+ """
+ An input with an help message in the left, with three given callbacks:
+ one when when successfully 'execute' the command and when we abort it.
+ The last callback is optional and is called on any input key
+ This input is used, for example, in the RosterTab when, to replace the
+ HelpMessage when a command is started
+ The on_input callback
+ """
+ history = list()
+
+ def __init__(self, help_message, on_abort, on_success, on_input=None):
+ HistoryInput.__init__(self)
+ self.on_abort = on_abort
+ self.on_success = on_success
+ self.on_input = on_input
+ self.help_message = help_message
+ self.key_func['^M'] = self.success
+ self.key_func['^G'] = self.abort
+ self.key_func['^C'] = self.abort
+ self.key_func["KEY_UP"] = self.key_up
+ self.key_func["M-A"] = self.key_up
+ self.key_func["KEY_DOWN"] = self.key_down
+ self.key_func["M-B"] = self.key_down
+ self.histo_pos = -1
+
+ def do_command(self, key, refresh=True, raw=False):
+ res = Input.do_command(self, key, refresh, raw)
+ if self.on_input:
+ self.on_input(self.get_text())
+ return res
+
+ def disable_history(self):
+ """
+ Disable the history (up/down) keys
+ """
+ if 'KEY_UP' in self.key_func:
+ del self.key_func['KEY_UP']
+ if 'KEY_DOWN' in self.key_func:
+ del self.key_func['KEY_DOWN']
+
+ @property
+ def history_disabled(self):
+ return 'KEY_UP' not in self.key_func and 'KEY_DOWN' not in self.key_func
+
+ def success(self):
+ """
+ call the success callback, passing the text as argument
+ """
+ self.on_input = None
+ if self.search:
+ self.history_enter()
+ res = self.on_success(self.get_text())
+ return res
+
+ def abort(self):
+ """
+ Call the abort callback, passing the text as argument
+ """
+ self.on_input = None
+ return self.on_abort(self.get_text())
+
+ def on_delete(self):
+ """
+ SERIOUSLY BIG WTF.
+
+ I can do
+ self.key_func.clear()
+
+ but not
+ del self.key_func
+ because that would raise an AttributeError exception. WTF.
+ """
+ self.on_abort = None
+ self.on_success = None
+ self.on_input = None
+ self.key_func.clear()
+
+ def key_enter(self):
+ txt = self.get_text()
+ if len(txt) != 0:
+ if not self.history or self.history[0] != txt:
+ # add the message to history, but avoid duplicates
+ self.history.insert(0, txt)
+ self.histo_pos = -1
+