From ff5a5f6537e514995e5bea61575f9ff78b60becc Mon Sep 17 00:00:00 2001 From: Mike Shoss Date: Mon, 6 Apr 2026 13:16:54 -0400 Subject: [PATCH 1/5] feat(macros): add Type Text step type to keyboard macros --- ui/localization/messages/en.json | 8 + ui/src/components/MacroBar.tsx | 4 +- ui/src/components/MacroForm.tsx | 46 +++++- ui/src/components/MacroStepCard.tsx | 225 ++++++++++++++++++---------- ui/src/hooks/stores.ts | 1 + ui/src/hooks/useKeyboard.ts | 65 +++++++- 6 files changed, 263 insertions(+), 86 deletions(-) diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index 93004e077..72956f6b2 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -506,6 +506,14 @@ "macro_step_modifiers_label": "Modifiers", "macro_step_no_matching_keys_found": "No matching keys found", "macro_step_search_for_key": "Search for key…", + "macro_step_text_description": "Text to type on the remote machine. Each character will be sent as individual keystrokes.", + "macro_step_text_invalid_chars": "Some characters may not be typeable with the current keyboard layout", + "macro_step_text_label": "Text to Type", + "macro_step_text_placeholder": "Enter text to type…", + "macro_step_type_description": "Choose whether this step sends key presses or types text.", + "macro_step_type_keys": "Keys", + "macro_step_type_label": "Step Type", + "macro_step_type_text": "Type Text", "macro_steps_description": "Keys/modifiers executed in sequence with a delay between each step.", "macro_steps_label": "Steps", "macros_add_description": "Create a new keyboard macro", diff --git a/ui/src/components/MacroBar.tsx b/ui/src/components/MacroBar.tsx index 63c99b616..d7c96af01 100644 --- a/ui/src/components/MacroBar.tsx +++ b/ui/src/components/MacroBar.tsx @@ -3,6 +3,7 @@ import { LuCommand } from "react-icons/lu"; import { useMacrosStore } from "@hooks/stores"; import useKeyboard from "@hooks/useKeyboard"; +import useKeyboardLayout from "@hooks/useKeyboardLayout"; import { useJsonRpc } from "@hooks/useJsonRpc"; import { Button } from "@components/Button"; import Container from "@components/Container"; @@ -10,6 +11,7 @@ import Container from "@components/Container"; export default function MacroBar() { const { macros, initialized, loadMacros, setSendFn } = useMacrosStore(); const { executeMacro } = useKeyboard(); + const { selectedKeyboard } = useKeyboardLayout(); const { send } = useJsonRpc(); useEffect(() => { @@ -38,7 +40,7 @@ export default function MacroBar() { size="XS" theme="light" text={macro.name} - onClick={() => executeMacro(macro.steps)} + onClick={() => executeMacro(macro.steps, selectedKeyboard)} /> ))} diff --git a/ui/src/components/MacroForm.tsx b/ui/src/components/MacroForm.tsx index c299e26ac..74369c2be 100644 --- a/ui/src/components/MacroForm.tsx +++ b/ui/src/components/MacroForm.tsx @@ -60,11 +60,14 @@ export function MacroForm({ const steps = macro.steps || []; if (steps.length) { - const hasKeyOrModifier = steps.some( - step => step.keys.length > 0 || step.modifiers.length > 0, + const hasContent = steps.some( + step => + step.keys.length > 0 || + step.modifiers.length > 0 || + (step.text !== undefined && step.text.length > 0), ); - if (!hasKeyOrModifier) { + if (!hasContent) { newErrors.steps = { 0: { keys: m.macro_at_least_one_step_keys_or_modifiers() }, }; @@ -162,6 +165,41 @@ export function MacroForm({ setMacro({ ...macro, steps: newSteps }); }; + const handleTextChange = (stepIndex: number, text: string) => { + const newSteps = [...(macro.steps || [])]; + newSteps[stepIndex].text = text; + setMacro({ ...macro, steps: newSteps }); + + // Clear step errors when text is entered + if (errors.steps?.[stepIndex]?.keys && text.length > 0) { + const newErrors = { ...errors }; + delete newErrors.steps?.[stepIndex].keys; + if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) { + delete newErrors.steps?.[stepIndex]; + } + if (Object.keys(newErrors.steps || {}).length === 0) { + delete newErrors.steps; + } + setErrors(newErrors); + } + }; + + const handleStepTypeChange = (stepIndex: number, type: "keys" | "text") => { + const newSteps = [...(macro.steps || [])]; + if (type === "text") { + newSteps[stepIndex] = { + keys: [], + modifiers: [], + delay: newSteps[stepIndex].delay, + text: newSteps[stepIndex].text ?? "", + }; + } else { + const { text: _, ...rest } = newSteps[stepIndex]; + newSteps[stepIndex] = rest; + } + setMacro({ ...macro, steps: newSteps }); + }; + const handleStepMove = (stepIndex: number, direction: "up" | "down") => { const newSteps = [...(macro.steps || [])]; const newIndex = direction === "up" ? stepIndex - 1 : stepIndex + 1; @@ -231,6 +269,8 @@ export function MacroForm({ keyQuery={keyQueries[stepIndex] || ""} onModifierChange={modifiers => handleModifierChange(stepIndex, modifiers)} onDelayChange={delay => handleDelayChange(stepIndex, delay)} + onTextChange={text => handleTextChange(stepIndex, text)} + onStepTypeChange={type => handleStepTypeChange(stepIndex, type)} isLastStep={stepIndex === (macro.steps?.length || 0) - 1} keyboard={selectedKeyboard} /> diff --git a/ui/src/components/MacroStepCard.tsx b/ui/src/components/MacroStepCard.tsx index ee39c7364..265422fa6 100644 --- a/ui/src/components/MacroStepCard.tsx +++ b/ui/src/components/MacroStepCard.tsx @@ -56,6 +56,7 @@ interface MacroStep { keys: string[]; modifiers: string[]; delay: number; + text?: string; } interface MacroStepCardProps { @@ -69,6 +70,8 @@ interface MacroStepCardProps { keyQuery: string; onModifierChange: (modifiers: string[]) => void; onDelayChange: (delay: number) => void; + onTextChange: (text: string) => void; + onStepTypeChange: (type: "keys" | "text") => void; isLastStep: boolean; keyboard: KeyboardLayout; } @@ -92,11 +95,15 @@ export function MacroStepCard({ keyQuery, onModifierChange, onDelayChange, + onTextChange, + onStepTypeChange, isLastStep, keyboard, }: Readonly) { const { keyDisplayMap } = keyboard; + const isTextMode = step.text !== undefined; + const keyOptions = useMemo( () => Object.keys(keys) @@ -130,6 +137,17 @@ export function MacroStepCard({ } }, [keyOptions, keyQuery, step.keys]); + const invalidChars = useMemo(() => { + if (!isTextMode || !step.text) return []; + return [ + ...new Set( + [...(new Intl.Segmenter().segment(step.text) ?? [])] + .map(x => x.segment.normalize("NFC")) + .filter(char => !keyboard.chars[char]), + ), + ]; + }, [isTextMode, step.text, keyboard.chars]); + return (
@@ -169,92 +187,143 @@ export function MacroStepCard({
-
-
- + +
+
-
- ))} -
+ -
-
+
+ {isTextMode ? ( + /* Type Text Mode */ +
+