Files

277 lines
7.7 KiB
Go

// 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
}
}
}
}
}