=== modified file 'NEWS'
--- NEWS	2008-01-30 10:24:58 +0000
+++ NEWS	2008-02-21 13:18:20 +0000
@@ -13,12 +13,21 @@
       - DBUS is used instead of DCOP, bla bla bla.
 	(TODO)
 
+  IMPROVEMENTS
+
+    * Command line arguments that are directories will not be simply
+      discarded, and all playable files underneath them will be added to
+      the playlist.
+
   FOO
 
     * XXX Mention the improvement when loading in the tree view a
       directory which had already been opened.
 
 
+0.9	IN DEVELOPMENT
+
+
 0.8.1	2008-01-29
 
   BUGFIXES

=== modified file 'README.Bugs'
--- README.Bugs	2008-02-22 11:51:16 +0000
+++ README.Bugs	2008-02-29 11:45:16 +0000
@@ -34,11 +34,15 @@
 Known bugs
 ==========
 
-XXX-KDE4
-Some features (notably global keybindings and configuring toolbar layout)
-do not work due to shortcomings in the PyKDE bindings. See pykde-bugs/README
-for details. Upstream is not very interested in fixing them for KDE 3,
-hopefully version 4 won't suffer from them.
-
-Known bugs that can/should get fixed some day (earlier if there's enough
-demand for it), are documented in the TODO file.
+Here is a list of known bugs or quirks that I don't plan on addressing,
+most likely because I wouldn't know how -- help or patches welcome. Known
+bugs that can/should get fixed someday are documented in the TODO file.
+
+  * When configuring the toolbars, the Undo/Redo actions appear twice.
+
+    (This is because there are two pairs of Undo/Redo actions: a QAction
+    pair, created with QUndoStack.createUndo/RedoAction(), that is meant
+    to be on the toolbar (they know when to disable themselves, etc.),
+    and a KAction pair, present so that Undo/Redo's shortcuts can be
+    configured -- KDE does not support configuring shortcuts for
+    QActions at the moment.)

=== modified file 'TODO'
--- TODO	2008-01-30 10:24:58 +0000
+++ TODO	2008-02-22 17:33:10 +0000
@@ -4,12 +4,13 @@
   * As we ship icons, we have to "touch `kde4-config --expandvars
     --install icon`/hicolor", both in setup.sh and postinst! (postrm
     too?)
+  * Using util.playable_from_untrusted in load_saved_playlist()
+    eliminates duplicates.
 
 Tree View:
   * Use a regex as well to control the appearance of file names?
 
 Playlist:
-  * Undo/redo?
   * Tabs??
   * Previous button behaves more like a "Back" button: does not go to
     the item above the current item, but to the item played right before
@@ -18,8 +19,14 @@
     not show the current item as visible.
   * Limiting the search, and removing all visible items, removes all
     non-visible items in between.
-  * Er, can't drag & drop tracks into the *first position* of the
-    playlist.
+  * Allow "read tags when item visible", à-la-xmms?
+
+  * Make Undo preserve stuff like the "stop after".
+  * Make Undo/Redo preserve the selection?
+
+  * The current item border pans over all width of they playlist,
+    whereas the "show focus" border from Qt only until the width of the
+    rightmos column.
 
 UI:
   * save size of the sides of QSplitter

=== removed file 'config/minirokrc'
--- config/minirokrc	2007-08-11 13:08:02 +0000
+++ config/minirokrc	1970-01-01 00:00:00 +0000
@@ -1,3 +0,0 @@
-[Playlist Columns]
-Visible=Track,Artist,Title,Length
-Order=Track,Artist,Album,Title,Length

=== modified file 'config/minirokui.rc'
--- config/minirokui.rc	2008-01-25 11:42:51 +0000
+++ config/minirokui.rc	2008-01-30 11:51:03 +0000
@@ -14,6 +14,9 @@
     <Action name="action_stop" />
     <Action name="action_next" />
     <Separator/>
+    <Action name="action_playlist_undo" />
+    <Action name="action_playlist_redo" />
+    <Separator/>
     <Action name="action_clear_playlist" />
   </ToolBar>
 </gui>

=== modified file 'debian/changelog'
--- debian/changelog	2008-01-30 10:24:58 +0000
+++ debian/changelog	2008-02-06 19:26:12 +0000
@@ -4,6 +4,12 @@
 
  -- Adeodato Simó <dato@net.com.org.es>  Thu, 03 Jan 2008 17:11:18 +0100
 
+minirok (0.9-1) UNRELEASED; urgency=low
+
+  * New upstream release.
+
+ -- Adeodato Simó <dato@net.com.org.es>  Sat, 02 Feb 2008 12:24:12 +0100
+
 minirok (0.8.1-1) unstable; urgency=low
 
   * New upstream bugfix release:

=== removed file 'images/black_tiny_stop.png'
Binary files images/black_tiny_stop.png	2007-08-24 16:55:31 +0000 and images/black_tiny_stop.png	1970-01-01 00:00:00 +0000 differ
=== modified file 'minirok/dcop.py'
--- minirok/dcop.py	2007-09-01 12:46:11 +0000
+++ minirok/dcop.py	2008-02-11 16:44:54 +0000
@@ -34,18 +34,19 @@
         self.addMethod('void appendToPlaylist(QStringList)', self.append_to_playlist)
 
     def formatted_now_playing(self, format=None):
-        currently_playing = minirok.Globals.playlist.currently_playing
-        if currently_playing is None:
+        tags = minirok.Globals.playlist.get_current_tags()
+
+        if not tags:
             formatted = ''
         else:
             if format is not None:
                 try:
-                    formatted = str(format) % currently_playing
+                    formatted = str(format) % tags
                 except (KeyError, ValueError, TypeError), e:
                     formatted = '>> Error when formatting string: %s' % e
             else:
-                title = currently_playing['Title']
-                artist = currently_playing['Artist']
+                title = tags['Title']
+                artist = tags['Artist']
                 if artist is not None:
                     formatted = '%s - %s' % (artist, title)
                 else:

=== modified file 'minirok/drag.py'
--- minirok/drag.py	2008-02-04 10:41:32 +0000
+++ minirok/drag.py	2008-02-06 15:46:33 +0000
@@ -46,48 +46,3 @@
 
             self.setMimeData(mimedata)
             self.setPixmap(self.pixmap)
-
-##
-
-def mimedata_playable_files(mimedata):
-    """Return a list of playable stuff from a QMimeData object.
-
-    The QMimeData object must haveUrls(). Files that the engine can't play
-    will not be included, and directories will be read and all its files
-    included.
-
-    However, if the QMimeData comes from Minirok itself (as determined by
-    the presence of FileListDrag.MIME_TYPE, all URLs will be considered to
-    be files (and not directories) that the engine can play.
-    """
-    assert mimedata.hasUrls(), 'You tried to drop something without URLs.'
-    urls = kdecore.KUrl.List.fromMimeData(mimedata)
-
-    if mimedata.hasFormat(FileListDrag.MIME_TYPE):
-        return map(util.kurl_to_path, urls)
-    else:
-        files = []
-        def append_path(path):
-            try:
-                mode = os.stat(path).st_mode
-            except OSError, e:
-                minirok.logger.warn('ignoring dropped %r: %s' % (path, e))
-                return
-
-            if stat.S_ISDIR(mode):
-                try:
-                    contents = sorted(os.listdir(path))
-                except OSError, e:
-                    minirok.logger.warn('could not list directory %r: %s', path, e)
-                else:
-                    for entry in contents:
-                        append_path(os.path.join(path, entry))
-            elif (stat.S_ISREG(mode)
-                    and minirok.Globals.engine.can_play(path)
-                    and path not in files):
-                files.append(path)
-
-        for url in urls:
-            append_path(util.kurl_to_path(url))
-
-        return files

=== modified file 'minirok/engine.py'
--- minirok/engine.py	2008-01-11 11:07:46 +0000
+++ minirok/engine.py	2008-02-27 17:52:02 +0000
@@ -13,6 +13,8 @@
 import minirok
 from PyQt4 import QtCore
 
+gobject.threads_init()
+
 ##
 
 class State:
@@ -111,7 +113,7 @@
     def _message_eos(self, bus, message):
         self.bin.set_state(gst.STATE_NULL)
         self.status = State.STOPPED
-        self.emit(QtCore.SIGNAL('end_of_stream'), self.uri)
+        self.emit(QtCore.SIGNAL('end_of_stream'))
 
     def _message_error(self, bus, message):
         error, debug_info = message.parse_error()

=== modified file 'minirok/lastfm_submit.py'
--- minirok/lastfm_submit.py	2008-02-28 12:30:08 +0000
+++ minirok/lastfm_submit.py	2008-02-29 11:45:16 +0000
@@ -55,7 +55,7 @@
         func(self.timer, QtCore.SIGNAL('timeout()'), self.slot_submit)
 
     def slot_new_track(self):
-        all_tags = minirok.Globals.playlist.currently_playing
+        all_tags = minirok.Globals.playlist.get_current_tags()
         self.data = dict((k.lower(), v) for k, v in all_tags.items()
                                         if k in ['Title', 'Artist', 'Length'])
 

=== modified file 'minirok/main_window.py'
--- minirok/main_window.py	2008-02-28 11:59:22 +0000
+++ minirok/main_window.py	2008-02-29 11:45:16 +0000
@@ -182,7 +182,7 @@
     def eventFilter(self, object_, event):
         if (object_ == self
                 and event.type() == QtCore.QEvent.ToolTip):
-            tags = minirok.Globals.playlist.currently_playing
+            tags = minirok.Globals.playlist.get_current_tags()
             if tags:
                 title = tags.get('Title', '')
                 artist = tags.get('Artist', '')

=== modified file 'minirok/playlist.py'
--- minirok/playlist.py	2008-01-30 10:24:58 +0000
+++ minirok/playlist.py	2008-02-28 22:42:00 +0000
@@ -6,38 +6,41 @@
 
 import os
 import re
-import sys
 import errno
 
+from PyQt4.QtCore import Qt
 from PyQt4 import QtGui, QtCore
 from PyKDE4 import kdeui, kdecore
 
 import minirok
-from minirok import engine, tag_reader, util # XXX-KDE4 drag
+from minirok import drag, engine, tag_reader, util
 
 ##
 
-class Playlist(QtGui.QTreeWidget, util.HasConfig, util.HasGUIConfig):
+class Playlist(QtCore.QAbstractTableModel, util.HasConfig):#, util.HasGUIConfig):
     # This is the value self.current_item has whenver just the first item on
     # the playlist should be used. Only set to this value when the playlist
     # contains items!
     FIRST_ITEM = object()
 
     def __init__(self, *args):
-        QtGui.QTreeWidget.__init__(self, *args)
+        QtCore.QAbstractTableModel.__init__(self, *args)
         util.HasConfig.__init__(self)
-        util.HasGUIConfig.__init__(self)
+        # util.HasGUIConfig.__init__(self)
 
-        # XXX-KDE4
-        self.init_actions()
-        return
+        # Core model stuff
+        self._itemlist = []
+        self._row_count = 0
+        self._empty_model_index = QtCore.QModelIndex()
+        self._column_count = len(PlaylistItem.ALLOWED_TAGS)
 
         self.queue = []
         self.visualizer_rect = None
-        self.columns = Columns(self)
         self.stop_mode = StopMode.NONE
+        self.tag_reader = tag_reader.TagReader()
         self.random_queue = util.RandomOrderedList()
-        self.tag_reader = tag_reader.TagReader()
+
+        self.tag_reader.start()
 
         # these have a property() below
         self._stop_after = None
@@ -46,32 +49,11 @@
         self._current_item = None
         self._currently_playing = None
 
-        self._currently_playing_taken = False
-
-        self.setSorting(-1)
-        self.setAcceptDrops(True)
-        self.setDragEnabled(True)
-        self.setAllColumnsShowFocus(True)
-        self.setSelectionModeExt(kdeui.KListView.Extended)
-
-        self.header().installEventFilter(self)
-
-        self.connect(self, qt.SIGNAL('dropped(QDropEvent *, QListViewItem *)'),
-                self.slot_accept_drop)
-
-        self.connect(self, qt.SIGNAL('returnPressed(QListViewItem *)'),
-                self.slot_new_current_item)
-
-        self.connect(self, qt.SIGNAL('doubleClicked(QListViewItem *, const QPoint &, int)'),
-                self.slot_new_current_item)
-
-        self.connect(self, qt.SIGNAL('mouseButtonPressed(int, QListViewItem *, const QPoint &, int)'),
-                self.slot_mouse_button_pressed)
-
-        self.connect(self, qt.SIGNAL('moved()'), self.slot_list_changed)
-
         self.connect(self, QtCore.SIGNAL('list_changed'), self.slot_list_changed)
 
+        self.connect(self.tag_reader, QtCore.SIGNAL('items_ready'),
+                self.slot_update_tags)
+
         self.connect(minirok.Globals.engine, QtCore.SIGNAL('status_changed'),
                 self.slot_engine_status_changed)
 
@@ -79,10 +61,246 @@
                 self.slot_engine_end_of_stream)
 
         self.init_actions()
+        self.init_undo_stack()
         self.apply_preferences()
         self.load_saved_playlist()
 
-    ##
+        # XXX This is dataChanged() abuse: there are a bunch of places in which
+        # the model wants to say: "my state (but not my data) changed somehow,
+        # you may want to redraw your visible parts if you're paying attention
+        # to state". I don't know of a method in the view that will do that
+        # (redisplay the visible part calling with the appropriate drawRow()
+        # and Delegate.paint() calls, without needing to refetch data()), so
+        # I'm abusing dataChanged(0, 1) for this purpose, which seems to work!
+        self.connect(self, QtCore.SIGNAL('repaint_needed'),
+                            lambda: self.my_emit_dataChanged(0, 1))
+
+    ##
+
+    """Model functions."""
+
+    def rowCount(self, parent=None):
+        if parent is None or parent == self._empty_model_index:
+            return self._row_count
+        else:
+            return 0 # as per QAbstractItemModel::rowCount docs
+
+    def columnCount(self, parent=None):
+        return self._column_count
+
+    def data(self, index, role):
+        row = index.row()
+        column = index.column()
+
+        if (role != Qt.DisplayRole
+                or not index.isValid()
+                or row > self._row_count
+                or column > self._column_count):
+            return QtCore.QVariant()
+        else:
+            return QtCore.QVariant(QtCore.QString(
+                        self._itemlist[row].tag_by_index(column) or ''))
+
+    def headerData(self, section, orientation, role):
+        if (role == Qt.DisplayRole and orientation == Qt.Horizontal):
+            return QtCore.QVariant(
+                    QtCore.QString(PlaylistItem.ALLOWED_TAGS[section]))
+        else:
+            return QtCore.QVariant()
+
+    ##
+
+    """Methods used by the view, header, and delegate."""
+
+    def sorted_column_names(self):
+        return PlaylistItem.ALLOWED_TAGS[:]
+
+    def row_is_stop_after(self, row):
+        assert 0 <= row < self._row_count
+        return self._itemlist[row] is self.stop_after
+
+    def row_is_current(self, row):
+        assert 0 <= row < self._row_count
+        return self._itemlist[row] is self.current_item
+
+    def row_is_playing(self, row):
+        assert 0 <= row < self._row_count
+        return self._itemlist[row] is self.currently_playing
+
+    def row_queue_position(self, row):
+        assert 0 <= row < self._row_count
+        return self._itemlist[row].queue_position or 0
+
+    ## 
+
+    """Drag and drop functions."""
+
+    PLAYLIST_DND_MIME_TYPE = 'application/x-minirok-playlist-dnd'
+
+    def supportedDropActions(self):
+        return Qt.CopyAction | Qt.MoveAction
+
+    def mimeTypes(self):
+        types = QtCore.QStringList()
+        types.append('text/uri-list')
+        types.append(self.PLAYLIST_DND_MIME_TYPE)
+        return types
+
+    def flags(self, index):
+        if index.isValid():
+            return (Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled)
+        else:
+            return Qt.ItemIsDropEnabled
+
+    def mimeData(self, indexes):
+        """Encodes a list of the rows in indexes."""
+        mimedata = QtCore.QMimeData()
+        bytearray = QtCore.QByteArray()
+        datastream = QtCore.QDataStream(bytearray, QtCore.QIODevice.WriteOnly)
+        rows = set(x.row() for x in indexes)
+        datastream.writeUInt32(len(rows))
+        for row in rows:
+            datastream.writeUInt32(row)
+        mimedata.setData(self.PLAYLIST_DND_MIME_TYPE, bytearray)
+        return mimedata
+
+    def dropMimeData(self, mimedata, action, row, column, index):
+        if mimedata.hasUrls():
+            files = map(util.kurl_to_path,
+                            kdecore.KUrl.List.fromMimeData(mimedata))
+
+            if not mimedata.hasFormat(drag.FileListDrag.MIME_TYPE):
+                # Drop does not come from ourselves, so:
+                files = util.playable_from_untrusted(files, warn=False)
+
+            if (QtGui.QApplication.keyboardModifiers() & Qt.ControlModifier):
+                row = -1
+
+            self.add_files(files, position=row)
+            return True
+
+        elif mimedata.hasFormat(self.PLAYLIST_DND_MIME_TYPE):
+            bytearray = mimedata.data(self.PLAYLIST_DND_MIME_TYPE)
+            datastream = QtCore.QDataStream(bytearray, QtCore.QIODevice.ReadOnly)
+            rows = set(datastream.readUInt32()
+                        for x in range(datastream.readUInt32()))
+
+            if row < 0:
+                row = self._row_count
+
+            # now, we remove items after the drop, so...
+            row -= len(filter(lambda r: r <= row, rows))
+
+            self.undo_stack.beginMacro('move ' + _n_tracks_str(len(rows)))
+            try:
+                removecmd = RemoveItemsCmd(self, rows, do_queue=False)
+                InsertItemsCmd(self, row, removecmd.get_items(), do_queue=False)
+            finally:
+                self.undo_stack.endMacro()
+
+            # restore the selection: better UI experience
+            top = self.index(row, 0, QtCore.QModelIndex())
+            bottom = self.index(row + len(rows) - 1, 0, QtCore.QModelIndex())
+            self.selection_model.select(QtGui.QItemSelection(top, bottom),
+                    QtGui.QItemSelectionModel.Rows
+                    | QtGui.QItemSelectionModel.ClearAndSelect)
+            self.selection_model.setCurrentIndex(
+                    min(rows) > row and top or bottom, # \o/
+                    QtGui.QItemSelectionModel.Rows
+                    | QtGui.QItemSelectionModel.NoUpdate)
+            return True
+
+        else:
+            return False
+
+    ##
+
+    """Adding and removing items to _itemlist.
+    
+    (NB: No other function should modify _itemlist directly.)
+    """
+
+    def insert_items(self, position, items):
+        # if currently_playing is absent, we'll check whether
+        # it's getting re-added in this call
+        current_item = None
+        if (self.current_item in (None, self.FIRST_ITEM)
+                and self.currently_playing is not None):
+            playing_path = self.currently_playing.path
+        else:
+            playing_path = None
+
+        try:
+            nitems = len(items)
+            for item in self._itemlist[position:]:
+                item.position += nitems
+            for i, item in enumerate(items):
+                item.position = position + i
+                if (playing_path is not None
+                        and playing_path == item.path):
+                    current_item = item
+                    playing_path = None
+            self.beginInsertRows(QtCore.QModelIndex(),
+                                 position, position + nitems - 1)
+            self._itemlist[position:0] = items
+            self._row_count += nitems
+        finally:
+            self.endInsertRows()
+
+        self.random_queue.extend(x for x in items if not x.already_played)
+        self.tag_reader.queue_many(x for x in items if x.needs_tag_reader)
+
+        self.emit(QtCore.SIGNAL('list_changed'))
+
+        if current_item is not None:
+            self.current_item = self.currently_playing = current_item
+
+    def remove_items(self, position, amount):
+        items = self._itemlist[position:position+amount]
+
+        for item in items:
+            if item.needs_tag_reader:
+                self.tag_reader.dequeue(item)
+            if not item.already_played:
+                try:
+                    self.random_queue.remove(item)
+                except ValueError:
+                    pass
+            if item is self.current_item:
+                self.current_item = self.FIRST_ITEM
+
+            item.position = None
+
+        for item in self._itemlist[position+amount:]:
+            item.position -= amount
+
+        try:
+            self.beginRemoveRows(QtCore.QModelIndex(),
+                                 position, position + amount - 1)
+            self._itemlist[position:position+amount] = []
+            self._row_count -= amount
+        finally:
+            self.endRemoveRows()
+
+        self.emit(QtCore.SIGNAL('list_changed'))
+        return items
+
+    def clear_itemlist(self):
+        self.current_item = None
+        self.random_queue[:] = []
+        self.tag_reader.clear_queue()
+
+        items = self._itemlist[:]
+        self._row_count = 0
+        self._itemlist[:] = []
+        self.reset()
+
+        self.emit(QtCore.SIGNAL('list_changed'))
+        return items
+
+    ##
+
+    """Initialization."""
 
     def init_actions(self):
         self.action_play = util.create_action('action_play', 'Play',
@@ -116,26 +334,41 @@
                 self.slot_toggle_stop_after_current, 'media-playback-stop',
                 'Ctrl+K')#, 'Ctrl+I+K')
 
-    def column_index(self, col_name):
-        try:
-            return self.columns.index(col_name)
-        except Columns.NoSuchColumn:
-            minirok.logger.critical('column %r not found', col_name)
-            sys.exit(1)
+    def init_undo_stack(self):
+        self.undo_stack = QtGui.QUndoStack(self)
+
+        self.undo_action = self.undo_stack.createUndoAction(self)
+        self.redo_action = self.undo_stack.createRedoAction(self)
+
+        self.undo_action.setIcon(kdeui.KIcon('edit-undo'))
+        self.redo_action.setIcon(kdeui.KIcon('edit-redo'))
+
+        ac = minirok.Globals.action_collection
+        ac.addAction('action_playlist_undo', self.undo_action)
+        ac.addAction('action_playlist_redo', self.redo_action)
+
+        # Now, we need this for the shortcuts to be configurable...
+        # Note: not using KStandardAction.undo()/redo(), because they'll want
+        # to appear in the main toolbar, and we want that one to be empty.
+        self.undo_kaction = util.create_action('kaction_playlist_undo',
+                'Undo', self.undo_stack.undo, 'edit-undo',
+                kdeui.KStandardShortcut.shortcut(kdeui.KStandardShortcut.Undo))
+        self.redo_kaction = util.create_action('kaction_playlist_redo',
+                'Redo', self.undo_stack.redo, 'edit-redo',
+                kdeui.KStandardShortcut.shortcut(kdeui.KStandardShortcut.Redo))
 
     ##
 
+    """Properties."""
+
     def _set_stop_after(self, value):
-        update = lambda: \
-                self._stop_after is not None and self._stop_after.repaint()
-
-        update()
         self._stop_after = value
-        update()
 
         if value is None:
             self.stop_mode = StopMode.NONE
 
+        self.emit(QtCore.SIGNAL('repaint_needed'))
+
     stop_after = property(lambda self: self._stop_after, _set_stop_after)
 
     def _set_repeat_mode(self, value):
@@ -151,16 +384,7 @@
     random_mode = property(lambda self: self._random_mode, _set_random_mode)
 
     def _set_current_item(self, value):
-        def set_current(current):
-            if self.current_item not in (self.FIRST_ITEM, None):
-                if current:
-                    self.ensureItemVisible(self.current_item)
-                self.current_item.set_current(current)
-                self.current_item.repaint()
-
-        set_current(False)
-
-        if not (value is self.FIRST_ITEM and self.childCount() == 0):
+        if not (value is self.FIRST_ITEM and self._row_count == 0):
             self._current_item = value
             try:
                 self.random_queue.remove(value)
@@ -169,35 +393,27 @@
         else:
             self._current_item = None
 
-        set_current(True)
         self.emit(QtCore.SIGNAL('list_changed'))
+        self.emit(QtCore.SIGNAL('repaint_needed'))
+
+        if self.current_item not in (self.FIRST_ITEM, None):
+            self.emit(QtCore.SIGNAL('scroll_needed'),
+                    self.index(self.current_item.position, 0))
 
     current_item = property(lambda self: self._current_item, _set_current_item)
 
-    def _get_currently_playing(self):
-        """Return a dict of the tags of the currently played track, or None."""
-        if self._currently_playing is not None:
-            return self._currently_playing._tags # XXX Private member!
-        else:
-            return None
-
     def _set_currently_playing(self, item):
-        def set_playing(value):
-            if self._currently_playing not in (self.FIRST_ITEM, None):
-                self._currently_playing.set_playing(value)
-                self._currently_playing.repaint()
-
-        set_playing(False)
         self._currently_playing = item
-        self._currently_playing_taken = False
-        set_playing(True)
+        self.emit(QtCore.SIGNAL('repaint_needed'))
 
-    currently_playing = property(_get_currently_playing, _set_currently_playing)
+    currently_playing = property(lambda self: self._currently_playing, _set_currently_playing)
 
     ##
 
+    """Maintain the state of actions current."""
+
     def slot_list_changed(self):
-        if self.childCount() == 0:
+        if self._row_count == 0:
             self._current_item = None # can't use the property here
             self.action_next.setEnabled(False)
             self.action_clear.setEnabled(False)
@@ -206,15 +422,15 @@
             if self.current_item is None:
                 self._current_item = self.FIRST_ITEM
             if self.current_item is self.FIRST_ITEM:
-                current = self.firstChild()
+                current = 0
             else:
-                current = self.current_item
+                current = self.current_item.position
             self.action_clear.setEnabled(True)
-            self.action_previous.setEnabled(bool(current.itemAbove()))
+            self.action_previous.setEnabled(current > 0)
             self.action_next.setEnabled(bool(self.queue
                     or self.repeat_mode == RepeatMode.PLAYLIST
-                    or ((self.random_mode and self.random_queue)
-                        or (not self.random_mode and current.itemBelow()))))
+                    or (self.random_mode and self.random_queue)
+                    or (not self.random_mode and current+1 < self._row_count)))
 
         self.slot_engine_status_changed(minirok.Globals.engine.status)
 
@@ -223,17 +439,17 @@
             self.action_stop.setEnabled(False)
             self.action_pause.setEnabled(False)
             self.action_pause.setChecked(False)
-            self.action_play.setEnabled(bool(self.current_item))
+            self.action_play.setEnabled(self._row_count > 0)
             self.action_play_pause.setChecked(False)
-            self.action_play_pause.setIcon('player_play')
-            self.action_play_pause.setEnabled(bool(self.current_item))
+            self.action_play_pause.setEnabled(self._row_count > 0)
+            self.action_play_pause.setIcon(kdeui.KIcon('media-playback-start'))
 
         elif new_status == engine.State.PLAYING:
             self.action_stop.setEnabled(True)
             self.action_pause.setEnabled(True)
             self.action_pause.setChecked(False)
             self.action_play_pause.setChecked(False)
-            self.action_play_pause.setIcon('player_pause')
+            self.action_play_pause.setIcon(kdeui.KIcon('media-playback-pause'))
 
         elif new_status == engine.State.PAUSED:
             self.action_pause.setChecked(True)
@@ -241,59 +457,15 @@
 
     ##
 
-    def slot_accept_drop(self, event, prev_item):
-        if event.source() != self.viewport(): # XXX
-            # If Control is pressed, we want to append at the end:
-            if (kdecore.KApplication.kApplication().keyboardMouseState()
-                    & qt.Qt.ControlButton):
-                prev_item = self.lastItem()
-            files = drag.FileListDrag.file_list(event)
-            self.add_files(files, prev_item)
-
     def slot_clear(self):
-        self.queue[:] = []
-        self.random_queue[:] = []
-        self.tag_reader.worker.clear_queue()
-
-        if self._currently_playing not in (self.FIRST_ITEM, None):
-            # We don't want the currently playing item to be deleted,
-            # because it breaks actions upon it, eg. stop().
-            self.takeItem(self._currently_playing)
-
-        if self.stop_after is not None:
-            if (self.stop_mode == StopMode.AFTER_ONE
-                    and self.stop_after != self._currently_playing):
-                self.stop_after = None
-            elif self.stop_mode == StopMode.AFTER_QUEUE:
-                self._stop_after = None # don't touch stop_mode
-
-        self.clear()
-        self.emit(QtCore.SIGNAL('list_changed'))
-
-    def remove_items(self, items):
-        if not items:
-            return
-
-        for item in items:
-            self.takeItem(item)
-            self.tag_reader.worker.dequeue(item)
-            self.toggle_enqueued(item, only_dequeue=True)
-            try:
-                self.random_queue.remove(item)
-            except ValueError:
-                pass
-            if item == self.current_item:
-                self.current_item = self.FIRST_ITEM
-            if item != self._currently_playing:
-                del item # maybe memory gets freed even without this?
-
-        self.emit(QtCore.SIGNAL('list_changed'))
-
-    def slot_new_current_item(self, item):
+        ClearItemlistCmd(self)
+
+    def slot_activate_index(self, index):
         self.maybe_populate_random_queue()
-        self.current_item = item
+        self.current_item = self._itemlist[index.row()]
         self.slot_play()
 
+    # XXX-KDE4 TODO
     def slot_play_first_visible(self, search_string):
         if not unicode(search_string).strip():
             return
@@ -301,23 +473,37 @@
                 qt.QListViewItemIterator.Visible).current()
         self.slot_play()
 
+    def slot_update_tags(self):
+        rows = []
+
+        for item, tags in self.tag_reader.pop_done():
+            item.update_tags(tags)
+            item.needs_tag_reader = False
+            rows.append(item.position)
+
+        if rows:
+            self.my_emit_dataChanged(min(rows), max(rows))
+
     ##
 
+    """Actions."""
+
     def slot_play(self):
         if self.current_item is not None:
             if self.current_item is self.FIRST_ITEM:
                 if self.queue:
-                    self.current_item = self.queue_pop(0)
+                    self.current_item = self.queue_popfront()
                 else:
                     self.current_item = self.my_first_child()
 
             self.currently_playing = self.current_item
+            self.currently_playing.already_played = True
             minirok.Globals.engine.play(self.current_item.path)
 
-            if self.current_item._tags['Length'] is None:
+            if self.current_item.tags()['Length'] is None:
                 tags = tag_reader.TagReader.tags(self.current_item.path)
                 self.current_item.update_tags({'Length': tags.get('Length', 0)})
-                self.current_item.update_display()
+                self.my_emit_dataChanged(self.current_item.position)
 
             self.emit(QtCore.SIGNAL('new_track'))
 
@@ -342,7 +528,7 @@
     def slot_next(self, force_play=False):
         if self.current_item is not None:
             if self.queue:
-                next = self.queue_pop(0)
+                next = self.queue_popfront()
             elif self.random_mode:
                 try:
                     next = self.random_queue.pop(0)
@@ -352,7 +538,11 @@
             elif self.current_item is self.FIRST_ITEM:
                 next = self.my_first_child()
             else:
-                next = self.current_item.itemBelow()
+                index = self.current_item.position + 1
+                if index < self._row_count:
+                    next = self._itemlist[index]
+                else:
+                    next = None
 
             if next is None and self.repeat_mode is RepeatMode.PLAYLIST:
                 next = self.my_first_child()
@@ -370,27 +560,20 @@
 
     def slot_previous(self):
         if self.current_item not in (self.FIRST_ITEM, None):
-            previous = self.current_item.itemAbove()
-            if previous is not None:
-                self.current_item = previous
+            index = self.current_item.position - 1
+            if index >= 0:
+                self.current_item = self._itemlist[index]
                 if minirok.Globals.engine.status != engine.State.STOPPED:
                     self.slot_play()
 
-    def slot_engine_end_of_stream(self, uri):
+    def slot_engine_end_of_stream(self):
+        finished_item = self.currently_playing
         self.currently_playing = None
 
-        if (self.stop_mode == StopMode.AFTER_ONE or
-                (self.stop_mode == StopMode.AFTER_QUEUE and not self.queue)):
-            if self.stop_after is not None:
-                if self.stop_after.path == re.sub('^file://', '', uri):
-                    self.stop_after = None
-                    self.slot_next(force_play=False)
-                    return
-            elif self.stop_mode == StopMode.AFTER_ONE: # AFTER_QUEUE is ok
-                minirok.logger.warn(
-                        'BUG: stop_after is None with stop_mode = AFTER_ONE')
-
-        if self.repeat_mode == RepeatMode.TRACK:
+        if finished_item is self.stop_after:
+            self.stop_after = None
+            self.slot_next(force_play=False)
+        elif self.repeat_mode == RepeatMode.TRACK:
             # This can't be in slot_next() because the next button should move
             # to the next track *even* with repeat_mode == TRACK.
             self.slot_play()
@@ -399,128 +582,110 @@
 
     ##
 
-    def slot_mouse_button_pressed(self, button, item, qpoint, column):
-        if button != qt.Qt.RightButton or not item:
-            return
-
-        popup = kdeui.KPopupMenu()
-        popup.setCheckable(True)
-
-        selected_items = self.selected_items()
-
-        if not selected_items: # what gives?
-            selected_items = [ item ]
-        else:
-            assert item in selected_items
-
-        if len(selected_items) == 1:
-            popup.insertItem('Enqueue track', 0)
-            popup.setItemChecked(0, bool(item in self.queue))
-        else:
-            popup.insertItem('Enqueue/Dequeue tracks', 0)
-
-        popup.insertItem('Stop playing after this track', 1)
-        popup.setItemChecked(1, bool(item == self.stop_after))
-
-        popup.insertItem('Crop tracks', 2)
-
-        selected = popup.exec_loop(qt.QCursor.pos())
-
-        if selected == 0:
-            for item in selected_items:
-                self.toggle_enqueued(item)
-        elif selected == 1:
-            self.toggle_stop_after(item)
-        elif selected == 2:
-            self.remove_items(self.unselected_items())
+    def toggle_stop_after_row(self, row):
+        assert 0 <= row < self._row_count
+        self.toggle_stop_after(self._itemlist[row])
 
     def slot_toggle_stop_after_current(self):
-        self.toggle_stop_after(self._currently_playing or self.current_item)
+        current = self.currently_playing or self.current_item
+
+        if current not in (self.FIRST_ITEM, None):
+            self.toggle_stop_after(current)
 
     def toggle_stop_after(self, item):
-        if item in (self.FIRST_ITEM, None):
-            return
-
         if item == self.stop_after:
             self.stop_after = None
         else:
             self.stop_after = item
             self.stop_mode = StopMode.AFTER_ONE
 
-    def toggle_enqueued(self, item, only_dequeue=False):
-        try:
-            index = self.queue.index(item)
-        except ValueError:
-            if only_dequeue:
-                # XXX this implicitly skips the emit() below, which is what we
-                # want (so that not every removed item triggers a list_changed
-                # signal), but feels very dirty.
-                return
-            self.queue.append(item)
-            if self.stop_mode == StopMode.AFTER_QUEUE:
-                self.stop_after = item # this repaints
-            else:
-                item.repaint()
-        else:
-            item = self.queue_pop(index)
-            if (index == len(self.queue) # not len-1, 'coz we already popped()
-                    and self.stop_mode == StopMode.AFTER_QUEUE):
-                try:
-                    self.stop_after = self.queue[-1]
-                except IndexError:
-                    self.stop_after = None
-                    self.stop_mode = StopMode.AFTER_QUEUE
+    def toggle_enqueued_row(self, row):
+        assert 0 <= row < self._row_count
+        self.toggle_enqueued_many([ self._itemlist[row] ])
+
+    def toggle_enqueued_many_rows(self, rows):
+        self.toggle_enqueued_many([ self._itemlist[row] for row in rows ])
+
+    def toggle_enqueued_many(self, items, preserve_stop_after=False):
+        """Toggle a list of items from being in the queue.
+        
+        If :param preserve_stop_after: is True, stop_after will not be touched.
+            (This is mostly useful when dequeueing for playing what may be the
+            last item in the queue, see queue_popfront() below.)
+        """
+        # items to queue, and items to dequeue
+        enqueue = [ item for item in items if not item.queue_position ]
+        dequeue = [ item for item in items if item.queue_position ]
+
+        if dequeue:
+            indexes = sorted(item.queue_position - 1 for item in dequeue)
+
+            chunks = AlterItemlistMixin.contiguous_chunks(indexes)
+            chunks.append((len(self.queue), 0)) # fake chunk at the end
+
+            # Now this is simple (at least compared to what was here before):
+            # starting after each removal chunk, and until the beginning of the
+            # next one, we substract the cumulative amount of removed items.
+            accum = 0
+            for i, (index, amount) in enumerate(chunks[:-1]):
+                accum += amount
+                until = sum(chunks[i+1])
+                for item in self.queue[index+amount:until]:
+                    item.queue_position -= accum
+
+            for index in reversed(indexes):
+                item = self.queue.pop(index)
+                item.queue_position = None
+
+        if enqueue:
+            size = len(self.queue)
+            self.queue.extend(enqueue)
+            for i, item in enumerate(enqueue):
+                item.queue_position = size+i+1
+
+        if (not preserve_stop_after
+                and self.stop_mode == StopMode.AFTER_QUEUE):
+            if not self.queue:
+                self.stop_after = None
+                self.stop_mode = StopMode.AFTER_QUEUE
+            elif self.queue[-1] is not self.stop_after:
+                self.stop_after = self.queue[-1]
 
         self.emit(QtCore.SIGNAL('list_changed'))
+        self.emit(QtCore.SIGNAL('repaint_needed'))
 
-    def queue_pop(self, index):
-        """Pops an item from self.queue, and repaints the necessary items."""
+    def queue_popfront(self):
+        """Convenience function to dequeue and return the first item from the queue."""
         try:
-            popped = self.queue.pop(index)
+            popped = self.queue[0]
         except IndexError:
-            minirok.logger.warn('invalid index %r in queue_pop()', index)
+            minirok.logger.warn('queue_popfront() called on an empty queue')
         else:
-            for item in [ popped ] + self.queue[index:]:
-                item.repaint()
-
+            self.toggle_enqueued_many([ popped ], preserve_stop_after=True)
             return popped
 
     def my_first_child(self):
         """Return the first item to be played, honouring random_mode."""
         if self.random_mode:
-            if not self.random_queue:
-                self.maybe_populate_random_queue()
+            self.maybe_populate_random_queue()
             return self.random_queue.pop(0)
         else:
-            return self.firstChild()
+            return self._itemlist[0]
 
     def maybe_populate_random_queue(self):
         if not self.random_queue:
-            item = self.firstChild()
-            while item:
-                self.random_queue.append(item)
-                item = item.nextSibling()
-
-    def select_items_helper(self, iterator_flags):
-        """Return a list of items that match iterator_flags."""
-        iterator = qt.QListViewItemIterator(self, iterator_flags)
-
-        items = []
-        while iterator.current():
-            items.append(iterator.current())
-            iterator += 1
-
-        return items
-
-    def selected_items(self):
-        return self.select_items_helper(qt.QListViewItemIterator.Selected)
-
-    def unselected_items(self):
-        return self.select_items_helper(qt.QListViewItemIterator.Unselected)
+            self.random_queue.extend(self._itemlist)
+            for item in self._itemlist:
+                self.already_played = False
 
     ##
 
+    # XXX-KDE4 TODO
     def apply_preferences(self):
+        self._regex = None
+        self._regex_mode = 'Always'
+        return # XXX-KDE4
+
         prefs = minirok.Globals.preferences
 
         if prefs.tags_from_regex:
@@ -541,19 +706,14 @@
 
     def slot_save_config(self):
         """Saves the current playlist."""
-        items = []
-        item = self.firstChild()
-
-        while item:
-            items.append(item.path)
-            item = item.nextSibling()
+        paths = (item.path for item in self._itemlist)
 
         try:
             playlist = file(self.saved_playlist_path(), 'w')
         except IOError, e:
             minirok.logger.error('could not save playlist: %s', e)
         else:
-            playlist.write('\0'.join(items))
+            playlist.write('\0'.join(paths))
             playlist.close()
 
     def load_saved_playlist(self):
@@ -567,7 +727,10 @@
         else:
             files = re.split(r'\0+', playlist.read())
             if files != ['']: # empty saved playlist
-                self.add_files_untrusted(files)
+                # add_files_untrusted() will use InsertItemsCmd, and here
+                # that wouldn't be appropriate: cook up the code ourselves.
+                self.insert_items(0, map(self.create_item,
+                    util.playable_from_untrusted(files, warn=True)))
 
         self.slot_list_changed()
 
@@ -578,65 +741,43 @@
 
     ##
 
-    def add_files(self, files, prev_item=None):
-        """Add the given files to the playlist, after prev_item.
+    def add_files(self, files, position=-1):
+        """Add the given files to the playlist at a given position.
 
-        If prev_item is None, files will be added at the end of the playlist.
+        If position is < 0, files will be added at the end of the playlist.
         """
-        if prev_item is None:
-            prev_item = self.lastItem()
-        for f in files:
-            prev_item = self.add_file(f, prev_item)
-        self.emit(QtCore.SIGNAL('list_changed'))
+        if position < 0:
+            position = self._row_count
+
+        if files:
+            items = map(self.create_item, files)
+            InsertItemsCmd(self, position, items)
 
     def add_files_untrusted(self, files, clear_playlist=False):
         """Add to the playlist those files that exist and are playable."""
         if clear_playlist:
             self.slot_clear()
 
-        def _can_play_with_warning(path):
-            if minirok.Globals.engine.can_play(path):
-                if os.path.isfile(path):
-                    return True
-                else:
-                    if not os.path.exists(path):
-                        minirok.logger.warn('skipping nonexistent file %s', path)
-                    else:
-                        minirok.logger.warn('skipping non regular file %s', path)
-                    return False
-            else:
-                minirok.logger.warn('skipping unplayable file/extension %s',
-                        os.path.basename(path))
-                return False
-
-        self.add_files(filter(_can_play_with_warning, files))
-
-    def add_file(self, file_, prev_item):
-        tags = self.tags_from_filename(file_)
+        self.add_files(util.playable_from_untrusted(files, warn=True))
+
+    def create_item(self, path):
+        tags = self.tags_from_filename(path)
         if len(tags) == 0 or tags.get('Title', None) is None:
             regex_failed = True
-            dirname, filename = os.path.split(file_)
+            dirname, filename = os.path.split(path)
             tags['Title'] = util.unicode_from_path(filename)
         else:
             regex_failed = False
 
-        if (self._currently_playing_taken
-                and self._currently_playing is not None
-                and self._currently_playing.path == file_):
-            item = self._currently_playing
-            self.insertItem(item)
-            item.moveItem(prev_item)
-            self.current_item = item
-            self.currently_playing = item # unsets _currently_playing_taken
-        else:
-            item = PlaylistItem(file_, self, prev_item, tags)
-            self.random_queue.append(item)
+        item = PlaylistItem(path, tags)
 
         assert self._regex_mode in ['Always', 'OnRegexFail', 'Never']
 
         if self._regex_mode == 'Always' or (regex_failed
                 and self._regex_mode == 'OnRegexFail'):
-            self.tag_reader.worker.queue(item)
+            item.needs_tag_reader = True
+        else:
+            item.needs_tag_reader = False
 
         return item
 
@@ -659,21 +800,7 @@
 
     ##
 
-    def setColumnWidth(self, col, width):
-        self.header().setResizeEnabled(bool(width), col) # Qt does not do this for us
-        return kdeui.KListView.setColumnWidth(self, col, width)
-
-    def takeItem(self, item):
-        if item == self._currently_playing:
-            self._currently_playing_taken = True
-        return kdeui.KListView.takeItem(self, item)
-
-    def acceptDrag(self, event):
-        if drag.FileListDrag.canDecode(event):
-            return True
-        else:
-            return kdeui.KListView.acceptDrag(self, event)
-
+    # XXX-KDE4 TODO
     def contentsDragMoveEvent(self, event):
         if (not (kdecore.KApplication.kApplication().keyboardMouseState()
                     & qt.Qt.ControlButton)
@@ -696,47 +823,39 @@
             finally:
                 self.setDropVisualizer(True)
 
-    def eventFilter(self, object_, event):
-        # TODO Avoid so many calls to viewport(), type(), button(), ... ?
-
-        if (object_ == self.header()
-                and event.type() == qt.QEvent.MouseButtonPress
-                and event.button() == qt.QEvent.RightButton):
-
-            # Creates a menu for hiding/showing columns
-            self.columns.exec_popup(event.globalPos())
-
-            return True
-
-        # Handle Ctrl+MouseClick: RightButton: enqueue, MidButton: stop after
-        if (object_ == self.viewport()
-                and event.type() == qt.QEvent.MouseButtonPress
-                and event.state() == qt.Qt.ControlButton):
-            item = self.itemAt(event.pos())
-            if item is not None:
-                if event.button() == qt.QEvent.MidButton:
-                    self.toggle_stop_after(item)
-                elif event.button() == qt.QEvent.RightButton:
-                    self.toggle_enqueued(item)
-                elif event.button() == qt.QEvent.LeftButton:
-                    kdeui.KListView.eventFilter(self, object_, event)
-            return True
-
-        # Play/Pause when middle-click on the current playing track
-        if (object_ == self.viewport()
-                and event.type() == qt.QEvent.MouseButtonPress
-                and event.button() == qt.QEvent.MidButton
-                and self.itemAt(event.pos()) == self.current_item):
-            self.slot_pause()
-            return True
-
-        return kdeui.KListView.eventFilter(self, object_, event)
-
-    def keyPressEvent(self, event):
-        if event.key() == qt.QEvent.Key_Delete:
-            self.remove_items(self.selected_items())
-        else:
-            return kdeui.KListView.keyPressEvent(self, event)
+    ##
+
+    """Misc. helpers."""
+
+    def my_emit_dataChanged(self, row1, row2=None, column=None):
+        """Emit dataChanged() between sorted([row1, row2]).
+
+        If :param row2: is None, it will default to row1.
+        If :param column: is not None, only include that column in the signal.
+        """
+        if row2 is None:
+            row2 = row1
+        elif row1 > row2:
+            row1, row2 = row2, row1
+
+        if column is None:
+            col1 = 0
+            col2 = self.columnCount() - 1
+        else:
+            col1 = col2 = column
+
+        self.emit(QtCore.SIGNAL(
+                    'dataChanged(const QModelIndex &, const QModelIndex &)'),
+                    self.index(row1, col1), self.index(row2, col2))
+
+    ##
+
+    def get_current_tags(self):
+        """Return the tags of the currently played item, if any."""
+        if self.currently_playing is not None:
+            return self.currently_playing.tags()
+        else:
+            return {}
 
 ##
 
@@ -752,43 +871,45 @@
 
 class StopAction(kdeui.KToolBarPopupAction):
 
-    NOW = 0
-    AFTER_CURRENT = 1
-    AFTER_QUEUE = 2
-
     def __init__(self, *args):
         kdeui.KToolBarPopupAction.__init__(self, kdeui.KIcon(), "", None)
 
-    def xxx_kde4_disabled(): # XXX-KDE4
-        self.popup_menu = self.popupMenu()
-
-        self.popup_menu.insertTitle('Stop')
-        self.popup_menu.insertItem('Now', self.NOW)
-        self.popup_menu.insertItem('After current', self.AFTER_CURRENT)
-        self.popup_menu.insertItem('After queue', self.AFTER_QUEUE)
-
-        self.connect(self.popup_menu, qt.SIGNAL('aboutToShow()'), self.slot_prepare)
-        self.connect(self.popup_menu, qt.SIGNAL('activated(int)'), self.slot_activated)
+        menu = self.menu()
+        menu.addTitle('Stop')
+
+        self.action_now = menu.addAction('Now')
+        self.action_after_current = menu.addAction('After current')
+        self.action_after_queue = menu.addAction('After queue')
+
+        self.connect(menu, QtCore.SIGNAL('aboutToShow()'), self.slot_prepare)
+        self.connect(menu, QtCore.SIGNAL('triggered(QAction *)'), self.slot_activated)
 
     def slot_prepare(self):
         playlist = minirok.Globals.playlist
 
-        self.popup_menu.setItemChecked(self.AFTER_CURRENT,
-                playlist.stop_mode == StopMode.AFTER_ONE and
-                playlist.stop_after == playlist._currently_playing)
-        self.popup_menu.setItemChecked(self.AFTER_QUEUE,
-                playlist.stop_mode == StopMode.AFTER_QUEUE)
-
-    def slot_activated(self, selected):
+        if (playlist.stop_mode == StopMode.AFTER_ONE
+                and playlist.stop_after == playlist.currently_playing):
+            self.action_after_current.setCheckable(True)
+            self.action_after_current.setChecked(True)
+        else:
+            self.action_after_current.setCheckable(False)
+
+        if playlist.stop_mode == StopMode.AFTER_QUEUE:
+            self.action_after_queue.setCheckable(True)
+            self.action_after_queue.setChecked(True)
+        else:
+            self.action_after_queue.setCheckable(False)
+
+    def slot_activated(self, action):
         playlist = minirok.Globals.playlist
 
-        if selected == self.NOW:
-            minirok.Globals.action_collection.action('action_stop').activate()
+        if action is self.action_now:
+            self.trigger()
 
-        elif selected == self.AFTER_CURRENT:
+        elif action is self.action_after_current:
             playlist.slot_toggle_stop_after_current()
 
-        elif selected == self.AFTER_QUEUE:
+        elif action is self.action_after_queue:
             if playlist.stop_mode == StopMode.AFTER_QUEUE:
                 playlist.stop_after = None
             else:
@@ -799,31 +920,184 @@
 
 ##
 
-class PlaylistItem:#XXX-KDE4 (kdeui.KListViewItem):
+class PlaylistView(QtGui.QTreeView):
+
+    def __init__(self, playlist):
+        QtGui.QTreeView.__init__(self)
+
+        self.setModel(playlist)
+        self.setRootIsDecorated(False)
+        self.setDropIndicatorShown(True)
+        self.setAllColumnsShowFocus(True)
+        self.setDragDropMode(self.DragDrop)
+        self.setSelectionBehavior(self.SelectRows)
+        self.setSelectionMode(self.ExtendedSelection)
+
+        columns = Columns(self)
+        self.setHeader(columns)
+        columns.setup_from_config()
+
+        self.track_delegate = PlaylistTrackDelegate()
+        self.setItemDelegateForColumn(
+                self.model().sorted_column_names().index('Track'),
+                self.track_delegate)
+
+        self.connect(self, QtCore.SIGNAL('activated(const QModelIndex &)'),
+                playlist.slot_activate_index)
+
+        self.connect(playlist, QtCore.SIGNAL('scroll_needed'),
+                                lambda index: self.scrollTo(index))
+
+        # ok, this is a bit gross
+        playlist.selection_model = self.selectionModel()
+
+    ##
+
+    def selected_rows(self):
+        # The set is needed here because there is an index per row/column
+        return set(x.row() for x in self.selectedIndexes())
+
+    def unselected_rows(self):
+        selected = self.selected_rows()
+        all = set(range(self.model().rowCount()))
+        return all - selected
+
+    ##
+
+    def drawRow(self, painter, styleopt, index):
+        row = index.row()
+        model = self.model()
+
+        if model.row_is_playing(row):
+            styleopt = QtGui.QStyleOptionViewItem(styleopt) # make a copy
+            styleopt.font.setItalic(True)
+
+        QtGui.QTreeView.drawRow(self, painter, styleopt, index)
+
+        if model.row_is_current(row):
+            painter.save()
+            r = styleopt.rect
+            painter.setPen(styleopt.palette.highlight().color())
+            painter.drawRect(r.x(), r.y(), r.width(), r.height()-1)
+            painter.restore()
+
+    def startDrag(self, actions):
+        # Override this function to loose the ugly pixmap provided by Qt
+        indexes = self.selectedIndexes()
+        if len(indexes) > 0:
+            mimedata = self.model().mimeData(indexes)
+            drag = QtGui.QDrag(self)
+            drag.setMimeData(mimedata)
+            drag.setPixmap(QtGui.QPixmap(1, 1))
+            drag.exec_(actions)
+
+    def keyPressEvent(self, event):
+        if event.key() == Qt.Key_Delete:
+            event.accept()
+            RemoveItemsCmd(self.model(), self.selected_rows())
+        else:
+            return QtGui.QTreeView.keyPressEvent(self, event)
+
+    def mousePressEvent(self, event):
+        button = event.button()
+        keymod = QtGui.QApplication.keyboardModifiers()
+        index = self.indexAt(event.pos())
+
+        # TODO: accept event?
+
+        if not index.isValid():
+            # click on viewport
+            self.clearSelection()
+
+        elif keymod & Qt.ControlModifier:
+            if button & Qt.RightButton:
+                self.model().toggle_enqueued_row(index.row())
+            elif button & Qt.MidButton:
+                self.model().toggle_stop_after_row(index.row())
+            else:
+                return QtGui.QTreeView.mousePressEvent(self, event)
+
+        elif button & Qt.MidButton:
+            if self.model().row_is_playing(index.row()):
+                self.model().slot_pause()
+
+        elif button & Qt.RightButton:
+            QtGui.QTreeView.mousePressEvent(self, event)
+
+            menu = kdeui.KMenu(self)
+            selected_rows = self.selected_rows()
+            assert len(selected_rows) > 0 # or maybe use itemAt()
+
+            if len(selected_rows) == 1:
+                enqueue_action = menu.addAction('Enqueue track')
+                if self.model().row_queue_position(index.row()) > 0:
+                    enqueue_action.setCheckable(True)
+                    enqueue_action.setChecked(True)
+            else:
+                enqueue_action = menu.addAction('Enqueue/Dequeue tracks')
+
+            stop_after_action = menu.addAction('Stop playing after this track')
+
+            if index.model().row_is_stop_after(index.row()):
+                stop_after_action.setCheckable(True)
+                stop_after_action.setChecked(True)
+
+            crop_action = menu.addAction('Crop tracks')
+
+            ##
+
+            selected_action = menu.exec_(event.globalPos())
+
+            if selected_action == enqueue_action:
+                self.model().toggle_enqueued_many_rows(sorted(selected_rows))
+            elif selected_action == stop_after_action:
+                self.model().toggle_stop_after_row(index.row())
+            elif selected_action == crop_action:
+                RemoveItemsCmd(self.model(), self.unselected_rows())
+
+        else:
+            return QtGui.QTreeView.mousePressEvent(self, event)
+
+##
+
+class PlaylistItem(object):
+
+    # This class should be considered sort of private to the model
 
     ALLOWED_TAGS = [ 'Track', 'Artist', 'Album', 'Title', 'Length' ]
 
-    def __init__(self, path, parent, prev_item, tags={}):
-        kdeui.KListViewItem.__init__(self, parent, prev_item)
+    TRACK_COLUMN_INDEX = 0 # used by the model
 
+    def __init__(self, path, tags=None):
         self.path = path
-        self.playlist = parent
-
-        self._is_current = False
-        self._is_playing = False
 
         self._tags = dict((tag, None) for tag in self.ALLOWED_TAGS)
-        self.update_tags(tags)
-        self.update_display()
-
-    def set_current(self, value=True):
-        self._is_current = bool(value)
-
-    def set_playing(self, value=True):
-        self._is_playing = bool(value)
+
+        if tags is not None:
+            self.update_tags(tags)
+
+        # these are maintained up to date by the model
+        self.position = None
+        self.queue_position = None
+        self.already_played = False
+        self.needs_tag_reader = True
 
     ##
 
+    def tags(self):
+        return self._tags.copy()
+
+    def tag_text(self, tag):
+        value = self._tags[tag]
+
+        if tag == 'Length' and value is not None:
+            return util.fmt_seconds(value)
+        else:
+            return value
+
+    def tag_by_index(self, index):
+        return self.tag_text(self.ALLOWED_TAGS[index])
+
     def update_tags(self, tags):
         for tag, value in tags.items():
             if tag not in self._tags:
@@ -841,66 +1115,52 @@
                 except ValueError:
                     minirok.logger.warn('invalid length: %r', value)
                     continue
+
             self._tags[tag] = value
 
-    def update_display(self):
-        for column in Columns.DEFAULT_ORDER:
-            text = self._tags[column]
-            if text is not None:
-                if column == 'Length':
-                    text = util.fmt_seconds(text)
-                index = self.playlist.column_index(column)
-                self.setText(index, text)
-
     ##
 
-    def paintCell(self, painter, colorgrp, column, width, align):
-        """Draws a border for the current item, and the playing item in italics."""
-        if self._is_playing:
-            painter.font().setItalic(True)
-
-        kdeui.KListViewItem.paintCell(
-                self, painter, colorgrp, column, width, align)
-
-        if self._is_current:
-            # We use the superclass method here because Playlist.columns
-            # is something else.
-            num_columns = kdeui.KListView.columns(self.playlist)
-            prev_width = 0
-            full_width = 0
-            for c in range(num_columns):
-                w = self.playlist.columnWidth(c)
-                full_width += w
-                if c < column:
-                    prev_width += w
-
-            self.paintFocus(painter, colorgrp,
-                            qt.QRect(qt.QPoint(-prev_width, 0),
-                                     qt.QSize(full_width, self.height())))
-
-        # Now draw an ellipse with the stop after track icon and queue
-        # position. Code comes from Amarok's PlaylistItem::paintCell().
-        draw_stop = bool(self == self.playlist.stop_after)
-        try:
-            queue_pos = str(self.playlist.queue.index(self) + 1)
-        except ValueError:
-            queue_pos = None
-
-        if ((draw_stop or queue_pos)
-                and self.playlist.header().mapToIndex(column) == 0):
+    # XXX-KDE4 TODO
+    def paintFocus(self, painter, colorgrp, qrect):
+        """Only allows focus to be painted in the current item."""
+        if not self._is_current:
+            return
+        else:
+            kdeui.KListViewItem.paintFocus(self, painter, colorgrp, qrect)
+
+##
+
+class PlaylistTrackDelegate(QtGui.QItemDelegate):
+    """Paints the track number and the "stop after/queue pos" ellipse.
+    
+    Code originally comes from PlaylistItem::paintCell() in Amarok 1.4.
+    """
+
+    def paint(self, painter, option, index):
+        QtGui.QItemDelegate.paint(self, painter, option, index)
+
+        draw_stop = index.model().row_is_stop_after(index.row())
+        queue_pos = index.model().row_queue_position(index.row())
+
+        if draw_stop or queue_pos:
+            painter.save()
+            painter.translate(option.rect.x(), option.rect.y())
+
+            width = option.rect.width()
+            height = option.rect.height()
 
             e_width = 16
             e_margin = 2
-            e_height = self.height() - e_margin*2
+            e_height = height - e_margin*2
 
             if draw_stop:
-                stop_pixmap = util.get_png('black_tiny_stop')
-                s_width = stop_pixmap.width()
-                s_height = stop_pixmap.height()
+                s_width = 8
+                s_height = 8
             else:
                 s_width = s_height = 0
 
             if queue_pos:
+                queue_pos = str(queue_pos)
                 q_width = painter.fontMetrics().width(queue_pos)
                 q_height = painter.fontMetrics().height()
             else:
@@ -908,158 +1168,362 @@
 
             items_width = s_width + q_width
 
-            painter.setBrush(colorgrp.highlight())
-            painter.setPen(colorgrp.highlight().dark())
+            painter.setBrush(option.palette.highlight())
+            painter.setPen(option.palette.highlight().color().dark())
             painter.drawEllipse(width - items_width - e_width/2, e_margin, e_width, e_height)
-            painter.drawRect(width - items_width, e_margin, items_width, e_height)
-            painter.setPen(colorgrp.highlight())
-            painter.drawLine(width - items_width, e_margin+1, width - items_width, e_height)
+            painter.drawRect(width - items_width, e_margin, items_width+1, e_height)
+            painter.setPen(option.palette.highlight().color())
+            painter.drawLine(width - items_width, e_margin+1, width - items_width, e_height+1)
 
             x = width - items_width - e_margin
 
             if draw_stop:
                 y = e_height / 2 - s_height / 2 + e_margin
-                painter.drawPixmap(x, y, stop_pixmap)
+                painter.setBrush(QtGui.QColor(0, 0, 0))
+                painter.drawRect(x, y, s_width, s_height)
                 x += s_width + e_margin/2
 
             if queue_pos:
-                painter.setPen(colorgrp.highlightedText())
-                painter.drawText(x, 0, width-x, q_height, qt.Qt.AlignCenter, queue_pos)
+                painter.setPen(option.palette.highlightedText().color())
+                painter.drawText(x, 0, width-x, q_height, Qt.AlignCenter, queue_pos)
 
-    def paintFocus(self, painter, colorgrp, qrect):
-        """Only allows focus to be painted in the current item."""
-        if not self._is_current:
-            return
-        else:
-            kdeui.KListViewItem.paintFocus(self, painter, colorgrp, qrect)
+            painter.restore()
 
 ##
 
-class Columns(util.HasConfig):
-
-    DEFAULT_ORDER = [ 'Track', 'Artist', 'Album', 'Title', 'Length' ]
-    DEFAULT_WIDTH = {
-            'Track': 50,
-            'Artist': 200,
-            'Album': 200,
-            'Title': 300,
-            'Length': 75,
-    }
-
-    # The configuration for Playlist Columns has three options:
-    #   * Order: a list specifying the order in which the columns must be
-    #     displayed; should contain all known columns, but if some are missing,
-    #     they will be added at the end
-    #   * Visible: a list of the columns that should be displayed; order of
-    #     this list is not relevant
-    #   * Width: a list of "ColumnName:Width" pairs, specifying the width of
-    #     each column. Important: no width here should be zero!, even if the
-    #     column is not visible. Pairs with width set to 0 will be ignored.
-    CONFIG_SECTION = 'Playlist Columns'
-    CONFIG_ORDER_OPTION = 'Order'
-    CONFIG_WIDTH_OPTION = 'Width'
-    CONFIG_VISIBLE_OPTION = 'Visible'
-
-    class NoSuchColumn(Exception):
-        pass
-
-    def __init__(self, playlist):
+class Columns(QtGui.QHeaderView, util.HasConfig):
+
+    # We use a single configuration option, which contains the order in which
+    # columns are to be displayed, their width, and whether they are hidden or
+    # not.
+    CONFIG_SECTION = 'Playlist'
+    CONFIG_OPTION = 'Columns'
+    CONFIG_OPTION_DEFAULT = \
+            'Track:50:1,Artist:200:1,Album:200:0,Title:300:1,Length:75:1'
+
+    def __init__(self, parent):
+        QtGui.QHeaderView.__init__(self, Qt.Horizontal, parent)
         util.HasConfig.__init__(self)
-        config = minirok.Globals.config(self.CONFIG_SECTION)
-
-        def _read_list_entry(option):
-            return map(str, config.readListEntry(option))
-
-        ##
-
-        if config.hasKey(self.CONFIG_ORDER_OPTION):
-            self._order = _read_list_entry(self.CONFIG_ORDER_OPTION)
-            # self._order must contain all available columns, so ensure that
-            for c in self.DEFAULT_ORDER:
-                if c not in self._order:
-                    self._order.append(c)
-        else:
-            self._order = self.DEFAULT_ORDER
-
-        ##
-
-        if config.hasKey(self.CONFIG_VISIBLE_OPTION):
-            self._visible = _read_list_entry(self.CONFIG_VISIBLE_OPTION)
-        else:
-            self._visible = self.DEFAULT_ORDER
-
-        ##
-
-        configured_width = self.DEFAULT_WIDTH.copy()
-
-        if config.hasKey(self.CONFIG_WIDTH_OPTION):
-            for pair in _read_list_entry(self.CONFIG_WIDTH_OPTION):
-                name, width = pair.split(':', 1)
-                name = name.strip()
-                try:
-                    width = int(width)
-                except ValueError:
-                    minirok.logger.error('invalid width %r for column %s',
-                            width, name)
-                else:
-                    if width != 0:
-                        configured_width[name] = width
-
-        self._width = [ configured_width[c] for c in self._order ]
-
-        ##
-
-        for index, name in enumerate(self._order):
-            playlist.addColumn(name, 1) # width=1 to enfore WidthMode: Manual
-            if name in self._visible:
-                playlist.setColumnWidth(index, self._width[index])
-            else:
-                playlist.setColumnWidth(index, 0)
-            if name == 'Track':
-                playlist.setColumnAlignment(index, qt.Qt.AlignHCenter)
-            elif name == 'Length':
-                playlist.setColumnAlignment(index, qt.Qt.AlignRight)
-
-        self.playlist = playlist
+
+        self.setMovable(True)
+        self.setStretchLastSection(False)
+        self.setDefaultAlignment(Qt.AlignLeft)
+        self.setContextMenuPolicy(Qt.CustomContextMenu)
+
+        self.connect(self,
+                QtCore.SIGNAL('customContextMenuRequested(const QPoint&)'),
+                self.exec_popup)
+
+    def setup_from_config(self):
+        """Read config, sanitize it, and apply.
+        
+        NOTE: this code can't be in __init__, because at that time there is not
+        a model/view associated with the object.
+        """
+        self.config = kdecore.KGlobal.config()
+        group = self.config.group(self.CONFIG_SECTION)
+
+        if group.hasKey(self.CONFIG_OPTION):
+            entries = map(str, group.readEntry(
+                                self.CONFIG_OPTION, QtCore.QStringList()))
+        else:
+            entries = self.CONFIG_OPTION_DEFAULT.split(',')
+
+        columns = []
+        warn = minirok.logger.warn
+        known_columns = set(self.model().sorted_column_names())
+
+        for entry in entries:
+            try:
+                name, width, visible = entry.split(':', 2)
+            except ValueError:
+                warn('skipping invalid entry in column config: %r', entry)
+                continue
+
+            try:
+                width = int(width)
+            except ValueError:
+                warn('invalid column width for %s: %r', name, width)
+                continue
+
+            # TODO Maybe this one ought to be more flexible
+            try:
+                visible = bool(int(visible))
+            except ValueError:
+                warn('invalid visibility value for %s: %r', name, visible)
+                continue
+                    
+            try:
+                known_columns.remove(name)
+            except KeyError:
+                warn('skipping unknown or duplicated column: %r', name)
+                continue
+
+            columns.append((name, width, visible))
+
+        if len(known_columns) > 0:
+            defaults = dict((x.split(':')[0], map(int, x.split(':')[1:]))
+                                for x in self.CONFIG_OPTION_DEFAULT.split(','))
+            for c in known_columns:
+                warn('column %s missing in config, adding with default values', c)
+                width, visible = defaults[c]
+                columns.append((c, width, visible))
+
+        ##
+
+        model_columns = self.model().sorted_column_names()
+
+        for visual, (name, width, visible) in enumerate(columns):
+            logical = model_columns.index(name)
+            current = self.visualIndex(logical)
+
+            if current != visual:
+                self.moveSection(current, visual)
+
+            self.resizeSection(logical, width)
+            self.setSectionHidden(logical, not visible)
 
     ##
 
-    def index(self, name):
-        try:
-            return self._order.index(name)
-        except ValueError:
-            raise self.NoSuchColumn(name)
-
     def exec_popup(self, position):
-        popup = kdeui.KPopupMenu()
-        popup.setCheckable(True)
-
-        for i, column in enumerate(self._order):
-            pos = self.playlist.header().mapToIndex(i)
-            popup.insertItem(column, i, pos)
-            popup.setItemChecked(i, bool(self.playlist.columnWidth(i)))
-
-        selected = popup.exec_loop(position)
-
-        if self.playlist.columnWidth(selected) != 0:
-            self.playlist.setColumnWidth(selected, 0)
-        else:
-            self.playlist.setColumnWidth(selected, self._width[selected])
+        model = self.model()
+        menu = kdeui.KMenu(self)
+        menu.addTitle('Columns')
+
+        for i in range(model.columnCount()):
+            logindex = self.logicalIndex(i)
+            name = model.headerData(logindex,
+                    Qt.Horizontal, Qt.DisplayRole).toString()
+            action = menu.addAction(name)
+            action.setCheckable(True)
+            action.setData(QtCore.QVariant(logindex))
+            action.setChecked(not self.isSectionHidden(logindex))
+
+        selected_action = menu.exec_(self.mapToGlobal(position))
+
+        if selected_action is not None:
+            hide = not selected_action.isChecked()
+            column = selected_action.data().toInt()[0]
+            self.setSectionHidden(column, hide)
 
     ##
 
     def slot_save_config(self):
-        config = minirok.Globals.config(self.CONFIG_SECTION)
-        header = self.playlist.header()
-        order = []
-        width = []
-        visible = []
-        for i, name in enumerate(self._order):
-            order.append(self._order[header.mapToSection(i)])
-            w = self.playlist.columnWidth(i)
-            if w != 0:
-                visible.append(name)
-                width.append('%s:%d' % (name, w))
-        config.writeEntry(self.CONFIG_ORDER_OPTION, order)
-        config.writeEntry(self.CONFIG_WIDTH_OPTION, width)
-        config.writeEntry(self.CONFIG_VISIBLE_OPTION, visible)
+        entries = [None] * self.count()
+
+        for logical, name in enumerate(self.model().sorted_column_names()):
+            visible = int(not self.isSectionHidden(logical))
+            if not visible:
+                # gross, but sectionSize() would return 0 otherwise :-(
+                self.setSectionHidden(logical, False)
+            width = self.sectionSize(logical)
+            entry = '%s:%d:%d' % (name, width, visible)
+            entries[self.visualIndex(logical)] = entry
+
+        self.config = kdecore.KGlobal.config()
+        self.config.group(self.CONFIG_SECTION).writeEntry(
+                                self.CONFIG_OPTION, entries)
+
+##
+
+"""Undoable commands to modify the contents of the playlist.
+
+Note that they will add themselves to the model's QUndoStack.
+"""
+
+class AlterItemlistMixin(object):
+    """Common functionality to make changes to the item list.
+    
+    This class offers methods to insert and remove items from the list. Each
+    operation saves state, so that calling the reverse operation without any
+    arguments just undoes it.
+
+    In both cases, there is housekeeping of the playlist's queue, removing
+    items from it when removing, and restoring the previous state on insertion.
+    This can be disabled by passing "do_queue=False" to __init__.
+    """
+    def __init__(self, model, do_queue=True):
+        self.items = {}
+        self.chunks = []
+        self.queuepos = {}
+        self.current_item = None
+
+        self.model = model
+        self.do_queue = do_queue
+
+    ##
+
+    def insert_items(self, items=None):
+        """Insert items into the playlist.
+
+        :param items: should be a dict like:
+        
+            { pos1: itemlist1, pos2: itemlist2, ... }
+
+        The items will be inserted in *ascending* order by position.
+        If items is None, self.items will be used.
+        """
+        if items is None:
+            items = self.items
+
+        for position, items in sorted(items.iteritems()):
+            self.model.insert_items(position, items)
+
+        # Restore the current item, if we have one *and* the playlist doesn't
+        if (self.current_item is not None
+                and self.model.current_item in (None, Playlist.FIRST_ITEM)):
+            self.model.current_item = self.current_item
+
+        if self.do_queue:
+            # TODO Think whether to invalidate these queue positions if the
+            # queue changes between a removal and its undo.
+            for pos, amount in self.contiguous_chunks(self.queuepos.keys()):
+                items = [ self.queuepos[x] for x in range(pos, pos+amount) ]
+                tail = self.model.queue[pos-1:]
+                self.model.toggle_enqueued_many(tail + items)
+                self.model.toggle_enqueued_many(tail)
+
+    def remove_items(self, chunks=None):
+        """Remove items from the playlist.
+
+        :param chunks: should be a list like:
+
+            [ (pos1, amount1), (pos2, amount2), ... ]
+
+        The items will be removed in *descending* order by position.
+        If chunks is None, self.chunks will be used, and if empty, it will be
+        calculated first from self.items.
+
+        This method will fills self.items in the format explained in
+        insert_items() above.
+        """
+        if chunks is None:
+            if self.chunks:
+                chunks = self.chunks
+            else:
+                chunks = self.chunks = sorted((row, len(items))
+                            for row, items in self.items.iteritems())
+
+        self.items.clear()
+        self.queuepos.clear()
+
+        if self.model.current_item is not Playlist.FIRST_ITEM:
+            self.current_item = self.model.current_item
+
+        for position, amount in reversed(chunks):
+            self.items[position] = self.model.remove_items(position, amount)
+
+        if self.do_queue:
+            for itemlist in self.items.itervalues():
+                self.queuepos.update((item.queue_position, item)
+                        for item in itemlist if item.queue_position)
+
+            if self.queuepos:
+                self.model.toggle_enqueued_many(self.queuepos.values())
+
+    ##
+
+    def get_items(self):
+        """Return an ordered list of all items belonging to this command."""
+        result = []
+        for position, items in sorted(self.items.iteritems()):
+            result.extend(items)
+        return result
+
+    @staticmethod # TODO Move elsewhere
+    def contiguous_chunks(intlist):
+        """Calculate a list of contiguous areas in a possibly unsorted list.
+
+        >>> removecmd.contiguous_chunks([2, 9, 3, 5, 8, 1])
+        [ (1, 3), (5, 1), (8, 2) ]
+        """
+        if len(intlist) == 0:
+            return []
+
+        mylist = sorted(intlist)
+        result = [ [mylist[0], 1] ]
+
+        for x in mylist[1:]:
+            if x == sum(result[-1]):
+                result[-1][1] += 1
+            else:
+                result.append([x, 1])
+
+        return map(tuple, result)
+
+
+class InsertItemsCmd(QtGui.QUndoCommand, AlterItemlistMixin):
+    """Command to insert a list of items at a certain position."""
+
+    def __init__(self, model, position, items, do_queue=True):
+        QtGui.QUndoCommand.__init__(self, 'insert ' + _n_tracks_str(len(items)))
+        AlterItemlistMixin.__init__(self, model, do_queue)
+
+        if items:
+            self.items = { position: items }
+            self.model.undo_stack.push(self)
+
+    undo = AlterItemlistMixin.remove_items
+    redo = AlterItemlistMixin.insert_items
+
+
+class RemoveItemsCmd(QtGui.QUndoCommand, AlterItemlistMixin):
+    """Command to remove a list of rows from the playlist."""
+
+    def __init__(self, model, rows, do_queue=True):
+        """Create the command.
+
+        :param rows: A possibly unsorted/non-contiguous list of rows to remove.
+        """
+        QtGui.QUndoCommand.__init__(self, 'remove ' + _n_tracks_str(len(rows)))
+        AlterItemlistMixin.__init__(self, model, do_queue)
+
+        if rows:
+            self.chunks = self.contiguous_chunks(rows)
+            self.model.undo_stack.push(self)
+
+    undo = AlterItemlistMixin.insert_items
+    redo = AlterItemlistMixin.remove_items
+
+
+class ClearItemlistCmd(QtGui.QUndoCommand, AlterItemlistMixin):
+    """Command to completely clear the playlist.
+
+    This command offers a more efficient implementation of remove_items than
+    the mixin (uses the model's clear_itemlist), and handles the queue more
+    efficiently.
+    """
+    def __init__(self, model):
+        QtGui.QUndoCommand.__init__(self, 'clear playlist')
+        AlterItemlistMixin.__init__(self, model)
+        self.model.undo_stack.push(self)
+
+    def remove_items(self):
+        self.items.clear()
+        self.queuepos.clear()
+
+        if self.model.current_item is not Playlist.FIRST_ITEM:
+            self.current_item = self.model.current_item
+
+        self.items[0] = self.model.clear_itemlist()
+
+        if self.do_queue:
+            # iterate over model's queue directly, since we are
+            # dequeueing *everything*
+            self.queuepos.update((item.queue_position, item)
+                    for item in self.model.queue)
+
+            if self.queuepos:
+                self.model.toggle_enqueued_many(self.queuepos.values())
+
+    undo = AlterItemlistMixin.insert_items
+    redo = remove_items
+
+##
+
+def _n_tracks_str(amount):
+    """Return '1 track' if amount is 1 else '$amount tracks'."""
+    if amount == 1:
+        return '1 track'
+    else:
+        return '%d tracks' % (amount,)

=== modified file 'minirok/right_side.py'
--- minirok/right_side.py	2008-01-22 19:17:34 +0000
+++ minirok/right_side.py	2008-02-11 11:53:44 +0000
@@ -19,14 +19,16 @@
 
         self.playlist = playlist.Playlist()
         self.stretchtoolbar = QtGui.QWidget()
+        self.playlistview = playlist.PlaylistView(self.playlist)
         self.toolbar = kdeui.KToolBar('playlistToolBar', main_window,
                                                 QtCore.Qt.BottomToolBarArea)
-        self.playlist_search = PlaylistSearchLineWidget(None, self.playlist)
+        # XXX-KDE4
+        # self.playlist_search = PlaylistSearchLineWidget(None, self.playlist)
 
         vlayout = QtGui.QVBoxLayout()
         vlayout.setSpacing(0)
-        vlayout.addWidget(self.playlist_search)
-        vlayout.addWidget(self.playlist)
+        # vlayout.addWidget(self.playlist_search)
+        vlayout.addWidget(self.playlistview)
         vlayout.addWidget(self.stretchtoolbar)
         self.setLayout(vlayout)
 
@@ -38,9 +40,10 @@
 
         self.toolbar.setToolButtonStyle(QtCore.Qt.ToolButtonIconOnly)
 
-        self.connect(self.playlist_search.searchLine(),
-                QtCore.SIGNAL('returnPressed(const QString &)'),
-                self.playlist.slot_play_first_visible)
+        # XXX-KDE4
+        # self.connect(self.playlist_search.searchLine(),
+                # QtCore.SIGNAL('returnPressed(const QString &)'),
+                # self.playlist.slot_play_first_visible)
 
         minirok.Globals.playlist = self.playlist
 

=== modified file 'minirok/statusbar.py'
--- minirok/statusbar.py	2008-01-04 14:57:31 +0000
+++ minirok/statusbar.py	2008-02-11 16:44:54 +0000
@@ -71,7 +71,8 @@
         self.label2.set_time(self.remaining) # XXX what if length was unset
 
     def slot_start(self):
-        self.length = minirok.Globals.playlist.currently_playing['Length'] or 0
+        tags = minirok.Globals.playlist.get_current_tags()
+        self.length = tags.get('Length', 0)
         self.slider.setRange(0, self.length)
         self.timer.start(1000, False) # False: not single-shot
         self.slot_update()

=== modified file 'minirok/tag_reader.py'
--- minirok/tag_reader.py	2008-01-22 12:31:33 +0000
+++ minirok/tag_reader.py	2008-02-11 11:58:43 +0000
@@ -9,30 +9,16 @@
 import mutagen.mp3
 import mutagen.easyid3
 
-from PyQt4 import QtCore
-
 import minirok
 from minirok import util
 
 ##
 
-class TagReader(QtCore.QObject):
-    """Reads tags from files in a pending queue."""
+class TagReader(util.ThreadedWorker):
+    """Worker to read tags from files."""
 
     def __init__(self):
-        QtCore.QObject.__init__(self)
-
-        self.worker = util.ThreadedWorker(lambda item: TagReader.tags(item.path))
-        self.connect(self.worker, QtCore.SIGNAL('items_ready'), self.update_done)
-        self.worker.start()
-
-    ##
-
-    def update_done(self):
-        for item, tags in self.worker.pop_done():
-            if tags:
-                item.update_tags(tags)
-                item.update_display()
+        util.ThreadedWorker.__init__(self, lambda item: self.tags(item.path))
 
     ##
 

=== modified file 'minirok/tree_view.py'
--- minirok/tree_view.py	2008-02-06 15:08:12 +0000
+++ minirok/tree_view.py	2008-02-06 15:48:02 +0000
@@ -83,8 +83,7 @@
         if not unicode(search_string).strip():
             return
 
-        # XXX-KDE4 childCount()
-        playlist_was_empty = bool(minirok.Globals.playlist.childCount() == 0)
+        playlist_was_empty = bool(minirok.Globals.playlist.rowCount() == 0)
         minirok.Globals.playlist.add_files(self.visible_files())
 
         if (playlist_was_empty

=== modified file 'minirok/util.py'
--- minirok/util.py	2008-02-12 13:37:11 +0000
+++ minirok/util.py	2008-02-12 13:37:43 +0000
@@ -6,6 +6,7 @@
 
 import os
 import re
+import stat
 import time
 import random
 
@@ -100,6 +101,52 @@
 
     return action
 
+def playable_from_untrusted(files, warn=False):
+    """Filter a list of untrusted paths to only include playable files.
+
+    This method takes a list of paths, and drops from it files that do not
+    exist or the engine can't play. Directories will be read and all its files
+    included as appropriate.
+
+    :param warn: If True, emit a warning for each skipped file, stating the
+            reason; if False, debug() statements will be emitted instead.
+    """
+    result = []
+
+    if warn:
+        warn = minirok.logger.warn
+    else:
+        warn = minirok.logger.debug
+
+    def append_path(path):
+        try:
+            mode = os.stat(path).st_mode
+        except OSError, e:
+            warn('skipping %r: %s', path, e.strerror)
+            return
+
+        if stat.S_ISDIR(mode):
+            try:
+                contents = sorted(os.listdir(path))
+            except OSError, e:
+                warn('skipping %r: %s', path, e.strerror)
+            else:
+                for entry in contents:
+                    append_path(os.path.join(path, entry))
+        elif stat.S_ISREG(mode):
+            if minirok.Globals.engine.can_play(path):
+                if path not in result:
+                    result.append(path)
+            else:
+                warn('skipping %r: not a playable format', path)
+        else:
+            warn('skipping %r: not a regular file', path)
+
+    for f in files:
+        append_path(f)
+
+    return result
+
 ##
 
 class HasConfig(object):
@@ -194,6 +241,11 @@
     def append(self, item):
         self.insert(random.randrange(len(self)+1), item)
 
+    def extend(self, seq):
+        seq = list(seq)
+        random.shuffle(seq)
+        list.extend(self, seq)
+
 ##
 
 def needs_lock(mutex_name):

=== modified file 'setup.sh'
--- setup.sh	2008-01-24 12:01:54 +0000
+++ setup.sh	2008-02-05 11:47:44 +0000
@@ -18,7 +18,6 @@
 BIN=`kde4-config --expandvars --install exe`
 APPS=`kde4-config --expandvars --install data`
 ICONS=`kde4-config --expandvars --install icon`
-CONFIG=`kde4-config --expandvars --install config`
 DESKTOP=`kde4-config --expandvars --install xdgdata-apps`
 MINIROK="$APPS/minirok"
 KHOTKEYS="$APPS/khotkeys" # XXX-KDE4
@@ -88,7 +87,6 @@
 	install_manpage
 	install_icons images/icons "$ICONS"
 	install_icons images/icons/private "$MINIROK/icons"
-	install_file config/minirokrc "$CONFIG"
 	install_file config/minirok.desktop "$DESKTOP"
 	install_file config/minirok.khotkeys "$KHOTKEYS"
 	install_file config/khotkeys_minirok.upd "$KCONF_UPDATE"

