1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2007, 2008 Andrew Resch <andrewresch@gmail.com>
4#
5# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with
6# the additional special exception to link portions of this program with the OpenSSL library.
7# See LICENSE for more details.
8#
9
10"""The torrent view component that lists all torrents in the session."""
11from __future__ import unicode_literals
12
13import logging
14from locale import strcoll
15
16from gi.repository.Gdk import ModifierType, keyval_name
17from gi.repository.GLib import idle_add
18from gi.repository.GObject import TYPE_UINT64
19from gi.repository.Gtk import EntryIconPosition
20from twisted.internet import reactor
21
22import deluge.component as component
23from deluge.common import decode_bytes
24from deluge.ui.client import client
25
26from . import torrentview_data_funcs as funcs
27from .common import cmp
28from .listview import ListView
29from .removetorrentdialog import RemoveTorrentDialog
30
31log = logging.getLogger(__name__)
32
33try:
34    CTRL_ALT_MASK = ModifierType.CONTROL_MASK | ModifierType.MOD1_MASK
35except TypeError:
36    # Sphinx AutoDoc has a mock issue with Gdk masks.
37    pass
38
39
40def str_nocase_sort(model, iter1, iter2, data):
41    """Sort string column data using ISO 14651 in lowercase.
42
43    Uses locale.strcoll which (allegedly) uses ISO 14651. Compares first
44    value with second and returns -1, 0, 1 for where it should be placed.
45
46    """
47    v1 = model[iter1][data]
48    v2 = model[iter2][data]
49    # Catch any values of None from model.
50    v1 = v1.lower() if v1 else ''
51    v2 = v2.lower() if v2 else ''
52    return strcoll(v1, v2)
53
54
55def queue_peer_seed_sort_function(v1, v2):
56    if v1 == v2:
57        return 0
58    if v2 < 0:
59        return -1
60    if v1 < 0:
61        return 1
62    if v1 > v2:
63        return 1
64    if v2 > v1:
65        return -1
66
67
68def queue_column_sort(model, iter1, iter2, data):
69    v1 = model[iter1][data]
70    v2 = model[iter2][data]
71    return queue_peer_seed_sort_function(v1, v2)
72
73
74def eta_column_sort(model, iter1, iter2, data):
75    v1 = model[iter1][data]
76    v2 = model[iter2][data]
77    if v1 == v2:
78        return 0
79    if v1 == 0:
80        return 1
81    if v2 == 0:
82        return -1
83    if v1 > v2:
84        return 1
85    if v2 > v1:
86        return -1
87
88
89def seed_peer_column_sort(model, iter1, iter2, data):
90    v1 = model[iter1][data]  # num seeds/peers
91    v3 = model[iter2][data]  # num seeds/peers
92    if v1 == v3:
93        v2 = model[iter1][data + 1]  # total seeds/peers
94        v4 = model[iter2][data + 1]  # total seeds/peers
95        return queue_peer_seed_sort_function(v2, v4)
96    return queue_peer_seed_sort_function(v1, v3)
97
98
99def progress_sort(model, iter1, iter2, sort_column_id):
100    progress1 = model[iter1][sort_column_id]
101    progress2 = model[iter2][sort_column_id]
102    # Progress value is equal, so sort on state
103    if progress1 == progress2:
104        state1 = model[iter1][sort_column_id + 1]
105        state2 = model[iter2][sort_column_id + 1]
106        return cmp(state1, state2)
107    return cmp(progress1, progress2)
108
109
110class SearchBox(object):
111    def __init__(self, torrentview):
112        self.torrentview = torrentview
113        mainwindow = component.get('MainWindow')
114        main_builder = mainwindow.get_builder()
115
116        self.visible = False
117        self.search_pending = self.prefiltered = None
118
119        self.search_box = main_builder.get_object('search_box')
120        self.search_torrents_entry = main_builder.get_object('search_torrents_entry')
121        self.close_search_button = main_builder.get_object('close_search_button')
122        self.match_search_button = main_builder.get_object('search_torrents_match')
123        mainwindow.connect_signals(self)
124
125    def show(self):
126        self.visible = True
127        self.search_box.show_all()
128        self.search_torrents_entry.grab_focus()
129
130    def hide(self):
131        self.visible = False
132        self.clear_search()
133        self.search_box.hide()
134        self.search_pending = self.prefiltered = None
135
136    def clear_search(self):
137        if self.search_pending and self.search_pending.active():
138            self.search_pending.cancel()
139
140        if self.prefiltered:
141            filter_column = self.torrentview.columns['filter'].column_indices[0]
142            torrent_id_column = self.torrentview.columns['torrent_id'].column_indices[0]
143            for row in self.torrentview.liststore:
144                torrent_id = row[torrent_id_column]
145
146                if torrent_id in self.prefiltered:
147                    # Reset to previous filter state
148                    self.prefiltered.pop(self.prefiltered.index(torrent_id))
149                    row[filter_column] = not row[filter_column]
150
151        self.prefiltered = None
152
153        self.search_torrents_entry.set_text('')
154        if self.torrentview.filter and 'name' in self.torrentview.filter:
155            self.torrentview.filter.pop('name', None)
156            self.search_pending = reactor.callLater(0.5, self.torrentview.update)
157
158    def set_search_filter(self):
159        if self.search_pending and self.search_pending.active():
160            self.search_pending.cancel()
161
162        if self.torrentview.filter and 'name' in self.torrentview.filter:
163            self.torrentview.filter.pop('name', None)
164
165        elif self.torrentview.filter is None:
166            self.torrentview.filter = {}
167
168        search_string = self.search_torrents_entry.get_text()
169        if not search_string:
170            self.clear_search()
171        else:
172            if self.match_search_button.get_active():
173                search_string += '::match'
174            self.torrentview.filter['name'] = search_string
175        self.prefilter_torrentview()
176
177    def prefilter_torrentview(self):
178        filter_column = self.torrentview.columns['filter'].column_indices[0]
179        torrent_id_column = self.torrentview.columns['torrent_id'].column_indices[0]
180        torrent_name_column = self.torrentview.columns[_('Name')].column_indices[1]
181
182        match_case = self.match_search_button.get_active()
183        if match_case:
184            search_string = self.search_torrents_entry.get_text()
185        else:
186            search_string = self.search_torrents_entry.get_text().lower()
187
188        if self.prefiltered is None:
189            self.prefiltered = []
190
191        for row in self.torrentview.liststore:
192            torrent_id = row[torrent_id_column]
193
194            if torrent_id in self.prefiltered:
195                # Reset to previous filter state
196                self.prefiltered.pop(self.prefiltered.index(torrent_id))
197                row[filter_column] = not row[filter_column]
198
199            if not row[filter_column]:
200                # Row is not visible(filtered out, but not by our filter), skip it
201                continue
202
203            if match_case:
204                torrent_name = row[torrent_name_column]
205            else:
206                torrent_name = row[torrent_name_column].lower()
207
208            if search_string in torrent_name and not row[filter_column]:
209                row[filter_column] = True
210                self.prefiltered.append(torrent_id)
211            elif search_string not in torrent_name and row[filter_column]:
212                row[filter_column] = False
213                self.prefiltered.append(torrent_id)
214
215    def on_close_search_button_clicked(self, widget):
216        self.hide()
217
218    def on_search_filter_toggle(self, widget):
219        if self.visible:
220            self.hide()
221        else:
222            self.show()
223
224    def on_search_torrents_match_toggled(self, widget):
225        if self.search_torrents_entry.get_text():
226            self.set_search_filter()
227            self.search_pending = reactor.callLater(0.7, self.torrentview.update)
228
229    def on_search_torrents_entry_icon_press(self, entry, icon, event):
230        if icon != EntryIconPosition.SECONDARY:
231            return
232        self.clear_search()
233
234    def on_search_torrents_entry_changed(self, widget):
235        self.set_search_filter()
236        self.search_pending = reactor.callLater(0.7, self.torrentview.update)
237
238
239class TorrentView(ListView, component.Component):
240    """TorrentView handles the listing of torrents."""
241
242    def __init__(self):
243        component.Component.__init__(
244            self, 'TorrentView', interval=2, depend=['SessionProxy']
245        )
246        main_builder = component.get('MainWindow').get_builder()
247        # Call the ListView constructor
248        ListView.__init__(
249            self, main_builder.get_object('torrent_view'), 'torrentview.state'
250        )
251        log.debug('TorrentView Init..')
252
253        # If we have gotten the state yet
254        self.got_state = False
255
256        # This is where status updates are put
257        self.status = {}
258
259        # We keep a copy of the previous status to compare for changes
260        self.prev_status = {}
261
262        # Register the columns menu with the listview so it gets updated accordingly.
263        self.register_checklist_menu(main_builder.get_object('menu_columns'))
264
265        # Add the columns to the listview
266        self.add_text_column('torrent_id', hidden=True, unique=True)
267        self.add_bool_column('dirty', hidden=True)
268        self.add_func_column(
269            '#',
270            funcs.cell_data_queue,
271            [int],
272            status_field=['queue'],
273            sort_func=queue_column_sort,
274        )
275        self.add_texticon_column(
276            _('Name'),
277            status_field=['state', 'name'],
278            function=funcs.cell_data_statusicon,
279            sort_func=str_nocase_sort,
280            default_sort=True,
281        )
282        self.add_func_column(
283            _('Size'),
284            funcs.cell_data_size,
285            [TYPE_UINT64],
286            status_field=['total_wanted'],
287        )
288        self.add_func_column(
289            _('Downloaded'),
290            funcs.cell_data_size,
291            [TYPE_UINT64],
292            status_field=['all_time_download'],
293            default=False,
294        )
295        self.add_func_column(
296            _('Uploaded'),
297            funcs.cell_data_size,
298            [TYPE_UINT64],
299            status_field=['total_uploaded'],
300            default=False,
301        )
302        self.add_func_column(
303            _('Remaining'),
304            funcs.cell_data_size,
305            [TYPE_UINT64],
306            status_field=['total_remaining'],
307            default=False,
308        )
309        self.add_progress_column(
310            _('Progress'),
311            status_field=['progress', 'state'],
312            col_types=[float, str],
313            function=funcs.cell_data_progress,
314            sort_func=progress_sort,
315        )
316        self.add_func_column(
317            _('Seeds'),
318            funcs.cell_data_peer,
319            [int, int],
320            status_field=['num_seeds', 'total_seeds'],
321            sort_func=seed_peer_column_sort,
322            default=False,
323        )
324        self.add_func_column(
325            _('Peers'),
326            funcs.cell_data_peer,
327            [int, int],
328            status_field=['num_peers', 'total_peers'],
329            sort_func=seed_peer_column_sort,
330            default=False,
331        )
332        self.add_func_column(
333            _('Seeds:Peers'),
334            funcs.cell_data_ratio_seeds_peers,
335            [float],
336            status_field=['seeds_peers_ratio'],
337            default=False,
338        )
339        self.add_func_column(
340            _('Down Speed'),
341            funcs.cell_data_speed_down,
342            [int],
343            status_field=['download_payload_rate'],
344        )
345        self.add_func_column(
346            _('Up Speed'),
347            funcs.cell_data_speed_up,
348            [int],
349            status_field=['upload_payload_rate'],
350        )
351        self.add_func_column(
352            _('Down Limit'),
353            funcs.cell_data_speed_limit_down,
354            [float],
355            status_field=['max_download_speed'],
356            default=False,
357        )
358        self.add_func_column(
359            _('Up Limit'),
360            funcs.cell_data_speed_limit_up,
361            [float],
362            status_field=['max_upload_speed'],
363            default=False,
364        )
365        self.add_func_column(
366            _('ETA'),
367            funcs.cell_data_time,
368            [int],
369            status_field=['eta'],
370            sort_func=eta_column_sort,
371        )
372        self.add_func_column(
373            _('Ratio'),
374            funcs.cell_data_ratio_ratio,
375            [float],
376            status_field=['ratio'],
377            default=False,
378        )
379        self.add_func_column(
380            _('Avail'),
381            funcs.cell_data_ratio_avail,
382            [float],
383            status_field=['distributed_copies'],
384            default=False,
385        )
386        self.add_func_column(
387            _('Added'),
388            funcs.cell_data_date_added,
389            [int],
390            status_field=['time_added'],
391            default=False,
392        )
393        self.add_func_column(
394            _('Completed'),
395            funcs.cell_data_date_completed,
396            [int],
397            status_field=['completed_time'],
398            default=False,
399        )
400        self.add_func_column(
401            _('Complete Seen'),
402            funcs.cell_data_date_or_never,
403            [int],
404            status_field=['last_seen_complete'],
405            default=False,
406        )
407        self.add_texticon_column(
408            _('Tracker'),
409            function=funcs.cell_data_trackericon,
410            status_field=['tracker_host', 'tracker_host'],
411            default=False,
412        )
413        self.add_text_column(
414            _('Download Folder'), status_field=['download_location'], default=False
415        )
416        self.add_text_column(_('Owner'), status_field=['owner'], default=False)
417        self.add_bool_column(
418            _('Shared'),
419            status_field=['shared'],
420            default=False,
421            tooltip=_('Torrent is shared between other Deluge users or not.'),
422        )
423        self.restore_columns_order_from_state()
424
425        # Set filter to None for now
426        self.filter = None
427
428        # Connect Signals #
429        # Connect to the 'button-press-event' to know when to bring up the
430        # torrent menu popup.
431        self.treeview.connect('button-press-event', self.on_button_press_event)
432        # Connect to the 'key-press-event' to know when the bring up the
433        # torrent menu popup via keypress.
434        self.treeview.connect('key-release-event', self.on_key_press_event)
435        # Connect to the 'changed' event of TreeViewSelection to get selection
436        # changes.
437        self.treeview.get_selection().connect('changed', self.on_selection_changed)
438
439        self.treeview.connect('drag-drop', self.on_drag_drop)
440        self.treeview.connect('drag_data_received', self.on_drag_data_received)
441        self.treeview.connect('key-press-event', self.on_key_press_event)
442        self.treeview.connect('columns-changed', self.on_columns_changed_event)
443
444        self.search_box = SearchBox(self)
445        self.permanent_status_keys = ['owner']
446        self.columns_to_update = []
447
448    def start(self):
449        """Start the torrentview"""
450        # We need to get the core session state to know which torrents are in
451        # the session so we can add them to our list.
452        # Only get the status fields required for the visible columns
453        status_fields = []
454        for listview_column in self.columns.values():
455            if listview_column.column.get_visible():
456                if not listview_column.status_field:
457                    continue
458                status_fields.extend(listview_column.status_field)
459        component.get('SessionProxy').get_torrents_status(
460            {}, status_fields
461        ).addCallback(self._on_session_state)
462
463        client.register_event_handler(
464            'TorrentStateChangedEvent', self.on_torrentstatechanged_event
465        )
466        client.register_event_handler('TorrentAddedEvent', self.on_torrentadded_event)
467        client.register_event_handler(
468            'TorrentRemovedEvent', self.on_torrentremoved_event
469        )
470        client.register_event_handler('SessionPausedEvent', self.on_sessionpaused_event)
471        client.register_event_handler(
472            'SessionResumedEvent', self.on_sessionresumed_event
473        )
474        client.register_event_handler(
475            'TorrentQueueChangedEvent', self.on_torrentqueuechanged_event
476        )
477
478    def _on_session_state(self, state):
479        self.add_rows(state)
480        self.got_state = True
481        # Update the view right away with our status
482        self.status = state
483        self.set_columns_to_update()
484        self.update_view(load_new_list=True)
485        self.select_first_row()
486
487    def stop(self):
488        """Stops the torrentview"""
489        client.deregister_event_handler(
490            'TorrentStateChangedEvent', self.on_torrentstatechanged_event
491        )
492        client.deregister_event_handler('TorrentAddedEvent', self.on_torrentadded_event)
493        client.deregister_event_handler(
494            'TorrentRemovedEvent', self.on_torrentremoved_event
495        )
496        client.deregister_event_handler(
497            'SessionPausedEvent', self.on_sessionpaused_event
498        )
499        client.deregister_event_handler(
500            'SessionResumedEvent', self.on_sessionresumed_event
501        )
502        client.deregister_event_handler(
503            'TorrentQueueChangedEvent', self.on_torrentqueuechanged_event
504        )
505
506        if self.treeview.get_selection():
507            self.treeview.get_selection().unselect_all()
508
509        # Save column state before clearing liststore
510        # so column sort details are correctly saved.
511        self.save_state()
512        self.liststore.clear()
513        self.prev_status = {}
514        self.filter = None
515        self.search_box.hide()
516
517    def shutdown(self):
518        """Called when GtkUi is exiting"""
519        pass
520
521    def save_state(self):
522        """
523        Saves the state of the torrent view.
524        """
525        if component.get('MainWindow').visible():
526            ListView.save_state(self, 'torrentview.state')
527
528    def remove_column(self, header):
529        """Removes the column with the name 'header' from the torrentview"""
530        self.save_state()
531        ListView.remove_column(self, header)
532
533    def set_filter(self, filter_dict):
534        """
535        Sets filters for the torrentview..
536
537        see: core.get_torrents_status
538        """
539        search_filter = self.filter and self.filter.get('name', None) or None
540        self.filter = dict(filter_dict)  # Copied version of filter_dict.
541        if search_filter and 'name' not in filter_dict:
542            self.filter['name'] = search_filter
543        self.update(select_row=True)
544
545    def set_columns_to_update(self, columns=None):
546        status_keys = []
547        self.columns_to_update = []
548
549        if columns is None:
550            # We need to iterate through all columns
551            columns = list(self.columns)
552
553        # Iterate through supplied list of columns to update
554        for column in columns:
555            # Make sure column is visible and has 'status_field' set.
556            # If not, we can ignore it.
557            if (
558                self.columns[column].column.get_visible() is True
559                and self.columns[column].hidden is False
560                and self.columns[column].status_field is not None
561            ):
562                for field in self.columns[column].status_field:
563                    status_keys.append(field)
564                    self.columns_to_update.append(column)
565
566        # Remove duplicates
567        self.columns_to_update = list(set(self.columns_to_update))
568        status_keys = list(set(status_keys + self.permanent_status_keys))
569        return status_keys
570
571    def send_status_request(self, columns=None, select_row=False):
572        # Store the 'status_fields' we need to send to core
573        status_keys = self.set_columns_to_update(columns)
574
575        # If there is nothing in status_keys then we must not continue
576        if status_keys is []:
577            return
578
579        # Remove duplicates from status_key list
580        status_keys = list(set(status_keys))
581
582        # Request the statuses for all these torrent_ids, this is async so we
583        # will deal with the return in a signal callback.
584        d = (
585            component.get('SessionProxy')
586            .get_torrents_status(self.filter, status_keys)
587            .addCallback(self._on_get_torrents_status)
588        )
589        if select_row:
590            d.addCallback(self.select_first_row)
591
592    def select_first_row(self, ignored=None):
593        """
594        Set the first row in the list selected if a selection does
595        not already exist
596        """
597        rows = self.treeview.get_selection().get_selected_rows()[1]
598        # Only select row if noe rows are selected
599        if not rows:
600            self.treeview.get_selection().select_path((0,))
601
602    def update(self, select_row=False):
603        """
604        Sends a status request to core and updates the torrent list with the result.
605
606        :param select_row: if the first row in the list should be selected if
607                           no rows are already selected.
608        :type select_row: boolean
609
610        """
611        if self.got_state:
612            if (
613                self.search_box.search_pending is not None
614                and self.search_box.search_pending.active()
615            ):
616                # An update request is scheduled, let's wait for that one
617                return
618            # Send a status request
619            idle_add(self.send_status_request, None, select_row)
620
621    def update_view(self, load_new_list=False):
622        """Update the torrent view model with data we've received."""
623        filter_column = self.columns['filter'].column_indices[0]
624        status = self.status
625
626        if not load_new_list:
627            # Freeze notications while updating
628            self.treeview.freeze_child_notify()
629
630        # Get the columns to update from one of the torrents
631        if status:
632            torrent_id = list(status)[0]
633            fields_to_update = []
634            for column in self.columns_to_update:
635                column_index = self.get_column_index(column)
636                for i, status_field in enumerate(self.columns[column].status_field):
637                    # Only use columns that the torrent has in the state
638                    if status_field in status[torrent_id]:
639                        fields_to_update.append((column_index[i], status_field))
640
641        for row in self.liststore:
642            torrent_id = row[self.columns['torrent_id'].column_indices[0]]
643            # We expect the torrent_id to be in status and prev_status,
644            # as it will be as long as the list isn't changed by the user
645
646            torrent_id_in_status = False
647            try:
648                torrent_status = status[torrent_id]
649                torrent_id_in_status = True
650                if torrent_status == self.prev_status[torrent_id]:
651                    # The status dict is the same, so do nothing to update for this torrent
652                    continue
653            except KeyError:
654                pass
655
656            if not torrent_id_in_status:
657                if row[filter_column] is True:
658                    row[filter_column] = False
659            else:
660                if row[filter_column] is False:
661                    row[filter_column] = True
662
663                # Find the fields to update
664                to_update = []
665                for i, status_field in fields_to_update:
666                    row_value = status[torrent_id][status_field]
667                    if decode_bytes(row[i]) != row_value:
668                        to_update.append(i)
669                        to_update.append(row_value)
670                # Update fields in the liststore
671                if to_update:
672                    self.liststore.set(row.iter, *to_update)
673
674        if load_new_list:
675            # Create the model filter. This sets the model for the treeview and enables sorting.
676            self.create_model_filter()
677        else:
678            self.treeview.thaw_child_notify()
679
680        component.get('MenuBar').update_menu()
681        self.prev_status = status
682
683    def _on_get_torrents_status(self, status, select_row=False):
684        """Callback function for get_torrents_status().  'status' should be a
685        dictionary of {torrent_id: {key, value}}."""
686        self.status = status
687        if self.search_box.prefiltered is not None:
688            self.search_box.prefiltered = None
689
690        if self.status == self.prev_status and self.prev_status:
691            # We do not bother updating since the status hasn't changed
692            self.prev_status = self.status
693            return
694        self.update_view()
695
696    def add_rows(self, torrent_ids):
697        """Accepts a list of torrent_ids to add to self.liststore"""
698        torrent_id_column = self.columns['torrent_id'].column_indices[0]
699        dirty_column = self.columns['dirty'].column_indices[0]
700        filter_column = self.columns['filter'].column_indices[0]
701        for torrent_id in torrent_ids:
702            # Insert a new row to the liststore
703            row = self.liststore.append()
704            self.liststore.set(
705                row,
706                torrent_id_column,
707                torrent_id,
708                dirty_column,
709                True,
710                filter_column,
711                True,
712            )
713
714    def remove_row(self, torrent_id):
715        """Removes a row with torrent_id"""
716        for row in self.liststore:
717            if row[self.columns['torrent_id'].column_indices[0]] == torrent_id:
718                self.liststore.remove(row.iter)
719                # Force an update of the torrentview
720                self.update(select_row=True)
721                break
722
723    def mark_dirty(self, torrent_id=None):
724        for row in self.liststore:
725            if (
726                not torrent_id
727                or row[self.columns['torrent_id'].column_indices[0]] == torrent_id
728            ):
729                # log.debug('marking %s dirty', torrent_id)
730                row[self.columns['dirty'].column_indices[0]] = True
731                if torrent_id:
732                    break
733
734    def get_selected_torrent(self):
735        """Returns a torrent_id or None.  If multiple torrents are selected,
736        it will return the torrent_id of the first one."""
737        selected = self.get_selected_torrents()
738        if selected:
739            return selected[0]
740        else:
741            return selected
742
743    def get_selected_torrents(self):
744        """Returns a list of selected torrents or None"""
745        torrent_ids = []
746        try:
747            paths = self.treeview.get_selection().get_selected_rows()[1]
748        except AttributeError:
749            # paths is likely None .. so lets return []
750            return []
751        try:
752            for path in paths:
753                try:
754                    row = self.treeview.get_model().get_iter(path)
755                except Exception as ex:
756                    log.debug('Unable to get iter from path: %s', ex)
757                    continue
758
759                child_row = self.treeview.get_model().convert_iter_to_child_iter(row)
760                child_row = (
761                    self.treeview.get_model()
762                    .get_model()
763                    .convert_iter_to_child_iter(child_row)
764                )
765                if self.liststore.iter_is_valid(child_row):
766                    try:
767                        value = self.liststore.get_value(
768                            child_row, self.columns['torrent_id'].column_indices[0]
769                        )
770                    except Exception as ex:
771                        log.debug('Unable to get value from row: %s', ex)
772                    else:
773                        torrent_ids.append(value)
774            if len(torrent_ids) == 0:
775                return []
776
777            return torrent_ids
778        except (ValueError, TypeError):
779            return []
780
781    def get_torrent_status(self, torrent_id):
782        """Returns data stored in self.status, it may not be complete"""
783        try:
784            return self.status[torrent_id]
785        except KeyError:
786            return {}
787
788    def get_visible_torrents(self):
789        return list(self.status)
790
791    # Callbacks #
792    def on_button_press_event(self, widget, event):
793        """This is a callback for showing the right-click context menu."""
794        log.debug('on_button_press_event')
795        # We only care about right-clicks
796        if event.button == 3 and event.window == self.treeview.get_bin_window():
797            x, y = event.get_coords()
798            path = self.treeview.get_path_at_pos(int(x), int(y))
799            if not path:
800                return
801            row = self.model_filter.get_iter(path[0])
802
803            if self.get_selected_torrents():
804                if (
805                    self.model_filter.get_value(
806                        row, self.columns['torrent_id'].column_indices[0]
807                    )
808                    not in self.get_selected_torrents()
809                ):
810                    self.treeview.get_selection().unselect_all()
811                    self.treeview.get_selection().select_iter(row)
812            else:
813                self.treeview.get_selection().select_iter(row)
814            torrentmenu = component.get('MenuBar').torrentmenu
815            torrentmenu.popup(None, None, None, None, event.button, event.time)
816            return True
817
818    def on_selection_changed(self, treeselection):
819        """This callback is know when the selection has changed."""
820        log.debug('on_selection_changed')
821        component.get('TorrentDetails').update()
822        component.get('MenuBar').update_menu()
823
824    def on_drag_drop(self, widget, drag_context, x, y, timestamp):
825        widget.stop_emission('drag-drop')
826
827    def on_drag_data_received(
828        self, widget, drag_context, x, y, selection_data, info, timestamp
829    ):
830        widget.stop_emission('drag_data_received')
831
832    def on_columns_changed_event(self, treeview):
833        log.debug('Treeview Columns Changed')
834        self.save_state()
835
836    def on_torrentadded_event(self, torrent_id, from_state):
837        self.add_rows([torrent_id])
838        self.update(select_row=True)
839
840    def on_torrentremoved_event(self, torrent_id):
841        self.remove_row(torrent_id)
842
843    def on_torrentstatechanged_event(self, torrent_id, state):
844        # Update the torrents state
845        for row in self.liststore:
846            if torrent_id != row[self.columns['torrent_id'].column_indices[0]]:
847                continue
848
849            for name in self.columns_to_update:
850                if not self.columns[name].status_field:
851                    continue
852                for idx, status_field in enumerate(self.columns[name].status_field):
853                    # Update all columns that use the state field to current state
854                    if status_field != 'state':
855                        continue
856                    row[self.get_column_index(name)[idx]] = state
857
858            if self.filter.get('state', None) is not None:
859                # We have a filter set, let's see if theres anything to hide
860                # and remove from status
861                if (
862                    torrent_id in self.status
863                    and self.status[torrent_id]['state'] != state
864                ):
865                    row[self.columns['filter'].column_indices[0]] = False
866                    del self.status[torrent_id]
867
868        self.mark_dirty(torrent_id)
869
870    def on_sessionpaused_event(self):
871        self.mark_dirty()
872        self.update()
873
874    def on_sessionresumed_event(self):
875        self.mark_dirty()
876        self.update(select_row=True)
877
878    def on_torrentqueuechanged_event(self):
879        self.mark_dirty()
880        self.update()
881
882    # Handle keyboard shortcuts
883    def on_key_press_event(self, widget, event):
884        keyname = keyval_name(event.keyval)
885        if keyname is not None:
886            func = getattr(self, 'keypress_' + keyname.lower(), None)
887            if func:
888                return func(event)
889
890    def keypress_up(self, event):
891        """Handle any Up arrow keypresses"""
892        log.debug('keypress_up')
893        torrents = self.get_selected_torrents()
894        if not torrents:
895            return
896
897        # Move queue position up with Ctrl+Alt or Ctrl+Alt+Shift
898        if event.get_state() & CTRL_ALT_MASK:
899            if event.get_state() & ModifierType.SHIFT_MASK:
900                client.core.queue_top(torrents)
901            else:
902                client.core.queue_up(torrents)
903
904    def keypress_down(self, event):
905        """Handle any Down arrow keypresses"""
906        log.debug('keypress_down')
907        torrents = self.get_selected_torrents()
908        if not torrents:
909            return
910
911        # Move queue position down with Ctrl+Alt or Ctrl+Alt+Shift
912        if event.get_state() & CTRL_ALT_MASK:
913            if event.get_state() & ModifierType.SHIFT_MASK:
914                client.core.queue_bottom(torrents)
915            else:
916                client.core.queue_down(torrents)
917
918    def keypress_delete(self, event):
919        log.debug('keypress_delete')
920        torrents = self.get_selected_torrents()
921        if torrents:
922            if event.get_state() & ModifierType.SHIFT_MASK:
923                RemoveTorrentDialog(torrents, delete_files=True).run()
924            else:
925                RemoveTorrentDialog(torrents).run()
926
927    def keypress_menu(self, event):
928        log.debug('keypress_menu')
929        if not self.get_selected_torrent():
930            return
931
932        torrentmenu = component.get('MenuBar').torrentmenu
933        torrentmenu.popup(None, None, None, None, 3, event.time)
934        return True
935