/* * SPDX-License-Identifier: AGPL-3.0-or-later * Copyright (C) 2025 Sergej Görzen * This file is part of xAPI4Unity. */ #if UNITY_EDITOR using System; using System.Collections.Generic; using System.Linq; using UnityEditor; using UnityEngine; namespace xAPI4Unity.Editor { /// /// Provides custom GUI functionality for Unity Editor with extended layouts and styles. /// internal static class CustomGUI { /// /// Represents a tab within a tabbed GUI interface. /// internal struct Tab { internal readonly Action ClickTab; // Action to perform when the tab is clicked. internal readonly Action Content; // Action defining the tab's main content. internal readonly string TabLabel; // The label of the tab. /// /// Initializes a new instance of a Tab with the specified label, content, and click action. /// /// The label of the tab. /// The content to render when the tab is selected. /// An optional action to perform when the tab is clicked. public Tab(string tabLabel, Action content, Action onClickTab = null) { Content = content; TabLabel = tabLabel; ClickTab = onClickTab; } } private static readonly int TabsLimit = 3; // Maximum number of tabs before switching to dropdown mode. /// /// Builds a tab layout allowing selection between multiple tabs. /// /// The currently selected tab index. /// An array of Tab objects representing available options. /// The index of the newly selected tab. internal static int Tabs(int selectedTab, params Tab[] tabs) => Tabs(selectedTab, tabs, null); /// /// Builds a tab layout with an optional callback when the selected tab is changed. /// /// The currently selected tab index. /// An array of Tab objects representing available options. /// An optional callback triggered when a tab is selected. /// The index of the newly selected tab. internal static int Tabs(int selectedTab, Tab[] tabs, Action onChangeTab) { Horizontal.Wrap(() => { if (tabs.Length > TabsLimit) { // Render a dropdown when tabs exceed the limit. var options = tabs.Select(t => t.TabLabel).ToArray(); selectedTab = Popup("Download Options", options, selectedTab, index => { tabs[index].ClickTab?.Invoke(); onChangeTab?.Invoke(index); }); } else { for (var i = 0; i < tabs.Length; i++) { var tab = tabs[i]; Disabled(i == selectedTab, () => { if (Button(tab.TabLabel)) { selectedTab = i; tab.ClickTab?.Invoke(); } }); } } }); for (var i = 0; i < tabs.Length; i++) { var tab = tabs[i]; Region(i != selectedTab, tab.Content); } return selectedTab; } /// /// Provides vertical layout helpers. /// internal static class Vertical { public static void Begin(params GUILayoutOption[] options) => EditorGUILayout.BeginVertical(options); public static void End() => EditorGUILayout.EndVertical(); /// /// Wraps content between a vertical layout block. /// /// The content to display. /// Optional layout options. public static void Wrap(Action content, params GUILayoutOption[] options) { Begin(options); content(); End(); } } /// /// Provides horizontal layout helpers. /// internal static class Horizontal { public static void Begin(params GUILayoutOption[] options) => EditorGUILayout.BeginHorizontal(options); public static void End() => EditorGUILayout.EndHorizontal(); /// /// Wraps content between a horizontal layout block. /// /// The content to display. /// Optional layout options. public static void Wrap(Action content, params GUILayoutOption[] options) { Begin(options); content(); End(); } } /// /// Provides a scrollable layout region. /// internal class ScrollViewGUI { private Vector2 _scrollPos; private ScrollViewGUI(Vector2 startScroll) { _scrollPos = startScroll; } private void Begin(params GUILayoutOption[] options) { _scrollPos = EditorGUILayout.BeginScrollView(_scrollPos, options); } private void End() { EditorGUILayout.EndScrollView(); } /// /// Creates a scrollable region wrapping the provided content. /// /// The content to display. /// The initial scroll state. /// Optional layout options. public static void Wrap(Action action, Vector2 startScroll = default, params GUILayoutOption[] options) { var scrollView = new ScrollViewGUI(startScroll); scrollView.Begin(options); action(); scrollView.End(); } } private static readonly GUIStyle TitleLabelStyle = new GUIStyle(EditorStyles.whiteLargeLabel) { fontSize = 16, }; private static readonly GUIStyle SubtitleLabelStyle = new GUIStyle(EditorStyles.whiteLargeLabel) { fontSize = 14, }; private static readonly GUIStyle StepLabelStyleGreen = new GUIStyle(EditorStyles.whiteLargeLabel) { fontSize = 14, normal = new GUIStyleState() { textColor = Color.green }, hover = new GUIStyleState() { textColor = Color.green } }; private static readonly GUIStyle StepLabelStyleRed = new GUIStyle(EditorStyles.whiteLargeLabel) { fontSize = 14, normal = new GUIStyleState() { textColor = Color.red }, hover = new GUIStyleState() { textColor = Color.red } }; private static readonly GUIStyle ButtonStyle = new GUIStyle(GUI.skin.button) { fontSize = 12, padding = new RectOffset(10, 10, 5, 5), margin = new RectOffset(5, 5, -5, 0) }; private static readonly GUIStyle ListboxItemStyle = new GUIStyle(GUI.skin.button) { fontSize = 12, padding = new RectOffset(10, 10, 5, 5), margin = new RectOffset(5, 5, 0, 0), border = new RectOffset(1,1,1,1), alignment = TextAnchor.MiddleLeft, }; private static readonly GUIStyle ListboxSelectedItemStyle = new GUIStyle(GUI.skin.button) { fontSize = 12, padding = new RectOffset(10, 10, 5, 5), margin = new RectOffset(5, 5, 0, 0), border = new RectOffset(1,1,1,1), alignment = TextAnchor.MiddleLeft, normal = new GUIStyleState() { textColor = Color.white, background = MakeTex(2, 2, new Color(1f, 1f, 1f, 0.3f)), }, }; private static readonly GUIStyle TextStyle = new GUIStyle(GUI.skin.label) { fontSize = 11, wordWrap = true, richText = true }; private static readonly GUIStyle ErrorStyle = new GUIStyle(GUI.skin.label) { fontSize = 11, wordWrap = true, richText = true, normal = new GUIStyleState() { textColor = Color.red }, hover = new GUIStyleState() { textColor = Color.red } }; private static readonly GUIStyle WarningStyle = new GUIStyle(GUI.skin.label) { fontSize = 11, wordWrap = true, richText = true, normal = new GUIStyleState() { textColor = Color.yellow }, hover = new GUIStyleState() { textColor = Color.yellow } }; private static readonly GUIStyle HyperlinkStyle = new GUIStyle(GUI.skin.label) { fontSize = 11, wordWrap = true, richText = true, normal = new GUIStyleState() { textColor = new Color(0.8f, 0.8f, 0) }, hover = new GUIStyleState() { textColor = new Color(1f, 1f, 0) } }; /// /// Creates a colored 2D texture of specified size. /// /// Width of the texture. /// Height of the texture. /// Fill color of the texture. /// Newly generated Texture2D object. private static Texture2D MakeTex(int width, int height, Color color) { var pixels = new Color[width * height]; for (var i = 0; i < pixels.Length; i++) { pixels[i] = color; } var result = new Texture2D(width, height); result.SetPixels(pixels); result.Apply(); return result; } /// /// Renders a styled listbox with selectable entries inside a scroll view. /// /// Label displayed above the listbox. /// Array of items to be displayed in the list. /// Reference to the current scroll position. /// Currently selected index. /// Width of the listbox. /// Height of the listbox. /// Whether to always show horizontal scroll bar. /// Whether to always show vertical scroll bar. /// Optional callback triggered on item selection. /// Index of the selected item. public static int Listbox(string label, string[] entries, ref Vector2 scrollPosition, int selectedIndex, int width, int height, bool alwaysShowHorizontal = false, bool alwaysShowVertical = false, Action onClick = null) { BeginVertical(); Label(label); scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition, alwaysShowHorizontal, alwaysShowVertical, GUILayout.Height(height), GUILayout.Width(width)); for (var i = 0; i < entries.Length; i++) { if (GUILayout.Button(entries[i], i == selectedIndex ? ListboxSelectedItemStyle : ListboxItemStyle)) { selectedIndex = i; onClick?.Invoke(entries[i]); } } if (entries.Length < 1) Label("No items."); EditorGUILayout.EndScrollView(); EndVertical(); return selectedIndex; } /// /// Renders a GUI button with the given text content and optional layout options. /// /// Text to display on the button. /// Optional layout options. /// True if the button is clicked. public static bool Button(string textContent, params GUILayoutOption[] options) => Button(new GUIContent(textContent), options); /// /// Renders a GUI button with the given GUIContent and optional layout options. /// /// Content to display on the button (text, tooltip, etc.). /// Optional layout options. /// True if the button is clicked. public static bool Button(GUIContent textContent, params GUILayoutOption[] options) { var down = GUILayout.Button(textContent, ButtonStyle, (new[] { GUILayout.Height(30), GUILayout.ExpandWidth(false) }).Concat(options).ToArray()); return down; } /// /// Renders a Yes/No popup that maps a boolean value to two labels. /// /// Label displayed next to the popup. /// Current boolean value. /// Callback when value changes. /// Label for true value. /// Label for false value. /// New boolean value after user interaction. public static bool YesNoPopup(string label, bool value, Action onChange = null, string yesWord = "Yes", string noWord = "No") { var v = value ? yesWord : noWord; Horizontal.Begin(); v = Popup(label, new[] { yesWord, noWord }, v); Horizontal.End(); var newValue = v == yesWord; if (newValue != value) onChange?.Invoke(newValue); return newValue; } /// /// Displays a state-aware button that disables itself when in state mode. /// /// Text when not in state mode. /// If true, button is shown as inactive. /// Text when in state mode. /// Optional layout options. /// True if the button was clicked and active. public static bool StateButton(string normalText, bool isStateMode, string stateText, params GUILayoutOption[] options) { var result = false; Disabled(isStateMode, () => { result = Button(isStateMode ? stateText : normalText, options); }); return result; } private const string CorrectMark = "✓"; private const string WrongMark = "✗"; /// /// Renders a large title label. /// /// Text to display. public static void Title(string text) => GUILayout.Label(text, TitleLabelStyle); /// /// Renders a smaller subtitle label. /// /// Text to display. public static void Subtitle(string text) => GUILayout.Label(text, SubtitleLabelStyle); /// /// Renders a labeled step with visual checkmark or cross. /// /// Step label. /// Determines if the step is valid. public static void Step(string text, bool checkedCondition) => GUILayout.Label(text + " " + (checkedCondition ? CorrectMark : WrongMark), checkedCondition ? StepLabelStyleGreen : StepLabelStyleRed); /// /// Renders a plain label with default style. /// /// Text to display. public static void Label(string text) => GUILayout.Label(text); /// /// Renders a text block with default paragraph styling. /// /// Text to display. public static void Description(string text) => GUILayout.Label(text, TextStyle, GUILayout.ExpandWidth(true)); /// /// Renders a numbered bullet list from the given strings. /// /// The list of bullet point texts. public static void Bullets(params string[] bullets) { for (var i = 0; i < bullets.Length; i++) { var bullet = bullets[i]; GUILayout.Label((i + 1) + ". " + bullet, TextStyle, GUILayout.ExpandWidth(true)); } } /// /// Displays a hyperlink using default URL as display text. /// /// The URL to open when clicked. public static void Hyperlink(string url) => Hyperlink(url, url); /// /// Displays a hyperlink label that opens the specified URL when clicked. /// /// The display text. /// The URL to open. public static void Hyperlink(string text, string url) { var content = new GUIContent(text); var rect = GUILayoutUtility.GetRect(content, HyperlinkStyle); // Verändere den Cursor zum Hand-Symbol wenn die Maus über dem Link ist EditorGUIUtility.AddCursorRect(rect, MouseCursor.Link); if (GUI.Button(rect, content, HyperlinkStyle)) { Application.OpenURL(url); } } /// /// Renders an error message in red text. /// /// Error message text. public static void Error(string text) => GUILayout.Label(text, ErrorStyle, GUILayout.ExpandWidth(true)); /// /// Renders a warning message in yellow text. /// /// Warning message text. public static void Warning(string text) => GUILayout.Label(text, WarningStyle, GUILayout.ExpandWidth(true)); /// /// Inserts vertical space in the layout. /// /// Number of vertical spaces to insert. public static void Space(int lines = 1) { for (var i = 0; i < lines; i++) EditorGUILayout.Space(); } /// /// Displays a popup menu for string-based selection. /// /// Label shown next to the dropdown. /// List of options to choose from. /// Currently selected value. /// Optional layout parameters. /// The selected value. public static string Popup(string label, IEnumerable selectionOptions, string value, params GUILayoutOption[] options) { var opts = selectionOptions.Prepend("").ToArray(); if (string.IsNullOrEmpty(value)) value = ""; var optsIndex = Array.IndexOf(opts, value); if (optsIndex < 0) optsIndex = 0; var newOptions = options.Prepend(GUILayout.MaxWidth(300)).ToArray(); var newValue = opts[EditorGUILayout.Popup(label, optsIndex, opts, newOptions)]; return newValue; } /// /// Displays a popup menu with integer index selection and optional change callback. /// /// Label shown next to the dropdown. /// List of options to choose from. /// Currently selected index. /// Callback triggered if selection changes. /// Optional layout parameters. /// The selected index. public static int Popup(string label, IEnumerable selectionOptions, int selectedIndex, Action onChange = null, params GUILayoutOption[] options) { var opts = selectionOptions.Prepend("").ToArray(); var optsIndex = Array.IndexOf(opts, selectedIndex); if (optsIndex < 0) optsIndex = 0; var newOptions = options.Prepend(GUILayout.MaxWidth(300)).ToArray(); var newIndex = EditorGUILayout.Popup(label, optsIndex, opts, newOptions); if (newIndex != selectedIndex) onChange?.Invoke(newIndex); return newIndex; } /// /// Displays a popup for selecting enum values. /// /// Enum type to select from. /// Label shown next to the dropdown. /// Current selected value. /// Callback triggered on selection change. /// Optional layout parameters. /// The selected enum value. public static T Popup(string label, T value, Action onChange = null, params GUILayoutOption[] options) where T : Enum { var newOptions = options.Prepend(GUILayout.MaxWidth(300)).ToArray(); var newValue = (T)EditorGUILayout.EnumPopup(label, value, newOptions); if (!newValue.Equals(value)) onChange?.Invoke(newValue); return newValue; } /// /// Creates a visual divider with vertical spacing. /// /// Spacing lines before and after the divider. public static void Divider(int spaceLines = 1) { Space(spaceLines); const float height = 1.0f; var rect = EditorGUILayout.GetControlRect(false, height); rect.height = height; EditorGUI.DrawRect(rect, new Color(0.5f, 0.5f, 0.5f, 1)); Space(spaceLines); } /// /// Wraps content inside a region with divider spacing, conditionally rendered. /// /// Whether the content should be hidden. /// The content to render. /// Number of lines before and after the divider. public static void DividedRegion(bool hidden, Action content, int spaceLines = 1) { if (hidden) return; DividedRegion(content, spaceLines); } /// /// Wraps content inside a region with dividers. /// /// The content to render. /// Number of lines before and after the divider. public static void DividedRegion(Action content, int spaceLines = 1) { Divider(spaceLines); content(); Divider(spaceLines); } /// /// Wraps content inside a vertical region with spacing, conditionally rendered. /// /// Whether the content should be hidden. /// The content to render. /// Number of lines before and after the region. public static void Region(bool hidden, Action content, int spaceLines = 1) { if (hidden) return; Region(content, spaceLines); } /// /// Wraps content inside a vertical region with spacing. /// /// The content to render. /// Number of lines before and after the region. public static void Region(Action content, int spaceLines = 1) { Space(spaceLines); content(); Space(spaceLines); } /// /// Displays a text field with optional default value. /// /// Label displayed next to the field. /// Current text value. /// Default text if input is empty. /// Optional layout options. /// New or unchanged text value. public static string TextField(string label, string text, string defaultText = "", params GUILayoutOption[] options) { var newText = EditorGUILayout.TextField(label, string.IsNullOrEmpty(text) ? defaultText : text, (new[] { GUILayout.MinWidth(250) }).Concat(options).ToArray()); return newText; } /// /// Begins a vertical layout block. /// public static void BeginVertical() => EditorGUILayout.BeginVertical(); /// /// Ends the current vertical layout block. /// public static void EndVertical() => EditorGUILayout.EndVertical(); /// /// Starts a disabled GUI group. /// /// Whether to disable GUI interaction. public static void BeginDisabledGroup(bool disabled = false) => EditorGUI.BeginDisabledGroup(disabled); /// /// Ends a disabled GUI group. /// public static void EndDisabledGroup() => EditorGUI.EndDisabledGroup(); /// /// Executes content within a disabled GUI context. /// /// If true, disables the group. /// The content to render. public static void Disabled(bool disabled, Action content) { BeginDisabledGroup(disabled); content(); EndDisabledGroup(); } /// /// Creates a scroll view layout and renders the provided content inside it. /// /// Reference to the current scroll position. /// Content to render inside the scroll view. public static void ScrollView(ref Vector2 scrollPosition, Action content) { scrollPosition = GUILayout.BeginScrollView(scrollPosition); content(); GUILayout.EndScrollView(); } } } #endif