From 35b7e2a44f378ef87e4407d92b23ad8ee52a694a Mon Sep 17 00:00:00 2001 From: Foxyz Date: Fri, 2 Oct 2026 06:05:40 +0300 Subject: [PATCH] Menu: touch swipe-to-scroll with tap disambiguation, fling, wider scrollbar Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- HANDOFF.md | 12 +++- scripts/MainMenu.cs | 10 +-- scripts/MenuScreen.cs | 141 ++++++++++++++++++++++++++++++++++++++++-- scripts/MenuSpike.cs | 54 ++++++++++++++++ 5 files changed, 206 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e40c2d1..f9a9660 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 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 - 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 (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 diff --git a/HANDOFF.md b/HANDOFF.md index 402372f..4bebb07 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 + 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. + **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, 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), @@ -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 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 - 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". - **TeleportMarker.cs** — `scenes/teleport_marker.tscn`: disc + beam + Label3D, group "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, lower the resolution or try `SubViewportCompositionLayer`; if blurry up close, raise `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. - **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 diff --git a/scripts/MainMenu.cs b/scripts/MainMenu.cs index c201426..a9d095d 100644 --- a/scripts/MainMenu.cs +++ b/scripts/MainMenu.cs @@ -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; diff --git a/scripts/MenuScreen.cs b/scripts/MenuScreen.cs index 3a2e967..791bf83 100644 --- a/scripts/MenuScreen.cs +++ b/scripts/MenuScreen.cs @@ -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; } diff --git a/scripts/MenuSpike.cs b/scripts/MenuSpike.cs index 803075f..dbfb35e 100644 --- a/scripts/MenuSpike.cs +++ b/scripts/MenuSpike.cs @@ -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);