turbo-editors/turbo-corepublic Fork 0
v1.0.1
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.

📦 Turbo Core f3ade8d · on v1.0.1 · k33g · 4h ago
view.go · 256 lines · 6.9 KBGo Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
package terminal

import (
	"sync"
	"time"

	"rickub.com/turbo-editors/turbo-core/ui"
)

// refreshInterval is how often a busy terminal asks for a redraw.
//
// A command such as a build writes far faster than a screen can usefully be
// repainted, and asking for a redraw per chunk would both waste the work and
// flood the event queue — which drops what does not fit. Sixty times a second
// is past what an eye can follow.
const refreshInterval = 16 * time.Millisecond

// wheelStep is how many lines one notch of the mouse wheel scrolls back.
const wheelStep = 3

// View is a terminal window's contents: a shell in a pseudo-terminal, its
// output drawn through the theme, and the keyboard wired back to it.
//
// The shell writes from a goroutine of its own while the editor draws from the
// main one, so everything the two share is behind a lock.
//
//	view, err := terminal.NewView(terminal.ViewOptions{
//		Options:  terminal.Options{Dir: "."},
//		OnChange: wakeTheEventLoop,
//	})
//	if err != nil {
//		return err
//	}
//	window := ui.NewWindow(view.Title(), view)
type View struct {
	ui.FocusBox

	session *Session
	parser  *Parser

	// mu guards the parser and its screen, which the reading goroutine writes
	// to and the drawing one reads from.
	mu sync.Mutex

	// scrollOffset is how many lines back into the history the view is
	// looking. Zero is the live screen.
	scrollOffset int

	dirty  chan struct{}
	closed chan struct{}
	once   sync.Once

	// name, onChange and onExit come from ViewOptions and are never written
	// again.
	//
	// They are not exported fields for a reason worth stating: NewView starts
	// the reading goroutine, and that goroutine reads all three. A caller
	// assigning them *after* NewView returned is a data race — one that hid for
	// a whole feature because a shell takes longer to produce output than an
	// assignment takes to run, and only surfaced when a command finished
	// immediately. Taking them as options makes the race impossible rather
	// than merely unlikely.
	name     string
	onChange func()
	onExit   func()
}

// ViewOptions are everything a view needs, including what the pty needs.
//
// The callbacks are given here rather than assigned afterwards because NewView
// starts the goroutine that calls them.
type ViewOptions struct {
	Options

	// Name is what the window is called when the program inside has not asked
	// for a title of its own.
	//
	// Without it a command run through a shell shows as "sh", which says
	// nothing about what is in the window. A caller running one command should
	// set this to that command.
	Name string

	// OnChange is called when there is something new to draw. It is called
	// from a goroutine of its own, so it must be safe to call from one.
	OnChange func()
	// OnExit is called once the shell has gone, from that same goroutine.
	OnExit func()
}

// NewView starts a shell and returns the view showing it.
//
// A size of zero in the options is filled in once the view is given its bounds,
// which is what happens as soon as it goes into a window.
//
// Everything the view needs is given here rather than assigned afterwards,
// because this starts the goroutine that reads it.
func NewView(options ViewOptions) (*View, error) {
	if options.Width <= 0 {
		options.Width, options.Height = 80, 24
	}

	session, err := Start(options.Options)
	if err != nil {
		return nil, err
	}

	screen := NewScreen(options.Width, options.Height)
	v := &View{
		session:  session,
		parser:   NewParser(screen),
		dirty:    make(chan struct{}, 1),
		closed:   make(chan struct{}),
		name:     options.Name,
		onChange: options.OnChange,
		onExit:   options.OnExit,
	}
	v.SetFocused(true)

	// Everything the goroutines read is set by now, which is the whole reason
	// the callbacks are options rather than fields.
	go v.read()
	go v.refresh()
	return v, nil
}

// Title returns what the window holding this view should be called: the title
// the program asked for, the Name its caller gave it, or the name of the
// program running in it.
//
// A program that sets its own title wins, because it is saying something the
// caller could not have known — vim naming the file it has open, ssh naming
// the host.
func (v *View) Title() string {
	v.mu.Lock()
	defer v.mu.Unlock()

	if title := v.parser.Title(); title != "" {
		return title
	}
	if v.name != "" {
		return v.name
	}
	return v.session.Command()
}

// read copies the shell's output into the emulator until the shell has gone.
func (v *View) read() {
	buffer := make([]byte, 8192)

	for {
		n, err := v.session.Read(buffer)
		if n > 0 {
			v.consume(buffer[:n])
		}
		if err != nil {
			v.finish()
			return
		}
	}
}

// consume feeds a chunk to the emulator and notes that there is something new
// to draw.
func (v *View) consume(chunk []byte) {
	v.mu.Lock()
	v.parser.Write(chunk) //nolint:errcheck // the parser never fails
	// Output arriving while the user is reading history pulls the view back to
	// the live screen, which is what every terminal does.
	v.scrollOffset = 0
	v.mu.Unlock()

	select {
	case v.dirty <- struct{}{}:
	default: // a redraw is already pending, and one is enough
	}
}

// finish reports that the shell has gone.
//
// Why it went does not matter to the editor: a shell that exited cleanly and
// one whose pseudo-terminal was closed underneath it both leave a window with
// nothing behind it.
func (v *View) finish() {
	v.once.Do(func() { close(v.closed) })

	if v.onExit != nil {
		v.onExit()
	}
}

// refresh asks for a redraw at a steady rate for as long as there is something
// new, rather than once per chunk of output.
func (v *View) refresh() {
	ticker := time.NewTicker(refreshInterval)
	defer ticker.Stop()

	for {
		select {
		case <-v.closed:
			v.notify()
			return
		case <-ticker.C:
			select {
			case <-v.dirty:
				v.notify()
			default:
			}
		}
	}
}

// notify tells the editor there is something to draw.
func (v *View) notify() {
	if v.onChange != nil {
		v.onChange()
	}
}

// Exited reports whether the program in the terminal has gone.
//
// A window whose command has finished still shows its output, which is the
// point — but it should stop behaving like a terminal, or the editor's own
// keys never reach it again.
func (v *View) Exited() bool {
	select {
	case <-v.closed:
		return true
	default:
		return false
	}
}

// Close ends the session and stops the goroutines behind it.
func (v *View) Close() error {
	v.once.Do(func() { close(v.closed) })
	return v.session.Close()
}

// SetBounds places the view and tells the shell its terminal has changed size.
func (v *View) SetBounds(r ui.Rect) {
	v.FocusBox.SetBounds(r)

	width, height := max(r.W, 1), max(r.H, 1)

	v.mu.Lock()
	current, currentHeight := v.parser.Screen().Size()
	if current == width && currentHeight == height {
		v.mu.Unlock()
		return
	}
	v.parser.Screen().Resize(width, height)
	v.mu.Unlock()

	// A shell that is not told the new size goes on drawing at the old one.
	_ = v.session.Resize(width, height)
}