from PyQt4 import QtGui, QtCore, QtSvg import sys from traceback import print_exc from misc import * from clSong import Song from clSettings import settings # constants used for fSongs LIB_ROW=0 LIB_VALUE=1 LIB_INDENT=2 LIB_NEXTROW=3 LIB_EXPANDED=4 LIB_PARENT=5 clrRowSel=QtGui.QColor(180,180,180) class SongList(QtGui.QWidget): """The SongList widget is a list optimized for displaying an array of songs, with filtering option.""" # CONFIGURATION VARIABLES " height of line in pxl" lineHeight=30 " font size in pxl" fontSize=16 " margin" margin=4 vmargin=(lineHeight-fontSize)/2-1 " width of the vscrollbar" scrollbarWidth=15 " minimum column width" minColumnWidth=50 " colors for alternating rows" colors=[QtGui.QColor(255,255,255), QtGui.QColor(230,230,255)] " color of selection" clrSel=QtGui.QColor(100,100,180) " background color" clrBg=QtGui.QColor(255,255,255) " indentation of hierarchy, in pixels" indentation=lineHeight " what function to call when the list is double clicked" onDoubleClick=None mode='playlist' # what mode is the songlist in? values: 'playlist', 'library' " the headers: ( (header, width, visible)+ )" headers=None songs=None # original songs numSongs=None # number of songs # 'edited' songs # in playlist mode, this can only filtering # in library mode, this indicates all entries: (row, tag-value, indentation, next-row, expanded)* fSongs=None # filtered songs numVisEntries=None # number of entries that are visible (including when scrolling) levels=[] # levels from the groupBy in library-mode groupByStr='' # groupBy used in library-mode vScrollbar=None hScrollbar=None topRow=-1 numRows=-1 # total number of rows that can be visible in 1 time selRows=None # ranges of selected rows: ( (startROw,endRow)* ) selIDs=None # ranges of selected IDs: [ [startID,endID] ] selMiscs=None # array of indexes for selected non-songs in library mode selMode=False # currently in select mode? resizeCol=None # resizing a column? clrID=None # do we have to color a row with certain ID? [ID, color] scrollMult=1 # how many rows do we jump when scrolling by dragging xOffset=0 # offset for drawing. Is changed by hScrollbar resizeColumn=None # indicates this column should be recalculated redrawID=None # redraw this ID/row only wgGfxAlbum=QtSvg.QSvgRenderer('gfx/gnome-cd.svg') wgGfxArtist=QtSvg.QSvgRenderer('gfx/user_icon.svg') def __init__(self, parent, name, headers, onDoubleClick): QtGui.QWidget.__init__(self, parent) self.onDoubleClick=onDoubleClick # we receive an array of strings; we convert that to an array of (header, width) self.name=name # load the headers, and fetch from the settings the width and visibility self.headers=map(lambda h: [h, int(settings.get('l%s.%s.width'%(self.name,h),250)) , settings.get('l%s.%s.visible'%(self.name,h),'1')=='1'], headers) self.headers.insert(0, ['id', 30, settings.get('l%s.%s.visible'%(self.name,'id'),'0')=='1']) self.songs=None self.numSongs=None self.fSongs=None self.selMiscs=[] self.numVisEntries=None self.xOffset=0 self.resizeColumn=None self.vScrollbar=QtGui.QScrollBar(QtCore.Qt.Vertical, self) self.vScrollbar.setMinimum(0) self.vScrollbar.setMaximum(1) self.vScrollbar.setValue(0) self.hScrollbar=QtGui.QScrollBar(QtCore.Qt.Horizontal, self) self.hScrollbar.setMinimum(0) self.hScrollbar.setMaximum(1) self.hScrollbar.setValue(0) self.hScrollbar.setPageStep(200) self.topRow=0 self.numRows=0 self.selRows=[] self.selMode=False self.clrID=[-1,0] self.updateSongs([]) doEvents() self.connect(self.vScrollbar, QtCore.SIGNAL('valueChanged(int)'),self.onVScroll) self.connect(self.hScrollbar, QtCore.SIGNAL('valueChanged(int)'),self.onHScroll) self.setMouseTracking(True) self.setFocusPolicy(QtCore.Qt.TabFocus or QtCore.Qt.ClickFocus or QtCore.Qt.StrongFocus or QtCore.Qt.WheelFocus) self.setAttribute(QtCore.Qt.WA_OpaquePaintEvent) font=QtGui.QFont() font.setPixelSize(self.fontSize) font.setFamily('Comic Sans Ms') self.setFont(font) def sizeHint(self): return QtCore.QSize(10000,10000) def getSongs(self): return self.songs def setMode(self, mode, groupBy=''): self.selRows=[] self.selIDs=[] self.selMode=False if mode=='playlist': self.fSongs=self.songs self.numVisEntries=len(self.fSongs) elif mode=='library': self.groupBy(groupBy) else: raise Exception('Unknown mode %' %(mode)) self.mode=mode self.resizeEvent(None) self.update() def groupBy(self, groupBy, strFilter=''): self.groupByStr=groupBy self.levels=groupBy.split('/') strFilter=strFilter.strip() # TODO also sort by other means, if necessary ... if self.levels[0]=='artist': compare=lambda left, right: cmp(\ str(left.getArtist()).lower(), \ str(right.getArtist()).lower() ) elif self.levels[0]=='album': compare=lambda left, right: cmp(\ left.getAlbum().lower(), \ right.getAlbum().lower() ) elif self.levels[0]=='genre': compare=lambda left, right: cmp(\ str(left.getTag('genre')).lower(), \ str(right.getTag('genre')).lower() ) else: compare=lambda left, right: 0 songs=self.songs if strFilter!='': songs=filter(lambda song: strFilter in str(song).lower(), songs) songs=sorted(songs, compare) numLevels=len(self.levels) self.fSongs=[[0, 'dummy', 0, -1, False]] row=0 # four levels ought to be enough for everyone curLevels=[[None,0], [None,0], [None,0], [None,0]] # contains the values of current levels curLevel=0 # current level we're in parents=[-1,-1,-1,-1] # index of parent for song in songs: for level in xrange(numLevels): # does the file have the required tag? try: tagValue=song._data[self.levels[level]] except: tagValue='' if tagValue==curLevels[level][LIB_ROW]: pass else: finalRow=row for i in xrange(level,numLevels): try: tagValue2=song._data[self.levels[i]] except: tagValue2='' pass self.fSongs[curLevels[i][1]][LIB_NEXTROW]=finalRow self.fSongs.append([row, tagValue2, i, row+1, 0, parents[i]]) parents[i+1]=row row+=1 curLevels[i]=[tagValue2, row] curLevel=numLevels self.fSongs.append([row, song, curLevel, row+1, 2, parents[curLevel]]) row+=1 # update last entries' next-row of each level # If we have e.g. artist/album, then the last artist and last album of that # artist have to be repointed to the end of the list, else problems arise # showing those entries ... # indicate for each level whether we have processed that level yet processed=[False, False, False, False, False] numFSongs=len(self.fSongs) for i in xrange(numFSongs-1,0,-1): song=self.fSongs[i] # look for last top-level entry if song[LIB_INDENT]==0: song[LIB_NEXTROW]=numFSongs break if processed[song[LIB_INDENT]]==False: song[LIB_NEXTROW]=numFSongs processed[song[LIB_INDENT]]=True # remove the dummy self.fSongs.pop(0) self.numVisEntries=len(filter(lambda entry: entry[LIB_INDENT]==0, self.fSongs)) self.resizeEvent(None) def updateSongs(self, songs): """Update the displayed songs and clears selection.""" self.songs=songs self.numSongs=len(songs) self.setMode(self.mode, self.groupByStr) self.resizeEvent(None) self.redrawID=None self.update() def selectedSongs(self): """Returns the list of selected songs.""" ret=[] if self.mode=='playlist': cmp=lambda song: song._data['id']>=range[0] and song._data['id']<=range[1] elif self.mode=='library': cmp=lambda song: song._data['id']>=range[0] and song._data['id']<=range[1] for range in self.selIDs: # look for the songs in the current range songs=filter(cmp, self.songs) # add songs in range ret.extend(songs) return ret def filter(self, strFilter): """Filter songs according to $strFilter.""" if self.mode=='playlist': self.fSongs=filter(lambda song: strFilter in str(song).lower(), self.songs) self.numVisEntries=len(self.fSongs) self.resizeEvent(None) else: self.groupBy(self.groupByStr, strFilter) self.update() def colorID(self, id, clr): """Color the row which contains song with id $id $clr.""" self.clrID=[id, clr] self.redrawID=id self.update() def selectRow(self, row): """Make $row the current selection.""" self.selRows=[[row,row]] self.update() def showColumn(self, column, show=True): """Hide or show column $column.""" self.headers[column][2]=show self.update() def autoSizeColumn(self, column): """Resizes column $column to fit the widest entry in the non-filtered songs.""" # we can't calculate it here, as retrieving the text-width can only # be done in the paintEvent method ... self.resizeColumn=column self.update() def visibleSongs(self): """Get the songs currently visible.""" ret=[] for row in xrange(self.topRow, min(self.numSongs, self.topRow+self.numRows)-1): ret.append(self.fSongs[row]) return ret def ensureVisible(self, id): """Make sure the song with $id is visible.""" if len(filter(lambda song: song.getID()==id, self.visibleSongs())): return row=0 for song in self.fSongs: if song.getID()==id: self.vScrollbar.setValue(row-self.numRows/2) self.update() break row+=1 def onVScroll(self, value): # 'if value<0' needed because minimum can be after init <0 at some point ... if value<0: value=0 if value>self.numVisEntries:value=self.numVisEntries self.topRow=value self.update() def onHScroll(self, value): self.xOffset=-self.hScrollbar.value()*2 self.update() def _pos2row(self, pos): return int(pos.y()/self.lineHeight)-1 def _row2entry(self, row): entry=self.fSongs[0] try: while row>0: if entry[LIB_EXPANDED]: entry=self.fSongs[entry[LIB_ROW]+1] else: entry=self.fSongs[entry[LIB_NEXTROW]] row-=1 except: return None return entry def focusOutEvent(self, event): self.update() def focusInEvent(self, event): self.update() def wheelEvent(self, event): if self.vScrollbar.isVisible(): event.accept() numDegrees=event.delta() / 8 numSteps=5*numDegrees/15 self.vScrollbar.setValue(self.vScrollbar.value()-numSteps) def resizeEvent(self, event): # max nr of rows shown self.numRows=int(self.height()/self.lineHeight) # check vertical scrollbar if self.numRows>self.numVisEntries: self.vScrollbar.setVisible(False) self.vScrollbar.setValue(0) else: self.vScrollbar.setVisible(True) self.vScrollbar.setPageStep(self.numRows-2) self.vScrollbar.setMinimum(0) self.vScrollbar.setMaximum(self.numVisEntries-self.numRows+1) self.vScrollbar.resize(self.scrollbarWidth, self.height()-self.lineHeight-1) self.vScrollbar.move(self.width()-self.vScrollbar.width()-1, self.lineHeight-1) # check horizontal scrollbar self.scrollWidth=0 if self.mode=='playlist': for hdr in self.headers: if hdr[2]: self.scrollWidth+=hdr[1] if self.scrollWidth>self.width(): self.hScrollbar.setVisible(True) self.hScrollbar.setMinimum(0) self.hScrollbar.setMaximum((self.scrollWidth-self.width())/2) self.hScrollbar.resize(self.width()-4, self.scrollbarWidth) self.hScrollbar.move(2, self.height()-self.hScrollbar.height()-1) # some changes because the hScrollbar takes some vertical space ... self.vScrollbar.resize(self.vScrollbar.width(), self.vScrollbar.height()-self.lineHeight) self.vScrollbar.setMaximum(self.vScrollbar.maximum()+1) self.numRows-=1 else: self.hScrollbar.setVisible(False) self.hScrollbar.setValue(0) def mousePressEvent(self, event): self.setFocus() pos=event.pos() row=self._pos2row(pos) done=False # indicates whether some action has been done or not if self.mode=='playlist': self.scrollMult=1 if row==-1: # we're clicking in the header! self.resizeCol=None x=0+self.xOffset i=0 # check if we're clicking between two columns, if so: resize mode! for hdr in self.headers: if hdr[2]: x+=hdr[1] if abs(x-pos.x())<4: self.resizeCol=i done=True i+=1 elif self.mode=='library': entry=self._row2entry(row+self.topRow) if not entry: entry=self.fSongs[len(self.fSongs)-1] if entry and pos.x()>(1+entry[LIB_INDENT])*self.indentation \ and pos.x()<(1+entry[LIB_INDENT]+3/2)*self.indentation: # we clicked in the margin, to expand or collapse expanded=entry[LIB_EXPANDED] if expanded!=2: # there was a '+' or a '-'! entry[LIB_EXPANDED]=(expanded+1)%2 # we must find out how many entries have appeared/disappeard # while collapsing. visibles=0 # how many new elements have appeared? i=entry[LIB_ROW]+1 # current element looking at while i<=entry[LIB_NEXTROW]-1 and i=0: # we start selection mode if self.mode=='playlist': self.selRows=[[self.topRow+row,self.topRow+row]] elif self.mode=='library': self.selRows=[[entry[LIB_ROW], entry[LIB_NEXTROW]-1]] self.selMode=True self.update() def mouseMoveEvent(self, event): pos=event.pos() row=self._pos2row(pos) if self.selMode: # we're in selection mode if row<0: # scroll automatically when going out of the widget row=0 if self.topRow>0: self.scrollMult+=0.1 jump=int(self.scrollMult)*int(abs(pos.y())/self.lineHeight) self.vScrollbar.setValue(self.vScrollbar.value()-jump) row=jump elif row>=self.numRows: # scroll automatically when going out of the widget self.scrollMult+=0.1 jump=int(self.scrollMult)*int(abs(self.height()-pos.y())/self.lineHeight) self.vScrollbar.setValue(self.vScrollbar.value()+jump) row=self.numRows-jump else: # reset the scrollMultiplier self.scrollMult=1 if self.mode=='playlist': self.selRows[0][1]=row+self.topRow elif self.mode=='library': self.selRows[0][1]=self.libIthVisRowIndex(self.libIthVisRowIndex(0,self.topRow), row) self.update() elif self.resizeCol!=None: row-=1 # ohla, we're resizing a column! prev=0 # calculate where we are for i in xrange(self.resizeCol): hdr=self.headers[i] if hdr[2]: prev+=hdr[1] self.headers[self.resizeCol][1]=pos.x()-prev-self.xOffset # minimum width check? if self.headers[self.resizeCol][1]=0: self.onDoubleClick() else: # auto-size column x=0+self.xOffset i=0 for hdr in self.headers: if hdr[2]: x+=hdr[1] if abs(x-pos.x())<4: self.autoSizeColumn(i) break i+=1 def _paintPlaylist(self, p): self.redrawID=None lineHeight=self.lineHeight margin=self.margin vmargin=self.vmargin selRows=self.selRows width=self.width() if self.vScrollbar.isVisible(): width-=self.scrollbarWidth if self.resizeColumn!=None: # we're autoresizing! # must be done here, because only here we can check the textwidth! # This is because of limitations it can be only be done in paintEvent hdr=self.headers[self.resizeColumn][0] w=self.minColumnWidth # loop over all visible songs ... for song in self.fSongs: rect=p.boundingRect(10,10,1,1, QtCore.Qt.AlignLeft, str(song.getTag(hdr))) w=max(rect.width(), w) self.headers[self.resizeColumn][1]=w+2*margin self.saveColumnWidth(self.resizeColumn) self.resizeColumn=None self.resizeEvent(None) if self.redrawID!=None: # only update one row y=lineHeight for row in xrange(self.topRow, min(self.numVisEntries, self.topRow+self.numRows)): if self.fSongs[row]._data['id']==self.redrawID: self._paintPlaylistRow(p, row, y, width) y+=lineHeight self.redrawID=None return # paint the headers! p.fillRect(QtCore.QRect(0,0,width+self.vScrollbar.width(),lineHeight), QtGui.QBrush(QtCore.Qt.lightGray)) p.drawRect(QtCore.QRect(0,0,width+self.vScrollbar.width()-1,lineHeight-1)) x=margin+self.xOffset for hdr in self.headers: if hdr[2]: p.drawText(x, vmargin, hdr[1], lineHeight, QtCore.Qt.AlignLeft, hdr[0]) x+=hdr[1] p.drawLine(QtCore.QPoint(x-margin,0), QtCore.QPoint(x-margin,lineHeight)) if self.songs==None: return # fill the records! y=lineHeight for row in xrange(self.topRow, min(self.numVisEntries, self.topRow+self.numRows)): self._paintPlaylistRow(p, row, y, width) y+=lineHeight if y=min(range) and checkID<=max(range): clr=self.clrSel clrTxt=QtCore.Qt.white # it has a VIP-status! if id==int(self.clrID[0]): clrTxt=QtCore.Qt.white clr=self.clrID[1] # draw the row background p.fillRect(QtCore.QRect(2, y, width-3, lineHeight), QtGui.QBrush(clr)) # draw a subtile rectangle p.setPen(QtGui.QColor(230,230,255)) p.drawRect(QtCore.QRect(2, y, width-3, lineHeight)) # Back To Black p.setPen(QtCore.Qt.black) # draw the column x=margin+self.xOffset for hdr in self.headers: if hdr[2]: # only if visible, duh! # rectangle we're allowed to print in rect=p.boundingRect(x, y, hdr[1]-margin, lineHeight, QtCore.Qt.AlignLeft, str(song.getTag(hdr[0]))) text=str(song.getTag(hdr[0])) p.setPen(clrTxt) p.drawText(x, y+vmargin, hdr[1]-margin, lineHeight, QtCore.Qt.AlignLeft, text) if rect.width()>hdr[1]-margin: # print ellipsis, if necessary p.fillRect(x+hdr[1]-15,y+1,15,lineHeight-1, QtGui.QBrush(clr)) p.drawText(x+hdr[1]-15,y+vmargin,15,lineHeight-1, QtCore.Qt.AlignLeft, "...") x+=hdr[1] p.setPen(QtCore.Qt.black) p.drawLine(QtCore.QPoint(x-margin,y), QtCore.QPoint(x-margin,y+lineHeight)) def libFirstVisRowIndex(self): """Returns the index of the first visible row in library mode.""" # if not in library mode, the unthinkable might happen! Wooo! # TODO find better initial value row=0 # the visible rows we're visiting index=0 # what index does the current row have entries=self.fSongs while index=self.topRow: break entry=entries[index] if entry[LIB_EXPANDED]==0: index=entry[LIB_NEXTROW] else: index+=1 row+=1 return index def libIthVisRowIndex(self, index, i=1): """Returns the index of the $i-th next row after $index that is visible (or -1) in library mode.""" entries=self.fSongs while i>0 and index=min(range) and checkID<=max(range): clr=self.clrSel clrTxt=QtCore.Qt.white for i in self.selMiscs: if index==i: clr=self.clrSel clrTxt=QtCore.Qt.white # it has a VIP-status! if isSong and entry[LIB_VALUE].getID()==int(self.clrID[0]): clrTxt=QtCore.Qt.white clr=self.clrID[1] left=x+indent*(1+level) top=y+vmargin p.fillRect(QtCore.QRect(left,y,width-3,lineHeight), clr) p.setPen(clrTxt) p.drawText(left, top, 15, lineHeight, QtCore.Qt.AlignLeft, prefix) p.drawText(left+15, top, width, lineHeight, QtCore.Qt.AlignLeft, text) p.setPen(QtCore.Qt.black) obj=None if level