// 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() } }