// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package input import ( "fmt" "sort" "strconv" "strings" ) // An override replaces all shortcuts for one command in one layout and scope. // An empty Keys slice disables it; removing the override restores defaults. type BindingOverride struct { Layout KeyboardLayout `toml:"layout"` Scope string `toml:"scope"` Command Command `toml:"command"` Keys []string `toml:"keys"` } type BindingRow struct { Scope string Command Command Label string Keys string DefaultKeys string } func CloneBindingOverrides(overrides []BindingOverride) []BindingOverride { if len(overrides) == 0 { return nil } copy := append([]BindingOverride{}, overrides...) for i := range copy { copy[i].Keys = append([]string{}, overrides[i].Keys...) } return copy } func bindingDefaults(layout KeyboardLayout, scope string) CommandBindings { global := DefaultCommandBindings(layout) if scope == "global" { return global } browse := BrowseCommandBindings(layout) for chord := range global { delete(browse, chord) } return browse } func CommandLabel(command Command) string { switch command { case PresentCellFormula: return "Present the formula for the current cell" case OCRObject: return "Recognize object of interest" case OCRWindow: return "Recognize active window" case OCRDesktop: return "Recognize desktop" case OCRClipboard: return "Recognize clipboard image" case OCRRefresh: return "Refresh OCR result" } words := strings.Split(string(command), "-") if len(words) > 0 && words[0] != "" { words[0] = strings.ToUpper(words[0][:1]) + words[0][1:] } return strings.Join(words, " ") } // ResolveCommandBindings removes replaced defaults before inserting any custom // chords, allowing swaps while rejecting collisions with unchanged commands. func ResolveCommandBindings(layout KeyboardLayout, browse bool, overrides []BindingOverride) (CommandBindings, error) { global := bindingDefaults(layout, "global") document := bindingDefaults(layout, "browse") for _, override := range overrides { if override.Layout != layout { continue } bindings := global if override.Scope == "browse" { bindings = document } for chord, command := range bindings { if command == override.Command { delete(bindings, chord) } } } for _, override := range overrides { if override.Layout != layout { continue } bindings := global if override.Scope == "browse" { bindings = document } for _, key := range override.Keys { chord, err := ParseCommandChord(key) if err != nil { return nil, err } if previous := bindings[chord]; previous != "" { return nil, fmt.Errorf("%s conflicts with %s", key, CommandLabel(previous)) } bindings[chord] = override.Command } } for chord, command := range document { if previous := global[chord]; previous != "" { return nil, fmt.Errorf("%s conflicts with %s in browse mode", FormatCommandChord(chord), CommandLabel(previous)) } if browse { global[chord] = command } } return global, nil } func ValidateBindingOverrides(overrides []BindingOverride) error { if len(overrides) > 256 { return fmt.Errorf("too many key binding overrides") } seen := make(map[string]bool) for _, override := range overrides { if override.Layout != DesktopKeyboardLayout && override.Layout != LaptopKeyboardLayout { return fmt.Errorf("invalid binding layout") } if override.Scope != "global" && override.Scope != "browse" { return fmt.Errorf("invalid binding scope") } known := false for _, row := range BindingRows(override.Layout, nil) { known = known || row.Scope == override.Scope && row.Command == override.Command } if !known { return fmt.Errorf("unknown %s command %q", override.Scope, override.Command) } id := fmt.Sprintf("%d/%s/%s", override.Layout, override.Scope, override.Command) if seen[id] { return fmt.Errorf("duplicate override for %s", CommandLabel(override.Command)) } seen[id] = true if len(override.Keys) > 8 { return fmt.Errorf("too many shortcuts for %s", CommandLabel(override.Command)) } } for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} { if _, err := ResolveCommandBindings(layout, true, overrides); err != nil { return err } } return nil } func BindingRows(layout KeyboardLayout, overrides []BindingOverride) []BindingRow { var rows []BindingRow for _, scope := range []string{"global", "browse"} { groups := make(map[Command][]string) for chord, command := range bindingDefaults(layout, scope) { groups[command] = append(groups[command], FormatCommandChord(chord)) } if scope == "global" { for _, command := range []Command{PresentCellFormula, PresentDate, StickyFocusMode, StickyBrowseMode, PresentPreviousNotification, PresentNextNotification, OCRObject, OCRWindow, OCRDesktop, OCRClipboard} { if _, exists := groups[command]; !exists { groups[command] = nil } } } for command, keys := range groups { sort.Strings(keys) defaults := strings.Join(keys, "; ") for _, override := range overrides { if override.Layout == layout && override.Scope == scope && override.Command == command { keys = override.Keys break } } rows = append(rows, BindingRow{scope, command, CommandLabel(command), strings.Join(keys, "; "), defaults}) } } sort.Slice(rows, func(i, j int) bool { if rows[i].Scope != rows[j].Scope { return rows[i].Scope < rows[j].Scope } return rows[i].Label < rows[j].Label }) return rows } var namedKeySymbols = map[string]uint32{ "Space": 0x20, "Tab": 0xff09, "Return": 0xff0d, "Escape": 0xff1b, "BackSpace": 0xff08, "Delete": 0xffff, "Home": 0xff50, "End": 0xff57, "Left": 0xff51, "Up": 0xff52, "Right": 0xff53, "Down": 0xff54, "Page_Up": 0xff55, "Page_Down": 0xff56, "Pause": 0xff13, "Print": 0xff61, "KP_Enter": 0xff8d, "KP_Home": 0xff95, "KP_Left": 0xff96, "KP_Up": 0xff97, "KP_Right": 0xff98, "KP_Down": 0xff99, "KP_Page_Up": 0xff9a, "KP_Page_Down": 0xff9b, "KP_End": 0xff9c, "KP_Begin": 0xff9d, "KP_Delete": 0xff9f, "KP_Multiply": 0xffaa, "KP_Add": 0xffab, "KP_Separator": 0xffac, "KP_Subtract": 0xffad, "KP_Decimal": 0xffae, "KP_Divide": 0xffaf, "semicolon": ';', "plus": '+', } func ParseCommandChord(text string) (CommandChord, error) { var chord CommandChord if len(text) > 128 { return chord, fmt.Errorf("shortcut is too long") } parts := strings.Split(strings.TrimSpace(text), "+") for _, part := range parts[:len(parts)-1] { switch strings.ToLower(strings.TrimSpace(part)) { case "cthulhu": if chord.CthulhuModifier { return chord, fmt.Errorf("duplicate Cthulhu modifier") } chord.CthulhuModifier = true case "ctrl", "control": if chord.Modifiers&4 != 0 { return chord, fmt.Errorf("duplicate Control modifier") } chord.Modifiers |= 4 case "shift": if chord.Modifiers&1 != 0 { return chord, fmt.Errorf("duplicate Shift modifier") } chord.Modifiers |= 1 case "alt": if chord.Modifiers&8 != 0 { return chord, fmt.Errorf("duplicate Alt modifier") } chord.Modifiers |= 8 case "super": if chord.Modifiers&64 != 0 { return chord, fmt.Errorf("duplicate Super modifier") } chord.Modifiers |= 64 default: return chord, fmt.Errorf("unknown modifier %q", part) } } name := strings.TrimSpace(parts[len(parts)-1]) for key, symbol := range namedKeySymbols { if strings.EqualFold(key, name) { chord.Symbol = symbol return chord, nil } } if len(name) > 1 && (name[0] == 'F' || name[0] == 'f') { if number, err := strconv.Atoi(name[1:]); err == nil && number >= 1 && number <= 35 { chord.Symbol = uint32(0xffbd + number) return chord, nil } } if len(name) == 1 && name[0] >= 33 && name[0] <= 126 { chord.Symbol = uint32(strings.ToLower(name)[0]) return chord, nil } return chord, fmt.Errorf("unknown key %q; use a letter, function key, or named key such as Space", name) } func FormatCommandChord(chord CommandChord) string { var parts []string if chord.CthulhuModifier { parts = append(parts, "Cthulhu") } for _, modifier := range []struct { mask uint16 name string }{{4, "Ctrl"}, {1, "Shift"}, {8, "Alt"}, {64, "Super"}} { if chord.Modifiers&modifier.mask != 0 { parts = append(parts, modifier.name) } } name := "" for key, symbol := range namedKeySymbols { if symbol == chord.Symbol { name = key break } } if name == "" { if chord.Symbol >= 0xffbe && chord.Symbol <= 0xffe0 { name = fmt.Sprintf("F%d", chord.Symbol-0xffbd) } else { name = string(rune(chord.Symbol)) } } return strings.Join(append(parts, name), "+") }