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