Files
Chase CichorzandClaude Opus 5.5 551ef86d5f Export and display Remote Braille through the reader
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>
2026-11-06 16:06:38 -05:00

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