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.67 2002/11/20 23:55:16 tundra Exp $"
VERSION = RCSID.split()[2]


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

import getopt
import os
from socket import getfqdn
import sys

from Tkinter import *
from tkMessageBox import showerror, showwarning
from tkSimpleDialog import askstring

#----------------------------------------------------------#
#          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
REFRESH       = '<Control-l>'          # Refresh screen

# Directory Navigation

CHANGEDIR     = '<Control-x>'          # Enter a new path
DIRHOME       = '<Control-h>'          # Goto $HOME
DIRBACK       = '<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
SELEND        = '<Control-e>'          # Select bottom item
SELTOP        = '<Control-a>'          # Select top item
SELKEY        = '<space>'              # Select item w/keyboard
SELMOUSE      = '<Double-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)


#####
# Program Constants
#####

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
#####

HOSTNAME      = getfqdn() # Full name of this host
REFRESHINT    = 2000      # Interval (ms) for automatic refresh


# Set OS type - this allows us to trigger OS-specific code
# where needed.

OSNAME = os.name

#####
# 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"
eINITDIRBAD = "Cannot Open Starting Directory : %s - Check Permissions - ABORTING!."
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."

# Prompts

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

# Warnings

wCMDKEY  = "Configuration File Entry For: \'%s\' Has No Command Key Defined."
wSYMBACK = " Symbolic Link %s Points Back To Own Directory"
wWARN    = "WARNING"


#####
# Usage Information
#####

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:
        print x
# 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)

        # Bind handler of "Refresh Screen" 
        self.DirList.bind(REFRESH, RefreshDirList)

        # 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(DIRBACK, KeyBackDir)

        # 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(SELTOP, KeySelTop)

        # Bind handler for "Last Item"
        self.DirList.bind(SELEND, KeySelEnd)

        # 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(REFRESHINT, 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):

        mainwin.title(PROGNAME + " " + VERSION +
                      "       " + HOSTNAME + ":    "+
                      UI.CurrentDir)

    # 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:

        # Handle selections in form: /foo/bar/[....]

        cursel = UI.CurrentSelection()
        if cursel[0] == DIR_LDELIM and cursel[-1] == DIR_RDELIM:
            cursel = cursel[1:-1] + PSEP

        # Replace runtime-determined tokens
        cmd = cmd.replace(FILENAME, cursel)
        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 KeyBackDir(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 'KeyBackDir()'


#####
# 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):
    SetSelection(UI.DirList.index(ACTIVE) + 1)

# End of 'KeySelNext()'


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

def KeySelPrev(event):
    SetSelection(UI.DirList.index(ACTIVE) - 1)

# End of 'KeySelPrev()'


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

def KeySelEnd(event):

    # We just pass a really large number and
    # let the called routines bounds checking
    # do the work.

    SetSelection(100000)

# End of 'KeySelEnd()'


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

def KeySelTop(event):
    SetSelection(0)

# End of 'KeySelTop()'


#####
# 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]

        # If we're on Unix, don't follow symlinks pointing back to themselves

        if OSNAME == 'posix' and os.path.samefile(UI.CurrentDir, UI.CurrentDir + selected):
            WrnMsg(wSYMBACK % (UI.CurrentDir + selected))
            return

        # 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
        
        SetSelection(0)

    # File selected with a double-click
    # If we're running Win32, use OS association to try and run it.

    elif OSNAME == 'nt':
        os.startfile(os.path.join(os.path.abspath(UI.CurrentDir), selected))


    # 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):

    # Get path into canonical form
    newdir = os.path.abspath(newdir)

    # Make sure newdir properly terminated
    if newdir[-1] != PSEP:
        newdir += PSEP

    # Check right now to see if we can read
    # the directory.  If not, at least we
    # haven't screwed up the widget's current
    # contents or program state.

    try:
        contents = BuildDirList(newdir)
    except:
        # If CurrentDir set, we're still there: error w/ recovery
        if UI.CurrentDir:
            ErrMsg(eDIRRD % newdir)
            return

        # If not, we failed on the initial directory: error & abort
        else:
            ErrMsg(eINITDIRBAD % newdir)
            sys.exit(1)

    # 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

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

    # Load new directory contents into UI
    for x in contents:
        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
    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)

    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):

    # Save location & content of active item

    active   = UI.DirList.index(ACTIVE)
    selected =  UI.DirList.get(active)

    # Get new directory listing
    newlist = BuildDirList(UI.CurrentDir)

    # Clean out old listbox contents
    UI.DirList.delete(0,END)

    # Save the new directory information
    UI.DirList.insert(0, *newlist)

    # If possible, maintain selection of same *item*.
    # If not, maintain same *position* in widget (set above).

    if selected in newlist:
        active = newlist.index(selected)

    # Restore active item
    SetSelection(active)

# End of 'RefreshDirList()


#####
# Set a particular selection, w/bounds checking
#####

def SetSelection(index):

    # Clear all current selection(s)
    UI.DirList.selection_clear(0, END)

    # Get current maximum index
    maxindex =  UI.DirList.size() - 1

    # And bounds check/adjust the value desired

    if index > maxindex:
       index = maxindex

    if index < 0:
        index = 0

    # Select specified item - make active selection track this
    UI.DirList.activate(index)
    UI.DirList.select_set(index)

    UI.DirList.see(index)

# End of 'SetSelection()'


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

# 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":
        print 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()