Menu: touch swipe-to-scroll with tap disambiguation, fling, wider scrollbar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
FoxyzandClaude Opus 5.5 committed 2026-10-02 06:05:40 +03:00
1 parent b5e7241ac1
commit 35b7e2a44f
5 files changed
+205 -12

No files matched your search

+1 -1
View File
@@ -174,7 +174,7 @@ device only; the editor has an XR simulator for dev. Trackers/action map: see `x
0.6 cm presses, backing out past 1.8 cm releases (hysteresis; entering from the side/behind 0.6 cm presses, backing out past 1.8 cm releases (hysteresis; entering from the side/behind
never presses) — buttons fire on release like a real tap, sliders drag while touching. never presses) — buttons fire on release like a real tap, sliders drag while touching.
Farther away = LASER cursor, trigger clicks/drags (edge-triggered: a trigger already held Farther away = LASER cursor, trigger clicks/drags (edge-triggered: a trigger already held
never clicks). Thumbstick Y while hovering = mouse wheel (scroll containers). One Godot mouse never clicks). Thumbstick Y while hovering = mouse wheel (scroll containers); touch on a scrollable area = swipe-vs-tap (press held back, > 6 mm travel scrolls + flings, else a tap is sent at the contact point). One Godot mouse
per viewport → one owning hand (press holder ≻ touching hand ≻ laser). Cursor dot per hand per viewport → one owning hand (press holder ≻ touching hand ≻ laser). Cursor dot per hand
(shrinks as the tip nears = proximity cue), haptic tick on press, synthesized click (shrinks as the tip nears = proximity cue), haptic tick on press, synthesized click
(code-generated `AudioStreamWav`, no asset). Menu actions run DEFERRED (never from inside (code-generated `AudioStreamWav`, no asset). Menu actions run DEFERRED (never from inside
+10 -2
View File
@@ -155,6 +155,13 @@ TERMINOLOGY (owner-corrected — use precisely):
on owner gain/loss; owner's stick Y beyond 0.3 → wheel notches at 14/s (`WheelUp/Down`, press + on owner gain/loss; owner's stick Y beyond 0.3 → wheel notches at 14/s (`WheelUp/Down`, press +
release). Cursor dot per hand (touch: blue, shrinks as the tip nears; laser: orange; non-owner release). Cursor dot per hand (touch: blue, shrinks as the tip nears; laser: orange; non-owner
half-transparent). Buttons fire on release (Godot default) = tap semantics. half-transparent). Buttons fire on release (Godot default) = tap semantics.
**Swipe vs tap (owner: scrollbar too small to grab, 2026-10-02)**: a touch landing on a
vertically scrollable `ScrollContainer` (found by walking up from `GuiGetHoveredControl()`; a
`Range` — slider/scrollbar — on the way keeps the immediate press) is held back. Tip travel
> `SwipeThreshold` 16 px (~6 mm) before release = swipe: `ScrollVertical` follows the tip,
release flings (velocity smoothed at 30/s, decay `FlingDecay` 5/s, stops below 30 px/s or at an
end), and NO click is sent. Released sooner = tap: motion + press + release sent together at
the contact point. While held back, mouse events carry no button mask.
- **MainMenu.cs** — the menu content (`PanelContainer`, built in code, own `Theme`: 26 px font, - **MainMenu.cs** — the menu content (`PanelContainer`, built in code, own `Theme`: 26 px font,
64 px buttons ≈ 24 mm, round 36 px slider grabber, wide scrollbar, hover/pressed styles, no focus 64 px buttons ≈ 24 mm, round 36 px slider grabber, wide scrollbar, hover/pressed styles, no focus
boxes). Header: title, FPS (4 Hz), Close. Tabs: **Player** (Return to spawn, Recalibrate body), boxes). Header: title, FPS (4 Hz), Close. Tabs: **Player** (Return to spawn, Recalibrate body),
@@ -169,7 +176,8 @@ TERMINOLOGY (owner-corrected — use precisely):
Return-to-spawn fires once while the other hand idles as a laser = arbitration), side entry Return-to-spawn fires once while the other hand idles as a laser = arbitration), side entry
behind the surface does NOT press, laser + trigger (tab switch, Reset props, laser distance), behind the surface does NOT press, laser + trigger (tab switch, Reset props, laser distance),
held trigger arriving does NOT click, touch tab switch, laser slider drag, thumbstick scroll of held trigger arriving does NOT click, touch tab switch, laser slider drag, thumbstick scroll of
the log, Recalibrate disabled without / enabled + called with a stub "calibration" node, header the log, touch swipe scrolls with no click, fast swipe flings, a jittery tap on a button inside
the log still clicks, the scrollbar itself still drags, Recalibrate disabled without / enabled + called with a stub "calibration" node, header
Close by touch. Must print "MENU: overall PASS". Close by touch. Must print "MENU: overall PASS".
- **TeleportMarker.cs** — `scenes/teleport_marker.tscn`: disc + beam + Label3D, group - **TeleportMarker.cs** — `scenes/teleport_marker.tscn`: disc + beam + Label3D, group
"teleport_marker", StaticBody pick box. `SetNear` hides visuals, `SetHighlighted` swaps colors. "teleport_marker", StaticBody pick box. `SetNear` hides visuals, `SetHighlighted` swaps colors.
@@ -396,7 +404,7 @@ Floor StaticBody mask 1|3|4|5 (collision_mask 29).
in-headset: text sharpness/shimmer at normal distance (no mipmaps — if it shimmers when far, in-headset: text sharpness/shimmer at normal distance (no mipmaps — if it shimmers when far,
lower the resolution or try `SubViewportCompositionLayer`; if blurry up close, raise lower the resolution or try `SubViewportCompositionLayer`; if blurry up close, raise
`SizePixels`), touch press depth/feel (`ContactDepth`/`ReleaseDepth`), accidental presses when `SizePixels`), touch press depth/feel (`ContactDepth`/`ReleaseDepth`), accidental presses when
reaching through the panel, laser/touch hand-over near the panel, thumbstick scroll direction, reaching through the panel, laser/touch hand-over near the panel, thumbstick scroll direction, swipe threshold/fling feel,
the stylus tip vs the drawn cursor dot alignment, and whether 0.3 m summon distance is in reach. the stylus tip vs the drawn cursor dot alignment, and whether 0.3 m summon distance is in reach.
- **Locomotion direction undecided** — owner's lean: full physics thumbstick movement if - **Locomotion direction undecided** — owner's lean: full physics thumbstick movement if
the body track goes the Nimso way (stick pushes the physics body; sway/jog detection the body track goes the Nimso way (stick pushes the physics body; sway/jog detection
+5 -5
View File
@@ -146,7 +146,7 @@ public partial class MainMenu : PanelContainer
system.AddChild(volumeRow); system.AddChild(volumeRow);
CloseButton = MakeButton("Close menu", () => CloseRequested?.Invoke()); CloseButton = MakeButton("Close menu", () => CloseRequested?.Invoke());
system.AddChild(CloseButton); system.AddChild(CloseButton);
Label logTitle = new() { Text = "Log (thumbstick scrolls)" }; Label logTitle = new() { Text = "Log (swipe or thumbstick to scroll)" };
logTitle.AddThemeColorOverride("font_color", new Color(0.6f, 0.68f, 0.8f)); logTitle.AddThemeColorOverride("font_color", new Color(0.6f, 0.68f, 0.8f));
system.AddChild(logTitle); system.AddChild(logTitle);
LogScroll = new ScrollContainer LogScroll = new ScrollContainer
@@ -261,10 +261,10 @@ public partial class MainMenu : PanelContainer
theme.SetIcon("grabber_disabled", "HSlider", Circle(36, new Color(0.4f, 0.4f, 0.45f))); theme.SetIcon("grabber_disabled", "HSlider", Circle(36, new Color(0.4f, 0.4f, 0.45f)));
// Wide scrollbar so it is touchable. // Wide scrollbar so it is touchable.
theme.SetStylebox("scroll", "VScrollBar", Box(new Color(0.13f, 0.15f, 0.2f), 6, 9, 0)); theme.SetStylebox("scroll", "VScrollBar", Box(new Color(0.13f, 0.15f, 0.2f), 8, 14, 0));
theme.SetStylebox("grabber", "VScrollBar", Box(new Color(0.3f, 0.36f, 0.48f), 6, 9, 0)); theme.SetStylebox("grabber", "VScrollBar", Box(new Color(0.3f, 0.36f, 0.48f), 8, 14, 0));
theme.SetStylebox("grabber_highlight", "VScrollBar", Box(new Color(0.4f, 0.5f, 0.68f), 6, 9, 0)); theme.SetStylebox("grabber_highlight", "VScrollBar", Box(new Color(0.4f, 0.5f, 0.68f), 8, 14, 0));
theme.SetStylebox("grabber_pressed", "VScrollBar", Box(accent, 6, 9, 0)); theme.SetStylebox("grabber_pressed", "VScrollBar", Box(accent, 8, 14, 0));
theme.SetColor("font_color", "Label", new Color(0.88f, 0.9f, 0.95f)); theme.SetColor("font_color", "Label", new Color(0.88f, 0.9f, 0.95f));
return theme; return theme;
+135 -4
View File
@@ -16,6 +16,10 @@ using System;
// trigger (edge) clicks; holding the trigger drags (sliders). // trigger (edge) clicks; holding the trigger drags (sliders).
// One Godot mouse exists per viewport, so one hand owns it: the hand holding a press keeps it, // One Godot mouse exists per viewport, so one hand owns it: the hand holding a press keeps it,
// else a touching hand beats a laser hand, else the previous owner is preferred. // else a touching hand beats a laser hand, else the previous owner is preferred.
// SWIPE vs TAP: a touch landing on a vertically scrollable ScrollContainer (not on a slider or
// scrollbar inside it) holds its press back. Travel beyond SwipeThreshold before release = a
// swipe: the content follows the tip (and flings on release), no click is ever sent. Released
// sooner = a tap: press + release are sent together at the contact point. Thumbstick Y = wheel.
public partial class MenuScreen : Node3D public partial class MenuScreen : Node3D
{ {
public const float TipLength = 0.12f; public const float TipLength = 0.12f;
@@ -26,6 +30,9 @@ public partial class MenuScreen : Node3D
public const float TouchEdgeMargin = 0.015f; public const float TouchEdgeMargin = 0.015f;
public const float ScrollDeadzone = 0.3f; public const float ScrollDeadzone = 0.3f;
public const float ScrollNotchesPerSecond = 14f; public const float ScrollNotchesPerSecond = 14f;
public const float SwipeThreshold = 16f; // px (~6 mm) of tip travel before a touch is a swipe
public const float FlingDecay = 5f; // 1/s
public const float FlingMinSpeed = 30f; // px/s
public enum PointerMode { None, Touch, Laser } public enum PointerMode { None, Touch, Laser }
@@ -66,6 +73,16 @@ public partial class MenuScreen : Node3D
private bool _mouseInside; private bool _mouseInside;
private Vector2 _lastSent = new(-1, -1); private Vector2 _lastSent = new(-1, -1);
private float _scrollAccum; private float _scrollAccum;
// Touch held back for swipe-vs-tap (only while _down).
private bool _pending;
private bool _swiping;
private ScrollContainer _swipeTarget;
private Vector2 _pressPixel;
private float _swipeLastY;
private float _swipeScroll;
private float _swipeVelocity;
private ScrollContainer _flingTarget;
private float _flingVelocity;
private StandardMaterial3D _screenMaterial; private StandardMaterial3D _screenMaterial;
public MenuScreen() : this(new Vector2(0.36f, 0.24f), new Vector2I(960, 640)) { } public MenuScreen() : this(new Vector2(0.36f, 0.24f), new Vector2I(960, 640)) { }
@@ -86,6 +103,7 @@ public partial class MenuScreen : Node3D
public PointerMode GetMode(int hand) => _hands[hand].Mode; public PointerMode GetMode(int hand) => _hands[hand].Mode;
public bool IsPressing(int hand) => _owner == hand && _down; public bool IsPressing(int hand) => _owner == hand && _down;
public bool IsSwiping => _down && _pending && _swiping;
public Vector2 GetPixel(int hand) => _hands[hand].Pixel; public Vector2 GetPixel(int hand) => _hands[hand].Pixel;
public bool TryGetLaserDistance(int hand, out float distance) public bool TryGetLaserDistance(int hand, out float distance)
@@ -124,9 +142,11 @@ public partial class MenuScreen : Node3D
public void ReleaseAll() public void ReleaseAll()
{ {
if (_owner >= 0 && _down) if (_owner >= 0 && _down && !_pending)
SendButton(_lastSent, false); SendButton(_lastSent, false);
_down = false; _down = false;
_pending = false;
_flingTarget = null;
_owner = -1; _owner = -1;
if (_mouseInside) if (_mouseInside)
{ {
@@ -247,8 +267,10 @@ public partial class MenuScreen : Node3D
{ {
if (_owner >= 0 && _down) if (_owner >= 0 && _down)
{ {
if (!_pending)
SendButton(_lastSent, false); SendButton(_lastSent, false);
_down = false; _down = false;
_pending = false;
} }
_owner = owner; _owner = owner;
_scrollAccum = 0f; _scrollAccum = 0f;
@@ -276,18 +298,127 @@ public partial class MenuScreen : Node3D
if (h.WantDown != _down) if (h.WantDown != _down)
{ {
_down = h.WantDown; _down = h.WantDown;
SendButton(h.Pixel, _down);
if (_down) if (_down)
{
_flingTarget = null;
if (h.Mode == PointerMode.Touch && FindSwipeTarget() is ScrollContainer target)
BeginPending(target, h.Pixel);
else
SendButton(h.Pixel, true);
PointerPressed?.Invoke(current, h.Mode); PointerPressed?.Invoke(current, h.Mode);
} }
else if (_pending)
EndPending();
else
SendButton(h.Pixel, false);
}
else if (_down && _pending)
UpdateSwipe(h.Pixel, (float)delta);
// A UI handler may have reset us (ReleaseAll) during the injected event. // A UI handler may have reset us (ReleaseAll) during the injected event.
if (_owner == current && current < inputs.Length) if (_owner == current && current < inputs.Length)
Scroll(inputs[current].Stick, h.Pixel, (float)delta); Scroll(inputs[current].Stick, h.Pixel, (float)delta);
} }
Fling((float)delta);
UpdateDots(); UpdateDots();
} }
// The scroll container a touch at the current mouse position would swipe: walk up from the
// hovered control; a Range (slider, scrollbar) on the way keeps the normal immediate press.
private ScrollContainer FindSwipeTarget()
{
for (Node n = Viewport.GuiGetHoveredControl(); n is Control c; n = n.GetParent())
{
if (c is Godot.Range)
return null;
if (c is ScrollContainer sc)
{
VScrollBar bar = sc.GetVScrollBar();
if (bar.MaxValue - bar.Page > 0.5)
return sc;
}
}
return null;
}
private void BeginPending(ScrollContainer target, Vector2 pixel)
{
_pending = true;
_swiping = false;
_swipeTarget = target;
_pressPixel = pixel;
_swipeVelocity = 0f;
}
private void UpdateSwipe(Vector2 pixel, float delta)
{
if (!IsInstanceValid(_swipeTarget))
return;
if (!_swiping)
{
if (pixel.DistanceTo(_pressPixel) < SwipeThreshold)
return;
_swiping = true;
_swipeLastY = pixel.Y;
_swipeScroll = _swipeTarget.ScrollVertical;
return;
}
// Content follows the tip: dragging up reveals what is below.
float dy = pixel.Y - _swipeLastY;
_swipeLastY = pixel.Y;
_swipeScroll -= dy;
if (!SetScroll(_swipeTarget))
_swipeScroll = _swipeTarget.ScrollVertical; // at an end: no stored overscroll
if (delta > 0f)
_swipeVelocity = Mathf.Lerp(_swipeVelocity, -dy / delta, 1f - Mathf.Exp(-delta * 30f));
}
private void EndPending()
{
_pending = false;
if (_swiping)
{
_flingTarget = _swipeTarget;
_flingVelocity = _swipeVelocity;
_swipeScroll = _flingTarget.ScrollVertical;
}
else
{
// A tap: the whole click at the contact point (buttons fire on release anyway).
Vector2 back = _hands[_owner].Pixel;
SendMotion(_pressPixel);
SendButton(_pressPixel, true);
SendButton(_pressPixel, false);
if (back != _pressPixel)
SendMotion(back);
}
_swiping = false;
_swipeTarget = null;
}
private void Fling(float delta)
{
if (_flingTarget == null)
return;
if (!IsInstanceValid(_flingTarget) || Mathf.Abs(_flingVelocity) < FlingMinSpeed)
{
_flingTarget = null;
return;
}
_swipeScroll += _flingVelocity * delta;
if (!SetScroll(_flingTarget))
_flingTarget = null; // hit an end
_flingVelocity *= Mathf.Exp(-FlingDecay * delta);
}
// Applies _swipeScroll; false when the container clamped it (an end was reached).
private bool SetScroll(ScrollContainer target)
{
int want = Mathf.RoundToInt(_swipeScroll);
target.ScrollVertical = want;
return target.ScrollVertical == want;
}
private void Classify(int hand, PointerInput input) private void Classify(int hand, PointerInput input)
{ {
HandState h = _hands[hand]; HandState h = _hands[hand];
@@ -399,7 +530,7 @@ public partial class MenuScreen : Node3D
ButtonIndex = wheel, ButtonIndex = wheel,
Pressed = pressed, Pressed = pressed,
Factor = 1f, Factor = 1f,
ButtonMask = _down ? MouseButtonMask.Left : 0, ButtonMask = _down && !_pending ? MouseButtonMask.Left : 0,
}, true); }, true);
} }
} }
@@ -412,7 +543,7 @@ public partial class MenuScreen : Node3D
Position = pixel, Position = pixel,
GlobalPosition = pixel, GlobalPosition = pixel,
Relative = relative, Relative = relative,
ButtonMask = _down ? MouseButtonMask.Left : 0, ButtonMask = _down && !_pending ? MouseButtonMask.Left : 0,
}, true); }, true);
_lastSent = pixel; _lastSent = pixel;
} }
+54
View File
@@ -91,6 +91,32 @@ public partial class MenuSpike : Node3D
await Frame(3); await Frame(3);
} }
// Touch down at `from`, slide to `to` while in contact, lift. fling = a fast 4-frame slide.
private async Task TouchDrag(int hand, Vector2 from, Vector2 to, bool fling = false)
{
for (float d = 0.06f; d > -0.004f; d -= 0.004f)
{
TipAt(hand, from, d);
await Frame();
}
// A fling lifts while still moving (as a real flick does); a slow drag stops, then lifts.
int steps = fling ? 8 : 30;
for (int i = 1; i <= steps; i++)
{
float lift = fling ? Mathf.Max(0f, i - 4) * 0.008f : 0f;
TipAt(hand, from.Lerp(to, i / (float)steps), -0.004f + lift);
await Frame();
}
float start = fling ? -0.004f + (steps - 4) * 0.008f : -0.004f;
for (float d = start; d < 0.08f; d += 0.006f)
{
TipAt(hand, to, d);
await Frame();
}
ClearHand(hand);
await Frame(3);
}
// Laser from 0.8 m, off-axis, aimed at the pixel. // Laser from 0.8 m, off-axis, aimed at the pixel.
private void LaserAt(int hand, Vector2 pixel, bool trigger, Vector2 stick = default) private void LaserAt(int hand, Vector2 pixel, bool trigger, Vector2 stick = default)
{ {
@@ -235,6 +261,34 @@ public partial class MenuSpike : Node3D
await Frame(2); await Frame(2);
Check("stick: scroll down then up", scrolled > 50 && scrolledBack < scrolled, $"({scrolled} → {scrolledBack})"); Check("stick: scroll down then up", scrolled > 50 && scrolledBack < scrolled, $"({scrolled} → {scrolledBack})");
// 6b) Touch swipe vs tap inside the scrollable log.
Button logButton = new() { Text = "in-log button", CustomMinimumSize = new Vector2(0, 48) };
int logButtonPresses = 0;
logButton.Pressed += () => logButtonPresses++;
_menu.LogScroll.GetChild(0).AddChild(logButton);
_menu.LogScroll.GetChild(0).MoveChild(logButton, 0);
_menu.LogScroll.ScrollVertical = 0;
await Frame(5);
int clicksBefore = _clicks;
await TouchDrag(1, logPx, logPx + new Vector2(0, -150));
int swiped = _menu.LogScroll.ScrollVertical;
Check("touch: swipe up scrolls the log", swiped >= 120, $"(scroll 0 → {swiped})");
Check("touch: swipe sends no click", _clicks == clicksBefore && logButtonPresses == 0, $"(clicks +{_clicks - clicksBefore})");
await TouchDrag(1, logPx, logPx + new Vector2(0, 80), fling: true);
await Frame(60);
int flung = _menu.LogScroll.ScrollVertical;
Check("touch: fast swipe down flings past the finger", flung < swiped - 60, $"({swiped} → {flung})");
_menu.LogScroll.ScrollVertical = 0;
await Frame(5);
Vector2 btnPx = Center(logButton);
await TouchDrag(1, btnPx, btnPx + new Vector2(6, 5));
Check("touch: small-jitter tap on in-log button clicks", logButtonPresses == 1 && _menu.LogScroll.ScrollVertical == 0,
$"(presses={logButtonPresses}, scroll={_menu.LogScroll.ScrollVertical})");
VScrollBar vbar = _menu.LogScroll.GetVScrollBar();
Rect2 br = vbar.GetGlobalRect();
await TouchDrag(1, new Vector2(br.GetCenter().X, br.Position.Y + 10), new Vector2(br.GetCenter().X, br.End.Y - 10));
Check("touch: scrollbar still drags directly", _menu.LogScroll.ScrollVertical > 100, $"(scroll={_menu.LogScroll.ScrollVertical})");
// 7) Recalibrate: disabled without a calibration node, enabled + calls it when present. // 7) Recalibrate: disabled without a calibration node, enabled + calls it when present.
await TouchPress(1, TabCenter(0)); await TouchPress(1, TabCenter(0));
Check("Recalibrate disabled without group node", _menu.RecalibrateButton.Disabled); Check("Recalibrate disabled without group node", _menu.RecalibrateButton.Disabled);