Graphical terminal improvements.
It makes using programs who like to refresh a ton much more tollerable when I'm not in Emacs; I suspect others will like them too.
This commit is contained in:
1 parent
1fcf7e6f87
commit
00dbb1fea7
12 files changed
+608
-14
No files matched your search
@@ -25,6 +25,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
)
|
||||
|
||||
func TestProcessEventsTerminalScrollbackDeletionBothSessions(t *testing.T) {
|
||||
@@ -111,16 +112,22 @@ func TestTerminalScrollbackDeletionPreservesEditing(t *testing.T) {
|
||||
{"backspace", func(event *atspi.Event) { event.Payload = "x"; event.Detail1 = 109 }, false},
|
||||
{"delete", func(event *atspi.Event) { event.Payload = "x"; event.Detail1 = 110 }, false},
|
||||
{"delete word", func(event *atspi.Event) { event.Payload = "command"; event.Detail1 = 100 }, false},
|
||||
{"first character", func(event *atspi.Event) {
|
||||
{"single character at start", func(event *atspi.Event) {
|
||||
// The caret line starts at 100, so a deletion at offset 0 is a
|
||||
// wrap/scroll shift, not an edit on the caret line. A real
|
||||
// first-line backspace has a line start of 0 (see above).
|
||||
*event = atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "x")
|
||||
}, false},
|
||||
}, true},
|
||||
{"newline", func(event *atspi.Event) { event.Payload = "\n"; event.Detail1 = 100 }, false},
|
||||
{"current first line", func(event *atspi.Event) { event.Source.BrailleLineStart = 0 }, false},
|
||||
{"range reaches current line", func(event *atspi.Event) { event.Source.BrailleLineStart = 5 }, false},
|
||||
{"unknown caret", func(event *atspi.Event) { event.Source.TextCaretKnown = false }, false},
|
||||
{"partial line", func(event *atspi.Event) {
|
||||
// VTE wrapped rows have no trailing newline, so a scrollback
|
||||
// deletion entirely before the caret line is still a scroll,
|
||||
// never an edit on the caret line.
|
||||
*event = atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Old prompt")
|
||||
}, false},
|
||||
}, true},
|
||||
{"editor", func(event *atspi.Event) { event.Source.Role = "text" }, false},
|
||||
{"insertion", func(event *atspi.Event) { event.Type = atspi.TextInsertedEvent }, false},
|
||||
} {
|
||||
@@ -179,6 +186,257 @@ func TestProcessEventsCoalescesTerminalDeletionWithReplacementInsertion(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessEventsCoalescesTerminalRepaintAcrossScrollShift(t *testing.T) {
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 3)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 3)}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
completed := make(chan struct{})
|
||||
go func() {
|
||||
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
|
||||
close(completed)
|
||||
}()
|
||||
|
||||
// A TUI repaints its live region: it deletes the old region, a
|
||||
// one-character scroll shift at the start of the buffer moves every
|
||||
// later offset back by one, then it inserts the new region. Only the
|
||||
// insertion may speak.
|
||||
terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "")
|
||||
terminal.TextCaretKnown = true
|
||||
terminal.BrailleLineStart = 190
|
||||
terminal.BrailleCaret = 195
|
||||
repaint := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, strings.Repeat("x", 50))
|
||||
repaint.Detail1 = 100
|
||||
shift := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, " ")
|
||||
replacement := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "new region text")
|
||||
replacement.Detail1 = 99
|
||||
adapter.events <- repaint
|
||||
adapter.events <- shift
|
||||
adapter.events <- replacement
|
||||
|
||||
select {
|
||||
case text := <-speech.spoken:
|
||||
if text != "new region text" {
|
||||
t.Fatalf("spoken text = %q, want only the replacement insertion", text)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for replacement insertion")
|
||||
}
|
||||
select {
|
||||
case text := <-speech.spoken:
|
||||
t.Fatalf("terminal repaint deletion was presented as %q", text)
|
||||
case <-time.After(2 * terminalMutationDebounce):
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case <-completed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("event loop did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
func repaintSequence(adapter *fakeAdapter, terminal atspi.Snapshot, detail1 int, texts ...string) {
|
||||
for _, text := range texts {
|
||||
deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, strings.Repeat("x", len(text)))
|
||||
deletion.Detail1 = detail1
|
||||
insertion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, text)
|
||||
insertion.Detail1 = detail1
|
||||
adapter.events <- deletion
|
||||
adapter.events <- insertion
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessEventsLatestTerminalRepaintWins(t *testing.T) {
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
completed := make(chan struct{})
|
||||
go func() {
|
||||
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
|
||||
close(completed)
|
||||
}()
|
||||
|
||||
terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "")
|
||||
terminal.TextCaretKnown = true
|
||||
terminal.BrailleLineStart = 200
|
||||
terminal.BrailleCaret = 205
|
||||
repaintSequence(adapter, terminal, 100, "Thinking (1s)", "Thinking (2s)", "Thinking (3s)")
|
||||
|
||||
select {
|
||||
case text := <-speech.spoken:
|
||||
if text != "Thinking (3s)" {
|
||||
t.Fatalf("spoken text = %q, want only the latest repaint", text)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("latest repaint was not presented")
|
||||
}
|
||||
select {
|
||||
case text := <-speech.spoken:
|
||||
t.Fatalf("superseded repaint was presented as %q", text)
|
||||
case <-time.After(terminalRepaintDebounce):
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case <-completed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("event loop did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessEventsTerminalRepaintsInDifferentRegionsBothSpeak(t *testing.T) {
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
completed := make(chan struct{})
|
||||
go func() {
|
||||
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
|
||||
close(completed)
|
||||
}()
|
||||
|
||||
terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "")
|
||||
terminal.TextCaretKnown = true
|
||||
terminal.BrailleLineStart = 400
|
||||
terminal.BrailleCaret = 405
|
||||
repaintSequence(adapter, terminal, 100, "First region")
|
||||
repaintSequence(adapter, terminal, 300, "Second region")
|
||||
|
||||
for _, expected := range []string{"First region", "Second region"} {
|
||||
select {
|
||||
case text := <-speech.spoken:
|
||||
if text != expected {
|
||||
t.Fatalf("spoken text = %q, want %q", text, expected)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("timed out waiting for %q", expected)
|
||||
}
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case <-completed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("event loop did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessEventsDropsTrivialTerminalRepaintFragments(t *testing.T) {
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
completed := make(chan struct{})
|
||||
go func() {
|
||||
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
|
||||
close(completed)
|
||||
}()
|
||||
|
||||
terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "")
|
||||
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):
|
||||
}
|
||||
repaintSequence(adapter, terminal, 100, "Thinking (4s)")
|
||||
select {
|
||||
case text := <-speech.spoken:
|
||||
if text != "Thinking (4s)" {
|
||||
t.Fatalf("spoken text = %q, want the full repaint", text)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("full repaint was not presented")
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case <-completed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("event loop did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessEventsPresentsUserTerminalDeletionDespiteRedraw(t *testing.T) {
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 3)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 3)}
|
||||
tracker := &input.IntentTracker{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
completed := make(chan struct{})
|
||||
go func() {
|
||||
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, nil, 0, nil, tracker, nil, nil)
|
||||
close(completed)
|
||||
}()
|
||||
|
||||
// Backspace in a terminal input redraws the erased cell: the deleted
|
||||
// character is replaced by a blank at the same offset. The deletion is
|
||||
// the user's confirmation that the edit took and must still speak.
|
||||
terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "")
|
||||
terminal.TextCaretKnown = true
|
||||
terminal.BrailleLineStart = 100
|
||||
terminal.BrailleCaret = 105
|
||||
tracker.Observe(input.NewKeyEvent(0, true, "BackSpace"))
|
||||
deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "x")
|
||||
deletion.Detail1 = 105
|
||||
redraw := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, " ")
|
||||
redraw.Detail1 = 105
|
||||
adapter.events <- deletion
|
||||
adapter.events <- redraw
|
||||
|
||||
select {
|
||||
case text := <-speech.spoken:
|
||||
if text != "x" {
|
||||
t.Fatalf("spoken text = %q, want the deleted character", text)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("user deletion was not presented")
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case <-completed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("event loop did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserTerminalDeletionClassification(t *testing.T) {
|
||||
terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "")
|
||||
terminal.TextCaretKnown = true
|
||||
terminal.BrailleLineStart = 100
|
||||
terminal.BrailleCaret = 105
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
intent input.Intent
|
||||
modify func(*atspi.Event)
|
||||
want bool
|
||||
}{
|
||||
{"backspace at caret", input.DeleteIntent, func(event *atspi.Event) { event.Detail1 = 105 }, true},
|
||||
{"backspace before caret", input.DeleteIntent, func(event *atspi.Event) { event.Detail1 = 104 }, true},
|
||||
{"typing intent", input.TypingIntent, func(event *atspi.Event) { event.Detail1 = 105 }, false},
|
||||
{"no intent", input.NoIntent, func(event *atspi.Event) { event.Detail1 = 105 }, false},
|
||||
{"repaint above caret line", input.DeleteIntent, func(event *atspi.Event) { event.Detail1 = 40 }, false},
|
||||
{"unknown caret", input.DeleteIntent, func(event *atspi.Event) {
|
||||
event.Detail1 = 105
|
||||
event.Source.TextCaretKnown = false
|
||||
}, false},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
event := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "x")
|
||||
test.modify(&event)
|
||||
if got := is_user_terminal_deletion(event, test.intent); got != test.want {
|
||||
t.Fatalf("is_user_terminal_deletion = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessEventsPresentsStandaloneTerminalDeletion(t *testing.T) {
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 1)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 1)}
|
||||
|
||||
Reference in new issue
Block a user