Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,276 @@
|
||||
// Package braille provides the initial, device-independent BrlAPI diagnostic.
|
||||
package braille
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrUnavailable = errors.New("BrlAPI support is unavailable; rebuild with -tags brlapi")
|
||||
|
||||
type Geometry struct{ Columns, Rows int }
|
||||
|
||||
func (g Geometry) Cells() (int, error) {
|
||||
if g.Columns <= 0 || g.Rows <= 0 || g.Columns > 4096 || g.Rows > 4096/g.Columns {
|
||||
return 0, errors.New("unsupported braille display geometry")
|
||||
}
|
||||
return g.Columns * g.Rows, nil
|
||||
}
|
||||
|
||||
type Key int
|
||||
|
||||
// Frame is an uncontracted line. Cursor is a zero-based rune offset into Text;
|
||||
// -1 hides the cursor. The helper converts it to BrlAPI's one-based cell.
|
||||
type Frame struct {
|
||||
Text string `json:"text"`
|
||||
Cursor int `json:"cursor"`
|
||||
ID uint64 `json:"id"`
|
||||
Contract bool `json:"contract,omitempty"`
|
||||
Table string `json:"table,omitempty"`
|
||||
ExpandAtCursor bool `json:"expandAtCursor,omitempty"`
|
||||
WordWrap bool `json:"wordWrap,omitempty"`
|
||||
Dots []byte `json:"dots,omitempty"`
|
||||
// Routing metadata stays in the parent, never in the helper protocol.
|
||||
Routes []TextRoute `json:"-"`
|
||||
Virtual *VirtualRoute `json:"-"`
|
||||
Document *DocumentRoute `json:"-"`
|
||||
}
|
||||
|
||||
// DocumentRoute identifies the page and review epoch which produced a frame.
|
||||
// Each TextRoute still maps cells to the individual native text object.
|
||||
type DocumentRoute struct {
|
||||
Epoch uint64
|
||||
FocusBus, FocusPath, DocumentBus, DocumentPath string
|
||||
}
|
||||
|
||||
// VirtualRoute maps a temporary text frame to a generation-owned cursor.
|
||||
// End includes the synthetic end-of-line cell when the cursor is there.
|
||||
type VirtualRoute struct {
|
||||
Generation uint64
|
||||
Start, End, SourceStart int
|
||||
}
|
||||
|
||||
// TextRoute maps a half-open span of uncontracted cells to source characters.
|
||||
// End-of-line indicator cells clamp to the end of SourceText.
|
||||
type TextRoute struct {
|
||||
Start, End, SourceStart, TextLength int
|
||||
BusName, Path, SourceText string
|
||||
}
|
||||
|
||||
type RoutingEvent struct {
|
||||
ID uint64 `json:"id"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
const (
|
||||
NoKey Key = iota
|
||||
PanLeft
|
||||
PanRight
|
||||
// Route keys encode the zero-based physical cell as RouteBase + cell.
|
||||
RouteBase Key = 0x10000
|
||||
)
|
||||
|
||||
// Backend calls are serialized by the session owner. ReadKey must not wait
|
||||
// for input. Only supported commands are claimed; other keys stay with BRLTTY.
|
||||
type Backend interface {
|
||||
Open() (Geometry, error)
|
||||
Write(text string, cursor int) error // cursor: 0 hides it, otherwise one-based
|
||||
ReadKey() (Key, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// viewport holds displayed cells and their source offsets for routing.
|
||||
// Uncontracted text uses one Unicode rune per cell; liblouis supplies maps
|
||||
// when a frame requests contraction.
|
||||
type viewport struct {
|
||||
text []rune
|
||||
width, offset int
|
||||
cursor int
|
||||
id uint64
|
||||
inputPositions []int
|
||||
wordWrap bool
|
||||
dots []byte
|
||||
panHistory []int
|
||||
}
|
||||
|
||||
func (v *viewport) replace(text string) {
|
||||
v.setFrame(Frame{Text: text, Cursor: -1})
|
||||
}
|
||||
|
||||
func (v *viewport) setFrame(frame Frame) error {
|
||||
length := len([]rune(frame.Text))
|
||||
if frame.Cursor < -1 || frame.Cursor > length {
|
||||
return errors.New("invalid braille cursor offset")
|
||||
}
|
||||
positions := make([]int, length)
|
||||
if len(frame.Dots) != 0 && len(frame.Dots) != length {
|
||||
return errors.New("invalid braille indicator length")
|
||||
}
|
||||
for i := range positions {
|
||||
positions[i] = i
|
||||
}
|
||||
if frame.Contract && frame.Text != "" {
|
||||
translated, mapped, cursor, err := translate(frame.Text, frame.Table, frame.Cursor, frame.ExpandAtCursor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
frame.Text, positions, frame.Cursor = translated, mapped, cursor
|
||||
}
|
||||
if frame.Cursor == len([]rune(frame.Text)) {
|
||||
frame.Text += " "
|
||||
positions = append(positions, length)
|
||||
}
|
||||
v.text = []rune(strings.Map(func(r rune) rune {
|
||||
if r < 32 || r >= 127 && r < 160 {
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, frame.Text))
|
||||
v.offset = 0
|
||||
v.cursor = frame.Cursor
|
||||
v.id = frame.ID
|
||||
v.inputPositions = positions
|
||||
v.wordWrap = frame.WordWrap
|
||||
v.panHistory = nil
|
||||
v.dots = make([]byte, len(positions))
|
||||
for index, source := range positions {
|
||||
end := source + 1
|
||||
if frame.Contract {
|
||||
end = length
|
||||
for _, next := range positions[index+1:] {
|
||||
if next > source {
|
||||
end = next
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for input := source; input < min(end, len(frame.Dots)); input++ {
|
||||
v.dots[index] |= frame.Dots[input]
|
||||
}
|
||||
}
|
||||
if frame.Cursor >= v.width && v.width > 0 {
|
||||
v.offset = frame.Cursor / v.width * v.width
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *viewport) route(key Key) (RoutingEvent, bool) {
|
||||
cell := int(key - RouteBase)
|
||||
if key < RouteBase || cell < 0 || cell >= v.width || v.offset+cell >= v.visible_end() {
|
||||
return RoutingEvent{}, false
|
||||
}
|
||||
return RoutingEvent{ID: v.id, Offset: v.inputPositions[v.offset+cell]}, true
|
||||
}
|
||||
|
||||
func (v *viewport) pan(key Key) bool {
|
||||
previous := v.offset
|
||||
switch key {
|
||||
case PanLeft:
|
||||
if len(v.panHistory) > 0 {
|
||||
v.offset = v.panHistory[len(v.panHistory)-1]
|
||||
v.panHistory = v.panHistory[:len(v.panHistory)-1]
|
||||
} else {
|
||||
v.offset = max(0, v.offset-v.width)
|
||||
}
|
||||
case PanRight:
|
||||
if next := v.visible_end(); next < len(v.text) {
|
||||
v.panHistory = append(v.panHistory, v.offset)
|
||||
v.offset = next
|
||||
}
|
||||
}
|
||||
return previous != v.offset
|
||||
}
|
||||
|
||||
func (v *viewport) frame() string {
|
||||
end := v.visible_end()
|
||||
return string(v.text[v.offset:end]) + strings.Repeat(" ", v.width-(end-v.offset))
|
||||
}
|
||||
|
||||
func (v *viewport) displayDots() []byte {
|
||||
result := make([]byte, v.width)
|
||||
copy(result, v.dots[v.offset:v.visible_end()])
|
||||
return result
|
||||
}
|
||||
|
||||
func (v *viewport) displayCursor() int {
|
||||
if v.cursor < v.offset || v.cursor >= v.visible_end() {
|
||||
return 0
|
||||
}
|
||||
return v.cursor - v.offset + 1
|
||||
}
|
||||
|
||||
func braille_space(cell rune) bool { return cell == ' ' || cell == '\u2800' }
|
||||
|
||||
// Pad the remainder of a wrapped page instead of showing half of the next
|
||||
// word. Overlong words still advance by the display width. Do not hide a caret
|
||||
// which the initial viewport was positioned to show.
|
||||
func (v *viewport) visible_end() int {
|
||||
end := min(len(v.text), v.offset+v.width)
|
||||
if v.wordWrap && end < len(v.text) && !braille_space(v.text[end]) &&
|
||||
!braille_space(v.text[end-1]) {
|
||||
for boundary := end - 1; boundary > v.offset; boundary-- {
|
||||
if braille_space(v.text[boundary]) {
|
||||
if v.cursor >= boundary+1 && v.cursor < end {
|
||||
return end
|
||||
}
|
||||
return boundary + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
// Diagnostic uses only a generic fixture, never live application content.
|
||||
// It owns the backend until cancellation or error and always releases it.
|
||||
func Diagnostic(ctx context.Context, backend Backend, output io.Writer) (result error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Keep every BrlAPI operation on the connection's original OS thread.
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
geometry, err := backend.Open()
|
||||
// Close also cleans up a partially opened connection.
|
||||
defer func() { result = errors.Join(result, backend.Close()) }()
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to BRLTTY: %w", err)
|
||||
}
|
||||
cells, err := geometry.Cells()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
v := viewport{width: cells}
|
||||
v.replace("Cthulhu braille test. Pan right to read this sentence, then pan left to return.")
|
||||
if err := backend.Write(v.frame(), 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(output, "BrlAPI connected: %d columns, %d rows. Use display pan keys; Ctrl+C exits.\n", geometry.Columns, geometry.Rows); err != nil {
|
||||
return err
|
||||
}
|
||||
ticker := time.NewTicker(20 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
key, err := backend.ReadKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if v.pan(key) {
|
||||
if err := backend.Write(v.frame(), 0); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user