Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
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/muesli/reflow v0.3.0
github.com/nbd-wtf/go-nostr v0.50.0
golang.org/x/crypto v0.33.0
golang.org/x/term v0.29.0
Expand Down Expand Up @@ -36,7 +37,6 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/puzpuzpuz/xsync/v3 v3.4.0 // indirect
Expand Down
132 changes: 82 additions & 50 deletions tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/reflow/ansi"

"github.com/mdp/qrterminal/v3"
)
Expand All @@ -32,8 +33,7 @@ const (
var (
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("205")).
MarginBottom(1)
Foreground(lipgloss.Color("205"))

menuStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("246"))
Expand Down Expand Up @@ -802,7 +802,18 @@ func (m Model) View() string {
b.WriteString("\n" + m.messageStyle.Render(m.message))
}

b.WriteString("\n\n" + menuStyle.Render("Press Esc to go back, Ctrl+C to quit"))
// The QR screen ends with a block (title / QR / footer) that
// viewQRLogin right-pads to a uniform width so everything in
// the block shares an indent. The trailing global footer we
// append here is OUTSIDE that block, so lipgloss.Place would
// center it on its own and break the alignment. To keep the
// whole screen visually coherent, append a short "press esc"
// footer that wraps to the same block width as the rest.
if m.screen == ScreenQRLogin {
// Already inside viewQRLogin — nothing to append.
} else {
b.WriteString("\n\n" + menuStyle.Render("Press Esc to go back, Ctrl+C to quit"))
}

content := b.String()

Expand Down Expand Up @@ -929,63 +940,84 @@ func (m Model) viewLogin() string {
}

func (m Model) viewQRLogin() string {
var b strings.Builder

// Create content
b.WriteString(titleStyle.Render("📱 Scan with Amber"))
b.WriteString("\n\n")

// Build the screen as a single block: title, body, footer. The
// outer View() centers this block inside the terminal using
// lipgloss.Place, which right-pads every line to the block's
// widest line via ansi.PrintableRuneWidth. If individual lines
// differ in printable width, lipgloss leaves them at different
// indents (each line is centered on its own width). To keep
// every line visually "together" — title, QR, and footers all
// at the same column — we explicitly right-pad every line so
// each line reports the same printable width to lipgloss.
var body strings.Builder

// Title (right-padded below).
title := titleStyle.Render("📱 Scan with Amber")

var middle string
var suffix string
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
// QR is rendered. Don't append the global m.message here —
// it could be the leftover "Generating QR code..." string
// from handleLoginEnter, which was 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."))
middle = m.qrRendered
suffix = menuStyle.Render("Waiting for connection... scan this with your Amber app.")
} else if m.qrData != "" {
// 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...")
middle = m.spinner.View() + " Generating QR code..."
suffix = ""
} else {
// No URI yet. Also show a spinner — the previous static text
// was indistinguishable from a hung program.
b.WriteString(m.spinner.View() + " Generating connection...")
middle = m.spinner.View() + " Generating connection..."
suffix = ""
}

content := b.String()
// Find the QR's max ansi-printable width so we can right-pad
// every line to it. Use ansi.PrintableRuneWidth so the count
// matches what lipgloss will see in PlaceHorizontal — using
// runewidth.StringWidth would count double-width emoji as
// 2 cols and create a 1-col mismatch that breaks alignment.
qrWidth := 0
for _, l := range strings.Split(middle, "\n") {
if w := ansi.PrintableRuneWidth(l); w > qrWidth {
qrWidth = w
}
}
if suffix != "" {
if w := ansi.PrintableRuneWidth(suffix); w > qrWidth {
qrWidth = w
}
}
// Include the "Press Esc" global footer in the same block so the
// whole screen — including the global footer — is centered as one
// unit. Without this, View()'s trailing footer would sit at a
// different column than the title/QR group.
footer := menuStyle.Render("Press Esc to go back, Ctrl+C to quit")
if w := ansi.PrintableRuneWidth(footer); w > qrWidth {
qrWidth = w
}

// Center the content using lipgloss.Place if we have valid dimensions.
//
// IMPORTANT: do NOT set MaxWidth on the content style. The QR code
// is a fixed-width bitmap; if MaxWidth < the rendered QR width,
// lipgloss wraps it line-by-line, destroying the QR pattern. The
// QR is always ~50 columns wide visually (half-block Unicode
// glyphs at minimum QR size); on terminals narrower than that,
// the user will see the QR truncated horizontally rather than
// pseudo-rendered as wrapped text — which is still better than
// the previous behavior of rendering as ~80 lines of 1-column
// wide bars.
if m.width > 0 && m.height > 0 {
contentStyle := lipgloss.NewStyle().
Padding(1, 2) // Padding only — no MaxWidth.

styledContent := contentStyle.Render(content)

return lipgloss.Place(
m.height,
m.width,
lipgloss.Center,
lipgloss.Center,
styledContent,
)
padLine := func(s string) string {
w := ansi.PrintableRuneWidth(s)
if w >= qrWidth {
return s
}
return s + strings.Repeat(" ", qrWidth-w)
}

// Fallback to non-centered content if dimensions aren't available
return content
body.WriteString(padLine(title))
body.WriteString("\n\n")
for _, l := range strings.Split(middle, "\n") {
body.WriteString(padLine(l))
body.WriteString("\n")
}
if suffix != "" {
body.WriteString(padLine(suffix))
body.WriteString("\n")
}
body.WriteString(padLine(footer))

return body.String()
}

func (m Model) viewHome() string {
Expand Down
115 changes: 115 additions & 0 deletions tui/tui_qr_layout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package tui

import (
"os"
"strings"
"testing"

tea "github.com/charmbracelet/bubbletea"
)

// TestQRViewLayout dumps the full View() at every reasonable size and
// captures row-by-row layout information so we can find centering
// problems. The user's complaint is that the QR screen layout is
// "not quite together" — meaning the title, QR, and footer don't
// line up as a unit. We dump raw view output plus structural
// measurements (line count, leftmost column with content) for each
// size.
func TestQRViewLayout(t *testing.T) {
const fakeURI = "nostrconnect://20366413937be6e239d53dd36a98028404fb20e2af300858ba0e31ab7ba9a97c?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22hoot%22%7D"

cases := []struct{ w, h int }{
{40, 20}, {60, 20}, {80, 24}, {100, 30}, {120, 40}, {160, 50},
}

for _, c := range cases {
m := NewModel()
m.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 },
)
m.screen = ScreenQRLogin
upd, _ := m.Update(tea.WindowSizeMsg{Width: c.w, Height: c.h})
m = upd.(Model)
upd, _ = m.Update(qrGeneratedMsg{uri: fakeURI})
m = upd.(Model)
// Pump enough generic updates so any pending regen/decode runs.
for i := 0; i < 5; i++ {
upd, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{}})
m = upd.(Model)
}

view := m.View()
rawPath := "/tmp/hoot-qr-layout-" + itoa(c.w) + "x" + itoa(c.h) + ".txt"
_ = os.WriteFile(rawPath, []byte(view), 0644)
t.Logf("=== w=%d h=%d raw view dumped to %s ===", c.w, c.h, rawPath)

// Quick header analysis: where is "Scan with Amber"? Where is
// the QR (first | or ▀ block)? Where is "Waiting for
// connection..."? Where is "Press Esc"?
lines := strings.Split(view, "\n")
var headerLine, firstQRLine, footerScanLine, footerEscLine int = -1, -1, -1, -1
for i, l := range lines {
switch {
case strings.Contains(l, "Scan with Amber") && headerLine == -1:
headerLine = i
case (strings.Contains(l, "▄") || strings.Contains(l, "█")) && firstQRLine == -1:
firstQRLine = i
case strings.Contains(l, "Waiting for connection") && footerScanLine == -1:
footerScanLine = i
case strings.Contains(l, "Press Esc") && footerEscLine == -1:
footerEscLine = i
}
}
t.Logf(" header at line %d, first QR line %d, footer/Waiting line %d, footer/Press line %d, total %d lines",
headerLine, firstQRLine, footerScanLine, footerEscLine, len(lines))

// Compute leftmost content column for each of those lines,
// measuring by runewidth.StringWidth would be ideal but the
// simpler "first non-space column" is enough to detect misalignment.
for label, n := range map[string]int{"header": headerLine, "firstQR": firstQRLine, "waiting": footerScanLine, "esc": footerEscLine} {
if n < 0 || n >= len(lines) {
continue
}
l := lines[n]
indent := 0
for _, r := range l {
if r == ' ' {
indent++
} else {
break
}
}
t.Logf(" %-8s line %d indent=%d (visible col %d)", label, n, indent, indent)
}
}
}

// itoa is here so this test file doesn't pull in strconv just for sizes.
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := false
if n < 0 {
neg = true
n = -n
}
digits := []byte{}
for n > 0 {
digits = append([]byte{byte('0' + n%10)}, digits...)
n /= 10
}
if neg {
digits = append([]byte{'-'}, digits...)
}
return string(digits)
}
Loading