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
+206 -13

No files matched your search

+5 -5
View File
@@ -146,7 +146,7 @@ public partial class MainMenu : PanelContainer
system.AddChild(volumeRow);
CloseButton = MakeButton("Close menu", () => CloseRequested?.Invoke());
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));
system.AddChild(logTitle);
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)));
// Wide scrollbar so it is touchable.
theme.SetStylebox("scroll", "VScrollBar", Box(new Color(0.13f, 0.15f, 0.2f), 6, 9, 0));
theme.SetStylebox("grabber", "VScrollBar", Box(new Color(0.3f, 0.36f, 0.48f), 6, 9, 0));
theme.SetStylebox("grabber_highlight", "VScrollBar", Box(new Color(0.4f, 0.5f, 0.68f), 6, 9, 0));
theme.SetStylebox("grabber_pressed", "VScrollBar", Box(accent, 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), 8, 14, 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, 8, 14, 0));
theme.SetColor("font_color", "Label", new Color(0.88f, 0.9f, 0.95f));
return theme;
+136 -5
View File
@@ -16,6 +16,10 @@ using System;
// 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,
// 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 const float TipLength = 0.12f;
@@ -26,6 +30,9 @@ public partial class MenuScreen : Node3D
public const float TouchEdgeMargin = 0.015f;
public const float ScrollDeadzone = 0.3f;
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 }
@@ -66,6 +73,16 @@ public partial class MenuScreen : Node3D
private bool _mouseInside;
private Vector2 _lastSent = new(-1, -1);
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;
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 bool IsPressing(int hand) => _owner == hand && _down;
public bool IsSwiping => _down && _pending && _swiping;
public Vector2 GetPixel(int hand) => _hands[hand].Pixel;
public bool TryGetLaserDistance(int hand, out float distance)
@@ -124,9 +142,11 @@ public partial class MenuScreen : Node3D
public void ReleaseAll()
{
if (_owner >= 0 && _down)
if (_owner >= 0 && _down && !_pending)
SendButton(_lastSent, false);
_down = false;
_pending = false;
_flingTarget = null;
_owner = -1;
if (_mouseInside)
{
@@ -247,8 +267,10 @@ public partial class MenuScreen : Node3D
{
if (_owner >= 0 && _down)
{
SendButton(_lastSent, false);
if (!_pending)
SendButton(_lastSent, false);
_down = false;
_pending = false;
}
_owner = owner;
_scrollAccum = 0f;
@@ -276,18 +298,127 @@ public partial class MenuScreen : Node3D
if (h.WantDown != _down)
{
_down = h.WantDown;
SendButton(h.Pixel, _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);
}
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.
if (_owner == current && current < inputs.Length)
Scroll(inputs[current].Stick, h.Pixel, (float)delta);
}
Fling((float)delta);
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)
{
HandState h = _hands[hand];
@@ -399,7 +530,7 @@ public partial class MenuScreen : Node3D
ButtonIndex = wheel,
Pressed = pressed,
Factor = 1f,
ButtonMask = _down ? MouseButtonMask.Left : 0,
ButtonMask = _down && !_pending ? MouseButtonMask.Left : 0,
}, true);
}
}
@@ -412,7 +543,7 @@ public partial class MenuScreen : Node3D
Position = pixel,
GlobalPosition = pixel,
Relative = relative,
ButtonMask = _down ? MouseButtonMask.Left : 0,
ButtonMask = _down && !_pending ? MouseButtonMask.Left : 0,
}, true);
_lastSent = pixel;
}
+54
View File
@@ -91,6 +91,32 @@ public partial class MenuSpike : Node3D
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.
private void LaserAt(int hand, Vector2 pixel, bool trigger, Vector2 stick = default)
{
@@ -235,6 +261,34 @@ public partial class MenuSpike : Node3D
await Frame(2);
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.
await TouchPress(1, TabCenter(0));
Check("Recalibrate disabled without group node", _menu.RecalibrateButton.Disabled);