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

View File

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

View File

@@ -131,13 +131,6 @@ UInputComponent* AlxPlayerControllerBase::GetWidgetInputComponent(UUserWidget *W
return Cast<UInputComponent>(Value); 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) void AlxPlayerControllerBase::AddWidgetToRoot(UUserWidget *Widget)
{ {
if (!IsValid(Widget)) if (!IsValid(Widget))
@@ -309,31 +302,19 @@ void AlxPlayerControllerBase::UpdateInputMode()
GameViewportClient->SetIgnoreInput(false); GameViewportClient->SetIgnoreInput(false);
// How we handle focus depends on whether we're showing the pointer. // We always put keyboard focus on whatever user widget is in
// In pointer mode, we set focus to the desired state just once, // front. If the front widget doesn't want keyboard focus,
// and then we let the pointer control it from there on. In // then we put keyboard focus on the viewport.
// 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))
{
if (Focus) if (Focus)
{ {
if (TSharedPtr<SWidget> SlateFocus = Focus->GetCachedWidget()) if (TSharedPtr<SWidget> SlateFocus = Focus->GetCachedWidget())
{ {
SlateOperations.SetUserFocus(SlateFocus.ToSharedRef()); SlateOperations.SetUserFocus(SlateFocus.ToSharedRef());
RootCanvas->LastWidgetGrantedFocus = Focus;
} }
} }
else else
{ {
SlateOperations.SetUserFocus(ViewportWidgetRef); SlateOperations.SetUserFocus(ViewportWidgetRef);
RootCanvas->LastWidgetGrantedFocus = nullptr;
}
} }
} }

View File

@@ -65,10 +65,6 @@ public:
// FProperty so we always see the current value without caching it. // FProperty so we always see the current value without caching it.
static class UInputComponent* GetWidgetInputComponent(class UUserWidget *Widget); 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. // Add a widget to the root canvas at ZOrder 0 with default slot flags.
UFUNCTION(BlueprintCallable, Category = "Luprex|Root Canvas", UFUNCTION(BlueprintCallable, Category = "Luprex|Root Canvas",
meta = (DefaultToSelf = "Widget", HideSelfPin = "true")) meta = (DefaultToSelf = "Widget", HideSelfPin = "true"))

View File

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

View File

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