Building a Solid Foundation for Focus and Complex Controls
November was a busy month. A significant chunk of our time went to the layouting system to reduce its memory footprint, improve performance, and significantly reduce line-count, but we also managed to spend some time on controls and we're excited to share some of the progress there.
The Problem
Building UI tooling is often a story of solving the obvious problems first and then running head-first into the ones you don’t notice until you start building real, interactive controls. It’s one thing to get buttons, sliders, and layouting running smoothly, but once you begin layering behaviors, mixing interaction patterns, and creating controls that need to feel native, the invisible details start to matter a lot more.
Things like keyboard navigation, focus handling, and how compound controls behave under different user inputs are the glue that makes a UI feel solid. And as we’ve been pushing PanGui further - building more complex controls, more nested interaction patterns, and more editor-style workflows - we kept running into problems without obvious solutions:
- Popups need to close when something outside takes focus.
- Keyboard navigation needs to be scoped inside popups, so Tab doesn't escape out.
- Compound controls need to appear focused when any of their internal elements have focus.
A color field is a good example of this. It is one control, but made up of several interactable parts: a color swatch, a hex input, an alpha input, and a popup color picker. And when any of those parts are focused, the whole color field should appear focused. Even when the popup color picker is open and focused, the color field should still appear focused.
That naturally led us to the core topic of this update: building a better foundation for focus handling in PanGui. To do so, we have introduced two new concepts: parent pointers on interactables, and focus navigation scopes.
Interactable Hierarchies
To solve the problem of compound controls, we introduced the concept of interactable hierarchies. By default, interactables follow the layout hierarchy, but you can also manually link them.
This allows a parent control to query the state of its children. For our color field, we want the outer container to appear focused if any part of the control (including the popup) has focus.
1// Check if the patch or any of its children (including the dropdown) has focus2bool hasFocus = colorPickerInteractable.HasFocus() || colorPickerInteractable.OnChildAny(Interactions.HasFocus);34Color4F strokeColor = hasFocus ? Color4F.White : Color4F.Black;
Now, when you interact with any part of the color field, the whole control appears focused - exactly as you'd expect.
Focus Navigation Scopes
When a popup opens, we want the "Tab" key to cycle through the elements inside the popup, rather than jumping to the next element in the main window behind it.
This is where focus navigation scopes come in. They create a temporary "fence" around a set of interactables. Every interactable belongs to a scope, and only one scope is active at a time. This is perfect for popups, context menus, forms, and anywhere else you want to constrain focus navigation.
1if (dropdown.IsOpen())2{3 using (dropdown.EnterDropdown())4 using (gui.EnterFocusNavigationScope())5 {6 // ... draw color picker contents ...7 }8}
With just that one line, navigation is constrained to the popup. You can also query the current scope to override its navigation logic entirely. You can of course always switch between different navigation scopes as needed, introduce custom navigation logic - such as spatial rect-based focus navigation - and we've also added next and previous pointers to interactables, letting you manually link specific elements together.
But more will have to wait for the documentation we'll be shipping with the beta, and you are also of course welcome to come and ask us questions on Discord.
Popups
With interactable hierarchies and focus scopes in place, building a popup becomes surprisingly simple. In PanGui, a popup isn't a special system type. It's just a standard Interactable that we use to track the "open" state. If the popup's interactable (or any of its children) has focus, the popup is "open". We introduced a Popup helper struct for this pattern that also handles things like anchoring and screen bounds constraints.
1Popup popup = gui.GetPopup();23if (button.OnInteract())4 popup.Open();56if (popup.IsOpen())7{8 using (popup.EnterPopup())9 {10 gui.Button("I'm inside a popup!");11 }12}
Bringing it all together: the Color Picker
Combining these features allows us to build a robust, accessible Color Field that behaves exactly as a user expects. Here is the simplified logic for an example ColorField control:
1public static Control<Color4F> ColorField(this Gui gui, Color4F color)2{3 using (gui.StyledNode(ColorFieldId).Enter())4 {5 // The interactable color patch that shows the current color6 LayoutNode patch = gui.Node(20, Size.Expand()).Margin(4);7 Interactable patchInteractable = patch.GetInteractable();89 // Make it focusable, and respond to pointer down10 patchInteractable.MakeFocusable(Interactions.PointerDown);1112 Popup popup = gui.GetPopup();13 14 // Make the popup a child of the color patch interactable that opens it15 popup.Interactable.SetParent(patchInteractable);16 17 // Triggered by clicking, pressing or when focused and pressing Enter/Space while focused18 if (patchInteractable.OnInteract())19 popup.Open();2021 // Draws the popup if open22 if (popup.IsOpen())23 {24 using (popup.EnterPopup())25 using (var scope = gui.EnterFocusNavigationScope()) // Scope navigation26 {27 // Set the scope active when opened28 if (popup.OnOpened())29 scope.SetActive();3031 // Draw the complex picker (SV, Hue, Alpha sliders)32 color = gui.ColorPicker(color);33 }34 }3536 // Visual feedback based on hierarchy focus37 bool isFocused = patchInteractable.HasFocus() | patchInteractable.OnChildAny(Interactions.HasFocus);38 gui.DrawSdRect(patch, 3)39 .SolidColor(color)40 .Stroke(isFocused ? Color4F.White : Color4F.Black, 1);4142 // ... draw text inputs ...4344 return new Control<Color4F> { Value = color /*...*/ };45 }46}
This approach keeps the code declarative and simple, while solving complex UI behavior problems that usually require a lot of boilerplate in other systems.
Other Improvements
We've also added a few quality-of-life features to control the global state of the GUI, making it easier to handle modal states or disabled UIs:
1gui.SetGuiEnabled();2gui.SetGuiDisabled();3gui.SetGuiReadonly();
That's it for this update! We're getting closer to an open beta, and we can't wait to see what you will all build with these new tools.