Skip to content

Commit a53c44e

Browse files
SilkePilongitbutler-client
authored andcommitted
feat(macros): add text/wait step types and improve delays
Lower minimum step delay to 10ms to allow finer-grained macro timing. Introduce optional "text" and "wait" fields on macro steps (Go and TypeScript types, JSON-RPC parsing) so steps can either type text using the selected keyboard layout or act as explicit wait-only pauses. Implement client-side expansion of text steps into per-character key events (handling shift, AltRight, dead/accent keys and trailing space) and wire expansion into both remote and client-side macro execution. Adjust macro execution logic to treat wait steps as no-op delays and ensure key press followed by explicit release delay is sent for typed keys. These changes enable richer macro semantics (text composition and explicit waits) and more responsive timing control.
1 parent 1ffdca4 commit a53c44e

File tree

7 files changed

+345
-124
lines changed

7 files changed

+345
-124
lines changed

config.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,18 @@ const (
2323
MaxMacrosPerDevice = 25
2424
MaxStepsPerMacro = 10
2525
MaxKeysPerStep = 10
26-
MinStepDelay = 50
26+
MinStepDelay = 10
2727
MaxStepDelay = 2000
2828
)
2929

3030
type KeyboardMacroStep struct {
3131
Keys []string `json:"keys"`
3232
Modifiers []string `json:"modifiers"`
3333
Delay int `json:"delay"`
34+
// Optional: when set, this step types the given text using the configured keyboard layout.
35+
// The delay value is treated as the per-character delay.
36+
Text string `json:"text,omitempty"`
37+
Wait bool `json:"wait,omitempty"`
3438
}
3539

3640
func (s *KeyboardMacroStep) Validate() error {

jsonrpc.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,6 +1013,15 @@ func setKeyboardMacros(params KeyboardMacrosParams) (any, error) {
10131013
step.Delay = int(delay)
10141014
}
10151015

1016+
// Optional text field for advanced steps
1017+
if txt, ok := stepMap["text"].(string); ok {
1018+
step.Text = txt
1019+
}
1020+
1021+
if wv, ok := stepMap["wait"].(bool); ok {
1022+
step.Wait = wv
1023+
}
1024+
10161025
steps = append(steps, step)
10171026
}
10181027
}

ui/src/components/MacroForm.tsx

Lines changed: 75 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export function MacroForm({
6666
newErrors.steps = { 0: { keys: "At least one step is required" } };
6767
} else {
6868
const hasKeyOrModifier = macro.steps.some(
69-
step => (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0,
69+
step => (step.text && step.text.length > 0) || (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0,
7070
);
7171

7272
if (!hasKeyOrModifier) {
@@ -163,6 +163,40 @@ export function MacroForm({
163163
setMacro({ ...macro, steps: newSteps });
164164
};
165165

166+
const handleStepTypeChange = (stepIndex: number, type: "keys" | "text" | "wait") => {
167+
const newSteps = [...(macro.steps || [])];
168+
const prev = newSteps[stepIndex] || { keys: [], modifiers: [], delay: DEFAULT_DELAY };
169+
if (type === "text") {
170+
newSteps[stepIndex] = { keys: [], modifiers: [], delay: prev.delay, text: prev.text || "" } as any;
171+
} else if (type === "wait") {
172+
newSteps[stepIndex] = { keys: [], modifiers: [], delay: prev.delay, wait: true } as any;
173+
} else {
174+
// switch back to keys; drop text
175+
const { text, wait, ...rest } = prev as any;
176+
newSteps[stepIndex] = { ...rest } as any;
177+
}
178+
setMacro({ ...macro, steps: newSteps });
179+
};
180+
181+
const handleTextChange = (stepIndex: number, text: string) => {
182+
const newSteps = [...(macro.steps || [])];
183+
// Ensure this step is of text type
184+
newSteps[stepIndex] = { ...(newSteps[stepIndex] || { keys: [], modifiers: [], delay: DEFAULT_DELAY }), text } as any;
185+
setMacro({ ...macro, steps: newSteps });
186+
};
187+
188+
const insertStepAfter = (index: number) => {
189+
if (isMaxStepsReached) {
190+
showTemporaryError(
191+
`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`,
192+
);
193+
return;
194+
}
195+
const newSteps = [...(macro.steps || [])];
196+
newSteps.splice(index + 1, 0, { keys: [], modifiers: [], delay: DEFAULT_DELAY });
197+
setMacro(prev => ({ ...prev, steps: newSteps }));
198+
};
199+
166200
const handleStepMove = (stepIndex: number, direction: "up" | "down") => {
167201
const newSteps = [...(macro.steps || [])];
168202
const newIndex = direction === "up" ? stepIndex - 1 : stepIndex + 1;
@@ -213,31 +247,46 @@ export function MacroForm({
213247
<Fieldset>
214248
<div className="mt-2 space-y-4">
215249
{(macro.steps || []).map((step, stepIndex) => (
216-
<MacroStepCard
217-
key={stepIndex}
218-
step={step}
219-
stepIndex={stepIndex}
220-
onDelete={
221-
macro.steps && macro.steps.length > 1
222-
? () => {
223-
const newSteps = [...(macro.steps || [])];
224-
newSteps.splice(stepIndex, 1);
225-
setMacro(prev => ({ ...prev, steps: newSteps }));
226-
}
227-
: undefined
228-
}
229-
onMoveUp={() => handleStepMove(stepIndex, "up")}
230-
onMoveDown={() => handleStepMove(stepIndex, "down")}
231-
onKeySelect={option => handleKeySelect(stepIndex, option)}
232-
onKeyQueryChange={query => handleKeyQueryChange(stepIndex, query)}
233-
keyQuery={keyQueries[stepIndex] || ""}
234-
onModifierChange={modifiers =>
235-
handleModifierChange(stepIndex, modifiers)
236-
}
237-
onDelayChange={delay => handleDelayChange(stepIndex, delay)}
238-
isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
239-
keyboard={selectedKeyboard}
240-
/>
250+
<div key={stepIndex} className="space-y-3">
251+
<MacroStepCard
252+
step={step}
253+
stepIndex={stepIndex}
254+
onDelete={
255+
macro.steps && macro.steps.length > 1
256+
? () => {
257+
const newSteps = [...(macro.steps || [])];
258+
newSteps.splice(stepIndex, 1);
259+
setMacro(prev => ({ ...prev, steps: newSteps }));
260+
}
261+
: undefined
262+
}
263+
onMoveUp={() => handleStepMove(stepIndex, "up")}
264+
onMoveDown={() => handleStepMove(stepIndex, "down")}
265+
onKeySelect={option => handleKeySelect(stepIndex, option)}
266+
onKeyQueryChange={query => handleKeyQueryChange(stepIndex, query)}
267+
keyQuery={keyQueries[stepIndex] || ""}
268+
onModifierChange={modifiers =>
269+
handleModifierChange(stepIndex, modifiers)
270+
}
271+
onDelayChange={delay => handleDelayChange(stepIndex, delay)}
272+
isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
273+
keyboard={selectedKeyboard}
274+
onStepTypeChange={type => handleStepTypeChange(stepIndex, type)}
275+
onTextChange={text => handleTextChange(stepIndex, text)}
276+
/>
277+
{stepIndex < (macro.steps?.length || 0) - 1 && (
278+
<div className="flex justify-center">
279+
<Button
280+
size="XS"
281+
theme="light"
282+
LeadingIcon={LuPlus}
283+
text="Insert step here"
284+
onClick={() => insertStepAfter(stepIndex)}
285+
disabled={isMaxStepsReached}
286+
/>
287+
</div>
288+
)}
289+
</div>
241290
))}
242291
</div>
243292
</Fieldset>

ui/src/components/MacroStepCard.tsx

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,22 @@ const basePresetDelays = [
3838
];
3939

4040
const PRESET_DELAYS = basePresetDelays.map(delay => {
41-
if (parseInt(delay.value, 10) === DEFAULT_DELAY) {
42-
return { ...delay, label: "Default" };
43-
}
41+
if (parseInt(delay.value, 10) === DEFAULT_DELAY) return { ...delay, label: "Default" };
4442
return delay;
4543
});
4644

45+
const TEXT_EXTRA_DELAYS = [
46+
{ value: "10", label: "10ms" },
47+
{ value: "20", label: "20ms" },
48+
{ value: "30", label: "30ms" },
49+
];
50+
4751
interface MacroStep {
4852
keys: string[];
4953
modifiers: string[];
5054
delay: number;
55+
text?: string;
56+
wait?: boolean;
5157
}
5258

5359
interface MacroStepCardProps {
@@ -62,7 +68,9 @@ interface MacroStepCardProps {
6268
onModifierChange: (modifiers: string[]) => void;
6369
onDelayChange: (delay: number) => void;
6470
isLastStep: boolean;
65-
keyboard: KeyboardLayout
71+
keyboard: KeyboardLayout;
72+
onStepTypeChange: (type: "keys" | "text" | "wait") => void;
73+
onTextChange: (text: string) => void;
6674
}
6775

6876
const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
@@ -81,7 +89,9 @@ export function MacroStepCard({
8189
onModifierChange,
8290
onDelayChange,
8391
isLastStep,
84-
keyboard
92+
keyboard,
93+
onStepTypeChange,
94+
onTextChange,
8595
}: MacroStepCardProps) {
8696
const { keyDisplayMap } = keyboard;
8797

@@ -106,6 +116,8 @@ export function MacroStepCard({
106116
}
107117
}, [keyOptions, keyQuery, step.keys]);
108118

119+
const stepType: "keys" | "text" | "wait" = step.wait ? "wait" : (step.text !== undefined ? "text" : "keys");
120+
109121
return (
110122
<Card className="p-4">
111123
<div className="mb-2 flex items-center justify-between">
@@ -146,6 +158,46 @@ export function MacroStepCard({
146158
</div>
147159

148160
<div className="space-y-4 mt-2">
161+
<div className="w-full flex flex-col gap-2">
162+
<FieldLabel label="Step Type" />
163+
<div className="inline-flex gap-2">
164+
<Button
165+
size="XS"
166+
theme={stepType === "keys" ? "primary" : "light"}
167+
text="Keys/Modifiers"
168+
onClick={() => onStepTypeChange("keys")}
169+
/>
170+
<Button
171+
size="XS"
172+
theme={stepType === "text" ? "primary" : "light"}
173+
text="Text"
174+
onClick={() => onStepTypeChange("text")}
175+
/>
176+
<Button
177+
size="XS"
178+
theme={stepType === "wait" ? "primary" : "light"}
179+
text="Wait"
180+
onClick={() => onStepTypeChange("wait")}
181+
/>
182+
</div>
183+
</div>
184+
{stepType === "text" ? (
185+
<div className="w-full flex flex-col gap-1">
186+
<FieldLabel label="Text to type" description="Will be typed with this step's delay per character" />
187+
<input
188+
type="text"
189+
className="w-full rounded-md border border-slate-200 px-2 py-1 text-sm dark:border-slate-700 dark:bg-slate-800"
190+
value={step.text || ""}
191+
onChange={e => onTextChange(e.target.value)}
192+
placeholder="Enter text..."
193+
/>
194+
</div>
195+
) : stepType === "wait" ? (
196+
<div className="w-full flex flex-col gap-1">
197+
<FieldLabel label="Wait" description="Pause execution for the specified duration." />
198+
<p className="text-xs text-slate-500 dark:text-slate-400">This step waits for the configured duration, no keys are sent.</p>
199+
</div>
200+
) : (
149201
<div className="w-full flex flex-col gap-2">
150202
<FieldLabel label="Modifiers" />
151203
<div className="inline-flex flex-wrap gap-3">
@@ -176,7 +228,8 @@ export function MacroStepCard({
176228
))}
177229
</div>
178230
</div>
179-
231+
)}
232+
{stepType === "keys" && (
180233
<div className="w-full flex flex-col gap-1">
181234
<div className="flex items-center gap-1">
182235
<FieldLabel label="Keys" description={`Maximum ${MAX_KEYS_PER_STEP} keys per step.`} />
@@ -223,21 +276,22 @@ export function MacroStepCard({
223276
/>
224277
</div>
225278
</div>
226-
279+
)}
227280
<div className="w-full flex flex-col gap-1">
228281
<div className="flex items-center gap-1">
229-
<FieldLabel label="Step Duration" description="Time to wait before executing the next step." />
282+
<FieldLabel label="Step Duration" description={stepType === "text" ? "Delay per character when typing text" : stepType === "wait" ? "How long to pause before the next step" : "Time to wait before executing the next step."} />
230283
</div>
231284
<div className="flex items-center gap-3">
232285
<SelectMenuBasic
233286
size="SM"
234287
fullWidth
235288
value={step.delay.toString()}
236289
onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
237-
options={PRESET_DELAYS}
290+
options={stepType === 'text' ? [...TEXT_EXTRA_DELAYS, ...PRESET_DELAYS] : PRESET_DELAYS}
238291
/>
239292
</div>
240293
</div>
294+
241295
</div>
242296
</Card>
243297
);

ui/src/hooks/stores.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,8 @@ export interface KeySequenceStep {
763763
keys: string[];
764764
modifiers: string[];
765765
delay: number;
766+
text?: string; // optional: when set, type this text with per-character delay
767+
wait?: boolean; // optional: when true, this is a pure wait step (pause for delay ms)
766768
}
767769

768770
export interface KeySequence {

0 commit comments

Comments
 (0)