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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user