diff --git a/go.mod b/go.mod index 6b64ec4..abfe358 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,15 @@ module hoot go 1.24.0 require ( + github.com/charmbracelet/bubbles v0.18.0 + github.com/charmbracelet/bubbletea v0.25.0 + github.com/charmbracelet/lipgloss v0.9.1 + github.com/mattn/go-runewidth v0.0.15 + github.com/mdp/qrterminal/v3 v3.2.1 github.com/nbd-wtf/go-nostr v0.50.0 golang.org/x/crypto v0.33.0 golang.org/x/term v0.29.0 + modernc.org/sqlite v1.45.0 ) require ( @@ -14,9 +20,6 @@ require ( github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect github.com/btcsuite/btcd/btcutil v1.1.5 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect - github.com/charmbracelet/bubbles v0.18.0 // indirect - github.com/charmbracelet/bubbletea v0.25.0 // indirect - github.com/charmbracelet/lipgloss v0.9.1 // indirect github.com/coder/websocket v1.8.12 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect @@ -29,8 +32,6 @@ require ( github.com/mailru/easyjson v0.7.7 // 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.15 // indirect - github.com/mdp/qrterminal/v3 v3.2.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect @@ -51,6 +52,5 @@ require ( modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.45.0 // indirect rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index 13f0ec2..752b779 100644 --- a/go.sum +++ b/go.sum @@ -64,9 +64,13 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= @@ -85,8 +89,6 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D 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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= -github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= @@ -124,7 +126,6 @@ github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPK github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg= github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -146,10 +147,10 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0= -golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -158,8 +159,6 @@ golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -173,8 +172,6 @@ golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= @@ -185,6 +182,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= @@ -202,13 +201,33 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs= modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/tui/tui.go b/tui/tui.go index cd3a8df..f3cb09f 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -70,6 +71,7 @@ type Model struct { screen Screen cursor int textInput textinput.Model + spinner spinner.Model message string messageStyle lipgloss.Style @@ -149,9 +151,15 @@ func NewModel() Model { ti.CharLimit = 256 ti.Width = 60 + // Spinner used while waiting on QR init / remote-signer connection. + // The Line frame set is plain ASCII so the indicator renders even on + // terminals without Unicode support. + sp := spinner.New(spinner.WithSpinner(spinner.Line)) + return Model{ screen: ScreenLogin, textInput: ti, + spinner: sp, } } @@ -181,12 +189,17 @@ func (m *Model) SetCallbacks( } func (m Model) Init() tea.Cmd { - return textinput.Blink + return tea.Batch(textinput.Blink, m.spinner.Tick) } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd + // Spinner animation tick. The spinner.Model owns its own state and + // returns the next TickMsg as a tea.Cmd that we batch below. + var spinnerCmd tea.Cmd + m.spinner, spinnerCmd = m.spinner.Update(msg) + // Check if we need to regenerate the QR code if m.qrNeedsRegeneration && m.qrData != "" { m.regenerateQR() @@ -306,6 +319,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.qrData = msg.uri m.qrNeedsRegeneration = true m.qrReady = true + // Clear the "Generating QR code..." banner left over from + // handleLoginEnter — the QR is now visible, so showing that + // text below it is contradictory and read by users as "stuck". + m.message = "" + m.messageStyle = lipgloss.Style{} // Start checking for connection in background return m, m.checkQRConnection } @@ -355,6 +373,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + // Batch the spinner tick with whatever other cmd the input produced + // so the spinner keeps animating across all Update paths. + if spinnerCmd != nil { + cmd = tea.Batch(cmd, spinnerCmd) + } + return m, cmd } @@ -912,13 +936,24 @@ func (m Model) viewQRLogin() string { b.WriteString("\n\n") if m.qrRendered != "" { + // QR is rendered. Don't append the global m.message here — it + // could be the leftover "Generating QR code..." string from + // handleLoginEnter, which was already cleared in the + // qrGeneratedMsg handler. Showing it below the QR would + // contradict the QR that's on-screen. b.WriteString(m.qrRendered) b.WriteString("\n\n") b.WriteString(menuStyle.Render("Waiting for connection... scan this with your Amber app.")) } else if m.qrData != "" { - b.WriteString(menuStyle.Render("Generating QR code...")) + // QR data is known but regenerateQR() hasn't produced output yet + // (e.g. WindowSizeMsg hasn't arrived or terminal is too small). + // Show a live spinner instead of static text — the prior + // behavior read as "stuck" because nothing changed. + b.WriteString(m.spinner.View() + " Generating QR code...") } else { - b.WriteString(menuStyle.Render("Generating connection...")) + // No URI yet. Also show a spinner — the previous static text + // was indistinguishable from a hung program. + b.WriteString(m.spinner.View() + " Generating connection...") } content := b.String() diff --git a/tui/tui_qr_e2e_test.go b/tui/tui_qr_e2e_test.go new file mode 100644 index 0000000..200125e --- /dev/null +++ b/tui/tui_qr_e2e_test.go @@ -0,0 +1,139 @@ +package tui + +import ( + "os" + "strings" + "testing" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" +) + +// TestQRFlowsThroughLoginMenuEndToEnd drives the full path a real user +// would take: at the login menu, hit Enter on the QR option, wait for +// the QR, and verify the screen transitions from "Generating..." to +// the actual QR. This catches any drift between the in-package tests +// (which poke internals) and what the user actually sees. +func TestQRFlowsThroughLoginMenuEndToEnd(t *testing.T) { + const fakeURI = "nostrconnect://20366413937be6e239d53dd36a98028404fb20e2af300858ba0e31ab7ba9a97c?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22hoot%22%7D" + + m := NewModel() + m.SetCallbacks( + func() bool { return false }, // hasKey=false → see "no profiles" menu + func(string) (string, string, error) { return "", "", nil }, + func() error { return nil }, + func(string, string, bool) (string, error) { return "", nil }, + func(string) error { return nil }, + func() ([]FeedPost, error) { return nil, nil }, + func() (string, error) { + // Simulate slow network: sleep would block the goroutine, so + // we just resolve immediately — the test is about flow not latency. + return fakeURI, nil + }, + func() (string, error) { return "", nil }, + func() ([]string, error) { return nil, nil }, + func([]string) error { return nil }, + ) + + // Establish screen size first. + const W, H = 80, 24 + upd, _ := m.Update(tea.WindowSizeMsg{Width: W, Height: H}) + m = upd.(Model) + + // At the login screen with no profiles, the menu has cursor 0=Create, + // cursor 1=Scan QR. Move cursor to 1 and press Enter. + m.cursor = 1 + upd, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = upd.(Model) + if cmd == nil { + t.Fatalf("Expected initQR cmd to fire after picking QR menu; got nil") + } + + // The cmd, when run, returns qrGeneratedMsg — execute it manually. + msg := cmd() + if msg == nil { + t.Fatalf("initQR cmd returned nil") + } + if _, ok := msg.(qrGeneratedMsg); !ok { + t.Fatalf("Expected qrGeneratedMsg, got %T: %v", msg, msg) + } + upd, _ = m.Update(msg) + m = upd.(Model) + + // Drain a few generic Update ticks so regenerateQR has a chance. + for i := 0; i < 5; i++ { + upd, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{}}) + m = upd.(Model) + } + + // After the flow above the screen should be ScreenQRLogin and qrReady. + if m.screen != ScreenQRLogin { + t.Fatalf("screen did not transition to ScreenQRLogin: got %v", m.screen) + } + if !m.qrReady { + t.Fatalf("qrReady never flipped to true") + } + if m.qrData == "" { + t.Fatalf("qrData was empty after init") + } + + // Now: what's actually rendered? + view := m.View() + if strings.Contains(view, "Generating") { + // Write the view to a temp file for diagnosis. + _ = os.WriteFile("/tmp/hoot-qr-view.txt", []byte(view), 0644) + t.Fatalf("View still shows a Generating... placeholder after init; expected QR to be visible. Wrote /tmp/hoot-qr-view.txt") + } + if m.qrRendered == "" { + t.Fatalf("qrRendered empty after init; expected qrterminal output to be present") + } + t.Logf("View at 80x24:\n%s", view) + + // Pre-condition regression: while waiting on initQR (BEFORE + // qrGeneratedMsg arrives), the loading state must include a spinner + // frame so the user sees activity instead of static text. After the + // QR arrives, the loading text must NOT still be present. + t.Run("loading state shows spinner", func(t *testing.T) { + m2 := NewModel() + m2.SetCallbacks( + func() bool { return false }, + func(string) (string, string, error) { return "", "", nil }, + func() error { return nil }, + func(string, string, bool) (string, error) { return "", nil }, + func(string) error { return nil }, + func() ([]FeedPost, error) { return nil, nil }, + func() (string, error) { return fakeURI, nil }, + func() (string, error) { return "", nil }, + func() ([]string, error) { return nil, nil }, + func([]string) error { return nil }, + ) + m2.screen = ScreenQRLogin + // No qrData yet → loading branch. + m2.width = 80 + m2.height = 24 + // Pump the spinner once so it has a non-empty frame. + var cmd tea.Cmd + var modelIface tea.Model + modelIface, cmd = m2.Update(spinner.TickMsg{}) + if cmd == nil { + t.Fatal("spinner returned no Tick cmd") + } + m2 = modelIface.(Model) + view := m2.View() + // The Line spinner frame set contains | / - \\ characters. + spinnerChars := []string{"|", "/", "-", "\\"} + hasSpinner := false + for _, c := range spinnerChars { + if strings.Contains(view, c+" ") || strings.Contains(view, c+" ") { + hasSpinner = true + break + } + } + if !hasSpinner { + t.Logf("View contains no spinner frame:\n%s", view) + } + if !strings.Contains(view, "Generating") { + t.Errorf("loading view should mention Generating") + } + }) +}