1// Package webterm connects the ordinary client terminal boundary to a browser.
2package webterm
3
4import (
5 "context"
6 "errors"
7 "io"
8 "sync"
9
10 vt "github.com/bnema/vev-vt"
11 renderer "github.com/bnema/vev-vt/core"
12 "github.com/bnema/vev/internal/adapters/term"
13 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/ports"
15)
16
17const (
18 MaxColumns = 512
19 MaxRows = 256
20 inputQueueDepth = 64
21)
22
23// Terminal owns a VT mirror of successfully flushed client output. Input and
24// terminal query replies share one ordered writer, just like a physical TTY.
25type Terminal struct {
26 mu sync.Mutex
27 wheelAcc float64
28 screen *vt.Screen
29 geometry domain.Geometry
30 latest vt.ScreenSnapshot
31 changes chan struct{}
32 resize chan domain.Geometry
33 input chan []byte
34 reader *io.PipeReader
35 writer *io.PipeWriter
36 cancel context.CancelFunc
37 done chan struct{}
38 closed bool
39}
40
41var _ ports.Terminal = (*Terminal)(nil)
42
43func New(ctx context.Context, geometry domain.Geometry) (*Terminal, error) {
44 if geometry.Cols < 1 || geometry.Rows < 1 || geometry.Cols > MaxColumns || geometry.Rows > MaxRows {
45 return nil, errors.New("webterm: invalid geometry")
46 }
47 ctx, cancel := context.WithCancel(ctx)
48 reader, writer := io.Pipe()
49 t := &Terminal{screen: vt.NewScreen(geometry.Cols, geometry.Rows), geometry: geometry, changes: make(chan struct{}, 1), resize: make(chan domain.Geometry, 1), input: make(chan []byte, inputQueueDepth), reader: reader, writer: writer, cancel: cancel, done: make(chan struct{})}
50 t.screen.OnResponse = func(data []byte) {
51 select {
52 case t.input <- append([]byte(nil), data...):
53 default:
54 cancel()
55 }
56 }
57 // Match #terminal's default colors in app.css so the ordinary theme
58 // discovery enables native inactive-pane and modal-backdrop dimming.
59 t.screen.SetDefaultColors(renderer.RGB{R: 216, G: 216, B: 216}, renderer.RGB{R: 16, G: 16, B: 16}, true)
60 t.latest = t.screen.Snapshot()
61 go func() {
62 defer close(t.done)
63 stop := context.AfterFunc(ctx, func() { _ = writer.CloseWithError(ctx.Err()); _ = reader.CloseWithError(ctx.Err()) })
64 defer stop()
65 for {
66 select {
67 case <-ctx.Done():
68 return
69 case data := <-t.input:
70 if _, err := writer.Write(data); err != nil {
71 return
72 }
73 }
74 }
75 }()
76 return t, nil
77}
78
79// EnableKittyKeyboard is a no-op: a virtual terminal has no outer keyboard.
80func (t *Terminal) EnableKittyKeyboard(int) error { return nil }
81
82func (t *Terminal) EnterRaw() (func() error, error) {
83 if _, err := t.Write(term.VisualEnterSequence()); err != nil {
84 return nil, err
85 }
86 if err := t.Flush(); err != nil {
87 return nil, err
88 }
89 return func() error { return nil }, nil
90}
91func (t *Terminal) In() io.Reader { return t.reader }
92func (t *Terminal) Out() io.Writer { return t }
93func (t *Terminal) ResizeEvents() <-chan domain.Geometry { return t.resize }
94func (t *Terminal) Changes() <-chan struct{} { return t.changes }
95func (t *Terminal) Geometry() (domain.Geometry, error) {
96 t.mu.Lock()
97 defer t.mu.Unlock()
98 if t.closed {
99 return domain.Geometry{}, io.ErrClosedPipe
100 }
101 return t.geometry, nil
102}
103func (t *Terminal) Write(data []byte) (int, error) {
104 t.mu.Lock()
105 defer t.mu.Unlock()
106 if t.closed {
107 return 0, io.ErrClosedPipe
108 }
109 t.screen.Write(data)
110 return len(data), nil
111}
112func (t *Terminal) Flush() error {
113 t.mu.Lock()
114 defer t.mu.Unlock()
115 if t.closed {
116 return io.ErrClosedPipe
117 }
118 t.latest = t.screen.Snapshot()
119 select {
120 case t.changes <- struct{}{}:
121 default:
122 }
123 return nil
124}
125func (t *Terminal) Snapshot() vt.ScreenSnapshot {
126 t.mu.Lock()
127 defer t.mu.Unlock()
128 return t.latest
129}
130func (t *Terminal) Resize(geometry domain.Geometry) error {
131 if geometry.Cols < 1 || geometry.Rows < 1 || geometry.Cols > MaxColumns || geometry.Rows > MaxRows {
132 return errors.New("webterm: invalid geometry")
133 }
134 t.mu.Lock()
135 defer t.mu.Unlock()
136 if t.closed {
137 return io.ErrClosedPipe
138 }
139 if t.geometry == geometry {
140 return nil
141 }
142 t.geometry = geometry
143 t.screen.SetGeometry(vt.Geometry{Cols: geometry.Cols, Rows: geometry.Rows, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight})
144 select {
145 case <-t.resize:
146 default:
147 }
148 t.resize <- geometry
149 return nil
150}
151func (t *Terminal) Send(ctx context.Context, data []byte) error {
152 if len(data) == 0 {
153 return nil
154 }
155 select {
156 case <-ctx.Done():
157 return ctx.Err()
158 case <-t.done:
159 return io.ErrClosedPipe
160 case t.input <- append([]byte(nil), data...):
161 return nil
162 default:
163 return errors.New("webterm: input queue full")
164 }
165}
166func (t *Terminal) Close() {
167 t.mu.Lock()
168 t.closed = true
169 t.mu.Unlock()
170 t.cancel()
171 _ = t.reader.Close()
172 _ = t.writer.Close()
173 <-t.done
174}