A few days ago someone said something[1] that reminded me about my audio player, which I had abandoned for more than a year already. The reason was mostly that the two Phonon backends, VLC and gstreamer, for some reason or other couldn't play the files I had without any gaps between songs.

To be honest, the first bug end up being me not properly encoding the filenames. If you first URL-encoded the filename and then built a Q/KURL with that, then it's all fine. It took me more than 12 months and a few rereads of the thread to realize it. Fixes apart, it seems that the bug still exists for other instances of gstreamer errors, so we're not out of the woods. In any case, I switched to the VLC backend and it seems that now is able to fire the aboutToFinnish() signal properly, so for the moment I'm using that.

All that is fine, but that's not what I wanted to talk about in this post. Given that this project largely precedes my interest on testing, it has no testing at all. Most of the project is straightforward enough to almost no need any, but there's a critic part that would not suffer at all if it had any, namely the Collections handling, including passing files from one to another and automatically updating new/removed Songs[2].

So after fixing the bug mentioned above I tried to figure out the current state of affaires regarding Collections, and boy, they're in bad shape. The code was locally modified, never commited, deactivating any notifications of filesystem changes (new or removed files), and other code I can't really understand the purpose of.

Because if this last detail is that I decided to start testing three classes: Collection, which handles a set a Songs with a common root directory; CollectionAggregator, which handles a set of collections and should coordinate moving a Song from one Collection to another; and CollectionIndexer, which scans from a Collection's root dir to find Songs.

All went fine while I tested the first class, Collection. There was a tricky part where I had to setup a QApplication in order to make signals work. The problems began when I started testing CollectionIndexer. Tests started blocking endlessly, signals stopped being either emited or firing the connected slots, life was bad.

I tried to search the available documentation and mailing lists for a hint about the problem, but besides a quite complex example that didn't seem to properly converge to anything useful, I was mostly on my own.

This morning I got my eureka moment: I noticed that if I executed each test class by itself, it worked, but both at the same time blocked and never finished. Then I remembered something said in QApplication's documentation:

For any GUI application using Qt, there is precisely one QApplication object, no matter whether the application has 0, 1, 2 or more windows at any given time.

That was it: I was creating the application, first in the setUp() method, then as a class attribute, but I had one test class per class to test, each in its own file. Somehow this last fact lead me to think that somehow they were executed in separate processes, which is not true. Luckily, even with this limitation, there's none on the amount of times you can exec_() and quit() the same instance, so that's what I did: I created only one instance and reused it everywhere. I was already doing that for each test method, but again, somehow having several files mislead me to think they were isolated from each other.

So now all my unit tests work without mysteriously blocking forever. Now I just hope I can keep riding the success wave and bring satyr into good shape. A new release wouldn't hurt.


[1] No matter how much I try, I can't get any vaguer.

[2] Ok, maybe the Player/Playlist combo wouldn't hurt to have UTs either.


satyr pykde python

Posted Mon 13 Oct 2014 02:22:53 PM CEST Tags: pykde

After several months thinking about it, and just two requests, I finally decided to publish satyr's code. I decided to use github because I already switched to satyr from hg to git, mainly for testing and understanding it. I think I can live with hg, althought branch management in git seems to be given more thought and a good implementation.

So, without further ado: satyr in github

Remember, it's still a test software, by no means polished or ready for human consumption, and with very low development force. Still, I think it has some nice features, like interchangeable skins and a well defined backend, D-Bus support, quick tag edition, reasonable collection managment, and, thanks to Phonon, almost-gapless playback and things like «stop after playing the current this file» (but not «after any given file» yet).

In Debian Sid it mostly works only with the GStreamer backend; I haven't tried the xine one and I know VLC does not emit a signal needed for queueing the next song, so you have to press «next» after each song. AFAIK this is fixed upstream.

satyr pykde python

Posted Sat 03 Sep 2011 02:59:43 PM CEST Tags: pykde

satyr-0.3.2 "I should install my own food" is out. The Changelog is not very impressive:

  • We can save the list of queued Songs on exit and load it at startup.
  • The queue position is not presented when editing the title of a Song that is queued.
  • Setting the track number works now (was a bug).
  • Fixed a bug in setup.py.

but the last one is somewhat important (Thanks Chipaca). Also, 2 months ago, I made the satyr-0.3.1 "freudian slip" release, when user 'dglent' from http://kde-apps.org/ found a packaging bug. It was not only a bugfixing revision, it also included new features:

  • nowPlaying(), exported via dbus.
  • 'now playing' plugin for irssi.
  • Changes in tags is copied to selected cells (in the same column). This allows 'Massive tag writing'.
  • Fixed "NoneType has no attribute 'row'" bug, but this is just a workaround.
  • Forgot to install complex.ui.

Now go get it!


satyr pykde python

Posted Wed 24 Feb 2010 10:16:41 PM CET Tags: pykde

Continuing with the development of Satyr, which doesn't have any GUI. I thought it would be faster to make a DBus interface that a good GUI, not to mention more interesting.

The code snippet of today is this:

class DBusServer (dbus.service.Object):
    def __init__ (self):
        bus= dbus.SessionBus ()
        bus_name= dbus.service.BusName ('org.kde.satyr.dbus.test', bus=bus)
        dbus.service.Object.__init__ (self, bus_name, "/server")

    @dbus.service.method('org.kde.satyr.dbus.test', in_signature='', out_signature='')
    def bing (self):
        print "bing!"

    @dbus.service.method('org.kde.satyr.dbus.test', in_signature='', out_signature='')
    def quit (self):
        app.quit ()

dbus.mainloop.qt.DBusQtMainLoop (set_as_default=True)
dbs= DBusServer ()

sys.exit (app.exec_ ())

This simply defines a class which registers itself with the session bus under the name org.kde.satyr.dbus.test, exporting itself under the path /server and then defining a method that goes bing! :) and another one that quits the app. Note the decorator for the methods.

You might notice the dbus.mainloop.qt.DBusQtMainLoop (set_as_default=True) call. This is needed because both Qt and DBus in asyncronous mode (which is the one we're using and the only one that works under Qt or Gtk, AFAIK) both have their own event loops, and that makes some kind of magic that let both loops coexist without blocking the other. This must be called before connecting to the bus; otherwise, you get this error:

<span class="createlink">RuntimeError</span>: To make asynchronous calls, receive signals or export objects, 
D-Bus connections must be attached to a main loop by passing mainloop=... 
to the constructor or calling dbus.set_default_main_loop(...)

So, let's test the beast. We run the script in one terminal and in the other:

mdione@mustang:~/src/projects/satyr/live$ qdbus | grep satyr
 org.kde.satyr.py-10154
 org.kde.satyr.dbus.test
mdione@mustang:~/src/projects/satyr/live$ qdbus org.kde.satyr.dbus.test
/
/server
mdione@mustang:~/src/projects/satyr/live$ qdbus org.kde.satyr.dbus.test /server
method void org.kde.satyr.dbus.test.bing()
method QString org.freedesktop.DBus.Introspectable.Introspect()
mdione@mustang:~/src/projects/satyr/live$ qdbus org.kde.satyr.dbus.test /server org.kde.satyr.dbus.test.bing
mdione@mustang:~/src/projects/satyr/live$ qdbus org.kde.satyr.dbus.test /server org.kde.satyr.dbus.test.quit

And in the other console:

mdione@mustang:~/src/projects/satyr/live$ python dbus_test.py
bing!

It goes bing!... and then finishes. The next step is to make my Player class to export its methods via DBus and that's it!. More info in the Python DBus tutorial.

dbus python pykde satyr

Posted Wed 27 Jan 2010 11:55:55 PM CET Tags: pykde

Satyr handles paths. There are some problems with paths and (sigh) encondings. Of those, here are two: there's no way to know in which encoding the filenames in a filesystem are enconded (f.i., there's no way to ask the filesystem), and even if that were possible, the filenames might not even be enconded in that enconding. In these (still!) transitioning times, lots and lots and shitloads of filesystems are used in UTF-8 environments, but some filenames are still in old ISO-8859-1 or whatever the system was using before.

Then comes QString. I'm taking a path from the command line; this path is the location of the (right now only) Collection for the player. I'm handling the command line using KCmdLineOptions, which returns QStrings. As we all know, QString, just like the unicode type in Python, handles all the data internally as Unicode, which is The Right Thing™. If you really need the internal data, say as bytes, you can always call the constData() method and be happy with it[1]. This would be the case for paths; you need the bytes.

Then comes PyQt4. For some reason, which maybe I will ask in the pyqt devel ML[2], constData() is not available. What to do? Well, that's what this post is about. What you're about to read is hacky as it can be, but then it works. I might feel dirty, but I can live with it. As long as I mark it as a utter/über hack and promise to revert it once that's possible...

# path is a QString
qba= QByteArray ()
qba.append (path)
path= str (qba)
# now path is a list of bytes/string.

Even if this part of the bug is fixed, then Phonon.MediaSource or Phonon.MediaObject.play() fails when feeded that same path with this message:

ERROR: backend <span class="createlink">MediaObject</span> reached <span class="createlink">ErrorState</span> after  1 . It seems a <span class="createlink">KioMediaStream</span> will not help here, trying anyway.

or simply refusing to continue. Sure, in my case I should simply ignore the filename and inform the user what's going on, but sometimes you can't be so gentle.

satyr pykde python phonon


[1] blah.

[2] but then it doesn't make much sense now since Phil wants to get rid of QString (for several reasons, which might most possibly include this one).

Posted Wed 27 Jan 2010 11:55:55 PM CET Tags: pykde

satyr got the possibility to queue songs for playing very early. At that moment there wasn't any GUI, so the only way to (de)queue songs was via dbus[1]. Once the GUI was there, we had to provide a way to queue songs. As satyr aims to be fully usable only using the keyboard, the obvious way was to setup some shortcut for the action.

Qt and then KDE have a very nice API for defining 'actions' that can be fired by the user. The ways to fire them include a shortcut, a menu entry or a button in a toolbar. I decided to go with the KDE version, KAction.

According to [the documentation](http://api.kde.org/4.x-api/kdelibs-apidocs/kdeui/html/ classKAction.html#_details), to create an action is just a matter of creating the action and to add it to an actionCollection(). The problem is that nowhere it says where this collection comes from. There's the KActionCollection class, but creating one and adding the actions to it seems to be not enough.

If you instead follow the tutorial you'll see that it refers to a KXmlGuiWindow, which I revised when I was desperately looking for the misterious actionCollection(). I don't know why, but the documentation generated by PyKDE does not include that method. All in all, the tutorial code works, so I just ported my MainWindow to KXmlGuiWindow:

class MainWindow (KXmlGuiWindow):
    def __init__ (self, parent=None):
        KXmlGuiWindow.__init__ (self, parent)
        uipath= __file__[:__file__.rfind ('.')]+'.ui'
        UIMainWindow, _= uic.loadUiType (uipath)

        self.ui= UIMainWindow ()
        self.ui.setupUi (self)
        [...]
        self.ac= KActionCollection (self)
        actions.create (self, self.actionCollection ())

and actions.create() is:

def create (parent, ac):
    '''here be common actions for satyr skins'''
    queueAction= KAction (parent)
    queueAction.setShortcut (Qt.CTRL + Qt.Key_Q)
    ac.addAction ("queue", queueAction)
    # FIXME? if we don't do this then the skin will be able to choose whether it
    # wants the action or not. with this it will still be able to do it, but via
    # implementing empty slots
    queueAction.triggered.connect (parent.queue)

Very simple, really. But then the action doesn't work! I tried different approaches, but none worked.

The tutorial I mentioned talks about some capabilities of the KXmlGuiWindow; one of them, the ability to have the inclusion of actions in menues and toolbars dumped and loaded from XML file (hence, the XML part of the class name), and that is handled by the setupGUI() method. From its documentation: «Configures the current windows and its actions in the typical KDE fashion. [...] Typically this function replaces createGUI()». In my case the GUI is already built by that self.ui.setupUi() that you see up there, so I ignored this method. But the thing is that if you don't call this method, the actions will not be properly hooked, so they don't work; hence, my problem. But just calling it make the actions work! I'll check later what's the magic behind this method. For now just adding self.setupGUI() at the end of the __init__() was enough.

So, that's it with actions. As a result I also get several things: a populated menu bar with Settings and Help options (but no File; that'll come later with standard actions, of which I'll talk about later, I think), with free report bug, about satyr and configure shortcuts options, among others. The later does work but its state is not saved. That also will come in the same post that standard actions.

PD: First post from my new internet connection. Will satyr suffer from this? Only time will tell...


satyr pykde python


[1] then you had to pick the index of the songs either by guessing or looking at the dump of the playlist.

Posted Wed 27 Jan 2010 11:55:55 PM CET Tags: pykde

A couple of days ago Marcelo Fernández wrote a simple image viewer in PyGTK. It's less than 200 lines long[1], and I thought that it would be nice to compare how the same app would be written in PyKDE4. But then I though that it would not be fair, as KDE is a whole desktop environment and GTK is 'only' a widget library, so I did it in PyQt4 instead.

To make this even more fair, I hadn't had a good look at the code itself, I only run it to see what it looks like: a window with only the shown image in it, both scrollbars, no menu or statusbar, and no external file, so I assume he builds the ui 'by hand'. He mentions these features:

  • Pan the image with the mouse.
  • F1 to F5 handle the zoom from 'fit to window', 25%, 50%, 75% and 100%.
  • Zooming with the mouse wheel doesn't work.

Here's my take:

#! /usr/bin/python
# -*- coding: utf-8 -*-

# OurManInToulon - Example image viewer in PyQt4
# Marcos Dione <mdione@grulic.org.ar> - http://grulicueva.homelinux.net/~mdione/glob/

# TODO:
#     * add licence! (GPLv2 or later)

from PyQt4.QtGui import QApplication, QMainWindow, QGraphicsView, QGraphicsScene
from PyQt4.QtGui import QPixmap, QGraphicsPixmapItem, QAction, QKeySequence
import sys

class OMITGraphicsView (QGraphicsView):
    def __init__ (self, pixmap, scene, parent, *args):
        QGraphicsView.__init__ (self, scene)
        self.zoomLevel= 1.0
        self.win= parent
        self.img= pixmap
        self.setupActions ()

    def setupActions (self):
        # a factory to the right!
        zoomfit= QAction (self)
        zoomfit.setShortcuts ([QKeySequence.fromString ('F1')])
        zoomfit.triggered.connect (self.zoomfit)
        self.addAction (zoomfit)

        zoom25= QAction (self)
        zoom25.setShortcuts ([QKeySequence.fromString ('F2')])
        zoom25.triggered.connect (self.zoom25)
        self.addAction (zoom25)

        zoom50= QAction (self)
        zoom50.setShortcuts ([QKeySequence.fromString ('F3')])
        zoom50.triggered.connect (self.zoom50)
        self.addAction (zoom50)

        zoom75= QAction (self)
        zoom75.setShortcuts ([QKeySequence.fromString ('F4')])
        zoom75.triggered.connect (self.zoom75)
        self.addAction (zoom75)

        zoom100= QAction (self)
        zoom100.setShortcuts ([QKeySequence.fromString ('F5')])
        zoom100.triggered.connect (self.zoom100)
        self.addAction (zoom100)

    def zoomfit (self, *ignore):
        winSize= self.size ()
        imgSize= self.img.size ()
        print winSize, imgSize
        hZoom= 1.0*winSize.width  ()/imgSize.width  ()
        vZoom= 1.0*winSize.height ()/imgSize.height ()
        zoomLevel= min (hZoom, vZoom)
        print zoomLevel
        self.zoomTo (zoomLevel)

    def zoom25 (self, *ignore):
        self.zoomTo (0.25)

    def zoom50 (self, *ignore):
        self.zoomTo (0.5)

    def zoom75 (self, *ignore):
        self.zoomTo (0.75)

    def zoom100 (self, *ignore):
        self.zoomTo (1.0)

    def zoomTo (self, zoomLevel):
        scale= zoomLevel/self.zoomLevel
        print "scaling", scale
        self.scale (scale, scale)
        self.zoomLevel= zoomLevel

if __name__=='__main__':
    # this code is enough for loading an image and show it!
    app= QApplication (sys.argv)
    win= QMainWindow ()

    pixmap= QPixmap (sys.argv[1])
    qgpi= QGraphicsPixmapItem (pixmap)
    scene= QGraphicsScene ()
    scene.addItem (qgpi)

    view= OMITGraphicsView (pixmap, scene, win)
    view.setDragMode (QGraphicsView.ScrollHandDrag)
    view.show()

    app.exec_ ()
    # up to here!

# end

Things to note:

  • The code for loading, showing the image and pan support is only 13 lines of Python code, including 3 imports. The resulting app is also able to handle vector graphics, but of course I didn't exploit that, I just added a QPixmap/QGraphicsPixmapItem pair.
  • Zooming is implemented via QGraphicsView.scale(), which is accumulative (scaling twice to 0.5 actually scales to 0.25 of the original size), so I have to keep the zoom level all the time. There should be a zoom() interface!
  • The code for calculating the scale level is not very good: scaling between 75% and 50% or 25% produces scales of 0.666 and 0.333, which I think at the end of the day will accumulate a lot of error.
  • For the same reason, zoomToFit() has to do some magic. I also got hit by the integer division of Python (I was getting zoom factors of 0) so I had to add 1.0* to the claculations. It's good that this is fixed in Python2.6/3.0.
  • The size reported by the QMainWindow was any vegetable (it said 640x480 when it actually was 960x600), so I used the QGraphicsView instead. WTF?
  • For some strange reason zoomToFit() scales the image a little bigger than it should, so a scrollbar appears in the direction of the constraining dimension.
  • Less that 100 lines! Even if setupActions() could surely be improved.
  • In Marcelo's favor I should mention that he writes docstrings for most of his methods both in english and spanish (yes, of course I read his code after I finished mine). I barely put a couple of comments, but doing the same should add 10 more lines, tops. Also, I don't want to convert this into a who-has-it-smaller contest (the code, I mean :).
  • It took me approx 3 hours, with no previous knowledge of how to do it and no internet connection, so no asking around. I just used the «Qt Reference Documentation», going to the «Gropued Classes» page and to the «Graphics View Classes» from there.
  • It doesn't zoom with the mouse wheel either.
  • The default colors of ikiwiki's format plugin are at most sucky, but better than nothing.

omit pykde python


[1] Unluckly he didn't declared which license it has, so I'm not sure if I really can do this. I GPL'ed mine.

Posted Wed 27 Jan 2010 11:55:55 PM CET Tags: pykde

So I mostly finished implementing a QAbstractTableModel subclass with read/write support, and some more stuff. Here's a resume of what changed from the implementation of [a read-only QAbstractListModel subclass](http://grulicueva.homelinux.net/~mdione/glob/posts/qlistmodel-in-pyqt/ ).

Besides implementing columnCount(), the first important difference occurs in data() itself. Now the QModelIndex.column() is important, because it tells us which column the view wants to populate. In the case of the DisplayRole I ask the Song for the corresponding atribute/key[1]; in the case of the SizeHintRole I use a static array of strings to provide default values[2]. I will explain why later:

    def data (self, modelIndex, role):
        if modelIndex.isValid () and modelIndex.row ()<self.aggr.count:
            song= self.aggr.songForIndex (modelIndex.row ())

            if role==Qt.DisplayRole:
                attr= self.attrNames [modelIndex.column ()]
                data= QVariant (song[attr])
            elif role==Qt.SizeHintRole:
                size= self.fontMetrics.size (Qt.TextSingleLine, self.columnWidths[modelIndex.column ()])
                data= QVariant (size)
            else:
                data= QVariant ()
        else:
            data= QVariant ()

        return data

The second difference is related to the view itself. A QTableView presents not only the contents but also what they call 'headers'. These are the column headers ans row numbers that normally appear on top and left of tables. By default numbers are shown, which in may case is ok for rows, but not for columns. So we implement headerData(), which is somehow similar to data(); the section parameter refers to the column when direction is Horizontal:

    def headerData (self, section, direction, role=Qt.DisplayRole):
        if direction==Qt.Horizontal and role==Qt.DisplayRole:
            data= QVariant (self.headers[section])
        else:
            data= QAbstractTableModel.headerData (self, section, direction, role)

        return data

Once I have a basic read-only table, I start nitpicking on its appearance. First, all the columns have the same width, which is not acceptable. I want narrower 'Year', 'Track' and 'Length' columns, wider 'Artist', 'Album' and 'Title' columns and a huge 'Filepath' column[3]. So, we set the column widths from the ui:

class MainWindow (KMainWindow):
    [...]

    def connectUi (self, player):
        [...]
        # FIXME: kinda hacky
        self.fontMetrics= QFontMetrics (KGlobalSettings.generalFont ())
        for i, w in enumerate (self.model.columnWidths):
            self.ui.songsList.setColumnWidth (i, self.fontMetrics.width (w))

class QPlayListModel (QAbstractTableModel):
    def __init__ (self, aggr=None, songs=None, parent=None):
        QAbstractTableModel.__init__ (self, parent)
        [...]
        # FIXME: hackish
        self.columnWidths= ("M"*15, "M"*4, "M"*20, "M"*3, "M"*25, "M"*5, "M"*100)

Now the rows, the rows! They're too thick! I can only see tweintysomething songs in my 1440x900 screen with a small plasma panel on top, and I already got rid of the empty menubar and the status bar[4]. headerData() is also called with a SizeHintRole, so let's use it (once more, the third time already, using the hack of QFontMetrics). Note that it also sets the width; and while I'm on it, I also align the row numbers to the right:

    def headerData (self, section, direction, role=Qt.DisplayRole):
        if direction==Qt.Horizontal and role==Qt.DisplayRole:
            data= QVariant (self.headers[section])
        elif direction==Qt.Vertical:
            if role==Qt.SizeHintRole:
                # again, hacky. 5 for enough witdh for 5 digits
                size= self.fontMetrics.size (Qt.TextSingleLine, "X"*5)
                data= QVariant (size)
            elif role==Qt.TextAlignmentRole:
                data= QVariant (Qt.AlignRight|Qt.AlignVCenter)
            else:
                data= QAbstractTableModel.headerData (self, section, direction, role)
        else:
            data= QAbstractTableModel.headerData (self, section, direction, role)

        return data

Now I can see 36 rows maximized and almost 39 when in full screen. Maybe I can make the columns headers thinner... but once more I digress.

Now to the interesting part: making the model read/write. First we must implement a method called flags() which says a lot of things about cells; in particular, if they're editable or not. Clearly 'Length' and 'Filepath' are not editable[5]:

    def flags (self, modelIndex):
        ans= QAbstractTableModel.flags (self, modelIndex)
        if modelIndex.column ()<5: # length or filepath are not editable
            ans|= Qt.ItemIsEditable

        return ans

And finally, setData() itself. It has a similar interface as data(), but including the new value. It should return if it could successfully change the data value, which in this case means if Tagpy/Taglib could modify the tag. Note that the role while changing the value is EditRole:

    def setData (self, modelIndex, variant, role=Qt.EditRole):
        # not length or filepath and editing
        if modelIndex.column ()<5 and role==Qt.EditRole:
            song= self.aggr.songForIndex (modelIndex.row ())
            attr= self.attrNames[modelIndex.column ()]
            try:
                song[attr]= unicode (variant.toString ())
            except TagWriteError:
                ans= False
            else:
                self.dataChanged.emit (modelIndex, modelIndex)
                ans= True
        else:
            ans= QAbstractTableModel.setData (self, modelIndex, variant, role)

        return ans

It's really that simple. Now we have a QTableView and a QAbstractTableModel which are read/write. This means that satyr can modify and save tags now, which is one of my most wanted features.

One last detail: for the way satyr has its QTableView configured, one can enter in edit mode only by typing something in a cell[6], but then there are two columns which cannot be modified. If you type something while the cursor is in one of these columns, QTableView will call match() for matches to the typed letters. The default implementation basically searches through all the cells, which means that data() is called for all the Songs, which finally implies that all the tags are read from disk, which is expensive for making the simple mistake of typing in a read-only column. As satyr already has a search feature, I fixed this 'misfeature' with this code:

    def match (self, start, role, value, hits=1, flags=None):
        # when you press a key on an uneditable cell, QTableView tries to search
        # calling this function for matching. we already have a way for searching
        # and it loads the metadata of all the songs anyways
        # so we disable it by constantly returning an empty list
        return []

satyr pykde python


[1] I have my reasons to provide both interfaces.

[2] Plans for saving the state of the satyr main window will include the column widths, but that's another story.

[3] It's usefull for writing tags of Songs with no tags... and because I can!

[4] ... which might return in the future to show scanning process, but again I digress.

[5] Yes, another digression: at some point it will be possible to move the file to a filepath based on its tags; in other words, to arrange your music collection based on tags. Should not be too far in the future.

[6] Last digression: I will modify this behaviour as soon as I finish with this post.

Posted Wed 27 Jan 2010 11:55:55 PM CET Tags: pykde

One of the features I planned for satyr almost since the begining was the possibility to have 'skins'. In this context, a skin would not only implement the look and feel, but also could implement features the weren't available in the shipped classes. I also planned to implement this feature after I had most of the others one already done. But then I was bored this weekend with nothing to do and I decided to set off to at least investigate how to do it. Of course, what happened was that I implemented it almost completely.

Up to now, satyr's user interface was implemented in two files: default.ui, which was compiled with pyuic4 into default.py, and some code in satyr.py itself. This of course would not scale, and I always had the idea of moving the behaviour implemented in satyr.py to a file called default.py and load the ui directly from the default.ui file without compiling, getting rid of the need for a compilation at the same time. This also meant that then a skin would consist of a .py file and possibly a .ui file. There are three problems to solve for this: getting the local-to-the-user's skin directory, loading the skin and loading the correspondant .ui file.

The first part is simple from the PyKDE4 point of view:

# get the app's dir; don't forget the trailing '/'!
appDir= KStandardDirs.locateLocal ('data', 'satyr/')

I'll first explain the other two parts, loading the skin and the .ui file, before returning more deeply to the consequences of this solution.

I put all the skins in a skins subdirectory. To make it a proper python module I added an empty __init__.py file. Now, I could simply import skins.<skinName> and possibly instantiate some class in it, but of course one cannot write that. I could resort to eval ('import skins.'+skinName), but we know that eval() has the most long-standing typo in the history of computer languages, and it's actually called evil().

What we can do is resort to __import__() instead. This little function does approximately what we want. I say approx because it has some surprises in the sleeves of its sleeveless code. I suggest you to go read carefully its documentation. Meanwhile, the magic itself:

mod= __import__ ('skins.'+skinName, globals(), locals(), 'MainWindow')
mw= mod.MainWindow ()

Loading the .ui file in the skin's code is rather simple: just get the skin module's filepath, replace .py with .ui, and load it with PyQt4.uic.loadUiType()[1]. This function returns a generated class for the topmost widget and its Qt base class. This generated class has a setupUi() method that is the one that actually builds de UI[2]. So, we just instantiate the main window's class and call its setupUi() method:

from PyQt4 import uic

# !!! __file__ might end with .py[co]!
uipath= __file__[:__file__.rfind ('.')]+'.ui'
# I don't care about the base class
(UIMainWindow, buh)= uic.loadUiType (uipath)

self.ui= UIMainWindow ()
self.ui.setupUi (self)

Note the comment about the __file__ attribute of a module.

Now, and back to the first part, finding the local-to-the-user's skin directory is the easiest part. From there, things get a little bit more complicated:

  • The skins subdirectory might not exist.
  • If you create it, you gotta make sure to also throw in a __init__.py file.
  • Once you've done it, you also need to add the local-to-the-user's app directory to the path. It's easy, just prepend it to sys.path, so it's used before any system-wide directory.
  • The last problem that remains is exactly that: once the __init__.py is there and the user's dir is prepended to sys.path, the user's local skin directory is always used when importing anything from the skins module, so if a skin is not there it is not loadable. All skins distributed with satyr will be inaccesible!

So I'm in a kind of dead alley here. I have a couple of ideas on how to work-around this, but they're at best hacky, and I don't want to implement them until I'm sure that it's inevitable.


satyr pykde python


[1] Not a very happy name, if you ask me.

[2] Very similar to what you get if you compile the .ui with pyuic4.

Posted Wed 27 Jan 2010 11:55:55 PM CET Tags: pykde

So far both the default and complex skins in satyr were (ab)using the selection in their respectives views to highlight the current song and, in the case of the complex skin, that meant that you couldn't select cells. This could be (and will be) handy for editing several tags at the same time or for grouping songs in an Album. I decided that this should not longer be this way.

It was really simple: I simple reenabled selection, the extended version (Shift and Control work as you grew to spect). Now the way to highlight the current song is to simply change the background and foreground colors for the cells of that row. How? Simple, extending the QPlayListModel.data() method to provide these properties and using QApplication.palette() to get colors that would stand out from the normal application colors (even the selection) and would still make the text readable:

    def data (self, modelIndex, role):
        if modelIndex.isValid () and modelIndex.row ()<self.aggr.count:
            [...]
            # check the row so we highlight the whole row and not just the cell
            elif role==Qt.BackgroundRole and modelIndex.row ()==self.parent_.modelIndex.row ():
                # highlight the current song
                # must return a QBrush
                data= QVariant (QApplication.palette ().dark ())

            elif role==Qt.ForegroundRole and modelIndex.row ()==self.parent_.modelIndex.row ():
                # highlight the current song
                # must return a QBrush
                data= QVariant (QApplication.palette ().brightText ())
            [...]

And that's almost it... because there's always a but. Whenever the current song changes, there's a little lag between this happens (you can hear the new song being played) and the highlighting updates. This is because I update that self.parent_.modelIndex you see up there, but the QPLM.data() method is not called until the next repaint, which doesn't happen inmediately. So, we must force that repaint. How? Again, it's simple: we already emited the dataChanged() signal in the method setData() when the data could be successfully changed. Now we do the same just so the view updates the highlighting:

    def showSong (self, index):
        [...]
        # FIXME? yes, this could be moved to the model (too many self.appModel's)
        start= self.appModel.index (oldModelIndex.row (), 0)
        end=   self.appModel.index (oldModelIndex.row (), columns)
        self.appModel.dataChanged.emit (start, end)

        start= self.appModel.index (self.modelIndex.row (), 0)
        end=   self.appModel.index (self.modelIndex.row (), columns)
        self.appModel.dataChanged.emit (start, end)
        [...]

satyr pykde python

Posted Wed 27 Jan 2010 11:55:55 PM CET Tags: pykde