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