Newer
Older
twander / twander.py
#!/usr/bin/env python
# twander - Wander around the file system
# Copyright (c) 2002 TundraWare Inc.  All Rights Reserved.


PROGNAME = "twander"
RCSID = "$Id: twander.py,v 1.59 2002/11/17 09:06:50 tundra Exp $"
VERSION = RCSID.split()[2]


#----------------------------------------------------------#
#                     Imports                              #
#----------------------------------------------------------#

from Tkinter import *
from tkMessageBox import showerror, showinfo, showwarning
from tkSimpleDialog import askstring
import getopt
import os
import sys

#----------------------------------------------------------#
#          Variables User Might Change                     #
#----------------------------------------------------------#

#####
# Defaults
#####

#####
# Key Assignments
#####

# General Program Commands

KEYPRESS      = '<KeyPress>'           # Any keypress (for commands)
QUITPROG      = '<Escape>'             # Quit the program
READCONF      = '<Control-r>'          # Re-read the configuration file


# Directory Navigation

CHANGEDIR     = '<Control-x>'          # Enter a new path
DIRHOME       = '<Control-h>'          # Goto $HOME
DIRPREV       = '<Control-b>'          # Goto previous directory
DIRSTART      = '<Control-s>'          # Goto starting directory
DIRUP         = '<BackSpace>'          # Go up one directory level

# Selection Keys

SELNEXT       = '<Control-n>'          # Select next item
SELPREV       = '<Control-p>'          # Select previous item
SELFIRST      = '<Control-f>'          # Select top item
SELLAST       = '<Control-l>'          # Select bottom item
SELKEY        = '<space>'              # Select item w/keyboard
SELMOUSE      = '<ButtonRelease-1>'    # Select item w/mouse


#####
# Default Directories
#####

# Default startup directory
STARTDIR = "." + os.sep

# Default directory for config file
# Use $HOME if available, otherwise use starting directory

HOME = os.getenv("HOME") or STARTDIR

# Configuration file
CONF   = os.path.join(HOME, "." + PROGNAME)


#####
# Initial Dimensions
#####

HEIGHT = 25
WIDTH  = 60


#####
# Colors
#####

BCOLOR  = "black"
FCOLOR  = "green"


#####
# Fonts
#####

FNAME = "Courier"
FSZ   = 12
FWT   = "bold"


#####
# Enable Warnings
#####

WARN = TRUE


#------------------- Nothing Below Here Should Need Changing ------------------#


#----------------------------------------------------------#
#              Constants & Literals                        #
#----------------------------------------------------------#



#####
# Booleans
#####

# Don't need to define TRUE & FALSE - they are defined in the Tkinter module

#####
# Constants
#####


#####
# General Literals
#####

DIR_LDELIM = '['              # Directory left dsply. delimiter
DIR_RDELIM = ']'              # Directory left dsply. delimiter
PSEP       = os.sep           # Character separating path components


#####
# Configuration File Related Literals
#####

CMDKEY     = r'&'             # Command key delimiter
COMMENT    = r"#"             # Comment character
DIRNAME    = r'[DIRECTORY]'   # Substitution field in config files
FILENAME   = r'[FILE]'        # Substitution field in config files
ENVIRO     = r'$'             # Introduces environment variables


#----------------------------------------------------------#
#            Prompts, & Application Strings                #
#----------------------------------------------------------#


#####
# Error, Information, & Warning  Messages
#####

# Errors

eBADROOT = " %s Is Not A Directory"
eDIRRD   = "Cannot Open Directory : %s  ---  Check Permissions."
eDUPKEY  = "Duplicate Key In Configuration File Found In Entry: \'%s\'"
eERROR   = "ERROR"
eNOCONF  = "Cannot Find Configuration File: %s"
eNOENV   = "Configuration File References Undefined Environment Variable: %s"
eOPEN    = "Cannot Open File: %s"
eTOOMANY = "You Can Only Specify One Starting Directory."

# Informational

iINFO    = "INFORMATION"
iVERINFO = "Program Version Information: %s"

# Prompts

pCHPATH   = "Change Path"
pENPATH   = "Enter New Path Desired:"

wCMDKEY  = "Configuration File Entry For: \'%s\' Has No Command Key Defined."
wWARN    = "WARNING"


#####
# Usage Prompts
#####

uTable = [PROGNAME + " " + VERSION + " - Copyright 2002, TundraWare Inc., All Rights Reserved\n",
          "usage:  " + PROGNAME + " [-bcfhnsvwxy] [startdir] where,\n",
          "          startdir  name of directory in which to begin (default: current dir)",
          "          -b color  background color (default: black)",
          "          -c file   name of configuration file (default: " + CONF + ")",
          "          -f color  foreground color (default: green)",
          "          -h        print this help information",
          "          -n name   name of font to use (default: courier)",
          "          -q        quiet mode - no warnings (default: warnings on)",
          "          -s size   size of font to use (default: 12)",
          "          -v        print detailed version information",
          "          -w wght   weight/style of font to use (default: bold)",
          "          -x width  window width (default: 60)",
          "          -y height window height (default: 25)",
          ]


#---------------------------Code Begins Here----------------------------------#


#----------------------------------------------------------#
#             General Support Functions                    #
#----------------------------------------------------------#


#####
# Print An Error Message
#####

def ErrMsg(emsg):

    showerror(PROGNAME + " " + VERSION + "    " + eERROR, emsg)

# End of 'ErrMsg()'


#####
# Print An Information Message
#####

def InfoMsg(emsg):

    showinfo(PROGNAME + " " + VERSION + "    " + iINFO, emsg)

# End of 'ErrMsg()'


#####
# Print A Warning Message
#####

def WrnMsg(wmsg):
    if WARN:
        showwarning(PROGNAME + " " + VERSION + "    " + wWARN, wmsg)

# End of 'WrnMsg()'


#####
# Parse & Process The Configuraton File
# This is called once at program start time
# and again any time someone hits the READCONF key
# while the program is running.
#####

def ParseConfFile(*args):
    global UI
    
    try:
        cf = open(CONF)
    except:
        ErrMsg(eOPEN % CONF)
        sys.exit(1)

    # Cleanout any old 
    UI.rcfile = {}

    # Process and massage the configuration file
    for line in cf.read().splitlines():

        # Lex for comment token and discard until EOL
        # A line beginning with the comment token is thus
        # turned into a blank line, which is discarded.

        idx = line.find(COMMENT)
        if idx > -1:             # found a comment character
            line = line[:idx]

        # Anything which gets through the next conditional
        # must be a non-blank line - i.e., Configuration information
        # we care about, so process it and save for future use.

        if line != "":
            fields = line.split()
            for x in range(1,len(fields)):

                # Process environment variables
                if fields[x][0] == ENVIRO:
                    envval = os.getenv(fields[x][1:])
                    if not envval:       # Environment variable not defined
                        ErrMsg(eNOENV % fields[x])
                        sys.exit(1)
                    else:
                        fields[x] = envval

            # Get command key value and store in dictionary

            keypos = fields[0].find(CMDKEY) # Look for key delimiter

            # No delimiter or delimiter at end of string
            if (keypos < 0) or (CMDKEY == fields[0][-1]):
                WrnMsg(wCMDKEY % fields[0])
                key = fields[0]             # Use whole word as index

            # Found legit delimiter, so use it as
            # dictionary index - mapped to lower case so
            # command keys are case insensitive.
            else:
                key = fields[0][keypos+1].lower()

            if key in UI.rcfile:               # This is a Python 2.2 or later idiom
                ErrMsg(eDUPKEY % fields[0]) # Duplicate key found
                cf.close()
                sys.exit(1)
                
            # Save command name and command using key as index
            UI.rcfile[key] = ["".join(fields[0].split(CMDKEY)),
                           " ".join(fields[1:])
                          ]

    cf.close()

# End of 'ParseConfFile()'


#####
# Print Usage Information
#####

def Usage():
    ustring =""
    
    for x in uTable:
        ustring += x + "\n"

    # Build and display in a window

    root = Tk()
    root.title(PROGNAME + " " + VERSION)
    label = Label(root,
                  justify=LEFT,
                  font=(FNAME, FSZ, FWT),
                  text=ustring)
    label.pack()
    root.mainloop()
        
# End of 'Usage()'


#----------------------------------------------------------#
#                    GUI Definition                        #
#----------------------------------------------------------#



#####
# Enacapsulate the UI in a class
#####


class twanderUI:

    def __init__(self, root):

        # Setup the visual elements

        self.hSB = Scrollbar(root, orient=HORIZONTAL)
        self.vSB = Scrollbar(root, orient=VERTICAL)
        self.DirList = Listbox(root,
                               foreground = FCOLOR,
                               background  = BCOLOR,
                               font=(FNAME, FSZ, FWT),
                               selectmode=BROWSE,
                               exportselection=0,
                               xscrollcommand=self.hSB.set,
                               yscrollcommand=self.vSB.set,
                               height = HEIGHT,
                               width = WIDTH,
                               )

        # Make them visible by packing
        
        self.hSB.config(command=self.DirList.xview)
        self.hSB.pack(side=BOTTOM, fill=X)
        self.vSB.config(command=self.DirList.yview)
        self.vSB.pack(side=RIGHT, fill=Y)
        self.DirList.pack(side=LEFT, fill=BOTH, expand=1)

        #####
        # Bind the relevant event handlers
        #####
        
        # General Program Commands

        # Bind handler for individual keystrokes
        self.DirList.bind(KEYPRESS, KeystrokeHandler)

        # Bind handler for "Quit Program"
        self.DirList.bind(QUITPROG, KeyQuitProg)

        # Bind handler of "Read Config File"
        self.DirList.bind(READCONF, ParseConfFile)


        # Directory Navigation

        # Bind handler for "Change Directory"
        self.DirList.bind(CHANGEDIR, ChangeDir)

        # Bind handler for "Home Dir"
        self.DirList.bind(DIRHOME, KeyHomeDir)

        # Bind handler for "Previous Dir"
        self.DirList.bind(DIRPREV, KeyPrevDir)

        # Bind handler for "Starting Dir"
        self.DirList.bind(DIRSTART, KeyStartDir)

        # Bind handler for "Up Dir"
        self.DirList.bind(DIRUP, KeyUpDir)


        # Selection Keys

        # Bind handler for "Next Item"
        self.DirList.bind(SELNEXT, KeySelNext)

        # Bind handler for "Previous Item"
        self.DirList.bind(SELPREV, KeySelPrev)

        # Bind handler for "First Item"
        self.DirList.bind(SELFIRST, KeySelFirst)

        # Bind handler for "Last Item"
        self.DirList.bind(SELLAST, KeySelLast)

        # Bind handler for "Item Select"
        self.DirList.bind(SELKEY, DirListHandler)

        # Bind handler for "Mouse Select"
        self.DirList.bind(SELMOUSE, DirListHandler)

        # Give the listbox focus so it gets keystrokes
        self.DirList.focus()

        # End if method 'twanderUI.__init__()'


    #####
    # Support periodic polling to make sure widget stays
    # in sync with reality of current directory.
    #####

    def poll(self):

        RefreshDirList()
        self.DirList.after(250, self.poll)

    # End of method 'twanderUI.poll()'


    #####
    # Return name of currently selected item
    #####

    def CurrentSelection(self):
        index = self.DirList.curselection()
        if index:
            return self.DirList.get(index[-1])
        else:
            return ""

    # End of method 'twanderUI.CurrentSelection()'


    #####
    # Update title bar with most current information
    #####

    def UpdateTitle(self, mainwin):

        # Get current selection
        selected = self.DirList.get(ACTIVE)

        # If there was a file selection append to title
        if selected \
                 and selected[0] == DIR_LDELIM \
                 and selected[-1] == DIR_RDELIM:

            selected = ""

        # Update the titlebar
        mainwin.title(PROGNAME + " " + VERSION + "       " + UI.CurrentDir + selected)

    # End of method 'twanderUI.UpdateTitle()'

# End of class definition, 'twanderUI'


#----------------------------------------------------------#
#                   Handler Functions                      #
#----------------------------------------------------------#


#--------------- General Program Commands -----------------#


#####
# Event Handler: Individual Keystrokes
#####

def KeystrokeHandler(event):

    # If the key pressed is a command key,
    # get its associated string and
    # execute the command.

    cmd = UI.rcfile.get(event.char.lower(), ["",""])[1]

    # cmd == null means no matching command key -  do nothing
    # Otherwise, replace config tokens with actual file/dir names
    if cmd:

        # Replace runtime-determined tokens
        cmd = cmd.replace(FILENAME, UI.CurrentSelection())
        cmd = cmd.replace(DIRNAME, UI.CurrentDir)

        # Actually execute the command
        os.system(cmd)
    
# end of 'KeystrokeHandler()'
    

#####
# Event Handler: Program Quit
#####

def KeyQuitProg(event):
    sys.exit()

# End of 'KeyQuitProg()'


#------------------- Directory Navigation -----------------#


#####
# Event Handler: Change Directory/Path
####

def ChangeDir(event):

    newpath = askstring(pCHPATH, pENPATH)
    if newpath:
        LoadDirList(newpath)
    UI.DirList.focus()

# End of 'ChangeDir()'


#####
# Event Handler: Goto $HOME
#####

def KeyHomeDir(event):

    LoadDirList(HOME)

# End of 'KeyHomeDir()'


#####
# Event Handler: Move To Previous Directory
#####

def KeyPrevDir(event):

    # Move to last directory visited, if any - inhibit this from
    # being placed on the directory traversal stack
    if UI.LastDir:
        LoadDirList(UI.LastDir.pop(), save=FALSE)

    # No previous directory
    else:
        pass

# End of 'KeyPrevDir()'


#####
# Event Handler: Go Back to Initial Directory
#####

def KeyStartDir(event):
    global STARTDIR
    
    LoadDirList(STARTDIR)

# End of 'KeyStartDir()'


#####
# Event Handler: Move up one directory
#####

def KeyUpDir(event):

    # Move up one directory level unless we're already at the root
    if UI.CurrentDir != os.path.abspath(PSEP):
        LoadDirList(UI.CurrentDir + "..")

# End of 'KeyUpDir()'


#---------------------- Selection Keys ---------------------#


#####
# Event Handler: Select Next Item
#####

def KeySelNext(event):
    print "KeySelNext()"

# End of 'KeySelNext()'


#####
# Event Handler: Select Previous Item
#####

def KeySelPrev(event):
    print "KeySelPrev()"

# End of 'KeySelPrev()'


#####
# Event Handler: Select First Item
#####

def KeySelFirst(event):
    print "KeySelFirst()"

# End of 'KeySelFirst()'


#####
# Event Handler: Select Last Item
#####

def KeySelLast(event):
    print "KeySelLast()"

# End of 'KeySelLast()'


#####
# Event Hander: Process Current Selection
#####

def DirListHandler(event):
    SAVE = TRUE

    # Get current selection.  If none, just return, otherwise process
    selected =  UI.CurrentSelection()
    if not selected:
        return
    
    # If selection is a directory, move there and list contents.
    # We examine this by checking the string for the directory
    # delimiter characters previously inserted in BuildDirList()
    
    if selected[0] == DIR_LDELIM and selected[-1] == DIR_RDELIM:

        # Strip off delimiters to get real name
        selected = selected[1:-1]

        # We don't push this selection on the stack if
        # we are at root directory and user presses '..'

        if (selected == '..') and (UI.CurrentDir == os.path.abspath(PSEP)):
            SAVE = FALSE

        # Build full path name
        selected = os.path.join(os.path.abspath(UI.CurrentDir), selected)

        # Convert ending ".." into canonical path
        if selected.endswith(".."):
            selected = PSEP.join(selected.split(PSEP)[:-2])
        
        # Need to end the directory string with a path
        # separator character so that subsequent navigation
        # will work when we hit the root directory of the file
        # system.  In the case of Unix, this means that
        # if we ended up at the root directory, we'll just
        # get "/".  In the case of Win32, we will get
        # "DRIVE:/".

        selected += PSEP

        # Load UI with new directory
        LoadDirList(selected, save=SAVE)

        # And always force selection of first item there.
        # This guarantees a selection in the new
        # directory context, so subsequent commands
        # won't try to operate on an item selected in a
        # previous directory
        
        UI.DirList.selection_set(('0',))

    # File selected
    else:
        pass

    # Have to update the window title because selection changed
    UI.UpdateTitle(UIroot)

# End of 'DirListHandler()'


#####
# Load UI With Selected Directory
#####

def LoadDirList(newdir, save=TRUE):
    
    # Canonicalize the current directory name
    newdir = os.path.abspath(newdir)

    # Push last directory visited onto the visited stack

    # Do not do this if we've been told not to OR if
    # what we're about to save is the same as the top
    # of the stack OR if the current directory is ""
    
    # If there is anything on the stack, see if last element
    # matches what we're about to put there.
    
    if UI.LastDir and UI.LastDir[-1] == UI.CurrentDir:
        save = FALSE

    if save and UI.CurrentDir:
        UI.LastDir.append(UI.CurrentDir)

    # And select new directory to visit
    UI.CurrentDir = newdir

    # And make sure it ends with a path separator character
    if UI.CurrentDir[-1] != PSEP:
        UI.CurrentDir = UI.CurrentDir + PSEP

    # Clear out the old contents
    UI.DirList.delete(0,END)

    # Load new directory contents into UI
    for x in BuildDirList(UI.CurrentDir):
        UI.DirList.insert(END, x)

    # And update the title to reflect changes
    UI.UpdateTitle(UIroot)    

# End of 'LoadDirList():


#####
# Return Ordered List Of Directories & Files For Current Root
#####

def BuildDirList(currentdir):
    global UI
    
    dList, fList = [], []
    
    # Walk the directory separate subdirs and files
    try:
        for file in os.listdir(currentdir):
            if os.path.isdir(os.path.join(currentdir,file)):
                dList.append(DIR_LDELIM + file + DIR_RDELIM)
            else:
                fList.append(file)
    except:
        # Can't read selected directory - show error
        ErrMsg(eDIRRD % currentdir)

        # By setting this to null, we assure that it never gets pushed
        UI.CurrentDir = ""

        # If there was a previous dir, go there
        if UI.LastDir:

            # Get the previous directory
            curdir = UI.LastDir.pop()

            # Make it look like we are coming from the one before that
            if UI.LastDir:
                UI.CurrentDir = UI.LastDir.pop()

            # Now go there
            LoadDirList(curdir)

        # If no previous directory, we failed on pgm startup - abort
        else:
            sys.exit(1)
            

        # Satisfy the original call
        return []

    # Directory read was fine
    dList.sort()

    # Entry to move up one directory is always first,
    # no matter what the sort.  This is necessary because
    # OSs like Win32 like to use '$' in file names  which
    # sorts before "."

    dList.insert(0, DIR_LDELIM + ".." + DIR_RDELIM)

    fList.sort()
    return dList + fList

# End of  'BuildDirList()'


#####
# Refresh contents of directory listing to stay in sync with reality
#####

def RefreshDirList(*args):
    rm_list = []

    # After we fiddle with things we want to
    # restore the currently selected item as such.
    # Ordinarily, the active entry and the
    # selected entry are the same - We force that
    # to be so at the end of this routine.
    #
    # However, in the case of the PgUp and
    # PgDn keys, there  may be multiple selections
    # with yet another distinct active entry.  In this
    # case, the semantic we want is to choose
    # the active item as the one selected.
    # So ... we just need to save the active index.
    
    active=UI.DirList.index(ACTIVE)
    
    # Confirm that everything in the DirList widget
    # is still present in the directory.  If not,
    # remove it from the widget.

    for i in range(UI.DirList.index(END)):

        # Get entry
        x = UI.DirList.get(i)
        
        # First strip any directory delimiters
        if x[0] == DIR_LDELIM and x[-1] == DIR_RDELIM:
            x = x[1:-1]

        # Make list of items which no longer exist in this directory
        # Ignore '..', since it always exists
        
        if x != '..':
            if not os.path.exists(UI.CurrentDir + x):
                rm_list.append(i)

    # Now actually delete these items from the listbox

    for i in rm_list:
        UI.DirList.delete(i)

    # Update it visually
    UI.DirList.pack()

    # Restore current selection and active entry
    # Make sure they are still in range singe we
    # may have fewer items after the refresh.
    # If out of range, just set to last item in list
    
    maxindex =  UI.DirList.size() - 1

    if active >maxindex:
       active = maxindex
    
    UI.DirList.selection_clear(0, maxindex)
    UI.DirList.select_set(active)
    UI.DirList.see(active)

    UI.DirList.activate(active)

    # We have to update the title because the selection
    # may have changed.

    UI.UpdateTitle(UIroot)

# End of 'RefreshDirList()


#----------------------------------------------------------#
#                  Program Entry Point                     #
#----------------------------------------------------------#

# Newline to make sure cursor of invoking window is
# at LHS in case we get errors or warnings.

print ""

# Command line processing

try:
    opts, args = getopt.getopt(sys.argv[1:], '-b:c:f:hn:qs:vw:x:y:')
except getopt.GetoptError:
    Usage()
    sys.exit(1)

# Parse command line

for opt, val in opts:
    if opt == "-b":
        BCOLOR = val
    if opt == "-c":
        CONF = val
    if opt == "-f":
        FCOLOR = val
    if opt == "-h":
        Usage()
        sys.exit(0)
    if opt == "-n":
        FNAME = val
    if opt == "-q":
        WARN = FALSE
    if opt == "-s":
        FSZ = val
    if opt == "-v":
        InfoMsg(iVERINFO % RCSID)
        sys.exit(0)
    if opt == "-w":
        FWT = val
    if opt == "-x":
        WIDTH = val
    if opt == "-y":
        HEIGHT = val

# This program requires a config file

if not os.path.exists(CONF):
    ErrMsg(eNOCONF % CONF)
    sys.exit(1)

# Create an instance of the UI
UIroot = Tk()
UI = twanderUI(UIroot)

# Make the Tk window the topmost in the Z stack.
# 'Gotta do this or Win32 will not return input
# focus to our program after a startup warning
# display.

UIroot.tkraise()

#####
# Setup global UI variables
#####

# Figure out where to start
# Program can only have 0 or 1 arguments
# Make sure any startdir argument is legit

if len(args) > 1:
    ErrMsg(eTOOMANY)
    sys.exit(1)

if len(args) == 1:
    STARTDIR = args[0]
    if not os.path.isdir(STARTDIR):
        ErrMsg(eBADROOT % STARTDIR)
        sys.exit(1)

# Get starting directory into canonical form
STARTDIR = os.path.abspath(STARTDIR)

# Parse the and store configuration
ParseConfFile()

# Initialize directory stack
UI.LastDir    = []

# And current location
UI.CurrentDir = ""

# Initialize the UI directory listing
LoadDirList(STARTDIR)

# And start the periodic polling of the widget
UI.poll()

# Run the program interface
UIroot.mainloop()