| 🛟 Updated. 28d5985 k33g 21h ago | 1 | package acp |
| 2 | |
| 3 | import ( |
| 4 | "github.com/gdamore/tcell/v2" |
| 5 | |
| 📦 Turbo Core f3ade8d k33g 13h ago | 6 | "rickub.com/turbo-editors/turbo-core/theme" |
| 7 | "rickub.com/turbo-editors/turbo-core/ui" |
| 🛟 Updated. 28d5985 k33g 21h ago | 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. |
| 16 | func (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. |
| 33 | func (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. |
| 78 | func (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. |
| 94 | func (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. |
| 107 | func (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. |
| 118 | func 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. |
| 133 | func 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. |
| 140 | func (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. |
| 161 | func (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. |
| 179 | func (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 "@". |
| 192 | func (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. |
| 204 | func (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 | } |