Initial commit
This commit is contained in:
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user