Files
billboard/internal/tui/tui.go
T
2026-08-08 11:04:45 +02:00

362 lines
7.8 KiB
Go

package tui
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"billboard/internal/state"
"golang.org/x/term"
)
type Display struct {
server string
client *http.Client
state state.Snapshot
tick int
out *os.File
}
func Run(serverAddr, ttyPath string) error {
d := &Display{
server: strings.TrimRight(serverAddr, "/"),
client: &http.Client{Timeout: 10 * time.Second},
out: os.Stdout,
}
if ttyPath != "" {
f, err := os.OpenFile(ttyPath, os.O_RDWR, 0)
if err != nil {
return fmt.Errorf("open tty: %w", err)
}
defer f.Close()
d.out = f
}
if err := d.fetch(); err != nil {
return fmt.Errorf("initial fetch: %w", err)
}
d.out.WriteString("\x1b[?1049h\x1b%G\x1b[?25l\x1b[2J")
restore := func() { fmt.Fprint(d.out, "\x1b[0m\x1b[?25h\x1b[?1049l") }
defer restore()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
resize := make(chan os.Signal, 1)
signal.Notify(resize, syscall.SIGWINCH)
changed := make(chan struct{}, 1)
go d.listen(changed)
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
poll := time.NewTicker(5 * time.Second)
defer poll.Stop()
dirty := true
for {
select {
case <-quit:
return nil
case <-resize:
fmt.Fprint(d.out, "\x1b[2J")
dirty = true
case <-changed:
d.fetch()
d.debug("update via SSE")
dirty = true
case <-poll.C:
if d.fetchIfChanged() {
dirty = true
}
case <-ticker.C:
d.tick++
if d.tick%2 == 0 && d.narrow() {
dirty = true
}
}
if dirty {
d.render()
dirty = false
}
}
}
func (d *Display) debug(msg string) {
if os.Getenv("BILLBOARD_DEBUG") != "" {
fmt.Fprintf(os.Stderr, "%s %s\n", time.Now().Format("15:04:05"), msg)
}
}
func (d *Display) fetchIfChanged() bool {
before, _ := json.Marshal(d.state)
if err := d.fetch(); err != nil {
return false
}
after, _ := json.Marshal(d.state)
return string(before) != string(after)
}
func (d *Display) narrow() bool {
w, _, err := term.GetSize(int(d.out.Fd()))
return err != nil || w < 50
}
func (d *Display) fetch() error {
resp, err := d.client.Get(d.server + "/api/state")
if err != nil {
return err
}
defer resp.Body.Close()
var snap state.Snapshot
if err := json.NewDecoder(resp.Body).Decode(&snap); err != nil {
return err
}
d.state = snap
nonzero := 0
for _, row := range snap.Pixels {
for _, c := range row {
if c != 0 {
nonzero++
}
}
}
d.debug(fmt.Sprintf("fetch: %dx%d nonzero=%d msgs=%d", snap.Width, snap.Height, nonzero, len(snap.Messages)))
return nil
}
func (d *Display) listen(changed chan<- struct{}) {
notify := func() {
select {
case changed <- struct{}{}:
default:
}
}
for {
req, err := http.NewRequest("GET", d.server+"/api/events", nil)
if err != nil {
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
d.pollFallback(changed)
return
}
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
if strings.HasPrefix(scanner.Text(), "event:") &&
!strings.Contains(scanner.Text(), "hello") {
notify()
}
}
resp.Body.Close()
if err := scanner.Err(); err != nil && err != io.EOF {
time.Sleep(2 * time.Second)
}
}
}
func (d *Display) pollFallback(changed chan<- struct{}) {
last := d.state
for {
time.Sleep(2 * time.Second)
if err := d.fetch(); err != nil {
continue
}
a, _ := json.Marshal(last)
b, _ := json.Marshal(d.state)
if string(a) != string(b) {
last = d.state
select {
case changed <- struct{}{}:
default:
}
}
}
}
func (d *Display) render() {
w, h, err := term.GetSize(int(d.out.Fd()))
if err != nil || w <= 0 || h <= 0 {
w, h = 80, 24
}
snap := d.state
if snap.Width == 0 {
return
}
var lines []string
if w >= 50 {
lines = d.wideLines(w, h)
} else {
lines = d.narrowLines(w, h)
}
var b strings.Builder
for i, line := range lines {
fmt.Fprintf(&b, "\x1b[%d;1H", i+1)
b.WriteString(line)
}
b.WriteString("\x1b[0m\x1b[J")
fmt.Fprint(d.out, b.String())
}
func sgr16(fg, bg uint8) string {
f, b := int(fg), int(bg)
if f < 8 {
f += 30
} else {
f += 82 // 90-97
}
if b < 8 {
b += 40
} else {
b += 92 // 100-107
}
return fmt.Sprintf("\x1b[%d;%dm", f, b)
}
func (d *Display) canvasLines(areaW, areaH int) []string {
snap := d.state
scaleX := float64(areaW) / float64(snap.Width)
scaleY := float64(areaH*2) / float64(snap.Height)
scale := min(scaleX, scaleY)
if scale <= 0 {
return nil
}
dispW := max(1, min(int(float64(snap.Width)*scale), areaW))
dispH := max(1, min(int(float64(snap.Height)*scale), areaH*2))
cellRows := (dispH + 1) / 2
src := func(x, y int) uint8 {
sx := min(int(float64(x)/scale), snap.Width-1)
sy := min(int(float64(y)/scale), snap.Height-1)
return snap.Pixels[sy][sx]
}
padLeft := strings.Repeat(" ", (areaW-dispW)/2)
padRight := areaW - dispW - len(padLeft)
blank := strings.Repeat(" ", areaW)
lines := make([]string, 0, areaH)
for row := 0; row < cellRows; row++ {
var line strings.Builder
line.WriteString(padLeft)
for x := 0; x < dispW; x++ {
fg := src(x, row*2)
bg := uint8(0)
if row*2+1 < dispH {
bg = src(x, row*2+1)
}
line.WriteString(sgr16(fg, bg))
line.WriteString("▀")
}
line.WriteString("\x1b[0m")
line.WriteString(strings.Repeat(" ", padRight))
lines = append(lines, line.String())
}
for len(lines) < areaH {
lines = append(lines, blank)
}
top := (areaH - cellRows) / 2
out := make([]string, areaH)
copy(out[top:], lines)
for i := range out {
if out[i] == "" {
out[i] = blank
}
}
return out
}
func sanitize(s string) string {
return strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, s)
}
func wrapText(text string, width int) []string {
if width <= 0 {
return nil
}
var lines []string
runes := []rune(text)
for len(runes) > width {
cut := width
if i := strings.LastIndex(string(runes[:cut]), " "); i > 0 {
cut = len([]rune(string(runes[:cut])[:i])) + 1
}
lines = append(lines, strings.TrimRight(string(runes[:cut]), " "))
runes = []rune(strings.TrimLeft(string(runes[cut:]), " "))
}
return append(lines, string(runes))
}
func (d *Display) sidebarLines(msgW, h int) []string {
blank := strings.Repeat(" ", msgW)
msgs := d.state.Messages
var lines []string
for i := len(msgs) - 1; i >= 0 && len(lines) < h; i-- {
text := msgs[i].At.Local().Format("15:04") + " " + sanitize(msgs[i].Text)
wrapped := wrapText(text, msgW)
for j := len(wrapped) - 1; j >= 0 && len(lines) < h; j-- {
lines = append(lines, fmt.Sprintf("%-*s", msgW, wrapped[j]))
}
}
for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
lines[i], lines[j] = lines[j], lines[i]
}
for len(lines) < h {
lines = append([]string{blank}, lines...)
}
return lines
}
func (d *Display) wideLines(w, h int) []string {
msgW := min(40, w/3)
areaW := w - msgW - 1
canvas := d.canvasLines(areaW, h)
sidebar := d.sidebarLines(msgW, h)
lines := make([]string, h)
for row := 0; row < h; row++ {
lines[row] = canvas[row] + "│" + sidebar[row]
}
return lines
}
func (d *Display) narrowLines(w, h int) []string {
availRows := h - 2
lines := d.canvasLines(w, availRows)
lines = append(lines, strings.Repeat(" ", w))
lines = append(lines, d.tickerLine(w))
return lines
}
func (d *Display) tickerLine(w int) string {
msgs := d.state.Messages
if len(msgs) == 0 {
return "\x1b[2mno messages yet — POST /api/message {\"text\":\"...\"}\x1b[0m"
}
parts := make([]string, 0, len(msgs))
for i := len(msgs) - 1; i >= 0 && len(parts) < 10; i-- {
parts = append(parts, sanitize(msgs[i].Text))
}
feed := strings.Join(parts, " • ") + " • "
runes := []rune(feed)
if len(runes) <= w {
return feed
}
offset := (d.tick / 2) % len(runes)
rotated := append(append([]rune{}, runes[offset:]...), runes[:offset]...)
return string(rotated[:w])
}