Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
2026-07-01 Michael Hupp <mrtonik@mailbox.org>

* Source/NSMenuView.m (GSMenuCross)
(+[NSMenuView _mouseAt:aimsAtSubmenuFrame:fromApex:slack:]): New
point-in-triangle helper implementing Tognazzini's submenu "aim
triangle".
(-[NSMenuView _trackWithEvent:startingMenuView:]): Add an opt-in
directional replacement for the ad-hoc time/velocity heuristic that
decides when a just-opened submenu stops being protected. When the
GSMenuSubmenuAimTracking default is YES, keep the submenu open while the
pointer keeps aiming into it and switch the moment it leaves the wedge,
with a short park timeout (AIM_PARK_TICKS / AIM_MOVE_EPSILON) so a
pointer that stops over a sibling still switches. The previous behaviour
is preserved unchanged when the default is unset, and aim tracking is
confined to vertical menus so horizontal bars keep their instant switch.
* Headers/AppKit/NSMenuView.h: Document the new tracking mode and the
GSMenuSubmenuAimTracking default.
* Documentation/GuiUser/DefaultsSummary.gsdoc: Describe the new
default.
* Tests/gui/NSMenuView/aim_triangle.m: New regression test.

2026-07-03 Todd White <todd.white@thalion.global>

* Tests/gui/NSAffineTransform/basic.m:
Expand Down
13 changes: 13 additions & 0 deletions Documentation/GuiUser/DefaultsSummary.gsdoc
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,19 @@
vertical menu layout.
</p>
</desc>
<term>GSMenuSubmenuAimTracking</term>
<desc>
<p>
A boolean controlling how an open submenu decides when to give up
being kept open as the pointer moves over its parent menu. When NO
(the default) a simple time and horizontal-velocity heuristic is
used. When YES, Tognazzini's "aim triangle" is used instead: the
submenu stays open while the pointer keeps aiming into it (moving
diagonally toward it) and switches immediately on a deliberate move
off that path. This applies to vertical menus only; horizontal menu
bars keep their instant switch.
</p>
</desc>
<term>NSCommandKeys</term>
<desc>
<p>
Expand Down
13 changes: 10 additions & 3 deletions Headers/AppKit/NSMenuView.h
Original file line number Diff line number Diff line change
Expand Up @@ -387,16 +387,23 @@ APPKIT_EXPORT_CLASS
<item> It use periodic events to update the highlight state and
attach / detach submenus.
</item>
<item> The flag justAttachedNewSubmenu is set to YES when a new
submenu is attached. The effect is that the
<item> The flag justAttachedNewSubmenu is set to YES when a new
submenu is attached. The effect is that the
highlighting / attaching / detaching is supressed
for this menu. This is done so the user is given
a change to move the mouse pointer into the newly
attached submenu. Otherwise it would immediately
be removed as the mouse pointer move over another
item.

The logic for resetting the flag is rather adhoc.
By default the flag is reset with a simple time /
horizontal-velocity heuristic. Setting the user default
GSMenuSubmenuAimTracking to YES selects instead
Tognazzini's "aim triangle": the submenu stays open while
the pointer keeps aiming into it (moving diagonally toward
it), and a deliberate move off that path switches at once.
Aim tracking applies to vertical menus only; horizontal menu
bars keep the instant switch that suits them.
</item>

<item> the flag subMenusNeedRemoving means that we
Expand Down
120 changes: 118 additions & 2 deletions Source/NSMenuView.m
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,54 @@ - (void) _attachMenu: (NSMenu*)aMenu

@interface NSMenuView (Private)
- (BOOL) _rootIsHorizontal: (BOOL*)isAppMenu;
+ (BOOL) _mouseAt: (NSPoint)aPoint aimsAtSubmenuFrame: (NSRect)submenuFrame
fromApex: (NSPoint)apex slack: (CGFloat)slack;
@end

/* Signed area of the parallelogram spanned by ab and ac; its sign tells us
which side of the directed line a->b the point c lies on. Used only by
+_mouseAt:aimsAtSubmenuFrame:fromApex:slack: below. */
static inline CGFloat
GSMenuCross(NSPoint a, NSPoint b, NSPoint c)
{
return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
}

@implementation NSMenuView (Private)
/* Tognazzini's "aim triangle": while a submenu is open we let the user steer
toward it along a diagonal without the submenu closing. The triangle has its
apex where the pointer entered the parent item and its base on the submenu
edge facing the parent; as long as the pointer stays inside, it is still
plausibly heading for the submenu. We test containment with three edge
cross-products: a point is inside when it lies on the same side of all three
directed edges. The all-same-sign check is orientation independent, so it is
immune to GNUstep's flipped screen coordinates and to the triangle winding. */
+ (BOOL) _mouseAt: (NSPoint)aPoint aimsAtSubmenuFrame: (NSRect)submenuFrame
fromApex: (NSPoint)apex slack: (CGFloat)slack
{
CGFloat nearX;
NSPoint upper, lower;
CGFloat d1, d2, d3;
BOOL hasNeg, hasPos;

/* Base sits on the submenu's vertical edge nearest the parent. We pick the
side from the apex, not the moving pointer, so the wedge does not flip
mid-gesture; this also copes with submenus that opened to the left because
shiftOnScreen nudged them away from a screen edge. */
nearX = (apex.x < NSMidX(submenuFrame)) ? NSMinX(submenuFrame)
: NSMaxX(submenuFrame);
/* Slack fattens the wedge tip so the corners are not razor thin. */
upper = NSMakePoint(nearX, NSMaxY(submenuFrame) + slack);
lower = NSMakePoint(nearX, NSMinY(submenuFrame) - slack);

d1 = GSMenuCross(aPoint, apex, upper);
d2 = GSMenuCross(aPoint, upper, lower);
d3 = GSMenuCross(aPoint, lower, apex);
hasNeg = (d1 < 0.0) || (d2 < 0.0) || (d3 < 0.0);
hasPos = (d1 > 0.0) || (d2 > 0.0) || (d3 > 0.0);
return !(hasNeg && hasPos);
}

- (BOOL) _rootIsHorizontal: (BOOL*)isAppMenu
{
NSMenu *m = _attachedMenu;
Expand Down Expand Up @@ -1501,6 +1546,15 @@ - (void) performActionWithHighlightingForItemAtIndex: (NSInteger)index
#define MOVE_THRESHOLD_DELTA 2.0
#define DELAY_MULTIPLIER 10

/* Aim-triangle tracking (opt-in via GSMenuSubmenuAimTracking). Periodic events
fire every 0.01s, so AIM_PARK_TICKS ~= 0.3s of the pointer sitting still over
a sibling item before we give up protecting the just-opened submenu. Any
movement past AIM_MOVE_EPSILON pixels since the last checkpoint counts as the
user still steering and rearms the timer, so even a slow, deliberate arc keeps
the submenu open. */
#define AIM_PARK_TICKS 30
#define AIM_MOVE_EPSILON 3.0

- (BOOL) _executeItemAtIndex: (int)indexOfActionToExecute
removeSubmenu: (BOOL)subMenusNeedRemoving
{
Expand Down Expand Up @@ -1551,6 +1605,22 @@ - (BOOL) _trackWithEvent: (NSEvent*)event
int firstIndex = -1;
NSInterfaceStyle style =
NSInterfaceStyleForKey(@"NSMenuInterfaceStyle", self);
/* Aim-triangle state. wedgeApex is the pointer position (screen coords) when
the current submenu opened; aimRef is the last checkpoint (window base
coords) for the park timer. We read the defaults once per invocation - the
method recurses per menu level, but NSUserDefaults caches, and a live
`defaults write` simply takes effect on the next menu interaction. The
wedge is confined to vertical menus: for a horizontal bar, switching between
top-level items is a sideways move that should stay instant. */
NSUserDefaults *menuDefaults = [NSUserDefaults standardUserDefaults];
BOOL useAimTriangle = [menuDefaults boolForKey: @"GSMenuSubmenuAimTracking"]
&& ![self isHorizontal];
/* Slack widens the wedge base past the submenu corners so the tip is not
razor thin; 8px keeps the corners reachable without letting the wedge
swallow neighbouring items. */
CGFloat aimSlack = 8.0;
NSPoint wedgeApex = NSZeroPoint;
NSPoint aimRef = NSZeroPoint;
NSEvent *original;
NSEventType type;

Expand Down Expand Up @@ -1687,8 +1757,48 @@ - (BOOL) _trackWithEvent: (NSEvent*)event
*/
if (justAttachedNewSubmenu && index != -1
&& index != _highlightedItemIndex)
{
if (location.x - lastLocation.x > MOVE_THRESHOLD_DELTA)
{
if (useAimTriangle)
{
/* Keep the just-opened submenu protected for as long as the
pointer is still aiming into it (inside the wedge). Leaving
the wedge - e.g. a deliberate vertical move to a sibling -
drops protection at once so the sibling takes over. This
replaces the old velocity/time guess with the direction the
user is actually pointing. */
NSWindow *submenuWindow = [[_attachedMenu attachedMenu] window];
NSPoint pointerInScreen = [_window convertBaseToScreen: location];

if (submenuWindow == nil
|| ![NSMenuView _mouseAt: pointerInScreen
aimsAtSubmenuFrame: [submenuWindow frame]
fromApex: wedgeApex
slack: aimSlack])
{
justAttachedNewSubmenu = NO;
}
else
{
/* Still inside the wedge. Guard against the pointer just
parking over a sibling for good: if it has not moved
since the last checkpoint for AIM_PARK_TICKS, give up;
real movement (even a slow arc) resets the checkpoint. */
CGFloat dx = location.x - aimRef.x;
CGFloat dy = location.y - aimRef.y;

if (dx * dx + dy * dy
> AIM_MOVE_EPSILON * AIM_MOVE_EPSILON)
{
aimRef = location;
delayCount = 0;
}
else if (++delayCount >= AIM_PARK_TICKS)
{
justAttachedNewSubmenu = NO;
}
}
}
else if (location.x - lastLocation.x > MOVE_THRESHOLD_DELTA)
{
delayCount ++;
if (delayCount >= DELAY_MULTIPLIER)
Expand Down Expand Up @@ -1818,6 +1928,12 @@ - (BOOL) _trackWithEvent: (NSEvent*)event
[self attachSubmenuForItemAtIndex: index];
justAttachedNewSubmenu = YES;
delayCount = 0;
/* Anchor the wedge apex where the pointer entered this item,
i.e. where the submenu just opened. Screen coords match the
submenu window frame we test against; aimRef stays in base
coords alongside the raw pointer location. */
wedgeApex = [_window convertBaseToScreen: location];
aimRef = location;
}
}

Expand Down
Empty file added Tests/gui/NSMenuView/TestInfo
Empty file.
68 changes: 68 additions & 0 deletions Tests/gui/NSMenuView/aim_triangle.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/* Regression tests for the submenu aim-triangle geometry (Tognazzini's wedge)
used by -[NSMenuView _trackWithEvent:startingMenuView:] when the user default
GSMenuSubmenuAimTracking is enabled.

Copyright (C) 2026 Free Software Foundation, Inc.

This file is part of the GNUstep GUI Library.

This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2 of the License, or (at your option) any later version.
*/

#import "ObjectTesting.h"

#import <Foundation/NSGeometry.h>
#import <AppKit/NSMenuView.h>

/* _mouseAt:aimsAtSubmenuFrame:fromApex:slack: is a private helper; redeclare it
so the test can exercise the pure geometry without driving a live menu. */
@interface NSMenuView (AimTriangleTesting)
+ (BOOL) _mouseAt: (NSPoint)aPoint aimsAtSubmenuFrame: (NSRect)submenuFrame
fromApex: (NSPoint)apex slack: (CGFloat)slack;
@end

int main(void)
{
START_SET("NSMenuView submenu aim triangle")

/* A submenu that opened to the right of its parent, in y-up screen coords.
The parent item (and hence the apex) sits to its left, near the top. */
NSRect sub = NSMakeRect(200.0, 100.0, 100.0, 100.0); /* x:200..300 y:100..200 */
NSPoint apex = NSMakePoint(190.0, 190.0);

/* Steering diagonally toward the submenu keeps the pointer inside the wedge. */
pass([NSMenuView _mouseAt: NSMakePoint(196.0, 150.0)
aimsAtSubmenuFrame: sub fromApex: apex slack: 4.0],
"a diagonal move toward the submenu stays inside the wedge");

/* Deliberately veering above or below the aim line leaves the wedge at once,
so a sibling item can take over. */
pass(![NSMenuView _mouseAt: NSMakePoint(192.0, 210.0)
aimsAtSubmenuFrame: sub fromApex: apex slack: 4.0],
"a move above the aim line leaves the wedge");
pass(![NSMenuView _mouseAt: NSMakePoint(192.0, 150.0)
aimsAtSubmenuFrame: sub fromApex: apex slack: 4.0],
"a near-vertical move down to a sibling leaves the wedge");

/* Slack widens the wedge just past the submenu corners: this point is inside
only because of the 4px slack, and outside with no slack. */
pass([NSMenuView _mouseAt: NSMakePoint(199.5, 202.0)
aimsAtSubmenuFrame: sub fromApex: apex slack: 4.0],
"slack keeps a point just past the top corner inside");
pass(![NSMenuView _mouseAt: NSMakePoint(199.5, 202.0)
aimsAtSubmenuFrame: sub fromApex: apex slack: 0.0],
"without slack the same point falls outside");

/* A submenu that opened to the left (e.g. nudged by shiftOnScreen): the apex
is now to its right, and the wedge base must snap to the right edge. */
pass([NSMenuView _mouseAt: NSMakePoint(304.0, 150.0)
aimsAtSubmenuFrame: sub fromApex: NSMakePoint(310.0, 150.0) slack: 4.0],
"a left-opening submenu aims at the right edge");

END_SET("NSMenuView submenu aim triangle")

return 0;
}