277 lines
7.7 KiB
Go
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|