Skip to content

Commit af0f452

Browse files
SavidCopilot
andauthored
Add keyboard macros (#305)
* add jsonrpc keyboard macro get/set * add ui keyboard macros settings and macro bar * use notifications component and handle jsonrpc errors * cleanup settings menu * return error rather than truncate steps in validation Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * feat(ui): add className prop to Checkbox component to allow custom styling * use existing components and CTA * extract display key mappings * create generic combobox component * remove macro description * cleanup styles and macro list * create sortable list component * split up macro routes * remove sortable list and simplify * cleanup macrobar * use and add info to fieldlabel * add useCallback optimizations * add confirm dialog component * cleanup delete buttons * revert info on field label * cleanup combobox focus * cleanup icons * set default label for delay --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 3fbcb7e commit af0f452

20 files changed

+1768
-145
lines changed

config.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,64 @@ type WakeOnLanDevice struct {
1414
MacAddress string `json:"macAddress"`
1515
}
1616

17+
// Constants for keyboard macro limits
18+
const (
19+
MaxMacrosPerDevice = 25
20+
MaxStepsPerMacro = 10
21+
MaxKeysPerStep = 10
22+
MinStepDelay = 50
23+
MaxStepDelay = 2000
24+
)
25+
26+
type KeyboardMacroStep struct {
27+
Keys []string `json:"keys"`
28+
Modifiers []string `json:"modifiers"`
29+
Delay int `json:"delay"`
30+
}
31+
32+
func (s *KeyboardMacroStep) Validate() error {
33+
if len(s.Keys) > MaxKeysPerStep {
34+
return fmt.Errorf("too many keys in step (max %d)", MaxKeysPerStep)
35+
}
36+
37+
if s.Delay < MinStepDelay {
38+
s.Delay = MinStepDelay
39+
} else if s.Delay > MaxStepDelay {
40+
s.Delay = MaxStepDelay
41+
}
42+
43+
return nil
44+
}
45+
46+
type KeyboardMacro struct {
47+
ID string `json:"id"`
48+
Name string `json:"name"`
49+
Steps []KeyboardMacroStep `json:"steps"`
50+
SortOrder int `json:"sortOrder,omitempty"`
51+
}
52+
53+
func (m *KeyboardMacro) Validate() error {
54+
if m.Name == "" {
55+
return fmt.Errorf("macro name cannot be empty")
56+
}
57+
58+
if len(m.Steps) == 0 {
59+
return fmt.Errorf("macro must have at least one step")
60+
}
61+
62+
if len(m.Steps) > MaxStepsPerMacro {
63+
return fmt.Errorf("too many steps in macro (max %d)", MaxStepsPerMacro)
64+
}
65+
66+
for i := range m.Steps {
67+
if err := m.Steps[i].Validate(); err != nil {
68+
return fmt.Errorf("invalid step %d: %w", i+1, err)
69+
}
70+
}
71+
72+
return nil
73+
}
74+
1775
type Config struct {
1876
CloudURL string `json:"cloud_url"`
1977
CloudAppURL string `json:"cloud_app_url"`
@@ -26,6 +84,7 @@ type Config struct {
2684
LocalAuthToken string `json:"local_auth_token"`
2785
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
2886
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
87+
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
2988
EdidString string `json:"hdmi_edid_string"`
3089
ActiveExtension string `json:"active_extension"`
3190
DisplayMaxBrightness int `json:"display_max_brightness"`
@@ -43,6 +102,7 @@ var defaultConfig = &Config{
43102
CloudAppURL: "https://app.jetkvm.com",
44103
AutoUpdateEnabled: true, // Set a default value
45104
ActiveExtension: "",
105+
KeyboardMacros: []KeyboardMacro{},
46106
DisplayMaxBrightness: 64,
47107
DisplayDimAfterSec: 120, // 2 minutes
48108
DisplayOffAfterSec: 1800, // 30 minutes

jsonrpc.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,99 @@ func rpcSetScrollSensitivity(sensitivity string) error {
797797
return nil
798798
}
799799

800+
func getKeyboardMacros() (interface{}, error) {
801+
macros := make([]KeyboardMacro, len(config.KeyboardMacros))
802+
copy(macros, config.KeyboardMacros)
803+
804+
return macros, nil
805+
}
806+
807+
type KeyboardMacrosParams struct {
808+
Macros []interface{} `json:"macros"`
809+
}
810+
811+
func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
812+
if params.Macros == nil {
813+
return nil, fmt.Errorf("missing or invalid macros parameter")
814+
}
815+
816+
newMacros := make([]KeyboardMacro, 0, len(params.Macros))
817+
818+
for i, item := range params.Macros {
819+
macroMap, ok := item.(map[string]interface{})
820+
if !ok {
821+
return nil, fmt.Errorf("invalid macro at index %d", i)
822+
}
823+
824+
id, _ := macroMap["id"].(string)
825+
if id == "" {
826+
id = fmt.Sprintf("macro-%d", time.Now().UnixNano())
827+
}
828+
829+
name, _ := macroMap["name"].(string)
830+
831+
sortOrder := i + 1
832+
if sortOrderFloat, ok := macroMap["sortOrder"].(float64); ok {
833+
sortOrder = int(sortOrderFloat)
834+
}
835+
836+
steps := []KeyboardMacroStep{}
837+
if stepsArray, ok := macroMap["steps"].([]interface{}); ok {
838+
for _, stepItem := range stepsArray {
839+
stepMap, ok := stepItem.(map[string]interface{})
840+
if !ok {
841+
continue
842+
}
843+
844+
step := KeyboardMacroStep{}
845+
846+
if keysArray, ok := stepMap["keys"].([]interface{}); ok {
847+
for _, k := range keysArray {
848+
if keyStr, ok := k.(string); ok {
849+
step.Keys = append(step.Keys, keyStr)
850+
}
851+
}
852+
}
853+
854+
if modsArray, ok := stepMap["modifiers"].([]interface{}); ok {
855+
for _, m := range modsArray {
856+
if modStr, ok := m.(string); ok {
857+
step.Modifiers = append(step.Modifiers, modStr)
858+
}
859+
}
860+
}
861+
862+
if delay, ok := stepMap["delay"].(float64); ok {
863+
step.Delay = int(delay)
864+
}
865+
866+
steps = append(steps, step)
867+
}
868+
}
869+
870+
macro := KeyboardMacro{
871+
ID: id,
872+
Name: name,
873+
Steps: steps,
874+
SortOrder: sortOrder,
875+
}
876+
877+
if err := macro.Validate(); err != nil {
878+
return nil, fmt.Errorf("invalid macro at index %d: %w", i, err)
879+
}
880+
881+
newMacros = append(newMacros, macro)
882+
}
883+
884+
config.KeyboardMacros = newMacros
885+
886+
if err := SaveConfig(); err != nil {
887+
return nil, err
888+
}
889+
890+
return nil, nil
891+
}
892+
800893
var rpcHandlers = map[string]RPCHandler{
801894
"ping": {Func: rpcPing},
802895
"getDeviceID": {Func: rpcGetDeviceID},
@@ -862,4 +955,6 @@ var rpcHandlers = map[string]RPCHandler{
862955
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
863956
"getScrollSensitivity": {Func: rpcGetScrollSensitivity},
864957
"setScrollSensitivity": {Func: rpcSetScrollSensitivity, Params: []string{"sensitivity"}},
958+
"getKeyboardMacros": {Func: getKeyboardMacros},
959+
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
865960
}

ui/src/components/Checkbox.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,11 @@ type CheckBoxProps = {
3737
} & Omit<JSX.IntrinsicElements["input"], "size" | "type">;
3838

3939
const Checkbox = forwardRef<HTMLInputElement, CheckBoxProps>(function Checkbox(
40-
{ size = "MD", ...props },
40+
{ size = "MD", className, ...props },
4141
ref,
4242
) {
4343
const classes = checkboxVariants({ size });
44-
return <input ref={ref} {...props} type="checkbox" className={classes} />;
44+
return <input ref={ref} {...props} type="checkbox" className={clsx(classes, className)} />;
4545
});
4646
Checkbox.displayName = "Checkbox";
4747

ui/src/components/Combobox.tsx

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { useRef } from "react";
2+
import clsx from "clsx";
3+
import { Combobox as HeadlessCombobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
4+
import { cva } from "@/cva.config";
5+
import Card from "./Card";
6+
7+
export interface ComboboxOption {
8+
value: string;
9+
label: string;
10+
}
11+
12+
const sizes = {
13+
XS: "h-[24.5px] pl-3 pr-8 text-xs",
14+
SM: "h-[32px] pl-3 pr-8 text-[13px]",
15+
MD: "h-[40px] pl-4 pr-10 text-sm",
16+
LG: "h-[48px] pl-4 pr-10 px-5 text-base",
17+
} as const;
18+
19+
const comboboxVariants = cva({
20+
variants: { size: sizes },
21+
});
22+
23+
type BaseProps = React.ComponentProps<typeof HeadlessCombobox>;
24+
25+
interface ComboboxProps extends Omit<BaseProps, 'displayValue'> {
26+
displayValue: (option: ComboboxOption) => string;
27+
onInputChange: (option: string) => void;
28+
options: () => ComboboxOption[];
29+
placeholder?: string;
30+
emptyMessage?: string;
31+
size?: keyof typeof sizes;
32+
disabledMessage?: string;
33+
}
34+
35+
export function Combobox({
36+
onInputChange,
37+
displayValue,
38+
options,
39+
disabled = false,
40+
placeholder = "Search...",
41+
emptyMessage = "No results found",
42+
size = "MD",
43+
onChange,
44+
disabledMessage = "Input disabled",
45+
...otherProps
46+
}: ComboboxProps) {
47+
const inputRef = useRef<HTMLInputElement>(null);
48+
const classes = comboboxVariants({ size });
49+
50+
return (
51+
<HeadlessCombobox
52+
onChange={onChange}
53+
{...otherProps}
54+
>
55+
{() => (
56+
<>
57+
<Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30">
58+
<ComboboxInput
59+
ref={inputRef}
60+
className={clsx(
61+
classes,
62+
63+
// General styling
64+
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300",
65+
66+
// Hover
67+
"hover:bg-blue-50/80 active:bg-blue-100/60",
68+
69+
// Dark mode
70+
"dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60",
71+
72+
// Focus
73+
"focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500",
74+
75+
// Disabled
76+
disabled && "pointer-events-none select-none bg-slate-50 text-slate-500/80 dark:bg-slate-800 dark:text-slate-400/80 disabled:hover:bg-white dark:disabled:hover:bg-slate-800"
77+
)}
78+
placeholder={disabled ? disabledMessage : placeholder}
79+
displayValue={displayValue}
80+
onChange={(event) => onInputChange(event.target.value)}
81+
disabled={disabled}
82+
/>
83+
</Card>
84+
85+
{options().length > 0 && (
86+
<ComboboxOptions className="absolute left-0 z-[100] mt-1 w-full max-h-60 overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700 hide-scrollbar">
87+
{options().map((option) => (
88+
<ComboboxOption
89+
key={option.value}
90+
value={option}
91+
className={clsx(
92+
// General styling
93+
"cursor-default select-none py-2 px-4",
94+
95+
// Hover and active states
96+
"hover:bg-blue-50/80 ui-active:bg-blue-50/80 ui-active:text-blue-900",
97+
98+
// Dark mode
99+
"dark:text-slate-300 dark:hover:bg-slate-700 dark:ui-active:bg-slate-700 dark:ui-active:text-blue-200"
100+
)}
101+
>
102+
{option.label}
103+
</ComboboxOption>
104+
))}
105+
</ComboboxOptions>
106+
)}
107+
108+
{options().length === 0 && inputRef.current?.value && (
109+
<div className="absolute left-0 z-[100] mt-1 w-full rounded-md bg-white dark:bg-slate-800 py-2 px-4 text-sm shadow-lg ring-1 ring-black/5 dark:ring-slate-700">
110+
<div className="text-slate-500 dark:text-slate-400">
111+
{emptyMessage}
112+
</div>
113+
</div>
114+
)}
115+
</>
116+
)}
117+
</HeadlessCombobox>
118+
);
119+
}

0 commit comments

Comments
 (0)