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