295 lines
8.6 KiB
Go
295 lines
8.6 KiB
Go
// 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), "+")
|
|
}
|