one-time blog

less is more. more or less

File Selection Dialog in QPython

| Comments

Almost any program ask user for file or folder names. Android does not provide file dialog by default. Let's create our own.

It should be simple enough, but it should allow user to browse throuh the file system and select file. Also it should allow user to refuse selection by clicking Cancel button. Our function will take two arguments: text prompt (title) and initial folder to browse:

def chooseFile(title='Choose File', folder='.')

See here if you don't know how to define functions in Python.

Both parameters have default values — they will be used if you don't indicate an argument when calling the function. '.' means "current folder".

You will use it as follows:

import filechooser
filename = filechooser.chooseFile("Open File", myworkdir)

So, first of all we should list of files in the given directory (folder). There are several ways to do it in Python. We will use function glob in module glob. This function returns list of paths matching a pattern given. For instance, if you need to get list of all Python files in the current directory, you can get it using glob('*.py'). We need a list of the whole contents of the directory, so we will use '*' pattern, which means "any file or folder name".

The item selected by user (folder or filename) will be kept in a variable d. In case of filename it will be returned, otherwise we will show the directory's contents to the user. Initially we set it from argument folder:

d = folder

Then we should get the list of items of the directory d. In order to do it, we join the file pattern '*' with the directory name before passing it to the function glob. We can do it using os.path.join() function (os.path is extremely useful module, and we're going to use other functions from it). By the way, you can always get help on every module, every class, object or function in python. Just ask help(...):

>>> help(os.path.join)

This show you short help message about the function os.path.join:

join(a, *p)
    Join two or more pathname components, inserting '/' as needed.
    If any component is an absolute path, all previous path components
    will be discarded.

Help function is the most useful features of Python and probably the most often used in the console.

So, let's check in the console what we are going to do:

>>> from glob import glob
>>> import os.path
>>> d = '.'
>>> os.path.join(d, '*')
'./*'
>>> glob(os.path.join(d, '*'))
['./filechooser.py', './utils.py', './file.py']
>>>

Ok, there are three files in the current directory (obviously, you will see another file names). And we are ready now to show them to the user. But I don't really like these ./ at the beginning of each name showed to user. I want to show local file names without any path. Let's cut off them. There is a function split in the module os.path, which splits a pathname and returns tuple "(head, tail)" where "tail" is everything after the final slash — this "tail" is what we need! So, assuming fn contains any pathname, we could get the last portion as os.path.split(fn)[1] (tuples' and lists' index starts with 0, so x[1] is the second item in a tuple or list).

Stop! We need to cut off each item in our list which was returned by glob()! And here is one thing I love in Python very much:

flist = [ os.path.split(fn)[1] for fn in glob(os.path.join(d, '*')) ]

Variable flist receives the list of last parts ("tails") of pathnames returned by glob(). This line equals to the following code but much shorter and expressive:

flist = []
for fn in glob(os.path.join(d, '*')):
    tail = os.path.split(fn)[1]
    flist.append(tail)

See more about list comprehensions here.

Ok, even if you didn't understand how it works, flist variable already contains the list of files and folders ready to display to user. ;)

Just one little thing: glob() returns folders/files which is inside the certain directory. How a user can go outside? Let's add a "special item", indicating "upper, parent directory". It's a common practice to indicate this special item as two dots: ... Only root directory (/) has no parent, so we should check before adding:

if d != '/':
    flist.insert(0, '..')

If you don't know how insert() works, type in the console: help([].insert).

Now, I propose to move platform-specific code to the separate function. In this case we could port our code to another system much more easily just replacing this function. Let a function _dialog() draws dialog box itself and shows it to the user. It should take two arguments: title and filelist to show, and return selected item's index in the list. It should return None in case the user clicked "Cancel". So, our chooseFile() function should call this function this way:

selected = _dialog(title, flist)

I'll show this functions later. And now we are going to analize what it has returned. First of all, we must check is it None, or no, and return None if yes:

if selected is None:
    return None

Then, in case of not None value, we should understand what the user selected. Since selected is just a number, indicating selected position in the list, flist[selected] gives us the item (filename) itself. Then we are going to get full, absolute path of the selected item. First of all, we're joining it with the current directory name: os.path.join(d, flist[selected]), then we're using os.path.abspath() function to convert the value to the absolute path. Since abspath() understands ".." as a parent directory as well, we don't worry about this "special case". Finally, this absolute path should become a new value of d, and if it is not a directory, it should be returned:

d = os.path.abspath(os.path.join(d, flist[selected]))
if not os.path.isdir(d):
    return d

If d now contains directory (folder) name, we must go back and repeat the whole process. So, I'm wrapping the whole code to "infinite" loop using while True: (it is not actually infinite since there is an "exit" — return operator).

Here is the whole code of our function:

def chooseFile(title='Choose File', folder='.'):
    '''Display choose file dialog with title and a list of
    files/folders in specified folder and allow user to
    browse file system and select file.
    Return full name of the file choosen or None
    '''
    import os.path
    from glob import glob
    d = folder
    while True:
        flist = [ os.path.split(fn)[1] for fn in glob(os.path.join(d, '*')) ]
        if d != '/':               # if it is not root

            flist.insert(0, '..')  # add parent 

        selected = _dialog(title, flist)
        if selected is None:       # user cancelled

            return None
        d = os.path.abspath(os.path.join(d, flist[selected]))
        if not os.path.isdir(d):
            return d

Well, let's talk about user interface. We agreed to move it to the separate function _dialog(). It must be placed before chooseFile since it is called from the last. Here is the function:

def _dialog(title, flist):
    '''display dialog with list of files/folders title
    allowing user to select any item or click Cancel 
    get user input and return selected index or None
    '''
    import androidhelper
    droid = androidhelper.Android()
    droid.dialogCreateAlert(title, '')
    droid.dialogSetItems(flist)
    droid.dialogSetNegativeButtonText('Cancel')
    droid.dialogShow()
    resp = droid.dialogGetResponse()
    droid.dialogDismiss()
    print resp
    if resp.result.has_key('item'):
        print 'RETURN:', resp.result['item']
        return resp.result['item']
    else:
        return None

The code is really simple and self-explaining. The first line after the function's definition and documentation string imports androidhelper, the next one creates an object droid (see my previous post if you don't know what is it), then we construct a dialog box with title, scrollable list of selectable items and a cancel button. There can be up to three buttons in a dialog: "positive" (i.e. "Ok"), "negative" ("Cancel") and "neutral". We need only negative button.

Then we show the dialog to the user (droid.dialogShow()), get response (resp = droid.dialogGetResponse()) and finally we must dismiss the dialog (droid.dialogDismiss()).

The last portion of the code analizes the response. If the user seleted one of the items in the list, the item's index returned as 'item' element of rezult dictionary. Otherwise (the user clicked "Cancel"), resp.result has no 'item' at all. Condition resp.result.has_key('item') returns True if dictionary resp.result has a key "item", and False otherwise. In the first case we return the value of resp.result['item'] — index of selected item in the list, and in the second case we return None indicating that the user refused selection.

That's it. I understand that it's a bit hard to understand such long code. Believe me, it's hard to explain it as well! :) That's why I provide links to different explanations in Pythons documentation. If you are really new to Python, please read Python Tutorial first of all.

Finally, you can get the whole code of the file chooser here to use in your own programs. To test it just run — it should ask you for the file name. And the selected file name will be shown in the program's output.

Next time I'm going to make this code portable (so, we could use the same module on different platforms with different GUIs). Stay tuned! :)

Comments

comments powered by Disqus