Preserve terminal feedback while coalescing repaints
This commit is contained in:
1 parent
8c26aad128
commit
e222d336a5
13 files changed
+321
-39
No files matched your search
@@ -8,6 +8,7 @@ Cthulhu is a Linux screen reader written in Go.
|
||||
- [Progress reporting](PROGRESS_BARS.md): web progress bars, Steam shader processing, and verification.
|
||||
- [Steam keyboard focus](STEAM_FOCUS.md): Xorg workspace focus recovery and verification.
|
||||
- [XTerm handoff](docs/testing/xterm-handoff.md): terminal reader keys, lock state, speech interruption, and verification.
|
||||
- [Graphical terminals](docs/testing/graphical-terminals.md): repaint coalescing, editing feedback, title animation, and verification.
|
||||
- [Messaging support](docs/testing/messaging.md): Pidgin message review/copy shortcuts, application boundaries, and verification.
|
||||
- [Flatpak foreground accessibility](docs/testing/flatpak-foreground.md): process identity and proxy matching, tests, and live acceptance.
|
||||
- [Background event filtering](docs/testing/background-events.md): early foreground checks, notification exceptions, and performance verification.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Graphical terminal regression checks
|
||||
|
||||
Terminal scrollback removal stays silent. In-place delete/insert repaints of
|
||||
the same region coalesce to the latest insertion for up to 500 milliseconds;
|
||||
continuous output retains that first deadline. Different terminals and regions
|
||||
keep their own feedback, and appended output flushes an earlier held repaint
|
||||
in order. Short updates such as `OK` and counts still speak. Backspace and
|
||||
Delete feedback on the caret line remains available. Moving focus to another
|
||||
control cancels the held repaint.
|
||||
|
||||
Typing in a focused terminal can establish flat-review focus if the reader
|
||||
missed its initial focus event. A different control's stale focused bit must
|
||||
not replace known review focus.
|
||||
|
||||
Animated window-title frames share a normalized repeat key for three seconds
|
||||
after their last occurrence. History belongs to that window and survives a
|
||||
workspace round trip; another window's identical title can still announce.
|
||||
Explicit title reading remains available. See [title checks](../../tests/window_titles.md).
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```sh
|
||||
go test ./internal/app -run 'Terminal|Title|Caret' -count=1
|
||||
go build -o /tmp/cthulhu-terminal-review ./cmd/cthulhu
|
||||
CTHULHU_BINARY=/tmp/cthulhu-terminal-review python3 tests/native_terminal.py
|
||||
```
|
||||
|
||||
The native fixture uses a private X11 display, accessibility bus, shell and
|
||||
speech protocol recorder. It checks scrolling, command output, Backspace,
|
||||
Ctrl+W, Delete, history and flat review without replacing the desktop reader.
|
||||
|
||||
For live acceptance with a rebuilt reader:
|
||||
|
||||
1. In a VTE terminal, run commands that scroll past the visible rows. Confirm
|
||||
each command's output speaks without replaying removed prompts.
|
||||
2. Type text, use Backspace, Ctrl+W, Left and Delete, then recall command
|
||||
history. Check speech and Braille against the edited line.
|
||||
3. Read current and previous lines with flat review. Repeat after starting
|
||||
the reader while that terminal already has focus, without switching away.
|
||||
4. Use a TUI that continuously rewrites a status region. Confirm its latest
|
||||
status speaks during the activity, including short counts or `OK`. Check
|
||||
separate regions, appended output, and two terminal panes. Move focus to
|
||||
another control and confirm an old repaint does not follow it.
|
||||
5. Check animated titles, meaningful title changes, workspace round trips,
|
||||
and two windows with identical titles. Check explicit title reading and
|
||||
disabled automatic title changes. Confirm XTerm still delegates input to
|
||||
the terminal reader and desktop controls resume normally on return.
|
||||
|
||||
Wayland Shift+digit heading navigation uses the default libxkbcommon keymap
|
||||
(`XKB_DEFAULT_LAYOUT`, variant and options). These must match the compositor;
|
||||
KeyboardMonitor does not expose its active keymap or group. Ambiguous shifted
|
||||
symbols in multiple layouts are not registered as additional grabs. Verify
|
||||
Shift+1 through Shift+6, Alt+Shift+digit heading lists, plain typing, learn mode,
|
||||
and releasing Shift before the digit on the actual compositor. Private D-Bus
|
||||
tests establish protocol dispatch, not physical key suppression.
|
||||
|
||||
Automated checks do not establish installed-reader, audible speech, physical
|
||||
keyboard or physical Braille acceptance.
|
||||
@@ -254,6 +254,17 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
|
||||
if autocompleteConsumed {
|
||||
return
|
||||
}
|
||||
flushTerminalRepaint := func() {
|
||||
if state.pending.terminalRepaint.event.Type == "" {
|
||||
return
|
||||
}
|
||||
pending, _ := state.pending.terminalRepaint.take()
|
||||
if is_trivial_terminal_repaint(pending) {
|
||||
logger.Debug("dropped trivial terminal repaint fragment", "role", pending.Source.Role)
|
||||
return
|
||||
}
|
||||
speak(pending)
|
||||
}
|
||||
replacedDeletion := false
|
||||
if state.pending.terminalDeletion.event.Type != "" && event.Type != atspi.CaretEvent {
|
||||
pendingDeletion := state.pending.terminalDeletion.event
|
||||
@@ -271,8 +282,9 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
|
||||
replacedDeletion = true
|
||||
state.pending.terminalDeletion.clear()
|
||||
logger.Debug("coalesced terminal deletion with following insertion", "role", event.Source.Role)
|
||||
} else if !(event.Type == atspi.TextDeletedEvent && sameTerminal && !userDeletion) {
|
||||
// Another deletion from the same terminal is part of the same
|
||||
} else if !(event.Type == atspi.TextDeletedEvent && sameTerminal && !userDeletion &&
|
||||
(should_ignore_terminal_scrollback(event) || terminalReplacementOverlap(pendingDeletion, event))) {
|
||||
// An overlapping deletion from the same terminal is part of the same
|
||||
// repaint burst. It is handled below: scroll shifts return
|
||||
// early, anything else replaces the pending deletion. Either
|
||||
// way the pending deletion must not flush early, or the burst
|
||||
@@ -308,6 +320,11 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
|
||||
return
|
||||
}
|
||||
if event.Type == atspi.TextDeletedEvent && event.Source.Role == "terminal" {
|
||||
if state.pending.terminalRepaint.event.Type != "" &&
|
||||
!should_ignore_terminal_scrollback(event) &&
|
||||
(is_user_terminal_deletion(event, intent) || !terminalReplacementOverlap(state.pending.terminalRepaint.event, event)) {
|
||||
flushTerminalRepaint()
|
||||
}
|
||||
if should_ignore_terminal_scrollback(event) {
|
||||
logger.Debug("skipped terminal scrollback deletion", "role", event.Source.Role)
|
||||
return
|
||||
@@ -323,23 +340,12 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
|
||||
// briefly so a newer rewrite of the same region replaces it before
|
||||
// either speaks. A rewrite of a different region, or appended output
|
||||
// from the same terminal, first flushes the held repaint in order.
|
||||
flushTerminalRepaint := func() {
|
||||
if state.pending.terminalRepaint.event.Type == "" {
|
||||
return
|
||||
}
|
||||
pending, _ := state.pending.terminalRepaint.take()
|
||||
if is_trivial_terminal_repaint(pending) {
|
||||
logger.Debug("dropped trivial terminal repaint fragment", "role", pending.Source.Role)
|
||||
return
|
||||
}
|
||||
speak(pending)
|
||||
}
|
||||
if is_latest_wins_terminal_repaint(event, intent, replacedDeletion) {
|
||||
if state.pending.terminalRepaint.event.Type != "" &&
|
||||
!terminalReplacementOverlap(state.pending.terminalRepaint.event, event) {
|
||||
flushTerminalRepaint()
|
||||
}
|
||||
state.pending.terminalRepaint.schedule(event, input.NoIntent, terminalRepaintDebounce)
|
||||
state.pending.terminalRepaint.coalesce_terminal_repaint(event)
|
||||
logger.Debug("queued latest-wins terminal repaint", "role", event.Source.Role)
|
||||
if autocompleteRequest.Text != "" {
|
||||
speech.Enqueue(autocompleteRequest)
|
||||
|
||||
@@ -29,6 +29,7 @@ func (handler *accessibilityEventHandler) handleCaret(event atspi.Event, activit
|
||||
// delete input keeps a background terminal's own output churn
|
||||
// from claiming review focus.
|
||||
if objectReview != nil && event.Source.Role == "terminal" && event.Source.Focused &&
|
||||
(!state.focusedID.Valid() || state.focusedID == event.Source.ID()) &&
|
||||
(intent == input.TypingIntent || intent == input.DeleteIntent) {
|
||||
objectReview.SyncFocus(event.Source.ID())
|
||||
if !state.focusedID.Valid() {
|
||||
|
||||
@@ -187,6 +187,13 @@ func TestTerminalCaretSeedsReviewFocusWithoutFocusEvent(t *testing.T) {
|
||||
if got := worker.Focus(); got != terminal.ID() {
|
||||
t.Fatalf("review focus after unfocused caret = %+v, want %+v", got, terminal.ID())
|
||||
}
|
||||
// Another widget in the same application can retain a stale focused
|
||||
// bit. Its caret must not move review away from the known focus.
|
||||
other.Focused = true
|
||||
handler.handleCaret(atspi.NewEvent(atspi.CaretEvent, other), input.Activity{}, input.TypingIntent)
|
||||
if got := worker.Focus(); got != state.focusedID {
|
||||
t.Fatalf("stale focused bit split review and input ownership: %+v != %+v", got, state.focusedID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessEventsSpeaksCaretCharacters(t *testing.T) {
|
||||
|
||||
@@ -47,6 +47,9 @@ func (handler *accessibilityEventHandler) acceptFocusedEvent(event atspi.Event,
|
||||
state.pending.browserButtonName.clear()
|
||||
}
|
||||
focusID := event.Source.ID()
|
||||
if isFocusGainEvent(event) && state.pending.terminalRepaint.event.Source.ID() != focusID {
|
||||
state.pending.terminalRepaint.clear()
|
||||
}
|
||||
if isFocusGainEvent(event) {
|
||||
state.focusCaretID = focusID
|
||||
state.focusCaretSequence = activity.Sequence
|
||||
|
||||
@@ -7,7 +7,6 @@ package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
@@ -56,13 +55,11 @@ func is_latest_wins_terminal_repaint(event atspi.Event, intent input.Intent, rep
|
||||
return true
|
||||
}
|
||||
|
||||
// is_trivial_terminal_repaint identifies a held repaint whose payload is
|
||||
// only the few cells that changed in a ticking status (timer digits such
|
||||
// as "40"). Uttered alone it has no context; a fuller repaint of the same
|
||||
// region replaces it before the flush if one follows.
|
||||
// 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" &&
|
||||
utf8.RuneCountInString(strings.TrimSpace(event.Payload)) <= 2
|
||||
strings.TrimSpace(event.Payload) == ""
|
||||
}
|
||||
|
||||
// terminalReplacementOverlap reports whether an insertion rewrites any of
|
||||
@@ -71,7 +68,21 @@ func is_trivial_terminal_repaint(event atspi.Event) bool {
|
||||
// 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
|
||||
}
|
||||
@@ -323,7 +323,7 @@ func TestProcessEventsTerminalRepaintsInDifferentRegionsBothSpeak(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessEventsDropsTrivialTerminalRepaintFragments(t *testing.T) {
|
||||
func TestProcessEventsPreservesShortTerminalRepaints(t *testing.T) {
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -338,11 +338,16 @@ func TestProcessEventsDropsTrivialTerminalRepaintFragments(t *testing.T) {
|
||||
terminal.TextCaretKnown = true
|
||||
terminal.BrailleLineStart = 200
|
||||
terminal.BrailleCaret = 205
|
||||
repaintSequence(adapter, terminal, 100, "40")
|
||||
select {
|
||||
case text := <-speech.spoken:
|
||||
t.Fatalf("trivial repaint fragment was presented as %q", text)
|
||||
case <-time.After(2 * terminalRepaintDebounce):
|
||||
for _, expected := range []string{"40", "OK", "猫"} {
|
||||
repaintSequence(adapter, terminal, 100, expected)
|
||||
select {
|
||||
case text := <-speech.spoken:
|
||||
if text != expected {
|
||||
t.Fatalf("short repaint = %q, want %q", text, expected)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("short repaint %q was lost", expected)
|
||||
}
|
||||
}
|
||||
repaintSequence(adapter, terminal, 100, "Thinking (4s)")
|
||||
select {
|
||||
@@ -576,3 +581,106 @@ func TestProcessEventsPresentsTerminalFocusWithoutFollowingInsertion(t *testing.
|
||||
t.Fatal("event loop did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
// Run the real event handler synchronously so coalescing assertions do not
|
||||
// depend on a scheduler processing an entire burst before a timer fires.
|
||||
func terminalHandlerFixture(t *testing.T) (*accessibilityEventHandler, *[]string) {
|
||||
t.Helper()
|
||||
state := &eventLoopState{}
|
||||
t.Cleanup(state.pending.clear)
|
||||
spoken := []string{}
|
||||
handler := &accessibilityEventHandler{
|
||||
state: state, logger: slog.New(slog.DiscardHandler), adapter: &fakeAdapter{},
|
||||
syncForeground: func() {}, webScripts: &webScriptController{},
|
||||
speak: func(event atspi.Event, _ ...input.Intent) { spoken = append(spoken, event.Payload) },
|
||||
}
|
||||
handler.flushTerminalDeletion = func() {
|
||||
event, _ := state.pending.terminalDeletion.take()
|
||||
handler.speak(event)
|
||||
}
|
||||
return handler, &spoken
|
||||
}
|
||||
|
||||
func TestTerminalRepaintsKeepTerminalIdentityAndFirstDeadline(t *testing.T) {
|
||||
for _, differentTerminal := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "same terminal", true: "different bus"}[differentTerminal], func(t *testing.T) {
|
||||
handler, spoken := terminalHandlerFixture(t)
|
||||
terminal := atspi.Snapshot{BusName: ":1.10", Path: "/terminal", Role: "terminal"}
|
||||
repaint := func(text string) {
|
||||
deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Old status")
|
||||
insertion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, text)
|
||||
handler.handle(deletion)
|
||||
handler.handle(insertion)
|
||||
}
|
||||
repaint("First status")
|
||||
firstTimer := handler.state.pending.terminalRepaint.timer
|
||||
if differentTerminal {
|
||||
terminal.BusName = ":1.11"
|
||||
}
|
||||
repaint("Second status")
|
||||
pending := &handler.state.pending.terminalRepaint
|
||||
if differentTerminal {
|
||||
if len(*spoken) != 1 || (*spoken)[0] != "First status" {
|
||||
t.Fatalf("different terminal discarded earlier repaint: %v", *spoken)
|
||||
}
|
||||
} else if pending.timer != firstTimer {
|
||||
t.Fatal("continuous repaint postponed the first deadline")
|
||||
}
|
||||
event, _ := pending.take()
|
||||
if event.Payload != "Second status" {
|
||||
t.Fatalf("latest repaint = %q", event.Payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalSeparateDeletionsPreserveEarlierFeedback(t *testing.T) {
|
||||
handler, spoken := terminalHandlerFixture(t)
|
||||
terminal := atspi.Snapshot{BusName: ":1.10", Path: "/terminal", Role: "terminal"}
|
||||
first := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "First deletion")
|
||||
first.Detail1 = 100
|
||||
second := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Second deletion")
|
||||
second.Detail1 = 300
|
||||
handler.handle(first)
|
||||
handler.handle(second)
|
||||
if len(*spoken) != 1 || (*spoken)[0] != first.Payload {
|
||||
t.Fatalf("unrelated deletion discarded earlier feedback: %v", *spoken)
|
||||
}
|
||||
if handler.state.pending.terminalDeletion.event.Payload != second.Payload {
|
||||
t.Fatal("later deletion was lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalHeldRepaintPreservesOrderAndRetiresOnFocusChange(t *testing.T) {
|
||||
for _, next := range []string{"append", "deletion", "focus"} {
|
||||
t.Run(next, func(t *testing.T) {
|
||||
handler, spoken := terminalHandlerFixture(t)
|
||||
terminal := atspi.Snapshot{BusName: ":1.10", Path: "/terminal", Role: "terminal"}
|
||||
deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Old status")
|
||||
insertion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "Latest status")
|
||||
handler.handle(deletion)
|
||||
handler.handle(insertion)
|
||||
event := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "Appended output")
|
||||
event.Detail1 = 300
|
||||
switch next {
|
||||
case "deletion":
|
||||
event.Type, event.Payload = atspi.TextDeletedEvent, "Later deletion"
|
||||
case "focus":
|
||||
event = atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.10", Path: "/entry", Role: "text", Focused: true})
|
||||
}
|
||||
handler.handle(event)
|
||||
if handler.state.pending.terminalRepaint.event.Type != "" {
|
||||
t.Fatal("old repaint remains pending")
|
||||
}
|
||||
if next == "focus" {
|
||||
for _, text := range *spoken {
|
||||
if text == "Latest status" {
|
||||
t.Fatal("old terminal spoke after focus moved to another control")
|
||||
}
|
||||
}
|
||||
} else if len(*spoken) == 0 || (*spoken)[0] != "Latest status" {
|
||||
t.Fatalf("later output overtook pending repaint: %v", *spoken)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,12 @@ type pendingWindowTitle struct {
|
||||
inputSequence uint64
|
||||
}
|
||||
|
||||
type windowTitleRepeatKey struct {
|
||||
window atspi.ObjectID
|
||||
nativeWindow, titleWindow uint64
|
||||
text string
|
||||
}
|
||||
|
||||
// windowTitleController is owned by the accessibility event loop. Discovery
|
||||
// stays in the native adapter; this controller owns coalescing, preferences,
|
||||
// and duplicate suppression across AT-SPI and X11.
|
||||
@@ -47,7 +53,7 @@ type windowTitleController struct {
|
||||
inputSource any
|
||||
lastNative atspi.ForegroundTitle
|
||||
lastTitle string
|
||||
recent map[string]time.Time
|
||||
recent map[windowTitleRepeatKey]time.Time
|
||||
application string
|
||||
pending *pendingWindowTitle
|
||||
timer *time.Timer
|
||||
@@ -189,8 +195,8 @@ func (titles *windowTitleController) nativeChanged() {
|
||||
// Keep the repeat history: a native sampler can re-report the
|
||||
// same client as a switch (new epoch or title-window id) on
|
||||
// every title sample, and clearing here would defeat animated
|
||||
// title suppression. Genuine window switches clear it through
|
||||
// AT-SPI activation instead.
|
||||
// title suppression. History is scoped to the window identity,
|
||||
// so another window can still announce the same title.
|
||||
if titles.logger != nil {
|
||||
titles.logger.Debug("native window title switch",
|
||||
"epoch", latest.Epoch, "window", latest.Title.Window,
|
||||
@@ -244,9 +250,25 @@ func animatedWindowTitleKey(title string) string {
|
||||
// the repeat window. A suppressed sighting refreshes its entry, so a
|
||||
// running animation stays suppressed instead of breaking through once per
|
||||
// window; it expires shortly after the animation stops.
|
||||
func (titles *windowTitleController) repeatedWithinWindow(text string, at time.Time) bool {
|
||||
key := animatedWindowTitleKey(text)
|
||||
if key == "" {
|
||||
func (titles *windowTitleController) repeatedWithinWindow(pending pendingWindowTitle, at time.Time) bool {
|
||||
key := windowTitleRepeatKey{window: pending.window, text: animatedWindowTitleKey(pending.text)}
|
||||
if key.text == "" {
|
||||
return false
|
||||
}
|
||||
// Correlate AT-SPI and native samples through the same native client
|
||||
// when available. Do not include its epoch: returning to that client
|
||||
// should retain its animation history.
|
||||
native := pending.native
|
||||
if native.Window == 0 && titles.source != nil {
|
||||
latest := titles.source.ForegroundTitle()
|
||||
if latest.Epoch == pending.epoch {
|
||||
native = latest.Title
|
||||
}
|
||||
}
|
||||
if native.Window != 0 {
|
||||
key.window = atspi.ObjectID{}
|
||||
key.nativeWindow, key.titleWindow = native.Window, native.TitleWindow
|
||||
} else if !key.window.Valid() {
|
||||
return false
|
||||
}
|
||||
if at.IsZero() {
|
||||
@@ -258,7 +280,7 @@ func (titles *windowTitleController) repeatedWithinWindow(text string, at time.T
|
||||
}
|
||||
}
|
||||
if titles.recent == nil {
|
||||
titles.recent = make(map[string]time.Time)
|
||||
titles.recent = make(map[windowTitleRepeatKey]time.Time)
|
||||
}
|
||||
_, repeated := titles.recent[key]
|
||||
titles.recent[key] = at
|
||||
@@ -392,7 +414,7 @@ func (titles *windowTitleController) flush() {
|
||||
if pending.inputSequence != recentInput(titles.inputSource).TitleInterruptionSequence {
|
||||
return
|
||||
}
|
||||
if pending.changed && titles.repeatedWithinWindow(pending.text, pending.observedAt) {
|
||||
if pending.changed && titles.repeatedWithinWindow(*pending, time.Now()) {
|
||||
if titles.logger != nil {
|
||||
titles.logger.Debug("suppressed repeated window title",
|
||||
"key", animatedWindowTitleKey(pending.text), "recent", len(titles.recent))
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
@@ -746,3 +747,45 @@ func TestWineReviewTitlesUseNativeApplicationPreferences(t *testing.T) {
|
||||
t.Fatal("Wine title ignored its application preference")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitleRepeatHistoryIsScopedToWindow(t *testing.T) {
|
||||
titles, queue, changed := titleControllerFixture(t)
|
||||
changed.Source.Name = "Build finished"
|
||||
titles.accessible(changed)
|
||||
titles.flush()
|
||||
other := ownerEvent(atspi.WindowActivatedEvent, ":1.51", "/window", "/window")
|
||||
other.Source.Name, other.Source.Role = "Original", "frame"
|
||||
titles.state.owner.activate(other)
|
||||
titles.activated(other)
|
||||
other.Type, other.Source.Name = atspi.AccessibleNameEvent, "Build finished"
|
||||
titles.accessible(other)
|
||||
titles.flush()
|
||||
if len(queue.requests) != 2 {
|
||||
t.Fatalf("another window's title was suppressed: %+v", queue.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitleRepeatHistoryRefreshesAndExpires(t *testing.T) {
|
||||
titles, _, changed := titleControllerFixture(t)
|
||||
pending := pendingWindowTitle{window: changed.Source.ID(), text: "⠋ Terminal"}
|
||||
start := time.Now()
|
||||
if titles.repeatedWithinWindow(pending, start) {
|
||||
t.Fatal("first title suppressed")
|
||||
}
|
||||
pending.text = "⠙ Terminal"
|
||||
if !titles.repeatedWithinWindow(pending, start.Add(2*time.Second)) ||
|
||||
!titles.repeatedWithinWindow(pending, start.Add(4*time.Second)) {
|
||||
t.Fatal("active animation history expired")
|
||||
}
|
||||
if titles.repeatedWithinWindow(pending, start.Add(8*time.Second)) {
|
||||
t.Fatal("inactive animation history did not expire")
|
||||
}
|
||||
pending.native = xorg.WindowTitle{Window: 10, TitleWindow: 11}
|
||||
if titles.repeatedWithinWindow(pending, start.Add(8*time.Second)) {
|
||||
t.Fatal("native window inherited AT-SPI-only history")
|
||||
}
|
||||
pending.native.TitleWindow = 12
|
||||
if titles.repeatedWithinWindow(pending, start.Add(8*time.Second)) {
|
||||
t.Fatal("another native child inherited title history")
|
||||
}
|
||||
}
|
||||
@@ -61,8 +61,8 @@ func lower_symbol(symbol uint32) uint32 { return uint32(C.xkb_keysym_to_lower(C.
|
||||
// registration and event handling translate through shift pairs taken from
|
||||
// the default XKB keymap (the XKB_DEFAULT_* environment, else libxkbcommon
|
||||
// defaults). The monitor interface does not expose the compositor's keymap;
|
||||
// when its layout differs from that keymap, shifted digit and punctuation
|
||||
// chords keep the previous pass-through behavior instead of misfiring.
|
||||
// shifted digit and punctuation chords therefore require these defaults to
|
||||
// match the compositor's layout. The protocol cannot verify that match.
|
||||
func shifted_symbol(symbol uint32) uint32 {
|
||||
return uint32(C.xkb_keysym_to_upper(C.xkb_keysym_t(symbol)))
|
||||
}
|
||||
@@ -183,8 +183,11 @@ func make_grabs(configuration keyboardConfiguration, policy keyboardPolicy) ([]u
|
||||
unique[keyGrab{shifted_symbol(chord.Symbol), mask}] = true
|
||||
}
|
||||
if mask&1 != 0 {
|
||||
for _, symbol := range loadShiftPairs().shifted[chord.Symbol] {
|
||||
unique[keyGrab{symbol, mask}] = true
|
||||
pairs := loadShiftPairs()
|
||||
for _, symbol := range pairs.shifted[chord.Symbol] {
|
||||
if pairs.unshifted[symbol] == chord.Symbol {
|
||||
unique[keyGrab{symbol, mask}] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +214,25 @@ func TestBrowseShiftedDigitHeadingLevels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmbiguousShiftPartnersAreNotGrabbed(t *testing.T) {
|
||||
// US Shift+apostrophe and GB Shift+2 both produce a double quote.
|
||||
// Without the active group, the monitor cannot route it reliably.
|
||||
pairs := build_shift_pairs("us,gb")
|
||||
if _, ok := pairs.unshifted['"']; ok {
|
||||
t.Fatal("ambiguous double quote has a reverse mapping")
|
||||
}
|
||||
original := loadShiftPairs
|
||||
loadShiftPairs = func() shiftPairs { return pairs }
|
||||
t.Cleanup(func() { loadShiftPairs = original })
|
||||
_, grabs, _ := make_grabs(keyboardConfiguration{layout: input.DesktopKeyboardLayout, modifiers: []string{"Insert"}},
|
||||
keyboardPolicy{enabled: true, browse: true})
|
||||
for _, grab := range grabs {
|
||||
if grab.Symbol == '"' && grab.Modifiers&1 != 0 {
|
||||
t.Fatalf("ambiguous shifted symbol grabbed: %+v", grab)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLearnModeAndSecureReset(t *testing.T) {
|
||||
owner := test_owner(t, input.LaptopKeyboardLayout, "Insert")
|
||||
send_key(t, owner, 0xff63, 118, 0, false)
|
||||
|
||||
@@ -22,7 +22,7 @@ import "strings"
|
||||
const Name = "Cthulhu"
|
||||
|
||||
const Version = "2026.11.04"
|
||||
const Channel = "testing"
|
||||
const Channel = "hunter"
|
||||
|
||||
var Build = Version
|
||||
|
||||
|
||||
Reference in new issue
Block a user