1/*
2 * This file Copyright (C) Mnemosyne LLC
3 *
4 * This program is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License version 2
6 * as published by the Free Software Foundation.
7 *
8 * http://www.gnu.org/licenses/old-licenses/gpl-2.0.html
9 *
10 * $Id: filterbar.cc 12607 2011-08-01 22:24:24Z jordan $
11 */
12
13#include <QString>
14#include <QtGui>
15
16#include "app.h"
17#include "favicon.h"
18#include "filters.h"
19#include "filterbar.h"
20#include "hig.h"
21#include "prefs.h"
22#include "torrent-filter.h"
23#include "torrent-model.h"
24#include "utils.h"
25
26/****
27*****
28*****  DELEGATE
29*****
30****/
31
32enum
33{
34    TorrentCountRole = Qt::UserRole + 1,
35    ActivityRole,
36    TrackerRole
37};
38
39namespace
40{
41    int getHSpacing( QWidget * w )
42    {
43        return qMax( int(HIG::PAD_SMALL), w->style()->pixelMetric( QStyle::PM_LayoutHorizontalSpacing, 0, w ) );
44    }
45}
46
47FilterBarComboBoxDelegate :: FilterBarComboBoxDelegate( QObject * parent, QComboBox * combo ):
48    QItemDelegate( parent ),
49    myCombo( combo )
50{
51}
52
53bool
54FilterBarComboBoxDelegate :: isSeparator( const QModelIndex &index )
55{
56    return index.data(Qt::AccessibleDescriptionRole).toString() == QLatin1String("separator");
57}
58void
59FilterBarComboBoxDelegate :: setSeparator( QAbstractItemModel * model, const QModelIndex& index )
60{
61    model->setData( index, QString::fromLatin1("separator"), Qt::AccessibleDescriptionRole );
62
63    if( QStandardItemModel *m = qobject_cast<QStandardItemModel*>(model) )
64       if (QStandardItem *item = m->itemFromIndex(index))
65           item->setFlags(item->flags() & ~(Qt::ItemIsSelectable|Qt::ItemIsEnabled));
66}
67
68void
69FilterBarComboBoxDelegate :: paint( QPainter                    * painter,
70                                    const QStyleOptionViewItem  & option,
71                                    const QModelIndex           & index ) const
72{
73    if( isSeparator( index ) )
74    {
75        QRect rect = option.rect;
76        if (const QStyleOptionViewItemV3 *v3 = qstyleoption_cast<const QStyleOptionViewItemV3*>(&option))
77            if (const QAbstractItemView *view = qobject_cast<const QAbstractItemView*>(v3->widget))
78                rect.setWidth(view->viewport()->width());
79        QStyleOption opt;
80        opt.rect = rect;
81        myCombo->style()->drawPrimitive(QStyle::PE_IndicatorToolBarSeparator, &opt, painter, myCombo);
82    }
83    else
84    {
85        QStyleOptionViewItem disabledOption = option;
86        disabledOption.state &= ~( QStyle::State_Enabled | QStyle::State_Selected );
87        QRect boundingBox = option.rect;
88
89        const int hmargin = getHSpacing( myCombo );
90        boundingBox.setLeft( boundingBox.left() + hmargin );
91        boundingBox.setRight( boundingBox.right() - hmargin );
92
93        QRect decorationRect = rect( option, index, Qt::DecorationRole );
94        decorationRect.moveLeft( decorationRect.left( ) );
95        decorationRect.setSize( myCombo->iconSize( ) );
96        decorationRect = QStyle::alignedRect( Qt::LeftToRight,
97                                              Qt::AlignLeft|Qt::AlignVCenter,
98                                              decorationRect.size(), boundingBox );
99        boundingBox.setLeft( decorationRect.right() + hmargin );
100
101        QRect countRect  = rect( option, index, TorrentCountRole );
102        countRect = QStyle::alignedRect( Qt::LeftToRight,
103                                         Qt::AlignRight|Qt::AlignVCenter,
104                                         countRect.size(), boundingBox );
105        boundingBox.setRight( countRect.left() - hmargin );
106        const QRect displayRect = boundingBox;
107
108        drawBackground( painter, option, index );
109        QStyleOptionViewItem option2 = option;
110        option2.decorationSize = myCombo->iconSize( );
111        drawDecoration( painter, option, decorationRect, decoration(option2,index.data(Qt::DecorationRole)) );
112        drawDisplay( painter, option, displayRect, index.data(Qt::DisplayRole).toString() );
113        drawDisplay( painter, disabledOption, countRect, index.data(TorrentCountRole).toString() );
114        drawFocus( painter, option, displayRect|countRect );
115    }
116}
117
118QSize
119FilterBarComboBoxDelegate :: sizeHint( const QStyleOptionViewItem & option,
120                                       const QModelIndex          & index ) const
121{
122    if( isSeparator( index ) )
123    {
124        const int pm = myCombo->style()->pixelMetric(QStyle::PM_DefaultFrameWidth, 0, myCombo);
125        return QSize( pm, pm + 10 );
126    }
127    else
128    {
129        QStyle * s = myCombo->style( );
130        const int hmargin = getHSpacing( myCombo );
131
132        QSize size = QItemDelegate::sizeHint( option, index );
133        size.setHeight( qMax( size.height(), myCombo->iconSize().height() + 6 ) );
134        size.rwidth() += s->pixelMetric( QStyle::PM_FocusFrameHMargin, 0, myCombo );
135        size.rwidth() += rect(option,index,TorrentCountRole).width();
136        size.rwidth() += hmargin * 4;
137        return size;
138    }
139}
140
141/**
142***
143**/
144
145FilterBarComboBox :: FilterBarComboBox( QWidget * parent ):
146    QComboBox( parent )
147{
148}
149
150void
151FilterBarComboBox :: paintEvent( QPaintEvent * e )
152{
153    Q_UNUSED( e );
154
155    QStylePainter painter(this);
156    painter.setPen(palette().color(QPalette::Text));
157
158    // draw the combobox frame, focusrect and selected etc.
159    QStyleOptionComboBox opt;
160    initStyleOption(&opt);
161    painter.drawComplexControl(QStyle::CC_ComboBox, opt);
162
163    // draw the icon and text
164    const QModelIndex modelIndex = model()->index( currentIndex(), 0, rootModelIndex() );
165    if( modelIndex.isValid( ) )
166    {
167        QStyle * s = style();
168        QRect rect = s->subControlRect( QStyle::CC_ComboBox, &opt, QStyle::SC_ComboBoxEditField, this );
169        const int hmargin = getHSpacing( this );
170        rect.setRight( rect.right() - hmargin );
171
172        // draw the icon
173        QPixmap pixmap;
174        QVariant variant = modelIndex.data( Qt::DecorationRole );
175        switch( variant.type( ) ) {
176            case QVariant::Pixmap: pixmap = qvariant_cast<QPixmap>(variant); break;
177            case QVariant::Icon:   pixmap = qvariant_cast<QIcon>(variant).pixmap(iconSize()); break;
178            default: break;
179        }
180        if( !pixmap.isNull() ) {
181            s->drawItemPixmap( &painter, rect, Qt::AlignLeft|Qt::AlignVCenter, pixmap );
182            rect.setLeft( rect.left() + pixmap.width() + hmargin );
183        }
184
185        // draw the count
186        QString text = modelIndex.data(TorrentCountRole).toString();
187        if( !text.isEmpty( ) )
188        {
189            const QPen pen = painter.pen( );
190            painter.setPen( opt.palette.color( QPalette::Disabled, QPalette::Text ) );
191            QRect r = s->itemTextRect( painter.fontMetrics(), rect, Qt::AlignRight|Qt::AlignVCenter, false, text );
192            painter.drawText( r, 0, text );
193            rect.setRight( r.left() - hmargin );
194            painter.setPen( pen );
195        }
196
197        // draw the text
198        text = modelIndex.data( Qt::DisplayRole ).toString();
199        text = painter.fontMetrics().elidedText ( text, Qt::ElideRight, rect.width() );
200        s->drawItemText( &painter, rect, Qt::AlignLeft|Qt::AlignVCenter, opt.palette, true, text );
201    }
202}
203
204/****
205*****
206*****  ACTIVITY
207*****
208****/
209
210QComboBox*
211FilterBar :: createActivityCombo( )
212{
213    QComboBox * c = new FilterBarComboBox( this );
214    FilterBarComboBoxDelegate * delegate = new FilterBarComboBoxDelegate( 0, c );
215    c->setItemDelegate( delegate );
216
217    QPixmap blankPixmap( c->iconSize( ) );
218    blankPixmap.fill( Qt::transparent );
219    QIcon blankIcon( blankPixmap );
220
221    QStandardItemModel * model = new QStandardItemModel;
222
223    QStandardItem * row = new QStandardItem( tr( "All" ) );
224    row->setData( FilterMode::SHOW_ALL, ActivityRole );
225    model->appendRow( row );
226
227    model->appendRow( new QStandardItem ); // separator
228    delegate->setSeparator( model, model->index( 1, 0 ) );
229
230    row = new QStandardItem( QIcon::fromTheme( "system-run", blankIcon ), tr( "Active" ) );
231    row->setData( FilterMode::SHOW_ACTIVE, ActivityRole );
232    model->appendRow( row );
233
234    row = new QStandardItem( QIcon::fromTheme( "go-down", blankIcon ), tr( "Downloading" ) );
235    row->setData( FilterMode::SHOW_DOWNLOADING, ActivityRole );
236    model->appendRow( row );
237
238    row = new QStandardItem( QIcon::fromTheme( "go-up", blankIcon ), tr( "Seeding" ) );
239    row->setData( FilterMode::SHOW_SEEDING, ActivityRole );
240    model->appendRow( row );
241
242    row = new QStandardItem( QIcon::fromTheme( "media-playback-pause", blankIcon ), tr( "Paused" ) );
243    row->setData( FilterMode::SHOW_PAUSED, ActivityRole );
244    model->appendRow( row );
245
246    row = new QStandardItem( blankIcon, tr( "Finished" ) );
247    row->setData( FilterMode::SHOW_FINISHED, ActivityRole );
248    model->appendRow( row );
249
250    row = new QStandardItem( QIcon::fromTheme( "view-refresh", blankIcon ), tr( "Verifying" ) );
251    row->setData( FilterMode::SHOW_VERIFYING, ActivityRole );
252    model->appendRow( row );
253
254    row = new QStandardItem( QIcon::fromTheme( "dialog-error", blankIcon ), tr( "Error" ) );
255    row->setData( FilterMode::SHOW_ERROR, ActivityRole );
256    model->appendRow( row );
257
258    c->setModel( model );
259    return c;
260}
261
262/****
263*****
264*****
265*****
266****/
267
268namespace
269{
270    QString readableHostName( const QString host )
271    {
272        // get the readable name...
273        QString name = host;
274        const int pos = name.lastIndexOf( '.' );
275        if( pos >= 0 )
276            name.truncate( pos );
277        if( !name.isEmpty( ) )
278            name[0] = name[0].toUpper( );
279        return name;
280    }
281}
282
283void
284FilterBar :: refreshTrackers( )
285{
286    Favicons& favicons = dynamic_cast<MyApp*>(QApplication::instance())->favicons;
287    const int firstTrackerRow = 2; // skip over the "All" and separator...
288
289    // pull info from the tracker model...
290    QSet<QString> oldHosts;
291    for( int row=firstTrackerRow; ; ++row ) {
292        QModelIndex index = myTrackerModel->index( row, 0 );
293        if( !index.isValid( ) )
294            break;
295        oldHosts << index.data(TrackerRole).toString();
296    }
297
298    // pull the new stats from the torrent model...
299    QSet<QString> newHosts;
300    QMap<QString,int> torrentsPerHost;
301    for( int row=0; ; ++row )
302    {
303        QModelIndex index = myTorrents.index( row, 0 );
304        if( !index.isValid( ) )
305            break;
306        const Torrent * tor = index.data( TorrentModel::TorrentRole ).value<const Torrent*>();
307        const QStringList trackers = tor->trackers( );
308        QSet<QString> torrentNames;
309        foreach( QString tracker, trackers ) {
310            const QString host = Favicons::getHost( QUrl( tracker ) );
311            if( host.isEmpty( ) )
312                qWarning() << "torrent" << qPrintable(tor->name()) << "has an invalid announce URL:" << tracker;
313            else {
314                newHosts.insert( host );
315                torrentNames.insert( readableHostName( host ) );
316            }
317        }
318        foreach( QString name, torrentNames )
319            ++torrentsPerHost[ name ];
320    }
321
322    // update the "All" row
323    myTrackerModel->setData( myTrackerModel->index(0,0), getCountString(myTorrents.rowCount()), TorrentCountRole );
324
325    // rows to update
326    foreach( QString host, oldHosts & newHosts )
327    {
328        const QString name = readableHostName( host );
329        QStandardItem * row = myTrackerModel->findItems(name).front();
330        row->setData( getCountString(torrentsPerHost[name]), TorrentCountRole );
331        row->setData( favicons.findFromHost(host), Qt::DecorationRole );
332    }
333
334    // rows to remove
335    foreach( QString host, oldHosts - newHosts ) {
336        const QString name = readableHostName( host );
337        QStandardItem * item = myTrackerModel->findItems(name).front();
338        if( !item->data(TrackerRole).toString().isEmpty() ) // don't remove "All"
339            myTrackerModel->removeRows( item->row(), 1 );
340    }
341
342    // rows to add
343    bool anyAdded = false;
344    foreach( QString host, newHosts - oldHosts )
345    {
346        const QString name = readableHostName( host );
347
348        if( !myTrackerModel->findItems(name).isEmpty() )
349            continue;
350
351        // find the sorted position to add this row
352        int i = firstTrackerRow;
353        for( int n=myTrackerModel->rowCount(); i<n; ++i ) {
354            const QString rowName = myTrackerModel->index(i,0).data(Qt::DisplayRole).toString();
355            if( rowName >= name )
356                break;
357        }
358
359        // add the row
360        QStandardItem * row = new QStandardItem( favicons.findFromHost( host ), name );
361        row->setData( getCountString(torrentsPerHost[host]), TorrentCountRole );
362        row->setData( favicons.findFromHost(host), Qt::DecorationRole );
363        row->setData( host, TrackerRole );
364        myTrackerModel->insertRow( i, row );
365        anyAdded = true;
366    }
367
368    if( anyAdded ) // the one added might match our filter...
369        refreshPref( Prefs::FILTER_TRACKERS );
370}
371
372
373QComboBox*
374FilterBar :: createTrackerCombo( QStandardItemModel * model )
375{
376    QComboBox * c = new FilterBarComboBox( this );
377    FilterBarComboBoxDelegate * delegate = new FilterBarComboBoxDelegate( 0, c );
378    c->setItemDelegate( delegate );
379
380    QStandardItem * row = new QStandardItem( tr( "All" ) );
381    row->setData( "", TrackerRole );
382    row->setData( getCountString(myTorrents.rowCount()), TorrentCountRole );
383    model->appendRow( row );
384
385    model->appendRow( new QStandardItem ); // separator
386    delegate->setSeparator( model, model->index( 1, 0 ) );
387
388    c->setModel( model );
389    return c;
390}
391
392/****
393*****
394*****
395*****
396****/
397
398FilterBar :: FilterBar( Prefs& prefs, TorrentModel& torrents, TorrentFilter& filter, QWidget * parent ):
399    QWidget( parent ),
400    myPrefs( prefs ),
401    myTorrents( torrents ),
402    myFilter( filter ),
403    myRecountTimer( new QTimer( this ) ),
404    myIsBootstrapping( true )
405{
406    QHBoxLayout * h = new QHBoxLayout( this );
407    const int hmargin = qMax( int(HIG::PAD), style()->pixelMetric( QStyle::PM_LayoutHorizontalSpacing ) );
408
409    h->setSpacing( 0 );
410    h->setContentsMargins( 2, 2, 2, 2 );
411    h->addWidget( new QLabel( tr( "Show:" ), this ) );
412    h->addSpacing( hmargin );
413
414    myActivityCombo = createActivityCombo( );
415    h->addWidget( myActivityCombo, 1 );
416    h->addSpacing( hmargin );
417
418    myTrackerModel = new QStandardItemModel;
419    myTrackerCombo = createTrackerCombo( myTrackerModel );
420    h->addWidget( myTrackerCombo, 1 );
421    h->addSpacing( hmargin*2 );
422
423    myLineEdit = new QLineEdit( this );
424    h->addWidget( myLineEdit );
425    connect( myLineEdit, SIGNAL(textChanged(QString)), this, SLOT(onTextChanged(QString)));
426
427    QPushButton * p = new QPushButton;
428    QIcon icon = QIcon::fromTheme( "edit-clear", style()->standardIcon( QStyle::SP_DialogCloseButton ) );
429    int iconSize = style()->pixelMetric( QStyle::PM_SmallIconSize );
430    p->setIconSize( QSize( iconSize, iconSize ) );
431    p->setIcon( icon );
432    p->setFlat( true );
433    h->addWidget( p );
434    connect( p, SIGNAL(clicked(bool)), myLineEdit, SLOT(clear()));
435
436    // listen for changes from the other players
437    connect( &myPrefs, SIGNAL(changed(int)), this, SLOT(refreshPref(int)));
438    connect( myActivityCombo, SIGNAL(currentIndexChanged(int)), this, SLOT(onActivityIndexChanged(int)));
439    connect( myTrackerCombo, SIGNAL(currentIndexChanged(int)), this, SLOT(onTrackerIndexChanged(int)));
440    connect( &myTorrents, SIGNAL(modelReset()), this, SLOT(onTorrentModelReset()));
441    connect( &myTorrents, SIGNAL(rowsInserted(const QModelIndex&,int,int)), this, SLOT(onTorrentModelRowsInserted(const QModelIndex&,int,int)));
442    connect( &myTorrents, SIGNAL(rowsRemoved(const QModelIndex&,int,int)), this, SLOT(onTorrentModelRowsRemoved(const QModelIndex&,int,int)));
443    connect( &myTorrents, SIGNAL(dataChanged(const QModelIndex&,const QModelIndex&)), this, SLOT(onTorrentModelDataChanged(const QModelIndex&,const QModelIndex&)));
444    connect( myRecountTimer, SIGNAL(timeout()), this, SLOT(recount()) );
445
446    recountSoon( );
447    refreshTrackers( );
448    myIsBootstrapping = false;
449
450    // initialize our state
451    QList<int> initKeys;
452    initKeys << Prefs :: FILTER_MODE
453             << Prefs :: FILTER_TRACKERS;
454    foreach( int key, initKeys )
455        refreshPref( key );
456}
457
458FilterBar :: ~FilterBar( )
459{
460    delete myRecountTimer;
461}
462
463/***
464****
465***/
466
467void
468FilterBar :: refreshPref( int key )
469{
470    switch( key )
471    {
472        case Prefs :: FILTER_MODE: {
473            const FilterMode m = myPrefs.get<FilterMode>( key );
474            QAbstractItemModel * model = myActivityCombo->model( );
475            QModelIndexList indices = model->match( model->index(0,0), ActivityRole, m.mode(), -1 );
476            myActivityCombo->setCurrentIndex( indices.isEmpty() ? 0 : indices.first().row( ) );
477            break;
478        }
479
480        case Prefs :: FILTER_TRACKERS: {
481            const QString tracker = myPrefs.getString( key );
482            const QString name = readableHostName( tracker );
483            QList<QStandardItem*> rows = myTrackerModel->findItems(name);
484            if( !rows.isEmpty() )
485                myTrackerCombo->setCurrentIndex( rows.front()->row() );
486            else { // hm, we don't seem to have this tracker anymore...
487                const bool isBootstrapping = myTrackerModel->rowCount( ) <= 2;
488                if( !isBootstrapping )
489                    myPrefs.set( key, "" );
490            }
491            break;
492        }
493
494        case Prefs :: FILTER_TEXT:
495            myLineEdit->setText( myPrefs.getString( key ) );
496            break;
497    }
498}
499
500void
501FilterBar :: onTextChanged( const QString& str )
502{
503    if( !myIsBootstrapping )
504        myPrefs.set( Prefs::FILTER_TEXT, str.trimmed( ) );
505}
506
507void
508FilterBar :: onTrackerIndexChanged( int i )
509{
510    if( !myIsBootstrapping )
511    {
512        QString str;
513        const bool isTracker = !myTrackerCombo->itemData(i,TrackerRole).toString().isEmpty();
514        if( !isTracker ) // show all
515            str = "";
516        else {
517            str = myTrackerCombo->itemData(i,TrackerRole).toString();
518            const int pos = str.lastIndexOf( '.' );
519            if( pos >= 0 )
520              str.truncate( pos+1 );
521        }
522        myPrefs.set( Prefs::FILTER_TRACKERS, str );
523    }
524}
525
526void
527FilterBar :: onActivityIndexChanged( int i )
528{
529    if( !myIsBootstrapping )
530    {
531        const FilterMode mode = myActivityCombo->itemData( i, ActivityRole ).toInt( );
532        myPrefs.set( Prefs::FILTER_MODE, mode );
533    }
534}
535
536/***
537****
538***/
539
540void FilterBar :: onTorrentModelReset( ) { recountSoon( ); }
541void FilterBar :: onTorrentModelRowsInserted( const QModelIndex&, int, int ) { recountSoon( ); }
542void FilterBar :: onTorrentModelRowsRemoved( const QModelIndex&, int, int ) { recountSoon( ); }
543void FilterBar :: onTorrentModelDataChanged( const QModelIndex&, const QModelIndex& ) { recountSoon( ); }
544
545void
546FilterBar :: recountSoon( )
547{
548    if( !myRecountTimer->isActive( ) )
549    {
550        myRecountTimer->setSingleShot( true );
551        myRecountTimer->start( 500 );
552    }
553}
554void
555FilterBar :: recount ( )
556{
557    // recount the activity combobox...
558    for( int i=0, n=FilterMode::NUM_MODES; i<n; ++i )
559    {
560        const FilterMode m( i );
561        QAbstractItemModel * model = myActivityCombo->model( );
562        QModelIndexList indices = model->match( model->index(0,0), ActivityRole, m.mode(), -1 );
563        if( !indices.isEmpty( ) )
564            model->setData( indices.first(), getCountString(myFilter.count(m)), TorrentCountRole );
565    }
566
567    refreshTrackers( );
568}
569
570QString
571FilterBar :: getCountString( int n ) const
572{
573    return QString("%L1").arg(n);
574}
575