refactor(qr): draw the code centered with the image widget in a white margin, encoded once

This commit is contained in:
ayn2op 2026-10-18 16:25:03 -04:00
parent b860df0a25
commit 23b45b9c16
3 changed files with 40 additions and 61 deletions

View file

@ -2,27 +2,37 @@ package qr
import ( import (
"crypto/rsa" "crypto/rsa"
"strings" stdimage "image"
"time" "time"
"github.com/ayn2op/tview" "github.com/ayn2op/tview"
"github.com/ayn2op/tview/box"
"github.com/ayn2op/tview/center" "github.com/ayn2op/tview/center"
"github.com/ayn2op/tview/column"
"github.com/ayn2op/tview/image"
"github.com/ayn2op/tview/layout" "github.com/ayn2op/tview/layout"
"github.com/ayn2op/tview/text" "github.com/ayn2op/tview/text"
"github.com/ayn2op/tview/viewport"
"github.com/gdamore/tcell/v3" "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 { type Model struct {
scrollState viewport.ScrollState
conn *gatewayConn conn *gatewayConn
heartbeatInterval time.Duration heartbeatInterval time.Duration
privateKey *rsa.PrivateKey privateKey *rsa.PrivateKey
fingerprint string fingerprint string
qrCode *qrcode.QRCode // qrCode is the code at one pixel per module, made once as it arrives.
qrCode stdimage.Image
status string status string
} }
@ -54,9 +64,6 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
return m, closeConn(m.conn) return m, closeConn(m.conn)
} }
return m, nil return m, nil
case scrollMsg:
m.scrollState = viewport.ScrollState(msg)
return m, nil
case connCreateMsg: case connCreateMsg:
m.conn = msg.conn m.conn = msg.conn
@ -113,53 +120,21 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
return m, nil return m, nil
} }
// halfBlock packs a vertical pair of QR pixels into one glyph, fitting two bitmap rows into a single terminal row. // View centers the code above the status.
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.
func (m Model) View() tview.Widget { func (m Model) View() tview.Widget {
code := m.code() var qrCode tview.Widget
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
if m.qrCode != nil { if m.qrCode != nil {
bitmap := m.qrCode.Bitmap() // The white margin around the code, two modules wide, is what scanners find it by against a dark terminal.
for y := 0; y < len(bitmap); y += 2 { qrCode = box.New(image.New(m.qrCode)).
if y > 0 { Background(quietZoneColor).
out.WriteByte('\n') Padding(quietZoneRows, quietZoneRows, quietZoneColumns, quietZoneColumns).
} Width(layout.Shrink).
for x := range bitmap[y] { Height(layout.Shrink)
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)
} }
return out.String() return center.New(column.New(qrCode, text.New(m.status)).
Spacing(statusSpacing).
AlignX(tview.HorizontalAlignmentCenter).
Width(layout.Shrink).
Height(layout.Shrink))
} }

View file

@ -27,7 +27,7 @@ func TestModelView(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
m.qrCode = code m.qrCode = code.Image(-1)
m.status = "status" m.status = "status"
m.View().Draw(screen, tview.Rectangle{Width: width, Height: height}) 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(), "█▀▄") { if !strings.Contains(drawn.String(), "status") || !strings.ContainsAny(drawn.String(), "█▀▄") {
t.Fatalf("missing content:\n%s", 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())
}
}
}) })
} }

View file

@ -8,6 +8,7 @@ import (
"encoding/base64" "encoding/base64"
json "encoding/json/v2" json "encoding/json/v2"
"errors" "errors"
"image"
"log/slog" "log/slog"
"net" "net"
"strings" "strings"
@ -18,7 +19,6 @@ import (
"github.com/ayn2op/discordo/internal/gateway" "github.com/ayn2op/discordo/internal/gateway"
"github.com/ayn2op/discordo/internal/http" "github.com/ayn2op/discordo/internal/http"
"github.com/ayn2op/tview" "github.com/ayn2op/tview"
"github.com/ayn2op/tview/viewport"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/skip2/go-qrcode" "github.com/skip2/go-qrcode"
) )
@ -231,7 +231,7 @@ func sendNonceProof(conn *gatewayConn, privateKey *rsa.PrivateKey, encryptedNonc
} }
type qrCodeMsg struct { type qrCodeMsg struct {
qrCode *qrcode.QRCode qrCode image.Image
} }
func generateQRCode(fingerprint string) tview.Cmd { func generateQRCode(fingerprint string) tview.Cmd {
@ -242,7 +242,7 @@ func generateQRCode(fingerprint string) tview.Cmd {
return err return err
} }
qrCode.DisableBorder = true 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. // startMsg starts connecting to the Remote Auth Gateway.
type startMsg struct{} type startMsg struct{}