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