Editor/Launcher/HotCodeLauncher.cs
using Sandbox;
using Editor;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
/// <summary>
/// HotCode's start window:
/// <code>
/// ┌ sidebar ──────┬ main ─────────────────────────────────┐
/// │ HotCode │ Projects [search...] │
/// │ New Project │ ───────────────────────────────────── │
/// │ Open Project │ Pinned / Recent / Loaded in s&box │
/// │ Open Solution │ [card] [card] ... │
/// └───────────────┴───────────────────────────────────────┘
/// </code>
/// </summary>
[EditorApp( "HotCode", HotCodeLogo.IconName, "A code editor built right into s&box" )]
public class HotCodeLauncher : Window
{
const string LastLocationCookie = "hotcode.newproject.location";
private readonly Widget _main;
private readonly SidebarButton _newButton;
private string _search = "";
public HotCodeLauncher()
{
WindowTitle = "HotCode";
SetWindowIcon( HotCodeLogo.WindowIcon );
Size = new Vector2( 980, 620 );
MinimumSize = new Vector2( 700, 420 );
Canvas = new Widget( this );
Canvas.Layout = Layout.Row();
// --- Sidebar ---
var sidebar = Canvas.Layout.Add( new Widget( Canvas ) );
sidebar.FixedWidth = 230;
sidebar.OnPaintOverride = () =>
{
Paint.ClearPen();
Paint.SetBrush( Theme.SidebarBackground );
Paint.DrawRect( sidebar.LocalRect );
return true;
};
sidebar.Layout = Layout.Column();
sidebar.Layout.Margin = 12;
sidebar.Layout.Spacing = 4;
sidebar.Layout.Add( new LauncherLogo( sidebar ) );
sidebar.Layout.AddSpacingCell( 12 );
sidebar.Layout.AddSeparator( true );
sidebar.Layout.AddSpacingCell( 8 );
var projects = sidebar.Layout.Add( new SidebarButton( sidebar, "dashboard", "Projects", "Recent and loaded projects" ) { Selected = true } );
_newButton = sidebar.Layout.Add( new SidebarButton( sidebar, "add_circle_outline", "New Project", "Create a game or library" ) );
var openProject = sidebar.Layout.Add( new SidebarButton( sidebar, "folder_open", "Open Project...", "Pick an .sbproj file" ) );
var openSolution = sidebar.Layout.Add( new SidebarButton( sidebar, "account_tree", "Open Solution...", "Pick a .sln or .slnx file" ) );
projects.Clicked = () => { projects.Selected = true; _newButton.Selected = false; ShowProjects(); };
_newButton.Clicked = () => { projects.Selected = false; _newButton.Selected = true; ShowNewProject(); };
openProject.Clicked = HandleOpenProjectDialog;
openSolution.Clicked = HandleOpenSolutionDialog;
sidebar.Layout.AddStretchCell();
var settings = sidebar.Layout.Add( new SidebarButton( sidebar, "settings", "Settings", "Editor, code help, files" ) );
settings.Clicked = HotCodeSettingsWindow.Open;
sidebar.Layout.AddSpacingCell( 8 );
var hint = sidebar.Layout.Add( new Label( "Each project opens in its own HotCode window.", sidebar ) );
hint.WordWrap = true;
hint.SetStyles( $"color: {Theme.TextLight.Hex}; font-size: 11px;" );
Canvas.Layout.AddSeparator( 1, Theme.Border );
// --- Main area ---
_main = Canvas.Layout.Add( new Widget( Canvas ), 1 );
_main.OnPaintOverride = () =>
{
Paint.ClearPen();
Paint.SetBrush( Theme.TabBackground );
Paint.DrawRect( _main.LocalRect );
return true;
};
_main.Layout = Layout.Column();
ShowProjects();
Show();
}
[Menu( "Editor", "HotCode/Launcher", HotCodeLogo.IconName )]
public static void OpenFromEditorMenu() => new HotCodeLauncher();
// --- PROJECTS ---
private void ShowProjects()
{
_main.Layout.Clear( true );
// Header: "Projects" [search]
var header = _main.Layout.AddRow();
header.Margin = new Sandbox.UI.Margin( 20, 16, 20, 12 );
header.Spacing = 8;
header.Add( new Label.Title( "Projects", _main ) );
header.AddStretchCell();
var search = header.Add( new LineEdit( _main ) { PlaceholderText = "Search projects...", Text = _search, FixedWidth = 260 } );
_main.Layout.AddSeparator( 1, Theme.Border );
var scroll = _main.Layout.Add( new ScrollArea( _main ), 1 );
scroll.Canvas = new Widget( scroll );
scroll.Canvas.Layout = Layout.Column();
scroll.Canvas.Layout.Margin = new Sandbox.UI.Margin( 20, 12, 20, 20 );
scroll.Canvas.Layout.Spacing = 6;
var list = scroll.Canvas;
search.TextEdited += text =>
{
_search = text;
FillProjectList( list );
};
FillProjectList( list );
search.Focus( false );
}
private void FillProjectList( Widget list )
{
list.Layout.Clear( true );
var loadedPaths = LoadedProjectPaths();
bool IsLoaded( HotCodeEntry e ) => loadedPaths.Contains( RecentEntries.Normalize( e.Path ) );
bool Matches( HotCodeEntry e ) => _search.Length == 0
|| e.Title.Contains( _search, StringComparison.OrdinalIgnoreCase )
|| e.Path.Contains( _search, StringComparison.OrdinalIgnoreCase );
var recents = RecentEntries.GetAll().ToList();
var recentPaths = recents.Select( x => RecentEntries.Normalize( x.Path ) ).ToHashSet();
var pinned = recents.Where( e => e.Pinned && Matches( e ) ).ToList();
var recent = recents.Where( e => !e.Pinned && Matches( e ) ).ToList();
// Projects s&box has loaded right now that aren't remembered yet
var loaded = GetLoadedProjects().Where( e => !recentPaths.Contains( RecentEntries.Normalize( e.Path ) ) && Matches( e ) ).ToList();
AddSection( list, "Pinned", "star", pinned, IsLoaded );
AddSection( list, "Recent", "history", recent, IsLoaded );
AddSection( list, "Loaded in s&box", "bolt", loaded, IsLoaded );
if ( pinned.Count + recent.Count + loaded.Count == 0 )
{
var empty = list.Layout.Add( new Label( _search.Length > 0 ? $"No projects match \"{_search}\"." : "No projects yet. Open or create one from the left.", list ) );
empty.SetStyles( $"color: {Theme.TextLight.Hex}; padding: 20px 0px;" );
}
list.Layout.AddStretchCell();
}
private void AddSection( Widget list, string title, string icon, List<HotCodeEntry> entries, Func<HotCodeEntry, bool> isLoaded )
{
if ( entries.Count == 0 ) return;
var header = list.Layout.Add( new SectionHeader( list, title, icon, entries.Count ) );
header.FixedHeight = 30;
foreach ( var entry in entries )
{
var hasHotCode = HotCodeInstallUi.StateOf( entry ) == HotCodeInstallUi.State.Installed;
var card = list.Layout.Add( new ProjectCard( list, entry, isLoaded( entry ), hasHotCode ) );
card.Opened = () => OpenEntry( entry );
card.PinToggled = pinned =>
{
RecentEntries.SetPinned( entry.Path, pinned );
FillProjectList( list );
};
card.ContextMenuRequested = () => ShowCardMenu( list, entry );
}
list.Layout.AddSpacingCell( 10 );
}
private void ShowCardMenu( Widget list, HotCodeEntry entry )
{
var menu = new ContextMenu( this );
menu.AddOption( "Open", "open_in_new", () => OpenEntry( entry ) );
menu.AddOption( entry.Pinned ? "Unpin" : "Pin to Top", entry.Pinned ? "star_border" : "star", () =>
{
RecentEntries.SetPinned( entry.Path, !entry.Pinned );
FillProjectList( list );
} );
menu.AddSeparator();
HotCodeInstallUi.AddMenuOptions( menu, entry, () => FillProjectList( list ) );
menu.AddSeparator();
menu.AddOption( "Reveal in File Explorer", "folder_open", () => EditorUtility.OpenFileFolder( entry.Path ) );
menu.AddOption( "Copy Path", "content_copy", () => EditorUtility.Clipboard.Copy( entry.Path ) );
if ( entry.LastOpened is not null )
{
menu.AddSeparator();
menu.AddOption( "Remove from List", "close", () =>
{
RecentEntries.Remove( entry.Path );
FillProjectList( list );
} );
}
menu.OpenAtCursor();
}
private static HashSet<string> LoadedProjectPaths() => EditorUtility.Projects.GetAll()
.Where( p => p.ConfigFilePath is not null )
.Select( p => RecentEntries.Normalize( p.ConfigFilePath ) )
.ToHashSet();
private static IEnumerable<HotCodeEntry> GetLoadedProjects()
{
return EditorUtility.Projects.GetAll()
.Where( p => !p.IsBuiltIn && p.ConfigFilePath is not null && p.ConfigFilePath.EndsWith( ".sbproj", StringComparison.OrdinalIgnoreCase ) && File.Exists( p.ConfigFilePath ) )
.OrderByDescending( p => p == Project.Current )
.ThenBy( p => p.Config.Title )
.Select( p => HotCodeEntry.FromPath( p.ConfigFilePath ) );
}
// --- NEW PROJECT ---
private void ShowNewProject()
{
_main.Layout.Clear( true );
var header = _main.Layout.AddRow();
header.Margin = new Sandbox.UI.Margin( 20, 16, 20, 12 );
header.Add( new Label.Title( "New Project", _main ) );
header.AddStretchCell();
_main.Layout.AddSeparator( 1, Theme.Border );
var form = _main.Layout.AddColumn();
form.Margin = 20;
form.Spacing = 6;
// Kind of project, as tiles
form.Add( new Label.Subtitle( "Type", _main ) );
var type = "game";
var tiles = form.AddRow();
tiles.Spacing = 10;
var game = tiles.Add( new ChoiceTile( _main, "sports_esports", "Game", "Something people play", Theme.Green ) { Selected = true }, 1 );
var library = tiles.Add( new ChoiceTile( _main, "local_library", "Library", "Code and assets for other projects", Theme.Blue ), 1 );
game.Clicked = () => { type = "game"; game.Selected = true; library.Selected = false; game.Update(); library.Update(); };
library.Clicked = () => { type = "library"; game.Selected = false; library.Selected = true; game.Update(); library.Update(); };
form.AddSpacingCell( 12 );
form.Add( new Label.Subtitle( "Name", _main ) );
var nameInput = form.Add( new LineEdit( _main ) { Text = "MyProject" } );
form.AddSpacingCell( 8 );
form.Add( new Label.Subtitle( "Location", _main ) );
Label preview = null;
var locationRow = form.AddRow();
locationRow.Spacing = 4;
var locationInput = locationRow.Add( new LineEdit( _main ) { Text = GetDefaultLocation() }, 1 );
locationRow.Add( new Button( "Browse...", "folder", _main )
{
Clicked = () =>
{
var folder = PickFolder( "Choose Project Location", locationInput.Text );
if ( folder is not null )
{
locationInput.Text = folder;
UpdatePreview();
}
}
} );
preview = form.Add( new Label( "", _main ) );
preview.SetStyles( $"color: {Theme.TextLight.Hex};" );
void UpdatePreview() => preview.Text = $"Creates {Path.Combine( locationInput.Text.Trim(), nameInput.Text.Trim() )}";
nameInput.TextEdited += _ => UpdatePreview();
locationInput.TextEdited += _ => UpdatePreview();
UpdatePreview();
var error = form.Add( new Label( "", _main ) );
error.SetStyles( $"color: {Theme.Red.Hex};" );
error.Visible = false;
form.AddStretchCell();
_main.Layout.AddSeparator( 1, Theme.Border );
// Footer
var footer = _main.Layout.AddRow();
footer.Margin = new Sandbox.UI.Margin( 20, 10, 20, 12 );
footer.Spacing = 8;
footer.AddStretchCell();
footer.Add( new Button( "Cancel", "close", _main ) { Clicked = () => { _newButton.Selected = false; ShowProjects(); } } );
footer.Add( new Button.Primary( "Create Project", "done", _main )
{
Clicked = () =>
{
try
{
var sbproj = ProjectCreator.Create( nameInput.Text, locationInput.Text, type );
EditorCookie.SetString( LastLocationCookie, locationInput.Text.Trim() );
Log.Info( $"[HotCode] Created project at {sbproj}" );
_newButton.Selected = false;
OpenEntry( HotCodeEntry.FromPath( sbproj ) );
}
catch ( Exception e ) when ( e is InvalidOperationException or IOException or UnauthorizedAccessException )
{
error.Text = e.Message;
error.Visible = true;
}
}
} );
nameInput.Focus( false );
nameInput.SelectAll();
}
private static string GetDefaultLocation()
{
var last = EditorCookie.GetString( LastLocationCookie, null );
if ( !string.IsNullOrEmpty( last ) && Directory.Exists( last ) ) return last;
// Default to the folder the current project lives in
var root = Project.Current?.GetRootPath();
return root is null ? "" : Path.GetDirectoryName( root.TrimEnd( '/', '\\' ) ) ?? "";
}
// --- DIALOGS ---
private void HandleOpenProjectDialog()
{
var file = PickFile( "Open s&box Project", "s&box Project (*.sbproj)" );
if ( file is not null ) OpenEntry( HotCodeEntry.FromPath( file ) );
}
private void HandleOpenSolutionDialog()
{
var file = PickFile( "Open Solution", "Solution (*.sln *.slnx)" );
if ( file is not null ) OpenEntry( HotCodeEntry.FromPath( file ) );
}
private static string PickFile( string title, string filter )
{
var fd = new FileDialog( null );
fd.Title = title;
fd.Directory = GetDefaultLocation();
fd.SetFindExistingFile();
fd.SetModeOpen();
fd.SetNameFilter( filter );
return fd.Execute() ? fd.SelectedFile : null;
}
private static string PickFolder( string title, string startIn )
{
var fd = new FileDialog( null );
fd.Title = title;
fd.Directory = Directory.Exists( startIn ) ? startIn : GetDefaultLocation();
fd.SetFindDirectory();
return fd.Execute() ? fd.SelectedFile : null;
}
// --- OPENING ---
private void OpenEntry( HotCodeEntry entry )
{
RecentEntries.Touch( entry.Path );
Log.Info( $"[HotCode] Opening {entry.Kind.ToString().ToLowerInvariant()} '{entry.Title}' ({entry.Path})" );
new HotCodeEditor( entry );
ShowProjects();
}
/// <summary>
/// The HotCode name and tagline at the top of the sidebar.
/// </summary>
private class LauncherLogo : Widget
{
public LauncherLogo( Widget parent ) : base( parent )
{
FixedHeight = 48;
}
protected override void OnPaint()
{
HotCodeLogo.PaintBadge( new Rect( 2, 4, 40, 40 ) );
Paint.SetHeadingFont( 13, 700 );
Paint.SetPen( Theme.Text );
Paint.DrawText( new Rect( 52, 4, Width - 52, 24 ), "HotCode", TextFlag.LeftCenter );
Paint.SetDefaultFont( 7 );
Paint.SetPen( Theme.TextLight );
Paint.DrawText( new Rect( 52, 26, Width - 52, 18 ), "Code editor for s&box", TextFlag.LeftCenter );
}
}
/// <summary>
/// "★ PINNED 2" with a line to the right.
/// </summary>
private class SectionHeader : Widget
{
private readonly string _title;
private readonly string _icon;
private readonly int _count;
public SectionHeader( Widget parent, string title, string icon, int count ) : base( parent )
{
_title = title;
_icon = icon;
_count = count;
}
protected override void OnPaint()
{
Paint.SetPen( Theme.TextLight );
Paint.DrawIcon( new Rect( 0, 0, 16, Height ), _icon, 14, TextFlag.LeftCenter );
Paint.SetDefaultFont( 7, 700 );
var label = $"{_title.ToUpperInvariant()} {_count}";
var width = Paint.MeasureText( label ).x;
Paint.DrawText( new Rect( 22, 0, width + 4, Height ), label, TextFlag.LeftCenter );
Paint.SetPen( Theme.Border );
Paint.DrawLine( new Vector2( 34 + width, Height / 2 ), new Vector2( Width, Height / 2 ) );
}
}
}