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]) }