Version 473

This commit is contained in:
Hydrus Network Developer 2022-02-09 15:04:42 -06:00
parent 3be38d0594
commit f2669f3f5b
29 changed files with 1580 additions and 167 deletions

View File

@ -184,6 +184,7 @@ jobs:
run: |
move ${{ steps.setup_ffmpeg.outputs.ffmpeg-path }} hydrus\bin\
move hydrus\static\build_files\windows\sqlite3.dll hydrus\
move hydrus\static\build_files\windows\sqlite3.exe hydrus\db
move hydrus\static\build_files\windows\client-win.spec client-win.spec
move hydrus\static\build_files\windows\server-win.spec server-win.spec
pyinstaller server-win.spec

View File

@ -8,6 +8,29 @@
<div class="content">
<h3 id="changelog"><a href="#changelog">changelog</a></h3>
<ul>
<li><h3 id="version_473"><a href="#version_473">version 473</a></h3></li>
<ul>
<li>misc:</li>
<li>fixed the recent problem with drag and dropping thumbnails to a level below the top row of pages. sorry for the trouble!</li>
<li>fixed a bug where the client would not load results sorting by 'import time' when the search file domain was a single deleted file domain</li>
<li>fixed a list display bug in the edit page parser dialog when a subsidiary page parser has two complicated string-match based content parsers</li>
<li>collections now sort by modified time, using the largest known modified time in their collection</li>
<li>added sqlite3.exe console back into the windows build--sorry, it was missing since the github build changeover!</li>
<li>added a note to the help about backing up when tight on space, which I will repeat here: the sqlite database files are very compressible (70GB->17GB on default 7zip settings!), so if you need more space on your backup drive, this is a good way to reclaim it</li>
<li>.</li>
<li>command palette:</li>
<li>a user has written a cool 'command palette' for the program! it brings up a type-and-search interface to navigate to pages or menu entries.</li>
<li>I have integrated his first version and set the default shortcut to Ctrl+P. users who update will get this shortcut if they have nothing else on Ctrl+P on 'main window' set. if you prefer Ctrl+K or anything else, you can change it under _file->shortcuts->the main window_</li>
<li>regular users will get a page list they can search and select, advanced users will also get the (potentially dangerous) full scan of the menubar and current thumbnail right-click menu. I will be polishing this latter feature in future to filter out big maintenance jobs and show checkbox status and similar, so if you are advanced, please be careful for now</li>
<li>try it out, and let me know how it goes. the underlying widget is neat, and I can change its behaviour and extend it significantly</li>
<li>.</li>
<li>(mostly advanced) deleted file improvements:</li>
<li>files that have been deleted from a local file domain are now aware of their file deletion reason. this is visible in the right-click menu of thumb or media canvas</li>
<li>the advanced file deletion dialog now initialises using this stored reason. if all pending deletees have the same existing reason stored, it will display it, and if they are all set but differ, this will be noted and an option to not alter them is also available. this will come up later in niche advanced situations with mutiple file services</li>
<li>reversing a recent change, local file deletion reasons are no longer cleared on undelete or (re)import. they'll now hang around invisibly and initialise any future advanced file deletion dialog</li>
<li>updated the thumbnail and canvas undelete mechanism to handle multiple services. now, if the files are deleted in more than one domain, you will be asked to multiple-select which you wish to undelete for. if there is only one eligible undelete service, the process remains unchanged--you'll just get a yes/no confirmation if the 'confirm trash' option is set</li>
<li>misc multiple local file services code conversion work</li>
</ul>
<li><h3 id="version_472"><a href="#version_472">version 472</a></h3></li>
<ul>
<li>highlights:</li>
@ -26,7 +49,6 @@
<li>if the 'durable' temporary database exists on boot, it is now deleted and a fresh one created rather than trying to re-use the old one (which would not have any useful information anyway), and a note is made to log. one user recently had a problem where an existing corrupt temp dir was stopping boot, which this fixes</li>
<li>.</li>
<li>misc:</li>
<li>updated the windows build to use a newer version of mpv, moving from roughly 2021-02 to 2022-01. this replaces mpv-1.dll with mpv-2.dll, and I set the installer to delete the old '1'. if you extract, you can delete the old '1' yourself, but things seems fine with both. in any case, let me know if you have any trouble!</li>
<li>updated the windows build to use sqlite 3.37.2, the sqlite3 in the db dir is also updated</li>
<li>the deleted files system now neatly cleans up old file deletion reasons on file import and file undelete</li>
<li>cleared out some old thumbnail generation code, including deleting an old and now obselete optimisation where too-large thumbs were scaled down to make new thumbs rather than revisiting source. since our thumb situation is more complicated now, this is gone in favour of nicer quality thumbs and simpler code</li>

View File

@ -131,6 +131,7 @@
<p>I recommend you always backup before you update, just in case there is a problem with my code that breaks your database. If that happens, please <a href="contact.html">contact me</a>, describing the problem, and revert to the functioning older version. I'll get on any problems like that immediately.</p>
<h3 id="backing_up_small"><a href="#backing_up_small">backing up with not much space</a></h3>
<p>If you decide not to maintain a backup because you cannot afford drive space for all your files, please please at least back up your actual database files. Use FreeFileSync or a similar program to back up the four 'client*.db' files in install_dir/db when the client is not running. Just make sure you have a copy of those files, and then if your main install becomes damaged, we will have a reference to either roll back to or manually restore data from. Even if you lose a bunch of media files in this case, with an intact database we'll be able to schedule recovery of anything with a URL.<p>
<p>If you are really short on space, note also that the database files are very compressible. A very large database where the four files add up to 70GB can compress down to 17GB zip with 7zip on default settings. This obviously takes some additional time to do, but if you are really short on space it may be the only way it fits, and if your only backup drive is a slow USB stick, then you might actually save time from not having to transfer the other 53GB! Media files (jpegs, webms, etc...) are generally not very compressible, usually 5% at best, so it is usually not worth trying.</p>
<p>It is best to have all four database files. It is generally easy and quick to fix problems if you have a backup of all four. If client.caches.db is missing, you can recover but it might take ten or more hours of CPU work to regenerate. If client.mappings.db is missing, you might be able to recover tags for your local files from a mirror in an intact client.caches.db. However, client.master.db and client.db are the most important. If you lose either of those, or they become too damaged to read and you have no backup, then your database is essentially dead and likely every single archive and view and tag and note and url record you made is lost. This has happened before, do not let it be you.</p>
<p class="right"><a href="getting_started_files.html">Let's import some files! ----></a></p>
</div>

View File

@ -141,6 +141,7 @@ SIMPLE_AUTOCOMPLETE_IF_EMPTY_MEDIA_NEXT = 133
SIMPLE_MEDIA_SEEK_DELTA = 134
SIMPLE_GLOBAL_PROFILE_MODE_FLIP = 135
SIMPLE_GLOBAL_FORCE_ANIMATION_SCANBAR_SHOW = 136
SIMPLE_OPEN_COMMAND_PALETTE = 137
simple_enum_to_str_lookup = {
SIMPLE_ARCHIVE_DELETE_FILTER_BACK : 'archive/delete filter: back',
@ -279,7 +280,8 @@ simple_enum_to_str_lookup = {
SIMPLE_AUTOCOMPLETE_IF_EMPTY_MEDIA_NEXT : 'if input & results list are empty and in media viewer manage tags dialog, move to previous media',
SIMPLE_MEDIA_SEEK_DELTA : 'seek media',
SIMPLE_GLOBAL_PROFILE_MODE_FLIP : 'flip profile mode on/off',
SIMPLE_GLOBAL_FORCE_ANIMATION_SCANBAR_SHOW : 'force the animation scanbar to show (flip on/off)'
SIMPLE_GLOBAL_FORCE_ANIMATION_SCANBAR_SHOW : 'force the animation scanbar to show (flip on/off)',
SIMPLE_OPEN_COMMAND_PALETTE : 'open the command palette'
}
legacy_simple_str_to_enum_lookup = {

View File

@ -381,6 +381,7 @@ def GetDefaultShortcuts():
main_gui.SetCommand( ClientGUIShortcuts.Shortcut( ClientGUIShortcuts.SHORTCUT_TYPE_KEYBOARD_CHARACTER, ord( 'W' ), ClientGUIShortcuts.SHORTCUT_PRESS_TYPE_PRESS, [ ClientGUIShortcuts.SHORTCUT_MODIFIER_CTRL ] ), CAC.ApplicationCommand.STATICCreateSimpleCommand( CAC.SIMPLE_CLOSE_PAGE ) )
main_gui.SetCommand( ClientGUIShortcuts.Shortcut( ClientGUIShortcuts.SHORTCUT_TYPE_KEYBOARD_CHARACTER, ord( 'Y' ), ClientGUIShortcuts.SHORTCUT_PRESS_TYPE_PRESS, [ ClientGUIShortcuts.SHORTCUT_MODIFIER_CTRL ] ), CAC.ApplicationCommand.STATICCreateSimpleCommand( CAC.SIMPLE_REDO ) )
main_gui.SetCommand( ClientGUIShortcuts.Shortcut( ClientGUIShortcuts.SHORTCUT_TYPE_KEYBOARD_CHARACTER, ord( 'Z' ), ClientGUIShortcuts.SHORTCUT_PRESS_TYPE_PRESS, [ ClientGUIShortcuts.SHORTCUT_MODIFIER_CTRL ] ), CAC.ApplicationCommand.STATICCreateSimpleCommand( CAC.SIMPLE_UNDO ) )
main_gui.SetCommand( ClientGUIShortcuts.Shortcut( ClientGUIShortcuts.SHORTCUT_TYPE_KEYBOARD_CHARACTER, ord( 'P' ), ClientGUIShortcuts.SHORTCUT_PRESS_TYPE_PRESS, [ ClientGUIShortcuts.SHORTCUT_MODIFIER_CTRL ] ), CAC.ApplicationCommand.STATICCreateSimpleCommand( CAC.SIMPLE_OPEN_COMMAND_PALETTE ) )
shortcuts.append( main_gui )

View File

@ -7,6 +7,10 @@ from hydrus.core import HydrusSerialisable
from hydrus.client import ClientConstants as CC
def ValidLocalDomainsFilter( service_keys ):
return [ service_key for service_key in service_keys if HG.client_controller.services_manager.ServiceExists( service_key ) and HG.client_controller.services_manager.GetServiceType( service_key ) == HC.LOCAL_FILE_DOMAIN ]
class LocationContext( HydrusSerialisable.SerialisableBase ):
SERIALISABLE_TYPE = HydrusSerialisable.SERIALISABLE_TYPE_location_context

View File

@ -6783,6 +6783,8 @@ class DB( HydrusDB.HydrusDB ):
hash_ids_to_file_modified_timestamps = dict( self._Execute( 'SELECT hash_id, file_modified_timestamp FROM {} CROSS JOIN file_modified_timestamps USING ( hash_id );'.format( temp_table_name ) ) )
hash_ids_to_local_file_deletion_reasons = self.modules_files_storage.GetHashIdsToFileDeletionReasons( temp_table_name )
hash_ids_to_current_file_service_ids = { hash_id : [ file_service_id for ( file_service_id, timestamp ) in file_service_ids_and_timestamps ] for ( hash_id, file_service_ids_and_timestamps ) in hash_ids_to_current_file_service_ids_and_timestamps.items() }
hash_ids_to_tags_managers = self._GetForceRefreshTagsManagersWithTableHashIds( missing_hash_ids, temp_table_name, hash_ids_to_current_file_service_ids = hash_ids_to_current_file_service_ids )
@ -6825,7 +6827,16 @@ class DB( HydrusDB.HydrusDB ):
file_modified_timestamp = None
locations_manager = ClientMediaManagers.LocationsManager( current_file_service_keys_to_timestamps, deleted_file_service_keys_to_timestamps, pending_file_service_keys, petitioned_file_service_keys, inbox, urls, service_keys_to_filenames, file_modified_timestamp = file_modified_timestamp )
if hash_id in hash_ids_to_local_file_deletion_reasons:
local_file_deletion_reason = hash_ids_to_local_file_deletion_reasons[ hash_id ]
else:
local_file_deletion_reason = None
locations_manager = ClientMediaManagers.LocationsManager( current_file_service_keys_to_timestamps, deleted_file_service_keys_to_timestamps, pending_file_service_keys, petitioned_file_service_keys, inbox, urls, service_keys_to_filenames, file_modified_timestamp = file_modified_timestamp, local_file_deletion_reason = local_file_deletion_reason )
#
@ -6897,7 +6908,7 @@ class DB( HydrusDB.HydrusDB ):
return media_results[0]
def _GetMediaResultsFromHashes( self, hashes: typing.Iterable[ bytes ], sorted: bytes = False ) -> typing.List[ ClientMediaResult.MediaResult ]:
def _GetMediaResultsFromHashes( self, hashes: typing.Iterable[ bytes ], sorted: bool = False ) -> typing.List[ ClientMediaResult.MediaResult ]:
query_hash_ids = set( self.modules_hashes_local_cache.GetHashIds( hashes ) )
@ -8322,16 +8333,31 @@ class DB( HydrusDB.HydrusDB ):
#
file_import_options = file_import_job.GetFileImportOptions()
file_info_manager = ClientMediaManagers.FileInfoManager( hash_id, hash, size, mime, width, height, duration, num_frames, has_audio, num_words )
now = HydrusData.GetNow()
for destination_file_service_key in ( CC.LOCAL_FILE_SERVICE_KEY, ): # get this list from FIO, with fallback recovery
destination_location_context = file_import_options.GetDestinationLocationContext()
destination_location_context.FixMissingServices( ClientLocation.ValidLocalDomainsFilter )
if not destination_location_context.IncludesCurrent():
service_ids = self.modules_services.GetServiceIds( ( HC.LOCAL_FILE_DOMAIN, ) )
service_id = min( service_ids )
service_key = self.modules_services.GetService( service_id ).GetServiceKey()
destination_location_context = ClientLocation.LocationContext( current_service_keys = ( service_key, ) )
for destination_file_service_key in destination_location_context.current_service_keys:
destination_service_id = self.modules_services.GetServiceId( destination_file_service_key )
self.modules_files_storage.ClearFileDeletionReason( ( hash_id, ) )
self._AddFiles( destination_service_id, [ ( hash_id, now ) ] )
content_update = HydrusData.ContentUpdate( HC.CONTENT_TYPE_FILES, HC.CONTENT_UPDATE_ADD, ( file_info_manager, now ) )
@ -8341,8 +8367,6 @@ class DB( HydrusDB.HydrusDB ):
#
file_import_options = file_import_job.GetFileImportOptions()
if file_import_options.AutomaticallyArchives():
if HG.file_import_report_mode:
@ -9104,8 +9128,6 @@ class DB( HydrusDB.HydrusDB ):
elif action == HC.CONTENT_UPDATE_UNDELETE:
self.modules_files_storage.ClearFileDeletionReason( hash_ids )
self._UndeleteFiles( service_id, hash_ids )
elif action == HC.CONTENT_UPDATE_PEND:
@ -10045,7 +10067,6 @@ class DB( HydrusDB.HydrusDB ):
elif action == 'missing_thumbnail_hashes': result = self._GetRepositoryThumbnailHashesIDoNotHave( *args, **kwargs )
elif action == 'num_deferred_file_deletes': result = self.modules_files_storage.GetDeferredPhysicalDeleteCounts()
elif action == 'nums_pending': result = self._GetNumsPending( *args, **kwargs )
elif action == 'trash_hashes': result = self._GetTrashHashes( *args, **kwargs )
elif action == 'options': result = self._GetOptions( *args, **kwargs )
elif action == 'pending': result = self._GetPending( *args, **kwargs )
elif action == 'random_potential_duplicate_hashes': result = self._DuplicatesGetRandomPotentialDuplicateHashes( *args, **kwargs )
@ -10072,6 +10093,7 @@ class DB( HydrusDB.HydrusDB ):
elif action == 'tag_display_decorators': result = self.modules_tag_display.GetUIDecorators( *args, **kwargs )
elif action == 'tag_siblings_and_parents_lookup': result = self.modules_tag_display.GetSiblingsAndParentsForTags( *args, **kwargs )
elif action == 'tag_siblings_lookup': result = self.modules_tag_siblings.GetTagSiblingsForTags( *args, **kwargs )
elif action == 'trash_hashes': result = self._GetTrashHashes( *args, **kwargs )
elif action == 'potential_duplicates_count': result = self._DuplicatesGetPotentialDuplicatesCount( *args, **kwargs )
elif action == 'url_statuses': result = self._GetURLStatuses( *args, **kwargs )
elif action == 'vacuum_data': result = self.modules_db_maintenance.GetVacuumData( *args, **kwargs )
@ -11642,7 +11664,7 @@ class DB( HydrusDB.HydrusDB ):
if sort_data == CC.SORT_FILES_BY_IMPORT_TIME:
if location_context.IsOneDomain():
if location_context.IsOneDomain() and location_context.IncludesCurrent():
file_service_key = list( location_context.current_service_keys )[0]
@ -13596,6 +13618,36 @@ class DB( HydrusDB.HydrusDB ):
if version == 472:
try:
from hydrus.client.gui import ClientGUIShortcuts
main_gui = self.modules_serialisable.GetJSONDumpNamed( HydrusSerialisable.SERIALISABLE_TYPE_SHORTCUT_SET, dump_name = 'main_gui' )
palette_shortcut = ClientGUIShortcuts.Shortcut( ClientGUIShortcuts.SHORTCUT_TYPE_KEYBOARD_CHARACTER, ord( 'P' ), ClientGUIShortcuts.SHORTCUT_PRESS_TYPE_PRESS, [ ClientGUIShortcuts.SHORTCUT_MODIFIER_CTRL ] )
palette_command = CAC.ApplicationCommand.STATICCreateSimpleCommand( CAC.SIMPLE_OPEN_COMMAND_PALETTE )
result = main_gui.GetCommand( palette_shortcut )
if result is None:
main_gui.SetCommand( palette_shortcut, palette_command )
self.modules_serialisable.SetJSONDump( main_gui )
except Exception as e:
HydrusData.PrintException( e )
message = 'The new palette shortcut failed to set! This is not super important, but hydev would be interested in seeing the error that was printed to the log.'
self.pub_initial_message( message )
self._controller.frame_splash_status.SetTitleText( 'updated db to v{}'.format( HydrusData.ToHumanInt( version + 1 ) ) )
self._Execute( 'UPDATE version SET version = ?;', ( version + 1, ) )

View File

@ -857,6 +857,11 @@ class ClientDBFilesStorage( ClientDBModule.ClientDBModule ):
return hash_ids_to_current_file_service_ids
def GetHashIdsToFileDeletionReasons( self, hash_ids_table_name ):
return dict( self._Execute( 'SELECT hash_id, text FROM {} CROSS JOIN local_file_deletion_reasons USING ( hash_id ) CROSS JOIN texts ON ( reason_id = text_id );'.format( hash_ids_table_name ) ) )
def GetHashIdsToServiceInfoDicts( self, temp_hash_ids_table_name ):
hash_ids_to_current_file_service_ids_and_timestamps = collections.defaultdict( list )

View File

@ -75,6 +75,8 @@ from hydrus.client.gui import ClientGUITags
from hydrus.client.gui import ClientGUITime
from hydrus.client.gui import ClientGUITopLevelWindows
from hydrus.client.gui import ClientGUITopLevelWindowsPanels
from hydrus.client.gui import QLocator
from hydrus.client.gui import ClientGUILocatorSearchProviders
from hydrus.client.gui import QtPorting as QP
from hydrus.client.gui.networking import ClientGUIHydrusNetwork
from hydrus.client.gui.networking import ClientGUINetwork
@ -583,6 +585,31 @@ class FrameGUI( ClientGUITopLevelWindows.MainFrameThatResizes ):
ClientGUIFunctions.UpdateAppDisplayName()
# locator setup
self._locator = QLocator.QLocator( self )
self._locator.setIconBasePath( HC.STATIC_DIR + os.path.sep )
# TODO: make configurable which providers + order
self._locator.addProvider( ClientGUILocatorSearchProviders.CalculatorSearchProvider() )
self._locator.addProvider( ClientGUILocatorSearchProviders.MainMenuSearchProvider() )
self._locator.addProvider( ClientGUILocatorSearchProviders.MediaMenuSearchProvider() )
self._locator.addProvider( ClientGUILocatorSearchProviders.PagesSearchProvider() )
self._locator_widget = QLocator.QLocatorWidget( self,
width = 800,
resultHeight = 36,
titleHeight = 36,
primaryTextWidth = 430,
secondaryTextWidth = 280,
maxVisibleItemCount = 16
)
self._locator_widget.setDefaultStylingEnabled( False )
self._locator_widget.setLocator( self._locator )
self._locator_widget.setAlignment( QC.Qt.AlignCenter )
self._locator_widget.setEscapeShortcuts( [ QG.QKeySequence( QC.Qt.Key_Escape ) ] )
# self._locator_widget.setQueryTimeout( 100 ) # how much to wait before starting a search after user edit. default 0
def _AboutWindow( self ):
@ -7257,6 +7284,10 @@ The password is cleartext here but obscured in the entry dialog. Enter a blank p
HG.client_controller.new_options.FlipBoolean( 'force_animation_scanbar_show' )
elif action == CAC.SIMPLE_OPEN_COMMAND_PALETTE:
self._locator_widget.start()
elif action == CAC.SIMPLE_SHOW_HIDE_SPLITTERS:
self._ShowHideSplitters()

View File

@ -0,0 +1,368 @@
from hydrus.client.gui.QLocator import QAbstractLocatorSearchProvider, QCalculatorSearchProvider, QLocatorSearchResult
from hydrus.core import HydrusGlobals as HG
from qtpy import QtWidgets as QW
from html import escape
def highlight_result_text( result_text: str, query_text: str ):
result_text = escape( result_text )
if query_text:
result_text = result_text.replace( escape( query_text ), '<b>' + escape( query_text ) + '</b>' )
return result_text
# Subclass for customizing icon paths
class CalculatorSearchProvider( QCalculatorSearchProvider ):
def __init__( self, parent = None ):
super().__init__( parent )
def titleIconPath( self ):
return str()
def selectedIconPath( self ):
return str()
def iconPath( self ):
return str()
class PagesSearchProvider( QAbstractLocatorSearchProvider ):
def __init__( self, parent = None ):
super().__init__( parent )
self.result_id_counter = 0
self.result_ids_to_pages = {}
def title( self ):
return "Pages"
# How many preallocated result widgets should be created (so that we don't have to recreate the entire result list on each search)
# Should be larger than the average expected result count
def suggestedReservedItemCount( self ):
return 32
# Called when the user activates a result
def resultSelected( self, resultID: int ):
page = self.result_ids_to_pages.get( resultID, None )
if page:
HG.client_controller.gui._notebook.ShowPage( page )
self.result_ids_to_pages = {}
# Should generate a list of QLocatorSearchResults
def processQuery( self, query: str, context, jobID: int ):
self.result_ids_to_pages = {}
if not HG.client_controller.gui or not HG.client_controller.gui._notebook:
return
tab_widget = HG.client_controller.gui._notebook
# helper function to traverse tab tree and generate entries
def get_child_tabs( tab_widget: QW.QTabWidget, parent_name: str ) -> list:
result = []
for i in range( tab_widget.count() ):
widget = tab_widget.widget(i)
if isinstance( widget, QW.QTabWidget ): # page of pages
result.extend( get_child_tabs( widget, widget.GetName() ) )
else:
selectable_media_page = widget
label = '{} - {}'.format( selectable_media_page.GetName(), selectable_media_page.GetPrettyStatus() )
if not query in label:
continue
primary_text = highlight_result_text( label, query )
secondary_text = 'top level page' if not parent_name else "child of '" + escape( parent_name ) + "'"
result.append( QLocatorSearchResult( self.result_id_counter, 'thumbnails.png', 'thumbnails.png', True, [ primary_text, secondary_text ] ) )
self.result_ids_to_pages[ self.result_id_counter ] = selectable_media_page
self.result_id_counter += 1
return result
tab_data = get_child_tabs( tab_widget, '' )
if tab_data:
self.resultsAvailable.emit( jobID, tab_data )
# When this is called, it means that the Locator/LocatorWidget is done with these jobs and no results will be activated either
# So if any still-in-progress search can be stopped and any resources associated with these jobs can be freed
def stopJobs( self, jobs: list ):
self.result_ids_to_pages = {}
# Should the title item be visible in the result list
def hideTitle( self ):
return False
def titleIconPath( self ):
return str() #TODO fill this in
class MainMenuSearchProvider( QAbstractLocatorSearchProvider ):
def __init__( self, parent = None ):
super().__init__( parent )
self.result_id_counter = 0
self.result_ids_to_actions = {}
def title( self ):
return "Main Menu"
def suggestedReservedItemCount( self ):
return 128
def resultSelected( self, resultID: int ):
action = self.result_ids_to_actions.get( resultID, None )
if action:
action.trigger()
self.result_ids_to_actions = {}
def processQuery( self, query: str, context, jobID: int ):
if not HG.client_controller.new_options.GetBoolean( 'advanced_mode' ):
return
if len( query ) < 3:
return
self.result_ids_to_pages = {}
if not HG.client_controller.gui or not HG.client_controller.gui._menubar:
return
menubar = HG.client_controller.gui._menubar
# helper function to traverse menu and generate entries
# TODO: need to filter out menu items not suitable for display in locator
# (probably best to mark them when they are created and just check a property here)
# TODO: need special icon or secondary text for toggle-able items to see toggle state
def get_menu_items( menu: QW.QWidget, parent_name: str ) -> list:
result = []
for action in menu.actions():
actionText = action.text().replace( "&", "" )
if action.menu():
new_parent_name = parent_name + " | " + actionText if parent_name else actionText
result.extend( get_menu_items( action.menu(), new_parent_name ) )
else:
if not query in action.text() and not query in actionText:
continue
primary_text = highlight_result_text( actionText, query )
secondary_text = escape( parent_name )
result.append( QLocatorSearchResult( self.result_id_counter, 'lightning.png', 'lightning.png', True, [ primary_text, secondary_text ] ) )
self.result_ids_to_actions[ self.result_id_counter ] = action
self.result_id_counter += 1
return result
menu_data = get_menu_items( menubar, '' )
if menu_data:
self.resultsAvailable.emit( jobID, menu_data )
def stopJobs( self, jobs ):
self.result_ids_to_actions = {}
def hideTitle( self ):
return False
def titleIconPath( self ):
return str() #TODO fill this in
class MediaMenuSearchProvider( QAbstractLocatorSearchProvider ):
def __init__( self, parent = None ):
super().__init__( parent )
self.result_id_counter = 0
self.result_ids_to_actions = {}
self.menu = None
def title( self ):
return "Media"
def suggestedReservedItemCount( self ):
return 64
def resultSelected( self, resultID: int ):
action = self.result_ids_to_actions.get( resultID, None )
if action:
action.trigger()
self.result_ids_to_actions = {}
self.menu = None
def processQuery( self, query: str, context, jobID: int ):
if not HG.client_controller.new_options.GetBoolean( 'advanced_mode' ):
return
if len( query ) < 3:
return
self.result_ids_to_pages = {}
self.menu = None
if not HG.client_controller.gui or not HG.client_controller.gui._notebook:
return
media_page = HG.client_controller.gui._notebook.GetCurrentMediaPage()
if not media_page or not media_page._media_panel:
return
self.menu = media_page._media_panel.ShowMenu( True )
# helper function to traverse menu and generate entries
# TODO: need to filter out menu items not suitable for display in locator
# (probably best to mark them when they are created and just check a property here)
# TODO: need special icon or secondary text for toggle-able items to see toggle state
def get_menu_items( menu: QW.QWidget, parent_name: str ) -> list:
result = []
for action in menu.actions():
actionText = action.text().replace( "&", "" )
if action.menu():
new_parent_name = parent_name + " | " + actionText if parent_name else actionText
result.extend( get_menu_items( action.menu(), new_parent_name ) )
else:
if not query in action.text() and not query in actionText:
continue
primary_text = highlight_result_text( actionText, query )
secondary_text = escape( parent_name )
result.append( QLocatorSearchResult( self.result_id_counter, 'images.png', 'images.png', True, [ primary_text, secondary_text ] ) )
self.result_ids_to_actions[ self.result_id_counter ] = action
self.result_id_counter += 1
return result
menu_data = get_menu_items( self.menu, '' )
if menu_data:
self.resultsAvailable.emit( jobID, menu_data )
def stopJobs( self, jobs ):
self.result_ids_to_actions = {}
self.menu = {}
def hideTitle( self ):
return False
def titleIconPath( self ):
return str() #TODO fill this in
# TODO: provider for page tab right click menu actions?

View File

@ -10,6 +10,7 @@ from hydrus.core import HydrusGlobals as HG
from hydrus.client import ClientApplicationCommand as CAC
from hydrus.client import ClientConstants as CC
from hydrus.client.gui import ClientGUIDialogsQuick
from hydrus.client.gui import ClientGUIScrolledPanelsEdit
from hydrus.client.gui import ClientGUITopLevelWindowsPanels
from hydrus.client.media import ClientMedia
@ -323,3 +324,74 @@ def UndeleteFiles( hashes ):
def UndeleteMedia( win, media ):
media_deleted_service_keys = HydrusData.MassUnion( ( m.GetLocationsManager().GetDeleted() for m in media ) )
local_file_services = HG.client_controller.services_manager.GetServices( ( HC.LOCAL_FILE_DOMAIN, ) )
undeletable_services = [ local_file_service for local_file_service in local_file_services if local_file_service.GetServiceKey() in media_deleted_service_keys ]
if len( undeletable_services ) > 0:
do_it = False
if len( undeletable_services ) > 1:
choice_tuples = []
for ( i, service ) in enumerate( undeletable_services ):
choice_tuples.append( ( service.GetName(), service, i == 0 ) )
try:
undelete_services = ClientGUIDialogsQuick.SelectMultipleFromList( win, 'Undelete for?', choice_tuples )
do_it = True
except HydrusExceptions.CancelledException:
return
else:
undelete_services = undeletable_services
if HC.options[ 'confirm_trash' ]:
result = ClientGUIDialogsQuick.GetYesNo( win, 'Undelete this file back to {}?'.format( undelete_services[0].GetName() ) )
if result == QW.QDialog.Accepted:
do_it = True
else:
do_it = True
if do_it:
for chunk_of_media in HydrusData.SplitIteratorIntoChunks( media, 64 ):
service_keys_to_content_updates = collections.defaultdict( list )
for service in undelete_services:
service_key = service.GetServiceKey()
undeletee_hashes = [ m.GetHash() for m in chunk_of_media if service_key in m.GetLocationsManager().GetDeleted() ]
service_keys_to_content_updates[ service_key ] = [ HydrusData.ContentUpdate( HC.CONTENT_TYPE_FILES, HC.CONTENT_UPDATE_UNDELETE, undeletee_hashes ) ]
HG.client_controller.Write( 'content_updates', service_keys_to_content_updates )

View File

@ -3087,7 +3087,7 @@ class EditPageParserPanel( ClientGUIScrolledPanels.EditPanel ):
produces = page_parser.GetParsableContent()
produces = sorted( produces )
produces = sorted( produces, key = lambda row: ( row[0], row[1] ) ) # ( name, content_type ), ignores potentially unsortable StringMatch etc.. in additional info in case of dupe
pretty_name = name
pretty_formula = formula.ToPrettyString()

View File

@ -344,6 +344,9 @@ class EditDefaultTagImportOptionsPanel( ClientGUIScrolledPanels.EditPanel ):
class EditDeleteFilesPanel( ClientGUIScrolledPanels.EditPanel ):
SPECIAL_CHOICE_CUSTOM = 0
SPECIAL_CHOICE_NO_REASON = 1
def __init__( self, parent: QW.QWidget, media, default_reason, suggested_file_service_key = None ):
ClientGUIScrolledPanels.EditPanel.__init__( self, parent )
@ -352,6 +355,8 @@ class EditDeleteFilesPanel( ClientGUIScrolledPanels.EditPanel ):
self._question_is_already_resolved = len( self._media ) == 0
( self._all_files_have_existing_file_deletion_reasons, self._existing_shared_file_deletion_reason ) = self._GetExistingSharedFileDeletionReason()
self._simple_description = ClientGUICommon.BetterStaticText( self, label = 'init' )
self._permitted_action_choices = []
@ -389,9 +394,20 @@ class EditDeleteFilesPanel( ClientGUIScrolledPanels.EditPanel ):
self._reason_panel = ClientGUICommon.StaticBox( self, 'reason' )
existing_reason_was_in_list = False
permitted_reason_choices = []
permitted_reason_choices.append( ( default_reason, default_reason ) )
if self._existing_shared_file_deletion_reason is not None and default_reason == self._existing_shared_file_deletion_reason:
existing_reason_was_in_list = True
permitted_reason_choices.append( ( 'keep existing reason: {}'.format( default_reason ), default_reason ) )
else:
permitted_reason_choices.append( ( default_reason, default_reason ) )
if HG.client_controller.new_options.GetBoolean( 'remember_last_advanced_file_deletion_reason' ):
@ -413,7 +429,16 @@ class EditDeleteFilesPanel( ClientGUIScrolledPanels.EditPanel ):
for ( i, s ) in enumerate( HG.client_controller.new_options.GetStringList( 'advanced_file_deletion_reasons' ) ):
permitted_reason_choices.append( ( s, s ) )
if self._existing_shared_file_deletion_reason is not None and s == self._existing_shared_file_deletion_reason and not existing_reason_was_in_list:
existing_reason_was_in_list = True
permitted_reason_choices.append( ( 'keep existing reason: {}'.format( s ), s ) )
else:
permitted_reason_choices.append( ( s, s ) )
if last_advanced_file_deletion_reason is not None and s == last_advanced_file_deletion_reason:
@ -421,7 +446,19 @@ class EditDeleteFilesPanel( ClientGUIScrolledPanels.EditPanel ):
permitted_reason_choices.append( ( 'custom', None ) )
if self._existing_shared_file_deletion_reason is not None and not existing_reason_was_in_list:
permitted_reason_choices.append( ( 'keep existing reason: {}'.format( self._existing_shared_file_deletion_reason ), self._existing_shared_file_deletion_reason ) )
custom_index = len( permitted_reason_choices )
permitted_reason_choices.append( ( 'custom', self.SPECIAL_CHOICE_CUSTOM ) )
if self._all_files_have_existing_file_deletion_reasons and self._existing_shared_file_deletion_reason is None:
permitted_reason_choices.append( ( '(all files have existing file deletion reasons and they differ): do not alter them.', self.SPECIAL_CHOICE_NO_REASON ) )
self._reason_radio = ClientGUICommon.BetterRadioBox( self._reason_panel, choices = permitted_reason_choices, vertical = True )
@ -429,7 +466,7 @@ class EditDeleteFilesPanel( ClientGUIScrolledPanels.EditPanel ):
if selection_index is None:
selection_index = len( permitted_reason_choices ) - 1 # custom
selection_index = custom_index
self._custom_reason.setText( last_advanced_file_deletion_reason )
@ -510,16 +547,51 @@ class EditDeleteFilesPanel( ClientGUIScrolledPanels.EditPanel ):
return media
def _GetExistingSharedFileDeletionReason( self ):
all_files_have_existing_file_deletion_reasons = True
reasons = set()
for m in self._media:
lm = m.GetLocationsManager()
if not lm.HasLocalFileDeletionReason():
all_files_have_existing_file_deletion_reasons = False
return ( all_files_have_existing_file_deletion_reasons, None )
reason = lm.GetLocalFileDeletionReason()
reasons.add( reason )
shared_reason = None
if all_files_have_existing_file_deletion_reasons and len( reasons ) == 1:
shared_reason = list( reasons )[0]
return ( all_files_have_existing_file_deletion_reasons, shared_reason )
def _GetReason( self ):
if self._reason_panel.isEnabled():
reason = self._reason_radio.GetValue()
if reason is None:
if reason == self.SPECIAL_CHOICE_CUSTOM:
reason = self._custom_reason.text()
elif reason == self.SPECIAL_CHOICE_NO_REASON:
reason = None
else:
@ -655,7 +727,7 @@ class EditDeleteFilesPanel( ClientGUIScrolledPanels.EditPanel ):
reason = self._reason_radio.GetValue()
if reason is None:
if reason == self.SPECIAL_CHOICE_CUSTOM:
self._custom_reason.setEnabled( True )

View File

@ -223,7 +223,7 @@ SHORTCUTS_GLOBAL_ACTIONS = [ CAC.SIMPLE_GLOBAL_AUDIO_MUTE, CAC.SIMPLE_GLOBAL_AUD
SHORTCUTS_MEDIA_ACTIONS = [ CAC.SIMPLE_MANAGE_FILE_TAGS, CAC.SIMPLE_MANAGE_FILE_RATINGS, CAC.SIMPLE_MANAGE_FILE_URLS, CAC.SIMPLE_MANAGE_FILE_NOTES, CAC.SIMPLE_ARCHIVE_FILE, CAC.SIMPLE_INBOX_FILE, CAC.SIMPLE_DELETE_FILE, CAC.SIMPLE_UNDELETE_FILE, CAC.SIMPLE_EXPORT_FILES, CAC.SIMPLE_EXPORT_FILES_QUICK_AUTO_EXPORT, CAC.SIMPLE_REMOVE_FILE_FROM_VIEW, CAC.SIMPLE_OPEN_FILE_IN_EXTERNAL_PROGRAM, CAC.SIMPLE_OPEN_SELECTION_IN_NEW_PAGE, CAC.SIMPLE_LAUNCH_THE_ARCHIVE_DELETE_FILTER, CAC.SIMPLE_COPY_BMP, CAC.SIMPLE_COPY_BMP_OR_FILE_IF_NOT_BMPABLE, CAC.SIMPLE_COPY_FILE, CAC.SIMPLE_COPY_PATH, CAC.SIMPLE_COPY_SHA256_HASH, CAC.SIMPLE_COPY_MD5_HASH, CAC.SIMPLE_COPY_SHA1_HASH, CAC.SIMPLE_COPY_SHA512_HASH, CAC.SIMPLE_GET_SIMILAR_TO_EXACT, CAC.SIMPLE_GET_SIMILAR_TO_VERY_SIMILAR, CAC.SIMPLE_GET_SIMILAR_TO_SIMILAR, CAC.SIMPLE_GET_SIMILAR_TO_SPECULATIVE, CAC.SIMPLE_DUPLICATE_MEDIA_SET_ALTERNATE, CAC.SIMPLE_DUPLICATE_MEDIA_SET_ALTERNATE_COLLECTIONS, CAC.SIMPLE_DUPLICATE_MEDIA_SET_CUSTOM, CAC.SIMPLE_DUPLICATE_MEDIA_SET_FOCUSED_BETTER, CAC.SIMPLE_DUPLICATE_MEDIA_SET_FOCUSED_KING, CAC.SIMPLE_DUPLICATE_MEDIA_SET_SAME_QUALITY, CAC.SIMPLE_DUPLICATE_MEDIA_SET_POTENTIAL, CAC.SIMPLE_OPEN_KNOWN_URL ]
SHORTCUTS_MEDIA_VIEWER_ACTIONS = [ CAC.SIMPLE_PAUSE_MEDIA, CAC.SIMPLE_PAUSE_PLAY_MEDIA, CAC.SIMPLE_MEDIA_SEEK_DELTA, CAC.SIMPLE_MOVE_ANIMATION_TO_PREVIOUS_FRAME, CAC.SIMPLE_MOVE_ANIMATION_TO_NEXT_FRAME, CAC.SIMPLE_SWITCH_BETWEEN_FULLSCREEN_BORDERLESS_AND_REGULAR_FRAMED_WINDOW, CAC.SIMPLE_PAN_UP, CAC.SIMPLE_PAN_DOWN, CAC.SIMPLE_PAN_LEFT, CAC.SIMPLE_PAN_RIGHT, CAC.SIMPLE_PAN_TOP_EDGE, CAC.SIMPLE_PAN_BOTTOM_EDGE, CAC.SIMPLE_PAN_LEFT_EDGE, CAC.SIMPLE_PAN_RIGHT_EDGE, CAC.SIMPLE_PAN_VERTICAL_CENTER, CAC.SIMPLE_PAN_HORIZONTAL_CENTER, CAC.SIMPLE_ZOOM_IN, CAC.SIMPLE_ZOOM_OUT, CAC.SIMPLE_SWITCH_BETWEEN_100_PERCENT_AND_CANVAS_ZOOM, CAC.SIMPLE_FLIP_DARKMODE, CAC.SIMPLE_CLOSE_MEDIA_VIEWER ]
SHORTCUTS_MEDIA_VIEWER_BROWSER_ACTIONS = [ CAC.SIMPLE_VIEW_NEXT, CAC.SIMPLE_VIEW_FIRST, CAC.SIMPLE_VIEW_LAST, CAC.SIMPLE_VIEW_PREVIOUS, CAC.SIMPLE_PAUSE_PLAY_SLIDESHOW, CAC.SIMPLE_SHOW_MENU, CAC.SIMPLE_CLOSE_MEDIA_VIEWER ]
SHORTCUTS_MAIN_GUI_ACTIONS = [ CAC.SIMPLE_REFRESH, CAC.SIMPLE_REFRESH_ALL_PAGES, CAC.SIMPLE_REFRESH_PAGE_OF_PAGES_PAGES, CAC.SIMPLE_NEW_PAGE, CAC.SIMPLE_NEW_PAGE_OF_PAGES, CAC.SIMPLE_NEW_DUPLICATE_FILTER_PAGE, CAC.SIMPLE_NEW_GALLERY_DOWNLOADER_PAGE, CAC.SIMPLE_NEW_URL_DOWNLOADER_PAGE, CAC.SIMPLE_NEW_SIMPLE_DOWNLOADER_PAGE, CAC.SIMPLE_NEW_WATCHER_DOWNLOADER_PAGE, CAC.SIMPLE_SET_MEDIA_FOCUS, CAC.SIMPLE_SHOW_HIDE_SPLITTERS, CAC.SIMPLE_SET_SEARCH_FOCUS, CAC.SIMPLE_UNCLOSE_PAGE, CAC.SIMPLE_CLOSE_PAGE, CAC.SIMPLE_REDO, CAC.SIMPLE_UNDO, CAC.SIMPLE_FLIP_DARKMODE, CAC.SIMPLE_RUN_ALL_EXPORT_FOLDERS, CAC.SIMPLE_CHECK_ALL_IMPORT_FOLDERS, CAC.SIMPLE_FLIP_DEBUG_FORCE_IDLE_MODE_DO_NOT_SET_THIS, CAC.SIMPLE_SHOW_AND_FOCUS_MANAGE_TAGS_FAVOURITE_TAGS, CAC.SIMPLE_SHOW_AND_FOCUS_MANAGE_TAGS_RELATED_TAGS, CAC.SIMPLE_REFRESH_RELATED_TAGS, CAC.SIMPLE_SHOW_AND_FOCUS_MANAGE_TAGS_FILE_LOOKUP_SCRIPT_TAGS, CAC.SIMPLE_SHOW_AND_FOCUS_MANAGE_TAGS_RECENT_TAGS, CAC.SIMPLE_FOCUS_MEDIA_VIEWER, CAC.SIMPLE_MOVE_PAGES_SELECTION_LEFT, CAC.SIMPLE_MOVE_PAGES_SELECTION_RIGHT, CAC.SIMPLE_MOVE_PAGES_SELECTION_HOME, CAC.SIMPLE_MOVE_PAGES_SELECTION_END ]
SHORTCUTS_MAIN_GUI_ACTIONS = [ CAC.SIMPLE_REFRESH, CAC.SIMPLE_REFRESH_ALL_PAGES, CAC.SIMPLE_REFRESH_PAGE_OF_PAGES_PAGES, CAC.SIMPLE_NEW_PAGE, CAC.SIMPLE_NEW_PAGE_OF_PAGES, CAC.SIMPLE_NEW_DUPLICATE_FILTER_PAGE, CAC.SIMPLE_NEW_GALLERY_DOWNLOADER_PAGE, CAC.SIMPLE_NEW_URL_DOWNLOADER_PAGE, CAC.SIMPLE_NEW_SIMPLE_DOWNLOADER_PAGE, CAC.SIMPLE_NEW_WATCHER_DOWNLOADER_PAGE, CAC.SIMPLE_SET_MEDIA_FOCUS, CAC.SIMPLE_SHOW_HIDE_SPLITTERS, CAC.SIMPLE_SET_SEARCH_FOCUS, CAC.SIMPLE_UNCLOSE_PAGE, CAC.SIMPLE_CLOSE_PAGE, CAC.SIMPLE_REDO, CAC.SIMPLE_UNDO, CAC.SIMPLE_FLIP_DARKMODE, CAC.SIMPLE_RUN_ALL_EXPORT_FOLDERS, CAC.SIMPLE_CHECK_ALL_IMPORT_FOLDERS, CAC.SIMPLE_FLIP_DEBUG_FORCE_IDLE_MODE_DO_NOT_SET_THIS, CAC.SIMPLE_SHOW_AND_FOCUS_MANAGE_TAGS_FAVOURITE_TAGS, CAC.SIMPLE_SHOW_AND_FOCUS_MANAGE_TAGS_RELATED_TAGS, CAC.SIMPLE_REFRESH_RELATED_TAGS, CAC.SIMPLE_SHOW_AND_FOCUS_MANAGE_TAGS_FILE_LOOKUP_SCRIPT_TAGS, CAC.SIMPLE_SHOW_AND_FOCUS_MANAGE_TAGS_RECENT_TAGS, CAC.SIMPLE_FOCUS_MEDIA_VIEWER, CAC.SIMPLE_MOVE_PAGES_SELECTION_LEFT, CAC.SIMPLE_MOVE_PAGES_SELECTION_RIGHT, CAC.SIMPLE_MOVE_PAGES_SELECTION_HOME, CAC.SIMPLE_MOVE_PAGES_SELECTION_END, CAC.SIMPLE_OPEN_COMMAND_PALETTE ]
SHORTCUTS_TAGS_AUTOCOMPLETE_ACTIONS = [ CAC.SIMPLE_SYNCHRONISED_WAIT_SWITCH, CAC.SIMPLE_AUTOCOMPLETE_FORCE_FETCH, CAC.SIMPLE_AUTOCOMPLETE_IME_MODE ]
SHORTCUTS_DUPLICATE_FILTER_ACTIONS = [ CAC.SIMPLE_DUPLICATE_FILTER_THIS_IS_BETTER_AND_DELETE_OTHER, CAC.SIMPLE_DUPLICATE_FILTER_THIS_IS_BETTER_BUT_KEEP_BOTH, CAC.SIMPLE_DUPLICATE_FILTER_EXACTLY_THE_SAME, CAC.SIMPLE_DUPLICATE_FILTER_ALTERNATES, CAC.SIMPLE_DUPLICATE_FILTER_FALSE_POSITIVE, CAC.SIMPLE_DUPLICATE_FILTER_CUSTOM_ACTION, CAC.SIMPLE_DUPLICATE_FILTER_SKIP, CAC.SIMPLE_DUPLICATE_FILTER_BACK, CAC.SIMPLE_CLOSE_MEDIA_VIEWER, CAC.SIMPLE_VIEW_NEXT ]
SHORTCUTS_ARCHIVE_DELETE_FILTER_ACTIONS = [ CAC.SIMPLE_ARCHIVE_DELETE_FILTER_KEEP, CAC.SIMPLE_ARCHIVE_DELETE_FILTER_DELETE, CAC.SIMPLE_ARCHIVE_DELETE_FILTER_SKIP, CAC.SIMPLE_ARCHIVE_DELETE_FILTER_BACK, CAC.SIMPLE_CLOSE_MEDIA_VIEWER ]

View File

@ -0,0 +1,766 @@
# QLocator
# Copyright (C) 2020-2022 qcomixdev
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from qtpy import QtCore as QC
from qtpy import QtWidgets as QW
from qtpy import QtGui as QG
import random
import math
import re
def elideRichText(richText: str, maxWidth: int, widget, elideFromLeft: bool):
doc = QG.QTextDocument()
opt = QG.QTextOption()
opt.setWrapMode(QG.QTextOption.NoWrap)
doc.setDefaultTextOption(opt)
doc.setDocumentMargin(0)
doc.setHtml(richText)
doc.adjustSize()
if doc.size().width() > maxWidth:
cursor = QG.QTextCursor (doc)
if elideFromLeft:
cursor.movePosition(QG.QTextCursor.Start)
else:
cursor.movePosition(QG.QTextCursor.End)
elidedPostfix = ""
metric = QG.QFontMetrics(widget.font())
postfixWidth = metric.horizontalAdvance(elidedPostfix)
while doc.size().width() > maxWidth - postfixWidth:
if elideFromLeft:
cursor.deleteChar()
else:
cursor.deletePreviousChar()
doc.adjustSize()
cursor.insertText(elidedPostfix)
return doc.toHtml()
return richText
class FocusEventFilter(QC.QObject):
focused = QC.Signal()
def __init__(self, parent = None):
super().__init__(parent)
def eventFilter(self, object, event) -> bool:
if event.type() == QC.QEvent.FocusIn:
self.focused.emit()
return False
class QLocatorSearchResult:
def __init__(self, id: int, defaultIconPath: str, selectedIconPath: str, closeOnActivated: False, text: list):
self.id = id
self.defaultIconPath = defaultIconPath
self.selectedIconPath = selectedIconPath
self.closeOnActivated = closeOnActivated
self.text = text
class QLocatorTitleWidget(QW.QWidget):
def __init__(self, title: str, iconPath: str, height: int, shouldRemainHidden: bool, parent = None):
super().__init__(parent)
self.icon = QG.QIcon(iconPath)
self.iconHeight = height - 2
self.setLayout(QW.QHBoxLayout())
self.iconLabel = QW.QLabel()
self.iconLabel.setFixedHeight(self.iconHeight)
self.iconLabel.setPixmap(self.icon.pixmap(self.iconHeight, self.iconHeight))
self.titleLabel = QW.QLabel()
self.titleLabel.setText(title)
self.titleLabel.setTextFormat(QC.Qt.RichText)
self.countLabel = QW.QLabel()
self.layout().setContentsMargins(4, 1, 4, 1)
self.layout().addWidget(self.iconLabel)
self.layout().addWidget(self.titleLabel)
self.layout().addStretch(1)
self.layout().addWidget(self.countLabel)
self.layout().setAlignment(self.countLabel, QC.Qt.AlignVCenter)
self.layout().setAlignment(self.iconLabel, QC.Qt.AlignVCenter)
self.layout().setAlignment(self.titleLabel, QC.Qt.AlignVCenter)
titleFont = self.titleLabel.font()
titleFont.setBold(True)
self.titleLabel.setFont(titleFont)
self.setFixedHeight(height)
self.shouldRemainHidden = shouldRemainHidden
def updateData(self, count: int):
self.countLabel.setText(str(count))
def paintEvent(self, event):
opt = QW.QStyleOption()
opt.initFrom(self)
p = QG.QPainter(self)
self.style().drawPrimitive(QW.QStyle.PE_Widget, opt, p, self)
class QLocatorResultWidget(QW.QWidget):
up = QC.Signal()
down = QC.Signal()
activated = QC.Signal(int, int, bool)
def __init__(self, keyEventTarget: QW.QWidget, height: int, primaryTextWidth: int, secondaryTextWidth: int, parent = None):
super().__init__(parent)
self.iconHeight = height - 2
self.setObjectName("unselectedLocatorResult")
self.keyEventTarget = keyEventTarget
self.setLayout(QW.QHBoxLayout())
self.iconLabel = QW.QLabel( self )
self.iconLabel.setFixedHeight(self.iconHeight)
self.mainTextLabel = QW.QLabel( self )
self.primaryTextWidth = primaryTextWidth
self.mainTextLabel.setMinimumWidth(primaryTextWidth)
self.mainTextLabel.setTextFormat(QC.Qt.RichText)
self.secondaryTextLabel = QW.QLabel( self )
self.secondaryTextWidth = secondaryTextWidth
self.secondaryTextLabel.setMaximumWidth(secondaryTextWidth)
self.secondaryTextLabel.setTextFormat(QC.Qt.RichText)
self.layout().setContentsMargins(4, 1, 4, 1)
self.layout().addWidget(self.iconLabel)
self.layout().addWidget(self.mainTextLabel)
self.layout().addStretch(1)
self.layout().addWidget(self.secondaryTextLabel)
self.layout().setAlignment(self.mainTextLabel, QC.Qt.AlignVCenter)
self.layout().setAlignment(self.iconLabel, QC.Qt.AlignVCenter)
self.layout().setAlignment(self.secondaryTextLabel, QC.Qt.AlignVCenter)
self.setFixedHeight(height)
self.setSizePolicy(QW.QSizePolicy.Expanding, QW.QSizePolicy.Fixed)
self.activateEnterShortcut = QW.QShortcut(QG.QKeySequence(QC.Qt.Key_Enter), self)
self.activateEnterShortcut.setContext(QC.Qt.WidgetShortcut)
self.activateReturnShortcut = QW.QShortcut(QG.QKeySequence(QC.Qt.Key_Return), self)
self.activateReturnShortcut.setContext(QC.Qt.WidgetShortcut)
self.upShortcut = QW.QShortcut(QG.QKeySequence(QC.Qt.Key_Up), self)
self.upShortcut.setContext(QC.Qt.WidgetShortcut)
self.downShortcut = QW.QShortcut(QG.QKeySequence(QC.Qt.Key_Down), self)
self.downShortcut.setContext(QC.Qt.WidgetShortcut)
self.selectedPalette = self.palette()
self.selectedPalette.setColor(QG.QPalette.Window, QG.QPalette().color(QG.QPalette.WindowText))
self.selectedPalette.setColor(QG.QPalette.WindowText, QG.QPalette().color(QG.QPalette.Window))
self.id = -1
self.providerIndex = -1
self.closeOnActivated = False
self.selected = False
self.defaultStylingEnabled = True
self.currentIcon = QG.QIcon()
def handleActivated():
self.activated.emit(self.providerIndex, self.id, self.closeOnActivated)
self.activateEnterShortcut.activated.connect(handleActivated)
self.activateReturnShortcut.activated.connect(handleActivated)
self.upShortcut.activated.connect(self.up)
self.downShortcut.activated.connect(self.down)
def paintEvent(self, event):
opt = QW.QStyleOption()
opt.initFrom(self)
p = QG.QPainter(self)
self.style().drawPrimitive(QW.QStyle.PE_Widget, opt, p, self)
def updateData(self, providerIndex: int, data: QLocatorSearchResult):
self.currentIcon = QG.QIcon()
self.currentIcon.addFile(data.defaultIconPath, QC.QSize(), QG.QIcon.Normal)
self.currentIcon.addFile(data.selectedIconPath, QC.QSize(), QG.QIcon.Selected)
self.iconLabel.setPixmap(self.currentIcon.pixmap(self.iconHeight, self.iconHeight, QG.QIcon.Selected if self.selected else QG.QIcon.Normal))
self.mainTextLabel.clear()
self.secondaryTextLabel.clear()
if len(data.text) > 0:
self.mainTextLabel.setText(elideRichText(data.text[0], self.primaryTextWidth, self.mainTextLabel, False))
if len(data.text) > 1:
self.secondaryTextLabel.setText(elideRichText(data.text[1], self.secondaryTextWidth, self.secondaryTextLabel, True))
self.id = data.id
self.closeOnActivated = data.closeOnActivated
self.providerIndex = providerIndex
def setDefaultStylingEnabled(self, enabled: bool):
if self.defaultStylingEnabled and not enabled: self.setPalette(QG.QPalette())
self.defaultStylingEnabled = enabled
def setSelected(self, selected: bool):
self.selected = selected
if selected:
self.setObjectName("selectedLocatorResult")
if self.defaultStylingEnabled: self.setPalette(self.selectedPalette)
self.style().unpolish(self)
self.style().polish(self)
self.setFocus()
else:
self.setObjectName("unselectedLocatorResult")
if self.defaultStylingEnabled: self.setPalette(QG.QPalette())
self.style().unpolish(self)
self.style().polish(self)
self.iconLabel.setPixmap(self.currentIcon.pixmap(self.iconHeight, self.iconHeight, QG.QIcon.Selected if self.selected else QG.QIcon.Normal))
def keyPressEvent(self, ev: QG.QKeyEvent):
if ev.key() != QC.Qt.Key_Up and ev.key() != QC.Qt.Key_Down and ev.key() != QC.Qt.Key_Enter and ev.key() != QC.Qt.Key_Return:
QW.QApplication.postEvent(self.keyEventTarget, QG.QKeyEvent(ev.type(), ev.key(), ev.modifiers(), ev.text(), ev.isAutoRepeat()))
self.keyEventTarget.setFocus()
else:
super().keyPressEvent(self, ev)
class QAbstractLocatorSearchProvider(QC.QObject):
resultsAvailable = QC.Signal(int, list)
def __init__(self, parent = None):
super().__init__(parent)
class QExampleSearchProvider(QAbstractLocatorSearchProvider):
def __init__(self, parent = None):
super().__init__(parent)
def title(self):
return "Example search provider"
def suggestedReservedItemCount(self):
return 32
def resultSelected(self, resultID: int):
pass
def processQuery(self, query: str, context, jobID: int):
resCount = random.randint(0, 50)
results = []
for i in range(resCount):
randomStr = str()
for j in range(5):
randomStr += str(chr(97+random.randint(0, 26)))
txt = []
txt.append("Result <b>text</b> #" + str(i) + randomStr)
txt.append("Secondary result text")
results.append(QLocatorSearchResult(0, "icon.svg", "icon.svg", True, txt))
self.resultsAvailable.emit(jobID, results)
def stopJobs(self, jobs):
pass
def hideTitle(self):
return False
def titleIconPath(self):
return "icon.svg"
class QCalculatorSearchProvider(QAbstractLocatorSearchProvider):
def __init__(self, parent = None):
super().__init__(parent)
self.safeEnv = {
'ceil': math.ceil,
'abs': abs,
'floor': math.floor,
'gcd': math.gcd,
'exp': math.exp,
'log': math.log,
'log2': math.log2,
'log10': math.log10,
'pow': math.pow,
'sqrt': math.sqrt,
'acos': math.acos,
'asin': math.asin,
'atan': math.atan,
'atan2': math.atan2,
'cos': math.cos,
'hypot': math.hypot,
'sin': math.sin,
'tan': math.tan,
'degrees': math.degrees,
'radians': math.radians,
'acosh': math.acosh,
'asinh': math.asinh,
'atanh': math.atanh,
'cosh': math.cosh,
'sinh': math.sinh,
'tanh': math.tanh,
'erf': math.erf,
'erfc': math.erfc,
'gamma': math.gamma,
'lgamma': math.lgamma,
'pi': math.pi,
'e': math.e,
'inf': math.inf,
'randint': random.randint,
'random': random.random,
'factorial': math.factorial
}
self.safePattern = re.compile("^("+"|".join(self.safeEnv.keys())+r"|[0-9.*+\-%/()]|\s" + ")+$")
def processQuery(self, query: str, context, jobID: int):
try:
if len(query.strip()) and self.safePattern.match(query):
result = str(eval(query, {"__builtins__": {}}, self.safeEnv))
try:
int(result)
except:
result = str(float(result))
self.resultsAvailable.emit(jobID, [QLocatorSearchResult(0, self.iconPath(), self.selectedIconPath(), False, [result,"Calculator"])])
except:
pass
def title(self):
return str()
def suggestedReservedItemCount(self):
return 1
def resultSelected(self, resultID: int):
pass
def stopJobs(self, jobs):
pass
def hideTitle(self):
return True
def titleIconPath(self):
return str()
def selectedIconPath(self):
return str()
def iconPath(self):
return str()
class QLocatorWidget(QW.QWidget):
finished = QC.Signal()
def __init__(self, parent = None, width: int = 600, resultHeight: int = 36, titleHeight: int = 36, primaryTextWidth: int = 320, secondaryTextWidth: int = 200, maxVisibleItemCount: int = 8):
super().__init__(parent)
self.alignment = QC.Qt.AlignCenter
self.resultHeight = resultHeight
self.titleHeight = titleHeight
self.primaryTextWidth = primaryTextWidth
self.locator = None
self.secondaryTextWidth = secondaryTextWidth
self.maxVisibleItemCount = maxVisibleItemCount
self.reservedItemCounts = []
self.visibleResultItemCounts = []
self.currentJobIds = []
self.titleItems = []
self.resultItems = []
self.escapeShortcuts = []
self.selectedLayoutItemIndex = 0
self.defaultStylingEnabled = True
self.context = None
self.lastQuery = str()
self.setVisible(False)
self.setLayout(QW.QVBoxLayout())
self.searchEdit = QW.QLineEdit()
self.resultList = QW.QScrollArea()
self.resultLayout = QW.QVBoxLayout()
self.resultList.setWidget(QW.QWidget())
self.resultList.widget().setLayout(self.resultLayout)
self.resultList.setWidgetResizable(True)
self.resultLayout.setSizeConstraint(QW.QLayout.SetMinAndMaxSize)
self.layout().addWidget(self.searchEdit)
self.layout().addWidget(self.resultList)
self.layout().setContentsMargins(0, 0, 0, 0)
self.layout().setSpacing(0)
self.resultLayout.setContentsMargins(0, 0, 0, 0)
self.resultLayout.setSpacing(0)
self.setFixedWidth(width)
self.setWindowFlags(QC.Qt.FramelessWindowHint | QC.Qt.WindowStaysOnTopHint | QC.Qt.CustomizeWindowHint | QC.Qt.Popup)
self.resultList.setSizeAdjustPolicy(QW.QAbstractScrollArea.AdjustToContents)
self.setSizePolicy(QW.QSizePolicy.Fixed, QW.QSizePolicy.Maximum)
self.setEscapeShortcuts([QC.Qt.Key_Escape])
self.editorDownShortcut = QW.QShortcut(QG.QKeySequence(QC.Qt.Key_Down), self.searchEdit)
self.editorDownShortcut.setContext(QC.Qt.WidgetShortcut)
self.editorDownShortcut.activated.connect(self.handleEditorDown)
def handleTextEdited():
for i in range(len(self.resultItems)):
for it in self.resultItems[i]: self.setResultVisible(it, False)
self.setResultVisible(self.titleItems[i], False)
self.selectedLayoutItemIndex = 0
self.updateResultListHeight()
self.searchEdit.textEdited.connect(handleTextEdited)
def handleSearchFocused():
if self.selectedLayoutItemIndex < self.resultLayout.count():
widget = self.resultLayout.itemAt(self.selectedLayoutItemIndex).widget()
if widget:
if isinstance(widget, QLocatorResultWidget):
widget.setSelected(False)
self.selectedLayoutItemIndex = 0
filter = FocusEventFilter()
self.searchEdit.installEventFilter(filter)
filter.focused.connect(handleSearchFocused)
self.queryTimer = QC.QTimer(self)
self.queryTimer.setInterval(0)
self.queryTimer.setSingleShot(True)
def handleQueryTimeout():
if not self.locator: return
self.currentJobIds = self.locator.query(self.lastQuery, self.context)
self.queryTimer.timeout.connect(handleQueryTimeout)
self.searchEdit.textEdited.connect(self.queryLocator)
self.updateResultListHeight()
def setAlignment( self, alignment ):
if alignment == QC.Qt.AlignCenter:
self.alignment = alignment
self.updateAlignment()
elif alignment == QC.Qt.AlignTop:
self.alignment = alignment
self.updateAlignment()
def updateAlignment( self ):
widget = self
while True:
parent = widget.parentWidget()
if not parent:
break
else:
widget = parent
screenRect = QW.QApplication.primaryScreen().availableGeometry()
if widget != self: # there is a parent
screenRect = widget.geometry()
if self.alignment == QC.Qt.AlignCenter:
centerRect = QW.QStyle.alignedRect(QC.Qt.LeftToRight, QC.Qt.AlignCenter, self.size(), screenRect)
centerRect.setY(max(0, centerRect.y() - self.resultHeight * 4))
self.setGeometry(centerRect)
elif self.alignment == QC.Qt.AlignTop:
rect = QW.QStyle.alignedRect(QC.Qt.LeftToRight, QC.Qt.AlignHCenter | QC.Qt.AlignTop, self.size(), screenRect)
self.setGeometry(rect)
def paintEvent(self, event):
opt = QW.QStyleOption()
opt.initFrom(self)
p = QG.QPainter(self)
self.style().drawPrimitive(QW.QStyle.PE_Widget, opt, p, self)
def providerAdded(self, title: str, titleIconPath: str, suggestedReservedItemCount: int, hideTitle: bool):
newTitleWidget = QLocatorTitleWidget(title, titleIconPath, self.titleHeight, hideTitle)
self.visibleResultItemCounts.append(0)
self.reservedItemCounts.append(suggestedReservedItemCount)
self.titleItems.append(newTitleWidget)
self.resultLayout.addWidget(newTitleWidget)
newTitleWidget.setVisible(False)
self.resultItems.append([])
for i in range(suggestedReservedItemCount):
newWidget = QLocatorResultWidget(self.searchEdit, self.resultHeight, self.primaryTextWidth, self.secondaryTextWidth, self)
self.setupResultWidget(newWidget)
self.resultItems[-1].append(newWidget)
self.resultLayout.addWidget(newWidget)
newWidget.setVisible(False)
def setEscapeShortcuts(self, shortcuts):
for escapeShortcut in self.escapeShortcuts:
escapeShortcut.deleteLater()
self.escapeShortcuts = []
for shortcut in shortcuts:
newShortcut = QW.QShortcut(QG.QKeySequence(shortcut), self)
self.escapeShortcuts.append(newShortcut)
newShortcut.activated.connect(self.finish)
def setLocator(self, locator):
if self.locator:
self.locator.providerAdded.disconnect(self.providerAdded)
self.locator.resultsAvailable.disconnect(self.handleResultsAvailable)
self.reset()
self.locator = locator
if self.locator:
for provider in self.locator.providers:
self.providerAdded(provider.title(), self.locator.iconBasePath + provider.titleIconPath(), provider.suggestedReservedItemCount(), provider.hideTitle())
self.locator.providerAdded.connect(self.providerAdded)
self.locator.resultsAvailable.connect(self.handleResultsAvailable)
def setDefaultStylingEnabled(self, enabled: bool) -> None:
self.defaultStylingEnabled = enabled
for i in range(len(self.resultItems)):
for it in self.resultItems[i]: it.setDefaultStylingEnabled(enabled)
def __del__(self):
self.queryTimer.stop()
if self.locator:
if self.currentJobIds: self.locator.stopJobs(self.currentJobIds)
self.locator.providerAdded.disconnect(self.providerAdded)
self.locator.resultsAvailable.disconnect(self.handleResultsAvailable)
for it in self.titleItems:
it.deleteLater()
for i in range(len(self.resultItems)):
for it in self.resultItems[i]: it.deleteLater()
def setContext(self, context):
self.context = context
def setQueryTimeout(self, msec: int):
self.queryTimer.setInterval(msec)
def start(self):
self.clear()
self.updateAlignment()
self.show()
self.searchEdit.setFocus()
self.searchEdit.textEdited.emit("") # pylint: disable=E1101
def finish(self, doNotStopJobs: bool = False):
self.queryTimer.stop()
if self.locator and self.currentJobIds and not doNotStopJobs: self.locator.stopJobs(self.currentJobIds)
self.clear()
self.hide()
self.finished.emit()
def reset(self):
if self.locator and self.currentJobIds: self.locator.stopJobs(self.currentJobIds)
self.currentJobIds.clear()
self.reservedItemCounts.clear()
self.visibleResultItemCounts.clear()
for it in self.titleItems:
it.deleteLater()
self.titleItems = []
for i in range(len(self.resultItems)):
for it in self.resultItems[i]: it.deleteLater()
self.resultItems = []
if self.locator:
for provider in self.locator.providers:
self.providerAdded(provider.title(), self.locator.iconBasePath + provider.titleIconPath(), provider.suggestedReservedItemCount(), provider.hideTitle())
def handleResultsAvailable(self, providerIndex: int, jobID: int) -> None:
data = self.locator.getResult(jobID)
self.titleItems[providerIndex].updateData(len(data))
if not self.titleItems[providerIndex].shouldRemainHidden:
self.setResultVisible(self.titleItems[providerIndex], len(data) != 0)
if len(data): self.resultList.setVisible(True)
if len(data) > len(self.resultItems[providerIndex]):
titleItemIdx = 0
for i in range(self.resultLayout.count()):
if self.resultLayout.itemAt(i).widget() == self.titleItems[providerIndex]: break
titleItemIdx += 1
i = len(self.resultItems[providerIndex])
while i < len(data):
newWidget = QLocatorResultWidget(self.searchEdit, self.resultHeight, self.primaryTextWidth, self.secondaryTextWidth, self)
self.setupResultWidget(newWidget)
self.resultLayout.insertWidget(titleItemIdx + i + 1, newWidget)
self.resultItems[providerIndex].append(newWidget)
i += 1
for i in range(len(self.resultItems[providerIndex])):
if i < len(data):
self.resultItems[providerIndex][i].updateData(providerIndex, data[i])
if not self.resultItems[providerIndex][i].isVisible():
self.setResultVisible(self.resultItems[providerIndex][i], True)
else:
if self.resultItems[providerIndex][i].isVisible():
self.setResultVisible(self.resultItems[providerIndex][i], False)
self.updateResultListHeight()
def queryLocator(self, query: str) -> None:
if not self.locator: return
if self.currentJobIds:
self.locator.stopJobs(self.currentJobIds)
self.lastQuery = query
self.queryTimer.start()
def handleResultUp(self):
resultWidget = self.sender()
resultWidget.setSelected(False)
i = self.selectedLayoutItemIndex - 1
while i > 0:
widget = self.resultLayout.itemAt(i).widget()
if widget and widget.isVisible():
if isinstance(widget, QLocatorResultWidget):
self.selectedLayoutItemIndex = i
widget.setSelected(True)
self.resultList.ensureVisible(0, widget.pos().y(), 0, 0)
return
i = i - 1
self.searchEdit.setFocus()
self.resultList.ensureVisible(0, 0, 0, 0)
def handleResultDown(self):
resultWidget = self.sender()
i = self.selectedLayoutItemIndex + 1
while i < self.resultLayout.count():
widget = self.resultLayout.itemAt(i).widget()
if widget and widget.isVisible():
if isinstance(widget, QLocatorResultWidget):
self.selectedLayoutItemIndex = i
resultWidget.setSelected(False)
widget.setSelected(True)
self.resultList.ensureVisible(0, widget.pos().y() + widget.height(), 0, 0)
break
i = i + 1
def handleEditorDown(self):
for i in range(self.resultLayout.count()):
widget = self.resultLayout.itemAt(i).widget()
if widget and widget.isVisible():
if isinstance(widget, QLocatorResultWidget):
self.selectedLayoutItemIndex = i
widget.setSelected(True)
break
def handleResultActivated(self, provider: int, id: int, closeOnSelected: bool):
currJobIdsTmp = self.currentJobIds[:]
if closeOnSelected: self.finish(True)
if self.locator:
self.locator.activateResult(provider, id)
if closeOnSelected and currJobIdsTmp: self.locator.stopJobs(currJobIdsTmp)
def clear(self):
self.searchEdit.clear()
self.queryTimer.stop()
self.lastQuery = str()
self.context = None
for i in range(len(self.titleItems)):
self.titleItems[i].setVisible(False)
for it in self.resultItems[i]:
it.setVisible(False)
it.setSelected(False)
self.visibleResultItemCounts[i] = 0
self.deleteAdditionalItems()
self.updateResultListHeight()
def deleteAdditionalItems(self):
titleIndex = 0
for i in range(len(self.titleItems)):
resultItemCount = len(self.resultItems[i])
j = titleIndex
while j < self.resultLayout.count():
if self.resultLayout.itemAt(j).widget() == self.titleItems[i]: break
titleIndex += 1
j += 1
k = self.reservedItemCounts[i]
while k < resultItemCount:
self.resultLayout.takeAt(titleIndex + k).widget().deleteLater()
k += 1
self.resultItems[i] = self.resultItems[i][:self.reservedItemCounts[i]]
def getVisibleHeight(self) -> int:
itemCount = 0
height = 0
for i in range(len(self.titleItems)):
if itemCount >= self.maxVisibleItemCount: break
if self.visibleResultItemCounts[i] > 0:
if not self.titleItems[i].shouldRemainHidden:
itemCount += 1
height += self.titleHeight
if itemCount >= self.maxVisibleItemCount: break
visibleItems = min(self.visibleResultItemCounts[i], self.maxVisibleItemCount - itemCount)
itemCount += visibleItems
height += self.resultHeight * visibleItems
return height
def setResultVisible(self, widget: QW.QWidget, visible: bool):
if widget.isVisible() and not visible:
widget.setVisible(False)
if isinstance(widget, QLocatorResultWidget):
self.visibleResultItemCounts[widget.providerIndex] -= 1
widget.setSelected(False)
elif not widget.isVisible() and visible:
if isinstance(widget, QLocatorResultWidget):
self.visibleResultItemCounts[widget.providerIndex] += 1
widget.setVisible(True)
def updateResultListHeight(self):
pos = self.pos()
visibleHeight = self.getVisibleHeight()
if visibleHeight == 0:
self.resultList.setVisible(False)
else:
self.resultList.setVisible(True)
self.resultList.widget().setMaximumHeight(visibleHeight)
if self.resultList.isVisible():
self.setFixedHeight(self.searchEdit.sizeHint().height() + (self.resultList.height() - self.resultList.contentsRect().height()) + visibleHeight)
else:
self.setFixedHeight(self.searchEdit.sizeHint().height())
self.move(pos)
def setupResultWidget(self, widget):
widget.up.connect(self.handleResultUp)
widget.down.connect(self.handleResultDown)
widget.activated.connect(self.handleResultActivated)
widget.setDefaultStylingEnabled(self.defaultStylingEnabled)
class QLocator(QC.QObject):
iconBasePathChanged = QC.Signal(str)
providerAdded = QC.Signal(str, str, int, bool)
resultsAvailable = QC.Signal(int, int)
def __init__(self, parent):
super().__init__(parent)
self.providers = []
self.currentJobs = {}
self.savedProviderData = {}
self.jobIDCounter = 0
self.iconBasePath = str()
def addProvider(self, provider) -> None:
self.providers.append(provider)
provider.setParent(self)
provider.resultsAvailable.connect(self.handleItemUpdate)
self.providerAdded.emit(provider.title(), self.iconBasePath + provider.titleIconPath(), provider.suggestedReservedItemCount(), provider.hideTitle())
def provider(self, idx: int):
if idx >= 0 and idx <= len(self.providers):
return self.providers[idx]
return None
def getResult(self, jobID: int):
return self.savedProviderData.pop(jobID, None)
def __del__(self):
self.stopJobs()
def setIconBasePath(self, iconBasePath: str) -> None:
if self.iconBasePath != iconBasePath:
self.iconBasePath = iconBasePath
self.iconBasePathChanged.emit(self.iconBasePath)
def query(self, queryText: str, context) -> list:
jobIDs = []
for i in range(len(self.providers)):
self.currentJobs[self.jobIDCounter] = i
jobIDs.append(self.jobIDCounter)
self.providers[i].processQuery(queryText, context, self.jobIDCounter)
self.jobIDCounter += 1
return jobIDs
def activateResult(self, provider: int, id: int) -> None:
if provider >= 0 and provider < len(self.providers):
self.providers[provider].resultSelected(id)
def handleItemUpdate(self, jobID: int, data) -> None:
if jobID in self.currentJobs:
providerIndex = self.currentJobs[jobID]
del self.currentJobs[jobID]
self.savedProviderData[jobID] = data
for dataItem in self.savedProviderData[jobID]:
dataItem.defaultIconPath = self.iconBasePath + dataItem.defaultIconPath
dataItem.selectedIconPath = self.iconBasePath + dataItem.selectedIconPath
self.resultsAvailable.emit(providerIndex, jobID)
def stopJobs(self, ids = []) -> None:
if not len(ids):
self.currentJobs = {}
for provider in self.providers:
provider.stopJobs([])
self.savedProviderData = {}
else:
for provider in self.providers:
provider.stopJobs(ids)
for id in ids:
if id in self.currentJobs:
del self.currentJobs[id]
if id in self.savedProviderData:
del self.savedProviderData[id]

View File

@ -1221,51 +1221,7 @@ class Canvas( QW.QWidget ):
return
locations_manager = self._current_media.GetLocationsManager()
local_file_services = HG.client_controller.services_manager.GetServices( ( HC.LOCAL_FILE_DOMAIN, ) )
deleted_local_services = [ local_file_service for local_file_service in local_file_services if local_file_service.GetServiceKey() in locations_manager.GetDeleted() ]
if len( deleted_local_services ) > 0:
choice_tuples = []
for service in deleted_local_services:
choice_tuples.append( ( service.GetName(), service, service.GetName() ) )
try:
undelete_service = ClientGUIDialogsQuick.SelectFromListButtons( self, 'Undelete for?', choice_tuples, message = 'Which service to undelete back to?' )
except HydrusExceptions.CancelledException:
return
do_it = False
if len( choice_tuples ) > 1 or not HC.options[ 'confirm_trash' ]:
do_it = True
else:
result = ClientGUIDialogsQuick.GetYesNo( self, 'Undelete this file back to {}?'.format( undelete_service.GetName() ) )
if result == QW.QDialog.Accepted:
do_it = True
if do_it:
HG.client_controller.Write( 'content_updates', { undelete_service.GetServiceKey() : [ HydrusData.ContentUpdate( HC.CONTENT_TYPE_FILES, HC.CONTENT_UPDATE_UNDELETE, ( self._current_media.GetHash(), ) ) ] } )
ClientGUIMediaActions.UndeleteMedia( self, ( self._current_media, ) )
def _UpdateBackgroundColour( self ):
@ -3743,7 +3699,7 @@ class CanvasMediaList( ClientMedia.ListeningMediaList, CanvasWithHovers ):
def CommitArchiveDelete( page_key, kept_hashes, deleted_hashes ):
def CommitArchiveDelete( page_key: bytes, location_context: ClientLocation.LocationContext, kept_hashes: typing.Collection[ bytes ], deleted_hashes: typing.Collection[ bytes ] ):
if HC.options[ 'remove_filtered_files' ]:
@ -3765,7 +3721,19 @@ def CommitArchiveDelete( page_key, kept_hashes, deleted_hashes ):
kept_hashes = list( kept_hashes )
# we do a second set of removes to deal with late processing and a quick F5ing user
location_context = location_context.Duplicate()
location_context.FixMissingServices( ClientLocation.ValidLocalDomainsFilter )
if location_context.IncludesCurrent():
deletee_file_service_keys = location_context.current_service_keys
else:
# if we are in a weird search domain, then just say 'delete from all local'
deletee_file_service_keys = HG.client_controller.services_manager.GetServiceKeys( ( HC.LOCAL_FILE_DOMAIN, ) )
for block_of_deleted_hashes in HydrusData.SplitListIntoChunks( deleted_hashes, 64 ):
@ -3773,10 +3741,15 @@ def CommitArchiveDelete( page_key, kept_hashes, deleted_hashes ):
reason = 'Deleted in Archive/Delete filter.'
service_keys_to_content_updates[ CC.LOCAL_FILE_SERVICE_KEY ] = [ HydrusData.ContentUpdate( HC.CONTENT_TYPE_FILES, HC.CONTENT_UPDATE_DELETE, block_of_deleted_hashes, reason = reason ) ]
for deletee_file_service_key in deletee_file_service_keys:
service_keys_to_content_updates[ deletee_file_service_key ] = [ HydrusData.ContentUpdate( HC.CONTENT_TYPE_FILES, HC.CONTENT_UPDATE_DELETE, block_of_deleted_hashes, reason = reason ) ]
HG.client_controller.WriteSynchronous( 'content_updates', service_keys_to_content_updates )
# we do a second set of removes to deal with late processing and a quick F5ing user
if HC.options[ 'remove_filtered_files' ]:
HG.client_controller.pub( 'remove_media', page_key, block_of_deleted_hashes )
@ -3880,7 +3853,7 @@ class CanvasMediaListFilterArchiveDelete( CanvasMediaList ):
self._current_media = self._GetFirst() # so the pubsub on close is better
HG.client_controller.CallToThread( CommitArchiveDelete, self._page_key, kept_hashes, deleted_hashes )
HG.client_controller.CallToThread( CommitArchiveDelete, self._page_key, self._location_context, kept_hashes, deleted_hashes )

View File

@ -969,16 +969,23 @@ class CanvasHoverFrameTop( CanvasHoverFrame ):
locations_manager = self._current_media.GetLocationsManager()
if CC.LOCAL_FILE_SERVICE_KEY in locations_manager.GetCurrent():
self._trash_button.show()
self._delete_button.hide()
self._undelete_button.hide()
elif locations_manager.IsTrashed():
if locations_manager.IsTrashed():
self._trash_button.hide()
self._delete_button.show()
elif locations_manager.IsLocal():
self._trash_button.show()
self._delete_button.hide()
if set( locations_manager.GetDeleted() ).isdisjoint( HG.client_controller.services_manager.GetServiceKeys( ( HC.LOCAL_FILE_DOMAIN, ) ) ):
self._undelete_button.hide()
else:
self._undelete_button.show()

View File

@ -1389,14 +1389,7 @@ class PagesNotebook( QP.TabWidgetWithDnD ):
else:
tab_index = ClientGUIFunctions.NotebookScreenToHitTest( self, screen_position )
if tab_index == -1:
return self
on_child_notebook_somewhere = screen_position.y() > current_page.pos().y()
on_child_notebook_somewhere = current_page.mapFromGlobal( screen_position ).y() > current_page.pos().y()
if on_child_notebook_somewhere:

View File

@ -1733,42 +1733,9 @@ class MediaPanel( ClientMedia.ListeningMediaList, QW.QScrollArea ):
def _Undelete( self ):
hashes = self._GetSelectedHashes( has_location = CC.TRASH_SERVICE_KEY )
media = self._GetSelectedFlatMedia()
num_to_undelete = len( hashes )
if num_to_undelete > 0:
do_it = False
if not HC.options[ 'confirm_trash' ]:
do_it = True
else:
if num_to_undelete == 1:
message = 'Are you sure you want to undelete this file?'
else:
message = 'Are you sure you want to undelete these ' + HydrusData.ToHumanInt( num_to_undelete ) + ' files?'
result = ClientGUIDialogsQuick.GetYesNo( self, message )
if result == QW.QDialog.Accepted:
do_it = True
if do_it:
HG.client_controller.Write( 'content_updates', { CC.LOCAL_FILE_SERVICE_KEY : [ HydrusData.ContentUpdate( HC.CONTENT_TYPE_FILES, HC.CONTENT_UPDATE_UNDELETE, hashes ) ] } )
ClientGUIMediaActions.UndeleteMedia( self, media )
def _UpdateBackgroundColour( self ):
@ -3261,7 +3228,7 @@ class MediaPanelThumbnails( MediaPanel ):
self.ShowMenu()
def ShowMenu( self ):
def ShowMenu( self, do_not_show_just_return = False ):
new_options = HG.client_controller.new_options
@ -4204,9 +4171,14 @@ class MediaPanelThumbnails( MediaPanel ):
ClientGUIMenus.AppendMenu( menu, share_menu, 'share' )
if not do_not_show_just_return:
CGC.core().PopupMenu( self, menu )
CGC.core().PopupMenu( self, menu )
else:
return menu
def MaintainPageCache( self ):

View File

@ -2061,7 +2061,20 @@ class MediaCollection( MediaList, Media ):
pending = HydrusData.MassUnion( [ locations_manager.GetPending() for locations_manager in all_locations_managers ] )
petitioned = HydrusData.MassUnion( [ locations_manager.GetPetitioned() for locations_manager in all_locations_managers ] )
self._locations_manager = ClientMediaManagers.LocationsManager( current_to_timestamps, deleted_to_timestamps, pending, petitioned )
modified_times = { locations_manager.GetFileModifiedTimestamp() for locations_manager in all_locations_managers }
modified_times.discard( None )
if len( modified_times ) > 0:
modified_time = max( modified_times )
else:
modified_time = None
self._locations_manager = ClientMediaManagers.LocationsManager( current_to_timestamps, deleted_to_timestamps, pending, petitioned, file_modified_timestamp = modified_time )
def _RecalcInternals( self ):
@ -2485,11 +2498,13 @@ class MediaSingleton( Media ):
deleted_local_file_services = [ service for service in local_file_services if service.GetServiceKey() in deleted_service_keys ]
local_file_deletion_reason = locations_manager.GetLocalFileDeletionReason()
if CC.COMBINED_LOCAL_FILE_SERVICE_KEY in deleted_service_keys:
( timestamp, original_timestamp ) = locations_manager.GetDeletedTimestamps( CC.COMBINED_LOCAL_FILE_SERVICE_KEY )
lines.append( 'deleted from this client {}'.format( ClientData.TimestampToPrettyTimeDelta( timestamp ) ) )
lines.append( 'deleted from this client {} ({})'.format( ClientData.TimestampToPrettyTimeDelta( timestamp ), local_file_deletion_reason ) )
elif len( deleted_local_file_services ) > 0:
@ -2497,7 +2512,19 @@ class MediaSingleton( Media ):
( timestamp, original_timestamp ) = locations_manager.GetDeletedTimestamps( local_file_service.GetServiceKey() )
lines.append( 'removed from {} {}'.format( local_file_service.GetName(), ClientData.TimestampToPrettyTimeDelta( timestamp ) ) )
l = 'removed from {} {}'.format( local_file_service.GetName(), ClientData.TimestampToPrettyTimeDelta( timestamp ) )
if len( deleted_local_file_services ) == 1:
l = '{} ({})'.format( l, local_file_deletion_reason )
lines.append( l )
if len( deleted_local_file_services ) > 1:
lines.append( 'Deletion reason: {}'.format( local_file_deletion_reason ) )

View File

@ -269,7 +269,8 @@ class LocationsManager( object ):
inbox: bool = False,
urls: typing.Optional[ typing.Set[ str ] ] = None,
service_keys_to_filenames: typing.Optional[ typing.Dict[ bytes, str ] ] = None,
file_modified_timestamp: typing.Optional[ int ] = None
file_modified_timestamp: typing.Optional[ int ] = None,
local_file_deletion_reason: str = None
):
self._current_to_timestamps = current_to_timestamps
@ -297,6 +298,7 @@ class LocationsManager( object ):
self._service_keys_to_filenames = service_keys_to_filenames
self._file_modified_timestamp = file_modified_timestamp
self._local_file_deletion_reason = local_file_deletion_reason
def DeletePending( self, service_key ):
@ -314,7 +316,7 @@ class LocationsManager( object ):
urls = set( self._urls )
service_keys_to_filenames = dict( self._service_keys_to_filenames )
return LocationsManager( current_to_timestamps, deleted_to_timestamps, pending, petitioned, self.inbox, urls, service_keys_to_filenames, self._file_modified_timestamp )
return LocationsManager( current_to_timestamps, deleted_to_timestamps, pending, petitioned, self.inbox, urls, service_keys_to_filenames, self._file_modified_timestamp, self._local_file_deletion_reason )
def GetCDPP( self ):
@ -425,11 +427,28 @@ class LocationsManager( object ):
def GetLocalFileDeletionReason( self ) -> str:
if self._local_file_deletion_reason is None:
return 'Unknown deletion reason.'
else:
return self._local_file_deletion_reason
def GetURLs( self ):
return self._urls
def HasLocalFileDeletionReason( self ) -> bool:
return self._local_file_deletion_reason is not None
def IsDownloading( self ):
return CC.COMBINED_LOCAL_FILE_SERVICE_KEY in self._pending
@ -466,7 +485,9 @@ class LocationsManager( object ):
self._deleted.discard( service_key )
if service_key == CC.LOCAL_FILE_SERVICE_KEY:
local_service_keys = HG.client_controller.services_manager.GetServiceKeys( ( HC.LOCAL_FILE_DOMAIN, ) )
if service_key in local_service_keys:
if CC.TRASH_SERVICE_KEY in self._current_to_timestamps:
@ -492,7 +513,7 @@ class LocationsManager( object ):
self._pending.discard( service_key )
def _DeleteFromService( self, service_key ):
def _DeleteFromService( self, service_key: bytes, reason: typing.Optional[ str ] ):
if service_key in self._current_to_timestamps:
@ -517,7 +538,12 @@ class LocationsManager( object ):
local_service_keys = HG.client_controller.services_manager.GetServiceKeys( ( HC.LOCAL_FILE_DOMAIN, ) )
if service_key == CC.LOCAL_FILE_SERVICE_KEY:
if service_key in local_service_keys:
if reason is not None:
self._local_file_deletion_reason = reason
if self._current.isdisjoint( local_service_keys ):
@ -528,12 +554,12 @@ class LocationsManager( object ):
for local_service_key in list( self._current.intersection( local_service_keys ) ):
self._DeleteFromService( local_service_key )
self._DeleteFromService( local_service_key, reason )
if CC.TRASH_SERVICE_KEY in self._current:
self._DeleteFromService( CC.TRASH_SERVICE_KEY )
self._DeleteFromService( CC.TRASH_SERVICE_KEY, reason )
self.inbox = False
@ -588,7 +614,16 @@ class LocationsManager( object ):
elif action == HC.CONTENT_UPDATE_DELETE:
self._DeleteFromService( service_key )
if content_update.HasReason():
reason = content_update.GetReason()
else:
reason = None
self._DeleteFromService( service_key, reason )
elif action == HC.CONTENT_UPDATE_UNDELETE:

View File

@ -81,7 +81,7 @@ options = {}
# Misc
NETWORK_VERSION = 20
SOFTWARE_VERSION = 472
SOFTWARE_VERSION = 473
CLIENT_API_VERSION = 25
SERVER_THUMBNAIL_DIMENSIONS = ( 200, 200 )

View File

@ -2434,44 +2434,40 @@ class TestClientAPI( unittest.TestCase ):
for media_result in media_results:
metadata_row = {}
file_info_manager = media_result.GetFileInfoManager()
metadata_row[ 'file_id' ] = file_info_manager.hash_id
metadata_row[ 'hash' ] = file_info_manager.hash.hex()
metadata_row[ 'size' ] = file_info_manager.size
metadata_row[ 'mime' ] = HC.mime_mimetype_string_lookup[ file_info_manager.mime ]
metadata_row[ 'ext' ] = HC.mime_ext_lookup[ file_info_manager.mime ]
metadata_row[ 'width' ] = file_info_manager.width
metadata_row[ 'height' ] = file_info_manager.height
metadata_row[ 'duration' ] = file_info_manager.duration
metadata_row[ 'has_audio' ] = file_info_manager.has_audio
metadata_row[ 'num_frames' ] = file_info_manager.num_frames
metadata_row[ 'num_words' ] = file_info_manager.num_words
metadata_row[ 'file_services' ] = {
'current' : {
random_file_service_hex_current.hex() : {
'time_imported' : current_import_timestamp
metadata_row = {
'file_id' : file_info_manager.hash_id,
'hash' : file_info_manager.hash.hex(),
'size' : file_info_manager.size,
'mime' : HC.mime_mimetype_string_lookup[ file_info_manager.mime ],
'ext' : HC.mime_ext_lookup[ file_info_manager.mime ],
'width' : file_info_manager.width,
'height' : file_info_manager.height,
'duration' : file_info_manager.duration,
'has_audio' : file_info_manager.has_audio,
'num_frames' : file_info_manager.num_frames,
'num_words' : file_info_manager.num_words,
'file_services' : {
'current' : {
random_file_service_hex_current.hex() : {
'time_imported' : current_import_timestamp
}
},
'deleted' : {
random_file_service_hex_deleted.hex() : {
'time_deleted' : deleted_deleted_timestamp,
'time_imported' : deleted_import_timestamp
}
}
},
'deleted' : {
random_file_service_hex_deleted.hex() : {
'time_deleted' : deleted_deleted_timestamp,
'time_imported' : deleted_import_timestamp
}
}
'time_modified' : file_modified_timestamp,
'is_inbox' : False,
'is_local' : False,
'is_trashed' : False,
'known_urls' : list( sorted_urls )
}
metadata_row[ 'time_modified' ] = file_modified_timestamp
metadata_row[ 'is_inbox' ] = False
metadata_row[ 'is_local' ] = False
metadata_row[ 'is_trashed' ] = False
metadata_row[ 'known_urls' ] = list( sorted_urls )
tags_manager = media_result.GetTagsManager()
service_names_to_statuses_to_tags = {}

Binary file not shown.

View File

@ -64,6 +64,7 @@ jobs:
run: |
move ${{ steps.setup_ffmpeg.outputs.ffmpeg-path }} hydrus\bin\
move hydrus\static\build_files\windows\sqlite3.dll hydrus\
move hydrus\static\build_files\windows\sqlite3.exe hydrus\db
move hydrus\static\build_files\windows\client-win.spec client-win.spec
move hydrus\static\build_files\windows\server-win.spec server-win.spec
pyinstaller server-win.spec

BIN
static/images.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 B

BIN
static/lightning.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 B

View File

@ -120,6 +120,18 @@ QPushButton#HydrusOnOffButton[hydrus_on=false]
color: #800000;
}
/*
This is the Command Palette (default Ctrl+P), and specifically the background colour of the item you currently have selected.
*/
QLocatorResultWidget#selectedLocatorResult
{
background-color: #006ffa
}
/*
Custom Controls

BIN
static/thumbnails.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 B