Projects : yrc : yrc_completion

yrc/yrc.py

Dir - Raw

1"""
2yrc: the Unix IRC client
3Jacob Welsh
4February 2017 - June 2019
5"""
6
7__version__ = '96 Kelvin'
8
9# Knobs that one might conceivably want to tweak
10KILL_RING_SIZE = 8
11HISTORY_RING_SIZE = 1024
12DEFAULT_PORT = 6667
13RECONN_DELAY_MIN = 4 # seconds
14RECONN_DELAY_MAX = 256
15PING_INTERVAL = 120
16PING_TIMEOUT = 240
17BUFLIST_WIDTH_MIN = len('* =#a$|')
18BUFLIST_WIDTH_STEP = 4
19BUFLIST_WIDTH_START = BUFLIST_WIDTH_MIN + 3*BUFLIST_WIDTH_STEP
20format_time = lambda tt: '%02d:%02d:%02d' % (tt.tm_hour, tt.tm_min, tt.tm_sec)
21
22from os import (
23 O_APPEND, O_CREAT, O_NONBLOCK, O_WRONLY, chdir, close, getenv, mkdir,
24 open as os_open, pipe, read, strerror, write
25)
26from time import localtime, time
27from errno import EAGAIN, EWOULDBLOCK, EEXIST, EINPROGRESS, EINTR, ENOENT
28from select import select, error as SelectError
29from signal import signal, SIG_DFL, SIGINT, SIGWINCH
30from socket import socket, SOL_SOCKET, SO_ERROR, SHUT_RDWR, error as SockError
31
32from fcntl import fcntl, F_GETFL, F_SETFL
33
34import yterm as t
35from yterm import NUL, BS, HT, LF, CR, SO, SI, ESC, CSI, cseq
36STDIN = 0
37STDOUT = 1
38self_pipe_rd, self_pipe_wr = pipe()
39
40is_ctrl = lambda c: c < '\x20' or c == '\x7f'
41# Toggle the control status of a character. As "control" isn't a dedicated bit, per ASCII table this is meaningful only for: @ A-Z [ \ ] ^ _ ?
42ctrl = lambda c: chr(ord(c) ^ 0x40)
43
44# Meta or Alt weren't even so lucky as to get a fractional bit, so an escape sequence represents them. In principle this means case can be distinguished, and on xterm this even works, but on the Linux console Alt-Shift-x sends nothing at all, so best stick to lowercase.
45meta = lambda c: ESC + c
46
47BEL = chr(7)
48CRLF = CR + LF
49C_X = ctrl('X')
50C_G = ctrl('G') # aka BEL
51
52# Cursor control keys
53LEFT = t.CURSOR_BACK1
54RIGHT = t.CURSOR_FORWARD1
55UP = t.CURSOR_UP1
56DOWN = t.CURSOR_DOWN1
57HOME = t.CURSOR_HOME
58# Not sure where these originate; vt220 perhaps? Seen on xterm, linux, screen
59END = cseq('F')
60INS = cseq('~', 2)
61DEL = cseq('~', 3)
62PGUP = cseq('~', 5)
63PGDN = cseq('~', 6)
64# Likewise; so far seen on linux, screen
65LNX_HOME = cseq('~', 1)
66LNX_END = cseq('~', 4)
67
68GLOBAL_KEYS = {
69 C_X + 'n': 'buflist-switch-next',
70 C_X + 'p': 'buflist-switch-prev',
71 C_X + 'l': 'buflist-last-selected',
72 C_X + 'w': 'buflist-enter',
73 ctrl('L'): 'redraw',
74}
75
76PROMPT_KEYS = {
77 CR: 'prompt-submit',
78
79 INS: 'prompt-exit',
80
81 ctrl('B'): 'prompt-back',
82 LEFT: 'prompt-back',
83
84 ctrl('F'): 'prompt-forward',
85 RIGHT: 'prompt-forward',
86
87 ctrl('A'): 'prompt-start',
88 HOME: 'prompt-start',
89 LNX_HOME: 'prompt-start',
90
91 ctrl('E'): 'prompt-end',
92 END: 'prompt-end',
93 LNX_END: 'prompt-end',
94
95 meta('b'): 'prompt-back-word',
96
97 meta('f'): 'prompt-forward-word',
98
99 ctrl('P'): 'history-prev',
100 UP: 'history-prev',
101
102 ctrl('N'): 'history-next',
103 DOWN: 'history-next',
104
105 BS: 'prompt-backspace',
106 ctrl('?'): 'prompt-backspace',
107
108 ctrl('D'): 'prompt-delete',
109 DEL: 'prompt-delete',
110
111 HT: 'prompt-complete',
112
113 ctrl('U'): 'kill-start',
114
115 ctrl('K'): 'kill-end',
116
117 meta(BS): 'kill-back-word',
118 meta(ctrl('?')): 'kill-back-word',
119
120 meta('d'): 'kill-forward-word',
121
122 ctrl('Y'): 'yank',
123
124 meta('y'): 'yank-next',
125
126 meta('v'): 'scroll-up-page',
127 PGUP: 'scroll-up-page',
128
129 ctrl('V'): 'scroll-down-page',
130 PGDN: 'scroll-down-page',
131
132 meta('<'): 'scroll-top',
133
134 meta('>'): 'scroll-bottom',
135}
136
137BUFLIST_KEYS = {
138 CR: 'buflist-submit',
139
140 ctrl('N'): 'buflist-next',
141 DOWN: 'buflist-next',
142 'j': 'buflist-next',
143
144 ctrl('P'): 'buflist-prev',
145 UP: 'buflist-prev',
146 'k': 'buflist-prev',
147
148 meta('<'): 'buflist-top',
149 HOME: 'buflist-top',
150 LNX_HOME: 'buflist-top',
151 'g': 'buflist-top',
152
153 meta('>'): 'buflist-bottom',
154 END: 'buflist-bottom',
155 LNX_END: 'buflist-bottom',
156 'G': 'buflist-bottom',
157
158 'h': 'buflist-shrink',
159 LEFT: 'buflist-shrink',
160
161 'l': 'buflist-grow',
162 RIGHT: 'buflist-grow',
163}
164
165BUF_KEYS = {
166 'i': 'prompt-enter',
167 INS: 'prompt-enter',
168
169 meta('v'): 'scroll-up-page',
170 PGUP: 'scroll-up-page',
171 ctrl('B'): 'scroll-up-page',
172 'b': 'scroll-up-page',
173
174 ctrl('V'): 'scroll-down-page',
175 PGDN: 'scroll-down-page',
176 ctrl('F'): 'scroll-down-page',
177 'f': 'scroll-down-page',
178 ' ': 'scroll-down-page',
179
180 ctrl('P'): 'scroll-up-line',
181 UP: 'scroll-up-line',
182 'k': 'scroll-up-line',
183
184 ctrl('N'): 'scroll-down-line',
185 DOWN: 'scroll-down-line',
186 'j': 'scroll-down-line',
187
188 meta('<'): 'scroll-top',
189 HOME: 'scroll-top',
190 LNX_HOME: 'scroll-top',
191 'g': 'scroll-top',
192
193 meta('>'): 'scroll-bottom',
194 END: 'scroll-bottom',
195 LNX_END: 'scroll-bottom',
196 'G': 'scroll-bottom',
197}
198
199# I'm inclined to get rid of this feature now that we have tab completion, but not pushing it yet.
200CMD_ABBREVS = {
201 '/c': '/connect',
202 '/d': '/disconnect',
203 '/j': '/join',
204 '/k': '/kick',
205 '/l': '/list',
206 '/m': '/mode',
207 '/n': '/nick',
208 '/nam': '/names',
209 '/p': '/part',
210 '/q': '/quit',
211 '/s': '/send',
212 '/t': '/topic',
213 '/st': '/set-topic',
214 '/w': '/whois',
215 '/ww': '/whowas',
216 '/x': '/close',
217 '/xn': '/close-net',
218}
219
220# file.write can barf up EINTR and unclear how to retry due to buffering. Nuts.
221def write_all(fd, s):
222 n = 0
223 while n < len(s):
224 try:
225 n += write(fd, s[n:])
226 except EnvironmentError, e:
227 if e.errno != EINTR:
228 raise
229
230out_buf = bytearray()
231write_out = out_buf.extend
232def flush_out():
233 write_all(STDOUT, out_buf)
234 del out_buf[:]
235
236# MLtronics
237def variant(vtype, name, nargs):
238 tag = (vtype, len(vtype), name)
239 def cons(*args):
240 if len(args) != nargs:
241 raise TypeError('%s takes %d args (%d given)'
242 % (name, nargs, len(args)))
243 return (tag, args)
244 vtype.append((name, cons))
245 return cons
246
247variant_name = lambda val: val[0][2]
248variant_args = lambda val: val[1]
249
250def matcher(vtype, cases):
251 def receiver(name, cons):
252 for case, recv in cases:
253 if case is cons:
254 return recv
255 raise TypeError('missing case %s' % name)
256 tbl = [receiver(name, cons) for name, cons in vtype]
257 def match(val):
258 tag, args = val
259 if tag[0] is not vtype:
260 raise TypeError
261 return tbl[tag[1]](*args)
262 return match
263
264message = []
265m_privmsg = variant(message, 'PRIVMSG', 2)
266m_notice = variant(message, 'NOTICE', 2)
267m_join = variant(message, 'JOIN', 2)
268m_part = variant(message, 'PART', 3)
269m_quit = variant(message, 'QUIT', 2)
270m_nick = variant(message, 'NICK', 2)
271m_kick = variant(message, 'KICK', 4) # kick of specified user
272m_kicked = variant(message, 'KICKED', 3) # kick of self
273m_topic = variant(message, 'TOPIC', 2) # unifies TOPIC and RPL_(NO)TOPIC
274m_chantopic = variant(message, 'CHANTOPIC', 3) # for unjoined channels
275m_mode = variant(message, 'MODE', 2)
276m_chanmode = variant(message, 'CHANMODE', 3)
277m_names = variant(message, 'NAMES', 3)
278m_endnames = variant(message, 'ENDNAMES', 2)
279m_error = variant(message, 'ERROR', 2) # ERROR from server
280m_client = variant(message, 'CLIENT', 1) # generated by client, not logged
281m_server = variant(message, 'SERVER', 2) # catch-all for other server msgs
282
283scr_height = None
284scr_width = None
285
286def sequence(*thunks):
287 def run():
288 for f in thunks:
289 f()
290 return run
291
292def partition(l, pred):
293 left = []
294 right = []
295 for elem in l:
296 if pred(elem):
297 left.append(elem)
298 else:
299 right.append(elem)
300 return left, right
301
302def split_pair(s, sep=' '):
303 pair = s.split(sep, 1)
304 if len(pair) == 1:
305 pair.append('')
306 return pair
307
308def insert_multi(l, index, values):
309 tail = l[index:]
310 del l[index:]
311 l.extend(values)
312 l.extend(tail)
313
314def make_encoder(f):
315 tbl = [f(i) for i in range(0x100)]
316 return lambda s: ''.join(tbl[ord(c)] for c in s)
317
318# Non-invertible terminal sanitizing
319asciify = make_encoder(lambda i:
320 '^' + ctrl(chr(i)) if i < 0x20 or i == 0x7f
321 else chr(i) if 0x20 <= i <= 0x7e
322 else '\\x%X' % i)
323
324# Uglier, injective encoding for file names
325fs_encode = make_encoder(lambda i:
326 chr(i) if 0x20 <= i <= 0x7e and chr(i) not in './%'
327 else '%' + ('%02X' % i))
328
329make_casemapper = lambda upper_bound: make_encoder(lambda i:
330 chr(i + 0x20) if 0x41 <= i <= upper_bound
331 else chr(i))
332
333# We currently support only the original IRC case mapping (as implemented, not
334# as specified; one of many howlers in the RFC). The RPL_ISUPPORT reply that
335# some servers use to advertise alternate mappings was specified in a draft
336# long since expired
337# (http://www.irc.org/tech_docs/draft-brocklesby-irc-isupport-03.txt). Besides,
338# a case-insensitive protocol that can't agree on what "case" means is
339# preposterous. This is an IRC client; if a server does not behave like an IRC
340# server, it doesn't fall to us to decipher its nonsense.
341
342IRC_CASEMAP = {'rfc1459': make_casemapper(0x5e)}
343
344casemap_ascii = make_casemapper(0x5a)
345
346clip = lambda min_, max_, x: max(min_, min(max_, x))
347clip_to = lambda l, i: clip(0, len(l) - 1, i)
348get_clipped = lambda l, i: l[clip_to(l, i)]
349
350def clip_str(s, width):
351 if width == 0:
352 return ''
353 if len(s) > width:
354 return '%s$' % s[:width - 1]
355 return s
356
357def pad_or_clip_str(s, width, pad=' '):
358 if len(s) < width:
359 return s + pad*(width - len(s))
360 return clip_str(s, width)
361
362def wrap(line, width, sep=' '):
363 width = max(1, width)
364 start = 0
365 lines = []
366 while start + width < len(line):
367 end = start + width
368 cut = line.rfind(sep, start, end + 1)
369 if cut == -1:
370 lines.append(line[start:end])
371 start = end
372 else:
373 lines.append(line[start:cut])
374 start = cut + 1
375 lines.append(line[start:])
376 return lines
377
378is_digit = lambda c: '0' <= c <= '9'
379is_alpha = lambda c: ('a' <= c <= 'z') or ('A' <= c <= 'Z')
380
381def parse_address(addr):
382 addr = addr.rsplit(':', 1)
383 host = addr[0]
384 if len(addr) == 1:
385 port = DEFAULT_PORT
386 else:
387 port = addr[1]
388 if not all(map(is_digit, port)):
389 raise ValueError('port not a positive integer: %r' % port)
390 port = int(port)
391 if port >= 2**16:
392 raise ValueError('port out of range: %d' % port)
393 return host, port
394
395format_address = lambda host_port: '%s:%d' % host_port
396
397# Finding word boundaries: these currently use the simplistic emacs/readline style, e.g. punctuation isn't distinguished from whitespace and the spot you land on depends on the direction you're coming from.
398
399def rfind_word_start(chars, start_cursor):
400 # Always move at least 1 if possible
401 i = start_cursor - 1
402 if i < 0:
403 return start_cursor
404 # Continue until character at cursor is part of a word
405 while i > 0 and not is_alpha(chars[i]):
406 i -= 1
407 # Continue until character before cursor is non-word
408 while i > 0 and is_alpha(chars[i-1]):
409 i -= 1
410 return i
411
412def find_word_end(chars, start_cursor):
413 # Always move at least 1 if possible
414 i = start_cursor + 1
415 if i > len(chars):
416 return start_cursor
417 # Continue until character before cursor is part of a word
418 while i < len(chars) and not is_alpha(chars[i-1]):
419 i += 1
420 # Continue until character at cursor is non-word
421 while i < len(chars) and is_alpha(chars[i]):
422 i += 1
423 return i
424
425# Finding a prefix for completion at cursor (nick, channel or command).
426
427def rfind_prefix_start(chars, start_cursor):
428 # No intervening space allowed
429 i = start_cursor
430 # 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.
431 while i > 0 and chars[i-1] not in CHAN_ILLEGAL:
432 i -= 1
433 return i
434
435def common_prefix(s1, s2):
436 i = 0
437 limit = min(len(s1), len(s2))
438 while i < limit:
439 if s1[i] != s2[i]:
440 break
441 i += 1
442 return s1[:i]
443
444int_of_bytes = lambda b: reduce(lambda acc, byte: acc*256 + ord(byte), b, 0)
445
446# int.bit_length() added in Python 2.7
447bit_length = lambda i: len(bin(i).lstrip('-0b'))
448
449def rand_int(n):
450 """Get a random integer from 0 (inclusive) to n (exclusive) using the
451 system's nonblocking entropy pool. More-or-less deterministic run time, at
452 the cost of a small modulo bias."""
453 nbytes = (bit_length(n) + 7) / 8
454 with open('/dev/urandom', 'rb') as f:
455 return int_of_bytes(f.read(2*nbytes)) % n
456
457#
458# Binary min-heap, used as priority queue for scheduling
459#
460
461def heap_insert(h, key, value):
462 h.append((key, value))
463 # Percolate up
464 i = len(h) - 1
465 while i > 0:
466 i_parent = (i - 1)/2
467 item = h[i]
468 parent = h[i_parent]
469 if parent[0] <= item[0]:
470 break
471 h[i] = parent
472 h[i_parent] = item
473 i = i_parent
474
475heap_peek = lambda h: h[0]
476
477def heap_extract(h):
478 if len(h) == 1:
479 return h.pop()
480 result = h[0]
481 h[0] = h.pop()
482 # Percolate down
483 i = 0
484 while True:
485 i_child = 2*i + 1
486 if i_child >= len(h):
487 break
488 i_right = i_child + 1
489 if i_right < len(h) and h[i_right][0] < h[i_child][0]:
490 i_child = i_right
491 item = h[i]
492 child = h[i_child]
493 if item[0] <= child[0]:
494 break
495 h[i] = child
496 h[i_child] = item
497 i = i_child
498 return result
499
500# purely for testing
501def heapsort(iterable):
502 h = []
503 for item in iterable:
504 heap_insert(h, item, None)
505 while h:
506 yield heap_extract(h)[0]
507
508# Rings (cyclic lists) for input history and kills
509
510new_ring = lambda size: [0, [None]*size]
511ring_start = lambda r: r[0]
512ring_list = lambda r: r[1]
513ring_set_start = lambda r, k: r.__setitem__(0, k)
514
515ring_len = lambda r: len(ring_list(r))
516ring_index = lambda r, k: (ring_start(r) + k) % len(ring_list(r))
517ring_get = lambda r, k: ring_list(r)[ring_index(r, k)]
518ring_set = lambda r, k, v: ring_list(r).__setitem__(ring_index(r, k), v)
519ring_rotate = lambda r, k: ring_set_start(r, ring_index(r, k))
520
521def ring_append(r, v):
522 # By analogy to an appended list, the idea is that the virtual indices 0 to N-1 address the entries oldest to newest (equivalently, -N to -1). The analogy breaks in that the "low" entries start out as None (uninitialized), with the appended items appearing to shift in from high to low, 0 being the last entry filled.
523 ring_set(r, 0, v)
524 ring_rotate(r, 1)
525
526#
527# Config
528#
529
530def safe_filename(name):
531 return '/' not in name and name not in ('', '.', '..')
532
533def get_config(key, paths=(()), default=None):
534 assert safe_filename(key)
535 for path in paths:
536 assert all(map(safe_filename, path))
537 fspath = []
538 fspath.extend(path)
539 fspath.append(key)
540 try:
541 with open('/'.join(fspath), 'rb') as f:
542 return f.read().rstrip('\n')
543 except EnvironmentError, e:
544 if e.errno != ENOENT:
545 error(e)
546 return default
547
548def config_lines(text):
549 if text is None:
550 return None
551 lines = text.split('\n')
552 lines = [l.strip() for l in lines if not l.startswith('#')]
553 lines = [l for l in lines if len(l) > 0]
554 return lines if lines else None
555
556#
557# UI stuff
558#
559
560class CommandError(Exception):
561 pass
562
563commands = {}
564slashcommands = []
565def command(name, min_args=0, max_args=None, extended_arg=False):
566 if max_args is None:
567 max_args = min_args
568 def register(func):
569 if name in commands:
570 raise ValueError('duplicate command %s' % name)
571 if name.startswith('/'):
572 slashcommands.append(name)
573 def run(arg_str=''):
574 args = []
575 while len(args) != max_args:
576 arg, arg_str = split_pair(arg_str.lstrip())
577 if arg == '':
578 break
579 args.append(arg)
580 arg_str = arg_str.lstrip()
581 if extended_arg:
582 args.append(arg_str)
583 elif arg_str:
584 raise CommandError('%s: too many arguments' % name)
585 if len(args) < min_args:
586 raise CommandError('%s: too few arguments' % name)
587 return func(*args)
588 commands[name] = run
589 return func
590 return register
591
592def check_command_dicts(dicts):
593 for d in dicts:
594 for cmd in d.itervalues():
595 assert cmd in commands, cmd
596
597def run_command(line):
598 cmd, args = split_pair(line)
599 cmd = CMD_ABBREVS.get(cmd, cmd)
600 func = commands.get(cmd)
601 if func is None:
602 raise CommandError('bad command: %s' % cmd)
603 return func(args)
604
605flags = [False]*12
606_getf = lambda i: (lambda: flags[i])
607_setf = lambda i: (lambda: flags.__setitem__(i, True))
608_clrf = lambda i: (lambda: flags.__setitem__(i, False))
609
610refresh_flag = _getf(0)
611schedule_refresh = _setf(0)
612refresh_done = _clrf(0)
613
614redraw_flag = _getf(1)
615schedule_redraw = _setf(1)
616redraw_done = _clrf(1)
617
618buflist_draw_flag = _getf(2)
619schedule_buflist_draw = _setf(2)
620buflist_draw_done = _clrf(2)
621
622buf_draw_flag = _getf(3)
623schedule_buf_draw = _setf(3)
624buf_draw_done = _clrf(3)
625
626prompt_draw_flag = _getf(4)
627schedule_prompt_draw = _setf(4)
628prompt_draw_done = _clrf(4)
629
630status_draw_flag = _getf(5)
631schedule_status_draw = _setf(5)
632status_draw_done = _clrf(5)
633
634quit_flag = _getf(6)
635schedule_quit = _setf(6)
636
637extend_kill_flag = _getf(7)
638set_extend_kill = _setf(7)
639exit_extend_kill = _clrf(7)
640
641# 8: available
642
643buflist_flag = _getf(9)
644buflist_enter = _setf(9)
645buflist_exit = _clrf(9)
646
647prompt_flag = _getf(10)
648prompt_enter = _setf(10)
649prompt_exit = _clrf(10)
650
651ping_draw_flag = _getf(11)
652set_ping_draw = _setf(11)
653clr_ping_draw = _clrf(11)
654
655new_buf = lambda name, parent, title: [name, parent, title,
656 (0, 0), [], 0, True]
657
658buf_name = lambda b: b[0]
659buf_parent = lambda b: b[1]
660buf_title = lambda b: b[2]
661buf_vscroll = lambda b: b[3]
662buf_lines = lambda b: b[4]
663buf_num_read = lambda b: b[5]
664buf_unread = lambda b: len(buf_lines(b)) - buf_num_read(b)
665buf_at_end = lambda b: b[6]
666
667buf_set_title = lambda b, title: b.__setitem__(2, title)
668buf_set_vscroll = lambda b, coords: b.__setitem__(3,
669 (clip_to(buf_lines(b), coords[0]), coords[1]))
670buf_set_num_read = lambda b, n: b.__setitem__(5, max(buf_num_read(b), n))
671buf_set_at_end = lambda b: b.__setitem__(6, True)
672buf_clr_at_end = lambda b: b.__setitem__(6, False)
673
674format_buf_msg = matcher(message, (
675 (m_privmsg, lambda sender, msg: '<%s> %s' % (sender, msg)),
676 (m_server, lambda sender, msg: '<%s> %s' % (sender, msg)),
677 (m_notice, lambda sender, msg: '<-%s-> %s' % (sender, msg)),
678 (m_join, lambda sender, chan: '%s joins %s' % (sender, chan)),
679 (m_part, lambda sender, chan, msg: '%s parts %s [%s]'
680 % (sender, chan, msg)),
681 (m_quit, lambda sender, msg: '%s quits [%s]' % (sender, msg)),
682 (m_nick, lambda sender, nick: '%s is now known as %s' % (sender, nick)),
683 (m_kick, lambda sender, chan, name, msg: '%s kicked from %s by %s [%s]'
684 % (name, chan, sender, msg)),
685 (m_kicked, lambda sender, chan, msg: 'kicked from %s by %s [%s]'
686 % (chan, sender, msg)),
687 (m_topic, lambda sender, topic:
688 '%s sets topic to %s' % (sender, topic)
689 if topic else
690 '%s removes topic' % sender),
691 (m_chantopic, lambda sender, chan, topic:
692 '<%s> Topic for %s: %s' % (sender, chan, topic)
693 if topic is not None else
694 '<%s> No topic for %s' % (sender, chan)),
695 (m_mode, lambda sender, modes: 'MODE %s by %s' % (modes, sender)),
696 (m_chanmode, lambda sender, chan, modes: 'MODE %s on %s by %s'
697 % (modes, chan, sender)),
698 (m_names, lambda sender, chan, names: '<%s> NAMES in %s: %s'
699 % (sender, chan, names)),
700 (m_endnames, lambda sender, chan: '<%s> end NAMES in %s'
701 % (sender, chan)),
702 (m_error, lambda sender, msg: '<%s> server error: %s' % (sender, msg)),
703 (m_client, lambda msg: msg)))
704
705def buf_log_msg(buf, m):
706 buf_lines(buf).append(format_time(localtime()) + ' ' + format_buf_msg(m))
707 schedule_buflist_draw() # for unread counts
708 if buf is cur_buf and buf_at_end(buf):
709 schedule_buf_draw()
710
711def buf_privmsg(buf, msg):
712 if buf_parent(buf) is None:
713 raise CommandError("can't send messages here")
714 conn_privmsg(buf_registered_conn(buf), buf_name(buf), msg)
715
716buf_width = lambda: scr_width - buflist_width()
717buf_height = lambda: scr_height - 3
718render_line = lambda line: wrap(asciify(line), buf_width())
719
720def buf_max_vscroll(buf):
721 lines = buf_lines(buf)
722 if len(lines) == 0:
723 return 0, 0
724 origin = len(lines) - 1
725 return origin, len(render_line(lines[origin])) - 1
726
727def add_scroll_coords(lines, coords, delta):
728 origin, offset = coords
729 if origin >= len(lines):
730 return max(1, len(lines)) - 1, 0
731 delta += offset
732 while delta > 0:
733 n_rows = len(render_line(lines[origin]))
734 if n_rows > delta:
735 break
736 delta -= n_rows
737 origin += 1
738 if origin == len(lines): # past the last line
739 return origin - 1, n_rows - 1
740 return origin, delta
741
742def sub_scroll_coords(lines, coords, delta):
743 origin, offset = coords
744 if offset >= delta:
745 return origin, offset - delta
746 delta -= offset
747 while origin > 0:
748 origin -= 1
749 n_rows = len(render_line(lines[origin]))
750 if n_rows >= delta:
751 return origin, n_rows - delta
752 delta -= n_rows
753 return 0, 0
754
755def render_lines(lines, coords, row_limit):
756 origin, offset = coords
757 rows = []
758 row_limit += offset
759 while len(rows) < row_limit and origin < len(lines):
760 group = render_line(lines[origin])
761 space = row_limit - len(rows)
762 if len(group) > space:
763 group = group[:space]
764 else:
765 origin += 1
766 rows.extend(group)
767 return rows[offset:], origin
768
769def buf_draw(buf):
770 if scr_height < 2:
771 return
772 schedule_refresh()
773 write_out(t.CURSOR_HOME)
774 write_out(t.render((
775 t.A_REVERSE, pad_or_clip_str(asciify(buf_title(buf)), scr_width))))
776 left_column = buflist_width() + 1
777 w = buf_width()
778 h = buf_height()
779 if w < 1 or h < 1:
780 return
781 top_row = 2
782 lines = buf_lines(buf)
783 rows, next_origin = render_lines(lines, buf_vscroll(buf), h)
784 if next_origin == len(lines):
785 buf_set_at_end(buf)
786 elif buf_at_end(buf):
787 # no longer at end: autoscroll
788 buf_set_vscroll(buf,
789 sub_scroll_coords(lines, buf_max_vscroll(buf), h - 1))
790 rows, next_origin = render_lines(lines, buf_vscroll(buf), h)
791 buf_set_num_read(buf, next_origin)
792 n = -1
793 for n, row in enumerate(rows):
794 write_out(t.cursor_pos(top_row + n, left_column))
795 write_out(row)
796 if len(row) < w:
797 write_out(t.ERASE_LINE_TO_END)
798 for n in range(n + 1, h):
799 write_out(t.cursor_pos(top_row + n, left_column))
800 write_out(t.ERASE_LINE_TO_END)
801 buf_draw_done()
802
803cur_buf = new_buf('yrc', None, 'yrc general messages')
804buffers = [cur_buf]
805buffer_index = {}
806
807is_child_of = lambda parent: lambda buf: buf_parent(buf) is parent
808
809def sort_buffers():
810 global buffers
811 schedule_buflist_draw()
812 # "yrc" is always first
813 acc, buffers = buffers[:1], buffers[1:]
814 roots, buffers = partition(buffers, is_child_of(None))
815 roots.sort(key=buf_name)
816 for root in roots:
817 children, buffers = partition(buffers, is_child_of(root))
818 children.sort(key=buf_name)
819 acc.append(root)
820 acc.extend(children)
821 buffers = acc
822 buffer_index.clear()
823 for b in buffers:
824 parent = buf_parent(b)
825 parent_name = buf_name(parent) if parent else None
826 buffer_index[(buf_name(b), parent_name)] = b
827
828def get_buf(name, parent_name):
829 try:
830 return buffer_index[(name, parent_name)]
831 except KeyError:
832 if parent_name is None:
833 parent = None
834 title = 'Network messages: %s' % name
835 else:
836 parent = get_buf(parent_name, None)
837 title = name if is_chan(name) else 'Private messages: %s' % name
838 b = new_buf(name, parent, title)
839 buffers.append(b)
840 sort_buffers()
841 return b
842
843def find_buf(buf):
844 for i, b in enumerate(buffers):
845 if b is buf:
846 return i
847 raise ValueError("not in buffer list")
848
849def close_buf(buf):
850 i = find_buf(buf)
851 assert i > 0
852 last = buflist_last()
853 if last is buf:
854 last = buffers[i - 1]
855 buflist_select(last)
856 buflist_set_last(last)
857 parent = buf_parent(buf)
858 parent_name = buf_name(parent) if parent else None
859 del buffer_index[(buf_name(buf), parent_name)]
860 buffers.pop(i)
861
862command('scroll-down-page')(sequence(
863 schedule_buf_draw,
864 schedule_buflist_draw, # update unread counts
865 lambda: buf_set_vscroll(cur_buf,
866 add_scroll_coords(buf_lines(cur_buf),
867 buf_vscroll(cur_buf), buf_height() - 1))))
868
869command('scroll-up-page')(sequence(
870 schedule_buf_draw,
871 lambda: buf_set_vscroll(cur_buf,
872 sub_scroll_coords(buf_lines(cur_buf),
873 buf_vscroll(cur_buf), buf_height() - 1)),
874 # Stop autoscrolling, to be resumed by buf_draw if it turns out the last line is still on screen
875 lambda: buf_clr_at_end(cur_buf)))
876
877command('scroll-down-line')(sequence(
878 schedule_buf_draw,
879 schedule_buflist_draw,
880 lambda: buf_set_vscroll(cur_buf,
881 add_scroll_coords(buf_lines(cur_buf),
882 buf_vscroll(cur_buf), 1))))
883
884command('scroll-up-line')(sequence(
885 schedule_buf_draw,
886 lambda: buf_set_vscroll(cur_buf,
887 sub_scroll_coords(buf_lines(cur_buf),
888 buf_vscroll(cur_buf), 1)),
889 lambda: buf_clr_at_end(cur_buf)))
890
891command('scroll-bottom')(sequence(
892 schedule_buf_draw,
893 schedule_buflist_draw,
894 lambda: buf_set_vscroll(cur_buf, buf_max_vscroll(cur_buf))))
895
896command('scroll-top')(sequence(
897 schedule_buf_draw,
898 lambda: buf_set_vscroll(cur_buf, (0, 0)),
899 lambda: buf_clr_at_end(cur_buf)))
900
901def info(msg, buf=None):
902 buf_log_msg(buf or buffers[0], m_client(msg))
903
904def error(msg_or_exc, buf=None):
905 buf_log_msg(buf or buffers[0], m_client('ERROR: ' + str(msg_or_exc)))
906
907# The prompt is a one-line text input field at the bottom of the screen, spanning its width. This record tracks its state.
908prompt = [[], 0, 0, 1]
909
910prompt_chars = lambda: prompt[0]
911prompt_cursor = lambda: prompt[1]
912prompt_hscroll = lambda: prompt[2]
913prompt_cursor_column = lambda: prompt[3]
914
915def prompt_set_cursor(c):
916 schedule_prompt_draw()
917 if 0 <= c <= len(prompt_chars()):
918 prompt[1] = c
919 return True
920 return False
921
922def prompt_set_hscroll(s):
923 # This is called automatically by prompt_draw itself, but make sure a redraw gets done in case more calls are added.
924 schedule_prompt_draw()
925 prompt[2] = s
926
927def prompt_set_cursor_column(c):
928 prompt[3] = c
929
930def prompt_clear():
931 schedule_prompt_draw()
932 del prompt_chars()[:]
933 prompt_set_cursor(0)
934 # hscroll and cursor_column are always updated at drawing time so no need to explicitly reset them, as with any code that changes chars or cursor.
935
936history_ring = new_ring(HISTORY_RING_SIZE)
937history_pos = None
938history_stash = None
939
940kill_ring = new_ring(KILL_RING_SIZE)
941kill_pos = None
942yank_start_pos = None
943yank_end_pos = None
944
945def exit_yank():
946 global kill_pos, yank_start_pos, yank_end_pos
947 kill_pos = None
948 yank_start_pos = None
949 yank_end_pos = None
950
951# Commands from the prompt keymap known to not require resetting the yank state, i.e. those that can't change the prompt's contents or move its cursor.
952YANK_SAFE_COMMANDS = set((
953 'prompt-exit',
954 'yank-next',
955 'scroll-up-page',
956 'scroll-down-page',
957 'scroll-top',
958 'scroll-bottom',
959))
960
961# Commands from the prompt keymap that don't reset the kill-extending state, in addition to YANK_SAFE_COMMANDS. (yank-next is accepted here too, because if we just killed then we didn't just yank, so it does nothing.)
962KILL_COMMANDS = set((
963 'kill-start',
964 'kill-end',
965 'kill-back-word',
966 'kill-forward-word',
967))
968
969def prompt_insert(char):
970 schedule_prompt_draw()
971 c = prompt_cursor()
972 prompt_chars().insert(c, char)
973 prompt_set_cursor(c + 1)
974
975@command('prompt-submit')
976def prompt_submit():
977 global history_pos, history_stash
978 line = ''.join(prompt_chars())
979 if len(line) == 0:
980 return
981 ring_append(history_ring, line)
982 history_pos = None
983 history_stash = None
984 prompt_clear()
985 schedule_prompt_draw()
986 try:
987 if line.startswith('/'):
988 if len(line) < 2:
989 raise CommandError('empty command')
990 if line[1] == '/':
991 line = line[1:] # doubled slash to escape
992 else:
993 run_command(line)
994 return
995 buf_privmsg(cur_buf, line)
996 except CommandError, e:
997 error(e, cur_buf)
998
999command('prompt-back')(lambda: prompt_set_cursor(prompt_cursor() - 1))
1000command('prompt-forward')(lambda: prompt_set_cursor(prompt_cursor() + 1))
1001command('prompt-start')(lambda: prompt_set_cursor(0))
1002
1003@command('prompt-end')
1004def prompt_end():
1005 prompt_set_cursor(len(prompt_chars()))
1006
1007command('prompt-back-word')(lambda:
1008 prompt_set_cursor(rfind_word_start(prompt_chars(), prompt_cursor())))
1009command('prompt-forward-word')(lambda:
1010 prompt_set_cursor(find_word_end(prompt_chars(), prompt_cursor())))
1011
1012@command('history-prev')
1013def history_prev():
1014 global history_pos, history_stash
1015 if ring_get(history_ring, -1) is None:
1016 # Empty history
1017 return
1018 if history_pos is None:
1019 # Editing a fresh line: stash it and enter history
1020 history_stash = ''.join(prompt_chars())
1021 history_pos = ring_len(history_ring)
1022 elif history_pos == 0 or ring_get(history_ring, history_pos-1) is None:
1023 # Already at oldest initialized position
1024 return
1025 history_pos -= 1
1026 # Copy into the current prompt input. The string from history is immutable as it should be! (GNU Readline fails here)
1027 prompt_clear()
1028 prompt_chars().extend(ring_get(history_ring, history_pos))
1029 prompt_end()
1030 schedule_prompt_draw()
1031
1032@command('history-next')
1033def history_next():
1034 global history_pos, history_stash
1035 if history_pos is None:
1036 # Editing a fresh line: not in history
1037 return
1038 history_pos += 1
1039 prompt_clear()
1040 if history_pos < ring_len(history_ring):
1041 prompt_chars().extend(ring_get(history_ring, history_pos))
1042 else:
1043 # Past the end: exit history and restore from stash (pdksh fails here)
1044 history_pos = None
1045 prompt_chars().extend(history_stash)
1046 history_stash = None
1047 prompt_end()
1048 schedule_prompt_draw()
1049
1050@command('prompt-backspace')
1051def prompt_backspace():
1052 c = prompt_cursor() - 1
1053 if c >= 0:
1054 prompt_set_cursor(c)
1055 prompt_chars().pop(c)
1056 schedule_prompt_draw()
1057
1058@command('prompt-delete')
1059def prompt_delete():
1060 c = prompt_cursor()
1061 chars = prompt_chars()
1062 if c < len(chars):
1063 chars.pop(c)
1064 schedule_prompt_draw()
1065
1066@command('prompt-complete')
1067def prompt_complete():
1068 # Get prefix to complete
1069 chars = prompt_chars()
1070 cur = prompt_cursor()
1071 prefix = ''.join(chars[rfind_prefix_start(chars, cur):cur])
1072
1073 # Get candidates and filter by prefix
1074 extras = []
1075 try:
1076 conn = buf_conn(cur_buf)
1077 chans = conn_channels(conn)
1078 # Cogito ergo sum: we always know about ourself at minimum (network dependent)
1079 extras.append(conn_nick(conn))
1080 except CommandError:
1081 # No current network
1082 chans = {}
1083 if buf_parent(cur_buf):
1084 venue = buf_name(cur_buf)
1085 nicks = chans.get(venue, set())
1086 # Covers PM recipient or current channel if unjoined
1087 extras.append(venue)
1088 else:
1089 nicks = set()
1090 # O(n), because python's list/set/dict types don't support prefix search
1091 matches = []
1092 matches.extend(s for s in nicks if s.startswith(prefix))
1093 matches.extend(s for s in chans if s.startswith(prefix))
1094 matches.extend(s for s in slashcommands if s.startswith(prefix))
1095 for s in extras:
1096 if s.startswith(prefix) and s not in matches:
1097 matches.append(s)
1098
1099 # Reduce to final result
1100 if len(matches) == 0:
1101 return
1102 if len(matches) == 1:
1103 result = matches[0]
1104 # 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).
1105 if result.startswith('/'):
1106 result += ' '
1107 else:
1108 # Ambiguous: complete to the longest common prefix
1109 result = reduce(common_prefix, matches)
1110
1111 # Insert at prompt
1112 completion = result[len(prefix):]
1113 if len(completion) > 0:
1114 insert_multi(chars, cur, completion)
1115 prompt_set_cursor(cur + len(completion))
1116 schedule_prompt_draw()
1117
1118def kill_range(a, b, is_forward):
1119 if a < b: # Don't send empty strings to the kill ring
1120 chars = prompt_chars()
1121 s = ''.join(chars[a:b])
1122 # When killing multiple times in a row, accumulate into single entry.
1123 if extend_kill_flag():
1124 acc = ring_get(kill_ring, -1)
1125 if is_forward:
1126 acc = acc + s
1127 else:
1128 acc = s + acc
1129 ring_set(kill_ring, -1, acc)
1130 else:
1131 ring_append(kill_ring, s)
1132 prompt_set_cursor(a)
1133 del chars[a:b]
1134 schedule_prompt_draw()
1135 set_extend_kill()
1136
1137command('kill-start')(lambda: kill_range(0, prompt_cursor(), False))
1138command('kill-end')(lambda: kill_range(
1139 prompt_cursor(),
1140 len(prompt_chars()),
1141 True))
1142
1143command('kill-back-word')(lambda: kill_range(
1144 rfind_word_start(prompt_chars(), prompt_cursor()),
1145 prompt_cursor(),
1146 False))
1147command('kill-forward-word')(lambda: kill_range(
1148 prompt_cursor(),
1149 find_word_end(prompt_chars(), prompt_cursor()),
1150 True))
1151
1152@command('yank')
1153def yank():
1154 global kill_pos, yank_start_pos, yank_end_pos
1155 # Always start from most recent kill (pdksh and GNU Readline disagree here; I favor the former's way, as it matches history behavior and reduces hidden state)
1156 saved = ring_get(kill_ring, -1)
1157 if saved is None:
1158 # Empty kill ring
1159 return
1160 kill_pos = ring_len(kill_ring) - 1
1161 yank_start_pos = prompt_cursor()
1162 yank_end_pos = yank_start_pos + len(saved)
1163 insert_multi(prompt_chars(), yank_start_pos, saved)
1164 prompt_set_cursor(yank_end_pos)
1165 schedule_prompt_draw()
1166
1167@command('yank-next')
1168def yank_next():
1169 global kill_pos, yank_end_pos
1170 if kill_pos is None:
1171 # Nothing yanked yet
1172 return
1173 if kill_pos == 0 or ring_get(kill_ring, kill_pos-1) is None:
1174 # Past oldest initialized kill slot: wrap back to newest
1175 kill_pos = ring_len(kill_ring)
1176 kill_pos -= 1
1177 saved = ring_get(kill_ring, kill_pos)
1178 chars = prompt_chars()
1179 assert yank_end_pos == prompt_cursor()
1180 # ^ something changed and we failed to call exit_yank(), so the positions are no longer valid
1181 tail = chars[yank_end_pos:]
1182 del chars[yank_start_pos:]
1183 chars.extend(saved)
1184 chars.extend(tail)
1185 yank_end_pos = yank_start_pos + len(saved)
1186 prompt_set_cursor(yank_end_pos)
1187 schedule_prompt_draw()
1188
1189def prompt_draw():
1190 if scr_height < 1:
1191 return
1192 write_out(t.cursor_pos(scr_height, 1))
1193 if scr_width < 4:
1194 write_out(t.ERASE_LINE_TO_END)
1195 return
1196 # XXX Sometimes this redraws more than strictly necessary, such as when simply appending a character or moving the cursor without scrolling. Over a network the added transmission overhead is minimal (probably all fitting in the same packet as the required part of the update) but over a serial line it might be bothersome. A brute-force approach would be to stash the last rendered box contents, find the common prefix and draw only the rest.
1197 prompt_str = '> '
1198 write_out(prompt_str)
1199 chars = prompt_chars()
1200 cursor = prompt_cursor()
1201 hscr = prompt_hscroll()
1202 # XXX O(N) rendering (one difficulty is that hscroll is measured in rendered rather than source characters)
1203 before_cursor = asciify(chars[:cursor])
1204 cursor_offset = len(before_cursor) - hscr
1205 max_width = scr_width - len(prompt_str)
1206 if not 0 <= cursor_offset < max_width:
1207 # Cursor outside box: scroll text to center it.
1208 hscr = max(0, len(before_cursor) - max_width/2)
1209 prompt_set_hscroll(hscr)
1210 cursor_offset = len(before_cursor) - hscr
1211 prompt_set_cursor_column(1 + len(prompt_str) + cursor_offset)
1212 write_out(before_cursor[hscr:])
1213 trailing_cols = max_width - cursor_offset
1214 # Pre-clip to skip needless O(N) rendering, then re-clip in case the rendering added characters.
1215 write_out(asciify(chars[cursor:cursor+trailing_cols])[:trailing_cols])
1216 write_out(t.ERASE_LINE_TO_END)
1217 schedule_refresh()
1218 prompt_draw_done()
1219
1220def conn_ping_status(c):
1221 t0 = conn_ping_ts(c)
1222 t1 = conn_pong_ts(c)
1223 if t1 < t0:
1224 if not ping_draw_flag():
1225 set_ping_draw()
1226 run_in(5, sequence(clr_ping_draw, status_draw))
1227 delta = int(mono_time() - t0)
1228 return '%d...' % (delta - delta%5)
1229 return '%.3f' % (t1 - t0)
1230
1231def status_draw():
1232 row = scr_height - 1
1233 if row < 1:
1234 return
1235 if kbd_state is ks_esc:
1236 status = 'ESC-'
1237 elif kbd_state is ks_cx:
1238 status = 'C-x-'
1239 elif kbd_state in (ks_cseq, ks_cs_intermed):
1240 status = ' '.join(['CSI'] + list(str(kbd_accum[len(CSI):]))) + '-'
1241 elif cur_buf is buffers[0]:
1242 status = ''
1243 else:
1244 parent = buf_parent(cur_buf)
1245 network = buf_name(cur_buf if parent is None else parent)
1246 status = asciify(network)
1247 c = network_conns.get(network)
1248 if c:
1249 status = asciify(conn_nick(c) + '@') + status
1250 if parent:
1251 venue = buf_name(cur_buf)
1252 status += ' | ' + asciify(venue)
1253 if c and conn_sock(c):
1254 if not conn_registered(c):
1255 status += ' | unregistered'
1256 elif parent and is_chan(venue) and venue not in conn_channels(c):
1257 status += ' | unjoined'
1258 status += ' | ping: %s' % conn_ping_status(c)
1259 else:
1260 status += ' | offline'
1261 write_out(t.cursor_pos(row, 1))
1262 write_out(t.ATTR_REVERSE)
1263 write_out(pad_or_clip_str(status, scr_width))
1264 write_out(t.ATTR_NONE)
1265 schedule_refresh()
1266 status_draw_done()
1267
1268# buflist singleton: lists the open buffers in a left-hand pane
1269
1270buflist = [0, BUFLIST_WIDTH_START, cur_buf, cur_buf, cur_buf]
1271buflist_vscroll = lambda: buflist[0]
1272buflist_width = lambda: buflist[1]
1273buflist_cursor = lambda: buflist[2]
1274buflist_last = lambda: buflist[3]
1275
1276def buflist_set_width(w):
1277 schedule_redraw()
1278 if w > scr_width:
1279 w = scr_width - (scr_width - w) % BUFLIST_WIDTH_STEP
1280 buflist[1] = max(BUFLIST_WIDTH_MIN, w)
1281
1282def buflist_set_cursor(b):
1283 schedule_buflist_draw()
1284 buflist[2] = b
1285
1286def buflist_set_last(b):
1287 buflist[3] = b
1288
1289def buflist_select(b):
1290 global cur_buf
1291 schedule_buflist_draw()
1292 if b is not cur_buf:
1293 schedule_buf_draw()
1294 schedule_status_draw()
1295 buflist_set_last(cur_buf)
1296 # Stop autoscrolling for newly selected window, to be resumed by buf_draw if it turns out the last line is still on screen
1297 buf_clr_at_end(b)
1298 buflist_set_cursor(b)
1299 cur_buf = b
1300
1301command('buflist-switch-next')(
1302 lambda: buflist_select(
1303 get_clipped(buffers, find_buf(cur_buf) + 1)))
1304command('buflist-switch-prev')(
1305 lambda: buflist_select(
1306 get_clipped(buffers, find_buf(cur_buf) - 1)))
1307command('buflist-last-selected')(lambda: buflist_select(buflist_last()))
1308
1309command('buflist-next')(
1310 lambda: buflist_set_cursor(
1311 get_clipped(buffers, find_buf(buflist_cursor()) + 1)))
1312command('buflist-prev')(
1313 lambda: buflist_set_cursor(
1314 get_clipped(buffers, find_buf(buflist_cursor()) - 1)))
1315command('buflist-top')(lambda: buflist_set_cursor(buffers[0]))
1316command('buflist-bottom')(lambda: buflist_set_cursor(buffers[-1]))
1317command('buflist-shrink')(lambda:
1318 buflist_set_width(buflist_width() - BUFLIST_WIDTH_STEP))
1319command('buflist-grow')(lambda:
1320 buflist_set_width(buflist_width() + BUFLIST_WIDTH_STEP))
1321
1322@command('buflist-submit')
1323def buflist_submit():
1324 buflist_exit()
1325 buflist_select(buflist_cursor())
1326
1327def buflist_draw():
1328 schedule_refresh()
1329 top_row = 2
1330 h = scr_height - 3
1331 w = min(scr_width, buflist_width() - 1)
1332 cursor = buflist_cursor()
1333 def network_status(network):
1334 c = network_conns.get(network)
1335 if c is None: # not connected
1336 return ' '
1337 sock = conn_sock(c)
1338 if sock is None: # down, awaiting reconnection
1339 return '~'
1340 fd = conn_sock(c).fileno()
1341 if fd in opening_conns: # DNS / TCP handshake
1342 return '~'
1343 if conn_registered(c):
1344 return '='
1345 else: # TCP connected but unregistered (no WELCOME yet)
1346 return '-'
1347 def get_label(buf):
1348 parts = []
1349 if buf is cur_buf:
1350 parts.append('*')
1351 elif buf is buflist_last():
1352 parts.append('-')
1353 else:
1354 parts.append(' ')
1355 if buf_parent(buf) is None:
1356 if buf is buffers[0]:
1357 parts.append(' ')
1358 else:
1359 parts.append(network_status(buf_name(buf)))
1360 else: # channel/pm
1361 parts.append(' ')
1362 parts.append(asciify(buf_name(buf)))
1363 unread = buf_unread(buf)
1364 if unread > 0:
1365 parts.append(' +%d' % unread)
1366 label = ''.join(parts)
1367 if buf is cursor and buflist_flag():
1368 return t.render((t.A_REVERSE, pad_or_clip_str(label, w)))
1369 else:
1370 return clip_str(label, w)
1371 write_out(t.cursor_pos(2, 1))
1372 scroll = buflist_vscroll()
1373 for row in range(h):
1374 write_out(t.cursor_pos(top_row + row, w) + t.ERASE_LINE_FROM_START)
1375 i = scroll + row
1376 if i < len(buffers):
1377 write_out(CR + get_label(buffers[i]))
1378 buflist_draw_done()
1379
1380def buflist_vline_draw():
1381 top_row = 2
1382 height = scr_height - 3
1383 column = buflist_width()
1384 if column > scr_width:
1385 return
1386 move_down = LF if column == scr_width else BS + LF
1387 write_out(t.cursor_pos(top_row, column))
1388 write_out(SO)
1389 write_out(move_down.join(t.SGC_VBAR*height))
1390 write_out(SI)
1391
1392command('prompt-enter')(sequence(schedule_refresh, prompt_enter))
1393command('prompt-exit')(sequence(schedule_refresh, prompt_exit))
1394command('buflist-enter')(sequence(schedule_buflist_draw, buflist_enter))
1395command('redraw')(schedule_redraw)
1396
1397# Terminal input state machine
1398
1399# Only valid control sequences per ECMA-48 5ed. sec. 5.4 are accepted.
1400# Esc always cancels any sequence in progress and moves to ks_esc, to avoid
1401# control sequences leaking through as text input. C-g always cancels and
1402# returns to ks_start.
1403
1404kbd_accum = bytearray()
1405kaccum = kbd_accum.extend
1406
1407def kaccept(sym=''):
1408 kaccum(sym)
1409 seq = str(kbd_accum)
1410 ktrans(ks_start)
1411 def try_keymap(km):
1412 cmd = km.get(seq)
1413 if cmd is None:
1414 return False
1415 run_command(cmd)
1416 return True
1417 try:
1418 if try_keymap(GLOBAL_KEYS):
1419 return
1420 elif buflist_flag():
1421 try_keymap(BUFLIST_KEYS)
1422 elif prompt_flag():
1423 # Prompt has the focus. A bit of an awkward special case: any editing action other than yank-next needs to reset yank state, if any. Likewise, any editing action other than a kill needs to reset kill-extending state. Rather than littering every command handler with state reset calls (and risk missing some), we peek inside the box here at the common ancestor.
1424 cmd = PROMPT_KEYS.get(seq)
1425 if cmd is None:
1426 if len(seq) == 1 and not is_ctrl(seq):
1427 exit_yank()
1428 exit_extend_kill()
1429 prompt_insert(seq)
1430 else:
1431 if cmd not in YANK_SAFE_COMMANDS:
1432 exit_yank()
1433 if cmd not in KILL_COMMANDS:
1434 exit_extend_kill()
1435 run_command(cmd)
1436 else:
1437 try_keymap(BUF_KEYS)
1438 except CommandError, e:
1439 error(e, cur_buf)
1440
1441def ktrans(state):
1442 global kbd_state
1443 kbd_state = state
1444 schedule_status_draw()
1445 if state in (ks_start, ks_esc):
1446 del kbd_accum[:]
1447 elif state is ks_cseq:
1448 del kbd_accum[:]
1449 kaccum(CSI)
1450
1451# States
1452
1453def ks_start(sym):
1454 if sym == C_X:
1455 kaccum(C_X)
1456 ktrans(ks_cx)
1457 elif sym == ESC:
1458 ktrans(ks_esc)
1459 else:
1460 kaccept(sym)
1461
1462def ks_cx(sym):
1463 if sym == C_G:
1464 ktrans(ks_start)
1465 elif sym == ESC:
1466 ktrans(ks_esc)
1467 else:
1468 kaccept(casemap_ascii(ctrl(sym)) if is_ctrl(sym) else sym)
1469
1470def ks_esc(sym):
1471 if sym == C_G:
1472 ktrans(ks_start)
1473 elif sym == ESC:
1474 pass
1475 elif sym == '[':
1476 ktrans(ks_cseq)
1477 else:
1478 kaccept(meta(sym))
1479
1480def ks_cseq(sym):
1481 if sym == ESC:
1482 ktrans(ks_esc)
1483 elif '\x20' <= sym <= '\x2F':
1484 kaccum(sym)
1485 ktrans(ks_cs_intermed)
1486 elif '\x30' <= sym <= '\x3F':
1487 kaccum(sym)
1488 schedule_status_draw()
1489 elif '\x40' <= sym <= '\x7E':
1490 kaccept(sym)
1491 else:
1492 ktrans(ks_start)
1493
1494def ks_cs_intermed(sym):
1495 if sym == ESC:
1496 ktrans(ks_esc)
1497 elif '\x20' <= sym <= '\x2F':
1498 kaccum(sym)
1499 schedule_status_draw()
1500 elif '\x40' <= sym <= '\x7E':
1501 kaccept(sym)
1502 else:
1503 ktrans(ks_start)
1504
1505kbd_state = ks_start
1506
1507def buf_conn(buf):
1508 if buf is buffers[0]:
1509 raise CommandError('this window not associated with a network')
1510 parent = buf_parent(buf)
1511 network = buf_name(buf if parent is None else parent)
1512 try:
1513 return network_conns[network]
1514 except KeyError:
1515 raise CommandError('not connected to %s' % network)
1516
1517def buf_registered_conn(buf):
1518 c = buf_conn(buf)
1519 if conn_sock(c) is None:
1520 raise CommandError('connection to %s is down' % conn_network(c))
1521 if not conn_registered(c):
1522 raise CommandError('connection to %s not registered' % conn_network(c))
1523 return c
1524
1525@command('/connect', 1, 4)
1526def connect_cmd(*args):
1527 net = args[0]
1528 if net in network_conns:
1529 raise CommandError('connect: connection already active for %s' % net)
1530
1531 if not safe_filename(net):
1532 raise CommandError('connect: bad network name: %s' % net)
1533 conf_paths = (('nets', net), ())
1534
1535 if len(args) > 1:
1536 addrs = [args[1]]
1537 else:
1538 addrs = config_lines(get_config('addrs', conf_paths))
1539 if addrs is None:
1540 raise CommandError('connect: no addrs for network %s' % net)
1541 try:
1542 addrs = map(parse_address, addrs)
1543 except ValueError, e:
1544 raise CommandError('connect: %s' % e)
1545
1546 if len(args) > 2:
1547 nick = args[2]
1548 else:
1549 nick = get_config('nick', conf_paths)
1550 if nick is None:
1551 raise CommandError('connect: no nick for %s' % net)
1552 if not valid_nick(nick):
1553 raise CommandError('connect: bad nick: %s' % nick)
1554
1555 if len(args) > 3:
1556 pw = args[3]
1557 else:
1558 pw = get_config('pass', conf_paths)
1559 if not valid_password(pw):
1560 raise CommandError(
1561 'connect: illegal character in password for %s' % net)
1562
1563 conn_start(new_conn(net, addrs, nick, pw))
1564
1565@command('/disconnect', 0, 1)
1566def disconnect_cmd(net=None):
1567 if net is None:
1568 c = buf_conn(cur_buf)
1569 net = conn_network(c)
1570 else:
1571 try:
1572 c = network_conns[net]
1573 except KeyError:
1574 raise CommandError('no connection for network %s' % net)
1575 del network_conns[net]
1576 conn_info(c, 'disconnected')
1577 conn_close(c)
1578
1579@command('/join', 1, 2)
1580def join_cmd(chan, key=None):
1581 if not valid_chan(chan):
1582 raise CommandError('join: bad channel name: %s' % chan)
1583 conn_join(buf_registered_conn(cur_buf), chan, key)
1584
1585@command('/kick', 1, extended_arg=True)
1586def kick_cmd(user, msg=''):
1587 chan = buf_name(cur_buf)
1588 if buf_parent(cur_buf) is None or not is_chan(chan):
1589 raise CommandError('kick: this window not a channel')
1590 conn_send(buf_registered_conn(cur_buf), 'KICK', [chan, user, msg])
1591
1592@command('/list', 0, 1)
1593def list_cmd(*args):
1594 conn_send(buf_registered_conn(cur_buf), 'LIST', args)
1595
1596@command('/me', extended_arg=True)
1597def me_cmd(msg=''):
1598 buf_privmsg(cur_buf, '\x01ACTION %s\x01' % msg)
1599
1600@command('/mode', 1, 3)
1601def mode_cmd(*args):
1602 conn_send(buf_registered_conn(cur_buf), 'MODE', args)
1603
1604@command('/msg', 1, extended_arg=True)
1605def msg_cmd(target, msg=None):
1606 c = buf_registered_conn(cur_buf)
1607 if is_chan(target) and not valid_chan(target):
1608 raise CommandError('msg: bad channel name: %s' % target)
1609 if msg:
1610 conn_privmsg(c, target, msg)
1611 buflist_select(get_buf(target, conn_network(c)))
1612
1613@command('/names', 0, 1)
1614def names_cmd(*args):
1615 conn_send(buf_registered_conn(cur_buf), 'NAMES', args)
1616
1617@command('/nick', 1)
1618def nick_cmd(nick):
1619 c = buf_conn(cur_buf)
1620 if conn_sock(c) is None:
1621 schedule_status_draw()
1622 conn_set_nick(c, nick)
1623 conn_info(c, 'nick changed to %s for next reconnection' % nick)
1624 else:
1625 if not conn_registered(c):
1626 schedule_status_draw()
1627 conn_set_nick(c, nick)
1628 conn_info(c, 'nick changed to %s' % nick)
1629 conn_set_ping_ts(c, mono_time())
1630 conn_run_in(c, PING_TIMEOUT, conn_reg_timeout)
1631 conn_send(c, 'NICK', [nick])
1632
1633@command('/part', extended_arg=True)
1634def part_cmd(msg=''):
1635 chan = buf_name(cur_buf)
1636 if buf_parent(cur_buf) is None or not is_chan(chan):
1637 raise CommandError('part: this window not a channel')
1638 conn_send(buf_registered_conn(cur_buf), 'PART', [chan, msg])
1639
1640@command('/quit', extended_arg=True)
1641def quit_cmd(*msg):
1642 schedule_quit()
1643 for c in open_conns.itervalues():
1644 conn_send(c, 'QUIT', msg)
1645
1646@command('/send', extended_arg=True)
1647def send_cmd(line):
1648 if len(line) > MAX_MSG_LEN:
1649 raise CommandError('send: line too long')
1650 conn_write(buf_registered_conn(cur_buf), line)
1651
1652@command('/topic', 0, 1)
1653def topic_cmd(chan=None):
1654 if chan is None:
1655 chan = buf_name(cur_buf)
1656 if buf_parent(cur_buf) is None or not is_chan(chan):
1657 raise CommandError(
1658 'topic: this window not a channel and none specified')
1659 conn_send(buf_registered_conn(cur_buf), 'TOPIC', [chan])
1660
1661@command('/set-topic', extended_arg=True)
1662def set_topic_cmd(topic=''):
1663 chan = buf_name(cur_buf)
1664 if buf_parent(cur_buf) is None or not is_chan(chan):
1665 raise CommandError('set-topic: this window not a channel')
1666 conn_send(buf_registered_conn(cur_buf), 'TOPIC', [chan, topic])
1667
1668@command('/whois', 1)
1669def whois_cmd(nick):
1670 conn_send(buf_registered_conn(cur_buf), 'WHOIS', [nick])
1671
1672@command('/whowas', 1)
1673def whowas_cmd(nick):
1674 conn_send(buf_registered_conn(cur_buf), 'WHOWAS', [nick])
1675
1676@command('/close')
1677def close_cmd():
1678 parent = buf_parent(cur_buf)
1679 if parent is None:
1680 raise CommandError(
1681 "close: won't close a top-level window (try close-net)")
1682 venue = buf_name(cur_buf)
1683 try:
1684 c = buf_registered_conn(cur_buf)
1685 except CommandError:
1686 pass
1687 else:
1688 if venue in conn_channels(c):
1689 conn_send(c, 'PART', [venue, ''])
1690 del conn_channels(c)[venue]
1691 close_buf(cur_buf)
1692
1693@command('/close-net')
1694def close_net_cmd():
1695 raise CommandError('stub') # TODO
1696
1697def place_cursor():
1698 if buflist_flag():
1699 write_out(t.cursor_pos(
1700 max(1, scr_height - 2),
1701 clip(1, scr_width, buflist_width() - 1)))
1702 elif prompt_flag():
1703 write_out(t.cursor_pos(scr_height, prompt_cursor_column()))
1704 else:
1705 write_out(t.cursor_pos(max(1, scr_height - 2), max(1, scr_width)))
1706
1707def refresh_if_needed():
1708 if redraw_flag():
1709 write_out(t.ERASE_FULL)
1710 buf_draw(cur_buf)
1711 buflist_vline_draw()
1712 buflist_draw()
1713 status_draw()
1714 prompt_draw()
1715 redraw_done()
1716 else:
1717 if buf_draw_flag():
1718 buf_draw(cur_buf)
1719 if buflist_draw_flag():
1720 buflist_draw()
1721 if status_draw_flag():
1722 status_draw()
1723 if prompt_draw_flag():
1724 prompt_draw()
1725 if refresh_flag():
1726 place_cursor()
1727 flush_out()
1728 refresh_done()
1729
1730#
1731# IRC stuff
1732#
1733
1734RPL_WELCOME = 1
1735RPL_NOTOPIC = 331
1736RPL_TOPIC = 332
1737RPL_NAMREPLY = 353
1738RPL_ENDOFNAMES = 366
1739ERR_NICKNAMEINUSE = 433
1740ERR_NICKCOLLISION = 436
1741ERR_NOTREGISTERED = 451
1742
1743MAX_MSG_LEN = 510
1744MAX_CHAN_LEN = 50
1745MAX_NICK_LEN = 31
1746
1747IRC_ILLEGAL = NUL + CR + LF
1748# Oddly, HT or other controls are allowed in chan names by both rfc1459 and rfc2812.
1749CHAN_ILLEGAL = IRC_ILLEGAL + BEL + ' ,:'
1750# '+!' are found in rfc2812 but not rfc1459.
1751CHAN_START = '&#+!'
1752# rfc1459 allows -[]\`^{} in nicks but requires a letter to start.
1753# rfc2812 allows specials at start too except for '-', and adds '_|'.
1754IRC_SPECIAL = '[]\\`_^{|}'
1755
1756is_nick_start = lambda c: is_alpha(c) or c in IRC_SPECIAL
1757is_nick_body = lambda c: is_nick_start(c) or is_digit(c) or c == '-'
1758
1759class ProtocolError(Exception):
1760 pass
1761
1762def build_msg(prefix, cmd, params):
1763 """Build an IRC wire message.
1764
1765 Conditions caller must enforce:
1766 * No args may contain NUL, CR, or LF
1767 * Only last param may be empty, contain spaces, or start with :
1768 * Valid cmd
1769 * 15 parameters max
1770 """
1771 parts = []
1772 if prefix is not None:
1773 parts.append(':' + prefix)
1774 parts.append(cmd)
1775 if len(params):
1776 parts.extend(params[:-1])
1777 parts.append(':' + params[-1])
1778 return ' '.join(parts)
1779
1780def max_param_len(cmd, prefix=None):
1781 # colon before prefix + space after prefix
1782 prefix_len = 0 if prefix is None else len(prefix) + 2
1783 # space after cmd + colon before last param
1784 return MAX_MSG_LEN - prefix_len - len(cmd) - 2
1785
1786def parse_msg(msg):
1787 if any(c in msg for c in IRC_ILLEGAL):
1788 raise ProtocolError('illegal character in message')
1789 start = 0
1790 end = len(msg)
1791 def next_word():
1792 cut = msg.find(' ', start)
1793 if cut == -1:
1794 cut = end
1795 return cut + 1, msg[start:cut]
1796 if msg.startswith(':'):
1797 start = 1
1798 start, prefix = next_word()
1799 else:
1800 prefix = None
1801 start, cmd = next_word()
1802 if not cmd:
1803 raise ProtocolError('message with no command')
1804 params = []
1805 while start < end:
1806 if msg[start] == ':':
1807 params.append(msg[start+1:])
1808 break
1809 start, param = next_word()
1810 params.append(param)
1811 return prefix, casemap_ascii(cmd), params
1812
1813is_chan = lambda n: len(n) > 0 and n[0] in CHAN_START
1814
1815def valid_chan(n):
1816 return is_chan(n) and len(n) <= MAX_CHAN_LEN \
1817 and not any(c in CHAN_ILLEGAL for c in n)
1818
1819def valid_nick(n):
1820 return 0 < len(n) <= MAX_NICK_LEN \
1821 and is_nick_start(n[0]) \
1822 and all(is_nick_body(c) for c in n[1:])
1823
1824def valid_password(p):
1825 return p is None or (
1826 len(p) <= max_param_len('PASS')
1827 and not any(c in IRC_ILLEGAL for c in p))
1828
1829sender_nick = lambda s: s.split('!', 1)[0]
1830
1831#
1832# Networking / main loop
1833#
1834
1835set_nonblock = lambda fd: fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK)
1836
1837def read_all(fd):
1838 chunks = []
1839 try:
1840 chunk = read(fd, 4096)
1841 if not chunk:
1842 raise EOFError
1843 while chunk:
1844 chunks.append(chunk)
1845 chunk = read(fd, 4096)
1846 except EnvironmentError, e:
1847 if e.errno not in (EAGAIN, EWOULDBLOCK):
1848 raise
1849 return ''.join(chunks)
1850
1851def new_conn(network, addrs, nick, password=None):
1852 i = rand_int(len(addrs))
1853 addrs = addrs[i:] + addrs[:i]
1854 return [network, None, '', '', addrs, nick, password, False, dict(),
1855 IRC_CASEMAP['rfc1459'], RECONN_DELAY_MIN, 0, 0, 0]
1856
1857conn_network = lambda c: c[0]
1858conn_sock = lambda c: c[1]
1859conn_rdbuf = lambda c: c[2]
1860conn_wrbuf = lambda c: c[3]
1861conn_addrs = lambda c: c[4]
1862conn_nick = lambda c: c[5]
1863conn_password = lambda c: c[6]
1864conn_registered = lambda c: c[7]
1865conn_channels = lambda c: c[8]
1866conn_casemap = lambda c, s: c[9](s)
1867conn_count = lambda c: c[11]
1868conn_ping_ts = lambda c: c[12]
1869conn_pong_ts = lambda c: c[13]
1870
1871conn_set_sock = lambda c, s: c.__setitem__(1, s)
1872conn_rdbuf_add = lambda c, b: c.__setitem__(2, c[2] + b)
1873conn_rdbuf_rm = lambda c, n: c.__setitem__(2, c[2][n:])
1874conn_wrbuf_add = lambda c, b: c.__setitem__(3, c[3] + b)
1875conn_wrbuf_rm = lambda c, n: c.__setitem__(3, c[3][n:])
1876conn_set_nick = lambda c, n: c.__setitem__(5, n)
1877conn_set_registered = lambda c: c.__setitem__(7, True)
1878conn_clr_registered = lambda c: c.__setitem__(7, False)
1879conn_reset_reconn_delay = lambda c: c.__setitem__(10, RECONN_DELAY_MIN)
1880conn_set_count = lambda c, n: c.__setitem__(11, n)
1881conn_set_ping_ts = lambda c, t: c.__setitem__(12, t)
1882conn_set_pong_ts = lambda c, t: c.__setitem__(13, t)
1883
1884def conn_reconn_delay(c):
1885 # limited exponential backoff
1886 d = c[10]
1887 c[10] = min(2*d, RECONN_DELAY_MAX)
1888 return d
1889
1890conn_nick_lc = lambda c: conn_casemap(c, conn_nick(c))
1891
1892def conn_run_in(c, delay, method, run_if_down=False):
1893 count = conn_count(c)
1894 def run():
1895 # Drop leftover tasks from old connections
1896 if c is network_conns.get(conn_network(c)) and conn_count(c) == count \
1897 and (run_if_down or conn_sock(c) is not None):
1898 method(c)
1899 run_in(delay, run)
1900
1901def conn_log_msg(c, venue, m):
1902 network = conn_network(c)
1903 if venue is None:
1904 buf = get_buf(network, None)
1905 else:
1906 buf = get_buf(venue, network)
1907 buf_log_msg(buf, m)
1908 file_log_msg(network, venue, m)
1909
1910def conn_info(c, msg):
1911 conn_log_msg(c, None, m_client(msg))
1912
1913def conn_error(c, msg):
1914 conn_log_msg(c, None, m_client('ERROR: ' + msg))
1915
1916opening_conns = {}
1917network_conns = {}
1918def conn_start(c):
1919 schedule_buflist_draw()
1920 schedule_status_draw()
1921 net = conn_network(c)
1922 assert conn_sock(c) is None, 'socket exists when starting connection'
1923 sock = socket()
1924 set_nonblock(sock.fileno())
1925 conn_set_sock(c, sock)
1926 conn_set_count(c, conn_count(c) + 1)
1927 addrs = conn_addrs(c)
1928 addrs.append(addrs.pop(0))
1929 conn_info(c, 'connecting to %s' % format_address(addrs[0]))
1930 network_conns[net] = opening_conns[sock.fileno()] = c
1931 try:
1932 sock.connect(addrs[0]) # TODO async DNS
1933 except SockError, e:
1934 if e.errno != EINPROGRESS:
1935 del opening_conns[sock.fileno()]
1936 conn_error(c, e.strerror)
1937 else:
1938 conn_handle_connected(c)
1939
1940def conn_write(c, msg):
1941 if len(msg) > MAX_MSG_LEN:
1942 msg = msg[:MAX_MSG_LEN]
1943 conn_error(c, 'outbound message truncated')
1944 conn_wrbuf_add(c, msg + CRLF)
1945
1946def conn_send(c, cmd, params, prefix=None):
1947 conn_write(c, build_msg(prefix, cmd, params))
1948
1949open_conns = {}
1950def conn_handle_connected(c):
1951 n = conn_nick(c)
1952 p = conn_password(c)
1953 s = conn_sock(c)
1954 e = s.getsockopt(SOL_SOCKET, SO_ERROR)
1955 if e == EINPROGRESS:
1956 return
1957 schedule_buflist_draw()
1958 schedule_status_draw()
1959 del opening_conns[s.fileno()]
1960 if e != 0:
1961 conn_error(c, strerror(e))
1962 conn_close(c)
1963 return
1964 conn_reset_reconn_delay(c)
1965 open_conns[s.fileno()] = c
1966 conn_info(c, 'connection established')
1967 conn_set_ping_ts(c, mono_time())
1968 conn_run_in(c, PING_TIMEOUT, conn_reg_timeout)
1969 if p is not None:
1970 conn_send(c, 'PASS', [p])
1971 conn_send(c, 'NICK', [n])
1972 conn_send(c, 'USER', [n, '0', '*', n])
1973
1974def conn_close(c):
1975 sock = conn_sock(c)
1976 if sock is None:
1977 return
1978 schedule_buflist_draw()
1979 schedule_status_draw()
1980 fd = sock.fileno()
1981 if fd in open_conns:
1982 del open_conns[fd]
1983 elif fd in opening_conns:
1984 del opening_conns[fd]
1985 try:
1986 sock.shutdown(SHUT_RDWR)
1987 except SockError:
1988 pass
1989 sock.close()
1990 conn_set_sock(c, None)
1991 conn_rdbuf_rm(c, len(conn_rdbuf(c)))
1992 conn_wrbuf_rm(c, len(conn_wrbuf(c)))
1993 conn_clr_registered(c)
1994 conn_info(c, 'connection closed')
1995 if conn_network(c) in network_conns:
1996 delay = conn_reconn_delay(c)
1997 conn_run_in(c, delay, conn_start, True)
1998 conn_info(c, 'reconnecting in %d seconds' % delay)
1999
2000def conn_handle_data(c, data):
2001 conn_rdbuf_add(c, data)
2002 data = conn_rdbuf(c)
2003 start = 0
2004 while start < len(data):
2005 end = data.find(CRLF, start)
2006 if end == -1:
2007 if len(data) - start >= MAX_MSG_LEN:
2008 conn_error(c, 'received oversize message')
2009 conn_close(c)
2010 return
2011 break
2012 if end > start:
2013 try:
2014 conn_handle_msg(c, data[start:end])
2015 except ProtocolError, e:
2016 conn_error(c, e)
2017 start = end + 2
2018 conn_rdbuf_rm(c, start)
2019
2020def conn_handle_msg(c, msg):
2021 #pylint: disable=unbalanced-tuple-unpacking,too-many-locals
2022 prefix, cmd, params = parse_msg(msg)
2023
2024 def welcome():
2025 if destination != conn_nick(c): # for pre-welcome nick change
2026 schedule_status_draw()
2027 conn_info(c, 'nick changed to %s' % destination)
2028 conn_set_nick(c, destination)
2029 conn_log_msg(c, None, m_server(prefix or '', ' '.join(params)))
2030 if not conn_registered(c):
2031 schedule_buflist_draw()
2032 conn_set_registered(c)
2033 pong()
2034 for chan in conn_channels(c):
2035 conn_join(c, chan)
2036 conn_channels(c).clear()
2037
2038 def names_reply():
2039 if len(params) != 3:
2040 conn_error(c, 'RPL_NAMREPLY with bad parameter count: %s' % msg)
2041 return
2042 _, chan, names = params
2043 chan_lc = conn_casemap(c, chan)
2044 members = conn_channels(c).get(chan_lc)
2045 conn_log_msg(c, None if members is None else chan_lc,
2046 m_names(prefix or '', chan, names))
2047 if members is not None:
2048 for nick in names.split(' '):
2049 if not nick:
2050 conn_error(c, 'RPL_NAMREPLY with empty nick')
2051 break
2052 if nick[0] in '@+':
2053 nick = nick[1:]
2054 members.add(conn_casemap(c, nick))
2055
2056 def end_names():
2057 if len(params) != 2:
2058 conn_error(c, 'RPL_ENDOFNAMES with bad parameter count: %s' % msg)
2059 return
2060 chan = params[0]
2061 chan_lc = conn_casemap(c, chan)
2062 conn_log_msg(c, chan_lc if chan_lc in conn_channels(c) else None,
2063 m_endnames(prefix or '', chan))
2064
2065 def error_msg():
2066 if len(params) != 1:
2067 conn_error(c, 'ERROR with bad parameter count: %s' % msg)
2068 return
2069 conn_log_msg(c, None, m_error(prefix or '', params[0]))
2070
2071 def ping():
2072 conn_send(c, 'PONG', params)
2073
2074 def pong():
2075 schedule_status_draw()
2076 conn_set_pong_ts(c, mono_time())
2077 conn_run_in(c, PING_INTERVAL, conn_ping)
2078
2079 def privmsg():
2080 if len(params) != 2:
2081 conn_error(c, 'message with bad parameter count: %s' % msg)
2082 return
2083 target, content = params
2084 target_lc = conn_casemap(c, target)
2085 if prefix is None:
2086 conn_error(c, 'message without sender: %s' % msg)
2087 return
2088 sender = sender_nick(prefix)
2089 if target_lc == conn_nick_lc(c): # PM
2090 venue = conn_casemap(c, sender)
2091 elif valid_chan(target_lc):
2092 if target_lc in conn_channels(c):
2093 venue = target_lc
2094 else:
2095 return # drop messages to unknown channels
2096 elif target_lc == '*': # not sure if standard but freenode does this
2097 venue = None
2098 else:
2099 conn_error(c, 'message to unknown target: %s' % msg)
2100 return
2101 conn_log_msg(c, venue,
2102 (m_notice if cmd == 'notice' else m_privmsg)(sender, content))
2103
2104 def join():
2105 if len(params) != 1:
2106 conn_error(c, 'JOIN with bad parameter count: %s' % msg)
2107 return
2108 chan, = params
2109 chan_lc = conn_casemap(c, chan)
2110 if prefix is None:
2111 conn_error(c, 'JOIN without sender: %s' % msg)
2112 return
2113 sender_lc = conn_casemap(c, sender_nick(prefix))
2114 channels_dict = conn_channels(c)
2115 if sender_lc == conn_nick_lc(c):
2116 if chan_lc in channels_dict:
2117 conn_error(c, 'JOIN to already joined channel %s' % chan)
2118 return
2119 channels_dict[chan_lc] = set()
2120 else:
2121 if chan_lc not in channels_dict:
2122 conn_error(c, 'JOIN %s to unknown channel %s' % (prefix, chan))
2123 return
2124 channels_dict[chan_lc].add(sender_lc)
2125 conn_log_msg(c, chan_lc, m_join(prefix, chan))
2126
2127 def mode():
2128 if len(params) < 1:
2129 conn_error(c, 'MODE with bad parameter count: %s' % msg)
2130 return
2131 target = params[0]
2132 modes = ' '.join(params[1:])
2133 target_lc = conn_casemap(c, target)
2134 if prefix is None:
2135 conn_error(c, 'MODE without sender: %s' % msg)
2136 return
2137 if is_chan(target_lc):
2138 if target_lc not in conn_channels(c):
2139 conn_error(c, 'MODE to unknown channel: %s' % msg)
2140 return
2141 conn_log_msg(c, target_lc, m_chanmode(prefix, target, modes))
2142 else:
2143 if not target_lc == prefix == conn_nick(c):
2144 conn_error(c, 'MODE to unknown target: %s' % msg)
2145 return
2146 conn_log_msg(c, None, m_mode(prefix, modes))
2147
2148 def part():
2149 if len(params) == 1:
2150 comment = ''
2151 elif len(params) == 2:
2152 comment = params[1]
2153 else:
2154 conn_error(c, 'PART with bad parameter count: %s' % msg)
2155 return
2156 parted_chans = params[0].split(',')
2157 if prefix is None:
2158 conn_error(c, 'PART without sender: %s' % msg)
2159 return
2160 sender_lc = conn_casemap(c, sender_nick(prefix))
2161 channels_dict = conn_channels(c)
2162 me = (sender_lc == conn_nick_lc(c))
2163 for chan in parted_chans:
2164 chan_lc = conn_casemap(c, chan)
2165 if chan_lc not in channels_dict:
2166 # drop PARTs from unknown channels (e.g. closed window)
2167 continue
2168 try:
2169 if me:
2170 del channels_dict[chan_lc]
2171 conn_info(c, 'parted %s' % chan)
2172 schedule_status_draw()
2173 else:
2174 channels_dict[chan_lc].remove(sender_lc)
2175 except KeyError:
2176 conn_error(c, 'PART non-member %s from %s' % (prefix, chan))
2177 continue
2178 conn_log_msg(c, chan_lc, m_part(prefix, chan, comment))
2179
2180 def quit_msg():
2181 if len(params) != 1:
2182 conn_error(c, 'QUIT with bad parameter count: %s' % msg)
2183 return
2184 quit_msg, = params
2185 if prefix is None:
2186 conn_error(c, 'QUIT without sender [%s]' % quit_msg)
2187 return
2188 sender_lc = conn_casemap(c, sender_nick(prefix))
2189 for chan_lc, members in conn_channels(c).items():
2190 if sender_lc in members:
2191 conn_log_msg(c, chan_lc, m_quit(prefix, quit_msg))
2192 members.remove(sender_lc)
2193
2194 def kick():
2195 if len(params) < 2 or len(params) > 3:
2196 conn_error(c, 'KICK with bad parameter count: %s' % msg)
2197 return
2198 if prefix is None:
2199 conn_error(c, 'KICK without sender: %s' % msg)
2200 return
2201 chan = params[0]
2202 chan_lc = conn_casemap(c, chan)
2203 kicked_user = params[1]
2204 kicked_user_lc = conn_casemap(c, kicked_user)
2205 comment = params[2] if len(params) == 3 else ''
2206 channels_dict = conn_channels(c)
2207 if kicked_user_lc == conn_nick_lc(c):
2208 try:
2209 del channels_dict[chan_lc]
2210 except KeyError:
2211 conn_error(c, 'KICK from unknown channel %s by %s [%s]'
2212 % (chan, prefix, comment))
2213 return
2214 schedule_status_draw()
2215 conn_log_msg(c, chan_lc, m_kicked(prefix, chan, comment))
2216 else:
2217 if chan_lc not in channels_dict:
2218 conn_error(c, 'KICK %s from unknown channel %s by %s [%s]'
2219 % (kicked_user, chan, prefix, comment))
2220 return
2221 try:
2222 channels_dict[chan_lc].remove(kicked_user_lc)
2223 except KeyError:
2224 conn_error(c, 'KICK non-member %s from %s by %s [%s]'
2225 % (kicked_user, chan, prefix, comment))
2226 return
2227 conn_log_msg(c, chan_lc,
2228 m_kick(prefix, chan, kicked_user, comment))
2229
2230 def nick():
2231 if len(params) != 1:
2232 conn_error(c, 'NICK with bad parameter count: %s' % msg)
2233 return
2234 new_nick, = params
2235 if prefix is None:
2236 conn_error(c, 'NICK without sender: %s' % msg)
2237 return
2238 sender = sender_nick(prefix)
2239 new_nick_lc = conn_casemap(c, new_nick)
2240 sender_lc = conn_casemap(c, sender)
2241 if sender_lc == conn_nick_lc(c):
2242 conn_info(c, 'nick changed to %s' % new_nick)
2243 conn_set_nick(c, new_nick)
2244 schedule_status_draw()
2245 for chan_lc, members in conn_channels(c).items():
2246 if sender_lc in members:
2247 members.remove(sender_lc)
2248 members.add(new_nick_lc)
2249 conn_log_msg(c, chan_lc, m_nick(sender, new_nick))
2250
2251 def topic():
2252 if len(params) != 2:
2253 conn_error(c, '(RPL_(NO))TOPIC with bad parameter count: %s' % msg)
2254 chan = params[0]
2255 topic = None
2256 if cmd != str(RPL_NOTOPIC):
2257 topic = params[1]
2258 chan_lc = conn_casemap(c, chan)
2259 if chan_lc in conn_channels(c):
2260 buf_set_title(get_buf(chan_lc, conn_network(c)), topic or chan_lc)
2261 conn_log_msg(c, chan_lc, m_topic(prefix or '', topic))
2262 else:
2263 conn_log_msg(c, None, m_chantopic(prefix or '', chan, topic))
2264
2265 def unknown_command():
2266 conn_info(c, 'unknown command from server: %s' % msg)
2267
2268 def unknown_reply():
2269 conn_log_msg(c, None, m_server(prefix or '', ' '.join([cmd] + params)))
2270
2271 if len(cmd) == 3 and all(map(is_digit, cmd)):
2272 # Replies
2273 if not params:
2274 conn_error(c, 'reply %s with no destination' % cmd)
2275 return
2276 destination = params.pop(0)
2277 {
2278 RPL_WELCOME: welcome,
2279 RPL_NAMREPLY: names_reply,
2280 RPL_ENDOFNAMES: end_names,
2281 RPL_TOPIC: topic,
2282 RPL_NOTOPIC: topic,
2283 #ERR_NICKNAMEINUSE:
2284 #ERR_NICKCOLLISION:
2285 #ERR_NOTREGISTERED:
2286 }.get(int(cmd), unknown_reply)()
2287 else:
2288 {
2289 'error': error_msg,
2290 'ping': ping,
2291 'pong': pong,
2292 'privmsg': privmsg,
2293 'notice': privmsg,
2294 'join': join,
2295 'mode': mode,
2296 'part': part,
2297 'quit': quit_msg,
2298 'kick': kick,
2299 'nick': nick,
2300 'topic': topic,
2301 }.get(cmd, unknown_command)()
2302
2303def conn_join(c, chan, key=None):
2304 conn_info(c, 'joining %s' % chan)
2305 conn_send(c, 'JOIN', [chan] if key is None else [chan, key])
2306
2307def conn_privmsg(c, target, msg):
2308 # There is NO SANE WAY to deduce the max privmsg length, go figure.
2309 for line in wrap(msg, 400):
2310 conn_log_msg(c, target, m_privmsg(conn_nick(c), line))
2311 conn_send(c, 'PRIVMSG', [target, line])
2312
2313def conn_ping(c):
2314 conn_set_ping_ts(c, mono_time())
2315 conn_send(c, 'PING', [conn_nick(c)])
2316 conn_run_in(c, PING_TIMEOUT, conn_timeout)
2317
2318def conn_timeout(c):
2319 if mono_time() - conn_pong_ts(c) > PING_TIMEOUT:
2320 conn_error(c, 'ping timed out')
2321 conn_close(c)
2322
2323def conn_reg_timeout(c):
2324 if mono_time() - max(conn_pong_ts(c), conn_ping_ts(c)) > PING_TIMEOUT:
2325 conn_error(c, 'registration timed out')
2326 conn_close(c)
2327
2328# Log file message formatting is simpler than UI as the message type and
2329# sender are always shown explicitly.
2330arg2 = lambda a, b: b
2331arg3 = lambda a, b, c: c
2332empty2 = lambda a, b: ''
2333def lterr(*_):
2334 raise TypeError('bad log message type')
2335
2336format_log_msg = matcher(message, (
2337 (m_privmsg, arg2),
2338 (m_notice, arg2),
2339 (m_join, empty2),
2340 (m_part, arg3),
2341 (m_quit, arg2),
2342 (m_nick, arg2),
2343 (m_kick, lambda sender, chan, name, msg: name + ' ' + msg),
2344 (m_kicked, arg3),
2345 (m_topic, lambda sender, topic: topic or ''),
2346 (m_chantopic, lterr),
2347 (m_mode, lterr),
2348 (m_chanmode, arg3),
2349 (m_names, arg3),
2350 (m_endnames, empty2),
2351 (m_error, arg2),
2352 (m_client, lterr),
2353 (m_server, arg2)))
2354
2355def file_log_msg(network, venue, m):
2356 if venue is None or len(venue) == 0:
2357 return
2358 path = ('logs', network, fs_encode(venue) + '.log')
2359 # Possible optimization: cache log FDs and check freshness by comparing
2360 # (dev, inode) with stat() on each write. Exceeding the max FDs rlimit
2361 # would need to be handled, e.g. by LRU eviction using a minheap with
2362 # global write counter.
2363 def open_log():
2364 return os_open('/'.join(path), O_WRONLY | O_APPEND | O_CREAT, 0666)
2365 try:
2366 try:
2367 fd = open_log()
2368 except EnvironmentError, e:
2369 if e.errno != ENOENT:
2370 raise
2371 try:
2372 mkdir(path[0])
2373 except EnvironmentError, e:
2374 if e.errno != EEXIST:
2375 raise
2376 mkdir('/'.join(path[:2]))
2377 fd = open_log()
2378 try:
2379 write_all(fd, ''.join((
2380 str(int(time()*100)),
2381 ' ', variant_name(m),
2382 ' <', variant_args(m)[0], '> ', format_log_msg(m), LF)))
2383 except EnvironmentError, e:
2384 error(e)
2385 close(fd)
2386 except EnvironmentError, e:
2387 error(e)
2388
2389def handle_resize(*_):
2390 global scr_height, scr_width
2391 scr_height, scr_width = t.screen_size()
2392 # Re-clip scrolling as last line may now fit above viewport
2393 max_vsc = buf_max_vscroll(cur_buf)
2394 if buf_vscroll(cur_buf) > max_vsc:
2395 buf_set_vscroll(cur_buf, max_vsc)
2396 # Stop autoscrolling, to be resumed by buf_draw if it turns out the last line is still on screen
2397 buf_clr_at_end(cur_buf)
2398 schedule_redraw()
2399 # Wake main event loop
2400 try:
2401 write(self_pipe_wr, '\0')
2402 except EnvironmentError, e:
2403 if e.errno not in (EAGAIN, EWOULDBLOCK):
2404 raise
2405
2406# Poor man's monotonic clock. Doesn't give real time, but at least avoids
2407# overly delayed events if the clock is stepped backward.
2408mono_last = mono_offset = 0
2409def mono_time(time_=time):
2410 global mono_last, mono_offset
2411 now = time_() + mono_offset
2412 if now < mono_last:
2413 mono_offset += mono_last - now
2414 now = mono_last
2415 mono_last = now
2416 return now
2417
2418schedule = []
2419run_in = lambda seconds, thunk: heap_insert(schedule, mono_time() + seconds,
2420 thunk)
2421
2422def main():
2423 yrc_home = getenv('HOME', '') + '/.yrc'
2424 try:
2425 chdir(yrc_home)
2426 except EnvironmentError:
2427 mkdir(yrc_home)
2428 chdir(yrc_home)
2429
2430 set_nonblock(self_pipe_rd)
2431 set_nonblock(self_pipe_wr)
2432 signal(SIGINT, SIG_DFL)
2433 signal(SIGWINCH, handle_resize)
2434 handle_resize()
2435
2436 info('Welcome to yrc: the Unix IRC client')
2437 info('Version: %s' % __version__)
2438 info("Documentation is included in the distribution: refer to README and manual.txt to get started, or NEWS for what's new in this release")
2439 info('Type /quit<Enter> to exit')
2440 prompt_enter()
2441
2442 while not quit_flag():
2443 refresh_if_needed()
2444 timeout = None
2445 if schedule:
2446 timeout = max(0, heap_peek(schedule)[0] - mono_time())
2447 try:
2448 readers, writers, _ = select(
2449 [STDIN, self_pipe_rd] + open_conns.keys(),
2450 opening_conns.keys() +
2451 [fd for fd, c in open_conns.items() if conn_wrbuf(c)],
2452 (), timeout)
2453 except SelectError, e:
2454 if e[0] == EINTR:
2455 continue
2456 raise
2457 for fd in readers:
2458 if fd == STDIN:
2459 try:
2460 data = read_all(fd)
2461 except (EOFError, EnvironmentError):
2462 schedule_quit()
2463 for c in data:
2464 kbd_state(c)
2465 elif fd == self_pipe_rd:
2466 read_all(self_pipe_rd)
2467 elif fd in open_conns:
2468 c = open_conns[fd]
2469 try:
2470 data = read_all(fd)
2471 except EOFError:
2472 conn_info(c, 'connection closed by server')
2473 conn_close(c)
2474 except EnvironmentError, e:
2475 conn_error(c, str(e))
2476 conn_close(c)
2477 else:
2478 conn_handle_data(c, data)
2479 for fd in writers:
2480 if fd in opening_conns:
2481 conn_handle_connected(opening_conns[fd])
2482 elif fd in open_conns:
2483 c = open_conns[fd]
2484 try:
2485 n = write(fd, conn_wrbuf(c))
2486 except EnvironmentError, e:
2487 if e.errno not in (EAGAIN, EWOULDBLOCK):
2488 conn_error(c, str(e))
2489 conn_close(c)
2490 else:
2491 conn_wrbuf_rm(c, n)
2492 while schedule and heap_peek(schedule)[0] < mono_time():
2493 heap_extract(schedule)[1]()
2494
2495def crash_handler():
2496 cleanup = t.init(nonblock_read=True)
2497 try:
2498 main()
2499 finally:
2500 cleanup()
2501
2502check_command_dicts((
2503 GLOBAL_KEYS,
2504 PROMPT_KEYS,
2505 BUFLIST_KEYS,
2506 BUF_KEYS,
2507 CMD_ABBREVS
2508))