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) } }