Editor/TreeAssetBrowser.cs
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace GeneralGame.Editor;
/// <summary>
/// Custom Asset Browser displayed as a tree view of files and folders.
/// Supports multiple side-by-side panels for comparing/browsing different locations.
/// Supports different panel types: Tree View and Icon Grid.
/// Automatically switches to tab mode when window is narrow.
/// </summary>
[Dock("Editor", "Tree Asset Browser", "account_tree")]
public class TreeAssetBrowser : Widget
{
private List<Widget> _panels = new();
private List<Splitter> _splitters = new();
private Widget _panelsContainer;
private Widget _mainToolbar;
private Widget _tabsContainer;
private Label _titleLabel;
private IconButton _toggleModeBtn;
private int _activeTabIndex = 0;
private bool _isTabMode = false;
// Unique instance ID for this browser window (assigned from saved state or generated)
private string _instanceId;
private static int _nextInstanceId = 0;
private static HashSet<string> _usedInstanceIds = new();
// Cookie keys
private string BrowserStateCookieKey => $"TreeAssetBrowser.{_instanceId}.State";
private const string AllBrowserIdsCookieKey = "TreeAssetBrowser.AllInstanceIds";
// Selected asset callback (forwarded from panels)
public Action<Asset> OnAssetSelected;
public Action<string> OnFileSelected;
public Action<string> OnFolderSelected;
public TreeAssetBrowser(Widget parent) : base(parent)
{
// Try to claim an existing saved instance ID, or generate new one
_instanceId = ClaimOrCreateInstanceId();
_usedInstanceIds.Add(_instanceId);
WindowTitle = "Tree Asset Browser";
MinimumSize = new Vector2(250, 200);
SetSizeMode(SizeMode.CanGrow, SizeMode.CanGrow);
CreateUI();
// Try to restore saved state, or create default panel
if (!RestoreBrowserState())
{
AddTreePanel();
}
}
public override void OnDestroyed()
{
base.OnDestroyed();
SaveBrowserState();
_usedInstanceIds.Remove(_instanceId);
}
/// <summary>
/// Find an available saved instance ID or create a new one
/// </summary>
private string ClaimOrCreateInstanceId()
{
// Get list of saved instance IDs
var savedIds = ProjectCookie.Get<List<string>>(AllBrowserIdsCookieKey, new List<string>());
// Find first unused saved ID
foreach (var id in savedIds)
{
if (!_usedInstanceIds.Contains(id))
{
return id;
}
}
// Generate new unique ID
string newId;
do
{
newId = $"Browser_{_nextInstanceId++}";
} while (_usedInstanceIds.Contains(newId) || savedIds.Contains(newId));
return newId;
}
private void CreateUI()
{
Layout = Layout.Column();
Layout.Spacing = 2;
// Main toolbar with tabs, toggle and add buttons
_mainToolbar = Layout.Add(new Widget(this));
_mainToolbar.Layout = Layout.Row();
_mainToolbar.Layout.Spacing = 4;
_mainToolbar.Layout.Margin = 4;
_mainToolbar.FixedHeight = 28;
// Title label (hidden in tab mode)
_titleLabel = _mainToolbar.Layout.Add(new Label("Panels:", this));
_titleLabel.SetStyles("color: #888; font-size: 11px;");
// Tabs container (hidden by default, shown in tab mode)
_tabsContainer = _mainToolbar.Layout.Add(new Widget(this));
_tabsContainer.Layout = Layout.Row();
_tabsContainer.Layout.Spacing = 2;
_tabsContainer.SetSizeMode(SizeMode.CanGrow, SizeMode.Default);
_tabsContainer.Visible = false;
_mainToolbar.Layout.AddStretchCell();
// Toggle tab/side-by-side mode button
_toggleModeBtn = _mainToolbar.Layout.Add(new IconButton("tab"));
_toggleModeBtn.ToolTip = "Switch to Tab Mode";
_toggleModeBtn.Background = Color.Transparent;
_toggleModeBtn.OnClick = ToggleLayoutMode;
// Add panel button (with dropdown menu)
var addBtn = _mainToolbar.Layout.Add(new IconButton("add"));
addBtn.ToolTip = "Add Browser Panel";
addBtn.Background = Color.Transparent;
addBtn.OnClick = ShowAddPanelMenu;
// Panels container (horizontal layout with splitters)
_panelsContainer = Layout.Add(new Widget(this));
_panelsContainer.Layout = Layout.Row();
_panelsContainer.SetSizeMode(SizeMode.CanGrow, SizeMode.CanGrow);
}
/// <summary>
/// Save browser state (panels, tab mode, etc.) to cookie
/// </summary>
private void SaveBrowserState()
{
var state = new BrowserState
{
IsTabMode = _isTabMode,
ActiveTabIndex = _activeTabIndex,
PanelTypes = _panels.Select(GetPanelTypeName).ToList()
};
ProjectCookie.Set(BrowserStateCookieKey, state);
// Update global list of instance IDs
var savedIds = ProjectCookie.Get<List<string>>(AllBrowserIdsCookieKey, new List<string>());
if (!savedIds.Contains(_instanceId))
{
savedIds.Add(_instanceId);
ProjectCookie.Set(AllBrowserIdsCookieKey, savedIds);
}
}
/// <summary>
/// Restore browser state from cookie
/// </summary>
private bool RestoreBrowserState()
{
var state = ProjectCookie.Get<BrowserState>(BrowserStateCookieKey, null);
if (state == null || state.PanelTypes == null || state.PanelTypes.Count == 0)
return false;
// Recreate panels
foreach (var panelType in state.PanelTypes)
{
switch (panelType)
{
case "Tree":
AddTreePanel(saveState: false);
break;
case "IconGrid":
AddIconGridPanel(saveState: false);
break;
case "Cloud":
AddCloudAssetPanel(saveState: false);
break;
case "CloudIconGrid":
AddCloudIconGridPanel(saveState: false);
break;
}
}
// Restore tab mode
_activeTabIndex = Math.Clamp(state.ActiveTabIndex, 0, Math.Max(0, _panels.Count - 1));
if (state.IsTabMode && _panels.Count > 1)
{
SwitchToTabMode();
}
return _panels.Count > 0;
}
/// <summary>
/// Get type name for saving
/// </summary>
private string GetPanelTypeName(Widget panel)
{
return panel switch
{
AssetBrowserPanel => "Tree",
IconGridPanel => "IconGrid",
CloudAssetPanel => "Cloud",
CloudIconGridPanel => "CloudIconGrid",
_ => "Unknown"
};
}
/// <summary>
/// Show context menu to choose panel type
/// </summary>
private void ShowAddPanelMenu()
{
var menu = new ContextMenu(this);
menu.AddOption("Tree View", "account_tree", () => AddTreePanel());
menu.AddOption("Icon Grid", "grid_view", () => AddIconGridPanel());
menu.AddSeparator();
menu.AddOption("Cloud Assets", "cloud", () => AddCloudAssetPanel());
menu.OpenAtCursor();
}
/// <summary>
/// Add a new tree view panel
/// </summary>
public void AddTreePanel(bool saveState = true)
{
AddSplitterIfNeeded();
// Generate unique panel ID within this browser
var panelId = $"{_instanceId}_Panel{_panels.Count}";
var panel = new AssetBrowserPanel(_panelsContainer, panelId);
panel.OnCloseRequested = () => RemovePanel(panel);
panel.OnMoveLeftRequested = () => MovePanelLeft(panel);
panel.OnMoveRightRequested = () => MovePanelRight(panel);
// Forward callbacks
panel.OnAssetSelected = (asset) => OnAssetSelected?.Invoke(asset);
panel.OnFileSelected = (path) => OnFileSelected?.Invoke(path);
panel.OnFolderSelected = (path) => OnFolderSelected?.Invoke(path);
// Sync folder selection with all IconGridPanels
panel.OnFolderClicked = OnFolderSelectedInTree;
_panelsContainer.Layout.Add(panel);
_panels.Add(panel);
_activeTabIndex = _panels.Count - 1;
UpdatePanelButtons();
if (_isTabMode)
{
SelectTab(_activeTabIndex);
}
if (saveState)
{
SaveBrowserState();
}
}
/// <summary>
/// Add a new icon grid panel
/// </summary>
public void AddIconGridPanel(bool saveState = true)
{
AddSplitterIfNeeded();
var panel = new IconGridPanel(_panelsContainer);
panel.OnCloseRequested = () => RemovePanel(panel);
panel.OnMoveLeftRequested = () => MovePanelLeft(panel);
panel.OnMoveRightRequested = () => MovePanelRight(panel);
_panelsContainer.Layout.Add(panel);
_panels.Add(panel);
_activeTabIndex = _panels.Count - 1;
UpdatePanelButtons();
if (_isTabMode)
{
SelectTab(_activeTabIndex);
}
if (saveState)
{
SaveBrowserState();
}
}
/// <summary>
/// Add a new cloud asset search panel
/// </summary>
public void AddCloudAssetPanel(bool saveState = true)
{
AddSplitterIfNeeded();
var panel = new CloudAssetPanel(_panelsContainer);
panel.OnCloseRequested = () => RemovePanel(panel);
panel.OnMoveLeftRequested = () => MovePanelLeft(panel);
panel.OnMoveRightRequested = () => MovePanelRight(panel);
// Sync cloud asset selection with all CloudIconGridPanels
panel.OnCloudAssetsLoaded = OnCloudAssetsLoaded;
_panelsContainer.Layout.Add(panel);
_panels.Add(panel);
_activeTabIndex = _panels.Count - 1;
UpdatePanelButtons();
if (_isTabMode)
{
SelectTab(_activeTabIndex);
}
if (saveState)
{
SaveBrowserState();
}
}
/// <summary>
/// Add a new cloud icon grid panel for previewing cloud assets
/// </summary>
public void AddCloudIconGridPanel(bool saveState = true)
{
AddSplitterIfNeeded();
var panel = new CloudIconGridPanel(_panelsContainer);
panel.OnCloseRequested = () => RemovePanel(panel);
panel.OnMoveLeftRequested = () => MovePanelLeft(panel);
panel.OnMoveRightRequested = () => MovePanelRight(panel);
_panelsContainer.Layout.Add(panel);
_panels.Add(panel);
_activeTabIndex = _panels.Count - 1;
UpdatePanelButtons();
if (_isTabMode)
{
SelectTab(_activeTabIndex);
}
if (saveState)
{
SaveBrowserState();
}
}
/// <summary>
/// Add splitter before new panel if not the first one
/// </summary>
private void AddSplitterIfNeeded()
{
if (_panels.Count > 0)
{
var splitter = new Splitter(_panelsContainer);
splitter.IsVertical = true;
_panelsContainer.Layout.Add(splitter);
_splitters.Add(splitter);
}
}
/// <summary>
/// Called when folder is selected in any tree panel - syncs all IconGridPanels
/// </summary>
private void OnFolderSelectedInTree(string folderPath)
{
foreach (var panel in _panels.OfType<IconGridPanel>())
{
panel.ShowFolder(folderPath);
}
}
/// <summary>
/// Called when cloud assets are loaded in any CloudAssetPanel - syncs all grid panels
/// </summary>
private void OnCloudAssetsLoaded(List<Package> packages)
{
// Update CloudIconGridPanels
foreach (var panel in _panels.OfType<CloudIconGridPanel>())
{
panel.ShowPackages(packages);
}
// Update regular IconGridPanels
foreach (var panel in _panels.OfType<IconGridPanel>())
{
panel.ShowCloudPackages(packages, "Cloud Assets");
}
}
/// <summary>
/// Remove a panel (minimum 1 panel must remain)
/// </summary>
public void RemovePanel(Widget panel)
{
if (_panels.Count <= 1)
return; // Keep at least one panel
var index = _panels.IndexOf(panel);
if (index < 0)
return;
// Remove the panel from list first
_panels.RemoveAt(index);
// Destroy the panel widget
panel.Destroy();
// Remove associated splitter
if (_splitters.Count > 0)
{
// If removing first panel, remove first splitter
// If removing other panel, remove splitter before it (index - 1)
int splitterIndex = index > 0 ? index - 1 : 0;
if (splitterIndex < _splitters.Count)
{
_splitters[splitterIndex].Destroy();
_splitters.RemoveAt(splitterIndex);
}
}
// Adjust active tab index if needed
if (_activeTabIndex >= _panels.Count)
{
_activeTabIndex = _panels.Count - 1;
}
// Update visibility in tab mode
if (_isTabMode)
{
for (int i = 0; i < _panels.Count; i++)
{
_panels[i].Visible = (i == _activeTabIndex);
}
RebuildTabBar();
// If only one panel left, switch back to normal mode
if (_panels.Count <= 1)
{
SwitchToNormalMode();
}
}
UpdatePanelButtons();
SaveBrowserState();
}
/// <summary>
/// Move a panel to the left (swap with previous panel)
/// </summary>
public void MovePanelLeft(Widget panel)
{
var index = _panels.IndexOf(panel);
if (index <= 0)
return; // Already first or not found
SwapPanels(index - 1, index);
}
/// <summary>
/// Move a panel to the right (swap with next panel)
/// </summary>
public void MovePanelRight(Widget panel)
{
var index = _panels.IndexOf(panel);
if (index < 0 || index >= _panels.Count - 1)
return; // Already last or not found
SwapPanels(index, index + 1);
}
/// <summary>
/// Swap two adjacent panels
/// </summary>
private void SwapPanels(int indexA, int indexB)
{
if (indexA < 0 || indexB >= _panels.Count || indexA >= indexB)
return;
// Swap in the list
var temp = _panels[indexA];
_panels[indexA] = _panels[indexB];
_panels[indexB] = temp;
// Rebuild the layout
RebuildPanelsLayout();
UpdatePanelButtons();
if (_isTabMode)
{
RebuildTabBar();
}
SaveBrowserState();
}
/// <summary>
/// Rebuild the panels container layout after reordering
/// </summary>
private void RebuildPanelsLayout()
{
// Clear current layout (but don't destroy widgets)
_panelsContainer.Layout.Clear(false);
// Clear old splitters
foreach (var splitter in _splitters)
{
splitter.Destroy();
}
_splitters.Clear();
// Re-add panels with splitters
for (int i = 0; i < _panels.Count; i++)
{
if (i > 0)
{
var splitter = new Splitter(_panelsContainer);
splitter.IsVertical = true;
_panelsContainer.Layout.Add(splitter);
_splitters.Add(splitter);
}
_panelsContainer.Layout.Add(_panels[i]);
}
}
/// <summary>
/// Update close button and move arrows visibility on all panels
/// </summary>
private void UpdatePanelButtons()
{
bool showClose = _panels.Count > 1;
for (int i = 0; i < _panels.Count; i++)
{
if (_panels[i] is IBrowserPanel browserPanel)
{
// In tab mode, hide panel buttons (close is on tab)
if (_isTabMode)
{
browserPanel.ShowCloseButton = false;
browserPanel.ShowMoveLeftButton = false;
browserPanel.ShowMoveRightButton = false;
}
else
{
browserPanel.ShowCloseButton = showClose;
browserPanel.ShowMoveLeftButton = showClose && i > 0;
browserPanel.ShowMoveRightButton = showClose && i < _panels.Count - 1;
}
}
}
}
/// <summary>
/// Navigate to and select a specific file in the first tree panel
/// </summary>
public void NavigateToFile(string path)
{
_panels.OfType<AssetBrowserPanel>().FirstOrDefault()?.NavigateToFile(path);
}
/// <summary>
/// Refresh all tree panels
/// </summary>
public void RefreshAll()
{
foreach (var panel in _panels.OfType<AssetBrowserPanel>())
{
panel.RefreshTree();
}
}
[EditorEvent.Frame]
public void OnFrame()
{
// Update toggle button visibility (only show when multiple panels)
if (_toggleModeBtn != null)
{
_toggleModeBtn.Visible = _panels.Count > 1;
}
}
/// <summary>
/// Toggle between tab mode and side-by-side mode
/// </summary>
private void ToggleLayoutMode()
{
if (_panels.Count <= 1)
return;
if (_isTabMode)
{
SwitchToNormalMode();
}
else
{
SwitchToTabMode();
}
SaveBrowserState();
}
/// <summary>
/// Switch to tab mode - show tabs and only one panel at a time
/// </summary>
private void SwitchToTabMode()
{
_isTabMode = true;
_titleLabel.Visible = false;
_tabsContainer.Visible = true;
// Update toggle button
if (_toggleModeBtn != null)
{
_toggleModeBtn.Icon = "view_column";
_toggleModeBtn.ToolTip = "Switch to Side-by-Side Mode";
}
// Hide splitters
foreach (var splitter in _splitters)
{
splitter.Visible = false;
}
// Show only active panel
for (int i = 0; i < _panels.Count; i++)
{
_panels[i].Visible = (i == _activeTabIndex);
}
RebuildTabBar();
UpdatePanelButtons();
}
/// <summary>
/// Switch to normal side-by-side mode
/// </summary>
private void SwitchToNormalMode()
{
_isTabMode = false;
_titleLabel.Visible = true;
_tabsContainer.Visible = false;
// Update toggle button
if (_toggleModeBtn != null)
{
_toggleModeBtn.Icon = "tab";
_toggleModeBtn.ToolTip = "Switch to Tab Mode";
}
// Show splitters
foreach (var splitter in _splitters)
{
splitter.Visible = true;
}
// Show all panels
foreach (var panel in _panels)
{
panel.Visible = true;
}
UpdatePanelButtons();
}
/// <summary>
/// Rebuild the tab bar with current panels
/// </summary>
private void RebuildTabBar()
{
_tabsContainer.Layout.Clear(true);
for (int i = 0; i < _panels.Count; i++)
{
var index = i;
var panel = _panels[i];
var tabName = GetPanelTabName(panel);
var tabIcon = GetPanelTabIcon(panel);
var tab = new TabButton(_tabsContainer, tabIcon, tabName, index == _activeTabIndex);
tab.OnClick = () => SelectTab(index);
tab.OnCloseClick = () => RemovePanel(panel);
tab.ShowClose = _panels.Count > 1;
_tabsContainer.Layout.Add(tab);
}
}
/// <summary>
/// Get display name for panel tab
/// </summary>
private string GetPanelTabName(Widget panel)
{
return panel switch
{
AssetBrowserPanel => "Tree",
IconGridPanel => "Grid",
CloudAssetPanel => "Cloud",
CloudIconGridPanel => "Cloud Grid",
_ => "Panel"
};
}
/// <summary>
/// Get icon for panel tab
/// </summary>
private string GetPanelTabIcon(Widget panel)
{
return panel switch
{
AssetBrowserPanel => "account_tree",
IconGridPanel => "grid_view",
CloudAssetPanel => "cloud",
CloudIconGridPanel => "cloud",
_ => "tab"
};
}
/// <summary>
/// Select a tab by index
/// </summary>
private void SelectTab(int index)
{
if (index < 0 || index >= _panels.Count)
return;
_activeTabIndex = index;
// Show only selected panel
for (int i = 0; i < _panels.Count; i++)
{
_panels[i].Visible = (i == _activeTabIndex);
}
RebuildTabBar();
}
}
/// <summary>
/// State data for saving/restoring browser configuration
/// </summary>
[Serializable]
internal class BrowserState
{
public bool IsTabMode { get; set; }
public int ActiveTabIndex { get; set; }
public List<string> PanelTypes { get; set; } = new();
}
/// <summary>
/// Custom tab button widget for the tab bar
/// </summary>
internal class TabButton : Widget
{
public Action OnClick;
public Action OnCloseClick;
public bool ShowClose { get; set; } = true;
private string _icon;
private string _text;
private bool _isActive;
private bool _isHovered;
private bool _isCloseHovered;
private Rect _closeRect;
public TabButton(Widget parent, string icon, string text, bool isActive) : base(parent)
{
_icon = icon;
_text = text;
_isActive = isActive;
MinimumWidth = 50;
MaximumWidth = 150;
Cursor = CursorShape.Finger;
SetSizeMode(SizeMode.CanGrow, SizeMode.Default);
}
protected override void OnPaint()
{
var rect = LocalRect;
// Background
if (_isActive)
{
Paint.SetBrush(Theme.Primary.WithAlpha(0.3f));
Paint.SetPen(Theme.Primary);
}
else if (_isHovered)
{
Paint.SetBrush(Color.White.WithAlpha(0.05f));
Paint.SetPen(Color.White.WithAlpha(0.2f));
}
else
{
Paint.SetBrush(Color.White.WithAlpha(0.02f));
Paint.SetPen(Color.White.WithAlpha(0.1f));
}
Paint.DrawRect(rect, 4);
// Icon
var iconRect = rect;
iconRect.Left += 6;
iconRect.Width = 16;
Paint.SetPen(_isActive ? Theme.Primary : Color.White.WithAlpha(0.7f));
Paint.DrawIcon(iconRect, _icon, 14, TextFlag.LeftCenter);
// Text
var textRect = rect;
textRect.Left += 24;
textRect.Right -= ShowClose ? 22 : 6;
Paint.SetPen(_isActive ? Color.White : Color.White.WithAlpha(0.7f));
Paint.SetDefaultFont(8, 400);
Paint.DrawText(textRect, _text, TextFlag.LeftCenter);
// Close button
if (ShowClose)
{
_closeRect = rect;
_closeRect.Left = rect.Right - 20;
_closeRect.Width = 16;
Paint.SetPen(_isCloseHovered ? Theme.Red : Color.White.WithAlpha(0.5f));
Paint.DrawIcon(_closeRect, "close", 12, TextFlag.Center);
}
}
protected override void OnMouseEnter()
{
_isHovered = true;
Update();
}
protected override void OnMouseLeave()
{
_isHovered = false;
_isCloseHovered = false;
Update();
}
protected override void OnMouseMove(MouseEvent e)
{
var wasCloseHovered = _isCloseHovered;
_isCloseHovered = ShowClose && _closeRect.IsInside(e.LocalPosition);
if (wasCloseHovered != _isCloseHovered)
Update();
}
protected override void OnMousePress(MouseEvent e)
{
if (e.LeftMouseButton)
{
if (ShowClose && _closeRect.IsInside(e.LocalPosition))
{
OnCloseClick?.Invoke();
}
else
{
OnClick?.Invoke();
}
}
}
}