hydrus/hydrus/client/gui/lists/ClientGUIListCtrl.py

1518 lines
49 KiB
Python

import os
import typing
from qtpy import QtCore as QC
from qtpy import QtWidgets as QW
from hydrus.core import HydrusData
from hydrus.core import HydrusExceptions
from hydrus.core import HydrusGlobals as HG
from hydrus.core import HydrusSerialisable
from hydrus.client import ClientConstants as CC
from hydrus.client import ClientSerialisable
from hydrus.client.gui import ClientGUIDragDrop
from hydrus.client.gui import ClientGUICore as CGC
from hydrus.client.gui import ClientGUIFunctions
from hydrus.client.gui import ClientGUIShortcuts
from hydrus.client.gui import QtPorting as QP
from hydrus.client.gui.lists import ClientGUIListConstants as CGLC
from hydrus.client.gui.lists import ClientGUIListStatus
from hydrus.client.gui.widgets import ClientGUICommon
from hydrus.client.gui.widgets import ClientGUIMenuButton
def SafeNoneInt( value ):
return -1 if value is None else value
def SafeNoneStr( value ):
return '' if value is None else value
class BetterListCtrl( QW.QTreeWidget ):
columnListContentsChanged = QC.Signal()
columnListStatusChanged = QC.Signal()
def __init__( self, parent, column_list_type, height_num_chars, data_to_tuples_func, use_simple_delete = False, delete_key_callback = None, activation_callback = None, style = None, column_types_to_name_overrides = None ):
QW.QTreeWidget.__init__( self, parent )
self._have_shown_a_column_data_error = False
self._creation_time = HydrusData.GetNow()
self._column_list_type = column_list_type
self._column_list_status: ClientGUIListStatus.ColumnListStatus = HG.client_controller.column_list_manager.GetStatus( self._column_list_type )
self._original_column_list_status = self._column_list_status
self.setAlternatingRowColors( True )
self.setColumnCount( self._column_list_status.GetColumnCount() )
self.setSortingEnabled( False ) # Keeping the custom sort implementation. It would be better to use Qt's native sorting in the future so sort indicators are displayed on the headers as expected.
self.setSelectionMode( QW.QAbstractItemView.ExtendedSelection )
self.setRootIsDecorated( False )
self._initial_height_num_chars = height_num_chars
self._forced_height_num_chars = None
self._data_to_tuples_func = data_to_tuples_func
self._use_simple_delete = use_simple_delete
self._menu_callable = None
( self._sort_column_type, self._sort_asc ) = self._column_list_status.GetSort()
self._indices_to_data_info = {}
self._data_to_indices = {}
# old way
'''
#sizing_column_initial_width = self.fontMetrics().boundingRect( 'x' * sizing_column_initial_width_num_chars ).width()
total_width = self.fontMetrics().boundingRect( 'x' * sizing_column_initial_width_num_chars ).width()
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 = self.fontMetrics().boundingRect( 'x' * width_num_chars ).width()
total_width += width
self.headerItem().setText( i, name )
self.setColumnWidth( i, width )
# Technically this is the previous behavior, but the two commented lines might work better in some cases (?)
self.header().setStretchLastSection( False )
self.header().setSectionResizeMode( resize_column - 1 , QW.QHeaderView.Stretch )
#self.setColumnWidth( resize_column - 1, sizing_column_initial_width )
#self.header().setStretchLastSection( True )
self.setMinimumWidth( total_width )
'''
main_tlw = HG.client_controller.GetMainTLW()
# if last section is set too low, for instance 3, the column seems unable to ever shrink from initial (expanded to fill space) size
# _ _ ___ _ _ __ __ ___
# ( \/\/ )( _)( \/\/ ) ( ) ( ) ( \
# \ / ) _) \ / )(__ /__\ ) ) )
# \/\/ (___) \/\/ (____)(_)(_)(___/
#
# I think this is because of mismatch between set size and min size! So ensuring we never set smaller than that initially should fix this???!?
MIN_SECTION_SIZE_CHARS = 3
MIN_LAST_SECTION_SIZE_CHARS = 10
self._min_section_width = ClientGUIFunctions.ConvertTextToPixelWidth( main_tlw, MIN_SECTION_SIZE_CHARS )
self.header().setMinimumSectionSize( self._min_section_width )
last_column_index = self._column_list_status.GetColumnCount() - 1
for ( i, column_type ) in enumerate( self._column_list_status.GetColumnTypes() ):
self.headerItem().setData( i, QC.Qt.UserRole, column_type )
if column_types_to_name_overrides is not None and column_type in column_types_to_name_overrides:
name = column_types_to_name_overrides[ column_type ]
else:
name = CGLC.column_list_column_name_lookup[ self._column_list_type ][ column_type ]
self.headerItem().setText( i, name )
self.headerItem().setToolTip( i, name )
if i == last_column_index:
width_chars = MIN_SECTION_SIZE_CHARS
else:
width_chars = self._column_list_status.GetColumnWidth( column_type )
width_chars = max( width_chars, MIN_SECTION_SIZE_CHARS )
# ok this is a pain in the neck issue, but fontmetrics changes afte widget init. I guess font gets styled on top afterwards
# this means that if I use this window's fontmetrics here, in init, then it is different later on, and we get creeping growing columns lmao
# several other places in the client are likely affected in different ways by this also!
width_pixels = ClientGUIFunctions.ConvertTextToPixelWidth( main_tlw, width_chars )
self.setColumnWidth( i, width_pixels )
self.header().setStretchLastSection( True )
self._delete_key_callback = delete_key_callback
self._activation_callback = activation_callback
self._widget_event_filter = QP.WidgetEventFilter( self )
self._widget_event_filter.EVT_KEY_DOWN( self.EventKeyDown )
self.itemDoubleClicked.connect( self.EventItemActivated )
self.header().setSectionsMovable( False ) # can only turn this on when we move from data/sort tuples
# self.header().setFirstSectionMovable( True ) # same
self.header().setSectionsClickable( True )
self.header().sectionClicked.connect( self.EventColumnClick )
#self.header().sectionMoved.connect( self._DoStatusChanged ) # same
self.header().sectionResized.connect( self._SectionsResized )
def _AddDataInfo( self, data_info ):
( data, display_tuple, sort_tuple ) = data_info
if data in self._data_to_indices:
return
append_item = QW.QTreeWidgetItem()
for i in range( len( display_tuple ) ):
text = display_tuple[i]
if len( text ) > 0:
text = text.splitlines()[0]
append_item.setText( i, text )
append_item.setToolTip( i, text )
self.addTopLevelItem( append_item )
index = self.topLevelItemCount() - 1
self._indices_to_data_info[ index ] = data_info
self._data_to_indices[ data ] = index
def _SectionsResized( self, logical_index, old_size, new_size ):
self._DoStatusChanged()
self.updateGeometry()
def _DoStatusChanged( self ):
self._column_list_status = self._GenerateCurrentStatus()
HG.client_controller.column_list_manager.SaveStatus( self._column_list_status )
def _GenerateCurrentStatus( self ) -> ClientGUIListStatus.ColumnListStatus:
status = ClientGUIListStatus.ColumnListStatus()
status.SetColumnListType( self._column_list_type )
main_tlw = HG.client_controller.GetMainTLW()
columns = []
header = self.header()
num_columns = header.count()
last_column_index = num_columns - 1
# ok, the big pain in the ass situation here is getting a precise last column size that is reproduced on next dialog launch
# ultimately, with fuzzy sizing, style padding, scrollbars appearing, and other weirdness, the more precisely we try to define it, the more we will get dialogs that grow/shrink by a pixel each time
# *therefore*, the actual solution here is to move to snapping with a decent snap distance. the user loses size setting precision, but we'll snap back to a decent size every time, compensating for fuzz
LAST_COLUMN_SNAP_DISTANCE_CHARS = 5
for visual_index in range( num_columns ):
logical_index = header.logicalIndex( visual_index )
column_type = self.headerItem().data( logical_index, QC.Qt.UserRole )
width_pixels = header.sectionSize( logical_index )
shown = not header.isSectionHidden( logical_index )
if visual_index == last_column_index:
if self.verticalScrollBar().isVisible():
width_pixels += max( 0, min( self.verticalScrollBar().width(), 20 ) )
width_chars = ClientGUIFunctions.ConvertPixelsToTextWidth( main_tlw, width_pixels )
if visual_index == last_column_index:
# here's the snap magic
width_chars = round( width_chars // LAST_COLUMN_SNAP_DISTANCE_CHARS ) * LAST_COLUMN_SNAP_DISTANCE_CHARS
columns.append( ( column_type, width_chars, shown ) )
status.SetColumns( columns )
status.SetSort( self._sort_column_type, self._sort_asc )
return status
def _GetDisplayAndSortTuples( self, data ):
try:
( display_tuple, sort_tuple ) = self._data_to_tuples_func( data )
except Exception as e:
if not self._have_shown_a_column_data_error:
HydrusData.ShowText( 'A multi-column list was unable to generate text or sort data for one or more rows! Please send hydrus dev the traceback!' )
HydrusData.ShowException( e )
self._have_shown_a_column_data_error = True
error_display_tuple = [ 'unable to display' for i in range( self._column_list_status.GetColumnCount() ) ]
return ( error_display_tuple, None )
better_sort = []
for item in sort_tuple:
if isinstance( item, str ):
item = HydrusData.HumanTextSortKey( item )
better_sort.append( item )
sort_tuple = tuple( better_sort )
return ( display_tuple, sort_tuple )
def _GetSelected( self ):
indices = []
for i in range( self.topLevelItemCount() ):
if self.topLevelItem( i ).isSelected():
indices.append( i )
return indices
def _RecalculateIndicesAfterDelete( self ):
indices_and_data_info = sorted( self._indices_to_data_info.items() )
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
CGC.core().PopupMenu( self, menu )
def _SortDataInfo( self ):
sort_column_index = self._column_list_status.GetColumnIndexFromType( self._sort_column_type )
data_infos = list( self._indices_to_data_info.values() )
data_infos_good = [ ( data, display_tuple, sort_tuple ) for ( data, display_tuple, sort_tuple ) in data_infos if sort_tuple is not None ]
data_infos_bad = [ ( data, display_tuple, sort_tuple ) for ( data, display_tuple, sort_tuple ) in data_infos if sort_tuple is None ]
def sort_key( data_info ):
( data, display_tuple, sort_tuple ) = data_info
return ( sort_tuple[ sort_column_index ], sort_tuple ) # add the sort tuple to get secondary sorting
try:
data_infos_good.sort( key = sort_key, reverse = not self._sort_asc )
except Exception as e:
HydrusData.ShowText( 'A multi-column list failed to sort! Please send hydrus dev the traceback!' )
HydrusData.ShowException( e )
data_infos_bad.extend( data_infos_good )
data_infos = data_infos_bad
return data_infos
def _SortAndRefreshRows( self ):
selected_data_quick = set( self.GetData( only_selected = True ) )
self.clearSelection()
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.topLevelItem( index ).setSelected( True )
def _UpdateRow( self, index, display_tuple ):
for ( column_index, value ) in enumerate( display_tuple ):
if len( value ) > 0:
value = value.splitlines()[0]
tree_widget_item = self.topLevelItem( index )
existing_value = tree_widget_item.text( column_index )
if existing_value != value:
tree_widget_item.setText( column_index, value )
tree_widget_item.setToolTip( column_index, value )
def AddDatas( self, datas: typing.Iterable[ object ] ):
for data in datas:
( display_tuple, sort_tuple ) = self._GetDisplayAndSortTuples( data )
self._AddDataInfo( ( data, display_tuple, sort_tuple ) )
self.columnListContentsChanged.emit()
def AddMenuCallable( self, menu_callable ):
self._menu_callable = menu_callable
self.setContextMenuPolicy( QC.Qt.CustomContextMenu )
self.customContextMenuRequested.connect( self.EventShowMenu )
def DeleteDatas( self, datas: typing.Iterable[ object ] ):
deletees = [ ( self._data_to_indices[ data ], data ) for data in datas ]
deletees.sort( reverse = True )
# The below comment is most probably obsolote (from before the Qt port), but keeping it just in case it is not and also as an explanation.
#
# 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.takeTopLevelItem( index )
for ( index, data ) in deletees:
del self._data_to_indices[ data ]
del self._indices_to_data_info[ index ]
self._RecalculateIndicesAfterDelete()
self.columnListContentsChanged.emit()
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 ]
item = self.takeTopLevelItem( index )
del item
del self._data_to_indices[ data ]
del self._indices_to_data_info[ index ]
self._RecalculateIndicesAfterDelete()
self.columnListContentsChanged.emit()
def EventColumnClick( self, col ):
sort_column_type = self._column_list_status.GetColumnTypeFromIndex( col )
if sort_column_type == self._sort_column_type:
self._sort_asc = not self._sort_asc
else:
self._sort_column_type = sort_column_type
self._sort_asc = True
self._SortAndRefreshRows()
self._DoStatusChanged()
def EventItemActivated( self, item, column ):
if self._activation_callback is not None:
self._activation_callback()
def EventKeyDown( self, event ):
( modifier, key ) = ClientGUIShortcuts.ConvertKeyEventToSimpleTuple( event )
if key in ClientGUIShortcuts.DELETE_KEYS_QT:
self.ProcessDeleteAction()
elif key in ( ord( 'A' ), ord( 'a' ) ) and modifier == QC.Qt.ControlModifier:
self.selectAll()
else:
return True # was: event.ignore()
def EventShowMenu( self ):
QP.CallAfter( self._ShowMenu )
def ForceHeight( self, rows ):
self._forced_height_num_chars = rows
self.updateGeometry()
# +2 for the header row and * 1.25 for magic rough text-to-rowheight conversion
#existing_min_width = self.minimumWidth()
#( width_gumpf, ideal_client_height ) = ClientGUIFunctions.ConvertTextToPixels( self, ( 20, int( ( ideal_rows + 2 ) * 1.25 ) ) )
#QP.SetMinClientSize( self, ( existing_min_width, ideal_client_height ) )
def GetData( self, only_selected = False ):
if only_selected:
indices = self._GetSelected()
else:
indices = list(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: object ):
return data in self._data_to_indices
def HasOneSelected( self ):
return len( self.selectedItems() ) == 1
def HasSelected( self ):
return len( self.selectedItems() ) > 0
def ProcessDeleteAction( self ):
if self._use_simple_delete:
self.ShowDeleteSelectedDialog()
elif self._delete_key_callback is not None:
self._delete_key_callback()
def SelectDatas( self, datas: typing.Iterable[ object ] ):
for data in datas:
if data in self._data_to_indices:
index = self._data_to_indices[ data ]
self.topLevelItem( index ).setSelected( True )
def SetData( self, datas: typing.Iterable[ object ] ):
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()
self.columnListContentsChanged.emit()
def ShowDeleteSelectedDialog( self ):
from hydrus.client.gui import ClientGUIDialogsQuick
result = ClientGUIDialogsQuick.GetYesNo( self, 'Remove all selected?' )
if result == QW.QDialog.Accepted:
self.DeleteSelected()
def _GetRowHeightEstimate( self ):
if self.topLevelItemCount() > 0:
height = self.rowHeight( self.indexFromItem( self.topLevelItem( 0 ) ) )
else:
( width_gumpf, height ) = ClientGUIFunctions.ConvertTextToPixels( self, ( 20, 1 ) )
return height
def minimumSizeHint( self ):
width = 0
for i in range( self.columnCount() - 1 ):
width += self.columnWidth( i )
width += self._min_section_width # the last column
width += self.frameWidth() * 2
if self._forced_height_num_chars is None:
min_num_rows = 4
else:
min_num_rows = self._forced_height_num_chars
header_size = self.header().sizeHint() # this is better than min size hint for some reason ?( 69, 69 )?
data_area_height = self._GetRowHeightEstimate() * min_num_rows
PADDING = 10
min_size_hint = QC.QSize( width, header_size.height() + data_area_height + PADDING )
return min_size_hint
def resizeEvent( self, event ):
self._DoStatusChanged()
return QW.QTreeWidget.resizeEvent( self, event )
def sizeHint( self ):
width = 0
# all but last column
for i in range( self.columnCount() - 1 ):
width += self.columnWidth( i )
#
# ok, we are going full slippery dippery doo now
# the issue is: when we first boot up, we want to give a 'hey, it would be nice' size of the last actual recorded final column
# HOWEVER, after that: we want to use the current size of the last column
# so, if it is the first couple of seconds, lmao. after that, oaml
# I later updated this to use the columnWidth, rather than hickery dickery text-to-pixel-width, since it was juddering resize around text width phase
last_column_type = self._column_list_status.GetColumnTypes()[-1]
if HydrusData.TimeHasPassed( self._creation_time + 2 ):
width += self.columnWidth( self.columnCount() - 1 )
else:
last_column_chars = self._original_column_list_status.GetColumnWidth( last_column_type )
main_tlw = HG.client_controller.GetMainTLW()
width += ClientGUIFunctions.ConvertTextToPixelWidth( main_tlw, last_column_chars )
#
width += self.frameWidth() * 2
if self._forced_height_num_chars is None:
num_rows = self._initial_height_num_chars
else:
num_rows = self._forced_height_num_chars
header_size = self.header().sizeHint()
data_area_height = self._GetRowHeightEstimate() * num_rows
PADDING = 10
size_hint = QC.QSize( width, header_size.height() + data_area_height + PADDING )
return size_hint
def Sort( self, sort_column_type = None, sort_asc = None ):
if sort_column_type is not None:
self._sort_column_type = sort_column_type
if sort_asc is not None:
self._sort_asc = sort_asc
self._SortAndRefreshRows()
self.columnListContentsChanged.emit()
self._DoStatusChanged()
def UpdateDatas( self, datas: typing.Optional[ typing.Iterable[ object ] ] = None ):
if datas is None:
# keep it sorted here, which is sometimes useful
indices_and_datas = sorted( ( ( index, data ) for ( data, index ) in self._data_to_indices.items() ) )
datas = [ data for ( index, data ) in indices_and_datas ]
sort_data_has_changed = False
sort_index = self._column_list_status.GetColumnIndexFromType( self._sort_column_type )
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 existing_sort_tuple is not None and sort_tuple is not None:
# this does not govern secondary sorts, but let's not spam sorts m8
if sort_tuple[ sort_index ] != existing_sort_tuple[ sort_index ]:
sort_data_has_changed = True
self._indices_to_data_info[ index ] = data_info
self._UpdateRow( index, display_tuple )
self.columnListContentsChanged.emit()
return sort_data_has_changed
def SetNonDupeName( self, obj: object ):
current_names = { o.GetName() for o in self.GetData() if o is not obj }
HydrusSerialisable.SetNonDupeName( obj, current_names )
def ReplaceData( self, old_data: object, new_data: object ):
new_data = QP.ListsToTuples( new_data )
data_index = self._data_to_indices[ old_data ]
( display_tuple, sort_tuple ) = self._GetDisplayAndSortTuples( new_data )
data_info = ( new_data, display_tuple, sort_tuple )
self._indices_to_data_info[ data_index ] = data_info
del self._data_to_indices[ old_data ]
self._data_to_indices[ new_data ] = data_index
self._UpdateRow( data_index, display_tuple )
class BetterListCtrlPanel( QW.QWidget ):
def __init__( self, parent ):
QW.QWidget.__init__( self, parent )
self._vbox = QP.VBoxLayout()
self._buttonbox = QP.HBoxLayout()
self._listctrl = None
self._permitted_object_types = []
self._import_add_callable = lambda x: None
self._custom_get_callable = 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_only_on_single_selection = False, enabled_check_func = None ):
QP.AddToLayout( self._buttonbox, button, CC.FLAGS_CENTER_PERPENDICULAR )
if enabled_only_on_selection:
enabled_check_func = self._HasSelected
if enabled_only_on_single_selection:
enabled_check_func = self._HasOneSelected
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 ]
from hydrus.client.gui import ClientGUIDialogsQuick
try:
defaults_to_add = ClientGUIDialogsQuick.SelectMultipleFromList( self, 'select the defaults to add', choice_tuples )
except HydrusExceptions.CancelledException:
return
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 _ExportToJSON( self ):
export_object = self._GetExportObject()
if export_object is not None:
json = export_object.DumpToString()
with QP.FileDialog( self, 'select where to save the json file', default_filename = 'export.json', wildcard = 'JSON (*.json)', acceptMode = QW.QFileDialog.AcceptSave, fileMode = QW.QFileDialog.AnyFile ) as f_dlg:
if f_dlg.exec() == QW.QDialog.Accepted:
path = f_dlg.GetPath()
if os.path.exists( path ):
from hydrus.client.gui import ClientGUIDialogsQuick
message = 'The path "{}" already exists! Ok to overwrite?'.format( path )
result = ClientGUIDialogsQuick.GetYesNo( self, message )
if result != QW.QDialog.Accepted:
return
with open( path, 'w', encoding = 'utf-8' ) as f:
f.write( json )
def _ExportToPNG( self ):
export_object = self._GetExportObject()
if export_object is not None:
from hydrus.client.gui import ClientGUITopLevelWindowsPanels
from hydrus.client.gui import ClientGUISerialisable
with ClientGUITopLevelWindowsPanels.DialogNullipotent( self, 'export to png' ) as dlg:
panel = ClientGUISerialisable.PNGExportPanel( dlg, export_object )
dlg.SetPanel( panel )
dlg.exec()
def _ExportToPNGs( self ):
export_object = self._GetExportObject()
if export_object is None:
return
if not isinstance( export_object, HydrusSerialisable.SerialisableList ):
self._ExportToPNG()
return
from hydrus.client.gui import ClientGUITopLevelWindowsPanels
from hydrus.client.gui import ClientGUISerialisable
with ClientGUITopLevelWindowsPanels.DialogNullipotent( self, 'export to pngs' ) as dlg:
panel = ClientGUISerialisable.PNGsExportPanel( dlg, export_object )
dlg.SetPanel( panel )
dlg.exec()
def _GetExportObject( self ):
if self._custom_get_callable is None:
to_export = HydrusSerialisable.SerialisableList()
for obj in self._listctrl.GetData( only_selected = True ):
to_export.append( obj )
else:
to_export = [ self._custom_get_callable() ]
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 _HasOneSelected( self ):
return self._listctrl.HasOneSelected()
def _ImportFromClipboard( self ):
try:
raw_text = HG.client_controller.GetClipboardText()
except HydrusExceptions.DataMissing as e:
QW.QMessageBox.critical( self, 'Error', str(e) )
return
try:
obj = HydrusSerialisable.CreateFromString( raw_text, raise_error_on_future_version = True )
self._ImportObject( obj )
except HydrusExceptions.SerialisationException as e:
QW.QMessageBox.critical( self, 'Problem loading', str( e ) )
except Exception as e:
QW.QMessageBox.critical( self, 'Error', 'I could not understand what was in the clipboard' )
self._listctrl.Sort()
def _ImportFromJSON( self ):
with QP.FileDialog( self, 'select the json or jsons with the serialised data', acceptMode = QW.QFileDialog.AcceptOpen, fileMode = QW.QFileDialog.ExistingFiles, wildcard = 'JSON (*.json)|*.json' ) as dlg:
if dlg.exec() == QW.QDialog.Accepted:
paths = dlg.GetPaths()
self._ImportJSONs( paths )
self._listctrl.Sort()
def _ImportFromPNG( self ):
with QP.FileDialog( self, 'select the png or pngs with the encoded data', acceptMode = QW.QFileDialog.AcceptOpen, fileMode = QW.QFileDialog.ExistingFiles, wildcard = 'PNG (*.png)|*.png' ) as dlg:
if dlg.exec() == QW.QDialog.Accepted:
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 ) )
QW.QMessageBox.critical( self, 'Error', message )
def _ImportJSONs( self, paths ):
have_shown_load_error = False
for path in paths:
try:
with open( path, 'r', encoding = 'utf-8' ) as f:
payload = f.read()
except Exception as e:
QW.QMessageBox.critical( self, 'Error', str(e) )
return
try:
obj = HydrusSerialisable.CreateFromString( payload, raise_error_on_future_version = True )
self._ImportObject( obj )
except HydrusExceptions.SerialisationException as e:
if not have_shown_load_error:
message = str( e )
if len( paths ) > 1:
message += os.linesep * 2
message += 'If there are more objects in this import with similar load problems, they will now be skipped silently.'
QW.QMessageBox.critical( self, 'Problem loading', str( e ) )
have_shown_load_error = True
except:
QW.QMessageBox.critical( self, 'Error', 'I could not understand what was encoded in "{}"!'.format( path ) )
return
def _ImportPNGs( self, paths ):
have_shown_load_error = False
for path in paths:
try:
payload = ClientSerialisable.LoadFromPNG( path )
except Exception as e:
QW.QMessageBox.critical( self, 'Error', str(e) )
return
try:
obj = HydrusSerialisable.CreateFromNetworkBytes( payload, raise_error_on_future_version = True )
self._ImportObject( obj )
except HydrusExceptions.SerialisationException as e:
if not have_shown_load_error:
message = str( e )
if len( paths ) > 1:
message += os.linesep * 2
message += 'If there are more objects in this import with similar load problems, they will now be skipped silently.'
QW.QMessageBox.critical( self, 'Problem loading', str( e ) )
have_shown_load_error = True
except:
QW.QMessageBox.critical( self, 'Error', 'I could not understand what was encoded in "{}"!'.format( path ) )
return
def _UpdateButtons( self ):
for ( button, enabled_check_func ) in self._button_infos:
if enabled_check_func():
button.setEnabled( True )
else:
button.setEnabled( False )
def AddBitmapButton( self, bitmap, clicked_func, tooltip = None, enabled_only_on_selection = False, enabled_only_on_single_selection = False, enabled_check_func = None ):
button = ClientGUICommon.BetterBitmapButton( self, bitmap, clicked_func )
if tooltip is not None:
button.setToolTip( tooltip )
self._AddButton( button, enabled_only_on_selection = enabled_only_on_selection, enabled_only_on_single_selection = enabled_only_on_single_selection, enabled_check_func = enabled_check_func )
self._UpdateButtons()
def AddButton( self, label, clicked_func, enabled_only_on_selection = False, enabled_only_on_single_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_only_on_single_selection = enabled_only_on_single_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 AddDeleteButton( self, enabled_check_func = None ):
if enabled_check_func is None:
enabled_only_on_selection = True
else:
enabled_only_on_selection = False
self.AddButton( 'delete', self._listctrl.ProcessDeleteAction, enabled_check_func = enabled_check_func, enabled_only_on_selection = enabled_only_on_selection )
def AddImportExportButtons( self, permitted_object_types, import_add_callable, custom_get_callable = None ):
self._permitted_object_types = permitted_object_types
self._import_add_callable = import_add_callable
self._custom_get_callable = custom_get_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 json file', 'Serialise the selected data and export to a json file.', self._ExportToJSON ) )
export_menu_items.append( ( 'normal', 'to png file', 'Serialise the selected data and encode it to an image file you can easily share with other hydrus users.', self._ExportToPNG ) )
if self._custom_get_callable is None:
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 json files', 'Load a data from .json files.', self._ImportFromJSON ) )
import_menu_items.append( ( 'normal', 'from png files (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.setAcceptDrops( True )
self.installEventFilter( ClientGUIDragDrop.FileDropTarget( self, filenames_callable = self.ImportFromDragDrop ) )
def AddMenuButton( self, label, menu_items, enabled_only_on_selection = False, enabled_check_func = None ):
button = ClientGUIMenuButton.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.addSpacing( 12 )
def AddWindow( self, window ):
QP.AddToLayout( self._buttonbox, window, CC.FLAGS_CENTER_PERPENDICULAR )
def EventContentChanged( self, parent, first, last ):
if not self._listctrl:
return
self._UpdateButtons()
def EventSelectionChanged( self ):
if not self._listctrl:
return
self._UpdateButtons()
def ImportFromDragDrop( self, paths ):
from hydrus.client.gui import ClientGUIDialogsQuick
message = 'Try to import the {} dropped files to this list? I am expecting json or png files.'.format( HydrusData.ToHumanInt( len( paths ) ) )
result = ClientGUIDialogsQuick.GetYesNo( self, message )
if result == QW.QDialog.Accepted:
( jsons, pngs ) = HydrusData.PartitionIteratorIntoLists( lambda path: path.endswith( '.png' ), paths )
self._ImportPNGs( pngs )
self._ImportJSONs( jsons )
self._listctrl.Sort()
def NewButtonRow( self ):
self._buttonbox = QP.HBoxLayout()
QP.AddToLayout( self._vbox, self._buttonbox, CC.FLAGS_ON_RIGHT )
def SetListCtrl( self, listctrl ):
self._listctrl = listctrl
QP.AddToLayout( self._vbox, self._listctrl, CC.FLAGS_EXPAND_SIZER_BOTH_WAYS )
QP.AddToLayout( self._vbox, self._buttonbox, CC.FLAGS_ON_RIGHT )
self.setLayout( self._vbox )
self._listctrl.itemSelectionChanged.connect( self.EventSelectionChanged )
self._listctrl.model().rowsInserted.connect( self.EventContentChanged )
self._listctrl.model().rowsRemoved.connect( self.EventContentChanged )
def UpdateButtons( self ):
self._UpdateButtons()