#!/usr/local/bin/python
"""$Id: Xmore.py,v 1.9 1997/11/10 12:44:33 arcege Exp $
A GUI version of the UNIX more."""

from Tkinter import *
from FileDialog import LoadFileDialog
import string, sys

version = '2.2'
author = 'Michael P. Reilly'

default_editor = 'vi'
xterm_opts = ''
GUIeditors = ('emacs', 'textedit')
noquit = 0

# constants used for seaching in more
M_SRCH_EXACT=1
M_SRCH_REGEXP=2

# "semaphore" so only one "help" dialog pops-up at a time
help_check = 0
help_maxheight = 20
help_commands = [
  'sp      Next page',
  'return  Next line',
  'b       Previous page',
  'd       Next half-page',
  'u       Previous half-page',
  '^       Beginning of file',
  '$       End of file',
  'g       Absolute goto',
  '%       Percentage goto',
  '=       Report position',
  '/       Search (forward)',
  '?       Search (backward)',
  'n       Repeat search',
  'N       Repeat search',
  '        (reverse direction)',
  'v       Edit file',
  'q       Close file',
  'r       Reload file',
  '^o      Open new file',
  '^q      Quit program',
  'h       This help'
]
help_text = [
  'Basic help text'
]

def help():
  """Create a pop-up displaying the commands."""
  global help_check, help_commands, help_text
  if help_check:
    return
  help_check = 1
  top = Toplevel()
  top.title("Xmore help")
  frame = Frame(top)
  Button(frame, text='Ok', command=top.destroy).pack(side=LEFT)
  Button(frame, text='About', command=about).pack(side=LEFT)
  frame.pack(side=BOTTOM)
  frame = Frame(top)
  h = len(help_commands)+1
  if h > help_maxheight:
    h = help_maxheight
  w = reduce(max, map(len, help_commands), 0)
  cmdpane = Text(frame, bd=2, height=h, width=w+1)
  scroll = Scrollbar(frame, command=cmdpane.yview, bd=2, relief=SUNKEN)
  cmdpane['yscrollcommand'] = scroll.set
  scroll.pack(side=RIGHT, fill=Y)
  cmdpane.pack(fill=BOTH, expand=YES)
  frame.pack(side=LEFT, fill=BOTH, expand=YES)
  for text in help_commands:
    cmdpane.insert(AtEnd(), text + '\n')
  frame = Frame(top)
  txtpane = Text(frame, bd=2, height=h, width=40)
  scroll = Scrollbar(frame, command=txtpane.yview, bd=2, relief=SUNKEN)
  txtpane['yscrollcommand'] = scroll.set
  scroll.pack(side=RIGHT, fill=Y)
  txtpane.pack(fill=BOTH, expand=YES)
  frame.pack(side=RIGHT, fill=BOTH, expand=YES)
  for text in help_text:
    txtpane.insert(AtEnd(), text + '\n')
  top.bind('<KeyPress-q>', lambda e, top=top: top.destroy())
  # do not put focus here, let it be a free standing widget
  top.wait_window()
  help_check = 0

def about():
  global author, version
  text=('Xmore v%s' % version,
        '',
        'Written by %s' % author)
  MyDialog(title='About Xmore', text=text).run()

def list_search(list, patt, way, start=0, direction=1):
  import regex, string
  lineno, last_line = start, len(list)
  found = 0
  if direction == 0:
    raise ValueError, 'direction'
  while 0 <= lineno < last_line:
    if way == M_SRCH_EXACT:
      result = string.find(list[lineno], patt)
      length = len(patt)
    elif way == M_SRCH_REGEXP:
      re = regex.compile("\(" + patt + "\)")
      result = re.search(list[lineno])
      if re.regs is not None:
        length = re.regs[0][1] - re.regs[0][0]
    if result != -1:
      found = 1
      break
    lineno = lineno + direction
  if found:
    return lineno, result, length
  else:
    # more doesn't do file-wrap-around
    raise IndexError, 'end-of-file'

def cntlchar(ch):
  return chr(ord(ch) & 027)

def quit_app(event):
  """Either quit the application or the file window, depending on which
window the event comes from."""
  # control-q
  if event.char == cntlchar('q'):
    event.widget.quit()
  # else 'q'

  global noquit
  if noquit:
    return
  widget = event.widget
  # find the "toplevel" widget
  while widget:
    if widget.__class__ in (More, Config):
      break
    widget = widget.master

  if widget:
    if widget.__class__ is More:
      widget.destroy()
    else:
      widget.quit()

def is_GUIeditor(editor):
  import os
  global GUIeditors
  dummy, filename = os.path.split(editor)
  if editor in GUIeditors:
    return 1

class MyDialog:
  def __init__(self, master=None, cnf={}, **kw):
    from Tkinter import _cnfmerge
    cnf = _cnfmerge((cnf, kw))
    self.top = Toplevel(master)
    if master:
      self.master = master
    else:
      self.master = self.top.master
    if cnf.has_key('value'):
      self.value = cnf['value']
      del cnf['value']
    else:
      self.value = None
    if cnf.has_key('title'):
      self.top.title(cnf['title'])
      del cnf['title']
    elif hasattr(self, 'title'):
      self.top.title(self.title)
    else:
      self.top.title("message")
    self.to_focus = self.top    # unless overridden by a subwidget
    self.focus(self.master)
    self.widgets(cnf)

  def destroy(self, event=None):
    self.top.destroy()

  def widgets(self, cnf={}):
    b = Button(self.top, text='Ok', command=self.destroy)
    b.pack(side=BOTTOM,expand=YES)
    self.to_focus = b
    msgs = cnf['text']
    if type(msgs) == type(''):
      msgs = (msgs,)
    for item in msgs:
      Message(self.top, text=item, aspect=1300).pack(side=TOP, fill=BOTH)

  def run(self):
    global noquit
    self.to_focus.focus()
    self.top.grab_set()
    noquit = 1
    self.top.wait_window()
    self.focus()
    noquit = 0

  def focus(self, widget=None):
    if widget is None:
      if hasattr(self, 'last_focus'):
        self.last_focus.focus()
      else:
        self.master.focus()
    else:
      self.last_focus = widget.focus_get()

class Search(MyDialog):
  """Create a search dialog. The run method returns the search string and
either M_SRCH_EXACT or M_SRCH_REGEXP."""
  title = 'Search for...'

  def widgets(self, cnf):
    self.patt_v, self.choice_v = StringVar(), IntVar()
    self.patt_v.set(cnf['patt'])
    self.choice_v.set(cnf['choice'])
    self.to_focus = entry = Entry(self.top, textvariable=self.patt_v,
				  relief=SUNKEN, bd=2)
    entry.pack(side=TOP, fill=X, expand=YES)
    choice_f = Frame(self.top)
    choice_f.pack(side=TOP, fill=X, expand=YES)
    Radiobutton(choice_f, text='Exact', value=M_SRCH_EXACT,
		variable=self.choice_v).pack(side=LEFT)
    Radiobutton(choice_f, text='Reg Exp', value=M_SRCH_REGEXP,
		variable=self.choice_v).pack(side=LEFT)
    Button(self.top, text='Ok', command=self.destroy).pack(side=BOTTOM)
    self.top.bind("<Return>", self.destroy)

  def run(self):
    MyDialog.run(self)
    e1 = self.patt_v.get()
    c2 = self.choice_v.get()
    return (e1, c2)

class NumEntry(MyDialog):
  title = 'Number Entry'

  def widgets(self, cnf):
    self.var = StringVar()
    if self.value:
      self.var.set(self.value)
    text = cnf['text']
    Label(self.top, text=text).pack(anchor=NW)
    self.to_focus = entry = Entry(self.top, textvariable=self.var,
				  relief=SUNKEN)
    entry.pack(side=TOP, fill=X)
    self.top.bind("<Return>", self.destroy)

  def run(self):
    MyDialog.run(self)
    var = self.var.get()
    for ch in var:
      if ch not in string.digits + '-+':
	raise ValueError
    try:
      if var:
        rc = eval(var)
        return rc
    except SyntaxError:
      raise ValueError

class NotFound(MyDialog):
  title = 'Not Found'

  def widgets(self, cnf):
    self.top.config(relief=RIDGE)
    self.var = IntVar(self.top)
    self.var.set(0)

    msgcnf = {
      'justify': LEFT, 'aspect': 1300,
      Pack: { 'pady': 2, 'side': TOP, 'fill': BOTH, 'expand': YES, }
    }
    Message(self.top, msgcnf, text="Search string not found")
    if cnf.has_key('text'):
      Message(self.top, msgcnf, text=cnf['text'])

    f = Frame(self.top)
    f.pack(side=BOTTOM, fill=X, expand=YES)
    Label(f, text="Continue?").pack(side=LEFT,expand=YES)
    b= Button(f, text="Yes", command=self.yes, relief=FLAT)
    b.pack(side=LEFT,expand=YES)
    b= Button(f, text="No", command=self.no, relief=RIDGE)
    b.pack(side=LEFT,expand=YES)
    self.to_focus = b
    self.top.bind("<KeyPress-y>", self.yes)
    self.top.bind("<KeyPress-n>", self.no)
    self.top.bind("<Return>", self.no)

  def yes(self, event=None):
    self.var.set(1)
    self.destroy()
  def no(self, event=None):
    self.var.set(0)
    self.destroy()

  def run(self):
    MyDialog.run(self)
    return self.var.get()

class More(Toplevel):
  pagesize = 24

  def __init__(self, file, cnf={}):
    Toplevel.__init__(self, cnf)
    self.widgets()
    # a pathname
    if type(file) == type(''):
      self.filename = file
      self.fp = None
    # a file object (or like class instance)
    else:
      self.filename = '<stdin>'
      self.fp = file
    self.title(self.filename)
    self.curline = 0
    self.load_file()
    self.draw_pane()
    self.bindings()
    self.srchpatt = ""
    self.srchway = M_SRCH_REGEXP
    self.last_direction = 1
    self.halfpage = self.pagesize / 24
    self.clrnum()

  def widgets(self):
    global c  # the appl widget
    menubar = Frame(self, relief=RAISED, bd=2)
    menubar.pack(side=TOP, fill=X)

    # "File" menu
    mb = Menubutton(menubar, text='File', underline=0)
    mb.pack(side=LEFT)
    menu = mb['menu'] = Menu(mb)
    menu.add_command(label='Clone', command=self.clone, accelerator='^c')
    menu.add_command(label='Open', accelerator='^o',
		     command=lambda master=c: Xopen(master))
    menu.add_command(label='Edit', command=self.edit, accelerator='v')
    menu.add_command(label='Reload', command=self.reload, accelerator='r')
    menu.add_command(label='Close', command=self.destroy, accelerator='q')
    menu.add_command(label='Quit', accelerator='^q',
		     command=lambda master=c: master.quit())
    self.bind('<Alt-KeyPress-f>', menu.invoke)
    # "Search" menu
    mb = Menubutton(menubar, text='Search', underline=0)
    mb.pack(side=LEFT)
    menu = mb['menu'] = Menu(mb)
    menu.add_command(label='Forward', command=self.forward_srchask,
		     accelerator='/')
    menu.add_command(label='Backward', command=self.backward_srchask,
		     accelerator='?')
    menu.add_separator()
    menu.add_command(label='Next', command=self.nextsrch, accelerator='n')
    menu.add_command(label='Previous', command=self.prevsrch, accelerator='N')
    self.bind('<Alt-KeyPress-s>', menu.invoke)
    # "View" menu
    mb = Menubutton(menubar, text='View', underline=0)
    mb.pack(side=LEFT)
    menu = mb['menu'] = Menu(mb)
    menu.add_command(label='Next Page', command=self.nextpage,
		     accelerator='Space')
    menu.add_command(label='Previous Page', command=self.prevpage,
		     accelerator='b')
    menu.add_command(label='Next Halfpage', command=self.nexthalf,
		     accelerator='d')
    menu.add_command(label='Previous Halfpage', command=self.prevhalf,
    		     accelerator='u')
    menu.add_separator()
    menu.add_command(label='Top', command=self.toppage,
    		     accelerator='^')
    menu.add_command(label='Bottom', command=self.botpage,
    		     accelerator='$')
    menu.add_command(label='Goto', command=self.goto,
    		     accelerator='g')
    menu.add_command(label='Percentage', command=self.percent,
		     accelerator='%')
    menu.add_command(label='Where', command=self.where,
    		     accelerator='=')
    menu.add_command(label='Count', command=self.count,
    		     accelerator='#')
    self.bind('<Alt-KeyPress-v>', menu.invoke)
    # "Help" menu
    mb = Menubutton(menubar, text='Help', underline=0)
    mb.pack(side=RIGHT)
    menu = mb['menu'] = Menu(mb)
    menu.add_command(label='About', command=about)
    menu.add_command(label='Help', command=self.help, accelerator='h')
    self.bind('<Alt-KeyPress-h>', menu.invoke)

    display = Frame(self)
    display.pack(side=BOTTOM, fill=BOTH, expand=YES)
    self.pane = Text(display, relief=SUNKEN, height=24, width=80)
    scroll = Scrollbar(display, relief=SUNKEN, command=self.pane.yview)
    self.pane['yscrollcommand'] = scroll.set
    scroll.pack(side=RIGHT, fill=Y)
    self.pane.pack(fill=BOTH, expand=YES)
    self.pane.tag_config("search", background="Seagreen2")

  def load_file(self):
    if self.fp:
      file = self.fp
    else:
      file = open(self.filename, "r")
    self.lines = file.readlines()
    if not self.fp:
      file.close()

  def draw_pane(self):
    for line in self.lines:
      self.pane.insert(AtEnd(), line)
    self.moveto(0)

  def reload(self):
    if not self.fp:
      self.load_file()
    self.pane.delete("1.0", AtEnd())
    self.draw_pane()

  def clone(self):
    if self.fp:
      from StringIO import StringIO
      f = StringIO()
      f.writelines(self.lines)
      f.seek(0)
      More(f)
    else:
      More(self.filename)

  def bindings(self):
    self.pane.unbind_class("Text", "<Return>")
    self.pane.unbind_class("Text", "<Any-KeyPress>")
    self.pane.unbind_class("Text", "<Any-KeyRelease>")
    #  ^-- doesn't quite work - workaround?
    self.pane.bind("<KeyPress>", self.evhdlr)

  def evhdlr(self, event):
    key = event.keysym
    if key == '':
      pass
    # when modifiers act on the key
    elif len(key) == 1 and key != event.char:
      key = event.char
    if self.keysym_handlers.has_key(key):
      rc = apply(self.keysym_handlers[key], (self,))
    elif key in ('minus', 'plus'):
      self.addnum(event.char)
    elif len(key) > 1:
      pass
    elif key in string.digits:
      self.addnum(key)

  # keep a user-entered count for certain operations
  def clrnum(self):
    self.op_count = ''
  def setnum(self, num):
    self.op_count = str(num)
  def addnum(self, digit):
    self.op_count = self.op_count + digit
  def delnum(self):
    self.op_count = self.op_count[:-1]
    return 1
  def evalnum(self):
    if self.op_count:
      return eval(self.op_count)
  def count(self):
    num = NumEntry(self, text='How many times').run()
    if num:
      self.setnum(num)
    else:
      self.clrnum()

  def reset_insert(self):
    self.pane.mark_set(AtInsert(), "%d.0" % self.curline)
    self.pane.yview(AtInsert())
  def moveto(self, pos, whence=0):
    if whence == 0 and pos < 0:
      whence = -1
      pos = -pos
    # absolute
    if whence == 0:
      self.curline = pos
    # from end
    elif whence == -1:
      self.curline = len(self.lines) - pos
    # relative
    elif whence == 1:
      self.curline = self.curline + pos
    # percentage
    elif whence == 2:
      if pos > 100:
	pos = 100
      lineno = int(len(self.lines) * (pos / 100.0))
      self.moveto(lineno)  # recurse
      return
    else:
      return
    self.reset_insert()

  def goto(self, pos=None):
    if pos is None:
      pos = self.evalnum()
      if pos is None:
        pos = NumEntry(self, text="Line Number").run()
      else:
	self.clrnum()
    if pos is not None:
      self.moveto(pos)
  def percent(self, pos=None):
    if pos is None:
      pos = self.evalnum()
      if pos is None:
	pos = NumEntry(self, text="Percentage of file").run()
      else:
	self.clrnum()
    if pos is not None:
      self.moveto(pos, 2)

  def nextline(self):
    self.moveto(1, 1)
  def toppage(self):
    self.moveto(0)
  def botpage(self):
    self.moveto(0, -1)
  def nextpage(self):
    size = self.evalnum()
    if size is None:
      size = self.pagesize
    self.moveto(+size, 1)
    self.clrnum()
  def prevpage(self):
    size = self.evalnum()
    if size is None:
      size = self.pagesize
    self.moveto(-size, 1)
    self.clrnum()
  def nexthalf(self):
    size = self.evalnum()
    if size is None:
      size = self.halfpage
    else:
      self.halfpage = size
    self.moveto(+size, 1)
    self.clrnum()
  def prevhalf(self):
    size = self.evalnum()
    if size is None:
      size = self.halfpage
    else:
      self.halfpage = size
    self.moveto(-size, 1)
    self.clrnum()
  def where(self):
    lineno = self.curline
    p = int(lineno / float(len(self.lines)) * 100)
    MyDialog(self, text=("Filename: %s" % self.filename,
			 "Current line number: %d" % lineno,
			 "Percentage of file: %d%%" % p),
	     title='Line #')

  def search(self, patt, way, direction):
    try:
      lineno, column, length = list_search(self.lines, patt, way,
					   self.curline+direction,
					   direction)
      # remove the "search" tag
      ranges = self.pane.tag_ranges("search")
      if ranges:
        # why will there be only one search tag?
        self.pane.tag_remove("search", ranges[0], ranges[1])
      if lineno >= 2:
	pos = lineno - 1
      else:
	pos = 0
      self.moveto(pos)
      self.pane.tag_add("search", "%d.%d" % (lineno+1, column),
			"%d.%d" % (lineno+1, column + length))
      # needs to be set here because of position offset in moveto
      self.curline = lineno
    except IndexError, error:
      if NotFound(self).run():
	if direction == 1:
	  self.moveto(0, -1)
	else:
	  self.moveto(0)
	self.search(patt, way, direction)

  def forward_srchask(self):
    patt, way = Search(self, patt=self.srchpatt, choice=self.srchway).run()
    self.last_direction = 1
    count = self.evalnum() or 1
    for i in range(count):
      self.search(patt, way, self.last_direction)
    self.srchpatt, self.srchway = patt, way
    self.clrnum()
  def backward_srchask(self):
    patt, way = Search(self, patt=self.srchpatt, choice=self.srchway).run()
    self.last_direction = -1
    count = self.evalnum() or 1
    for i in range(count):
      self.search(patt, way, self.last_direction)
    self.srchpatt, self.srchway = patt, way
    self.clrnum()
  def nextsrch(self):
    if self.srchpatt:
      count = self.evalnum() or 1
      for i in range(count):
        self.search(self.srchpatt, self.srchway, self.last_direction)
    self.clrnum()
  def prevsrch(self):
    if self.srchpatt:
      count = self.evalnum() or 1
      for i in range(count):
        self.search(self.srchpatt, self.srchway, -self.last_direction)
    self.clrnum()

  def edit(self):
    import os
    global xterm_opts, default_editor
    if self.fp:
      MyDialog(self, title='Error', text="Unable to edit standard input").run()
      return
    lineno, file = self.curline, self.filename
    if os.environ.has_key('VISUAL'):
      editor = os.environ['VISUAL']
    elif os.environ.has_key('EDITOR'):
      editor = os.environ['EDITOR']
    else:
      editor = default_editor
    cmd = "%(editor)s +%(lineno)d %(file)s" % locals()
    if is_GUIeditor(editor):
      os.system(cmd)
    else:
      os.system("xterm%s -e %s" % (xterm_opts, cmd))
    self.reload()

  def help(self):
    help()

  # this must be at the end of the class definition
  # the datum of each should be an unbound method in the class
  keysym_handlers = {
    'slash':       forward_srchask,
    'question':    backward_srchask,
    'n':           nextsrch,
    'N':           prevsrch,
    'asciicircum': toppage,
    'dollar':      botpage,
    'Return':      nextline,
    'space':       nextpage,
    'd':           nexthalf,
    'u':           prevhalf,
    'b':           prevpage,
    'r':           reload,
    'v':           edit,
    'numbersign':  count,
    'BackSpace':   delnum,
    'equal':       where,
    'g':           goto,
    'percent':     percent,
    'h':           help,
    cntlchar('c'): clone,
  }

def Xopen(master=None):
  dialog = LoadFileDialog(master)
  file = dialog.go(key="Xmore")
  if file:
    try:
      More(file)
    except IOError:
      MyDialog(text="File Not Found", title="File Error").run()

def Config(master=None, cnf={}):
  top = Frame(master, cnf)
  top.master.title("Xmore")
  open_f = lambda e=None, t=top: Xopen(t)
  Button(top, text="Open",
	 command=open_f, relief=RAISED).pack(side=LEFT)
  Button(top, text="Help", command=help, relief=RAISED).pack(side=LEFT)
  Button(top, text="Quit", command=top.quit, relief=RAISED).pack(side=LEFT)
  top.bind_all("<Control-KeyPress-o>", open_f)
  top.bind_all("<KeyPress-q>", quit_app)
  top.bind_all("<Control-KeyPress-q>", quit_app)
  top.pack()
  top.tk_focusFollowsMouse()
  return top

if __name__ == '__main__':
  c = Config()
  for file in sys.argv[1:]:
    try:
      if file == '-':
        More(sys.stdin)
      else:
        More(file)
    except IOError:
      sys.stderr.write("File not found: %s\n"% file)
      raise SystemExit, 1
  c.mainloop()
