362 lines
7.8 KiB
Go
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])
|
|
}
|