package hotkeys import ( "fmt" "strings" "unicode" ) // knownModifiers are the modifier names mpv accepts in key combos. They are // matched case-insensitively (mpv writes "Ctrl", but the embedded presets // also use "ctrl"); the base key itself stays case-sensitive. var knownModifiers = []string{"Ctrl", "Shift", "Alt", "Meta"} // ValidateKeyName reports whether key is a syntactically plausible mpv key name. // Accepts single chars, named keys (SPACE, ENTER, TAB, UP/DOWN/LEFT/RIGHT, HOME, // END, PGUP/PGDWN, INS, DEL, ESC, BACKSPACE, F1-F24, KP0-KP9, KP_ADD..., PRINT, // PLAY, PAUSE, STOP, REWIND, FORWARD, etc.), and modifier combos joined with // '+' (Ctrl+X, Shift+Ctrl+F1, Alt+KP0, Meta+A). mpv is case-sensitive; case is // NOT normalized. Rejects: empty, whitespace anywhere, control chars, '+' at // either end or doubled, unknown modifier names. // // Named keys are not hardcoded: mpv names them in all-uppercase (KP_ADD, // MBTN_LEFT, WHEEL_UP, GAMEPAD_ACTION_DOWN, ...), so any all-uppercase token is // accepted as plausible rather than maintaining a list that drifts out of date. // Bare modifier names (e.g. "ctrl") are accepted as keys — recent mpv supports // modifier-only bindings — but a name followed by '+' must be a known modifier. func ValidateKeyName(key string) error { if key == "" { return fmt.Errorf("key name is empty") } for _, r := range key { switch { case unicode.IsSpace(r): return fmt.Errorf("key name %q contains whitespace", key) case unicode.IsControl(r): return fmt.Errorf("key name %q contains a control character", key) case r > unicode.MaxASCII: return fmt.Errorf("key name %q contains non-ASCII character %q", key, r) } } // Strip leading modifiers (e.g. "Ctrl+Shift+"), matched case-insensitively. base := key for { stripped := false for _, mod := range knownModifiers { prefix := strings.ToLower(mod) + "+" if strings.HasPrefix(strings.ToLower(base), prefix) { base = base[len(prefix):] stripped = true break } } if !stripped { break } } if base == "" { return fmt.Errorf("key name %q has no key after the modifier(s)", key) } // '+' may only appear between modifiers and the base key. The one exception // is the '+' key itself: "ctrl++" binds Ctrl plus the '+' key. if base != "+" && strings.Contains(base, "+") { return fmt.Errorf("key name %q has a misplaced '+' or an unknown modifier", key) } return validateBaseKey(base) } // validateBaseKey validates the key portion that remains after modifiers have // been stripped. The input is known to be printable ASCII at this point. func validateBaseKey(base string) error { // A literal # at the beginning of an input.conf line is the comment // delimiter, so serializing it as a key would silently create a comment. // mpv exposes the unambiguous named spelling SHARP for this key. if base == "#" { return fmt.Errorf("key name %q is reserved for comments; use SHARP", base) } // Single printable ASCII chars are always valid keys (letters, digits, // punctuation such as ",", "/", "*", "+"). if len(base) == 1 { return nil } // Bare modifier names (any case) are accepted as keys; see ValidateKeyName. for _, mod := range knownModifiers { if strings.EqualFold(base, mod) { return nil } } // mpv's named keys are all-uppercase tokens (letters, digits, underscores). for _, r := range base { if !unicode.IsUpper(r) && !unicode.IsDigit(r) && r != '_' { return fmt.Errorf("unknown key name %q (mpv named keys are uppercase, e.g. SPACE, F1, KP_ADD)", base) } } return nil } // ValidateBindingCommand rejects empty commands and anything containing \n, \r, // or other control characters. Spaces/quotes are fine (mpv commands have args, // e.g. `seek 5`, `show-text "hi"`, `cycle pause`). Trailing inline comments // (` #...`) are allowed. func ValidateBindingCommand(cmd string) error { if strings.TrimSpace(cmd) == "" { return fmt.Errorf("command is empty") } if err := validateNoControlChars(cmd); err != nil { return fmt.Errorf("invalid command: %w", err) } return nil } // validateComment ensures a binding comment can be serialized after "#" on a // single line. An empty comment is fine. func validateComment(comment string) error { if err := validateNoControlChars(comment); err != nil { return fmt.Errorf("invalid comment: %w", err) } return nil } // validateNoControlChars rejects s if it contains any control character // (newline, carriage return, tab, ...) — anything that would corrupt the // one-binding-per-line input.conf format. func validateNoControlChars(s string) error { for _, r := range s { if unicode.IsControl(r) { return fmt.Errorf("%q contains a control character (newline, tab, ...)", s) } } return nil }