diff --git a/config/default_keybinds.json b/config/default_keybinds.json index 6a325ef617dff949cd449f5fa2c710e5491bc628..19b4ec092ff772e578e30afcddb1eba888e14e8f 100644 --- a/config/default_keybinds.json +++ b/config/default_keybinds.json @@ -31,7 +31,8 @@ "composer": { "external_editor": "ctrl+e", "next_field": "tab", - "prev_field": "shift+tab" + "prev_field": "shift+tab", + "delete": "d" }, "folder": { "next_folder": "tab", diff --git a/config/keybinds.go b/config/keybinds.go index a9053f56b932b8b62742da07a21f2ada7d758a74..945f184043d078717e177d985a96cd5c88a573c6 100644 --- a/config/keybinds.go +++ b/config/keybinds.go @@ -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, diff --git a/tui/composer.go b/tui/composer.go index 8614cb1d79a90740d8719d1c7fa0947af63b59c2..74ae6b2716c30b96df95be148796e0c9177ae025 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -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) } diff --git a/tui/composer_test.go b/tui/composer_test.go index d08c51e64230b7c9fb32d73576340429d8ff1079..ffc10e0be32663a85e82533b069ed858cd17e705 100644 --- a/tui/composer_test.go +++ b/tui/composer_test.go @@ -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) {