aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/src/Godosa.Core/Input/InputScript.cs
blob: 481b78f606987b857e66c1b3175ca35ee274a0ce (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;

namespace Godosa.Core.Input;

public enum ScriptAction { Down, Up, Click, Type }

/// <param name="Arg">Physical key name (Down/Up), button label (Click) or text (Type).</param>
public readonly record struct ScriptEvent(double Time, ScriptAction Action, string Arg);

/// <summary>Timed input for smoke runs (the host replays it as real key events and button
/// clicks). One per line: <c>TIME ACTION ARGS</c>, TIME absolute seconds or <c>+seconds</c>
/// after the previous line; actions <c>down K</c>, <c>up K</c>, <c>press K</c>,
/// <c>hold K SECONDS</c>, <c>click LABEL</c>, <c>type TEXT</c> (into the focused text box); <c>#</c> comments.</summary>
public sealed class InputScript
{
    public const double PressSeconds = 0.1;

    private int _next;

    public IReadOnlyList<ScriptEvent> Events { get; }

    public bool Finished => _next >= Events.Count;

    private InputScript(List<ScriptEvent> events) => Events = events;

    public static InputScript Parse(string text)
    {
        var events = new List<ScriptEvent>();
        double? previous = null;
        var lines = text.Split('\n');
        for (int n = 0; n < lines.Length; n++)
        {
            string line = lines[n].Trim();
            if (line.Length == 0 || line.StartsWith('#')) continue;
            string[] parts = line.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries);
            FormatException Bad(string why) => new($"input script line {n + 1}: {why}: '{line}'");
            if (parts.Length < 3) throw Bad("expected TIME ACTION ARG");

            bool relative = parts[0].StartsWith('+');
            if (!double.TryParse(parts[0].TrimStart('+'), NumberStyles.Float, CultureInfo.InvariantCulture, out double t))
                throw Bad("bad time");
            if (relative)
            {
                if (previous is null) throw Bad("relative time needs a line before it");
                t += previous.Value;
            }
            previous = t;

            string arg = parts[2];
            switch (parts[1])
            {
                case "down": events.Add(new(t, ScriptAction.Down, arg)); break;
                case "up": events.Add(new(t, ScriptAction.Up, arg)); break;
                case "press":
                    events.Add(new(t, ScriptAction.Down, arg));
                    events.Add(new(t + PressSeconds, ScriptAction.Up, arg));
                    break;
                case "hold":
                    string[] hold = arg.Split(' ', StringSplitOptions.RemoveEmptyEntries);
                    if (hold.Length != 2 || !double.TryParse(hold[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double seconds))
                        throw Bad("hold needs KEY SECONDS");
                    events.Add(new(t, ScriptAction.Down, hold[0]));
                    events.Add(new(t + seconds, ScriptAction.Up, hold[0]));
                    break;
                case "click": events.Add(new(t, ScriptAction.Click, arg)); break;
                case "type": events.Add(new(t, ScriptAction.Type, arg)); break;
                default: throw Bad($"unknown action '{parts[1]}'");
            }
        }
        return new InputScript(events.OrderBy(e => e.Time).ToList()); // stable: same-time lines keep order
    }

    /// <summary>Appends every not-yet-returned event with Time ≤ <paramref name="now"/>.</summary>
    public void Due(double now, List<ScriptEvent> into)
    {
        while (_next < Events.Count && Events[_next].Time <= now)
            into.Add(Events[_next++]);
    }
}