#!/usr/bin/env python # # pyurlview.py v0.1 # a more flexible replacement for mutt's "urlview" # # (c) 2001 Maciej Kalisiak # updates: http://www.dgp.toronto.edu/~mac/projects/ # # Quick start: # # This script either takes a filename or reads text in from stdin, finds all # the URLs, and presents them in a menu. Quick key help: press 'h' in the # menu screen. # # Suggestions, improvements, and bug reports welcome. # # TODO: # - do we need a tag-prefix? (i.e., `;' from mutt) if so, implement; will # probably also need a visual reminder in the status bar whether the prefix is # on or not # - warn about duplicate keybindings in rc file # - allow user-defined URL-regexp in user's rc file # - allow searches in menu ('/' by default) # - place this script under GPL # - parse command line options using Getopt # - # of context lines # - which rc file to use # - write more thorough documentation # - format of rc file # - allowed key strings # - botch for ENTER # - examples # - command line options # - reformat using Python's default 4-space indentation # - doesn't seem to handle xterm window resizes gracefully # - in show_context(), protect againts super long lines which will cause # output past the end of the screen # - other cmd's to put into the default binding # - 'm': www mirroring tool # - when showing context, show the EXACT location where a given URL was taken # from (consider the current problem with a list of URLs like # "http://a.b.c/d/e" # "http://a.b.c/d" # "http://a.b.c" # in that order) # - rewrite in C? (so that it starts faster); currently the Python version # takes a bit too long? import re import curses.wrapper import sys import os, os.path import string from string import split,find,strip,replace import time ## constants context_lines = 5 # this is the regular expression for finding URLs url_regex = r''' ( (?:http|https|ftp|gopher|mailto):(?://)?[^ <>()"\r\n\t]* | www\.[-a-z0-9.]+ ) ''' ## default config file # NOTE on bindings: # - you can write your own in ~/.pyurlviewrc # - key names: # - normal printable keys represent themselves # - ^X means CTRL-X # - !X should mean Alt/Meta-X (not tested yet) # (this is how curses.unctrl() does things) # - stuff like KEY_UP, KEY_ENTER: drop the "KEY_" prefix # - I had problems with ENTER; there's a kludge in the code below; # if ENTER doesn't work for you, use "^J" or "^M" directly def_rc = """ bind q quit bind j down bind DOWN down bind k up bind UP up bind g start bind HOME start bind G end bind END end bind H top bind M middle bind L bottom bind ENTER cmd navigator -remote 'openURL(%s)' bind ^J cmd navigator -remote 'openURL(%s)' bind ^M cmd navigator -remote 'openURL(%s)' #bind ENTER cmd /usr/bin/galeon -x -n --noraise '%s' bind w cmd wget '%s' bind 3 cmd w3m '%s' bind ^B pgup bind ^F pgdn bind SPACE pgdn bind c context bind h help bind ? help bind t tag_toggle bind T tag bind u untag bind U untag_all bind ^T tag_all """ help_str = """ Brief help screen (describing default bindings) j/k move down/up q quit g/G goto top/bottom of list c show context of URL H/M/L goto top/mid/bottom of screen ENTER bring up URL(s) in `netscape' ^F/^B page forward/back w download URL(s) with `wget' h/? this help screen t/T/u/U toggle tag/tag/untag/untag all (see script for a more complete list) You can rebind the keys as you wish. Look inside the script for the format. All bindable functions have a default binding. The args of the "cmd" function are executed in a shell after expanding "%s" to the current URL. Place your own bindings in ~/.pyurlviewrc. NOTE: this script is alpha. Works great for me, but hasn't been tested on a wide variety of platforms, configurations, or text inputs. (i.e., expect bugs in border cases) Comments, suggestions, bugs to mac@dgp.toronto.edu. """ ## global vars menu = [] bindings = {} full_text = None class MenuItem: def __init__(self, text): self.text = text self.tagged = 0 # XXX: this is a diagnostic hack; dumps the passed in text to /tmp/foo # this is useful as we can't just `print()' to screen when curses is up def dump_text(text): os.system('echo "%s" > /tmp/foo' % text) def run_cmds(stdscr, cmds): '''Runs the commands passed in `cmds' in the shell.''' # setup the screen for use by the cmd being called stdscr.clear() stdscr.refresh() curses.reset_shell_mode() for cmd in cmds: #print 'cmd =', cmd os.system(cmd) # restore environment, and wait for user OK to go back to menu curses.reset_prog_mode() stdscr.addstr('Press a key to continue.',curses.A_REVERSE) stdscr.getch() stdscr.clear() def help(stdscr): stdscr.erase() stdscr.move(0,0) stdscr.addstr(help_str) stdscr.refresh() stdscr.getch() def show_context(stdscr, url): stdscr.erase() stdscr.move(0,0) # find the URL in text and then back up a few context lines url_posn = string.find(full_text, url) posn = url_posn assert posn != -1 for i in range(context_lines): new_posn = string.rfind(full_text, '\n', 0, posn) if new_posn == -1: # hit top of text posn = 0 break else: posn = new_posn # show context stdscr.addstr(full_text[posn:url_posn]) stdscr.addstr(url, curses.A_STANDOUT) posn = url_posn + len(url) for i in range(context_lines): if posn >= len(full_text): break new_posn = string.find(full_text, '\n', posn) if new_posn == -1: new_posn = len(full_text) else: new_posn += 1 # include the newline stdscr.addstr(full_text[posn:new_posn]) posn = new_posn stdscr.refresh() # wait for key stdscr.getch() def frob_urls(stdscr): global menu # some further curses init curses.curs_set(0) # the main UI loop: user gets to select URLs curitem = 0 curtop = 0 h,w = stdscr.getmaxyx() menu_h = h-1 # last line goes to the statusline while 1: # display the menu stdscr.erase() stdscr.move(0,0) item = curtop for m in menu[curtop:curtop+menu_h]: line = item - curtop str = m.text if m.tagged: str = '* '+str else: str = ' '+str if item == curitem: stdscr.addstr(str,curses.A_REVERSE) else: stdscr.addstr(str) stdscr.addstr('\n') item = item+1 # draw status line posn_str = '' if len(menu) <= menu_h: posn_str = 'all' elif curtop == 0: posn_str = 'top' elif curtop + menu_h >= len(menu): posn_str = 'bot' else: posn_str = '%2.0f%%' % (100.0*curtop/float(len(menu))) status_str = 'URL %d/%d (%s)' % (curitem+1, len(menu), posn_str) stdscr.move(h-1, 0) stdscr.attron(curses.A_STANDOUT) stdscr.addstr(status_str) stdscr.attroff(curses.A_STANDOUT) stdscr.refresh() try: c = bindings[curses.unctrl(stdscr.getch())] except KeyError: c = '' if c == 'quit': break elif c == 'tag': menu[curitem].tagged = 1 curitem = min(curitem+1, len(menu)-1) elif c == 'untag': menu[curitem].tagged = 0 curitem = min(curitem+1, len(menu)-1) elif c == 'tag_toggle': menu[curitem].tagged ^= 1 curitem = min(curitem+1, len(menu)-1) elif c == 'untag_all': for i in range(len(menu)): menu[i].tagged = 0 elif c =='tag_all': for i in range(len(menu)): menu[i].tagged = 1 elif c == 'down': curitem = min(curitem+1, len(menu)-1) elif c == 'up': curitem = max(curitem-1, 0) elif c == 'start': curitem = 0 elif c == 'end': curitem = len(menu)-1 elif c == 'top': curitem = curtop elif c == 'middle': curitem = curtop + menu_h/2 elif c == 'bottom': curitem = curtop + menu_h -1 elif c == 'pgdn': if curtop < len(menu)-2*menu_h: curtop += menu_h else: curtop = len(menu)-menu_h if curitem < len(menu)-1-menu_h: curitem += menu_h else: curitem = len(menu)-1 elif c == 'pgup': if curtop >= menu_h: curtop = curtop - menu_h else: curtop = 0 if curitem >= menu_h: curitem = curitem - menu_h else: curitem = 0 elif c == 'context': show_context(stdscr, menu[curitem].text) elif c == 'help': help(stdscr) elif find(c,'cmd ') == 0: tagged_list = filter(lambda i: menu[i].tagged, range(len(menu))) if len(tagged_list) == 0: tagged_list.append(curitem) cmds = [] for i in range(len(tagged_list)): cmds.append(replace(strip(c[4:]),'%s',menu[tagged_list[i]].text)) run_cmds(stdscr, cmds) if curitem >= len(menu): curitem = len(menu)-1 if curitem < curtop: curtop = curitem if curitem >= curtop+menu_h: curtop = curitem - menu_h + 1 def do_config(lines): while lines: line = lines.pop(0).strip() if len(line) == 0: # empty lines continue if line[0] == '#': # skip comments continue cmd, rest = split(line, None, 1) if strip(cmd) == 'bind': bind_key(rest) else: print 'Unknown config file command "%s".\n' % cmd sys.exit(-1) def bind_key(bind_args): global bindings key, action = split(bind_args, None, 1) action = strip(action) if key == 'ENTER': # special case for Enter key, as curses module bindings['^J'] = action # gets it wrong (should use 'cr', not 'kent') elif len(key)>1 and key[0] != '^' and key[0] != '!': bindings['KEY_'+key] = action else: bindings[key] = action def main(): global full_text, menu # read in the built in configuration lines = def_rc.split('\n') do_config(lines) # read in the user config file, if it exists try: rcfile = os.path.expanduser('~/.pyurlviewrc') f = open(rcfile,'r') lines = f.readlines() f.close() do_config(lines) except IOError: # user config file does not exist; no problem pass if text_file != '-': f = open(text_file) full_text = f.read() f.close() else: full_text = sys.stdin.read() p = re.compile(url_regex, re.VERBOSE) matches = p.findall(full_text) for text in matches: # structure of a menu item: (, ) menu.append(MenuItem(text)) # reopen stdin if text_file == '-': os.close(0) sys.stdin = os.open('/dev/tty',os.O_RDONLY) # the rest is mostly stolen from curses.wrapper(); we inline that code here so # that we can toss the start_color() call, and gain greater flexibility for # any changes in the future try: # Initialize curses stdscr=curses.initscr() # Turn off echoing of keys, and enter cbreak mode, # where no buffering is performed on keyboard input curses.noecho() curses.cbreak() # In keypad mode, escape sequences for special keys # (like the cursor keys) will be interpreted and # a special value like curses.KEY_LEFT will be returned stdscr.keypad(1) # Start color, too. Harmless if the terminal doesn't have # color; user can test with has_color() later on. The try/catch # works around a minor bit of over-conscientiousness in the curses # module -- the error return from C start_color() is ignorable. # try: # curses.start_color() # except: # pass frob_urls(stdscr) except: # In the event of an error, restore the terminal # to a sane state. stdscr.keypad(0) curses.echo() curses.nocbreak() curses.endwin() # Pass the exception upwards (exc_type, exc_value, exc_traceback) = sys.exc_info() raise exc_type, exc_value, exc_traceback else: # Set everything back to normal stdscr.keypad(0) curses.echo() curses.nocbreak() curses.endwin() # Terminate curses if __name__ == '__main__': text_file = '-' if len(sys.argv) > 1: text_file = sys.argv[1] main()