Improve the input a lot

Noticeable changes:

- The input "view" is smarter, it always move to a decent position so we can
  see enough text around the cursor.
- The cursor goes at the end of the input when pasting some long text
- The formatting chars (^C and o, b, a, 1, 2, 3 etc) are now visible in the
  input. This makes it a lot easier to know where these special characters
  are, to change them and efficiently edit our text (we just lose a little,
  on the cosmetic side, but who cares)
- The code is actually a lot simpler in the functions to move the cursor,
  insert/delete chars: we do not have to deal with special cases where the
  formatting characters are actually composed of two chars.

fixes #2183
This commit is contained in:
Florent Le Coz 2013-10-20 23:42:13 +02:00
parent dd4f8661a9
commit b12a6b3ba9
3 changed files with 142 additions and 152 deletions

View file

@ -402,7 +402,7 @@ class TextPrivateWin(TextSingleWin):
self._win.erase()
if self.color:
self._win.attron(to_curses_attr(self.color))
self.addstr('*'*len(self.text[self.line_pos:self.line_pos+self.width-1]))
self.addstr('*'*len(self.text[self.view_pos:self.view_pos+self.width-1]))
if self.color:
(y, x) = self._win.getyx()
size = self.width-x

View file

@ -40,6 +40,11 @@ import collections
from theming import get_theme, to_curses_attr, read_tuple, dump_tuple
FORMAT_CHAR = '\x19'
# These are non-printable chars, so they should never appear in the input, I
# guess. But maybe we can find better chars that are even less reasky.
format_chars = ['\x0E', '\x0F', '\x10', '\x11', '\x12','\x13', '\x14', '\x15','\x16', '\x17']
allowed_color_digits = ('0', '1', '2', '3', '4', '5', '6', '7')
# msg is a reference to the corresponding Message tuple. text_start and text_end are the position
# delimiting the text in this line.
@ -50,6 +55,16 @@ g_lock = RLock()
LINES_NB_LIMIT = 4096
def find_first_format_char(text):
pos = -1
for char in format_chars:
p = text.find(char)
if p == -1:
continue
if pos == -1 or p < pos:
pos = p
return pos
def truncate_nick(nick, size=None):
size = size or config.get('max_nick_length', 25)
if size < 1:
@ -59,7 +74,7 @@ def truncate_nick(nick, size=None):
return nick
def parse_attrs(text, previous=None):
next_attr_char = text.find('\x19')
next_attr_char = text.find(FORMAT_CHAR)
if previous:
attrs = previous
else:
@ -82,7 +97,7 @@ def parse_attrs(text, previous=None):
text = text[next_attr_char+len(color_str)+2:]
else:
text = text[next_attr_char+2:]
next_attr_char = text.find('\x19')
next_attr_char = text.find(FORMAT_CHAR)
return attrs
@ -151,7 +166,7 @@ class Win(object):
"""
if y is not None and x is not None:
self.move(y, x)
next_attr_char = text.find('\x19')
next_attr_char = text.find(FORMAT_CHAR)
while next_attr_char != -1 and text:
if next_attr_char + 1 < len(text):
attr_char = text[next_attr_char+1].lower()
@ -182,34 +197,7 @@ class Win(object):
text = text[next_attr_char+len(color_str)+2:]
else:
text = text[next_attr_char+2:]
next_attr_char = text.find('\x19')
self.addstr(text)
def addstr_colored_lite(self, text, y=None, x=None):
"""
Just like addstr_colored, but only handles colors with one digit.
\x193 is the 3rd color. We do not use any } char in this version
"""
if y is not None and x is not None:
self.move(y, x)
next_attr_char = text.find('\x19')
while next_attr_char != -1:
if next_attr_char + 1 < len(text):
attr_char = text[next_attr_char+1].lower()
else:
attr_char = str()
if next_attr_char != 0:
self.addstr(text[:next_attr_char])
text = text[next_attr_char+2:]
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)))
next_attr_char = text.find('\x19')
next_attr_char = text.find(FORMAT_CHAR)
self.addstr(text)
def finish_line(self, color=None):
@ -924,7 +912,7 @@ class TextWin(Win):
saved = Line(msg=message, start_pos=line[0], end_pos=line[1], prepend=prepend)
attrs = parse_attrs(message.txt[line[0]:line[1]], attrs)
if attrs:
prepend = '\x19' + '\x19'.join(attrs)
prepend = FORMAT_CHAR + FORMAT_CHAR.join(attrs)
else:
prepend = ''
ret.append(saved)
@ -1179,8 +1167,11 @@ class Input(Win):
}
Win.__init__(self)
self.text = ''
self.pos = 0 # cursor position
self.line_pos = 0 # position (in self.text) of
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
@ -1196,18 +1187,29 @@ class Input(Win):
self.rewrite_text()
def is_empty(self):
return len(self.text) == 0
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 not len(self.text) or self.pos == 0:
if self.pos == 0:
return
separators = string.punctuation+' '
while self.pos > 0 and self.text[self.pos+self.line_pos-1] in separators:
while self.pos > 0 and self.text[self.pos-1] in separators:
self.key_left()
while self.pos > 0 and self.text[self.pos+self.line_pos-1] not in separators:
while self.pos > 0 and self.text[self.pos-1] not in separators:
self.key_left()
return True
@ -1215,12 +1217,12 @@ class Input(Win):
"""
Move the cursor one word to the right
"""
if len(self.text) == self.pos+self.line_pos or not len(self.text):
return
if self.is_cursor_at_end():
return False
separators = string.punctuation+' '
while len(self.text) != self.pos+self.line_pos and self.text[self.pos+self.line_pos] in separators:
while not self.is_cursor_at_end() and self.text[self.pos] in separators:
self.key_right()
while len(self.text) != self.pos+self.line_pos and self.text[self.pos+self.line_pos] not in separators:
while not self.is_cursor_at_end() and self.text[self.pos] not in separators:
self.key_right()
return True
@ -1228,27 +1230,21 @@ class Input(Win):
"""
Delete the word just before the cursor
"""
if not len(self.text) or self.pos == 0:
return
separators = string.punctuation+' '
while self.pos <= len(self.text) and self.pos > 0 and self.text[self.pos+self.line_pos-1] in separators:
while self.pos > 0 and self.text[self.pos-1] in separators:
self.key_backspace()
while self.pos <= len(self.text) and self.pos > 0 and self.text[self.pos+self.line_pos-1] not in separators:
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
"""
log.debug("delete_next_word")
if len(self.text) == self.pos+self.line_pos or not len(self.text):
return
separators = string.punctuation+' '
while len(self.text) != self.pos+self.line_pos and self.text[self.pos+self.line_pos] in separators:
while not self.is_cursor_at_end() and self.text[self.pos] in separators:
self.key_dc()
while len(self.text) != self.pos+self.line_pos and self.text[self.pos+self.line_pos] not in separators:
while not self.is_cursor_at_end() and self.text[self.pos] not in separators:
self.key_dc()
return True
@ -1256,10 +1252,10 @@ class Input(Win):
"""
Cut the text from cursor to the end of line
"""
if len(self.text) == self.pos+self.line_pos:
return # nothing to cut
Input.clipboard = self.text[self.pos+self.line_pos:]
self.text = self.text[:self.pos+self.line_pos]
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
@ -1267,10 +1263,10 @@ class Input(Win):
"""
Cut the text from cursor to the begining of line
"""
if self.pos+self.line_pos == 0:
if self.pos == 0:
return
Input.clipboard = self.text[:self.pos+self.line_pos]
self.text = self.text[self.pos+self.line_pos:]
Input.clipboard = self.text[:self.pos]
self.text = self.text[self.pos:]
self.key_home()
return True
@ -1278,7 +1274,7 @@ class Input(Win):
"""
Insert what is in the clipboard at the cursor position
"""
if not Input.clipboard or len(Input.clipboard) == 0:
if not Input.clipboard:
return
for letter in Input.clipboard:
self.do_command(letter, False)
@ -1290,11 +1286,9 @@ class Input(Win):
delete char just after the cursor
"""
self.reset_completion()
if self.pos + self.line_pos == len(self.text):
if self.is_cursor_at_end():
return # end of line, nothing to delete
if self.text[self.pos+self.line_pos] == '\x19':
self.text = self.text[:self.pos+self.line_pos]+self.text[self.pos+self.line_pos+1:]
self.text = self.text[:self.pos+self.line_pos]+self.text[self.pos+self.line_pos+1:]
self.text = self.text[:self.pos]+self.text[self.pos+1:]
self.rewrite_text()
return True
@ -1304,7 +1298,6 @@ class Input(Win):
"""
self.reset_completion()
self.pos = 0
self.line_pos = 0
self.rewrite_text()
return True
@ -1314,12 +1307,8 @@ class Input(Win):
"""
if reset:
self.reset_completion()
if len(self.text) >= self.width-1:
self.pos = self.width-1
self.line_pos = len(self.text)-self.pos
else:
self.pos = len(self.text)
self.line_pos = 0
self.pos = len(self.text)
assert(self.is_cursor_at_end())
self.rewrite_text()
return True
@ -1329,21 +1318,10 @@ class Input(Win):
"""
if reset:
self.reset_completion()
if self.pos < (3*(self.width)//4) and self.line_pos > 0 and self.line_pos+self.pos-1<=len(self.text):
self.line_pos -= self.width//4
if self.line_pos < 0:
self.pos += (self.width//4) + self.line_pos - 1
self.line_pos = 0
else:
self.pos += self.width//4 - 1
elif self.pos >= 1:
self.pos -= 1
elif self.line_pos > 0:
self.line_pos -= 1
if jump and self.pos+self.line_pos >= 1 and self.text[self.pos+self.line_pos-1] == '\x19':
self.key_left()
elif reset:
if self.pos == 0:
return
self.pos -= 1
if reset:
self.rewrite_text()
return True
@ -1353,14 +1331,10 @@ class Input(Win):
"""
if reset:
self.reset_completion()
if self.pos == self.width-1:
if self.line_pos + self.width-1 < len(self.text):
self.line_pos += 1
elif self.pos < len(self.text):
self.pos += 1
if jump and self.pos+self.line_pos < len(self.text) and self.text[self.pos+self.line_pos-1] == '\x19':
self.key_right()
elif reset:
if self.is_cursor_at_end():
return
self.pos += 1
if reset:
self.rewrite_text()
return True
@ -1371,12 +1345,8 @@ class Input(Win):
self.reset_completion()
if self.pos == 0:
return
self.text = self.text[:self.pos+self.line_pos-1]+self.text[self.pos+self.line_pos:]
self.key_left(False)
if self.pos+self.line_pos >= 1 and self.text[self.pos+self.line_pos-1] == '\x19':
self.text = self.text[:self.pos+self.line_pos-1]+self.text[self.pos+self.line_pos:]
if reset:
self.rewrite_text()
self.key_left()
self.key_dc()
return True
def auto_completion(self, word_list, add_after='', quotify=True):
@ -1506,7 +1476,7 @@ class Input(Win):
if command_stop == -1 or self.pos <= command_stop:
return 0
text = self.text[command_stop+1:]
pos = self.pos + self.line_pos - len(self.text) + len(text) - 1
pos = self.pos - len(self.text) + len(text) - 1
val = common.find_argument(pos, text, quoted=quoted) + 1
return val
@ -1522,7 +1492,7 @@ class Input(Win):
Normal completion
"""
(y, x) = self._win.getyx()
pos = self.pos + self.line_pos
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:
@ -1571,12 +1541,9 @@ class Input(Win):
return False # ignore non-handled keyboard shortcuts
if reset:
self.reset_completion()
self.text = self.text[:self.pos+self.line_pos]+key+self.text[self.pos+self.line_pos:]
if self.pos + len(key) >= self.width - 1:
self.line_pos += self.pos + len(key) - (3*(self.width//4))
self.pos = 3*(self.width//4)
else:
self.pos += len(key)
# 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:
@ -1596,26 +1563,75 @@ class Input(Win):
"""
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
"""
if y is not None and x is not None:
self.move(y, x)
format_char = find_first_format_char(text)
while format_char != -1:
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)
self.addstr(text)
def rewrite_text(self):
"""
Refresh the line onscreen, from the pos and pos_line
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.replace('\n', '|')
self._win.erase()
if self.color:
self._win.attron(to_curses_attr(self.color))
displayed_text = text[self.line_pos:self.line_pos+self.width-1]
self.addstr(displayed_text)
displayed_text = text[self.view_pos:self.view_pos+self.width-1]
self._win.attrset(0)
self.addstr_colored_lite(displayed_text)
# Fill the rest of the line with the input color
if self.color:
(y, 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.addstr(0, poopt.wcswidth(displayed_text[:self.pos-self.view_pos]), '')
if self.color:
self._win.attroff(to_curses_attr(self.color))
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)
"""
if self.pos == 0:
self.view_pos = 0
return
if self.pos < self.view_pos:
self.view_pos = self.pos - 6
if self.pos > self.view_pos + self.width:
self.view_pos = self.pos - self.width + 6
assert(self.view_pos > 0 and
self.pos > self.view_pos and
self.pos < self.view_pos + self.width)
def refresh(self):
log.debug('Refresh: %s',self.__class__.__name__)
self.rewrite_text()
@ -1623,7 +1639,6 @@ class Input(Win):
def clear_text(self):
self.text = ''
self.pos = 0
self.line_pos = 0
self.rewrite_text()
def key_enter(self):
@ -1712,39 +1727,6 @@ class HistoryInput(Input):
self.histo_pos = -1
self.key_end()
def rewrite_text(self):
"""
Rewrite the text just like a normal input, but with the instruction
on the left or a "completion bar" on the right (those are mutually
exclusive)
"""
with g_lock:
text = self.text.replace('\n', '|').replace('\t', ' ')
self._win.erase()
if self.help_message:
self.addstr(self.help_message, to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
text_pos = len(self.help_message) + 1
self.addstr(' ')
else:
text_pos = 0
if self.color:
self._win.attron(to_curses_attr(self.color))
width = self.width // 2 if self.search else self.width
displayed_text = text[self.line_pos:self.line_pos+width-1]
self._win.attrset(0)
self.addstr_colored_lite(displayed_text)
if self.search:
self.update_completed()
self.addstr(0, width, self.current_completed.ljust(width+1, ' '), to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
self.addstr(0, poopt.wcswidth(displayed_text[:self.pos]) + text_pos, '')
if self.color:
self._win.attroff(to_curses_attr(self.color))
self._refresh()
class MessageInput(HistoryInput):
"""
The input featuring history and that is being used in
@ -1752,7 +1734,7 @@ class MessageInput(HistoryInput):
Also letting the user enter colors or other text markups
"""
history = list() # The history is common to all MessageInput
text_attributes = set(('b', 'o', 'u'))
text_attributes = ['b', 'o', 'u', '1', '2', '3', '4', '5', '6', '7']
def __init__(self):
HistoryInput.__init__(self)
@ -1766,12 +1748,13 @@ class MessageInput(HistoryInput):
def enter_attrib(self):
"""
Read one more char (c) and add \x19c to the string
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 or attr_char in allowed_color_digits:
self.do_command('\x19', False)
self.do_command(attr_char)
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():

View file

@ -388,6 +388,13 @@ def convert_simple_to_full_colors(text):
takes a \x19n formatted string and returns
a \x19n} formatted one.
"""
# TODO, have a single list of this. This is some sort of
# dusplicate from windows.format_chars
mapping = str.maketrans({'\x0E': '\x19b', '\x0F': '\x19o', '\x10': '\x19u',
'\x11': '\x191', '\x12': '\x192','\x13': '\x193',
'\x14': '\x194', '\x15': '\x195','\x16': '\x196',
'\x17': '\x197', '\x18': '\x198','\x19': '\x199'})
text = text.translate(mapping)
def add_curly_bracket(match):
return match.group(0) + '}'
return re.sub(xhtml_simple_attr_re, add_curly_bracket, text)