diff --git a/go.mod b/go.mod
index c424c4b..9d1c284 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,8 @@ module github.com/floatpane/matcha
go 1.26.0
require (
+ charm.land/bubbletea/v2 v2.0.0
+ charm.land/lipgloss/v2 v2.0.0
github.com/PuerkitoBio/goquery v1.11.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
@@ -16,25 +18,26 @@ 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/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
- github.com/charmbracelet/colorprofile v0.4.1 // indirect
+ github.com/charmbracelet/colorprofile v0.4.2 // indirect
+ github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
- github.com/clipperhouse/displaywidth v0.9.0 // indirect
- github.com/clipperhouse/stringish v0.1.1 // indirect
- github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
- github.com/danieljoos/wincred v1.2.2 // indirect
+ github.com/charmbracelet/x/termios v0.1.1 // indirect
+ github.com/charmbracelet/x/windows v0.2.2 // indirect
+ github.com/clipperhouse/displaywidth v0.11.0 // indirect
+ github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
- github.com/mattn/go-runewidth v0.0.19 // indirect
+ github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
@@ -43,4 +46,5 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zalando/go-keyring v0.2.6 // indirect
golang.org/x/net v0.47.0 // indirect
+ golang.org/x/sync v0.19.0 // indirect
)
diff --git a/go.sum b/go.sum
index 6a96835..eb6faa8 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,9 @@
-al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
-al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
+charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
+charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
+charm.land/bubbletea/v2 v2.0.0 h1:p0d6CtWyJXJ9GfzMpUUqbP/XUUhhlk06+vCKWmox1wQ=
+charm.land/bubbletea/v2 v2.0.0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
+charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
+charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
@@ -10,32 +14,34 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
-github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
-github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
+github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
+github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
-github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
-github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
+github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
+github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
+github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
-github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
-github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
-github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
-github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
-github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
-github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
-github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
-github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
-github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
-github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
+github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
+github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
+github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
+github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
+github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
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=
@@ -60,8 +66,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
-github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
-github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
+github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -110,6 +116,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/main.go b/main.go
index 0730746..01e55b1 100644
--- a/main.go
+++ b/main.go
@@ -19,7 +19,7 @@ import (
"sync"
"time"
- tea "github.com/charmbracelet/bubbletea"
+ tea "charm.land/bubbletea/v2"
"github.com/floatpane/matcha/config"
"github.com/floatpane/matcha/fetcher"
"github.com/floatpane/matcha/sender"
@@ -112,7 +112,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height
return m, nil
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
@@ -856,8 +856,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
-func (m *mainModel) View() string {
- return m.current.View()
+func (m *mainModel) View() tea.View {
+ v := m.current.View()
+ v.AltScreen = true
+ return v
}
func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email {
@@ -1881,7 +1883,7 @@ func main() {
initialModel = newInitialModel(cfg)
}
- p := tea.NewProgram(initialModel, tea.WithAltScreen())
+ p := tea.NewProgram(initialModel)
if _, err := p.Run(); err != nil {
fmt.Printf("Alas, there's been an error: %v", err)
diff --git a/tui/choice.go b/tui/choice.go
index f9890e9..56ccbbe 100644
--- a/tui/choice.go
+++ b/tui/choice.go
@@ -5,8 +5,8 @@ import (
"reflect"
"strings"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
"github.com/floatpane/matcha/config"
)
@@ -61,7 +61,7 @@ func (m Choice) Init() tea.Cmd {
func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
switch msg.String() {
case "up", "k":
if m.cursor > 0 {
@@ -115,7 +115,7 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
-func (m Choice) View() string {
+func (m Choice) View() tea.View {
var b strings.Builder
b.WriteString(logoStyle.Render(choiceLogo))
@@ -147,5 +147,5 @@ func (m Choice) View() string {
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("Use ↑/↓ to navigate, enter to select, and ctrl+c to quit."))
- return docStyle.Render(b.String())
+ return tea.NewView(docStyle.Render(b.String()))
}
diff --git a/tui/composer.go b/tui/composer.go
index 06e46c8..fbffe54 100644
--- a/tui/composer.go
+++ b/tui/composer.go
@@ -4,10 +4,10 @@ import (
"fmt"
"strings"
- "github.com/charmbracelet/bubbles/textarea"
- "github.com/charmbracelet/bubbles/textinput"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "charm.land/bubbles/v2/textarea"
+ "charm.land/bubbles/v2/textinput"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
"github.com/floatpane/matcha/config"
"github.com/google/uuid"
)
@@ -22,7 +22,6 @@ var (
var (
focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
- cursorStyle = focusedStyle.Copy()
noStyle = lipgloss.NewStyle()
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
focusedButton = focusedStyle.Copy().Render("[ Send ]")
@@ -87,41 +86,34 @@ func NewComposer(from, to, subject, body string) *Composer {
}
m.toInput = textinput.New()
- m.toInput.Cursor.Style = cursorStyle
m.toInput.Placeholder = "To"
m.toInput.SetValue(to)
m.toInput.Prompt = "> "
m.toInput.CharLimit = 256
m.ccInput = textinput.New()
- m.ccInput.Cursor.Style = cursorStyle
m.ccInput.Placeholder = "Cc"
m.ccInput.Prompt = "> "
m.ccInput.CharLimit = 256
m.bccInput = textinput.New()
- m.bccInput.Cursor.Style = cursorStyle
m.bccInput.Placeholder = "Bcc"
m.bccInput.Prompt = "> "
m.bccInput.CharLimit = 256
m.subjectInput = textinput.New()
- m.subjectInput.Cursor.Style = cursorStyle
m.subjectInput.Placeholder = "Subject"
m.subjectInput.SetValue(subject)
m.subjectInput.Prompt = "> "
m.subjectInput.CharLimit = 256
m.bodyInput = textarea.New()
- m.bodyInput.Cursor.Style = cursorStyle
m.bodyInput.Placeholder = "Body (Markdown supported)..."
m.bodyInput.SetValue(body)
m.bodyInput.Prompt = "> "
m.bodyInput.SetHeight(10)
- m.bodyInput.SetCursor(0)
m.signatureInput = textarea.New()
- m.signatureInput.Cursor.Style = cursorStyle
m.signatureInput.Placeholder = "Signature (optional)..."
m.signatureInput.Prompt = "> "
m.signatureInput.SetHeight(3)
@@ -153,7 +145,7 @@ func NewComposerWithAccounts(accounts []config.Account, selectedAccountID string
return m
}
-// ResetConfirmation ensures a restored draft isn't stuck in the exit prompt.
+// ResetConfirmation ensures a restored draft isnt stuck in the exit prompt.
func (m *Composer) ResetConfirmation() {
m.confirmingExit = false
}
@@ -189,22 +181,18 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width = msg.Width
m.height = msg.Height
inputWidth := msg.Width - 6
- m.toInput.Width = inputWidth
- m.ccInput.Width = inputWidth
- m.bccInput.Width = inputWidth
- m.subjectInput.Width = inputWidth
+ m.toInput.SetWidth(inputWidth)
+ m.ccInput.SetWidth(inputWidth)
+ m.bccInput.SetWidth(inputWidth)
+ m.subjectInput.SetWidth(inputWidth)
m.bodyInput.SetWidth(inputWidth)
m.signatureInput.SetWidth(inputWidth)
- case SetComposerCursorToStartMsg:
- m.bodyInput.SetCursor(0)
- return m, nil
-
case FileSelectedMsg:
m.attachmentPath = msg.Path
return m, nil
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
// Handle contact suggestions mode
if m.showSuggestions && len(m.suggestions) > 0 {
switch msg.String() {
@@ -236,7 +224,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
// For shift+tab, close suggestions and let it fall through to normal handling
- if msg.Type == tea.KeyShiftTab {
+ if msg.String() == "shift+tab" {
m.showSuggestions = false
m.suggestions = nil
}
@@ -273,15 +261,15 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
- switch msg.Type {
- case tea.KeyCtrlC:
+ switch msg.String() {
+ case "ctrl+c":
return m, tea.Quit
- case tea.KeyEsc:
+ case "esc":
m.confirmingExit = true
return m, nil
- case tea.KeyTab, tea.KeyShiftTab:
- if msg.Type == tea.KeyShiftTab {
+ case "tab", "shift+tab":
+ if msg.String() == "shift+tab" {
m.focusIndex--
} else {
m.focusIndex++
@@ -318,13 +306,12 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, m.subjectInput.Focus())
case focusBody:
cmds = append(cmds, m.bodyInput.Focus())
- cmds = append(cmds, func() tea.Msg { return SetComposerCursorToStartMsg{} })
case focusSignature:
cmds = append(cmds, m.signatureInput.Focus())
}
return m, tea.Batch(cmds...)
- case tea.KeyEnter:
+ case "enter":
switch m.focusIndex {
case focusFrom:
if len(m.accounts) > 1 {
@@ -396,7 +383,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
-func (m *Composer) View() string {
+func (m *Composer) View() tea.View {
var composerView strings.Builder
var button string
@@ -494,7 +481,7 @@ func (m *Composer) View() string {
accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel"))
dialog := DialogBoxStyle.Render(accountList.String())
- return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
+ return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
}
if m.confirmingExit {
@@ -504,10 +491,10 @@ func (m *Composer) View() string {
HelpStyle.Render("\n(y/n)"),
),
)
- return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
+ return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
}
- return composerView.String()
+ return tea.NewView(composerView.String())
}
// SetAccounts sets the available accounts for sending.
diff --git a/tui/composer_test.go b/tui/composer_test.go
index 9a49143..ca3a9b8 100644
--- a/tui/composer_test.go
+++ b/tui/composer_test.go
@@ -3,7 +3,7 @@ package tui
import (
"testing"
- tea "github.com/charmbracelet/bubbletea"
+ tea "charm.land/bubbletea/v2"
"github.com/floatpane/matcha/config"
)
@@ -23,49 +23,49 @@ func TestComposerUpdate(t *testing.T) {
}
// Simulate pressing Tab to move to the 'Cc' field.
- model, _ := composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+ model, _ := composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
composer = model.(*Composer)
if composer.focusIndex != focusCc {
t.Errorf("After one Tab, focusIndex should be %d (focusCc), got %d", focusCc, composer.focusIndex)
}
// Simulate pressing Tab to move to the 'Bcc' field.
- model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+ model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
composer = model.(*Composer)
if composer.focusIndex != focusBcc {
t.Errorf("After two Tabs, focusIndex should be %d (focusBcc), got %d", focusBcc, composer.focusIndex)
}
// Simulate pressing Tab to move to the 'Subject' field.
- model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+ model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
composer = model.(*Composer)
if composer.focusIndex != focusSubject {
t.Errorf("After three Tabs, focusIndex should be %d (focusSubject), got %d", focusSubject, composer.focusIndex)
}
// Simulate pressing Tab again to move to the 'Body' field.
- model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+ model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
composer = model.(*Composer)
if composer.focusIndex != focusBody {
t.Errorf("After four Tabs, focusIndex should be %d (focusBody), got %d", focusBody, composer.focusIndex)
}
// Simulate pressing Tab again to move to the 'Signature' field.
- model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+ model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
composer = model.(*Composer)
if composer.focusIndex != focusSignature {
t.Errorf("After five Tabs, focusIndex should be %d (focusSignature), got %d", focusSignature, composer.focusIndex)
}
// Simulate pressing Tab again to move to the 'Attachment' field.
- model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+ model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
composer = model.(*Composer)
if composer.focusIndex != focusAttachment {
t.Errorf("After six Tabs, focusIndex should be %d (focusAttachment), got %d", focusAttachment, composer.focusIndex)
}
// Simulate pressing Tab again to move to the 'Send' button.
- model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+ model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
composer = model.(*Composer)
if composer.focusIndex != focusSend {
t.Errorf("After seven Tabs, focusIndex should be %d (focusSend), got %d", focusSend, composer.focusIndex)
@@ -73,7 +73,7 @@ func TestComposerUpdate(t *testing.T) {
// Simulate one more Tab to wrap around.
// With single account, From field is skipped, so it wraps to focusTo.
- model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+ model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
composer = model.(*Composer)
if composer.focusIndex != focusTo {
t.Errorf("After eight Tabs, focusIndex should wrap to %d (focusTo) since single account skips From, got %d", focusTo, composer.focusIndex)
@@ -92,7 +92,7 @@ func TestComposerUpdate(t *testing.T) {
composer.focusIndex = focusSend
// Simulate pressing Enter to send the email.
- _, cmd := composer.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ _, cmd := composer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if cmd == nil {
t.Fatal("Expected a command to be returned, but got nil.")
}
@@ -130,7 +130,7 @@ func TestComposerUpdate(t *testing.T) {
multiComposer.focusIndex = focusFrom
// Press Enter to open account picker
- model, _ := multiComposer.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ model, _ := multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
multiComposer = model.(*Composer)
if !multiComposer.showAccountPicker {
@@ -138,7 +138,7 @@ func TestComposerUpdate(t *testing.T) {
}
// Navigate down to select second account
- model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyDown})
+ model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyDown})
multiComposer = model.(*Composer)
if multiComposer.selectedAccountIdx != 1 {
@@ -146,7 +146,7 @@ func TestComposerUpdate(t *testing.T) {
}
// Press Enter to confirm selection
- model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
multiComposer = model.(*Composer)
if multiComposer.showAccountPicker {
@@ -169,7 +169,7 @@ func TestComposerUpdate(t *testing.T) {
singleComposer.focusIndex = focusFrom
// Press Enter - should not open picker with single account
- model, _ := singleComposer.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ model, _ := singleComposer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
singleComposer = model.(*Composer)
if singleComposer.showAccountPicker {
@@ -190,23 +190,23 @@ func TestComposerUpdate(t *testing.T) {
}
// Tab through all fields: To -> Cc -> Bcc -> Subject -> Body -> Signature -> Attachment -> Send -> From (wrap)
- model, _ := multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // To -> Cc
+ model, _ := multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // To -> Cc
multiComposer = model.(*Composer)
- model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Cc -> Bcc
+ model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Cc -> Bcc
multiComposer = model.(*Composer)
- model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Bcc -> Subject
+ model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Bcc -> Subject
multiComposer = model.(*Composer)
- model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Subject -> Body
+ model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Subject -> Body
multiComposer = model.(*Composer)
- model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Body -> Signature
+ model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Body -> Signature
multiComposer = model.(*Composer)
- model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Signature -> Attachment
+ model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Signature -> Attachment
multiComposer = model.(*Composer)
- model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Attachment -> Send
+ model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Attachment -> Send
multiComposer = model.(*Composer)
- model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Send -> From (wrap)
+ model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Send -> From (wrap)
multiComposer = model.(*Composer)
- model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // From -> To (wrap)
+ model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // From -> To (wrap)
multiComposer = model.(*Composer)
// With multiple accounts, From field should be included in tab order
@@ -257,9 +257,9 @@ func TestComposerGetFromAddress(t *testing.T) {
// TestComposerSetSelectedAccount verifies account selection.
func TestComposerSetSelectedAccount(t *testing.T) {
accounts := []config.Account{
- {ID: "account-1", Email: "test1@example.com"},
- {ID: "account-2", Email: "test2@example.com"},
- {ID: "account-3", Email: "test3@example.com"},
+ {ID: "account-1", FetchEmail: "test1@example.com"},
+ {ID: "account-2", FetchEmail: "test2@example.com"},
+ {ID: "account-3", FetchEmail: "test3@example.com"},
}
composer := NewComposerWithAccounts(accounts, "account-1", "", "", "")
diff --git a/tui/drafts.go b/tui/drafts.go
index b401112..afdd282 100644
--- a/tui/drafts.go
+++ b/tui/drafts.go
@@ -5,10 +5,10 @@ import (
"strings"
"time"
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/list"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "charm.land/bubbles/v2/key"
+ "charm.land/bubbles/v2/list"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
"github.com/floatpane/matcha/config"
)
@@ -116,7 +116,7 @@ func (m *Drafts) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.list.SetHeight(msg.Height - 4)
return m, nil
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
// Handle delete confirmation
if m.confirmDelete {
switch msg.String() {
@@ -183,7 +183,7 @@ func (m *Drafts) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
}
-func (m *Drafts) View() string {
+func (m *Drafts) View() tea.View {
var b strings.Builder
if m.confirmDelete {
@@ -193,18 +193,19 @@ func (m *Drafts) View() string {
HelpStyle.Render("\n(y/n)"),
),
)
- return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
+ return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
}
if len(m.drafts) == 0 {
emptyMsg := lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Render("No drafts saved.\n\nPress esc to go back.")
- return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, emptyMsg)
+ return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, emptyMsg))
}
+ // list.View() still returns string in v2
b.WriteString(m.list.View())
- return b.String()
+ return tea.NewView(b.String())
}
// SetDrafts updates the drafts list
diff --git a/tui/email_view.go b/tui/email_view.go
index 2da090e..5ed92fc 100644
--- a/tui/email_view.go
+++ b/tui/email_view.go
@@ -6,9 +6,9 @@ import (
"os"
"strings"
- "github.com/charmbracelet/bubbles/viewport"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "charm.land/bubbles/v2/viewport"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
"github.com/floatpane/matcha/fetcher"
"github.com/floatpane/matcha/view"
)
@@ -59,8 +59,10 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma
}
// Build viewport with initial size and set wrapped content.
- vp := viewport.New(width, height-headerHeight-attachmentHeight)
- wrapped := wrapBodyToWidth(body, vp.Width)
+ vp := viewport.New()
+ vp.SetWidth(width)
+ vp.SetHeight(height - headerHeight - attachmentHeight)
+ wrapped := wrapBodyToWidth(body, vp.Width())
vp.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
return &EmailView{
@@ -83,9 +85,9 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
// Handle 'esc' key locally
- if msg.Type == tea.KeyEsc {
+ if msg.String() == "esc" {
if m.focusOnAttachments {
m.focusOnAttachments = false
return m, nil
@@ -136,7 +138,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if err != nil {
body = fmt.Sprintf("Error rendering body: %v", err)
}
- wrapped := wrapBodyToWidth(body, m.viewport.Width)
+ wrapped := wrapBodyToWidth(body, m.viewport.Width())
m.viewport.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
return m, nil
}
@@ -178,8 +180,8 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
attachmentHeight = len(m.email.Attachments) + 2
}
// Update viewport dimensions
- m.viewport.Width = msg.Width
- m.viewport.Height = msg.Height - headerHeight - attachmentHeight
+ m.viewport.SetWidth(msg.Width)
+ m.viewport.SetHeight(msg.Height - headerHeight - attachmentHeight)
// When the window size changes, wrap and clear kitty images to keep placement stable
inlineImages := inlineImagesFromAttachments(m.email.Attachments)
@@ -187,7 +189,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if err != nil {
body = fmt.Sprintf("Error rendering body: %v", err)
}
- wrapped := wrapBodyToWidth(body, m.viewport.Width)
+ wrapped := wrapBodyToWidth(body, m.viewport.Width())
m.viewport.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
}
@@ -197,14 +199,14 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
-func (m *EmailView) View() string {
+func (m *EmailView) View() tea.View {
// Clear all Kitty graphics before rendering to prevent image stacking on scroll.
// This must be done synchronously via stdout before the frame is drawn,
// as escape sequences in the return string execute too late.
clearKittyGraphics()
header := fmt.Sprintf("From: %s | Subject: %s", m.email.From, m.email.Subject)
- styledHeader := emailHeaderStyle.Width(m.viewport.Width).Render(header)
+ styledHeader := emailHeaderStyle.Width(m.viewport.Width()).Render(header)
var help string
if m.focusOnAttachments {
@@ -234,7 +236,8 @@ func (m *EmailView) View() string {
attachmentView = attachmentBoxStyle.Render(b.String())
}
- return fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help)
+ // m.viewport.View() returns a string in Bubbles v2 viewport
+ return tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help))
}
// GetAccountID returns the account ID for this email
diff --git a/tui/email_view_test.go b/tui/email_view_test.go
index 3af1a26..80233a2 100644
--- a/tui/email_view_test.go
+++ b/tui/email_view_test.go
@@ -4,7 +4,7 @@ import (
"testing"
"time"
- tea "github.com/charmbracelet/bubbletea"
+ tea "charm.land/bubbletea/v2"
"github.com/floatpane/matcha/fetcher"
)
@@ -34,7 +34,7 @@ func TestEmailViewUpdate(t *testing.T) {
}
// Tab to focus on attachments
- model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab})
+ model, _ := emailView.Update(tea.KeyPressMsg{Code: tea.KeyTab})
emailView = model.(*EmailView)
if !emailView.focusOnAttachments {
@@ -42,7 +42,7 @@ func TestEmailViewUpdate(t *testing.T) {
}
// Tab back to body
- model, _ = emailView.Update(tea.KeyMsg{Type: tea.KeyTab})
+ model, _ = emailView.Update(tea.KeyPressMsg{Code: tea.KeyTab})
emailView = model.(*EmailView)
if emailView.focusOnAttachments {
t.Error("focusOnAttachments should be false after tabbing again")
@@ -55,7 +55,7 @@ func TestEmailViewUpdate(t *testing.T) {
t.Error("focusOnAttachments should be initially false")
}
// Tab
- model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab})
+ model, _ := emailView.Update(tea.KeyPressMsg{Code: tea.KeyTab})
emailView = model.(*EmailView)
if emailView.focusOnAttachments {
t.Error("focusOnAttachments should remain false when there are no attachments")
@@ -65,7 +65,7 @@ func TestEmailViewUpdate(t *testing.T) {
t.Run("Navigate attachments", func(t *testing.T) {
emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox, false)
// Focus on attachments
- model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab})
+ model, _ := emailView.Update(tea.KeyPressMsg{Code: tea.KeyTab})
emailView = model.(*EmailView)
if emailView.attachmentCursor != 0 {
@@ -73,21 +73,21 @@ func TestEmailViewUpdate(t *testing.T) {
}
// Move down
- model, _ = emailView.Update(tea.KeyMsg{Type: tea.KeyDown})
+ model, _ = emailView.Update(tea.KeyPressMsg{Code: tea.KeyDown})
emailView = model.(*EmailView)
if emailView.attachmentCursor != 1 {
t.Errorf("After one down arrow, attachmentCursor should be 1, got %d", emailView.attachmentCursor)
}
// Move down again (should not go past the end)
- model, _ = emailView.Update(tea.KeyMsg{Type: tea.KeyDown})
+ model, _ = emailView.Update(tea.KeyPressMsg{Code: tea.KeyDown})
emailView = model.(*EmailView)
if emailView.attachmentCursor != 1 {
t.Errorf("attachmentCursor should not go past the end of the list, got %d", emailView.attachmentCursor)
}
// Move up
- model, _ = emailView.Update(tea.KeyMsg{Type: tea.KeyUp})
+ model, _ = emailView.Update(tea.KeyPressMsg{Code: tea.KeyUp})
emailView = model.(*EmailView)
if emailView.attachmentCursor != 0 {
t.Errorf("After one up arrow, attachmentCursor should be 0, got %d", emailView.attachmentCursor)
@@ -97,15 +97,15 @@ func TestEmailViewUpdate(t *testing.T) {
t.Run("Download attachment", func(t *testing.T) {
emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox, false)
// Focus on attachments
- model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab})
+ model, _ := emailView.Update(tea.KeyPressMsg{Code: tea.KeyTab})
emailView = model.(*EmailView)
// Move to the second attachment
- model, _ = emailView.Update(tea.KeyMsg{Type: tea.KeyDown})
+ model, _ = emailView.Update(tea.KeyPressMsg{Code: tea.KeyDown})
emailView = model.(*EmailView)
// Press enter
- _, cmd := emailView.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ _, cmd := emailView.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if cmd == nil {
t.Fatal("Expected a command, but got nil")
}
@@ -126,7 +126,7 @@ func TestEmailViewUpdate(t *testing.T) {
t.Run("Reply to email", func(t *testing.T) {
emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox, false)
- _, cmd := emailView.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")})
+ _, cmd := emailView.Update(tea.KeyPressMsg{Code: 'r', Text: "r"})
if cmd == nil {
t.Fatal("Expected a command, but got nil")
}
diff --git a/tui/filepicker.go b/tui/filepicker.go
index 115cbd2..9aa2a33 100644
--- a/tui/filepicker.go
+++ b/tui/filepicker.go
@@ -7,8 +7,8 @@ import (
"path/filepath"
"strings"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
)
var (
@@ -52,7 +52,7 @@ func (m *FilePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width = msg.Width
m.height = msg.Height
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
switch msg.String() {
case "up", "k":
if m.cursor > 0 {
@@ -92,7 +92,7 @@ func (m *FilePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
-func (m *FilePicker) View() string {
+func (m *FilePicker) View() tea.View {
var b strings.Builder
b.WriteString(titleStyle.Render("Select a File") + "\n")
@@ -121,5 +121,5 @@ func (m *FilePicker) View() string {
b.WriteString("\n" + helpStyle.Render("↑/↓: navigate • enter: select • backspace: up • esc: cancel"))
- return docStyle.Render(b.String())
+ return tea.NewView(docStyle.Render(b.String()))
}
diff --git a/tui/inbox.go b/tui/inbox.go
index b6c98db..1f1643b 100644
--- a/tui/inbox.go
+++ b/tui/inbox.go
@@ -5,17 +5,18 @@ import (
"io"
"strings"
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/list"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "charm.land/bubbles/v2/key"
+ "charm.land/bubbles/v2/list"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
"github.com/floatpane/matcha/config"
"github.com/floatpane/matcha/fetcher"
)
var (
- paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4)
- inboxHelpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1)
+ // In bubbles v2, list.DefaultStyles() takes a boolean for hasDarkBackground
+ paginationStyle = list.DefaultStyles(true).PaginationStyle.PaddingLeft(4)
+ inboxHelpStyle = list.DefaultStyles(true).HelpStyle.PaddingLeft(4).PaddingBottom(1)
tabStyle = lipgloss.NewStyle().Padding(0, 2)
activeTabStyle = lipgloss.NewStyle().Padding(0, 2).Foreground(lipgloss.Color("42")).Bold(true).Underline(true)
tabBarStyle = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).PaddingBottom(1).MarginBottom(1)
@@ -299,7 +300,7 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
if m.list.FilterState() == list.Filtering {
break
}
@@ -490,7 +491,7 @@ func (m *Inbox) fetchMoreCmds() []tea.Cmd {
return cmds
}
-func (m *Inbox) View() string {
+func (m *Inbox) View() tea.View {
var b strings.Builder
// Render tabs if there are multiple accounts
@@ -564,7 +565,7 @@ func (m *Inbox) View() string {
b.WriteString(helpView)
- return b.String()
+ return tea.NewView(b.String())
}
// GetCurrentAccountID returns the currently selected account ID
diff --git a/tui/inbox_test.go b/tui/inbox_test.go
index 84881a5..a24c40f 100644
--- a/tui/inbox_test.go
+++ b/tui/inbox_test.go
@@ -4,7 +4,7 @@ import (
"testing"
"time"
- tea "github.com/charmbracelet/bubbletea"
+ tea "charm.land/bubbletea/v2"
"github.com/floatpane/matcha/config"
"github.com/floatpane/matcha/fetcher"
)
@@ -14,21 +14,21 @@ func collectMsgs(cmd tea.Cmd) []tea.Msg {
return nil
}
msg := cmd()
- switch batch := msg.(type) {
- case tea.BatchMsg:
+ if msg == nil {
+ return nil
+ }
+
+ // Try type assertion to see if it's a BatchMsg
+ if batch, ok := msg.(tea.BatchMsg); ok {
var msgs []tea.Msg
for _, m := range batch {
- if m != nil {
- msgs = append(msgs, m)
- }
+ msgs = append(msgs, collectMsgs(m)...)
}
return msgs
- default:
- if msg != nil {
- return []tea.Msg{msg}
- }
}
- return nil
+
+ // Otherwise it's a regular message
+ return []tea.Msg{msg}
}
// TestInboxUpdate verifies the state transitions in the inbox view.
@@ -51,10 +51,10 @@ func TestInboxUpdate(t *testing.T) {
t.Run("Select email to view", func(t *testing.T) {
// By default, the first item is selected (index 0).
// Move down to the second item (index 1).
- inbox.list, _ = inbox.list.Update(tea.KeyMsg{Type: tea.KeyDown})
+ inbox.list, _ = inbox.list.Update(tea.KeyPressMsg{Code: tea.KeyDown})
// Simulate pressing Enter to view the selected email.
- _, cmd := inbox.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ _, cmd := inbox.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if cmd == nil {
t.Fatal("Expected a command, but got nil.")
}
@@ -157,7 +157,7 @@ func TestInboxDeleteEmailMsg(t *testing.T) {
inbox := NewInbox(emails, accounts)
// Simulate pressing 'd' to delete
- _, cmd := inbox.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}})
+ _, cmd := inbox.Update(tea.KeyPressMsg{Code: 'd', Text: "d"})
if cmd == nil {
t.Fatal("Expected a command, but got nil.")
}
@@ -190,7 +190,7 @@ func TestInboxArchiveEmailMsg(t *testing.T) {
inbox := NewInbox(emails, accounts)
// Simulate pressing 'a' to archive
- _, cmd := inbox.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}})
+ _, cmd := inbox.Update(tea.KeyPressMsg{Code: 'a', Text: "a"})
if cmd == nil {
t.Fatal("Expected a command, but got nil.")
}
@@ -283,7 +283,7 @@ func TestFetchMoreTriggeredAtListEnd(t *testing.T) {
inbox := NewInbox(emails, accounts)
- _, cmd := inbox.Update(tea.KeyMsg{Type: tea.KeyDown})
+ _, cmd := inbox.Update(tea.KeyPressMsg{Code: tea.KeyDown})
msgs := collectMsgs(cmd)
var fetchMsg FetchMoreEmailsMsg
diff --git a/tui/login.go b/tui/login.go
index 348097d..d6d85d1 100644
--- a/tui/login.go
+++ b/tui/login.go
@@ -3,9 +3,9 @@ package tui
import (
"strconv"
- "github.com/charmbracelet/bubbles/textinput"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "charm.land/bubbles/v2/textinput"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
)
// Login holds the state for the login/add account form.
@@ -41,7 +41,6 @@ func NewLogin() *Login {
var t textinput.Model
for i := range m.inputs {
t = textinput.New()
- t.Cursor.Style = focusedStyle
t.CharLimit = 128
switch i {
@@ -93,15 +92,15 @@ func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width = msg.Width
m.height = msg.Height
for i := range m.inputs {
- m.inputs[i].Width = msg.Width - 6
+ m.inputs[i].SetWidth(msg.Width - 6)
}
- case tea.KeyMsg:
- switch msg.Type {
- case tea.KeyEsc:
+ case tea.KeyPressMsg:
+ switch msg.String() {
+ case "esc":
return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
- case tea.KeyEnter:
+ case "enter":
// Check if provider is "custom" to show/hide custom fields
provider := m.inputs[inputProvider].Value()
if provider == "custom" {
@@ -146,7 +145,7 @@ func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
fallthrough
- case tea.KeyTab, tea.KeyShiftTab, tea.KeyUp, tea.KeyDown:
+ case "tab", "shift+tab", "up", "down":
s := msg.String()
// Check provider to update showCustom
@@ -205,7 +204,7 @@ func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// View renders the login form.
-func (m *Login) View() string {
+func (m *Login) View() tea.View {
title := "Add Account"
if m.isEditMode {
title = "Edit Account"
@@ -240,7 +239,7 @@ func (m *Login) View() string {
views = append(views, helpStyle.Render("\nenter: save • tab: next field • esc: back to menu"))
- return lipgloss.JoinVertical(lipgloss.Left, views...)
+ return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, views...))
}
// SetEditMode sets the login form to edit an existing account.
diff --git a/tui/settings.go b/tui/settings.go
index 7df506a..c363d1b 100644
--- a/tui/settings.go
+++ b/tui/settings.go
@@ -4,8 +4,8 @@ import (
"fmt"
"strings"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
"github.com/floatpane/matcha/config"
)
@@ -58,7 +58,7 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height
return m, nil
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
if m.state == SettingsMain {
return m.updateMain(msg)
} else {
@@ -68,7 +68,7 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
-func (m *Settings) updateMain(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+func (m *Settings) updateMain(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "up", "k":
if m.cursor > 0 {
@@ -99,7 +99,7 @@ func (m *Settings) updateMain(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
-func (m *Settings) updateAccounts(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if m.confirmingDelete {
switch msg.String() {
case "y", "Y":
@@ -146,11 +146,11 @@ func (m *Settings) updateAccounts(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
// View renders the settings screen.
-func (m *Settings) View() string {
+func (m *Settings) View() tea.View {
if m.state == SettingsMain {
- return m.viewMain()
+ return tea.NewView(m.viewMain())
}
- return m.viewAccounts()
+ return tea.NewView(m.viewAccounts())
}
func (m *Settings) viewMain() string {
diff --git a/tui/signature.go b/tui/signature.go
index f9da04b..c434716 100644
--- a/tui/signature.go
+++ b/tui/signature.go
@@ -1,9 +1,9 @@
package tui
import (
- "github.com/charmbracelet/bubbles/textarea"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "charm.land/bubbles/v2/textarea"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
"github.com/floatpane/matcha/config"
)
@@ -48,11 +48,11 @@ func (m *SignatureEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.textarea.SetHeight(msg.Height - 10)
return m, nil
- case tea.KeyMsg:
- switch msg.Type {
- case tea.KeyCtrlC:
+ case tea.KeyPressMsg:
+ switch msg.String() {
+ case "ctrl+c":
return m, tea.Quit
- case tea.KeyEsc:
+ case "esc":
// Save and go back to settings
signature := m.textarea.Value()
go config.SaveSignature(signature)
@@ -65,16 +65,16 @@ func (m *SignatureEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// View renders the signature editor screen.
-func (m *SignatureEditor) View() string {
+func (m *SignatureEditor) View() tea.View {
title := titleStyle.Render("Email Signature")
hint := accountEmailStyle.Render("This signature will be appended to your emails.")
- return lipgloss.JoinVertical(lipgloss.Left,
+ return tea.NewView(lipgloss.JoinVertical(lipgloss.Left,
title,
hint,
"",
m.textarea.View(),
"",
helpStyle.Render("esc: save & back"),
- )
+ ))
}
diff --git a/tui/styles.go b/tui/styles.go
index 6a82cee..c987d31 100644
--- a/tui/styles.go
+++ b/tui/styles.go
@@ -3,9 +3,9 @@ package tui
import (
"fmt"
- "github.com/charmbracelet/bubbles/spinner"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "charm.land/bubbles/v2/spinner"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
)
// ASCII logo for Matcha displayed during loading screens
@@ -68,11 +68,11 @@ func (m Status) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
}
-func (m Status) View() string {
+func (m Status) View() tea.View {
logoStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
styledLogo := logoStyle.Render(asciiLogo)
spinnerLine := fmt.Sprintf(" %s %s", m.spinner.View(), m.message)
- return fmt.Sprintf("%s\n%s\n\n", styledLogo, spinnerLine)
+ return tea.NewView(fmt.Sprintf("%s\n%s\n\n", styledLogo, spinnerLine))
}
diff --git a/tui/trash_archive.go b/tui/trash_archive.go
index 0f1cd9c..00a225a 100644
--- a/tui/trash_archive.go
+++ b/tui/trash_archive.go
@@ -3,9 +3,9 @@ package tui
import (
"strings"
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "charm.land/bubbles/v2/key"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
"github.com/floatpane/matcha/config"
"github.com/floatpane/matcha/fetcher"
)
@@ -42,7 +42,7 @@ func (m *TrashArchive) Init() tea.Cmd {
func (m *TrashArchive) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
switch msg.String() {
case "tab":
// Toggle between trash and archive
@@ -81,7 +81,7 @@ func (m *TrashArchive) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
}
-func (m *TrashArchive) View() string {
+func (m *TrashArchive) View() tea.View {
var b strings.Builder
// Render the mailbox toggle tabs
@@ -104,12 +104,12 @@ func (m *TrashArchive) View() string {
// Render the active inbox
if m.activeView == MailboxTrash {
- b.WriteString(m.trashInbox.View())
+ b.WriteString(m.trashInbox.View().Content)
} else {
- b.WriteString(m.archiveInbox.View())
+ b.WriteString(m.archiveInbox.View().Content)
}
- return b.String()
+ return tea.NewView(b.String())
}
// GetActiveMailbox returns the currently active mailbox kind
diff --git a/tui/trash_archive_test.go b/tui/trash_archive_test.go
index 79f6d08..bd1cda9 100644
--- a/tui/trash_archive_test.go
+++ b/tui/trash_archive_test.go
@@ -4,7 +4,7 @@ import (
"testing"
"time"
- tea "github.com/charmbracelet/bubbletea"
+ tea "charm.land/bubbletea/v2"
"github.com/floatpane/matcha/config"
"github.com/floatpane/matcha/fetcher"
)
@@ -63,7 +63,7 @@ func TestTrashArchiveToggle(t *testing.T) {
}
// Press tab to switch to Archive
- ta.Update(tea.KeyMsg{Type: tea.KeyTab})
+ ta.Update(tea.KeyPressMsg{Code: tea.KeyTab})
if ta.activeView != MailboxArchive {
t.Errorf("Expected view to be MailboxArchive after tab, got %v", ta.activeView)
@@ -74,7 +74,7 @@ func TestTrashArchiveToggle(t *testing.T) {
}
// Press tab again to switch back to Trash
- ta.Update(tea.KeyMsg{Type: tea.KeyTab})
+ ta.Update(tea.KeyPressMsg{Code: tea.KeyTab})
if ta.activeView != MailboxTrash {
t.Errorf("Expected view to be MailboxTrash after second tab, got %v", ta.activeView)
@@ -180,7 +180,7 @@ func TestTrashArchiveViewEmailMsg(t *testing.T) {
ta := NewTrashArchive(trashEmails, nil, accounts)
// Simulate pressing Enter on trash view
- _, cmd := ta.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ _, cmd := ta.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if cmd == nil {
t.Fatal("Expected a command, but got nil")
}
@@ -213,10 +213,10 @@ func TestTrashArchiveDeleteEmailMsg(t *testing.T) {
ta := NewTrashArchive(nil, archiveEmails, accounts)
// Switch to archive view
- ta.Update(tea.KeyMsg{Type: tea.KeyTab})
+ ta.Update(tea.KeyPressMsg{Code: tea.KeyTab})
// Simulate pressing 'd' to delete
- _, cmd := ta.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}})
+ _, cmd := ta.Update(tea.KeyPressMsg{Code: 'd', Text: "d"})
if cmd == nil {
t.Fatal("Expected a command, but got nil")
}
diff --git a/view/html.go b/view/html.go
index 6512849..ae25e63 100644
--- a/view/html.go
+++ b/view/html.go
@@ -17,8 +17,8 @@ import (
_ "image/gif"
_ "image/jpeg"
+ "charm.land/lipgloss/v2"
"github.com/PuerkitoBio/goquery"
- "github.com/charmbracelet/lipgloss"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/renderer/html"
"golang.org/x/sys/unix"
diff --git a/view/html_test.go b/view/html_test.go
index 6f78586..36c0a90 100644
--- a/view/html_test.go
+++ b/view/html_test.go
@@ -2,10 +2,11 @@ package view
import (
"os"
+ "regexp"
"strings"
"testing"
- "github.com/charmbracelet/lipgloss"
+ "charm.land/lipgloss/v2"
)
// clearAllTerminalEnv clears all environment variables that could indicate terminal capabilities
@@ -478,7 +479,7 @@ func TestProcessBodyWithHyperlinkSupport(t *testing.T) {
},
input: `Click here`,
expectedContains: "Click here",
- expectedNotContains: "<http://example.com>",
+ expectedNotContains: "",
},
{
name: "Link without hyperlink support",
@@ -497,7 +498,7 @@ func TestProcessBodyWithHyperlinkSupport(t *testing.T) {
},
input: `
`,
expectedContains: "[Click here to view image: alt text]",
- expectedNotContains: "<http://example.com/img.png>",
+ expectedNotContains: "",
},
{
name: "Image link without hyperlink support",
@@ -509,6 +510,9 @@ func TestProcessBodyWithHyperlinkSupport(t *testing.T) {
},
}
+ // Regex to strip out ANSI SGR escape codes (e.g. \x1b[38;2;...m)
+ ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
+
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tc.setupHyperlinks()
@@ -518,12 +522,14 @@ func TestProcessBodyWithHyperlinkSupport(t *testing.T) {
t.Fatalf("ProcessBody() failed: %v", err)
}
- if !strings.Contains(processed, tc.expectedContains) {
- t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", processed, tc.expectedContains)
+ cleanProcessed := ansiEscapeRegex.ReplaceAllString(processed, "")
+
+ if !strings.Contains(cleanProcessed, tc.expectedContains) {
+ t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", cleanProcessed, tc.expectedContains)
}
- if tc.expectedNotContains != "" && strings.Contains(processed, tc.expectedNotContains) {
- t.Errorf("Processed body contains unexpected text.\nGot: %q\nShould not contain: %q", processed, tc.expectedNotContains)
+ if tc.expectedNotContains != "" && strings.Contains(cleanProcessed, tc.expectedNotContains) {
+ t.Errorf("Processed body contains unexpected text.\nGot: %q\nShould not contain: %q", cleanProcessed, tc.expectedNotContains)
}
})
}
@@ -630,6 +636,8 @@ func TestProcessBodyWithImageProtocol(t *testing.T) {
},
}
+ ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
+
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tc.clearAllImageEnv()
@@ -640,12 +648,14 @@ func TestProcessBodyWithImageProtocol(t *testing.T) {
t.Fatalf("ProcessBody() failed: %v", err)
}
- if !strings.Contains(processed, tc.expectedContains) {
- t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", processed, tc.expectedContains)
+ cleanProcessed := ansiEscapeRegex.ReplaceAllString(processed, "")
+
+ if !strings.Contains(cleanProcessed, tc.expectedContains) {
+ t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", cleanProcessed, tc.expectedContains)
}
- if tc.expectedNotContains != "" && strings.Contains(processed, tc.expectedNotContains) {
- t.Errorf("Processed body contains unexpected text.\nGot: %q\nShould not contain: %q", processed, tc.expectedNotContains)
+ if tc.expectedNotContains != "" && strings.Contains(cleanProcessed, tc.expectedNotContains) {
+ t.Errorf("Processed body contains unexpected text.\nGot: %q\nShould not contain: %q", cleanProcessed, tc.expectedNotContains)
}
})
}
@@ -683,15 +693,19 @@ func TestProcessBody(t *testing.T) {
},
}
+ ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
+
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
if err != nil {
t.Fatalf("ProcessBody() failed: %v", err)
}
- // Use Contains because styles add ANSI codes
- if !strings.Contains(processed, tc.expected) {
- t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", processed, tc.expected)
+
+ cleanProcessed := ansiEscapeRegex.ReplaceAllString(processed, "")
+
+ if !strings.Contains(cleanProcessed, tc.expected) {
+ t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", cleanProcessed, tc.expected)
}
})
}