Compare commits

..

2 Commits

Author SHA1 Message Date
4680a0f3f4 Crosshair is back 2026-04-25 01:14:16 -04:00
3f6ef4b56c Simplify keyboard focus rule to just 'widget in front', full stop. 2026-04-24 20:17:08 -04:00
8 changed files with 112 additions and 134 deletions

1
.gitignore vendored
View File

@@ -52,3 +52,4 @@ GPF-output/**
__pycache__/
.clangd-query/
COMMIT.txt
CLAUDE.md

Binary file not shown.

View File

@@ -28,72 +28,53 @@ Our window management system, in order to keep things
simple, has to make some assumptions about how Luprex games
work. So, here are the rules.
The presumption is that most of the time, you're interacting
with the 3D world, and importantly, we assume that if you're
using keyboard and mouse, you're using the mouse to control
the camera - aka "mouselook."
Top-level UserWidgets get inserted into a "Root Canvas",
instead of into the viewport. The root canvas implements most
of the functionality of our window management system.
We also assume that as you interact with the 3D world, you
will occasionally be popping up GUI widgets that can
coexist with mouselook. These mouselook-compatible
widgets don't need a mouse pointer, they don't need you to
click on anything. They rely on buttons alone. We assume
that most of the GUI elements you interact with will be
mouselook-compatible, in order to allow you to stay
immersed in the 3D world.
The keyboard focus rule is simple: the UserWidget in front
according to the z-order gets keyboard focus. The window
management system will put focus on the front widget and
will keep it there. The *only* way to give a UserWidget
keyboard focus is to raise it to the front of the z-order.
But we also assume that there may be moments when you want
to pop up a very complicated widget, for example, a big
inventory management screen, for which a mouse pointer would
be very helpful. For occasions like these, a widget can
declare "ShowPointer".
Mouse movements events are handled in two different ways:
the system can shift between "mouselook" mode and
"point-and-click" mode. Every top-level UserWidget declares
whether it wants a mouse pointer or not. If the front
UserWidget wants a pointer, the system shifts into
point-and-click mode.
When one of these ShowPointer widgets is on the screen,
the entire system switches into point-and-click mode.
In point-and-click mode, the pointer is visible. Mouse
movements move the pointer. Mouse movements do *not* get
translated into mouselook.
In point-and-click mode, enhanced input mouse move events
cannot happen. In mouselook mode, widget OnMouseDown and
OnMouseMove events cannot happen. In both modes, you can
track mouse movement, but you have to use different
mechanisms.
Widgets have a z-order: one widget is always "in front." In
mouselook mode, only the front widget can get keyboard
focus. In mouselook mode, the window management system will
put focus on the front widget, and it will keep it there.
If you want some other widget to have focus, you'll have to
bring that widget to the front.
Widgets that declare that they want a pointer are
automatically put in front of widgets that don't want a
pointer. Because of this rule, the system essentially
separates into the "mouselook" layer underneath, and the
"point-and-click" layer on top. When the point-and-click
layer gets out of the way, then you can drive the 3D world.
In point-and-click mode, the keyboard focus rules differ.
When you raise a widget to the front, the window management
system will give it focus. But, if you click the mouse
pointer on a different widget - say, on a text box in a
different widget - keyboard focus can get transferred.
# State Variables of the Window Management System
When a ShowPointer window is on the screen, not only
does the system shift to point-and-click mode, but it
also keeps all ShowPointer windows in front of any
non-ShowPointer window.
Basically, you can think of the system as a 3D world with
its mouselook-compatible widgets as one layer, and the
point-and-click stuff as a second layer on top of that.
When the point-and-click layer gets out of the way, then you
can drive the 3D world.
# The Root Canvas
I have made an effort to keep the number of state variables
that you have to control to an absolute minimum, and to
concentrate them all in one place. That place is the "Root
Canvas Slot."
Typically, in Unreal, when you create a new top-level
widget, you insert it into the viewport using
AddToViewport. But to use our window management
system, you must instead insert top-level widgets into a
'root canvas', using AddWidgetToRoot.
widget, you insert it into the viewport using AddToViewport.
But to use our window management system, you must instead
insert top-level widgets into a 'root canvas', using
AddWidgetToRoot.
The main reason for the creation of the root canvas is that
it gives us a place to store window-management related
hints, and window-management related state.
The root canvas object attaches a RootCanvasSlot to each
The root canvas object associates a RootCanvasSlot to each
top-level widget. The RootCanvasSlot is a place where we can
store management-related hints for that widget. The contents
of the RootCanvasSlot include the following:
store window management-related hints for that widget. The
contents of the RootCanvasSlot include the following:
- `ShowPointer`: If true, this is a point-and-click widget.
When this widget is in front, the pointer is visible,
@@ -106,20 +87,23 @@ of the RootCanvasSlot include the following:
*this* widget are disabled.
- `BringToFrontCount`: Effectively, a timestamp indicating
the last time this window was brought to the front.
the last time this window was brought to the front. This
is the main factor determining the z-order of the widgets.
In addition, the top-level widget itself contains some
window-management related properties. Currently, these are:
- `DesiredFocusWidget`: Indicates which sub-widget, if any,
should be given focus.
should be given focus. When the system grants focus to
the frontmost UserWidget, the focus actually goes here.
That is all the state variables that control our new window
management system. If your blueprint is managing these
properties, then it is doing everything it needs to do.
There are deliberately *no other variables* that control our
new window management system. If your blueprint is managing
these properties, then it is doing everything it needs to
do.
The function SetWidgetWindowManagement can set all of these
properties in a single operation. That one function is all
variables in a single operation. That one function is all
you need to control the entire window management system.
# Handling Keyboard and Gamepad Buttons
@@ -177,7 +161,8 @@ This is all almost entirely unchanged from Unreal's default
behavior. We've only made two tiny tweaks: we send enhanced
input to widgets in front-to-back order, and, widgets
disable enhanced input by setting a flag instead of by
unregistering their input component.
unregistering their input component. Other than that, this
is all just stock unreal.
# Handling mouse buttons
@@ -185,9 +170,9 @@ Mouse buttons behave differently than keyboard buttons.
Widgets have an OnMouseDown handler. This is only active in
point-and-click mode. OnMouseDown only fires when three
things are true: the pointer is visible, the pointer is
inside the rectangle of a widget, and the widget is marked
hit-testable.
things are true: the system must be in point-and-click mode,
the pointer must be inside the rectangle of a widget, and
the widget must be marked hit-testable.
If no OnMouseDown event fires, or if OnMouseDown declares
the mouse down to be "not handled," then the mouse down
@@ -199,18 +184,18 @@ buttons. It can be mapped to an enhanced input event by the
input mapping context, and then from there, it can be
handled by any enhanced input event handler in a blueprint.
The upshot of all this is: if you want to think of a
mouse button as "just another button," then the
way to achieve that is to handle the mouse button using
an enhanced input handler.
The upshot of all this is: if you want to think of a mouse
button as "just another button," then the way to achieve
that is to *not* write an OnMouseDown handler, but instead,
to deal with it through enhanced input.
We have very slightly tweaked the default behavior of
unreal. If the pointer is visible, and you click on a
widget that is hit-testable, but which has no OnMouseDown
handler, we provide a default OnMouseDown behavior: we
bring the widget to the front. Because our system
grants keyboard focus to the widget in front, this
will grant focus, if the widget can accept it.
We have tweaked the default behavior of unreal. If the
system is in point-and-click mode, and you click on a widget
that is hit-testable, but which has no OnMouseDown handler,
we provide a default OnMouseDown behavior: we bring the
top-level UserWidget to the front. Because our system grants
keyboard focus to the widget in front, this will also grant
focus.
# Handling Mouse Movement
@@ -251,17 +236,19 @@ If you're using our Luprex window management system, there are
several things your blueprint should *NOT* do:
- DO NOT use SetKeyboardFocus, SetUserFocus, or any other
function with Set-Focus in the name. Instead, set
the DesiredFocusWidget inside a top-level widget, and our
window management system will decide who gets focus.
function with Set-Focus in the name. Instead, just
be aware that the frontmost UserWidget will get focus.
It can delegate that focus to one of its components by
setting DesiredFocusWidget.
- DO NOT use SetShowMouseCursor, or set the bShowMouseCursor
flag. Instead, set the ShowPointer flag in the configuration
of any top-level widget.
flag. Instead, set the ShowPointer flag in the
RootCanvasSlot of any top-level widget.
- DO NOT use UserWidget::RegisterInputComponent or
UserWidget::UnregisterInputComponent. These will be ignored.
Instead, set or unset the flag EnableEnhancedInput, which
UserWidget::UnregisterInputComponent. These will be
ignored. Instead, set or unset the flag
EnableEnhancedInput in the RootCanvasSlot, which
effectively does the same thing.
- DO NOT use SetZOrder. If you try, you will be overridden
@@ -279,7 +266,7 @@ several things your blueprint should *NOT* do:
window management system.
- DO NOT use AddToViewport or AddToPlayerScreen. Top level
widgets should be inserted into the root canvas using
UserWidgets should be inserted into the root canvas using
AddWidgetToRoot.
- DO NOT use SetIgnoreInput. You will be overridden. Our
@@ -287,7 +274,8 @@ several things your blueprint should *NOT* do:
system being active, turning it off would cause everything
to fail. However, a widget can handle keyboard or
character events, causing them not to be propagated, it
can also block events to any window lower in the z-order.
can also block events to any widget lower in the z-order,
and to the player controller and character.
- DO NOT use SetInputModeXXX. Be aware that there is no
"input mode" enum or "input mode" variable anywhere in

View File

@@ -6,6 +6,8 @@
#include "Layout/Geometry.h"
#include "Widgets/Layout/Anchors.h"
#include "Common.h"
#include "Engine/GameViewportClient.h"
#include "Slate/SGameLayerManager.h"
#include "Kismet/KismetTextLibrary.h"
#include "UObject/UObjectIterator.h"
@@ -246,17 +248,32 @@ FFormatArgumentData UlxFormatDataLibrary::FormatArgumentDataTransform(const FTra
FFormatArgumentData UlxFormatDataLibrary::FormatArgumentDataGeometry(const FGeometry &AutoConvertedValue, const FString &Name)
{
FVector2D LocalSize = AutoConvertedValue.GetLocalSize();
FVector2D AbsPos = AutoConvertedValue.GetAbsolutePosition();
FVector2D AbsSize = AutoConvertedValue.GetAbsoluteSize();
FVector2D UL = AutoConvertedValue.GetAbsolutePosition();
FVector2D LR = AutoConvertedValue.GetAbsolutePositionAtCoordinates(FVector2f(1.0f, 1.0f));
if (GEngine && GEngine->GameViewport)
{
TSharedPtr<IGameLayerManager> GameLayerManager = GEngine->GameViewport->GetGameLayerManager();
if (GameLayerManager.IsValid())
{
const FGeometry ViewportGeometry = GameLayerManager->GetViewportWidgetHostGeometry();
const FVector2D ViewportLocalSize = FVector2D(ViewportGeometry.GetLocalSize());
FVector2D ViewportPixelSize;
GEngine->GameViewport->GetViewportSize(ViewportPixelSize);
if (ViewportLocalSize.X > 0.0 && ViewportLocalSize.Y > 0.0)
{
const FVector2D PixelScale = ViewportPixelSize / ViewportLocalSize;
UL = ViewportGeometry.AbsoluteToLocal(UL) * PixelScale;
LR = ViewportGeometry.AbsoluteToLocal(LR) * PixelScale;
}
}
}
FFormatArgumentData Result;
Result.ArgumentValueType = EFormatArgumentType::Text;
Result.ArgumentName = Name;
Result.ArgumentValue = FText::Format(
INVTEXT("Geom(Local={0}x{1} Abs={2}x{3} Pos={4},{5})"),
FText::AsNumber(LocalSize.X), FText::AsNumber(LocalSize.Y),
FText::AsNumber(AbsSize.X), FText::AsNumber(AbsSize.Y),
FText::AsNumber(AbsPos.X), FText::AsNumber(AbsPos.Y));
INVTEXT("UL={0},{1} LR={2},{3}"),
FText::AsNumber(UL.X), FText::AsNumber(UL.Y),
FText::AsNumber(LR.X), FText::AsNumber(LR.Y));
return Result;
}
@@ -312,7 +329,7 @@ void UlxFormatDataLibrary::FormatLogMessageInternal(UObject *Context, ElxFormatL
double Now = FPlatformTime::Seconds();
FString Key = Context->GetClass()->GetName() + TEXT("::") + InPattern;
double &Last = LastLogTime.FindOrAdd(Key, 0.0);
if (Now - Last < 1.0)
if (Now - Last < 2.0)
{
return;
}

View File

@@ -131,13 +131,6 @@ UInputComponent* AlxPlayerControllerBase::GetWidgetInputComponent(UUserWidget *W
return Cast<UInputComponent>(Value);
}
void AlxPlayerControllerBase::RestoreFocusToFrontWidget(const UObject *Context)
{
// This will trigger UpdateInputMode to shift focus back to
// the front window, if the front window wants focus.
FromContext(Context)->RootCanvas->LastWidgetGrantedFocus = nullptr;
}
void AlxPlayerControllerBase::AddWidgetToRoot(UUserWidget *Widget)
{
if (!IsValid(Widget))
@@ -309,31 +302,19 @@ void AlxPlayerControllerBase::UpdateInputMode()
GameViewportClient->SetIgnoreInput(false);
// How we handle focus depends on whether we're showing the pointer.
// In pointer mode, we set focus to the desired state just once,
// and then we let the pointer control it from there on. In
// no-pointer mode, we set focus to the desired state and
// keep putting it back forever.
//
// If the user clicks the mouse on a focusable widget, the
// viewport client notifies us of that fact. We then focus the
// widget if possible.
//
if ((!ShowPointer) || (RootCanvas->LastWidgetGrantedFocus != Focus))
{
// We always put keyboard focus on whatever user widget is in
// front. If the front widget doesn't want keyboard focus,
// then we put keyboard focus on the viewport.
if (Focus)
{
if (TSharedPtr<SWidget> SlateFocus = Focus->GetCachedWidget())
{
SlateOperations.SetUserFocus(SlateFocus.ToSharedRef());
RootCanvas->LastWidgetGrantedFocus = Focus;
}
}
else
{
SlateOperations.SetUserFocus(ViewportWidgetRef);
RootCanvas->LastWidgetGrantedFocus = nullptr;
}
}
}

View File

@@ -65,10 +65,6 @@ public:
// FProperty so we always see the current value without caching it.
static class UInputComponent* GetWidgetInputComponent(class UUserWidget *Widget);
// Restore focus back to the window that is in front, if it wants focus.
UFUNCTION(BlueprintCallable, meta = (WorldContext = "Context"), Category = "Luprex|Root Canvas")
static void RestoreFocusToFrontWidget(const UObject *Context);
// Add a widget to the root canvas at ZOrder 0 with default slot flags.
UFUNCTION(BlueprintCallable, Category = "Luprex|Root Canvas",
meta = (DefaultToSelf = "Widget", HideSelfPin = "true"))

View File

@@ -101,8 +101,6 @@ void UlxRootCanvasPanel::BringToFront(UUserWidget *Widget)
UlxRootCanvasPanel *Panel = Cast<UlxRootCanvasPanel>(Slot->Parent);
if (!Panel) return;
Slot->BringToFrontCount = ++Panel->BringToFrontCounter;
// This refocuses the widget, even if it was already in front.
Panel->LastWidgetGrantedFocus = Panel;
}
void UlxRootCanvasPanel::SetWidgetWindowManagement(class UUserWidget *Widget,

View File

@@ -129,9 +129,6 @@ public:
meta = (DefaultToSelf = "Widget", ExpandEnumAsExecs = "Result"))
static UlxRootCanvasSlot *GetRootCanvasSlot(class UUserWidget *Widget, ElxSuccessOrWrongType &Result);
// The last widget whose focus request was granted.
TObjectKey<UWidget> LastWidgetGrantedFocus;
protected:
// We inherit most of our code from CanvasPanel. This causes the