| 🛟 Updated. 28d5985 k33g 20h ago | 1 | package app |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | |
| 6 | "github.com/gdamore/tcell/v2" |
| 7 | |
| 📦 Turbo Core f3ade8d k33g 13h ago | 8 | "rickub.com/turbo-editors/turbo-core/lsp" |
| 9 | "rickub.com/turbo-editors/turbo-core/theme" |
| 10 | "rickub.com/turbo-editors/turbo-core/ui" |
| 🛟 Updated. 28d5985 k33g 20h ago | 11 | ) |
| 12 | |
| 13 | // Completion popup geometry. It is deliberately small: a list that covers half |
| 14 | // the file hides the very code the completion is about. |
| 15 | const ( |
| 16 | completionMaxRows = 8 |
| 17 | completionMinWidth = 24 |
| 18 | completionMaxWidth = 60 |
| 19 | ) |
| 20 | |
| 21 | // CompletionBox is the list of suggestions that pops up under the cursor. |
| 22 | // |
| 23 | // It filters as the user types, so the language server is asked once and the |
| 24 | // narrowing that follows costs nothing. |
| 25 | type CompletionBox struct { |
| 26 | ui.Box |
| 27 | items []lsp.CompletionItem |
| 28 | matches []int // indexes into items, in the order they are shown |
| 29 | selected int |
| 30 | top int |
| 31 | prefix string |
| 32 | visible bool |
| 33 | |
| 34 | // OnAccept is called with the chosen item. |
| 35 | OnAccept func(lsp.CompletionItem) |
| 36 | } |
| 37 | |
| 38 | // Visible reports whether the popup is showing. |
| 39 | func (c *CompletionBox) Visible() bool { return c.visible } |
| 40 | |
| 41 | // Show opens the popup with a set of suggestions, filtered by prefix, anchored |
| 42 | // so that its top-left corner is just below the cursor. |
| 43 | // |
| 44 | // It closes again straight away if nothing matches, because a popup showing |
| 45 | // nothing is worse than no popup. |
| 46 | func (c *CompletionBox) Show(items []lsp.CompletionItem, prefix string, cursorX, cursorY int, screen ui.Rect) { |
| 47 | c.items = items |
| 48 | c.prefix = prefix |
| 49 | c.selected = 0 |
| 50 | c.top = 0 |
| 51 | c.refilter() |
| 52 | |
| 53 | if len(c.matches) == 0 { |
| 54 | c.Hide() |
| 55 | return |
| 56 | } |
| 57 | c.visible = true |
| 58 | c.SetBounds(c.placeUnder(cursorX, cursorY, screen)) |
| 59 | } |
| 60 | |
| 61 | // Hide closes the popup. |
| 62 | func (c *CompletionBox) Hide() { |
| 63 | c.visible = false |
| 64 | c.items = nil |
| 65 | c.matches = nil |
| 66 | c.prefix = "" |
| 67 | } |
| 68 | |
| 69 | // SetPrefix narrows the list as the user keeps typing, and closes it once |
| 70 | // nothing matches any more. |
| 71 | func (c *CompletionBox) SetPrefix(prefix string) { |
| 72 | if !c.visible { |
| 73 | return |
| 74 | } |
| 75 | c.prefix = prefix |
| 76 | c.selected = 0 |
| 77 | c.top = 0 |
| 78 | c.refilter() |
| 79 | |
| 80 | if len(c.matches) == 0 { |
| 81 | c.Hide() |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | // refilter recomputes which items match the prefix. |
| 86 | // |
| 87 | // Matching is case-insensitive on the prefix, which is what makes typing |
| 88 | // "prin" find "Println" without having to guess the case first. |
| 89 | func (c *CompletionBox) refilter() { |
| 90 | c.matches = c.matches[:0] |
| 91 | wanted := strings.ToLower(c.prefix) |
| 92 | |
| 93 | for i, item := range c.items { |
| 94 | if strings.HasPrefix(strings.ToLower(item.Label), wanted) { |
| 95 | c.matches = append(c.matches, i) |
| 96 | } |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | // placeUnder returns where the popup should sit: under the cursor if it fits, |
| 101 | // above it if it does not, and always inside the screen. |
| 102 | func (c *CompletionBox) placeUnder(cursorX, cursorY int, screen ui.Rect) ui.Rect { |
| 103 | height := min(len(c.matches), completionMaxRows) + 2 |
| 104 | width := min(max(c.widestEntry()+4, completionMinWidth), completionMaxWidth) |
| 105 | |
| 106 | bounds := ui.Rect{X: cursorX - 1, Y: cursorY + 1, W: width, H: height} |
| 107 | if bounds.Bottom() > screen.Bottom() { |
| 108 | bounds.Y = cursorY - height |
| 109 | } |
| 110 | return bounds.ClampInto(screen) |
| 111 | } |
| 112 | |
| 113 | // widestEntry returns the width of the longest line the popup will show. |
| 114 | func (c *CompletionBox) widestEntry() int { |
| 115 | widest := 0 |
| 116 | for _, index := range c.matches { |
| 117 | widest = max(widest, len([]rune(c.entryText(c.items[index])))) |
| 118 | } |
| 119 | return widest |
| 120 | } |
| 121 | |
| 122 | // entryText is one line of the popup: the label, then its kind. |
| 123 | func (c *CompletionBox) entryText(item lsp.CompletionItem) string { |
| 124 | if item.Kind == 0 { |
| 125 | return item.Label |
| 126 | } |
| 127 | return item.Label + " " + item.Kind.String() |
| 128 | } |
| 129 | |
| 130 | // Selected returns the highlighted item and whether there is one. |
| 131 | func (c *CompletionBox) Selected() (lsp.CompletionItem, bool) { |
| 132 | if !c.visible || c.selected >= len(c.matches) { |
| 133 | return lsp.CompletionItem{}, false |
| 134 | } |
| 135 | return c.items[c.matches[c.selected]], true |
| 136 | } |
| 137 | |
| 138 | // Count returns how many suggestions are currently shown. |
| 139 | func (c *CompletionBox) Count() int { return len(c.matches) } |
| 140 | |
| 141 | // Matches returns the suggestions currently shown, in the order they appear. |
| 142 | func (c *CompletionBox) Matches() []lsp.CompletionItem { |
| 143 | items := make([]lsp.CompletionItem, 0, len(c.matches)) |
| 144 | for _, index := range c.matches { |
| 145 | items = append(items, c.items[index]) |
| 146 | } |
| 147 | return items |
| 148 | } |
| 149 | |
| 150 | // Accept chooses the highlighted item and closes the popup. |
| 151 | func (c *CompletionBox) Accept() { |
| 152 | item, ok := c.Selected() |
| 153 | c.Hide() |
| 154 | if ok && c.OnAccept != nil { |
| 155 | c.OnAccept(item) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | // move walks the list, clamping at either end rather than wrapping: a list |
| 160 | // that wraps makes it easy to sail past the entry you wanted. |
| 161 | func (c *CompletionBox) move(by int) { |
| 162 | if len(c.matches) == 0 { |
| 163 | return |
| 164 | } |
| 165 | c.selected = min(max(c.selected+by, 0), len(c.matches)-1) |
| 166 | |
| 167 | rows := min(len(c.matches), completionMaxRows) |
| 168 | c.top = min(c.top, c.selected) |
| 169 | if c.selected >= c.top+rows { |
| 170 | c.top = c.selected - rows + 1 |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | // Draw paints the popup, framed and shadowed like every other floating thing. |
| 175 | func (c *CompletionBox) Draw(p *ui.Painter, th *theme.Theme) { |
| 176 | if !c.visible { |
| 177 | return |
| 178 | } |
| 179 | |
| 180 | bounds := c.Bounds() |
| 181 | ui.DrawShadow(p, bounds, th.Style(theme.KeyShadow)) |
| 182 | |
| 183 | panel := p.Sub(bounds) |
| 184 | panel.Clear(th.Style(theme.KeyCompletionItem)) |
| 185 | ui.DrawFrame(panel, panel.Size(), ui.FrameSingle, th.Style(theme.KeyCompletionFrame)) |
| 186 | |
| 187 | rows := min(panel.Size().H-2, len(c.matches)-c.top) |
| 188 | for row := range rows { |
| 189 | c.drawEntry(panel, th, row) |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | // drawEntry paints one line of the popup. |
| 194 | func (c *CompletionBox) drawEntry(p *ui.Painter, th *theme.Theme, row int) { |
| 195 | index := c.matches[c.top+row] |
| 196 | item := c.items[index] |
| 197 | width := p.Size().W - 2 |
| 198 | |
| 199 | style := th.Style(theme.KeyCompletionItem) |
| 200 | if c.top+row == c.selected { |
| 201 | style = th.Style(theme.KeyCompletionSelected) |
| 202 | } |
| 203 | |
| 204 | p.HLine(1, row+1, width, ' ', style) |
| 205 | end := p.TextLimited(1, row+1, width, item.Label, style) |
| 206 | |
| 207 | if item.Kind != 0 { |
| 208 | tag := " " + item.Kind.String() |
| 209 | x := p.Size().W - 1 - len([]rune(tag)) |
| 210 | if x > end { |
| 211 | p.Text(x, row+1, tag, th.Style(theme.KeyCompletionDetail)) |
| 212 | } |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | // HandleKey works the popup and reports whether it consumed the key. |
| 217 | // |
| 218 | // Anything it does not use — a printable character, most notably — falls |
| 219 | // through to the editor, which is what lets the user keep typing while the |
| 220 | // list narrows itself. |
| 221 | func (c *CompletionBox) HandleKey(ev *tcell.EventKey) bool { |
| 222 | if !c.visible { |
| 223 | return false |
| 224 | } |
| 225 | |
| 226 | switch ev.Key() { |
| 227 | case tcell.KeyUp: |
| 228 | c.move(-1) |
| 229 | case tcell.KeyDown: |
| 230 | c.move(+1) |
| 231 | case tcell.KeyPgUp: |
| 232 | c.move(-completionMaxRows) |
| 233 | case tcell.KeyPgDn: |
| 234 | c.move(+completionMaxRows) |
| 235 | case tcell.KeyEnter, tcell.KeyTab: |
| 236 | c.Accept() |
| 237 | case tcell.KeyEscape: |
| 238 | c.Hide() |
| 239 | default: |
| 240 | return false |
| 241 | } |
| 242 | return true |
| 243 | } |
| 244 | |
| 245 | // HandleMouse selects and accepts entries with the pointer. |
| 246 | func (c *CompletionBox) HandleMouse(ev *tcell.EventMouse) bool { |
| 247 | if !c.visible { |
| 248 | return false |
| 249 | } |
| 250 | |
| 251 | x, y := ev.Position() |
| 252 | if !c.Bounds().Contains(x, y) { |
| 253 | if ev.Buttons() == tcell.Button1 { |
| 254 | c.Hide() // a click elsewhere puts the list away |
| 255 | } |
| 256 | return false |
| 257 | } |
| 258 | |
| 259 | index := c.top + y - c.Bounds().Y - 1 |
| 260 | if index < 0 || index >= len(c.matches) { |
| 261 | return true |
| 262 | } |
| 263 | c.selected = index |
| 264 | if ev.Buttons() == tcell.Button1 { |
| 265 | c.Accept() |
| 266 | } |
| 267 | return true |
| 268 | } |