From 4137962252984bb848f17270003e563a9f129562 Mon Sep 17 00:00:00 2001 From: Rose Hellsing Date: Sat, 8 Aug 2026 11:04:45 +0200 Subject: [PATCH] Initial commit --- README.md | 27 +++ cmd/billboard/main.go | 75 ++++++ go.mod | 7 + go.sum | 4 + internal/server/server.go | 365 ++++++++++++++++++++++++++++++ internal/server/static/index.html | 122 ++++++++++ internal/state/state.go | 200 ++++++++++++++++ internal/state/state_test.go | 112 +++++++++ internal/tui/tui.go | 361 +++++++++++++++++++++++++++++ internal/tui/tui_test.go | 49 ++++ 10 files changed, 1322 insertions(+) create mode 100644 README.md create mode 100644 cmd/billboard/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/server/server.go create mode 100644 internal/server/static/index.html create mode 100644 internal/state/state.go create mode 100644 internal/state/state_test.go create mode 100644 internal/tui/tui.go create mode 100644 internal/tui/tui_test.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..0fbcdc8 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# billboard + +Public pixel-canvas / message billboard. Runs at https://billboard.pinkro.se + +## API + +Palette colors are indices 0–15 (standard ANSI colors). Origin is top-left. + +```sh +# place one pixel +curl -X POST localhost:8080/api/pixel -d '{"x":10,"y":5,"color":3}' + +# place many pixels atomically (1-2048 per request, fails as a whole if any is invalid) +curl -X POST localhost:8080/api/pixels -d '{"pixels":[{"x":1,"y":1,"color":3},{"x":2,"y":1,"color":4}]}' + +# stamp a full image (max 32x32) at offset x,y; edges clip +curl -X POST localhost:8080/api/image -d '{"x":20,"y":8,"pixels":[[0,4,4,0],[4,4,4,4],[4,0,0,4],[0,4,4,0]]}' + +# post a message (max 140 chars, last 50 kept) +curl -X POST localhost:8080/api/message -d '{"text":"hello billboard"}' + +# full state snapshot +curl localhost:8080/api/state + +# live event stream +curl -N localhost:8080/api/events +``` diff --git a/cmd/billboard/main.go b/cmd/billboard/main.go new file mode 100644 index 0000000..3a7f7b0 --- /dev/null +++ b/cmd/billboard/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "billboard/internal/server" + "billboard/internal/state" + "billboard/internal/tui" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + switch os.Args[1] { + case "server": + serverCmd(os.Args[2:]) + case "display": + displayCmd(os.Args[2:]) + default: + usage() + os.Exit(2) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, `usage: + billboard server [-addr :8080] [-width 64] [-height 32] [-snapshot billboard.json] + billboard display [-server http://localhost:8080] [-tty /dev/tty2] + +env: + BILLBOARD_TOKEN if set on the server, POST requests need "Authorization: Bearer " +`) +} + +func serverCmd(args []string) { + fs := flag.NewFlagSet("server", flag.ExitOnError) + addr := fs.String("addr", ":8080", "listen address") + width := fs.Int("width", 64, "canvas width in pixels") + height := fs.Int("height", 32, "canvas height in pixels") + snapshot := fs.String("snapshot", "billboard.json", "snapshot file (loaded if it exists)") + fs.Parse(args) + + var st *state.State + if loaded, err := state.Load(*snapshot); err == nil { + st = loaded + fmt.Printf("loaded snapshot %s (%dx%d)\n", *snapshot, st.Width, st.Height) + } else { + st = state.New(*width, *height) + } + + srv := server.New(st, server.Config{ + Addr: *addr, + Token: os.Getenv("BILLBOARD_TOKEN"), + SnapshotPath: *snapshot, + }) + if err := srv.Run(); err != nil { + fmt.Fprintln(os.Stderr, "server:", err) + os.Exit(1) + } +} + +func displayCmd(args []string) { + fs := flag.NewFlagSet("display", flag.ExitOnError) + serverAddr := fs.String("server", "http://localhost:8080", "billboard server URL") + tty := fs.String("tty", "", "tty device to render on (e.g. /dev/tty2); default stdout") + fs.Parse(args) + if err := tui.Run(*serverAddr, *tty); err != nil { + fmt.Fprintln(os.Stderr, "display:", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d554c69 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module billboard + +go 1.26.5 + +require golang.org/x/term v0.45.0 + +require golang.org/x/sys v0.47.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d2468be --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..4cd3384 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,365 @@ +package server + +import ( + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "log" + "net" + "net/http" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "billboard/internal/state" +) + +type Config struct { + Addr string + Token string + SnapshotPath string +} + +type Server struct { + st *state.State + cfg Config + hub *hub + limit *rateLimiter +} + +func New(st *state.State, cfg Config) *Server { + return &Server{ + st: st, + cfg: cfg, + hub: newHub(), + limit: newRateLimiter(), + } +} + +//go:embed static/index.html +var indexHTML []byte + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(indexHTML) + }) + mux.HandleFunc("GET /api/state", s.handleState) + mux.HandleFunc("GET /api/events", s.handleEvents) + mux.HandleFunc("POST /api/pixel", s.handlePixel) + mux.HandleFunc("POST /api/pixels", s.handlePixels) + mux.HandleFunc("POST /api/image", s.handleImage) + mux.HandleFunc("POST /api/message", s.handleMessage) + return mux +} + +func (s *Server) Run() error { + if s.cfg.SnapshotPath != "" { + go s.snapshotLoop() + } + httpSrv := &http.Server{Addr: s.cfg.Addr, Handler: s.Handler()} + go func() { + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + <-sig + if s.cfg.SnapshotPath != "" { + if err := s.st.Save(s.cfg.SnapshotPath); err != nil { + log.Printf("shutdown snapshot save: %v", err) + } else { + log.Printf("saved snapshot to %s", s.cfg.SnapshotPath) + } + } + httpSrv.Shutdown(context.Background()) + }() + log.Printf("billboard server listening on %s (canvas %dx%d)", s.cfg.Addr, s.st.Width, s.st.Height) + if err := httpSrv.ListenAndServe(); err != http.ErrServerClosed { + return err + } + return nil +} + +func (s *Server) snapshotLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + if err := s.st.Save(s.cfg.SnapshotPath); err != nil { + log.Printf("snapshot save: %v", err) + } + } +} + +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func (s *Server) authorized(r *http.Request) bool { + if s.cfg.Token == "" { + return true + } + return r.Header.Get("Authorization") == "Bearer "+s.cfg.Token +} + +func writeErr(w http.ResponseWriter, code int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + +func decode(w http.ResponseWriter, r *http.Request, v any) bool { + return decodeLimit(w, r, v, 64<<10) +} + +func decodeLimit(w http.ResponseWriter, r *http.Request, v any, maxBytes int64) bool { + r.Body = http.MaxBytesReader(w, r.Body, maxBytes) + if err := json.NewDecoder(r.Body).Decode(v); err != nil { + writeErr(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return false + } + return true +} + +func stateErr(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, state.ErrOutOfBounds), + errors.Is(err, state.ErrInvalidColor), + errors.Is(err, state.ErrImageTooBig), + errors.Is(err, state.ErrEmptyMessage), + errors.Is(err, state.ErrMessageLong): + writeErr(w, http.StatusBadRequest, err.Error()) + default: + writeErr(w, http.StatusInternalServerError, "internal error") + } +} + +func (s *Server) guard(w http.ResponseWriter, r *http.Request, cost float64) bool { + if !s.authorized(r) { + writeErr(w, http.StatusUnauthorized, "missing or invalid token") + return false + } + if !s.limit.allow(clientIP(r), cost) { + writeErr(w, http.StatusTooManyRequests, "rate limit exceeded") + return false + } + return true +} + +func (s *Server) handleState(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(s.st.Snapshot()) +} + +type pixelReq struct { + X int `json:"x"` + Y int `json:"y"` + Color uint8 `json:"color"` +} + +func (s *Server) handlePixel(w http.ResponseWriter, r *http.Request) { + if !s.guard(w, r, 1) { + return + } + var req pixelReq + if !decode(w, r, &req) { + return + } + if err := s.st.SetPixel(req.X, req.Y, req.Color); err != nil { + stateErr(w, err) + return + } + s.hub.broadcast("pixel") + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handlePixels(w http.ResponseWriter, r *http.Request) { + var req struct { + Pixels []pixelReq `json:"pixels"` + } + if !decodeLimit(w, r, &req, 256<<10) { + return + } + if len(req.Pixels) == 0 || len(req.Pixels) > 2048 { + writeErr(w, http.StatusBadRequest, "batch must contain 1-2048 pixels") + return + } + cost := float64(len(req.Pixels)) / 8 + if cost < 1 { + cost = 1 + } + if !s.guard(w, r, cost) { + return + } + for _, p := range req.Pixels { + if err := s.st.CheckPixel(p.X, p.Y, p.Color); err != nil { + stateErr(w, err) + return + } + } + for _, p := range req.Pixels { + s.st.SetPixel(p.X, p.Y, p.Color) + } + s.hub.broadcast("pixel") + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) { + if !s.guard(w, r, 10) { + return + } + var req struct { + X int `json:"x"` + Y int `json:"y"` + Pixels []state.Row `json:"pixels"` + } + if !decode(w, r, &req) { + return + } + if err := s.st.StampImage(req.X, req.Y, req.Pixels); err != nil { + stateErr(w, err) + return + } + s.hub.broadcast("image") + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleMessage(w http.ResponseWriter, r *http.Request) { + if !s.guard(w, r, 5) { + return + } + var req struct { + Text string `json:"text"` + } + if !decode(w, r, &req) { + return + } + req.Text = strings.TrimSpace(req.Text) + if err := s.st.AddMessage(req.Text); err != nil { + stateErr(w, err) + return + } + s.hub.broadcast("message") + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + flusher, ok := w.(http.Flusher) + if !ok { + writeErr(w, http.StatusInternalServerError, "streaming unsupported") + return + } + ch := s.hub.subscribe() + defer s.hub.unsubscribe(ch) + fmt.Fprint(w, "event: hello\ndata: {}\n\n") + flusher.Flush() + for { + select { + case <-r.Context().Done(): + return + case ev := <-ch: + fmt.Fprintf(w, "event: %s\ndata: {}\n\n", ev) + flusher.Flush() + } + } +} + +type hub struct { + mu sync.Mutex + subs map[chan string]struct{} +} + +func newHub() *hub { + return &hub{subs: make(map[chan string]struct{})} +} + +func (h *hub) subscribe() chan string { + ch := make(chan string, 16) + h.mu.Lock() + h.subs[ch] = struct{}{} + h.mu.Unlock() + return ch +} + +func (h *hub) unsubscribe(ch chan string) { + h.mu.Lock() + delete(h.subs, ch) + h.mu.Unlock() +} + +func (h *hub) broadcast(ev string) { + h.mu.Lock() + defer h.mu.Unlock() + for ch := range h.subs { + select { + case ch <- ev: + default: + } + } +} + +type bucket struct { + tokens float64 + last time.Time +} + +type rateLimiter struct { + mu sync.Mutex + buckets map[string]*bucket +} + +func newRateLimiter() *rateLimiter { + rl := &rateLimiter{buckets: make(map[string]*bucket)} + go rl.gc() + return rl +} + +const ( + ratePerSec = 4.0 + burst = 40.0 +) + +func (rl *rateLimiter) allow(key string, cost float64) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + b, ok := rl.buckets[key] + if !ok { + b = &bucket{tokens: burst, last: time.Now()} + rl.buckets[key] = b + } + now := time.Now() + b.tokens += now.Sub(b.last).Seconds() * ratePerSec + if b.tokens > burst { + b.tokens = burst + } + b.last = now + if b.tokens < cost { + return false + } + b.tokens -= cost + return true +} + +func (rl *rateLimiter) gc() { + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + for range ticker.C { + rl.mu.Lock() + for k, b := range rl.buckets { + if time.Since(b.last) > 10*time.Minute { + delete(rl.buckets, k) + } + } + rl.mu.Unlock() + } +} diff --git a/internal/server/static/index.html b/internal/server/static/index.html new file mode 100644 index 0000000..72b1389 --- /dev/null +++ b/internal/server/static/index.html @@ -0,0 +1,122 @@ + + + + +billboard + + + + + +
+
+ + + + diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..aad3372 --- /dev/null +++ b/internal/state/state.go @@ -0,0 +1,200 @@ +package state + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "sync" + "time" +) + +const PaletteSize = 16 + +type Row []uint8 + +func (r Row) MarshalJSON() ([]byte, error) { + vals := make([]int, len(r)) + for i, v := range r { + vals[i] = int(v) + } + return json.Marshal(vals) +} + +func (r *Row) UnmarshalJSON(data []byte) error { + var vals []int + if err := json.Unmarshal(data, &vals); err != nil { + return err + } + row := make(Row, len(vals)) + for i, v := range vals { + if v < 0 || v > 255 { + return fmt.Errorf("pixel value %d out of range", v) + } + row[i] = uint8(v) + } + *r = row + return nil +} + +var ( + ErrOutOfBounds = errors.New("coordinates out of bounds") + ErrInvalidColor = errors.New("invalid color index") + ErrImageTooBig = errors.New("image too large") + ErrEmptyMessage = errors.New("message is empty") + ErrMessageLong = errors.New("message too long") +) + +type Message struct { + Text string `json:"text"` + At time.Time `json:"at"` +} + +type State struct { + mu sync.RWMutex + Width int `json:"width"` + Height int `json:"height"` + Pixels []Row `json:"pixels"` + Messages []Message `json:"messages"` + maxMessages int + maxMsgLen int + maxImageDim int +} + +func New(width, height int) *State { + pixels := make([]Row, height) + for i := range pixels { + pixels[i] = make(Row, width) + } + return &State{ + Width: width, + Height: height, + Pixels: pixels, + Messages: []Message{}, + maxMessages: 50, + maxMsgLen: 140, + maxImageDim: 32, + } +} + +func (s *State) CheckPixel(x, y int, color uint8) error { + if x < 0 || x >= s.Width || y < 0 || y >= s.Height { + return ErrOutOfBounds + } + if color >= PaletteSize { + return ErrInvalidColor + } + return nil +} + +func (s *State) SetPixel(x, y int, color uint8) error { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.CheckPixel(x, y, color); err != nil { + return err + } + s.Pixels[y][x] = color + return nil +} + +func (s *State) StampImage(x, y int, pixels []Row) error { + if len(pixels) == 0 || len(pixels) > s.maxImageDim { + return ErrImageTooBig + } + for _, row := range pixels { + if len(row) == 0 || len(row) > s.maxImageDim { + return ErrImageTooBig + } + for _, c := range row { + if c >= PaletteSize { + return ErrInvalidColor + } + } + } + s.mu.Lock() + defer s.mu.Unlock() + for dy, row := range pixels { + for dx, c := range row { + px, py := x+dx, y+dy + if px >= 0 && px < s.Width && py >= 0 && py < s.Height { + s.Pixels[py][px] = c + } + } + } + return nil +} + +func (s *State) AddMessage(text string) error { + if text == "" { + return ErrEmptyMessage + } + if len(text) > s.maxMsgLen { + return ErrMessageLong + } + s.mu.Lock() + defer s.mu.Unlock() + s.Messages = append(s.Messages, Message{Text: text, At: time.Now().UTC()}) + if len(s.Messages) > s.maxMessages { + s.Messages = s.Messages[len(s.Messages)-s.maxMessages:] + } + return nil +} + +type Snapshot struct { + Width int `json:"width"` + Height int `json:"height"` + Pixels []Row `json:"pixels"` + Messages []Message `json:"messages"` +} + +func (s *State) Snapshot() Snapshot { + s.mu.RLock() + defer s.mu.RUnlock() + pixels := make([]Row, s.Height) + for i, row := range s.Pixels { + cp := make(Row, len(row)) + copy(cp, row) + pixels[i] = cp + } + msgs := make([]Message, len(s.Messages)) + copy(msgs, s.Messages) + return Snapshot{Width: s.Width, Height: s.Height, Pixels: pixels, Messages: msgs} +} + +func (s *State) Save(path string) error { + snap := s.Snapshot() + data, err := json.Marshal(snap) + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func Load(path string) (*State, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var snap Snapshot + if err := json.Unmarshal(data, &snap); err != nil { + return nil, err + } + if snap.Width <= 0 || snap.Height <= 0 || len(snap.Pixels) != snap.Height { + return nil, fmt.Errorf("invalid snapshot dimensions") + } + for _, row := range snap.Pixels { + if len(row) != snap.Width { + return nil, fmt.Errorf("invalid snapshot row width") + } + } + st := New(snap.Width, snap.Height) + st.Pixels = snap.Pixels + if snap.Messages != nil { + st.Messages = snap.Messages + } + return st, nil +} diff --git a/internal/state/state_test.go b/internal/state/state_test.go new file mode 100644 index 0000000..f4fa732 --- /dev/null +++ b/internal/state/state_test.go @@ -0,0 +1,112 @@ +package state + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSetPixel(t *testing.T) { + s := New(8, 8) + if err := s.SetPixel(3, 4, 5); err != nil { + t.Fatal(err) + } + if got := s.Snapshot().Pixels[4][3]; got != 5 { + t.Fatalf("got %d, want 5", got) + } + if err := s.SetPixel(8, 0, 1); err != ErrOutOfBounds { + t.Fatalf("want ErrOutOfBounds, got %v", err) + } + if err := s.SetPixel(0, 0, 16); err != ErrInvalidColor { + t.Fatalf("want ErrInvalidColor, got %v", err) + } +} + +func TestStampImage(t *testing.T) { + s := New(4, 4) + img := []Row{{1, 2}, {3, 4}} + if err := s.StampImage(1, 1, img); err != nil { + t.Fatal(err) + } + p := s.Snapshot().Pixels + if p[1][1] != 1 || p[1][2] != 2 || p[2][1] != 3 || p[2][2] != 4 { + t.Fatalf("unexpected pixels: %v", p) + } + if err := s.StampImage(3, 3, img); err != nil { + t.Fatalf("clipping should succeed: %v", err) + } + p = s.Snapshot().Pixels + if p[3][3] != 1 { + t.Fatalf("clipped stamp wrong: %v", p) + } + big := make([]Row, 33) + for i := range big { + big[i] = make(Row, 1) + } + if err := s.StampImage(0, 0, big); err != ErrImageTooBig { + t.Fatalf("want ErrImageTooBig, got %v", err) + } +} + +func TestMessages(t *testing.T) { + s := New(4, 4) + if err := s.AddMessage(""); err != ErrEmptyMessage { + t.Fatalf("want ErrEmptyMessage, got %v", err) + } + long := make([]byte, 141) + for i := range long { + long[i] = 'a' + } + if err := s.AddMessage(string(long)); err != ErrMessageLong { + t.Fatalf("want ErrMessageLong, got %v", err) + } + for i := 0; i < 60; i++ { + if err := s.AddMessage("hi"); err != nil { + t.Fatal(err) + } + } + if got := len(s.Snapshot().Messages); got != 50 { + t.Fatalf("ring buffer size %d, want 50", got) + } +} + +func TestPixelsEncodeAsNumbers(t *testing.T) { + s := New(2, 1) + s.SetPixel(1, 0, 7) + data, err := json.Marshal(s.Snapshot()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `"pixels":[[0,7]]`) { + t.Fatalf("pixels must encode as number arrays, got %s", data) + } +} + +func TestSnapshotRoundtrip(t *testing.T) { + s := New(16, 8) + s.SetPixel(5, 5, 9) + s.AddMessage("hello") + path := filepath.Join(t.TempDir(), "snap.json") + if err := s.Save(path); err != nil { + t.Fatal(err) + } + loaded, err := Load(path) + if err != nil { + t.Fatal(err) + } + snap := loaded.Snapshot() + if snap.Width != 16 || snap.Height != 8 { + t.Fatalf("dims %dx%d", snap.Width, snap.Height) + } + if snap.Pixels[5][5] != 9 { + t.Fatalf("pixel not preserved") + } + if len(snap.Messages) != 1 || snap.Messages[0].Text != "hello" { + t.Fatalf("messages not preserved: %v", snap.Messages) + } + if _, err := Load(filepath.Join(t.TempDir(), "missing.json")); !os.IsNotExist(err) { + t.Fatalf("want not-exist error, got %v", err) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..6dedcd4 --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,361 @@ +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]) +} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go new file mode 100644 index 0000000..acfb5f6 --- /dev/null +++ b/internal/tui/tui_test.go @@ -0,0 +1,49 @@ +package tui + +import ( + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "billboard/internal/server" + "billboard/internal/state" +) + +func TestFetchDecodesPixels(t *testing.T) { + st := state.New(8, 4) + if err := st.SetPixel(3, 2, 9); err != nil { + t.Fatal(err) + } + st.AddMessage("test msg") + srv := httptest.NewServer(server.New(st, server.Config{}).Handler()) + defer srv.Close() + + d := &Display{server: srv.URL, client: &http.Client{Timeout: 5 * time.Second}} + if err := d.fetch(); err != nil { + t.Fatal(err) + } + if got := d.state.Pixels[2][3]; got != 9 { + t.Fatalf("pixel = %d, want 9; state=%+v", got, d.state) + } + if len(d.state.Messages) != 1 || d.state.Messages[0].Text != "test msg" { + t.Fatalf("messages = %+v", d.state.Messages) + } + + f, err := os.CreateTemp(t.TempDir(), "render") + if err != nil { + t.Fatal(err) + } + defer f.Close() + d.out = f + d.render() + data, err := os.ReadFile(f.Name()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "91") && !strings.Contains(string(data), "101") { + t.Fatalf("render output lacks color-9 SGR code; block count: %d", strings.Count(string(data), "▀")) + } +}