Initial build: scrawl, a shared ASCII canvas over SSH
ssh in and draw on a live, shared doodle wall -- everyone connected sees everyone else's edits in real time. Same wish+bubbletea security model as delve-term (no shell, no exec, structurally can't reach a real shell on the host), extended with the one thing delve-term didn't need: multiple sessions sharing live state. wish's bm.Middleware helper hides the *tea.Program it creates, so this builds the program directly instead, keeping a registry (game.Canvas) that broadcasts a redraw signal to every other connected session the instant one of them paints. Banner generated via `figlet -f slant SCRAWL` rather than hand-drawn -- slant felt right for a doodle/scribble tool. Two real bugs caught by actually running this with real SSH sessions, not just unit-testing the game logic in isolation: 1. Program.Send() blocks until that program's Run() event loop is actively reading from it. Broadcasting synchronously from inside Join()/Paint() (including a session broadcasting to its own, not-yet-running program on join) deadlocked every session before it ever reached Run() -- the very first connection just hung with nothing rendered. Fixed by sending asynchronously (go p.Send(...)) everywhere the canvas notifies sessions of a change. 2. Subtler: lipgloss's default package-level styles detect color support from the *server process's* os.Stdout, not any given session's actual terminal -- and a server's stdout is typically redirected (a log file, systemd journal), so every connected session silently lost all color/background styling at once, server-wide. Manifested as painting a cell your own cursor already sat on being invisible (the cursor glyph before/after looked identical, so bubbletea's diffing renderer correctly sent zero bytes for a change that produced no visual diff) -- confirmed via server-side debug logging that painting itself worked correctly every time, isolating the bug to rendering, then confirmed via a Go test that forcing a real color profile was the difference between 0 and 1327 runes of diff between two frames that should look different. Fixed with a lipgloss.Renderer created per-session, bound to that session's actual output, forced to TrueColor; cursors now highlight whatever's already painted at that cell (background tint) rather than replacing the character, so a session's own paint is never masked by its own cursor marker sitting on top of it. Verified end-to-end with two real, simultaneous SSH sessions (scripted via pexpect): peer count syncs correctly, and one session's paint genuinely arrives at the other via the live broadcast -- not just unit-tested in isolation. Also verified the actual Docker image builds and serves correctly over real SSH. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Copy to .env to override docker-compose.yml defaults.
|
||||||
|
IMAGE=repo.explewd.com/explewd/scrawl:latest
|
||||||
|
HOST_PORT=23235
|
||||||
53
.gitea/workflows/docker.yml
Normal file
53
.gitea/workflows/docker.yml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
name: Docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Derive image name and tags
|
||||||
|
id: meta
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SERVER_URL="${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-}}"
|
||||||
|
REPO="${GITHUB_REPOSITORY:-${GITEA_REPOSITORY:-}}"
|
||||||
|
SHA="${GITHUB_SHA:-${GITEA_SHA:-}}"
|
||||||
|
if [[ -z "${SERVER_URL}" || -z "${REPO}" || -z "${SHA}" ]]; then
|
||||||
|
echo "Missing SERVER_URL/REPO/SHA env vars." >&2; exit 1
|
||||||
|
fi
|
||||||
|
HOST=$(echo "${SERVER_URL}" | sed 's|https://||;s|http://||')
|
||||||
|
IMAGE=$(echo "${HOST}/${REPO}" | tr '[:upper:]' '[:lower:]')
|
||||||
|
SHORT_SHA=$(echo "${SHA}" | cut -c1-7)
|
||||||
|
echo "host=${HOST}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Log in to Gitea container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ steps.meta.outputs.host }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
# Create a Gitea PAT with packages:write scope and add it as a
|
||||||
|
# repository secret named TKNTKN (Settings → Secrets)
|
||||||
|
password: ${{ secrets.TKNTKN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ steps.meta.outputs.image }}:latest
|
||||||
|
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }}
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.ssh/
|
||||||
|
scrawl
|
||||||
|
.env
|
||||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM golang:1.24-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -o /scrawl .
|
||||||
|
|
||||||
|
FROM alpine:3.20
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
COPY --from=build /scrawl /usr/local/bin/scrawl
|
||||||
|
# Host key persists in a volume — see docker-compose.yml — so it doesn't
|
||||||
|
# regenerate (and change the server's fingerprint) on every redeploy.
|
||||||
|
ENV HOST_KEY_PATH=/data/host_key
|
||||||
|
EXPOSE 23235
|
||||||
|
CMD ["scrawl"]
|
||||||
71
README.md
Normal file
71
README.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# scrawl
|
||||||
|
|
||||||
|
A shared ASCII doodle wall you connect to over SSH. Everyone connected
|
||||||
|
right now is drawing on the same canvas, live.
|
||||||
|
|
||||||
|
```
|
||||||
|
ssh scrawl.goonk.se -p 23235
|
||||||
|
```
|
||||||
|
|
||||||
|
Not aiming to be useful to anyone but me — built purely because a
|
||||||
|
multiplayer canvas over raw SSH is a genuinely fun, weird shape for a
|
||||||
|
toy, and the `wish`+`bubbletea` pattern from
|
||||||
|
[delve-term](https://repo.explewd.com/explewd/delve-term) was still
|
||||||
|
fresh enough to reuse without re-learning it.
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
- Arrow keys or `hjkl` — move your cursor
|
||||||
|
- `space` / `enter` — paint
|
||||||
|
- `backspace` / `delete` — erase
|
||||||
|
- `[` / `]` — cycle the ink character
|
||||||
|
- `1`-`8` — pick a color
|
||||||
|
- `c` — clear the whole canvas (yes, for everyone — it's a shared wall)
|
||||||
|
- `q` / `ctrl+c` — quit
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Same security model as `delve-term`: the SSH server
|
||||||
|
([`charmbracelet/wish`](https://github.com/charmbracelet/wish)) hands
|
||||||
|
each connection straight to a [bubbletea](https://github.com/charmbracelet/bubbletea)
|
||||||
|
program — no shell, no exec, structurally can't reach a real shell on
|
||||||
|
the host.
|
||||||
|
|
||||||
|
The one thing this app needed beyond `delve-term`'s pattern: multiple
|
||||||
|
sessions sharing live state. `wish`'s `bm.Middleware` helper hides the
|
||||||
|
`*tea.Program` it creates behind its own closure, so this app builds the
|
||||||
|
program directly instead, keeping a reference in a small registry
|
||||||
|
(`game.Canvas`) that broadcasts a redraw signal to every *other*
|
||||||
|
connected session the instant one of them paints.
|
||||||
|
|
||||||
|
A real bug worth knowing about if you're extending this: `Program.Send()`
|
||||||
|
blocks until that program's `Run()` event loop is actively reading from
|
||||||
|
it. Broadcasting synchronously from inside `Join()`/`Paint()` deadlocked
|
||||||
|
every session before it ever reached `Run()` — fixed by sending
|
||||||
|
asynchronously (`go p.Send(...)`) everywhere the canvas notifies
|
||||||
|
sessions of a change.
|
||||||
|
|
||||||
|
A second, subtler bug: `lipgloss`'s default styles detect color support
|
||||||
|
from the *server process's* `os.Stdout`, not any given session's actual
|
||||||
|
terminal — and a server's stdout is typically redirected (a log file, a
|
||||||
|
systemd journal), so every session silently lost all color/background
|
||||||
|
styling at once. Fixed with a `lipgloss.Renderer` created per-session,
|
||||||
|
bound to that session's actual output, forced to `TrueColor`.
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run-local.sh # starts on :23235
|
||||||
|
ssh -p 23235 localhost -o StrictHostKeyChecking=no # in another terminal
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Verified with two simultaneous SSH sessions (scripted, not just live
|
||||||
|
poking) — one paints, the other sees it via the broadcast, peer count
|
||||||
|
stays in sync. No persistence: canvas state lives in memory only and
|
||||||
|
resets on restart, which is fine for what this is.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT.
|
||||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: ${IMAGE}
|
||||||
|
container_name: scrawl
|
||||||
|
restart: unless-stopped
|
||||||
|
pull_policy: always
|
||||||
|
ports:
|
||||||
|
- "${HOST_PORT:-23235}:23235"
|
||||||
|
volumes:
|
||||||
|
# Named volume — just the SSH host key, so the server's fingerprint
|
||||||
|
# stays stable across redeploys instead of changing (and tripping
|
||||||
|
# every client's "REMOTE HOST IDENTIFICATION HAS CHANGED" warning)
|
||||||
|
# every time a new image ships.
|
||||||
|
- scrawl-data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
scrawl-data:
|
||||||
168
game/canvas.go
Normal file
168
game/canvas.go
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
Width = 76
|
||||||
|
Height = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
type Cell struct {
|
||||||
|
Char rune
|
||||||
|
Color string // lipgloss-compatible ANSI color string, "" = unset
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor is another connected session's position, shown as a marker on
|
||||||
|
// top of the canvas so drawing feels like a shared space, not a diff you
|
||||||
|
// only notice after the fact.
|
||||||
|
type Cursor struct {
|
||||||
|
X, Y int
|
||||||
|
Color string
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
// canvasUpdatedMsg is broadcast to every connected session's bubbletea
|
||||||
|
// program whenever the shared state changes — cells or cursors — so each
|
||||||
|
// client's own Update/View loop redraws without polling.
|
||||||
|
type canvasUpdatedMsg struct{}
|
||||||
|
|
||||||
|
var cursorPalette = []string{"#ff6b6b", "#feca57", "#1dd1a1", "#54a0ff", "#ff9ff3", "#48dbfb", "#f368e0", "#00d2d3"}
|
||||||
|
|
||||||
|
// Canvas is the single shared drawing surface plus the registry of
|
||||||
|
// connected sessions' bubbletea programs, used purely to fan a redraw
|
||||||
|
// signal out to everyone whenever anything changes -- there's no other
|
||||||
|
// use of the registry (no per-session logic reaches back in through it).
|
||||||
|
type Canvas struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
cells [Height][Width]Cell
|
||||||
|
cursors map[string]*Cursor
|
||||||
|
programs map[string]*tea.Program
|
||||||
|
nextColorIdx int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCanvas() *Canvas {
|
||||||
|
return &Canvas{
|
||||||
|
cursors: make(map[string]*Cursor),
|
||||||
|
programs: make(map[string]*tea.Program),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Canvas) Join(id string, label string, p *tea.Program) *Cursor {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
color := cursorPalette[c.nextColorIdx%len(cursorPalette)]
|
||||||
|
c.nextColorIdx++
|
||||||
|
cur := &Cursor{X: Width / 2, Y: Height / 2, Color: color, Label: label}
|
||||||
|
c.cursors[id] = cur
|
||||||
|
c.programs[id] = p
|
||||||
|
c.broadcastLocked()
|
||||||
|
return cur
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Canvas) Leave(id string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
delete(c.cursors, id)
|
||||||
|
delete(c.programs, id)
|
||||||
|
c.broadcastLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Canvas) Paint(x, y int, char rune, color string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if x < 0 || x >= Width || y < 0 || y >= Height {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.cells[y][x] = Cell{Char: char, Color: color}
|
||||||
|
c.broadcastLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Canvas) Clear() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.cells = [Height][Width]Cell{}
|
||||||
|
c.broadcastLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Canvas) MoveCursor(id string, dx, dy int) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
cur, ok := c.cursors[id]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cur.X = clamp(cur.X+dx, 0, Width-1)
|
||||||
|
cur.Y = clamp(cur.Y+dy, 0, Height-1)
|
||||||
|
c.broadcastLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns a copy of the current cells and cursors, safe to read
|
||||||
|
// from a View() call without holding the lock while rendering.
|
||||||
|
func (c *Canvas) Snapshot() (cells [Height][Width]Cell, cursors map[string]Cursor) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
cursors = make(map[string]Cursor, len(c.cursors))
|
||||||
|
for id, cur := range c.cursors {
|
||||||
|
cursors[id] = *cur
|
||||||
|
}
|
||||||
|
return c.cells, cursors
|
||||||
|
}
|
||||||
|
|
||||||
|
// CursorsSnapshot is Snapshot() without paying for a cell-array copy when
|
||||||
|
// only cursor positions are needed (the common case for input handling).
|
||||||
|
func (c *Canvas) CursorsSnapshot() map[string]Cursor {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
cursors := make(map[string]Cursor, len(c.cursors))
|
||||||
|
for id, cur := range c.cursors {
|
||||||
|
cursors[id] = *cur
|
||||||
|
}
|
||||||
|
return cursors
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Canvas) PeerCount() int {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return len(c.cursors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcastLocked must be called with c.mu already held. Each Send runs in
|
||||||
|
// its own goroutine rather than inline: a just-joined program's Run()
|
||||||
|
// loop hasn't started reading its input channel yet at the moment Join()
|
||||||
|
// calls this (Join registers the program, then broadcasts, before the
|
||||||
|
// caller gets around to calling p.Run()) -- an inline, blocking Send to
|
||||||
|
// that program would deadlock the whole session handler before it ever
|
||||||
|
// reaches Run(). Confirmed by hitting exactly that deadlock in testing:
|
||||||
|
// the very first session hung with nothing rendered.
|
||||||
|
func (c *Canvas) broadcastLocked() {
|
||||||
|
for _, p := range c.programs {
|
||||||
|
if p == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go p.Send(canvasUpdatedMsg{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp(v, lo, hi int) int {
|
||||||
|
if v < lo {
|
||||||
|
return lo
|
||||||
|
}
|
||||||
|
if v > hi {
|
||||||
|
return hi
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func RandomID() string {
|
||||||
|
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
b := make([]byte, 8)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = chars[rand.Intn(len(chars))]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
95
game/model.go
Normal file
95
game/model.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
var inkPalette = []string{"#ff6b6b", "#feca57", "#1dd1a1", "#54a0ff", "#ff9ff3", "#48dbfb", "#f368e0", "#ffffff"}
|
||||||
|
var charPalette = []rune{'█', '▓', '▒', '░', '#', '*', '.', '@', 'o', '+'}
|
||||||
|
|
||||||
|
type state int
|
||||||
|
|
||||||
|
const (
|
||||||
|
stateIntro state = iota
|
||||||
|
stateCanvas
|
||||||
|
)
|
||||||
|
|
||||||
|
type Model struct {
|
||||||
|
canvas *Canvas
|
||||||
|
id string
|
||||||
|
label string
|
||||||
|
state state
|
||||||
|
renderer *lipgloss.Renderer
|
||||||
|
|
||||||
|
inkColorIdx int
|
||||||
|
charIdx int
|
||||||
|
|
||||||
|
width, height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewModel(canvas *Canvas, id, label string, renderer *lipgloss.Renderer) Model {
|
||||||
|
return Model{canvas: canvas, id: id, label: label, state: stateIntro, renderer: renderer}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width, m.height = msg.Width, msg.Height
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case canvasUpdatedMsg:
|
||||||
|
// Someone (possibly this session) changed the shared state --
|
||||||
|
// nothing to do here but re-render; View() always reads a fresh
|
||||||
|
// snapshot from the canvas.
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case tea.KeyMsg:
|
||||||
|
if m.state == stateIntro {
|
||||||
|
m.state = stateCanvas
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m.handleKey(msg)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.String() {
|
||||||
|
case "ctrl+c", "q":
|
||||||
|
return m, tea.Quit
|
||||||
|
|
||||||
|
case "up", "k":
|
||||||
|
m.canvas.MoveCursor(m.id, 0, -1)
|
||||||
|
case "down", "j":
|
||||||
|
m.canvas.MoveCursor(m.id, 0, 1)
|
||||||
|
case "left", "h":
|
||||||
|
m.canvas.MoveCursor(m.id, -1, 0)
|
||||||
|
case "right", "l":
|
||||||
|
m.canvas.MoveCursor(m.id, 1, 0)
|
||||||
|
|
||||||
|
case " ", "enter":
|
||||||
|
if cur, ok := m.canvas.CursorsSnapshot()[m.id]; ok {
|
||||||
|
m.canvas.Paint(cur.X, cur.Y, charPalette[m.charIdx], inkPalette[m.inkColorIdx])
|
||||||
|
}
|
||||||
|
|
||||||
|
case "backspace", "delete":
|
||||||
|
if cur, ok := m.canvas.CursorsSnapshot()[m.id]; ok {
|
||||||
|
m.canvas.Paint(cur.X, cur.Y, ' ', "")
|
||||||
|
}
|
||||||
|
|
||||||
|
case "[":
|
||||||
|
m.charIdx = (m.charIdx - 1 + len(charPalette)) % len(charPalette)
|
||||||
|
case "]":
|
||||||
|
m.charIdx = (m.charIdx + 1) % len(charPalette)
|
||||||
|
|
||||||
|
case "1", "2", "3", "4", "5", "6", "7", "8":
|
||||||
|
m.inkColorIdx = int(msg.String()[0] - '1')
|
||||||
|
|
||||||
|
case "c":
|
||||||
|
m.canvas.Clear()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
120
game/model_test.go
Normal file
120
game/model_test.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/muesli/termenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestColorAndCharSwitching(t *testing.T) {
|
||||||
|
c := NewCanvas()
|
||||||
|
m := NewModel(c, "test-id", "tester", newTestRenderer())
|
||||||
|
c.Join("test-id", "tester", nil)
|
||||||
|
|
||||||
|
// First keypress just exits the intro screen, per Update()'s own logic.
|
||||||
|
mi, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("3")})
|
||||||
|
m = mi.(Model)
|
||||||
|
if m.state != stateCanvas {
|
||||||
|
t.Fatalf("expected first keypress to exit intro, state=%v", m.state)
|
||||||
|
}
|
||||||
|
if m.inkColorIdx != 0 {
|
||||||
|
t.Fatalf("expected inkColorIdx unchanged by the intro-exit keypress, got %d", m.inkColorIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now actually press "3" for color selection.
|
||||||
|
mi, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("3")})
|
||||||
|
m = mi.(Model)
|
||||||
|
if m.inkColorIdx != 2 {
|
||||||
|
t.Fatalf("expected inkColorIdx=2 after pressing '3', got %d", m.inkColorIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paint with the selected color.
|
||||||
|
mi, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||||
|
m = mi.(Model)
|
||||||
|
|
||||||
|
cells, cursors := c.Snapshot()
|
||||||
|
cur := cursors["test-id"]
|
||||||
|
painted := cells[cur.Y][cur.X]
|
||||||
|
if painted.Char != charPalette[0] {
|
||||||
|
t.Fatalf("expected painted char to be default charPalette[0]=%q, got %q", charPalette[0], painted.Char)
|
||||||
|
}
|
||||||
|
if painted.Color != inkPalette[2] {
|
||||||
|
t.Fatalf("expected painted color to be inkPalette[2]=%q (selected via '3'), got %q", inkPalette[2], painted.Color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCharCycling(t *testing.T) {
|
||||||
|
c := NewCanvas()
|
||||||
|
m := NewModel(c, "test-id", "tester", newTestRenderer())
|
||||||
|
c.Join("test-id", "tester", nil)
|
||||||
|
|
||||||
|
mi, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) // exit intro
|
||||||
|
m = mi.(Model)
|
||||||
|
|
||||||
|
mi, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("]")})
|
||||||
|
m = mi.(Model)
|
||||||
|
if m.charIdx != 1 {
|
||||||
|
t.Fatalf("expected charIdx=1 after ']', got %d", m.charIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewChangesAfterMoveAwayFromPaint(t *testing.T) {
|
||||||
|
c := NewCanvas()
|
||||||
|
m := NewModel(c, "test-id", "tester", newTestRenderer())
|
||||||
|
c.Join("test-id", "tester", nil)
|
||||||
|
|
||||||
|
mi, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) // exit intro
|
||||||
|
m = mi.(Model)
|
||||||
|
mi, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace}) // paint
|
||||||
|
m = mi.(Model)
|
||||||
|
|
||||||
|
viewAtPaintPosition := m.View()
|
||||||
|
|
||||||
|
mi, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}) // move right
|
||||||
|
m = mi.(Model)
|
||||||
|
|
||||||
|
viewAfterMove := m.View()
|
||||||
|
|
||||||
|
if viewAtPaintPosition == viewAfterMove {
|
||||||
|
t.Fatal("expected View() output to differ after moving away from a painted cell, but it's identical")
|
||||||
|
}
|
||||||
|
t.Logf("view at paint position:\n%s", viewAtPaintPosition)
|
||||||
|
t.Logf("view after move:\n%s", viewAfterMove)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveCursorActuallyMoves(t *testing.T) {
|
||||||
|
c := NewCanvas()
|
||||||
|
m := NewModel(c, "test-id", "tester", newTestRenderer())
|
||||||
|
c.Join("test-id", "tester", nil)
|
||||||
|
|
||||||
|
before := c.CursorsSnapshot()["test-id"]
|
||||||
|
t.Logf("before: x=%d y=%d", before.X, before.Y)
|
||||||
|
|
||||||
|
mi, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) // exit intro
|
||||||
|
m = mi.(Model)
|
||||||
|
mi, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}) // move right
|
||||||
|
m = mi.(Model)
|
||||||
|
|
||||||
|
after := c.CursorsSnapshot()["test-id"]
|
||||||
|
t.Logf("after: x=%d y=%d", after.X, after.Y)
|
||||||
|
|
||||||
|
if after.X != before.X+1 {
|
||||||
|
t.Fatalf("expected cursor X to move from %d to %d, got %d", before.X, before.X+1, after.X)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestRenderer forces TrueColor rather than letting lipgloss
|
||||||
|
// auto-detect from the writer (io.Discard, like any non-*os.File writer,
|
||||||
|
// would otherwise be treated as non-color-capable) -- matches main.go's
|
||||||
|
// real per-session renderer setup, so these tests actually exercise the
|
||||||
|
// same styling path a real SSH session does instead of silently testing
|
||||||
|
// an unstyled/plain-text code path that would mask exactly this class
|
||||||
|
// of bug.
|
||||||
|
func newTestRenderer() *lipgloss.Renderer {
|
||||||
|
r := lipgloss.NewRenderer(io.Discard)
|
||||||
|
r.SetColorProfile(termenv.TrueColor)
|
||||||
|
return r
|
||||||
|
}
|
||||||
119
game/view.go
Normal file
119
game/view.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generated via `figlet -f slant SCRAWL` — the slant felt right for a
|
||||||
|
// doodle/scribble tool, not hand-tuned character by character.
|
||||||
|
const banner = ` _____ __________ ___ _ ____
|
||||||
|
/ ___// ____/ __ \/ | | / / /
|
||||||
|
\__ \/ / / /_/ / /| | | /| / / /
|
||||||
|
___/ / /___/ _, _/ ___ | |/ |/ / /___
|
||||||
|
/____/\____/_/ |_/_/ |_|__/|__/_____/`
|
||||||
|
|
||||||
|
func (m Model) View() string {
|
||||||
|
if m.state == stateIntro {
|
||||||
|
return m.renderIntro()
|
||||||
|
}
|
||||||
|
return m.renderCanvas()
|
||||||
|
}
|
||||||
|
|
||||||
|
// style is a small accessor so every render call goes through m.renderer
|
||||||
|
// (bound to this specific SSH session's actual output) rather than
|
||||||
|
// lipgloss's package-level default styles, which detect color support
|
||||||
|
// from the *server process's* os.Stdout -- not any given session's
|
||||||
|
// terminal. That's a real bug this app hit: the server's stdout is
|
||||||
|
// typically redirected (a log file, a systemd journal, anything that
|
||||||
|
// isn't a TTY), so the default global renderer silently disabled all
|
||||||
|
// color/background styling for every connected session at once,
|
||||||
|
// regardless of what terminal they were actually using.
|
||||||
|
func (m Model) style() lipgloss.Style {
|
||||||
|
return m.renderer.NewStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) renderIntro() string {
|
||||||
|
title := m.style().Bold(true).Foreground(lipgloss.Color("#ff6b6b")).Render(banner)
|
||||||
|
dim := m.style().Foreground(lipgloss.Color("#777777"))
|
||||||
|
body := title + "\n" +
|
||||||
|
dim.Render("a shared doodle wall, one keystroke at a time") + "\n\n" +
|
||||||
|
"Everyone connected right now is drawing on the same canvas.\n" +
|
||||||
|
"Move with arrow keys or hjkl, space/enter to paint, backspace to\n" +
|
||||||
|
"erase. [ and ] cycle the ink character, 1-8 pick a color, c clears\n" +
|
||||||
|
"everything for everyone (yes, really — it's a shared wall).\n\n" +
|
||||||
|
dim.Render("press any key to start")
|
||||||
|
return m.boxStyle().Render(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) boxStyle() lipgloss.Style {
|
||||||
|
return m.style().Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("#555555")).Padding(1, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) renderCanvas() string {
|
||||||
|
cells, cursors := m.canvas.Snapshot()
|
||||||
|
|
||||||
|
// Cursors are a background highlight on whatever's actually painted at
|
||||||
|
// that cell, not a glyph that replaces it -- a glyph-replace bug meant
|
||||||
|
// painting a cell your own cursor already sat on was invisible: the
|
||||||
|
// cursor marker before and after painting rendered as the exact same
|
||||||
|
// character in the exact same color, so bubbletea's diffing renderer
|
||||||
|
// (correctly) sent zero bytes, since nothing had visually changed.
|
||||||
|
type painted struct {
|
||||||
|
ch rune
|
||||||
|
color string
|
||||||
|
bg string
|
||||||
|
}
|
||||||
|
grid := make([][]painted, Height)
|
||||||
|
for y := 0; y < Height; y++ {
|
||||||
|
grid[y] = make([]painted, Width)
|
||||||
|
for x := 0; x < Width; x++ {
|
||||||
|
c := cells[y][x]
|
||||||
|
if c.Char == 0 {
|
||||||
|
grid[y][x] = painted{ch: ' '}
|
||||||
|
} else {
|
||||||
|
grid[y][x] = painted{ch: c.Char, color: c.Color}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id, cur := range cursors {
|
||||||
|
if id == m.id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
grid[cur.Y][cur.X].bg = cur.Color
|
||||||
|
}
|
||||||
|
if self, ok := cursors[m.id]; ok {
|
||||||
|
grid[self.Y][self.X].bg = inkPalette[m.inkColorIdx]
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for y := 0; y < Height; y++ {
|
||||||
|
for x := 0; x < Width; x++ {
|
||||||
|
p := grid[y][x]
|
||||||
|
style := m.style()
|
||||||
|
if p.color != "" {
|
||||||
|
style = style.Foreground(lipgloss.Color(p.color))
|
||||||
|
}
|
||||||
|
if p.bg != "" {
|
||||||
|
style = style.Background(lipgloss.Color(p.bg)).Foreground(lipgloss.Color("#000000"))
|
||||||
|
}
|
||||||
|
ch := p.ch
|
||||||
|
if ch == 0 || ch == ' ' {
|
||||||
|
ch = ' '
|
||||||
|
}
|
||||||
|
b.WriteString(style.Render(" "))
|
||||||
|
b.WriteString(style.Render(string(ch)))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
inkSwatch := m.style().Foreground(lipgloss.Color(inkPalette[m.inkColorIdx])).Render(string(charPalette[m.charIdx]))
|
||||||
|
dim := m.style().Foreground(lipgloss.Color("#777777"))
|
||||||
|
status := fmt.Sprintf("ink %s (1-8 color, [ ] char) peers: %d %s",
|
||||||
|
inkSwatch, m.canvas.PeerCount(), dim.Render("space paint · c clear · q quit"))
|
||||||
|
|
||||||
|
return m.boxStyle().Render(b.String() + "\n" + status)
|
||||||
|
}
|
||||||
40
go.mod
Normal file
40
go.mod
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
module scrawl
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894
|
||||||
|
github.com/charmbracelet/wish v1.4.7
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
|
github.com/charmbracelet/keygen v0.5.3 // indirect
|
||||||
|
github.com/charmbracelet/log v0.4.1 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
|
github.com/charmbracelet/x/conpty v0.1.0 // indirect
|
||||||
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
|
github.com/charmbracelet/x/termios v0.1.0 // indirect
|
||||||
|
github.com/creack/pty v1.1.21 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
golang.org/x/crypto v0.36.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
||||||
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
|
golang.org/x/text v0.23.0 // indirect
|
||||||
|
)
|
||||||
75
go.sum
Normal file
75
go.sum
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||||
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||||
|
github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4=
|
||||||
|
github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk=
|
||||||
|
github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I=
|
||||||
|
github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894 h1:Ffon9TbltLGBsT6XE//YvNuu4OAaThXioqalhH11xEw=
|
||||||
|
github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894/go.mod h1:hg+I6gvlMl16nS9ZzQNgBIrrCasGwEw0QiLsDcP01Ko=
|
||||||
|
github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc=
|
||||||
|
github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||||
|
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||||
|
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||||
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||||
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.0 h1:y4rjAHeFksBAfGbkRDmVinMg7x7DELIGAFbdNvxg97k=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.0/go.mod h1:H/EVv/KRnrYjz+fCYa9bsKdqF3S8ouDK0AZEbG7r+/U=
|
||||||
|
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
|
||||||
|
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||||
|
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||||
|
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||||
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||||
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
||||||
|
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||||
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
123
main.go
Normal file
123
main.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/ssh"
|
||||||
|
"github.com/charmbracelet/wish"
|
||||||
|
"github.com/charmbracelet/wish/logging"
|
||||||
|
"github.com/muesli/termenv"
|
||||||
|
|
||||||
|
"scrawl/game"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "23235"
|
||||||
|
}
|
||||||
|
hostKeyPath := os.Getenv("HOST_KEY_PATH")
|
||||||
|
if hostKeyPath == "" {
|
||||||
|
hostKeyPath = ".ssh/scrawl_host_key"
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas := game.NewCanvas()
|
||||||
|
|
||||||
|
// Not wish/bubbletea's bm.Middleware helper -- that hides the
|
||||||
|
// *tea.Program behind its own closure, and the whole point of this
|
||||||
|
// app is a shared canvas that broadcasts a redraw to every OTHER
|
||||||
|
// connected session the moment one of them paints. Building the
|
||||||
|
// program directly keeps that reference so canvas.Join/Leave can
|
||||||
|
// register it. Same "no shell, no exec" guarantee as delve-term:
|
||||||
|
// this program is the entire session handler, nothing else is ever
|
||||||
|
// reachable through it.
|
||||||
|
scrawlMiddleware := func(next ssh.Handler) ssh.Handler {
|
||||||
|
return func(s ssh.Session) {
|
||||||
|
pty, winCh, isPty := s.Pty()
|
||||||
|
if !isPty {
|
||||||
|
next(s)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := game.RandomID()
|
||||||
|
label := s.User()
|
||||||
|
if label == "" {
|
||||||
|
label = id
|
||||||
|
}
|
||||||
|
|
||||||
|
// A renderer bound to lipgloss's package-level default detects
|
||||||
|
// color support from the *server process's* os.Stdout -- not
|
||||||
|
// this session's actual terminal, and that stdout is typically
|
||||||
|
// redirected (a log file, systemd journal), so every session
|
||||||
|
// would silently lose all color/background styling at once.
|
||||||
|
// Confirmed by hitting exactly that: a cursor's background
|
||||||
|
// highlight change produced zero output bytes over live SSH
|
||||||
|
// until styles were rebound to a per-session renderer forced
|
||||||
|
// to color output.
|
||||||
|
renderer := lipgloss.NewRenderer(s)
|
||||||
|
renderer.SetColorProfile(termenv.TrueColor)
|
||||||
|
m := game.NewModel(canvas, id, label, renderer)
|
||||||
|
|
||||||
|
p := tea.NewProgram(m,
|
||||||
|
tea.WithInput(s),
|
||||||
|
tea.WithOutput(s),
|
||||||
|
tea.WithAltScreen(),
|
||||||
|
)
|
||||||
|
|
||||||
|
canvas.Join(id, label, p)
|
||||||
|
defer canvas.Leave(id)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for w := range winCh {
|
||||||
|
p.Send(tea.WindowSizeMsg{Width: w.Width, Height: w.Height})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// Async, same reason as canvas.go's broadcastLocked: Program.Send
|
||||||
|
// blocks until Run()'s event loop is reading from it, which hasn't
|
||||||
|
// started yet at this line -- an inline Send here deadlocked every
|
||||||
|
// session before it ever reached Run(), confirmed by testing.
|
||||||
|
go p.Send(tea.WindowSizeMsg{Width: pty.Window.Width, Height: pty.Window.Height})
|
||||||
|
|
||||||
|
if _, err := p.Run(); err != nil {
|
||||||
|
log.Printf("session error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := wish.NewServer(
|
||||||
|
wish.WithAddress(net.JoinHostPort("0.0.0.0", port)),
|
||||||
|
wish.WithHostKeyPath(hostKeyPath),
|
||||||
|
wish.WithMiddleware(
|
||||||
|
scrawlMiddleware,
|
||||||
|
logging.Middleware(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(done, os.Interrupt, syscall.SIGTERM)
|
||||||
|
log.Printf("scrawl listening on :%s", port)
|
||||||
|
go func() {
|
||||||
|
if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-done
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := s.Shutdown(ctx); err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
run-local.sh
Executable file
11
run-local.sh
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Local test-run helper — not part of the deploy path (see Dockerfile /
|
||||||
|
# docker-compose.yml for that). Persists a host key under .ssh/ so the
|
||||||
|
# server's fingerprint doesn't change between runs and nag every client.
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
mkdir -p .ssh
|
||||||
|
export PORT="${PORT:-23235}"
|
||||||
|
export HOST_KEY_PATH="${HOST_KEY_PATH:-.ssh/scrawl_host_key}"
|
||||||
|
echo "starting scrawl on :$PORT -- connect with: ssh -p $PORT localhost"
|
||||||
|
go run .
|
||||||
Reference in New Issue
Block a user