Restore reading backspace if typing with autoread disabled.

This commit is contained in:
Storm Dragon committed 2026-10-28 01:14:09 -04:00
1 parent eaf450587c
commit 57799cd2eb
6 files changed
+295 -6

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package reader
import (
"reflect"
"testing"
"time"
"git.stormux.org/storm/fenrir/internal/command"
"git.stormux.org/storm/fenrir/internal/presentation"
"git.stormux.org/storm/fenrir/internal/screen"
)
func TestDeletionEchoWithAutomaticReadingDisabled(t *testing.T) {
for _, trial := range []struct {
name, before, after string
beforeX, afterX int
want []presentation.Message
}{
{"backspace", "$ abc", "$ ab", 5, 4, []presentation.Message{presentation.Character_message('c')}},
{"delete", "$ abc", "$ ac", 3, 3, []presentation.Message{presentation.Character_message('b')}},
{"midline backspace", "$ abc", "$ ac", 4, 3, []presentation.Message{presentation.Character_message('b')}},
{"unicode", "$ café", "$ caf", 6, 5, []presentation.Message{presentation.Character_message('é')}},
{"punctuation", "$ .", "$ ", 3, 2, []presentation.Message{presentation.Character_message('.')}},
{"space", "$ ab ", "$ ab", 5, 4, []presentation.Message{presentation.Character_message(' ')}},
{"word", "$ one two", "$ one ", 9, 6, []presentation.Message{{Text: "two"}}},
{"forward word", "$ one two", "$ two", 2, 2, []presentation.Message{{Text: "one "}}},
} {
t.Run(trial.name, func(t *testing.T) {
r := New()
r.Configure(false, false)
r.Configure_echo(false, false, false)
before := fixture(trial.before, "background")
before.X = trial.beforeX
r.Update(before)
r.Command(command.CurrentLine)
r.Note_input(true, false, false)
r.Note_deletion()
// An unchanged capture and background output can precede the edit.
r.Update(before)
background := fixture(trial.before, "changed background")
background.X = trial.beforeX
if got := r.Update(background); len(got) != 0 {
t.Fatal("background output escaped autoread", got)
}
after := fixture(trial.after, "more background")
after.X = trial.afterX
if got := r.Update(after); !reflect.DeepEqual(got, trial.want) {
t.Fatal("deletion echo", got, trial.want)
}
if got := r.Update(before); len(got) != 0 {
t.Fatal("redraw spoke after deletion", got)
}
if got := r.Update(after); len(got) != 0 {
t.Fatal("deletion authorization was reused", got)
}
})
}
}
func TestDeletionEchoRejectsUnconfirmedChanges(t *testing.T) {
for _, mode := range []string{"no key", "expired", "other key", "reader command", "redraw", "cursor only", "no echo", "new terminal", "new buffer", "resize", "inactive", "live region"} {
t.Run(mode, func(t *testing.T) {
r := New()
r.Configure(false, true)
before, after := fixture("$ abc", "background"), fixture("$ ab", "changed background")
before.X, after.X = 5, 4
r.Update(before)
if mode != "no key" {
r.Note_input(true, false, false)
r.Note_deletion()
}
switch mode {
case "expired":
r.progressDeletionAt = time.Now().Add(-time.Second)
case "other key":
r.Note_input(true, false, false)
case "reader command":
r.Command(command.CurrentLine)
case "redraw":
after = fixture("$ xx", "changed background")
after.X = 4
case "cursor only":
after = before
after.X = 4
case "no echo":
after = before
case "new terminal":
after.Terminal++
case "new buffer":
after.Generation++
case "resize":
after = fixture("$ ab")
after.X = 4
case "inactive":
after.Active = false
case "live region":
r.ensure_regions().live[0] = &screen.Region{Right: before.Cols - 1}
}
if got := r.Update(after); len(got) != 0 {
t.Fatal("unconfirmed or excluded deletion spoke", got)
}
if mode == "new terminal" || mode == "new buffer" || mode == "resize" || mode == "inactive" {
if got := r.Update(before); len(got) != 0 {
t.Fatal("old session spoke", got)
}
if got := r.Update(fixture("$ ab", "background")); len(got) != 0 {
t.Fatal("pending deletion survived session change", got)
}
}
})
}
}
+19
View File
@@ -138,3 +138,22 @@ func all_spaces(cells []rune) bool {
}
return true
}
// Deletion echo uses only text verified against the previous screen. Like
// typing echo, it remains available with autoread/cursor/character echo off,
// but never exposes text excluded by a live region.
func (r *Reader) deletion_echo(deleted []rune, next screen.Snapshot) []presentation.Message {
var allowed []rune
for index, char := range deleted {
if r.region_state().allows(next.X+index, next.Y, next.Cols, false, true) {
allowed = append(allowed, char)
}
}
if len(allowed) == 1 {
return []presentation.Message{presentation.Character_message(allowed[0])}
}
if len(allowed) > 1 {
return []presentation.Message{{Text: string(allowed)}}
}
return nil
}
+6 -5
View File
@@ -41,10 +41,11 @@ func (r *Reader) Note_deletion() { r.progressDeletionAt = time.Now() }
// A recent deletion key alone is insufficient: match the actual cursor-row
// deletion so background progress, including another row in the same snapshot,
// remains available to the detector. Automatic deletion speech is unchanged.
func (r *Reader) progress_deletion_row(previous, next screen.Snapshot) int {
// remains available to the detector. The same evidence permits deletion echo
// while automatic reading is disabled, without speaking other screen changes.
func (r *Reader) match_deletion(previous, next screen.Snapshot) []rune {
if r.progressDeletionAt.IsZero() || time.Since(r.progressDeletionAt) > echoWindow || previous.Y != next.Y || next.X > previous.X {
return -1
return nil
}
before, after := previous.Line(next.Y), next.Line(next.Y)
count := previous.X - next.X
@@ -61,8 +62,8 @@ func (r *Reader) progress_deletion_row(previous, next screen.Snapshot) int {
}
if count <= 0 || next.X+count > next.Cols || !slices.Equal(before[:next.X], after[:next.X]) ||
!slices.Equal(before[next.X+count:], after[next.X:next.Cols-count]) || !all_spaces(after[next.Cols-count:]) {
return -1
return nil
}
r.progressDeletionAt = time.Time{}
return next.Y
return before[next.X : next.X+count]
}
+11 -1
View File
@@ -149,11 +149,20 @@ func (r *Reader) Update(next screen.Snapshot) (output []presentation.Message) {
path = "typing-echo"
return echo
}
deleted := r.match_deletion(previous, next)
if r.progressEnabled {
r.progress.Update(regions.progress_snapshot(previous), regions.progress_snapshot(next), r.progress_deletion_row(previous, next))
deletionRow := -1
if len(deleted) > 0 {
deletionRow = next.Y
}
r.progress.Update(regions.progress_snapshot(previous), regions.progress_snapshot(next), deletionRow)
}
if !r.automatic {
path = "automatic-disabled"
if len(deleted) > 0 {
path = "deletion-echo"
return r.deletion_echo(deleted, next)
}
return nil
}
readingPrevious := regions.filtered_snapshot(previous, true, true)
@@ -171,6 +180,7 @@ func (r *Reader) Update(next screen.Snapshot) (output []presentation.Message) {
// presentation state for callers to retrieve after dispatch.
func (r *Reader) Command(id command.ID) presentation.CommandResult {
r.applicationInputAt = time.Time{}
r.progressDeletionAt = time.Time{}
if result, handled := r.region_command(id); handled {
return result
}