attachments.go

  1package attachments
  2
  3import (
  4	"fmt"
  5	"math"
  6	"path/filepath"
  7	"slices"
  8	"strings"
  9
 10	"charm.land/bubbles/v2/key"
 11	tea "charm.land/bubbletea/v2"
 12	"charm.land/lipgloss/v2"
 13	"github.com/charmbracelet/crush/internal/message"
 14	"github.com/charmbracelet/x/ansi"
 15)
 16
 17const maxFilename = 15
 18
 19type Keymap struct {
 20	DeleteMode,
 21	DeleteAll,
 22	Escape key.Binding
 23}
 24
 25func New(renderer *Renderer, keyMap Keymap) *Attachments {
 26	return &Attachments{
 27		keyMap:   keyMap,
 28		renderer: renderer,
 29	}
 30}
 31
 32type Attachments struct {
 33	renderer *Renderer
 34	keyMap   Keymap
 35	list     []message.Attachment
 36	deleting bool
 37}
 38
 39func (m *Attachments) List() []message.Attachment { return m.list }
 40func (m *Attachments) Reset()                     { m.list = nil }
 41
 42func (m *Attachments) Update(msg tea.Msg) bool {
 43	switch msg := msg.(type) {
 44	case message.Attachment:
 45		m.list = append(m.list, msg)
 46		return true
 47	case tea.KeyPressMsg:
 48		switch {
 49		case key.Matches(msg, m.keyMap.DeleteMode):
 50			if len(m.list) > 0 {
 51				m.deleting = true
 52			}
 53			return true
 54		case m.deleting && key.Matches(msg, m.keyMap.Escape):
 55			m.deleting = false
 56			return true
 57		case m.deleting && key.Matches(msg, m.keyMap.DeleteAll):
 58			m.deleting = false
 59			m.list = nil
 60			return true
 61		case m.deleting:
 62			// Handle digit keys for individual attachment deletion.
 63			r := msg.Code
 64			if r >= '0' && r <= '9' {
 65				num := int(r - '0')
 66				if num < len(m.list) {
 67					m.list = slices.Delete(m.list, num, num+1)
 68				}
 69				m.deleting = false
 70			}
 71			return true
 72		}
 73	}
 74	return false
 75}
 76
 77func (m *Attachments) Render(width int) string {
 78	return m.renderer.Render(m.list, m.deleting, width)
 79}
 80
 81// Renderer returns the attachment renderer so callers can update its
 82// styles in place.
 83func (m *Attachments) Renderer() *Renderer { return m.renderer }
 84
 85func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) *Renderer {
 86	return &Renderer{
 87		normalStyle:   normalStyle,
 88		textStyle:     textStyle,
 89		imageStyle:    imageStyle,
 90		deletingStyle: deletingStyle,
 91	}
 92}
 93
 94// SetStyles updates the renderer styles in place.
 95func (r *Renderer) SetStyles(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) {
 96	r.normalStyle = normalStyle
 97	r.textStyle = textStyle
 98	r.imageStyle = imageStyle
 99	r.deletingStyle = deletingStyle
100}
101
102type Renderer struct {
103	normalStyle, textStyle, imageStyle, deletingStyle lipgloss.Style
104}
105
106func (r *Renderer) Render(attachments []message.Attachment, deleting bool, width int) string {
107	var chips []string
108
109	maxItemWidth := lipgloss.Width(r.imageStyle.String() + r.normalStyle.Render(strings.Repeat("x", maxFilename)))
110	fits := int(math.Floor(float64(width)/float64(maxItemWidth))) - 1
111
112	for i, att := range attachments {
113		filename := filepath.Base(att.FileName)
114		// Truncate if needed.
115		if ansi.StringWidth(filename) > maxFilename {
116			filename = ansi.Truncate(filename, maxFilename, "…")
117		}
118
119		if deleting {
120			chips = append(
121				chips,
122				r.deletingStyle.Render(fmt.Sprintf("%d", i)),
123				r.normalStyle.Render(filename),
124			)
125		} else {
126			chips = append(
127				chips,
128				r.icon(att).String(),
129				r.normalStyle.Render(filename),
130			)
131		}
132
133		if i == fits && len(attachments) > i {
134			chips = append(chips, lipgloss.NewStyle().Width(maxItemWidth).Render(fmt.Sprintf("%d more…", len(attachments)-fits)))
135			break
136		}
137	}
138
139	return lipgloss.JoinHorizontal(lipgloss.Left, chips...)
140}
141
142func (r *Renderer) icon(a message.Attachment) lipgloss.Style {
143	if a.IsImage() {
144		return r.imageStyle
145	}
146	return r.textStyle
147}