Files

228 lines
7.0 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"strings"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/ocr"
)
func ocr_cursor_command(command input.Command) (ocr.Unit, int) {
switch command {
case input.OCRPreviousCharacter, input.ReviewPreviousCharacter:
return ocr.Character, -1
case input.OCRNextCharacter, input.ReviewNextCharacter:
return ocr.Character, 1
case input.ReviewCurrentCharacter, input.ReviewPhoneticCharacter:
return ocr.Character, 0
case input.OCRPreviousWord, input.ReviewPreviousWord:
return ocr.WordUnit, -1
case input.OCRNextWord, input.ReviewNextWord:
return ocr.WordUnit, 1
case input.ReviewCurrentWord, input.ReviewSpellWord, input.ReviewPhoneticWord:
return ocr.WordUnit, 0
case input.OCRPreviousLine, input.ReviewPreviousLine:
return ocr.LineUnit, -1
case input.OCRNextLine, input.ReviewNextLine:
return ocr.LineUnit, 1
case input.OCRPreviousPage:
return ocr.PageUnit, -1
case input.OCRNextPage:
return ocr.PageUnit, 1
case input.ReviewCurrentLine, input.WhereAmI:
return ocr.LineUnit, 0
case input.OCRLineStart:
return ocr.LineStart, 0
case input.OCRLineEnd, input.ReviewLineEnd:
return ocr.LineEnd, 0
case input.OCRDocumentStart, input.ReviewWindowStart:
return ocr.DocumentStart, 0
case input.OCRDocumentEnd, input.ReviewWindowEnd:
return ocr.DocumentEnd, 0
}
return 0, 0
}
// Handle runs on the command consumer and never waits for native input changes.
func (session *ocrSession) Handle(event input.CommandEvent) bool {
if session == nil {
return false
}
session.mu.Lock()
defer session.mu.Unlock()
if kind, ok := ocr_source_command(event.Command); ok {
session.request_locked(kind, event.Timestamp)
return true
}
if session.activating {
return strings.HasPrefix(string(event.Command), "ocr-")
}
if session.cancel == nil {
return strings.HasPrefix(string(event.Command), "ocr-")
}
if !session.current_locked(session.job) {
session.retire_locked()
return strings.HasPrefix(string(event.Command), "ocr-")
}
if event.Timestamp != 0 && (session.cutoff == 0 || int32(event.Timestamp-session.cutoff) > 0) {
session.cutoff = event.Timestamp
}
if session.cursor != nil {
from, to := session.selection_locked()
session.logger.Debug("OCR review command", "command", event.Command, "generation", session.generation,
"focus_path", session.focus.Path, "source_path", session.source.Object.Path,
"offset", session.cursor.Offset(), "selection_start", from, "selection_end", to)
}
switch event.Command {
case input.OCRDismiss, input.OCRDismissAndPass:
session.retire_locked()
if event.Command == input.OCRDismiss {
session.status_locked("OCR dismissed")
}
return true
case input.OCRSelectAll:
if session.cursor == nil {
return true
}
session.selectionAnchor = 0
position, _ := session.cursor.Set(session.cursor.Length())
session.speech.Enqueue(speechRequest{Text: "OCR text selected", Role: "system", Cause: "ocr-selection", context: session.job.ctx})
frame := session.frame_locked(position)
if session.display != nil {
session.display.EnqueueFrame(frame)
}
session.logger.Debug("OCR selection presentation", "command", event.Command, "offset", position.Offset,
"selection_start", 0, "selection_end", position.Offset, "speech_text", "OCR text selected",
"braille_text", frame.Text, "braille_cursor", frame.Cursor)
return true
case input.OCRCopySelection:
session.copy_selection_locked()
return true
case input.OCRActivate:
session.activate_locked()
return true
case input.OCRRefresh:
session.refresh_locked(false)
return true
case input.ReadAll:
session.read_all_locked(event.Timestamp)
return true
}
if unit, step := ocr_cursor_command(event.Command); unit != 0 {
if session.cursor == nil {
return true
}
moves := step != 0 || unit >= ocr.LineStart
oldStart, oldEnd := session.selection_locked()
if moves {
previous := session.cursor.Offset()
if event.Chord.Modifiers&1 != 0 {
if session.selectionAnchor < 0 {
session.selectionAnchor = previous
}
} else {
session.selectionAnchor = -1
}
}
position := session.cursor.Move(unit, step)
if moves && event.Chord.Modifiers&1 != 0 {
if message := session.selection_change_locked(oldStart, oldEnd); message != "" {
session.present_request_locked(position, unit, speechRequest{Text: message, Role: "text", Cause: "ocr-selection", context: session.job.ctx})
return true
}
}
if step != 0 && !position.Moved {
session.present_request_locked(position, unit, speechRequest{Text: "OCR text boundary", Role: "system", Cause: "ocr-status", context: session.job.ctx})
return true
}
session.present_spelling_locked(position, unit, flat_review_speech(event.Command))
return true
}
// Ordinary reader commands restore their existing source/routing first.
session.retire_locked()
return false
}
// Input sees XRecord observations independently of grabbed command dispatch.
// Match complete chords, and protect delayed initiating observations by X time.
// Every unclaimed press retires the session immediately, including Alt+Tab.
func (session *ocrSession) Input(event input.Event) {
if session == nil {
return
}
session.mu.Lock()
defer session.mu.Unlock()
if session.activating {
return
}
if event.Pressed {
session.held[event.Name] = true
} else {
delete(session.held, event.Name)
return
}
if session.cancel == nil {
return
}
session.logger.Debug("OCR observed input", "key", event.Name, "timestamp", event.Timestamp, "cutoff", session.cutoff)
if event.Timestamp != 0 && session.cutoff != 0 && int32(event.Timestamp-session.cutoff) <= 0 {
return
}
settings := session.settings()
symbolName := event.Name
// XRecord uses Xlib's keypad names; shortcut labels use the page names.
switch symbolName {
case "KP_Next":
symbolName = "KP_Page_Down"
case "KP_Prior":
symbolName = "KP_Page_Up"
}
chord, err := input.ParseCommandChord(symbolName)
for name := range session.held {
switch name {
case "Shift_L", "Shift_R":
chord.Modifiers |= 1
case "Control_L", "Control_R":
chord.Modifiers |= 4
case "Alt_L", "Alt_R", "Meta_L", "Meta_R", "Super_L", "Super_R":
session.retire_locked()
return
}
for _, modifier := range settings.CthulhuModifierKeys {
if modifier == name {
chord.CthulhuModifier = true
}
}
}
if event.Kind == input.KeyModifier {
return
}
for _, modifier := range settings.CthulhuModifierKeys {
if modifier == event.Name {
return
}
}
if err != nil {
session.retire_locked()
return
}
bindings, err := input.ResolveCommandBindings(input.KeyboardLayout(settings.KeyboardLayout), false, settings.KeyBindings)
if err != nil {
session.retire_locked()
return
}
if session.cursor == nil {
bindings = input.OCRPendingBindings(bindings)
} else {
bindings = input.OCRCommandBindings(bindings)
}
if bindings[chord] == "" {
session.logger.Debug("OCR dismissed by unclaimed input", "chord", input.FormatCommandChord(chord))
session.retire_locked()
}
}