Skip to content

Fix X11 cursor visibility on window enter - #100

Open
Abendlied wants to merge 10 commits into
Try:masterfrom
Abendlied:fix-cursor-visibility-clean
Open

Abendlied wants to merge 10 commits into
Try:masterfrom
Abendlied:fix-cursor-visibility-clean

Conversation

@Abendlied

Copy link
Copy Markdown

Fix for Linux on X11.
The native mouse cursor remained visible in-game even though CursorShape::Hidden was requested during game startup.

The initial PR https://github.com/Try/Tempest/pull/98/changes is flawed. It adressed the symptom but violated the framework cursor management model completely.

Hi, @Abendlied and thanks for PR!

Unfortunately, in current state this is not a working code. In engine, it's allowed to call setCursorShape at any point. For example have LineEdit under the mouse and call setCursorShape on the parent window - this swap cursor incorrectly. There can be multiple windows at once, and so on.

In principle, one way to actually fix this is to reevaluate EventDispatcher::mouseOver, if widget is created or widget's geometry is changed.

Now in the new approach I try to follow the recommendation.

EventDispatcher did not know the mouse position until after receiving a MotionNotify. When the mouse was already over the game window, and no mouse movement received, the cursor state was not updated. This happened every time due to fullscreen.

The new implementation now gets the initial pointer position from EnterNotify and reevaluates the hovered widget when the window receives focus. The hover state is also reevaluated with widget geometry change.
Window::setCursorShape() stays how it is, cursor management is now in EventDispatcher.

Tested on Linux with X11:
Cursor is hidden immediately on startup of the game and remains hidden as long as OpenGothic has focus. Cursor becomes visible when losing focus, e.g. alt-tabbing. Cursor is hidden again when focus comes back to OpenGothic for hovering.

Comment thread Engine/ui/widget.cpp Outdated
w->update();
w->applyLayout();
}
SystemApi::dispatchMouseReevaluate(*implTrieRoot(this));

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling to reevaluate whole tree, inside Widget::setGeometry just like so, can be huge performance issue - better not touch for now.

Comment thread Engine/system/eventdispatcher.cpp Outdated
}

void EventDispatcher::implSetMouseOver(const std::shared_ptr<Widget::Ref> &wptr,MouseEvent& orig) {
void EventDispatcher::implSetMouseOver(const std::shared_ptr<Widget::Ref> &wptr,MouseEvent& orig,bool force) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need force here?

Comment thread Engine/system/api/x11api.cpp Outdated
break;
}
case EnterNotify: {
SystemApi::dispatchMouseReevaluate(cb,Point(xev.xcrossing.x,xev.xcrossing.y));

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's good call to monitor EnterWindowMask, but then it suppose to be something like SystemAPI::dispatchMouseEnter/SystemAPI::dispatchMouseLeave

Comment thread Engine/ui/widget.cpp Outdated
if(astate.disable>0)
implDisableSum(w,astate.disable);
lay->applyLayout();
SystemApi::dispatchMouseReevaluate(*implTrieRoot(this));

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same problem as in setGeometry: construction of complex UI will hammer such reevaluation

Comment thread Engine/system/eventdispatcher.cpp Outdated
dispatchMouseReevaluate(wnd,mousePosition);
}

void EventDispatcher::dispatchMouseReevaluate(Widget& wnd, Point pos) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so far it look like hallucinated move-event... lets focus on EnterNotify first.

@Try

Try commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Just an observation:
When tested on windows, OS consistently send mouse move on just created window (even if mouse if idle), effectively hiding the problem.

@Abendlied

Copy link
Copy Markdown
Author

Fix X11 cursor visibility when entering a window with an already-idle pointer.

On X11, MotionNotify is not guaranteed when a window becomes active under the current pointer position. This could leave the mouseOver state stale and the native cursor visible even if the hovered widget requested CursorShape::Hidden.

Calling to reevaluate whole tree, inside Widget::setGeometry just like so, can be huge performance issue - better not touch for now.

Thank you for the feedback, I have tried to do it in a less performance expensive way and follow your suggestion where (and if) I understood it. The mouse reevaluation mechanism from widget creation, geometry, and visibility changes from earlier where therefore removed again. So it avoids repeatedly reevaluating the complete widget tree.

Why do you need force here?

I honestly do not know how to do it better, please suggest a good way if force is not an option.
on X11, returning to the window can send an EnterNotify event even when the framework still has the same widget in mouseOver. The widget has not changed, but the native cursor may have become visible already while the pointer was outside of the window. Calling implExcMouseOver() again applies the current cursor shape of the widget.
This can be tested by Alt-Tabbing back and forth with a non-moving mouse.

so far it look like hallucinated move-event... lets focus on EnterNotify first.

I tried to follow the suggestion by adding EnterWindowMask to the X11 event mask and otherwise focused on EnterNotify first: The approach now is to handle EnterNotify through dispatchMouseEnter() . It also forces cursor state re-application on window entry even when hovered widget itself has not changed. This follows normal system behaviour I think.

Just an observation:
When tested on windows, OS consistently send mouse move on just created window (even if mouse if idle), effectively hiding the problem.

I think with your comment now it does make sense to keep the fix locally on the X11 event path, which I tried in this attempt.

@Abendlied
Abendlied force-pushed the fix-cursor-visibility-clean branch from 659f8e1 to 270e884 Compare September 11, 2026 14:32
@Try

Try commented Sep 13, 2026

Copy link
Copy Markdown
Owner

The widget has not changed, but the native cursor may have become visible already while the pointer was outside of the window.

If I've understood you right, this is a system-side bug, isn't it? Basically you have the MainWindow, it assigned to invisible cursor, but still you you intent to apply cursor second time, just to poke X11?

Comment thread Engine/system/api/x11api.cpp Outdated
break;
}
case EnterNotify: {
SystemApi::dispatchMouseEnter(cb,Point(xev.xcrossing.x,xev.xcrossing.y));

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it be same as WM_ACTIVATE ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is not a literal one-to-one X11 message called WM_ACTIVATE. The X11 focus events that come close would be:

FocusIn = WM_ACTIVATE(WA_ACTIVE)
FocusOut =WM_ACTIVATE(WA_INACTIVE)

By contrast, EnterNotify / LeaveNotify are pointer events, not focus events.

So I agree with you, same as WM_ACTIVATE means it would probably be better to change it to FocusIn, not EnterNotify. I could test that soon and report back.

@Abendlied

Copy link
Copy Markdown
Author

The widget has not changed, but the native cursor may have become visible already while the pointer was outside of the window.

If I've understood you right, this is a system-side bug, isn't it? Basically you have the MainWindow, it assigned to invisible cursor, but still you you intent to apply cursor second time, just to poke X11?

Sort of, poking fixes the problem. X11 behaves according to the documentation, I would probably not call it a system-side bug - but it is definitely a behaviour Windows does not have.
X11 basically defines a window's cursor and says that cursor is used while the pointer is in that window. X11 never "forgets" the window cursor, it remains defined. The application however gets into a state where logical cursor state and native cursor state diverge. Therefore I am leaning towards Tempest state sync gap that we need to close, since X11 protocol will likely not change.

Tempest does not currently reapply the logical cursor state when activation changes while mouseOver remains unchanged.

@Abendlied

Copy link
Copy Markdown
Author

Current attempt: keep EnterNotify for establishing hover, move cursor reapplication to FocusIn, remove force :).

EnterNotify establishes the hovered widget from the X/Y position, while FocusIn re-applies the native cursor for the already-hovered widget when the window becomes active again. This is the X11 equivalent of Windows WM_ACTIVATE path.


unrelated, consistency nitpick:
eventdispatcher.h uses the parameter name s.
eventdispatcher.cpp uses wptr.
I normalised them to wptr, OK for you?

@Try

Try commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Cross checking withwindows:

    case WM_ACTIVATE:{  // essentially same thing as EnterNotify in X11
      SetCursor(cb->cursorShape()); // restore cursor
      ...
      }

Here one thing windows-build is bit buggy, as it "restores" cursor, only according to one relevant to the window, not to whole UI tree. This can be fixed by storing resolved cursor at implShowCursor. And add some dedicated getter, for example implResolvedCursor.

Tempest does not currently reapply the logical cursor state when activation changes while mouseOver remains unchanged.

If we have store of cursor state at implShowCursor, then it's simple - as it again and poke X11, right? You can also account for cursor been moved, by running EventDispatcher::dispatchMouseMove - that's fine.

Probably no need in dedicated EventDispatcher::dispatchMouseEnter - it's same as mouse-move. You may either remove it or call dispatchMouseEnter from it right away and avoid copy-paste.

I normalised them to wptr, OK for you?

You don't have to, but consistency is nice :)

@Abendlied

Copy link
Copy Markdown
Author

Cursor state is now restored from the stored resolved cursor state rather than by forcing a new hover calculation.

implShowCursor stores the resolved cursor.
The cursor is restored when the window regains focus. WM_ACTIVATE now uses that stored value. EventDispatcher::dispatchMouseEnter removed because EventDispatcher::dispatchMouseMove can be used.

test limitation: tested on Linux. Cannot test on Windows currently, please help with verification.

Comment thread Engine/ui/window.h Outdated
~Window() override;

void setWindowTitle(const char* utf8);
CursorShape implResolvedCursor() const { return resolvedCursor; }

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has to be private

Comment thread Engine/system/eventdispatcher.cpp Outdated
}
focusLast.reset();

if(auto w = mouseOver.lock()) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this code needed?

In windows you calling one-line:

SetCursor(cb->implResolvedCursor());

OK

On X11 you not doing it, but instead:

      case EnterNotify: {
        ...
        SystemApi::dispatchMouseMove(cb,e);
        break;
        }

Why not using same approach on both systems?

      case WM_ACTIVATE / EnterNotify: {
        ...
        SystemApi::dispatchMouseMove(cb,e);
        SetCursor(cb->implResolvedCursor());
        break;
        }

And why all of this code in EventDispatcher::dispatchFocus ? What use-case it covers?

Drop the cursor block from EventDispatcher::dispatchFocus - it was a
second writer of cursor state next to implExcMouseOver, and it handled
pointer state inside a keyboard-focus path.

Both backends now do the same two steps at their activation event:
dispatch a mouse move to recompute mouseOver, then re-apply the resolved
shape. Window::implResolvedCursor is private; backends reach it through
a protected SystemApi::cursorShape accessor, since friendship is not
inherited by WindowsApi/X11Api.
@Abendlied

Abendlied commented Sep 18, 2026

Copy link
Copy Markdown
Author

This [implResolvedCursor()] has to be private

Agree, private now. In the previous version it was the poor attempt to compensate friendship not being inherited and friend class SystemApi alone does not reach WindowsApi and X11Api. Solved with protected static SystemApi::cursorShape(Tempest::Window&) that the backend can call now.

Why this code needed?
And why all of this code in EventDispatcher::dispatchFocus? What use-case it covers?

It simply duplicated implExcMouseOver making a second writer of cursor state next to already existing implExcMouseOver. It handled pointer state. But it is a bad solution for non-Windows, so dispatchFocus block deleted.

In windows you calling one-line:

SetCursor(cb->implResolvedCursor());

OK

On X11 you not doing it, but instead:

case EnterNotify: {
        ...
        SystemApi::dispatchMouseMove(cb,e);
        break;
        }

Why not using same approach on both systems?

If we make both steps on both OS backends for the same behaviour it can be made symmetrical. Two steps at the activation event: dispatchMouseMove to recompute mouseOver, then re-apply the resolved shape.

Rationale:
Step1 alone is not enough on Windows because implExcMouseOver only pushes cursor when mouseOver changes., and on WM_ACTIVATE the hovered widget has not changed, so the OS side reset cursor stays wrong.
Step2 alone is not enough on X11 because mouseOver at startup is null, so resolvedCursor is the default cursor Arrow.

note on X11, Step2 really is a no-op due to XDefineCursor being sticky per window, unlike SetCursor. But kept it so both paths are now identically, yay!

tests pass on linux, please assist with Windows verification once this solution becomes acceptable.

@Try

Try commented Sep 19, 2026

Copy link
Copy Markdown
Owner

@Abendlied PR looks good; I've pushed a few alignment commits: to make sure, that engine works the same on Window/Mac.
Can you please double check, if I didn't broke anything on your end with X11?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants