diff -uNr a/yrc/codemap.txt b/yrc/codemap.txt --- a/yrc/codemap.txt 0e94d9aa6d76e3fb86f020d2e9dda5aa8a772c0ec01844cea75eb9c53e70b0c8c24ce5af1c3a9bb8336e8d974b45011d805ac6cdf109a18623d8e5edd8ee8ac3 +++ b/yrc/codemap.txt f725097ba8d01aba9b666e7ea2fc57ba8341d4e6929f9888fbbb3ae9638aaacd74b75e8c50e7ca27bdab1cc1313d5e8e68a8503ef262d981948e7ca8187140d8 @@ -87,6 +87,7 @@ self_pipe_rd: int self_pipe_wr: int commands: (name: str) => function +slashcommands: list of str Global state ============ @@ -158,6 +159,8 @@ format_address((host: str, port: int)) -> str rfind_word_start(chars: str | list of char, start_cursor: int) -> int find_word_end(chars: str | list of char, start_cursor: int) -> int +rfind_prefix_start(chars: str | list of char, start_cursor: int) -> int +common_prefix(sequence, sequence) -> sequence int_of_bytes(str) -> int bit_length(int) -> int heap_peek(heap: list) -> (key, value) / IndexError @@ -214,6 +217,7 @@ write_out(str) : stores for write to terminal flush_out : sends full terminal output buffer variant(vtype, name: str, nargs: int) -> constructor: function of (*args -> (tag, args)) +insert_multi(list, index: int, values: iterable) : inserts values into list at index rand_int(int) -> int heap_insert(heap: list, key: comparable, value) heap_extract(heap: list) -> (key, value) / IndexError @@ -233,6 +237,7 @@ history_next prompt_backspace prompt_delete +prompt_complete kill_range(int, int) yank yank_next diff -uNr a/yrc/manifest b/yrc/manifest --- a/yrc/manifest aa28414c9b81b228d2eb2972d968fba0c03b9bc2e621cc3bc390e8e65b580015602fcd07f990ea546042512dd4ec558599ad1ac4f36be9119fc61a72941c6c5e +++ b/yrc/manifest e164eeb41b3d8210753a65acb4645e36b8bef7bb6cb87626abfb491b4d8ef31264f885740caaacc770fd2bf5e659132e056323ae1ef0fdba2566929912728f04 @@ -9,3 +9,4 @@ 768777 yrc_word_level_commands jfw Implement word-level motions and kills. A new global flag is needed for state tracking to coalesce multiple sequential kills into one. kill_start and kill_end are rebased on a common kill_range routine which also supports the new word-level variants. 768778 yrc_reindent_docs jfw Reindent spaces to tabs in documentation. (In README.txt, indentation levels are reduced across the board.) 769932 yrc_minor_char_refactors jfw Use backspace character code from yterm rather than showing it in long form. Replace char_range() with simple character classifier functions (consistent with the existing ones and potentially faster than string membership tests). Add citations for some strange IRC character classifications (which came up when looking into how to delimit completion prefixes). +769934 yrc_completion jfw Implement tab completion for nicks, channels and slash commands. Relatedly: factor out the now common case of inserting multiple characters into the prompt at once, as insert_multi; correct rfind_word_start and find_word_end to always return a valid cursor position (empty returns previously happened to work due to obscure details of None handling); turn the leading slash into a proper part of slash command names, facilitating completion and better distinguishing them from keyboard commands. (Slash commands without parameters could in theory still be given key mappings, but the normal keyboard editing & scrolling commands make little sense to type at the prompt and are no longer recognized there.) diff -uNr a/yrc/manual.txt b/yrc/manual.txt --- a/yrc/manual.txt cef4dbb225fda495761caa82f7d7fbab8afd9af88e1dea0f59814b056780386bedea490eaeff8dc8b1733a54bba6c7873667d58e4efe2a76160bf36538974f77 +++ b/yrc/manual.txt cd116294c22e74fc7f9b4e66afc943f4b853881c8ef9f66f4d45d5323bbac4171953d0591cb336b1b91ec8a18e38de3f8849b38373ca8570a21a470d333260ed @@ -283,6 +283,10 @@ Deletes forward by one character. + Tab (C-i): prompt-complete + +Completes word at cursor. Possible completions are drawn from slash commands, currently joined channel names, and nicknames present in the current channel. When the prefix is ambiguous, completes up to the longest common prefix of the candidates (i.e. goes as far as it can based on what was given). + C-u: kill-start Deletes text from start of line to cursor, saving it to the kill ring. (Kill ring size is currently fixed at 8 entries.) @@ -404,7 +408,6 @@ High-priority missing features: - - Tab completion (nicks, slash commands) - Finish mode/op and close-net commands - Scrolling in window list if it exceeds available screen rows - More accurate derivation of message length limit to reduce overly-conservative wrapping diff -uNr a/yrc/yrc.py b/yrc/yrc.py --- a/yrc/yrc.py cbeab6de181f2551f99c55c00e787be652adf191a06e937a98c3a01ba4086737c6d193843e5cd6d51f65e5688d0e90256fd00f99a61d404148e93e9b2cdb9f1e +++ b/yrc/yrc.py cc6bf18dfc43af4d1bfb7ec3accaa2148283160120701d87e8cfd14a3de088bb4748c91190b8b250c846bf80de0ac65f9c50bb07891b2f25787c293ed76ec4e3 @@ -32,7 +32,7 @@ from fcntl import fcntl, F_GETFL, F_SETFL import yterm as t -from yterm import NUL, BS, LF, CR, SO, SI, ESC, CSI, cseq +from yterm import NUL, BS, HT, LF, CR, SO, SI, ESC, CSI, cseq STDIN = 0 STDOUT = 1 self_pipe_rd, self_pipe_wr = pipe() @@ -108,6 +108,8 @@ ctrl('D'): 'prompt-delete', DEL: 'prompt-delete', + HT: 'prompt-complete', + ctrl('U'): 'kill-start', ctrl('K'): 'kill-end', @@ -194,24 +196,25 @@ 'G': 'scroll-bottom', } +# I'm inclined to get rid of this feature now that we have tab completion, but not pushing it yet. CMD_ABBREVS = { - 'c': 'connect', - 'd': 'disconnect', - 'j': 'join', - 'k': 'kick', - 'l': 'list', - 'm': 'mode', - 'n': 'nick', - 'nam': 'names', - 'p': 'part', - 'q': 'quit', - 's': 'send', - 't': 'topic', - 'st': 'set-topic', - 'w': 'whois', - 'ww': 'whowas', - 'x': 'close', - 'xn': 'close-net', + '/c': '/connect', + '/d': '/disconnect', + '/j': '/join', + '/k': '/kick', + '/l': '/list', + '/m': '/mode', + '/n': '/nick', + '/nam': '/names', + '/p': '/part', + '/q': '/quit', + '/s': '/send', + '/t': '/topic', + '/st': '/set-topic', + '/w': '/whois', + '/ww': '/whowas', + '/x': '/close', + '/xn': '/close-net', } # file.write can barf up EINTR and unclear how to retry due to buffering. Nuts. @@ -302,6 +305,12 @@ pair.append('') return pair +def insert_multi(l, index, values): + tail = l[index:] + del l[index:] + l.extend(values) + l.extend(tail) + def make_encoder(f): tbl = [f(i) for i in range(0x100)] return lambda s: ''.join(tbl[ord(c)] for c in s) @@ -391,7 +400,7 @@ # Always move at least 1 if possible i = start_cursor - 1 if i < 0: - return + return start_cursor # Continue until character at cursor is part of a word while i > 0 and not is_alpha(chars[i]): i -= 1 @@ -404,7 +413,7 @@ # Always move at least 1 if possible i = start_cursor + 1 if i > len(chars): - return + return start_cursor # Continue until character before cursor is part of a word while i < len(chars) and not is_alpha(chars[i-1]): i += 1 @@ -413,6 +422,25 @@ i += 1 return i +# Finding a prefix for completion at cursor (nick, channel or command). + +def rfind_prefix_start(chars, start_cursor): + # No intervening space allowed + i = start_cursor + # Move back until character before cursor is non-word. To keep it simple, 'word' means a valid channel string, as that's the broadest set and includes all valid nicks and slash commands. + while i > 0 and chars[i-1] not in CHAN_ILLEGAL: + i -= 1 + return i + +def common_prefix(s1, s2): + i = 0 + limit = min(len(s1), len(s2)) + while i < limit: + if s1[i] != s2[i]: + break + i += 1 + return s1[:i] + int_of_bytes = lambda b: reduce(lambda acc, byte: acc*256 + ord(byte), b, 0) # int.bit_length() added in Python 2.7 @@ -533,12 +561,15 @@ pass commands = {} +slashcommands = [] def command(name, min_args=0, max_args=None, extended_arg=False): if max_args is None: max_args = min_args def register(func): if name in commands: raise ValueError('duplicate command %s' % name) + if name.startswith('/'): + slashcommands.append(name) def run(arg_str=''): args = [] while len(args) != max_args: @@ -954,10 +985,11 @@ schedule_prompt_draw() try: if line.startswith('/'): - line = line[1:] - if not line: + if len(line) < 2: raise CommandError('empty command') - if not line.startswith('/'): + if line[1] == '/': + line = line[1:] # doubled slash to escape + else: run_command(line) return buf_privmsg(cur_buf, line) @@ -1031,6 +1063,58 @@ chars.pop(c) schedule_prompt_draw() +@command('prompt-complete') +def prompt_complete(): + # Get prefix to complete + chars = prompt_chars() + cur = prompt_cursor() + prefix = ''.join(chars[rfind_prefix_start(chars, cur):cur]) + + # Get candidates and filter by prefix + extras = [] + try: + conn = buf_conn(cur_buf) + chans = conn_channels(conn) + # Cogito ergo sum: we always know about ourself at minimum (network dependent) + extras.append(conn_nick(conn)) + except CommandError: + # No current network + chans = {} + if buf_parent(cur_buf): + venue = buf_name(cur_buf) + nicks = chans.get(venue, set()) + # Covers PM recipient or current channel if unjoined + extras.append(venue) + else: + nicks = set() + # O(n), because python's list/set/dict types don't support prefix search + matches = [] + matches.extend(s for s in nicks if s.startswith(prefix)) + matches.extend(s for s in chans if s.startswith(prefix)) + matches.extend(s for s in slashcommands if s.startswith(prefix)) + for s in extras: + if s.startswith(prefix) and s not in matches: + matches.append(s) + + # Reduce to final result + if len(matches) == 0: + return + if len(matches) == 1: + result = matches[0] + # For unambiguous commands, add trailing space. Nicks will mostly be found in message text so we can't really know what delimiter is desired (space or punctuation). + if result.startswith('/'): + result += ' ' + else: + # Ambiguous: complete to the longest common prefix + result = reduce(common_prefix, matches) + + # Insert at prompt + completion = result[len(prefix):] + if len(completion) > 0: + insert_multi(chars, cur, completion) + prompt_set_cursor(cur + len(completion)) + schedule_prompt_draw() + def kill_range(a, b, is_forward): if a < b: # Don't send empty strings to the kill ring chars = prompt_chars() @@ -1076,11 +1160,7 @@ kill_pos = ring_len(kill_ring) - 1 yank_start_pos = prompt_cursor() yank_end_pos = yank_start_pos + len(saved) - chars = prompt_chars() - tail = chars[yank_start_pos:] - del chars[yank_start_pos:] - chars.extend(saved) - chars.extend(tail) + insert_multi(prompt_chars(), yank_start_pos, saved) prompt_set_cursor(yank_end_pos) schedule_prompt_draw() @@ -1442,7 +1522,7 @@ raise CommandError('connection to %s not registered' % conn_network(c)) return c -@command('connect', 1, 4) +@command('/connect', 1, 4) def connect_cmd(*args): net = args[0] if net in network_conns: @@ -1482,7 +1562,7 @@ conn_start(new_conn(net, addrs, nick, pw)) -@command('disconnect', 0, 1) +@command('/disconnect', 0, 1) def disconnect_cmd(net=None): if net is None: c = buf_conn(cur_buf) @@ -1496,32 +1576,32 @@ conn_info(c, 'disconnected') conn_close(c) -@command('join', 1, 2) +@command('/join', 1, 2) def join_cmd(chan, key=None): if not valid_chan(chan): raise CommandError('join: bad channel name: %s' % chan) conn_join(buf_registered_conn(cur_buf), chan, key) -@command('kick', 1, extended_arg=True) +@command('/kick', 1, extended_arg=True) def kick_cmd(user, msg=''): chan = buf_name(cur_buf) if buf_parent(cur_buf) is None or not is_chan(chan): raise CommandError('kick: this window not a channel') conn_send(buf_registered_conn(cur_buf), 'KICK', [chan, user, msg]) -@command('list', 0, 1) +@command('/list', 0, 1) def list_cmd(*args): conn_send(buf_registered_conn(cur_buf), 'LIST', args) -@command('me', extended_arg=True) +@command('/me', extended_arg=True) def me_cmd(msg=''): buf_privmsg(cur_buf, '\x01ACTION %s\x01' % msg) -@command('mode', 1, 3) +@command('/mode', 1, 3) def mode_cmd(*args): conn_send(buf_registered_conn(cur_buf), 'MODE', args) -@command('msg', 1, extended_arg=True) +@command('/msg', 1, extended_arg=True) def msg_cmd(target, msg=None): c = buf_registered_conn(cur_buf) if is_chan(target) and not valid_chan(target): @@ -1530,11 +1610,11 @@ conn_privmsg(c, target, msg) buflist_select(get_buf(target, conn_network(c))) -@command('names', 0, 1) +@command('/names', 0, 1) def names_cmd(*args): conn_send(buf_registered_conn(cur_buf), 'NAMES', args) -@command('nick', 1) +@command('/nick', 1) def nick_cmd(nick): c = buf_conn(cur_buf) if conn_sock(c) is None: @@ -1550,26 +1630,26 @@ conn_run_in(c, PING_TIMEOUT, conn_reg_timeout) conn_send(c, 'NICK', [nick]) -@command('part', extended_arg=True) +@command('/part', extended_arg=True) def part_cmd(msg=''): chan = buf_name(cur_buf) if buf_parent(cur_buf) is None or not is_chan(chan): raise CommandError('part: this window not a channel') conn_send(buf_registered_conn(cur_buf), 'PART', [chan, msg]) -@command('quit', extended_arg=True) +@command('/quit', extended_arg=True) def quit_cmd(*msg): schedule_quit() for c in open_conns.itervalues(): conn_send(c, 'QUIT', msg) -@command('send', extended_arg=True) +@command('/send', extended_arg=True) def send_cmd(line): if len(line) > MAX_MSG_LEN: raise CommandError('send: line too long') conn_write(buf_registered_conn(cur_buf), line) -@command('topic', 0, 1) +@command('/topic', 0, 1) def topic_cmd(chan=None): if chan is None: chan = buf_name(cur_buf) @@ -1578,22 +1658,22 @@ 'topic: this window not a channel and none specified') conn_send(buf_registered_conn(cur_buf), 'TOPIC', [chan]) -@command('set-topic', extended_arg=True) +@command('/set-topic', extended_arg=True) def set_topic_cmd(topic=''): chan = buf_name(cur_buf) if buf_parent(cur_buf) is None or not is_chan(chan): raise CommandError('set-topic: this window not a channel') conn_send(buf_registered_conn(cur_buf), 'TOPIC', [chan, topic]) -@command('whois', 1) +@command('/whois', 1) def whois_cmd(nick): conn_send(buf_registered_conn(cur_buf), 'WHOIS', [nick]) -@command('whowas', 1) +@command('/whowas', 1) def whowas_cmd(nick): conn_send(buf_registered_conn(cur_buf), 'WHOWAS', [nick]) -@command('close') +@command('/close') def close_cmd(): parent = buf_parent(cur_buf) if parent is None: @@ -1610,7 +1690,7 @@ del conn_channels(c)[venue] close_buf(cur_buf) -@command('close-net') +@command('/close-net') def close_net_cmd(): raise CommandError('stub') # TODO