code:
import itertools, os, random, time, wx

from lib.GUI.Canvas import CanvasFrame,  CanvasMediaListBrowser, \
	CanvasMediaListFilterArchiveDelete
from lib.GUI.Common import ApplyContentApplicationCommandToMedia, ConvertTextToPixels
from lib.GUI.Dialogs import DialogInputLocalBooruShare, DialogModifyAccounts, \
	DialogTextEntry, DialogYesNo, SelectServiceKey
from lib.GUI.Dialogs.Manage import DialogManageRatings
from lib.GUI.Dialogs.Quick import GetDeleteFilesJobs, SelectFromList
from lib.GUI.DragDrop import DoFileExportDragDrop
from lib.GUI.Export import ReviewExportFilesPanel
from lib.GUI.Menus import AppendMenu, AppendMenuItem, AppendMenuLabel, \
	AppendSeparator
from lib.GUI.ScrolledPanels import EditSingleCtrlPanel
from lib.GUI.ScrolledPanels.Edit import EditDuplicateActionOptionsPanel
from lib.GUI.ScrolledPanels.Management import ManageURLsPanel
from lib.GUI.Shortcuts import ShortcutsHandler
from lib.GUI.Tags import ExportToHTA, ManageTagsPanel
from lib.GUI.TopLevelWindows import DialogEdit, FrameThatTakesScrollablePanel,\
	DialogManage

from lib.Hydrus.Data import ContentUpdate, ConvertValueRangeToPrettyString, \
	GenerateKey, GetNow, GetNowPrecise, IntelligentMassIntersect, \
	MassUnion, ShowText, TimeHasPassedPrecise, ToHumanBytes, ToHumanInt
from lib.Hydrus.Data import AddPaddingToDimensions, ApplicationCommand, \
	GetLighterDarkerColour
from lib.Hydrus.Exceptions import CancelledException, DataMissing
from lib.Hydrus.Media import FlattenMedia, ListeningMediaList, MediaCollection, \
	MediaSingleton
from lib.Hydrus.Paths import LaunchFile, OpenFileLocation
from lib.Hydrus.Paths.Local import LaunchPathInWebBrowser, LaunchURLInWebBrowser
from lib.Hydrus.Search import Predicate
from lib.Hydrus.Threading import JobKey

### CONSTANTS ###

import lib.Hydrus.Globals as HG
import lib.Hydrus.Constants as HC
import lib.Hydrus.Constants.Local as CC

from lib.Hydrus.Caches import MENU_EVENT_ID_TO_ACTION_CACHE
from lib.Hydrus.Files.Local import REGENERATE_FILE_DATA_JOB_COMPLETE, \
	REGENERATE_FILE_DATA_JOB_FORCE_THUMBNAIL, REGENERATE_FILE_DATA_JOB_REFIT_THUMBNAIL

### FUNCTIONS ###

def CopyMediaURLs( medias ):
	urls = set()

	for media in medias:
		media_urls = media.GetLocationsManager().GetURLs()

		urls.update( media_urls )

	urls = list( urls )

	urls.sort()

	urls_string = os.linesep.join( urls )

	HG.client_controller.pub( 'clipboard', 'text', urls_string )

def CopyMediaURLMatchURLs( medias, url_match ):
	urls = set()

	for media in medias:
		media_urls = media.GetLocationsManager().GetURLs()

		for url in media_urls:
			if url_match.Matches( url ):
				urls.add( url )

	urls = list( urls )

	urls.sort()

	urls_string = os.linesep.join( urls )

	HG.client_controller.pub( 'clipboard', 'text', urls_string )

def DoOpenKnownURLFromShortcut( win, media ):
	urls = media.GetLocationsManager().GetURLs()

	matched_labels_and_urls = []
	unmatched_urls = []

	if len( urls ) > 0:
		for url in urls:
			url_match = HG.client_controller.network_engine.domain_manager.GetURLMatch( url )

			if url_match is None:
				unmatched_urls.append( url )

			else:
				label = url_match.GetName() + ': ' + url

				matched_labels_and_urls.append( ( label, url ) )

		matched_labels_and_urls.sort()
		unmatched_urls.sort()

	if len( matched_labels_and_urls ) == 0:
		return

	elif len( matched_labels_and_urls ) == 1:
		url = matched_labels_and_urls[0][1]

	else:
		matched_labels_and_urls.extend( ( url, url ) for url in unmatched_urls )

		try:
			url = SelectFromList( win, 'Select which URL', matched_labels_and_urls, sort_tuples = False )

		except CancelledException:
			return

	LaunchURLInWebBrowser( url )

def OpenURLs( urls ):
	urls = list( urls )

	urls.sort()

	if len( urls ) > 1:
		message = 'Open the {} URLs in your web browser?'.format( len( urls ) )

		if len( urls ) > 10:
			message += ' This will take some time.'

		with DialogYesNo( HG.client_controller.gui, message ) as dlg:
			if dlg.ShowModal() != wx.ID_YES:
				return

	def do_it( urls ):
		job_key = None

		num_urls = len( urls )

		if num_urls > 5:
			job_key = JobKey( pausable = True, cancellable = True )

			job_key.SetVariable( 'popup_title', 'Opening URLs' )

			HG.client_controller.pub( 'message', job_key )

		try:
			for ( i, url ) in enumerate( urls ):
				if job_key is not None:
					( i_paused, should_quit ) = job_key.WaitIfNeeded()

					if should_quit:
						return

					job_key.SetVariable( 'popup_text_1', ConvertValueRangeToPrettyString( i + 1, num_urls ) )
					job_key.SetVariable( 'popup_gauge_1', ( i + 1, num_urls ) )

				LaunchURLInWebBrowser( url )

				time.sleep( 1 )

		finally:
			if job_key is not None:
				job_key.Finish()

				job_key.Delete( 1 )

	HG.client_controller.CallToThread( do_it, urls )

def OpenMediaURLs( medias ):
	urls = set()

	for media in medias:
		media_urls = media.GetLocationsManager().GetURLs()

		urls.update( media_urls )

	OpenURLs( urls )

def OpenMediaURLMatchURLs( medias, url_match ):
	urls = set()

	for media in medias:
		media_urls = media.GetLocationsManager().GetURLs()

		for url in media_urls:
			if url_match.Matches( url ):
				urls.add( url )

	OpenURLs( urls )

def AddFileViewingStatsMenu( menu, focus_media ):
	view_style = HG.client_controller.new_options.GetInteger( 'file_viewing_stats_menu_display' )

	if view_style == CC.FILE_VIEWING_STATS_MENU_DISPLAY_NONE:
		return

	fvsm = focus_media.GetFileViewingStatsManager()

	if view_style == CC.FILE_VIEWING_STATS_MENU_DISPLAY_MEDIA_AND_PREVIEW_SUMMED:
		combined_line = fvsm.GetPrettyCombinedLine()

		AppendMenuLabel( menu, combined_line )

	else:
		media_line = fvsm.GetPrettyMediaLine()
		preview_line = fvsm.GetPrettyPreviewLine()

		if view_style == CC.FILE_VIEWING_STATS_MENU_DISPLAY_MEDIA_ONLY:
			AppendMenuLabel( menu, media_line )

		elif view_style == CC.FILE_VIEWING_STATS_MENU_DISPLAY_MEDIA_AND_PREVIEW_IN_SUBMENU:
			submenu = wx.Menu()

			AppendMenuLabel( submenu, preview_line )

			AppendMenu( menu, submenu, media_line )

		elif view_style == CC.FILE_VIEWING_STATS_MENU_DISPLAY_MEDIA_AND_PREVIEW_STACKED:
			AppendMenuLabel( menu, media_line )
			AppendMenuLabel( menu, preview_line )

def AddKnownURLsViewCopyMenu( win, menu, focus_media, selected_media = None ):
	# figure out which urls this focused file has

	focus_urls = focus_media.GetLocationsManager().GetURLs()

	focus_matched_labels_and_urls = []
	focus_unmatched_urls = []
	focus_labels_and_urls = []

	if len( focus_urls ) > 0:
		for url in focus_urls:
			url_match = HG.client_controller.network_engine.domain_manager.GetURLMatch( url )

			if url_match is None:
				focus_unmatched_urls.append( url )

			else:
				label = url_match.GetName() + ': ' + url

				focus_matched_labels_and_urls.append( ( label, url ) )

		focus_matched_labels_and_urls.sort()
		focus_unmatched_urls.sort()

		focus_labels_and_urls = list( focus_matched_labels_and_urls )

		focus_labels_and_urls.extend( ( ( url, url ) for url in focus_unmatched_urls ) )

	# figure out which urls these selected files have

	selected_media_url_matches = set()
	multiple_or_unmatching_selection_url_matches = False

	if selected_media is not None and len( selected_media ) > 1:
		selected_media = FlattenMedia( selected_media )

		SAMPLE_SIZE = 256

		if len( selected_media ) > SAMPLE_SIZE:
			selected_media_sample = random.sample( selected_media, SAMPLE_SIZE )

		else:
			selected_media_sample = selected_media

		for media in selected_media_sample:
			media_urls = media.GetLocationsManager().GetURLs()

			for url in media_urls:
				url_match = HG.client_controller.network_engine.domain_manager.GetURLMatch( url )

				if url_match is None:
					multiple_or_unmatching_selection_url_matches = True

				else:
					selected_media_url_matches.add( url_match )

		if len( selected_media_url_matches ) > 1:
			multiple_or_unmatching_selection_url_matches = True

	if len( focus_labels_and_urls ) > 0 or len( selected_media_url_matches ) > 0 or multiple_or_unmatching_selection_url_matches:
		urls_menu = wx.Menu()

		urls_visit_menu = wx.Menu()
		urls_copy_menu = wx.Menu()

		# copy each this file's urls (of a particular type)

		if len( focus_labels_and_urls ) > 0:
			for ( label, url ) in focus_labels_and_urls:
				AppendMenuItem( win, urls_visit_menu, label, 'Open this url in your web browser.', LaunchURLInWebBrowser, url )
				AppendMenuItem( win, urls_copy_menu, label, 'Copy this url to your clipboard.', HG.client_controller.pub, 'clipboard', 'text', url )

		# copy this file's urls

		there_are_focus_url_matches_to_action = len( focus_matched_labels_and_urls ) > 1
		multiple_or_unmatching_focus_url_matches = len( focus_unmatched_urls ) > 0 and len( focus_labels_and_urls ) > 1 # if there are unmatched urls and more than one thing total

		if there_are_focus_url_matches_to_action or multiple_or_unmatching_focus_url_matches:
			AppendSeparator( urls_visit_menu )
			AppendSeparator( urls_copy_menu )

		if there_are_focus_url_matches_to_action:
			urls = [ url for ( label, url ) in focus_matched_labels_and_urls ]

			label = 'open this file\'s ' + ToHumanInt( len( urls ) ) + ' recognised urls in your web browser'

			AppendMenuItem( win, urls_visit_menu, label, 'Open these urls in your web browser.', OpenURLs, urls )

			urls_string = os.linesep.join( urls )

			label = 'copy this file\'s ' + ToHumanInt( len( urls ) ) + ' recognised urls to your clipboard'

			AppendMenuItem( win, urls_copy_menu, label, 'Copy these urls to your clipboard.', HG.client_controller.pub, 'clipboard', 'text', urls_string )

		if multiple_or_unmatching_focus_url_matches:
			urls = [ url for ( label, url ) in focus_labels_and_urls ]

			label = 'open this file\'s ' + ToHumanInt( len( urls ) ) + ' urls in your web browser'

			AppendMenuItem( win, urls_visit_menu, label, 'Open these urls in your web browser.', OpenURLs, urls )

			urls_string = os.linesep.join( urls )

			label = 'copy this file\'s ' + ToHumanInt( len( urls ) ) + ' urls to your clipboard'

			AppendMenuItem( win, urls_copy_menu, label, 'Copy this url to your clipboard.', HG.client_controller.pub, 'clipboard', 'text', urls_string )

		# now by url match type

		there_are_selection_url_matches_to_action = len( selected_media_url_matches ) > 0

		if there_are_selection_url_matches_to_action or multiple_or_unmatching_selection_url_matches:
			AppendSeparator( urls_visit_menu )
			AppendSeparator( urls_copy_menu )

		if there_are_selection_url_matches_to_action:
			selected_media_url_matches = list( selected_media_url_matches )

			selected_media_url_matches.sort( key = lambda url_match: url_match.GetName() )

			for url_match in selected_media_url_matches:
				label = 'open files\' ' + url_match.GetName() + ' urls in your web browser'

				AppendMenuItem( win, urls_visit_menu, label, 'Open this url class in your web browser for all files.', OpenMediaURLMatchURLs, selected_media, url_match )

				label = 'copy files\' ' + url_match.GetName() + ' urls'

				AppendMenuItem( win, urls_copy_menu, label, 'Copy this url class for all files.', CopyMediaURLMatchURLs, selected_media, url_match )

		# now everything

		if multiple_or_unmatching_selection_url_matches:
			label = 'open all files\' urls'

			AppendMenuItem( win, urls_visit_menu, label, 'Open urls in your web browser for all files.', OpenMediaURLs, selected_media )

			label = 'copy all files\' urls'

			AppendMenuItem( win, urls_copy_menu, label, 'Copy urls for all files.', CopyMediaURLs, selected_media )

		#

		AppendMenu( urls_menu, urls_visit_menu, 'open' )
		AppendMenu( urls_menu, urls_copy_menu, 'copy' )

		AppendMenu( menu, urls_menu, 'known urls' )

def AddServiceKeyLabelsToMenu( menu, service_keys, phrase ):
	services_manager = HG.client_controller.services_manager

	if len( service_keys ) == 1:
		( service_key, ) = service_keys

		name = services_manager.GetName( service_key )

		label = phrase + ' ' + name

		AppendMenuLabel( menu, label )

	else:
		submenu = wx.Menu()

		for service_key in service_keys:
			name = services_manager.GetName( service_key )

			AppendMenuLabel( submenu, name )

		AppendMenu( menu, submenu, phrase + '\u2026' )

def AddServiceKeysToMenu( event_handler, menu, service_keys, phrase, description, callable ):
	services_manager = HG.client_controller.services_manager

	if len( service_keys ) == 1:
		( service_key, ) = service_keys

		name = services_manager.GetName( service_key )

		label = phrase + ' ' + name

		AppendMenuItem( event_handler, menu, label, description, callable, service_key )

	else:
		submenu = wx.Menu()

		for service_key in service_keys:
			name = services_manager.GetName( service_key )

			AppendMenuItem( event_handler, submenu, name, description, callable, service_key )

		AppendMenu( menu, submenu, phrase + '\u2026' )

class MediaPanel( ListeningMediaList, wx.ScrolledCanvas ):
	def __init__( self, parent, page_key, file_service_key, media_results ):
		wx.ScrolledCanvas.__init__( self, parent, size = ( 20, 20 ), style = wx.BORDER_SUNKEN )
		ListeningMediaList.__init__( self, file_service_key, media_results )

		self._UpdateBackgroundColour()

		self.SetScrollRate( 0, 50 )

		self._page_key = page_key

		self._focussed_media = None
		self._next_best_media_after_focussed_media_removed = None
		self._shift_focussed_media = None

		self._selected_media = set()

		HG.client_controller.sub( self, 'AddMediaResults', 'add_media_results' )
		HG.client_controller.sub( self, 'SetFocussedMedia', 'set_focus' )
		HG.client_controller.sub( self, 'Collect', 'collect_media' )
		HG.client_controller.sub( self, 'Sort', 'sort_media' )
		HG.client_controller.sub( self, 'FileDumped', 'file_dumped' )
		HG.client_controller.sub( self, 'RemoveMedia', 'remove_media' )
		HG.client_controller.sub( self, '_UpdateBackgroundColour', 'notify_new_colourset' )
		HG.client_controller.sub( self, 'SelectByTags', 'select_files_with_tags' )

		self._due_a_forced_selection_pub = False

		self._PublishSelectionChange()

		self._my_shortcut_handler = ShortcutsHandler( self, [ 'media' ] )

	def _Archive( self ):
		hashes = self._GetSelectedHashes( discriminant = CC.DISCRIMINANT_INBOX )

		if len( hashes ) > 0:
			if HC.options[ 'confirm_archive' ]:
				if len( hashes ) > 1:
					message = 'Archive ' + ToHumanInt( len( hashes ) ) + ' files?'

					with DialogYesNo( self, message ) as dlg:
						if dlg.ShowModal() != wx.ID_YES:
							return

			HG.client_controller.Write( 'content_updates', { CC.COMBINED_LOCAL_FILE_SERVICE_KEY : [ ContentUpdate( HC.CONTENT_TYPE_FILES, HC.CONTENT_UPDATE_ARCHIVE, hashes ) ] } )

	def _ArchiveDeleteFilter( self ):
		media_results = self.GenerateMediaResults( discriminant = CC.DISCRIMINANT_LOCAL_BUT_NOT_IN_TRASH, selected_media = set( self._selected_media ), for_media_viewer = True )

		if len( media_results ) > 0:
			canvas_frame = CanvasFrame( self.GetTopLevelParent() )

			canvas_window = CanvasMediaListFilterArchiveDelete( canvas_frame, self._page_key, media_results )

			canvas_frame.SetCanvas( canvas_window )

	def _CopyBMPToClipboard( self ):
		if self._focussed_media is not None:
			media = self._focussed_media.GetDisplayMedia()

			if media.GetMime() in HC.IMAGES and media.GetDuration() is None:
				HG.client_controller.pub( 'clipboard', 'bmp', media )

			else:
				wx.MessageBox( 'Sorry, cannot take bmps of anything but static images right now!' )

	def _CopyFilesToClipboard( self ):
		client_files_manager = HG.client_controller.client_files_manager

		media = self._GetSelectedFlatMedia( discriminant = CC.DISCRIMINANT_LOCAL )

		paths = []

		for m in media:
			hash = m.GetHash()
			mime = m.GetMime()

			path = client_files_manager.GetFilePath( hash, mime, check_file_exists = False )

			paths.append( path )

		HG.client_controller.pub( 'clipboard', 'paths', paths )

	def _CopyHashToClipboard( self, hash_type ):
		display_media = self._focussed_media.GetDisplayMedia()

		sha256_hash = display_media.GetHash()

		if hash_type == 'sha256':
			hex_hash = sha256_hash.hex()

		else:
			if display_media.GetLocationsManager().IsLocal():
				( other_hash, ) = HG.client_controller.Read( 'file_hashes', ( sha256_hash, ), 'sha256', hash_type )

				hex_hash = other_hash.hex()

			else:
				wx.MessageBox( 'Unfortunately, you do not have that file in your database, so its non-sha256 hashes are unknown.' )

				return

		HG.client_controller.pub( 'clipboard', 'text', hex_hash )

	def _CopyHashesToClipboard( self, hash_type ):
		if hash_type == 'sha256':
			hex_hashes = os.linesep.join( [ hash.hex() for hash in self._GetSelectedHashes( ordered = True ) ] )

		else:
			sha256_hashes = self._GetSelectedHashes( discriminant = CC.DISCRIMINANT_LOCAL, ordered = True )

			if len( sha256_hashes ) > 0:
				other_hashes = HG.client_controller.Read( 'file_hashes', sha256_hashes, 'sha256', hash_type )

				hex_hashes = os.linesep.join( [ other_hash.hex() for other_hash in other_hashes ] )

			else:
				wx.MessageBox( 'Unfortunately, none of those files are in your database, so their non-sha256 hashes are unknown.' )

				return

		HG.client_controller.pub( 'clipboard', 'text', hex_hashes )

	def _CopyPathToClipboard( self ):
		display_media = self._focussed_media.GetDisplayMedia()

		client_files_manager = HG.client_controller.client_files_manager

		path = client_files_manager.GetFilePath( display_media.GetHash(), display_media.GetMime() )

		HG.client_controller.pub( 'clipboard', 'text', path )

	def _CopyPathsToClipboard( self ):
		media_results = self.GenerateMediaResults( discriminant = CC.DISCRIMINANT_LOCAL, selected_media = set( self._selected_media ) )

		client_files_manager = HG.client_controller.client_files_manager

		paths = []

		for media_result in media_results:
			paths.append( client_files_manager.GetFilePath( media_result.GetHash(), media_result.GetMime(), check_file_exists = False ) )

		text = os.linesep.join( paths )

		HG.client_controller.pub( 'clipboard', 'text', text )

	def _CopyServiceFilenameToClipboard( self, service_key ):
		display_media = self._focussed_media.GetDisplayMedia()

		hash = display_media.GetHash()

		( filename, ) = HG.client_controller.Read( 'service_filenames', service_key, { hash } )

		service = HG.client_controller.services_manager.GetService( service_key )

		if service.GetServiceType() == HC.IPFS:
			multihash_prefix = service.GetMultihashPrefix()

			filename = multihash_prefix + filename

		HG.client_controller.pub( 'clipboard', 'text', filename )

	def _CopyServiceFilenamesToClipboard( self, service_key ):
		prefix = ''

		service = HG.client_controller.services_manager.GetService( service_key )

		if service.GetServiceType() == HC.IPFS:
			prefix = service.GetMultihashPrefix()

		hashes = self._GetSelectedHashes( has_location = service_key )

		if len( hashes ) > 0:
			filenames = [ prefix + filename for filename in HG.client_controller.Read( 'service_filenames', service_key, hashes ) ]

			if len( filenames ) > 0:
				copy_string = os.linesep.join( filenames )

				HG.client_controller.pub( 'clipboard', 'text', copy_string )

			else:
				ShowText( 'Could not find any service filenames for that selection!' )

		else:
			ShowText( 'Could not find any files with the requested service!' )

	def _Delete( self, file_service_key = None ):
		if file_service_key is None or file_service_key in ( CC.LOCAL_FILE_SERVICE_KEY, CC.TRASH_SERVICE_KEY ):
			default_reason = 'Deleted from Media Page.'

		else:
			default_reason = 'admin'

		try:
			( involves_physical_delete, jobs ) = GetDeleteFilesJobs( self, self._selected_media, default_reason, suggested_file_service_key = file_service_key )

		except CancelledException:
			return

		if involves_physical_delete:
			self._SetFocussedMedia( None )

		def do_it( jobs ):
			for service_keys_to_content_updates in jobs:
				HG.client_controller.WriteSynchronous( 'content_updates', service_keys_to_content_updates )

		HG.client_controller.CallToThread( do_it, jobs )

	def _DeselectSelect( self, media_to_deselect, media_to_select ):
		if len( media_to_deselect ) > 0:
			for m in media_to_deselect:
				m.Deselect()

			self._RedrawMedia( media_to_deselect )

			self._selected_media.difference_update( media_to_deselect )

		if len( media_to_select ) > 0:
			for m in media_to_select:
				m.Select()

			self._RedrawMedia( media_to_select )

			self._selected_media.update( media_to_select )

		self._PublishSelectionChange()

	def _DownloadSelected( self ):
		hashes = self._GetSelectedHashes( discriminant = CC.DISCRIMINANT_NOT_LOCAL )

		self._DownloadHashes( hashes )

	def _DownloadHashes( self, hashes ):
		HG.client_controller.Write( 'content_updates', { CC.COMBINED_LOCAL_FILE_SERVICE_KEY : [ ContentUpdate( HC.CONTENT_TYPE_FILES, HC.CONTENT_UPDATE_PEND, hashes ) ] } )

	def _EditDuplicateActionOptions( self, duplicate_type ):
		new_options = HG.client_controller.new_options

		duplicate_action_options = new_options.GetDuplicateActionOptions( duplicate_type )

		with DialogEdit( self, 'edit duplicate merge options' ) as dlg_2:
			panel = EditDuplicateActionOptionsPanel( dlg_2, duplicate_type, duplicate_action_options )

			dlg_2.SetPanel( panel )

			if dlg_2.ShowModal() == wx.ID_OK:
				duplicate_action_options = panel.GetValue()

				new_options.SetDuplicateActionOptions( duplicate_type, duplicate_action_options )

	def _ExportFiles( self, do_export_and_then_quit = False ):
		if len( self._selected_media ) > 0:
			flat_media = []

			for media in self._sorted_media:
				if media in self._selected_media:
					if media.IsCollection():
						flat_media.extend( media.GetFlatMedia() )

					else:
						flat_media.append( media )

			frame = FrameThatTakesScrollablePanel( self, 'export files' )

			panel = ReviewExportFilesPanel( frame, flat_media, do_export_and_then_quit = do_export_and_then_quit )

			frame.SetPanel( panel )

	def _ExportTags( self ):
		if len( self._selected_media ) > 0:
			services = HG.client_controller.services_manager.GetServices( ( HC.LOCAL_TAG, HC.TAG_REPOSITORY, HC.COMBINED_TAG ) )

			service_keys = [ service.GetServiceKey() for service in services ]

			service_key = SelectServiceKey( service_keys = service_keys )

			hashes = self._GetSelectedHashes()

			if service_key is not None:
				ExportToHTA( self, service_key, hashes )

	def _FullScreen( self, first_media = None ):
		if self._focussed_media is not None:
			display_media = self._focussed_media.GetDisplayMedia()

			new_options = HG.client_controller.new_options

			media_show_action = new_options.GetMediaShowAction( display_media.GetMime() )

			if media_show_action == CC.MEDIA_VIEWER_ACTION_DO_NOT_SHOW_ON_ACTIVATION_OPEN_EXTERNALLY:
				hash = display_media.GetHash()
				mime = display_media.GetMime()

				client_files_manager = HG.client_controller.client_files_manager

				path = client_files_manager.GetFilePath( hash, mime )

				new_options = HG.client_controller.new_options

				launch_path = new_options.GetMimeLaunch( mime )

				LaunchFile( path, launch_path )

				return

			elif media_show_action == CC.MEDIA_VIEWER_ACTION_DO_NOT_SHOW:
				return

		media_results = self.GenerateMediaResults( discriminant = CC.DISCRIMINANT_LOCAL, for_media_viewer = True )

		if len( media_results ) > 0:
			if first_media is None and self._focussed_media is not None:
				first_media = self._focussed_media

			if first_media is not None and first_media.GetLocationsManager().IsLocal():
				first_hash = first_media.GetDisplayMedia().GetHash()
			else:
				first_hash = None

			canvas_frame = CanvasFrame( self.GetTopLevelParent() )

			canvas_window = CanvasMediaListBrowser( canvas_frame, self._page_key, media_results, first_hash )

			canvas_frame.SetCanvas( canvas_window )

	def _GetNumSelected( self ):
		return sum( [ media.GetNumFiles() for media in self._selected_media ] )

	def _GetPrettyStatus( self ):
		num_files = len( self._hashes )

		num_selected = self._GetNumSelected()

		( num_files_descriptor, selected_files_descriptor ) = self._GetSortedSelectedMimeDescriptors()

		if num_files == 1:
			num_files_string = '1 ' + num_files_descriptor

		else:
			num_files_string = ToHumanInt( num_files ) + ' ' + num_files_descriptor + 's'

		s = num_files_string # 23 files

		if num_selected == 0:
			if num_files > 0:
				pretty_total_size = self._GetPrettyTotalSize()

				s += ' - totalling ' + pretty_total_size

		else:
			s += ' - '

			if num_selected == 1 or selected_files_descriptor == num_files_descriptor:
				selected_files_string = ToHumanInt( num_selected )

			else:
				selected_files_string = ToHumanInt( num_selected ) + ' ' + selected_files_descriptor + 's'

			if num_selected == 1:
			# 23 files - 1 video selected, file_info

				( selected_media, ) = self._selected_media

				s += selected_files_string + ' selected, ' + ', '.join( selected_media.GetPrettyInfoLines() )

			else:
			# 23 files - 5 selected, selection_info

				num_inbox = sum( ( media.GetNumInbox() for media in self._selected_media ) )

				if num_inbox == num_selected:
					inbox_phrase = 'all in inbox, '

				elif num_inbox == 0:
					inbox_phrase = 'all archived, '

				else:
					inbox_phrase = ToHumanInt( num_inbox ) + ' in inbox and ' + ToHumanInt( num_selected - num_inbox ) + ' archived, '

				pretty_total_size = self._GetPrettyTotalSize( only_selected = True )

				s += selected_files_string + ' selected, ' + inbox_phrase + 'totalling ' + pretty_total_size

		return s

	def _GetPrettyTotalSize( self, only_selected = False ):
		if only_selected:
			media_source = self._selected_media

		else:
			media_source = self._sorted_media

		total_size = sum( [ media.GetSize() for media in media_source ] )

		unknown_size = False in ( media.IsSizeDefinite() for media in media_source )

		if total_size == 0:
			if unknown_size:
				return 'unknown size'

			else:
				return ToHumanBytes( 0 )

		else:
			if unknown_size:
				return ToHumanBytes( total_size ) + ' + some unknown size'

			else:
				return ToHumanBytes( total_size )

	def _GetSelectedHashes( self, has_location = None, discriminant = None, not_uploaded_to = None, ordered = False ):
		if ordered:
			result = []

			for media in self._sorted_media:
				if media in self._selected_media:
					result.extend( media.GetHashes( has_location, discriminant, not_uploaded_to, ordered ) )

		else:
			result = set()

			for media in self._selected_media:
				result.update( media.GetHashes( has_location, discriminant, not_uploaded_to, ordered ) )

		return result

	def _GetSelectedCollections( self ):
		sorted_selected_collections = [ media for media in self._sorted_media if media.IsCollection() and media in self._selected_media ]

		return sorted_selected_collections

	def _GetSelectedFlatMedia( self, has_location = None, discriminant = None, not_uploaded_to = None ):
		# this now always delivers sorted results

		sorted_selected_media = [ media for media in self._sorted_media if media in self._selected_media ]

		flat_media = FlattenMedia( sorted_selected_media )

		flat_media = [ media for media in flat_media if media.MatchesDiscriminant( has_location = has_location, discriminant = discriminant, not_uploaded_to = not_uploaded_to ) ]

		return flat_media

	def _GetSimilarTo( self, max_hamming ):
		if self._focussed_media is not None:
			hash = self._focussed_media.GetDisplayMedia().GetHash()

			initial_predicates = [ Predicate( HC.PREDICATE_TYPE_SYSTEM_SIMILAR_TO, ( hash, max_hamming ) ) ]

			HG.client_controller.pub( 'new_page_query', CC.LOCAL_FILE_SERVICE_KEY, initial_predicates = initial_predicates )

	def _GetSortedSelectedMimeDescriptors( self ):
		def GetDescriptor( classes ):
			if len( classes ) == 0:
				return 'file'

			if len( classes ) == 1:
				( mime, ) = classes

				return HC.mime_string_lookup[ mime ]

			if len( classes.difference( HC.IMAGES ) ) == 0:
				return 'image'

			elif len( classes.difference( HC.VIDEO ) ) == 0:
				return 'video'

			elif len( classes.difference( HC.AUDIO ) ) == 0:
				return 'audio file'

			else:
				return 'file'

		if len( self._sorted_media ) > 1000:
			sorted_mime_descriptor = 'file'

		else:
			sorted_mimes = { media.GetMime() for media in self._sorted_media }

			sorted_mime_descriptor = GetDescriptor( sorted_mimes )

		if len( self._selected_media ) > 1000:
			selected_mime_descriptor = 'file'

		else:
			selected_mimes = { media.GetMime() for media in self._selected_media }

			selected_mime_descriptor = GetDescriptor( selected_mimes )

		return ( sorted_mime_descriptor, selected_mime_descriptor )

	def _HitMedia( self, media, ctrl, shift ):
		if media is None:
			if not ctrl and not shift:
				self._Select( 'none' )
				self._SetFocussedMedia( None )
				self._shift_focussed_media = None

		else:
			if ctrl:
				if media.IsSelected():
					self._DeselectSelect( ( media, ), () )

					if self._focussed_media == media:
						self._SetFocussedMedia( None )

					self._shift_focussed_media = None

				else:
					self._DeselectSelect( (), ( media, ) )

					if self._focussed_media is None:
						self._SetFocussedMedia( media )

					self._shift_focussed_media = media

			elif shift and self._shift_focussed_media is not None:
				start_index = self._sorted_media.index( self._shift_focussed_media )

				end_index = self._sorted_media.index( media )

				if start_index < end_index:
					media_to_select = set( self._sorted_media[ start_index : end_index + 1 ] )
				else:
					media_to_select = set( self._sorted_media[ end_index : start_index + 1 ] )

				self._DeselectSelect( (), media_to_select )

				self._SetFocussedMedia( media )

				self._shift_focussed_media = media

			else:
				if not media.IsSelected():
					self._DeselectSelect( self._selected_media, ( media, ) )

				else:
					self._PublishSelectionChange()

				self._SetFocussedMedia( media )
				self._shift_focussed_media = media

	def _Inbox( self ):
		hashes = self._GetSelectedHashes( discriminant = CC.DISCRIMINANT_ARCHIVE )

		if len( hashes ) > 0:
			if HC.options[ 'confirm_archive' ]:
				if len( hashes ) > 1:
					message = 'Send ' + ToHumanInt( len( hashes ) ) + ' files to inbox?'

					with DialogYesNo( self, message ) as dlg:
						if dlg.ShowModal() != wx.ID_YES:
							return

			HG.client_controller.Write( 'content_updates', { CC.COMBINED_LOCAL_FILE_SERVICE_KEY: [ ContentUpdate( HC.CONTENT_TYPE_FILES, HC.CONTENT_UPDATE_INBOX, hashes ) ] } )

	def _ManageNotes( self ):
		def wx_do_it( media, notes ):
			if not self:
				return

			title = 'manage notes'

			with DialogEdit( self, title ) as dlg:
				panel = EditSingleCtrlPanel( dlg, [ 'manage_file_notes' ] )

				control = wx.TextCtrl( panel, style = wx.TE_MULTILINE )

				size = ConvertTextToPixels( control, ( 80, 14 ) )

				control.SetInitialSize( size )

				control.SetValue( notes )

				panel.SetControl( control )

				dlg.SetPanel( panel )

				wx.CallAfter( control.SetFocus )
				wx.CallAfter( control.SetInsertionPointEnd )

				if dlg.ShowModal() == wx.ID_OK:
					notes = control.GetValue()

					hash = media.GetHash()

					content_updates = [ ContentUpdate( HC.CONTENT_TYPE_NOTES, HC.CONTENT_UPDATE_SET, ( notes, hash ) ) ]

					service_keys_to_content_updates = { CC.LOCAL_NOTES_SERVICE_KEY : content_updates }

					HG.client_controller.Write( 'content_updates', service_keys_to_content_updates )

			self.SetFocus()

		def thread_wait( media ):
			# if it ultimately makes sense, I can load/cache notes in the media result

			notes = HG.client_controller.Read( 'file_notes', media.GetHash() )

			wx.CallAfter( wx_do_it, media, notes )

		if self._focussed_media is None:
			return

		HG.client_controller.CallToThread( thread_wait, self._focussed_media.GetDisplayMedia() )

	def _ManageRatings( self ):
		if len( self._selected_media ) > 0:
			if len( HG.client_controller.services_manager.GetServices( HC.RATINGS_SERVICES ) ) > 0:
				flat_media = self._GetSelectedFlatMedia()

				with DialogManageRatings( self, flat_media ) as dlg:
					dlg.ShowModal()

				self.SetFocus()

	def _ManageTags( self ):
		if len( self._selected_media ) > 0:
			num_files = self._GetNumSelected()

			title = 'manage tags for ' + ToHumanInt( num_files ) + ' files'
			frame_key = 'manage_tags_dialog'

			with DialogManage( self, title, frame_key ) as dlg:
				panel = ManageTagsPanel( dlg, self._file_service_key, self._selected_media )

				dlg.SetPanel( panel )

				dlg.ShowModal()

			self.SetFocus()

	def _ManageURLs( self ):
		if len( self._selected_media ) > 0:
			num_files = self._GetNumSelected()

			title = 'manage urls for {} files'.format( num_files )

			with DialogManage( self, title ) as dlg:
				panel = ManageURLsPanel( dlg, self._selected_media )

				dlg.SetPanel( panel )

				dlg.ShowModal()

			self.SetFocus()

	def _ModifyUploaders( self, file_service_key ):
		wx.MessageBox( 'this does not work yet!' )

		return

		hashes = self._GetSelectedHashes()

		if hashes is not None and len( hashes ) > 0:
			# contents = [ Content( HC.CONTENT_TYPE_FILES, [ hash ] ) for hash in hashes ]

			subject_accounts = 'blah' # fetch subjects from server with the contents

			with DialogModifyAccounts( self, file_service_key, subject_accounts ) as dlg:
				dlg.ShowModal()

			self.SetFocus()

	def _OpenExternally( self ):
		if self._focussed_media is not None:
			open_externally_media = self._focussed_media.GetDisplayMedia()

			if open_externally_media.GetLocationsManager().IsLocal():
				hash = open_externally_media.GetHash()
				mime = open_externally_media.GetMime()

				client_files_manager = HG.client_controller.client_files_manager

				path = client_files_manager.GetFilePath( hash, mime )

				new_options = HG.client_controller.new_options

				launch_path = new_options.GetMimeLaunch( mime )

				LaunchFile( path, launch_path )

				HG.client_controller.pub( 'media_focus_went_to_external_program', self._page_key )

	def _OpenFileInWebBrowser( self ):
		if self._focussed_media is not None:
			if self._focussed_media.GetLocationsManager().IsLocal():
				hash = self._focussed_media.GetHash()
				mime = self._focussed_media.GetMime()

				client_files_manager = HG.client_controller.client_files_manager

				path = client_files_manager.GetFilePath( hash, mime )

				self._SetFocussedMedia( None )

				LaunchPathInWebBrowser( path )

	def _OpenFileLocation( self ):
		if self._focussed_media is not None:
			if self._focussed_media.GetLocationsManager().IsLocal():
				hash = self._focussed_media.GetHash()
				mime = self._focussed_media.GetMime()

				client_files_manager = HG.client_controller.client_files_manager

				path = client_files_manager.GetFilePath( hash, mime )

				self._SetFocussedMedia( None )

				OpenFileLocation( path )

	def _OpenKnownURL( self ):
		if self._focussed_media is not None:
			DoOpenKnownURLFromShortcut( self, self._focussed_media )

	def _PetitionFiles( self, remote_service_key ):
		hashes = self._GetSelectedHashes()

		if hashes is not None and len( hashes ) > 0:
			remote_service = HG.client_controller.services_manager.GetService( remote_service_key )

			service_type = remote_service.GetServiceType()

			if service_type == HC.FILE_REPOSITORY:
				if len( hashes ) == 1:
					message = 'Enter a reason for this file to be removed from ' + remote_service.GetName() + '.'

				else:
					message = 'Enter a reason for these ' + ToHumanInt( len( hashes ) ) + ' files to be removed from ' + remote_service.GetName() + '.'

				with DialogTextEntry( self, message ) as dlg:
					if dlg.ShowModal() == wx.ID_OK:
						reason = dlg.GetValue()

						content_update = ContentUpdate( HC.CONTENT_TYPE_FILES, HC.CONTENT_UPDATE_PETITION, hashes, reason = reason )

						service_keys_to_content_updates = { remote_service_key : ( content_update, ) }

						HG.client_controller.Write( 'content_updates', service_keys_to_content_updates )

				self.SetFocus()

			elif service_type == HC.IPFS:
				content_update = ContentUpdate( HC.CONTENT_TYPE_FILES, HC.CONTENT_UPDATE_PETITION, hashes, reason = 'ipfs' )

				service_keys_to_content_updates = { remote_service_key : ( content_update, ) }

				HG.client_controller.Write( 'content_updates', service_keys_to_content_updates )

	def _PublishSelectionChange( self, force_reload = False ):
		if HG.client_controller.gui.IsCurrentPage( self._page_key ):
			if len( self._selected_media ) == 0:
				tags_media = self._sorted_media

			else:
				tags_media = self._selected_media

			force_reload = force_reload or self._due_a_forced_selection_pub

			HG.client_controller.pub( 'new_tags_selection', self._page_key, tags_media, force_reload = force_reload )
			HG.client_controller.pub( 'new_page_status', self._page_key, self._GetPrettyStatus() )

			if force_reload:
				self._due_a_forced_selection_pub = False

		else:
			if force_reload:
				self._due_a_forced_selection_pub = True

	def _PublishSelectionIncrement( self, medias ):
		if HG.client_controller.gui.IsCurrentPage( self._page_key ):
			HG.client_controller.pub( 'increment_tags_selection', self._page_key, medias )
			HG.client_controller.pub( 'new_page_status', self._page_key, self._GetPrettyStatus() )

		else:
			self._due_a_forced_selection_pub = True

	def _RecalculateVirtualSize( self ): pass

	def _RedrawMedia( self, media ): pass

	def _Remove( self ):
		singletons = [ media for media in self._selected_media if not media.IsCollection() ]

		_collections = [ media for media in self._selected_media if media.IsCollection() ]

		self._RemoveMediaDirectly( singletons, _collections )

	def _RegenerateFileData( self, job_type ):
		flat_media = self._GetSelectedFlatMedia()

		hashes = { media.GetHash() for media in flat_media }

		num_files = len( hashes )

		if num_files > 0:
			if job_type == REGENERATE_FILE_DATA_JOB_COMPLETE:
				text = 'This will reparse the {} selected files\' metadata and regenerate their thumbnails.'.format( ToHumanInt( num_files ) )
				text += os.linesep * 2
				text += 'If the files were imported before some more recent improvement in the parsing code (such as EXIF rotation or bad video resolution or duration or frame count calculation), this will update them.'

			elif job_type == REGENERATE_FILE_DATA_JOB_FORCE_THUMBNAIL:
				text = 'This will force-regenerate the {} selected files\' thumbnails.'.format( ToHumanInt( num_files ) )

			elif job_type == REGENERATE_FILE_DATA_JOB_REFIT_THUMBNAIL:
				text = 'This will regenerate the {} selected files\' thumbnails, but only if they are the wrong size.'.format( ToHumanInt( num_files ) )

			text += os.linesep * 2
			text += 'It may take some time to finish this job.'

			with DialogYesNo( self, text ) as dlg:
				if dlg.ShowModal() == wx.ID_YES:
					self._SetFocussedMedia( None )

					time.sleep( 1 )

					HG.client_controller.Write( 'regenerate_file_data', job_type, hashes )

	def _RescindDownloadSelected( self ):
		hashes = self._GetSelectedHashes( discriminant = CC.DISCRIMINANT_NOT_LOCAL )

		HG.client_controller.Write( 'content_updates', { CC.COMBINED_LOCAL_FILE_SERVICE_KEY : [ ContentUpdate( HC.CONTENT_TYPE_FILES, HC.CONTENT_UPDATE_RESCIND_PEND, hashes ) ] } )

	def _RescindPetitionFiles( self, file_service_key ):
		hashes = self._GetSelectedHashes()

		if hashes is not None and len( hashes ) > 0:
			HG.client_controller.Write( 'content_updates', { file_service_key : [ ContentUpdate( HC.CONTENT_TYPE_FILES, HC.CONTENT_UPDATE_RESCIND_PETITION, hashes ) ] } )

	def _RescindUploadFiles( self, file_service_key ):
		hashes = self._GetSelectedHashes()

		if hashes is not None and len( hashes ) > 0:
			HG.client_controller.Write( 'content_updates', { file_service_key : [ ContentUpdate( HC.CONTENT_TYPE_FILES, HC.CONTENT_UPDATE_RESCIND_PEND, hashes ) ] } )

	def _Select( self, select_type, extra_info = None ):
		if select_type == 'all':
			self._DeselectSelect( [], self._sorted_media )

		else:
			if select_type == 'invert':
				( media_to_deselect, media_to_select ) = ( self._selected_media, { m for m in self._sorted_media if m not in self._selected_media } )

			elif select_type == 'none':
				( media_to_deselect, media_to_select ) = ( self._selected_media, [] )

			elif select_type in ( 'inbox', 'archive' ):
				inbox_media = [ m for m in self._sorted_media if m.HasInbox() ]
				archive_media = [ m for m in self._sorted_media if not m.HasInbox() ]

				if select_type == 'inbox':
					media_to_deselect = [ m for m in archive_media if m in self._selected_media ]
					media_to_select = [ m for m in inbox_media if m not in self._selected_media ]

				elif select_type == 'archive':
					media_to_deselect = [ m for m in inbox_media if m in self._selected_media ]
					media_to_select = [ m for m in archive_media if m not in self._selected_media ]

			elif select_type == 'file_service':
				file_service_key = extra_info

				media_to_deselect = [ m for m in self._selected_media if file_service_key not in m.GetLocationsManager().GetCurrent() ]
				media_to_select = [ m for m in self._sorted_media if m not in self._selected_media and file_service_key in m.GetLocationsManager().GetCurrent() ]

			elif select_type in ( 'local', 'remote' ):
				local_media = [ m for m in self._sorted_media if m.GetLocationsManager().IsLocal() ]
				remote_media = [ m for m in self._sorted_media if m.GetLocationsManager().IsRemote() ]

				if select_type == 'local':
					media_to_deselect = [ m for m in remote_media if m in self._selected_media ]
					media_to_select = [ m for m in local_media if m not in self._selected_media ]

				elif select_type == 'remote':
					media_to_deselect = [ m for m in local_media if m in self._selected_media ]
					media_to_select = [ m for m in remote_media if m not in self._selected_media ]

			elif select_type == 'tags':
				( and_or_or, select_tags ) = extra_info

				if and_or_or == 'AND':
					matching_media = { m for m in self._sorted_media if len( m.GetTagsManager().GetCurrentAndPending().intersection( select_tags ) ) == len( select_tags ) }

				elif and_or_or == 'OR':
					matching_media = { m for m in self._sorted_media if len( m.GetTagsManager().GetCurrentAndPending().intersection( select_tags ) ) > 0 }

				media_to_deselect = self._selected_media.difference( matching_media )
				media_to_select = matching_media.difference( self._selected_media )

			if self._focussed_media in media_to_deselect:
				self._SetFocussedMedia( None )

			self._DeselectSelect( media_to_deselect, media_to_select )

			self._shift_focussed_media = None

	def _SetCollectionsAsAlternate( self ):
		_collections = self._GetSelectedCollections()

		if len( _collections ) > 0:
			message = 'Are you sure you want to set files in the selected collections as alternates? Each collection will be considered a separate group of alternates.'
			message += os.linesep * 2
			message += 'Be careful applying this to large groups--any more than a few dozen files, and the client could hang a long time.'

			with DialogYesNo( self, message ) as dlg:
				if dlg.ShowModal() == wx.ID_YES:
					for collection in _collections:
						collection_pairs = list( itertools.combinations( collection.GetFlatMedia(), 2 ) )

						self._SetDuplicates( HC.DUPLICATE_ALTERNATE, media_pairs = collection_pairs, silent = True )

	def _SetDuplicates( self, duplicate_type, media_pairs = None, duplicate_action_options = None, silent = False ):
		yes_no_text = 'unknown duplicate action'

		if duplicate_type is None or duplicate_type == HC.DUPLICATE_UNKNOWN:
			if duplicate_type is None:
				yes_no_text = 'completely delete all pair duplicate relationships'

			elif duplicate_type == HC.DUPLICATE_UNKNOWN:
				yes_no_text = 'set all pair duplicate relationships to unknown/potential'

			duplicate_action_options = None

		elif duplicate_action_options is None:
			yes_no_text = 'set all pair relationships to ' + HC.duplicate_type_string_lookup[ duplicate_type ] + ' (with default duplicate action/merge options)'

			new_options = HG.client_controller.new_options

			duplicate_action_options = new_options.GetDuplicateActionOptions( duplicate_type )

		else:
			yes_no_text = 'set all pair relationships to ' + HC.duplicate_type_string_lookup[ duplicate_type ] + ' (with custom duplicate action/merge options)'

		file_deletion_reason = 'Deleted from duplicate action on Media Page ({}).'.format( yes_no_text )

		if media_pairs is None:
			flat_media = self._GetSelectedFlatMedia()

			if len( flat_media ) < 2:
				return False

			media_pairs = list( itertools.combinations( flat_media, 2 ) )

		if len( media_pairs ) == 0:
			return False

		if len( media_pairs ) > 100 and not silent:
			message = 'The duplicate system does not yet work well for large groups of duplicates. This is about to ask if you want to apply a dupe status for more than 100 pairs.'
			message += os.linesep * 2
			message += 'Unless you are testing the system or have another good reason to try this, I recommend you step back for now.'

			with DialogYesNo( self, message, yes_label = 'I know what I am doing', no_label = 'step back for now' ) as dlg:
				if dlg.ShowModal() != wx.ID_YES:
					return False

		if silent:
			do_it = True

		else:
			do_it = False

			message = 'Are you sure you want to ' + yes_no_text + ' for the ' + ToHumanInt( len( media_pairs ) ) + ' pairs?'

			with DialogYesNo( self, message ) as dlg:
				if dlg.ShowModal() == wx.ID_YES:
					do_it = True

		if do_it:
			pair_info = []

			for ( first_media, second_media ) in media_pairs:
				first_hash = first_media.GetHash()
				second_hash = second_media.GetHash()

				if duplicate_action_options is None:
					service_keys_to_content_updates = {}

				else:
					service_keys_to_content_updates = duplicate_action_options.ProcessPairIntoContentUpdates( first_media, second_media, file_deletion_reason = file_deletion_reason )

				pair_info.append( ( duplicate_type, first_hash, second_hash, service_keys_to_content_updates ) )

			if len( pair_info ) > 0:
				HG.client_controller.WriteSynchronous( 'duplicate_pair_status', pair_info )

				return True

		return False

	def _SetDuplicatesCustom( self ):
		duplicate_types = [ HC.DUPLICATE_BETTER, HC.DUPLICATE_SAME_QUALITY, HC.DUPLICATE_ALTERNATE, HC.DUPLICATE_NOT_DUPLICATE ]

		choice_tuples = [ ( HC.duplicate_type_string_lookup[ duplicate_type ], duplicate_type ) for duplicate_type in duplicate_types ]

		try:
			duplicate_type = SelectFromList( self, 'select duplicate type', choice_tuples )

		except CancelledException:
			return

		new_options = HG.client_controller.new_options

		duplicate_action_options = new_options.GetDuplicateActionOptions( duplicate_type )

		with DialogEdit( self, 'edit duplicate merge options' ) as dlg:
			panel = EditDuplicateActionOptionsPanel( dlg, duplicate_type, duplicate_action_options, for_custom_action = True )

			dlg.SetPanel( panel )

			if dlg.ShowModal() == wx.ID_OK:
				duplicate_action_options = panel.GetValue()

				self._SetDuplicates( duplicate_type, duplicate_action_options = duplicate_action_options )

	def _SetDuplicatesFocusedBetter( self, duplicate_action_options = None ):
		focused_hash = self._focussed_media.GetDisplayMedia().GetHash()

		flat_media = self._GetSelectedFlatMedia()

		( better_media, ) = [ media for media in flat_media if media.GetHash() == focused_hash ]

		worse_flat_media = [ media for media in flat_media if media.GetHash() != focused_hash ]

		media_pairs = [ ( better_media, worse_media ) for worse_media in worse_flat_media ]

		self._SetDuplicates( HC.DUPLICATE_BETTER, media_pairs = media_pairs )

	def _SetFocussedMedia( self, media ):
		if media is None and self._focussed_media is not None:
			next_best_media = self._focussed_media

			i = self._sorted_media.index( next_best_media )

			while next_best_media in self._selected_media:
				if i == 0:
					next_best_media = None

					break

				i -= 1

				next_best_media = self._sorted_media[ i ]

			self._next_best_media_after_focussed_media_removed = next_best_media

		else:
			self._next_best_media_after_focussed_media_removed = None

		self._focussed_media = media

		if self._focussed_media is None:
			publish_media = None

		else:
			publish_media = self._focussed_media.GetDisplayMedia()

		HG.client_controller.pub( 'preview_changed', self._page_key, publish_media )

	def _ShareOnLocalBooru( self ):
		if len( self._selected_media ) > 0:
			share_key = GenerateKey()

			name = ''
			text = ''
			timeout = GetNow() + 60 * 60 * 24
			hashes = self._GetSelectedHashes()

			with DialogInputLocalBooruShare( self, share_key, name, text, timeout, hashes, new_share = True ) as dlg:
				if dlg.ShowModal() == wx.ID_OK:
					( share_key, name, text, timeout, hashes ) = dlg.GetInfo()

					info = {}

					info[ 'name' ] = name
					info[ 'text' ] = text
					info[ 'timeout' ] = timeout
					info[ 'hashes' ] = hashes

					HG.client_controller.Write( 'local_booru_share', share_key, info )

			self.SetFocus()

	def _ShowDuplicatesInNewPage( self, hash, duplicate_type ):
		hashes = HG.client_controller.Read( 'file_duplicate_hashes', self._file_service_key, hash, duplicate_type )

		if hashes is not None and len( hashes ) > 0:
			HG.client_controller.pub( 'new_page_query', self._file_service_key, initial_hashes = hashes, do_sort = True )

	def _ShowSelectionInNewPage( self ):
		hashes = self._GetSelectedHashes( ordered = True )

		if hashes is not None and len( hashes ) > 0:
			HG.client_controller.pub( 'new_page_query', self._file_service_key, initial_hashes = hashes )

	def _Undelete( self ):
		hashes = self._GetSelectedHashes( has_location = CC.TRASH_SERVICE_KEY )

		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:
					text = 'Are you sure you want to undelete this file?'
				else:
					text = 'Are you sure you want to undelete these ' + ToHumanInt( num_to_undelete ) + ' files?'

				with DialogYesNo( self, text ) as dlg:
					if dlg.ShowModal() == wx.ID_YES:
						do_it = True

			if do_it:
				HG.client_controller.Write( 'content_updates', { CC.TRASH_SERVICE_KEY : [ ContentUpdate( HC.CONTENT_TYPE_FILES, HC.CONTENT_UPDATE_UNDELETE, hashes ) ] } )

	def _UpdateBackgroundColour( self ):
		self.Refresh()

	def _UploadDirectory( self, file_service_key ):
		hashes = self._GetSelectedHashes()

		if hashes is not None and len( hashes ) > 0:
			ipfs_service = HG.client_controller.services_manager.GetService( file_service_key )

		with DialogTextEntry( self, 'Enter a note to describe this directory.' ) as dlg:
			if dlg.ShowModal() == wx.ID_OK:
				note = dlg.GetValue()

				HG.client_controller.CallToThread( ipfs_service.PinDirectory, hashes, note )

	def _UploadFiles( self, file_service_key ):
		hashes = self._GetSelectedHashes( not_uploaded_to = file_service_key )

		if hashes is not None and len( hashes ) > 0:
			HG.client_controller.Write( 'content_updates', { file_service_key : [ ContentUpdate( HC.CONTENT_TYPE_FILES, HC.CONTENT_UPDATE_PEND, hashes ) ] } )

	def AddMediaResults( self, page_key, media_results, append = True ):
		if page_key == self._page_key:
			HG.client_controller.pub( 'refresh_page_name', self._page_key )

			return ListeningMediaList.AddMediaResults( self, media_results, append = append )

	def ClearPageKey( self ):
		self._page_key = 'dead media panel page key'

	def Collect( self, page_key, collect_by = -1 ):
		if page_key == self._page_key:
			self._Select( 'none' )

			ListeningMediaList.Collect( self, collect_by )

			self._RecalculateVirtualSize()

			# no refresh needed since the sort call that always comes after will do it

	def FileDumped( self, page_key, hash, status ):
		if page_key == self._page_key:
			media = self._GetMedia( { hash } )

			for m in media:
				m.Dumped( status )

			self._RedrawMedia( media )

	def PageHidden( self ):
		HG.client_controller.pub( 'preview_changed', self._page_key, None )

	def PageShown( self ):
		if self._focussed_media is None:
			publish_media = None

		else:
			publish_media = self._focussed_media.GetDisplayMedia()

		HG.client_controller.pub( 'preview_changed', self._page_key, publish_media )

		self._PublishSelectionChange()

	def ProcessApplicationCommand( self, command ):
		command_processed = True

		command_type = command.GetCommandType()
		data = command.GetData()

		if command_type == CC.APPLICATION_COMMAND_TYPE_SIMPLE:
			action = data

			if action == 'copy_bmp':
				self._CopyBMPToClipboard()

			elif action == 'copy_file':
				self._CopyFilesToClipboard()

			elif action == 'copy_path':
				self._CopyPathsToClipboard()

			elif action == 'copy_sha256_hash':
				self._CopyHashesToClipboard( 'sha256' )

			elif action == 'duplicate_media_remove_relationships':
				self._SetDuplicates( None )

			elif action == 'duplicate_media_reset_to_potential':
				self._SetDuplicates( HC.DUPLICATE_UNKNOWN )

			elif action == 'duplicate_media_set_alternate':
				self._SetDuplicates( HC.DUPLICATE_ALTERNATE )

			elif action == 'duplicate_media_set_alternate_collections':
				self._SetCollectionsAsAlternate()

			elif action == 'duplicate_media_set_custom':
				self._SetDuplicatesCustom()

			elif action == 'duplicate_media_set_focused_better':
				self._SetDuplicatesFocusedBetter()

			elif action == 'duplicate_media_set_not_duplicate':
				self._SetDuplicates( HC.DUPLICATE_NOT_DUPLICATE )

			elif action == 'duplicate_media_set_same_quality':
				self._SetDuplicates( HC.DUPLICATE_SAME_QUALITY )

			elif action == 'export_files':
				self._ExportFiles()

			elif action == 'export_files_quick_auto_export':
				self._ExportFiles( do_export_and_then_quit = True )

			elif action == 'manage_file_ratings':
				self._ManageRatings()

			elif action == 'manage_file_tags':
				self._ManageTags()

			elif action == 'manage_file_urls':
				self._ManageURLs()

			elif action == 'manage_file_notes':
				self._ManageNotes()

			elif action == 'open_known_url':
				self._OpenKnownURL()

			elif action == 'archive_file':
				self._Archive()

			elif action == 'delete_file':
				self._Delete()

			elif action == 'inbox_file':
				self._Inbox()

			elif action == 'remove_file_from_view':
				self._Remove()

			elif action == 'get_similar_to_exact':
				self._GetSimilarTo( HC.HAMMING_EXACT_MATCH )

			elif action == 'get_similar_to_very_similar':
				self._GetSimilarTo( HC.HAMMING_VERY_SIMILAR )

			elif action == 'get_similar_to_similar':
				self._GetSimilarTo( HC.HAMMING_SIMILAR )

			elif action == 'get_similar_to_speculative':
				self._GetSimilarTo( HC.HAMMING_SPECULATIVE )

			elif action == 'open_file_in_external_program':
				self._OpenExternally()

			elif action == 'open_selection_in_new_page':
				self._ShowSelectionInNewPage()

			elif action == 'launch_the_archive_delete_filter':
				self._ArchiveDeleteFilter()

			else:
				command_processed = False

		elif command_type == CC.APPLICATION_COMMAND_TYPE_CONTENT:
			command_processed = ApplyContentApplicationCommandToMedia( self, command, self._GetSelectedFlatMedia() )

		else:
			command_processed = False

		return command_processed

	def ProcessContentUpdates( self, service_keys_to_content_updates ):
		ListeningMediaList.ProcessContentUpdates( self, service_keys_to_content_updates )

		we_were_file_or_tag_affected = False

		for ( service_key, content_updates ) in list( service_keys_to_content_updates.items() ):
			for content_update in content_updates:
				hashes = content_update.GetHashes()

				if self._HasHashes( hashes ):
					affected_media = self._GetMedia( hashes )

					self._RedrawMedia( affected_media )

					if content_update.GetDataType() in ( HC.CONTENT_TYPE_FILES, HC.CONTENT_TYPE_MAPPINGS ):
						we_were_file_or_tag_affected = True

		if we_were_file_or_tag_affected:
			self._PublishSelectionChange( force_reload = True )

	def ProcessServiceUpdates( self, service_keys_to_service_updates ):
		ListeningMediaList.ProcessServiceUpdates( self, service_keys_to_service_updates )

		for ( service_key, service_updates ) in list( service_keys_to_service_updates.items() ):
			for service_update in service_updates:
				( action, row ) = service_update.ToTuple()

				if action in ( HC.SERVICE_UPDATE_DELETE_PENDING, HC.SERVICE_UPDATE_RESET ):
					self._RecalculateVirtualSize()

				self._PublishSelectionChange( force_reload = True )

	def RemoveMedia( self, page_key, hashes ):
		if page_key == self._page_key:
			self._RemoveMediaByHashes( hashes )

	def SelectByTags( self, page_key, and_or_or, tags ):
		if page_key == self._page_key:
			self._Select( 'tags', ( and_or_or, tags ) )

	def SetDuplicateStatusForAll( self, duplicate_type ):
		flat_media = FlattenMedia( self._sorted_media )

		media_pairs = list( itertools.combinations( flat_media, 2 ) )

		return self._SetDuplicates( duplicate_type, media_pairs = media_pairs )

	def SetFocussedMedia( self, page_key, media ):
		pass

	def Sort( self, page_key, media_sort = None ):
		if page_key == self._page_key:
			ListeningMediaList.Sort( self, media_sort )

class MediaPanelLoading( MediaPanel ):
	def __init__( self, parent, page_key, file_service_key ):
		self._current = None
		self._max = None

		MediaPanel.__init__( self, parent, page_key, file_service_key, [] )

		HG.client_controller.sub( self, 'SetNumQueryResults', 'set_num_query_results' )

	def _GetPrettyStatus( self ):
		s = 'Loading\u2026'

		if self._current is not None:
			s += ' ' + ToHumanInt( self._current )

			if self._max is not None:
				s += ' of ' + ToHumanInt( self._max )

		return s

	def GetSortedMedia( self ):
		return []

	def SetNumQueryResults( self, page_key, num_current, num_max ):
		if page_key == self._page_key:
			self._current = num_current

			self._max = num_max

			self._PublishSelectionChange()

class MediaPanelThumbnails( MediaPanel ):
	def __init__( self, parent, page_key, file_service_key, media_results ):
		self._clean_canvas_pages = {}
		self._dirty_canvas_pages = []
		self._num_rows_per_canvas_page = 1

		MediaPanel.__init__( self, parent, page_key, file_service_key, media_results )

		self._last_client_size = ( 20, 20 )
		self._num_columns = 1

		self._drag_init_coordinates = None
		self._thumbnails_being_faded_in = {}
		self._hashes_faded = set()

		( thumbnail_span_width, thumbnail_span_height ) = self._GetThumbnailSpanDimensions()

		thumbnail_scroll_rate = float( HG.client_controller.new_options.GetString( 'thumbnail_scroll_rate' ) )

		self.SetScrollRate( 0, int( round( thumbnail_span_height * thumbnail_scroll_rate ) ) )

		self.Bind( wx.EVT_LEFT_DOWN, self.EventLeftDown )
		self.Bind( wx.EVT_MOTION, self.EventDrag )
		self.Bind( wx.EVT_RIGHT_DOWN, self.EventShowMenu )
		self.Bind( wx.EVT_LEFT_DCLICK, self.EventMouseFullScreen )
		self.Bind( wx.EVT_MIDDLE_DOWN, self.EventMouseFullScreen )
		self.Bind( wx.EVT_PAINT, self.EventPaint )
		self.Bind( wx.EVT_SIZE, self.EventResize )
		self.Bind( wx.EVT_ERASE_BACKGROUND, self.EventEraseBackground )

		self.Bind( wx.EVT_KEY_DOWN, self.EventKeyDown )

		self.Bind( wx.EVT_MENU, self.EventMenu )

		self.RefreshAcceleratorTable()

		HG.client_controller.sub( self, 'MaintainPageCache', 'memory_maintenance_pulse' )
		HG.client_controller.sub( self, 'NewFileInfo', 'new_file_info' )
		HG.client_controller.sub( self, 'NewThumbnails', 'new_thumbnails' )
		HG.client_controller.sub( self, 'ThumbnailsResized', 'redraw_all_thumbnails' )
		HG.client_controller.sub( self, 'RefreshAcceleratorTable', 'notify_new_options' )
		HG.client_controller.sub( self, 'WaterfallThumbnails', 'waterfall_thumbnails' )

	def _CalculateVisiblePageIndices( self ):
		y_start = self._GetYStart()

		( xUnit, yUnit ) = self.GetScrollPixelsPerUnit()

		earliest_y = y_start * yUnit

		( client_width, client_height ) = self.GetClientSize()

		last_y = earliest_y + client_height

		( thumbnail_span_width, thumbnail_span_height ) = self._GetThumbnailSpanDimensions()

		page_height = self._num_rows_per_canvas_page * thumbnail_span_height

		first_visible_page_index = earliest_y // page_height

		last_visible_page_index = last_y // page_height

		page_indices = list( range( first_visible_page_index, last_visible_page_index + 1 ) )

		return page_indices

	def _CreateNewDirtyPage( self ):
		( client_width, client_height ) = self.GetClientSize()

		( thumbnail_span_width, thumbnail_span_height ) = self._GetThumbnailSpanDimensions()

		self._dirty_canvas_pages.append( HG.client_controller.bitmap_manager.GetBitmap( client_width, self._num_rows_per_canvas_page * thumbnail_span_height ) )

	def _DeleteAllDirtyPages( self ):
		for bmp in self._dirty_canvas_pages:
			HG.client_controller.bitmap_manager.ReleaseBitmap( bmp )

		self._dirty_canvas_pages = []

	def _DirtyAllPages( self ):
		clean_indices = list( self._clean_canvas_pages.keys() )

		for clean_index in clean_indices:
			self._DirtyPage( clean_index )

	def _DirtyPage( self, clean_index ):
		bmp = self._clean_canvas_pages[ clean_index ]

		del self._clean_canvas_pages[ clean_index ]

		thumbnails = [ thumbnail for ( thumbnail_index, thumbnail ) in self._GetThumbnailsFromPageIndex( clean_index ) ]

		HG.client_controller.GetCache( 'thumbnail' ).CancelWaterfall( self._page_key, thumbnails )

		self._dirty_canvas_pages.append( bmp )

	def _DrawCanvasPage( self, page_index, bmp ):
		( bmp_width, bmp_height ) = bmp.GetSize()

		dc = wx.MemoryDC( bmp )

		# new_options = HG.client_controller.new_options

		bg_colour = HG.client_controller.new_options.GetColour( CC.COLOUR_THUMBGRID_BACKGROUND )

		if HG.thumbnail_debug_mode and page_index % 2 == 0:
			bg_colour = GetLighterDarkerColour( bg_colour )

		dc.SetBackground( wx.Brush( bg_colour ) )

		dc.Clear()

		#

		page_thumbnails = self._GetThumbnailsFromPageIndex( page_index )

		( thumbnail_span_width, thumbnail_span_height ) = self._GetThumbnailSpanDimensions()

		thumbnails_to_render_later = []

		thumbnail_cache = HG.client_controller.GetCache( 'thumbnail' )

		thumbnail_margin = HG.client_controller.new_options.GetInteger( 'thumbnail_margin' )

		for ( thumbnail_index, thumbnail ) in page_thumbnails:
			hash = thumbnail.GetDisplayMedia().GetHash()

			if hash in self._hashes_faded and thumbnail_cache.HasThumbnailCached( thumbnail ):
				self._StopFading( hash )

				thumbnail_col = thumbnail_index % self._num_columns

				thumbnail_row = thumbnail_index // self._num_columns

				x = thumbnail_col * thumbnail_span_width + thumbnail_margin

				y = ( thumbnail_row - ( page_index * self._num_rows_per_canvas_page ) ) * thumbnail_span_height + thumbnail_margin

				dc.DrawBitmap( thumbnail.GetBmp(), x, y )

			else:
				thumbnails_to_render_later.append( thumbnail )

		HG.client_controller.GetCache( 'thumbnail' ).Waterfall( self._page_key, thumbnails_to_render_later )

	def _FadeThumbnails( self, thumbnails ):
		if len( thumbnails ) == 0:
			return

		self._hashes_faded.update( ( thumbnail.GetDisplayMedia().GetHash() for thumbnail in thumbnails ) )

		if not HG.client_controller.gui.IsCurrentPage( self._page_key ):
			self._DirtyAllPages()

			return

		now_precise = GetNowPrecise()

		for thumbnail in thumbnails:
			try:
				thumbnail_index = self._sorted_media.index( thumbnail )

			except DataMissing:
				# probably means a collect happened during an ongoing waterfall or whatever

				return

			if self._GetPageIndexFromThumbnailIndex( thumbnail_index ) not in self._clean_canvas_pages:
				return

			hash = thumbnail.GetDisplayMedia().GetHash()

			self._StopFading( hash )

			bmp = thumbnail.GetBmp()

			image = bmp.ConvertToImage()

			try:
				image.InitAlpha()
			except Exception:
				pass

			image = image.AdjustChannels( 1, 1, 1, 0.20 )

			alpha_bmp = wx.Bitmap( image, 32 )

			image.Destroy()

			self._thumbnails_being_faded_in[ hash ] = ( bmp, alpha_bmp, thumbnail_index, thumbnail, now_precise, 0 )

		HG.client_controller.gui.RegisterAnimationUpdateWindow( self )

	def _GenerateMediaCollection( self, media_results ):
		return ThumbnailMediaCollection( self._file_service_key, media_results )

	def _GenerateMediaSingleton( self, media_result ):
		return ThumbnailMediaSingleton( self._file_service_key, media_result )

	def _GetMediaCoordinates( self, media ):
		try:
			index = self._sorted_media.index( media )
		except Exception:
			return ( -1, -1 )

		row = index // self._num_columns
		column = index % self._num_columns

		( thumbnail_span_width, thumbnail_span_height ) = self._GetThumbnailSpanDimensions()

		thumbnail_margin = HG.client_controller.new_options.GetInteger( 'thumbnail_margin' )

		( x, y ) = ( column * thumbnail_span_width + thumbnail_margin, row * thumbnail_span_height + thumbnail_margin )

		return ( x, y )

	def _GetPageIndexFromThumbnailIndex( self, thumbnail_index ):
		thumbnails_per_page = self._num_columns * self._num_rows_per_canvas_page

		page_index = thumbnail_index // thumbnails_per_page

		return page_index

	def _GetThumbnailSpanDimensions( self ):
		thumbnail_border = HG.client_controller.new_options.GetInteger( 'thumbnail_border' )
		thumbnail_margin = HG.client_controller.new_options.GetInteger( 'thumbnail_margin' )

		return AddPaddingToDimensions( HC.options[ 'thumbnail_dimensions' ], ( thumbnail_border + thumbnail_margin ) * 2 )

	def _GetThumbnailUnderMouse( self, mouse_event ):
		( xUnit, yUnit ) = self.GetScrollPixelsPerUnit()

		( x_scroll, y_scroll ) = self.GetViewStart()

		y_offset = y_scroll * yUnit

		x = mouse_event.GetX()
		y = mouse_event.GetY() + y_offset

		( t_span_x, t_span_y ) = self._GetThumbnailSpanDimensions()

		x_mod = x % t_span_x
		y_mod = y % t_span_y

		thumbnail_margin = HG.client_controller.new_options.GetInteger( 'thumbnail_margin' )

		if x_mod <= thumbnail_margin or y_mod <= thumbnail_margin or x_mod > t_span_x - thumbnail_margin or y_mod > t_span_y - thumbnail_margin:
			return None

		column_index = x // t_span_x
		row_index = y // t_span_y

		if column_index >= self._num_columns:
			return None

		thumbnail_index = self._num_columns * row_index + column_index

		if thumbnail_index >= len( self._sorted_media ):
			return None

		return self._sorted_media[ thumbnail_index ]

	def _GetThumbnailsFromPageIndex( self, page_index ):
		num_thumbnails_per_page = self._num_columns * self._num_rows_per_canvas_page

		start_index = num_thumbnails_per_page * page_index

		if start_index <= len( self._sorted_media ):
			end_index = min( len( self._sorted_media ), start_index + num_thumbnails_per_page )

			thumbnails = [ ( index, self._sorted_media[ index ] ) for index in range( start_index, end_index ) ]

		else:
			thumbnails = []

		return thumbnails

	def _GetYStart( self ):
		( my_virtual_width, my_virtual_height ) = self.GetVirtualSize()

		( my_width, my_height ) = self.GetClientSize()

		( xUnit, yUnit ) = self.GetScrollPixelsPerUnit()

		max_y = ( my_virtual_height - my_height ) // yUnit

		if ( my_virtual_height - my_height ) % yUnit > 0:
			max_y += 1

		( x_start, y_start ) = self.GetViewStart()

		y_start = max( 0, y_start )

		y_start = min( y_start, max_y )

		return y_start

	def _MoveFocussedThumbnail( self, rows, columns, shift ):
		if self._focussed_media is not None:
			media_to_use = self._focussed_media

		elif self._next_best_media_after_focussed_media_removed is not None:
			media_to_use = self._next_best_media_after_focussed_media_removed

			if columns == -1:
			# treat it as if the focussed area is between this and the next

				columns = 0

		elif len( self._sorted_media ) > 0:
			media_to_use = self._sorted_media[ 0 ]

		else:
			media_to_use = None

		if media_to_use is not None:
			current_position = self._sorted_media.index( media_to_use )

			new_position = current_position + columns + ( self._num_columns * rows )

			if new_position < 0:
				new_position = 0

			elif new_position > len( self._sorted_media ) - 1:
				new_position = len( self._sorted_media ) - 1

			new_media = self._sorted_media[ new_position ]

			self._HitMedia( new_media, False, shift )

			self._ScrollToMedia( new_media )

	def _RecalculateVirtualSize( self ):
		( client_width, client_height ) = self.GetClientSize()

		if client_width > 0 and client_height > 0:
			( thumbnail_span_width, thumbnail_span_height ) = self._GetThumbnailSpanDimensions()

			num_media = len( self._sorted_media )

			num_rows = max( 1, num_media // self._num_columns )

			if num_media % self._num_columns > 0:
				num_rows += 1

			virtual_width = client_width

			virtual_height = num_rows * thumbnail_span_height

			( xUnit, yUnit ) = self.GetScrollPixelsPerUnit()

			excess = virtual_height % yUnit

			if excess > 0:
			# we want virtual height to fit exactly into scroll units, even if that puts some padding below bottom row

				top_up = yUnit - excess

				virtual_height += top_up

			virtual_height = max( virtual_height, client_height )

			if ( virtual_width, virtual_height ) != self.GetVirtualSize():
				self.SetVirtualSize( ( virtual_width, virtual_height ) )

	def _RedrawMedia( self, thumbnails ):
		visible_thumbnails = [ thumbnail for thumbnail in thumbnails if self._ThumbnailIsVisible( thumbnail ) ]

		thumbnail_cache = HG.client_controller.GetCache( 'thumbnail' )

		thumbnails_to_render_now = []
		thumbnails_to_render_later = []

		for thumbnail in visible_thumbnails:
			if thumbnail_cache.HasThumbnailCached( thumbnail ):
				thumbnails_to_render_now.append( thumbnail )

			else:
				thumbnails_to_render_later.append( thumbnail )

		if len( thumbnails_to_render_now ) > 0:
			self._FadeThumbnails( thumbnails_to_render_now )

		if len( thumbnails_to_render_later ) > 0:
			HG.client_controller.GetCache( 'thumbnail' ).Waterfall( self._page_key, thumbnails_to_render_later )

	def _ReinitialisePageCacheIfNeeded( self ):
		old_num_rows = self._num_rows_per_canvas_page
		old_num_columns = self._num_columns

		( old_client_width, old_client_height ) = self._last_client_size

		( client_width, client_height ) = self.GetClientSize()

		( thumbnail_span_width, thumbnail_span_height ) = self._GetThumbnailSpanDimensions()

		num_rows = ( client_height // thumbnail_span_height ) // 2 # roughly half a client_height's worth of thumbs

		self._num_rows_per_canvas_page = max( 1, num_rows )

		self._num_columns = max( 1, client_width // thumbnail_span_width )

		client_dimensions_changed = old_client_width != client_width or old_client_height != client_height
		thumb_layout_changed = old_num_columns != self._num_columns or old_num_rows != self._num_rows_per_canvas_page

		if client_dimensions_changed or thumb_layout_changed:
			width_got_bigger = old_client_width < client_width

			if thumb_layout_changed or width_got_bigger:
				self._DirtyAllPages()

				self._DeleteAllDirtyPages()

			self.Refresh()

	def _RemoveMediaDirectly( self, singleton_media, collected_media ):
		if self._focussed_media is not None:
			if self._focussed_media in singleton_media or self._focussed_media in collected_media:
				self._SetFocussedMedia( None )

		MediaPanel._RemoveMediaDirectly( self, singleton_media, collected_media )

		self._selected_media.difference_update( singleton_media )
		self._selected_media.difference_update( collected_media )

		self._shift_focussed_media = None

		self._RecalculateVirtualSize()

		self._DirtyAllPages()

		self._PublishSelectionChange()

		HG.client_controller.pub( 'refresh_page_name', self._page_key )

		self.Refresh()

	def _ScrollEnd( self, shift = False ):
		if len( self._sorted_media ) > 0:
			end_media = self._sorted_media[ -1 ]

			self._HitMedia( end_media, False, shift )

			self._ScrollToMedia( end_media )

	def _ScrollHome( self, shift = False ):
		if len( self._sorted_media ) > 0:
			home_media = self._sorted_media[ 0 ]

			self._HitMedia( home_media, False, shift )

			self._ScrollToMedia( home_media )

	def _ScrollToMedia( self, media ):
		if media is not None:
			( x, y ) = self._GetMediaCoordinates( media )

			( start_x, start_y ) = self.GetViewStart()

			( x_unit, y_unit ) = self.GetScrollPixelsPerUnit()

			( width, height ) = self.GetClientSize()

			( thumbnail_span_width, thumbnail_span_height ) = self._GetThumbnailSpanDimensions()

			new_options = HG.client_controller.new_options

			percent_visible = new_options.GetInteger( 'thumbnail_visibility_scroll_percent' ) / 100

			if y < start_y * y_unit:
				y_to_scroll_to = y / y_unit

				self.Scroll( -1, y_to_scroll_to )

				wx.QueueEvent( self.GetEventHandler(), wx.ScrollWinEvent( wx.wxEVT_SCROLLWIN_THUMBRELEASE, pos = y_to_scroll_to ) )

			elif y > ( start_y * y_unit ) + height - ( thumbnail_span_height * percent_visible ):
				y_to_scroll_to = ( y - height ) // y_unit

				self.Scroll( -1, y_to_scroll_to )

				wx.QueueEvent( self.GetEventHandler(), wx.ScrollWinEvent( wx.wxEVT_SCROLLWIN_THUMBRELEASE, pos = y_to_scroll_to + 2 ) )

	def _StopFading( self, hash ):
		if hash in self._thumbnails_being_faded_in:
			( bmp, alpha_bmp, thumbnail_index, thumbnail, animation_started, num_frames ) = self._thumbnails_being_faded_in[ hash ]

			del self._thumbnails_being_faded_in[ hash ]

			HG.client_controller.bitmap_manager.ReleaseBitmap( bmp )
			HG.client_controller.bitmap_manager.ReleaseBitmap( alpha_bmp )

	def _ThumbnailIsVisible( self, thumbnail ):
		try:
			index = self._sorted_media.index( thumbnail )

		except DataMissing:
			return False

		if self._GetPageIndexFromThumbnailIndex( index ) in self._clean_canvas_pages:
			return True

		else:
			return False

	def _UpdateBackgroundColour( self ):
		MediaPanel._UpdateBackgroundColour( self )

		self._DirtyAllPages()

		self._DeleteAllDirtyPages()

		self.Refresh()

	def AddMediaResults( self, page_key, media_results, append = True ):
		if page_key == self._page_key:
			thumbnails = MediaPanel.AddMediaResults( self, page_key, media_results, append = append )

			self._RecalculateVirtualSize()

			HG.client_controller.GetCache( 'thumbnail' ).Waterfall( self._page_key, thumbnails )

			if len( self._selected_media ) == 0:
				self._PublishSelectionIncrement( thumbnails )

	def EventDrag( self, event ):
		we_started_dragging_on_this_panel = self._drag_init_coordinates is not None

		if we_started_dragging_on_this_panel and event.LeftIsDown() and event.Dragging():
			( old_x, old_y ) = self._drag_init_coordinates

			( x, y ) = wx.GetMousePosition()

			( delta_x, delta_y ) = ( x - old_x, y - old_y )

			if abs( delta_x ) > 10 or abs( delta_y ) > 10:
				media = self._GetSelectedFlatMedia( discriminant = CC.DISCRIMINANT_LOCAL )

				if len( media ) > 0:
					alt_down = event.AltDown()

					result = DoFileExportDragDrop( self, self._page_key, media, alt_down )

					if result not in ( wx.DragError, wx.DragNone ):
						HG.client_controller.pub( 'media_focus_went_to_external_program', self._page_key )

		event.Skip()

	def EventEraseBackground( self, event ):
		pass

	def EventKeyDown( self, event ):
		# accelerator tables can't handle escape key in windows, gg

		if event.GetKeyCode() == wx.WXK_ESCAPE:
			self._Select( 'none' )

		elif event.GetKeyCode() in ( wx.WXK_PAGEUP, wx.WXK_PAGEDOWN ):
			if event.GetKeyCode() == wx.WXK_PAGEUP:
				direction = -1

			elif event.GetKeyCode() == wx.WXK_PAGEDOWN:
				direction = 1

			shift = event.ShiftDown()

			self._MoveFocussedThumbnail( self._num_rows_per_canvas_page * direction, 0, shift )

		else:
			event.Skip()

	def EventLeftDown( self, event ):
		self._drag_init_coordinates = wx.GetMousePosition()

		self._HitMedia( self._GetThumbnailUnderMouse( event ), event.CmdDown(), event.ShiftDown() )

		# this specifically does not scroll to media, as for clicking (esp. double-clicking attempts), the scroll can be jarring

		event.Skip()

	def EventMenu( self, event ):
		action = MENU_EVENT_ID_TO_ACTION_CACHE.GetAction( event.GetId() )

		if action is not None:
			( command, data ) = action

			if command == 'copy_files':
				self._CopyFilesToClipboard()
			elif command == 'ctrl-space':
				if self._focussed_media is not None:
					self._HitMedia( self._focussed_media, True, False )

			elif command == 'delete_file':
				if data is None:
					self._Delete()

				else:
					self._Delete( data )

			elif command == 'fullscreen':
				self._FullScreen()
			elif command == 'scroll_end':
				self._ScrollEnd( False )
			elif command == 'scroll_home':
				self._ScrollHome( False )
			elif command == 'shift_scroll_end':
				self._ScrollEnd( True )
			elif command == 'shift_scroll_home':
				self._ScrollHome( True )
			elif command == 'select':
				self._Select( data )
			elif command == 'undelete':
				self._Undelete()
			elif command == 'key_up':
				self._MoveFocussedThumbnail( -1, 0, False )
			elif command == 'key_down':
				self._MoveFocussedThumbnail( 1, 0, False )
			elif command == 'key_left':
				self._MoveFocussedThumbnail( 0, -1, False )
			elif command == 'key_right':
				self._MoveFocussedThumbnail( 0, 1, False )
			elif command == 'key_shift_up':
				self._MoveFocussedThumbnail( -1, 0, True )
			elif command == 'key_shift_down':
				self._MoveFocussedThumbnail( 1, 0, True )
			elif command == 'key_shift_left':
				self._MoveFocussedThumbnail( 0, -1, True )
			elif command == 'key_shift_right':
				self._MoveFocussedThumbnail( 0, 1, True )
			else:
				event.Skip()

	def EventMouseFullScreen( self, event ):
		t = self._GetThumbnailUnderMouse( event )

		if t is not None:
			locations_manager = t.GetLocationsManager()

			if locations_manager.IsLocal():
				self._FullScreen( t )

			elif len( locations_manager.GetCurrentRemote() ) > 0:
				self._DownloadHashes( t.GetHashes() )

		event.Skip()

	def EventPaint( self, event ):
		dc = wx.PaintDC( self )

		( client_x, client_y ) = self.GetClientSize()

		( thumbnail_span_width, thumbnail_span_height ) = self._GetThumbnailSpanDimensions()

		page_height = self._num_rows_per_canvas_page * thumbnail_span_height

		page_indices_to_display = self._CalculateVisiblePageIndices()

		earliest_page_index_to_display = min( page_indices_to_display )
		last_page_index_to_display = max( page_indices_to_display )

		page_indices_to_draw = list( page_indices_to_display )

		if earliest_page_index_to_display > 0:
			page_indices_to_draw.append( earliest_page_index_to_display - 1 )

		page_indices_to_draw.append( last_page_index_to_display + 1 )

		page_indices_to_draw.sort()

		potential_clean_indices_to_steal = [ page_index for page_index in list( self._clean_canvas_pages.keys() ) if page_index not in page_indices_to_draw ]

		random.shuffle( potential_clean_indices_to_steal )

		( xUnit, yUnit ) = self.GetScrollPixelsPerUnit()

		y_start = self._GetYStart()

		earliest_y = y_start * yUnit

		'''

		# an old effort to get a neat image drawn behind the thumbs. real hassle working with transparency and bmps across multiple platforms at the time

		bg_colour = HG.client_controller.new_options.GetColour( CC.COLOUR_THUMBGRID_BACKGROUND )

		dc.SetBackground( wx.Brush( bg_colour ) )

		dc.Clear()

		background_bmp = HG.client_controller.bitmap_manager.GetMediaBackgroundBitmap()

		if background_bmp is not None:
			( background_bmp_width, background_bmp_height ) = background_bmp.GetSize()

			dc.DrawBitmap( background_bmp, client_x - background_bmp_width, client_y - background_bmp_height )

		'''

		for page_index in page_indices_to_draw:
			if page_index not in self._clean_canvas_pages:
				if len( self._dirty_canvas_pages ) == 0:
					if len( potential_clean_indices_to_steal ) > 0:
						index_to_steal = potential_clean_indices_to_steal.pop()

						self._DirtyPage( index_to_steal )

					else:
						self._CreateNewDirtyPage()

				bmp = self._dirty_canvas_pages.pop()

				self._DrawCanvasPage( page_index, bmp )

				self._clean_canvas_pages[ page_index ] = bmp

			if page_index in page_indices_to_display:
				bmp = self._clean_canvas_pages[ page_index ]

				page_virtual_y = page_height * page_index

				page_client_y = page_virtual_y - earliest_y

				dc.DrawBitmap( bmp, 0, page_client_y )

	def EventResize( self, event ):
		self._ReinitialisePageCacheIfNeeded()

		self._RecalculateVirtualSize()

		self._last_client_size = self.GetClientSize()

	def EventShowMenu( self, event ):
		new_options = HG.client_controller.new_options

		advanced_mode = new_options.GetBoolean( 'advanced_mode' )

		services_manager = HG.client_controller.services_manager

		thumbnail = self._GetThumbnailUnderMouse( event )

		if thumbnail is not None:
			self._HitMedia( thumbnail, event.CmdDown(), event.ShiftDown() )

		all_locations_managers = [ media.GetLocationsManager() for media in self._sorted_media ]
		selected_locations_managers = [ media.GetLocationsManager() for media in self._selected_media ]

		selection_has_local = True in ( locations_manager.IsLocal() for locations_manager in selected_locations_managers )
		selection_has_local_file_domain = True in ( CC.LOCAL_FILE_SERVICE_KEY in locations_manager.GetCurrent() for locations_manager in selected_locations_managers )
		selection_has_trash = True in ( CC.TRASH_SERVICE_KEY in locations_manager.GetCurrent() for locations_manager in selected_locations_managers )
		selection_has_inbox = True in ( media.HasInbox() for media in self._selected_media )
		selection_has_archive = True in ( media.HasArchive() for media in self._selected_media )

		all_file_domains = MassUnion( locations_manager.GetCurrent() for locations_manager in all_locations_managers )
		all_specific_file_domains = all_file_domains.difference( { CC.COMBINED_FILE_SERVICE_KEY, CC.COMBINED_LOCAL_FILE_SERVICE_KEY } )
		all_local_file_domains = services_manager.Filter( all_specific_file_domains, ( HC.LOCAL_FILE_DOMAIN, ) )
		all_file_repos = services_manager.Filter( all_specific_file_domains, ( HC.FILE_REPOSITORY, ) )

		has_local = True in ( locations_manager.IsLocal() for locations_manager in all_locations_managers )
		has_remote = True in ( locations_manager.IsRemote() for locations_manager in all_locations_managers )

		num_inbox = sum( ( media.GetNumFiles() for media in self._sorted_media if media.HasInbox() ) )
		num_archive = sum( ( media.GetNumFiles() for media in self._sorted_media if media.HasArchive() ) )

		media_has_inbox = num_inbox > 0
		media_has_archive = num_archive > 0

		menu = wx.Menu()

		if self._focussed_media is not None:
			# variables

			num_selected = self._GetNumSelected()

			multiple_selected = num_selected > 1

			collections_selected = True in ( media.IsCollection() for media in self._selected_media )

			services_manager = HG.client_controller.services_manager

			services = services_manager.GetServices()

			service_keys_to_names = { service.GetServiceKey() : service.GetName() for service in services }

			# tag_repositories = [ service for service in services if service.GetServiceType() == HC.TAG_REPOSITORY ]

			file_repositories = [ service for service in services if service.GetServiceType() == HC.FILE_REPOSITORY ]

			ipfs_services = [ service for service in services if service.GetServiceType() == HC.IPFS ]

			local_ratings_services = [ service for service in services if service.GetServiceType() in ( HC.LOCAL_RATING_LIKE, HC.LOCAL_RATING_NUMERICAL ) ]

			local_booru_service = [ service for service in services if service.GetServiceType() == HC.LOCAL_BOORU ][0]

			local_booru_is_running = local_booru_service.GetPort() is not None

			i_can_post_ratings = len( local_ratings_services ) > 0

			focussed_is_local = CC.COMBINED_LOCAL_FILE_SERVICE_KEY in self._focussed_media.GetLocationsManager().GetCurrent()

			file_service_keys = { repository.GetServiceKey() for repository in file_repositories }
			upload_permission_file_service_keys = { repository.GetServiceKey() for repository in file_repositories if repository.HasPermission( HC.CONTENT_TYPE_FILES, HC.PERMISSION_ACTION_CREATE ) }
			petition_resolve_permission_file_service_keys = { repository.GetServiceKey() for repository in file_repositories if repository.HasPermission( HC.CONTENT_TYPE_FILES, HC.PERMISSION_ACTION_OVERRULE ) }
			petition_permission_file_service_keys = { repository.GetServiceKey() for repository in file_repositories if repository.HasPermission( HC.CONTENT_TYPE_FILES, HC.PERMISSION_ACTION_PETITION ) } - petition_resolve_permission_file_service_keys
			user_manage_permission_file_service_keys = { repository.GetServiceKey() for repository in file_repositories if repository.HasPermission( HC.CONTENT_TYPE_ACCOUNTS, HC.PERMISSION_ACTION_OVERRULE ) }
			ipfs_service_keys = { service.GetServiceKey() for service in ipfs_services }

			#focussed_is_ipfs = True in ( service_key in ipfs_service_keys for service_key in self._focussed_media.GetLocationsManager().GetCurrentRemote() )

			if multiple_selected:
				download_phrase = 'download all possible selected'
				rescind_download_phrase = 'cancel downloads for all possible selected'
				upload_phrase = 'upload all possible selected to'
				rescind_upload_phrase = 'rescind pending selected uploads to'
				petition_phrase = 'petition all possible selected for removal from'
				rescind_petition_phrase = 'rescind selected petitions for'
				remote_delete_phrase = 'delete all possible selected from'
				modify_account_phrase = 'modify the accounts that uploaded selected to'

				pin_phrase = 'pin all to'
				rescind_pin_phrase = 'rescind pin to'
				unpin_phrase = 'unpin all from'
				rescind_unpin_phrase = 'rescind unpin from'

				manage_tags_phrase = 'selected files\' tags'
				manage_urls_phrase = 'selected files\' urls'
				manage_ratings_phrase = 'selected files\' ratings'

				archive_phrase = 'archive selected'
				inbox_phrase = 'return selected to inbox'
				remove_phrase = 'remove selected from view'
				local_delete_phrase = 'delete selected'
				trash_delete_phrase = 'delete selected from trash now'
				undelete_phrase = 'undelete selected'
				# dump_phrase = 'dump selected to 4chan'
				export_phrase = 'files'
				copy_phrase = 'files'

			else:
				download_phrase = 'download'
				rescind_download_phrase = 'cancel download'
				upload_phrase = 'upload to'
				rescind_upload_phrase = 'rescind pending upload to'
				petition_phrase = 'petition for removal from'
				rescind_petition_phrase = 'rescind petition for'
				remote_delete_phrase = 'delete from'
				modify_account_phrase = 'modify the account that uploaded this to'

				pin_phrase = 'pin to'
				rescind_pin_phrase = 'rescind pin to'
				unpin_phrase = 'unpin from'
				rescind_unpin_phrase = 'rescind unpin from'

				manage_tags_phrase = 'file\'s tags'
				manage_urls_phrase = 'file\'s urls'
				manage_ratings_phrase = 'file\'s ratings'

				archive_phrase = 'archive'
				inbox_phrase = 'return to inbox'
				remove_phrase = 'remove from view'
				local_delete_phrase = 'delete'
				trash_delete_phrase = 'delete from trash now'
				undelete_phrase = 'undelete'
				# dump_phrase = 'dump to 4chan'
				export_phrase = 'file'
				copy_phrase = 'file'

			# info about the files

			groups_of_current_remote_service_keys = [ locations_manager.GetCurrentRemote() for locations_manager in selected_locations_managers ]
			groups_of_pending_remote_service_keys = [ locations_manager.GetPendingRemote() for locations_manager in selected_locations_managers ]
			groups_of_petitioned_remote_service_keys = [ locations_manager.GetPetitionedRemote() for locations_manager in selected_locations_managers ]
			groups_of_deleted_remote_service_keys = [ locations_manager.GetDeletedRemote() for locations_manager in selected_locations_managers ]

			current_remote_service_keys = MassUnion( groups_of_current_remote_service_keys )
			pending_remote_service_keys = MassUnion( groups_of_pending_remote_service_keys )
			petitioned_remote_service_keys = MassUnion( groups_of_petitioned_remote_service_keys )
			deleted_remote_service_keys = MassUnion( groups_of_deleted_remote_service_keys )

			common_current_remote_service_keys = IntelligentMassIntersect( groups_of_current_remote_service_keys )
			common_pending_remote_service_keys = IntelligentMassIntersect( groups_of_pending_remote_service_keys )
			common_petitioned_remote_service_keys = IntelligentMassIntersect( groups_of_petitioned_remote_service_keys )
			common_deleted_remote_service_keys = IntelligentMassIntersect( groups_of_deleted_remote_service_keys )

			disparate_current_remote_service_keys = current_remote_service_keys - common_current_remote_service_keys
			disparate_pending_remote_service_keys = pending_remote_service_keys - common_pending_remote_service_keys
			disparate_petitioned_remote_service_keys = petitioned_remote_service_keys - common_petitioned_remote_service_keys
			disparate_deleted_remote_service_keys = deleted_remote_service_keys - common_deleted_remote_service_keys

			some_downloading = True in ( locations_manager.IsDownloading() for locations_manager in selected_locations_managers )

			pending_file_service_keys = pending_remote_service_keys.intersection( file_service_keys )
			petitioned_file_service_keys = petitioned_remote_service_keys.intersection( file_service_keys )

			common_current_file_service_keys = common_current_remote_service_keys.intersection( file_service_keys )
			common_pending_file_service_keys = common_pending_remote_service_keys.intersection( file_service_keys )
			common_petitioned_file_service_keys = common_petitioned_remote_service_keys.intersection( file_service_keys )
			common_deleted_file_service_keys = common_deleted_remote_service_keys.intersection( file_service_keys )

			disparate_current_file_service_keys = disparate_current_remote_service_keys.intersection( file_service_keys )
			disparate_pending_file_service_keys = disparate_pending_remote_service_keys.intersection( file_service_keys )
			disparate_petitioned_file_service_keys = disparate_petitioned_remote_service_keys.intersection( file_service_keys )
			disparate_deleted_file_service_keys = disparate_deleted_remote_service_keys.intersection( file_service_keys )

			pending_ipfs_service_keys = pending_remote_service_keys.intersection( ipfs_service_keys )
			petitioned_ipfs_service_keys = petitioned_remote_service_keys.intersection( ipfs_service_keys )

			common_current_ipfs_service_keys = common_current_remote_service_keys.intersection( ipfs_service_keys )
			common_pending_ipfs_service_keys = common_pending_file_service_keys.intersection( ipfs_service_keys )
			common_petitioned_ipfs_service_keys = common_petitioned_remote_service_keys.intersection( ipfs_service_keys )

			disparate_current_ipfs_service_keys = disparate_current_remote_service_keys.intersection( ipfs_service_keys )
			disparate_pending_ipfs_service_keys = disparate_pending_remote_service_keys.intersection( ipfs_service_keys )
			disparate_petitioned_ipfs_service_keys = disparate_petitioned_remote_service_keys.intersection( ipfs_service_keys )

			# valid commands for the files

			uploadable_file_service_keys = set()

			downloadable_file_service_keys = set()

			petitionable_file_service_keys = set()

			deletable_file_service_keys = set()

			modifyable_file_service_keys = set()

			pinnable_ipfs_service_keys = set()

			unpinnable_ipfs_service_keys = set()

			for locations_manager in selected_locations_managers:
				# FILE REPOS

				# we can upload (set pending) to a repo_id when we have permission, a file is local, not current, not pending, and either ( not deleted or we_can_overrule )

				if locations_manager.IsLocal():
					uploadable_file_service_keys.update( upload_permission_file_service_keys - locations_manager.GetCurrentRemote() - locations_manager.GetPendingRemote() - ( locations_manager.GetDeletedRemote() - petition_resolve_permission_file_service_keys ) )

				# we can download (set pending to local) when we have permission, a file is not local and not already downloading and current

				if not locations_manager.IsLocal() and not locations_manager.IsDownloading():
					downloadable_file_service_keys.update( ipfs_service_keys.union( file_service_keys ) & locations_manager.GetCurrentRemote() )

				# we can petition when we have permission and a file is current and it is not already petitioned

				petitionable_file_service_keys.update( ( petition_permission_file_service_keys & locations_manager.GetCurrentRemote() ) - locations_manager.GetPetitionedRemote() )

				# we can delete remote when we have permission and a file is current and it is not already petitioned

				deletable_file_service_keys.update( ( petition_resolve_permission_file_service_keys & locations_manager.GetCurrentRemote() ) - locations_manager.GetPetitionedRemote() )

				# we can modify users when we have permission and the file is current or deleted

				modifyable_file_service_keys.update( user_manage_permission_file_service_keys & ( locations_manager.GetCurrentRemote() | locations_manager.GetDeletedRemote() ) )

				# IPFS

				# we can pin if a file is local, not current, not pending

				if locations_manager.IsLocal():
					pinnable_ipfs_service_keys.update( ipfs_service_keys - locations_manager.GetCurrentRemote() - locations_manager.GetPendingRemote() )

				# we can unpin a file if it is current and not petitioned

				unpinnable_ipfs_service_keys.update( ( ipfs_service_keys & locations_manager.GetCurrentRemote() ) - locations_manager.GetPetitionedRemote() )

			# do the actual menu

			if multiple_selected:
				AppendMenuLabel( menu, ToHumanInt( num_selected ) + ' files, ' + self._GetPrettyTotalSize( only_selected = True ) )

			else:
				for line in self._focussed_media.GetPrettyInfoLines():
					AppendMenuLabel( menu, line, line )

			if len( self._selected_media ) == 1:
				AddFileViewingStatsMenu( menu, self._focussed_media )

			if len( disparate_current_file_service_keys ) > 0:
				AddServiceKeyLabelsToMenu( menu, disparate_current_file_service_keys, 'some uploaded to' )

			if multiple_selected and len( common_current_file_service_keys ) > 0:
				AddServiceKeyLabelsToMenu( menu, common_current_file_service_keys, 'selected uploaded to' )

			if len( disparate_pending_file_service_keys ) > 0:
				AddServiceKeyLabelsToMenu( menu, disparate_pending_file_service_keys, 'some pending to' )

			if len( common_pending_file_service_keys ) > 0:
				AddServiceKeyLabelsToMenu( menu, common_pending_file_service_keys, 'pending to' )

			if len( disparate_petitioned_file_service_keys ) > 0:
				AddServiceKeyLabelsToMenu( menu, disparate_petitioned_file_service_keys, 'some petitioned from' )

			if len( common_petitioned_file_service_keys ) > 0:
				AddServiceKeyLabelsToMenu( menu, common_petitioned_file_service_keys, 'petitioned from' )

			if len( disparate_deleted_file_service_keys ) > 0:
				AddServiceKeyLabelsToMenu( menu, disparate_deleted_file_service_keys, 'some deleted from' )

			if len( common_deleted_file_service_keys ) > 0:
				AddServiceKeyLabelsToMenu( menu, common_deleted_file_service_keys, 'deleted from' )

			if len( disparate_current_ipfs_service_keys ) > 0:
				AddServiceKeyLabelsToMenu( menu, disparate_current_ipfs_service_keys, 'some pinned to' )

			if multiple_selected and len( common_current_ipfs_service_keys ) > 0:
				AddServiceKeyLabelsToMenu( menu, common_current_ipfs_service_keys, 'selected pinned to' )

			if len( disparate_pending_ipfs_service_keys ) > 0:
				AddServiceKeyLabelsToMenu( menu, disparate_pending_ipfs_service_keys, 'some to be pinned to' )

			if len( common_pending_ipfs_service_keys ) > 0:
				AddServiceKeyLabelsToMenu( menu, common_pending_ipfs_service_keys, 'to be pinned to' )

			if len( disparate_petitioned_ipfs_service_keys ) > 0:
				AddServiceKeyLabelsToMenu( menu, disparate_petitioned_ipfs_service_keys, 'some to be unpinned from' )

			if len( common_petitioned_ipfs_service_keys ) > 0:
				AddServiceKeyLabelsToMenu( menu, common_petitioned_ipfs_service_keys, unpin_phrase )

			AppendSeparator( menu )

			#

			len_interesting_remote_service_keys = 0

			len_interesting_remote_service_keys += len( downloadable_file_service_keys )
			len_interesting_remote_service_keys += len( uploadable_file_service_keys )
			len_interesting_remote_service_keys += len( pending_file_service_keys )
			len_interesting_remote_service_keys += len( petitionable_file_service_keys )
			len_interesting_remote_service_keys += len( petitioned_file_service_keys )
			len_interesting_remote_service_keys += len( deletable_file_service_keys )
			len_interesting_remote_service_keys += len( modifyable_file_service_keys )
			len_interesting_remote_service_keys += len( pinnable_ipfs_service_keys )
			len_interesting_remote_service_keys += len( pending_ipfs_service_keys )
			len_interesting_remote_service_keys += len( unpinnable_ipfs_service_keys )
			len_interesting_remote_service_keys += len( petitioned_ipfs_service_keys )

			if multiple_selected:
				len_interesting_remote_service_keys += len( ipfs_service_keys )

			if len_interesting_remote_service_keys > 0:
				remote_action_menu = wx.Menu()

				if len( downloadable_file_service_keys ) > 0:
					AppendMenuItem( self, remote_action_menu, download_phrase, 'Download all possible selected files.', self._DownloadSelected )

				if some_downloading:
					AppendMenuItem( self, remote_action_menu, rescind_download_phrase, 'Stop downloading any of the selected files.', self._RescindDownloadSelected )

				if len( uploadable_file_service_keys ) > 0:
					AddServiceKeysToMenu( self, remote_action_menu, uploadable_file_service_keys, upload_phrase, 'Upload all selected files to the file repository.', self._UploadFiles )

				if len( pending_file_service_keys ) > 0:
					AddServiceKeysToMenu( self, remote_action_menu, pending_file_service_keys, rescind_upload_phrase, 'Rescind the pending upload to the file repository.', self._RescindUploadFiles )

				if len( petitionable_file_service_keys ) > 0:
					AddServiceKeysToMenu( self, remote_action_menu, petitionable_file_service_keys, petition_phrase, 'Petition these files for deletion from the file repository.', self._PetitionFiles )

				if len( petitioned_file_service_keys ) > 0:
					AddServiceKeysToMenu( self, remote_action_menu, petitioned_file_service_keys, rescind_petition_phrase, 'Rescind the petition to delete these files from the file repository.', self._RescindPetitionFiles )

				if len( deletable_file_service_keys ) > 0:
					AddServiceKeysToMenu( self, remote_action_menu, deletable_file_service_keys, remote_delete_phrase, 'Delete these files from the file repository.', self._Delete )

				if len( modifyable_file_service_keys ) > 0:
					AddServiceKeysToMenu( self, remote_action_menu, modifyable_file_service_keys, modify_account_phrase, 'Modify the account(s) that uploaded these files to the file repository.', self._ModifyUploaders )

				if len( pinnable_ipfs_service_keys ) > 0:
					AddServiceKeysToMenu( self, remote_action_menu, pinnable_ipfs_service_keys, pin_phrase, 'Pin these files to the ipfs service.', self._UploadFiles )

				if len( pending_ipfs_service_keys ) > 0:
					AddServiceKeysToMenu( self, remote_action_menu, pending_ipfs_service_keys, rescind_pin_phrase, 'Rescind the pending pin to the ipfs service.', self._RescindUploadFiles )

				if len( unpinnable_ipfs_service_keys ) > 0:
					AddServiceKeysToMenu( self, remote_action_menu, unpinnable_ipfs_service_keys, unpin_phrase, 'Unpin these files from the ipfs service.', self._PetitionFiles )

				if len( petitioned_ipfs_service_keys ) > 0:
					AddServiceKeysToMenu( self, remote_action_menu, petitioned_ipfs_service_keys, rescind_unpin_phrase, 'Rescind the pending unpin from the ipfs service.', self._RescindPetitionFiles )

				if multiple_selected and len( ipfs_service_keys ) > 0:
					AddServiceKeysToMenu( self, remote_action_menu, ipfs_service_keys, 'pin new directory to', 'Pin these files as a directory to the ipfs service.', self._UploadDirectory )

				AppendMenu( menu, remote_action_menu, 'remote services' )

			#

			manage_menu = wx.Menu()

			AppendMenuItem( self, manage_menu, manage_tags_phrase, 'Manage tags for the selected files.', self._ManageTags )

			if i_can_post_ratings:
				AppendMenuItem( self, manage_menu, manage_ratings_phrase, 'Manage ratings for the selected files.', self._ManageRatings )

			AppendMenuItem( self, manage_menu, manage_urls_phrase, 'Manage urls for the selected files.', self._ManageURLs )
			AppendMenuItem( self, manage_menu, 'file\'s notes', 'Manage notes for the focused file.', self._ManageNotes )

			AppendMenu( menu, manage_menu, 'manage' )

			#

			if selection_has_local:
				AppendMenuItem( self, menu, 'archive/delete filter', 'Launch a special media viewer that will quickly archive (left-click) and delete (right-click) the selected media.', self._ArchiveDeleteFilter )

			AppendSeparator( menu )

			if selection_has_inbox:
				AppendMenuItem( self, menu, archive_phrase, 'Archive the selected files.', self._Archive )

			if selection_has_archive:
				AppendMenuItem( self, menu, inbox_phrase, 'Put the selected files back in the inbox.', self._Inbox )

			AppendMenuItem( self, menu, remove_phrase, 'Remove the selected files from the current view.', self._Remove )

			if selection_has_local_file_domain:
				AppendMenuItem( self, menu, local_delete_phrase, 'Delete the selected files from \'my files\'.', self._Delete)

			if selection_has_trash:
				AppendMenuItem( self, menu, trash_delete_phrase, 'Delete the selected files from the trash, forcing an immediate physical delete from your hard drive.', self._Delete, CC.TRASH_SERVICE_KEY )
				AppendMenuItem( self, menu, undelete_phrase, 'Restore the selected files back to \'my files\'.', self._Undelete )

			#

			AppendSeparator( menu )

			if focussed_is_local:
				AppendMenuItem( self, menu, 'open externally', 'Launch this file with your OS\'s default program for it.', self._OpenExternally )

			#

			AddKnownURLsViewCopyMenu( self, menu, self._focussed_media, selected_media = self._selected_media )

			# share

			share_menu = wx.Menu()

			#

			if focussed_is_local:
				show_open_in_web = True
				show_open_in_explorer = advanced_mode and not HC.PLATFORM_LINUX

				if show_open_in_web or show_open_in_explorer:
					open_menu = wx.Menu()

					if show_open_in_web:
						AppendMenuItem( self, open_menu, 'in web browser', 'Show this file in your OS\'s web browser.', self._OpenFileInWebBrowser )

					if show_open_in_explorer:
						AppendMenuItem( self, open_menu, 'in file browser', 'Show this file in your OS\'s file browser.', self._OpenFileLocation )

					AppendMenu( share_menu, open_menu, 'open' )

			copy_menu = wx.Menu()

			if selection_has_local:
				AppendMenuItem( self, copy_menu, copy_phrase, 'Copy the selected files to the clipboard.', self._CopyFilesToClipboard )

				if advanced_mode:
					copy_hash_menu = wx.Menu()

					AppendMenuItem( self, copy_hash_menu, 'sha256 (hydrus default)', 'Copy the selected file\'s SHA256 hash to the clipboard.', self._CopyHashToClipboard, 'sha256' )
					AppendMenuItem( self, copy_hash_menu, 'md5', 'Copy the selected file\'s MD5 hash to the clipboard.', self._CopyHashToClipboard, 'md5' )
					AppendMenuItem( self, copy_hash_menu, 'sha1', 'Copy the selected file\'s SHA1 hash to the clipboard.', self._CopyHashToClipboard, 'sha1' )
					AppendMenuItem( self, copy_hash_menu, 'sha512', 'Copy the selected file\'s SHA512 hash to the clipboard.', self._CopyHashToClipboard, 'sha512' )

					AppendMenu( copy_menu, copy_hash_menu, 'hash' )

					if multiple_selected:
						copy_hash_menu = wx.Menu()

						AppendMenuItem( self, copy_hash_menu, 'sha256 (hydrus default)', 'Copy the selected files\' SHA256 hashes to the clipboard.', self._CopyHashesToClipboard, 'sha256' )
						AppendMenuItem( self, copy_hash_menu, 'md5', 'Copy the selected files\' MD5 hashes to the clipboard.', self._CopyHashesToClipboard, 'md5' )
						AppendMenuItem( self, copy_hash_menu, 'sha1', 'Copy the selected files\' SHA1 hashes to the clipboard.', self._CopyHashesToClipboard, 'sha1' )
						AppendMenuItem( self, copy_hash_menu, 'sha512', 'Copy the selected files\' SHA512 hashes to the clipboard.', self._CopyHashesToClipboard, 'sha512' )

						AppendMenu( copy_menu, copy_hash_menu, 'hashes' )

			else:
				if advanced_mode:
					AppendMenuItem( self, copy_menu, 'sha256 hash', 'Copy the selected file\'s SHA256 hash to the clipboard.', self._CopyHashToClipboard, 'sha256' )

					if multiple_selected:
						AppendMenuItem( self, copy_menu, 'sha256 hashes', 'Copy the selected files\' SHA256 hash to the clipboard.', self._CopyHashesToClipboard, 'sha256' )

			for ipfs_service_key in self._focussed_media.GetLocationsManager().GetCurrentRemote().intersection( ipfs_service_keys ):
				name = service_keys_to_names[ ipfs_service_key ]

				AppendMenuItem( self, copy_menu, name + ' multihash', 'Copy the selected file\'s multihash to the clipboard.', self._CopyServiceFilenameToClipboard, ipfs_service_key )

			if multiple_selected:
				for ipfs_service_key in disparate_current_ipfs_service_keys.union( common_current_ipfs_service_keys ):
					name = service_keys_to_names[ ipfs_service_key ]

					AppendMenuItem( self, copy_menu, name + ' multihashes', 'Copy the selected files\' multihashes to the clipboard.', self._CopyServiceFilenamesToClipboard, ipfs_service_key )

			if focussed_is_local:
				if self._focussed_media.GetMime() in HC.IMAGES and self._focussed_media.GetDuration() is None:
					AppendMenuItem( self, copy_menu, 'image (bitmap)', 'Copy the selected file\'s image data to the clipboard (as a bmp ).', self._CopyBMPToClipboard )

				AppendMenuItem( self, copy_menu, 'path', 'Copy the selected file\'s path to the clipboard.', self._CopyPathToClipboard )

			if multiple_selected and selection_has_local:
				AppendMenuItem( self, copy_menu, 'paths', 'Copy the selected files\' paths to the clipboard.', self._CopyPathsToClipboard )

			AppendMenu( share_menu, copy_menu, 'copy' )

			#

			export_menu = wx.Menu()

			AppendMenuItem( self, export_menu, export_phrase, 'Export the selected files to an external folder.', self._ExportFiles )

			if advanced_mode:
				AppendMenuItem( self, export_menu, 'tags', 'Export the selected files\' tags to an external database.', self._ExportTags )

			AppendMenu( share_menu, export_menu, 'export' )

			#

			if local_booru_is_running:
				AppendMenuItem( self, share_menu, 'on local booru', 'Share the selected files on your client\'s local booru.', self._ShareOnLocalBooru )

			#

			AppendMenu( menu, share_menu, 'share' )

			#

			AppendSeparator( menu )

		AppendMenuItem( self, menu, 'refresh', 'Refresh the current search.', HG.client_controller.pub, 'refresh_query', self._page_key )

		if len( self._sorted_media ) > 0:
			AppendSeparator( menu )

			select_menu = wx.Menu()

			if len( self._selected_media ) < len( self._sorted_media ):
				all_label = 'all (' + ToHumanInt( len( self._sorted_media ) ) + ')'

				if media_has_archive and not media_has_inbox:
					all_label += ' (all in archive)'

				elif media_has_inbox and not media_has_archive:
					all_label += ' (all in inbox)'

				AppendMenuItem( self, select_menu, all_label, 'Select everything.', self._Select, 'all' )

			if media_has_archive and media_has_inbox:
				inbox_label = 'inbox (' + ToHumanInt( num_inbox ) + ')'
				archive_label = 'archive (' + ToHumanInt( num_archive ) + ')'

				AppendMenuItem( self, select_menu, inbox_label, 'Select everything in the inbox.', self._Select, 'inbox' )
				AppendMenuItem( self, select_menu, archive_label, 'Select everything that is archived.', self._Select, 'archive' )

			if len( all_specific_file_domains ) > 1:
				selectable_file_domains = list( all_local_file_domains )

				if CC.TRASH_SERVICE_KEY in all_specific_file_domains:
					selectable_file_domains.append( CC.TRASH_SERVICE_KEY )

				selectable_file_domains.extend( all_file_repos )

				for service_key in selectable_file_domains:
					name = services_manager.GetName( service_key )

					AppendMenuItem( self, select_menu, name, 'Select everything in ' + name + '.', self._Select, 'file_service', service_key )

			if has_local and has_remote:
				AppendMenuItem( self, select_menu, 'local', 'Select everything in the client.', self._Select, 'local' )
				AppendMenuItem( self, select_menu, 'remote', 'Select everything that is not in the client.', self._Select, 'remote' )

			if len( self._selected_media ) > 0:
				if len( self._selected_media ) < len( self._sorted_media ):
					invert_label = 'invert (' + ToHumanInt( len( self._sorted_media ) - len( self._selected_media ) ) + ')'

					AppendMenuItem( self, select_menu, invert_label, 'Swap what is and is not selected.', self._Select, 'invert' )

				AppendMenuItem( self, select_menu, 'none (0)', 'Deselect everything.', self._Select, 'none' )

			AppendMenu( menu, select_menu, 'select' )

		if self._focussed_media is not None:
			AppendSeparator( menu )

			AppendMenuItem( self, menu, 'open selection in a new page', 'Copy your current selection into a simple new page.', self._ShowSelectionInNewPage )

			if advanced_mode:
				AppendSeparator( menu )

				duplicates_menu = menu # this is important to make the menu flexible if not multiple selected

				focussed_hash = self._focussed_media.GetDisplayMedia().GetHash()

				if multiple_selected:
					duplicates_menu = wx.Menu()

					duplicates_action_submenu = wx.Menu()

					label = 'set this file as better than the ' + ToHumanInt( num_selected - 1 ) + ' other selected'

					AppendMenuItem( self, duplicates_action_submenu, label, 'Set the focused media to be better than the other selected files.', self.ProcessApplicationCommand, ApplicationCommand( CC.APPLICATION_COMMAND_TYPE_SIMPLE, 'duplicate_media_set_focused_better' ) )

					num_files = self._GetNumSelected()

					num_pairs = num_files * ( num_files - 1 ) / 2 # com // ations -- n!/2( n-2 )!

					num_pairs_text = ToHumanInt( num_pairs ) + ' pairs'

					AppendMenuItem( self, duplicates_action_submenu, 'set all selected as same quality', 'Set all the selected files as same quality duplicates.', self.ProcessApplicationCommand, ApplicationCommand( CC.APPLICATION_COMMAND_TYPE_SIMPLE, 'duplicate_media_set_same_quality' ) )

					AppendMenuItem( self, duplicates_action_submenu, 'set all selected as alternates', 'Set all the selected files as alternates.', self.ProcessApplicationCommand, ApplicationCommand( CC.APPLICATION_COMMAND_TYPE_SIMPLE, 'duplicate_media_set_alternate' ) )

					AppendMenuItem( self, duplicates_action_submenu, 'set all selected as not duplicates', 'Set all the selected files as not duplicates.', self.ProcessApplicationCommand, ApplicationCommand( CC.APPLICATION_COMMAND_TYPE_SIMPLE, 'duplicate_media_set_not_duplicate' ) )

					AppendMenuItem( self, duplicates_action_submenu, 'make a custom duplicates action', 'Choose which duplicates status to set to this selection and customise non-default merge options.', self.ProcessApplicationCommand, ApplicationCommand( CC.APPLICATION_COMMAND_TYPE_SIMPLE, 'duplicate_media_set_custom' ) )

					if collections_selected:
						AppendSeparator( duplicates_action_submenu )

						AppendMenuItem( self, duplicates_action_submenu, 'set selected collections as groups of alternates', 'Set files in the selection which are collected together as alternates.', self.ProcessApplicationCommand, ApplicationCommand( CC.APPLICATION_COMMAND_TYPE_SIMPLE, 'duplicate_media_set_alternate_collections' ) )

					AppendSeparator( duplicates_action_submenu )

					AppendMenuItem( self, duplicates_action_submenu, 'send the ' + num_pairs_text + ' in this selection to be compared in the duplicates filter', 'Set all the possible pairs in the selection as unknown/potential duplicate pairs.', self.ProcessApplicationCommand, ApplicationCommand( CC.APPLICATION_COMMAND_TYPE_SIMPLE, 'duplicate_media_reset_to_potential' ) )

					AppendMenuItem( self, duplicates_action_submenu, 'remove the ' + num_pairs_text + ' in this selection from the duplicates system', 'Remove all duplicates relationships from all the pairs in this selection.', self.ProcessApplicationCommand, ApplicationCommand( CC.APPLICATION_COMMAND_TYPE_SIMPLE, 'duplicate_media_remove_relationships' ) )

					AppendMenu( duplicates_menu, duplicates_action_submenu, 'set duplicate relationships' )

					duplicates_edit_action_submenu = wx.Menu()

					for duplicate_type in ( HC.DUPLICATE_BETTER, HC.DUPLICATE_SAME_QUALITY, HC.DUPLICATE_ALTERNATE, HC.DUPLICATE_NOT_DUPLICATE ):
						AppendMenuItem( self, duplicates_edit_action_submenu, 'for ' + HC.duplicate_type_string_lookup[ duplicate_type ], 'Edit what happens when you set this status.', self._EditDuplicateActionOptions, duplicate_type )

					AppendMenu( duplicates_menu, duplicates_edit_action_submenu, 'edit default merge options' )

					AppendMenu( menu, duplicates_menu, 'duplicates' )

				if HG.client_controller.DBCurrentlyDoingJob():
					AppendMenuLabel( duplicates_menu, 'Could not fetch duplicates (db currently locked)' )

				else:
					file_duplicate_types_to_counts = HG.client_controller.Read( 'file_duplicate_types_to_counts', self._file_service_key, focussed_hash )

					if len( file_duplicate_types_to_counts ) > 0:
						duplicates_view_menu = wx.Menu()

						for duplicate_type in ( HC.DUPLICATE_BETTER_OR_WORSE, HC.DUPLICATE_BETTER, HC.DUPLICATE_WORSE, HC.DUPLICATE_SAME_QUALITY, HC.DUPLICATE_ALTERNATE, HC.DUPLICATE_NOT_DUPLICATE, HC.DUPLICATE_UNKNOWN ):
							if duplicate_type in file_duplicate_types_to_counts:
								count = file_duplicate_types_to_counts[ duplicate_type ]

								label = ToHumanInt( count ) + ' ' + HC.duplicate_type_string_lookup[ duplicate_type ]

								AppendMenuItem( self, duplicates_view_menu, label, 'Show these duplicates in a new page.', self._ShowDuplicatesInNewPage, focussed_hash, duplicate_type )

						AppendMenu( duplicates_menu, duplicates_view_menu, 'view this file\'s duplicates' )

				if self._focussed_media.HasImages():
					similar_menu = wx.Menu()

					AppendMenuItem( self, similar_menu, 'exact match', 'Search the database for files that look precisely like this one.', self._GetSimilarTo, HC.HAMMING_EXACT_MATCH )
					AppendMenuItem( self, similar_menu, 'very similar', 'Search the database for files that look just like this one.', self._GetSimilarTo, HC.HAMMING_VERY_SIMILAR )
					AppendMenuItem( self, similar_menu, 'similar', 'Search the database for files that look generally like this one.', self._GetSimilarTo, HC.HAMMING_SIMILAR )
					AppendMenuItem( self, similar_menu, 'speculative', 'Search the database for files that probably look like this one. This is sometimes useful for symbols with sharp edges or lines.', self._GetSimilarTo, HC.HAMMING_SPECULATIVE )

					AppendMenu( menu, similar_menu, 'find similar files' )

				AppendSeparator( menu )

				regen_menu = wx.Menu()

				AppendMenuItem( self, regen_menu, 'thumbnails, but only if wrong size', 'Regenerate the selected files\' thumbnails, but only if they are the wrong size.', self._RegenerateFileData, REGENERATE_FILE_DATA_JOB_REFIT_THUMBNAIL )
				AppendMenuItem( self, regen_menu, 'thumbnails', 'Regenerate the selected files\'s thumbnails.', self._RegenerateFileData, REGENERATE_FILE_DATA_JOB_FORCE_THUMBNAIL )
				AppendMenuItem( self, regen_menu, 'file metadata and thumbnails', 'Regenerated the selected files\' metadata and thumbnails.', self._RegenerateFileData, REGENERATE_FILE_DATA_JOB_COMPLETE )

				AppendMenu( menu, regen_menu, 'regenerate' )

		HG.client_controller.PopupMenu( self, menu )

		event.Skip()

	def MaintainPageCache( self ):
		if not HG.client_controller.GetGUI().IsCurrentPage( self._page_key ):
			self._DirtyAllPages()

		self._DeleteAllDirtyPages()

	def NewFileInfo( self, hashes ):
		affected_media = self._GetMedia( hashes )

		for media in affected_media:
			media.RefreshFileInfo()

	def NewThumbnails( self, hashes ):
		affected_thumbnails = self._GetMedia( hashes )

		if len( affected_thumbnails ) > 0:
			self._RedrawMedia( affected_thumbnails )

	def RefreshAcceleratorTable( self ):
		if not self:
			return

		entries = [
			( wx.ACCEL_NORMAL, wx.WXK_HOME, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'scroll_home' ) ),
			( wx.ACCEL_NORMAL, wx.WXK_NUMPAD_HOME, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'scroll_home' ) ),
			( wx.ACCEL_NORMAL, wx.WXK_END, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'scroll_end' ) ),
			( wx.ACCEL_NORMAL, wx.WXK_NUMPAD_END, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'scroll_end' ) ),
			( wx.ACCEL_NORMAL, wx.WXK_DELETE, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'delete_file' ) ),
			( wx.ACCEL_NORMAL, wx.WXK_NUMPAD_DELETE, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'delete_file' ) ),
			( wx.ACCEL_NORMAL, wx.WXK_RETURN, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'fullscreen' ) ),
			( wx.ACCEL_NORMAL, wx.WXK_NUMPAD_ENTER, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'fullscreen' ) ),
			( wx.ACCEL_NORMAL, wx.WXK_UP, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'key_up' ) ),
			( wx.ACCEL_NORMAL, wx.WXK_NUMPAD_UP, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'key_up' ) ),
			( wx.ACCEL_NORMAL, wx.WXK_DOWN, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'key_down' ) ),
			( wx.ACCEL_NORMAL, wx.WXK_NUMPAD_DOWN, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'key_down' ) ),
			( wx.ACCEL_NORMAL, wx.WXK_LEFT, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'key_left' ) ),
			( wx.ACCEL_NORMAL, wx.WXK_NUMPAD_LEFT, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'key_left' ) ),
			( wx.ACCEL_NORMAL, wx.WXK_RIGHT, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'key_right' ) ),
			( wx.ACCEL_NORMAL, wx.WXK_NUMPAD_RIGHT, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'key_right' ) ),
			( wx.ACCEL_SHIFT, wx.WXK_HOME, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'shift_scroll_home' ) ),
			( wx.ACCEL_SHIFT, wx.WXK_NUMPAD_HOME, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'shift_scroll_home' ) ),
			( wx.ACCEL_SHIFT, wx.WXK_END, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'shift_scroll_end' ) ),
			( wx.ACCEL_SHIFT, wx.WXK_NUMPAD_END, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'shift_scroll_end' ) ),
			( wx.ACCEL_SHIFT, wx.WXK_DELETE, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'undelete' ) ),
			( wx.ACCEL_SHIFT, wx.WXK_NUMPAD_DELETE, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'undelete' ) ),
			( wx.ACCEL_SHIFT, wx.WXK_UP, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'key_shift_up' ) ),
			( wx.ACCEL_SHIFT, wx.WXK_NUMPAD_UP, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'key_shift_up' ) ),
			( wx.ACCEL_SHIFT, wx.WXK_DOWN, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'key_shift_down' ) ),
			( wx.ACCEL_SHIFT, wx.WXK_NUMPAD_DOWN, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'key_shift_down' ) ),
			( wx.ACCEL_SHIFT, wx.WXK_LEFT, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'key_shift_left' ) ),
			( wx.ACCEL_SHIFT, wx.WXK_NUMPAD_LEFT, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'key_shift_left' ) ),
			( wx.ACCEL_SHIFT, wx.WXK_RIGHT, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'key_shift_right' ) ),
			( wx.ACCEL_SHIFT, wx.WXK_NUMPAD_RIGHT, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'key_shift_right' ) ),
			( wx.ACCEL_CTRL, ord( 'A' ), MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'select', 'all' ) ),
			( wx.ACCEL_CTRL, wx.WXK_SPACE, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'ctrl-space' )  )
		]

		if HC.PLATFORM_OSX:
			entries.append( ( wx.ACCEL_NORMAL, wx.WXK_BACK, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'delete_file' ) ) )
			entries.append( ( wx.ACCEL_SHIFT, wx.WXK_BACK, MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'undelete' ) ) )

		self.SetAcceleratorTable( wx.AcceleratorTable( entries ) )

	def SetFocussedMedia( self, page_key, media ):
		MediaPanel.SetFocussedMedia( self, page_key, media )

		if page_key == self._page_key:
			if media is None:
				self._SetFocussedMedia( None )

			else:
				try:
					my_media = self._GetMedia( media.GetHashes() )[0]

					self._HitMedia( my_media, False, False )

					self._ScrollToMedia( self._focussed_media )

				except Exception:
					pass

	def Sort( self, page_key, media_sort = None ):
		MediaPanel.Sort( self, page_key, media_sort )

		self._DirtyAllPages()

		self.Refresh()

	def ThumbnailsResized( self ):
		( thumbnail_span_width, thumbnail_span_height ) = self._GetThumbnailSpanDimensions()

		thumbnail_scroll_rate = float( HG.client_controller.new_options.GetString( 'thumbnail_scroll_rate' ) )

		self.SetScrollRate( 0, int( round( thumbnail_span_height * thumbnail_scroll_rate ) ) )

		self._thumbnails_being_faded_in = {}
		self._hashes_faded = set()

		self._ReinitialisePageCacheIfNeeded()

		self._RecalculateVirtualSize()

		self._DirtyAllPages()

		self.Refresh()

	def TIMERAnimationUpdate( self ):
		FRAME_DURATION = 1.0 / 60
		NUM_FRAMES_TO_FILL_IN = 15

		loop_started = GetNowPrecise()
		loop_should_break_time = loop_started + ( FRAME_DURATION / 2 )

		( thumbnail_span_width, thumbnail_span_height ) = self._GetThumbnailSpanDimensions()

		thumbnail_margin = HG.client_controller.new_options.GetInteger( 'thumbnail_margin' )

		hashes = list( self._thumbnails_being_faded_in.keys() )

		random.shuffle( hashes )

		dcs = {}

		( xUnit, yUnit ) = self.GetScrollPixelsPerUnit()

		y_start = self._GetYStart()

		earliest_y = y_start * yUnit

		page_height = self._num_rows_per_canvas_page * thumbnail_span_height

		for hash in hashes:
			( original_bmp, alpha_bmp, thumbnail_index, thumbnail, animation_started, num_frames_rendered ) = self._thumbnails_being_faded_in[ hash ]

			num_frames_supposed_to_be_rendered = int( ( loop_started - animation_started ) / FRAME_DURATION )

			num_frames_to_render = num_frames_supposed_to_be_rendered - num_frames_rendered

			if num_frames_to_render == 0:
				continue

			delete_entry = False

			try:
				expected_thumbnail = self._sorted_media[ thumbnail_index ]

			except Exception:
				expected_thumbnail = None

			page_index = self._GetPageIndexFromThumbnailIndex( thumbnail_index )

			if expected_thumbnail != thumbnail:
				delete_entry = True

			elif page_index not in self._clean_canvas_pages:
				delete_entry = True

			else:
				times_to_draw = 1

				if num_frames_supposed_to_be_rendered >= NUM_FRAMES_TO_FILL_IN:
					bmp_to_use = original_bmp

					delete_entry = True

				else:
					times_to_draw = num_frames_to_render

					bmp_to_use = alpha_bmp

					num_frames_rendered += times_to_draw

					self._thumbnails_being_faded_in[ hash ] = ( original_bmp, alpha_bmp, thumbnail_index, thumbnail, animation_started, num_frames_rendered )

				thumbnail_col = thumbnail_index % self._num_columns

				thumbnail_row = thumbnail_index // self._num_columns

				x = thumbnail_col * thumbnail_span_width + thumbnail_margin

				y = ( thumbnail_row - ( page_index * self._num_rows_per_canvas_page ) ) * thumbnail_span_height + thumbnail_margin

				if page_index not in dcs:
					canvas_bmp = self._clean_canvas_pages[ page_index ]

					dc = wx.MemoryDC( canvas_bmp )

					dcs[ page_index ] = dc

				dc = dcs[ page_index ]

				for i in range( times_to_draw ):
					dc.DrawBitmap( bmp_to_use, x, y, True )

				#

				page_virtual_y = page_height * page_index

				page_client_y = page_virtual_y - earliest_y

				client_y = page_client_y + y

				self.RefreshRect( wx.Rect( x, client_y, thumbnail_span_width - thumbnail_margin, thumbnail_span_height - thumbnail_margin ) )

			if delete_entry:
				del self._thumbnails_being_faded_in[ hash ]

				HG.client_controller.bitmap_manager.ReleaseBitmap( original_bmp )
				HG.client_controller.bitmap_manager.ReleaseBitmap( alpha_bmp )

			if TimeHasPassedPrecise( loop_should_break_time ):
				break

		if len( self._thumbnails_being_faded_in ) == 0:
			HG.client_controller.gui.UnregisterAnimationUpdateWindow( self )

	def WaterfallThumbnails( self, page_key, thumbnails ):
		if self._page_key == page_key:
			self._FadeThumbnails( thumbnails )

class Selectable( object ):
	def __init__( self ): self._selected = False

	def Deselect( self ): self._selected = False

	def IsSelected( self ): return self._selected

	def Select( self ): self._selected = True

class Thumbnail( Selectable ):
	def __init__( self, file_service_key ):
		Selectable.__init__( self )

		self._dump_status = CC.DUMPER_NOT_DUMPED
		self._file_service_key = file_service_key

	def _ScaleUpThumbnailDimensions( self, thumbnail_dimensions, scale_up_dimensions ):
		( thumb_width, thumb_height ) = thumbnail_dimensions
		( scale_up_width, scale_up_height ) = scale_up_dimensions

		# we want to expand the image so that the smallest dimension fills everything

		scale_factor = max( scale_up_width / thumb_width, scale_up_height / thumb_height )

		destination_width = int( round( thumb_width * scale_factor ) )
		destination_height = int( round( thumb_height * scale_factor ) )

		offset_x = ( scale_up_width - destination_width ) // 2
		offset_y = ( scale_up_height - destination_height ) // 2

		offset_position = ( offset_x, offset_y )
		destination_dimensions = ( destination_width, destination_height )

		return ( offset_position, destination_dimensions )

	def Dumped( self, dump_status ):
		self._dump_status = dump_status

	def GetBmp( self ):
		inbox = self.HasInbox()

		local = self.GetLocationsManager().IsLocal()

		thumbnail_hydrus_bmp = HG.client_controller.GetCache( 'thumbnail' ).GetThumbnail( self )

		thumbnail_border = HG.client_controller.new_options.GetInteger( 'thumbnail_border' )

		( width, height ) = AddPaddingToDimensions( HC.options[ 'thumbnail_dimensions' ], thumbnail_border * 2 )

		bmp = HG.client_controller.bitmap_manager.GetBitmap( width, height, 24 )

		dc = wx.MemoryDC( bmp )

		new_options = HG.client_controller.new_options

		if not local:
			if self._selected:
				background_colour_type = CC.COLOUR_THUMB_BACKGROUND_REMOTE_SELECTED

			else:
				background_colour_type = CC.COLOUR_THUMB_BACKGROUND_REMOTE

		else:
			if self._selected:
				background_colour_type = CC.COLOUR_THUMB_BACKGROUND_SELECTED

			else:
				background_colour_type = CC.COLOUR_THUMB_BACKGROUND

		dc.SetPen( wx.TRANSPARENT_PEN )

		dc.SetBrush( wx.Brush( new_options.GetColour( background_colour_type ) ) )

		dc.DrawRectangle( thumbnail_border, thumbnail_border, width - ( thumbnail_border * 2 ), height - ( thumbnail_border * 2 ) )

		thumbnail_fill = HG.client_controller.new_options.GetBoolean( 'thumbnail_fill' )

		( thumb_width, thumb_height ) = thumbnail_hydrus_bmp.GetSize()

		if thumbnail_fill:
			wx_image = thumbnail_hydrus_bmp.GetWxImage()

			scale_up_dimensions = HC.options[ 'thumbnail_dimensions' ]

			( offset_position, destination_dimensions ) = self._ScaleUpThumbnailDimensions( ( thumb_width, thumb_height ), scale_up_dimensions )

			( destination_width, destination_height ) = destination_dimensions

			wx_image = wx_image.Scale( destination_width, destination_height, wx.IMAGE_QUALITY_HIGH )

			wx_bmp = wx.Bitmap( wx_image )

			wx_image.Destroy()

			( x_offset, y_offset ) = offset_position

			x_offset += thumbnail_border
			y_offset += thumbnail_border

		else:
			wx_bmp = thumbnail_hydrus_bmp.GetWxBitmap()

			x_offset = ( width - thumb_width ) // 2

			y_offset = ( height - thumb_height ) // 2

		dc.DrawBitmap( wx_bmp, x_offset, y_offset )

		HG.client_controller.bitmap_manager.ReleaseBitmap( wx_bmp )

		new_options = HG.client_controller.new_options

		tm = self.GetTagsManager()

		tags = tm.GetCurrent( CC.COMBINED_TAG_SERVICE_KEY ).union( tm.GetPending( CC.COMBINED_TAG_SERVICE_KEY ) )

		if len( tags ) > 0:
			siblings_manager = HG.client_controller.tag_siblings_manager

			tags = siblings_manager.CollapseTags( CC.COMBINED_TAG_SERVICE_KEY, tags )

			upper_tag_summary_generator = new_options.GetTagSummaryGenerator( 'thumbnail_top' )

			upper_summary = upper_tag_summary_generator.GenerateSummary( tags )

			lower_tag_summary_generator = new_options.GetTagSummaryGenerator( 'thumbnail_bottom_right' )

			lower_summary = lower_tag_summary_generator.GenerateSummary( tags )

			if len( upper_summary ) > 0 or len( lower_summary ) > 0:
				gc = wx.GraphicsContext.Create( dc )

				if len( upper_summary ) > 0:
					text_colour_with_alpha = upper_tag_summary_generator.GetTextColour()

					# protip, this renders unicode characters (such as \U0001f50a) in the Supplementary Multilingual Plane incorrectly, wew
					# DeviceContext does render them ok somehow

					gc.SetFont( wx.SystemSettings.GetFont( wx.SYS_DEFAULT_GUI_FONT ), text_colour_with_alpha )

					background_colour_with_alpha = upper_tag_summary_generator.GetBackgroundColour()

					gc.SetBrush( wx.Brush( background_colour_with_alpha ) )

					gc.SetPen( wx.TRANSPARENT_PEN )

					( text_x, text_y ) = gc.GetTextExtent( upper_summary )

					top_left_x = int( ( width - text_x ) // 2 )
					top_left_y = thumbnail_border

					gc.DrawRectangle( thumbnail_border, top_left_y, width - ( thumbnail_border * 2 ), text_y + 1 )

					gc.DrawText( upper_summary, top_left_x, top_left_y )

				if len( lower_summary ) > 0:
					text_colour_with_alpha = lower_tag_summary_generator.GetTextColour()

					gc.SetFont( wx.SystemSettings.GetFont( wx.SYS_DEFAULT_GUI_FONT ), text_colour_with_alpha )

					background_colour_with_alpha = lower_tag_summary_generator.GetBackgroundColour()

					gc.SetBrush( wx.Brush( background_colour_with_alpha ) )

					gc.SetPen( wx.TRANSPARENT_PEN )

					( text_x, text_y ) = gc.GetTextExtent( lower_summary )

					top_left_x = width - text_x - thumbnail_border
					top_left_y = height - text_y - thumbnail_border

					gc.DrawRectangle( top_left_x - 1, top_left_y - 1, text_x + 1, text_y + 1 )

					gc.DrawText( lower_summary, top_left_x, top_left_y )

				del gc

		if thumbnail_border > 0:
			if not local:
				if self._selected:
					border_colour_type = CC.COLOUR_THUMB_BORDER_REMOTE_SELECTED

				else:
					border_colour_type = CC.COLOUR_THUMB_BORDER_REMOTE

			else:
				if self._selected:
					border_colour_type = CC.COLOUR_THUMB_BORDER_SELECTED

				else:
					border_colour_type = CC.COLOUR_THUMB_BORDER

			# I had a hell of a time getting a transparent box to draw right with a pen border without crazy +1px in the params for reasons I did not understand
			# so I just decided four rects is neater and fine and actually prob faster in some cases

			#		 _____			______							  _____			______	  ________________
			# ___________(_)___  _________  /_______   _______ ______	  __  /______	  ___  /_________  /__  /__  /
			# ___  __ \_  /__  |/_/  _ \_  /__  ___/   __  __ `/  __ \	 _  __/  __ \	 __  __ \  _ \_  /__  /__  /
			# __  /_/ /  / __>  < /  __/  / _(__  )	_  /_/ // /_/ /	 / /_ / /_/ /	 _  / / /  __/  / _  /  /_/
			# _  .___//_/  /_/|_| \___//_/  /____/	 _\__, / \____/	  \__/ \____/	  /_/ /_/\___//_/  /_/  (_)
			# /_/									  /____/

			dc.SetBrush( wx.Brush( new_options.GetColour( border_colour_type ) ) )

			rectangles = []

			side_height = height - ( thumbnail_border * 2 )

			rectangles.append( ( 0, 0, width, thumbnail_border ) ) # top
			rectangles.append( ( 0, height - thumbnail_border, width, thumbnail_border ) ) # bottom
			rectangles.append( ( 0, thumbnail_border, thumbnail_border, side_height ) ) # left
			rectangles.append( ( width - thumbnail_border, thumbnail_border, thumbnail_border, side_height ) ) # right

			dc.DrawRectangleList( rectangles )

		locations_manager = self.GetLocationsManager()

		icons_to_draw = []

		if locations_manager.IsDownloading():
			icons_to_draw.append( CC.GlobalBMPs.downloading )

		if CC.TRASH_SERVICE_KEY in locations_manager.GetCurrent():
			icons_to_draw.append( CC.GlobalBMPs.trash )

		if inbox:
			icons_to_draw.append( CC.GlobalBMPs.inbox )

		if len( icons_to_draw ) > 0:
			icon_x = 0

			for icon in icons_to_draw:
				dc.DrawBitmap( icon, width + icon_x - 18, 0 )

				icon_x -= 18

		if self._dump_status == CC.DUMPER_DUMPED_OK:
			dc.DrawBitmap( CC.GlobalBMPs.dump_ok, width - 18, 18 )
		elif self._dump_status == CC.DUMPER_RECOVERABLE_ERROR:
			dc.DrawBitmap( CC.GlobalBMPs.dump_recoverable, width - 18, 18 )
		elif self._dump_status == CC.DUMPER_UNRECOVERABLE_ERROR:
			dc.DrawBitmap( CC.GlobalBMPs.dump_fail, width - 18, 18 )

		if self.IsCollection():
			dc.DrawBitmap( CC.GlobalBMPs.collection, 1, height - 17 )

			num_files_str = str( len( self._hashes ) )

			dc.SetFont( wx.SystemSettings.GetFont( wx.SYS_DEFAULT_GUI_FONT ) )

			( text_x, text_y ) = dc.GetTextExtent( num_files_str )

			dc.SetBrush( wx.Brush( CC.COLOUR_UNSELECTED ) )

			dc.SetTextForeground( CC.COLOUR_SELECTED_DARK )

			dc.SetPen( wx.TRANSPARENT_PEN )

			dc.DrawRectangle( 17, height - text_y - 3, text_x + 2, text_y + 2 )

			dc.DrawText( num_files_str, 18, height - text_y - 2 )

		# repo icons

		services_manager = HG.client_controller.services_manager

		repo_icon_x = 0

		current = locations_manager.GetCurrentRemote()
		pending = locations_manager.GetPendingRemote()
		petitioned = locations_manager.GetPetitionedRemote()

		current_to_display = current.difference( petitioned )

		#

		service_types = [ services_manager.GetService( service_key ).GetServiceType() for service_key in current_to_display ]

		if HC.FILE_REPOSITORY in service_types:
			dc.DrawBitmap( CC.GlobalBMPs.file_repository, repo_icon_x, 0 )

			repo_icon_x += 20

		if HC.IPFS in service_types:
			dc.DrawBitmap( CC.GlobalBMPs.ipfs, repo_icon_x, 0 )

			repo_icon_x += 20

		#

		service_types = [ services_manager.GetService( service_key ).GetServiceType() for service_key in pending ]

		if HC.FILE_REPOSITORY in service_types:
			dc.DrawBitmap( CC.GlobalBMPs.file_repository_pending, repo_icon_x, 0 )

			repo_icon_x += 20

		if HC.IPFS in service_types:
			dc.DrawBitmap( CC.GlobalBMPs.ipfs_pending, repo_icon_x, 0 )

			repo_icon_x += 20

		#

		service_types = [ services_manager.GetService( service_key ).GetServiceType() for service_key in petitioned ]

		if HC.FILE_REPOSITORY in service_types:
			dc.DrawBitmap( CC.GlobalBMPs.file_repository_petitioned, repo_icon_x, 0 )

			repo_icon_x += 20

		if HC.IPFS in service_types:
			dc.DrawBitmap( CC.GlobalBMPs.ipfs_petitioned, repo_icon_x, 0 )

			repo_icon_x += 20

		return bmp

class ThumbnailMediaCollection( Thumbnail, MediaCollection ):
	def __init__( self, file_service_key, media_results ):
		MediaCollection.__init__( self, file_service_key, media_results )
		Thumbnail.__init__( self, file_service_key )

class ThumbnailMediaSingleton( Thumbnail, MediaSingleton ):
	def __init__( self, file_service_key, media_result ):
		MediaSingleton.__init__( self, media_result )
		Thumbnail.__init__( self, file_service_key )

license: wtfpl