Cthulhu is no longer a fork. Now it is a screen reader written in Golang.

This commit is contained in:
Storm Dragon committed 2026-10-14 00:16:34 -04:00
1 parent a998fe67de
commit 069fc674dc
708 files changed
+112482 -532

No files matched your search

+292
View File
@@ -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), "+")
}