@@ -61,6 +61,7 @@ type ComposerKeys struct {
ExternalEditor string `json:"external_editor"`
NextField string `json:"next_field"`
PrevField string `json:"prev_field"`
+ Delete string `json:"delete"`
}
type FolderKeys struct {
@@ -105,7 +106,7 @@ func LoadKeybindsFromDir(cfgDir string) error {
return nil
}
- var kb KeybindsConfig
+ kb := defaultKeybinds()
if err := json.Unmarshal(data, &kb); err != nil {
return fmt.Errorf("keybinds: parse %s: %w", path, err)
}
@@ -167,6 +168,7 @@ func ValidateKeybinds(kb KeybindsConfig) []string {
"external_editor": kb.Composer.ExternalEditor,
"next_field": kb.Composer.NextField,
"prev_field": kb.Composer.PrevField,
+ "delete": kb.Composer.Delete,
})
check("folder", map[string]string{
"next_folder": kb.Folder.NextFolder,
@@ -47,20 +47,21 @@ const (
// Composer model holds the state of the email composition UI.
type Composer struct {
- focusIndex int
- toInput textinput.Model
- ccInput textinput.Model
- bccInput textinput.Model
- subjectInput textinput.Model
- bodyInput textarea.Model
- signatureInput textarea.Model
- attachmentPaths []string
- attachmentNames map[string]string
- encryptSMIME bool
- width int
- height int
- confirmingExit bool
- hideTips bool
+ focusIndex int
+ toInput textinput.Model
+ ccInput textinput.Model
+ bccInput textinput.Model
+ subjectInput textinput.Model
+ bodyInput textarea.Model
+ signatureInput textarea.Model
+ attachmentPaths []string
+ attachmentNames map[string]string
+ attachmentCursor int
+ encryptSMIME bool
+ width int
+ height int
+ confirmingExit bool
+ hideTips bool
// Multi-account support
accounts []config.Account
@@ -249,6 +250,31 @@ func (m *Composer) attachmentDisplayName(path string) string {
return filepath.Base(path)
}
+func (m *Composer) clampAttachmentCursor() {
+ if len(m.attachmentPaths) == 0 {
+ m.attachmentCursor = 0
+ return
+ }
+ if m.attachmentCursor < 0 {
+ m.attachmentCursor = 0
+ }
+ if m.attachmentCursor >= len(m.attachmentPaths) {
+ m.attachmentCursor = len(m.attachmentPaths) - 1
+ }
+}
+
+func (m *Composer) removeSelectedAttachment() {
+ if len(m.attachmentPaths) == 0 {
+ return
+ }
+
+ m.clampAttachmentCursor()
+ idx := m.attachmentCursor
+ delete(m.attachmentNames, m.attachmentPaths[idx])
+ m.attachmentPaths = append(m.attachmentPaths[:idx], m.attachmentPaths[idx+1:]...)
+ m.clampAttachmentCursor()
+}
+
func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
@@ -299,6 +325,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.attachmentNames[newPath] = formatAttachmentName(newPath)
}
}
+ m.clampAttachmentCursor()
return m, nil
case tea.KeyPressMsg:
@@ -413,6 +440,18 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
kb := config.Keybinds
+ attachmentPathSize := len(m.attachmentPaths)
+ if m.focusIndex == focusAttachment && attachmentPathSize > 0 {
+ switch msg.String() {
+ case "up", kb.Global.NavUp:
+ m.attachmentCursor = (m.attachmentCursor - 1 + attachmentPathSize) % attachmentPathSize
+ return m, nil
+ case "down", kb.Global.NavDown:
+ m.attachmentCursor = (m.attachmentCursor + 1) % attachmentPathSize
+ return m, nil
+ }
+ }
+
switch msg.String() {
case kb.Global.Quit:
return m, tea.Quit
@@ -470,10 +509,9 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, tea.Batch(cmds...)
- case "backspace", "delete", "d":
+ case kb.Composer.Delete:
if m.focusIndex == focusAttachment && len(m.attachmentPaths) > 0 {
- delete(m.attachmentNames, m.attachmentPaths[len(m.attachmentPaths)-1])
- m.attachmentPaths = m.attachmentPaths[:len(m.attachmentPaths)-1]
+ m.removeSelectedAttachment()
return m, nil
}
@@ -584,6 +622,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *Composer) View() tea.View {
var composerView strings.Builder
var button string
+ ck := config.Keybinds.Composer
if m.focusIndex == focusSend {
button = focusedStyle.Copy().Render("[ " + t("composer.send") + " ]")
@@ -626,16 +665,25 @@ func (m *Composer) View() tea.View {
attachmentField = blurredStyle.Render(fmt.Sprintf(" %s %s", t("composer.attachments"), attachmentText))
}
} else {
- var names []string
- for _, p := range m.attachmentPaths {
- names = append(names, m.attachmentDisplayName(p))
- }
- attachmentText := strings.Join(names, ", ")
+ var b strings.Builder
+ headerPrefix := " "
+ headerStyle := blurredStyle
if m.focusIndex == focusAttachment {
- attachmentField = focusedStyle.Render(fmt.Sprintf("> %s (%d): %s", t("composer.attachments"), len(m.attachmentPaths), attachmentText))
- } else {
- attachmentField = blurredStyle.Render(fmt.Sprintf(" %s (%d): %s", t("composer.attachments"), len(m.attachmentPaths), attachmentText))
+ headerPrefix = "> "
+ headerStyle = focusedStyle
+ }
+ b.WriteString(headerStyle.Render(fmt.Sprintf("%s%s (%d):", headerPrefix, t("composer.attachments"), len(m.attachmentPaths))))
+ for i, p := range m.attachmentPaths {
+ cursor := " "
+ style := blurredStyle
+ if m.focusIndex == focusAttachment && i == m.attachmentCursor {
+ cursor = " > "
+ style = focusedStyle
+ }
+ b.WriteString("\n")
+ b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, m.attachmentDisplayName(p))))
}
+ attachmentField = b.String()
}
encToggle := "[ ]"
@@ -690,7 +738,7 @@ func (m *Composer) View() tea.View {
case focusSignature:
tip = "Your email signature. This will be appended to the end of the email."
case focusAttachment:
- tip = "Enter: add file • backspace/d: remove last attachment"
+ tip = fmt.Sprintf("Enter: add file • up/down: select attachment • %s: remove selected", ck.Delete)
case focusEncryptSMIME:
tip = "Press Space or Enter to toggle S/MIME encryption on or off."
case focusSend:
@@ -708,10 +756,15 @@ func (m *Composer) View() tea.View {
signatureLabel,
m.signatureInput.View(),
attachmentStyle.Render(attachmentField),
+ }
+ if len(m.attachmentPaths) > 0 {
+ composerViewElements = append(composerViewElements, "")
+ }
+ composerViewElements = append(composerViewElements,
smimeToggleStyle.Render(encField),
button,
"",
- }
+ )
if !m.hideTips && tip != "" {
composerViewElements = append(composerViewElements, TipStyle.Render("Tip: "+tip))
@@ -971,6 +1024,7 @@ func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTip
for _, path := range m.attachmentPaths {
m.attachmentNames[path] = formatAttachmentName(path)
}
+ m.clampAttachmentCursor()
if m.isCatchAllAccount() && draft.FromOverride != "" {
m.fromInput.SetValue(draft.FromOverride)
}
@@ -249,6 +249,48 @@ func TestFormatAttachmentNameMissingFile(t *testing.T) {
}
}
+func TestComposerAttachmentSelectionAndRemoval(t *testing.T) {
+ composer := NewComposer("", "", "", "", false)
+ composer.focusIndex = focusAttachment
+ composer.attachmentPaths = []string{"/tmp/a.txt", "/tmp/b.txt", "/tmp/c.txt"}
+ composer.attachmentNames = map[string]string{
+ "/tmp/a.txt": "a.txt",
+ "/tmp/b.txt": "b.txt",
+ "/tmp/c.txt": "c.txt",
+ }
+
+ model, _ := composer.Update(tea.KeyPressMsg{Code: tea.KeyDown})
+ composer = model.(*Composer)
+ if composer.attachmentCursor != 1 {
+ t.Fatalf("Expected attachmentCursor 1 after Down, got %d", composer.attachmentCursor)
+ }
+
+ model, _ = composer.Update(tea.KeyPressMsg{Code: 'd', Text: "d"})
+ composer = model.(*Composer)
+
+ want := []string{"/tmp/a.txt", "/tmp/c.txt"}
+ if len(composer.attachmentPaths) != len(want) {
+ t.Fatalf("Expected %d attachments after removal, got %d", len(want), len(composer.attachmentPaths))
+ }
+ for i, path := range want {
+ if composer.attachmentPaths[i] != path {
+ t.Fatalf("attachmentPaths[%d] = %q, want %q", i, composer.attachmentPaths[i], path)
+ }
+ }
+ if _, ok := composer.attachmentNames["/tmp/b.txt"]; ok {
+ t.Fatal("Expected removed attachment display name to be deleted")
+ }
+ if composer.attachmentCursor != 1 {
+ t.Fatalf("Expected cursor to stay on the next attachment, got %d", composer.attachmentCursor)
+ }
+
+ model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyDown})
+ composer = model.(*Composer)
+ if composer.attachmentCursor != 0 {
+ t.Fatalf("Expected attachmentCursor to wrap to 0 after Down, got %d", composer.attachmentCursor)
+ }
+}
+
// TestComposerGetFromAddress verifies the from address formatting.
func TestComposerGetFromAddress(t *testing.T) {
t.Run("With name", func(t *testing.T) {