mirror of
https://github.com/charmbracelet/crush.git
synced 2025-08-02 05:20:46 +03:00
Merge pull request #294 from charmbracelet/charm-434
feat(tui): completions: dynamically adjust width based on items
This commit is contained in:
@@ -161,10 +161,17 @@ func (m *editorCmp) send() tea.Cmd {
|
||||
)
|
||||
}
|
||||
|
||||
func (m *editorCmp) repositionCompletions() tea.Msg {
|
||||
x, y := m.completionsPosition()
|
||||
return completions.RepositionCompletionsMsg{X: x, Y: y}
|
||||
}
|
||||
|
||||
func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
return m, m.repositionCompletions
|
||||
case filepicker.FilePickedMsg:
|
||||
if len(m.attachments) >= maxAttachments {
|
||||
return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
|
||||
@@ -182,32 +189,37 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
if item, ok := msg.Value.(FileCompletionItem); ok {
|
||||
word := m.textarea.Word()
|
||||
// If the selected item is a file, insert its path into the textarea
|
||||
value := m.textarea.Value()
|
||||
value = value[:m.completionsStartIndex]
|
||||
value += item.Path
|
||||
value = value[:m.completionsStartIndex] + // Remove the current query
|
||||
item.Path + // Insert the file path
|
||||
value[m.completionsStartIndex+len(word):] // Append the rest of the value
|
||||
// XXX: This will always move the cursor to the end of the textarea.
|
||||
m.textarea.SetValue(value)
|
||||
m.textarea.MoveToEnd()
|
||||
if !msg.Insert {
|
||||
m.isCompletionsOpen = false
|
||||
m.currentQuery = ""
|
||||
m.completionsStartIndex = 0
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
case openEditorMsg:
|
||||
m.textarea.SetValue(msg.Text)
|
||||
m.textarea.MoveToEnd()
|
||||
case tea.KeyPressMsg:
|
||||
cur := m.textarea.Cursor()
|
||||
curIdx := m.textarea.Width()*cur.Y + cur.X
|
||||
switch {
|
||||
// Completions
|
||||
case msg.String() == "/" && !m.isCompletionsOpen &&
|
||||
// only show if beginning of prompt, or if previous char is a space:
|
||||
(len(m.textarea.Value()) == 0 || m.textarea.Value()[len(m.textarea.Value())-1] == ' '):
|
||||
// only show if beginning of prompt, or if previous char is a space or newline:
|
||||
(len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))):
|
||||
m.isCompletionsOpen = true
|
||||
m.currentQuery = ""
|
||||
m.completionsStartIndex = len(m.textarea.Value())
|
||||
m.completionsStartIndex = curIdx
|
||||
cmds = append(cmds, m.startCompletions)
|
||||
case m.isCompletionsOpen && m.textarea.Cursor().X <= m.completionsStartIndex:
|
||||
case m.isCompletionsOpen && curIdx <= m.completionsStartIndex:
|
||||
cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
|
||||
}
|
||||
if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
|
||||
@@ -244,6 +256,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
if key.Matches(msg, m.keyMap.Newline) {
|
||||
m.textarea.InsertRune('\n')
|
||||
cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
|
||||
}
|
||||
// Handle Enter key
|
||||
if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
|
||||
@@ -275,12 +288,18 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// XXX: wont' work if editing in the middle of the field.
|
||||
m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
|
||||
m.currentQuery = word[1:]
|
||||
x, y := m.completionsPosition()
|
||||
x -= len(m.currentQuery)
|
||||
m.isCompletionsOpen = true
|
||||
cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
|
||||
Query: m.currentQuery,
|
||||
Reopen: m.isCompletionsOpen,
|
||||
}))
|
||||
} else {
|
||||
cmds = append(cmds,
|
||||
util.CmdHandler(completions.FilterCompletionsMsg{
|
||||
Query: m.currentQuery,
|
||||
Reopen: m.isCompletionsOpen,
|
||||
X: x,
|
||||
Y: y,
|
||||
}),
|
||||
)
|
||||
} else if m.isCompletionsOpen {
|
||||
m.isCompletionsOpen = false
|
||||
m.currentQuery = ""
|
||||
m.completionsStartIndex = 0
|
||||
@@ -293,6 +312,16 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *editorCmp) completionsPosition() (int, int) {
|
||||
cur := m.textarea.Cursor()
|
||||
if cur == nil {
|
||||
return m.x, m.y + 1 // adjust for padding
|
||||
}
|
||||
x := cur.X + m.x
|
||||
y := cur.Y + m.y + 1 // adjust for padding
|
||||
return x, y
|
||||
}
|
||||
|
||||
func (m *editorCmp) Cursor() *tea.Cursor {
|
||||
cursor := m.textarea.Cursor()
|
||||
if cursor != nil {
|
||||
@@ -373,9 +402,7 @@ func (m *editorCmp) startCompletions() tea.Msg {
|
||||
})
|
||||
}
|
||||
|
||||
cur := m.textarea.Cursor()
|
||||
x := cur.X + m.x // adjust for padding
|
||||
y := cur.Y + m.y + 1
|
||||
x, y := m.completionsPosition()
|
||||
return completions.OpenCompletionsMsg{
|
||||
Completions: completionItems,
|
||||
X: x,
|
||||
|
||||
@@ -27,6 +27,12 @@ type OpenCompletionsMsg struct {
|
||||
type FilterCompletionsMsg struct {
|
||||
Query string // The query to filter completions
|
||||
Reopen bool
|
||||
X int // X position for the completions popup
|
||||
Y int // Y position for the completions popup
|
||||
}
|
||||
|
||||
type RepositionCompletionsMsg struct {
|
||||
X, Y int
|
||||
}
|
||||
|
||||
type CompletionsClosedMsg struct{}
|
||||
@@ -51,18 +57,24 @@ type Completions interface {
|
||||
}
|
||||
|
||||
type completionsCmp struct {
|
||||
width int
|
||||
height int // Height of the completions component`
|
||||
x int // X position for the completions popup
|
||||
y int // Y position for the completions popup
|
||||
open bool // Indicates if the completions are open
|
||||
keyMap KeyMap
|
||||
wWidth int // The window width
|
||||
wHeight int // The window height
|
||||
width int
|
||||
lastWidth int
|
||||
height int // Height of the completions component`
|
||||
x, xorig int // X position for the completions popup
|
||||
y int // Y position for the completions popup
|
||||
open bool // Indicates if the completions are open
|
||||
keyMap KeyMap
|
||||
|
||||
list list.ListModel
|
||||
query string // The current filter query
|
||||
}
|
||||
|
||||
const maxCompletionsWidth = 80 // Maximum width for the completions popup
|
||||
const (
|
||||
maxCompletionsWidth = 80 // Maximum width for the completions popup
|
||||
minCompletionsWidth = 20 // Minimum width for the completions popup
|
||||
)
|
||||
|
||||
func New() Completions {
|
||||
completionsKeyMap := DefaultKeyMap()
|
||||
@@ -83,7 +95,7 @@ func New() Completions {
|
||||
)
|
||||
return &completionsCmp{
|
||||
width: 0,
|
||||
height: 0,
|
||||
height: maxCompletionsHeight,
|
||||
list: l,
|
||||
query: "",
|
||||
keyMap: completionsKeyMap,
|
||||
@@ -102,8 +114,7 @@ func (c *completionsCmp) Init() tea.Cmd {
|
||||
func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
c.width = min(msg.Width-c.x, maxCompletionsWidth)
|
||||
c.height = min(msg.Height-c.y, 15)
|
||||
c.wWidth, c.wHeight = msg.Width, msg.Height
|
||||
return c, nil
|
||||
case tea.KeyPressMsg:
|
||||
switch {
|
||||
@@ -154,13 +165,16 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case key.Matches(msg, c.keyMap.Cancel):
|
||||
return c, util.CmdHandler(CloseCompletionsMsg{})
|
||||
}
|
||||
case RepositionCompletionsMsg:
|
||||
c.x, c.y = msg.X, msg.Y
|
||||
c.adjustPosition()
|
||||
case CloseCompletionsMsg:
|
||||
c.open = false
|
||||
return c, util.CmdHandler(CompletionsClosedMsg{})
|
||||
case OpenCompletionsMsg:
|
||||
c.open = true
|
||||
c.query = ""
|
||||
c.x = msg.X
|
||||
c.x, c.xorig = msg.X, msg.X
|
||||
c.y = msg.Y
|
||||
items := []util.Model{}
|
||||
t := styles.CurrentTheme()
|
||||
@@ -168,10 +182,18 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle))
|
||||
items = append(items, item)
|
||||
}
|
||||
c.height = max(min(c.height, len(items)), 1) // Ensure at least 1 item height
|
||||
width := listWidth(items)
|
||||
if len(items) == 0 {
|
||||
width = listWidth(c.list.Items())
|
||||
}
|
||||
if c.x+width >= c.wWidth {
|
||||
c.x = c.wWidth - width - 1
|
||||
}
|
||||
c.width = width
|
||||
c.height = max(min(maxCompletionsHeight, len(items)), 1) // Ensure at least 1 item height
|
||||
return c, tea.Batch(
|
||||
c.list.SetSize(c.width, c.height),
|
||||
c.list.SetItems(items),
|
||||
c.list.SetSize(c.width, c.height),
|
||||
util.CmdHandler(CompletionsOpenedMsg{}),
|
||||
)
|
||||
case FilterCompletionsMsg:
|
||||
@@ -195,8 +217,11 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
c.query = msg.Query
|
||||
var cmds []tea.Cmd
|
||||
cmds = append(cmds, c.list.Filter(msg.Query))
|
||||
itemsLen := len(c.list.Items())
|
||||
c.height = max(min(maxCompletionsHeight, itemsLen), 1)
|
||||
items := c.list.Items()
|
||||
itemsLen := len(items)
|
||||
c.xorig = msg.X
|
||||
c.x, c.y = msg.X, msg.Y
|
||||
c.adjustPosition()
|
||||
cmds = append(cmds, c.list.SetSize(c.width, c.height))
|
||||
if itemsLen == 0 {
|
||||
cmds = append(cmds, util.CmdHandler(CloseCompletionsMsg{}))
|
||||
@@ -209,21 +234,54 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *completionsCmp) adjustPosition() {
|
||||
items := c.list.Items()
|
||||
itemsLen := len(items)
|
||||
width := listWidth(items)
|
||||
c.lastWidth = c.width
|
||||
if c.x < 0 || width < c.lastWidth {
|
||||
c.x = c.xorig
|
||||
} else if c.x+width >= c.wWidth {
|
||||
c.x = c.wWidth - width - 1
|
||||
}
|
||||
c.width = width
|
||||
c.height = max(min(maxCompletionsHeight, itemsLen), 1)
|
||||
}
|
||||
|
||||
// View implements Completions.
|
||||
func (c *completionsCmp) View() string {
|
||||
if !c.open || len(c.list.Items()) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return c.style().Render(c.list.View())
|
||||
}
|
||||
|
||||
func (c *completionsCmp) style() lipgloss.Style {
|
||||
t := styles.CurrentTheme()
|
||||
return t.S().Base.
|
||||
style := t.S().Base.
|
||||
Width(c.width).
|
||||
Height(c.height).
|
||||
Background(t.BgSubtle)
|
||||
|
||||
return style.Render(c.list.View())
|
||||
}
|
||||
|
||||
// listWidth returns the width of the last 10 items in the list, which is used
|
||||
// to determine the width of the completions popup.
|
||||
// Note this only works for [completionItemCmp] items.
|
||||
func listWidth[T any](items []T) int {
|
||||
var width int
|
||||
if len(items) == 0 {
|
||||
return width
|
||||
}
|
||||
|
||||
for i := len(items) - 1; i >= 0 && i >= len(items)-10; i-- {
|
||||
item, ok := any(items[i]).(*completionItemCmp)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
itemWidth := lipgloss.Width(item.text) + 2 // +2 for padding
|
||||
width = max(width, itemWidth)
|
||||
}
|
||||
|
||||
return width
|
||||
}
|
||||
|
||||
func (c *completionsCmp) Open() bool {
|
||||
|
||||
@@ -165,7 +165,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
p.keyboardEnhancements = msg
|
||||
return p, nil
|
||||
case tea.WindowSizeMsg:
|
||||
return p, p.SetSize(msg.Width, msg.Height)
|
||||
u, cmd := p.editor.Update(msg)
|
||||
p.editor = u.(editor.Editor)
|
||||
return p, tea.Batch(p.SetSize(msg.Width, msg.Height), cmd)
|
||||
case CancelTimerExpiredMsg:
|
||||
p.isCanceling = false
|
||||
return p, nil
|
||||
|
||||
@@ -118,19 +118,10 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return a, a.handleWindowResize(msg.Width, msg.Height)
|
||||
|
||||
// Completions messages
|
||||
case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg:
|
||||
case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg,
|
||||
completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg:
|
||||
u, completionCmd := a.completions.Update(msg)
|
||||
a.completions = u.(completions.Completions)
|
||||
switch msg := msg.(type) {
|
||||
case completions.OpenCompletionsMsg:
|
||||
x, _ := a.completions.Position()
|
||||
if a.completions.Width()+x >= a.wWidth {
|
||||
// Adjust X position to fit in the window.
|
||||
msg.X = a.wWidth - a.completions.Width() - 1
|
||||
u, completionCmd = a.completions.Update(msg)
|
||||
a.completions = u.(completions.Completions)
|
||||
}
|
||||
}
|
||||
return a, completionCmd
|
||||
|
||||
// Dialog messages
|
||||
|
||||
Reference in New Issue
Block a user