228 lines
7.0 KiB
Go
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()
|
|
}
|
|
}
|