using Facepunch.ActionGraphs; using NativeEngine; using Sandbox.Engine.Settings; using Sandbox.Engine.Shaders; using Sandbox.Internal; using Sandbox.Utility; using System; using System.Diagnostics; using System.IO; using System.Threading; namespace Editor; public static partial class EditorUtility { public class OnInspectArgs : EventArgs { public bool Cancel { get; set; } public object Object { get; init; } } /// /// Called when InspectorObject changes /// public static event Action OnInspect; static object _inspectorObject; /// /// Set the object to be inspected by the inspector. /// public static object InspectorObject { get => _inspectorObject; set { if ( _inspectorObject == value ) return; if ( TryInspectActionGraphElement( value ) ) { _inspectorObject = value; return; } if ( OnInspect == null ) { _inspectorObject = value; return; } var eventArgs = new OnInspectArgs { Object = value, Cancel = false }; OnInspect.Invoke( eventArgs ); if ( eventArgs.Cancel == false ) { _inspectorObject = eventArgs.Object; } } } private static bool TryInspectActionGraphElement( object value ) { switch ( value ) { case Array arr: return arr.OfType().Any( TryInspectActionGraphElement ); case Facepunch.ActionGraphs.IMessageContext element: EditorEvent.Run( "actiongraph.inspect", element ); return true; default: return false; } } public static void AddLogger( Action logger ) { Sandbox.Diagnostics.Logging.OnMessage += logger; } public static void RemoveLogger( Action logger ) { Sandbox.Diagnostics.Logging.OnMessage -= logger; } public static ConCmdAttribute.AutoCompleteResult[] AutoComplete( string text, int maxCount ) { return ConVarSystem.GetAutoComplete( text, maxCount ); } /// /// Get all the root panels. /// public static HashSet GetRootPanels() => IPanel.GetAllRootPanels(); public static void SendToRecycleBin( string filename ) { NativeEngine.EngineGlobal.Plat_SafeRemoveFile( filename ); } /// /// Open a folder (or url) /// public static void OpenFolder( string path ) { System.Diagnostics.Process.Start( new System.Diagnostics.ProcessStartInfo() { FileName = path, UseShellExecute = true, Verb = "open", } ); } /// /// Open a folder (or url) /// public static void OpenFile( string path ) { try { System.Diagnostics.Process.Start( new System.Diagnostics.ProcessStartInfo() { FileName = path, UseShellExecute = true, Verb = "open", } ); } catch { // Show "Open With" dialog System.Diagnostics.Process.Start( new System.Diagnostics.ProcessStartInfo() { FileName = path, UseShellExecute = false, Verb = "openas", } ); } } /// /// Open given file's folder in OS file explorer and select given file. /// public static void OpenFileFolder( string filepath ) { filepath = System.IO.Path.GetFullPath( filepath ); System.Diagnostics.Process.Start( "explorer.exe", string.Format( "/select,\"{0}\"", filepath ) ); } private static string GetDestinationPath( string source, string directory ) { var fileName = System.IO.Path.GetFileName( source ); var destinationPath = System.IO.Path.Combine( directory, fileName ); return destinationPath; } /// /// Copies a file to a directory /// internal static void CopyFileToDirectory( string filePath, string directory, bool overwrite = true ) { var destinationPath = GetDestinationPath( filePath, directory ); if ( !System.IO.File.Exists( filePath ) ) throw new FileNotFoundException( $"File '{filePath}' doesn't exist" ); if ( !System.IO.Directory.Exists( directory ) ) throw new DirectoryNotFoundException( $"Directory '{directory}' doesn't exist" ); System.IO.File.Copy( filePath, destinationPath, overwrite ); } /// /// Moves an asset's source and compiled files to a directory (if they exist) /// public static void MoveAssetToDirectory( Asset asset, string directory, bool overwrite = true ) { var currentPath = System.IO.Path.GetDirectoryName( asset.AbsolutePath ); // fuck me c# - is this how we compare paths var dirA = System.IO.Path.GetFullPath( currentPath ).ToLower().Trim( '/', '\\' ); var dirB = System.IO.Path.GetFullPath( directory ).ToLower().Trim( '/', '\\' ); // already in this directory! if ( dirA == dirB ) return; CopyAssetToDirectory( asset, directory ); var absoluteSource = asset.GetSourceFile( true ); var absoluteCompiled = asset.GetCompiledFile( true ); if ( !string.IsNullOrEmpty( absoluteSource ) ) System.IO.File.Delete( absoluteSource ); if ( !string.IsNullOrEmpty( absoluteCompiled ) ) System.IO.File.Delete( absoluteCompiled ); } public static void RenameDirectory( string directory, string newDirectory, bool recursive = false ) { if ( !System.IO.Directory.Exists( directory ) ) return; // don't allow moving a folder inside a subfolder of itself! if ( newDirectory.StartsWith( directory, StringComparison.OrdinalIgnoreCase ) ) return; if ( !System.IO.Directory.Exists( newDirectory ) ) System.IO.Directory.CreateDirectory( newDirectory ); var assets = AssetSystem.All.Where( x => x.AbsolutePath.StartsWith( directory.Replace( '\\', '/' ), StringComparison.OrdinalIgnoreCase ) && x.AbsolutePath.Substring( directory.Length ).Contains( '/' ) ); foreach ( var asset in assets ) { if ( !System.IO.Path.Exists( asset.AbsolutePath ) ) continue; MoveAssetToDirectory( asset, newDirectory ); } var files = System.IO.Directory.GetFiles( directory, "*", System.IO.SearchOption.TopDirectoryOnly ); foreach ( var file in files ) { var newFile = file.Replace( directory, newDirectory ); System.IO.File.Move( file, newFile ); } var folders = System.IO.Directory.GetDirectories( directory, "*", System.IO.SearchOption.TopDirectoryOnly ); foreach ( var folder in folders ) { RenameDirectory( folder, folder.Replace( directory, newDirectory ), true ); } if ( !recursive ) System.IO.Directory.Delete( directory, true ); } /// /// Moves a file to the same directory but gives it a new name /// /// /// public static bool RenameAsset( Asset asset, string newName ) { if ( string.IsNullOrEmpty( newName ) ) return false; newName = newName.Trim().GetFilenameSafe(); if ( string.IsNullOrEmpty( newName ) ) return false; var compiledPath = asset.GetCompiledFile( true ); var newCompiledPath = compiledPath.Replace( asset.Name, newName ); var sourcePath = asset.GetSourceFile( true ); var newSourcePath = sourcePath.Replace( asset.Name, newName ); if ( string.Equals( asset.Name, newName, StringComparison.OrdinalIgnoreCase ) ) { // we've just changed the capitalisation // nothing's really changed for us as our asset system is case insensitive, so just do OS move } else { if ( System.IO.File.Exists( newSourcePath ) ) { Log.Error( $"Cannot rename asset, '{asset.Name}' already exists!" ); return false; } // if there's a compiled asset of this name already, but NOT a source file, just bin it (?) if ( System.IO.File.Exists( newCompiledPath ) ) { System.IO.File.Delete( newCompiledPath ); } // moving the asset will register another, so let's delete the old one asset.IsDeleted = true; } if ( !string.IsNullOrEmpty( compiledPath ) ) System.IO.File.Move( compiledPath, newCompiledPath ); if ( !string.IsNullOrEmpty( sourcePath ) ) System.IO.File.Move( sourcePath, newSourcePath ); return true; } /// /// Copies an asset's source and compiled files to a directory (if they exist) /// public static void CopyAssetToDirectory( Asset asset, string directory, bool overwrite = true ) { var absoluteSource = asset.GetSourceFile( true ); var absoluteCompiled = asset.GetCompiledFile( true ); if ( !string.IsNullOrEmpty( absoluteCompiled ) && System.IO.Path.Exists( absoluteCompiled ) ) CopyFileToDirectory( absoluteCompiled, directory, overwrite ); if ( !string.IsNullOrEmpty( absoluteSource ) && System.IO.Path.Exists( absoluteSource ) ) CopyFileToDirectory( absoluteSource, directory, overwrite ); } public static Task PutAsync( Stream fileStream, string endpoint, Sandbox.Utility.DataProgress.Callback progress = null, CancellationToken token = default ) { try { return Sandbox.Utility.Web.PutAsync( fileStream, endpoint, token, progress ); } catch ( System.Exception e ) { Log.Warning( e, $"PutAsync failed: {e.Message}" ); return Task.FromResult( false ); } } public static Task DownloadAsync( string url, string targetfile, Sandbox.Utility.DataProgress.Callback progress = null, CancellationToken token = default ) { try { return Sandbox.Utility.Web.DownloadFile( url, targetfile, token, progress ); } catch ( System.Exception e ) { Log.Warning( e, $"DownloadAsync failed: {e.Message}" ); return Task.FromResult( false ); } } /// /// People are lazy and will be calling this all the time if we let them. Lets keep it hidden for as long as possible /// internal static void RedrawActiveWindow() { Native.QApp.redrawActiveWindow(); } public static SceneWorld CreateSceneWorld() { return new SceneWorld() { IsTransient = false }; } static SoundHandle lastSound; /// /// Stop a sound playing from an asset preview /// public static void StopAssetSound() { lastSound?.Stop(); lastSound = null; } /// /// Plays an asset sound in 2d space /// public static bool PlayAssetSound( Asset asset ) { StopAssetSound(); if ( asset == null ) return false; var compiledFile = asset.GetCompiledFile( false ); if ( string.IsNullOrEmpty( compiledFile ) ) { asset.Compile( false ); return false; } if ( asset.AssetType == AssetType.SoundFile ) { var foundFile = SoundFile.Load( compiledFile ); if ( foundFile is null ) return false; return PlayAssetSound( foundFile ); } else if ( asset.AssetType == AssetType.SoundEvent ) { var foundFile = SoundEvent.Load( compiledFile ); if ( foundFile is null ) return false; return PlayAssetSound( foundFile ); } return false; } /// /// Plays an asset sound in 2d space /// public static bool PlayAssetSound( SoundEvent file ) { StopAssetSound(); if ( file is null ) return false; lastSound = Sound.Play( file ); if ( lastSound.IsValid() ) { lastSound.ListenLocal = true; lastSound.Position = Vector3.Forward * 64.0f; } return lastSound.IsValid(); } /// /// Plays an asset sound in 2d space /// public static bool PlayAssetSound( SoundFile file ) { StopAssetSound(); if ( file is null ) return false; lastSound = Sound.PlayFile( file, 0.5f, 1.0f ); if ( lastSound.IsValid() ) { lastSound.ListenLocal = true; lastSound.Position = Vector3.Forward * 64.0f; } return lastSound.IsValid(); } /// /// Plays a sound event /// public static SoundHandle PlaySound( string sound, float startTime = 0.0f ) { var foundFile = SoundFile.Load( sound ); if ( foundFile is null ) return null; var s = Sound.PlayFile( foundFile ); s.Time = startTime; s.ListenLocal = true; s.Position = Vector3.Forward * 64.0f; return s; } /// /// Plays a sound via the OS, which is the way you play a sound if you /// want it to be heard when the game is tabbed away /// public static bool PlayRawSound( string file ) { var fullPath = EngineFileSystem.Assets.GetFullPath( file ); if ( fullPath == null ) fullPath = EngineFileSystem.CoreContent.GetFullPath( file ); if ( fullPath == null ) return false; g_pSoundSystem.PlaySoundAtOSLevel( fullPath ); return true; } /// /// Delete the cached package info. This will cause any future requests to get fresh information /// from the backend. This is useful if you just updated something and want to see the changes. /// public static void ClearPackageCache() { Package.ClearCache(); } /// /// Create an unlimited web surface /// public static WebSurface CreateWebSurface() { return new WebSurface( false ); } /// /// Get a serialized object for this object. Because you're in the editor, this is an /// unrestricted object, we aren't whitelisting or using TypeLibrary. /// public static SerializedObject GetSerializedObject( object obj ) { return new ReflectionSerializedObject( obj ); } /// /// Create a video writer /// public static VideoWriter CreateVideoWriter( string path, VideoWriter.Config config ) { return new VideoWriter( path, config ); } static bool disabledStreaming = false; /// /// Force textures to load fully when loading a model etc.. /// public static IDisposable DisableTextureStreaming() { bool prev = disabledStreaming; disabledStreaming = true; g_pRenderDevice.SetForcePreloadStreamingData( true ); return new DisposeAction( () => { disabledStreaming = prev; g_pRenderDevice.SetForcePreloadStreamingData( prev ); } ); } /// /// Quit the whole engine /// /// Open the launcher on exit, if it's not already open. public static void Quit( bool toLauncher = false ) { Sandbox.Application.Exit(); if ( toLauncher ) { ProcessStartInfo info = new ProcessStartInfo( "sbox-launcher.exe" ); info.WorkingDirectory = System.Environment.CurrentDirectory; Process.Start( info ); } } /// /// Used for shadergraph /// public static bool IsVulkan => g_pRenderDevice.GetRenderDeviceAPI() == NativeEngine.RenderDeviceAPI_t.RENDER_DEVICE_API_VULKAN; /// /// True if we're currently recording a video (using the video command, or F6) /// public static bool IsRecordingVideo => ScreenRecorder.IsRecording(); /// /// Display a modal dialog message. This is a blocking call. /// public static void DisplayDialog( string title, string message, string okay = "Okay", string icon = "⚠️", Widget parent = null ) { var popup = new PopupDialogWidget( icon ); popup.WindowTitle = title; popup.MessageLabel.Text = message; popup.ButtonLayout.AddStretchCell(); popup.ButtonLayout.Add( new Button.Primary( okay ) { Clicked = () => popup.Destroy() } ); popup.SetModal( true, true ); popup.Hide(); popup.Show(); // todo - find window for parent and center to window } /// /// Display a modal dialog message. This is a blocking call. /// public static void DisplayDialog( string title, string message, string noLabel, string yesLabel, Action action, string icon = "❓", Widget parent = null ) { var popup = new PopupDialogWidget( icon ); popup.WindowTitle = title; popup.MessageLabel.Text = message; popup.ButtonLayout.AddStretchCell(); popup.ButtonLayout.Add( new Button( noLabel ) { Clicked = () => { popup.Destroy(); } } ); popup.ButtonLayout.Add( new Button.Primary( yesLabel ) { Clicked = () => { popup.Destroy(); action(); } } ); popup.SetModal( true, true ); popup.Hide(); popup.Show(); } /// /// Show a popup control sheet for this. You should set parent to the control from this this sheet is created. /// If you do that properly, when that control is deleted, this popup will get deleted too. If you set it to null /// then the control sheet will stay open until it's closed. /// public static Widget OpenControlSheet( SerializedObject so, Widget parent, bool createWindow = true ) { var originalType = so.Targets.FirstOrDefault()?.GetType() ?? null; if ( originalType is null ) return null; // Store a list of types to check (including original and base types) var typesToCheck = new List(); var currentType = originalType; // Build list of types to check while maintaining reference to original type while ( currentType is not null ) { typesToCheck.Add( currentType ); currentType = currentType.BaseType; } foreach ( var typeToCheck in typesToCheck ) { // Make IPopupEditor with the original target type var genericType = typeof( IPopupEditor<> ).MakeGenericType( typeToCheck ); bool IsSpecificEditor( TypeDescription widgetType ) { var interfaces = widgetType.Interfaces .Where( x => x.IsGenericType ) .ToList(); // // This is somehow necessary because GetInterfaces() lists out System.Object, and TextureGenerator for TextureGeneratorPopup // var mostSpecificInterface = interfaces.FirstOrDefault( x => x.GetGenericTypeDefinition() != typeof( IPopupEditor<> ) && x.GetGenericArguments().Any( arg => arg != typeof( object ) && arg.IsAssignableTo( typeToCheck ) ) ); var isSpecific = mostSpecificInterface != null; return isSpecific; } // Find any Widgets that implement it var allWidgets = EditorTypeLibrary.GetTypes() .Where( x => x.Interfaces.Contains( genericType ) ) .ToList(); var list = allWidgets .OrderByDescending( x => IsSpecificEditor( x ) ) .ThenBy( x => x.Name ) .ToList(); var found = list.FirstOrDefault(); if ( found is not null ) { try { var editor = found.Create( [so, parent] ); if ( createWindow ) { var window = new PopupEditorWindow( parent ); window.Layout.Add( editor ); window.WindowTitle = $"Editing {so.TypeTitle}"; window.SetWindowIcon( so.TypeIcon ?? "edit_note" ); if ( so.ParentProperty is not null ) { window.WindowTitle = $"Editing {so.ParentProperty.DisplayName} ({so.TypeTitle})"; } window.ShowWindowAtCursor(); } return editor; } catch ( System.Exception e ) { Log.Error( e, $"Exception when creating {found.FullName}( SerializedObject target, Widget parent )" ); } } } Log.Warning( $"OpenControlSheet - No editor found for {originalType.Name}" ); return default; } /// /// Gets every search path seperated by ; /// public static string GetSearchPaths() { return EngineGlobal.GetGameSearchPath(); } public static IEnumerable FontFamilies => FontManager.FontFamilies; /// /// Access to the client's render settings /// public static RenderSettings RenderSettings => Sandbox.Engine.Settings.RenderSettings.Instance; /// /// Some assets are kv3, we want to convert them to json /// public static string KeyValues3ToJson( string kvString ) { var kv = EngineGlue.LoadKeyValues3( kvString ); if ( kv.IsNull ) return null; var json = EngineGlue.KeyValues3ToJson( kv ); kv.DeleteThis(); return json; } /// /// Some old ass assets are keyvalues (1). Convert them to Json so we can use them. /// public static string KeyValues1ToJson( string kvString ) { return EngineGlue.KeyValuesToJson( kvString ); } public static Pixmap GetFileThumbnail( string filePath, int width, int height ) { QFileInfo fileInfo = QFileInfo.Create( filePath ); return Pixmap.FromNative( fileInfo.GetIcon( width, height ) ); } /// /// Restarts the editor with the same project. /// public static void RestartEditor() { EditorWindow.Close(); ProcessStartInfo info = new ProcessStartInfo( "sbox-dev.exe", $"{Environment.CommandLine} -project \"{Project.Current.ConfigFilePath}\"" ); info.UseShellExecute = true; info.CreateNoWindow = true; info.WorkingDirectory = System.Environment.CurrentDirectory; Process.Start( info ); } /// /// Open a dialog prompt asking the user to restart the editor. /// /// /// public static void RestartEditorPrompt( string message, string title = "Restart Editor" ) { Dialog.AskConfirm( RestartEditor, message, title, "Restart" ); } /// /// Checks if a given folder is a code folder, e.g. [project root]/Code /// /// /// public static bool IsCodeFolder( string fullPath ) { if ( fullPath == null ) throw new ArgumentNullException( nameof( fullPath ) ); if ( fullPath.Length == 0 ) return false; var normalizedPath = Path.GetFullPath( fullPath ).TrimEnd( Path.DirectorySeparatorChar ); foreach ( var project in Project.All ) { var projectCodePath = project.GetCodePath(); if ( string.IsNullOrEmpty( projectCodePath ) ) continue; var normalizedProjectPath = Path.GetFullPath( projectCodePath ).TrimEnd( Path.DirectorySeparatorChar ); if ( normalizedPath.Equals( normalizedProjectPath, StringComparison.OrdinalIgnoreCase ) || normalizedPath.StartsWith( normalizedProjectPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase ) ) { return true; } } return false; } /// /// Checks if a given file is a code file /// /// /// public static bool IsCodeFile( string fullPath ) { return Path.GetExtension( fullPath ).Equals( ".cs", StringComparison.OrdinalIgnoreCase ) || Path.GetExtension( fullPath ).Equals( ".scss", StringComparison.OrdinalIgnoreCase ) || Path.GetExtension( fullPath ).Equals( ".razor", StringComparison.OrdinalIgnoreCase ); } /// /// Gets the source location for the given scene, used by action graph stack traces, /// and so the action graph editor knows which asset to save when editing a graph. /// public static ISourceLocation GetSourceLocation( this Scene scene ) { return scene.OverrideSourceLocation ?? scene.Source?.SerializationOptions.SourceLocation; } /// /// Tries to find a project based on a given directory. /// public static Project FindProjectByDirectory( string fullPath ) { if ( fullPath == null ) throw new ArgumentNullException( nameof( fullPath ) ); if ( fullPath.Length == 0 ) return null; var normalizedPath = Path.GetFullPath( fullPath ).TrimEnd( Path.DirectorySeparatorChar ); foreach ( var project in Project.All.OrderByDescending( x => x.GetRootPath().Length ) ) { var projectCodePath = project.GetRootPath(); if ( string.IsNullOrEmpty( projectCodePath ) ) continue; var normalizedProjectPath = Path.GetFullPath( projectCodePath ).TrimEnd( Path.DirectorySeparatorChar ); if ( normalizedPath.Equals( normalizedProjectPath, StringComparison.OrdinalIgnoreCase ) || normalizedPath.StartsWith( normalizedProjectPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase ) ) { return project; } } return null; } /// /// Compile a fucking shader. Takes a .shader file and compiles it. /// public static async Task CompileShader( string localPath, ShaderCompileOptions options, CancellationToken token = default ) { if ( string.IsNullOrWhiteSpace( localPath ) ) throw new ArgumentNullException( nameof( localPath ) ); var path = FileSystem.Content.GetFullPath( localPath ); var result = await ShaderCompile.Compile( path, localPath, options, token ); if ( result.Success ) { ConsoleSystem.Run( $"mat_reloadshaders {localPath}" ); } return result; } // TODO: Just use one function and check if it's a full path already public static async Task CompileShader( BaseFileSystem fs, string localPath, ShaderCompileOptions options, CancellationToken token = default ) { if ( string.IsNullOrWhiteSpace( localPath ) ) throw new ArgumentNullException( nameof( localPath ) ); var path = fs.GetFullPath( localPath ); return await ShaderCompile.Compile( path, localPath, options, token ); } [EditorEvent.Hotload] private static void ClearReflectionQueryCache() { ReflectionQueryCache.ClearTypeCache(); } public static Asset GetAssetFromProject( Project project ) { return project.ProjectSourceObject as Asset; } /// /// Create a TypeLibrary from a collection of assemblies /// /// /// internal static TypeLibrary CreateTypeLibrary( CompilerOutput[] assemblies ) { var library = new TypeLibrary(); var packageLoader = new Sandbox.PackageLoader( "EditorTypeLibrary", typeof( GameInstanceDll ).Assembly ); using var enroller = packageLoader.CreateEnroller( "EditorTypeLibrary" ); enroller.OnAssemblyAdded = ( a ) => { library.AddAssembly( a.Assembly, true ); }; foreach ( var assm in assemblies ) { var ms = new MemoryStream( assm.AssemblyData ); enroller.LoadAssemblyFromStream( assm.Compiler.AssemblyName, ms ); } return library; } /// /// Finds a component in the scene and selects it in the editor /// /// public static void FindInScene( Component component ) { if ( !component.IsValid() ) return; var go = component.GameObject; FindInScene( go ); } /// /// Finds a GameObject in the scene and selects it in the editor /// /// public static void FindInScene( GameObject go ) { if ( !go.IsValid() ) return; using ( SceneEditorSession.Active.UndoScope( $"Selected {go}" ).Push() ) { SceneEditorSession.Active?.Selection.Set( go ); } } } /// /// This is created using EditorUtility.OpenControlSheet /// class PopupEditorWindow : Widget { public PopupEditorWindow( Widget parent ) : base( parent ) { Size = new Vector2( 500, 500 ); DeleteOnClose = true; Layout = Layout.Column(); WindowFlags = WindowFlags.Tool | WindowFlags.Customized | WindowFlags.WindowSystemMenuHint | WindowFlags.CloseButton; Width = 400; ShowWindowAtCursor(); } public virtual void ShowWindowAtCursor() { Show(); Position = Application.CursorPosition - new Vector2( Width * 0.5f, 3 ); UpdateGeometry(); ConstrainToScreen(); Focus(); } protected override void OnKeyPress( KeyEvent e ) { if ( e.Key == KeyCode.Escape ) { Close(); e.Accepted = true; return; } base.OnKeyPress( e ); } }