import collections import itertools import os import threading import typing from qtpy import QtCore as QC from qtpy import QtWidgets as QW from qtpy import QtGui as QG from hydrus.core import HydrusConstants as HC from hydrus.core import HydrusData from hydrus.core import HydrusExceptions from hydrus.core import HydrusGlobals as HG from hydrus.core import HydrusSerialisable from hydrus.core import HydrusTags from hydrus.client import ClientConstants as CC from hydrus.client import ClientSearch from hydrus.client import ClientSerialisable from hydrus.client.gui import ClientGUIAsync from hydrus.client.gui import ClientGUICore as CGC from hydrus.client.gui import ClientGUIFunctions from hydrus.client.gui import ClientGUIMenus from hydrus.client.gui import ClientGUIShortcuts from hydrus.client.gui import ClientGUITagSorting from hydrus.client.gui import QtPorting as QP from hydrus.client.gui.lists import ClientGUIListBoxesData from hydrus.client.gui.search import ClientGUISearch from hydrus.client.gui.widgets import ClientGUICommon from hydrus.client.gui.widgets import ClientGUIMenuButton from hydrus.client.media import ClientMedia from hydrus.client.metadata import ClientTags from hydrus.client.metadata import ClientTagSorting class BetterQListWidget( QW.QListWidget ): def _DeleteIndices( self, indices: typing.Iterable[ int ] ): indices = sorted( indices, reverse = True ) for index in indices: item = self.takeItem( index ) del item def _GetDataIndices( self, datas: typing.Collection[ object ] ) -> typing.List[ int ]: indices = [] for index in range( self.count() ): list_widget_item = self.item( index ) data = self._GetRowData( list_widget_item ) if data in datas: indices.append( index ) return indices def _GetListWidgetItems( self, only_selected = False ): # not sure if selectedItems is always sorted, so just do it manually list_widget_items = [] for index in range( self.count() ): list_widget_item = self.item( index ) if only_selected and not list_widget_item.isSelected(): continue list_widget_items.append( list_widget_item ) return list_widget_items def _GetRowData( self, list_widget_item: QW.QListWidgetItem ): return list_widget_item.data( QC.Qt.UserRole ) def _GetSelectedIndices( self ): return [ model_index.row() for model_index in self.selectedIndexes() ] def _MoveRow( self, index: int, distance: int ): new_index = index + distance new_index = max( 0, new_index ) new_index = min( new_index, self.count() - 1 ) if index == new_index: return was_selected = self.item( index ).isSelected() list_widget_item = self.takeItem( index ) self.insertItem( new_index, list_widget_item ) list_widget_item.setSelected( was_selected ) def Append( self, text: str, data: object ): item = QW.QListWidgetItem() item.setText( text ) item.setData( QC.Qt.UserRole, data ) self.addItem( item ) def DeleteData( self, datas: typing.Collection[ object ] ): indices = self._GetDataIndices( datas ) self._DeleteIndices( indices ) def DeleteSelected( self ): indices = self._GetSelectedIndices() self._DeleteIndices( indices ) def GetData( self, only_selected: bool = False ) -> typing.List[ object ]: datas = [] list_widget_items = self._GetListWidgetItems( only_selected = only_selected ) for list_widget_item in list_widget_items: data = self._GetRowData( list_widget_item ) datas.append( data ) return datas def GetNumSelected( self ) -> int: indices = self._GetSelectedIndices() return len( indices ) def MoveSelected( self, distance: int ): if distance == 0: return # if going up, -1, then do them in ascending order # if going down, +1, then do them in descending order indices = sorted( self._GetSelectedIndices(), reverse = distance > 0 ) for index in indices: self._MoveRow( index, distance ) def PopData( self, index: int ): if index < 0 or index > self.count() - 1: return None list_widget_item = self.item( index ) data = self._GetRowData( list_widget_item ) self._DeleteIndices( [ index ] ) return data def SelectData( self, datas: typing.Collection[ object ] ): list_widget_items = self._GetListWidgetItems() for list_widget_item in list_widget_items: data = self._GetRowData( list_widget_item ) list_widget_item.setSelected( data in datas ) class AddEditDeleteListBox( QW.QWidget ): listBoxChanged = QC.Signal() def __init__( self, parent, height_num_chars, data_to_pretty_callable, add_callable, edit_callable ): self._data_to_pretty_callable = data_to_pretty_callable self._add_callable = add_callable self._edit_callable = edit_callable QW.QWidget.__init__( self, parent ) self._listbox = BetterQListWidget( self ) self._listbox.setSelectionMode( QW.QListWidget.ExtendedSelection ) self._add_button = ClientGUICommon.BetterButton( self, 'add', self._Add ) self._edit_button = ClientGUICommon.BetterButton( self, 'edit', self._Edit ) self._delete_button = ClientGUICommon.BetterButton( self, 'delete', self._Delete ) self._enabled_only_on_selection_buttons = [] self._permitted_object_types = [] # vbox = QP.VBoxLayout() self._buttons_hbox = QP.HBoxLayout() QP.AddToLayout( self._buttons_hbox, self._add_button, CC.FLAGS_EXPAND_BOTH_WAYS ) QP.AddToLayout( self._buttons_hbox, self._edit_button, CC.FLAGS_EXPAND_BOTH_WAYS ) QP.AddToLayout( self._buttons_hbox, self._delete_button, CC.FLAGS_EXPAND_BOTH_WAYS ) QP.AddToLayout( vbox, self._listbox, CC.FLAGS_EXPAND_BOTH_WAYS ) QP.AddToLayout( vbox, self._buttons_hbox, CC.FLAGS_EXPAND_PERPENDICULAR ) self.setLayout( vbox ) # ( width, height ) = ClientGUIFunctions.ConvertTextToPixels( self._listbox, ( 20, height_num_chars ) ) self._listbox.setMinimumWidth( width ) self._listbox.setMinimumHeight( height ) # self._ShowHideButtons() self._listbox.itemSelectionChanged.connect( self._ShowHideButtons ) if self._edit_callable is not None: self._listbox.itemDoubleClicked.connect( self._Edit ) else: self._listbox.itemDoubleClicked.connect( self._Delete ) def _Add( self ): try: data = self._add_callable() except HydrusExceptions.VetoException: return self._AddData( data ) self.listBoxChanged.emit() def _AddAllDefaults( self, defaults_callable ): defaults = defaults_callable() for default in defaults: self._AddData( default ) self.listBoxChanged.emit() def _AddData( self, data ): self._SetNonDupeName( data ) pretty_data = self._data_to_pretty_callable( data ) self._listbox.Append( pretty_data, data ) def _AddSomeDefaults( self, defaults_callable ): defaults = defaults_callable() selected = False choice_tuples = [ ( self._data_to_pretty_callable( default ), 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: self._AddData( default ) self.listBoxChanged.emit() def _Delete( self ): num_selected = self._listbox.GetNumSelected() if num_selected == 0: return from hydrus.client.gui import ClientGUIDialogsQuick result = ClientGUIDialogsQuick.GetYesNo( self, 'Remove {} selected?'.format( HydrusData.ToHumanInt( num_selected ) ) ) if result != QW.QDialog.Accepted: return self._listbox.DeleteSelected() self.listBoxChanged.emit() def _Edit( self ): for list_widget_item in self._listbox.selectedItems(): data = list_widget_item.data( QC.Qt.UserRole ) try: new_data = self._edit_callable( data ) except HydrusExceptions.VetoException: break self._SetNonDupeName( new_data ) pretty_new_data = self._data_to_pretty_callable( new_data ) list_widget_item.setText( pretty_new_data ) list_widget_item.setData( QC.Qt.UserRole, new_data ) self.listBoxChanged.emit() def _Duplicate( self ): dupe_data = self._GetExportObject() if dupe_data is not None: dupe_data = dupe_data.Duplicate() self._ImportObject( dupe_data ) 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: 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 ): to_export = HydrusSerialisable.SerialisableList() for obj in self.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 _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 ) self._ImportObject( obj ) except Exception as e: QW.QMessageBox.critical( self, 'Error', 'I could not understand what was in the clipboard' ) 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: for path in dlg.GetPaths(): try: payload = ClientSerialisable.LoadFromPNG( path ) except Exception as e: QW.QMessageBox.critical( self, 'Error', str(e) ) return try: obj = HydrusSerialisable.CreateFromNetworkBytes( payload ) self._ImportObject( obj ) except: QW.QMessageBox.critical( self, 'Error', 'I could not understand what was encoded in the png!' ) return 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._AddData( 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 ) self.listBoxChanged.emit() def _SetNonDupeName( self, obj ): pass def _ShowHideButtons( self ): if self._listbox.GetNumSelected() == 0: self._edit_button.setEnabled( False ) self._delete_button.setEnabled( False ) for button in self._enabled_only_on_selection_buttons: button.setEnabled( False ) else: self._edit_button.setEnabled( True ) self._delete_button.setEnabled( True ) for button in self._enabled_only_on_selection_buttons: button.setEnabled( True ) def AddDatas( self, datas ): for data in datas: self._AddData( data ) self.listBoxChanged.emit() def AddDefaultsButton( self, defaults_callable ): import_menu_items = [] all_call = HydrusData.Call( self._AddAllDefaults, defaults_callable ) some_call = HydrusData.Call( self._AddSomeDefaults, defaults_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 ) ) button = ClientGUIMenuButton.MenuButton( self, 'add defaults', import_menu_items ) QP.AddToLayout( self._buttons_hbox, button, CC.FLAGS_CENTER_PERPENDICULAR ) def AddImportExportButtons( self, permitted_object_types ): self._permitted_object_types = permitted_object_types 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', 'Load a data from an encoded png.', self._ImportFromPNG ) ) button = ClientGUIMenuButton.MenuButton( self, 'export', export_menu_items ) QP.AddToLayout( self._buttons_hbox, button, CC.FLAGS_CENTER_PERPENDICULAR ) self._enabled_only_on_selection_buttons.append( button ) button = ClientGUIMenuButton.MenuButton( self, 'import', import_menu_items ) QP.AddToLayout( self._buttons_hbox, button, CC.FLAGS_CENTER_PERPENDICULAR ) button = ClientGUICommon.BetterButton( self, 'duplicate', self._Duplicate ) QP.AddToLayout( self._buttons_hbox, button, CC.FLAGS_CENTER_PERPENDICULAR ) self._enabled_only_on_selection_buttons.append( button ) self._ShowHideButtons() def AddSeparator( self ): QP.AddToLayout( self._buttons_hbox, (20,20), CC.FLAGS_EXPAND_PERPENDICULAR ) def GetCount( self ): return self._listbox.count() def GetData( self, only_selected = False ): return self._listbox.GetData( only_selected = only_selected ) def GetValue( self ): return self.GetData() class AddEditDeleteListBoxUniqueNamedObjects( AddEditDeleteListBox ): def _SetNonDupeName( self, obj ): disallowed_names = { o.GetName() for o in self.GetData() } HydrusSerialisable.SetNonDupeName( obj, disallowed_names ) class QueueListBox( QW.QWidget ): listBoxChanged = QC.Signal() def __init__( self, parent, height_num_chars, data_to_pretty_callable, add_callable = None, edit_callable = None ): self._data_to_pretty_callable = data_to_pretty_callable self._add_callable = add_callable self._edit_callable = edit_callable QW.QWidget.__init__( self, parent ) self._listbox = BetterQListWidget( self ) self._listbox.setSelectionMode( QW.QListWidget.ExtendedSelection ) self._up_button = ClientGUICommon.BetterButton( self, '\u2191', self._Up ) self._delete_button = ClientGUICommon.BetterButton( self, 'X', self._Delete ) self._down_button = ClientGUICommon.BetterButton( self, '\u2193', self._Down ) self._add_button = ClientGUICommon.BetterButton( self, 'add', self._Add ) self._edit_button = ClientGUICommon.BetterButton( self, 'edit', self._Edit ) if self._add_callable is None: self._add_button.hide() if self._edit_callable is None: self._edit_button.hide() # vbox = QP.VBoxLayout() buttons_vbox = QP.VBoxLayout() QP.AddToLayout( buttons_vbox, self._up_button, CC.FLAGS_CENTER_PERPENDICULAR ) QP.AddToLayout( buttons_vbox, self._delete_button, CC.FLAGS_CENTER_PERPENDICULAR ) QP.AddToLayout( buttons_vbox, self._down_button, CC.FLAGS_CENTER_PERPENDICULAR ) hbox = QP.HBoxLayout() QP.AddToLayout( hbox, self._listbox, CC.FLAGS_EXPAND_BOTH_WAYS ) QP.AddToLayout( hbox, buttons_vbox, CC.FLAGS_CENTER_PERPENDICULAR ) buttons_hbox = QP.HBoxLayout() QP.AddToLayout( buttons_hbox, self._add_button, CC.FLAGS_EXPAND_BOTH_WAYS ) QP.AddToLayout( buttons_hbox, self._edit_button, CC.FLAGS_EXPAND_BOTH_WAYS ) QP.AddToLayout( vbox, hbox, CC.FLAGS_EXPAND_BOTH_WAYS ) QP.AddToLayout( vbox, buttons_hbox, CC.FLAGS_EXPAND_PERPENDICULAR ) self.setLayout( vbox ) # ( width, height ) = ClientGUIFunctions.ConvertTextToPixels( self._listbox, ( 20, height_num_chars ) ) self._listbox.setMinimumWidth( width ) self._listbox.setMinimumHeight( height ) # self._listbox.itemSelectionChanged.connect( self._UpdateButtons ) if self._edit_callable is not None: self._listbox.itemDoubleClicked.connect( self._Edit ) else: self._listbox.itemDoubleClicked.connect( self._Delete ) self._UpdateButtons() def _Add( self ): try: data = self._add_callable() except HydrusExceptions.VetoException: return self._AddData( data ) self.listBoxChanged.emit() def _AddData( self, data ): pretty_data = self._data_to_pretty_callable( data ) self._listbox.Append( pretty_data, data ) def _Delete( self ): num_selected = self._listbox.GetNumSelected() if num_selected == 0: return from hydrus.client.gui import ClientGUIDialogsQuick result = ClientGUIDialogsQuick.GetYesNo( self, 'Remove {} selected?'.format( HydrusData.ToHumanInt( num_selected ) ) ) if result == QW.QDialog.Accepted: self._listbox.DeleteSelected() self.listBoxChanged.emit() def _Down( self ): self._listbox.MoveSelected( 1 ) self.listBoxChanged.emit() def _Edit( self ): for list_widget_item in self._listbox.selectedItems(): data = list_widget_item.data( QC.Qt.UserRole ) try: new_data = self._edit_callable( data ) except HydrusExceptions.VetoException: break pretty_new_data = self._data_to_pretty_callable( new_data ) list_widget_item.setText( pretty_new_data ) list_widget_item.setData( QC.Qt.UserRole, new_data ) self.listBoxChanged.emit() def _Up( self ): self._listbox.MoveSelected( -1 ) self.listBoxChanged.emit() def _UpdateButtons( self ): if self._listbox.GetNumSelected() == 0: self._up_button.setEnabled( False ) self._delete_button.setEnabled( False ) self._down_button.setEnabled( False ) self._edit_button.setEnabled( False ) else: self._up_button.setEnabled( True ) self._delete_button.setEnabled( True ) self._down_button.setEnabled( True ) self._edit_button.setEnabled( True ) def AddDatas( self, datas ): for data in datas: self._AddData( data ) self.listBoxChanged.emit() def GetCount( self ): return self._listbox.count() def GetData( self, only_selected = False ): return self._listbox.GetData( only_selected = only_selected ) def Pop( self ): if self._listbox.count() == 0: return None return self._listbox.PopData( 0 ) class ListBox( QW.QScrollArea ): listBoxChanged = QC.Signal() mouseActivationOccurred = QC.Signal() TEXT_X_PADDING = 3 def __init__( self, parent: QW.QWidget, child_rows_allowed: bool, terms_may_have_child_rows: bool, height_num_chars = 10, has_async_text_info = False ): QW.QScrollArea.__init__( self, parent ) self.setFrameStyle( QW.QFrame.Panel | QW.QFrame.Sunken ) self.setHorizontalScrollBarPolicy( QC.Qt.ScrollBarAlwaysOff ) self.setVerticalScrollBarPolicy( QC.Qt.ScrollBarAsNeeded ) self.setWidget( ListBox._InnerWidget( self ) ) self.setWidgetResizable( True ) self._background_colour = QG.QColor( 255, 255, 255 ) self._ordered_terms = [] self._terms_to_logical_indices = {} self._terms_to_positional_indices = {} self._positional_indices_to_terms = {} self._selected_terms = set() self._total_positional_rows = 0 self._last_hit_logical_index = None self._last_drag_start_logical_index = None self._drag_started = False self._last_view_start = None self._height_num_chars = height_num_chars self._minimum_height_num_chars = 8 self._num_rows_per_page = 0 self._child_rows_allowed = child_rows_allowed self._terms_may_have_child_rows = terms_may_have_child_rows # self._has_async_text_info = has_async_text_info self._terms_to_async_text_info = {} self._pending_async_text_info_terms = set() self._currently_fetching_async_text_info_terms = set() self._async_text_info_lock = threading.Lock() self._async_text_info_shared_data = dict() self._async_text_info_updater = self._InitialiseAsyncTextInfoUpdater() # self.setFont( QW.QApplication.font() ) self._widget_event_filter = QP.WidgetEventFilter( self.widget() ) self._widget_event_filter.EVT_LEFT_DOWN( self.EventMouseSelect ) self._widget_event_filter.EVT_RIGHT_DOWN( self.EventMouseSelect ) def __len__( self ): return len( self._ordered_terms ) def __bool__( self ): return QP.isValid( self ) def _Activate( self, shift_down ) -> bool: return False def _ActivateFromKeyboard( self, shift_down ): selected_indices = [] for term in self._selected_terms: try: logical_index = self._GetLogicalIndexFromTerm( term ) selected_indices.append( logical_index ) except HydrusExceptions.DataMissing: pass action_occurred = self._Activate( shift_down ) if action_occurred and len( self._selected_terms ) == 0 and len( selected_indices ) > 0: ideal_index = min( selected_indices ) ideal_indices = [ ideal_index, ideal_index - 1, 0 ] for ideal_index in ideal_indices: if ideal_index <= len( self._ordered_terms ) - 1: self._Hit( False, False, ideal_index ) break return action_occurred def _AddEditMenu( self, menu: QW.QMenu ): pass def _ApplyAsyncInfoToTerm( self, term, info ) -> typing.Tuple[ bool, bool ]: # this guy comes with the lock return ( False, False ) def _DeleteActivate( self ): pass def _AppendTerms( self, terms ): previously_selected_terms = { term for term in terms if term in self._selected_terms } clear_terms = [ term for term in terms if term in self._terms_to_logical_indices ] if len( clear_terms ) > 0: self._RemoveTerms( clear_terms ) for term in terms: self._ordered_terms.append( term ) self._terms_to_logical_indices[ term ] = len( self._ordered_terms ) - 1 self._terms_to_positional_indices[ term ] = self._total_positional_rows self._positional_indices_to_terms[ self._total_positional_rows ] = term if self._has_async_text_info: # goes before getrowcount so we can populate if needed self._StartAsyncTextInfoLookup( term ) self._total_positional_rows += term.GetRowCount( self._child_rows_allowed ) if len( previously_selected_terms ) > 0: self._selected_terms.update( previously_selected_terms ) def _Clear( self ): self._ordered_terms = [] self._selected_terms = set() self._terms_to_logical_indices = {} self._terms_to_positional_indices = {} self._positional_indices_to_terms = {} self._total_positional_rows = 0 self._last_hit_logical_index = None self._last_view_start = None def _DataHasChanged( self ): self._SetVirtualSize() self.widget().update() self.listBoxChanged.emit() def _Deselect( self, index ): term = self._GetTermFromLogicalIndex( index ) self._selected_terms.discard( term ) def _DeselectAll( self ): self._selected_terms = set() def _GetLogicalIndexFromTerm( self, term ): if term in self._terms_to_logical_indices: return self._terms_to_logical_indices[ term ] raise HydrusExceptions.DataMissing() def _GetLogicalIndexUnderMouse( self, mouse_event ): y = mouse_event.pos().y() if mouse_event.type() == QC.QEvent.MouseMove: visible_rect = QP.ScrollAreaVisibleRect( self ) visible_rect_y = visible_rect.y() y += visible_rect_y text_height = self.fontMetrics().height() positional_index = y // text_height if positional_index >= self._total_positional_rows: return None ( logical_index, positional_index ) = self._GetLogicalIndicesFromPositionalIndex( positional_index ) return logical_index def _GetLogicalIndicesFromPositionalIndex( self, positional_index: int ): if positional_index < 0 or positional_index >= self._total_positional_rows: return ( None, positional_index ) while positional_index not in self._positional_indices_to_terms and positional_index >= 0: positional_index -= 1 if positional_index < 0: return ( None, 0 ) return ( self._terms_to_logical_indices[ self._positional_indices_to_terms[ positional_index ] ], positional_index ) def _GetPositionalIndexFromLogicalIndex( self, logical_index: int ): try: term = self._GetTermFromLogicalIndex( logical_index ) except HydrusExceptions.DataMissing: return 0 return self._terms_to_positional_indices[ term ] def _GetPredicatesFromTerms( self, terms: typing.Collection[ ClientGUIListBoxesData.ListBoxItem ] ): return list( itertools.chain.from_iterable( ( term.GetSearchPredicates() for term in terms ) ) ) def _GetRowsOfTextsAndColours( self, term: ClientGUIListBoxesData.ListBoxItem ): raise NotImplementedError() def _GetSelectedPredicatesAndInverseCopies( self ): predicates = self._GetPredicatesFromTerms( self._selected_terms ) inverse_predicates = [ predicate.GetInverseCopy() for predicate in predicates if predicate.IsInvertible() ] if len( predicates ) > 1 and ClientSearch.PREDICATE_TYPE_OR_CONTAINER not in ( p.GetType() for p in predicates ): or_predicate = ClientSearch.Predicate( ClientSearch.PREDICATE_TYPE_OR_CONTAINER, value = list( predicates ) ) else: or_predicate = None return ( predicates, or_predicate, inverse_predicates ) def _GetSafeHitIndex( self, logical_index, direction = None ): if direction is None: if logical_index == 0: direction = 1 else: direction = -1 num_terms = len( self._ordered_terms ) if num_terms == 0: return None original_logical_index = logical_index if logical_index is not None: # if click/selection is out of bounds, fix it if logical_index == -1 or logical_index > num_terms: logical_index = num_terms - 1 elif logical_index == num_terms or logical_index < -1: logical_index = 0 return logical_index def _GetTagsFromTerms( self, terms: typing.Collection[ ClientGUIListBoxesData.ListBoxItem ] ): return list( itertools.chain.from_iterable( ( term.GetTags() for term in terms ) ) ) def _GetTermFromLogicalIndex( self, logical_index ) -> ClientGUIListBoxesData.ListBoxItem: if logical_index < 0 or logical_index > len( self._ordered_terms ) - 1: raise HydrusExceptions.DataMissing( 'No term for index ' + str( logical_index ) ) return self._ordered_terms[ logical_index ] def _HandleClick( self, event ): logical_index = self._GetLogicalIndexUnderMouse( event ) shift = event.modifiers() & QC.Qt.ShiftModifier ctrl = event.modifiers() & QC.Qt.ControlModifier self._Hit( shift, ctrl, logical_index ) def _Hit( self, shift, ctrl, logical_index, only_add = False ): if logical_index is not None and ( logical_index < 0 or logical_index >= len( self._ordered_terms ) ): logical_index = None to_select = set() to_deselect = set() deselect_all = False if shift: if logical_index is not None: if ctrl: if self._LogicalIndexIsSelected( logical_index ): if self._last_hit_logical_index is not None: lower = min( logical_index, self._last_hit_logical_index ) upper = max( logical_index, self._last_hit_logical_index ) to_deselect = list( range( lower, upper + 1 ) ) else: to_deselect.add( logical_index ) else: # we are now saying if you shift-click on something already selected, we'll make no changes, but we'll move focus ghost if not self._LogicalIndexIsSelected( logical_index ): if self._last_hit_logical_index is not None: lower = min( logical_index, self._last_hit_logical_index ) upper = max( logical_index, self._last_hit_logical_index ) to_select = list( range( lower, upper + 1 ) ) else: to_select.add( logical_index ) elif ctrl: if logical_index is not None: if self._LogicalIndexIsSelected( logical_index ): to_deselect.add( logical_index ) else: to_select.add( logical_index ) else: if logical_index is None: deselect_all = True else: if not self._LogicalIndexIsSelected( logical_index ): deselect_all = True to_select.add( logical_index ) if deselect_all: self._DeselectAll() for index in to_select: self._Select( index ) for index in to_deselect: self._Deselect( index ) self._last_hit_logical_index = logical_index if self._last_hit_logical_index is not None: text_height = self.fontMetrics().height() last_hit_positional_index = self._GetPositionalIndexFromLogicalIndex( self._last_hit_logical_index ) y = text_height * last_hit_positional_index visible_rect = QP.ScrollAreaVisibleRect( self ) visible_rect_y = visible_rect.y() visible_rect_height = visible_rect.height() if y < visible_rect_y: self.ensureVisible( 0, y, 0, 0 ) elif y > visible_rect_y + visible_rect_height - text_height: self.ensureVisible( 0, y + text_height , 0, 0 ) self.widget().update() def _HitFirstSelectedItem( self ): selected_indices = [] if len( self._selected_terms ) > 0: for term in self._selected_terms: try: logical_index = self._GetLogicalIndexFromTerm( term ) selected_indices.append( logical_index ) except HydrusExceptions.DataMissing: pass if len( selected_indices ) > 0: first_logical_index = min( selected_indices ) self._Hit( False, False, first_logical_index ) def _InitialiseAsyncTextInfoUpdater( self ): def loading_callable(): pass work_callable = self._InitialiseAsyncTextInfoUpdaterWorkCallable() def publish_callable( terms_to_info ): any_sort_info_changed = False any_num_rows_changed = False with self._async_text_info_lock: self._currently_fetching_async_text_info_terms.difference_update( terms_to_info.keys() ) self._terms_to_async_text_info.update( terms_to_info ) for ( term, info ) in terms_to_info.items(): # ok in the time since this happened, we may have actually changed the term object, so let's cycle to the actual object in use atm! if term in self._terms_to_positional_indices: term = self._positional_indices_to_terms[ self._terms_to_positional_indices[ term ] ] ( sort_info_changed, num_rows_changed ) = self._ApplyAsyncInfoToTerm( term, info ) if sort_info_changed: any_sort_info_changed = True if num_rows_changed: any_num_rows_changed = True if any_sort_info_changed: self._Sort() # this does regentermstoindices elif any_num_rows_changed: self._RegenTermsToIndices() self._DataHasChanged() return ClientGUIAsync.AsyncQtUpdater( self, loading_callable, work_callable, publish_callable ) def _InitialiseAsyncTextInfoUpdaterWorkCallable( self ): async_lock = self._async_text_info_lock currently_fetching = self._currently_fetching_async_text_info_terms pending = self._pending_async_text_info_terms def work_callable(): with async_lock: to_lookup = set( pending ) pending.clear() currently_fetching.update( to_lookup ) terms_to_info = { term : None for term in to_lookup } return terms_to_info return work_callable def _LogicalIndexIsSelected( self, logical_index ): try: term = self._GetTermFromLogicalIndex( logical_index ) except HydrusExceptions.DataMissing: return False return term in self._selected_terms def _Redraw( self, painter ): painter.setBackground( QG.QBrush( self._background_colour ) ) painter.eraseRect( painter.viewport() ) if len( self._ordered_terms ) == 0: return # text_height = self.fontMetrics().height() visible_rect = QP.ScrollAreaVisibleRect( self ) visible_rect_y = visible_rect.y() visible_rect_width = visible_rect.width() visible_rect_height = visible_rect.height() first_visible_positional_index = max( 0, visible_rect_y // text_height ) last_visible_positional_index = ( visible_rect_y + visible_rect_height ) // text_height if ( visible_rect_y + visible_rect_height ) % text_height != 0: last_visible_positional_index += 1 last_visible_positional_index = max( 0, min( last_visible_positional_index, self._total_positional_rows - 1 ) ) # ( first_visible_logical_index, first_visible_positional_index ) = self._GetLogicalIndicesFromPositionalIndex( first_visible_positional_index ) ( last_visible_logical_index, last_visible_positional_index ) = self._GetLogicalIndicesFromPositionalIndex( last_visible_positional_index ) # some crazy situation with ultra laggy sessions where we are rendering a zero or negative size list or something if first_visible_logical_index is None or last_visible_logical_index is None: return current_visible_index = first_visible_positional_index for logical_index in range( first_visible_logical_index, last_visible_logical_index + 1 ): term = self._GetTermFromLogicalIndex( logical_index ) rows_of_texts_and_colours = self._GetRowsOfTextsAndColours( term ) for texts_and_colours in rows_of_texts_and_colours: x_start = self.TEXT_X_PADDING y_top = current_visible_index * text_height for ( text, ( r, g, b ) ) in texts_and_colours: text_colour = QG.QColor( r, g, b ) if term in self._selected_terms: painter.setBrush( QG.QBrush( text_colour ) ) painter.setPen( QC.Qt.NoPen ) if x_start == self.TEXT_X_PADDING: background_colour_x = 0 else: background_colour_x = x_start painter.drawRect( background_colour_x, y_top, visible_rect_width, text_height ) text_colour = self._background_colour painter.setPen( QG.QPen( text_colour ) ) ( this_text_size, text ) = ClientGUIFunctions.GetTextSizeFromPainter( painter, text ) this_text_width = this_text_size.width() this_text_height = this_text_size.height() painter.drawText( QC.QRectF( x_start, y_top, this_text_width, this_text_height ), text ) x_start += this_text_width current_visible_index += 1 def _RegenTermsToIndices( self ): self._terms_to_logical_indices = {} self._terms_to_positional_indices = {} self._positional_indices_to_terms = {} self._total_positional_rows = 0 for ( logical_index, term ) in enumerate( self._ordered_terms ): self._terms_to_logical_indices[ term ] = logical_index self._terms_to_positional_indices[ term ] = self._total_positional_rows self._positional_indices_to_terms[ self._total_positional_rows ] = term self._total_positional_rows += term.GetRowCount( self._child_rows_allowed ) def _RemoveSelectedTerms( self ): self._RemoveTerms( list( self._selected_terms ) ) def _RemoveTerms( self, terms ): removable_terms = { term for term in terms if term in self._terms_to_logical_indices } if len( removable_terms ) == 0: return for term in removable_terms: self._ordered_terms.remove( term ) self._selected_terms.difference_update( removable_terms ) self._RegenTermsToIndices() self._last_hit_logical_index = None def _Select( self, index ): try: term = self._GetTermFromLogicalIndex( index ) self._selected_terms.add( term ) except HydrusExceptions.DataMissing: pass def _SelectAll( self ): self._selected_terms = set( self._terms_to_logical_indices.keys() ) def _SetVirtualSize( self ): self.setWidgetResizable( True ) my_size = self.widget().size() text_height = self.fontMetrics().height() ideal_virtual_size = QC.QSize( my_size.width(), text_height * self._total_positional_rows ) if ideal_virtual_size != my_size: self.widget().setMinimumSize( ideal_virtual_size ) def _Sort( self ): self._ordered_terms.sort() self._RegenTermsToIndices() def _StartAsyncTextInfoLookup( self, term ): with self._async_text_info_lock: if term in self._terms_to_async_text_info: info = self._terms_to_async_text_info[ term ] self._ApplyAsyncInfoToTerm( term, info ) elif term not in self._currently_fetching_async_text_info_terms: self._pending_async_text_info_terms.add( term ) self._async_text_info_updater.update() def _GetAsyncTextInfoLookupCallable( self ): return lambda terms: {} def keyPressEvent( self, event ): shift = event.modifiers() & QC.Qt.ShiftModifier ctrl = event.modifiers() & QC.Qt.ControlModifier key_code = event.key() if self.hasFocus() and key_code in ClientGUIShortcuts.DELETE_KEYS_QT: self._DeleteActivate() elif key_code in ( QC.Qt.Key_Enter, QC.Qt.Key_Return ): self._ActivateFromKeyboard( shift ) else: if ctrl and key_code in ( ord( 'A' ), ord( 'a' ) ): self._SelectAll() self.widget().update() else: hit_logical_index = None if len( self._ordered_terms ) > 1: roll_up = False roll_down = False if key_code in ( QC.Qt.Key_Home, ): hit_logical_index = 0 elif key_code in ( QC.Qt.Key_End, ): hit_logical_index = len( self._ordered_terms ) - 1 roll_up = True elif self._last_hit_logical_index is not None: if key_code in ( QC.Qt.Key_Up, ): hit_logical_index = ( self._last_hit_logical_index - 1 ) % len( self._ordered_terms ) elif key_code in ( QC.Qt.Key_Down, ): hit_logical_index = ( self._last_hit_logical_index + 1 ) % len( self._ordered_terms ) elif key_code in ( QC.Qt.Key_PageUp, QC.Qt.Key_PageDown ): last_hit_positional_index = self._GetPositionalIndexFromLogicalIndex( self._last_hit_logical_index ) if key_code == QC.Qt.Key_PageUp: hit_positional_index = max( 0, last_hit_positional_index - self._num_rows_per_page ) else: hit_positional_index = min( self._total_positional_rows - 1, last_hit_positional_index + self._num_rows_per_page ) ( hit_logical_index, hit_positional_index ) = self._GetLogicalIndicesFromPositionalIndex( hit_positional_index ) if hit_logical_index is None: # don't send to parent, which will do silly scroll window business with arrow key presses event.ignore() else: self._Hit( shift, ctrl, hit_logical_index ) def mouseDoubleClickEvent( self, event ): if event.button() == QC.Qt.LeftButton: shift_down = event.modifiers() & QC.Qt.ShiftModifier action_occurred = self._Activate( shift_down ) if action_occurred: self.mouseActivationOccurred.emit() else: QW.QScrollArea.mouseDoubleClickEvent( self, event ) def mouseMoveEvent( self, event ): is_dragging = event.buttons() & QC.Qt.LeftButton if is_dragging: logical_index = self._GetLogicalIndexUnderMouse( event ) if self._last_drag_start_logical_index is None: self._last_drag_start_logical_index = logical_index elif logical_index != self._last_drag_start_logical_index: ctrl = event.modifiers() & QC.Qt.ControlModifier if not self._drag_started: self._Hit( True, ctrl, self._last_drag_start_logical_index ) self._drag_started = True self._Hit( True, ctrl, logical_index ) else: event.ignore() def mouseReleaseEvent( self, event ): self._last_drag_start_logical_index = None self._drag_started = False event.ignore() def EventMouseSelect( self, event ): self._HandleClick( event ) return True # was: event.ignore() class _InnerWidget( QW.QWidget ): def __init__( self, parent ): QW.QWidget.__init__( self, parent ) self._parent = parent def paintEvent( self, event ): painter = QG.QPainter( self ) self._parent._Redraw( painter ) def resizeEvent( self, event ): text_height = self.fontMetrics().height() visible_rect = QP.ScrollAreaVisibleRect( self ) self.verticalScrollBar().setSingleStep( text_height ) visible_rect_height = visible_rect.height() self._num_rows_per_page = visible_rect_height // text_height self._SetVirtualSize() self.widget().update() def GetIdealHeight( self ): text_height = self.fontMetrics().height() return text_height * self._total_positional_rows + 20 def HasValues( self ): return len( self._ordered_terms ) > 0 def minimumSizeHint( self ): size_hint = QW.QScrollArea.minimumSizeHint( self ) text_height = self.fontMetrics().height() minimum_height = self._minimum_height_num_chars * text_height + ( self.frameWidth() * 2 ) size_hint.setHeight( minimum_height ) return size_hint def MoveSelectionDown( self ): if len( self._ordered_terms ) > 1 and self._last_hit_logical_index is not None: logical_index = ( self._last_hit_logical_index + 1 ) % len( self._ordered_terms ) self._Hit( False, False, logical_index ) def MoveSelectionUp( self ): if len( self._ordered_terms ) > 1 and self._last_hit_logical_index is not None: logical_index = ( self._last_hit_logical_index - 1 ) % len( self._ordered_terms ) self._Hit( False, False, logical_index ) def SelectTopItem( self ): if len( self._ordered_terms ) > 0: if len( self._selected_terms ) == 1 and self._LogicalIndexIsSelected( 0 ): return self._DeselectAll() self._Hit( False, False, 0 ) self.widget().update() def SetChildRowsAllowed( self, value: bool ): if self._terms_may_have_child_rows and self._child_rows_allowed != value: self._child_rows_allowed = value self._RegenTermsToIndices() self._SetVirtualSize() self.widget().update() def SetMinimumHeightNumChars( self, minimum_height_num_chars ): self._minimum_height_num_chars = minimum_height_num_chars def sizeHint( self ): size_hint = QW.QScrollArea.sizeHint( self ) text_height = self.fontMetrics().height() ideal_height = self._height_num_chars * text_height + ( self.frameWidth() * 2 ) size_hint.setHeight( ideal_height ) return size_hint COPY_ALL_TAGS = 0 COPY_ALL_TAGS_WITH_COUNTS = 1 COPY_SELECTED_TAGS = 2 COPY_SELECTED_TAGS_WITH_COUNTS = 3 COPY_SELECTED_SUBTAGS = 4 COPY_SELECTED_SUBTAGS_WITH_COUNTS = 5 COPY_ALL_SUBTAGS = 6 COPY_ALL_SUBTAGS_WITH_COUNTS = 7 class ListBoxTags( ListBox ): can_spawn_new_windows = True def __init__( self, parent, *args, tag_display_type: int = ClientTags.TAG_DISPLAY_STORAGE, **kwargs ): self._tag_display_type = tag_display_type child_rows_allowed = HG.client_controller.new_options.GetBoolean( 'expand_parents_on_storage_taglists' ) terms_may_have_child_rows = self._tag_display_type == ClientTags.TAG_DISPLAY_STORAGE ListBox.__init__( self, parent, child_rows_allowed, terms_may_have_child_rows, *args, **kwargs ) self._render_for_user = not self._tag_display_type == ClientTags.TAG_DISPLAY_STORAGE self._sibling_decoration_allowed = self._tag_display_type == ClientTags.TAG_DISPLAY_STORAGE self._page_key = None # placeholder. if a subclass sets this, it changes menu behaviour to allow 'select this tag' menu pubsubs self._UpdateBackgroundColour() self._widget_event_filter.EVT_MIDDLE_DOWN( self.EventMouseMiddleClick ) HG.client_controller.sub( self, 'ForceTagRecalc', 'refresh_all_tag_presentation_gui' ) HG.client_controller.sub( self, '_UpdateBackgroundColour', 'notify_new_colourset' ) def _GetCopyableTagStrings( self, command ): only_selected = command in ( COPY_SELECTED_TAGS, COPY_SELECTED_TAGS_WITH_COUNTS, COPY_SELECTED_SUBTAGS, COPY_SELECTED_SUBTAGS_WITH_COUNTS ) with_counts = command in ( COPY_ALL_TAGS_WITH_COUNTS, COPY_ALL_SUBTAGS_WITH_COUNTS, COPY_SELECTED_TAGS_WITH_COUNTS, COPY_SELECTED_SUBTAGS_WITH_COUNTS ) only_subtags = command in ( COPY_ALL_SUBTAGS, COPY_ALL_SUBTAGS_WITH_COUNTS, COPY_SELECTED_SUBTAGS, COPY_SELECTED_SUBTAGS_WITH_COUNTS ) if only_selected: if len( self._selected_terms ) > 1: # keep order terms = [ term for term in self._ordered_terms if term in self._selected_terms ] else: # nice and fast terms = self._selected_terms else: terms = self._ordered_terms copyable_tag_strings = [ term.GetCopyableText( with_counts = with_counts ) for term in terms ] if only_subtags: copyable_tag_strings = [ HydrusTags.SplitTag( tag_string )[1] for tag_string in copyable_tag_strings ] if not with_counts: copyable_tag_strings = HydrusData.DedupeList( copyable_tag_strings ) return copyable_tag_strings def _GetCurrentFileServiceKey( self ): return CC.LOCAL_FILE_SERVICE_KEY def _GetCurrentPagePredicates( self ) -> typing.Set[ ClientSearch.Predicate ]: return set() def _GetNamespaceColours( self ): return HC.options[ 'namespace_colours' ] def _CanProvideCurrentPagePredicates( self ): return False def _GetRowsOfTextsAndColours( self, term: ClientGUIListBoxesData.ListBoxItem ): namespace_colours = self._GetNamespaceColours() rows_of_texts_and_namespaces = term.GetRowsOfPresentationTextsWithNamespaces( self._render_for_user, self._sibling_decoration_allowed, self._child_rows_allowed ) rows_of_texts_and_colours = [] for texts_and_namespaces in rows_of_texts_and_namespaces: texts_and_colours = [] for ( text, namespace ) in texts_and_namespaces: if namespace in namespace_colours: rgb = namespace_colours[ namespace ] else: rgb = namespace_colours[ None ] texts_and_colours.append( ( text, rgb ) ) rows_of_texts_and_colours.append( texts_and_colours ) return rows_of_texts_and_colours def _HasCounts( self ): return False def _NewSearchPages( self, pages_of_predicates ): activate_window = HG.client_controller.new_options.GetBoolean( 'activate_window_on_tag_search_page_activation' ) for predicates in pages_of_predicates: predicates = ClientGUISearch.FleshOutPredicates( self, predicates ) if len( predicates ) == 0: break s = sorted( ( predicate.ToString() for predicate in predicates ) ) page_name = ', '.join( s ) file_service_key = self._GetCurrentFileServiceKey() HG.client_controller.pub( 'new_page_query', file_service_key, initial_predicates = predicates, page_name = page_name, activate_window = activate_window ) activate_window = False def _ProcessMenuCopyEvent( self, command ): texts = self._GetCopyableTagStrings( command ) if len( texts ) > 0: text = os.linesep.join( texts ) HG.client_controller.pub( 'clipboard', 'text', text ) def _ProcessMenuPredicateEvent( self, command ): pass def _ProcessMenuTagEvent( self, command ): tags = self._GetTagsFromTerms( self._selected_terms ) tags = [ tag for tag in tags if tag is not None ] if command in ( 'hide', 'hide_namespace' ): if len( tags ) == 1: ( tag, ) = tags if command == 'hide': message = 'Hide "{}" from here?'.format( tag ) from hydrus.client.gui import ClientGUIDialogsQuick result = ClientGUIDialogsQuick.GetYesNo( self, message ) if result != QW.QDialog.Accepted: return HG.client_controller.tag_display_manager.HideTag( self._tag_display_type, CC.COMBINED_TAG_SERVICE_KEY, tag ) elif command == 'hide_namespace': ( namespace, subtag ) = HydrusTags.SplitTag( tag ) if namespace == '': insert = 'unnamespaced' else: insert = '"{}"'.format( namespace ) message = 'Hide {} tags from here?'.format( insert ) from hydrus.client.gui import ClientGUIDialogsQuick result = ClientGUIDialogsQuick.GetYesNo( self, message ) if result != QW.QDialog.Accepted: return if namespace != '': namespace += ':' HG.client_controller.tag_display_manager.HideTag( self._tag_display_type, CC.COMBINED_TAG_SERVICE_KEY, namespace ) HG.client_controller.pub( 'notify_new_tag_display_rules' ) else: from hydrus.client.gui import ClientGUITags if command == 'parent': title = 'manage tag parents' elif command == 'sibling': title = 'manage tag siblings' from hydrus.client.gui import ClientGUITopLevelWindowsPanels with ClientGUITopLevelWindowsPanels.DialogManage( self, title ) as dlg: if command == 'parent': panel = ClientGUITags.ManageTagParents( dlg, tags ) elif command == 'sibling': panel = ClientGUITags.ManageTagSiblings( dlg, tags ) dlg.SetPanel( panel ) dlg.exec() def _UpdateBackgroundColour( self ): new_options = HG.client_controller.new_options self._background_colour = new_options.GetColour( CC.COLOUR_TAGS_BOX ) self.widget().update() def EventMouseMiddleClick( self, event ): self._HandleClick( event ) if self.can_spawn_new_windows: ( predicates, or_predicate, inverse_predicates ) = self._GetSelectedPredicatesAndInverseCopies() if len( predicates ) > 0: shift_down = event.modifiers() & QC.Qt.ShiftModifier if shift_down and or_predicate is not None: predicates = ( or_predicate, ) self._NewSearchPages( [ predicates ] ) def contextMenuEvent( self, event ): if event.reason() == QG.QContextMenuEvent.Keyboard: self.ShowMenu() def mouseReleaseEvent( self, event ): if event.button() != QC.Qt.RightButton: ListBox.mouseReleaseEvent( self, event ) return self.ShowMenu() def ShowMenu( self ): sub_selection_string = None if len( self._ordered_terms ) > 0: selected_actual_tags = self._GetTagsFromTerms( self._selected_terms ) menu = QW.QMenu() if self._terms_may_have_child_rows: add_it = True if self._child_rows_allowed: if len( self._ordered_terms ) == self._total_positional_rows: # no parents to hide! add_it = False message = 'hide parent rows' else: message = 'show parent rows' if add_it: ClientGUIMenus.AppendMenuItem( menu, message, 'Show/hide parents.', self.SetChildRowsAllowed, not self._child_rows_allowed ) ClientGUIMenus.AppendSeparator( menu ) copy_menu = QW.QMenu( menu ) selected_copyable_tag_strings = self._GetCopyableTagStrings( COPY_SELECTED_TAGS ) selected_copyable_subtag_strings = self._GetCopyableTagStrings( COPY_SELECTED_SUBTAGS ) if len( selected_copyable_tag_strings ) == 1: ( selection_string, ) = selected_copyable_tag_strings else: selection_string = '{} selected'.format( HydrusData.ToHumanInt( len( selected_copyable_tag_strings ) ) ) if len( selected_copyable_tag_strings ) > 0: ClientGUIMenus.AppendMenuItem( copy_menu, selection_string, 'Copy the selected tags to your clipboard.', self._ProcessMenuCopyEvent, COPY_SELECTED_TAGS ) if len( selected_copyable_subtag_strings ) == 1: # this does a quick test for 'are we selecting a namespaced tags' that also allows for having both 'samus aran' and 'character:samus aran' if set( selected_copyable_subtag_strings ) != set( selected_copyable_tag_strings ): ( sub_selection_string, ) = selected_copyable_subtag_strings ClientGUIMenus.AppendMenuItem( copy_menu, sub_selection_string, 'Copy the selected subtag to your clipboard.', self._ProcessMenuCopyEvent, COPY_SELECTED_SUBTAGS ) else: sub_selection_string = '{} selected subtags'.format( HydrusData.ToHumanInt( len( selected_copyable_subtag_strings ) ) ) ClientGUIMenus.AppendMenuItem( copy_menu, sub_selection_string, 'Copy the selected subtags to your clipboard.', self._ProcessMenuCopyEvent, COPY_SELECTED_SUBTAGS ) if self._HasCounts(): ClientGUIMenus.AppendSeparator( copy_menu ) ClientGUIMenus.AppendMenuItem( copy_menu, '{} with counts'.format( selection_string ), 'Copy the selected tags, with their counts, to your clipboard.', self._ProcessMenuCopyEvent, COPY_SELECTED_TAGS_WITH_COUNTS ) if sub_selection_string is not None: ClientGUIMenus.AppendMenuItem( copy_menu, '{} with counts'.format( sub_selection_string ), 'Copy the selected subtags, with their counts, to your clipboard.', self._ProcessMenuCopyEvent, COPY_SELECTED_SUBTAGS_WITH_COUNTS ) copy_all_is_appropriate = len( self._ordered_terms ) > len( self._selected_terms ) if copy_all_is_appropriate: ClientGUIMenus.AppendSeparator( copy_menu ) ClientGUIMenus.AppendMenuItem( copy_menu, 'all tags', 'Copy all the tags in this list to your clipboard.', self._ProcessMenuCopyEvent, COPY_ALL_TAGS ) ClientGUIMenus.AppendMenuItem( copy_menu, 'all subtags', 'Copy all the subtags in this list to your clipboard.', self._ProcessMenuCopyEvent, COPY_ALL_SUBTAGS ) if self._HasCounts(): ClientGUIMenus.AppendMenuItem( copy_menu, 'all tags with counts', 'Copy all the tags in this list, with their counts, to your clipboard.', self._ProcessMenuCopyEvent, COPY_ALL_TAGS_WITH_COUNTS ) ClientGUIMenus.AppendMenuItem( copy_menu, 'all subtags with counts', 'Copy all the subtags in this list, with their counts, to your clipboard.', self._ProcessMenuCopyEvent, COPY_ALL_SUBTAGS_WITH_COUNTS ) ClientGUIMenus.AppendMenu( menu, copy_menu, 'copy' ) # can_launch_sibling_and_parent_dialogs = len( selected_actual_tags ) > 0 and self.can_spawn_new_windows can_show_siblings_and_parents = len( selected_actual_tags ) == 1 if can_show_siblings_and_parents or can_launch_sibling_and_parent_dialogs: siblings_menu = QW.QMenu( menu ) parents_menu = QW.QMenu( menu ) ClientGUIMenus.AppendMenu( menu, siblings_menu, 'siblings' ) ClientGUIMenus.AppendMenu( menu, parents_menu, 'parents' ) if can_launch_sibling_and_parent_dialogs: if len( selected_actual_tags ) == 1: ( tag, ) = selected_actual_tags text = tag else: text = 'selection' ClientGUIMenus.AppendMenuItem( siblings_menu, 'add siblings to ' + text, 'Add a sibling to this tag.', self._ProcessMenuTagEvent, 'sibling' ) ClientGUIMenus.AppendMenuItem( parents_menu, 'add parents to ' + text, 'Add a parent to this tag.', self._ProcessMenuTagEvent, 'parent' ) if can_show_siblings_and_parents: ( selected_tag, ) = selected_actual_tags def sp_work_callable(): selected_tag_to_service_keys_to_siblings_and_parents = HG.client_controller.Read( 'tag_siblings_and_parents_lookup', ( selected_tag, ) ) service_keys_to_siblings_and_parents = selected_tag_to_service_keys_to_siblings_and_parents[ selected_tag ] return service_keys_to_siblings_and_parents def sp_publish_callable( service_keys_to_siblings_and_parents ): service_keys_in_order = HG.client_controller.services_manager.GetServiceKeys( HC.REAL_TAG_SERVICES ) all_siblings = set() siblings_to_service_keys = collections.defaultdict( set ) parents_to_service_keys = collections.defaultdict( set ) children_to_service_keys = collections.defaultdict( set ) ideals_to_service_keys = collections.defaultdict( set ) for ( service_key, ( sibling_chain_members, ideal_tag, descendants, ancestors ) ) in service_keys_to_siblings_and_parents.items(): all_siblings.update( sibling_chain_members ) for sibling in sibling_chain_members: if sibling == ideal_tag: ideals_to_service_keys[ ideal_tag ].add( service_key ) continue if sibling == selected_tag: # don't care about the selected tag unless it is ideal continue siblings_to_service_keys[ sibling ].add( service_key ) for ancestor in ancestors: parents_to_service_keys[ ancestor ].add( service_key ) for descendant in descendants: children_to_service_keys[ descendant ].add( service_key ) all_siblings.discard( selected_tag ) num_siblings = len( all_siblings ) num_parents = len( parents_to_service_keys ) num_children = len( children_to_service_keys ) service_keys_to_service_names = { service_key : HG.client_controller.services_manager.GetName( service_key ) for service_key in service_keys_in_order } ALL_SERVICES_LABEL = 'all services' def convert_service_keys_to_name_string( s_ks ): if len( s_ks ) == len( service_keys_in_order ): return ALL_SERVICES_LABEL return ', '.join( ( service_keys_to_service_names[ service_key ] for service_key in service_keys_in_order if service_key in s_ks ) ) def group_and_sort_siblings_to_service_keys( t_to_s_ks ): # convert "tag -> everywhere I am" to "sorted groups of locations -> what we have in common, also sorted" service_key_groups_to_tags = collections.defaultdict( list ) for ( t, s_ks ) in t_to_s_ks.items(): service_key_groups_to_tags[ tuple( s_ks ) ].append( t ) tag_sort = ClientTagSorting.TagSort.STATICGetTextASCDefault() for t_list in service_key_groups_to_tags.values(): ClientTagSorting.SortTags( tag_sort, t_list ) service_key_groups = sorted( service_key_groups_to_tags.keys(), key = lambda s_k_g: ( -len( s_k_g ), convert_service_keys_to_name_string( s_k_g ) ) ) service_key_group_names_and_tags = [ ( convert_service_keys_to_name_string( s_k_g ), service_key_groups_to_tags[ s_k_g ] ) for s_k_g in service_key_groups ] return service_key_group_names_and_tags def group_and_sort_parents_to_service_keys( p_to_s_ks, c_to_s_ks ): # convert two lots of "tag -> everywhere I am" to "sorted groups of locations -> what we have in common, also sorted" service_key_groups_to_tags = collections.defaultdict( lambda: ( [], [] ) ) for ( p, s_ks ) in p_to_s_ks.items(): service_key_groups_to_tags[ tuple( s_ks ) ][0].append( p ) for ( c, s_ks ) in c_to_s_ks.items(): service_key_groups_to_tags[ tuple( s_ks ) ][1].append( c ) tag_sort = ClientTagSorting.TagSort.STATICGetTextASCDefault() for ( t_list_1, t_list_2 ) in service_key_groups_to_tags.values(): ClientTagSorting.SortTags( tag_sort, t_list_1 ) ClientTagSorting.SortTags( tag_sort, t_list_2 ) service_key_groups = sorted( service_key_groups_to_tags.keys(), key = lambda s_k_g: ( -len( s_k_g ), convert_service_keys_to_name_string( s_k_g ) ) ) service_key_group_names_and_tags = [ ( convert_service_keys_to_name_string( s_k_g ), service_key_groups_to_tags[ s_k_g ] ) for s_k_g in service_key_groups ] return service_key_group_names_and_tags if num_siblings == 0: siblings_menu.setTitle( 'no siblings' ) else: siblings_menu.setTitle( '{} siblings'.format( HydrusData.ToHumanInt( num_siblings ) ) ) # ClientGUIMenus.AppendSeparator( siblings_menu ) ideals = sorted( ideals_to_service_keys.keys(), key = HydrusTags.ConvertTagToSortable ) for ideal in ideals: if ideal == selected_tag: continue ideal_label = 'ideal is "{}" on: {}'.format( ideal, convert_service_keys_to_name_string( ideals_to_service_keys[ ideal ] ) ) ClientGUIMenus.AppendMenuItem( siblings_menu, ideal_label, ideal_label, HG.client_controller.pub, 'clipboard', 'text', ideal_tag ) # for ( s_k_name, tags ) in group_and_sort_siblings_to_service_keys( siblings_to_service_keys ): ClientGUIMenus.AppendSeparator( siblings_menu ) if s_k_name != ALL_SERVICES_LABEL: ClientGUIMenus.AppendMenuLabel( siblings_menu, '--{}--'.format( s_k_name ) ) for tag in tags: ClientGUIMenus.AppendMenuLabel( siblings_menu, tag ) # if num_parents + num_children == 0: parents_menu.setTitle( 'no parents' ) else: parents_menu.setTitle( '{} parents, {} children'.format( HydrusData.ToHumanInt( num_parents ), HydrusData.ToHumanInt( num_children ) ) ) ClientGUIMenus.AppendSeparator( parents_menu ) for ( s_k_name, ( parents, children ) ) in group_and_sort_parents_to_service_keys( parents_to_service_keys, children_to_service_keys ): ClientGUIMenus.AppendSeparator( parents_menu ) if s_k_name != ALL_SERVICES_LABEL: ClientGUIMenus.AppendMenuLabel( parents_menu, '--{}--'.format( s_k_name ) ) for parent in parents: parent_label = 'parent: {}'.format( parent ) ClientGUIMenus.AppendMenuItem( parents_menu, parent_label, parent_label, HG.client_controller.pub, 'clipboard', 'text', parent ) for child in children: child_label = 'child: {}'.format( child ) ClientGUIMenus.AppendMenuItem( parents_menu, child_label, child_label, HG.client_controller.pub, 'clipboard', 'text', child ) async_job = ClientGUIAsync.AsyncQtJob( menu, sp_work_callable, sp_publish_callable ) async_job.start() if len( self._selected_terms ) > 0: ClientGUIMenus.AppendSeparator( menu ) ( predicates, or_predicate, inverse_predicates ) = self._GetSelectedPredicatesAndInverseCopies() if len( predicates ) > 0: if self.can_spawn_new_windows or self._CanProvideCurrentPagePredicates(): search_menu = QW.QMenu( menu ) ClientGUIMenus.AppendMenu( menu, search_menu, 'search' ) if self.can_spawn_new_windows: ClientGUIMenus.AppendMenuItem( search_menu, 'open a new search page for ' + selection_string, 'Open a new search page starting with the selected predicates.', self._NewSearchPages, [ predicates ] ) if or_predicate is not None: ClientGUIMenus.AppendMenuItem( search_menu, 'open a new OR search page for ' + selection_string, 'Open a new search page starting with the selected merged as an OR search predicate.', self._NewSearchPages, [ ( or_predicate, ) ] ) if len( predicates ) > 1: for_each_predicates = [ ( predicate, ) for predicate in predicates ] ClientGUIMenus.AppendMenuItem( search_menu, 'open new search pages for each in selection', 'Open one new search page for each selected predicate.', self._NewSearchPages, for_each_predicates ) ClientGUIMenus.AppendSeparator( search_menu ) if self._CanProvideCurrentPagePredicates(): current_predicates = self._GetCurrentPagePredicates() predicates = set( predicates ) inverse_predicates = set( inverse_predicates ) if len( predicates ) == 1: ( pred, ) = predicates predicates_selection_string = pred.ToString( with_count = False ) else: predicates_selection_string = 'selected' some_selected_in_current = HydrusData.SetsIntersect( predicates, current_predicates ) if some_selected_in_current: ClientGUIMenus.AppendMenuItem( search_menu, 'remove {} from current search'.format( predicates_selection_string ), 'Remove the selected predicates from the current search.', self._ProcessMenuPredicateEvent, 'remove_predicates' ) some_selected_not_in_current = len( predicates.intersection( current_predicates ) ) < len( predicates ) if some_selected_not_in_current: ClientGUIMenus.AppendMenuItem( search_menu, 'add {} to current search'.format( predicates_selection_string ), 'Add the selected predicates to the current search.', self._ProcessMenuPredicateEvent, 'add_predicates' ) if or_predicate is not None: ClientGUIMenus.AppendMenuItem( search_menu, 'add an OR of {} to current search'.format( predicates_selection_string ), 'Add the selected predicates as an OR predicate to the current search.', self._ProcessMenuPredicateEvent, 'add_or_predicate' ) some_selected_are_excluded_explicitly = HydrusData.SetsIntersect( inverse_predicates, current_predicates ) if some_selected_are_excluded_explicitly: ClientGUIMenus.AppendMenuItem( search_menu, 'permit {} for current search'.format( predicates_selection_string ), 'Stop disallowing the selected predicates from the current search.', self._ProcessMenuPredicateEvent, 'remove_inverse_predicates' ) some_selected_are_not_excluded_explicitly = len( inverse_predicates.intersection( current_predicates ) ) < len( inverse_predicates ) if some_selected_are_not_excluded_explicitly: ClientGUIMenus.AppendMenuItem( search_menu, 'exclude {} from current search'.format( predicates_selection_string ), 'Disallow the selected predicates for the current search.', self._ProcessMenuPredicateEvent, 'add_inverse_predicates' ) self._AddEditMenu( menu ) if len( selected_actual_tags ) > 0 and self._page_key is not None: select_menu = QW.QMenu( menu ) tags_sorted_to_show_on_menu = HydrusTags.SortNumericTags( selected_actual_tags ) tags_sorted_to_show_on_menu_string = ', '.join( tags_sorted_to_show_on_menu ) while len( tags_sorted_to_show_on_menu_string ) > 64: if len( tags_sorted_to_show_on_menu ) == 1: tags_sorted_to_show_on_menu_string = '(many/long tags)' else: tags_sorted_to_show_on_menu.pop( -1 ) tags_sorted_to_show_on_menu_string = ', '.join( tags_sorted_to_show_on_menu + [ '\u2026' ] ) if len( selected_actual_tags ) == 1: label = 'files with "{}"'.format( tags_sorted_to_show_on_menu_string ) else: label = 'files with all of "{}"'.format( tags_sorted_to_show_on_menu_string ) ClientGUIMenus.AppendMenuItem( select_menu, label, 'Select the files with these tags.', HG.client_controller.pub, 'select_files_with_tags', self._page_key, 'AND', set( selected_actual_tags ) ) if len( selected_actual_tags ) > 1: label = 'files with any of "{}"'.format( tags_sorted_to_show_on_menu_string ) ClientGUIMenus.AppendMenuItem( select_menu, label, 'Select the files with any of these tags.', HG.client_controller.pub, 'select_files_with_tags', self._page_key, 'OR', set( selected_actual_tags ) ) ClientGUIMenus.AppendMenu( menu, select_menu, 'select' ) if len( selected_actual_tags ) == 1: ( selected_tag, ) = selected_actual_tags if self._tag_display_type in ( ClientTags.TAG_DISPLAY_SINGLE_MEDIA, ClientTags.TAG_DISPLAY_SELECTION_LIST ): ClientGUIMenus.AppendSeparator( menu ) ( namespace, subtag ) = HydrusTags.SplitTag( selected_tag ) hide_menu = QW.QMenu( menu ) ClientGUIMenus.AppendMenuItem( hide_menu, '"{}" tags from here'.format( ClientTags.RenderNamespaceForUser( namespace ) ), 'Hide this namespace from view in future.', self._ProcessMenuTagEvent, 'hide_namespace' ) ClientGUIMenus.AppendMenuItem( hide_menu, '"{}" from here'.format( selected_tag ), 'Hide this tag from view in future.', self._ProcessMenuTagEvent, 'hide' ) ClientGUIMenus.AppendMenu( menu, hide_menu, 'hide' ) def set_favourite_tags( tag ): favourite_tags = list( HG.client_controller.new_options.GetStringList( 'favourite_tags' ) ) if selected_tag in favourite_tags: favourite_tags.remove( tag ) else: favourite_tags.append( tag ) HG.client_controller.new_options.SetStringList( 'favourite_tags', favourite_tags ) HG.client_controller.pub( 'notify_new_favourite_tags' ) favourite_tags = list( HG.client_controller.new_options.GetStringList( 'favourite_tags' ) ) if selected_tag in favourite_tags: label = 'remove "{}" from favourites'.format( selected_tag ) description = 'Remove this tag from your favourites' else: label = 'add "{}" to favourites'.format( selected_tag ) description = 'Add this tag from your favourites' favourites_menu = QW.QMenu( menu ) ClientGUIMenus.AppendMenuItem( favourites_menu, label, description, set_favourite_tags, selected_tag ) m = ClientGUIMenus.AppendMenu( menu, favourites_menu, 'favourites' ) CGC.core().PopupMenu( self, menu ) def ForceTagRecalc( self ): pass class ListBoxTagsPredicates( ListBoxTags ): def __init__( self, *args, tag_display_type = ClientTags.TAG_DISPLAY_ACTUAL, **kwargs ): ListBoxTags.__init__( self, *args, tag_display_type = tag_display_type, **kwargs ) def _GenerateTermFromPredicate( self, predicate: ClientSearch.Predicate ) -> ClientGUIListBoxesData.ListBoxItemPredicate: return ClientGUIListBoxesData.ListBoxItemPredicate( predicate ) def _GetMutuallyExclusivePredicates( self, predicate ): all_predicates = self._GetPredicatesFromTerms( self._ordered_terms ) m_e_predicates = { existing_predicate for existing_predicate in all_predicates if existing_predicate.IsMutuallyExclusive( predicate ) } return m_e_predicates def _HasCounts( self ): return True def GetPredicates( self ) -> typing.Set[ ClientSearch.Predicate ]: return set( self._GetPredicatesFromTerms( self._ordered_terms ) ) def SetPredicates( self, predicates ): selected_terms = set( self._selected_terms ) self._Clear() terms = [ self._GenerateTermFromPredicate( predicate ) for predicate in predicates ] self._AppendTerms( terms ) for term in selected_terms: if term in self._terms_to_logical_indices: self._selected_terms.add( term ) self._HitFirstSelectedItem() self._DataHasChanged() class ListBoxTagsColourOptions( ListBoxTags ): PROTECTED_TERMS = ( None, '' ) can_spawn_new_windows = False def __init__( self, parent, initial_namespace_colours ): ListBoxTags.__init__( self, parent ) terms = [] for ( namespace, colour ) in initial_namespace_colours.items(): colour = tuple( colour ) # tuple to convert from list, for oooold users who have list colours term = self._GenerateTermFromNamespaceAndColour( namespace, colour ) terms.append( term ) self._AppendTerms( terms ) self._Sort() self._DataHasChanged() def _Activate( self, shift_down ): deletable_terms = [ term for term in self._selected_terms if term.GetNamespace() not in self.PROTECTED_TERMS ] if len( deletable_terms ) > 0: from hydrus.client.gui import ClientGUIDialogsQuick result = ClientGUIDialogsQuick.GetYesNo( self, 'Delete all selected colours?' ) if result == QW.QDialog.Accepted: self._RemoveTerms( deletable_terms ) self._DataHasChanged() return True return False def _DeleteActivate( self ): shift_down = False self._Activate( shift_down ) def _GenerateTermFromNamespaceAndColour( self, namespace, colour ) -> ClientGUIListBoxesData.ListBoxItemNamespaceColour: return ClientGUIListBoxesData.ListBoxItemNamespaceColour( namespace, colour ) def _GetNamespaceColours( self ): return dict( ( term.GetNamespaceAndColour() for term in self._ordered_terms ) ) def SetNamespaceColour( self, namespace, colour: QG.QColor ): colour_tuple = ( colour.red(), colour.green(), colour.blue() ) for term in self._ordered_terms: if term.GetNamespace() == namespace: self._RemoveTerms( ( term, ) ) break term = self._GenerateTermFromNamespaceAndColour( namespace, colour_tuple ) self._AppendTerms( ( term, ) ) self._Sort() self._DataHasChanged() def GetNamespaceColours( self ): return self._GetNamespaceColours() def GetSelectedNamespaceColours( self ): namespace_colours = dict( ( term.GetNamespaceAndColour() for term in self._selected_terms ) ) return namespace_colours class ListBoxTagsFilter( ListBoxTags ): tagsRemoved = QC.Signal( list ) def __init__( self, parent ): ListBoxTags.__init__( self, parent ) def _Activate( self, shift_down ) -> bool: if len( self._selected_terms ) > 0: tag_slices = [ term.GetTagSlice() for term in self._selected_terms ] self._RemoveSelectedTerms() self.tagsRemoved.emit( tag_slices ) self._DataHasChanged() return True return False def _GenerateTermFromTagSlice( self, tag_slice ) -> ClientGUIListBoxesData.ListBoxItemTagSlice: return ClientGUIListBoxesData.ListBoxItemTagSlice( tag_slice ) def AddTagSlices( self, tag_slices ): terms = [ self._GenerateTermFromTagSlice( tag_slice ) for tag_slice in tag_slices ] self._AppendTerms( terms ) self._Sort() self._DataHasChanged() def EnterTagSlices( self, tag_slices ): for tag_slice in tag_slices: term = self._GenerateTermFromTagSlice( tag_slice ) if term in self._terms_to_logical_indices: self._RemoveTerms( ( term, ) ) else: self._AppendTerms( ( term, ) ) self._Sort() self._DataHasChanged() def GetSelectedTagSlices( self ): return [ term.GetTagSlice() for term in self._selected_terms ] def GetTagSlices( self ): return [ term.GetTagSlice() for term in self._ordered_terms ] def RemoveTagSlices( self, tag_slices ): removee_terms = [ self._GenerateTermFromTagSlice( tag_slice ) for tag_slice in tag_slices ] self._RemoveTerms( removee_terms ) self._Sort() self._DataHasChanged() def SetTagSlices( self, tag_slices ): self._Clear() self.AddTagSlices( tag_slices ) class ListBoxTagsDisplayCapable( ListBoxTags ): def __init__( self, parent, service_key = None, tag_display_type = ClientTags.TAG_DISPLAY_ACTUAL, **kwargs ): if service_key is None: service_key = CC.COMBINED_TAG_SERVICE_KEY self._service_key = service_key has_async_text_info = tag_display_type == ClientTags.TAG_DISPLAY_STORAGE ListBoxTags.__init__( self, parent, has_async_text_info = has_async_text_info, tag_display_type = tag_display_type, **kwargs ) def _ApplyAsyncInfoToTerm( self, term, info ) -> typing.Tuple[ bool, bool ]: # this guy comes with the lock if info is None: return ( False, False ) sort_info_changed = False num_rows_changed = False ( ideal, parents ) = info if ideal is not None and ideal != term.GetTag(): term.SetIdealTag( ideal ) sort_info_changed = True if parents is not None: term.SetParents( parents ) num_rows_changed = True return ( sort_info_changed, num_rows_changed ) def _InitialiseAsyncTextInfoUpdaterWorkCallable( self ): if not self._has_async_text_info: return ListBoxTags._InitialiseAsyncTextInfoUpdaterWorkCallable( self ) self._async_text_info_shared_data[ 'service_key' ] = self._service_key async_text_info_shared_data = self._async_text_info_shared_data async_lock = self._async_text_info_lock currently_fetching = self._currently_fetching_async_text_info_terms pending = self._pending_async_text_info_terms def work_callable(): with async_lock: to_lookup = list( pending ) pending.clear() currently_fetching.update( to_lookup ) service_key = async_text_info_shared_data[ 'service_key' ] terms_to_info = { term : None for term in to_lookup } for batch_to_lookup in HydrusData.SplitListIntoChunks( to_lookup, 500 ): tags_to_terms = { term.GetTag() : term for term in batch_to_lookup } tags_to_lookup = set( tags_to_terms.keys() ) db_tags_to_ideals_and_parents = HG.client_controller.Read( 'tag_display_decorators', service_key, tags_to_lookup ) terms_to_info.update( { tags_to_terms[ tag ] : info for ( tag, info ) in db_tags_to_ideals_and_parents.items() } ) return terms_to_info return work_callable def GetSelectedTags( self ): return set( self._GetTagsFromTerms( self._selected_terms ) ) def SetTagServiceKey( self, service_key ): self._service_key = service_key with self._async_text_info_lock: self._async_text_info_shared_data[ 'service_key' ] = self._service_key self._pending_async_text_info_terms.clear() self._currently_fetching_async_text_info_terms.clear() self._terms_to_async_text_info = {} class ListBoxTagsStrings( ListBoxTagsDisplayCapable ): def __init__( self, parent, service_key = None, sort_tags = True, **kwargs ): self._sort_tags = sort_tags ListBoxTagsDisplayCapable.__init__( self, parent, service_key = service_key, **kwargs ) def _GenerateTermFromTag( self, tag: str ) -> ClientGUIListBoxesData.ListBoxItemTextTag: return ClientGUIListBoxesData.ListBoxItemTextTag( tag ) def GetTags( self ): return set( self._GetTagsFromTerms( self._ordered_terms ) ) def SetTags( self, tags ): previously_selected_terms = set( self._selected_terms ) self._Clear() terms_to_add = [ self._GenerateTermFromTag( tag ) for tag in tags ] self._AppendTerms( terms_to_add ) for term in previously_selected_terms: if term in self._terms_to_logical_indices: self._selected_terms.add( term ) self._HitFirstSelectedItem() if self._sort_tags: self._Sort() self._DataHasChanged() class ListBoxTagsStringsAddRemove( ListBoxTagsStrings ): tagsAdded = QC.Signal() tagsRemoved = QC.Signal() def _Activate( self, shift_down ) -> bool: if len( self._selected_terms ) > 0: tags = self._GetTagsFromTerms( self._selected_terms ) self._RemoveSelectedTerms() self._DataHasChanged() self.tagsRemoved.emit() return True return False def _RemoveTags( self, tags ): terms = [ self._GenerateTermFromTag( tag ) for tag in tags ] self._RemoveTerms( terms ) self._DataHasChanged() self.tagsRemoved.emit() def AddTags( self, tags ): terms = [ self._GenerateTermFromTag( tag ) for tag in tags ] self._AppendTerms( terms ) if self._sort_tags: self._Sort() self._DataHasChanged() self.tagsAdded.emit() def Clear( self ): self._Clear() self._DataHasChanged() # doesn't do a removed tags call, this is a different lad def EnterTags( self, tags ): tags_removed = False tags_added = False for tag in tags: term = self._GenerateTermFromTag( tag ) if term in self._terms_to_logical_indices: self._RemoveTerms( ( term, ) ) tags_removed = True else: self._AppendTerms( ( term, ) ) tags_added = True if self._sort_tags: self._Sort() self._DataHasChanged() if tags_added: self.tagsAdded.emit() if tags_removed: self.tagsRemoved.emit() def keyPressEvent( self, event ): ( modifier, key ) = ClientGUIShortcuts.ConvertKeyEventToSimpleTuple( event ) if key in ClientGUIShortcuts.DELETE_KEYS_QT: shift_down = modifier == ClientGUIShortcuts.SHORTCUT_MODIFIER_SHIFT action_occurred = self._Activate( shift_down ) else: ListBoxTagsStrings.keyPressEvent( self, event ) def RemoveTags( self, tags ): self._RemoveTags( tags ) class ListBoxTagsMedia( ListBoxTagsDisplayCapable ): def __init__( self, parent, tag_display_type, service_key = None, include_counts = True ): if service_key is None: service_key = CC.COMBINED_TAG_SERVICE_KEY ListBoxTagsDisplayCapable.__init__( self, parent, service_key = service_key, tag_display_type = tag_display_type, height_num_chars = 24 ) self._tag_sort = HG.client_controller.new_options.GetDefaultTagSort() self._last_media_results = set() self._include_counts = include_counts self._current_tags_to_count = collections.Counter() self._deleted_tags_to_count = collections.Counter() self._pending_tags_to_count = collections.Counter() self._petitioned_tags_to_count = collections.Counter() self._show_current = True self._show_deleted = False self._show_pending = True self._show_petitioned = True def _GenerateTermFromTag( self, tag: str ) -> ClientGUIListBoxesData.ListBoxItemTextTag: current_count = self._current_tags_to_count[ tag ] if self._show_current and tag in self._current_tags_to_count else 0 deleted_count = self._deleted_tags_to_count[ tag ] if self._show_deleted and tag in self._deleted_tags_to_count else 0 pending_count = self._pending_tags_to_count[ tag ] if self._show_pending and tag in self._pending_tags_to_count else 0 petitioned_count = self._petitioned_tags_to_count[ tag ] if self._show_petitioned and tag in self._petitioned_tags_to_count else 0 return ClientGUIListBoxesData.ListBoxItemTextTagWithCounts( tag, current_count, deleted_count, pending_count, petitioned_count, self._include_counts ) def _HasCounts( self ): return self._include_counts def _UpdateTerms( self, limit_to_these_tags = None ): previous_selected_terms = set( self._selected_terms ) if limit_to_these_tags is None: self._Clear() nonzero_tags = set() if self._show_current: nonzero_tags.update( ( tag for ( tag, count ) in self._current_tags_to_count.items() if count > 0 ) ) if self._show_deleted: nonzero_tags.update( ( tag for ( tag, count ) in self._deleted_tags_to_count.items() if count > 0 ) ) if self._show_pending: nonzero_tags.update( ( tag for ( tag, count ) in self._pending_tags_to_count.items() if count > 0 ) ) if self._show_petitioned: nonzero_tags.update( ( tag for ( tag, count ) in self._petitioned_tags_to_count.items() if count > 0 ) ) else: if not isinstance( limit_to_these_tags, set ): limit_to_these_tags = set( limit_to_these_tags ) clear_terms = [ self._GenerateTermFromTag( tag ) for tag in limit_to_these_tags ] self._RemoveTerms( clear_terms ) nonzero_tags = set() if self._show_current: nonzero_tags.update( ( tag for ( tag, count ) in self._current_tags_to_count.items() if count > 0 and tag in limit_to_these_tags ) ) if self._show_deleted: nonzero_tags.update( ( tag for ( tag, count ) in self._deleted_tags_to_count.items() if count > 0 and tag in limit_to_these_tags ) ) if self._show_pending: nonzero_tags.update( ( tag for ( tag, count ) in self._pending_tags_to_count.items() if count > 0 and tag in limit_to_these_tags ) ) if self._show_petitioned: nonzero_tags.update( ( tag for ( tag, count ) in self._petitioned_tags_to_count.items() if count > 0 and tag in limit_to_these_tags ) ) nonzero_terms = [ self._GenerateTermFromTag( tag ) for tag in nonzero_tags ] self._AppendTerms( nonzero_terms ) for term in previous_selected_terms: if term in self._terms_to_logical_indices: self._selected_terms.add( term ) self._Sort() def _Sort( self ): # I do this weird terms to count instead of tags to count because of tag vs ideal tag gubbins later on in sort terms_to_count = collections.Counter() jobs = [ ( self._show_current, self._current_tags_to_count ), ( self._show_deleted, self._deleted_tags_to_count ), ( self._show_pending, self._pending_tags_to_count ), ( self._show_petitioned, self._petitioned_tags_to_count ) ] counts_to_include = [ c for ( show, c ) in jobs if show ] for term in self._ordered_terms: tag = term.GetTag() count = sum( ( c[ tag ] for c in counts_to_include if tag in c ) ) terms_to_count[ term ] = count item_to_tag_key_wrapper = lambda term: term.GetTag() item_to_sibling_key_wrapper = item_to_tag_key_wrapper if self._sibling_decoration_allowed: item_to_sibling_key_wrapper = lambda term: term.GetBestTag() ClientTagSorting.SortTags( self._tag_sort, self._ordered_terms, tag_items_to_count = terms_to_count, item_to_tag_key_wrapper = item_to_tag_key_wrapper, item_to_sibling_key_wrapper = item_to_sibling_key_wrapper ) self._RegenTermsToIndices() def IncrementTagsByMedia( self, media ): flat_media = ClientMedia.FlattenMedia( media ) media_results = [ m.GetMediaResult() for m in flat_media ] self.IncrementTagsByMediaResults( media_results ) def IncrementTagsByMediaResults( self, media_results ): if not isinstance( media_results, set ): media_results = set( media_results ) media_results = media_results.difference( self._last_media_results ) ( current_tags_to_count, deleted_tags_to_count, pending_tags_to_count, petitioned_tags_to_count ) = ClientMedia.GetMediaResultsTagCount( media_results, self._service_key, self._tag_display_type ) tags_changed = set() if self._show_current: tags_changed.update( current_tags_to_count.keys() ) if self._show_deleted: tags_changed.update( deleted_tags_to_count.keys() ) if self._show_pending: tags_changed.update( pending_tags_to_count.keys() ) if self._show_petitioned: tags_changed.update( petitioned_tags_to_count.keys() ) self._current_tags_to_count.update( current_tags_to_count ) self._deleted_tags_to_count.update( deleted_tags_to_count ) self._pending_tags_to_count.update( pending_tags_to_count ) self._petitioned_tags_to_count.update( petitioned_tags_to_count ) if len( tags_changed ) > 0: self._UpdateTerms( tags_changed ) self._last_media_results.update( media_results ) self._DataHasChanged() def SetTagsByMedia( self, media ): flat_media = ClientMedia.FlattenMedia( media ) media_results = [ m.GetMediaResult() for m in flat_media ] self.SetTagsByMediaResults( media_results ) def SetTagsByMediaResults( self, media_results ): if not isinstance( media_results, set ): media_results = set( media_results ) ( current_tags_to_count, deleted_tags_to_count, pending_tags_to_count, petitioned_tags_to_count ) = ClientMedia.GetMediaResultsTagCount( media_results, self._service_key, self._tag_display_type ) self._current_tags_to_count = current_tags_to_count self._deleted_tags_to_count = deleted_tags_to_count self._pending_tags_to_count = pending_tags_to_count self._petitioned_tags_to_count = petitioned_tags_to_count self._UpdateTerms() self._last_media_results = media_results self._DataHasChanged() def SetTagsByMediaFromMediaPanel( self, media, tags_changed ): flat_media = ClientMedia.FlattenMedia( media ) media_results = [ m.GetMediaResult() for m in flat_media ] self.SetTagsByMediaResultsFromMediaPanel( media_results, tags_changed ) def SetTagsByMediaResultsFromMediaPanel( self, media_results, tags_changed ): if not isinstance( media_results, set ): media_results = set( media_results ) # this uses the last-set media and count cache to generate new numbers and is faster than re-counting from scratch when the tags have not changed selection_shrank_a_lot = len( media_results ) < len( self._last_media_results ) // 10 # if we are dropping to a much smaller selection (e.g. 5000 -> 1), we should just recalculate from scratch if tags_changed or selection_shrank_a_lot: self.SetTagsByMediaResults( media_results ) return removees = self._last_media_results.difference( media_results ) if len( removees ) == 0: self.IncrementTagsByMediaResults( media_results ) return adds = media_results.difference( self._last_media_results ) ( current_tags_to_count, deleted_tags_to_count, pending_tags_to_count, petitioned_tags_to_count ) = ClientMedia.GetMediaResultsTagCount( removees, self._service_key, self._tag_display_type ) self._current_tags_to_count.subtract( current_tags_to_count ) self._deleted_tags_to_count.subtract( deleted_tags_to_count ) self._pending_tags_to_count.subtract( pending_tags_to_count ) self._petitioned_tags_to_count.subtract( petitioned_tags_to_count ) ( current_tags_to_count, deleted_tags_to_count, pending_tags_to_count, petitioned_tags_to_count ) = ClientMedia.GetMediaResultsTagCount( adds, self._service_key, self._tag_display_type ) self._current_tags_to_count.update( current_tags_to_count ) self._deleted_tags_to_count.update( deleted_tags_to_count ) self._pending_tags_to_count.update( pending_tags_to_count ) self._petitioned_tags_to_count.update( petitioned_tags_to_count ) for counter in ( self._current_tags_to_count, self._deleted_tags_to_count, self._pending_tags_to_count, self._petitioned_tags_to_count ): tags = list( counter.keys() ) for tag in tags: if counter[ tag ] == 0: del counter[ tag ] self._UpdateTerms() self._last_media_results = media_results self._DataHasChanged() def SetTagServiceKey( self, service_key ): ListBoxTagsDisplayCapable.SetTagServiceKey( self, service_key ) self.SetTagsByMediaResults( self._last_media_results ) def SetSort( self, tag_sort: ClientTagSorting.TagSort ): self._tag_sort = tag_sort self._Sort() self._DataHasChanged() def SetShow( self, show_type, value ): if show_type == 'current': self._show_current = value elif show_type == 'deleted': self._show_deleted = value elif show_type == 'pending': self._show_pending = value elif show_type == 'petitioned': self._show_petitioned = value self._UpdateTerms() def ForceTagRecalc( self ): self.SetTagsByMediaResults( self._last_media_results ) class StaticBoxSorterForListBoxTags( ClientGUICommon.StaticBox ): def __init__( self, parent, title, show_siblings_sort = False ): ClientGUICommon.StaticBox.__init__( self, parent, title ) self._original_title = title self._tags_box = None # make this its own panel self._tag_sort = ClientGUITagSorting.TagSortControl( self, HG.client_controller.new_options.GetDefaultTagSort(), show_siblings = show_siblings_sort ) self._tag_sort.valueChanged.connect( self.EventSort ) self.Add( self._tag_sort, CC.FLAGS_EXPAND_PERPENDICULAR ) def SetTagServiceKey( self, service_key ): if self._tags_box is None: return self._tags_box.SetTagServiceKey( service_key ) title = self._original_title if service_key != CC.COMBINED_TAG_SERVICE_KEY: title = '{} for {}'.format( title, HG.client_controller.services_manager.GetName( service_key ) ) self.SetTitle( title ) def EventSort( self ): if self._tags_box is None: return sort = self._tag_sort.GetValue() self._tags_box.SetSort( sort ) def SetTagsBox( self, tags_box: ListBoxTagsMedia ): self._tags_box = tags_box self.Add( self._tags_box, CC.FLAGS_EXPAND_BOTH_WAYS ) def SetTagsByMedia( self, media ): if self._tags_box is None: return self._tags_box.SetTagsByMedia( media ) class ListBoxTagsMediaHoverFrame( ListBoxTagsMedia ): def __init__( self, parent, canvas_key ): ListBoxTagsMedia.__init__( self, parent, ClientTags.TAG_DISPLAY_SINGLE_MEDIA, include_counts = False ) self._canvas_key = canvas_key def _Activate( self, shift_down ) -> bool: HG.client_controller.pub( 'canvas_manage_tags', self._canvas_key ) return True class ListBoxTagsMediaTagsDialog( ListBoxTagsMedia ): def __init__( self, parent, enter_func, delete_func ): ListBoxTagsMedia.__init__( self, parent, ClientTags.TAG_DISPLAY_STORAGE, include_counts = True ) self._enter_func = enter_func self._delete_func = delete_func def _Activate( self, shift_down ) -> bool: if len( self._selected_terms ) > 0: tags = set( self._GetTagsFromTerms( self._selected_terms ) ) self._enter_func( tags ) return True return False def _DeleteActivate( self ): if len( self._selected_terms ) > 0: tags = set( self._GetTagsFromTerms( self._selected_terms ) ) self._delete_func( tags )