namespace DodoSSH.Client.Shell.Keyboard;
/// Which arrow key a cursor gesture is pressing, if any.
internal enum ArrowDirection
{
None,
Left,
Down,
Up,
Right,
}
///
/// Long-press-and-drag on the terminal as a joystick for the arrow keys.
///
///
///
/// A joystick rather than a trackpad, and the difference is what the gesture is for. A trackpad would
/// send one arrow per so many pixels of travel, which runs out of screen long before a long command line
/// runs out of characters, and does nothing at all for the other half of the job — holding Up to walk back
/// through history. Here the finger's distance from where the press landed is a speed, and the key repeats
/// for as long as it is held there: a short way out steps slowly enough to stop on the character wanted, and
/// further out in the same direction shifts up through the second and third speeds.
///
///
/// The first step is immediate and the second waits. Crossing the dead zone sends one key at once, so
/// a quick nudge is exactly one character, and the slowest speed then holds off for
/// before repeating — the delay every hardware keyboard puts between a
/// keypress and auto-repeat, for the same reason.
///
///
/// The axis is sticky. A thumb dragging left does not travel in a straight line, and without some
/// hysteresis a drift upwards of a few pixels would flip the gesture onto Up and walk into history mid-edit.
/// The other axis has to win by to take over.
///
///
/// Pure arithmetic over displacements in density-independent pixels and times in milliseconds, which is
/// what lets it be tested without a touchscreen. The platform half — noticing the long press, taking the
/// touch stream away from the page, running the clock — is in the Android head's RendererFocus.
///
///
internal sealed class ArrowGesture
{
/// How far from the press the finger can wander before anything is sent, in dp.
internal const double DeadZone = 16;
/// Where the second speed starts, in dp from the press.
internal const double SecondSpeedAt = 64;
/// Where the third speed starts, in dp from the press.
internal const double ThirdSpeedAt = 128;
/// How long the slowest speed waits after its first key before repeating.
internal const long FirstRepeatDelayMs = 400;
/// How much the other axis has to dominate before the direction changes axis.
internal const double AxisSwitchRatio = 1.4;
/// The most keys one call reports, so a stalled clock cannot come back as a burst.
private const int MaximumBurst = 3;
private long nextDue;
/// The key being held, or inside the dead zone.
internal ArrowDirection Direction { get; private set; }
/// The speed, from 1 to 3, or 0 inside the dead zone.
internal int Speed { get; private set; }
/// The interval between repeats at a speed.
internal static long IntervalMs(int speed) => speed switch
{
1 => 220,
2 => 90,
_ => 35,
};
/// Takes the finger's new displacement from where the press landed.
/// How many keys to send now, in .
internal int Move(double dx, double dy, long nowMs)
{
var direction = DirectionFor(dx, dy);
if (direction is ArrowDirection.None)
{
Direction = ArrowDirection.None;
Speed = 0;
return 0;
}
var along = direction is ArrowDirection.Left or ArrowDirection.Right ? Math.Abs(dx) : Math.Abs(dy);
var speed = along >= ThirdSpeedAt ? 3 : along >= SecondSpeedAt ? 2 : 1;
if (direction != Direction)
{
Direction = direction;
Speed = speed;
nextDue = nowMs + (speed == 1 ? FirstRepeatDelayMs : IntervalMs(speed));
return 1;
}
if (speed > Speed)
{
// Faster takes effect at once rather than after the slower interval already running out.
nextDue = Math.Min(nextDue, nowMs + IntervalMs(speed));
}
Speed = speed;
return Advance(nowMs);
}
/// Lets time pass with the finger held still.
/// How many keys have come due, in .
internal int Advance(long nowMs)
{
if (Direction is ArrowDirection.None)
{
return 0;
}
var interval = IntervalMs(Speed);
var count = 0;
while (nowMs >= nextDue && count < MaximumBurst)
{
count++;
nextDue += interval;
}
if (nowMs >= nextDue)
{
// Behind by more than a burst: drop the backlog rather than owe it.
nextDue = nowMs + interval;
}
return count;
}
/// Lets go.
internal void Reset()
{
Direction = ArrowDirection.None;
Speed = 0;
}
private ArrowDirection DirectionFor(double dx, double dy)
{
var ax = Math.Abs(dx);
var ay = Math.Abs(dy);
if (Math.Max(ax, ay) < DeadZone)
{
return ArrowDirection.None;
}
var horizontal = Direction switch
{
ArrowDirection.Left or ArrowDirection.Right => ay <= ax * AxisSwitchRatio,
ArrowDirection.Up or ArrowDirection.Down => ax > ay * AxisSwitchRatio,
_ => ax >= ay,
};
// Screen coordinates: y grows downwards, so a drag towards the top of the screen is Up.
return horizontal
? dx < 0 ? ArrowDirection.Left : ArrowDirection.Right
: dy < 0 ? ArrowDirection.Up : ArrowDirection.Down;
}
}