OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / AvaloniaShell.Tests / DragScrubTests.cs

216 lines · csharp ref:
using System;
using System.Collections.Generic;
using System.Linq;

using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Headless.XUnit;
using Avalonia.Input;
using Avalonia.Threading;
using Avalonia.VisualTree;

using IFSRenderer.AvaloniaShell.Controls;

using Xunit;

namespace IFSRenderer.AvaloniaShell.Tests;

/// <summary>
/// "is there a way to lock the cursor on the slider when sliding? The sliders are hard to use
/// on 2 screens."
/// </summary>
/// <remarks>
/// <para>
/// The scrub wraps the pointer at the edge of the MONITOR holding the window, so it never runs
/// out of desk - what Blender calls continuous grab. An earlier attempt pinned the pointer to
/// the drag origin on every move instead, which fought event latency: queued moves re-applied a
/// stale offset, so values drifted unprompted and bounded ones pinned to a limit.
/// </para>
/// <para>
/// The warp is a system call and cannot be asserted here - that needs a real X11 run. What IS
/// asserted is the arithmetic around it: that travel accumulates, that the screen-sized jump a
/// wrap produces is rejected by magnitude, and that a stationary pointer never moves the value.
/// </para>
/// </remarks>
public class DragScrubTests : IDisposable
{
    private readonly Func<PixelPoint, bool> _realWarp = ValueSlider.PointerWarp;

    public void Dispose() => ValueSlider.PointerWarp = _realWarp;

    private static (MainWindow Window, ValueSlider Slider, Border Field) Setup()
    {
        (MainWindow window, _) = MainWindowHarness.Start();
        window.Measure(new Size(1500, 900));
        window.Arrange(new Rect(0, 0, 1500, 900));
        Dispatcher.UIThread.RunJobs();

        var slider = window.FindControl<ValueSlider>("BrightnessSlider");
        Assert.NotNull(slider);
        Border field = slider.GetVisualDescendants().OfType<Border>().First(b => b.Name == "ValueField");
        return (window, slider, field);
    }

    private static Point Centre(Control control, Window window) =>
        control.TranslatePoint(
            new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window) ?? default;

    /// <summary>
    /// Dragging accumulates one increment per pixel TRAVELLED, and keeps going far past the
    /// width of the control.
    /// </summary>
    /// <remarks>
    /// Deltas, not an offset from the drag origin. The origin-based model only worked because
    /// the pointer was warped home every move and the baseline rebased with it; with edge
    /// wrapping the pointer roams, so the value has to follow how far it moved, not where it is.
    /// The first qualifying move only STARTS the drag (it crosses the 2px click threshold) and
    /// contributes nothing.
    /// </remarks>
    [AvaloniaFact]
    public void Dragging_accumulates_distance_travelled_well_past_the_control()
    {
        ValueSlider.PointerWarp = _ => true;

        (MainWindow window, ValueSlider slider, Border field) = Setup();
        slider.Value = 0;
        double increment = slider.Increment;
        Point start = Centre(field, window);

        window.MouseDown(start, MouseButton.Left);
        Dispatcher.UIThread.RunJobs();

        const int steps = 40, dxPerStep = 10;
        for (int i = 1; i <= steps; i++)
        {
            window.MouseMove(start + new Vector(i * dxPerStep, 0));
            Dispatcher.UIThread.RunJobs();
        }

        window.MouseUp(start + new Vector(steps * dxPerStep, 0), MouseButton.Left);
        Dispatcher.UIThread.RunJobs();

        // 400px of travel, less the first 10px that started the drag.
        double expected = (steps - 1) * dxPerStep * increment;
        Assert.True(slider.Value > 0, $"drag did not scrub at all (value {slider.Value})");
        Assert.Equal(expected, slider.Value, 6);
    }

    /// <summary>
    /// Without a warp — Wayland, or any platform that will not move the pointer — the scrub must
    /// still track the pointer exactly; it simply stops when the cursor runs out of screen.
    /// </summary>
    [AvaloniaFact]
    public void Dragging_still_works_when_the_pointer_cannot_be_warped()
    {
        ValueSlider.PointerWarp = _ => false;

        (MainWindow window, ValueSlider slider, Border field) = Setup();
        slider.Value = 0;
        double increment = slider.Increment;
        Point start = Centre(field, window);

        window.MouseDown(start, MouseButton.Left);
        Dispatcher.UIThread.RunJobs();

        for (int i = 1; i <= 20; i++)
        {
            window.MouseMove(start + new Vector(i * 5, 0));
            Dispatcher.UIThread.RunJobs();
        }
        window.MouseUp(start + new Vector(100, 0), MouseButton.Left);
        Dispatcher.UIThread.RunJobs();

        // 100px travelled, less the first 5px that started the drag.
        Assert.Equal(95 * increment, slider.Value, 6);
    }

    /// <summary>A press that never moves opens the editor rather than scrubbing.</summary>
    [AvaloniaFact]
    public void A_press_without_movement_is_a_click_not_a_drag()
    {
        ValueSlider.PointerWarp = _ => true;

        (MainWindow window, ValueSlider slider, Border field) = Setup();
        double before = slider.Value;
        Point at = Centre(field, window);

        window.MouseDown(at, MouseButton.Left);
        window.MouseUp(at, MouseButton.Left);
        Dispatcher.UIThread.RunJobs();

        var editor = slider.GetVisualDescendants().OfType<TextBox>().First(t => t.Name == "ValueEditor");
        Assert.True(editor.IsVisible, "a click should open the editor");
        Assert.Equal(before, slider.Value, 6);
    }

    /// <summary>
    /// A wrap teleports the pointer a whole screen away. That induced move must NOT be read as
    /// the user's hand — it is rejected by MAGNITUDE, which is what makes the wrap robust
    /// against however late the event arrives.
    /// </summary>
    [AvaloniaFact]
    public void A_screen_sized_jump_is_treated_as_a_wrap_and_ignored()
    {
        ValueSlider.PointerWarp = _ => true;

        (MainWindow window, ValueSlider slider, Border field) = Setup();
        slider.Value = 0;
        double increment = slider.Increment;
        Point start = Centre(field, window);

        window.MouseDown(start, MouseButton.Left);
        Dispatcher.UIThread.RunJobs();

        // Start the drag.
        window.MouseMove(start + new Vector(20, 0));
        Dispatcher.UIThread.RunJobs();
        double afterFirstMove = slider.Value;

        // Now a jump far larger than half a monitor, as a wrap produces.
        window.MouseMove(start + new Vector(-1600, 0));
        Dispatcher.UIThread.RunJobs();

        window.MouseUp(start, MouseButton.Left);
        Dispatcher.UIThread.RunJobs();

        Assert.Equal(afterFirstMove, slider.Value, 6);
        Assert.True(Math.Abs(slider.Value) < 1600 * increment,
            "a wrap-sized jump was applied to the value instead of being rejected");
    }

    /// <summary>
    /// The regression that made the first attempt unusable: the value must not move on its own
    /// while the pointer is still. Warping used to re-apply a stale offset on every queued
    /// event, so bounded parameters crept to a limit without the user doing anything.
    /// </summary>
    [AvaloniaFact]
    public void A_stationary_pointer_never_changes_the_value()
    {
        var warps = 0;
        ValueSlider.PointerWarp = _ => { warps++; return true; };

        (MainWindow window, ValueSlider slider, Border field) = Setup();
        slider.Value = 0;
        Point start = Centre(field, window);

        window.MouseDown(start, MouseButton.Left);
        Dispatcher.UIThread.RunJobs();
        window.MouseMove(start + new Vector(15, 0));
        Dispatcher.UIThread.RunJobs();
        double settled = slider.Value;

        // Repeat the SAME position many times, as a stream of no-op move events would.
        for (int i = 0; i < 25; i++)
        {
            window.MouseMove(start + new Vector(15, 0));
            Dispatcher.UIThread.RunJobs();
        }

        window.MouseUp(start + new Vector(15, 0), MouseButton.Left);
        Dispatcher.UIThread.RunJobs();

        Assert.Equal(settled, slider.Value, 6);
    }
}