//#define WIN_DIR_CHECK_WITHOUT_TIMEOUT // When uncommented, Directory.Exists won't be wrapped inside a Task/Thread on Windows but we won't be able to set a timeout for unreachable directories/drives using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; using System; using System.IO; using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Text; #if ENABLE_INPUT_SYSTEM && !ENABLE_LEGACY_INPUT_MANAGER using UnityEngine.InputSystem; #endif namespace SimpleFileBrowser { public class FileBrowser : MonoBehaviour, IListViewAdapter { public enum Permission { Denied = 0, Granted = 1, ShouldAsk = 2 }; public enum PickMode { Files = 0, Folders = 1, FilesAndFolders = 2 }; #region Structs #pragma warning disable 0649 [Serializable] private struct FiletypeIcon { public string extension; public Sprite icon; } [Serializable] private struct QuickLink { #if UNITY_EDITOR || ( !UNITY_WSA && !UNITY_WSA_10_0 ) public Environment.SpecialFolder target; #endif public string name; public Sprite icon; } #pragma warning restore 0649 #endregion #region Inner Classes public class Filter { public readonly string name; public readonly string[] extensions; public readonly HashSet extensionsSet; public readonly string defaultExtension; public readonly bool allExtensionsHaveSingleSuffix; // 'false' when some extensions have multiple suffixes like ".tar.gz" internal Filter( string name ) { this.name = name; extensions = null; extensionsSet = null; defaultExtension = null; allExtensionsHaveSingleSuffix = true; } public Filter( string name, string extension ) { this.name = name; extension = extension.ToLowerInvariant(); if( extension[0] != '.' ) extension = "." + extension; extensions = new string[1] { extension }; extensionsSet = new HashSet() { extension }; defaultExtension = extension; allExtensionsHaveSingleSuffix = ( extension.LastIndexOf( '.' ) == 0 ); } public Filter( string name, params string[] extensions ) { this.name = name; allExtensionsHaveSingleSuffix = true; for( int i = 0; i < extensions.Length; i++ ) { extensions[i] = extensions[i].ToLowerInvariant(); if( extensions[i][0] != '.' ) extensions[i] = "." + extensions[i]; allExtensionsHaveSingleSuffix &= ( extensions[i].LastIndexOf( '.' ) == 0 ); } this.extensions = extensions; extensionsSet = new HashSet( extensions ); defaultExtension = extensions[0]; } public bool MatchesExtension( string extension, bool extensionMayHaveMultipleSuffixes ) { if( extensionsSet == null || extensionsSet.Contains( extension ) ) return true; // When the provided extension may have multiple suffixes (e.g. ".tar.gz"), check if it ends with any of the // extensions in this filter (e.g. return true when this Filter has ".gz" and the provided extension is ".tar.gz") if( extensionMayHaveMultipleSuffixes ) { for( int i = 0; i < extensions.Length; i++ ) { if( extension.EndsWith( extensions[i], StringComparison.Ordinal ) ) { extensionsSet.Add( extension ); return true; } } } return false; } public override string ToString() { string result = string.Empty; if( name != null ) result += name; if( extensions != null ) { if( name != null ) result += " ("; for( int i = 0; i < extensions.Length; i++ ) { if( i > 0 ) result += ", " + extensions[i]; else result += extensions[i]; } if( name != null ) result += ")"; } return result; } } #endregion #region Constants private const int FILENAME_INPUT_FIELD_MAX_FILE_COUNT = 7; private const string SAF_PICK_FOLDER_QUICK_LINK_PATH = "SAF_PICK_FOLDER"; #endregion #region Static Variables public static bool IsOpen { get; private set; } public static bool Success { get; private set; } public static string[] Result { get; private set; } [SerializeField] private UISkin m_skin; #if UNITY_EDITOR private UISkin prevSkin; #endif private int m_skinVersion = 0; private Sprite m_skinPrevDriveIcon, m_skinPrevFolderIcon; public static UISkin Skin { get { return Instance.m_skin; } set { if( value && Instance.m_skin != value ) { Instance.m_skin = value; Instance.m_skinVersion = Instance.m_skin.Version; Instance.RefreshSkin(); } } } private static bool m_askPermissions = true; public static bool AskPermissions { get { return m_askPermissions; } set { m_askPermissions = value; } } private static bool m_singleClickMode = false; public static bool SingleClickMode { get { return m_singleClickMode; } set { m_singleClickMode = value; } } private static FileSystemEntryFilter m_displayedEntriesFilter; public static event FileSystemEntryFilter DisplayedEntriesFilter { add { m_displayedEntriesFilter -= value; m_displayedEntriesFilter += value; if( m_instance ) { m_instance.PersistFileEntrySelection(); m_instance.RefreshFiles( false ); } } remove { m_displayedEntriesFilter -= value; if( m_instance ) { m_instance.PersistFileEntrySelection(); m_instance.RefreshFiles( false ); } } } private static bool m_showFileOverwriteDialog = true; public static bool ShowFileOverwriteDialog { get { return m_showFileOverwriteDialog; } set { m_showFileOverwriteDialog = value; } } private static bool m_checkWriteAccessToDestinationDirectory = false; public static bool CheckWriteAccessToDestinationDirectory { get { return m_checkWriteAccessToDestinationDirectory; } set { m_checkWriteAccessToDestinationDirectory = value; } } #if UNITY_EDITOR || ( !UNITY_ANDROID && !UNITY_IOS && !UNITY_WSA && !UNITY_WSA_10_0 ) private static float m_drivesRefreshInterval = 5f; #else private static float m_drivesRefreshInterval = -1f; #endif public static float DrivesRefreshInterval { get { return m_drivesRefreshInterval; } set { m_drivesRefreshInterval = value; } } public static bool ShowHiddenFiles { get { return Instance.showHiddenFilesToggle.isOn; } set { Instance.showHiddenFilesToggle.isOn = value; } } private static bool m_displayHiddenFilesToggle = true; public static bool DisplayHiddenFilesToggle { get { return m_displayHiddenFilesToggle; } set { if( m_displayHiddenFilesToggle != value ) { m_displayHiddenFilesToggle = value; if( m_instance ) { if( !value ) m_instance.showHiddenFilesToggle.gameObject.SetActive( false ); else if( m_instance.windowTR.sizeDelta.x >= m_instance.narrowScreenWidth ) { #if !UNITY_EDITOR && UNITY_ANDROID if( !FileBrowserHelpers.ShouldUseSAF ) #endif m_instance.showHiddenFilesToggle.gameObject.SetActive( true ); } } } } } private static string m_allFilesFilterText = "All Files (.*)"; public static string AllFilesFilterText { get { return m_allFilesFilterText; } set { if( m_allFilesFilterText != value ) { string oldValue = m_allFilesFilterText; m_allFilesFilterText = value; if( m_instance ) { Filter oldAllFilesFilter = m_instance.allFilesFilter; m_instance.allFilesFilter = new Filter( value ); if( m_instance.filters.Count > 0 && m_instance.filters[0] == oldAllFilesFilter ) m_instance.filters[0] = m_instance.allFilesFilter; if( m_instance.filtersDropdown.options[0].text == oldValue ) { m_instance.filtersDropdown.options[0].text = value; m_instance.filtersDropdown.RefreshShownValue(); } } } } } private static string m_foldersFilterText = "Folders"; public static string FoldersFilterText { get { return m_foldersFilterText; } set { if( m_foldersFilterText != value ) { string oldValue = m_foldersFilterText; m_foldersFilterText = value; if( m_instance && m_instance.filtersDropdown.options[0].text == oldValue ) { m_instance.filtersDropdown.options[0].text = value; m_instance.filtersDropdown.RefreshShownValue(); } } } } private static string m_pickFolderQuickLinkText = "Browse..."; public static string PickFolderQuickLinkText { get { return m_pickFolderQuickLinkText; } set { if( m_pickFolderQuickLinkText != value ) { m_pickFolderQuickLinkText = value; if( m_instance ) { for( int i = 0; i < m_instance.allQuickLinks.Count; i++ ) { FileBrowserQuickLink quickLink = m_instance.allQuickLinks[i]; if( quickLink && quickLink.TargetPath == SAF_PICK_FOLDER_QUICK_LINK_PATH ) { quickLink.SetQuickLink( Skin.DriveIcon, value, SAF_PICK_FOLDER_QUICK_LINK_PATH ); break; } } } } } } private static FileBrowser m_instance = null; private static FileBrowser Instance { get { if( !m_instance ) { m_instance = Instantiate( Resources.Load( "SimpleFileBrowserCanvas" ) ).GetComponent(); DontDestroyOnLoad( m_instance.gameObject ); m_instance.gameObject.SetActive( false ); } return m_instance; } } #endregion #region Variables #pragma warning disable 0649 [Header( "Settings" )] [SerializeField] internal int minWidth = 380; [SerializeField] internal int minHeight = 300; [SerializeField] private float narrowScreenWidth = 380f; [SerializeField] private float quickLinksMaxWidthPercentage = 0.4f; [SerializeField] private bool sortFilesByName = true; [SerializeField, UnityEngine.Serialization.FormerlySerializedAs( "excludeExtensions" )] private string[] excludedExtensions; #pragma warning disable 0414 [SerializeField] private QuickLink[] quickLinks; private static bool quickLinksInitialized; #pragma warning restore 0414 private readonly HashSet excludedExtensionsSet = new HashSet(); [SerializeField] private bool generateQuickLinksForDrives = true; [SerializeField] private bool contextMenuShowDeleteButton = true; [SerializeField] private bool contextMenuShowRenameButton = true; [SerializeField] private bool showResizeCursor = true; [Header( "Internal References" )] [SerializeField] private FileBrowserMovement window; private RectTransform windowTR; [SerializeField] private RectTransform topViewNarrowScreen; [SerializeField] private RectTransform middleView; private Vector2 middleViewOriginalPosition; private Vector2 middleViewOriginalSize; [SerializeField] private RectTransform middleViewQuickLinks; private Vector2 middleViewQuickLinksOriginalSize; [SerializeField] private RectTransform middleViewFiles; [SerializeField] private RectTransform middleViewSeparator; [SerializeField] private FileBrowserItem itemPrefab; private readonly List allItems = new List( 16 ); [SerializeField] private FileBrowserQuickLink quickLinkPrefab; private readonly List allQuickLinks = new List( 8 ); [SerializeField] private Text titleText; [SerializeField] private Button backButton; [SerializeField] private Button forwardButton; [SerializeField] private Button upButton; [SerializeField] private Button moreOptionsButton; [SerializeField] private InputField pathInputField; [SerializeField] private RectTransform pathInputFieldSlotTop; [SerializeField] private RectTransform pathInputFieldSlotBottom; [SerializeField] private InputField searchInputField; [SerializeField] private RectTransform quickLinksContainer; [SerializeField] private ScrollRect quickLinksScrollRect; [SerializeField] private RectTransform filesContainer; [SerializeField] private ScrollRect filesScrollRect; [SerializeField] private RecycledListView listView; [SerializeField] private InputField filenameInputField; [SerializeField] private Text filenameInputFieldOverlayText; [SerializeField] private Image filenameImage; [SerializeField] private Dropdown filtersDropdown; [SerializeField] private RectTransform filtersDropdownContainer; [SerializeField] private Text filterItemTemplate; [SerializeField] private Toggle showHiddenFilesToggle; [SerializeField] private Text submitButtonText; [SerializeField] private Button[] allButtons; [SerializeField] private RectTransform moreOptionsContextMenuPosition; [SerializeField] private FileBrowserRenamedItem renameItem; [SerializeField] private FileBrowserContextMenu contextMenu; [SerializeField] private FileBrowserFileOperationConfirmationPanel fileOperationConfirmationPanel; [SerializeField] private FileBrowserAccessRestrictedPanel accessRestrictedPanel; [SerializeField] private FileBrowserCursorHandler resizeCursorHandler; #pragma warning restore 0649 internal RectTransform rectTransform; private Canvas canvas; private FileAttributes ignoredFileAttributes = FileAttributes.System; private FileSystemEntry[] allFileEntries; private readonly List validFileEntries = new List(); private readonly List selectedFileEntries = new List( 4 ); private readonly List pendingFileEntrySelection = new List(); private readonly List submittedFileEntryPaths = new List( 4 ); private readonly List submittedFolderPaths = new List( 4 ); // Used to check if all destination folders have write access private readonly List submittedFileEntriesToOverwrite = new List( 4 ); // Existing files selected by the user in save mode #pragma warning disable 0414 // Value is assigned but never used on Android & iOS private int multiSelectionPivotFileEntry; #pragma warning restore 0414 private StringBuilder multiSelectionFilenameBuilder; private readonly List filters = new List(); private Filter allFilesFilter; private bool showAllFilesFilter = true; // Single suffix: ".mp4", ".txt", etc. // Multiple suffixes: ".tar.gz", etc. private bool allFiltersHaveSingleSuffix = true; private bool allExcludedExtensionsHaveSingleSuffix = true; // When its value is 'true', file extensions will be handled in a more optimized way private bool AllExtensionsHaveSingleSuffix { get { return allFiltersHaveSingleSuffix && allExcludedExtensionsHaveSingleSuffix && m_skin.AllIconExtensionsHaveSingleSuffix; } } private string defaultInitialPath; private int currentPathIndex = -1; private readonly List pathsFollowed = new List(); private HashSet invalidFilenameChars; private float drivesNextRefreshTime; #if !UNITY_EDITOR && UNITY_ANDROID private string driveQuickLinks; #else private string[] driveQuickLinks; #endif private int numberOfDriveQuickLinks; #if !WIN_DIR_CHECK_WITHOUT_TIMEOUT && ( UNITY_EDITOR_WIN || UNITY_STANDALONE_WIN ) private readonly List timedOutDirectoryExistsRequests = new List( 2 ); #endif private bool canvasDimensionsChanged; private readonly CompareInfo textComparer = new CultureInfo( "en-US" ).CompareInfo; private readonly CompareOptions textCompareOptions = CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace; // Required in RefreshFiles() function private PointerEventData nullPointerEventData; #endregion #region Properties private string m_currentPath = string.Empty; private string CurrentPath { get { return m_currentPath; } set { if( value != null ) { value = value.Trim(); #if !UNITY_EDITOR && UNITY_ANDROID if( !FileBrowserHelpers.ShouldUseSAFForPath( value ) ) #endif value = GetPathWithoutTrailingDirectorySeparator( value ); } if( string.IsNullOrEmpty( value ) ) { pathInputField.text = m_currentPath; return; } if( m_currentPath != value ) { if( !FileBrowserHelpers.DirectoryExists( value ) ) { pathInputField.text = m_currentPath; return; } m_currentPath = value; pathInputField.text = m_currentPath; if( currentPathIndex == -1 || pathsFollowed[currentPathIndex] != m_currentPath ) { currentPathIndex++; if( currentPathIndex < pathsFollowed.Count ) { pathsFollowed[currentPathIndex] = value; for( int i = pathsFollowed.Count - 1; i >= currentPathIndex + 1; i-- ) pathsFollowed.RemoveAt( i ); } else pathsFollowed.Add( m_currentPath ); } backButton.interactable = currentPathIndex > 0; forwardButton.interactable = currentPathIndex < pathsFollowed.Count - 1; #if !UNITY_EDITOR && UNITY_ANDROID if( FileBrowserHelpers.ShouldUseSAF ) { string parentPath = FileBrowserHelpers.GetDirectoryName( m_currentPath ); upButton.interactable = !string.IsNullOrEmpty( parentPath ) && ( FileBrowserHelpers.ShouldUseSAFForPath( parentPath ) || FileBrowserHelpers.DirectoryExists( parentPath ) ); // DirectoryExists: Directory may not be accessible on Android 10+, this function checks that } else #endif { try // When "C:/" or "C:" is typed instead of "C:\", an exception is thrown { upButton.interactable = Directory.GetParent( m_currentPath ) != null; } catch { upButton.interactable = false; } } m_searchString = string.Empty; searchInputField.text = m_searchString; multiSelectionPivotFileEntry = 0; filesScrollRect.verticalNormalizedPosition = 1; filenameImage.color = m_skin.InputFieldNormalBackgroundColor; if( m_pickerMode != PickMode.Files ) { filenameInputField.text = string.Empty; filenameInputField.interactable = true; } // If a quick link points to this directory, highlight it #if !UNITY_EDITOR && UNITY_ANDROID // Path strings aren't deterministic on Storage Access Framework but the paths' absolute parts usually are if( FileBrowserHelpers.ShouldUseSAFForPath( m_currentPath ) ) { int SAFAbsolutePathSeparatorIndex = m_currentPath.LastIndexOf( '/' ); if( SAFAbsolutePathSeparatorIndex >= 0 ) { string absoluteSAFPath = m_currentPath.Substring( SAFAbsolutePathSeparatorIndex ); for( int i = 0; i < allQuickLinks.Count; i++ ) allQuickLinks[i].SetSelected( allQuickLinks[i].TargetPath == m_currentPath || allQuickLinks[i].TargetPath.EndsWith( absoluteSAFPath ) ); } else { for( int i = 0; i < allQuickLinks.Count; i++ ) allQuickLinks[i].SetSelected( allQuickLinks[i].TargetPath == m_currentPath ); } } else #endif { for( int i = 0; i < allQuickLinks.Count; i++ ) allQuickLinks[i].SetSelected( allQuickLinks[i].TargetPath == m_currentPath ); } } m_multiSelectionToggleSelectionMode = false; RefreshFiles( true ); } } private string m_searchString = string.Empty; private string SearchString { get { return m_searchString; } set { if( m_searchString != value ) { m_searchString = value; searchInputField.text = m_searchString; RefreshFiles( false ); } } } private bool m_acceptNonExistingFilename = false; // Is set to true when showing save dialog for Files or FilesAndFolders, false otherwise private bool AcceptNonExistingFilename { get { return m_acceptNonExistingFilename; } set { m_acceptNonExistingFilename = value; } } private PickMode m_pickerMode = PickMode.Files; internal PickMode PickerMode { get { return m_pickerMode; } private set { m_pickerMode = value; if( m_pickerMode == PickMode.Folders ) { filtersDropdown.options[0].text = FoldersFilterText; filtersDropdown.value = 0; filtersDropdown.interactable = false; } else { filtersDropdown.options[0].text = filters[0].ToString(); filtersDropdown.interactable = true; } filtersDropdown.RefreshShownValue(); Text placeholder = filenameInputField.placeholder as Text; if( placeholder ) placeholder.gameObject.SetActive( m_pickerMode != PickMode.Folders ); } } private bool m_allowMultiSelection; internal bool AllowMultiSelection { get { return m_allowMultiSelection; } private set { m_allowMultiSelection = value; } } private bool m_multiSelectionToggleSelectionMode; internal bool MultiSelectionToggleSelectionMode { get { return m_multiSelectionToggleSelectionMode; } private set { if( m_multiSelectionToggleSelectionMode != value ) { m_multiSelectionToggleSelectionMode = value; for( int i = 0; i < allItems.Count; i++ ) { if( allItems[i].gameObject.activeSelf ) allItems[i].SetSelected( selectedFileEntries.Contains( allItems[i].Position ) ); } } } } private string Title { get { return titleText.text; } set { titleText.text = value; } } private string SubmitButtonText { get { return submitButtonText.text; } set { submitButtonText.text = value; } } private string LastBrowsedFolder { get { return PlayerPrefs.GetString( "FBLastPath", null ); } set { PlayerPrefs.SetString( "FBLastPath", value ); } } #endregion #region Delegates public delegate void OnSuccess( string[] paths ); public delegate void OnCancel(); public delegate bool FileSystemEntryFilter( FileSystemEntry entry ); #if UNITY_EDITOR || UNITY_ANDROID public delegate void AndroidSAFDirectoryPickCallback( string rawUri, string name ); #endif private OnSuccess onSuccess; private OnCancel onCancel; #endregion #region Messages private void Awake() { m_instance = this; rectTransform = (RectTransform) transform; windowTR = (RectTransform) window.transform; canvas = GetComponent(); middleViewOriginalPosition = middleView.anchoredPosition; middleViewOriginalSize = middleView.sizeDelta; middleViewQuickLinksOriginalSize = middleViewQuickLinks.sizeDelta; nullPointerEventData = new PointerEventData( null ); #if !UNITY_EDITOR && ( UNITY_ANDROID || UNITY_IOS || UNITY_WSA || UNITY_WSA_10_0 ) defaultInitialPath = Application.persistentDataPath; #else defaultInitialPath = Environment.GetFolderPath( Environment.SpecialFolder.MyDocuments ); #endif #if !UNITY_EDITOR && UNITY_ANDROID if( FileBrowserHelpers.ShouldUseSAF ) { // These UI elements have no use in Storage Access Framework mode (Android 10+) pathInputField.gameObject.SetActive( false ); showHiddenFilesToggle.gameObject.SetActive( false ); } #endif SetExcludedExtensions( excludedExtensions ); backButton.interactable = false; forwardButton.interactable = false; upButton.interactable = false; filenameInputField.onValidateInput += OnValidateFilenameInput; filenameInputField.onValueChanged.AddListener( OnFilenameInputChanged ); allFilesFilter = new Filter( AllFilesFilterText ); filters.Add( allFilesFilter ); invalidFilenameChars = new HashSet( Path.GetInvalidFileNameChars() ) { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }; window.Initialize( this ); listView.SetAdapter( this ); // Refresh the skin immediately m_skinVersion = m_skin.Version; RefreshSkin(); if( !showResizeCursor ) Destroy( resizeCursorHandler ); #if ENABLE_INPUT_SYSTEM && !ENABLE_LEGACY_INPUT_MANAGER // On new Input System, scroll sensitivity is much higher than legacy Input system filesScrollRect.scrollSensitivity *= 0.25f; quickLinksContainer.GetComponentInParent().scrollSensitivity *= 0.25f; filtersDropdownContainer.GetComponent().scrollSensitivity *= 0.25f; #endif } private void OnRectTransformDimensionsChange() { canvasDimensionsChanged = true; } #if UNITY_EDITOR protected virtual void OnValidate() { // Refresh the skin in the next Update if it is changed via Unity Inspector at runtime if( UnityEditor.EditorApplication.isPlaying && m_skin != prevSkin ) { if( !m_skin ) // Don't allow null UISkin m_skin = prevSkin; else m_skinVersion = m_skin.Version - 1; } } #endif private void Update() { if( m_skin && m_skinVersion != m_skin.Version ) { m_skinVersion = m_skin.Version; RefreshSkin(); #if UNITY_EDITOR prevSkin = m_skin; #endif } } private void LateUpdate() { if( canvasDimensionsChanged ) { canvasDimensionsChanged = false; Vector2 windowSize = windowTR.sizeDelta; EnsureWindowIsWithinBounds(); if( windowTR.sizeDelta != windowSize ) OnWindowDimensionsChanged( windowTR.sizeDelta ); fileOperationConfirmationPanel.OnCanvasDimensionsChanged( rectTransform.sizeDelta ); if( contextMenu.gameObject.activeSelf ) contextMenu.Hide(); } #if UNITY_EDITOR || UNITY_STANDALONE || UNITY_WEBGL || UNITY_WSA || UNITY_WSA_10_0 // Handle keyboard shortcuts if( !EventSystem.current.currentSelectedGameObject ) { #if ENABLE_INPUT_SYSTEM && !ENABLE_LEGACY_INPUT_MANAGER if( Keyboard.current != null ) #endif { #if ENABLE_INPUT_SYSTEM && !ENABLE_LEGACY_INPUT_MANAGER if( Keyboard.current[Key.Delete].wasPressedThisFrame ) #else if( Input.GetKeyDown( KeyCode.Delete ) ) #endif DeleteSelectedFiles(); #if ENABLE_INPUT_SYSTEM && !ENABLE_LEGACY_INPUT_MANAGER if( Keyboard.current[Key.F2].wasPressedThisFrame ) #else if( Input.GetKeyDown( KeyCode.F2 ) ) #endif RenameSelectedFile(); #if ENABLE_INPUT_SYSTEM && !ENABLE_LEGACY_INPUT_MANAGER if( Keyboard.current[Key.A].wasPressedThisFrame && IsCtrlKeyHeld() ) #else if( Input.GetKeyDown( KeyCode.A ) && IsCtrlKeyHeld() ) #endif SelectAllFiles(); } } #endif // 2 Text objects are used in the filename input field: // filenameInputField.textComponent: visible when editing the text, has Horizontal Overflow set to Wrap (cuts out words, ugly) // filenameInputFieldOverlayText: visible when not editing the text, has Horizontal Overflow set to Overflow (doesn't cut out words) if( EventSystem.current.currentSelectedGameObject == filenameInputField.gameObject ) { if( filenameInputFieldOverlayText.enabled ) { filenameInputFieldOverlayText.enabled = false; filenameInputField.textComponent.color = m_skin.InputFieldTextColor; } } else if( !filenameInputFieldOverlayText.enabled ) { filenameInputFieldOverlayText.enabled = true; Color c = m_skin.InputFieldTextColor; c.a = 0f; filenameInputField.textComponent.color = c; } // Refresh drive quick links #if UNITY_EDITOR || ( !UNITY_IOS && !UNITY_WSA && !UNITY_WSA_10_0 ) #if !UNITY_EDITOR && UNITY_ANDROID if( !FileBrowserHelpers.ShouldUseSAF ) #endif if( quickLinksInitialized && generateQuickLinksForDrives && m_drivesRefreshInterval >= 0f && Time.realtimeSinceStartup >= drivesNextRefreshTime ) { drivesNextRefreshTime = Time.realtimeSinceStartup + m_drivesRefreshInterval; RefreshDriveQuickLinks(); } #endif } private void OnApplicationFocus( bool focus ) { if( !focus ) PersistFileEntrySelection(); else RefreshFiles( true ); } #endregion #region Interface Methods OnItemClickedHandler IListViewAdapter.OnItemClicked { get { return null; } set { } } int IListViewAdapter.Count { get { return validFileEntries.Count; } } float IListViewAdapter.ItemHeight { get { return m_skin.FileHeight; } } ListItem IListViewAdapter.CreateItem() { FileBrowserItem item = (FileBrowserItem) Instantiate( itemPrefab, filesContainer, false ); item.SetFileBrowser( this, m_skin ); allItems.Add( item ); return item; } void IListViewAdapter.SetItemContent( ListItem item ) { FileBrowserItem file = (FileBrowserItem) item; FileSystemEntry fileInfo = validFileEntries[item.Position]; file.SetFile( GetIconForFileEntry( fileInfo ), fileInfo.Name, fileInfo.IsDirectory ); file.SetSelected( selectedFileEntries.Contains( file.Position ) ); file.SetHidden( ( fileInfo.Attributes & FileAttributes.Hidden ) == FileAttributes.Hidden ); } #endregion #region Initialization Functions private void InitializeQuickLinks() { quickLinksInitialized = true; drivesNextRefreshTime = Time.realtimeSinceStartup + m_drivesRefreshInterval; #if !UNITY_EDITOR && UNITY_ANDROID if( FileBrowserHelpers.ShouldUseSAF ) { AddQuickLink( m_skin.DriveIcon, PickFolderQuickLinkText, SAF_PICK_FOLDER_QUICK_LINK_PATH ); try { FetchPersistedSAFQuickLinks(); } catch( Exception e ) { Debug.LogException( e ); } return; } #endif if( generateQuickLinksForDrives ) { #if UNITY_EDITOR || ( !UNITY_IOS && !UNITY_WSA && !UNITY_WSA_10_0 ) RefreshDriveQuickLinks(); #else AddQuickLink( m_skin.DriveIcon, "Files", Application.persistentDataPath ); #endif #if UNITY_STANDALONE_OSX // Add a quick link for user directory on Mac OS string userDirectory = Environment.GetFolderPath( Environment.SpecialFolder.MyDocuments ); if( !string.IsNullOrEmpty( userDirectory ) ) AddQuickLink( m_skin.DriveIcon, userDirectory.Substring( userDirectory.LastIndexOf( '/' ) + 1 ), userDirectory ); #endif } #if UNITY_EDITOR || ( !UNITY_ANDROID && !UNITY_WSA && !UNITY_WSA_10_0 ) for( int i = 0; i < quickLinks.Length; i++ ) { QuickLink quickLink = quickLinks[i]; string quickLinkPath = Environment.GetFolderPath( quickLink.target ); #if UNITY_STANDALONE_OSX // Documents folder must be appended manually on Mac OS if( quickLink.target == Environment.SpecialFolder.MyDocuments && !string.IsNullOrEmpty( quickLinkPath ) ) quickLinkPath = Path.Combine( quickLinkPath, "Documents" ); #endif AddQuickLink( quickLink.icon, quickLink.name, quickLinkPath ); } quickLinks = null; #endif } private void RefreshDriveQuickLinks() { // Check if drives has changed since the last refresh #if !UNITY_EDITOR && UNITY_ANDROID string drivesList = FileBrowserHelpers.AJC.CallStatic( "GetExternalDrives", FileBrowserHelpers.Context ); if( drivesList == driveQuickLinks || ( string.IsNullOrEmpty( drivesList ) && string.IsNullOrEmpty( driveQuickLinks ) ) ) return; driveQuickLinks = drivesList; #else string[] drives = Directory.GetLogicalDrives(); if( driveQuickLinks != null && drives.Length == driveQuickLinks.Length ) { bool drivesListHasntChanged = true; for( int i = 0; i < drives.Length; i++ ) { #if !WIN_DIR_CHECK_WITHOUT_TIMEOUT && ( UNITY_EDITOR_WIN || UNITY_STANDALONE_WIN ) if( timedOutDirectoryExistsRequests.Contains( drives[i] ) ) continue; #endif if( drives[i] != driveQuickLinks[i] ) { drivesListHasntChanged = false; break; } } if( drivesListHasntChanged ) return; } driveQuickLinks = drives; #endif // Drives has changed, remove previous drive quick links for( ; numberOfDriveQuickLinks > 0; numberOfDriveQuickLinks-- ) { Destroy( allQuickLinks[numberOfDriveQuickLinks - 1].gameObject ); allQuickLinks.RemoveAt( numberOfDriveQuickLinks - 1 ); } FileBrowserQuickLink[] customQuickLinks = allQuickLinks.Count > 0 ? allQuickLinks.ToArray() : null; allQuickLinks.Clear(); quickLinksContainer.sizeDelta = Vector2.zero; // Create drive quick links #if !UNITY_EDITOR && UNITY_ANDROID if( drivesList != null && drivesList.Length > 0 ) { bool defaultPathInitialized = false; int driveIndex = 1; string[] drives = drivesList.Split( ':' ); for( int i = 0; i < drives.Length; i++ ) { try { //string driveName = new DirectoryInfo( drives[i] ).Name; //if( driveName.Length <= 1 ) //{ // try // { // driveName = Directory.GetParent( drives[i] ).Name + "/" + driveName; // } // catch // { // driveName = "Drive " + driveIndex++; // } //} string driveName; if( !defaultPathInitialized ) { defaultInitialPath = drives[i]; defaultPathInitialized = true; driveName = "Primary Drive"; } else { if( driveIndex == 1 ) driveName = "External Drive"; else driveName = "External Drive " + driveIndex; driveIndex++; } if( AddQuickLink( m_skin.DriveIcon, driveName, drives[i] ) ) numberOfDriveQuickLinks++; } catch { } } } #else for( int i = 0; i < drives.Length; i++ ) { if( string.IsNullOrEmpty( drives[i] ) ) continue; #if UNITY_STANDALONE_OSX // There are a number of useless drives listed on Mac OS, filter them if( drives[i] == "/" ) { if( AddQuickLink( m_skin.DriveIcon, "Root", drives[i] ) ) numberOfDriveQuickLinks++; } else if( drives[i].StartsWith( "/Volumes/" ) && drives[i] != "/Volumes/Recovery" ) { if( AddQuickLink( m_skin.DriveIcon, drives[i].Substring( drives[i].LastIndexOf( '/' ) + 1 ), drives[i] ) ) numberOfDriveQuickLinks++; } #else if( AddQuickLink( m_skin.DriveIcon, drives[i], drives[i] ) ) numberOfDriveQuickLinks++; #endif } #endif // Reposition custom quick links if( customQuickLinks != null ) { Vector2 anchoredPos = new Vector2( 0f, -quickLinksContainer.sizeDelta.y ); for( int i = 0; i < customQuickLinks.Length; i++ ) { customQuickLinks[i].TransformComponent.anchoredPosition = anchoredPos; anchoredPos.y -= m_skin.FileHeight; allQuickLinks.Add( customQuickLinks[i] ); } quickLinksContainer.sizeDelta = new Vector2( 0f, -anchoredPos.y ); } // Verify that current directory still exists try { if( !string.IsNullOrEmpty( m_currentPath ) && !FileBrowserHelpers.DirectoryExists( m_currentPath ) ) { string currentPathRoot = Path.GetPathRoot( m_currentPath ); if( !string.IsNullOrEmpty( currentPathRoot ) && FileBrowserHelpers.DirectoryExists( currentPathRoot ) ) CurrentPath = currentPathRoot; else if( allQuickLinks.Count > 0 ) CurrentPath = allQuickLinks[0].TargetPath; } } catch { } } private void RefreshSkin() { window.GetComponent().color = m_skin.WindowColor; middleView.GetComponent().color = m_skin.FilesListColor; middleViewSeparator.GetComponent().color = m_skin.FilesVerticalSeparatorColor; titleText.transform.parent.GetComponent().color = m_skin.TitleBackgroundColor; m_skin.ApplyTo( titleText, m_skin.TitleTextColor ); backButton.image.color = m_skin.HeaderButtonsColor; forwardButton.image.color = m_skin.HeaderButtonsColor; upButton.image.color = m_skin.HeaderButtonsColor; moreOptionsButton.image.color = m_skin.HeaderButtonsColor; backButton.image.sprite = m_skin.HeaderBackButton; forwardButton.image.sprite = m_skin.HeaderForwardButton; upButton.image.sprite = m_skin.HeaderUpButton; moreOptionsButton.image.sprite = m_skin.HeaderContextMenuButton; if( resizeCursorHandler ) { Image windowResizeGizmo = resizeCursorHandler.GetComponent(); windowResizeGizmo.color = m_skin.WindowResizeGizmoColor; windowResizeGizmo.sprite = m_skin.WindowResizeGizmo; } m_skin.ApplyTo( filenameInputField ); m_skin.ApplyTo( pathInputField ); m_skin.ApplyTo( searchInputField ); m_skin.ApplyTo( renameItem.InputField ); m_skin.ApplyTo( filenameInputFieldOverlayText, m_skin.InputFieldTextColor ); if( !EventSystem.current || EventSystem.current.currentSelectedGameObject != filenameInputField.gameObject ) { Color c = m_skin.InputFieldTextColor; c.a = 0f; filenameInputField.textComponent.color = c; } for( int i = 0; i < allButtons.Length; i++ ) m_skin.ApplyTo( allButtons[i] ); m_skin.ApplyTo( filtersDropdown ); m_skin.ApplyTo( showHiddenFilesToggle ); m_skin.ApplyTo( quickLinksScrollRect.verticalScrollbar ); m_skin.ApplyTo( filesScrollRect.verticalScrollbar ); m_skin.ApplyTo( filtersDropdown.template.GetComponent().verticalScrollbar ); for( int i = 0; i < allQuickLinks.Count; i++ ) { allQuickLinks[i].OnSkinRefreshed( m_skin ); allQuickLinks[i].TransformComponent.anchoredPosition = new Vector2( 0f, allQuickLinks[i].TransformComponent.GetSiblingIndex() * -m_skin.FileHeight ); if( allQuickLinks[i].Icon.sprite == m_skinPrevDriveIcon ) allQuickLinks[i].Icon.sprite = m_skin.DriveIcon; else if( allQuickLinks[i].Icon.sprite == m_skinPrevFolderIcon ) allQuickLinks[i].Icon.sprite = m_skin.FolderIcon; } quickLinksContainer.sizeDelta = new Vector2( 0f, allQuickLinks.Count * m_skin.FileHeight ); for( int i = 0; i < allItems.Count; i++ ) allItems[i].OnSkinRefreshed( m_skin ); renameItem.TransformComponent.sizeDelta = new Vector2( renameItem.TransformComponent.sizeDelta.x, m_skin.FileHeight ); contextMenu.RefreshSkin( m_skin ); fileOperationConfirmationPanel.RefreshSkin( m_skin ); accessRestrictedPanel.RefreshSkin( m_skin ); listView.OnSkinRefreshed(); m_skinPrevDriveIcon = m_skin.DriveIcon; m_skinPrevFolderIcon = m_skin.FolderIcon; } #endregion #region Button Events public void OnBackButtonPressed() { if( currentPathIndex > 0 ) CurrentPath = pathsFollowed[--currentPathIndex]; } public void OnForwardButtonPressed() { if( currentPathIndex < pathsFollowed.Count - 1 ) CurrentPath = pathsFollowed[++currentPathIndex]; } public void OnUpButtonPressed() { #if !UNITY_EDITOR && UNITY_ANDROID if( FileBrowserHelpers.ShouldUseSAF ) { string parentPath = FileBrowserHelpers.GetDirectoryName( m_currentPath ); if( !string.IsNullOrEmpty( parentPath ) && ( FileBrowserHelpers.ShouldUseSAFForPath( parentPath ) || FileBrowserHelpers.DirectoryExists( parentPath ) ) ) // DirectoryExists: Directory may not be accessible on Android 10+, this function checks that CurrentPath = parentPath; } else #endif { try // When "C:/" or "C:" is typed instead of "C:\", an exception is thrown { DirectoryInfo parentPath = Directory.GetParent( m_currentPath ); if( parentPath != null ) CurrentPath = parentPath.FullName; } catch { } } } public void OnMoreOptionsButtonClicked() { ShowContextMenuAt( rectTransform.InverseTransformPoint( moreOptionsContextMenuPosition.position ), true ); } internal void OnContextMenuTriggered( Vector2 pointerPos ) { filesScrollRect.velocity = Vector2.zero; Vector2 position; RectTransformUtility.ScreenPointToLocalPointInRectangle( rectTransform, pointerPos, canvas.worldCamera, out position ); ShowContextMenuAt( position, false ); } private void ShowContextMenuAt( Vector2 position, bool isMoreOptionsMenu ) { if( string.IsNullOrEmpty( m_currentPath ) ) return; bool selectAllButtonVisible = isMoreOptionsMenu && m_allowMultiSelection && validFileEntries.Count > 0; bool deselectAllButtonVisible = isMoreOptionsMenu && selectedFileEntries.Count > 1; bool deleteButtonVisible = contextMenuShowDeleteButton && selectedFileEntries.Count > 0; bool renameButtonVisible = contextMenuShowRenameButton && selectedFileEntries.Count == 1; if( selectAllButtonVisible && m_pickerMode == PickMode.Files ) { // In file selection mode, if only folders exist in the current path, "Select All" option shouldn't be visible selectAllButtonVisible = false; for( int i = 0; i < validFileEntries.Count; i++ ) { if( !validFileEntries[i].IsDirectory ) { selectAllButtonVisible = true; break; } } } contextMenu.Show( selectAllButtonVisible, deselectAllButtonVisible, deleteButtonVisible, renameButtonVisible, position, isMoreOptionsMenu ); } public void OnSubmitButtonClicked() { string[] result = null; string filenameInput = filenameInputField.text.Trim(); submittedFileEntryPaths.Clear(); submittedFolderPaths.Clear(); submittedFileEntriesToOverwrite.Clear(); if( filenameInput.Length == 0 ) { if( m_pickerMode == PickMode.Files ) { filenameImage.color = m_skin.InputFieldInvalidBackgroundColor; return; } else { result = new string[1] { m_currentPath }; submittedFolderPaths.Add( m_currentPath ); } } if( result == null ) { if( m_allowMultiSelection && selectedFileEntries.Count > 1 ) { // When multiple files are selected via file browser UI, filenameInputField is not interactable and will show // only the first FILENAME_INPUT_FIELD_MAX_FILE_COUNT entries for performance reasons. We should iterate over // selectedFileEntries instead of filenameInputField // Beforehand, check if a folder is selected in file selection mode. If so, open that directory if( m_pickerMode == PickMode.Files ) { for( int i = 0; i < selectedFileEntries.Count; i++ ) { if( validFileEntries[selectedFileEntries[i]].IsDirectory ) { CurrentPath = validFileEntries[selectedFileEntries[i]].Path; return; } } } result = new string[selectedFileEntries.Count]; for( int i = 0; i < selectedFileEntries.Count; i++ ) { result[i] = validFileEntries[selectedFileEntries[i]].Path; if( validFileEntries[selectedFileEntries[i]].IsDirectory ) submittedFolderPaths.Add( result[i] ); else if( m_acceptNonExistingFilename ) { submittedFileEntriesToOverwrite.Add( validFileEntries[selectedFileEntries[i]] ); if( !submittedFolderPaths.Contains( m_currentPath ) ) submittedFolderPaths.Add( m_currentPath ); } } } else { // When multiple files aren't selected via file browser UI, we must consider the rare case where user manually enters // multiple filenames to filenameInputField in format "file1" "file2" and so on. So, we must parse filenameInputField for( int startIndex = 0, nextStartIndex = 0; startIndex < filenameInput.Length; startIndex = nextStartIndex ) { int filenameLength = ExtractFilenameFromInput( filenameInput, ref startIndex, out nextStartIndex ); if( filenameLength == 0 ) continue; string filename = filenameInput.Substring( startIndex, filenameLength ).Trim(); if( !VerifyFilename( filename ) ) { // Check if user has entered a full path to input field instead of just a filename. Even if it's the case, don't immediately accept the full path, // first verify that it doesn't point to a file/folder that is ignored by the file browser try { if( FileBrowserHelpers.DirectoryExists( filename ) ) { FileSystemEntry fileEntry = new FileSystemEntry( filename, FileBrowserHelpers.GetFilename( filename ), "", true ); if( FileSystemEntryMatchesFilters( fileEntry, AllExtensionsHaveSingleSuffix ) ) { if( m_pickerMode == PickMode.Files ) { CurrentPath = filename; return; } else { submittedFileEntryPaths.Add( filename ); submittedFolderPaths.Add( filename ); continue; } } } else if( m_pickerMode != PickMode.Folders && FileBrowserHelpers.FileExists( filename ) ) { string fullPathFilename = FileBrowserHelpers.GetFilename( filename ); FileSystemEntry fileEntry = new FileSystemEntry( filename, fullPathFilename, GetExtensionFromFilename( fullPathFilename, AllExtensionsHaveSingleSuffix ), false ); if( FileSystemEntryMatchesFilters( fileEntry, AllExtensionsHaveSingleSuffix ) ) { submittedFileEntryPaths.Add( filename ); submittedFileEntriesToOverwrite.Add( fileEntry ); if( m_acceptNonExistingFilename ) submittedFolderPaths.Add( FileBrowserHelpers.GetDirectoryName( filename ) ); continue; } } } catch { } // Filename contains invalid characters or is completely whitespace filenameImage.color = m_skin.InputFieldInvalidBackgroundColor; return; } try { int fileEntryIndex = FilenameToFileEntryIndex( filename ); if( fileEntryIndex < 0 ) { if( m_pickerMode != PickMode.Folders ) { bool isAllFilesFilterActive = filters[filtersDropdown.value].extensions == null; if( !m_acceptNonExistingFilename || !isAllFilesFilterActive ) { // File couldn't be found but perhaps filename is missing the extension, check if any of the files match the filename without extension for( int i = 0; i < validFileEntries.Count; i++ ) { if( !validFileEntries[i].IsDirectory && validFileEntries[i].Name.Length >= filename.Length + 2 && validFileEntries[i].Name[filename.Length] == '.' ) { if( validFileEntries[i].Name.StartsWith( filename ) ) // Case-sensitive filename query { fileEntryIndex = i; break; } else if( textComparer.IsPrefix( validFileEntries[i].Name, filename, textCompareOptions ) ) // Case-insensitive filename query { // Don't exit the loop immediately because case-sensitive query takes precedence, we need to check all files to see if there's a case-sensitive match fileEntryIndex = i; } } } } if( m_acceptNonExistingFilename && fileEntryIndex < 0 && !isAllFilesFilterActive ) { // In file saving mode, make sure that nonexisting files' extensions match one of the required extensions string fileExtension = GetExtensionFromFilename( filename, AllExtensionsHaveSingleSuffix ); if( string.IsNullOrEmpty( fileExtension ) || !filters[filtersDropdown.value].MatchesExtension( fileExtension, !AllExtensionsHaveSingleSuffix ) ) { filename = Path.ChangeExtension( filename, filters[filtersDropdown.value].defaultExtension ); fileEntryIndex = FilenameToFileEntryIndex( filename ); } } } } if( fileEntryIndex >= 0 ) // This is an existing file/folder { if( validFileEntries[fileEntryIndex].IsDirectory && m_pickerMode == PickMode.Files ) { // Selected a directory in file selection mode, open that directory CurrentPath = validFileEntries[fileEntryIndex].Path; return; } else { submittedFileEntryPaths.Add( validFileEntries[fileEntryIndex].Path ); if( validFileEntries[fileEntryIndex].IsDirectory ) submittedFolderPaths.Add( validFileEntries[fileEntryIndex].Path ); else if( m_acceptNonExistingFilename ) { submittedFileEntriesToOverwrite.Add( validFileEntries[fileEntryIndex] ); if( !submittedFolderPaths.Contains( m_currentPath ) ) submittedFolderPaths.Add( m_currentPath ); } } } else // File/folder doesn't exist { if( !m_acceptNonExistingFilename ) { filenameImage.color = m_skin.InputFieldInvalidBackgroundColor; return; } else { #if !UNITY_EDITOR && UNITY_ANDROID if( FileBrowserHelpers.ShouldUseSAFForPath( m_currentPath ) ) { if( m_pickerMode == PickMode.Folders ) submittedFileEntryPaths.Add( FileBrowserHelpers.CreateFolderInDirectory( m_currentPath, filename ) ); else submittedFileEntryPaths.Add( FileBrowserHelpers.CreateFileInDirectory( m_currentPath, filename ) ); } else #endif { submittedFileEntryPaths.Add( Path.Combine( m_currentPath, filename ) ); if( !submittedFolderPaths.Contains( m_currentPath ) ) submittedFolderPaths.Add( m_currentPath ); } } } } catch( ArgumentException e ) { filenameImage.color = m_skin.InputFieldInvalidBackgroundColor; Debug.LogException( e ); return; } } if( submittedFileEntryPaths.Count == 0 ) { filenameImage.color = m_skin.InputFieldInvalidBackgroundColor; return; } result = submittedFileEntryPaths.ToArray(); } } if( result != null ) { if( m_checkWriteAccessToDestinationDirectory ) { for( int i = 0; i < submittedFolderPaths.Count; i++ ) { if( !string.IsNullOrEmpty( submittedFolderPaths[i] ) && !CheckDirectoryWriteAccess( submittedFolderPaths[i] ) ) { accessRestrictedPanel.Show(); return; } } } if( m_showFileOverwriteDialog && submittedFileEntriesToOverwrite.Count > 0 ) { fileOperationConfirmationPanel.Show( this, submittedFileEntriesToOverwrite, FileBrowserFileOperationConfirmationPanel.OperationType.Overwrite, () => OnOperationSuccessful( result ) ); return; } OnOperationSuccessful( result ); } } public void OnCancelButtonClicked() { OnOperationCanceled( true ); } #endregion #region Other Events private void OnOperationSuccessful( string[] paths ) { Success = true; Result = paths; Hide(); if( !string.IsNullOrEmpty( m_currentPath ) ) LastBrowsedFolder = m_currentPath; OnSuccess _onSuccess = onSuccess; onSuccess = null; onCancel = null; if( _onSuccess != null ) _onSuccess( paths ); } private void OnOperationCanceled( bool invokeCancelCallback ) { Success = false; Result = null; Hide(); if( !string.IsNullOrEmpty( m_currentPath ) ) LastBrowsedFolder = m_currentPath; OnCancel _onCancel = onCancel; onSuccess = null; onCancel = null; if( invokeCancelCallback && _onCancel != null ) _onCancel(); } public void OnPathChanged( string newPath ) { // Fixes harmless NullReferenceException that occurs when Play button is clicked while SimpleFileBrowserCanvas prefab is open in prefab mode // https://github.com/yasirkula/UnitySimpleFileBrowser/issues/30 if( !canvas ) return; CurrentPath = newPath; } public void OnSearchStringChanged( string newSearchString ) { if( !canvas ) // Same as OnPathChanged return; PersistFileEntrySelection(); SearchString = newSearchString; } public void OnFilterChanged() { if( !canvas ) // Same as OnPathChanged return; bool extensionsSingleSuffixModeChanged = false; if( filters != null && filtersDropdown.value < filters.Count ) { bool allExtensionsHadSingleSuffix = AllExtensionsHaveSingleSuffix; allFiltersHaveSingleSuffix = filters[filtersDropdown.value].allExtensionsHaveSingleSuffix; extensionsSingleSuffixModeChanged = ( AllExtensionsHaveSingleSuffix != allExtensionsHadSingleSuffix ); } PersistFileEntrySelection(); RefreshFiles( extensionsSingleSuffixModeChanged ); } public void OnShowHiddenFilesToggleChanged() { if( !canvas ) // Same as OnPathChanged return; PersistFileEntrySelection(); RefreshFiles( false ); } public void OnItemSelected( FileBrowserItem item, bool isDoubleClick ) { if( item == null ) return; if( item is FileBrowserQuickLink ) { #if !UNITY_EDITOR && UNITY_ANDROID if( ( (FileBrowserQuickLink) item ).TargetPath == SAF_PICK_FOLDER_QUICK_LINK_PATH ) FileBrowserHelpers.AJC.CallStatic( "PickSAFFolder", FileBrowserHelpers.Context, new FBDirectoryReceiveCallbackAndroid( OnSAFDirectoryPicked ) ); else #endif CurrentPath = ( (FileBrowserQuickLink) item ).TargetPath; return; } if( m_multiSelectionToggleSelectionMode ) { // In file selection mode, we shouldn't include folders in the multi-selection if( item.IsDirectory && m_pickerMode == PickMode.Files && !selectedFileEntries.Contains( item.Position ) ) return; // If a file/folder is double clicked in multi-selection mode, instead of opening that file/folder, we want to toggle its selected state isDoubleClick = false; } if( !isDoubleClick ) { if( !m_allowMultiSelection ) { selectedFileEntries.Clear(); selectedFileEntries.Add( item.Position ); } else { #if UNITY_EDITOR || UNITY_STANDALONE || UNITY_WEBGL || UNITY_WSA || UNITY_WSA_10_0 // When Shift key is held, all items from the pivot item to the clicked item will be selected #if ENABLE_INPUT_SYSTEM && !ENABLE_LEGACY_INPUT_MANAGER if( Keyboard.current != null && Keyboard.current.shiftKey.isPressed ) #else if( Input.GetKey( KeyCode.LeftShift ) || Input.GetKey( KeyCode.RightShift ) ) #endif { multiSelectionPivotFileEntry = Mathf.Clamp( multiSelectionPivotFileEntry, 0, validFileEntries.Count - 1 ); selectedFileEntries.Clear(); for( int i = multiSelectionPivotFileEntry; i < item.Position; i++ ) selectedFileEntries.Add( i ); for( int i = multiSelectionPivotFileEntry; i > item.Position; i-- ) selectedFileEntries.Add( i ); selectedFileEntries.Add( item.Position ); } else #endif { multiSelectionPivotFileEntry = item.Position; // When in toggle selection mode or Control/Command key is held, individual items can be multi-selected #if UNITY_EDITOR || UNITY_STANDALONE || UNITY_WEBGL || UNITY_WSA || UNITY_WSA_10_0 if( m_multiSelectionToggleSelectionMode || IsCtrlKeyHeld() ) #else if( m_multiSelectionToggleSelectionMode ) #endif { if( !selectedFileEntries.Contains( item.Position ) ) selectedFileEntries.Add( item.Position ); else { selectedFileEntries.Remove( item.Position ); if( selectedFileEntries.Count == 0 ) MultiSelectionToggleSelectionMode = false; } } else { selectedFileEntries.Clear(); selectedFileEntries.Add( item.Position ); } } } UpdateFilenameInputFieldWithSelection(); } for( int i = 0; i < allItems.Count; i++ ) { if( allItems[i].gameObject.activeSelf ) allItems[i].SetSelected( selectedFileEntries.Contains( allItems[i].Position ) ); } if( selectedFileEntries.Count > 0 && ( isDoubleClick || ( SingleClickMode && !m_multiSelectionToggleSelectionMode ) ) ) { if( !item.IsDirectory ) { // Submit selection OnSubmitButtonClicked(); } else { // Enter the directory #if !UNITY_EDITOR && UNITY_ANDROID if( FileBrowserHelpers.ShouldUseSAFForPath( m_currentPath ) ) { for( int i = 0; i < validFileEntries.Count; i++ ) { FileSystemEntry fileInfo = validFileEntries[i]; if( fileInfo.IsDirectory && fileInfo.Name == item.Name ) { CurrentPath = fileInfo.Path; return; } } } else #endif CurrentPath = Path.Combine( m_currentPath, item.Name ); } } } public void OnItemHeld( FileBrowserItem item ) { if( item is FileBrowserQuickLink ) OnItemSelected( item, false ); else if( m_allowMultiSelection && ( !item.IsDirectory || m_pickerMode != PickMode.Files ) ) // Holding a folder in file selection mode should do nothing { if( !MultiSelectionToggleSelectionMode ) { if( m_pickerMode == PickMode.Files ) { // If some folders are selected in file selection mode, deselect these folders before enabling the selection toggles because otherwise, // user won't be able to deselect the selected folders without exiting MultiSelectionToggleSelectionMode for( int i = selectedFileEntries.Count - 1; i >= 0; i-- ) { if( validFileEntries[selectedFileEntries[i]].IsDirectory ) selectedFileEntries.RemoveAt( i ); } } MultiSelectionToggleSelectionMode = true; } if( !selectedFileEntries.Contains( item.Position ) ) OnItemSelected( item, false ); } } #if !UNITY_EDITOR && UNITY_ANDROID private void OnSAFDirectoryPicked( string rawUri, string name ) { if( !string.IsNullOrEmpty( rawUri ) ) { if( AddQuickLink( m_skin.FolderIcon, name, rawUri ) ) CurrentPath = rawUri; } } private void FetchPersistedSAFQuickLinks() { string resultRaw = FileBrowserHelpers.AJC.CallStatic( "FetchSAFQuickLinks", FileBrowserHelpers.Context ); if( resultRaw == "0" ) return; int separatorIndex = resultRaw.LastIndexOf( "<>" ); if( separatorIndex <= 0 ) { Debug.LogError( "Entry count does not exist" ); return; } int entryCount = 0; for( int i = separatorIndex + 2; i < resultRaw.Length; i++ ) { char ch = resultRaw[i]; if( ch < '0' && ch > '9' ) { Debug.LogError( "Couldn't parse entry count" ); return; } entryCount = entryCount * 10 + ( ch - '0' ); } if( entryCount <= 0 ) return; bool defaultPathInitialized = false; separatorIndex = 0; for( int i = 0; i < entryCount; i++ ) { int nextSeparatorIndex = resultRaw.IndexOf( "<>", separatorIndex ); if( nextSeparatorIndex <= 0 ) { Debug.LogError( "Entry name is empty" ); return; } string entryName = resultRaw.Substring( separatorIndex, nextSeparatorIndex - separatorIndex ); separatorIndex = nextSeparatorIndex + 2; nextSeparatorIndex = resultRaw.IndexOf( "<>", separatorIndex ); if( nextSeparatorIndex <= 0 ) { Debug.LogError( "Entry rawUri is empty" ); return; } string rawUri = resultRaw.Substring( separatorIndex, nextSeparatorIndex - separatorIndex ); separatorIndex = nextSeparatorIndex + 2; if( AddQuickLink( m_skin.FolderIcon, entryName, rawUri ) && !defaultPathInitialized ) { defaultInitialPath = rawUri; defaultPathInitialized = true; } } } #endif private char OnValidateFilenameInput( string text, int charIndex, char addedChar ) { if( addedChar == '\n' ) { OnSubmitButtonClicked(); return '\0'; } return addedChar; } private void OnFilenameInputChanged( string text ) { filenameInputFieldOverlayText.text = text; filenameImage.color = m_skin.InputFieldNormalBackgroundColor; } #endregion #region Helper Functions public void Show( string initialPath, string initialFilename ) { if( AskPermissions ) RequestPermission(); if( !quickLinksInitialized ) InitializeQuickLinks(); selectedFileEntries.Clear(); m_multiSelectionToggleSelectionMode = false; m_searchString = string.Empty; searchInputField.text = m_searchString; filesScrollRect.verticalNormalizedPosition = 1; IsOpen = true; Success = false; Result = null; gameObject.SetActive( true ); CurrentPath = GetInitialPath( initialPath ); filenameInputField.text = initialFilename ?? string.Empty; filenameInputField.interactable = true; filenameImage.color = m_skin.InputFieldNormalBackgroundColor; } public void Hide() { IsOpen = false; currentPathIndex = -1; pathsFollowed.Clear(); backButton.interactable = false; forwardButton.interactable = false; upButton.interactable = false; gameObject.SetActive( false ); } public void RefreshFiles( bool pathChanged ) { bool allExtensionsHaveSingleSuffix = AllExtensionsHaveSingleSuffix; if( pathChanged ) { if( !string.IsNullOrEmpty( m_currentPath ) ) allFileEntries = FileBrowserHelpers.GetEntriesInDirectory( m_currentPath, allExtensionsHaveSingleSuffix ); else allFileEntries = null; } selectedFileEntries.Clear(); if( !showHiddenFilesToggle.isOn ) ignoredFileAttributes |= FileAttributes.Hidden; else ignoredFileAttributes &= ~FileAttributes.Hidden; validFileEntries.Clear(); if( allFileEntries != null ) { if( sortFilesByName ) { // Sort the files and folders in the following order: // 1. Directories come before files // 2. Directories and files are sorted by their names Array.Sort( allFileEntries, ( entry1, entry2 ) => { if( entry1.IsDirectory != entry2.IsDirectory ) return entry1.IsDirectory ? -1 : 1; else return entry1.Name.CompareTo( entry2.Name ); } ); } for( int i = 0; i < allFileEntries.Length; i++ ) { try { FileSystemEntry item = allFileEntries[i]; if( FileSystemEntryMatchesFilters( item, allExtensionsHaveSingleSuffix ) ) validFileEntries.Add( item ); } catch( Exception e ) { Debug.LogException( e ); } } } // Restore the selection if( pendingFileEntrySelection.Count > 0 ) { for( int i = 0; i < pendingFileEntrySelection.Count; i++ ) { string pendingFileEntry = pendingFileEntrySelection[i]; for( int j = 0; j < validFileEntries.Count; j++ ) { if( validFileEntries[j].Name == pendingFileEntry ) { selectedFileEntries.Add( j ); break; } } } pendingFileEntrySelection.Clear(); } if( !filenameInputField.interactable && selectedFileEntries.Count <= 1 ) { filenameInputField.interactable = true; if( selectedFileEntries.Count == 0 ) filenameInputField.text = string.Empty; } listView.UpdateList(); // Prevent the case where all the content stays offscreen after changing the search string EnsureScrollViewIsWithinBounds(); } // Returns whether or not the FileSystemEntry passes the file browser's filters and should be displayed in the files list private bool FileSystemEntryMatchesFilters( FileSystemEntry item, bool allExtensionsHaveSingleSuffix ) { if( !item.IsDirectory ) { if( m_pickerMode == PickMode.Folders ) return false; if( ( item.Attributes & ignoredFileAttributes ) != 0 ) return false; string extension = item.Extension; if( excludedExtensionsSet.Contains( extension ) ) return false; else if( !allExtensionsHaveSingleSuffix ) { for( int j = 0; j < excludedExtensions.Length; j++ ) { if( extension.EndsWith( excludedExtensions[j], StringComparison.Ordinal ) ) { excludedExtensionsSet.Add( extension ); continue; } } } if( !filters[filtersDropdown.value].MatchesExtension( extension, !allExtensionsHaveSingleSuffix ) ) return false; } else { if( ( item.Attributes & ignoredFileAttributes ) != 0 ) return false; } if( m_searchString.Length > 0 && textComparer.IndexOf( item.Name, m_searchString, textCompareOptions ) < 0 ) return false; if( m_displayedEntriesFilter != null && !m_displayedEntriesFilter( item ) ) return false; return true; } // Quickly selects all files and folders in the current directory public void SelectAllFiles() { if( !m_allowMultiSelection || validFileEntries.Count == 0 ) return; multiSelectionPivotFileEntry = 0; selectedFileEntries.Clear(); if( m_pickerMode != PickMode.Files ) { for( int i = 0; i < validFileEntries.Count; i++ ) selectedFileEntries.Add( i ); } else { // Don't select folders in file picking mode if MultiSelectionToggleSelectionMode is enabled or about to be enabled for( int i = 0; i < validFileEntries.Count; i++ ) { #if UNITY_EDITOR || UNITY_STANDALONE || UNITY_WEBGL || UNITY_WSA || UNITY_WSA_10_0 if( !m_multiSelectionToggleSelectionMode || !validFileEntries[i].IsDirectory ) #else if( !validFileEntries[i].IsDirectory ) #endif selectedFileEntries.Add( i ); } } #if !UNITY_EDITOR && !UNITY_STANDALONE && !UNITY_WSA && !UNITY_WSA_10_0 MultiSelectionToggleSelectionMode = true; #endif UpdateFilenameInputFieldWithSelection(); listView.UpdateList(); } // Quickly deselects all files and folders in the current directory public void DeselectAllFiles() { if( selectedFileEntries.Count == 0 ) return; selectedFileEntries.Clear(); MultiSelectionToggleSelectionMode = false; filenameInputField.text = string.Empty; filenameInputField.interactable = true; listView.UpdateList(); } // Prompts user to create a new folder in the current directory public void CreateNewFolder() { StartCoroutine( CreateNewFolderCoroutine() ); } private IEnumerator CreateNewFolderCoroutine() { filesScrollRect.verticalNormalizedPosition = 1f; filesScrollRect.velocity = Vector2.zero; if( selectedFileEntries.Count > 0 ) { selectedFileEntries.Clear(); MultiSelectionToggleSelectionMode = false; filenameInputField.text = string.Empty; filenameInputField.interactable = true; listView.UpdateList(); } filesScrollRect.movementType = ScrollRect.MovementType.Unrestricted; // The easiest way to insert a new item to the top of the list view is to just shift // the list view downwards. However, it doesn't always work if we don't shift it twice yield return null; filesContainer.anchoredPosition = new Vector2( 0f, -m_skin.FileHeight ); yield return null; filesContainer.anchoredPosition = new Vector2( 0f, -m_skin.FileHeight ); ( (RectTransform) renameItem.transform ).anchoredPosition = new Vector2( 1f, m_skin.FileHeight ); renameItem.Show( string.Empty, m_skin.FileSelectedBackgroundColor, m_skin.FolderIcon, ( folderName ) => { filesScrollRect.movementType = ScrollRect.MovementType.Clamped; filesContainer.anchoredPosition = Vector2.zero; if( string.IsNullOrEmpty( folderName ) ) return; FileBrowserHelpers.CreateFolderInDirectory( CurrentPath, folderName ); pendingFileEntrySelection.Clear(); pendingFileEntrySelection.Add( folderName ); RefreshFiles( true ); if( m_pickerMode != PickMode.Files ) filenameInputField.text = folderName; // Focus on the newly created folder int fileEntryIndex = Mathf.Max( 0, FilenameToFileEntryIndex( folderName ) ); filesScrollRect.verticalNormalizedPosition = validFileEntries.Count > 1 ? ( 1f - (float) fileEntryIndex / ( validFileEntries.Count - 1 ) ) : 1f; } ); } // Prompts user to rename the selected file/folder public void RenameSelectedFile() { if( selectedFileEntries.Count != 1 ) return; MultiSelectionToggleSelectionMode = false; int fileEntryIndex = selectedFileEntries[0]; FileSystemEntry fileInfo = validFileEntries[fileEntryIndex]; // Check if selected file is currently visible in ScrollRect // We consider it visible if both the previous file entry and the next file entry are visible bool prevFileEntryVisible = false, nextFileEntryVisible = false; for( int i = 0; i < allItems.Count; i++ ) { if( !allItems[i].gameObject.activeSelf ) continue; if( allItems[i].Position == fileEntryIndex - 1 ) { prevFileEntryVisible = true; if( prevFileEntryVisible && nextFileEntryVisible ) break; } else if( allItems[i].Position == fileEntryIndex + 1 ) { nextFileEntryVisible = true; if( prevFileEntryVisible && nextFileEntryVisible ) break; } } if( !prevFileEntryVisible || !nextFileEntryVisible ) filesScrollRect.verticalNormalizedPosition = validFileEntries.Count > 1 ? ( 1f - (float) fileEntryIndex / ( validFileEntries.Count - 1 ) ) : 1f; filesScrollRect.velocity = Vector2.zero; ( (RectTransform) renameItem.transform ).anchoredPosition = new Vector2( 1f, -fileEntryIndex * m_skin.FileHeight ); renameItem.Show( fileInfo.Name, m_skin.FileSelectedBackgroundColor, GetIconForFileEntry( fileInfo ), ( newName ) => { if( string.IsNullOrEmpty( newName ) || newName == fileInfo.Name ) return; if( fileInfo.IsDirectory ) FileBrowserHelpers.RenameDirectory( fileInfo.Path, newName ); else FileBrowserHelpers.RenameFile( fileInfo.Path, newName ); pendingFileEntrySelection.Clear(); pendingFileEntrySelection.Add( newName ); RefreshFiles( true ); if( ( fileInfo.IsDirectory && m_pickerMode != PickMode.Files ) || ( !fileInfo.IsDirectory && m_pickerMode != PickMode.Folders ) ) filenameInputField.text = newName; } ); } // Prompts user to delete the selected files & folders public void DeleteSelectedFiles() { if( selectedFileEntries.Count == 0 ) return; selectedFileEntries.Sort(); fileOperationConfirmationPanel.Show( this, validFileEntries, selectedFileEntries, FileBrowserFileOperationConfirmationPanel.OperationType.Delete, () => { for( int i = selectedFileEntries.Count - 1; i >= 0; i-- ) { FileSystemEntry fileInfo = validFileEntries[selectedFileEntries[i]]; if( fileInfo.IsDirectory ) FileBrowserHelpers.DeleteDirectory( fileInfo.Path ); else FileBrowserHelpers.DeleteFile( fileInfo.Path ); } selectedFileEntries.Clear(); MultiSelectionToggleSelectionMode = false; RefreshFiles( true ); } ); } // Makes sure that the selection persists after Refreshing the file entries private void PersistFileEntrySelection() { pendingFileEntrySelection.Clear(); for( int i = 0; i < selectedFileEntries.Count; i++ ) pendingFileEntrySelection.Add( validFileEntries[selectedFileEntries[i]].Name ); } private bool AddQuickLink( Sprite icon, string name, string path ) { if( string.IsNullOrEmpty( path ) ) return false; #if !UNITY_EDITOR && UNITY_ANDROID if( !FileBrowserHelpers.ShouldUseSAFForPath( path ) ) #endif { #if !WIN_DIR_CHECK_WITHOUT_TIMEOUT && ( UNITY_EDITOR_WIN || UNITY_STANDALONE_WIN ) if( !CheckDirectoryExistsWithTimeout( path ) ) #else if( !Directory.Exists( path ) ) #endif return false; path = GetPathWithoutTrailingDirectorySeparator( path.Trim() ); } // Don't add quick link if it already exists for( int i = 0; i < allQuickLinks.Count; i++ ) { if( allQuickLinks[i].TargetPath == path ) return false; } FileBrowserQuickLink quickLink = (FileBrowserQuickLink) Instantiate( quickLinkPrefab, quickLinksContainer, false ); quickLink.SetFileBrowser( this, m_skin ); if( icon != null ) quickLink.SetQuickLink( icon, name, path ); else quickLink.SetQuickLink( m_skin.FolderIcon, name, path ); Vector2 anchoredPos = new Vector2( 0f, -quickLinksContainer.sizeDelta.y ); quickLink.TransformComponent.anchoredPosition = anchoredPos; anchoredPos.y -= m_skin.FileHeight; quickLinksContainer.sizeDelta = new Vector2( 0f, -anchoredPos.y ); allQuickLinks.Add( quickLink ); return true; } private void ClearQuickLinksInternal() { Vector2 anchoredPos = Vector2.zero; for( int i = 0; i < allQuickLinks.Count; i++ ) { if( allQuickLinks[i].TargetPath == SAF_PICK_FOLDER_QUICK_LINK_PATH ) { allQuickLinks[i].TransformComponent.anchoredPosition = anchoredPos; anchoredPos.y -= m_skin.FileHeight; } else { Destroy( allQuickLinks[i].gameObject ); allQuickLinks.RemoveAt( i-- ); } } quickLinksContainer.sizeDelta = new Vector2( 0f, -anchoredPos.y ); quickLinksInitialized = true; generateQuickLinksForDrives = false; } // Makes sure that scroll view's contents are within scroll view's bounds private void EnsureScrollViewIsWithinBounds() { // When scrollbar is snapped to the very bottom of the scroll view, sometimes OnScroll alone doesn't work if( filesScrollRect.verticalNormalizedPosition <= Mathf.Epsilon ) filesScrollRect.verticalNormalizedPosition = 0.0001f; filesScrollRect.OnScroll( nullPointerEventData ); } internal void EnsureWindowIsWithinBounds() { Vector2 canvasSize = rectTransform.sizeDelta; Vector2 windowSize = windowTR.sizeDelta; if( windowSize.x < minWidth ) windowSize.x = minWidth; if( windowSize.y < minHeight ) windowSize.y = minHeight; if( windowSize.x > canvasSize.x ) windowSize.x = canvasSize.x; if( windowSize.y > canvasSize.y ) windowSize.y = canvasSize.y; Vector2 windowPos = windowTR.anchoredPosition; Vector2 canvasHalfSize = canvasSize * 0.5f; Vector2 windowHalfSize = windowSize * 0.5f; Vector2 windowBottomLeft = windowPos - windowHalfSize + canvasHalfSize; Vector2 windowTopRight = windowPos + windowHalfSize + canvasHalfSize; if( windowBottomLeft.x < 0f ) windowPos.x -= windowBottomLeft.x; else if( windowTopRight.x > canvasSize.x ) windowPos.x -= windowTopRight.x - canvasSize.x; if( windowBottomLeft.y < 0f ) windowPos.y -= windowBottomLeft.y; else if( windowTopRight.y > canvasSize.y ) windowPos.y -= windowTopRight.y - canvasSize.y; windowTR.anchoredPosition = windowPos; windowTR.sizeDelta = windowSize; } // Handles responsive user interface internal void OnWindowDimensionsChanged( Vector2 size ) { float windowWidth = size.x; float quickLinksWidth = Mathf.Min( middleViewQuickLinksOriginalSize.x, windowWidth * quickLinksMaxWidthPercentage ); if( middleViewQuickLinks.sizeDelta.x != quickLinksWidth ) { middleViewQuickLinks.sizeDelta = new Vector2( quickLinksWidth, middleViewQuickLinksOriginalSize.y ); middleViewFiles.anchoredPosition = new Vector2( quickLinksWidth, 0f ); middleViewFiles.sizeDelta = new Vector2( -quickLinksWidth, middleViewQuickLinksOriginalSize.y ); middleViewSeparator.anchoredPosition = new Vector2( quickLinksWidth, 0f ); } #if !UNITY_EDITOR && UNITY_ANDROID // Responsive layout doesn't affect any other visible UI elements on Storage Access Framework if( FileBrowserHelpers.ShouldUseSAF ) return; #endif if( windowWidth >= narrowScreenWidth ) { if( pathInputField.transform.parent == pathInputFieldSlotBottom ) { pathInputField.transform.SetParent( pathInputFieldSlotTop, false ); middleView.anchoredPosition = middleViewOriginalPosition; middleView.sizeDelta = middleViewOriginalSize; showHiddenFilesToggle.gameObject.SetActive( m_displayHiddenFilesToggle ); listView.OnViewportDimensionsChanged(); EnsureScrollViewIsWithinBounds(); } } else { if( pathInputField.transform.parent == pathInputFieldSlotTop ) { pathInputField.transform.SetParent( pathInputFieldSlotBottom, false ); float topViewAdditionalHeight = topViewNarrowScreen.sizeDelta.y; middleView.anchoredPosition = middleViewOriginalPosition - new Vector2( 0f, topViewAdditionalHeight * 0.5f ); middleView.sizeDelta = middleViewOriginalSize - new Vector2( 0f, topViewAdditionalHeight ); // Responsive layout for narrow screens doesn't include "Show Hidden Files" toggle. // We simply hide it because I think creating a new row for it would be an overkill showHiddenFilesToggle.gameObject.SetActive( false ); listView.OnViewportDimensionsChanged(); EnsureScrollViewIsWithinBounds(); } } } internal Sprite GetIconForFileEntry( FileSystemEntry fileInfo ) { return m_skin.GetIconForFileEntry( fileInfo, !AllExtensionsHaveSingleSuffix ); } internal static string GetExtensionFromFilename( string filename, bool extractOnlyLastSuffix ) { int length = filename.Length; if( extractOnlyLastSuffix ) { // We are only interested in the last suffix of the extension for( int i = length - 2; i >= 0; i-- ) { if( filename[i] == '.' ) return filename.Substring( i, length - i ).ToLowerInvariant(); } } else { // We are interested in all suffixes of the extension for( int i = 0, upperLimit = length - 2; i <= upperLimit; i++ ) { if( filename[i] == '.' ) return filename.Substring( i, length - i ).ToLowerInvariant(); } } return string.Empty; } private string GetPathWithoutTrailingDirectorySeparator( string path ) { if( string.IsNullOrEmpty( path ) ) return null; // Credit: http://stackoverflow.com/questions/6019227/remove-the-last-character-if-its-directoryseparatorchar-with-c-sharp try { if( Path.GetDirectoryName( path ) != null ) { char lastChar = path[path.Length - 1]; if( lastChar == Path.DirectorySeparatorChar || lastChar == Path.AltDirectorySeparatorChar ) path = path.Substring( 0, path.Length - 1 ); } } catch { } return path; } private void UpdateFilenameInputFieldWithSelection() { // Refresh filenameInputField as follows: // 0 files selected: *blank* // 1 file selected: file.Name // 2+ files selected: "file1.Name" "file2.Name" ... (up to FILENAME_INPUT_FIELD_MAX_FILE_COUNT filenames are displayed for performance reasons) int filenameContributingFileCount = 0; if( m_pickerMode != PickMode.Files ) filenameContributingFileCount = selectedFileEntries.Count; else { for( int i = 0; i < selectedFileEntries.Count; i++ ) { if( !validFileEntries[selectedFileEntries[i]].IsDirectory ) { filenameContributingFileCount++; if( filenameContributingFileCount >= FILENAME_INPUT_FIELD_MAX_FILE_COUNT ) break; } } } filenameInputField.interactable = selectedFileEntries.Count <= 1; if( filenameContributingFileCount == 0 ) { // If multiple files were previously selected, clear the input field. If a single file was selected, preserve the filename if( filenameInputField.text.StartsWith( "\"" ) ) filenameInputField.text = string.Empty; } else { if( filenameContributingFileCount > 1 ) { if( multiSelectionFilenameBuilder == null ) multiSelectionFilenameBuilder = new StringBuilder( 75 ); else multiSelectionFilenameBuilder.Length = 0; } for( int i = 0, fileCount = 0; i < selectedFileEntries.Count; i++ ) { FileSystemEntry selectedFile = validFileEntries[selectedFileEntries[i]]; if( m_pickerMode != PickMode.Files || !selectedFile.IsDirectory ) { if( filenameContributingFileCount == 1 ) { filenameInputField.text = selectedFile.Name; break; } else { multiSelectionFilenameBuilder.Append( "\"" ).Append( selectedFile.Name ).Append( "\" " ); if( ++fileCount >= FILENAME_INPUT_FIELD_MAX_FILE_COUNT ) { multiSelectionFilenameBuilder.Append( "..." ); break; } } } } if( filenameContributingFileCount > 1 ) filenameInputField.text = multiSelectionFilenameBuilder.ToString(); } } // Extracts filenames from input field. Input can be in 2 formats: // 1 filename: file.Name // 2+ filenames: "file1.Name" "file2.Name" ... // Returns the length of the iterated filename private int ExtractFilenameFromInput( string input, ref int startIndex, out int nextStartIndex ) { if( !m_allowMultiSelection || input[startIndex] != '"' ) { // Single file is selected, return it nextStartIndex = input.Length; return input.Length - startIndex; } // Seems like multiple files are selected // Filename is " (a single quotation mark), very unlikely to happen but probably possible on some platforms if( startIndex + 1 >= input.Length ) { nextStartIndex = input.Length; return 1; } int filenameEndIndex = input.IndexOf( '"', startIndex + 1 ); while( true ) { // 1st iteration: filename is "abc // 2nd iteration: filename is "abc"def if( filenameEndIndex == -1 ) { nextStartIndex = input.Length; return input.Length - startIndex; } // 1st iteration: filename is abc (extracted from "abc") // 2nd iteration: filename is abc"def (extracted from "abc"def") if( filenameEndIndex == input.Length - 1 || input[filenameEndIndex + 1] == ' ' ) { startIndex++; nextStartIndex = filenameEndIndex + 1; while( nextStartIndex < input.Length && input[nextStartIndex] == ' ' ) nextStartIndex++; return filenameEndIndex - startIndex; } // Filename contains a " char filenameEndIndex = input.IndexOf( '"', filenameEndIndex + 1 ); } } // Checks if a substring of the input field points to an existing file private int FilenameToFileEntryIndex( string filename ) { // Case-sensitive search result takes precedence, so case-insensitive search result is returned only if a case-sensitive match isn't found int caseInsensitiveResult = -1; for( int i = 0; i < validFileEntries.Count; i++ ) { if( validFileEntries[i].Name.Length == filename.Length ) { if( filename == validFileEntries[i].Name ) // Case-sensitive filename query return i; else if( textComparer.Compare( filename, validFileEntries[i].Name, textCompareOptions ) == 0 ) // Case-insensitive filename query caseInsensitiveResult = i; } } return caseInsensitiveResult; } // Verifies that filename doesn't contain any invalid characters private bool VerifyFilename( string filename ) { bool isWhitespace = true; for( int i = 0; i < filename.Length; i++ ) { char ch = filename[i]; if( invalidFilenameChars.Contains( ch ) ) return false; if( isWhitespace && !char.IsWhiteSpace( ch ) ) isWhitespace = false; } return !isWhitespace; } // Credit: http://answers.unity3d.com/questions/898770/how-to-get-the-width-of-ui-text-with-horizontal-ov.html private int CalculateLengthOfDropdownText( string str ) { Font font = filterItemTemplate.font; font.RequestCharactersInTexture( str, filterItemTemplate.fontSize, filterItemTemplate.fontStyle ); int totalLength = 0; for( int i = 0; i < str.Length; i++ ) { CharacterInfo characterInfo; if( !font.GetCharacterInfo( str[i], out characterInfo, filterItemTemplate.fontSize ) ) totalLength += 5; totalLength += characterInfo.advance; } return totalLength; } private string GetInitialPath( string initialPath ) { if( !string.IsNullOrEmpty( initialPath ) && !FileBrowserHelpers.DirectoryExists( initialPath ) && FileBrowserHelpers.FileExists( initialPath ) ) { // Path points to a file, use its parent directory's path instead initialPath = FileBrowserHelpers.GetDirectoryName( initialPath ); } if( string.IsNullOrEmpty( initialPath ) || !FileBrowserHelpers.DirectoryExists( initialPath ) ) { if( CurrentPath.Length > 0 ) initialPath = CurrentPath; else { string lastBrowsedFolder = LastBrowsedFolder; if( !string.IsNullOrEmpty( lastBrowsedFolder ) && FileBrowserHelpers.DirectoryExists( lastBrowsedFolder ) ) initialPath = lastBrowsedFolder; else initialPath = defaultInitialPath; } } m_currentPath = string.Empty; // Needed to correctly reset the pathsFollowed return initialPath; } #if !WIN_DIR_CHECK_WITHOUT_TIMEOUT && ( UNITY_EDITOR_WIN || UNITY_STANDALONE_WIN ) private bool CheckDirectoryExistsWithTimeout( string path, int timeout = 750 ) { if( timedOutDirectoryExistsRequests.Contains( path ) ) return false; // Directory.Exists freezes for ~15 seconds for unreachable network drives on Windows, set a timeout using threads bool directoryExists = false; try { #if NET_STANDARD_2_0 || NET_4_6 // Credit: https://stackoverflow.com/a/52661569/2373034 System.Threading.Tasks.Task task = new System.Threading.Tasks.Task( () => directoryExists = Directory.Exists( path ) ); task.Start(); if( !task.Wait( timeout ) ) timedOutDirectoryExistsRequests.Add( path ); #else // Credit: https://stackoverflow.com/q/1232953/2373034 System.Threading.Thread thread = new System.Threading.Thread( new System.Threading.ThreadStart( () => directoryExists = Directory.Exists( path ) ) ); thread.Start(); if( !thread.Join( timeout ) ) { timedOutDirectoryExistsRequests.Add( path ); thread.Abort(); } #endif } catch { directoryExists = Directory.Exists( path ); } return directoryExists; } #endif private bool CheckDirectoryWriteAccess( string path ) { #if !UNITY_EDITOR && UNITY_ANDROID if( FileBrowserHelpers.ShouldUseSAFForPath( path ) ) return true; #endif string tempFilePath = Path.Combine( path, "__fsWrite.tmp" ); try { File.Create( tempFilePath ).Close(); File.Delete( tempFilePath ); return true; } catch { return false; } finally { try { File.Delete( tempFilePath ); } catch { } } } // Check if Control/Command key is held private bool IsCtrlKeyHeld() { #if ENABLE_INPUT_SYSTEM && !ENABLE_LEGACY_INPUT_MANAGER #if UNITY_EDITOR_OSX || ( !UNITY_EDITOR && UNITY_STANDALONE_OSX ) return Keyboard.current != null && ( Keyboard.current.leftCommandKey.isPressed || Keyboard.current.rightCommandKey.isPressed ); #else return Keyboard.current != null && Keyboard.current.ctrlKey.isPressed; #endif #else #if UNITY_EDITOR_OSX || ( !UNITY_EDITOR && UNITY_STANDALONE_OSX ) return Input.GetKey( KeyCode.LeftCommand ) || Input.GetKey( KeyCode.RightCommand ); #else return Input.GetKey( KeyCode.LeftControl ) || Input.GetKey( KeyCode.RightControl ); #endif #endif } #endregion #region File Browser Functions (static) public static bool ShowSaveDialog( OnSuccess onSuccess, OnCancel onCancel, PickMode pickMode, bool allowMultiSelection = false, string initialPath = null, string initialFilename = null, string title = "Save", string saveButtonText = "Save" ) { return ShowDialogInternal( onSuccess, onCancel, pickMode, allowMultiSelection, pickMode != PickMode.Folders, initialPath, initialFilename, title, saveButtonText ); } public static bool ShowLoadDialog( OnSuccess onSuccess, OnCancel onCancel, PickMode pickMode, bool allowMultiSelection = false, string initialPath = null, string initialFilename = null, string title = "Load", string loadButtonText = "Select" ) { return ShowDialogInternal( onSuccess, onCancel, pickMode, allowMultiSelection, false, initialPath, initialFilename, title, loadButtonText ); } private static bool ShowDialogInternal( OnSuccess onSuccess, OnCancel onCancel, PickMode pickMode, bool allowMultiSelection, bool acceptNonExistingFilename, string initialPath, string initialFilename, string title, string submitButtonText ) { // Instead of ignoring this dialog request, let's just override the currently visible dialog's properties //if( Instance.gameObject.activeSelf ) //{ // Debug.LogError( "Error: Multiple dialogs are not allowed!" ); // return false; //} Instance.onSuccess = onSuccess; Instance.onCancel = onCancel; Instance.PickerMode = pickMode; Instance.AllowMultiSelection = allowMultiSelection; Instance.Title = title; Instance.SubmitButtonText = submitButtonText; Instance.AcceptNonExistingFilename = acceptNonExistingFilename; Instance.Show( initialPath, initialFilename ); return true; } public static void HideDialog( bool invokeCancelCallback = false ) { Instance.OnOperationCanceled( invokeCancelCallback ); } public static IEnumerator WaitForSaveDialog( PickMode pickMode, bool allowMultiSelection = false, string initialPath = null, string initialFilename = null, string title = "Save", string saveButtonText = "Save" ) { if( !ShowSaveDialog( null, null, pickMode, allowMultiSelection, initialPath, initialFilename, title, saveButtonText ) ) yield break; while( Instance.gameObject.activeSelf ) yield return null; } public static IEnumerator WaitForLoadDialog( PickMode pickMode, bool allowMultiSelection = false, string initialPath = null, string initialFilename = null, string title = "Load", string loadButtonText = "Select" ) { if( !ShowLoadDialog( null, null, pickMode, allowMultiSelection, initialPath, initialFilename, title, loadButtonText ) ) yield break; while( Instance.gameObject.activeSelf ) yield return null; } public static bool AddQuickLink( string name, string path, Sprite icon = null ) { if( string.IsNullOrEmpty( path ) || !FileBrowserHelpers.DirectoryExists( path ) ) return false; if( !quickLinksInitialized ) { // Fetching the list of external drives is only possible with the READ_EXTERNAL_STORAGE permission granted on Android if( AskPermissions ) RequestPermission(); Instance.InitializeQuickLinks(); } return Instance.AddQuickLink( icon, name, path ); } public static void ClearQuickLinks() { Instance.ClearQuickLinksInternal(); } public static void SetExcludedExtensions( params string[] excludedExtensions ) { Instance.excludedExtensions = excludedExtensions ?? new string[0]; Instance.excludedExtensionsSet.Clear(); Instance.allExcludedExtensionsHaveSingleSuffix = true; if( excludedExtensions != null ) { for( int i = 0; i < excludedExtensions.Length; i++ ) { excludedExtensions[i] = excludedExtensions[i].ToLowerInvariant(); if( excludedExtensions[i][0] != '.' ) excludedExtensions[i] = "." + excludedExtensions[i]; Instance.excludedExtensionsSet.Add( excludedExtensions[i] ); Instance.allExcludedExtensionsHaveSingleSuffix &= ( excludedExtensions[i].LastIndexOf( '.' ) == 0 ); } } } public static void SetFilters( bool showAllFilesFilter ) { SetFilters( showAllFilesFilter, (string[]) null ); } public static void SetFilters( bool showAllFilesFilter, IEnumerable filters ) { SetFiltersPreProcessing( showAllFilesFilter ); if( filters != null ) { foreach( string filter in filters ) { if( !string.IsNullOrEmpty( filter ) ) Instance.filters.Add( new Filter( null, filter ) ); } } SetFiltersPostProcessing(); } public static void SetFilters( bool showAllFilesFilter, params string[] filters ) { SetFiltersPreProcessing( showAllFilesFilter ); if( filters != null ) { for( int i = 0; i < filters.Length; i++ ) { if( !string.IsNullOrEmpty( filters[i] ) ) Instance.filters.Add( new Filter( null, filters[i] ) ); } } SetFiltersPostProcessing(); } public static void SetFilters( bool showAllFilesFilter, IEnumerable filters ) { SetFiltersPreProcessing( showAllFilesFilter ); if( filters != null ) { foreach( Filter filter in filters ) { if( filter != null && filter.defaultExtension.Length > 0 ) Instance.filters.Add( filter ); } } SetFiltersPostProcessing(); } public static void SetFilters( bool showAllFilesFilter, params Filter[] filters ) { SetFiltersPreProcessing( showAllFilesFilter ); if( filters != null ) { for( int i = 0; i < filters.Length; i++ ) { if( filters[i] != null && filters[i].defaultExtension.Length > 0 ) Instance.filters.Add( filters[i] ); } } SetFiltersPostProcessing(); } private static void SetFiltersPreProcessing( bool showAllFilesFilter ) { Instance.showAllFilesFilter = showAllFilesFilter; Instance.filters.Clear(); if( showAllFilesFilter ) Instance.filters.Add( Instance.allFilesFilter ); } private static void SetFiltersPostProcessing() { List filters = Instance.filters; if( filters.Count == 0 ) filters.Add( Instance.allFilesFilter ); int maxFilterStrLength = 100; List dropdownValues = new List( filters.Count ); for( int i = 0; i < filters.Count; i++ ) { string filterStr = filters[i].ToString(); dropdownValues.Add( filterStr ); maxFilterStrLength = Mathf.Max( maxFilterStrLength, Instance.CalculateLengthOfDropdownText( filterStr ) ); } Vector2 size = Instance.filtersDropdownContainer.sizeDelta; size.x = maxFilterStrLength + 28; Instance.filtersDropdownContainer.sizeDelta = size; Instance.filtersDropdown.ClearOptions(); Instance.filtersDropdown.AddOptions( dropdownValues ); Instance.filtersDropdown.value = 0; Instance.allFiltersHaveSingleSuffix = filters[0].allExtensionsHaveSingleSuffix; } public static bool SetDefaultFilter( string defaultFilter ) { if( string.IsNullOrEmpty( defaultFilter ) ) { if( Instance.showAllFilesFilter ) { Instance.filtersDropdown.value = 0; Instance.filtersDropdown.RefreshShownValue(); return true; } return false; } defaultFilter = defaultFilter.ToLowerInvariant(); if( defaultFilter[0] != '.' ) defaultFilter = "." + defaultFilter; for( int i = 0; i < Instance.filters.Count; i++ ) { HashSet extensions = Instance.filters[i].extensionsSet; if( extensions != null && extensions.Contains( defaultFilter ) ) { Instance.filtersDropdown.value = i; Instance.filtersDropdown.RefreshShownValue(); return true; } } return false; } public static Permission CheckPermission() { #if !UNITY_EDITOR && UNITY_ANDROID Permission result = (Permission) FileBrowserHelpers.AJC.CallStatic( "CheckPermission", FileBrowserHelpers.Context ); if( result == Permission.Denied && (Permission) PlayerPrefs.GetInt( "FileBrowserPermission", (int) Permission.ShouldAsk ) == Permission.ShouldAsk ) result = Permission.ShouldAsk; return result; #else return Permission.Granted; #endif } public static Permission RequestPermission() { #if !UNITY_EDITOR && UNITY_ANDROID object threadLock = new object(); lock( threadLock ) { FBPermissionCallbackAndroid nativeCallback = new FBPermissionCallbackAndroid( threadLock ); FileBrowserHelpers.AJC.CallStatic( "RequestPermission", FileBrowserHelpers.Context, nativeCallback, PlayerPrefs.GetInt( "FileBrowserPermission", (int) Permission.ShouldAsk ) ); if( nativeCallback.Result == -1 ) System.Threading.Monitor.Wait( threadLock ); if( (Permission) nativeCallback.Result != Permission.ShouldAsk && PlayerPrefs.GetInt( "FileBrowserPermission", -1 ) != nativeCallback.Result ) { PlayerPrefs.SetInt( "FileBrowserPermission", nativeCallback.Result ); PlayerPrefs.Save(); } return (Permission) nativeCallback.Result; } #else return Permission.Granted; #endif } #endregion } }