turbo-editors/turbo-corepublic Fork 0
v0.9.0
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_draw.go · 227 lines · 6.7 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 20h ago1package acp
2
3import (
4 "github.com/gdamore/tcell/v2"
5
6 "codeberg.org/turbo-editors/turbo-core/theme"
7 "codeberg.org/turbo-editors/turbo-core/ui"
8)
9
10// Draw paints the conversation, the rule, and the box you type in.
11//
12// Everything below works in the sub-painter's **local** coordinates. Widget
13// bounds are absolute throughout ui — which is what makes hit-testing a plain
14// rectangle test — but drawing calls are local, and mixing the two puts the
15// whole window somewhere else on the screen.
16func (v *View) Draw(p *ui.Painter, th *theme.Theme) {
17 area := p.Sub(v.Bounds())
18 size := area.Size()
19 if size.IsEmpty() {
20 return
21 }
22
23 body := th.Style(theme.KeyWindowBody)
24 area.Clear(body)
25
26 v.drawTranscript(area, th, body)
27 v.drawRule(area, th)
28 v.drawInput(area, th, body)
29 v.drawPicker(area, th)
30}
31
32// drawTranscript paints the conversation, scrolled to wherever it is.
33func (v *View) drawTranscript(p *ui.Painter, th *theme.Theme, body tcell.Style) {
34 size := p.Size()
35 height := v.transcriptHeight()
36 if height < 1 {
37 return
38 }
39
40 lines := v.lines()
41 v.laidOut = len(lines)
42 v.clampScroll(height, len(lines))
43
44 from, to, selected := v.Selection()
45 highlight := th.Style(theme.KeyEditorSelection)
46
47 width := max(size.W-2, 1) // a column for the bar, and one for a margin
48 for row := 0; row < height; row++ {
49 at := v.scroll + row
50 if at >= len(lines) {
51 break
52 }
53
54 if selected && at >= from && at <= to {
55 // A selected line is drawn whole, in one colour: the point of it is
56 // to show what would be copied, and syntax colours inside a
57 // highlight say nothing about that.
58 p.HLine(0, row, size.W-1, ' ', highlight)
59 p.TextLimited(1, row, width, lines[at].Text, highlight)
60 continue
61 }
62 v.drawLine(p, th, 1, row, width, lines[at], body)
63 }
64
65 v.drawCaret(p, th, height, len(lines))
66
67 ui.DrawVScrollBar(p, size.W-1, 0, height, v.scroll, height, len(lines),
68 th.Style(theme.KeyScrollBar), th.Style(theme.KeyScrollBarThumb))
69}
70
71// drawCaret marks the line the transcript's cursor is on, when that pane has
72// the focus and nothing is selected.
73//
74// A bar in the left margin rather than a highlighted row: the cursor says
75// "this is where copying starts", which is a much quieter fact than "this is
76// what would be copied", and a full-width highlight for it would be
77// indistinguishable from a one-line selection.
78func (v *View) drawCaret(p *ui.Painter, th *theme.Theme, height, total int) {
79 if !v.focused || v.onInput || total == 0 {
80 return
81 }
82 if _, _, selected := v.Selection(); selected {
83 return
84 }
85
86 row := v.caret - v.scroll
87 if row < 0 || row >= height {
88 return
89 }
90 p.SetCell(0, row, '▌', th.Style(theme.KeyEditorSelection))
91}
92
93// drawLine paints one laid-out line, either in one style or span by span.
94func (v *View) drawLine(p *ui.Painter, th *theme.Theme, x, y, width int, line Line, body tcell.Style) {
95 if len(line.Spans) == 0 {
96 p.TextLimited(x, y, width, line.Text, v.styleOf(th, line, body))
97 return
98 }
99
100 runes := []rune(line.Text)
101 for at := 0; at < len(runes) && at < width; at++ {
102 p.SetCell(x+at, y, runes[at], spanStyle(th, line.Spans, at, body))
103 }
104}
105
106// styleOf returns the style a whole line takes.
107func (v *View) styleOf(th *theme.Theme, line Line, body tcell.Style) tcell.Style {
108 if line.Style == "" {
109 return body
110 }
111 return th.Style(line.Style).Background(backgroundOf(body))
112}
113
114// spanStyle returns the style of one column of a coloured line.
115//
116// Text no span covers is drawn as ordinary body text, which is the same rule
117// the editor's own painter follows.
118func spanStyle(th *theme.Theme, spans []Span, at int, body tcell.Style) tcell.Style {
119 for _, span := range spans {
120 if at >= span.Start && at < span.End {
121 return th.Style(span.Class.StyleKey()).Background(backgroundOf(body))
122 }
123 }
124 return body
125}
126
127// backgroundOf returns a style's background, so a syntax colour drawn in the
128// conversation keeps the window's own background rather than the one the theme
129// chose it against.
130//
131// The syntax styles are picked to read on window.body, which is what this
132// window is painted in, so only the foreground is wanted from them.
133func backgroundOf(style tcell.Style) tcell.Color {
134 _, background, _ := style.Decompose()
135 return background
136}
137
138// drawRule separates the conversation from what you are typing, and says what
139// the editor is waiting for.
140func (v *View) drawRule(p *ui.Painter, th *theme.Theme) {
141 size := p.Size()
142 row := v.transcriptHeight()
143 if row < 0 || row >= size.H {
144 return
145 }
146
147 style := th.Style(theme.KeyWindowFrameInactive)
148 if v.focused && v.onInput {
149 style = th.Style(theme.KeyWindowFrameActive)
150 }
151 p.HLine(0, row, size.W, '─', style)
152 p.TextLimited(2, row, max(size.W-4, 0), v.ruleLabel(), style)
153}
154
155// ruleLabel says what is going on, on the rule itself, where there is room and
156// where somebody is already looking.
157//
158// One return rather than one per state: the rule is a mapping from what the
159// window is doing to one sentence, and the states are easier to check for
160// completeness as a list than as six exit points.
161func (v *View) ruleLabel() string {
162 label := " Enter sends · Alt-Enter new line · Tab switches "
163
164 switch {
165 case v.session.Err() != nil:
166 label = " stopped "
167 case !v.session.Ready():
168 label = " starting… "
169 case v.session.Running():
170 label = " " + string(Spinner(v.now())) + " thinking — Esc stops it "
171 default:
172 label = v.readingLabel(label)
173 }
174 return label
175}
176
177// readingLabel is what the rule says while the agent is idle: what the keys do
178// where the focus actually is.
179func (v *View) readingLabel(typing string) string {
180 if _, _, selected := v.Selection(); selected {
181 return " Ctrl-C copies the selection · Esc drops it "
182 }
183 if !v.onInput {
184 return " ↑↓ move · Shift-↑↓ select · Ctrl-C copies this block · Tab returns "
185 }
186 return typing + v.pickerLabel()
187}
188
189// pickerLabel says which markers open the picker, only once there is
190// something for them to list: an agent with no commands gets no "/" on its
191// rule, and an editor that supplied no files gets no "@".
192func (v *View) pickerLabel() string {
193 label := ""
194 if len(v.session.Commands()) > 0 {
195 label += "· / commands "
196 }
197 if v.Files != nil {
198 label += "· @ files "
199 }
200 return label
201}
202
203// drawInput paints what has been typed, and the cursor when it has the focus.
204func (v *View) drawInput(p *ui.Painter, th *theme.Theme, body tcell.Style) {
205 size := p.Size()
206 top := v.transcriptHeight() + 1
207 height := size.H - top
208 if height < 1 {
209 return
210 }
211
212 prompt := th.Style(theme.KeySyntaxKeyword).Background(backgroundOf(body))
213 p.Text(0, top, ">", prompt)
214
215 from := max(v.cursor-height+1, 0)
216 for row := 0; row < height; row++ {
217 at := from + row
218 if at >= len(v.input) {
219 break
220 }
221 p.TextLimited(2, top+row, max(size.W-2, 0), v.input[at], body)
222 }
223
224 if v.focused && v.onInput {
225 p.ShowCursor(2+v.column, top+v.cursor-from)
226 }
227}