style: make keybinds always stick to the bottom (#213)

Drew Smirnoff created

Change summary

go.mod          |  1 +
go.sum          |  2 --
main.go         | 11 +++++++++++
tui/choice.go   | 22 +++++++++++++++++++---
tui/composer.go | 19 +++++++++++++++++--
tui/inbox.go    | 38 +++++---------------------------------
tui/settings.go | 32 +++++++++++++++++++++++++++-----
7 files changed, 80 insertions(+), 45 deletions(-)

Detailed changes

go.mod 🔗

@@ -18,6 +18,7 @@ require (
 
 require (
 	al.essio.dev/pkg/shellescape v1.5.1 // indirect
+	charm.land/bubbles/v2 v2.0.0 // indirect
 	github.com/andybalholm/cascadia v1.3.3 // indirect
 	github.com/atotto/clipboard v0.1.4 // indirect
 	github.com/charmbracelet/colorprofile v0.4.2 // indirect

go.sum 🔗

@@ -36,8 +36,6 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
 github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
 github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
 github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
 github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
 github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=

main.go 🔗

@@ -126,6 +126,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
 			case *tui.Inbox, *tui.Login, *tui.TrashArchive:
 				m.current = tui.NewChoice()
+				m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 				return m, m.current.Init()
 			}
 		}
@@ -135,6 +136,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.current = m.inbox
 		} else {
 			m.current = tui.NewChoice()
+			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 		}
 		return m, nil
 
@@ -157,6 +159,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 		m.current = tui.NewChoice()
+		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 		return m, nil
 
 	case tui.DiscardDraftMsg:
@@ -170,6 +173,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}()
 		}
 		m.current = tui.NewChoice()
+		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 		return m, m.current.Init()
 
 	case tui.Credentials:
@@ -220,6 +224,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 
 		m.current = tui.NewChoice()
+		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 		return m, m.current.Init()
 
 	case tui.GoToInboxMsg:
@@ -546,6 +551,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	case tui.GoToChoiceMenuMsg:
 		m.current = tui.NewChoice()
+		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 		return m, m.current.Init()
 
 	case tui.DeleteAccountMsg:
@@ -734,12 +740,14 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if msg.Err != nil {
 			log.Printf("Failed to send email: %v", msg.Err)
 			m.previousModel = tui.NewChoice()
+			m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
 			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
 				return tui.RestoreViewMsg{}
 			})
 		}
 		m.current = tui.NewChoice()
+		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 		return m, m.current.Init()
 
 	case tui.DeleteEmailMsg:
@@ -809,6 +817,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return m, m.current.Init()
 			}
 			m.current = tui.NewChoice()
+			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 			return m, m.current.Init()
 		}
 
@@ -820,6 +829,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return m, m.current.Init()
 			}
 			m.current = tui.NewChoice()
+			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 			return m, m.current.Init()
 		}
 
@@ -830,6 +840,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return m, m.current.Init()
 		}
 		m.current = tui.NewChoice()
+		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 		return m, m.current.Init()
 
 	case tui.DownloadAttachmentMsg:

tui/choice.go 🔗

@@ -36,6 +36,8 @@ type Choice struct {
 	UpdateAvailable bool
 	LatestVersion   string
 	CurrentVersion  string
+	width           int
+	height          int
 }
 
 func NewChoice() Choice {
@@ -61,6 +63,10 @@ func (m Choice) Init() tea.Cmd {
 
 func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		m.width = msg.Width
+		m.height = msg.Height
+		return m, nil
 	case tea.KeyPressMsg:
 		switch msg.String() {
 		case "up", "k":
@@ -144,8 +150,18 @@ func (m Choice) View() tea.View {
 		b.WriteString("\n")
 	}
 
-	b.WriteString("\n\n")
-	b.WriteString(helpStyle.Render("Use ↑/↓ to navigate, enter to select, and ctrl+c to quit."))
+	mainContent := b.String()
+	helpView := helpStyle.Render("Use ↑/↓ to navigate, enter to select, and ctrl+c to quit.")
+
+	if m.height > 0 {
+		currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
+		gap := m.height - currentHeight
+		if gap > 0 {
+			mainContent += strings.Repeat("\n", gap)
+		}
+	} else {
+		mainContent += "\n\n"
+	}
 
-	return tea.NewView(docStyle.Render(b.String()))
+	return tea.NewView(docStyle.Render(mainContent + helpView))
 }

tui/composer.go 🔗

@@ -488,9 +488,24 @@ func (m *Composer) View() tea.View {
 	if !m.hideTips && tip != "" {
 		composerViewElements = append(composerViewElements, TipStyle.Render("Tip: "+tip))
 	}
-	composerViewElements = append(composerViewElements, helpStyle.Render("Markdown/HTML • tab/shift+tab: navigate • esc: save draft & exit"))
 
-	composerView.WriteString(lipgloss.JoinVertical(lipgloss.Left, composerViewElements...))
+	mainContent := lipgloss.JoinVertical(lipgloss.Left, composerViewElements...)
+	helpView := helpStyle.Render("Markdown/HTML • tab/shift+tab: navigate • esc: save draft & exit")
+
+	if m.height > 0 {
+		currentHeight := lipgloss.Height(mainContent) + lipgloss.Height(helpView)
+		gap := m.height - currentHeight
+		if gap >= 0 {
+			mainContent += strings.Repeat("\n", gap+1)
+		} else {
+			mainContent += "\n"
+		}
+	} else {
+		mainContent += "\n\n"
+	}
+
+	composerView.WriteString(mainContent)
+	composerView.WriteString(helpView)
 
 	// Account picker overlay
 	if m.showAccountPicker {

tui/inbox.go 🔗

@@ -516,45 +516,17 @@ func (m *Inbox) View() tea.View {
 
 	b.WriteString(m.list.View())
 
-	// Calculate remaining height to push help to bottom
-	// m.height is total height.
-	// We need to account for tabs (if present) and the list height.
-	// The list height is set to m.height / 2.
-	// Tabs take about 3 lines (border + padding + content).
+	// Ensure we don't start gap calculation on the same line as the list
+	if !strings.HasSuffix(b.String(), "\n") {
+		b.WriteString("\n")
+	}
 
 	helpView := inboxHelpStyle.Render(m.list.Help.View(m.list))
 
-	// If we have a known height, we can try to fill the space.
 	if m.height > 0 {
-		// Calculate how many lines we have used
-		usedHeight := 0
-		if len(m.tabs) > 1 {
-			// Re-render tabs just to measure height
-			var tabViews []string
-			for i, tab := range m.tabs {
-				label := tab.Label
-				if tab.ID == "" {
-					label = "ALL"
-				}
-
-				if i == m.activeTabIndex {
-					tabViews = append(tabViews, activeTabStyle.Render(label))
-				} else {
-					tabViews = append(tabViews, tabStyle.Render(label))
-				}
-			}
-			tabBar := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...))
-			usedHeight += lipgloss.Height(tabBar)
-		}
-
-		// List
-		usedHeight += m.list.Height()
-
-		// Help
-		// Use lipgloss to measure help height
+		usedHeight := lipgloss.Height(b.String())
 		helpHeight := lipgloss.Height(helpView)
 
-		// Calculate gap
 		gap := m.height - usedHeight - helpHeight
 		if gap > 0 {
 			b.WriteString(strings.Repeat("\n", gap))

tui/settings.go 🔗

@@ -226,9 +226,20 @@ func (m *Settings) viewMain() string {
 		}
 	}
 
-	b.WriteString(helpStyle.Render("↑/↓: navigate • enter: select/toggle • esc: back"))
+	mainContent := b.String()
+	helpView := helpStyle.Render("↑/↓: navigate • enter: select/toggle • esc: back")
+
+	if m.height > 0 {
+		currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
+		gap := m.height - currentHeight
+		if gap > 0 {
+			mainContent += strings.Repeat("\n", gap)
+		}
+	} else {
+		mainContent += "\n\n"
+	}
 
-	return docStyle.Render(b.String())
+	return docStyle.Render(mainContent + helpView)
 }
 
 func (m *Settings) viewAccounts() string {
@@ -270,9 +281,20 @@ func (m *Settings) viewAccounts() string {
 	} else {
 		b.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", addAccountText)))
 	}
-	b.WriteString("\n\n")
+	b.WriteString("\n")
 
-	b.WriteString(helpStyle.Render("↑/↓: navigate • enter: select • d: delete account • esc: back"))
+	mainContent := b.String()
+	helpView := helpStyle.Render("↑/↓: navigate • enter: select • d: delete account • esc: back")
+
+	if m.height > 0 {
+		currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
+		gap := m.height - currentHeight
+		if gap > 0 {
+			mainContent += strings.Repeat("\n", gap)
+		}
+	} else {
+		mainContent += "\n\n"
+	}
 
 	if m.confirmingDelete {
 		accountName := m.cfg.Accounts[m.cursor].Email
@@ -286,7 +308,7 @@ func (m *Settings) viewAccounts() string {
 		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
 	}
 
-	return docStyle.Render(b.String())
+	return docStyle.Render(mainContent + helpView)
 }
 
 // UpdateConfig updates the configuration (used when accounts are deleted).