// Cthulhu screen reader. // 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/atspi" "git.stormux.org/storm/cthulhu/internal/input" ) func should_ignore_terminal_scrollback(event atspi.Event) bool { // VTE can remove old lines at the start of its accessible text while // appending output at a different offset. A deletion entirely before // the caret line is not an edit to the prompt. VTE wrapped rows have // no trailing newline, so do not require one. This event pattern // occurs on both X11 and Wayland. return event.Type == atspi.TextDeletedEvent && event.Source.Role == "terminal" && event.Detail1 == 0 && event.Source.TextCaretKnown && event.Source.BrailleLineStart > 0 && event.TextChangeLength > 0 && event.TextChangeLength <= event.Source.BrailleLineStart } // is_user_terminal_deletion identifies a deletion the user just caused at // the caret on its own line: a Backspace or Delete edit. The deleted text // is the user's confirmation that the edit took, so repaint coalescing // must not swallow it. Program repaints start above the caret line, and // deletions without a preceding delete key are program output churn. func is_user_terminal_deletion(event atspi.Event, intent input.Intent) bool { return intent == input.DeleteIntent && event.Type == atspi.TextDeletedEvent && event.Source.Role == "terminal" && event.Source.TextCaretKnown && event.Detail1 >= event.Source.BrailleLineStart && event.Detail1 <= event.Source.BrailleCaret } // is_latest_wins_terminal_repaint identifies an in-place rewrite: an // insertion that replaced a just-deleted range. Status regions update // this way several times a second and only the newest state is worth // hearing. Editing feedback on the caret line after a typing or delete // key stays immediate; appended output has no preceding deletion and // never qualifies. func is_latest_wins_terminal_repaint(event atspi.Event, intent input.Intent, replacedDeletion bool) bool { if !replacedDeletion || event.Type != atspi.TextInsertedEvent || event.Source.Role != "terminal" { return false } if (intent == input.TypingIntent || intent == input.DeleteIntent) && event.Source.TextCaretKnown && event.Detail1 >= event.Source.BrailleLineStart { return false } return true } // Blank repaints carry no speech. Short nonblank updates can still be // meaningful ("OK", a count, or one Unicode character) and must survive. func is_trivial_terminal_repaint(event atspi.Event) bool { return event.Type == atspi.TextInsertedEvent && event.Source.Role == "terminal" && strings.TrimSpace(event.Payload) == "" } // terminalReplacementOverlap reports whether an insertion rewrites any of // the range a pending deletion removed. Offsets are not compared exactly: // a scroll shift at the start of the buffer between the deletion and its // replacement insertion moves the insertion's offset, and a repainted // region is routinely rewritten at a neighboring offset. func terminalReplacementOverlap(deletion, insertion atspi.Event) bool { if deletion.Source.Role != "terminal" || insertion.Source.Role != "terminal" || deletion.Source.Path == "" || deletion.Source.ID() != insertion.Source.ID() { return false } deletionEnd := deletion.Detail1 + max(deletion.TextChangeLength, 1) insertionEnd := insertion.Detail1 + max(insertion.TextChangeLength, 1) return insertion.Detail1 < deletionEnd && deletion.Detail1 < insertionEnd } // Preserve the first deadline so a continuously redrawn status still speaks // its latest value. The event loop owns both the event and the timer. func (pending *pendingPresentation) coalesce_terminal_repaint(event atspi.Event) { if pending.event.Type == "" { pending.schedule(event, input.NoIntent, terminalRepaintDebounce) return } pending.event = event }