ConsoleX: A Unity EditorWindow example

ConsoleX

I was wondering recently why the Unity console window is so… spare. It lacks a lot of features you’d find in most modern logging implementations. Having some time on my hands at the moment, I thought I’d look into making a replacement. It’s maybe not the sexiest project, and I’m sure there are others out there (I haven’t looked) but it seemed like a good opportunity to dig into a deeper bit of editor scripting than I’ve done in the past.

My new console window has user-configurable channels, string filtering, Unity Debug.Log capturing and reporting, saving to a file and so on. As usual, pulling this off required a significant amount of time digging around with Google in forums, answers, random blogs etc. It might benefit someone to collect all of that in one place. So I won’t talk about feature design unless it overlaps with an interesting piece of Unity lore!

Initialization

The ConsoleX class (the X standing for Xtension or possibly Xtreme depending on your current caffeine levels) is derived from EditorWindow, and the script is placed in the Editor folder. Interesting note – it doesn’t have to be “Assets/Editor”, but any subfolder of Assets called Editor works too – Assets/MySubFolder/Editor, for instance.

The window is opened by the user from a menu option, so I have an Init function tied to a MenuItem attribute:

[MenuItem("Window/ConsoleX")]
static void Init()
{
     EditorWindow.GetWindow(typeof(ConsoleX));
}

Next, the window needs to initialize a few things. The best place to do that turns out to be the OnEnable function, which is called after Init whenever the window is reinitialized. This happens more often than you’d expect – the most common case is when play mode is ended.

void OnEnable()
{
     LoadResources();

     if (channels == null)
     {
          channels = new List();
          SetupChannels();
     }

     if (logs == null)
     {
          logs = new List();
     }

...

Notice that I’m checking for null objects in that snippet – I’ll explain why in the next section.

Serialization

When working with editor scripting, proper serialization of your objects is critical. The lack of it is the reason why, after going into and out of play mode, your shiny, incredibly useful new EditorWindow all of a sudden clears its data and starts spewing errors everywhere.

The first part of fixing this problem is of course understanding what is actually happening. It took me longer than it should have to find this essential blog post on the subject by Tim Cooper, but luckily it’s very thorough and I had my objects serializing within minutes. One thing to bear in mind that isn’t called out in that post is that static variables aren’t serialized. Public variables are serialized automatically, protected/private fields need the [SerializeField] attribute, but static vars aren’t serialized at all. That’s because serialization works on instantiated objects; static fields are not instanced. Something to keep in mind for your editor class data design.

Serialization is the reason we check for nulls in the OnEnable function – data is reserialized back into the class before OnEnable is called, so those fields may in fact be initialized with valid data at that point.

GUI issues

Laying out your EditorWindow GUI is mostly straightforward, but as soon as you want to do something a bit off the beaten path it can be tricky to get the controls looking exactly like you want them to.

You’ll probably need to use most or all of the following classes:

  • GUIStyle – Styles can be supplied per control, and determine exactly how the control will look.
  • GUI – Methods to add controls manually; that is, without any automatic placement.
  • GUILayout – Methods to add controls which are automatically positioned, and specify how they’re sized.
  • EditorGUI – Editor-focused version of the GUI class.
  • EditorGUILayout – Editor-focused version of the GUILayout class. This is the GUI control class I used the most.
  • EditorGUIUtility – Less frequently-used but still vital layout controls.
  • EditorUtility – Utility class used for a lot of miscellaneous functionality.

 

These classes all interrelate somewhat and you need to use most of them together. You usually won’t mix layout with non-layout classes though; in other words you’ll probably use GUI and EditorGUI together, or GUILayout and EditorGUILayout instead.

A good case study here would be my log lines themselves. I wanted to add icons before the text in some cases:

Log Example

It turns out there are several ways to do that!

The first thing I tried was using an EditorGUILayout.LabelField, a function which has many different versions and has two different ways to achieve my goal: you can use a GUIContent object and populate it with both an image and text, or you can pass two labels into the function at one time, either GUIContent or plain strings.

Using a single composite GUIContent object gave me a problem with long strings; the image part of the GUIContent would no longer be drawn when the text was longer than the width of the label. The second method of passing in two labels had several problems. First, you can only have one GUIStyle for both items. A pain, but in this case not a deal-breaker. Next, the gap between the first and second labels looks like this by default:

Log Example 2

It took a LOT of searching before I found the solution to that: EditorGUIUtility has a function called LookLikeControls which allows the prefix label width to be set:

EditorGUIUtility.LookLikeControls(kIconColumnWidth);

 

The final problem was annoying: I’m using a ScrollArea through EditorGUILayout to hold the log messages, and for some reason using EditorGUILayout.LabelField didn’t give me a horizontal scrollbar for long strings. This can be fixed using CalcSize on the style to find the desired width of the label, and a GUILayout option to properly size it (GUILayout options can be passed into all control functions):

Vector2 textSize = labelStyle.CalcSize(textContent);
EditorGUILayout.LabelField(iconContent, textContent, labelStyle,
                           GUILayout.MinWidth(kIconColumnWidth + textSize.x));

 

So after figuring all of that out, I decided I wanted a separate GUIStyle for the icons after all, and threw most of it away! This is what I ended up with:

EditorGUIUtility.LookLikeControls(iconColumnWidth);
Rect labelRect = EditorGUILayout.BeginHorizontal();

EditorGUILayout.PrefixLabel(logs[i].logIcon, logStyle, iconStyle);
GUILayout.Label(logs[i].logText, logStyle);

EditorGUILayout.EndHorizontal();

 

GUILayout.Label automatically sizes the label correctly so there’s no need to manually calculate and specify the control width. And using the separate PrefixLabel call allows me to specify a unique style for the icons. Done!

Transferring data between the game and the editor

Getting data from the editor to the game is trivial – editor classes can access game classes directly. Going the other way is a little more tricky as the inverse is not true.

My first solution was to log my data into a static buffer provided by a game-side class, and use OnInspectorUpdate polling to check the buffer and pull anything new over into the ConsoleX log. This worked fine, but OnInspectorUpdate is called ten times a second and is therefore introducing unnecessary overhead.

My friend Jodon (who runs his own company Coding Jar, check him out if you need any contracting work done!) suggested using C#’s events instead of polling. This works just as well and is much more efficient. I still need a game-side class, but now I define a delegate and an event in it. The main EditorWindow ConsoleX class uses another event (EditorApplication.playmodeStateChanged) to detect when the user enters play mode, then adds its own handler function to the game-side’s event.

The game-side class, ConsoleXLog:

public class ConsoleXLog : MonoBehaviour
{
     public delegate void ConsoleXEventHandler(ConsoleXLogEntry newLog);
     public static event ConsoleXEventHandler ConsoleXLogAdded;
     ...
}

…and in the editor class ConsoleX:

void PlayStateChanged()
{
     if (EditorApplication.isPlaying)
     {
          ConsoleXLog.ConsoleXLogAdded += LogAddedListener;
     }
}

It seems to take a few frames before the event is setup. If logs come in during that time, helpfully the ConsoleXLogAdded event reports as being null, and I can check that and store the logs locally on the game side until the editor class has added its handler.

I’ve still got some things to talk about – editor resources, EditorPrefs, and more – but I’ll leave that for another post. Hope this helps someone, someday!

, , , , , , ,

Comments are closed.


SetPageWidth