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 (
"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))
}

View file

@ -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())
}
}
})
}

View file

@ -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{}