package app import ( "strings" "github.com/gdamore/tcell/v2" "rickub.com/turbo-editors/turbo-core/lsp" "rickub.com/turbo-editors/turbo-core/theme" "rickub.com/turbo-editors/turbo-core/ui" ) // Completion popup geometry. It is deliberately small: a list that covers half // the file hides the very code the completion is about. const ( completionMaxRows = 8 completionMinWidth = 24 completionMaxWidth = 60 ) // CompletionBox is the list of suggestions that pops up under the cursor. // // It filters as the user types, so the language server is asked once and the // narrowing that follows costs nothing. type CompletionBox struct { ui.Box items []lsp.CompletionItem matches []int // indexes into items, in the order they are shown selected int top int prefix string visible bool // OnAccept is called with the chosen item. OnAccept func(lsp.CompletionItem) } // Visible reports whether the popup is showing. func (c *CompletionBox) Visible() bool { return c.visible } // Show opens the popup with a set of suggestions, filtered by prefix, anchored // so that its top-left corner is just below the cursor. // // It closes again straight away if nothing matches, because a popup showing // nothing is worse than no popup. func (c *CompletionBox) Show(items []lsp.CompletionItem, prefix string, cursorX, cursorY int, screen ui.Rect) { c.items = items c.prefix = prefix c.selected = 0 c.top = 0 c.refilter() if len(c.matches) == 0 { c.Hide() return } c.visible = true c.SetBounds(c.placeUnder(cursorX, cursorY, screen)) } // Hide closes the popup. func (c *CompletionBox) Hide() { c.visible = false c.items = nil c.matches = nil c.prefix = "" } // SetPrefix narrows the list as the user keeps typing, and closes it once // nothing matches any more. func (c *CompletionBox) SetPrefix(prefix string) { if !c.visible { return } c.prefix = prefix c.selected = 0 c.top = 0 c.refilter() if len(c.matches) == 0 { c.Hide() } } // refilter recomputes which items match the prefix. // // Matching is case-insensitive on the prefix, which is what makes typing // "prin" find "Println" without having to guess the case first. func (c *CompletionBox) refilter() { c.matches = c.matches[:0] wanted := strings.ToLower(c.prefix) for i, item := range c.items { if strings.HasPrefix(strings.ToLower(item.Label), wanted) { c.matches = append(c.matches, i) } } } // placeUnder returns where the popup should sit: under the cursor if it fits, // above it if it does not, and always inside the screen. func (c *CompletionBox) placeUnder(cursorX, cursorY int, screen ui.Rect) ui.Rect { height := min(len(c.matches), completionMaxRows) + 2 width := min(max(c.widestEntry()+4, completionMinWidth), completionMaxWidth) bounds := ui.Rect{X: cursorX - 1, Y: cursorY + 1, W: width, H: height} if bounds.Bottom() > screen.Bottom() { bounds.Y = cursorY - height } return bounds.ClampInto(screen) } // widestEntry returns the width of the longest line the popup will show. func (c *CompletionBox) widestEntry() int { widest := 0 for _, index := range c.matches { widest = max(widest, len([]rune(c.entryText(c.items[index])))) } return widest } // entryText is one line of the popup: the label, then its kind. func (c *CompletionBox) entryText(item lsp.CompletionItem) string { if item.Kind == 0 { return item.Label } return item.Label + " " + item.Kind.String() } // Selected returns the highlighted item and whether there is one. func (c *CompletionBox) Selected() (lsp.CompletionItem, bool) { if !c.visible || c.selected >= len(c.matches) { return lsp.CompletionItem{}, false } return c.items[c.matches[c.selected]], true } // Count returns how many suggestions are currently shown. func (c *CompletionBox) Count() int { return len(c.matches) } // Matches returns the suggestions currently shown, in the order they appear. func (c *CompletionBox) Matches() []lsp.CompletionItem { items := make([]lsp.CompletionItem, 0, len(c.matches)) for _, index := range c.matches { items = append(items, c.items[index]) } return items } // Accept chooses the highlighted item and closes the popup. func (c *CompletionBox) Accept() { item, ok := c.Selected() c.Hide() if ok && c.OnAccept != nil { c.OnAccept(item) } } // move walks the list, clamping at either end rather than wrapping: a list // that wraps makes it easy to sail past the entry you wanted. func (c *CompletionBox) move(by int) { if len(c.matches) == 0 { return } c.selected = min(max(c.selected+by, 0), len(c.matches)-1) rows := min(len(c.matches), completionMaxRows) c.top = min(c.top, c.selected) if c.selected >= c.top+rows { c.top = c.selected - rows + 1 } } // Draw paints the popup, framed and shadowed like every other floating thing. func (c *CompletionBox) Draw(p *ui.Painter, th *theme.Theme) { if !c.visible { return } bounds := c.Bounds() ui.DrawShadow(p, bounds, th.Style(theme.KeyShadow)) panel := p.Sub(bounds) panel.Clear(th.Style(theme.KeyCompletionItem)) ui.DrawFrame(panel, panel.Size(), ui.FrameSingle, th.Style(theme.KeyCompletionFrame)) rows := min(panel.Size().H-2, len(c.matches)-c.top) for row := range rows { c.drawEntry(panel, th, row) } } // drawEntry paints one line of the popup. func (c *CompletionBox) drawEntry(p *ui.Painter, th *theme.Theme, row int) { index := c.matches[c.top+row] item := c.items[index] width := p.Size().W - 2 style := th.Style(theme.KeyCompletionItem) if c.top+row == c.selected { style = th.Style(theme.KeyCompletionSelected) } p.HLine(1, row+1, width, ' ', style) end := p.TextLimited(1, row+1, width, item.Label, style) if item.Kind != 0 { tag := " " + item.Kind.String() x := p.Size().W - 1 - len([]rune(tag)) if x > end { p.Text(x, row+1, tag, th.Style(theme.KeyCompletionDetail)) } } } // HandleKey works the popup and reports whether it consumed the key. // // Anything it does not use — a printable character, most notably — falls // through to the editor, which is what lets the user keep typing while the // list narrows itself. func (c *CompletionBox) HandleKey(ev *tcell.EventKey) bool { if !c.visible { return false } switch ev.Key() { case tcell.KeyUp: c.move(-1) case tcell.KeyDown: c.move(+1) case tcell.KeyPgUp: c.move(-completionMaxRows) case tcell.KeyPgDn: c.move(+completionMaxRows) case tcell.KeyEnter, tcell.KeyTab: c.Accept() case tcell.KeyEscape: c.Hide() default: return false } return true } // HandleMouse selects and accepts entries with the pointer. func (c *CompletionBox) HandleMouse(ev *tcell.EventMouse) bool { if !c.visible { return false } x, y := ev.Position() if !c.Bounds().Contains(x, y) { if ev.Buttons() == tcell.Button1 { c.Hide() // a click elsewhere puts the list away } return false } index := c.top + y - c.Bounds().Y - 1 if index < 0 || index >= len(c.matches) { return true } c.selected = index if ev.Buttons() == tcell.Button1 { c.Accept() } return true }