1472 lines
42 KiB
Python
1472 lines
42 KiB
Python
import ClientConstants as CC
|
|
import ClientData
|
|
import ClientDragDrop
|
|
import ClientGUICommon
|
|
import ClientSerialisable
|
|
import ClientGUIShortcuts
|
|
import HydrusData
|
|
import HydrusExceptions
|
|
import HydrusGlobals as HG
|
|
import HydrusSerialisable
|
|
import os
|
|
import wx
|
|
from wx.lib.mixins.listctrl import ListCtrlAutoWidthMixin
|
|
|
|
( ListCtrlEvent, EVT_LIST_CTRL ) = wx.lib.newevent.NewCommandEvent()
|
|
|
|
# This used to be ColumnSorterMixin, but it was crashing on sort-click on clients with many pages open
|
|
# I've disabled it for now because it was still catching people. The transition to BetterListCtrl will nuke the whole thing eventually.
|
|
class SaneListCtrl( wx.ListCtrl, ListCtrlAutoWidthMixin ):
|
|
|
|
def __init__( self, parent, height, columns, delete_key_callback = None, activation_callback = None ):
|
|
|
|
num_columns = len( columns )
|
|
|
|
wx.ListCtrl.__init__( self, parent, style = wx.LC_REPORT )
|
|
ListCtrlAutoWidthMixin.__init__( self )
|
|
|
|
self.itemDataMap = {}
|
|
self._data_indices_to_sort_indices = {}
|
|
self._data_indices_to_sort_indices_dirty = False
|
|
self._next_data_index = 0
|
|
|
|
resize_column = 1
|
|
|
|
for ( i, ( name, width ) ) in enumerate( columns ):
|
|
|
|
self.InsertColumn( i, name, width = width )
|
|
|
|
if width == -1:
|
|
|
|
resize_column = i + 1
|
|
|
|
|
|
|
|
self.setResizeColumn( resize_column )
|
|
|
|
self.SetMinSize( ( -1, height ) )
|
|
|
|
self._delete_key_callback = delete_key_callback
|
|
self._activation_callback = activation_callback
|
|
|
|
self.Bind( wx.EVT_KEY_DOWN, self.EventKeyDown )
|
|
self.Bind( wx.EVT_LIST_ITEM_ACTIVATED, self.EventItemActivated )
|
|
|
|
self.Bind( wx.EVT_LIST_COL_BEGIN_DRAG, self.EventBeginColDrag )
|
|
|
|
|
|
def _GetIndexFromDataIndex( self, data_index ):
|
|
|
|
if self._data_indices_to_sort_indices_dirty:
|
|
|
|
self._data_indices_to_sort_indices = { self.GetItemData( index ) : index for index in range( self.GetItemCount() ) }
|
|
|
|
self._data_indices_to_sort_indices_dirty = False
|
|
|
|
|
|
try:
|
|
|
|
return self._data_indices_to_sort_indices[ data_index ]
|
|
|
|
except KeyError:
|
|
|
|
raise HydrusExceptions.DataMissing( 'Data not found!' )
|
|
|
|
|
|
|
|
def Append( self, display_tuple, sort_tuple ):
|
|
|
|
index = wx.ListCtrl.Append( self, display_tuple )
|
|
|
|
data_index = self._next_data_index
|
|
|
|
self.SetItemData( index, data_index )
|
|
|
|
self.itemDataMap[ data_index ] = list( sort_tuple )
|
|
self._data_indices_to_sort_indices[ data_index ] = index
|
|
|
|
self._next_data_index += 1
|
|
|
|
|
|
def DeleteItem( self, *args, **kwargs ):
|
|
|
|
wx.ListCtrl.DeleteItem( self, *args, **kwargs )
|
|
|
|
self._data_indices_to_sort_indices_dirty = True
|
|
|
|
|
|
def EventBeginColDrag( self, event ):
|
|
|
|
# resizeCol is not zero-indexed
|
|
|
|
if event.GetColumn() == self._resizeCol - 1:
|
|
|
|
last_column = self.GetColumnCount()
|
|
|
|
if self._resizeCol != last_column:
|
|
|
|
self.setResizeColumn( last_column )
|
|
|
|
else:
|
|
|
|
event.Veto()
|
|
|
|
return
|
|
|
|
|
|
|
|
event.Skip()
|
|
|
|
|
|
def EventItemActivated( self, event ):
|
|
|
|
if self._activation_callback is not None:
|
|
|
|
self._activation_callback()
|
|
|
|
else:
|
|
|
|
event.Skip()
|
|
|
|
|
|
|
|
def EventKeyDown( self, event ):
|
|
|
|
( modifier, key ) = ClientGUIShortcuts.ConvertKeyEventToSimpleTuple( event )
|
|
|
|
if key in CC.DELETE_KEYS:
|
|
|
|
if self._delete_key_callback is not None:
|
|
|
|
self._delete_key_callback()
|
|
|
|
|
|
elif key in ( ord( 'A' ), ord( 'a' ) ) and modifier == wx.ACCEL_CTRL:
|
|
|
|
self.SelectAll()
|
|
|
|
else:
|
|
|
|
event.Skip()
|
|
|
|
|
|
|
|
def GetAllSelected( self ):
|
|
|
|
indices = []
|
|
|
|
i = self.GetFirstSelected()
|
|
|
|
while i != -1:
|
|
|
|
indices.append( i )
|
|
|
|
i = self.GetNextSelected( i )
|
|
|
|
|
|
return indices
|
|
|
|
|
|
def GetClientData( self, index = None ):
|
|
|
|
if index is None:
|
|
|
|
data_indicies = [ self.GetItemData( index ) for index in range( self.GetItemCount() ) ]
|
|
|
|
datas = [ tuple( self.itemDataMap[ data_index ] ) for data_index in data_indicies ]
|
|
|
|
return datas
|
|
|
|
else:
|
|
|
|
data_index = self.GetItemData( index )
|
|
|
|
return tuple( self.itemDataMap[ data_index ] )
|
|
|
|
|
|
|
|
def GetIndexFromClientData( self, data, column_index = None ):
|
|
|
|
for index in range( self.GetItemCount() ):
|
|
|
|
client_data = self.GetClientData( index )
|
|
|
|
if column_index is None:
|
|
|
|
comparison_data = client_data
|
|
|
|
else:
|
|
|
|
comparison_data = client_data[ column_index ]
|
|
|
|
|
|
if comparison_data == data:
|
|
|
|
return index
|
|
|
|
|
|
|
|
raise HydrusExceptions.DataMissing( 'Data not found!' )
|
|
|
|
|
|
def GetSecondarySortValues( self, col, key1, key2 ):
|
|
|
|
# This overrides the ColumnSortedMixin. Just spam the whole tuple back.
|
|
|
|
return ( self.itemDataMap[ key1 ], self.itemDataMap[ key2 ] )
|
|
|
|
|
|
def GetListCtrl( self ):
|
|
|
|
return self
|
|
|
|
|
|
def GetSelectedClientData( self ):
|
|
|
|
indices = self.GetAllSelected()
|
|
|
|
results = []
|
|
|
|
for index in indices:
|
|
|
|
results.append( self.GetClientData( index ) )
|
|
|
|
|
|
return results
|
|
|
|
|
|
def HasClientData( self, data, column_index = None ):
|
|
|
|
try:
|
|
|
|
index = self.GetIndexFromClientData( data, column_index )
|
|
|
|
return True
|
|
|
|
except HydrusExceptions.DataMissing:
|
|
|
|
return False
|
|
|
|
|
|
|
|
def OnSortOrderChanged( self ):
|
|
|
|
self._data_indices_to_sort_indices_dirty = True
|
|
|
|
|
|
def RemoveAllSelected( self ):
|
|
|
|
indices = self.GetAllSelected()
|
|
|
|
self.RemoveIndices( indices )
|
|
|
|
|
|
def RemoveIndices( self, indices ):
|
|
|
|
indices.sort()
|
|
|
|
indices.reverse() # so we don't screw with the indices of deletees below
|
|
|
|
for index in indices:
|
|
|
|
self.DeleteItem( index )
|
|
|
|
|
|
|
|
def SelectAll( self ):
|
|
|
|
currently_selected = set( self.GetAllSelected() )
|
|
|
|
currently_not_selected = [ index for index in range( self.GetItemCount() ) if index not in currently_selected ]
|
|
|
|
for index in currently_not_selected:
|
|
|
|
self.Select( index )
|
|
|
|
|
|
|
|
def UpdateRow( self, index, display_tuple, sort_tuple ):
|
|
|
|
column = 0
|
|
|
|
for value in display_tuple:
|
|
|
|
self.SetItem( index, column, value )
|
|
|
|
column += 1
|
|
|
|
|
|
data_index = self.GetItemData( index )
|
|
|
|
self.itemDataMap[ data_index ] = list( sort_tuple )
|
|
|
|
|
|
class SaneListCtrlForSingleObject( SaneListCtrl ):
|
|
|
|
def __init__( self, *args, **kwargs ):
|
|
|
|
# this could one day just take column parameters that the user can pick
|
|
# it could just take obj in append or whatever and generate column tuples off that
|
|
|
|
self._data_indices_to_objects = {}
|
|
self._objects_to_data_indices = {}
|
|
|
|
SaneListCtrl.__init__( self, *args, **kwargs )
|
|
|
|
|
|
def Append( self, display_tuple, sort_tuple, obj ):
|
|
|
|
self._data_indices_to_objects[ self._next_data_index ] = obj
|
|
self._objects_to_data_indices[ obj ] = self._next_data_index
|
|
|
|
SaneListCtrl.Append( self, display_tuple, sort_tuple )
|
|
|
|
|
|
def GetIndexFromObject( self, obj ):
|
|
|
|
try:
|
|
|
|
data_index = self._objects_to_data_indices[ obj ]
|
|
|
|
index = self._GetIndexFromDataIndex( data_index )
|
|
|
|
return index
|
|
|
|
except KeyError:
|
|
|
|
raise HydrusExceptions.DataMissing( 'Data not found!' )
|
|
|
|
|
|
|
|
def GetObject( self, index ):
|
|
|
|
data_index = self.GetItemData( index )
|
|
|
|
return self._data_indices_to_objects[ data_index ]
|
|
|
|
|
|
def GetObjects( self, only_selected = False ):
|
|
|
|
if only_selected:
|
|
|
|
indicies = self.GetAllSelected()
|
|
|
|
else:
|
|
|
|
indicies = range( self.GetItemCount() )
|
|
|
|
|
|
data_indicies = [ self.GetItemData( index ) for index in indicies ]
|
|
|
|
datas = [ self._data_indices_to_objects[ data_index ] for data_index in data_indicies ]
|
|
|
|
return datas
|
|
|
|
|
|
def HasObject( self, obj ):
|
|
|
|
try:
|
|
|
|
index = self.GetIndexFromObject( obj )
|
|
|
|
return True
|
|
|
|
except HydrusExceptions.DataMissing:
|
|
|
|
return False
|
|
|
|
|
|
|
|
def SetNonDupeName( self, obj ):
|
|
|
|
# when column population is handled here, we can tuck this into normal append/update calls internally
|
|
|
|
current_names = { o.GetName() for o in self.GetObjects() if o is not obj }
|
|
|
|
HydrusSerialisable.SetNonDupeName( obj, current_names )
|
|
|
|
|
|
def UpdateRow( self, index, display_tuple, sort_tuple, obj ):
|
|
|
|
SaneListCtrl.UpdateRow( self, index, display_tuple, sort_tuple )
|
|
|
|
data_index = self.GetItemData( index )
|
|
|
|
self._data_indices_to_objects[ data_index ] = obj
|
|
self._objects_to_data_indices[ obj ] = data_index
|
|
|
|
|
|
class SaneListCtrlPanel( wx.Panel ):
|
|
|
|
def __init__( self, parent ):
|
|
|
|
wx.Panel.__init__( self, parent )
|
|
|
|
self._vbox = wx.BoxSizer( wx.VERTICAL )
|
|
|
|
self._buttonbox = wx.BoxSizer( wx.HORIZONTAL )
|
|
|
|
self._listctrl = None
|
|
|
|
self._button_infos = []
|
|
|
|
|
|
def _SomeSelected( self ):
|
|
|
|
return self._listctrl.GetSelectedItemCount() > 0
|
|
|
|
|
|
def _UpdateButtons( self ):
|
|
|
|
for ( button, enabled_check_func ) in self._button_infos:
|
|
|
|
if enabled_check_func():
|
|
|
|
button.Enable()
|
|
|
|
else:
|
|
|
|
button.Disable()
|
|
|
|
|
|
|
|
|
|
def AddButton( self, label, clicked_func, enabled_only_on_selection = False, enabled_check_func = None ):
|
|
|
|
button = ClientGUICommon.BetterButton( self, label, clicked_func )
|
|
|
|
self._buttonbox.Add( button, CC.FLAGS_VCENTER )
|
|
|
|
if enabled_only_on_selection:
|
|
|
|
enabled_check_func = self._SomeSelected
|
|
|
|
|
|
if enabled_check_func is not None:
|
|
|
|
self._button_infos.append( ( button, enabled_check_func ) )
|
|
|
|
|
|
|
|
def AddWindow( self, window ):
|
|
|
|
self._buttonbox.Add( window, CC.FLAGS_VCENTER )
|
|
|
|
|
|
def EventContentChanged( self, event ):
|
|
|
|
if not self._listctrl:
|
|
|
|
return
|
|
|
|
|
|
self._UpdateButtons()
|
|
|
|
event.Skip()
|
|
|
|
|
|
def EventSelectionChanged( self, event ):
|
|
|
|
if not self._listctrl:
|
|
|
|
return
|
|
|
|
|
|
self._UpdateButtons()
|
|
|
|
event.Skip()
|
|
|
|
|
|
def SetListCtrl( self, listctrl ):
|
|
|
|
self._listctrl = listctrl
|
|
|
|
self._vbox.Add( self._listctrl, CC.FLAGS_EXPAND_SIZER_BOTH_WAYS )
|
|
self._vbox.Add( self._buttonbox, CC.FLAGS_BUTTON_SIZER )
|
|
|
|
self.SetSizer( self._vbox )
|
|
|
|
self._listctrl.Bind( wx.EVT_LIST_ITEM_SELECTED, self.EventSelectionChanged )
|
|
self._listctrl.Bind( wx.EVT_LIST_ITEM_DESELECTED, self.EventSelectionChanged )
|
|
|
|
self._listctrl.Bind( wx.EVT_LIST_INSERT_ITEM, self.EventContentChanged )
|
|
self._listctrl.Bind( wx.EVT_LIST_DELETE_ITEM, self.EventContentChanged )
|
|
self._listctrl.Bind( wx.EVT_LIST_DELETE_ALL_ITEMS, self.EventContentChanged )
|
|
|
|
|
|
class BetterListCtrl( wx.ListCtrl, ListCtrlAutoWidthMixin ):
|
|
|
|
def __init__( self, parent, name, height_num_chars, sizing_column_initial_width_num_chars, columns, data_to_tuples_func, delete_key_callback = None, activation_callback = None, style = None ):
|
|
|
|
if style is None:
|
|
|
|
style = wx.LC_REPORT
|
|
|
|
else:
|
|
|
|
style = wx.LC_REPORT | style
|
|
|
|
|
|
wx.ListCtrl.__init__( self, parent, style = style )
|
|
ListCtrlAutoWidthMixin.__init__( self )
|
|
|
|
self._data_to_tuples_func = data_to_tuples_func
|
|
|
|
self._menu_callable = None
|
|
|
|
self._sort_column = 0
|
|
self._sort_asc = True
|
|
|
|
# eventually have it look up 'name' in some options somewhere and see previous height, width, and column selection
|
|
# this thing should deal with missing entries but also have some filtered defaults for subs listctrl, which will have a bunch of possible columns
|
|
|
|
self._indices_to_data_info = {}
|
|
self._data_to_indices = {}
|
|
|
|
total_width = ClientGUICommon.ConvertTextToPixelWidth( self, sizing_column_initial_width_num_chars )
|
|
|
|
resize_column = 1
|
|
|
|
for ( i, ( name, width_num_chars ) ) in enumerate( columns ):
|
|
|
|
if width_num_chars == -1:
|
|
|
|
width = -1
|
|
|
|
resize_column = i + 1
|
|
|
|
else:
|
|
|
|
width = ClientGUICommon.ConvertTextToPixelWidth( self, width_num_chars )
|
|
|
|
total_width += width
|
|
|
|
|
|
self.InsertColumn( i, name, width = width )
|
|
|
|
|
|
self.setResizeColumn( resize_column )
|
|
|
|
self.SetInitialSize( ( total_width, -1 ) )
|
|
|
|
self.GrowShrinkColumnsHeight( height_num_chars )
|
|
|
|
self._delete_key_callback = delete_key_callback
|
|
self._activation_callback = activation_callback
|
|
|
|
self.Bind( wx.EVT_KEY_DOWN, self.EventKeyDown )
|
|
self.Bind( wx.EVT_LIST_ITEM_ACTIVATED, self.EventItemActivated )
|
|
|
|
self.Bind( wx.EVT_LIST_COL_BEGIN_DRAG, self.EventBeginColDrag )
|
|
self.Bind( wx.EVT_LIST_COL_CLICK, self.EventColumnClick )
|
|
|
|
|
|
def _AddDataInfo( self, data_info ):
|
|
|
|
( data, display_tuple, sort_tuple ) = data_info
|
|
|
|
index = self.Append( display_tuple )
|
|
|
|
self._indices_to_data_info[ index ] = data_info
|
|
self._data_to_indices[ data ] = index
|
|
|
|
|
|
def _GetDisplayAndSortTuples( self, data ):
|
|
|
|
( display_tuple, sort_tuple ) = self._data_to_tuples_func( data )
|
|
|
|
better_sort = []
|
|
|
|
for item in sort_tuple:
|
|
|
|
if isinstance( item, ( str, unicode ) ):
|
|
|
|
item = item.lower()
|
|
|
|
|
|
better_sort.append( item )
|
|
|
|
|
|
sort_tuple = tuple( better_sort )
|
|
|
|
return ( display_tuple, sort_tuple )
|
|
|
|
|
|
def _GetSelected( self ):
|
|
|
|
indices = []
|
|
|
|
i = self.GetFirstSelected()
|
|
|
|
while i != -1:
|
|
|
|
indices.append( i )
|
|
|
|
i = self.GetNextSelected( i )
|
|
|
|
|
|
return indices
|
|
|
|
|
|
def _RecalculateIndicesAfterDelete( self ):
|
|
|
|
indices_and_data_info = list( self._indices_to_data_info.items() )
|
|
|
|
indices_and_data_info.sort()
|
|
|
|
self._indices_to_data_info = {}
|
|
self._data_to_indices = {}
|
|
|
|
for ( index, ( old_index, data_info ) ) in enumerate( indices_and_data_info ):
|
|
|
|
( data, display_tuple, sort_tuple ) = data_info
|
|
|
|
self._data_to_indices[ data ] = index
|
|
self._indices_to_data_info[ index ] = data_info
|
|
|
|
|
|
|
|
def _ShowMenu( self ):
|
|
|
|
try:
|
|
|
|
menu = self._menu_callable()
|
|
|
|
except HydrusExceptions.DataMissing:
|
|
|
|
return
|
|
|
|
|
|
HG.client_controller.PopupMenu( self, menu )
|
|
|
|
|
|
def _SortDataInfo( self ):
|
|
|
|
data_infos = list( self._indices_to_data_info.values() )
|
|
|
|
def sort_key( data_info ):
|
|
|
|
( data, display_tuple, sort_tuple ) = data_info
|
|
|
|
return ( sort_tuple[ self._sort_column ], sort_tuple ) # add the sort tuple to get secondary sorting
|
|
|
|
|
|
data_infos.sort( key = sort_key, reverse = not self._sort_asc )
|
|
|
|
return data_infos
|
|
|
|
|
|
def _SortAndRefreshRows( self ):
|
|
|
|
selected_data_quick = set( self.GetData( only_selected = True ) )
|
|
|
|
selected_indices = self._GetSelected()
|
|
|
|
for selected_index in selected_indices:
|
|
|
|
self.Select( selected_index, False )
|
|
|
|
|
|
sorted_data_info = self._SortDataInfo()
|
|
|
|
self._indices_to_data_info = {}
|
|
self._data_to_indices = {}
|
|
|
|
for ( index, data_info ) in enumerate( sorted_data_info ):
|
|
|
|
self._indices_to_data_info[ index ] = data_info
|
|
|
|
( data, display_tuple, sort_tuple ) = data_info
|
|
|
|
self._data_to_indices[ data ] = index
|
|
|
|
self._UpdateRow( index, display_tuple )
|
|
|
|
if data in selected_data_quick:
|
|
|
|
self.Select( index )
|
|
|
|
|
|
|
|
|
|
def _UpdateRow( self, index, display_tuple ):
|
|
|
|
for ( column_index, value ) in enumerate( display_tuple ):
|
|
|
|
existing_value = self.GetItem( index, column_index )
|
|
|
|
if existing_value != value:
|
|
|
|
self.SetItem( index, column_index, value )
|
|
|
|
|
|
|
|
|
|
def AddDatas( self, datas ):
|
|
|
|
for data in datas:
|
|
|
|
( display_tuple, sort_tuple ) = self._GetDisplayAndSortTuples( data )
|
|
|
|
self._AddDataInfo( ( data, display_tuple, sort_tuple ) )
|
|
|
|
|
|
wx.QueueEvent( self.GetEventHandler(), ListCtrlEvent( -1 ) )
|
|
|
|
|
|
def AddMenuCallable( self, menu_callable ):
|
|
|
|
self._menu_callable = menu_callable
|
|
|
|
self.Bind( wx.EVT_RIGHT_DOWN, self.EventShowMenu )
|
|
|
|
|
|
def DeleteDatas( self, datas ):
|
|
|
|
deletees = [ ( self._data_to_indices[ data ], data ) for data in datas ]
|
|
|
|
deletees.sort( reverse = True )
|
|
|
|
# I am not sure, but I think if subsequent deleteitems occur in the same event, the event processing of the first is forced!!
|
|
# this means that button checking and so on occurs for n-1 times on an invalid indices structure in this thing before correcting itself in the last one
|
|
# if a button update then tests selected data against the invalid index and a selection is on the i+1 or whatever but just got bumped up into invalid area, we are exception city
|
|
# this doesn't normally affect us because mostly we _are_ deleting selections when we do deletes, but 'try to link url stuff' auto thing hit this
|
|
# I obviously don't want to recalc all indices for every delete
|
|
# so I wrote a catch in getdata to skip the missing error, and now I'm moving the data deletion to a second loop, which seems to help
|
|
|
|
for ( index, data ) in deletees:
|
|
|
|
self.DeleteItem( index )
|
|
|
|
|
|
for ( index, data ) in deletees:
|
|
|
|
del self._data_to_indices[ data ]
|
|
|
|
del self._indices_to_data_info[ index ]
|
|
|
|
|
|
self._RecalculateIndicesAfterDelete()
|
|
|
|
wx.QueueEvent( self.GetEventHandler(), ListCtrlEvent( -1 ) )
|
|
|
|
|
|
def DeleteSelected( self ):
|
|
|
|
indices = self._GetSelected()
|
|
|
|
indices.sort( reverse = True )
|
|
|
|
for index in indices:
|
|
|
|
( data, display_tuple, sort_tuple ) = self._indices_to_data_info[ index ]
|
|
|
|
self.DeleteItem( index )
|
|
|
|
del self._data_to_indices[ data ]
|
|
|
|
del self._indices_to_data_info[ index ]
|
|
|
|
|
|
self._RecalculateIndicesAfterDelete()
|
|
|
|
wx.QueueEvent( self.GetEventHandler(), ListCtrlEvent( -1 ) )
|
|
|
|
|
|
def EventBeginColDrag( self, event ):
|
|
|
|
# resizeCol is not zero-indexed
|
|
|
|
if event.GetColumn() == self._resizeCol - 1:
|
|
|
|
last_column = self.GetColumnCount()
|
|
|
|
if self._resizeCol != last_column:
|
|
|
|
self.setResizeColumn( last_column )
|
|
|
|
else:
|
|
|
|
event.Veto()
|
|
|
|
return
|
|
|
|
|
|
|
|
event.Skip()
|
|
|
|
|
|
def EventColumnClick( self, event ):
|
|
|
|
col = event.GetColumn()
|
|
|
|
if col == self._sort_column:
|
|
|
|
self._sort_asc = not self._sort_asc
|
|
|
|
else:
|
|
|
|
self._sort_column = col
|
|
|
|
self._sort_asc = True
|
|
|
|
|
|
self._SortAndRefreshRows()
|
|
|
|
|
|
def EventItemActivated( self, event ):
|
|
|
|
if self._activation_callback is not None:
|
|
|
|
self._activation_callback()
|
|
|
|
else:
|
|
|
|
event.Skip()
|
|
|
|
|
|
|
|
def EventKeyDown( self, event ):
|
|
|
|
( modifier, key ) = ClientGUIShortcuts.ConvertKeyEventToSimpleTuple( event )
|
|
|
|
if key in CC.DELETE_KEYS:
|
|
|
|
if self._delete_key_callback is not None:
|
|
|
|
self._delete_key_callback()
|
|
|
|
|
|
elif key in ( ord( 'A' ), ord( 'a' ) ) and modifier == wx.ACCEL_CTRL:
|
|
|
|
self.SelectAll()
|
|
|
|
else:
|
|
|
|
event.Skip()
|
|
|
|
|
|
|
|
def EventShowMenu( self, event ):
|
|
|
|
wx.CallAfter( self._ShowMenu )
|
|
|
|
event.Skip() # let the right click event go through before doing menu, in case selection should happen
|
|
|
|
|
|
def GetData( self, only_selected = False ):
|
|
|
|
if only_selected:
|
|
|
|
indices = self._GetSelected()
|
|
|
|
else:
|
|
|
|
indices = self._indices_to_data_info.keys()
|
|
|
|
|
|
result = []
|
|
|
|
for index in indices:
|
|
|
|
# this can get fired while indices are invalid, wew
|
|
if index not in self._indices_to_data_info:
|
|
|
|
continue
|
|
|
|
|
|
( data, display_tuple, sort_tuple ) = self._indices_to_data_info[ index ]
|
|
|
|
result.append( data )
|
|
|
|
|
|
return result
|
|
|
|
|
|
def HasData( self, data ):
|
|
|
|
return data in self._data_to_indices
|
|
|
|
|
|
def HasSelected( self ):
|
|
|
|
return self.GetSelectedItemCount() > 0
|
|
|
|
|
|
def SelectAll( self ):
|
|
|
|
currently_selected = set( self._GetSelected() )
|
|
|
|
currently_not_selected = [ index for index in range( self.GetItemCount() ) if index not in currently_selected ]
|
|
|
|
for index in currently_not_selected:
|
|
|
|
self.Select( index )
|
|
|
|
|
|
|
|
def SelectDatas( self, datas ):
|
|
|
|
for data in datas:
|
|
|
|
if data in self._data_to_indices:
|
|
|
|
index = self._data_to_indices[ data ]
|
|
|
|
self.Select( index )
|
|
|
|
|
|
|
|
|
|
def SelectNone( self ):
|
|
|
|
currently_selected = set( self._GetSelected() )
|
|
|
|
for index in currently_selected:
|
|
|
|
self.Select( index, False )
|
|
|
|
|
|
|
|
def SetData( self, datas ):
|
|
|
|
existing_datas = set( self._data_to_indices.keys() )
|
|
|
|
# useful to preserve order here sometimes (e.g. export file path generation order)
|
|
datas_to_add = [ data for data in datas if data not in existing_datas ]
|
|
datas_to_update = [ data for data in datas if data in existing_datas ]
|
|
datas_to_delete = existing_datas.difference( datas )
|
|
|
|
if len( datas_to_delete ) > 0:
|
|
|
|
self.DeleteDatas( datas_to_delete )
|
|
|
|
|
|
if len( datas_to_update ) > 0:
|
|
|
|
self.UpdateDatas( datas_to_update )
|
|
|
|
|
|
if len( datas_to_add ) > 0:
|
|
|
|
self.AddDatas( datas_to_add )
|
|
|
|
|
|
self._SortAndRefreshRows()
|
|
|
|
wx.QueueEvent( self.GetEventHandler(), ListCtrlEvent( -1 ) )
|
|
|
|
|
|
def GrowShrinkColumnsHeight( self, ideal_rows ):
|
|
|
|
# +2 for the header row and * 1.25 for magic rough text-to-rowheight conversion
|
|
|
|
existing_min_width = self.GetMinClientSize()[0]
|
|
|
|
( width_gumpf, ideal_client_height ) = ClientGUICommon.ConvertTextToPixels( self, ( 20, int( ( ideal_rows + 2 ) * 1.25 ) ) )
|
|
|
|
self.SetMinClientSize( ( existing_min_width, ideal_client_height ) )
|
|
|
|
|
|
def Sort( self, col = None, asc = None ):
|
|
|
|
if col is not None:
|
|
|
|
self._sort_column = col
|
|
|
|
|
|
if asc is not None:
|
|
|
|
self._sort_asc = asc
|
|
|
|
|
|
self._SortAndRefreshRows()
|
|
|
|
wx.QueueEvent( self.GetEventHandler(), ListCtrlEvent( -1 ) )
|
|
|
|
|
|
def UpdateDatas( self, datas = None ):
|
|
|
|
if datas is None:
|
|
|
|
# keep it sorted here, which is sometimes useful
|
|
|
|
indices_and_datas = [ ( index, data ) for ( data, index ) in self._data_to_indices.items() ]
|
|
|
|
indices_and_datas.sort()
|
|
|
|
datas = [ data for ( index, data ) in indices_and_datas ]
|
|
|
|
|
|
sort_data_has_changed = False
|
|
|
|
for data in datas:
|
|
|
|
( display_tuple, sort_tuple ) = self._GetDisplayAndSortTuples( data )
|
|
|
|
data_info = ( data, display_tuple, sort_tuple )
|
|
|
|
index = self._data_to_indices[ data ]
|
|
|
|
existing_data_info = self._indices_to_data_info[ index ]
|
|
|
|
if data_info != existing_data_info:
|
|
|
|
if not sort_data_has_changed:
|
|
|
|
( existing_data, existing_display_tuple, existing_sort_tuple ) = existing_data_info
|
|
|
|
if sort_tuple[ self._sort_column ] != existing_sort_tuple[ self._sort_column ]: # this does not govern secondary sorts, but let's not spam sorts m8
|
|
|
|
sort_data_has_changed = True
|
|
|
|
|
|
|
|
|
|
self._indices_to_data_info[ index ] = data_info
|
|
|
|
self._UpdateRow( index, display_tuple )
|
|
|
|
|
|
|
|
wx.QueueEvent( self.GetEventHandler(), ListCtrlEvent( -1 ) )
|
|
|
|
return sort_data_has_changed
|
|
|
|
|
|
class BetterListCtrlPanel( wx.Panel ):
|
|
|
|
def __init__( self, parent ):
|
|
|
|
wx.Panel.__init__( self, parent )
|
|
|
|
self._vbox = wx.BoxSizer( wx.VERTICAL )
|
|
|
|
self._buttonbox = wx.BoxSizer( wx.HORIZONTAL )
|
|
|
|
self._listctrl = None
|
|
|
|
self._permitted_object_types = []
|
|
self._import_add_callable = lambda x: None
|
|
|
|
self._button_infos = []
|
|
|
|
|
|
def _AddAllDefaults( self, defaults_callable, add_callable ):
|
|
|
|
defaults = defaults_callable()
|
|
|
|
for default in defaults:
|
|
|
|
add_callable( default )
|
|
|
|
|
|
self._listctrl.Sort()
|
|
|
|
|
|
def _AddButton( self, button, enabled_only_on_selection = False, enabled_check_func = None ):
|
|
|
|
self._buttonbox.Add( button, CC.FLAGS_VCENTER )
|
|
|
|
if enabled_only_on_selection:
|
|
|
|
enabled_check_func = self._HasSelected
|
|
|
|
|
|
if enabled_check_func is not None:
|
|
|
|
self._button_infos.append( ( button, enabled_check_func ) )
|
|
|
|
|
|
|
|
def _AddSomeDefaults( self, defaults_callable, add_callable ):
|
|
|
|
defaults = defaults_callable()
|
|
|
|
selected = False
|
|
|
|
choice_tuples = [ ( default.GetName(), default, selected ) for default in defaults ]
|
|
|
|
import ClientGUITopLevelWindows
|
|
import ClientGUIScrolledPanelsEdit
|
|
|
|
with ClientGUITopLevelWindows.DialogEdit( self, 'select the defaults to add' ) as dlg:
|
|
|
|
panel = ClientGUIScrolledPanelsEdit.EditChooseMultiple( dlg, choice_tuples )
|
|
|
|
dlg.SetPanel( panel )
|
|
|
|
if dlg.ShowModal() == wx.ID_OK:
|
|
|
|
defaults_to_add = panel.GetValue()
|
|
|
|
for default in defaults_to_add:
|
|
|
|
add_callable( default )
|
|
|
|
|
|
|
|
|
|
self._listctrl.Sort()
|
|
|
|
|
|
def _Duplicate( self ):
|
|
|
|
dupe_data = self._GetExportObject()
|
|
|
|
if dupe_data is not None:
|
|
|
|
dupe_data = dupe_data.Duplicate()
|
|
|
|
self._ImportObject( dupe_data )
|
|
|
|
|
|
self._listctrl.Sort()
|
|
|
|
|
|
def _ExportToClipboard( self ):
|
|
|
|
export_object = self._GetExportObject()
|
|
|
|
if export_object is not None:
|
|
|
|
json = export_object.DumpToString()
|
|
|
|
HG.client_controller.pub( 'clipboard', 'text', json )
|
|
|
|
|
|
|
|
def _ExportToPng( self ):
|
|
|
|
export_object = self._GetExportObject()
|
|
|
|
if export_object is not None:
|
|
|
|
import ClientGUITopLevelWindows
|
|
import ClientGUISerialisable
|
|
|
|
with ClientGUITopLevelWindows.DialogNullipotent( self, 'export to png' ) as dlg:
|
|
|
|
panel = ClientGUISerialisable.PngExportPanel( dlg, export_object )
|
|
|
|
dlg.SetPanel( panel )
|
|
|
|
dlg.ShowModal()
|
|
|
|
|
|
|
|
|
|
def _ExportToPngs( self ):
|
|
|
|
export_object = self._GetExportObject()
|
|
|
|
if export_object is None:
|
|
|
|
return
|
|
|
|
|
|
if not isinstance( export_object, HydrusSerialisable.SerialisableList ):
|
|
|
|
self._ExportToPng()
|
|
|
|
return
|
|
|
|
|
|
import ClientGUITopLevelWindows
|
|
import ClientGUISerialisable
|
|
|
|
with ClientGUITopLevelWindows.DialogNullipotent( self, 'export to pngs' ) as dlg:
|
|
|
|
panel = ClientGUISerialisable.PngsExportPanel( dlg, export_object )
|
|
|
|
dlg.SetPanel( panel )
|
|
|
|
dlg.ShowModal()
|
|
|
|
|
|
|
|
def _GetExportObject( self ):
|
|
|
|
to_export = HydrusSerialisable.SerialisableList()
|
|
|
|
for obj in self._listctrl.GetData( only_selected = True ):
|
|
|
|
to_export.append( obj )
|
|
|
|
|
|
if len( to_export ) == 0:
|
|
|
|
return None
|
|
|
|
elif len( to_export ) == 1:
|
|
|
|
return to_export[0]
|
|
|
|
else:
|
|
|
|
return to_export
|
|
|
|
|
|
|
|
def _HasSelected( self ):
|
|
|
|
return self._listctrl.HasSelected()
|
|
|
|
|
|
def _ImportFromClipboard( self ):
|
|
|
|
raw_text = HG.client_controller.GetClipboardText()
|
|
|
|
try:
|
|
|
|
obj = HydrusSerialisable.CreateFromString( raw_text )
|
|
|
|
self._ImportObject( obj )
|
|
|
|
except Exception as e:
|
|
|
|
wx.MessageBox( 'I could not understand what was in the clipboard' )
|
|
|
|
|
|
self._listctrl.Sort()
|
|
|
|
|
|
def _ImportFromPng( self ):
|
|
|
|
with wx.FileDialog( self, 'select the png or pngs with the encoded data', style = wx.FD_OPEN | wx.FD_MULTIPLE, wildcard = 'PNG (*.png)|*.png' ) as dlg:
|
|
|
|
if dlg.ShowModal() == wx.ID_OK:
|
|
|
|
paths = dlg.GetPaths()
|
|
|
|
self._ImportPngs( paths )
|
|
|
|
|
|
|
|
self._listctrl.Sort()
|
|
|
|
|
|
def _ImportObject( self, obj ):
|
|
|
|
bad_object_type_names = set()
|
|
|
|
if isinstance( obj, HydrusSerialisable.SerialisableList ):
|
|
|
|
for sub_obj in obj:
|
|
|
|
self._ImportObject( sub_obj )
|
|
|
|
|
|
else:
|
|
|
|
if isinstance( obj, self._permitted_object_types ):
|
|
|
|
self._import_add_callable( obj )
|
|
|
|
else:
|
|
|
|
bad_object_type_names.add( HydrusData.GetTypeName( type( obj ) ) )
|
|
|
|
|
|
|
|
if len( bad_object_type_names ) > 0:
|
|
|
|
message = 'The imported objects included these types:'
|
|
message += os.linesep * 2
|
|
message += os.linesep.join( bad_object_type_names )
|
|
message += os.linesep * 2
|
|
message += 'Whereas this control only allows:'
|
|
message += os.linesep * 2
|
|
message += os.linesep.join( ( HydrusData.GetTypeName( o ) for o in self._permitted_object_types ) )
|
|
|
|
wx.MessageBox( message )
|
|
|
|
|
|
|
|
def _ImportPngs( self, paths ):
|
|
|
|
for path in paths:
|
|
|
|
path = HydrusData.ToUnicode( path )
|
|
|
|
try:
|
|
|
|
payload = ClientSerialisable.LoadFromPng( path )
|
|
|
|
except Exception as e:
|
|
|
|
wx.MessageBox( HydrusData.ToUnicode( e ) )
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
obj = HydrusSerialisable.CreateFromNetworkString( payload )
|
|
|
|
self._ImportObject( obj )
|
|
|
|
except:
|
|
|
|
wx.MessageBox( 'I could not understand what was encoded in the file!' )
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
def _UpdateButtons( self ):
|
|
|
|
for ( button, enabled_check_func ) in self._button_infos:
|
|
|
|
if enabled_check_func():
|
|
|
|
button.Enable()
|
|
|
|
else:
|
|
|
|
button.Disable()
|
|
|
|
|
|
|
|
|
|
def AddButton( self, label, clicked_func, enabled_only_on_selection = False, enabled_check_func = None ):
|
|
|
|
button = ClientGUICommon.BetterButton( self, label, clicked_func )
|
|
|
|
self._AddButton( button, enabled_only_on_selection = enabled_only_on_selection, enabled_check_func = enabled_check_func )
|
|
|
|
self._UpdateButtons()
|
|
|
|
|
|
def AddDefaultsButton( self, defaults_callable, add_callable ):
|
|
|
|
import_menu_items = []
|
|
|
|
all_call = HydrusData.Call( self._AddAllDefaults, defaults_callable, add_callable )
|
|
some_call = HydrusData.Call( self._AddSomeDefaults, defaults_callable, add_callable )
|
|
|
|
import_menu_items.append( ( 'normal', 'add them all', 'Load all the defaults.', all_call ) )
|
|
import_menu_items.append( ( 'normal', 'select from a list', 'Load some of the defaults.', some_call ) )
|
|
|
|
self.AddMenuButton( 'add defaults', import_menu_items )
|
|
|
|
|
|
def AddImportExportButtons( self, permitted_object_types, import_add_callable ):
|
|
|
|
self._permitted_object_types = permitted_object_types
|
|
self._import_add_callable = import_add_callable
|
|
|
|
export_menu_items = []
|
|
|
|
export_menu_items.append( ( 'normal', 'to clipboard', 'Serialise the selected data and put it on your clipboard.', self._ExportToClipboard ) )
|
|
export_menu_items.append( ( 'normal', 'to png', 'Serialise the selected data and encode it to an image file you can easily share with other hydrus users.', self._ExportToPng ) )
|
|
|
|
all_objs_are_named = False not in ( issubclass( o, HydrusSerialisable.SerialisableBaseNamed ) for o in self._permitted_object_types )
|
|
|
|
if all_objs_are_named:
|
|
|
|
export_menu_items.append( ( 'normal', 'to pngs', 'Serialise the selected data and encode it to multiple image files you can easily share with other hydrus users.', self._ExportToPngs ) )
|
|
|
|
|
|
import_menu_items = []
|
|
|
|
import_menu_items.append( ( 'normal', 'from clipboard', 'Load a data from text in your clipboard.', self._ImportFromClipboard ) )
|
|
import_menu_items.append( ( 'normal', 'from pngs (note you can also drag and drop pngs onto this list)', 'Load a data from an encoded png.', self._ImportFromPng ) )
|
|
|
|
self.AddMenuButton( 'export', export_menu_items, enabled_only_on_selection = True )
|
|
self.AddMenuButton( 'import', import_menu_items )
|
|
self.AddButton( 'duplicate', self._Duplicate, enabled_only_on_selection = True )
|
|
|
|
self.SetDropTarget( ClientDragDrop.FileDropTarget( self, filenames_callable = self.ImportFromDragDrop ) )
|
|
|
|
|
|
def AddMenuButton( self, label, menu_items, enabled_only_on_selection = False, enabled_check_func = None ):
|
|
|
|
button = ClientGUICommon.MenuButton( self, label, menu_items )
|
|
|
|
self._AddButton( button, enabled_only_on_selection = enabled_only_on_selection, enabled_check_func = enabled_check_func )
|
|
|
|
self._UpdateButtons()
|
|
|
|
|
|
def AddSeparator( self ):
|
|
|
|
self._buttonbox.Add( ( 20, 20 ), CC.FLAGS_EXPAND_PERPENDICULAR )
|
|
|
|
|
|
def AddWindow( self, window ):
|
|
|
|
self._buttonbox.Add( window, CC.FLAGS_VCENTER )
|
|
|
|
|
|
def EventContentChanged( self, event ):
|
|
|
|
if not self._listctrl:
|
|
|
|
return
|
|
|
|
|
|
self._UpdateButtons()
|
|
|
|
event.Skip()
|
|
|
|
|
|
def EventSelectionChanged( self, event ):
|
|
|
|
if not self._listctrl:
|
|
|
|
return
|
|
|
|
|
|
self._UpdateButtons()
|
|
|
|
event.Skip()
|
|
|
|
|
|
def ImportFromDragDrop( self, paths ):
|
|
|
|
import ClientGUIDialogs
|
|
|
|
message = 'Try to import the ' + HydrusData.ToHumanInt( len( paths ) ) + ' dropped files to this list? I am expecting png files.'
|
|
|
|
with ClientGUIDialogs.DialogYesNo( self, message ) as dlg:
|
|
|
|
if dlg.ShowModal() == wx.ID_YES:
|
|
|
|
self._ImportPngs( paths )
|
|
|
|
self._listctrl.Sort()
|
|
|
|
|
|
|
|
|
|
def NewButtonRow( self ):
|
|
|
|
self._buttonbox = wx.BoxSizer( wx.HORIZONTAL )
|
|
|
|
self._vbox.Add( self._buttonbox, CC.FLAGS_BUTTON_SIZER )
|
|
|
|
|
|
def SetListCtrl( self, listctrl ):
|
|
|
|
self._listctrl = listctrl
|
|
|
|
self._vbox.Add( self._listctrl, CC.FLAGS_EXPAND_SIZER_BOTH_WAYS )
|
|
self._vbox.Add( self._buttonbox, CC.FLAGS_BUTTON_SIZER )
|
|
|
|
self.SetSizer( self._vbox )
|
|
|
|
self._listctrl.Bind( wx.EVT_LIST_ITEM_SELECTED, self.EventSelectionChanged )
|
|
self._listctrl.Bind( wx.EVT_LIST_ITEM_DESELECTED, self.EventSelectionChanged )
|
|
|
|
self._listctrl.Bind( wx.EVT_LIST_INSERT_ITEM, self.EventContentChanged )
|
|
self._listctrl.Bind( wx.EVT_LIST_DELETE_ITEM, self.EventContentChanged )
|
|
self._listctrl.Bind( wx.EVT_LIST_DELETE_ALL_ITEMS, self.EventContentChanged )
|
|
|
|
|
|
def UpdateButtons( self ):
|
|
|
|
self._UpdateButtons()
|
|
|