From 9f27a5d5c36dc79f6bd170fdca1fbed469e0667c Mon Sep 17 00:00:00 2001 From: Silke pilon Date: Sat, 20 Sep 2025 20:56:15 +0200 Subject: [PATCH 1/2] 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. --- config.go | 6 +- jsonrpc.go | 9 + ui/src/components/MacroForm.tsx | 101 +++++--- ui/src/components/MacroStepCard.tsx | 72 +++++- ui/src/hooks/stores.ts | 2 + ui/src/hooks/useKeyboard.ts | 58 ++++- ui/src/routes/devices.$id.settings.macros.tsx | 221 +++++++++++------- 7 files changed, 345 insertions(+), 124 deletions(-) diff --git a/config.go b/config.go index 680999a3..9cf138cd 100644 --- a/config.go +++ b/config.go @@ -23,7 +23,7 @@ const ( MaxMacrosPerDevice = 25 MaxStepsPerMacro = 10 MaxKeysPerStep = 10 - MinStepDelay = 50 + MinStepDelay = 10 MaxStepDelay = 2000 ) @@ -31,6 +31,10 @@ 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"` } func (s *KeyboardMacroStep) Validate() error { diff --git a/jsonrpc.go b/jsonrpc.go index 3e3d9c94..4b328874 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -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) } } diff --git a/ui/src/components/MacroForm.tsx b/ui/src/components/MacroForm.tsx index 1aafe9c9..c5be524e 100644 --- a/ui/src/components/MacroForm.tsx +++ b/ui/src/components/MacroForm.tsx @@ -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) { @@ -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; @@ -213,31 +247,46 @@ export function MacroForm({
{(macro.steps || []).map((step, stepIndex) => ( - 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} - /> +
+ 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 && ( +
+
+ )} +
))}
diff --git a/ui/src/components/MacroStepCard.tsx b/ui/src/components/MacroStepCard.tsx index cf22468b..1c85b79f 100644 --- a/ui/src/components/MacroStepCard.tsx +++ b/ui/src/components/MacroStepCard.tsx @@ -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 { @@ -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 = (arr: T[] | null | undefined): T[] => { @@ -81,7 +89,9 @@ export function MacroStepCard({ onModifierChange, onDelayChange, isLastStep, - keyboard + keyboard, + onStepTypeChange, + onTextChange, }: MacroStepCardProps) { const { keyDisplayMap } = keyboard; @@ -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 (
@@ -146,6 +158,46 @@ export function MacroStepCard({
+
+ +
+
+
+ {stepType === "text" ? ( +
+ + onTextChange(e.target.value)} + placeholder="Enter text..." + /> +
+ ) : stepType === "wait" ? ( +
+ +

This step waits for the configured duration, no keys are sent.

+
+ ) : (
@@ -176,7 +228,8 @@ export function MacroStepCard({ ))}
- + )} + {stepType === "keys" && (
@@ -223,10 +276,10 @@ export function MacroStepCard({ />
- + )}
- +
onDelayChange(parseInt(e.target.value, 10))} - options={PRESET_DELAYS} + options={stepType === 'text' ? [...TEXT_EXTRA_DELAYS, ...PRESET_DELAYS] : PRESET_DELAYS} />
+
); diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index bfbbb26e..967f569a 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -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 { diff --git a/ui/src/hooks/useKeyboard.ts b/ui/src/hooks/useKeyboard.ts index 8d101b3b..5d90798e 100644 --- a/ui/src/hooks/useKeyboard.ts +++ b/ui/src/hooks/useKeyboard.ts @@ -16,6 +16,7 @@ import { import { useHidRpc } from "@/hooks/useHidRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings"; +import useKeyboardLayout from "@/hooks/useKeyboardLayout"; const MACRO_RESET_KEYBOARD_STATE = { keys: new Array(hidKeyBufferSize).fill(0), @@ -27,6 +28,8 @@ export interface MacroStep { keys: string[] | null; modifiers: string[] | null; delay: number; + text?: string | undefined; + wait?: boolean | undefined; } export type MacroSteps = MacroStep[]; @@ -34,6 +37,7 @@ export type MacroSteps = MacroStep[]; const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)); export default function useKeyboard() { + const { selectedKeyboard } = useKeyboardLayout(); const { send } = useJsonRpc(); const { rpcDataChannel } = useRTCStore(); const { keysDownState, setKeysDownState, setKeyboardLedState, setPasteModeEnabled } = @@ -284,9 +288,38 @@ export default function useKeyboard() { // After the delay, the keys and modifiers are released and the next step is executed. // If a step has no keys or modifiers, it is treated as a delay-only step. // A small pause is added between steps to ensure that the device can process the events. + const expandTextSteps = useCallback((steps: MacroSteps): MacroSteps => { + const expanded: MacroSteps = []; + for (const step of steps) { + if (step.text && step.text.length > 0 && selectedKeyboard) { + for (const char of step.text) { + const keyprops = selectedKeyboard.chars[char]; + if (!keyprops) continue; + const { key, shift, altRight, deadKey, accentKey } = keyprops; + if (!key) continue; + if (accentKey) { + const accentModifiers: string[] = []; + if (accentKey.shift) accentModifiers.push("ShiftLeft"); + if (accentKey.altRight) accentModifiers.push("AltRight"); + expanded.push({ keys: [String(accentKey.key)], modifiers: accentModifiers, delay: step.delay }); + } + const mods: string[] = []; + if (shift) mods.push("ShiftLeft"); + if (altRight) mods.push("AltRight"); + expanded.push({ keys: [String(key)], modifiers: mods, delay: step.delay }); + if (deadKey) expanded.push({ keys: ["Space"], modifiers: null, delay: step.delay }); + } + } else { + expanded.push(step); + } + } + return expanded; + }, [selectedKeyboard]); + const executeMacroRemote = useCallback(async ( - steps: MacroSteps, + stepsIn: MacroSteps, ) => { + const steps = expandTextSteps(stepsIn); const macro: KeyboardMacroStep[] = []; for (const [_, step] of steps.entries()) { @@ -297,16 +330,22 @@ export default function useKeyboard() { .reduce((acc, val) => acc + val, 0); - // If the step has keys and/or modifiers, press them and hold for the delay - if (keyValues.length > 0 || modifierMask > 0) { + if (step.wait) { + // pure wait: send a no-op clear state with desired delay + macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 }); + } else if (keyValues.length > 0 || modifierMask > 0) { macro.push({ keys: keyValues, modifier: modifierMask, delay: 20 }); macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 }); + } else { + // empty step (pause only) + macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 }); } } sendKeyboardMacroEventHidRpc(macro); - }, [sendKeyboardMacroEventHidRpc]); - const executeMacroClientSide = useCallback(async (steps: MacroSteps) => { + }, [sendKeyboardMacroEventHidRpc, expandTextSteps]); + const executeMacroClientSide = useCallback(async (stepsIn: MacroSteps) => { + const steps = expandTextSteps(stepsIn); const promises: (() => Promise)[] = []; const ac = new AbortController(); @@ -318,11 +357,14 @@ export default function useKeyboard() { .map(mod => modifiers[mod]) .reduce((acc, val) => acc + val, 0); - // If the step has keys and/or modifiers, press them and hold for the delay - if (keyValues.length > 0 || modifierMask > 0) { + if (step.wait) { + promises.push(() => sleep(step.delay || 100)); + } else if (keyValues.length > 0 || modifierMask > 0) { promises.push(() => sendKeystrokeLegacy(keyValues, modifierMask, ac)); promises.push(() => resetKeyboardState()); promises.push(() => sleep(step.delay || 100)); + } else { + promises.push(() => sleep(step.delay || 100)); } } @@ -354,7 +396,7 @@ export default function useKeyboard() { reject(error); }); }); - }, [sendKeystrokeLegacy, resetKeyboardState, setAbortController]); + }, [sendKeystrokeLegacy, resetKeyboardState, setAbortController, expandTextSteps]); const executeMacro = useCallback(async (steps: MacroSteps) => { if (rpcHidReady) { return executeMacroRemote(steps); diff --git a/ui/src/routes/devices.$id.settings.macros.tsx b/ui/src/routes/devices.$id.settings.macros.tsx index 94fded36..0d781af6 100644 --- a/ui/src/routes/devices.$id.settings.macros.tsx +++ b/ui/src/routes/devices.$id.settings.macros.tsx @@ -1,4 +1,4 @@ -import { useEffect, Fragment, useMemo, useState, useCallback } from "react"; +import { useEffect, Fragment, useMemo, useState, useCallback, useRef } from "react"; import { useNavigate } from "react-router"; import { LuPenLine, @@ -9,6 +9,7 @@ import { LuArrowDown, LuTrash2, LuCommand, + LuDownload, } from "react-icons/lu"; import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores"; @@ -36,6 +37,7 @@ export default function SettingsMacrosRoute() { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [macroToDelete, setMacroToDelete] = useState(null); const { selectedKeyboard } = useKeyboardLayout(); + const fileInputRef = useRef(null); const isMaxMacrosReached = useMemo( () => macros.length >= MAX_TOTAL_MACROS, @@ -48,74 +50,52 @@ export default function SettingsMacrosRoute() { } }, [initialized, loadMacros]); - const handleDuplicateMacro = useCallback( - async (macro: KeySequence) => { - if (!macro?.id || !macro?.name) { - notifications.error("Invalid macro data"); - return; - } + const handleDuplicateMacro = useCallback(async (macro: KeySequence) => { + if (!macro?.id || !macro?.name) { + notifications.error("Invalid macro data"); + return; + } + if (isMaxMacrosReached) { + notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`); + return; + } + setActionLoadingId(macro.id); + const newMacroCopy: KeySequence = { + ...JSON.parse(JSON.stringify(macro)), + id: generateMacroId(), + name: `${macro.name} ${COPY_SUFFIX}`, + sortOrder: macros.length + 1, + }; + try { + await saveMacros(normalizeSortOrders([...macros, newMacroCopy])); + notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`); + } catch (e: any) { + notifications.error(`Failed to duplicate macro: ${e?.message || 'error'}`); + } finally { + setActionLoadingId(null); + } + }, [macros, saveMacros, isMaxMacrosReached]); - if (isMaxMacrosReached) { - notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`); - return; - } - - setActionLoadingId(macro.id); - - const newMacroCopy: KeySequence = { - ...JSON.parse(JSON.stringify(macro)), - id: generateMacroId(), - name: `${macro.name} ${COPY_SUFFIX}`, - sortOrder: macros.length + 1, - }; - - try { - await saveMacros(normalizeSortOrders([...macros, newMacroCopy])); - notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`); - } catch (error: unknown) { - if (error instanceof Error) { - notifications.error(`Failed to duplicate macro: ${error.message}`); - } else { - notifications.error("Failed to duplicate macro"); - } - } finally { - setActionLoadingId(null); - } - }, - [isMaxMacrosReached, macros, saveMacros, setActionLoadingId], - ); - - const handleMoveMacro = useCallback( - async (index: number, direction: "up" | "down", macroId: string) => { - if (!Array.isArray(macros) || macros.length === 0) { - notifications.error("No macros available"); - return; - } - - const newIndex = direction === "up" ? index - 1 : index + 1; - if (newIndex < 0 || newIndex >= macros.length) return; - - setActionLoadingId(macroId); - - try { - const newMacros = [...macros]; - [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]]; - const updatedMacros = normalizeSortOrders(newMacros); - - await saveMacros(updatedMacros); - notifications.success("Macro order updated successfully"); - } catch (error: unknown) { - if (error instanceof Error) { - notifications.error(`Failed to reorder macros: ${error.message}`); - } else { - notifications.error("Failed to reorder macros"); - } - } finally { - setActionLoadingId(null); - } - }, - [macros, saveMacros, setActionLoadingId], - ); + const handleMoveMacro = useCallback(async (index: number, direction: "up" | "down", macroId: string) => { + if (!Array.isArray(macros) || macros.length === 0) { + notifications.error("No macros available"); + return; + } + const newIndex = direction === "up" ? index - 1 : index + 1; + if (newIndex < 0 || newIndex >= macros.length) return; + setActionLoadingId(macroId); + try { + const newMacros = [...macros]; + [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]]; + const updatedMacros = normalizeSortOrders(newMacros); + await saveMacros(updatedMacros); + notifications.success("Macro order updated successfully"); + } catch (e: any) { + notifications.error(`Failed to reorder macros: ${e?.message || 'error'}`); + } finally { + setActionLoadingId(null); + } + }, [macros, saveMacros]); const handleDeleteMacro = useCallback(async () => { if (!macroToDelete?.id) return; @@ -178,9 +158,12 @@ export default function SettingsMacrosRoute() { - {(Array.isArray(step.modifiers) && - step.modifiers.length > 0) || - (Array.isArray(step.keys) && step.keys.length > 0) ? ( + {step.text && step.text.length > 0 ? ( + Text: "{step.text}" + ) : step.wait ? ( + Wait + ) : (Array.isArray(step.modifiers) && step.modifiers.length > 0) || + (Array.isArray(step.keys) && step.keys.length > 0) ? ( <> {Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => ( @@ -224,7 +207,7 @@ export default function SettingsMacrosRoute() { ) : ( - Delay only + Pause only )} {step.delay !== DEFAULT_DELAY && ( @@ -261,6 +244,27 @@ export default function SettingsMacrosRoute() { disabled={actionLoadingId === macro.id} aria-label={`Duplicate macro ${macro.name}`} /> +