Projects : yrc : yrc_prompt_redraw_optimization

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