turbo-editors/turbo-corepublic Fork 0
v1.0.2
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

view.go · 256 lines · 6.9 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 21h ago1package terminal
2
3import (
4 "sync"
5 "time"
6
📦 Turbo Core f3ade8d k33g 13h ago7 "rickub.com/turbo-editors/turbo-core/ui"
🛟 Updated. 28d5985 k33g 21h ago8)
9
10// refreshInterval is how often a busy terminal asks for a redraw.
11//
12// A command such as a build writes far faster than a screen can usefully be
13// repainted, and asking for a redraw per chunk would both waste the work and
14// flood the event queue — which drops what does not fit. Sixty times a second
15// is past what an eye can follow.
16const refreshInterval = 16 * time.Millisecond
17
18// wheelStep is how many lines one notch of the mouse wheel scrolls back.
19const wheelStep = 3
20
21// View is a terminal window's contents: a shell in a pseudo-terminal, its
22// output drawn through the theme, and the keyboard wired back to it.
23//
24// The shell writes from a goroutine of its own while the editor draws from the
25// main one, so everything the two share is behind a lock.
26//
27// view, err := terminal.NewView(terminal.ViewOptions{
28// Options: terminal.Options{Dir: "."},
29// OnChange: wakeTheEventLoop,
30// })
31// if err != nil {
32// return err
33// }
34// window := ui.NewWindow(view.Title(), view)
35type View struct {
36 ui.FocusBox
37
38 session *Session
39 parser *Parser
40
41 // mu guards the parser and its screen, which the reading goroutine writes
42 // to and the drawing one reads from.
43 mu sync.Mutex
44
45 // scrollOffset is how many lines back into the history the view is
46 // looking. Zero is the live screen.
47 scrollOffset int
48
49 dirty chan struct{}
50 closed chan struct{}
51 once sync.Once
52
53 // name, onChange and onExit come from ViewOptions and are never written
54 // again.
55 //
56 // They are not exported fields for a reason worth stating: NewView starts
57 // the reading goroutine, and that goroutine reads all three. A caller
58 // assigning them *after* NewView returned is a data race — one that hid for
59 // a whole feature because a shell takes longer to produce output than an
60 // assignment takes to run, and only surfaced when a command finished
61 // immediately. Taking them as options makes the race impossible rather
62 // than merely unlikely.
63 name string
64 onChange func()
65 onExit func()
66}
67
68// ViewOptions are everything a view needs, including what the pty needs.
69//
70// The callbacks are given here rather than assigned afterwards because NewView
71// starts the goroutine that calls them.
72type ViewOptions struct {
73 Options
74
75 // Name is what the window is called when the program inside has not asked
76 // for a title of its own.
77 //
78 // Without it a command run through a shell shows as "sh", which says
79 // nothing about what is in the window. A caller running one command should
80 // set this to that command.
81 Name string
82
83 // OnChange is called when there is something new to draw. It is called
84 // from a goroutine of its own, so it must be safe to call from one.
85 OnChange func()
86 // OnExit is called once the shell has gone, from that same goroutine.
87 OnExit func()
88}
89
90// NewView starts a shell and returns the view showing it.
91//
92// A size of zero in the options is filled in once the view is given its bounds,
93// which is what happens as soon as it goes into a window.
94//
95// Everything the view needs is given here rather than assigned afterwards,
96// because this starts the goroutine that reads it.
97func NewView(options ViewOptions) (*View, error) {
98 if options.Width <= 0 {
99 options.Width, options.Height = 80, 24
100 }
101
102 session, err := Start(options.Options)
103 if err != nil {
104 return nil, err
105 }
106
107 screen := NewScreen(options.Width, options.Height)
108 v := &View{
109 session: session,
110 parser: NewParser(screen),
111 dirty: make(chan struct{}, 1),
112 closed: make(chan struct{}),
113 name: options.Name,
114 onChange: options.OnChange,
115 onExit: options.OnExit,
116 }
117 v.SetFocused(true)
118
119 // Everything the goroutines read is set by now, which is the whole reason
120 // the callbacks are options rather than fields.
121 go v.read()
122 go v.refresh()
123 return v, nil
124}
125
126// Title returns what the window holding this view should be called: the title
127// the program asked for, the Name its caller gave it, or the name of the
128// program running in it.
129//
130// A program that sets its own title wins, because it is saying something the
131// caller could not have known — vim naming the file it has open, ssh naming
132// the host.
133func (v *View) Title() string {
134 v.mu.Lock()
135 defer v.mu.Unlock()
136
137 if title := v.parser.Title(); title != "" {
138 return title
139 }
140 if v.name != "" {
141 return v.name
142 }
143 return v.session.Command()
144}
145
146// read copies the shell's output into the emulator until the shell has gone.
147func (v *View) read() {
148 buffer := make([]byte, 8192)
149
150 for {
151 n, err := v.session.Read(buffer)
152 if n > 0 {
153 v.consume(buffer[:n])
154 }
155 if err != nil {
156 v.finish()
157 return
158 }
159 }
160}
161
162// consume feeds a chunk to the emulator and notes that there is something new
163// to draw.
164func (v *View) consume(chunk []byte) {
165 v.mu.Lock()
166 v.parser.Write(chunk) //nolint:errcheck // the parser never fails
167 // Output arriving while the user is reading history pulls the view back to
168 // the live screen, which is what every terminal does.
169 v.scrollOffset = 0
170 v.mu.Unlock()
171
172 select {
173 case v.dirty <- struct{}{}:
174 default: // a redraw is already pending, and one is enough
175 }
176}
177
178// finish reports that the shell has gone.
179//
180// Why it went does not matter to the editor: a shell that exited cleanly and
181// one whose pseudo-terminal was closed underneath it both leave a window with
182// nothing behind it.
183func (v *View) finish() {
184 v.once.Do(func() { close(v.closed) })
185
186 if v.onExit != nil {
187 v.onExit()
188 }
189}
190
191// refresh asks for a redraw at a steady rate for as long as there is something
192// new, rather than once per chunk of output.
193func (v *View) refresh() {
194 ticker := time.NewTicker(refreshInterval)
195 defer ticker.Stop()
196
197 for {
198 select {
199 case <-v.closed:
200 v.notify()
201 return
202 case <-ticker.C:
203 select {
204 case <-v.dirty:
205 v.notify()
206 default:
207 }
208 }
209 }
210}
211
212// notify tells the editor there is something to draw.
213func (v *View) notify() {
214 if v.onChange != nil {
215 v.onChange()
216 }
217}
218
219// Exited reports whether the program in the terminal has gone.
220//
221// A window whose command has finished still shows its output, which is the
222// point — but it should stop behaving like a terminal, or the editor's own
223// keys never reach it again.
224func (v *View) Exited() bool {
225 select {
226 case <-v.closed:
227 return true
228 default:
229 return false
230 }
231}
232
233// Close ends the session and stops the goroutines behind it.
234func (v *View) Close() error {
235 v.once.Do(func() { close(v.closed) })
236 return v.session.Close()
237}
238
239// SetBounds places the view and tells the shell its terminal has changed size.
240func (v *View) SetBounds(r ui.Rect) {
241 v.FocusBox.SetBounds(r)
242
243 width, height := max(r.W, 1), max(r.H, 1)
244
245 v.mu.Lock()
246 current, currentHeight := v.parser.Screen().Size()
247 if current == width && currentHeight == height {
248 v.mu.Unlock()
249 return
250 }
251 v.parser.Screen().Resize(width, height)
252 v.mu.Unlock()
253
254 // A shell that is not told the new size goes on drawing at the old one.
255 _ = v.session.Resize(width, height)
256}