There Goes Captain Beto, Through Space

comment []    trackback []

I said in dot.kde.org that I could write a spatial file manager in a weekend. I even said that I could write it this weekend if my date failed me.

Well, guess what? No date (she's gone to give a conference), so.... I will write it, and it will be called Captain Beto. The reason for the name will be clear for almost any argentinian, (think translation, fellow countrymen), and obscure for almost anyone else. Which is as good a combination as any.

Ok, here's all I know about this stuff:

  • It's supposed to manage files, so it should be able to link, move, copy, and remove files

  • It's supposed to be spatial, which as far as I know means it should be inconvenient ;-)

    • It should not let you open the same folder twice

    • It should remember everything about a folder

      • Individualized per-folder preferences

      • Remember object position in the folder view

Of course, I intend to do it using PyQt, and if we are lucky, this trip will be educational for me, and if we are really lucky, also for some readers.

I am pretty sure this would be much easier using PyKDE instead, but I must confess right now I don't have it in my computer. However, I intend to get it in a few days, so that means that I will intentionally not do some stuff, like thumbnails, which are much easier to do using some KDE API.

So, this will be a pretty simple application on this first stage. Also, since I have no intent to actually use this thing, it's not a baby with a very bright future, unless some reader feels like adopting it.

It's saturday, may 29th, 2004, and my fuzzy clock says it's five to one... but it's really five to 4.

Timestamp: 29/5/04 15:55

The Plan

I have never written a file manager. I have never even used them much. So, this is really exploration of obscure territory for me.

Here's the roadmap I am imagining:

  1. Create a widget that can show a folder

  2. Make it configurable

  3. Make it configurable per-folder

  4. Let it "navigate" the folder tree

  5. Add basic file operations

Sounds easy enough. I like it because every step should be doable in a reasonable amount of time. I tend to lose interest if the next goal is too far. I am a sprinter, not a distance runner. Like Gimli in "The Two Towers".

Timestamp: 29/5/04 16:05

Stage One

Stupid Application

Let's start with some boilerplate code: A main script that opens a window.

#!/usr/bin/env python

from qt import *
from window import Window
import sys

def main(args):
        
app=QApplication(args)

        
win=Window()
        
app.setMainWidget(win)
        
win.show()
        
app.connect(app, SIGNAL("lastWindowClosed()"), app, SLOT("quit()"))
        
app.exec_loop()

if __name__=="__main__":

        
main(sys.argv)

As you can see, all this does is open a window, literally. And it assumes there is another module, called window.py, containing a Window class.

Well, that's boilerplate also (and very simple one, too):

from qt import *

class Window (QWidget):

        
def __init__(self,parent=0):
                
QWidget.__init__(self,parent)

So, we have a program that, when called, displays a blank widget. When you close that widget, it dies. Not too interesting, that one.

So, let's make it do some tricks.

Choosing the Widget

It turns out Qt has a widget called QIconView, described in the manual as:

A QIconView can display and manage a grid or other 2D layout of labelled icons. Each labelled icon is a QIconViewItem. Items (QIconViewItems) can be added or deleted at any time; items can be moved within the QIconView. Single or multiple items can be selected. Items can be renamed in-place. QIconView also supports drag and drop.

Looks like a file manager to me. So, we change our Window to inherit from QIconView.

from qt import *

class Window (QIconView):

        
def __init__(self,parent=0):
                
QIconView.__init__(self)

Now, that's an uninteresting white window ;-)

../static/beto1.png

Of course, to make it useful, we need to be able to fill it with the contents of a folder.

Well, let's add an argument to the constructor, called folder, that is the folder that should be displayed, and add a method, called changeFolder, that fills the QIconView with the contens of that folder.

Iterating over the contents of a folder in Python is easy enough, using the os module. Adding icons to a QIconView is mostly a matter of creating a bunch of QIconViewItems.

class Window (QIconView):

        
def __init__(self,folder="/",parent=0):
                
QIconView.__init__(self)
                
self.changeFolder(folder)
                
        
def changeFolder(self,folder):
                
folder=os.path.abspath(folder)
                
for item in os.listdir(folder):
                        
QIconViewItem(self,item)

Now, this is somewhat better :-)

../static/beto2.png

That the icon seems right for what is there is just a coincidence.

So, let's use two icons, one called "folder" and the other called "file", and stat the contents of the folder, assigning the right icons.

It really shoud have a better caption, reflecting what you are seeing in the window, too.

Break

Timestamp: 29/05/04 16:20

Turns out I don't have the python docs installed here. Never program Python without the library reference at hand. So, I'm building them now (I had the TeX sources inside the Python sources). Should only take a few minutes

I would normally just use the version at www.python.org, but this is at home, without Internet.

Timestamp: 29/05/04 17:00

The reason I need the library reference is that I don't remember what the values returned by os.stat() mean.

Turns out what I wanted was not os.stat(), but os.path.isdir() and company. See why the library reference is your friend? ;-)

I have visitors. Be back later.

Timestamp: 29/05/04 17:05 Timestamp: 29/05/04 19:25

So this is how the Window class looks now:

class Window (QIconView):

        
def __init__(self,folder="/",parent=0):
                
QIconView.__init__(self)
                
#Fixme, this should load the icons in a reasonable way ;-)
                
self.folderIcon=QPixmap("/usr/share/icons/crystalsvg/32x32/filesystems/folder.png")
                
self.fileIcon=QPixmap("/usr/share/icons/crystalsvg/32x32/mimetypes/document.png")
                
self.changeFolder(folder)
                
        
def changeFolder(self,folder):
                
folder=os.path.abspath(folder)
                
self.setCaption (folder+" - captain beto")
                
for item in os.listdir(folder):
                        
fullpath=os.path.join(folder,item)
                        
#FIXME this should check for different things, like
                        
#links, devices, etc.
                        
if os.path.isdir(fullpath):
                                
QIconViewItem(self,item,self.folderIcon)
                        
elif os.path.isfile(fullpath):
                                
QIconViewItem(self,item,self.fileIcon)
                        
else:
                                
QIconViewItem(self,item,self.fileIcon)

It's basically the same, except that now, when I create the QIconViewItems, we try (feebly) to use the right kind of icon.

And it looks a little better now:

../static/beto3.png

Notice how it uses different icons. It turns out you can even move the icons around, too :-)

So, right now, I think I am close to the first milestone (creating a widget that can show a folder). Of course, it's not a good widget, but it has potential.

Some of the problems with the current code, like loading the right icon, will be solved when this is turned into a PyKDE app later.

The second milestone was "make it configurable". Well, let's.

Making it configurable

The usual stuff that you can configure in a file manager is:

  • Choice to show hidden files or not

  • It should remember the position of the icons

  • You should be able to set a background

Anything else will happen eventually in a distant future. Maybe tomorrow.

Showing (or not) hidden files

Let's add another argument to changeFolder, called showHidden, defaulting to false. Then ignore (or not) hidden files in the loop over os.listdir().

Should be simple. I will not hide ".." because it is the natural way to go up.

Timestamp: 29/05/04 19:40

Here is changeFolder ignoring (or not) hidden files. Do you notice how each thing I do is a 5, maybe 10 line change? I like to program in very small increments. But that's just personal preference.

def changeFolder(self,folder, showHidden=False):
        
folder=os.path.abspath(folder)
        
self.setCaption (folder+" - captain beto")
        
for item in os.listdir(folder):
                
if not showHidden:
                        
if item[0]=="." and not item=="..":
                                
continue
                
fullpath=os.path.join(folder,item)
                
#FIXME this should check for different things, like
                
#links, devices, etc.
                
if os.path.isdir(fullpath):
                        
QIconViewItem(self,item,self.folderIcon)
                
elif os.path.isfile(fullpath):
                        
QIconViewItem(self,item,self.fileIcon)
                
else:
                        
QIconViewItem(self,item,self.fileIcon) 

Now, I want this to follow, as far as I know about it, the dogma of spatial file management, so a setting such as showHidden should be stored and remembered.

It should also, of course, be possible to change, and to be changed on a per-folder basis.

So, what UI should the user have to change this stuff: I like direct manipulation of objects, which in this case probably means a RMB popup on the folder itself.

So, let's add that menu, and make "Show Hidden" a toggle in it. The right way is to create an action and stuff.

In the Window constructor, we add a chunk of code to create the showHiddenAction, which will be a toggle. Also, we create a popup menu, and put the action in it.

self.rmbMenu=QPopupMenu(self)
                
self.showHiddenAction=QAction("Show Hidden Files",QKeySequence("CTRL+H"),self)
self.showHiddenAction.setToggleAction(True)
self.showHiddenAction.addTo(self.rmbMenu)

As you can see, actions are pretty damn simple. Now, we need to connect the action to a slot that toggles the display, and make the popup menu show on right-button-click.

To make the popup ... well, pop up, we need to connect the rightButtonClicked() signal of the QIconView, and connect it to a slot that pops the menu.

This is the slot we add to the Window class:

def showContextMenu(self,item,pos):
        
if not item:
                
self.rmbMenu.exec_loop(pos)
        
else:
                
#FIXME Should show context menu for the item where the
                
#user right-clicked
                
pass

As you can see, there is a glaring lack of functionality. But that's ok, we are just building up stuff here, the holes can be filled later.

To connect the signal to this slot, we add this line to Window.__init__ :

self.connect(self,SIGNAL("rightButtonClicked(QIconViewItem*,const QPoint&)"),self.showContextMenu)      

And lo and behold, we have a context menu, and it has a toggle saying "Show Hidden Files". Now, we have to make it show (or not) according to the toggle.

Simple: we add a member called showHidden to the Window class, remove the showHidden argument from changeFolder, and make it use the variable. Then, we add an accessor called setShowHidden which toggles and redisplays. And we connect that to showHiddenAction.

Also, I renamed changeFolder to setFolder, to be a bit consistent.

Here's the new window.py:

from qt import *
import os

class Window (QIconView):

        
def __init__(self,folder="/",parent=0):
                
QIconView.__init__(self)
                
#Fixme, this should load the icons in a reasonable way ;-)
                
self.folderIcon=QPixmap("/usr/share/icons/crystalsvg/32x32/filesystems/folder.png")
                
self.fileIcon=QPixmap("/usr/share/icons/crystalsvg/32x32/mimetypes/document.png")

                
self.showHidden=False
                
                
self.rmbMenu=QPopupMenu(self)
                                
                
self.showHiddenAction=QAction("Show Hidden Files",QKeySequence("CTRL+H"),self)
                
self.showHiddenAction.setToggleAction(True)
                
self.showHiddenAction.addTo(self.rmbMenu)

                
self.connect(self.showHiddenAction,SIGNAL("toggled(bool)"),self.toggleShowHiddenSlot)
                
                
self.connect(self,SIGNAL("rightButtonClicked(QIconViewItem*,const QPoint&)"),self.showContextMenu)      
                                
                
self.setFolder(folder)
                
        
def setFolder(self,folder):
                
folder=os.path.abspath(folder)
                
self.setCaption (folder+" - captain beto")
                
self.folder=folder
                
self.clear()
                
for item in os.listdir(folder):
                        
if not self.showHidden:
                                
if item[0]=="." and not item=="..":
                                        
continue
                        
fullpath=os.path.join(folder,item)
                        
#FIXME this should check for different things, like
                        
#links, devices, etc.
                        
if os.path.isdir(fullpath):
                                
QIconViewItem(self,item,self.folderIcon)
                        
elif os.path.isfile(fullpath):
                                
QIconViewItem(self,item,self.fileIcon)
                        
else:
                                
QIconViewItem(self,item,self.fileIcon)
                                
                        
                                
        
def showContextMenu(self,item,pos):
                
if not item:
                        
self.rmbMenu.exec_loop(pos)
                
else:
                        
#FIXME Should show context menu for the item where the
                        
#user right-clicked
                        
pass

        
def toggleShowHiddenSlot(self,show):
                
self.showHidden=show
                
self.setFolder(self.folder)

The app looks exactly the same, although it has a little functionality extra, so no screenshot for you! ;-)

Timestamp: 29/05/04 20:15

Now, let's see how it looks with a background pixmap. There is a setPaletteBackgroundPixmap() method, so it shouldn't be hard...

I say it looks good:

../static/beto4.png

Again, I create an action, called setBackgroundAction, connect it to a slot called setBackgroundSlot, and slap it on the context menu:

The action and the connection (for Window.__init__):

self.setBackgroundAction=QAction("Set &Background...",QKeySequence(""),self)
self.setBackgroundAction.addTo(self.rmbMenu)
self.connect(self.setBackgroundAction,SIGNAL("activated()"),self.setBackgroundSlot)

The slot:

def setBackgroundSlot(self):
        
bg=str(QFileDialog.getOpenFileName())
        
self.setPaletteBackgroundPixmap(QPixmap("bg"))

Ok, that is getting boring. So let's just say it is configurable enough for a while, OK?

And then we are at a new milestone. We made it configurable , now we must make it configurable per-folder. That should be a bit trickier.

Per-folder settings

Timestamp: 29/05/04 20:35

You know... I'm getting hungry. I'll order some Kun-Pao... nah, it can wait a little.

There are many different ways to go at storing configuration data. I intend to keep this as simple as possible, so I will try to do it using the shelve module.

You can think of shelve as a very (very) simple database. You just store objects in it, indexed by a single key. However, that fits rather nicely with the idea of per-folder settings, doesn't it? I mean, just index them by the folder path :-)

So, I will create a little dictionary for the config values, called settings, and (un)shelve it as needed. So, self.bg and self.showHidden will have to change again, now to stuff like self.settings["showHidden"]. Such is life for variables.

To shelve settings, all you need is a global shelf object. I will create it in the main script, and pass it as argument to the Window class on creation, so I can get a reference to it easily. This way to do it is probably not the nicest, but is what I can think about over the noise of my hungry stomach.

Here's the new main script, beto.py:

#!/usr/bin/env python

from qt import *
from window import Window
import sys
import shelve

def main(args):
        
app=QApplication(args)

        
shelf=shelve.open(".betosettings")
        
        
win=Window(shelf=shelf)
        
win.show()
        
app.connect(app, SIGNAL("lastWindowClosed()"), app, SLOT("quit()"))
        
app.exec_loop()

if __name__=="__main__":

        
main(sys.argv)

As you can see, no big changes I removed the setMainWidget call, because it breaks the app if it is meant to support multiple windows.

The shelf itself is used just like a dictionary, so this is really simple to use. Look at the saveSettings and loadSettings in the next listing, as well as the places that call them.

window.py suffered a bit more. Making sure all things change in harmonious waves is always trickier than I expect. In particular, the handling of the background here is very inelegant:

from qt import *
import os

class Window (QIconView):

        
def __init__(self,folder="/",parent=None,shelf=None):
                
QIconView.__init__(self,parent)
                
                
self.shelf=shelf
                
self.folder=None
                                
                
#Fixme, this should load the icons in a reasonable way ;-)
                
self.folderIcon=QPixmap("/usr/share/icons/crystalsvg/32x32/filesystems/folder.png")
                
self.fileIcon=QPixmap("/usr/share/icons/crystalsvg/32x32/mimetypes/document.png")

                                
                
self.rmbMenu=QPopupMenu(self)
                                
                
self.showHiddenAction=QAction("Show &Hidden Files",QKeySequence("CTRL+H"),self)
                
self.showHiddenAction.setToggleAction(True)
                
self.showHiddenAction.addTo(self.rmbMenu)

                
self.connect(self.showHiddenAction,SIGNAL("toggled(bool)"),self.toggleShowHiddenSlot)
                
                
self.setBackgroundAction=QAction("Set &Background...",QKeySequence(""),self)
                
self.setBackgroundAction.addTo(self.rmbMenu)
                
self.connect(self.setBackgroundAction,SIGNAL("activated()"),self.setBackgroundSlot)
                
                
                
                
self.connect(self,SIGNAL("rightButtonClicked(QIconViewItem*,const QPoint&)"),self.showContextMenu)      
                
                
self.setFolder(folder)
                
        
def setFolder(self,folder):
                
folder=os.path.abspath(folder)
                
self.setCaption (folder+" - captain beto")
                
if folder!=self.folder:
                        
self.folder=folder
                        
self.loadSettings()
                
                
self.clear()
                
                
for item in os.listdir(folder):
                        
if not self.settings["showHidden"]:
                                
if item[0]=="." and not item=="..":
                                        
continue
                        
fullpath=os.path.join(folder,item)
                        
#FIXME this should check for different things, like
                        
#links, devices, etc.
                        
if os.path.isdir(fullpath):
                                
QIconViewItem(self,item,self.folderIcon)
                        
elif os.path.isfile(fullpath):
                                
QIconViewItem(self,item,self.fileIcon)
                        
else:
                                
QIconViewItem(self,item,self.fileIcon)
                                
                        
                                
        
def showContextMenu(self,item,pos):
                
if not item:
                        
self.rmbMenu.exec_loop(pos)
                
else:
                        
#FIXME Should show context menu for the item where the
                        
#user right-clicked
                        
pass

        
def toggleShowHiddenSlot(self,show):
                
self.settings["showHidden"]=show
                
self.saveSettings()
                
self.setFolder(self.folder)
                
        
def setBackgroundSlot(self,choose=True):
                
if choose:
                        
self.settings["bg"]=str(QFileDialog.getOpenFileName())
                
if self.settings["bg"]:
                        
self.setPaletteBackgroundPixmap(QPixmap(self.settings["bg"]))
                
else:
                        
self.unsetPalette()
                
self.saveSettings()

        
def saveSettings(self):
                
self.shelf[self.folder]=self.settings           

        
def loadSettings(self):
                
try:
                        
self.settings=self.shelf[self.folder]
                
except KeyError:
                        
self.settings={}
                        
#Here you have to set *ALL* defaults
                        
self.settings["bg"]=None
                        
self.settings["showHidden"]=False
                        
                
self.setBackgroundSlot(choose=False)

Also, I added a little error checking here and there ;-)

And that's that when it comes to per-folder settings. Not that hard, was it?

Timestamp: 29/05/04 21:05

And I have only written about 115 lines of code!

So, what's next? Folder navigation!

Folder Navigation

Now, this is simple: when you click on a folder, open it in another window. When you click on a file, open that file. Piece of cake.

There is a signal called clicked(QIconViewItem). Guess when is it triggered? Right, when you click on stuff.

If someone wants a double-click version, use doubleClicked instead ;-)

So, when someone clicks on stuff, we see if he clicked on an item, and if yes, whether it's a file or a folder, and act on it.

There is a trick, in that if you don't want the new window to vanish without a trace as soon as it gets out of scope, there must be some global place where they are referenced. So I created a global dictionary called windows in window.py, where they get indexed by folder name on creation.

windows={}

This is useful to avoid opening the same folder twice, which is supposed to be bad for some reason (don't ask me about it)

Here's the slot handling opening stuff:

def openItemSlot(self,item):
        
global windows
        
if not item: #Clicked on the background
                
return
        
name=os.path.join(self.folder,str(item.text()))
        
if os.path.isdir(name):
                
#Check if it is already open, if not, open it
                
if not windows.has_key(name):
                        
Window(name,shelf=self.shelf).show()
                
else:
                        
windows[name].show()
        
else: #Open it
                
#FIXME this is damn insecure!
                
os.system("kfmclient exec '%s'&"%name)

Here's how it connects to the clicking:

self.connect(self,SIGNAL("clicked (QIconViewItem *)"),self.openItemSlot)

When a window closes, it has to delete itself, and remove itslf from that global dictionary. So, we override the closeEvent() handler:

def closeEvent(self,event):
        
event.accept()
        
global windows
        
del windows[self.folder]
        
print windows

Timestamp: 29/05/04 21:40

Now, the next milestone should be to add file managing capabilities, but I cheated too much a while back, so I will get back to making it configurable.

In particular, folders should remember their position and size, and the position of the stuff inside them.

Making it more configurable, still

For geometry, it's simple. Whenever you move or resize the window, store a setting, and, on creation, follow it. Trivial.

So, we override resizeEvent() and moveEvent(), retouch __init__ a bit, and add new defaults in loadSettings().

The events:

def moveEvent(self,event):
        
self.settings["x"]=self.pos().x()
        
self.settings["y"]=self.pos().y()
        
self.saveSettings()
        
QIconView.moveEvent(self,event)
        
def resizeEvent(self,event):
        
self.settings["width"]=self.frameGeometry().width()
        
self.settings["height"]=self.frameGeometry().height()
        
self.saveSettings()
        
QIconView.resizeEvent(self,event)

The new loadSettings():

def loadSettings(self):
        
try:
                
self.settings=self.shelf[self.folder]
        
except KeyError:
                
self.settings={}
                
#Here you have to set *ALL* defaults
                
self.settings["bg"]=None
                
self.settings["showHidden"]=False
                
self.settings["x"]=self.pos().x()
                
self.settings["y"]=self.pos().y()
                
self.settings["width"]=self.frameGeometry().width()
                
self.settings["height"]=self.frameGeometry().height()
                
        
self.setBackgroundSlot(choose=False)

And for the settings to take effect, I added this in setFolder (it could have gone to the __init__, but it looked cleaner this way), after we call loadSettings:

self.move(self.settings["x"],
          
self.settings["y"])
self.resize(self.settings["width"],
            
self.settings["height"])

Maybe it should be part of loadSettings itself? Well, at least refactoring that should be simple ;-)

And here's beto, showing different backgrounds. And yes, all this so far works. At least as far as I tested it, which is not all that much.

../static/beto5.png

Timestamp: 29/05/04 22:05

Now, about the position of items in the view... that's going to be somewhat harder.

By default, when we create the items, they simply spread themselves at will. They can be moved by drag and drop. So, here's what I think should be done:

  1. When they are created, check if we remember a position for that name.

  2. If we don't, just create it, and save the position it gets.

  3. Figure out when one is moved, and store that as well.

So, we will have to do some more stuff with settings. I will add a self.settings["itemPositions"] that should be a dictionary, where positions will be indexed by name.

So, the position for afile should be in self.settings["itemPositions"]["afile"]. Of course there will be accesor methods for that ;-)

To figure out