// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package presentation import ( "strings" "unicode" "unicode/utf8" "git.stormux.org/storm/cthulhu/internal/atspi" ) func TypingEchoText(event atspi.Event, previousText string, options Options) string { text, _ := TypingEcho(event, previousText, options) return text } func TypingEcho(event atspi.Event, previousText string, options Options) (string, string) { kind := "" if event.Type == atspi.TextInsertedEvent && utf8.RuneCountInString(event.Payload) == 1 { kind = "key" if options.EnableEchoByCharacter { kind = "character" } } characterEcho := EventTextWithOptions(event, options) if event.Type != atspi.TextInsertedEvent || !options.EnableKeyEcho || utf8.RuneCountInString(event.Payload) != 1 { return characterEcho, kind } context := event.Source.TextBeforeCaret if context == "" { context = event.Source.Text } if (event.Payload == "\n" || event.Payload == "\t") && strings.TrimSpace(context) == "" { context = previousText } if options.EnableEchoBySentence { if sentence := completedSentence(context, event.Payload); sentence != "" { return sentence, "sentence" } } if options.EnableEchoByWord { if word := completedWord(context, event.Payload); word != "" { return word, "word" } } return characterEcho, kind } func completedWord(context, inserted string) string { if !isWordDelimiter(inserted) { return "" } context = strings.TrimSuffix(context, inserted) characters := []rune(context) if len(characters) == 0 || isWordDelimiter(string(characters[len(characters)-1])) { return "" } start := len(characters) - 1 for start >= 0 && !isWordDelimiter(string(characters[start])) { start-- } return string(characters[start+1:]) } func completedSentence(context, inserted string) string { context = strings.TrimSuffix(context, inserted) characters := []rune(context) if len(characters) == 0 { return "" } if inserted != "\n" && inserted != "\r" { if !unicode.IsSpace(firstRune(inserted)) || !strings.ContainsRune("!.?:;", characters[len(characters)-1]) { return "" } } start := 0 for index := 1; index < len(characters); index++ { if characters[index] == '\n' || characters[index] == '\r' || unicode.IsSpace(characters[index]) && strings.ContainsRune("!.?:;", characters[index-1]) { start = index + 1 } } return strings.TrimSpace(string(characters[start:])) } func isWordDelimiter(value string) bool { if utf8.RuneCountInString(value) != 1 { return false } r := firstRune(value) return unicode.IsSpace(r) || strings.ContainsRune("!*+,-./:;<=>?@[\\]^_{|}", r) || r == '\u00a0' } func firstRune(value string) rune { r, _ := utf8.DecodeRuneInString(value) return r } func shouldEchoInsertion(text string, options Options) bool { if text == "" { return false } if utf8.RuneCountInString(text) != 1 { return true } if !options.EnableKeyEcho { return false } r, _ := utf8.DecodeRuneInString(text) if options.EnableEchoByCharacter && unicode.IsPrint(r) { return true } switch { case unicode.IsLetter(r): return options.EnableAlphabeticKeys case unicode.IsNumber(r): return options.EnableNumericKeys case unicode.IsPunct(r) || unicode.IsSymbol(r): return options.EnablePunctuationKeys case r == ' ': return options.EnableSpace case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Mc, r) || unicode.Is(unicode.Me, r): return options.EnableDiacriticalKeys default: return false } }