A controlled reader renders accepted application frames in a new virtual-only cthulhu-braille mode at the controller's width and applies its pan and route gestures; reader UI, flashes and secure frames are never exported, and hidden frames are unroutable. A controller reader reports its display width, shows peer cells as an untranslated raw frame and forwards local pan and routing keys. The physical helper now reports its width and edge pans. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
283 lines
8.1 KiB
Go
283 lines
8.1 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.
|
|
// Raw marks final cells from a Remote peer as Unicode Braille runes; the
|
|
// parent sends them without contraction or word wrap.
|
|
Raw bool `json:"-"`
|
|
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
|
|
}
|
|
|
|
// RoutingEvent reports a routing key, or, with Pan set to "left" or "right"
|
|
// and Offset -1, a pan key that could not move the local viewport.
|
|
type RoutingEvent struct {
|
|
ID uint64 `json:"id"`
|
|
Offset int `json:"offset"`
|
|
Pan string `json:"pan,omitempty"`
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|