Projects : yrc : yrc_minor_char_refactors

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