diff -uNr a/yrc/NEWS b/yrc/NEWS --- a/yrc/NEWS false +++ b/yrc/NEWS b47716297df92a443029a4e5186581a9556badfc467f7697f287bd7b93c01edcc07a2b3deca7b4810f41681c6869dd5952ce2d119af3ab2213e58c96c5f33e03 @@ -0,0 +1,50 @@ +97K (2019-06-05) +================ + +New features: +- Adjustable window list width +- Recognition of terminal control sequence input, enabling use of arrow keys and Ins/Del/Home/End/PgUp/PgDn +- Chat logging + +Bugfixes: +- Unhandled network read error on RST +- Unhandled network write errors on blocking and RST +- Race condition and unportability in signal handling (textbook case for djb self-pipe trick) +- Unhandled EINTR on resize during terminal write (planting Python firmly in the "Worse is Better" camp) +- Neglected terminal escaping for topic and status strings + +Removed antifeatures: +- Microsoftesque redirection of crash tracebacks to debug.log + +Performance: +- Lookup tables for character escaping and casefolding +- Full buffering of screen updates + +Behavior changes: +- Key mapping for prompt to scrollback focus switch (not presently very useful) changed to Ins, freeing Tab for future completion feature +- C-x and Esc states now cancelled by C-g rather than repeating the original; Esc must always start a new escape sequence for terminal controls not to leak +- chdir to yrc home on startup to simplify config and log access + +Documentation: +- Flesh out stub manual section on configuration and now logging +- Key mapping updates and typo fix + +98K (2017-03-24) +================ +- Clipped labels indicated by a final $ sign, especially important for the as-yet non-resizable window list +- Indicator added for previously selected window in list +- Active window indicator changed from > to * for consistency with tmux +- "Return" keybinding removed from scrollback window as it would be prone to accidental message posting +- Reconnection bugs fixed + +99K (2017-03-11) +================ +- ncurses replaced with direct VT100 drawing +- Documentation improved +- Timeout detection, reconnection, and ping display implemented +- Minimal Python packaging added +- Bugs fixed + +100K (2017-03-03) +================= +- Initial prototype diff -uNr a/yrc/README.txt b/yrc/README.txt --- a/yrc/README.txt false +++ b/yrc/README.txt ab08d43b0754f25bae7f43b5ed9f2d88ebf71605d2426ce6a78369699f5239464ab23a91d4a1aa950d109f7f1b7ad0f598d5a2028c70b6d1ec22e3985540cd74 @@ -0,0 +1,66 @@ +yrc, the Unix IRC client + +INSTALLATION + + The dependencies of yrc are Python 2.7 (on a POSIX system) and VT100-style terminal. (Earlier Pythons may work as well but are presently untested.) + + It can be run directly from the source tree: + + chmod +x yrc + ./yrc + + To install the Python modules and script to the system paths, run as root: + + python2 setup.py install + +QUICKSTART + + At this point you are expected to read manual.txt, but here's a quick exercise: + + 1. Configure a network and a default nick: + + mkdir -p ~/.yrc/nets/freenode + echo chat.freenode.net > ~/.yrc/nets/freenode/addrs + whoami > ~/.yrc/nick + + 2. Start yrc: + + yrc + + 3. Connect to the configured network: + + /connect freenode + + 4. Switch to the network's server messages window by typing Ctrl-x n. + + 5. If your chosen nick is unavailable, you can change it temporarily: + + /nick your_nick_ + + 6. Once your connection is registered (shows an = sign in the window list), you can join a channel: + + /join #some-test-chan + + 7. Try another way to switch windows: Ctrl-x w. This selects the window list; use j/k or Ctrl-n/Ctrl-p to navigate and Return to apply. + + 8. Shut down with a parting message: + + /quit bye for now + + 9. yrc does not advertise e.g. through your IRC name or part messages, so go tell a friend or three if you like it! + +BUGS + + 1. When nick in use, connection gets stuck at unregistered even after /nick. (Workaround: /disconnect and change nick in /connect arguments or config.) + + 2. part/kick/disconnect doesn't clear topic from window title, causing subsequent rejoin to retain the old topic if it was meanwhile removed. + + A frequent source of bugs during development was screen updates -- ensuring UI elements are redrawn when their underlying data sources change. This situation resulted from a conversion from a previous approach of "when in doubt, redraw everything and let ncurses compute the differences", and could doubtless be handled better. For now, these are easily fixed once identified (e.g. something updates only after pressing Ctrl-L). + + yrc does not go out of its way to accomodate non-standard server behavior (for example, alternate casemappings). This is not a bug, but simple compatibility workarounds can be considered if the problem server is widely used. + + See the roadmap section in the manual for planned features. + +HACKING + + The codemap.txt file serves as a kind of index of function signatures and data structures (as there are many), and should be kept reasonably up to date. It also contains some sketchier notes-to-self. diff -uNr a/yrc/codemap.txt b/yrc/codemap.txt --- a/yrc/codemap.txt false +++ b/yrc/codemap.txt 6d9b991a507a3a8cb7cfdf6830017901a1519d6860cfb29f38a29f0a1a416cb191da6a7f39a793536b763ec542f80e4fa431029bca8df0c6740f520760592ef1 @@ -0,0 +1,284 @@ +Record types +============ + +conn +- network: file-safe str +- sock: socket +- rdbuf: str +- wrbuf: str +- addrs: non-empty list of (host: str, port: int) (first entry is current) +- nick: str +- password: str | None +- registered: bool +- channels: dict of (channel: casemapped str) => (set of nick: casemapped str) +- casemapper: function of (str -> casemapped str) +- reconn_delay: int +- count: int (number of times connected) +- ping_ts: float (monotonic time of last sent ping) +- pong_ts: float (monotonic time of last received pong) + +buf +- name: str +- parent: buf +- title: str +- vscroll: int +- lines: list of str +- num_read: int +- at_end: bool + +buflist (singleton) +- vscroll: int +- width: int +- selection: buf +- cursor: buf +- last: buf + +prompt (singleton) +- chars: list of str +- cursor: int +- hscroll: int + +flags (singleton) +- refresh +- redraw +- buflist_draw +- buf_draw +- prompt_draw +- status_draw +- quit +- buflist +- prompt +- ping_draw + +Enumerations +============ + +kbd_state: ks_start ks_cx ks_esc ks_cseq ks_cs_intermed + +Variant types +============= + +message: (args all str unless noted) +- m_privmsg(sender, msg) +- m_notice(sender, msg) +- m_join(sender, chan) +- m_part(sender, chan, msg) +- m_quit(sender, msg) +- m_nick(sender, nick) +- m_kick(sender, chan, name, msg) +- m_kicked(sender, chan, msg) +- m_topic(sender, topic: str | None) +- m_chantopic(sender, chan, topic: str | None) +- m_mode(sender, modes) +- m_chanmode(sender, chan, modes) +- m_names(sender, chan, names) +- m_endnames(sender, chan) +- m_error(sender, msg) +- m_client(msg) +- m_server(sender, msg) + +Quasiconstants +============== + +self_pipe_rd: int +self_pipe_wr: int + +Global state +============ + +cur_buf: buf +scr_height: int +scr_width: int +mono_last: float +mono_offset: float +out_buf: bytearray +kbd_accum: bytearray +kbd_state: function + +Collections +=========== + +commands: (name: str) => function +buffers: non-empty list of buf +buffer_index: (name: str, parent_name: str) => buf +opening_conns: (fileno: int) => conn +open_conns: (fileno: int) => conn +network_conns: (network: str) => conn : all enabled networks, whether TCP alive or not +schedule: min-heap of (time: float) => thunk + +Functions +========= + +Excluding record constructors, accessors and basic mutators. + +Startup only: +command(name: str, min_args=0: int, max_args=None: int | None, extended_arg=False: bool)(function) -> function +check_command_dicts(keymap) +make_casemapper(int) -> function of (str -> casemapped str) + +Boring: +set_nonblock + +Pure functions: +format_time(time_tuple) -> str +is_ctrl(chr) -> bool +ctrl(chr) -> chr +is_meta(chr) -> bool +meta(chr) -> chr +variant_name(val) -> str +variant_args(val) -> list +matcher(vtype, cases: iterable of (constructor, receiver: function of (*args -> x))) -> function of (val: vtype -> x) +sequence(*thunks) -> thunk +flatten(iterable of iterable) -> iterable +char_range(pair) -> str +partition(list, pred) -> (left: list, right: list) +split_pair(str, sep=' ': str) -> [str, str] +make_encoder(function of chr -> str) -> function of (str -> str) +asciify(str) -> str +fs_encode(str) -> str +casemap_ascii(str) -> str +clip(min: comparable, max: comparable, comparable) -> comparable +clip_to(list, int) -> int +get_clipped(list, int) -> element of list +clip_str(str, width: int) -> str +pad_or_clip_str(str, width: int, pad=' ': chr) -> str +wrap(line, width, indent=0) -> list of str +is_digit(chr) -> bool +parse_address(addr) -> (host: str, port: int) / ValueError +format_address((host: str, port: int)) -> str +int_of_bytes(str) -> int +heap_peek(heap: list) -> (key, value) / IndexError +safe_filename(name) -> bool +config_lines(text) -> non-empty list of non-empty str | None +format_buf_msg(val: message) -> str +render_lines(lines: list of str, width: int, start: int, row_limit: int) -> (list of list, int) +build_msg(prefix, cmd, params) -> str +max_param_len(cmd, prefix=None) -> int +parse_msg(msg) -> (prefix: str | None, cmd: str, params: list of str) / ProtocolError +is_chan(str) -> bool +valid_chan(str) -> bool +valid_nick(str) -> bool +valid_password(str) -> bool +conn_nick_lc(conn) -> casemapped str +sender_nick(str) -> str +arg2 +arg3 +empty2 +lterr / TypeError +format_log_msg(val: message except (m_chantopic | m_mode | m_client)) -> str + +Pure I/O: +write_all(blocking fd, str|bytearray) / EnvironmentError +read_all(nonblocking fd) -> str / EOFError, EnvironmentError : may return empty + +Pure drawing: +buf_draw(buf) +prompt_draw +draw_status(y: int) +buflist_draw +buflist_vline_draw +place_cursor +refresh_if_needed + +"Queries": +find_buf(buf) -> int : find buf's index in buffers list +#buf_network(buf) -> str +buf_conn(buf) -> conn / CommandError +buf_registered_conn(buf) -> conn / CommandError +get_config(key, paths=(()), default=None) -> str | default + +Side effects on data: +write_out(str) : stores for write to terminal +flush_out : sends full terminal output buffer +variant(vtype, name: str, nargs: int) -> constructor: function of (*args -> (tag, args)) +rand_int(int) -> int +heap_insert(heap: list, key: comparable, value) +heap_extract(heap: list) -> (key, value) / IndexError +run_command(line: str) -> * / CommandError +buf_log_msg(buf, m: message) +buf_privmsg(buf, msg: str) : buf_parent ; buf_registered_conn ; conn_privmsg / CommandError +check_buf_at_end : buf_set_at_end(cur_buf) | buf_clr_at_end(cur_buf) +is_child_of(buf) -> function of (buf -> bool) +sort_buffers : sorts buffers & generates buffer_index +get_buf(name, parent_name) -> buf : buffer_index lookup but creates if not found +close_buf(buf) +prompt_insert(chr) +prompt_delete +prompt_backspace +prompt_submit +info(msg: str, buf=None) : buf_log_msg to buf or buffers[0] +error(msg_or_exc: str | Exception, buf=None) : buf_log_msg to buf or buffers[0] +kaccum(str) +kaccept(str) +ktrans(kbd_state) +ks_start(chr) +ks_cx(chr) +ks_esc(chr) +ks_cseq(chr) +ks_cs_intermed(chr) +conn_run_in(conn, seconds: float, method: function of (conn), run_if_down=False) : run_in +conn_log_msg(conn, venue: casemapped str | None, m: message) : get_buf ; buf_log_msg ; file_log_msg +conn_info(conn, str) : uses conn_log_msg +conn_error(conn, str) : uses conn_log_msg +conn_start(conn) : starts connecting; bumps count; rotates addrs; adds to opening_conns and network_conns +conn_write(conn, str) : stores for nonblocking write by main loop +conn_send(conn, cmd: str, params: list of str, prefix=None: str) : build_msg ;conn_write (TODO check msg well-formedness) +conn_handle_connected(conn) : if successful, moves c from opening_conns to open_conns & sends login +conn_close(conn) : shuts down and removes from open_conns +conn_handle_data(conn, data: str) : does input buffering; extracts messages and calls... +conn_handle_msg(conn, msg: str) : mega-function for handling IRC commands +conn_join(conn, chan: str, key=None) : conn_info ; conn_send +conn_privmsg(conn, target: str, msg: str) : conn_log_msg ; conn_send +conn_ping(conn) +conn_timeout(conn) +conn_reg_timeout(conn) +file_log_msg(network: file-safe str, venue: casemapped str | None, m: message) +handle_resize +mono_time -> float +run_in(seconds, thunk) + +Slash commands: +quit_cmd +connect_cmd +disconnect_cmd +join_cmd +kick_cmd +mode_cmd +nick_cmd +part_cmd +send_cmd + +Lifecycle: +main +crash_handler + +MVP: +finish commands + close/close-net debug +prefix case sensitivity in conn_handle_msg? + +TODO: +nick fallback +buflist vscroll +modes +date changes +prompt history, kill ring +tab completion (but how to focus buf/prompt?) +username, realname +logging +bold nicks (generally: formatted wrap) +search in scrollback +channel key (config, persist across reconnect) +buffer cleanup by category +help +scripting +self-ping to find user@host to calc max privmsg length +Proliferation of casemapping is bound to be buggy. +Recognize WHOIS/WHOWAS/LIST responses +bracketed paste + +Possible cleanups: +more use of lambda/sequence for trivial functions +rename buf* to wind* to match manual's terminology (nobody but emacs uses "buffer" to mean "window"...) +move cur_buf global to a buflist attribute +use the new scheduler for schedule_*; use delayed redraw to perform well under message floods diff -uNr a/yrc/manifest b/yrc/manifest --- a/yrc/manifest false +++ b/yrc/manifest 8ef173b8984b3b7d5356ed70caeab06943c95d6d569bc2cd3137652b6477a167ca7e8b69767678b232de7228ce79d980cdd5bfcd5745f2a613510e10c8eecaba @@ -0,0 +1 @@ +632836 yrc_subdir_genesis jfw Initial V release of yrc, version 97K (regrind of yrc_genesis to follow the project subdir and manifest naming conventions and indent with tabs rather than spaces) diff -uNr a/yrc/manual.txt b/yrc/manual.txt --- a/yrc/manual.txt false +++ b/yrc/manual.txt 41380b168aeac2af439580b38a51b022d63d1212427d0392bc39ac97059ff25f5ec098eec856705b9bc96aaa1a69333208264d6dc43da62cd2d69d2445b96263 @@ -0,0 +1,398 @@ +The yrc manual + +Version 97 Kelvin +Jacob Welsh + +Table of Contents + 1 About yrc + 2 Display + 3 Commands + 3.1 Slash commands + 3.2 Keyboard commands + 3.2.1 Anywhere + 3.2.2 In the window list + 3.2.3 At the prompt + 3.2.4 In the scrollback window + 4 Configuration + 4.1 Keys + 4.2 Logging + 4.3 Example + 5 Roadmap + 6 Notes + +1. About yrc + +yrc is a text-based, screen-oriented Internet Relay Chat (IRC) client providing a vital dose of simplicity in a computing world drowning in complexity. There are certain things that just shouldn't have bugs, and in the author's opinion, chat programs are among them. + +Simple does not necessarily mean "user-friendly" in the sense that you can expect to operate it without reading this manual. It does mean the literate operator should be able to read and comprehend the manual in an hour or two, and on this basis to fully grasp the workings of the program with a little practice. + +For yrc to achieve its goals, the following decisions were made: + + 1. High-level language implementation. In a network-exposed, not performance-critical application, there's just no excuse for the possibility of invoking Undefined Behavior and the ensuing vulnerabilities. + + 2. Simple configuration mechanism employing the filesystem as a hierarchical key-value database; no structured format to parse. [1] + + 3. Support exclusively VT100-compatible terminals on Unix-like systems. + + 4. No dependencies besides POSIX. [2] + + 5. No superfluous features such as mIRC colors, DCC or CTCP. [3] + + 6. No Unicode or any other flavor of hieroglyphs. Control and 8-bit characters in any IRC string are sanitized on display, both to protect the terminal and show the operator exactly what he is receiving. [4] + + 7. No SSL/TLS/TLS_1.3/whatever-they're-calling-it-today. [5] + +It does support several niceties: + + 1. Multiple message windows (separated channels/conversations) + + 2. Maintenance of connections to multiple networks, defined either by configuration or on the fly + + 3. Round-robin server balancing independent of DNS + + 4. Unlimited scrollback [6] + + 5. Basic authentication (the PASS command) + + 6. Sending raw commands to the IRC server for unsupported features + + 7. Communication of internal state to the operator: unread messages, connection state + + 8. Chat logging + +It is written in Python 2.7 (though this is an implementation detail and subject to change). Some of its more reusable terminal interface code is included as a separate "yterm" module. The yrc+yterm code weighs in around 2360 raw lines, compressing to 18KB. + +While source code is published freely, the project does not accept code contributions from unknown entities. + +2. Display + +The yrc display is designed to maximize information and context available on a single screen, because eyes move faster than fingers. It has five parts: + + Title bar: shows a description of the current window, or the channel topic. + + Window list: an index of open windows (see below). + + Scrollback window: shows message history for a particular channel or private conversation, or server messages for a network, or general messages. + + Status bar: typically, shows the current nickname, network, and message target. + + Prompt: messages and commands are typed here. + +The window list is kept in alphabetical order, grouped by network, after the general messages window "yrc". An asterisk (*) in the first column indicates the currently selected window and a hyphen (-) the previous. Unread messages are indicated by a +N after the window name, where N is the number unread. A network's connection state is indicated by a symbol in front of its name, as follows: + + Blank: Not connected + + Tilde (~): Connection down: awaiting TCP handshake completion, or connection lost and waiting to reconnect + + Hyphen (-): TCP connection established but not registered to the server + + Equals (=): Fully registered; clear to send messages + +3. Commands + +yrc is controlled using _slash commands_ and _keyboard commands_. Slash commands are entered at the prompt by starting the line with the forward "/" character. Most can be given as abbreviations, as indicated below. Some take one or more space-delimited arguments. To send a line starting with a slash as a normal message, use two slashes. Keyboard commands are invoked by typing special keys or key combinations. + +For interactive use, the phrases "current network" or "current channel" mean the network or channel of the currently selected window. For scripts (not yet implemented), they mean the network or channel under which the script is configured. + +3.1. Slash commands + + [c]onnect NETWORK [HOST[:PORT] [NICK [PASS]]] + +Connect to NETWORK, either preconfigured or with the given parameters. + + [d]isconnect [NETWORK] + +Disconnect from NETWORK or the current network, or cancel a reconnection attempt. + + [j]oin CHANNEL [KEY] + +Join a channel on the current network. + + [k]ick USER [MESSAGE] + +Kick a user from the current channel. + + [l]ist [CHANNEL] + +Request a list of channels and topics, or the status of a given channel, on the current network. + + me MESSAGE + +Send MESSAGE as a CTCP ACTION (rendered by many clients in an alternate style showing the nickname as the start of a sentence). + + [m]ode MODE {[CHANNEL] | NICK [CHANNEL]} + +[TODO; uncertain...] Set a mode on CHANNEL or the current channel, or a user mode on NICK in CHANNEL or the current channel. + + msg {CHANNEL | NICK} [MESSAGE] + +Switch to the window for CHANNEL or NICK on the current network, creating one if needed, and send the given MESSAGE, if any. (This does not join a channel; messages to an unjoined channel might be rejected by the server.) + + [na]mes [CHANNEL] + +Request the list of names in the given channel, or all visible channels and users on the current network. + + [n]ick NICK + +Temporarily change nickname on the current network (persists across reconnections but not new connections). + + [p]art [PART_MESSAGE] + +Leave the current channel, keeping its window open. + + [q]uit [QUIT_MESSAGE] + +Disconnect from all networks and quit yrc. + + [s]end COMMAND + +Send a raw IRC command to the server for the current network. + + [st] set-topic TOPIC + +Set the topic for the current channel to TOPIC. + + [t]opic [CHANNEL] + +Get the topic for the current channel or the given channel on the current network. + + [w]hois NICK + +Request information on a user on the current network. + + [ww] whowas NICK + +Request cached information for a disconnected user on the current network. + + [x] close + +Close the current window, parting with no message if it is a channel. + + [xn] close-net (TODO) + +Disconnect and close all windows for the current network. + +3.2. Keyboard commands + +In the Emacs tradition, C- means to hold Ctrl, and M- means to hold Alt (Meta). + +Some commands are multi-key sequences beginning with C-x. For these, you can either continue holding Ctrl or not. That is, C-x n is equivalent to C-x C-n. If necessary, an Alt combination can be emulated by pressing (not holding) Esc followed by the letter. Key sequences in progress are indicated in the status bar and can be cancelled with C-g. + +Each keyboard command has an associated name (used internally for key mapping and perhaps a future scripting feature). The mapping of some keys to commands depends on which part of the interface is focused. Most commands have multiple keybindings, providing a choice of Emacs, vi, or dedicated key styles. To become a more productive Unix user, it can't hurt to learn them all! + +3.2.1. Anywhere + + C-x n: buflist-switch-next + +Switches to the next scrollback window in the list. + + C-x p: buflist-switch-prev + +Switches to the previous scrollback window in the list. + + C-x l: buflist-last-selected + +Switches to the previously active scrollback window. + + C-x w: buflist-enter + +Moves focus to the window list (see 3.2.2). + + C-l: redraw + +Performs a full redraw of the screen, e.g. in case of corruption from the output of another program. + +3.2.2. In the window list + + C-n, j, Down: buflist-next + +Moves the cursor to the next entry in the window list. + + C-p, k, Up: buflist-prev + +Moves the cursor to the previous entry in the window list. + + h, Left: buflist-shrink + +Reduces window list width. + + l, Right: buflist-grow + +Increases window list width. + + M-<, g, Home: buflist-top + +Moves the cursor to the top of the window list. + + M->, G, End: buflist-bottom + +Moves the cursor to the bottom of the window list. + + Return (C-m): buflist-submit + +Activates the selected scrollback window and returns focus to where it was. + +3.2.3. At the prompt + + Return (C-m): prompt-submit + +Submits the message or command given at the prompt. + + Ins: prompt-exit + +Moves focus to the scrollback window (see 3.2.4). + + C-f, Right: prompt-forward + +Moves the cursor forward by one character. + + C-b, Left: prompt-backward + +Moves the cursor backward by one character. + + C-a, Home: prompt-start + +Moves the cursor to the start of the line. + + C-e, End: prompt-end + +Moves the cursor to the end of the line. + + Backspace (C-h, C-?): prompt-backspace + +Deletes backward by one character. + + C-d, Del: prompt-delete + +Deletes forward by one character. + + C-v, PgDn: scroll-down-page + M-v, PgUp: scroll-up-page + +Scrolls the active scrollback window (see 3.2.4). + +3.2.4. In the scrollback window + +If the window is scrolled to the bottom (the last available message is fully visible), it will autoscroll as new messages arrive. + + i, Ins: prompt-enter + +Moves focus to the prompt (see 3.2.3). + + C-v, C-f, f, PgDn, Space: scroll-down-page + M-v, C-b, b, PgUp: scroll-up-page + +Scrolls the window down or up by a page. The current implementation is fairly stupid and scrolls by half a page worth of messages, ignorant of wrapping. + + C-n, j, Down: scroll-down-line + C-p, k, Up: scroll-up-line + +Scrolls the window down or up by one message. + + M-<, g, Home: scroll-top + M->, G, End: scroll-bottom + +Scrolls to the top or bottom. + +4. Configuration + +As mentioned above, yrc is configured using the filesystem as a key-value store. The config tree is rooted at ~/.yrc (the hidden directory ".yrc" under your home directory), known as the yrc home. Each config key is represented by a text file; trailing linefeeds are stripped. + +Under the yrc home may be a "nets" subdirectory. IRC networks are defined by creating further subdirectories under "nets" with names of your choice; these names are used for NETWORK in the yrc commands. + +4.1. Keys + +The following keys may be defined at top level (directly in the yrc home) or per network, with network level taking precedence. + + addrs -- one or more server addresses (required) + nick -- initial IRC nickname (required) + pass -- plain-text password to send on connect + +Addresses are specified one per line as either hostname or IPv4 address optionally followed by ":" and port number. Lines starting with the "#" character are ignored. Per common practice, the default port is 6667. On initial connection to a network, a server is selected at random; subsequent reconnections by the same process rotate sequentially through the list. + +4.2. Logging + +A "logs" tree is created automatically under the yrc home with subdirectories for each network. Messages associated with chats are appended to log files under these, named by VENUE.log where VENUE is the channel name or correspondent nick, casefolded and %-encoded for problematic characters. [7] To suppress logging for a particular entity, you could symlink its log file to /dev/null. + +yrc behaves as if each log file is opened fresh on each write, in the sense that deleting, truncating, renaming or linking log files works immediately and as expected. [8] yrc attempts to write each message with a single system call, so concurrent processes should be safe, though not recommended. + +The log format, designed for a degree of human and machine readability, is one message per line with the following single-space-delimited fields: + + timestamp -- decimal integer, centiseconds since the epoch + type -- mostly matching the IRC protocol but with some variation; consult the "message" variant type in yrc.py for details + sender -- quoted in for aesthetics + content -- running to end of line + +Note that unlike in the UI, message content is not escaped; the only characters excluded by protocol are ASCII NUL, CR and LF. Thus printing a log directly to terminal (as opposed to using an editor or "less") can cause unexpected and possibly dangerous behavior. + +4.3. Example + +An example tree, including some planned keys not yet implemented: + +~/.yrc +|-- nets +| |-- net1 +| | |-- auto +| | |-- addrs +| | |-- nick +| | |-- pass +| | |-- chans +| | | `-- #somechan +| | | |-- auto +| | | `-- key +| | `-- contacts +| | `-- buddy +| `-- net2 +| `-- addrs +|-- nick +|-- realname +|-- user +`-- logs + |-- net1 + | |-- buddy.log + | `-- #channel.log + `-- net2 + `-- 1337%2Fhax0r%0A.log + +5. Roadmap + +High-priority missing features: + + - Prompt history, kill/yank commands + - Indication of nick mentions (ring bell + bold the window name?) + - Tab completion (nicks, slash commands) + - Formatting for displayed messages (e.g. bold sender, highlight own nick) + - Finish mode/op and close-net commands + - Filtering of inbound PMs (/ignore or similar) + - Scrolling based on rendered lines rather than messages + - Scrolling + more efficient navigation of window list + +Nice to have: + + - Search in scrollback + - Nickname fallback + - Online /help command + - Auto-connect/join or scripting + - Parse WHOIS/WHOWAS/LIST responses + - Bracketed paste [meh, not all it's cracked up to be] + - Date change messages + - Channel join status indicators + - Finish config fields [?] + +6. Notes + +[1] Inspired by DJB software, http://cr.yp.to/. + +[2] Not even "ncurses", a lesson learned from the first implementation. It's a mess. + +[3] With the possible exception of CTCP ACTION (the /me command) due to its popularity. + +[4] Contrary to presently popular opinion, this does not preclude non-English communication. It's simpler, and probably healthier, for people to learn "pinyin"/transliteration systems to a single alphabet, than to have every program pretend to support some Consortium's never-finished Babel of an infinite alphabet and all the related landmines. Double-width characters in fixed-width fonts; bidirectionality; combining ligatures, diacritics, and canonicalization thereof; surrogate escapes; multiple types of spaces; characters that look the same but aren't; non-standard private mappings; variable length encodings; byte orderings; decode error handling; glyph substitution because no single font designer can hope to provide complete coverage... are you scared yet? + +[5] Historically it's had more holes than Swiss cheese -- in design, in every major implementation -- and there's no cause to imagine that just one more patch release will fix everything. Use good cryptography if you need it, and stop deluding yourself otherwise. + +[6] Well, limited by your available memory. It would take a very busy channel indeed to make a serious dent in today's memory sizes, but in such a case, you can reclaim memory by closing its specific window from time to time. + +[7] Control and 8-bit characters plus ".", "/" and "%"; this does not include various shell metacharacters. + +[8] Currently this is in fact the implementation, which may result in interface lag e.g. on network filesystems. FD caching or a separate log thread are possible remedies if anyone cares. diff -uNr a/yrc/pylintrc b/yrc/pylintrc --- a/yrc/pylintrc false +++ b/yrc/pylintrc 13cc8d8884d9d0d48877f5e4d43c26556763ed55e2d1ef5cd943f2e742168b6c9cc3282164251ae9e9f66c6f7c3c7929e77f7b7b8fd6e4b8be9443793e03ed56 @@ -0,0 +1,33 @@ +# Use pylint --generate-rcfile for a full example + +[MASTER] + +[MESSAGES CONTROL] + +disable=global-statement,missing-docstring,bad-builtin + +[REPORTS] + +# Set the output format. Available formats are text, parseable, colorized, msvs +# (visual studio) and html. You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=colorized + +# Tells whether to display a full report or only the messages +reports=no + +[BASIC] + +#attr-rgx = [a-z_][a-z0-9_]{0,30}$ +variable-rgx = [a-z_][a-z0-9_]{0,30}$ +argument-rgx = [a-z_][a-z0-9_]{0,30}$ +#method-rgx = [a-z_][a-z0-9_]{2,30}$ +#function-rgx = [a-z_][a-z0-9_]{2,30}$ +const-rgx = (([A-Z_][A-Z0-9_]*)|(__.*__)|([a-z_][a-z0-9_]{2,30}))$ + +[FORMAT] +max-line-length = 80 +max-module-lines = 10000 + +[DESIGN] +max-branches = 15 diff -uNr a/yrc/setup.py b/yrc/setup.py --- a/yrc/setup.py false +++ b/yrc/setup.py bc991e62d46adb82f669327a05bc6b24009d61b88a24162009ebc99999dfff141001b0aa16b1b6296aac593cce0a3f0b26570f3bdbe2c662ce28ad40f56c84f9 @@ -0,0 +1,9 @@ +from distutils.core import setup + +setup( + name='yrc', + version='97K', + description='A Unix IRC client', + py_modules=['yrc', 'yterm'], + scripts=['yrc'], +) diff -uNr a/yrc/yrc b/yrc/yrc --- a/yrc/yrc false +++ b/yrc/yrc 2d6b540ecae5f8d14760a30a343b196f0f67180dcf317f23a582ab3098fb30c7eda88e32f6f66c5d77c835015cd7b9335554ec5fcc644cdd36f2d4e4b69c8fdf @@ -0,0 +1,3 @@ +#!/usr/bin/python2 +import yrc +yrc.crash_handler() diff -uNr a/yrc/yrc.py b/yrc/yrc.py --- a/yrc/yrc.py false +++ b/yrc/yrc.py 92cf8fe956976c0aac193657ed7a86b1d0d338c132766cd952e7123140badcea77bc51261819a30c23767e3bc5a518a76b511ea218e70d07dada6fc402275836 @@ -0,0 +1,2141 @@ +""" +yrc: the Unix IRC client +Jacob Welsh +February 2017 - June 2019 +""" + +__version__ = '97 Kelvin' + +# Knobs that one might conceivably want to tweak +DEFAULT_PORT = 6667 +RECONN_DELAY_MIN = 4 # seconds +RECONN_DELAY_MAX = 256 +PING_INTERVAL = 120 +PING_TIMEOUT = 240 +BUFLIST_WIDTH_MIN = len('* =#a$|') +BUFLIST_WIDTH_STEP = 4 +BUFLIST_WIDTH_START = BUFLIST_WIDTH_MIN + 3*BUFLIST_WIDTH_STEP +format_time = lambda tt: '%02d:%02d:%02d' % (tt.tm_hour, tt.tm_min, tt.tm_sec) + +from os import ( + O_APPEND, O_CREAT, O_NONBLOCK, O_WRONLY, chdir, close, getenv, mkdir, + open as os_open, pipe, read, strerror, write +) +from time import localtime, time +from errno import EAGAIN, EWOULDBLOCK, EEXIST, EINPROGRESS, EINTR, ENOENT +from select import select, error as SelectError +from signal import signal, SIG_DFL, SIGINT, SIGWINCH +from socket import socket, SOL_SOCKET, SO_ERROR, SHUT_RDWR, error as SockError + +from fcntl import fcntl, F_GETFL, F_SETFL + +import yterm as t +from yterm import NUL, BS, LF, CR, SO, SI, ESC, CSI, cseq +STDIN = 0 +STDOUT = 1 +self_pipe_rd, self_pipe_wr = pipe() + +is_ctrl = lambda c: c < '\x20' or c == '\x7f' +ctrl = lambda c: chr(ord(c) ^ 0x40) + +meta = lambda c: ESC + c + +BEL = chr(7) +CRLF = CR + LF +C_X = ctrl('X') +C_G = ctrl('G') # aka BEL + +# Cursor control keys +LEFT = t.CURSOR_BACK1 +RIGHT = t.CURSOR_FORWARD1 +UP = t.CURSOR_UP1 +DOWN = t.CURSOR_DOWN1 +HOME = t.CURSOR_HOME +# Not sure where these originate; vt220 perhaps? Seen on xterm, linux, screen +END = cseq('F') +INS = cseq('~', 2) +DEL = cseq('~', 3) +PGUP = cseq('~', 5) +PGDN = cseq('~', 6) +# Likewise; so far seen on linux, screen +LNX_HOME = cseq('~', 1) +LNX_END = cseq('~', 4) + +GLOBAL_KEYS = { + C_X + 'n': 'buflist-switch-next', + C_X + 'p': 'buflist-switch-prev', + C_X + 'l': 'buflist-last-selected', + C_X + 'w': 'buflist-enter', + ctrl('L'): 'redraw', +} + +PROMPT_KEYS = { + CR: 'prompt-submit', + + INS: 'prompt-exit', + + ctrl('F'): 'prompt-forward', + RIGHT: 'prompt-forward', + + ctrl('B'): 'prompt-backward', + LEFT: 'prompt-backward', + + ctrl('A'): 'prompt-start', + HOME: 'prompt-start', + LNX_HOME: 'prompt-start', + + ctrl('E'): 'prompt-end', + END: 'prompt-end', + LNX_END: 'prompt-end', + + ctrl('H'): 'prompt-backspace', + ctrl('?'): 'prompt-backspace', + + ctrl('D'): 'prompt-delete', + DEL: 'prompt-delete', + + meta('v'): 'scroll-up-page', + PGUP: 'scroll-up-page', + + ctrl('V'): 'scroll-down-page', + PGDN: 'scroll-down-page', +} + +BUFLIST_KEYS = { + CR: 'buflist-submit', + + ctrl('N'): 'buflist-next', + DOWN: 'buflist-next', + 'j': 'buflist-next', + + ctrl('P'): 'buflist-prev', + UP: 'buflist-prev', + 'k': 'buflist-prev', + + meta('<'): 'buflist-top', + HOME: 'buflist-top', + LNX_HOME: 'buflist-top', + 'g': 'buflist-top', + + meta('>'): 'buflist-bottom', + END: 'buflist-bottom', + LNX_END: 'buflist-bottom', + 'G': 'buflist-bottom', + + 'h': 'buflist-shrink', + LEFT: 'buflist-shrink', + + 'l': 'buflist-grow', + RIGHT: 'buflist-grow', +} + +BUF_KEYS = { + 'i': 'prompt-enter', + INS: 'prompt-enter', + + meta('v'): 'scroll-up-page', + PGUP: 'scroll-up-page', + ctrl('B'): 'scroll-up-page', + 'b': 'scroll-up-page', + + ctrl('V'): 'scroll-down-page', + PGDN: 'scroll-down-page', + ctrl('F'): 'scroll-down-page', + 'f': 'scroll-down-page', + ' ': 'scroll-down-page', + + ctrl('P'): 'scroll-up-line', + UP: 'scroll-up-line', + 'k': 'scroll-up-line', + + ctrl('N'): 'scroll-down-line', + DOWN: 'scroll-down-line', + 'j': 'scroll-down-line', + + meta('<'): 'scroll-top', + HOME: 'scroll-top', + LNX_HOME: 'scroll-top', + 'g': 'scroll-top', + + meta('>'): 'scroll-bottom', + END: 'scroll-bottom', + LNX_END: 'scroll-bottom', + 'G': 'scroll-bottom', +} + +CMD_ABBREVS = { + 'c': 'connect', + 'd': 'disconnect', + 'j': 'join', + 'k': 'kick', + 'l': 'list', + 'm': 'mode', + 'n': 'nick', + 'nam': 'names', + 'p': 'part', + 'q': 'quit', + 's': 'send', + 't': 'topic', + 'st': 'set-topic', + 'w': 'whois', + 'ww': 'whowas', + 'x': 'close', + 'xn': 'close-net', +} + +# file.write can barf up EINTR and unclear how to retry due to buffering. Nuts. +def write_all(fd, s): + n = 0 + while n < len(s): + try: + n += write(fd, s[n:]) + except EnvironmentError, e: + if e.errno != EINTR: + raise + +out_buf = bytearray() +write_out = out_buf.extend +def flush_out(): + write_all(STDOUT, out_buf) + del out_buf[:] + +# MLtronics +def variant(vtype, name, nargs): + tag = (vtype, len(vtype), name) + def cons(*args): + if len(args) != nargs: + raise TypeError('%s takes %d args (%d given)' + % (name, nargs, len(args))) + return (tag, args) + vtype.append((name, cons)) + return cons + +variant_name = lambda val: val[0][2] +variant_args = lambda val: val[1] + +def matcher(vtype, cases): + def receiver(name, cons): + for case, recv in cases: + if case is cons: + return recv + raise TypeError('missing case %s' % name) + tbl = [receiver(name, cons) for name, cons in vtype] + def match(val): + tag, args = val + if tag[0] is not vtype: + raise TypeError + return tbl[tag[1]](*args) + return match + +message = [] +m_privmsg = variant(message, 'PRIVMSG', 2) +m_notice = variant(message, 'NOTICE', 2) +m_join = variant(message, 'JOIN', 2) +m_part = variant(message, 'PART', 3) +m_quit = variant(message, 'QUIT', 2) +m_nick = variant(message, 'NICK', 2) +m_kick = variant(message, 'KICK', 4) # kick of specified user +m_kicked = variant(message, 'KICKED', 3) # kick of self +m_topic = variant(message, 'TOPIC', 2) # unifies TOPIC and RPL_(NO)TOPIC +m_chantopic = variant(message, 'CHANTOPIC', 3) # for unjoined channels +m_mode = variant(message, 'MODE', 2) +m_chanmode = variant(message, 'CHANMODE', 3) +m_names = variant(message, 'NAMES', 3) +m_endnames = variant(message, 'ENDNAMES', 2) +m_error = variant(message, 'ERROR', 2) # ERROR from server +m_client = variant(message, 'CLIENT', 1) # generated by client, not logged +m_server = variant(message, 'SERVER', 2) # catch-all for other server msgs + +scr_height = None +scr_width = None + +def sequence(*thunks): + def run(): + for f in thunks: + f() + return run + +def flatten(iterables): + for it in iterables: + for val in it: + yield val + +def char_range(pair): + a, b = pair + return ''.join(map(chr, range(ord(a), ord(b) + 1))) + +def partition(l, pred): + left = [] + right = [] + for elem in l: + if pred(elem): + left.append(elem) + else: + right.append(elem) + return left, right + +def split_pair(s, sep=' '): + pair = s.split(sep, 1) + if len(pair) == 1: + pair.append('') + return pair + +def make_encoder(f): + tbl = [f(i) for i in range(0x100)] + return lambda s: ''.join(tbl[ord(c)] for c in s) + +# Non-invertible terminal sanitizing +asciify = make_encoder(lambda i: + '^' + ctrl(chr(i)) if i < 0x20 or i == 0x7f + else chr(i) if 0x20 <= i <= 0x7e + else '\\x%X' % i) + +# Uglier, injective encoding for file names +fs_encode = make_encoder(lambda i: + chr(i) if 0x20 <= i <= 0x7e and chr(i) not in './%' + else '%' + ('%02X' % i)) + +make_casemapper = lambda upper_bound: make_encoder(lambda i: + chr(i + 0x20) if 0x41 <= i <= upper_bound + else chr(i)) + +# We currently support only the original IRC case mapping (as implemented, not +# as specified; one of many howlers in the RFC). The RPL_ISUPPORT reply that +# some servers use to advertise alternate mappings was specified in a draft +# long since expired +# (http://www.irc.org/tech_docs/draft-brocklesby-irc-isupport-03.txt). Besides, +# a case-insensitive protocol that can't agree on what "case" means is +# preposterous. This is an IRC client; if a server does not behave like an IRC +# server, it doesn't fall to us to decipher its nonsense. + +IRC_CASEMAP = {'rfc1459': make_casemapper(0x5e)} + +casemap_ascii = make_casemapper(0x5a) + +clip = lambda min_, max_, x: max(min_, min(max_, x)) +clip_to = lambda l, i: clip(0, len(l) - 1, i) +get_clipped = lambda l, i: l[clip_to(l, i)] + +def clip_str(s, width): + if width == 0: + return '' + if len(s) > width: + return '%s$' % s[:width - 1] + return s + +def pad_or_clip_str(s, width, pad=' '): + if len(s) < width: + return s + pad*(width - len(s)) + return clip_str(s, width) + +def wrap(line, width, sep=' '): + start = 0 + lines = [] + while start + width < len(line): + end = start + width + cut = line.rfind(sep, start, end + 1) + if cut == -1: + lines.append(line[start:end]) + start = end + else: + lines.append(line[start:cut]) + start = cut + 1 + lines.append(line[start:]) + return lines + +def is_digit(c): + return '0' <= c <= '9' + +def parse_address(addr): + addr = addr.rsplit(':', 1) + host = addr[0] + if len(addr) == 1: + port = DEFAULT_PORT + else: + port = addr[1] + if not all(map(is_digit, port)): + raise ValueError('port not a positive integer: %r' % port) + port = int(port) + if port >= 2**16: + raise ValueError('port out of range: %d' % port) + return host, port + +format_address = lambda host_port: '%s:%d' % host_port + +int_of_bytes = lambda b: reduce(lambda acc, byte: acc*256 + ord(byte), b, 0) + +def rand_int(n): + """Get a random integer from 0 (inclusive) to n (exclusive) using the + system's nonblocking entropy pool. More-or-less deterministic run time, at + the cost of a small modulo bias.""" + nbytes = (n.bit_length() + 7) / 8 + with open('/dev/urandom', 'rb') as f: + return int_of_bytes(f.read(2*nbytes)) % n + +# +# Binary min-heap, used as priority queue for scheduling +# + +def heap_insert(h, key, value): + h.append((key, value)) + # Percolate up + i = len(h) - 1 + while i > 0: + i_parent = (i - 1)/2 + item = h[i] + parent = h[i_parent] + if parent[0] <= item[0]: + break + h[i] = parent + h[i_parent] = item + i = i_parent + +heap_peek = lambda h: h[0] + +def heap_extract(h): + if len(h) == 1: + return h.pop() + result = h[0] + h[0] = h.pop() + # Percolate down + i = 0 + while True: + i_child = 2*i + 1 + if i_child >= len(h): + break + i_right = i_child + 1 + if i_right < len(h) and h[i_right][0] < h[i_child][0]: + i_child = i_right + item = h[i] + child = h[i_child] + if item[0] <= child[0]: + break + h[i] = child + h[i_child] = item + i = i_child + return result + +# purely for testing +def heapsort(iterable): + h = [] + for item in iterable: + heap_insert(h, item, None) + while h: + yield heap_extract(h)[0] + +# +# Config +# + +def safe_filename(name): + return '/' not in name and name not in ('', '.', '..') + +def get_config(key, paths=(()), default=None): + assert safe_filename(key) + for path in paths: + assert all(map(safe_filename, path)) + fspath = [] + fspath.extend(path) + fspath.append(key) + try: + with open('/'.join(fspath), 'rb') as f: + return f.read().rstrip('\n') + except EnvironmentError, e: + if e.errno != ENOENT: + error(e) + return default + +def config_lines(text): + if text is None: + return None + lines = text.split('\n') + lines = [l.strip() for l in lines if not l.startswith('#')] + lines = [l for l in lines if len(l) > 0] + return lines if lines else None + +# +# UI stuff +# + +class CommandError(Exception): + pass + +commands = {} +def command(name, min_args=0, max_args=None, extended_arg=False): + if max_args is None: + max_args = min_args + def register(func): + if name in commands: + raise ValueError('duplicate command %s' % name) + def run(arg_str=''): + args = [] + while len(args) != max_args: + arg, arg_str = split_pair(arg_str.lstrip()) + if arg == '': + break + args.append(arg) + arg_str = arg_str.lstrip() + if extended_arg: + args.append(arg_str) + elif arg_str: + raise CommandError('%s: too many arguments' % name) + if len(args) < min_args: + raise CommandError('%s: too few arguments' % name) + return func(*args) + commands[name] = run + return func + return register + +def check_command_dicts(dicts): + for d in dicts: + for cmd in d.itervalues(): + assert cmd in commands, cmd + +def run_command(line): + cmd, args = split_pair(line) + cmd = CMD_ABBREVS.get(cmd, cmd) + func = commands.get(cmd) + if func is None: + raise CommandError('bad command: %s' % cmd) + return func(args) + +flags = [False]*12 +_getf = lambda i: (lambda: flags[i]) +_setf = lambda i: (lambda: flags.__setitem__(i, True)) +_clrf = lambda i: (lambda: flags.__setitem__(i, False)) + +refresh_flag = _getf(0) +schedule_refresh = _setf(0) +refresh_done = _clrf(0) + +redraw_flag = _getf(1) +schedule_redraw = _setf(1) +redraw_done = _clrf(1) + +buflist_draw_flag = _getf(2) +schedule_buflist_draw = _setf(2) +buflist_draw_done = _clrf(2) + +buf_draw_flag = _getf(3) +schedule_buf_draw = _setf(3) +buf_draw_done = _clrf(3) + +prompt_draw_flag = _getf(4) +schedule_prompt_draw = _setf(4) +prompt_draw_done = _clrf(4) + +status_draw_flag = _getf(5) +schedule_status_draw = _setf(5) +status_draw_done = _clrf(5) + +quit_flag = _getf(6) +schedule_quit = _setf(6) + +# 7-8: available + +buflist_flag = _getf(9) +buflist_enter = _setf(9) +buflist_exit = _clrf(9) + +prompt_flag = _getf(10) +prompt_enter = _setf(10) +prompt_exit = _clrf(10) + +ping_draw_flag = _getf(11) +set_ping_draw = _setf(11) +clr_ping_draw = _clrf(11) + +new_buf = lambda name, parent, title: [name, parent, title, 0, [], 0, True] + +buf_name = lambda b: b[0] +buf_parent = lambda b: b[1] +buf_title = lambda b: b[2] +buf_vscroll = lambda b: b[3] +buf_lines = lambda b: b[4] +buf_num_read = lambda b: b[5] +buf_unread = lambda b: len(buf_lines(b)) - buf_num_read(b) +buf_at_end = lambda b: b[6] + +buf_set_title = lambda b, title: b.__setitem__(2, title) +buf_set_vscroll = lambda b, i: b.__setitem__(3, clip_to(buf_lines(b), i)) +buf_set_num_read = lambda b, n: b.__setitem__(5, max(buf_num_read(b), n)) +buf_set_at_end = lambda b: b.__setitem__(6, True) +buf_clr_at_end = lambda b: b.__setitem__(6, False) + +format_buf_msg = matcher(message, ( + (m_privmsg, lambda sender, msg: '<%s> %s' % (sender, msg)), + (m_server, lambda sender, msg: '<%s> %s' % (sender, msg)), + (m_notice, lambda sender, msg: '<-%s-> %s' % (sender, msg)), + (m_join, lambda sender, chan: '%s joins %s' % (sender, chan)), + (m_part, lambda sender, chan, msg: '%s parts %s [%s]' + % (sender, chan, msg)), + (m_quit, lambda sender, msg: '%s quits [%s]' % (sender, msg)), + (m_nick, lambda sender, nick: '%s is now known as %s' % (sender, nick)), + (m_kick, lambda sender, chan, name, msg: '%s kicked from %s by %s [%s]' + % (name, chan, sender, msg)), + (m_kicked, lambda sender, chan, msg: 'kicked from %s by %s [%s]' + % (chan, sender, msg)), + (m_topic, lambda sender, topic: + '%s sets topic to %s' % (sender, topic) + if topic else + '%s removes topic' % sender), + (m_chantopic, lambda sender, chan, topic: + '<%s> Topic for %s: %s' % (sender, chan, topic) + if topic is not None else + '<%s> No topic for %s' % (sender, chan)), + (m_mode, lambda sender, modes: 'MODE %s by %s' % (modes, sender)), + (m_chanmode, lambda sender, chan, modes: 'MODE %s on %s by %s' + % (modes, chan, sender)), + (m_names, lambda sender, chan, names: '<%s> NAMES in %s: %s' + % (sender, chan, names)), + (m_endnames, lambda sender, chan: '<%s> end NAMES in %s' + % (sender, chan)), + (m_error, lambda sender, msg: '<%s> server error: %s' % (sender, msg)), + (m_client, lambda msg: msg))) + +def buf_log_msg(buf, m): + buf_lines(buf).append(format_time(localtime()) + ' ' + format_buf_msg(m)) + schedule_buflist_draw() # for unread counts + if buf is cur_buf and buf_at_end(buf): + schedule_buf_draw() + +def buf_privmsg(buf, msg): + if buf_parent(buf) is None: + raise CommandError("can't send messages here") + conn_privmsg(buf_registered_conn(buf), buf_name(buf), msg) + +def render_lines(lines, width, start, row_limit): + """Returns list of wrapped line lists, and the number of *complete* source + lines in the output""" + rows = 0 + groups = [] + while rows < row_limit and start < len(lines): + msg = lines[start] + group = wrap(asciify(msg), width) + rows += len(group) + if rows > row_limit: + group = group[:row_limit - rows] + groups.append(group) + return groups, len(groups) - 1 + groups.append(group) + start += 1 + return groups, len(groups) + +def check_buf_at_end(): + width = scr_width - buflist_width() + height = scr_height - 3 + if height < 1 or width < 1: + return + lines = buf_lines(cur_buf) + scroll = buf_vscroll(cur_buf) + lines_shown = render_lines(lines, width, scroll, height)[1] + if scroll + lines_shown == len(lines): + buf_set_at_end(cur_buf) + else: + buf_clr_at_end(cur_buf) + +def buf_draw(buf): + if scr_height < 2: + return + schedule_refresh() + write_out(t.CURSOR_HOME) + write_out(t.render(( + t.A_REVERSE, pad_or_clip_str(asciify(buf_title(buf)), scr_width)))) + left_column = buflist_width() + 1 + width = scr_width - buflist_width() + top_row = 2 + height = scr_height - 3 + if height < 1 or width < 1: + return + lines = buf_lines(buf) + while True: + scroll = buf_vscroll(buf) + groups, lines_shown = render_lines(lines, width, scroll, height) + lines_shown += scroll + if lines_shown == len(lines): + buf_set_at_end(buf) + break + if not buf_at_end(buf): + break + # Was at end until now: autoscroll + if scroll + 1 == len(lines): + break # Couldn't even fit one complete line + buf_set_vscroll(buf, max(scroll + 1, len(lines) - height)) + row = 0 + for row, text in enumerate(flatten(groups)): + write_out(t.cursor_pos(top_row + row, left_column) + text) + if len(text) != width: + write_out(t.ERASE_LINE_TO_END) + for row in range(row + 1, height): + write_out(t.cursor_pos(top_row + row, left_column)) + write_out(t.ERASE_LINE_TO_END) + buf_set_num_read(buf, lines_shown) + buf_draw_done() + +cur_buf = new_buf('yrc', None, 'yrc general messages') +buffers = [cur_buf] +buffer_index = {} + +is_child_of = lambda parent: lambda buf: buf_parent(buf) is parent + +def sort_buffers(): + global buffers + schedule_buflist_draw() + # "yrc" is always first + acc, buffers = buffers[:1], buffers[1:] + roots, buffers = partition(buffers, is_child_of(None)) + roots.sort(key=buf_name) + for root in roots: + children, buffers = partition(buffers, is_child_of(root)) + children.sort(key=buf_name) + acc.append(root) + acc.extend(children) + buffers = acc + buffer_index.clear() + for b in buffers: + parent = buf_parent(b) + parent_name = buf_name(parent) if parent else None + buffer_index[(buf_name(b), parent_name)] = b + +def get_buf(name, parent_name): + try: + return buffer_index[(name, parent_name)] + except KeyError: + if parent_name is None: + parent = None + title = 'Network messages: %s' % name + else: + parent = get_buf(parent_name, None) + title = name if is_chan(name) else 'Private messages: %s' % name + b = new_buf(name, parent, title) + buffers.append(b) + sort_buffers() + return b + +def find_buf(buf): + for i, b in enumerate(buffers): + if b is buf: + return i + raise ValueError("not in buffer list") + +def close_buf(buf): + i = find_buf(buf) + assert i > 0 + prev = buffers[i - 1] + buflist_set_cursor(prev) + last = buflist_last() + if last is buf: # not sure if this is possible but just in case + last = prev + buflist_select(prev) + buflist_set_last(last) + parent = buf_parent(buf) + parent_name = buf_name(parent) if parent else None + del buffer_index[(buf_name(buf), parent_name)] + buffers.pop(i) + +command('scroll-down-page')(sequence( + schedule_buf_draw, + schedule_buflist_draw, # update unread counts + lambda: buf_set_vscroll(cur_buf, buf_vscroll(cur_buf) + (scr_height-3)/2))) + +command('scroll-up-page')(sequence( + schedule_buf_draw, + lambda: buf_set_vscroll(cur_buf, buf_vscroll(cur_buf) - (scr_height-3)/2), + check_buf_at_end)) # stop autoscrolling if no longer at end + +command('scroll-down-line')(sequence( + schedule_buf_draw, + schedule_buflist_draw, + lambda: buf_set_vscroll(cur_buf, buf_vscroll(cur_buf) + 1))) + +command('scroll-up-line')(sequence( + schedule_buf_draw, + lambda: buf_set_vscroll(cur_buf, buf_vscroll(cur_buf) - 1), + check_buf_at_end)) + +command('scroll-bottom')(sequence( + schedule_buf_draw, + schedule_buflist_draw, + lambda: buf_set_vscroll(cur_buf, len(buf_lines(cur_buf))))) + +command('scroll-top')(sequence( + schedule_buf_draw, + lambda: buf_set_vscroll(cur_buf, 0), + check_buf_at_end)) + +def info(msg, buf=None): + buf_log_msg(buf or buffers[0], m_client(msg)) + +def error(msg_or_exc, buf=None): + buf_log_msg(buf or buffers[0], m_client('ERROR: ' + str(msg_or_exc))) + +prompt = [[], 0, 0, 1] + +prompt_chars = lambda: prompt[0] +prompt_cursor = lambda: prompt[1] +prompt_hscroll = lambda: prompt[2] +prompt_cursor_column = lambda: prompt[3] + +def prompt_clear(): + schedule_prompt_draw() + prompt[0] = [] + prompt_set_cursor(0) + prompt_set_hscroll(0) + +def prompt_set_cursor(c): + schedule_prompt_draw() + if 0 <= c <= len(prompt_chars()): + prompt[1] = c + return True + return False + +def prompt_set_hscroll(s): + schedule_prompt_draw() + prompt[2] = s + +def prompt_set_cursor_column(c): + prompt[3] = c + +def prompt_insert(char): + schedule_prompt_draw() + c = prompt_cursor() + prompt_chars().insert(c, char) + prompt_set_cursor(c + 1) + +@command('prompt-delete') +def prompt_delete(): + schedule_prompt_draw() + c = prompt_cursor() + chars = prompt_chars() + if c < len(chars): + chars.pop(c) + +@command('prompt-backspace') +def prompt_backspace(): + schedule_prompt_draw() + c = prompt_cursor() + if prompt_set_cursor(c - 1): + prompt_delete() + +@command('prompt-submit') +def prompt_submit(): + schedule_prompt_draw() + line = ''.join(prompt_chars()) + prompt_clear() + if len(line) == 0: + return + try: + if line.startswith('/'): + line = line[1:] + if not line: + raise CommandError('empty command') + if not line.startswith('/'): + run_command(line) + return + buf_privmsg(cur_buf, line) + except CommandError, e: + error(e, cur_buf) + +command('prompt-forward')(lambda: prompt_set_cursor(prompt_cursor() + 1)) +command('prompt-backward')(lambda: prompt_set_cursor(prompt_cursor() - 1)) +command('prompt-start')(lambda: prompt_set_cursor(0)) +command('prompt-end')(lambda: prompt_set_cursor(len(prompt_chars()))) + +def prompt_draw(): + if scr_height < 1: + return + write_out(t.cursor_pos(scr_height, 1)) + if scr_width < 4: + write_out(t.ERASE_LINE_TO_END) + return + prompt_str = '> ' + write_out(prompt_str) + chars = prompt_chars() + cursor = prompt_cursor() + hscr = prompt_hscroll() + before_cursor = asciify(chars[:cursor]) + cursor_offset = len(before_cursor) - hscr + max_width = scr_width - len(prompt_str) + if not 0 <= cursor_offset < max_width: + hscr = max(0, hscr + cursor_offset - max_width/2) + prompt_set_hscroll(hscr) + cursor_offset = len(before_cursor) - hscr + prompt_set_cursor_column(1 + len(prompt_str) + cursor_offset) + printable = (before_cursor[hscr:] + + asciify(chars[cursor:])[:max_width - cursor_offset]) + write_out(printable) + write_out(t.ERASE_LINE_TO_END) + schedule_refresh() + prompt_draw_done() + +def conn_ping_status(c): + t0 = conn_ping_ts(c) + t1 = conn_pong_ts(c) + if t1 < t0: + if not ping_draw_flag(): + set_ping_draw() + run_in(5, sequence(clr_ping_draw, status_draw)) + delta = int(mono_time() - t0) + return '%d...' % (delta - delta%5) + return '%.3f' % (t1 - t0) + +def status_draw(): + row = scr_height - 1 + if row < 1: + return + if kbd_state is ks_esc: + status = 'ESC-' + elif kbd_state is ks_cx: + status = 'C-x-' + elif kbd_state in (ks_cseq, ks_cs_intermed): + status = ' '.join(['CSI'] + list(str(kbd_accum[len(CSI):]))) + '-' + elif cur_buf is buffers[0]: + status = '' + else: + parent = buf_parent(cur_buf) + network = buf_name(cur_buf if parent is None else parent) + status = asciify(network) + c = network_conns.get(network) + if c: + status = asciify(conn_nick(c) + '@') + status + if parent: + venue = buf_name(cur_buf) + status += ' | ' + asciify(venue) + if c and conn_sock(c): + if not conn_registered(c): + status += ' | unregistered' + elif parent and is_chan(venue) and venue not in conn_channels(c): + status += ' | unjoined' + status += ' | ping: %s' % conn_ping_status(c) + else: + status += ' | offline' + write_out(t.cursor_pos(row, 1)) + write_out(t.ATTR_REVERSE) + write_out(pad_or_clip_str(status, scr_width)) + write_out(t.ATTR_NONE) + schedule_refresh() + status_draw_done() + +# buflist singleton: lists the open buffers in a left-hand pane + +buflist = [0, BUFLIST_WIDTH_START, cur_buf, cur_buf, cur_buf] +buflist_vscroll = lambda: buflist[0] +buflist_width = lambda: buflist[1] +buflist_cursor = lambda: buflist[2] +buflist_last = lambda: buflist[3] + +def buflist_set_width(w): + schedule_redraw() + if w > scr_width: + w = scr_width - (scr_width - w) % BUFLIST_WIDTH_STEP + buflist[1] = max(BUFLIST_WIDTH_MIN, w) + +def buflist_set_cursor(b): + schedule_buflist_draw() + buflist[2] = b + +def buflist_set_last(b): + buflist[3] = b + +def buflist_select(b): + global cur_buf + schedule_buflist_draw() + schedule_buf_draw() + schedule_status_draw() + if b is not cur_buf: + buflist_set_last(cur_buf) + # don't autoscroll a newly selected window (at_end will get restored on + # draw if it's still at the end) + buf_clr_at_end(b) + buflist_set_cursor(b) + cur_buf = b + +command('buflist-switch-next')( + lambda: buflist_select( + get_clipped(buffers, find_buf(cur_buf) + 1))) +command('buflist-switch-prev')( + lambda: buflist_select( + get_clipped(buffers, find_buf(cur_buf) - 1))) +command('buflist-last-selected')(lambda: buflist_select(buflist_last())) + +command('buflist-next')( + lambda: buflist_set_cursor( + get_clipped(buffers, find_buf(buflist_cursor()) + 1))) +command('buflist-prev')( + lambda: buflist_set_cursor( + get_clipped(buffers, find_buf(buflist_cursor()) - 1))) +command('buflist-top')(lambda: buflist_set_cursor(buffers[0])) +command('buflist-bottom')(lambda: buflist_set_cursor(buffers[-1])) +command('buflist-shrink')(lambda: + buflist_set_width(buflist_width() - BUFLIST_WIDTH_STEP)) +command('buflist-grow')(lambda: + buflist_set_width(buflist_width() + BUFLIST_WIDTH_STEP)) + +@command('buflist-submit') +def buflist_submit(): + buflist_exit() + buflist_select(buflist_cursor()) + +def buflist_draw(): + schedule_refresh() + top_row = 2 + h = scr_height - 3 + w = min(scr_width, buflist_width() - 1) + cursor = buflist_cursor() + def network_status(network): + c = network_conns.get(network) + if c is None: # not connected + return ' ' + sock = conn_sock(c) + if sock is None: # down, awaiting reconnection + return '~' + fd = conn_sock(c).fileno() + if fd in opening_conns: # DNS / TCP handshake + return '~' + if conn_registered(c): + return '=' + else: # TCP connected but unregistered (no WELCOME yet) + return '-' + def get_label(buf): + parts = [] + if buf is cur_buf: + parts.append('*') + elif buf is buflist_last(): + parts.append('-') + else: + parts.append(' ') + if buf_parent(buf) is None: + if buf is buffers[0]: + parts.append(' ') + else: + parts.append(network_status(buf_name(buf))) + else: # channel/pm + parts.append(' ') + parts.append(asciify(buf_name(buf))) + unread = buf_unread(buf) + if unread > 0: + parts.append(' +%d' % unread) + label = ''.join(parts) + if buf is cursor and buflist_flag(): + return t.render((t.A_REVERSE, pad_or_clip_str(label, w))) + else: + return clip_str(label, w) + write_out(t.cursor_pos(2, 1)) + scroll = buflist_vscroll() + for row in range(h): + write_out(t.cursor_pos(top_row + row, w) + t.ERASE_LINE_FROM_START) + i = scroll + row + if i < len(buffers): + write_out(CR + get_label(buffers[i])) + buflist_draw_done() + +def buflist_vline_draw(): + top_row = 2 + height = scr_height - 3 + column = buflist_width() + if column > scr_width: + return + move_down = LF if column == scr_width else BS + LF + write_out(t.cursor_pos(top_row, column)) + write_out(SO) + write_out(move_down.join(t.SGC_VBAR*height)) + write_out(SI) + +command('prompt-enter')(sequence(schedule_refresh, prompt_enter)) +command('prompt-exit')(sequence(schedule_refresh, prompt_exit)) +command('buflist-enter')(sequence(schedule_buflist_draw, buflist_enter)) +command('redraw')(schedule_redraw) + +# Terminal input state machine + +# Only valid control sequences per ECMA-48 5ed. sec. 5.4 are accepted. +# Esc always cancels any sequence in progress and moves to ks_esc, to avoid +# control sequences leaking through as text input. C-g always cancels and +# returns to ks_start. + +kbd_accum = bytearray() +kaccum = kbd_accum.extend + +def kaccept(sym=''): + kaccum(sym) + seq = str(kbd_accum) + ktrans(ks_start) + def try_keymap(km): + cmd = km.get(seq) + if cmd is None: + return False + run_command(cmd) + return True + try: + if try_keymap(GLOBAL_KEYS): + return + elif buflist_flag(): + try_keymap(BUFLIST_KEYS) + elif prompt_flag(): + if not try_keymap(PROMPT_KEYS): + if len(seq) == 1 and not is_ctrl(seq): + prompt_insert(seq) + else: + try_keymap(BUF_KEYS) + except CommandError, e: + error(e, cur_buf) + +def ktrans(state): + global kbd_state + kbd_state = state + schedule_status_draw() + if state in (ks_start, ks_esc): + del kbd_accum[:] + elif state is ks_cseq: + del kbd_accum[:] + kaccum(CSI) + +# States + +def ks_start(sym): + if sym == C_X: + kaccum(C_X) + ktrans(ks_cx) + elif sym == ESC: + ktrans(ks_esc) + else: + kaccept(sym) + +def ks_cx(sym): + if sym == C_G: + ktrans(ks_start) + elif sym == ESC: + ktrans(ks_esc) + else: + kaccept(casemap_ascii(ctrl(sym)) if is_ctrl(sym) else sym) + +def ks_esc(sym): + if sym == C_G: + ktrans(ks_start) + elif sym == ESC: + pass + elif sym == '[': + ktrans(ks_cseq) + else: + kaccept(meta(sym)) + +def ks_cseq(sym): + if sym == ESC: + ktrans(ks_esc) + elif '\x20' <= sym <= '\x2F': + kaccum(sym) + ktrans(ks_cs_intermed) + elif '\x30' <= sym <= '\x3F': + kaccum(sym) + schedule_status_draw() + elif '\x40' <= sym <= '\x7E': + kaccept(sym) + else: + ktrans(ks_start) + +def ks_cs_intermed(sym): + if sym == ESC: + ktrans(ks_esc) + elif '\x20' <= sym <= '\x2F': + kaccum(sym) + schedule_status_draw() + elif '\x40' <= sym <= '\x7E': + kaccept(sym) + else: + ktrans(ks_start) + +kbd_state = ks_start + +def buf_conn(buf): + if buf is buffers[0]: + raise CommandError('this window not associated with a network') + parent = buf_parent(buf) + network = buf_name(buf if parent is None else parent) + try: + return network_conns[network] + except KeyError: + raise CommandError('not connected to %s' % network) + +def buf_registered_conn(buf): + c = buf_conn(buf) + if conn_sock(c) is None: + raise CommandError('connection to %s is down' % conn_network(c)) + if not conn_registered(c): + raise CommandError('connection to %s not registered' % conn_network(c)) + return c + +@command('connect', 1, 4) +def connect_cmd(*args): + net = args[0] + if net in network_conns: + raise CommandError('connect: connection already active for %s' % net) + + if not safe_filename(net): + raise CommandError('connect: bad network name: %s' % net) + conf_paths = (('nets', net), ()) + + if len(args) > 1: + addrs = [args[1]] + else: + addrs = config_lines(get_config('addrs', conf_paths)) + if addrs is None: + raise CommandError('connect: no addrs for network %s' % net) + try: + addrs = map(parse_address, addrs) + except ValueError, e: + raise CommandError('connect: %s' % e) + + if len(args) > 2: + nick = args[2] + else: + nick = get_config('nick', conf_paths) + if nick is None: + raise CommandError('connect: no nick for %s' % net) + if not valid_nick(nick): + raise CommandError('connect: bad nick: %s' % nick) + + if len(args) > 3: + pw = args[3] + else: + pw = get_config('pass', conf_paths) + if not valid_password(pw): + raise CommandError( + 'connect: illegal character in password for %s' % net) + + conn_start(new_conn(net, addrs, nick, pw)) + +@command('disconnect', 0, 1) +def disconnect_cmd(net=None): + if net is None: + c = buf_conn(cur_buf) + net = conn_network(c) + else: + try: + c = network_conns[net] + except KeyError: + raise CommandError('no connection for network %s' % net) + del network_conns[net] + conn_info(c, 'disconnected') + conn_close(c) + +@command('join', 1, 2) +def join_cmd(chan, key=None): + if not valid_chan(chan): + raise CommandError('join: bad channel name: %s' % chan) + conn_join(buf_registered_conn(cur_buf), chan, key) + +@command('kick', 1, extended_arg=True) +def kick_cmd(user, msg=''): + chan = buf_name(cur_buf) + if buf_parent(cur_buf) is None or not is_chan(chan): + raise CommandError('kick: this window not a channel') + conn_send(buf_registered_conn(cur_buf), 'KICK', [chan, user, msg]) + +@command('list', 0, 1) +def list_cmd(*args): + conn_send(buf_registered_conn(cur_buf), 'LIST', args) + +@command('me', extended_arg=True) +def me_cmd(msg=''): + buf_privmsg(cur_buf, '\x01ACTION %s\x01' % msg) + +@command('mode', 1, 3) +def mode_cmd(*args): + conn_send(buf_registered_conn(cur_buf), 'MODE', args) + +@command('msg', 1, extended_arg=True) +def msg_cmd(target, msg=None): + c = buf_registered_conn(cur_buf) + if is_chan(target) and not valid_chan(target): + raise CommandError('msg: bad channel name: %s' % target) + if msg: + conn_privmsg(c, target, msg) + buflist_select(get_buf(target, conn_network(c))) + +@command('names', 0, 1) +def names_cmd(*args): + conn_send(buf_registered_conn(cur_buf), 'NAMES', args) + +@command('nick', 1) +def nick_cmd(nick): + c = buf_conn(cur_buf) + if conn_sock(c) is None: + schedule_status_draw() + conn_set_nick(c, nick) + conn_info(c, 'nick changed to %s for next reconnection' % nick) + else: + if not conn_registered(c): + schedule_status_draw() + conn_set_nick(c, nick) + conn_info(c, 'nick changed to %s' % nick) + conn_set_ping_ts(c, mono_time()) + conn_run_in(c, PING_TIMEOUT, conn_reg_timeout) + conn_send(c, 'NICK', [nick]) + +@command('part', extended_arg=True) +def part_cmd(msg=''): + chan = buf_name(cur_buf) + if buf_parent(cur_buf) is None or not is_chan(chan): + raise CommandError('part: this window not a channel') + conn_send(buf_registered_conn(cur_buf), 'PART', [chan, msg]) + +@command('quit', extended_arg=True) +def quit_cmd(*msg): + schedule_quit() + for c in open_conns.itervalues(): + conn_send(c, 'QUIT', msg) + +@command('send', extended_arg=True) +def send_cmd(line): + if len(line) > MAX_MSG_LEN: + raise CommandError('send: line too long') + conn_write(buf_registered_conn(cur_buf), line) + +@command('topic', 0, 1) +def topic_cmd(chan=None): + if chan is None: + chan = buf_name(cur_buf) + if buf_parent(cur_buf) is None or not is_chan(chan): + raise CommandError( + 'topic: this window not a channel and none specified') + conn_send(buf_registered_conn(cur_buf), 'TOPIC', [chan]) + +@command('set-topic', extended_arg=True) +def set_topic_cmd(topic=''): + chan = buf_name(cur_buf) + if buf_parent(cur_buf) is None or not is_chan(chan): + raise CommandError('set-topic: this window not a channel') + conn_send(buf_registered_conn(cur_buf), 'TOPIC', [chan, topic]) + +@command('whois', 1) +def whois_cmd(nick): + conn_send(buf_registered_conn(cur_buf), 'WHOIS', [nick]) + +@command('whowas', 1) +def whowas_cmd(nick): + conn_send(buf_registered_conn(cur_buf), 'WHOWAS', [nick]) + +@command('close') +def close_cmd(): + parent = buf_parent(cur_buf) + if parent is None: + raise CommandError( + "close: won't close a top-level window (try close-net)") + venue = buf_name(cur_buf) + try: + c = buf_registered_conn(cur_buf) + except CommandError: + pass + else: + if venue in conn_channels(c): + conn_send(c, 'PART', [venue, '']) + del conn_channels(c)[venue] + close_buf(cur_buf) + +@command('close-net') +def close_net_cmd(): + raise CommandError('stub') # TODO + +def place_cursor(): + if buflist_flag(): + write_out(t.cursor_pos( + max(1, scr_height - 2), + clip(1, scr_width, buflist_width() - 1))) + elif prompt_flag(): + write_out(t.cursor_pos(scr_height, prompt_cursor_column())) + else: + write_out(t.cursor_pos(max(1, scr_height - 2), max(1, scr_width))) + +def refresh_if_needed(): + if redraw_flag(): + write_out(t.ERASE_FULL) + buf_draw(cur_buf) + buflist_vline_draw() + buflist_draw() + status_draw() + prompt_draw() + redraw_done() + else: + if buf_draw_flag(): + buf_draw(cur_buf) + if buflist_draw_flag(): + buflist_draw() + if status_draw_flag(): + status_draw() + if prompt_draw_flag(): + prompt_draw() + if refresh_flag(): + place_cursor() + flush_out() + refresh_done() + +# +# IRC stuff +# + +RPL_WELCOME = 1 +RPL_NOTOPIC = 331 +RPL_TOPIC = 332 +RPL_NAMREPLY = 353 +RPL_ENDOFNAMES = 366 +ERR_NICKNAMEINUSE = 433 +ERR_NICKCOLLISION = 436 +ERR_NOTREGISTERED = 451 + +MAX_MSG_LEN = 510 +MAX_CHAN_LEN = 50 +MAX_NICK_LEN = 31 + +IRC_ILLEGAL = NUL + CR + LF +CHAN_ILLEGAL = IRC_ILLEGAL + BEL + ' ,:' +CHAN_START = '&#+!' +IRC_SPECIAL = '[]\\`_^{|}' + +LETTER = char_range('AZ') + char_range('az') +DIGIT = char_range('09') +NICK_START = LETTER + IRC_SPECIAL +NICK_BODY = NICK_START + DIGIT + '-' + +class ProtocolError(Exception): + pass + +def build_msg(prefix, cmd, params): + """Build an IRC wire message. + + Conditions caller must enforce: + * No args may contain NUL, CR, or LF + * Only last param may be empty, contain spaces, or start with : + * Valid cmd + * 15 parameters max + """ + parts = [] + if prefix is not None: + parts.append(':' + prefix) + parts.append(cmd) + if len(params): + parts.extend(params[:-1]) + parts.append(':' + params[-1]) + return ' '.join(parts) + +def max_param_len(cmd, prefix=None): + # colon before prefix + space after prefix + prefix_len = 0 if prefix is None else len(prefix) + 2 + # space after cmd + colon before last param + return MAX_MSG_LEN - prefix_len - len(cmd) - 2 + +def parse_msg(msg): + if any(c in msg for c in IRC_ILLEGAL): + raise ProtocolError('illegal character in message') + start = 0 + end = len(msg) + def next_word(): + cut = msg.find(' ', start) + if cut == -1: + cut = end + return cut + 1, msg[start:cut] + if msg.startswith(':'): + start = 1 + start, prefix = next_word() + else: + prefix = None + start, cmd = next_word() + if not cmd: + raise ProtocolError('message with no command') + params = [] + while start < end: + if msg[start] == ':': + params.append(msg[start+1:]) + break + start, param = next_word() + params.append(param) + return prefix, casemap_ascii(cmd), params + +is_chan = lambda n: len(n) > 0 and n[0] in CHAN_START + +def valid_chan(n): + return is_chan(n) and len(n) <= MAX_CHAN_LEN \ + and not any(c in CHAN_ILLEGAL for c in n) + +def valid_nick(n): + return 0 < len(n) <= MAX_NICK_LEN \ + and n[0] in NICK_START \ + and all(c in NICK_BODY for c in n[1:]) + +def valid_password(p): + return p is None or ( + len(p) <= max_param_len('PASS') + and not any(c in IRC_ILLEGAL for c in p)) + +sender_nick = lambda s: s.split('!', 1)[0] + +# +# Networking / main loop +# + +set_nonblock = lambda fd: fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK) + +def read_all(fd): + chunks = [] + try: + chunk = read(fd, 4096) + if not chunk: + raise EOFError + while chunk: + chunks.append(chunk) + chunk = read(fd, 4096) + except EnvironmentError, e: + if e.errno not in (EAGAIN, EWOULDBLOCK): + raise + return ''.join(chunks) + +def new_conn(network, addrs, nick, password=None): + i = rand_int(len(addrs)) + addrs = addrs[i:] + addrs[:i] + return [network, None, '', '', addrs, nick, password, False, dict(), + IRC_CASEMAP['rfc1459'], RECONN_DELAY_MIN, 0, 0, 0] + +conn_network = lambda c: c[0] +conn_sock = lambda c: c[1] +conn_rdbuf = lambda c: c[2] +conn_wrbuf = lambda c: c[3] +conn_addrs = lambda c: c[4] +conn_nick = lambda c: c[5] +conn_password = lambda c: c[6] +conn_registered = lambda c: c[7] +conn_channels = lambda c: c[8] +conn_casemap = lambda c, s: c[9](s) +conn_count = lambda c: c[11] +conn_ping_ts = lambda c: c[12] +conn_pong_ts = lambda c: c[13] + +conn_set_sock = lambda c, s: c.__setitem__(1, s) +conn_rdbuf_add = lambda c, b: c.__setitem__(2, c[2] + b) +conn_rdbuf_rm = lambda c, n: c.__setitem__(2, c[2][n:]) +conn_wrbuf_add = lambda c, b: c.__setitem__(3, c[3] + b) +conn_wrbuf_rm = lambda c, n: c.__setitem__(3, c[3][n:]) +conn_set_nick = lambda c, n: c.__setitem__(5, n) +conn_set_registered = lambda c: c.__setitem__(7, True) +conn_clr_registered = lambda c: c.__setitem__(7, False) +conn_reset_reconn_delay = lambda c: c.__setitem__(10, RECONN_DELAY_MIN) +conn_set_count = lambda c, n: c.__setitem__(11, n) +conn_set_ping_ts = lambda c, t: c.__setitem__(12, t) +conn_set_pong_ts = lambda c, t: c.__setitem__(13, t) + +def conn_reconn_delay(c): + # limited exponential backoff + d = c[10] + c[10] = min(2*d, RECONN_DELAY_MAX) + return d + +conn_nick_lc = lambda c: conn_casemap(c, conn_nick(c)) + +def conn_run_in(c, delay, method, run_if_down=False): + count = conn_count(c) + def run(): + # Drop leftover tasks from old connections + if c is network_conns.get(conn_network(c)) and conn_count(c) == count \ + and (run_if_down or conn_sock(c) is not None): + method(c) + run_in(delay, run) + +def conn_log_msg(c, venue, m): + network = conn_network(c) + if venue is None: + buf = get_buf(network, None) + else: + buf = get_buf(venue, network) + buf_log_msg(buf, m) + file_log_msg(network, venue, m) + +def conn_info(c, msg): + conn_log_msg(c, None, m_client(msg)) + +def conn_error(c, msg): + conn_log_msg(c, None, m_client('ERROR: ' + msg)) + +opening_conns = {} +network_conns = {} +def conn_start(c): + schedule_buflist_draw() + schedule_status_draw() + net = conn_network(c) + assert conn_sock(c) is None, 'socket exists when starting connection' + sock = socket() + set_nonblock(sock.fileno()) + conn_set_sock(c, sock) + conn_set_count(c, conn_count(c) + 1) + addrs = conn_addrs(c) + addrs.append(addrs.pop(0)) + conn_info(c, 'connecting to %s' % format_address(addrs[0])) + network_conns[net] = opening_conns[sock.fileno()] = c + try: + sock.connect(addrs[0]) # TODO async DNS + except SockError, e: + if e.errno != EINPROGRESS: + del opening_conns[sock.fileno()] + conn_error(c, e.strerror) + else: + conn_handle_connected(c) + +def conn_write(c, msg): + if len(msg) > MAX_MSG_LEN: + msg = msg[:MAX_MSG_LEN] + conn_error(c, 'outbound message truncated') + conn_wrbuf_add(c, msg + CRLF) + +def conn_send(c, cmd, params, prefix=None): + conn_write(c, build_msg(prefix, cmd, params)) + +open_conns = {} +def conn_handle_connected(c): + n = conn_nick(c) + p = conn_password(c) + s = conn_sock(c) + e = s.getsockopt(SOL_SOCKET, SO_ERROR) + if e == EINPROGRESS: + return + schedule_buflist_draw() + schedule_status_draw() + del opening_conns[s.fileno()] + if e != 0: + conn_error(c, strerror(e)) + conn_close(c) + return + conn_reset_reconn_delay(c) + open_conns[s.fileno()] = c + conn_info(c, 'connection established') + conn_set_ping_ts(c, mono_time()) + conn_run_in(c, PING_TIMEOUT, conn_reg_timeout) + if p is not None: + conn_send(c, 'PASS', [p]) + conn_send(c, 'NICK', [n]) + conn_send(c, 'USER', [n, '0', '*', n]) + +def conn_close(c): + sock = conn_sock(c) + if sock is None: + return + schedule_buflist_draw() + schedule_status_draw() + fd = sock.fileno() + if fd in open_conns: + del open_conns[fd] + elif fd in opening_conns: + del opening_conns[fd] + try: + sock.shutdown(SHUT_RDWR) + except SockError: + pass + sock.close() + conn_set_sock(c, None) + conn_rdbuf_rm(c, len(conn_rdbuf(c))) + conn_wrbuf_rm(c, len(conn_wrbuf(c))) + conn_clr_registered(c) + conn_info(c, 'connection closed') + if conn_network(c) in network_conns: + delay = conn_reconn_delay(c) + conn_run_in(c, delay, conn_start, True) + conn_info(c, 'reconnecting in %d seconds' % delay) + +def conn_handle_data(c, data): + conn_rdbuf_add(c, data) + data = conn_rdbuf(c) + start = 0 + while start < len(data): + end = data.find(CRLF, start) + if end == -1: + if len(data) - start >= MAX_MSG_LEN: + conn_error(c, 'received oversize message') + conn_close(c) + return + break + if end > start: + try: + conn_handle_msg(c, data[start:end]) + except ProtocolError, e: + conn_error(c, e) + start = end + 2 + conn_rdbuf_rm(c, start) + +def conn_handle_msg(c, msg): + #pylint: disable=unbalanced-tuple-unpacking,too-many-locals + prefix, cmd, params = parse_msg(msg) + + def welcome(): + if destination != conn_nick(c): # for pre-welcome nick change + schedule_status_draw() + conn_info(c, 'nick changed to %s' % destination) + conn_set_nick(c, destination) + conn_log_msg(c, None, m_server(prefix or '', ' '.join(params))) + if not conn_registered(c): + schedule_buflist_draw() + conn_set_registered(c) + pong() + for chan in conn_channels(c): + conn_join(c, chan) + conn_channels(c).clear() + + def names_reply(): + if len(params) != 3: + conn_error(c, 'RPL_NAMREPLY with bad parameter count: %s' % msg) + return + _, chan, names = params + chan_lc = conn_casemap(c, chan) + members = conn_channels(c).get(chan_lc) + conn_log_msg(c, None if members is None else chan_lc, + m_names(prefix or '', chan, names)) + if members is not None: + for nick in names.split(' '): + if not nick: + conn_error(c, 'RPL_NAMREPLY with empty nick') + break + if nick[0] in '@+': + nick = nick[1:] + members.add(conn_casemap(c, nick)) + + def end_names(): + if len(params) != 2: + conn_error(c, 'RPL_ENDOFNAMES with bad parameter count: %s' % msg) + return + chan = params[0] + chan_lc = conn_casemap(c, chan) + conn_log_msg(c, chan_lc if chan_lc in conn_channels(c) else None, + m_endnames(prefix or '', chan)) + + def error_msg(): + if len(params) != 1: + conn_error(c, 'ERROR with bad parameter count: %s' % msg) + return + conn_log_msg(c, None, m_error(prefix or '', params[0])) + + def ping(): + conn_send(c, 'PONG', params) + + def pong(): + schedule_status_draw() + conn_set_pong_ts(c, mono_time()) + conn_run_in(c, PING_INTERVAL, conn_ping) + + def privmsg(): + if len(params) != 2: + conn_error(c, 'message with bad parameter count: %s' % msg) + return + target, content = params + target_lc = conn_casemap(c, target) + if prefix is None: + conn_error(c, 'message without sender: %s' % msg) + return + sender = sender_nick(prefix) + if target_lc == conn_nick_lc(c): # PM + venue = conn_casemap(c, sender) + elif valid_chan(target_lc): + if target_lc in conn_channels(c): + venue = target_lc + else: + return # drop messages to unknown channels + elif target_lc == '*': # not sure if standard but freenode does this + venue = None + else: + conn_error(c, 'message to unknown target: %s' % msg) + return + conn_log_msg(c, venue, + (m_notice if cmd == 'notice' else m_privmsg)(sender, content)) + + def join(): + if len(params) != 1: + conn_error(c, 'JOIN with bad parameter count: %s' % msg) + return + chan, = params + chan_lc = conn_casemap(c, chan) + if prefix is None: + conn_error(c, 'JOIN without sender: %s' % msg) + return + sender_lc = conn_casemap(c, sender_nick(prefix)) + channels_dict = conn_channels(c) + if sender_lc == conn_nick_lc(c): + if chan_lc in channels_dict: + conn_error(c, 'JOIN to already joined channel %s' % chan) + return + channels_dict[chan_lc] = set() + else: + if chan_lc not in channels_dict: + conn_error(c, 'JOIN %s to unknown channel %s' % (prefix, chan)) + return + channels_dict[chan_lc].add(sender_lc) + conn_log_msg(c, chan_lc, m_join(prefix, chan)) + + def mode(): + if len(params) < 1: + conn_error(c, 'MODE with bad parameter count: %s' % msg) + return + target = params[0] + modes = ' '.join(params[1:]) + target_lc = conn_casemap(c, target) + if prefix is None: + conn_error(c, 'MODE without sender: %s' % msg) + return + if is_chan(target_lc): + if target_lc not in conn_channels(c): + conn_error(c, 'MODE to unknown channel: %s' % msg) + return + conn_log_msg(c, target_lc, m_chanmode(prefix, target, modes)) + else: + if not target_lc == prefix == conn_nick(c): + conn_error(c, 'MODE to unknown target: %s' % msg) + return + conn_log_msg(c, None, m_mode(prefix, modes)) + + def part(): + if len(params) == 1: + comment = '' + elif len(params) == 2: + comment = params[1] + else: + conn_error(c, 'PART with bad parameter count: %s' % msg) + return + parted_chans = params[0].split(',') + if prefix is None: + conn_error(c, 'PART without sender: %s' % msg) + return + sender_lc = conn_casemap(c, sender_nick(prefix)) + channels_dict = conn_channels(c) + me = (sender_lc == conn_nick_lc(c)) + for chan in parted_chans: + chan_lc = conn_casemap(c, chan) + if chan_lc not in channels_dict: + # drop PARTs from unknown channels (e.g. closed window) + continue + try: + if me: + del channels_dict[chan_lc] + conn_info(c, 'parted %s' % chan) + schedule_status_draw() + else: + channels_dict[chan_lc].remove(sender_lc) + except KeyError: + conn_error(c, 'PART non-member %s from %s' % (prefix, chan)) + continue + conn_log_msg(c, chan_lc, m_part(prefix, chan, comment)) + + def quit_msg(): + if len(params) != 1: + conn_error(c, 'QUIT with bad parameter count: %s' % msg) + return + quit_msg, = params + if prefix is None: + conn_error(c, 'QUIT without sender [%s]' % quit_msg) + return + sender_lc = conn_casemap(c, sender_nick(prefix)) + for chan_lc, members in conn_channels(c).items(): + if sender_lc in members: + conn_log_msg(c, chan_lc, m_quit(prefix, quit_msg)) + members.remove(sender_lc) + + def kick(): + if len(params) < 2 or len(params) > 3: + conn_error(c, 'KICK with bad parameter count: %s' % msg) + return + if prefix is None: + conn_error(c, 'KICK without sender: %s' % msg) + return + chan = params[0] + chan_lc = conn_casemap(c, chan) + kicked_user = params[1] + kicked_user_lc = conn_casemap(c, kicked_user) + comment = params[2] if len(params) == 3 else '' + channels_dict = conn_channels(c) + if kicked_user_lc == conn_nick_lc(c): + try: + del channels_dict[chan_lc] + except KeyError: + conn_error(c, 'KICK from unknown channel %s by %s [%s]' + % (chan, prefix, comment)) + return + schedule_status_draw() + conn_log_msg(c, chan_lc, m_kicked(prefix, chan, comment)) + else: + if chan_lc not in channels_dict: + conn_error(c, 'KICK %s from unknown channel %s by %s [%s]' + % (kicked_user, chan, prefix, comment)) + return + try: + channels_dict[chan_lc].remove(kicked_user_lc) + except KeyError: + conn_error(c, 'KICK non-member %s from %s by %s [%s]' + % (kicked_user, chan, prefix, comment)) + return + conn_log_msg(c, chan_lc, + m_kick(prefix, chan, kicked_user, comment)) + + def nick(): + if len(params) != 1: + conn_error(c, 'NICK with bad parameter count: %s' % msg) + return + new_nick, = params + if prefix is None: + conn_error(c, 'NICK without sender: %s' % msg) + return + sender = sender_nick(prefix) + new_nick_lc = conn_casemap(c, new_nick) + sender_lc = conn_casemap(c, sender) + if sender_lc == conn_nick_lc(c): + conn_info(c, 'nick changed to %s' % new_nick) + conn_set_nick(c, new_nick) + schedule_status_draw() + for chan_lc, members in conn_channels(c).items(): + if sender_lc in members: + members.remove(sender_lc) + members.add(new_nick_lc) + conn_log_msg(c, chan_lc, m_nick(sender, new_nick)) + + def topic(): + if len(params) != 2: + conn_error(c, '(RPL_(NO))TOPIC with bad parameter count: %s' % msg) + chan = params[0] + topic = None + if cmd != str(RPL_NOTOPIC): + topic = params[1] + chan_lc = conn_casemap(c, chan) + if chan_lc in conn_channels(c): + buf_set_title(get_buf(chan_lc, conn_network(c)), topic or chan_lc) + conn_log_msg(c, chan_lc, m_topic(prefix or '', topic)) + else: + conn_log_msg(c, None, m_chantopic(prefix or '', chan, topic)) + + def unknown_command(): + conn_info(c, 'unknown command from server: %s' % msg) + + def unknown_reply(): + conn_log_msg(c, None, m_server(prefix or '', ' '.join([cmd] + params))) + + if len(cmd) == 3 and all(map(is_digit, cmd)): + # Replies + if not params: + conn_error(c, 'reply %s with no destination' % cmd) + return + destination = params.pop(0) + { + RPL_WELCOME: welcome, + RPL_NAMREPLY: names_reply, + RPL_ENDOFNAMES: end_names, + RPL_TOPIC: topic, + RPL_NOTOPIC: topic, + #ERR_NICKNAMEINUSE: + #ERR_NICKCOLLISION: + #ERR_NOTREGISTERED: + }.get(int(cmd), unknown_reply)() + else: + { + 'error': error_msg, + 'ping': ping, + 'pong': pong, + 'privmsg': privmsg, + 'notice': privmsg, + 'join': join, + 'mode': mode, + 'part': part, + 'quit': quit_msg, + 'kick': kick, + 'nick': nick, + 'topic': topic, + }.get(cmd, unknown_command)() + +def conn_join(c, chan, key=None): + conn_info(c, 'joining %s' % chan) + conn_send(c, 'JOIN', [chan] if key is None else [chan, key]) + +def conn_privmsg(c, target, msg): + # There is NO SANE WAY to deduce the max privmsg length, go figure. + for line in wrap(msg, 400): + conn_log_msg(c, target, m_privmsg(conn_nick(c), line)) + conn_send(c, 'PRIVMSG', [target, line]) + +def conn_ping(c): + conn_set_ping_ts(c, mono_time()) + conn_send(c, 'PING', [conn_nick(c)]) + conn_run_in(c, PING_TIMEOUT, conn_timeout) + +def conn_timeout(c): + if mono_time() - conn_pong_ts(c) > PING_TIMEOUT: + conn_error(c, 'ping timed out') + conn_close(c) + +def conn_reg_timeout(c): + if mono_time() - max(conn_pong_ts(c), conn_ping_ts(c)) > PING_TIMEOUT: + conn_error(c, 'registration timed out') + conn_close(c) + +# Log file message formatting is simpler than UI as the message type and +# sender are always shown explicitly. +arg2 = lambda a, b: b +arg3 = lambda a, b, c: c +empty2 = lambda a, b: '' +def lterr(*_): + raise TypeError('bad log message type') + +format_log_msg = matcher(message, ( + (m_privmsg, arg2), + (m_notice, arg2), + (m_join, empty2), + (m_part, arg3), + (m_quit, arg2), + (m_nick, arg2), + (m_kick, lambda sender, chan, name, msg: name + ' ' + msg), + (m_kicked, arg3), + (m_topic, lambda sender, topic: topic or ''), + (m_chantopic, lterr), + (m_mode, lterr), + (m_chanmode, arg3), + (m_names, arg3), + (m_endnames, empty2), + (m_error, arg2), + (m_client, lterr), + (m_server, arg2))) + +def file_log_msg(network, venue, m): + if venue is None or len(venue) == 0: + return + path = ('logs', network, fs_encode(venue) + '.log') + # Possible optimization: cache log FDs and check freshness by comparing + # (dev, inode) with stat() on each write. Exceeding the max FDs rlimit + # would need to be handled, e.g. by LRU eviction using a minheap with + # global write counter. + def open_log(): + return os_open('/'.join(path), O_WRONLY | O_APPEND | O_CREAT, 0666) + try: + try: + fd = open_log() + except EnvironmentError, e: + if e.errno != ENOENT: + raise + try: + mkdir(path[0]) + except EnvironmentError, e: + if e.errno != EEXIST: + raise + mkdir('/'.join(path[:2])) + fd = open_log() + try: + write_all(fd, ''.join(( + str(int(time()*100)), + ' ', variant_name(m), + ' <', variant_args(m)[0], '> ', format_log_msg(m), LF))) + except EnvironmentError, e: + error(e) + close(fd) + except EnvironmentError, e: + error(e) + +def handle_resize(*_): + global scr_height, scr_width + scr_height, scr_width = t.screen_size() + schedule_redraw() + try: + write(self_pipe_wr, '\0') + except EnvironmentError, e: + if e.errno not in (EAGAIN, EWOULDBLOCK): + raise + +# Poor man's monotonic clock. Doesn't give real time, but at least avoids +# overly delayed events if the clock is stepped backward. +mono_last = mono_offset = 0 +def mono_time(time_=time): + global mono_last, mono_offset + now = time_() + mono_offset + if now < mono_last: + mono_offset += mono_last - now + now = mono_last + mono_last = now + return now + +schedule = [] +run_in = lambda seconds, thunk: heap_insert(schedule, mono_time() + seconds, + thunk) + +def main(): + yrc_home = getenv('HOME', '') + '/.yrc' + try: + chdir(yrc_home) + except EnvironmentError: + mkdir(yrc_home) + chdir(yrc_home) + + set_nonblock(self_pipe_rd) + set_nonblock(self_pipe_wr) + signal(SIGINT, SIG_DFL) + signal(SIGWINCH, handle_resize) + handle_resize() + + info('Welcome to yrc: the Unix IRC client') + info('Version: %s' % __version__) + 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") + info('Type /quit to exit') + prompt_enter() + + while not quit_flag(): + refresh_if_needed() + timeout = None + if schedule: + timeout = max(0, heap_peek(schedule)[0] - mono_time()) + try: + readers, writers, _ = select( + [STDIN, self_pipe_rd] + open_conns.keys(), + opening_conns.keys() + + [fd for fd, c in open_conns.items() if conn_wrbuf(c)], + (), timeout) + except SelectError, e: + if e[0] == EINTR: + continue + raise + for fd in readers: + if fd == STDIN: + try: + data = read_all(fd) + except (EOFError, EnvironmentError): + schedule_quit() + for c in data: + kbd_state(c) + elif fd == self_pipe_rd: + read_all(self_pipe_rd) + elif fd in open_conns: + c = open_conns[fd] + try: + data = read_all(fd) + except EOFError: + conn_info(c, 'connection closed by server') + conn_close(c) + except EnvironmentError, e: + conn_error(c, str(e)) + conn_close(c) + else: + conn_handle_data(c, data) + for fd in writers: + if fd in opening_conns: + conn_handle_connected(opening_conns[fd]) + elif fd in open_conns: + c = open_conns[fd] + try: + n = write(fd, conn_wrbuf(c)) + except EnvironmentError, e: + if e.errno not in (EAGAIN, EWOULDBLOCK): + conn_error(c, str(e)) + conn_close(c) + else: + conn_wrbuf_rm(c, n) + while schedule and heap_peek(schedule)[0] < mono_time(): + heap_extract(schedule)[1]() + +def crash_handler(): + cleanup = t.init(nonblock_read=True) + try: + main() + finally: + cleanup() + +check_command_dicts(( + GLOBAL_KEYS, + PROMPT_KEYS, + BUFLIST_KEYS, + BUF_KEYS, + CMD_ABBREVS +)) diff -uNr a/yrc/yterm.py b/yrc/yterm.py --- a/yrc/yterm.py false +++ b/yrc/yterm.py eda12507121074ec6d06b138abf2b654a2a3e25b879e2d00ad7ec6bb6956f49d120e7b2ec8ec0efc245a379a11a52ddf6db065fddb420b19a27b4aeddfdb52c6 @@ -0,0 +1,211 @@ +""" +yterm: a simple, standalone interface library for VT100-like terminals on UNIX. +Written by Jacob Welsh for yrc, March 2017. +Tested with Python 2.7 (should support at least 2.4). +""" + +__version__ = '9 Kelvin' + +from sys import stdout +from fcntl import ioctl +from struct import unpack +import termios as t + +NUL = chr(0) +BS = chr(8) +HT = chr(9) +LF = chr(10) +CR = chr(13) +SO = chr(14) +SI = chr(15) +ESC = chr(27) + +# VT100 http://vt100.net/docs/vt100-ug/chapter3.html +RESET = ESC + 'c' +CURSOR_SAVE = ESC + '7' +CURSOR_RESTORE = ESC + '8' +KEYPAD_APP = ESC + '=' # keypad produces escape sequences +KEYPAD_NUM = ESC + '>' # keypad produces numbers/symbols +select_g0 = lambda c: ESC + '(' + c +select_g1 = lambda c: ESC + ')' + c +SELECT_G0_ASCII = select_g0('B') +SELECT_G1_SPECIAL = select_g1('0') +CSI = ESC + '[' +cseq = lambda code, *params: ''.join((CSI, ';'.join(map(str, params)), code)) +# 1-based coordinates, from origin downward +cursor_pos = lambda row, col: cseq('H', row, col) +CURSOR_HOME = cseq('H') +cursor_up = lambda n: cseq('A', n) +CURSOR_UP1 = cseq('A') +cursor_down = lambda n: cseq('B', n) +CURSOR_DOWN1 = cseq('B') +cursor_back = lambda n: cseq('D', n) +CURSOR_BACK1 = cseq('D') +cursor_forward = lambda n: cseq('C', n) +CURSOR_FORWARD1 = cseq('C') +# Top/bottom margins of scrolling region +margins = lambda top, bot: cseq('r', top, bot) +MARGINS_NONE = cseq('r') +set_mode = lambda m: cseq('h', m) +rst_mode = lambda m: cseq('l', m) +set_priv_mode = lambda m: set_mode('?' + str(m)) +rst_priv_mode = lambda m: rst_mode('?' + str(m)) +LINEFEED_ON = set_mode(20) # LF moves to first column of next row +LINEFEED_OFF = rst_mode(20) # LF moves down only +CURSOR_APP = set_priv_mode(1) # DECCKM +CURSOR_ANSI = rst_priv_mode(1) # implied by KEYPAD_NUM +SCROLL_SMOOTH = set_priv_mode(4) # DECSCLM +SCROLL_JUMP = rst_priv_mode(4) +ORIGIN_MARGIN = set_priv_mode(6) # DECOM: row 1 at top margin, cursor confined to scrolling region +ORIGIN_SCREEN = rst_priv_mode(6) # row 1 at top of screen +ERASE_TO_END = cseq('J') +ERASE_FROM_START = cseq('J', 1) +ERASE_FULL = cseq('J', 2) +ERASE_LINE_TO_END = cseq('K') +ERASE_LINE_FROM_START = cseq('K', 1) +ERASE_LINE_FULL = cseq('K', 2) +attrs = lambda *args: cseq('m', *args) +A_NONE = 0 +A_BOLD = 1 +A_ULINE = 4 +A_BLINK = 5 +A_REVERSE = 7 +ATTR_NONE = attrs() +ATTR_BOLD = attrs(A_BOLD) +ATTR_ULINE = attrs(A_ULINE) +ATTR_BLINK = attrs(A_BLINK) +ATTR_REVERSE = attrs(A_REVERSE) + +# DEC special graphics characters http://vt100.net/docs/vt100-ug/table3-9.html +SGC_BLANK = '_' +SGC_DIAMOND = '`' +SGC_CHECKER = 'a' +SGC_HT = 'b' +SGC_FF = 'c' +SGC_CR = 'd' +SGC_LF = 'e' +SGC_DEGREE = 'f' +SGC_PLUSMINUS = 'g' +SGC_NL = 'h' +SGC_VT = 'i' # ACS_LANTERN in ncurses, unclear where that comes from +SGC_LRCORNER = 'j' +SGC_URCORNER = 'k' +SGC_ULCORNER = 'l' +SGC_LLCORNER = 'm' +SGC_CROSSING = 'n' +SGC_HLINE1 = 'o' +SGC_HLINE3 = 'p' +SGC_HLINE5 = 'q' +SGC_HLINE7 = 'r' +SGC_HLINE9 = 's' +SGC_LTEE = 't' +SGC_RTEE = 'u' +SGC_BTEE = 'v' +SGC_TTEE = 'w' +SGC_VBAR = 'x' +SGC_LEQ = 'y' +SGC_GEQ = 'z' +SGC_PI = '{' +SGC_NEQ = '|' +SGC_STERLING = '}' +SGC_CENTERDOT = '~' + +# XTerm http://invisible-island.net/xterm/ctlseqs/ctlseqs.html +ALT_SCREEN_ON = set_priv_mode(47) +ALT_SCREEN_OFF = rst_priv_mode(47) +BRACKETED_PASTE_ON = set_priv_mode(2004) +BRACKETED_PASTE_OFF = rst_priv_mode(2004) +BRACKETED_PASTE_START = cseq('~', 200) +BRACKETED_PASTE_END = cseq('~', 201) + +def init(term_file=stdout, nonblock_read=False): + """ + Initialize the terminal for full-screen graphics and raw input, returning a cleanup function to be called to restore it at exit. + + Parameters: + term_file -- file object representing the terminal, default stdout (must support write, flush, and fileno methods) + nonblock_read -- if false (default), reading from the terminal is blocking and returns at least one character (except on EOF); otherwise it is nonblocking and may return no characters. + + Details: + - Sets terminal driver attributes for raw input: no line buffering; no flow control (^S/^Q), signal (^C etc.), or EOF (^D) characters; no CR/LF translation; 8-bit character size; no echo + - Saves terminal cursor, attribute, and character set state (DECSC) + - Initializes terminal character sets: G0 to ASCII and G1 to the DEC special graphics characters, initially selecting G0 (SI selects G0; SO selects G1) + - Selects the alternate screen (to preserve XTerm scrollback) + - Initializes terminal modes: standard linefeed handling (LNM); keypad produces ASCII characters (DECKPNM); jump scrolling (DECSCLM); no scroll margins (DECSTBM); no character attributes + + Cleanup details: + - Selects G0 character set + - Disables the alternate screen + - Restores terminal cursor, attribute, and character set state (DECRC) + - Restores original terminal driver state + """ + old_attrs = t.tcgetattr(term_file.fileno()) + # "It's a bad idea to simply initialize a struct termios structure to a + # chosen set of attributes and pass it directly to tcsetattr. Your program + # may be run years from now, on systems that support members not + # documented in this manual. The way to avoid setting these members to + # unreasonable values is to avoid changing them." + # -- glibc manual + # + # Nope. It's 2017; we don't need new pheaturez in termios. If forcing them + # to zero does something "unreasonable", then blindly accepting prior state + # is worse as it could just as well do the same, but unpredictably. + iflag = oflag = lflag = 0 + cflag = t.CS8 | t.CREAD + ispeed, ospeed = old_attrs[4:6] + cc = [NUL]*t.NCCS + if nonblock_read: + cc[t.VMIN] = 0 + else: + cc[t.VMIN] = 1 + cc[t.VTIME] = 0 + t.tcsetattr(term_file.fileno(), t.TCSANOW, + [iflag, oflag, cflag, lflag, ispeed, ospeed, cc]) + term_file.write(''.join(( + CURSOR_SAVE, ALT_SCREEN_ON, + SELECT_G0_ASCII, SI, SELECT_G1_SPECIAL, + LINEFEED_OFF, KEYPAD_NUM, SCROLL_JUMP, + ATTR_NONE, MARGINS_NONE, ERASE_FULL, + ))) + term_file.flush() + def cleanup(): + term_file.write(''.join(( + SI, ALT_SCREEN_OFF, CURSOR_RESTORE, + ))) + term_file.flush() + t.tcsetattr(term_file.fileno(), t.TCSANOW, old_attrs) + return cleanup + +def screen_size(term_file=stdout): + """ + Return the screen size (rows, columns) of the given terminal as reported by the driver. + """ + rows, cols = unpack('HH', ioctl(term_file.fileno(), t.TIOCGWINSZ, ' '*4)) + return rows, cols + +def render(tree, stack=None): + """ + Render a text attribute tree to a single string with escape sequences. + + A node is either: + - a string, or + - a sequence of attribute code followed by zero or more child nodes + + An attribute code is an integer (e.g. the A_* constants). + + Example: + >>> t = (A_NONE, 'normal, ', + ... (A_REVERSE, 'reverse, ', (A_BOLD, 'bold reverse,'), + ... ' reverse again,'), + ... ' and back to normal.') + >>> print render(t) + """ + if isinstance(tree, str): + return tree + if stack is None: + stack = [A_NONE] + tag, children = tree[0], tree[1:] + stack.append(tag) + body = ''.join(render(c, stack) for c in children) + stack.pop() + return ''.join((attrs(tag), body, attrs(*stack)))