Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,292 @@
|
||||
// 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 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{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), "+")
|
||||
}
|
||||
Reference in new issue
Block a user