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);
}
}