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
	 Kujtim Hoxha
					Kujtim Hoxha