diff --git a/pkg/keystroke/parser.go b/pkg/keystroke/parser.go index ccdd6c3..4d49036 100644 --- a/pkg/keystroke/parser.go +++ b/pkg/keystroke/parser.go @@ -36,6 +36,16 @@ func Parse(script string) ([]Action, error) { actions := make([]Action, 0, len(tokens)) for _, token := range tokens { + if multi, ok, err := parseMultiAction(token); ok || err != nil { + if err != nil { + return nil, err + } + + actions = append(actions, multi...) + + continue + } + action, err := parseToken(token) if err != nil { return nil, err @@ -47,6 +57,18 @@ func Parse(script string) ([]Action, error) { return actions, nil } +// parseMultiAction handles tokens that expand into several timed actions (rather +// than a single Write), returning ok=false for everything else so parseToken can +// handle it. +func parseMultiAction(token string) ([]Action, bool, error) { + mouseToken, mouseMods, _ := parseMouseModifierPrefix(token) + if strings.HasPrefix(mouseToken, "smoothdrag:") { + return parseSmoothDrag(token, mouseToken, mouseMods) + } + + return nil, false, nil +} + func parseToken(token string) (Action, error) { // Backtick-quoted tokens are always literal text. if len(token) >= 2 && token[0] == '`' && token[len(token)-1] == '`' { @@ -250,6 +272,69 @@ func parseDrag(token, mouseToken string, mouseMods int) (string, bool, error) { return seq, true, nil } +// defaultSmoothDragStepMs paces smoothdrag motion events when no step delay is +// given: ~25 moves/second, which reads as smooth real-time panning while still +// letting the app render each intermediate position. +const defaultSmoothDragStepMs = 40 + +// parseSmoothDrag expands smoothdrag:col1:row1:col2:row2[:stepMs] into a press, +// one motion event per cell stepped at stepMs apart, and a release. Unlike drag: +// (which emits the whole gesture at one instant, so apps coalesce it into a +// jump), spacing the motion events in time makes the app redraw each step, so a +// recording captures a smooth pan instead of a single hop. +func parseSmoothDrag(token, mouseToken string, mouseMods int) ([]Action, bool, error) { + value := mouseToken[len("smoothdrag:"):] + parts := strings.Split(value, ":") + if len(parts) != 4 && len(parts) != 5 { + return nil, true, fmt.Errorf( + "invalid smoothdrag token (expected smoothdrag:col1:row1:col2:row2[:stepMs]): %s", token) + } + + for _, p := range parts { + if p == "" || !allDigits(p) { + return nil, true, fmt.Errorf("invalid smoothdrag token (non-numeric value): %s", token) + } + } + + col1, _ := strconv.Atoi(parts[0]) + row1, _ := strconv.Atoi(parts[1]) + col2, _ := strconv.Atoi(parts[2]) + row2, _ := strconv.Atoi(parts[3]) + if col1 < 1 || row1 < 1 || col2 < 1 || row2 < 1 { + return nil, true, fmt.Errorf("mouse coordinates are 1-based: %s", token) + } + + stepMs := defaultSmoothDragStepMs + if len(parts) == 5 { + stepMs, _ = strconv.Atoi(parts[4]) + } + if stepMs <= 0 { + return nil, true, fmt.Errorf("smoothdrag step delay must be positive: %s", token) + } + delay := time.Duration(stepMs) * time.Millisecond + + dx := col2 - col1 + dy := row2 - row1 + steps := abs(dx) + if abs(dy) > steps { + steps = abs(dy) + } + if steps == 0 { + steps = 1 + } + + actions := make([]Action, 0, steps+2) + actions = append(actions, Action{Kind: Write, Sequence: sgrPress(mouseMods, col1, row1), Delay: delay, Label: token}) + for i := 1; i <= steps; i++ { + cx := col1 + dx*i/steps + cy := row1 + dy*i/steps + actions = append(actions, Action{Kind: Write, Sequence: sgrPress(32+mouseMods, cx, cy), Delay: delay, Label: token}) + } + actions = append(actions, Action{Kind: Write, Sequence: sgrRelease(mouseMods, col2, row2), Delay: delay, Label: token}) + + return actions, true, nil +} + // parseMouseMove handles move: or hover: tokens for mouse motion events (used // to trigger hover effects in TUIs that support mouse tracking). func parseMouseMove(token, mouseToken string, mouseMods int) (string, bool, error) { diff --git a/pkg/keystroke/parser_test.go b/pkg/keystroke/parser_test.go index a0eb091..f723536 100644 --- a/pkg/keystroke/parser_test.go +++ b/pkg/keystroke/parser_test.go @@ -345,3 +345,50 @@ func TestParseCapitalizedKeysStillWork(t *testing.T) { t.Fatalf("Parse() = %#v, want %#v", actions, want) } } + +func TestParseSmoothDragExpandsToTimedSteps(t *testing.T) { + t.Parallel() + + actions, err := Parse("smoothdrag:5:5:8:5:50") + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + want := []string{ + "\x1b[<0;5;5M", // press + "\x1b[<32;6;5M", // motion + "\x1b[<32;7;5M", + "\x1b[<32;8;5M", + "\x1b[<0;8;5m", // release + } + if len(actions) != len(want) { + t.Fatalf("got %d actions, want %d: %#v", len(actions), len(want), actions) + } + for i, a := range actions { + if a.Kind != Write { + t.Fatalf("action %d kind = %v, want Write", i, a.Kind) + } + if a.Sequence != want[i] { + t.Fatalf("action %d sequence = %q, want %q", i, a.Sequence, want[i]) + } + if a.Delay != 50*time.Millisecond { + t.Fatalf("action %d delay = %s, want 50ms", i, a.Delay) + } + } +} + +func TestParseSmoothDragDefaultsStepDelay(t *testing.T) { + t.Parallel() + + actions, err := Parse("smoothdrag:1:1:1:4") + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + // press + 3 vertical steps + release. + if len(actions) != 5 { + t.Fatalf("got %d actions, want 5", len(actions)) + } + if actions[1].Delay != defaultSmoothDragStepMs*time.Millisecond { + t.Fatalf("default step delay = %s, want %dms", actions[1].Delay, defaultSmoothDragStepMs) + } +} diff --git a/pkg/keystroke/player.go b/pkg/keystroke/player.go index bb43968..c97ec62 100644 --- a/pkg/keystroke/player.go +++ b/pkg/keystroke/player.go @@ -105,11 +105,17 @@ func (p Player) playAction(action Action) error { sequence = resolved } } - p.logf("key %s -> %s; delay %s\n", actionName(action), strconv.Quote(sequence), p.keystrokeDelay) + delay := p.keystrokeDelay + if action.Delay > 0 { + // A Write may carry its own pacing (e.g. the fast per-step cadence of + // smoothdrag), overriding the default keystroke delay. + delay = action.Delay + } + p.logf("key %s -> %s; delay %s\n", actionName(action), strconv.Quote(sequence), delay) if err := p.write(sequence); err != nil { return err } - p.sleeper.Sleep(p.keystrokeDelay) + p.sleeper.Sleep(delay) case Literal: return p.writeLiteral(action.Sequence) default: