A minimal email TUI where you read with Markdown and write in Neovim. neomd.ssp.sh/docs
email markdown neovim tui
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

two new functions: unread view and jump to next unread. add ascii art

sspaeti 579dfcfe 57a0a8c2

+291 -85
+8
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + # 2026-04-16 4 + - **Redesigned welcome screen** — new two-column layout with ASCII art logo, philosophy/getting started guide on the left, and essential shortcuts organized by category on the right; wider box (100 chars) with cleaner spacing; maintains kanagawa color scheme; more scannable and visually appealing for new users 5 + - **ASCII logo in help overlay** — pressing `?` now shows the neomd ASCII art logo overlaid on the top-right corner of the help screen; shortcuts start immediately at the top without vertical space taken by the logo; logo only appears when scrolled to the top 6 + - **`space+w` welcome shortcut** — press `space` then `w` to reopen the welcome screen anytime; useful for reviewing keybindings and getting started guide; documented in help overlay and keybindings reference 7 + - **`N` jump to next unread** — press `N` to jump to the next unread email in the current folder; wraps around to the beginning if no unread found after cursor; displays status message if no unread emails exist 8 + - **`z` toggle unread-only view** — press `z` to filter the inbox to show only unread emails (mnemonic: "zero in on unread"); press `z` again to show all emails; works alongside text filter (`/`) and can be cleared with `esc`; status bar indicates current view mode 9 + - **Fix: help menu search** — all keys (including `j`, `k`, `d`, `u`) are now available for typing when searching in help overlay (`?` then `/`); scroll keys only work when not in search mode 10 + 3 11 # 2026-04-15 4 12 - **Scheduled folder keybindings** — added `gc` (go to Scheduled, mnemonic: "calendar") and `Mc` (move to Scheduled) shortcuts; Scheduled folder now accessible via dedicated keybindings alongside existing tab navigation (`[]HL`, `space+1-9`); help overlay and generated keybindings documentation updated 5 13
+20 -1
docs/content/docs/configurations/proton-bridge.md
··· 13 13 14 14 ## neomd Configuration 15 15 16 - Add the following to your `~/.config/neomd/config.toml`: 16 + Add the following to your `~/.config/neomd/config.toml` - notice that you cannot use folder named `Scheduled` as it's internally used for scheduled emails: 17 17 18 18 ```toml 19 19 [[accounts]] ··· 25 25 from = "Your Name <your-proton-email@proton.me>" 26 26 starttls = false # Proton Bridge uses TLS on non-standard ports 27 27 tls_cert_file = "~/ProtonBridge/cert.pem" # optional: exported Bridge certificate 28 + 29 + 30 + [folders] 31 + inbox = "INBOX" 32 + sent = "Sent" 33 + trash = "Trash" 34 + drafts = "Drafts" 35 + to_screen = "Folders/ToScreen" 36 + feed = "Folders/Feed" 37 + papertrail = "Folders/PaperTrail" 38 + screened_out = "Folders/ScreenedOut" 39 + archive = "Archive" 40 + waiting = "Folders/Waiting" 41 + scheduled = "Folders/sched" 42 + someday = "Folders/Someday" 43 + spam = "Spam" 44 + work = "" 45 + tab_order = ["inbox", "to_screen", "feed", "papertrail", "waiting", "sched", "someday", "archive", "sent", "drafts", "screened_out", "spam", "trash"] 46 + 28 47 ``` 29 48 30 49 ## Key Configuration Details
+3
docs/content/docs/keybindings.md
··· 95 95 |-----|--------| 96 96 | `<space>1 … <space>9` | jump to folder tab by number (Inbox=1, ToScreen=2, …) | 97 97 | `<space>/` | IMAP search ALL emails on server (From + Subject) | 98 + | `<space>w` | show welcome screen | 98 99 99 100 100 101 ### Sort (, prefix) ··· 116 117 | Key | Action | 117 118 |-----|--------| 118 119 | `n` | toggle read/unread (marked or cursor) | 120 + | `N` | jump to next unread email | 119 121 | `ctrl+n` | mark all in current folder as read | 120 122 | `R` | reload / refresh folder | 121 123 | `r` | reply (from inbox or reader) | ··· 179 181 | Key | Action | 180 182 |-----|--------| 181 183 | `/` | filter loaded emails (From + Subject, in-memory) | 184 + | `z` | toggle unread-only view (zoomed out/zero inbox) | 182 185 | `<space>/ or :search` | IMAP search ALL emails on server (From + Subject) | 183 186 | `?` | toggle this help | 184 187 | `q` | quit (from inbox) |
+3
internal/ui/keys.go
··· 70 70 {"Leader Key Mappings (space prefix)", [][2]string{ 71 71 {"<space>1 … <space>9", "jump to folder tab by number (Inbox=1, ToScreen=2, …)"}, 72 72 {"<space>/", "IMAP search ALL emails on server (From + Subject)"}, 73 + {"<space>w", "show welcome screen"}, 73 74 }}, 74 75 {"Sort (, prefix)", [][2]string{ 75 76 {",m", "date newest first (default)"}, ··· 83 84 }}, 84 85 {"Email actions", [][2]string{ 85 86 {"n", "toggle read/unread (marked or cursor)"}, 87 + {"N", "jump to next unread email"}, 86 88 {"ctrl+n", "mark all in current folder as read"}, 87 89 {"R", "reload / refresh folder"}, 88 90 {"r", "reply (from inbox or reader)"}, ··· 134 136 }}, 135 137 {"General", [][2]string{ 136 138 {"/", "filter loaded emails (From + Subject, in-memory)"}, 139 + {"z", "toggle unread-only view (zoomed out/zero inbox)"}, 137 140 {"<space>/ or :search", "IMAP search ALL emails on server (From + Subject)"}, 138 141 {"?", "toggle this help"}, 139 142 {"q", "quit (from inbox)"},
+131 -84
internal/ui/model.go
··· 511 511 filterActive bool 512 512 filterText string 513 513 514 + // showUnreadOnly filters the inbox to show only unread emails when true. 515 + // Toggled with 'v' key. 516 + showUnreadOnly bool 517 + 514 518 // pendingResetUIDs holds ToScreen UIDs awaiting y/n confirmation before 515 519 // being bulk-moved back to Inbox. 516 520 pendingResetUIDs []uint32 ··· 2168 2172 return m, tea.Quit 2169 2173 2170 2174 case "esc": 2171 - if m.filterText != "" { 2175 + if m.filterText != "" || m.showUnreadOnly { 2172 2176 m.filterActive = false 2173 2177 m.filterText = "" 2178 + m.showUnreadOnly = false 2174 2179 return m, m.applyFilter() 2175 2180 } 2176 2181 if m.imapSearchResults { ··· 2199 2204 2200 2205 case " ": // leader key — wait for digit or shortcut 2201 2206 m.pendingKey = " " 2202 - m.status = "leader: 1-9 folder tab / IMAP search (esc to cancel)" 2207 + m.status = "leader: 1-9 folder tab / IMAP search w welcome (esc to cancel)" 2203 2208 return m, nil 2204 2209 2205 2210 case "M": ··· 2354 2359 m.loading = true 2355 2360 return m, tea.Batch(m.spinner.Tick, m.batchToggleSeenCmd(targets)) 2356 2361 2362 + case "N": 2363 + // Jump to next unread email 2364 + current := m.inbox.Index() 2365 + items := m.inbox.Items() 2366 + for i := current + 1; i < len(items); i++ { 2367 + if item, ok := items[i].(emailItem); ok { 2368 + if !item.email.Seen { 2369 + m.inbox.Select(i) 2370 + return m, nil 2371 + } 2372 + } 2373 + } 2374 + // Wrap around to beginning 2375 + for i := 0; i <= current; i++ { 2376 + if item, ok := items[i].(emailItem); ok { 2377 + if !item.email.Seen { 2378 + m.inbox.Select(i) 2379 + return m, nil 2380 + } 2381 + } 2382 + } 2383 + m.status = "No unread emails found." 2384 + return m, nil 2385 + 2357 2386 // ── Navigation ────────────────────────────────────────────────── 2358 2387 case "tab", "L", "]": 2359 2388 m.activeFolderI = (m.activeFolderI + 1) % len(m.folders) ··· 2396 2425 case "/": 2397 2426 m.filterActive = true 2398 2427 m.filterText = "" 2428 + return m, m.applyFilter() 2429 + 2430 + case "z": 2431 + // Toggle unread-only filter 2432 + m.showUnreadOnly = !m.showUnreadOnly 2433 + if m.showUnreadOnly { 2434 + m.status = "Showing unread only · z to show all" 2435 + } else { 2436 + m.status = "Showing all emails" 2437 + } 2399 2438 return m, m.applyFilter() 2400 2439 2401 2440 case "ctrl+n": // mark all loaded emails in this folder as read ··· 2583 2622 return result 2584 2623 } 2585 2624 2586 - // applyFilter filters m.emails by filterText and refreshes the list. 2587 - // Call this whenever filterText changes. 2625 + // applyFilter filters m.emails by filterText and showUnreadOnly and refreshes the list. 2626 + // Call this whenever filterText or showUnreadOnly changes. 2588 2627 func (m *Model) applyFilter() tea.Cmd { 2589 - if m.filterText == "" { 2590 - return setEmails(&m.inbox, m.emails, m.markedUIDs, m.shouldPrefixFolderInSubject(), m.sortField, m.sortReverse) 2591 - } 2592 - query := strings.ToLower(m.filterText) 2593 2628 var filtered []imap.Email 2629 + 2630 + // Apply both text filter and unread filter 2594 2631 for _, e := range m.emails { 2595 - hay := strings.ToLower(e.From + " " + e.Subject) 2596 - if strings.Contains(hay, query) { 2597 - filtered = append(filtered, e) 2632 + // Skip if unread-only mode is on and email is read 2633 + if m.showUnreadOnly && e.Seen { 2634 + continue 2598 2635 } 2636 + 2637 + // Skip if text filter is active and doesn't match 2638 + if m.filterText != "" { 2639 + query := strings.ToLower(m.filterText) 2640 + hay := strings.ToLower(e.From + " " + e.Subject) 2641 + if !strings.Contains(hay, query) { 2642 + continue 2643 + } 2644 + } 2645 + 2646 + filtered = append(filtered, e) 2599 2647 } 2648 + 2649 + // If no filters are active, use all emails 2650 + if m.filterText == "" && !m.showUnreadOnly { 2651 + filtered = m.emails 2652 + } 2653 + 2600 2654 return setEmails(&m.inbox, filtered, m.markedUIDs, m.shouldPrefixFolderInSubject(), m.sortField, m.sortReverse) 2601 2655 } 2602 2656 ··· 2608 2662 m.imapSearchActive = true 2609 2663 m.imapSearchText = "" 2610 2664 m.imapSearchResults = false 2665 + return m, nil 2666 + } 2667 + if key == "w" { 2668 + m.state = stateWelcome 2611 2669 return m, nil 2612 2670 } 2613 2671 if len(key) == 1 && key >= "1" && key <= "9" { ··· 4241 4299 m.helpSearchActive = true 4242 4300 } 4243 4301 case "j", "down": 4244 - if !m.helpSearchActive { 4302 + if m.helpSearchActive { 4303 + m.helpSearch += "j" 4304 + m.helpScroll = 0 4305 + } else { 4245 4306 m.helpScroll++ 4246 4307 } 4247 4308 case "k", "up": 4248 - if !m.helpSearchActive && m.helpScroll > 0 { 4309 + if m.helpSearchActive { 4310 + m.helpSearch += "k" 4311 + m.helpScroll = 0 4312 + } else if m.helpScroll > 0 { 4249 4313 m.helpScroll-- 4250 4314 } 4251 - case "d", "ctrl+d": 4315 + case "d": 4316 + if m.helpSearchActive { 4317 + m.helpSearch += "d" 4318 + m.helpScroll = 0 4319 + } else { 4320 + m.helpScroll += m.helpPageSize() 4321 + } 4322 + case "ctrl+d": 4252 4323 if !m.helpSearchActive { 4253 4324 m.helpScroll += m.helpPageSize() 4254 4325 } 4255 - case "u", "ctrl+u": 4326 + case "u": 4327 + if m.helpSearchActive { 4328 + m.helpSearch += "u" 4329 + m.helpScroll = 0 4330 + } else { 4331 + m.helpScroll -= m.helpPageSize() 4332 + } 4333 + case "ctrl+u": 4256 4334 if !m.helpSearchActive { 4257 4335 m.helpScroll -= m.helpPageSize() 4258 4336 } ··· 4266 4344 return m, nil 4267 4345 } 4268 4346 4269 - func (m Model) viewWelcome() string { 4270 - boxWidth := 64 4271 - box := lipgloss.NewStyle(). 4272 - Border(lipgloss.RoundedBorder()). 4273 - BorderForeground(colorPrimary). 4274 - Padding(1, 3). 4275 - Width(boxWidth) 4347 + func (m Model) viewHelp() string { 4348 + keyStyle := lipgloss.NewStyle().Foreground(colorPrimary).Bold(true).Width(24) 4349 + titleStyle := lipgloss.NewStyle().Foreground(colorDateCol).Bold(true) 4350 + descStyle := lipgloss.NewStyle().Foreground(colorText) 4351 + matchStyle := lipgloss.NewStyle().Foreground(colorAuthorUnread).Bold(true) 4276 4352 4277 - title := lipgloss.NewStyle().Foreground(colorPrimary).Bold(true) 4278 - key := lipgloss.NewStyle().Foreground(colorAuthorUnread).Bold(true) 4279 - dim := lipgloss.NewStyle().Foreground(colorDateCol) 4353 + filter := strings.ToLower(m.helpSearch) 4280 4354 4281 - warn := lipgloss.NewStyle().Foreground(colorError) 4355 + // Build header with logo on the right (only if at top of scroll) 4356 + var headerLines []string 4357 + if m.helpScroll == 0 { 4358 + heading := styleHeader.Render(" Keyboard shortcuts") 4359 + logo := asciiLogoCompact(colorPrimary) 4360 + logoLines := strings.Split(strings.TrimPrefix(logo, "\n"), "\n") 4282 4361 4283 - content := title.Render("Welcome to neomd!") + "\n\n" + 4284 - "Your IMAP folders have been created automatically.\n\n" + 4285 - title.Render("Quick start") + "\n" + 4286 - key.Render(" j/k") + " navigate " + key.Render("enter") + " open email\n" + 4287 - key.Render(" c") + " compose " + key.Render("r") + " reply\n" + 4288 - key.Render(" f") + " forward " + key.Render("ctrl+r") + " reply-all\n" + 4289 - key.Render(" ]") + " / " + key.Render("[") + " next/prev tab " + key.Render("?") + " all keys\n\n" + 4290 - title.Render("How the Screener works") + "\n" + 4291 - "Your screener lists are empty, so " + warn.Render("auto-screening") + "\n" + 4292 - warn.Render("is paused") + " until you classify your first senders.\n\n" + 4293 - title.Render("Getting started") + "\n" + 4294 - "1. Go to " + key.Render("Inbox") + " tab; once screener is active, use " + key.Render("ToScreen") + " (" + key.Render("gk") + " or " + key.Render("Tab") + " or click)\n" + 4295 - "2. Screen each sender:\n" + 4296 - key.Render(" I") + " screen " + title.Render("in") + " " + dim.Render("sender stays in Inbox forever") + "\n" + 4297 - key.Render(" O") + " screen " + title.Render("out") + " " + dim.Render("sender never reaches Inbox again") + "\n" + 4298 - key.Render(" F") + " feed " + dim.Render("newsletters go to Feed tab") + "\n" + 4299 - key.Render(" P") + " papertrail " + dim.Render("receipts go to PaperTrail tab") + "\n" + 4300 - "3. Use " + key.Render("m") + " to mark multiple, then " + key.Render("I") + " to batch-approve\n" + 4301 - "4. Normal loads only screen the newest " + key.Render(strconv.Itoa(m.cfg.UI.InboxCount)) + " Inbox emails\n" + 4302 - "5. Use " + key.Render(":screen-all") + " for the full Inbox on the server " + dim.Render("(slower; mailbox-wide)") + "\n\n" + 4303 - dim.Render("Once classified, senders are remembered forever.") + "\n" + 4304 - dim.Render("New emails auto-sort on every load. You choose") + "\n" + 4305 - dim.Render("who lands in your inbox. Bye-bye spam.") + "\n\n" + 4306 - dim.Render("Inline <leader>a attachments in nvim require custom.lua + yazi.") + "\n" + 4307 - dim.Render("Disable auto-screen: auto_screen_on_load = false") + "\n" + 4308 - dim.Render("Diagnostics: :debug All keys: ?") + "\n\n" + 4309 - dim.Render("Press any key to continue.") 4362 + // Shorten separator to fit left column only 4363 + leftColWidth := 70 4364 + sep := styleSeparator.Render(strings.Repeat("─", leftColWidth)) 4310 4365 4311 - rendered := box.Render(content) 4366 + // Create header rows with logo on the right 4367 + leftStyle := lipgloss.NewStyle().Width(leftColWidth).Align(lipgloss.Left) 4312 4368 4313 - // Center vertically and horizontally 4314 - lines := strings.Count(rendered, "\n") + 1 4315 - padTop := (m.height - lines) / 2 4316 - if padTop < 0 { 4317 - padTop = 0 4318 - } 4319 - padLeft := (m.width - boxWidth) / 2 4320 - if padLeft < 0 { 4321 - padLeft = 0 4322 - } 4323 - prefix := strings.Repeat(" ", padLeft) 4324 - var b strings.Builder 4325 - for i := 0; i < padTop; i++ { 4326 - b.WriteByte('\n') 4327 - } 4328 - for _, line := range strings.Split(rendered, "\n") { 4329 - b.WriteString(prefix + line + "\n") 4330 - } 4331 - return b.String() 4332 - } 4369 + // First line: heading + logo line 1 4370 + headerLines = append(headerLines, lipgloss.JoinHorizontal(lipgloss.Top, 4371 + leftStyle.Render(heading), 4372 + logoLines[0])) 4333 4373 4334 - func (m Model) viewHelp() string { 4335 - heading := styleHeader.Render(" Keyboard shortcuts") 4336 - sep := styleSeparator.Render(strings.Repeat("─", m.width)) 4374 + // Second line: separator + logo line 2 4375 + headerLines = append(headerLines, lipgloss.JoinHorizontal(lipgloss.Top, 4376 + leftStyle.Render(sep), 4377 + logoLines[1])) 4337 4378 4338 - keyStyle := lipgloss.NewStyle().Foreground(colorPrimary).Bold(true).Width(24) 4339 - titleStyle := lipgloss.NewStyle().Foreground(colorDateCol).Bold(true) 4340 - descStyle := lipgloss.NewStyle().Foreground(colorText) 4341 - matchStyle := lipgloss.NewStyle().Foreground(colorAuthorUnread).Bold(true) 4342 - 4343 - filter := strings.ToLower(m.helpSearch) 4379 + // Remaining logo lines with empty left side 4380 + for i := 2; i < len(logoLines); i++ { 4381 + headerLines = append(headerLines, lipgloss.JoinHorizontal(lipgloss.Top, 4382 + leftStyle.Render(""), 4383 + logoLines[i])) 4384 + } 4385 + } else { 4386 + // When scrolled, just show normal header 4387 + heading := styleHeader.Render(" Keyboard shortcuts") 4388 + sep := styleSeparator.Render(strings.Repeat("─", m.width)) 4389 + headerLines = []string{heading, sep} 4390 + } 4344 4391 4345 - lines := []string{heading, sep} 4392 + lines := headerLines 4346 4393 for _, sec := range HelpSections { 4347 4394 var matched [][2]string 4348 4395 for _, row := range sec.Rows {
+126
internal/ui/welcome.go
··· 1 + package ui 2 + 3 + import ( 4 + "strings" 5 + 6 + "github.com/charmbracelet/lipgloss" 7 + ) 8 + 9 + // asciiLogo returns the neomd ASCII art logo styled with the given color 10 + func asciiLogo(fg lipgloss.TerminalColor) string { 11 + logo := lipgloss.NewStyle().Foreground(fg) 12 + return logo.Render(` 13 + ███╗ ██╗███████╗ ██████╗ ███╗ ███╗██████╗ 14 + ████╗ ██║██╔════╝██╔═══██╗████╗ ████║██╔══██╗ 15 + ██╔██╗ ██║█████╗ ██║ ██║██╔████╔██║██║ ██║ 16 + ██║╚██╗██║██╔══╝ ██║ ██║██║╚██╔╝██║██║ ██║ 17 + ██║ ╚████║███████╗╚██████╔╝██║ ╚═╝ ██║██████╔╝ 18 + ╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ `) 19 + } 20 + 21 + // asciiLogoCompact returns a smaller version for constrained spaces 22 + func asciiLogoCompact(fg lipgloss.TerminalColor) string { 23 + logo := lipgloss.NewStyle().Foreground(fg) 24 + return logo.Render(` 25 + ███╗ ██╗███████╗ ██████╗ ███╗ ███╗██████╗ 26 + ████╗ ██║██╔════╝██╔═══██╗████╗ ████║██╔══██╗ 27 + ██╔██╗ ██║█████╗ ██║ ██║██╔████╔██║██║ ██║ 28 + ██║╚██╗██║██╔══╝ ██║ ██║██║╚██╔╝██║██║ ██║ 29 + ██║ ╚████║███████╗╚██████╔╝██║ ╚═╝ ██║██████╔╝ 30 + ╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝`) 31 + } 32 + 33 + func (m Model) viewWelcome() string { 34 + boxWidth := 100 35 + box := lipgloss.NewStyle(). 36 + Border(lipgloss.RoundedBorder()). 37 + BorderForeground(colorPrimary). 38 + Padding(1, 2). 39 + Width(boxWidth) 40 + 41 + title := lipgloss.NewStyle().Foreground(colorPrimary).Bold(true) 42 + key := lipgloss.NewStyle().Foreground(colorAuthorUnread).Bold(true) 43 + dim := lipgloss.NewStyle().Foreground(colorDateCol) 44 + warn := lipgloss.NewStyle().Foreground(colorError) 45 + 46 + subtitle := dim.Render(" terminal email, reimagined ") 47 + 48 + // Left column - Philosophy 49 + leftCol := title.Render("Philosophy") + "\n\n" + 50 + "Your IMAP folders have been created.\n\n" + 51 + "The Screener is a HEY-style workflow:\n" + 52 + "You decide who reaches your Inbox.\n\n" + 53 + dim.Render("Your screener lists are empty, so") + "\n" + 54 + warn.Render("auto-screening is paused") + dim.Render(" until you") + "\n" + 55 + dim.Render("classify your first senders.") + "\n\n" + 56 + dim.Render("Once classified, senders are") + "\n" + 57 + dim.Render("remembered forever. New emails") + "\n" + 58 + dim.Render("auto-sort on every load.") + "\n\n" + 59 + title.Render("Getting Started") + "\n\n" + 60 + "1. Navigate to " + key.Render("ToScreen") + " tab\n" + 61 + "2. Screen each sender:\n" + 62 + " " + key.Render("I") + " screen in " + dim.Render("→ Inbox") + "\n" + 63 + " " + key.Render("O") + " screen out " + dim.Render("→ ScreenedOut") + "\n" + 64 + " " + key.Render("F") + " feed " + dim.Render("→ Feed") + "\n" + 65 + " " + key.Render("P") + " papertrail " + dim.Render("→ PaperTrail") + "\n" + 66 + "3. Use " + key.Render("m") + " to mark multiple\n" + 67 + "4. Try " + key.Render(":screen-all") + " for full scan" 68 + 69 + // Right column - Essential shortcuts 70 + rightCol := title.Render("Essential Shortcuts") + "\n\n" + 71 + title.Render("Navigation") + "\n" + 72 + key.Render(" j/k") + " move up/down\n" + 73 + key.Render(" enter") + " open email\n" + 74 + key.Render(" ] / [") + " next/prev tab\n" + 75 + key.Render(" gi") + " go to Inbox\n" + 76 + key.Render(" gk") + " go to ToScreen\n" + 77 + key.Render(" space+1-9") + " jump to tab 1-9\n\n" + 78 + title.Render("Email Actions") + "\n" + 79 + key.Render(" c") + " compose\n" + 80 + key.Render(" r") + " reply\n" + 81 + key.Render(" ctrl+r") + " reply-all\n" + 82 + key.Render(" f") + " forward\n" + 83 + key.Render(" ctrl+e") + " emoji reaction\n" + 84 + key.Render(" n") + " toggle read/unread\n\n" + 85 + title.Render("Power User") + "\n" + 86 + key.Render(" /") + " filter emails\n" + 87 + key.Render(" space+/") + " IMAP search\n" + 88 + key.Render(" T") + " thread view\n" + 89 + key.Render(" ?") + " all keybindings\n" + 90 + key.Render(" :debug") + " diagnostics\n" 91 + 92 + // Create two columns side by side 93 + leftStyle := lipgloss.NewStyle().Width(46).Align(lipgloss.Left) 94 + rightStyle := lipgloss.NewStyle().Width(46).Align(lipgloss.Left).PaddingLeft(2) 95 + 96 + columns := lipgloss.JoinHorizontal( 97 + lipgloss.Top, 98 + leftStyle.Render(leftCol), 99 + rightStyle.Render(rightCol), 100 + ) 101 + 102 + content := asciiLogo(colorPrimary) + "\n" + subtitle + "\n\n" + columns + "\n\n" + 103 + dim.Render(" Press any key to continue ") 104 + 105 + rendered := box.Render(content) 106 + 107 + // Center vertically and horizontally 108 + lines := strings.Count(rendered, "\n") + 1 109 + padTop := (m.height - lines) / 2 110 + if padTop < 0 { 111 + padTop = 0 112 + } 113 + padLeft := (m.width - boxWidth) / 2 114 + if padLeft < 0 { 115 + padLeft = 0 116 + } 117 + prefix := strings.Repeat(" ", padLeft) 118 + var b strings.Builder 119 + for i := 0; i < padTop; i++ { 120 + b.WriteByte('\n') 121 + } 122 + for _, line := range strings.Split(rendered, "\n") { 123 + b.WriteString(prefix + line + "\n") 124 + } 125 + return b.String() 126 + }