Restore reading backspace if typing with autoread disabled.
This commit is contained in:
1 parent
eaf450587c
commit
57799cd2eb
6 files changed
+295
-6
No files matched your search
@@ -0,0 +1,55 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
|
||||||
|
Fenrir is a Linux console screen reader with an X11 terminal mode. This checkout
|
||||||
|
is on the Go migration's `testing` branch; confirm the intended branch before
|
||||||
|
contributing. `cmd/fenrir/` contains the executable entry point. `internal/`
|
||||||
|
contains packages for input, screen review, speech, sound, configuration, and
|
||||||
|
remote control. Go tests live beside their packages as `*_test.go`.
|
||||||
|
|
||||||
|
`config/` holds TOML settings, keyboard layouts, punctuation profiles, and sound
|
||||||
|
assets. `internal/i18n/locale/` holds translations. `docs/` contains manuals;
|
||||||
|
`tools/` contains maintenance utilities. `src/fenrirscreenreader/` retains Python
|
||||||
|
sources, while `tests/` includes Python unit, integration, and native fixtures.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
|
Use Linux, Go 1.27.2 or newer, a C compiler, and `pkg-config`, with development
|
||||||
|
libraries for libevdev, X11, libvterm, and ALSA. See `README.md` for runtime dependencies.
|
||||||
|
|
||||||
|
- `go build -o fenrir ./cmd/fenrir`: build the executable beside its resources.
|
||||||
|
- `./fenrir --config config/settings/settings.toml --check-config`: validate settings without opening devices.
|
||||||
|
- `./fenrir -x`: run in an X11 terminal with `DISPLAY` and `WINDOWID` available.
|
||||||
|
- `go test ./...`: run the Go test suite.
|
||||||
|
- `go test ./internal/reader`: run a focused package's tests.
|
||||||
|
- `go run ./tools/translations check`: validate extracted messages and catalogs; requires GNU gettext.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
|
Format Go changes with `gofmt` and preserve tab indentation. Follow neighboring
|
||||||
|
code: camelCase locals, snake_case helpers, and capitalized exported identifiers
|
||||||
|
where required by Go visibility. Preserve existing license headers. Keep changes
|
||||||
|
focused and use existing configuration and presentation APIs.
|
||||||
|
|
||||||
|
Keep interface messages translatable through `internal/i18n`; exclude screen
|
||||||
|
contents and protocol identifiers. After message changes, run
|
||||||
|
`go run ./tools/translations update`, review catalog changes, then run `check`.
|
||||||
|
Run `shellcheck` on edited shell scripts.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
Use Go's `testing` package with `Test...` functions in `*_test.go`. Cover changed
|
||||||
|
behavior and plausible regressions; no numeric coverage threshold is documented.
|
||||||
|
Follow each native fixture's prerequisites. Automated checks do not establish
|
||||||
|
audible speech or physical keyboard behavior: verify affected console or X11
|
||||||
|
workflows, including navigation and settings persistence. Preserve the active reader.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
|
History uses short descriptive subjects, without a consistent prefix convention.
|
||||||
|
Keep commits scoped. Describe the problem, resulting behavior, linked issues when
|
||||||
|
applicable, commands run, and pending live checks. Follow the README's
|
||||||
|
AI-assisted contribution policy: contributors must review the diff and test
|
||||||
|
intended behavior and likely regressions before submission. Leave submission to
|
||||||
|
the contributor after review.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Copyright (c) 2026 Storm Dragon
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.stormux.org/storm/fenrir/internal/command"
|
||||||
|
"git.stormux.org/storm/fenrir/internal/config"
|
||||||
|
"git.stormux.org/storm/fenrir/internal/input"
|
||||||
|
"git.stormux.org/storm/fenrir/internal/screen"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeletionThroughSharedRouterWithAutoreadOff(t *testing.T) {
|
||||||
|
for _, key := range []uint16{input.KeyBackspace, input.KeyDelete} {
|
||||||
|
for _, mode := range []string{"enabled", "muted", "temporary silence", "speech disabled"} {
|
||||||
|
t.Run(mode+"/"+map[uint16]string{input.KeyBackspace: "backspace", input.KeyDelete: "delete"}[key], func(t *testing.T) {
|
||||||
|
router, err := input.New_router([]input.Binding{{Name: "Quit", Keys: []string{"FENRIR", "Q"}, Command: command.Quit}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
beforeX := 11
|
||||||
|
if key == input.KeyDelete {
|
||||||
|
beforeX = 10
|
||||||
|
}
|
||||||
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true, Rows: 2, Cols: 20, X: beforeX, Cells: []rune("type 2% abc " + "Download 20% ")}, events: make(chan error, 1)}
|
||||||
|
keys := &fakeKeys{events: make(chan error, 1)}
|
||||||
|
voice, sounds := &fakeVoice{}, &fakeSounds{}
|
||||||
|
voice.muted = mode == "speech disabled"
|
||||||
|
reads, polls := 0, 0
|
||||||
|
source.afterRead = func() {
|
||||||
|
reads++
|
||||||
|
if reads == 2 {
|
||||||
|
keys.events <- nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys.poll = func(_ bool, handle func(input.Decision)) error {
|
||||||
|
polls++
|
||||||
|
if polls == 1 {
|
||||||
|
handle(input.Decision{Command: command.AutoRead, Interrupt: true})
|
||||||
|
if mode == "muted" {
|
||||||
|
handle(input.Decision{Command: command.Mute, Interrupt: true})
|
||||||
|
}
|
||||||
|
handle(router.Key(0, key, 1, false, false))
|
||||||
|
if mode == "temporary silence" {
|
||||||
|
handle(input.Decision{Command: command.TempDisableSpeech, Interrupt: true})
|
||||||
|
}
|
||||||
|
voice.messages = nil
|
||||||
|
source.snapshot.Cells = []rune("type 2% ab " + "Download 30% ")
|
||||||
|
source.snapshot.X = 10
|
||||||
|
source.events <- nil
|
||||||
|
} else {
|
||||||
|
handle(input.Decision{Command: command.Quit})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
settings := config.Defaults()
|
||||||
|
settings.Keyboard.CharacterEcho = false
|
||||||
|
settings.Keyboard.WordEcho = false
|
||||||
|
settings.Reading.CursorEcho = false
|
||||||
|
settings.Presentation.Progress.Enabled = true
|
||||||
|
settings.Presentation.Speech.Enabled = mode != "speech disabled"
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := run(ctx, source, keys, voice, nil, RunOptions{Settings: settings, Sounds: sounds}); err != nil || reads != 2 {
|
||||||
|
t.Fatal(err, reads)
|
||||||
|
}
|
||||||
|
var want []string
|
||||||
|
if mode == "enabled" {
|
||||||
|
want = []string{"c"}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(voice.messages, want) {
|
||||||
|
t.Fatal("deletion speech or concurrent output", voice.messages, want)
|
||||||
|
}
|
||||||
|
wantProgress := []float64{30}
|
||||||
|
if mode == "temporary silence" {
|
||||||
|
wantProgress = nil
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(sounds.progress, wantProgress) {
|
||||||
|
t.Fatal("concurrent background progress lost or deletion beeped", sounds.progress)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,3 +138,22 @@ func all_spaces(cells []rune) bool {
|
|||||||
}
|
}
|
||||||
return true
|
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
|
||||||
|
}
|
||||||
@@ -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
|
// A recent deletion key alone is insufficient: match the actual cursor-row
|
||||||
// deletion so background progress, including another row in the same snapshot,
|
// deletion so background progress, including another row in the same snapshot,
|
||||||
// remains available to the detector. Automatic deletion speech is unchanged.
|
// remains available to the detector. The same evidence permits deletion echo
|
||||||
func (r *Reader) progress_deletion_row(previous, next screen.Snapshot) int {
|
// 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 {
|
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)
|
before, after := previous.Line(next.Y), next.Line(next.Y)
|
||||||
count := previous.X - next.X
|
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]) ||
|
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:]) {
|
!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{}
|
r.progressDeletionAt = time.Time{}
|
||||||
return next.Y
|
return before[next.X : next.X+count]
|
||||||
}
|
}
|
||||||
@@ -149,11 +149,20 @@ func (r *Reader) Update(next screen.Snapshot) (output []presentation.Message) {
|
|||||||
path = "typing-echo"
|
path = "typing-echo"
|
||||||
return echo
|
return echo
|
||||||
}
|
}
|
||||||
|
deleted := r.match_deletion(previous, next)
|
||||||
if r.progressEnabled {
|
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 {
|
if !r.automatic {
|
||||||
path = "automatic-disabled"
|
path = "automatic-disabled"
|
||||||
|
if len(deleted) > 0 {
|
||||||
|
path = "deletion-echo"
|
||||||
|
return r.deletion_echo(deleted, next)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
readingPrevious := regions.filtered_snapshot(previous, true, true)
|
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.
|
// presentation state for callers to retrieve after dispatch.
|
||||||
func (r *Reader) Command(id command.ID) presentation.CommandResult {
|
func (r *Reader) Command(id command.ID) presentation.CommandResult {
|
||||||
r.applicationInputAt = time.Time{}
|
r.applicationInputAt = time.Time{}
|
||||||
|
r.progressDeletionAt = time.Time{}
|
||||||
if result, handled := r.region_command(id); handled {
|
if result, handled := r.region_command(id); handled {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user