diff --git a/internal/ui/login/qr/model.go b/internal/ui/login/qr/model.go index 303c048..9d10a28 100644 --- a/internal/ui/login/qr/model.go +++ b/internal/ui/login/qr/model.go @@ -2,27 +2,37 @@ package qr import ( "crypto/rsa" - "strings" + stdimage "image" "time" "github.com/ayn2op/tview" + "github.com/ayn2op/tview/box" "github.com/ayn2op/tview/center" + "github.com/ayn2op/tview/column" + "github.com/ayn2op/tview/image" "github.com/ayn2op/tview/layout" "github.com/ayn2op/tview/text" - "github.com/ayn2op/tview/viewport" "github.com/gdamore/tcell/v3" - "github.com/skip2/go-qrcode" + "github.com/gdamore/tcell/v3/color" +) + +const ( + // quietZoneColor is the white of the margin around the code, as go-qrcode draws its light modules. + quietZoneColor = color.IsRGB | color.IsValid | 0xffffff + // quietZoneRows and quietZoneColumns make a margin of two modules on every side, as a row holds two. + quietZoneRows, quietZoneColumns = 1, 2 + // statusSpacing is the blank rows between the code and the status. + statusSpacing = 1 ) type Model struct { - scrollState viewport.ScrollState - conn *gatewayConn heartbeatInterval time.Duration privateKey *rsa.PrivateKey fingerprint string - qrCode *qrcode.QRCode + // qrCode is the code at one pixel per module, made once as it arrives. + qrCode stdimage.Image status string } @@ -54,9 +64,6 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { return m, closeConn(m.conn) } return m, nil - case scrollMsg: - m.scrollState = viewport.ScrollState(msg) - return m, nil case connCreateMsg: m.conn = msg.conn @@ -113,53 +120,21 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { return m, nil } -// halfBlock packs a vertical pair of QR pixels into one glyph, fitting two bitmap rows into a single terminal row. -func halfBlock(top, bottom bool) rune { - switch [2]bool{top, bottom} { - case [2]bool{true, true}: - return '█' - case [2]bool{true, false}: - return '▀' - case [2]bool{false, true}: - return '▄' - default: - return ' ' - } -} - -// View centers the code, which scrolls when it is taller than the tab. +// View centers the code above the status. func (m Model) View() tview.Widget { - code := m.code() - return center.New( - viewport.New(text.New(code).Wrapping(text.WrappingNone).AlignX(tview.HorizontalAlignmentCenter), m.scrollState). - Height(layout.Shrink). - Focused(true). - OnChange(func(s *viewport.ScrollState) tview.Msg { return scrollMsg(*s) }), - ) -} - -// code returns the QR code above the status. -func (m Model) code() string { - var out strings.Builder + var qrCode tview.Widget if m.qrCode != nil { - bitmap := m.qrCode.Bitmap() - for y := 0; y < len(bitmap); y += 2 { - if y > 0 { - out.WriteByte('\n') - } - for x := range bitmap[y] { - top := bitmap[y][x] - bottom := y+1 < len(bitmap) && bitmap[y+1][x] - out.WriteRune(halfBlock(top, bottom)) - } - } - } - if m.status != "" { - if out.Len() > 0 { - out.WriteString("\n\n") - } - out.WriteString(m.status) + // The white margin around the code, two modules wide, is what scanners find it by against a dark terminal. + qrCode = box.New(image.New(m.qrCode)). + Background(quietZoneColor). + Padding(quietZoneRows, quietZoneRows, quietZoneColumns, quietZoneColumns). + Width(layout.Shrink). + Height(layout.Shrink) } - return out.String() + return center.New(column.New(qrCode, text.New(m.status)). + Spacing(statusSpacing). + AlignX(tview.HorizontalAlignmentCenter). + Width(layout.Shrink). + Height(layout.Shrink)) } diff --git a/internal/ui/login/qr/model_test.go b/internal/ui/login/qr/model_test.go index e76d34a..6ce47e8 100644 --- a/internal/ui/login/qr/model_test.go +++ b/internal/ui/login/qr/model_test.go @@ -27,7 +27,7 @@ func TestModelView(t *testing.T) { if err != nil { t.Fatal(err) } - m.qrCode = code + m.qrCode = code.Image(-1) m.status = "status" m.View().Draw(screen, tview.Rectangle{Width: width, Height: height}) @@ -42,5 +42,12 @@ func TestModelView(t *testing.T) { if !strings.Contains(drawn.String(), "status") || !strings.ContainsAny(drawn.String(), "█▀▄") { t.Fatalf("missing content:\n%s", drawn.String()) } + + // The status is centered across the width, under the centered code. + for line := range strings.Lines(drawn.String()) { + if start := strings.Index(line, "status"); start >= 0 && (start < width/2-4 || start > width/2-2) { + t.Fatalf("status starts at %d, want it centered:\n%s", start, drawn.String()) + } + } }) } diff --git a/internal/ui/login/qr/msg.go b/internal/ui/login/qr/msg.go index 6f53d10..2d87ac4 100644 --- a/internal/ui/login/qr/msg.go +++ b/internal/ui/login/qr/msg.go @@ -8,6 +8,7 @@ import ( "encoding/base64" json "encoding/json/v2" "errors" + "image" "log/slog" "net" "strings" @@ -18,7 +19,6 @@ import ( "github.com/ayn2op/discordo/internal/gateway" "github.com/ayn2op/discordo/internal/http" "github.com/ayn2op/tview" - "github.com/ayn2op/tview/viewport" "github.com/gorilla/websocket" "github.com/skip2/go-qrcode" ) @@ -231,7 +231,7 @@ func sendNonceProof(conn *gatewayConn, privateKey *rsa.PrivateKey, encryptedNonc } type qrCodeMsg struct { - qrCode *qrcode.QRCode + qrCode image.Image } func generateQRCode(fingerprint string) tview.Cmd { @@ -242,7 +242,7 @@ func generateQRCode(fingerprint string) tview.Cmd { return err } qrCode.DisableBorder = true - return qrCodeMsg{qrCode: qrCode} + return qrCodeMsg{qrCode: qrCode.Image(-1)} } } @@ -301,8 +301,5 @@ func exchangeTicket(fingerprint string, privateKey *rsa.PrivateKey, ticket strin } } -// scrollMsg scrolls the code. -type scrollMsg viewport.ScrollState - // startMsg starts connecting to the Remote Auth Gateway. type startMsg struct{}