Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion config/default_keybinds.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion config/keybinds.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
Expand Down
108 changes: 81 additions & 27 deletions tui/composer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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") + " ]")
Expand Down Expand Up @@ -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 := "[ ]"
Expand Down Expand Up @@ -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:
Expand All @@ -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))
Expand Down Expand Up @@ -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)
}
Expand Down
42 changes: 42 additions & 0 deletions tui/composer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading