Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion config.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,18 @@ const (
MaxMacrosPerDevice = 25
MaxStepsPerMacro = 10
MaxKeysPerStep = 10
MinStepDelay = 50
MinStepDelay = 10
MaxStepDelay = 2000
)

type KeyboardMacroStep struct {
Keys []string `json:"keys"`
Modifiers []string `json:"modifiers"`
Delay int `json:"delay"`
// Optional: when set, this step types the given text using the configured keyboard layout.
// The delay value is treated as the per-character delay.
Text string `json:"text,omitempty"`
Wait bool `json:"wait,omitempty"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we already do that with no keys and delay?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Wait" makes a pause explicit, always clears state first, and gives us a future hook; an empty keys+delay is ambiguous and less robust.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll defer to @adamshiervani or @ym on this... I don't think empty-keys+delay is any less robust... but if there's value in wait's implementation, the by all means. Otherwise this seems to be pretty solid

}

func (s *KeyboardMacroStep) Validate() error {
Expand Down
9 changes: 9 additions & 0 deletions jsonrpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,15 @@ func setKeyboardMacros(params KeyboardMacrosParams) (any, error) {
step.Delay = int(delay)
}

// Optional text field for advanced steps
if txt, ok := stepMap["text"].(string); ok {
step.Text = txt
}

if wv, ok := stepMap["wait"].(bool); ok {
step.Wait = wv
}

steps = append(steps, step)
}
}
Expand Down
101 changes: 75 additions & 26 deletions ui/src/components/MacroForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export function MacroForm({
newErrors.steps = { 0: { keys: "At least one step is required" } };
} else {
const hasKeyOrModifier = macro.steps.some(
step => (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0,
step => (step.text && step.text.length > 0) || (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0,
);

if (!hasKeyOrModifier) {
Expand Down Expand Up @@ -163,6 +163,40 @@ export function MacroForm({
setMacro({ ...macro, steps: newSteps });
};

const handleStepTypeChange = (stepIndex: number, type: "keys" | "text" | "wait") => {
const newSteps = [...(macro.steps || [])];
const prev = newSteps[stepIndex] || { keys: [], modifiers: [], delay: DEFAULT_DELAY };
if (type === "text") {
newSteps[stepIndex] = { keys: [], modifiers: [], delay: prev.delay, text: prev.text || "" } as any;
} else if (type === "wait") {
newSteps[stepIndex] = { keys: [], modifiers: [], delay: prev.delay, wait: true } as any;
} else {
// switch back to keys; drop text
const { text, wait, ...rest } = prev as any;
newSteps[stepIndex] = { ...rest } as any;
}
setMacro({ ...macro, steps: newSteps });
};

const handleTextChange = (stepIndex: number, text: string) => {
const newSteps = [...(macro.steps || [])];
// Ensure this step is of text type
newSteps[stepIndex] = { ...(newSteps[stepIndex] || { keys: [], modifiers: [], delay: DEFAULT_DELAY }), text } as any;
setMacro({ ...macro, steps: newSteps });
};

const insertStepAfter = (index: number) => {
if (isMaxStepsReached) {
showTemporaryError(
`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`,
);
return;
}
const newSteps = [...(macro.steps || [])];
newSteps.splice(index + 1, 0, { keys: [], modifiers: [], delay: DEFAULT_DELAY });
setMacro(prev => ({ ...prev, steps: newSteps }));
};

const handleStepMove = (stepIndex: number, direction: "up" | "down") => {
const newSteps = [...(macro.steps || [])];
const newIndex = direction === "up" ? stepIndex - 1 : stepIndex + 1;
Expand Down Expand Up @@ -213,31 +247,46 @@ export function MacroForm({
<Fieldset>
<div className="mt-2 space-y-4">
{(macro.steps || []).map((step, stepIndex) => (
<MacroStepCard
key={stepIndex}
step={step}
stepIndex={stepIndex}
onDelete={
macro.steps && macro.steps.length > 1
? () => {
const newSteps = [...(macro.steps || [])];
newSteps.splice(stepIndex, 1);
setMacro(prev => ({ ...prev, steps: newSteps }));
}
: undefined
}
onMoveUp={() => handleStepMove(stepIndex, "up")}
onMoveDown={() => handleStepMove(stepIndex, "down")}
onKeySelect={option => handleKeySelect(stepIndex, option)}
onKeyQueryChange={query => handleKeyQueryChange(stepIndex, query)}
keyQuery={keyQueries[stepIndex] || ""}
onModifierChange={modifiers =>
handleModifierChange(stepIndex, modifiers)
}
onDelayChange={delay => handleDelayChange(stepIndex, delay)}
isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
keyboard={selectedKeyboard}
/>
<div key={stepIndex} className="space-y-3">
<MacroStepCard
step={step}
stepIndex={stepIndex}
onDelete={
macro.steps && macro.steps.length > 1
? () => {
const newSteps = [...(macro.steps || [])];
newSteps.splice(stepIndex, 1);
setMacro(prev => ({ ...prev, steps: newSteps }));
}
: undefined
}
onMoveUp={() => handleStepMove(stepIndex, "up")}
onMoveDown={() => handleStepMove(stepIndex, "down")}
onKeySelect={option => handleKeySelect(stepIndex, option)}
onKeyQueryChange={query => handleKeyQueryChange(stepIndex, query)}
keyQuery={keyQueries[stepIndex] || ""}
onModifierChange={modifiers =>
handleModifierChange(stepIndex, modifiers)
}
onDelayChange={delay => handleDelayChange(stepIndex, delay)}
isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
keyboard={selectedKeyboard}
onStepTypeChange={type => handleStepTypeChange(stepIndex, type)}
onTextChange={text => handleTextChange(stepIndex, text)}
/>
{stepIndex < (macro.steps?.length || 0) - 1 && (
<div className="flex justify-center">
<Button
size="XS"
theme="light"
LeadingIcon={LuPlus}
text="Insert step here"
onClick={() => insertStepAfter(stepIndex)}
disabled={isMaxStepsReached}
/>
</div>
)}
</div>
))}
</div>
</Fieldset>
Expand Down
72 changes: 63 additions & 9 deletions ui/src/components/MacroStepCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,22 @@ const basePresetDelays = [
];

const PRESET_DELAYS = basePresetDelays.map(delay => {
if (parseInt(delay.value, 10) === DEFAULT_DELAY) {
return { ...delay, label: "Default" };
}
if (parseInt(delay.value, 10) === DEFAULT_DELAY) return { ...delay, label: "Default" };
return delay;
});

const TEXT_EXTRA_DELAYS = [
{ value: "10", label: "10ms" },
{ value: "20", label: "20ms" },
{ value: "30", label: "30ms" },
];

interface MacroStep {
keys: string[];
modifiers: string[];
delay: number;
text?: string;
wait?: boolean;
}

interface MacroStepCardProps {
Expand All @@ -62,7 +68,9 @@ interface MacroStepCardProps {
onModifierChange: (modifiers: string[]) => void;
onDelayChange: (delay: number) => void;
isLastStep: boolean;
keyboard: KeyboardLayout
keyboard: KeyboardLayout;
onStepTypeChange: (type: "keys" | "text" | "wait") => void;
onTextChange: (text: string) => void;
}

const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
Expand All @@ -81,7 +89,9 @@ export function MacroStepCard({
onModifierChange,
onDelayChange,
isLastStep,
keyboard
keyboard,
onStepTypeChange,
onTextChange,
}: MacroStepCardProps) {
const { keyDisplayMap } = keyboard;

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

const stepType: "keys" | "text" | "wait" = step.wait ? "wait" : (step.text !== undefined ? "text" : "keys");

return (
<Card className="p-4">
<div className="mb-2 flex items-center justify-between">
Expand Down Expand Up @@ -146,6 +158,46 @@ export function MacroStepCard({
</div>

<div className="space-y-4 mt-2">
<div className="w-full flex flex-col gap-2">
<FieldLabel label="Step Type" />
<div className="inline-flex gap-2">
<Button
size="XS"
theme={stepType === "keys" ? "primary" : "light"}
text="Keys/Modifiers"
onClick={() => onStepTypeChange("keys")}
/>
<Button
size="XS"
theme={stepType === "text" ? "primary" : "light"}
text="Text"
onClick={() => onStepTypeChange("text")}
/>
<Button
size="XS"
theme={stepType === "wait" ? "primary" : "light"}
text="Wait"
onClick={() => onStepTypeChange("wait")}
/>
</div>
</div>
{stepType === "text" ? (
<div className="w-full flex flex-col gap-1">
<FieldLabel label="Text to type" description="Will be typed with this step's delay per character" />
<input
type="text"
className="w-full rounded-md border border-slate-200 px-2 py-1 text-sm dark:border-slate-700 dark:bg-slate-800"
value={step.text || ""}
onChange={e => onTextChange(e.target.value)}
placeholder="Enter text..."
/>
</div>
) : stepType === "wait" ? (
<div className="w-full flex flex-col gap-1">
<FieldLabel label="Wait" description="Pause execution for the specified duration." />
<p className="text-xs text-slate-500 dark:text-slate-400">This step waits for the configured duration, no keys are sent.</p>
</div>
) : (
<div className="w-full flex flex-col gap-2">
<FieldLabel label="Modifiers" />
<div className="inline-flex flex-wrap gap-3">
Expand Down Expand Up @@ -176,7 +228,8 @@ export function MacroStepCard({
))}
</div>
</div>

)}
{stepType === "keys" && (
<div className="w-full flex flex-col gap-1">
<div className="flex items-center gap-1">
<FieldLabel label="Keys" description={`Maximum ${MAX_KEYS_PER_STEP} keys per step.`} />
Expand Down Expand Up @@ -223,21 +276,22 @@ export function MacroStepCard({
/>
</div>
</div>

)}
<div className="w-full flex flex-col gap-1">
<div className="flex items-center gap-1">
<FieldLabel label="Step Duration" description="Time to wait before executing the next step." />
<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."} />
</div>
<div className="flex items-center gap-3">
<SelectMenuBasic
size="SM"
fullWidth
value={step.delay.toString()}
onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
options={PRESET_DELAYS}
options={stepType === 'text' ? [...TEXT_EXTRA_DELAYS, ...PRESET_DELAYS] : PRESET_DELAYS}
/>
</div>
</div>

</div>
</Card>
);
Expand Down
2 changes: 2 additions & 0 deletions ui/src/hooks/stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,8 @@ export interface KeySequenceStep {
keys: string[];
modifiers: string[];
delay: number;
text?: string; // optional: when set, type this text with per-character delay
wait?: boolean; // optional: when true, this is a pure wait step (pause for delay ms)
}

export interface KeySequence {
Expand Down
Loading