#!/usr/bin/env python
#
# pyurlview.py  v0.1
# a more flexible replacement for mutt's "urlview"
# 
# (c) 2001  Maciej Kalisiak <mac@dgp.toronto.edu>
# 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: (<item text>, <is tagged?>)
    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()

