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 · 5h ago
view_draw.go · 227 lines · 6.7 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
package acp

import (
	"github.com/gdamore/tcell/v2"

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

// Draw paints the conversation, the rule, and the box you type in.
//
// Everything below works in the sub-painter's **local** coordinates. Widget
// bounds are absolute throughout ui — which is what makes hit-testing a plain
// rectangle test — but drawing calls are local, and mixing the two puts the
// whole window somewhere else on the screen.
func (v *View) Draw(p *ui.Painter, th *theme.Theme) {
	area := p.Sub(v.Bounds())
	size := area.Size()
	if size.IsEmpty() {
		return
	}

	body := th.Style(theme.KeyWindowBody)
	area.Clear(body)

	v.drawTranscript(area, th, body)
	v.drawRule(area, th)
	v.drawInput(area, th, body)
	v.drawPicker(area, th)
}

// drawTranscript paints the conversation, scrolled to wherever it is.
func (v *View) drawTranscript(p *ui.Painter, th *theme.Theme, body tcell.Style) {
	size := p.Size()
	height := v.transcriptHeight()
	if height < 1 {
		return
	}

	lines := v.lines()
	v.laidOut = len(lines)
	v.clampScroll(height, len(lines))

	from, to, selected := v.Selection()
	highlight := th.Style(theme.KeyEditorSelection)

	width := max(size.W-2, 1) // a column for the bar, and one for a margin
	for row := 0; row < height; row++ {
		at := v.scroll + row
		if at >= len(lines) {
			break
		}

		if selected && at >= from && at <= to {
			// A selected line is drawn whole, in one colour: the point of it is
			// to show what would be copied, and syntax colours inside a
			// highlight say nothing about that.
			p.HLine(0, row, size.W-1, ' ', highlight)
			p.TextLimited(1, row, width, lines[at].Text, highlight)
			continue
		}
		v.drawLine(p, th, 1, row, width, lines[at], body)
	}

	v.drawCaret(p, th, height, len(lines))

	ui.DrawVScrollBar(p, size.W-1, 0, height, v.scroll, height, len(lines),
		th.Style(theme.KeyScrollBar), th.Style(theme.KeyScrollBarThumb))
}

// drawCaret marks the line the transcript's cursor is on, when that pane has
// the focus and nothing is selected.
//
// A bar in the left margin rather than a highlighted row: the cursor says
// "this is where copying starts", which is a much quieter fact than "this is
// what would be copied", and a full-width highlight for it would be
// indistinguishable from a one-line selection.
func (v *View) drawCaret(p *ui.Painter, th *theme.Theme, height, total int) {
	if !v.focused || v.onInput || total == 0 {
		return
	}
	if _, _, selected := v.Selection(); selected {
		return
	}

	row := v.caret - v.scroll
	if row < 0 || row >= height {
		return
	}
	p.SetCell(0, row, '▌', th.Style(theme.KeyEditorSelection))
}

// drawLine paints one laid-out line, either in one style or span by span.
func (v *View) drawLine(p *ui.Painter, th *theme.Theme, x, y, width int, line Line, body tcell.Style) {
	if len(line.Spans) == 0 {
		p.TextLimited(x, y, width, line.Text, v.styleOf(th, line, body))
		return
	}

	runes := []rune(line.Text)
	for at := 0; at < len(runes) && at < width; at++ {
		p.SetCell(x+at, y, runes[at], spanStyle(th, line.Spans, at, body))
	}
}

// styleOf returns the style a whole line takes.
func (v *View) styleOf(th *theme.Theme, line Line, body tcell.Style) tcell.Style {
	if line.Style == "" {
		return body
	}
	return th.Style(line.Style).Background(backgroundOf(body))
}

// spanStyle returns the style of one column of a coloured line.
//
// Text no span covers is drawn as ordinary body text, which is the same rule
// the editor's own painter follows.
func spanStyle(th *theme.Theme, spans []Span, at int, body tcell.Style) tcell.Style {
	for _, span := range spans {
		if at >= span.Start && at < span.End {
			return th.Style(span.Class.StyleKey()).Background(backgroundOf(body))
		}
	}
	return body
}

// backgroundOf returns a style's background, so a syntax colour drawn in the
// conversation keeps the window's own background rather than the one the theme
// chose it against.
//
// The syntax styles are picked to read on window.body, which is what this
// window is painted in, so only the foreground is wanted from them.
func backgroundOf(style tcell.Style) tcell.Color {
	_, background, _ := style.Decompose()
	return background
}

// drawRule separates the conversation from what you are typing, and says what
// the editor is waiting for.
func (v *View) drawRule(p *ui.Painter, th *theme.Theme) {
	size := p.Size()
	row := v.transcriptHeight()
	if row < 0 || row >= size.H {
		return
	}

	style := th.Style(theme.KeyWindowFrameInactive)
	if v.focused && v.onInput {
		style = th.Style(theme.KeyWindowFrameActive)
	}
	p.HLine(0, row, size.W, '─', style)
	p.TextLimited(2, row, max(size.W-4, 0), v.ruleLabel(), style)
}

// ruleLabel says what is going on, on the rule itself, where there is room and
// where somebody is already looking.
//
// One return rather than one per state: the rule is a mapping from what the
// window is doing to one sentence, and the states are easier to check for
// completeness as a list than as six exit points.
func (v *View) ruleLabel() string {
	label := " Enter sends · Alt-Enter new line · Tab switches "

	switch {
	case v.session.Err() != nil:
		label = " stopped "
	case !v.session.Ready():
		label = " starting… "
	case v.session.Running():
		label = " " + string(Spinner(v.now())) + " thinking — Esc stops it "
	default:
		label = v.readingLabel(label)
	}
	return label
}

// readingLabel is what the rule says while the agent is idle: what the keys do
// where the focus actually is.
func (v *View) readingLabel(typing string) string {
	if _, _, selected := v.Selection(); selected {
		return " Ctrl-C copies the selection · Esc drops it "
	}
	if !v.onInput {
		return " ↑↓ move · Shift-↑↓ select · Ctrl-C copies this block · Tab returns "
	}
	return typing + v.pickerLabel()
}

// pickerLabel says which markers open the picker, only once there is
// something for them to list: an agent with no commands gets no "/" on its
// rule, and an editor that supplied no files gets no "@".
func (v *View) pickerLabel() string {
	label := ""
	if len(v.session.Commands()) > 0 {
		label += "· / commands "
	}
	if v.Files != nil {
		label += "· @ files "
	}
	return label
}

// drawInput paints what has been typed, and the cursor when it has the focus.
func (v *View) drawInput(p *ui.Painter, th *theme.Theme, body tcell.Style) {
	size := p.Size()
	top := v.transcriptHeight() + 1
	height := size.H - top
	if height < 1 {
		return
	}

	prompt := th.Style(theme.KeySyntaxKeyword).Background(backgroundOf(body))
	p.Text(0, top, ">", prompt)

	from := max(v.cursor-height+1, 0)
	for row := 0; row < height; row++ {
		at := from + row
		if at >= len(v.input) {
			break
		}
		p.TextLimited(2, top+row, max(size.W-2, 0), v.input[at], body)
	}

	if v.focused && v.onInput {
		p.ShowCursor(2+v.column, top+v.cursor-from)
	}
}