diff options
Diffstat (limited to 'src/windows/inputs.py')
-rw-r--r-- | src/windows/inputs.py | 764 |
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 + |