Projects : yrc : yrc_reindent_docs

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