// // This file is part of the Tremble package by Tiny Goose. // Copyright (c) 2024-2026 TinyGoose Ltd., All Rights Reserved. // using System; using System.Collections.Generic; using System.IO; using System.Linq; using UnityEditor; using UnityEngine; namespace TinyGoose.Tremble.Editor { public class ManualWindow : EditorWindow { // ----------------------------------------------------------------------------------------------------------------------------- // Statics - for storing state between reloads // ----------------------------------------------------------------------------------------------------------------------------- private const string MANUAL_PREFIX = "Manual_"; private const int MAX_BOOKMARKS = 5; private static readonly EditorPrefsBackedString s_Path = new(MANUAL_PREFIX + nameof(s_Path)); private static readonly EditorPrefsBackedString s_HomePath = new(MANUAL_PREFIX + nameof(s_HomePath)); private static readonly EditorPrefsBackedString s_SortOrderFirst = new(MANUAL_PREFIX + nameof(s_SortOrderFirst)); private static readonly EditorPrefsBackedString s_SortOrderLast = new(MANUAL_PREFIX + nameof(s_SortOrderLast)); private static readonly EditorPrefsBackedString s_Bookmarks = new(MANUAL_PREFIX + nameof(s_Bookmarks)); // ----------------------------------------------------------------------------------------------------------------------------- // State // ----------------------------------------------------------------------------------------------------------------------------- private Vector2 m_ListScrollPos; private Vector2 m_PageScrollPos; private ManualEntry m_CurrentNode; private ManualPageBase m_CurrentPage; private ManualEntry m_TreeRoot; private readonly Dictionary m_PageClasses = new(); private GUIStyle m_SelectedItemStyle; private float m_TreeViewWidth = 235f; private bool m_IsResizing; private readonly List m_History = new(); private int m_HistoryPtr = -1; public float PageWidth => position.width - m_TreeViewWidth - 20f; public static void OpenManual(string windowTitle, Type startPage = null, string[] sortOrderFirst = null, string[] sortOrderLast = null) { string startPath = null; if (startPage != null) { startPage.GetCustomAttributes(out ManualPageAttribute pageInfo); if (pageInfo == null) { Debug.LogWarning($"Tried to open manual to page: {startPage.Name} - which is not a valid page - going home..."); } else { startPath = pageInfo.Path; } } OpenManual(windowTitle, startPath, sortOrderFirst, sortOrderLast); } public static void OpenManual(string windowTitle, string startPath = null, string[] sortOrderFirst = null, string[] sortOrderLast = null) { s_Path.Value = startPath; s_SortOrderFirst.Values = sortOrderFirst ?? Array.Empty(); s_SortOrderLast.Values = sortOrderLast ?? Array.Empty(); ManualWindow window = GetWindow(true, windowTitle, true); Vector2 size = new(1200f, 640f); window.minSize = new(800f, 400f); window.position = new(EditorGUIUtility.GetMainWindowPosition().center - size / 2f, size); window.Show(); } private void OnEnable() { s_HomePath.Value = null; m_TreeRoot = new() { Title = "" }; TypeCache.TypeCollection pages = TypeCache.GetTypesWithAttribute(); foreach (Type pageClass in pages) { pageClass.GetCustomAttributes(out ManualPageAttribute pageInfo); if (pageInfo.IsHomePage) { if (s_HomePath.Value != null) { Debug.LogError($"Multiple home pages in manual! Was: {s_HomePath.Values}, now using: {pageInfo.Path}!"); } s_HomePath.Value = pageInfo.Path; } m_PageClasses[pageInfo.Path] = pageClass; List pathList = pageInfo.Path.Split("/", StringSplitOptions.RemoveEmptyEntries).ToList(); CreateHierarchy_Recursive(m_TreeRoot, pathList, pageInfo); } SortHierarchy_Recursive(m_TreeRoot, s_HomePath.Value); GoToPage(s_Path.Value ?? s_HomePath.Value); } public void GoToPage(Type type, bool noUpdateHistory = false) { type.GetCustomAttributes(out ManualPageAttribute manualPageAttribute); if (manualPageAttribute == null) { Debug.LogError($"Type {type.Name} is not a [ManualPage]"); return; } GoToPage(manualPageAttribute.Path, noUpdateHistory); } public void GoToPage(string path, bool noUpdateHistory = false) { if (!noUpdateHistory) { if (m_History.Count > 0 && m_HistoryPtr > -1 && m_HistoryPtr != m_History.Count - 1) { m_History.RemoveRange(m_HistoryPtr + 1, m_History.Count - m_HistoryPtr - 1); } m_History.Add(path); m_HistoryPtr = m_History.Count - 1; } if (!m_PageClasses.TryGetValue(path, out Type pageType)) { EditorUtility.DisplayDialog("Invalid Page", $"Could not go to page with path '{path}' - maybe it was removed or moved?", "Oh no!"); RemoveBookmark(path); return; } m_CurrentPage?.DeInit(); { m_CurrentNode = m_TreeRoot.FindEntryByPath_Recursive(path); m_CurrentPage = (ManualPageBase)Activator.CreateInstance(pageType); } m_CurrentPage.Init(this); s_Path.Value = path; m_PageScrollPos = Vector2.zero; ExpandHierarchy(path); } private void OnGUI() { if (m_SelectedItemStyle == null) { Color lightBlue = new(0f, 0.6f, 1f); m_SelectedItemStyle = new(EditorStyles.wordWrappedLabel) { fontStyle = FontStyle.Bold, normal = { textColor = lightBlue }, focused = { textColor = lightBlue }, hover = { textColor = lightBlue } }; } DrawToolbar(); // Horizontal separator GUILayout.Box(GUIContent.none, GUILayout.ExpandWidth(true), GUILayout.Height(2f)); GUILayout.BeginHorizontal(GUILayout.ExpandHeight(true)); { DrawTreeView(m_TreeViewWidth); DrawDraggableSeparator(); DrawContent(); DrawBookmarkOverlayButton(); } GUILayout.EndHorizontal(); } private void DrawToolbar() { GUILayout.BeginHorizontal(); { EditorGUI.BeginDisabledGroup(m_HistoryPtr < 1); { if (GUILayout.Button("<", GUILayout.Width(20f))) { m_HistoryPtr--; GoToPage(m_History[m_HistoryPtr], noUpdateHistory: true); } } EditorGUI.EndDisabledGroup(); EditorGUI.BeginDisabledGroup(m_HistoryPtr >= m_History.Count - 1); { if (GUILayout.Button(">", GUILayout.Width(20f))) { m_HistoryPtr++; GoToPage(m_History[m_HistoryPtr], noUpdateHistory: true); } } EditorGUI.EndDisabledGroup(); EditorGUI.BeginDisabledGroup(m_CurrentNode?.PagePath.EqualsInvariant(s_HomePath.Value) ?? false); { if (GUILayout.Button("Home", GUILayout.Width(50f))) { m_History.Clear(); m_HistoryPtr = -1; GoToPage(s_HomePath.Value); } } EditorGUI.EndDisabledGroup(); // Bookmarks GUILayout.Space(5f); string[] bookmarks = s_Bookmarks.Values; Array.Sort(bookmarks, String.CompareOrdinal); if (bookmarks.Length > MAX_BOOKMARKS) { if (EditorGUILayout.DropdownButton(new("Bookmarks"), FocusType.Passive)) { GenericMenu menu = new(); foreach (string bookmark in bookmarks) { menu.AddItem(new(bookmark), false, () => GoToPage(bookmark)); } menu.ShowAsContext(); } } else { foreach (string bookmark in bookmarks) { EditorGUI.BeginDisabledGroup(s_Path.Value.EqualsInvariant(bookmark)); { if (GUILayout.Button(new GUIContent(Path.GetFileName(bookmark)), EditorStyles.miniButton)) { GoToPage(bookmark); } } EditorGUI.EndDisabledGroup(); } } // GUI.color = Color.gray; // GUILayout.Label($"Path: {m_CurrentNode?.PagePath ?? ""}"); // GUI.color = Color.white; GUILayout.FlexibleSpace(); GUILayout.Label($"Scale: {ManualStyles.Scale*100f:F0}%"); float rawValue = GUILayout.HorizontalSlider(ManualStyles.Scale, 0.7f, 1.3f, GUILayout.Width(200f)); ManualStyles.Scale = Mathf.FloorToInt(rawValue * 10f) / 10f; EditorGUI.BeginDisabledGroup(ManualStyles.Scale == 1f); { if (GUILayout.Button(new GUIContent("x", "Reset Zoom"))) { ManualStyles.Scale = 1f; } } EditorGUI.EndDisabledGroup(); } GUILayout.EndHorizontal(); } private void DrawTreeView(float width) { // Tree view m_ListScrollPos = GUILayout.BeginScrollView(m_ListScrollPos, GUILayout.Width(width)); { DrawHierarchy_Recursive(m_TreeRoot, isRoot: true); } GUILayout.EndScrollView(); } private void DrawDraggableSeparator() { GUILayout.Box(GUIContent.none, GUILayout.Width(4f), GUILayout.ExpandHeight(true)); Rect resizeHandleRect = GUILayoutUtility.GetLastRect(); EditorGUIUtility.AddCursorRect(resizeHandleRect, MouseCursor.ResizeHorizontal); if (resizeHandleRect.Contains(Event.current.mousePosition)) { // Mouse down while over - start resizing if (Event.current.type == EventType.MouseDown) { m_IsResizing = true; Event.current.Use(); } } // Mouse up - no resizing if (Event.current.type == EventType.MouseUp) { m_IsResizing = false; } // If currently resizing, update the m_TreeViewWidth based on mouse movement if (m_IsResizing && Event.current.type != EventType.Layout && Event.current.type != EventType.Repaint) { m_TreeViewWidth += Event.current.delta.x; m_TreeViewWidth = Mathf.Clamp(m_TreeViewWidth, 10f, 500f); Event.current.Use(); } } private void DrawContent() { if (m_CurrentPage != null) { EditorGUILayoutUtil.Pad(10f, () => { m_PageScrollPos = GUILayout.BeginScrollView(m_PageScrollPos); { GUILayout.Label(m_CurrentNode.Title, ManualStyles.Styles.Title); m_CurrentPage.Render(); } GUILayout.EndScrollView(); }); } else { GUILayout.Label("Please select an item on the left!", EditorStyles.centeredGreyMiniLabel, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true)); } } private void DrawBookmarkOverlayButton() { // Bookmark overlay button bool wasBookmarked = s_Bookmarks.Values.Contains(s_Path.Value); Rect buttonPosition = position; buttonPosition.x = position.width - 32f - 30f; buttonPosition.y = 26f; buttonPosition.width = 32f; buttonPosition.height = 32f; GUIContent bookmarkButton = wasBookmarked ? new(ManualStyles.Styles.BookmarkIconOn, "Remove bookmark from this page") : new(ManualStyles.Styles.BookmarkIconOff, "Bookmark this page"); if (GUI.Button(buttonPosition, bookmarkButton, ManualStyles.Styles.Text)) { SetBookmark(s_Path.Value, !wasBookmarked); } } private void ExpandHierarchy(string path) { int endSlice = path.Length; // While the path still has '/' in it... while (endSlice != -1) { // Chop off after last slash (no-op on first run) path = path.Substring(0, endSlice); // Open the child, find the parent for further chopping EditorGUILayoutUtil.SetHeaderOpen(path, true); endSlice = path.LastIndexOf('/'); } } private void SetBookmark(string path, bool on) { if (on) { AddBookmark(path); } else { RemoveBookmark(path); } } private void AddBookmark(string path) { s_Bookmarks.Values = s_Bookmarks.Values.Append(path).ToArray(); ShowNotification(new("Page bookmarked!")); } private void RemoveBookmark(string path) { s_Bookmarks.Values = s_Bookmarks.Values.Where(p => p != path).ToArray(); ShowNotification(new("Bookmark removed!")); } private void CreateHierarchy_Recursive(ManualEntry node, List path, ManualPageAttribute pageInfo) { if (path.Count == 0) return; string newNodeTitle = path[0]; path.RemoveAt(0); // Find existing parent, and go down that route foreach (ManualEntry existingChild in node.Children) { if (existingChild.Title.EqualsInvariant(newNodeTitle)) { CreateHierarchy_Recursive(existingChild, path, pageInfo); return; } } // Or... create a new one, and go down that route! bool isLeaf = path.Count == 0; string newPagePath = isLeaf ? pageInfo.Path : (node.PagePath.IsNullOrEmpty() ? newNodeTitle : $"{node.PagePath}/{newNodeTitle}"); ManualEntry entry = new() { Title = isLeaf ? pageInfo.Title : newNodeTitle, PagePath = newPagePath, ShowInTree = pageInfo.ShowInTree }; node.Children.Add(entry); CreateHierarchy_Recursive(entry, path, pageInfo); } private void SortHierarchy_Recursive(ManualEntry node, string homePath) { string[] sortOrderFirst = s_SortOrderFirst.Values; string[] sortOrderLast = s_SortOrderLast.Values; node.Children.Sort((c1, c2) => { // Home path ALWAYS TOP if (c1.PagePath != null && c1.PagePath.EqualsInvariant(homePath)) return -1; if (c2.PagePath != null && c2.PagePath.EqualsInvariant(homePath)) return +1; // Specific FIRST sort order first? int c1FirstSort = Array.IndexOf(sortOrderFirst, c1.Title); int c2FirstSort = Array.IndexOf(sortOrderFirst, c2.Title); if (c1FirstSort != -1 || c2FirstSort != -1) { // One sorted, the other not - put sorted below if (c1FirstSort == -1) return +1; if (c2FirstSort == -1) return -1; // Both last-sorted! return c1FirstSort.CompareTo(c2FirstSort); } // Specific LAST sort order first? int c1LastSort = Array.IndexOf(sortOrderLast, c1.Title); int c2LastSort = Array.IndexOf(sortOrderLast, c2.Title); if (c1LastSort != -1 || c2LastSort != -1) { // One sorted, the other not - put sorted below if (c1LastSort == -1) return -1; if (c2LastSort == -1) return +1; // Both last-sorted! return c1LastSort.CompareTo(c2LastSort); } // Both no specific sort at all - check titles return String.Compare(c1.Title, c2.Title, StringComparison.Ordinal); }); foreach (ManualEntry child in node.Children) { SortHierarchy_Recursive(child, homePath); } } private void DrawHierarchy_Recursive(ManualEntry entry, bool isRoot = false) { if (entry == null) return; // It's a leaf! if (entry.Children.Count == 0) { if (!entry.ShowInTree) return; bool isCurrentPage = entry == m_CurrentNode; GUILayout.BeginHorizontal(); GUILayout.Space(26f); bool wasPressed = isCurrentPage ? GUILayout.Button(entry.Title, m_SelectedItemStyle) : GUILayout.Button(entry.Title, EditorStyles.wordWrappedLabel); GUILayout.EndHorizontal(); if (wasPressed) { GoToPage(entry.PagePath); } GUILayout.Space(5f); return; } if (isRoot) { foreach (ManualEntry child in entry.Children) { DrawHierarchy_Recursive(child); } } else { EditorGUILayoutUtil.CollapsibleHeader(entry.Title, entry.PagePath, () => { GUILayout.Space(5f); foreach (ManualEntry child in entry.Children) { DrawHierarchy_Recursive(child); } }); if (!EditorGUILayoutUtil.IsHeaderOpen(entry.PagePath)) { GUILayout.Space(5f); } } } } }