Files
Emberwild/Assets/GAME/Script/Editor/SceneFinderWindow.cs
2026-07-07 16:43:51 +02:00

317 lines
11 KiB
C#

using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
namespace Ashwild.EditorTools
{
/// <summary>
/// The Scene Finder — a lightweight editor window that lists every scene in the project, filters
/// them by a search box, and lets you pin the ones you use most as favourites so you never hunt
/// through the Project window again. Favourites are keyed by asset GUID (so they survive renames
/// and moves) and persisted per-machine in EditorPrefs, which means the pinned set follows the
/// user's PC rather than living in the repo. Each row can open the scene (single or additive) or
/// ping it in the Project window.
/// </summary>
public class SceneFinderWindow : EditorWindow
{
#region Constants
/// <summary>
/// EditorPrefs key holding the favourite scene GUIDs joined by ';'. Per-user, per-machine — the
/// favourites are a personal shortcut list, deliberately not shared through the project.
/// </summary>
private const string FavoritesKey = "Ashwild.SceneFinder.Favorites";
#endregion
#region State
private readonly List<SceneEntry> allScenes = new List<SceneEntry>();
private readonly HashSet<string> favoriteGuids = new HashSet<string>();
private string search = string.Empty;
private Vector2 scroll;
private bool showFavoritesOnly;
private GUIStyle pathStyle;
private GUIStyle sectionStyle;
#endregion
#region Types
/// <summary>
/// A single discovered scene: its GUID (stable key), display name and project-relative path.
/// </summary>
private struct SceneEntry
{
public string Guid;
public string Name;
public string Path;
}
#endregion
#region Menu
/// <summary>
/// Opens (or focuses) the Scene Finder window.
/// </summary>
[MenuItem("Tools/Scene Finder")]
public static void Open()
{
SceneFinderWindow window = GetWindow<SceneFinderWindow>();
window.titleContent = new GUIContent("Scene Finder", EditorGUIUtility.IconContent("SceneAsset Icon").image);
window.minSize = new Vector2(340, 300);
}
#endregion
#region Window Lifecycle
/// <summary>
/// Loads the persisted favourites and scans the project for scenes when the window appears.
/// </summary>
private void OnEnable()
{
LoadFavorites();
RefreshScenes();
}
#endregion
#region Scene Discovery
/// <summary>
/// Rebuilds the scene list from the AssetDatabase, sorted by name. Called on open and whenever
/// the user hits Refresh so newly created or deleted scenes are reflected.
/// </summary>
private void RefreshScenes()
{
allScenes.Clear();
foreach (string guid in AssetDatabase.FindAssets("t:Scene"))
{
string path = AssetDatabase.GUIDToAssetPath(guid);
if (string.IsNullOrEmpty(path)) continue;
allScenes.Add(new SceneEntry
{
Guid = guid,
Name = System.IO.Path.GetFileNameWithoutExtension(path),
Path = path
});
}
allScenes.Sort((a, b) => string.Compare(a.Name, b.Name, System.StringComparison.OrdinalIgnoreCase));
}
#endregion
#region Favorites Persistence
/// <summary>
/// Reads the favourite GUID set from EditorPrefs into memory.
/// </summary>
private void LoadFavorites()
{
favoriteGuids.Clear();
string raw = EditorPrefs.GetString(FavoritesKey, string.Empty);
if (string.IsNullOrEmpty(raw)) return;
foreach (string guid in raw.Split(';'))
if (!string.IsNullOrEmpty(guid)) favoriteGuids.Add(guid);
}
/// <summary>
/// Writes the current favourite GUID set back to EditorPrefs so it survives domain reloads and
/// editor restarts on this machine.
/// </summary>
private void SaveFavorites()
{
EditorPrefs.SetString(FavoritesKey, string.Join(";", favoriteGuids));
}
/// <summary>
/// Pins or unpins a scene and persists the change immediately.
/// </summary>
private void ToggleFavorite(string guid)
{
if (!favoriteGuids.Remove(guid)) favoriteGuids.Add(guid);
SaveFavorites();
}
#endregion
#region GUI
/// <summary>
/// Draws the toolbar and the favourites + all-scenes lists.
/// </summary>
private void OnGUI()
{
EnsureStyles();
DrawToolbar();
scroll = EditorGUILayout.BeginScrollView(scroll);
List<SceneEntry> filtered = Filter();
List<SceneEntry> favorites = filtered.Where(s => favoriteGuids.Contains(s.Guid)).ToList();
if (favorites.Count > 0)
{
DrawSectionHeader($"★ Favorites ({favorites.Count})");
foreach (SceneEntry entry in favorites) DrawRow(entry);
GUILayout.Space(6);
}
if (!showFavoritesOnly)
{
List<SceneEntry> rest = filtered.Where(s => !favoriteGuids.Contains(s.Guid)).ToList();
DrawSectionHeader($"All Scenes ({rest.Count})");
if (rest.Count == 0 && favorites.Count == 0)
EditorGUILayout.HelpBox("No scene matches the search.", MessageType.Info);
foreach (SceneEntry entry in rest) DrawRow(entry);
}
EditorGUILayout.EndScrollView();
}
/// <summary>
/// Draws the top toolbar: search field with a clear button, a favourites-only toggle and refresh.
/// </summary>
private void DrawToolbar()
{
EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
search = GUILayout.TextField(search, EditorStyles.toolbarSearchField, GUILayout.MinWidth(80));
if (GUILayout.Button("✕", EditorStyles.toolbarButton, GUILayout.Width(22)))
{
search = string.Empty;
GUI.FocusControl(null);
}
showFavoritesOnly = GUILayout.Toggle(showFavoritesOnly, "★ Only", EditorStyles.toolbarButton, GUILayout.Width(50));
if (GUILayout.Button("Refresh", EditorStyles.toolbarButton, GUILayout.Width(60)))
RefreshScenes();
EditorGUILayout.EndHorizontal();
}
/// <summary>
/// Draws a single scene row: the pin toggle, its name and dimmed path, and the open/ping actions.
/// </summary>
private void DrawRow(SceneEntry entry)
{
EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
bool isFav = favoriteGuids.Contains(entry.Guid);
bool newFav = GUILayout.Toggle(isFav, isFav ? "★" : "☆", EditorStyles.label, GUILayout.Width(20));
if (newFav != isFav) ToggleFavorite(entry.Guid);
EditorGUILayout.BeginVertical();
EditorGUILayout.LabelField(entry.Name, EditorStyles.boldLabel);
EditorGUILayout.LabelField(entry.Path, pathStyle);
EditorGUILayout.EndVertical();
if (GUILayout.Button(new GUIContent("Open", "Open this scene (replaces the current one)"), GUILayout.Width(46)))
OpenScene(entry.Path, OpenSceneMode.Single);
if (GUILayout.Button(new GUIContent("+", "Open additively alongside the current scene(s)"), GUILayout.Width(24)))
OpenScene(entry.Path, OpenSceneMode.Additive);
if (GUILayout.Button(new GUIContent("◎", "Ping in the Project window"), GUILayout.Width(24)))
PingScene(entry.Path);
EditorGUILayout.EndHorizontal();
}
/// <summary>
/// Draws a small section separator label.
/// </summary>
private void DrawSectionHeader(string title)
{
GUILayout.Label(title, sectionStyle);
}
#endregion
#region Actions
/// <summary>
/// Opens a scene, prompting to save any unsaved changes first (single mode only). Guards the
/// missing-scene case with a clear log so a stale favourite fails loudly rather than silently.
/// </summary>
private void OpenScene(string path, OpenSceneMode mode)
{
if (!System.IO.File.Exists(path))
{
Debug.LogError($"[SceneFinder] Scene not found at '{path}' — it may have been moved or deleted.");
RefreshScenes();
return;
}
if (mode == OpenSceneMode.Single &&
!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo())
return;
EditorSceneManager.OpenScene(path, mode);
}
/// <summary>
/// Selects and pings the scene asset in the Project window.
/// </summary>
private void PingScene(string path)
{
SceneAsset asset = AssetDatabase.LoadAssetAtPath<SceneAsset>(path);
if (asset == null) return;
Selection.activeObject = asset;
EditorGUIUtility.PingObject(asset);
}
#endregion
#region Internal Helpers
/// <summary>
/// Returns the scenes matching the current search text (case-insensitive, matched against both
/// name and path). An empty search returns everything.
/// </summary>
private List<SceneEntry> Filter()
{
if (string.IsNullOrWhiteSpace(search)) return allScenes;
string needle = search.Trim().ToLowerInvariant();
return allScenes.Where(s =>
s.Name.ToLowerInvariant().Contains(needle) ||
s.Path.ToLowerInvariant().Contains(needle)).ToList();
}
/// <summary>
/// Lazily builds the cached GUI styles once the skin is available (styles can't be created in
/// OnEnable because the editor skin isn't ready yet).
/// </summary>
private void EnsureStyles()
{
if (pathStyle == null)
{
pathStyle = new GUIStyle(EditorStyles.miniLabel) { wordWrap = false };
pathStyle.normal.textColor = new Color(0.55f, 0.55f, 0.55f);
}
if (sectionStyle == null)
{
sectionStyle = new GUIStyle(EditorStyles.boldLabel) { margin = new RectOffset(4, 4, 6, 2) };
}
}
#endregion
}
}