Files
rgoue/term/tcell.go
sneak 3f0a14c5e6 WIP: restore lost C behaviors (schtick message, forced redraw, greeting)
Incomplete checkpoint, preserved after the implementing session hit a
capacity limit partway through mutation verification. NOT reviewed and NOT
gate-clean: make fmt has not been run, so fmt-check currently fails on
ARCHITECTURE.md and TODO.md.

Work still outstanding before this is fit for review:
  - run make fmt and fold the result in
  - finish mutation-proving each of the three behaviors
  - verify every message string byte-for-byte against the C sources
  - confirm TestSeedCompatItemTables passes with its golden untouched

Refs #13.
2026-08-09 09:55:38 +00:00

234 lines
5.9 KiB
Go

// Package term provides the tcell-backed Terminal for the Rogue port. It
// replaces curses and the 900-line escape-sequence decoder in mdport.c:
// tcell delivers decoded key events, which are translated here to the
// single-byte command codes the game understands.
package term
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"github.com/gdamore/tcell/v2"
"git.eeqj.de/sneak/rgoue/game"
)
// Tcell renders game Windows on a tcell screen and turns key events into
// Rogue input bytes.
type Tcell struct {
screen tcell.Screen
last *game.Window // last rendered window, for resize redraws
}
// ErrScreenTooSmall reports a terminal below the required 80x24.
var ErrScreenTooSmall = errors.New("screen too small")
// New initializes the terminal. The screen must be at least 80x24, as the
// C game required.
func New() (*Tcell, error) {
s, err := tcell.NewScreen()
if err != nil {
return nil, err
}
initErr := s.Init()
if initErr != nil {
return nil, initErr
}
w, h := s.Size()
if h < game.NumLines || w < game.NumCols {
s.Fini()
return nil, fmt.Errorf("sorry, %w: %dx%d required",
ErrScreenTooSmall, game.NumCols, game.NumLines)
}
s.HideCursor()
return &Tcell{screen: s}, nil
}
// Fini restores the terminal.
func (t *Tcell) Fini() {
t.screen.Fini()
}
// Render blits a game window to the terminal (curses refresh).
func (t *Tcell) Render(w *game.Window) {
t.last = w
rows, cols := w.Size()
for y := range rows {
for x := range cols {
ch, standout := w.CellAt(y, x)
style := tcell.StyleDefault
if standout {
style = style.Reverse(true)
}
t.screen.SetContent(x, y, rune(ch), nil, style)
}
}
t.screen.Show()
}
// Repaint redraws the whole physical screen from tcell's content buffer
// — which holds what Render last blitted, so this is C's clearok(curscr,
// TRUE) + wrefresh(curscr) (command.c, the CTRL('R') arm) rather than a
// fresh draw of stdscr. Sync throws away tcell's record of what the
// terminal is showing, so unlike Show it repaints cells it believes are
// already correct, which is what makes it fix a corrupted screen.
func (t *Tcell) Repaint() {
t.screen.Sync()
}
// ReadChar blocks for the next key, translated to the byte codes the C
// game reads: arrows become hjkl, control keys their C0 codes. ok is
// false when Interrupt woke the read instead of a key arriving.
func (t *Tcell) ReadChar() (byte, bool) {
for {
ev := t.screen.PollEvent()
switch ev := ev.(type) {
case *tcell.EventResize:
if t.last != nil {
t.Render(t.last)
}
case *tcell.EventInterrupt:
// Interrupt posted this from the signal goroutine: hand
// control back so the game goroutine can service a pending
// autosave, then it reads again.
return 0, false
case *tcell.EventKey:
if b, ok := translateKey(ev); ok {
return b, true
}
}
}
}
// Interrupt wakes a ReadChar parked in PollEvent by posting an interrupt
// event onto tcell's own event queue — the mechanism tcell provides for
// exactly this, and the only Tcell method called from another goroutine
// (Screen.PostEvent is a channel send, safe to call concurrently).
//
// Best effort by design: PostEvent fails only when the event queue is
// full, which means the game goroutine is not parked waiting for a key,
// and a game goroutine that is running turns reaches the between-turns
// check on its own.
func (t *Tcell) Interrupt() {
_ = t.screen.PostEvent(tcell.NewEventInterrupt(nil))
}
// translateKey converts a key event to a game input byte; ok is false
// for keys the C game does not understand.
func translateKey(ev *tcell.EventKey) (byte, bool) {
if b, ok := namedKey(ev.Key()); ok {
return b, true
}
if ev.Key() >= tcell.KeyCtrlA && ev.Key() <= tcell.KeyCtrlZ {
return byte(ev.Key()), true //nolint:gosec // G115: 1..26 fits
}
if r := ev.Rune(); r > 0 && r < 0x80 {
return byte(r), true
}
return 0, false
}
// namedKey translates tcell's navigation and editing keys to the single
// bytes the C game reads (arrows become hjkl, etc.); ok is false for
// keys handled elsewhere.
func namedKey(k tcell.Key) (byte, bool) {
if b, ok := motionKey(k); ok {
return b, true
}
return editingKey(k)
}
// motionKey translates the arrow and paging keys to Rogue's movement
// letters (tcell.go ReadChar).
func motionKey(k tcell.Key) (byte, bool) {
//nolint:exhaustive // translation table: all other keys fall through
switch k {
case tcell.KeyUp:
return 'k', true
case tcell.KeyDown:
return 'j', true
case tcell.KeyLeft:
return 'h', true
case tcell.KeyRight:
return 'l', true
case tcell.KeyHome:
return 'y', true
case tcell.KeyPgUp:
return 'u', true
case tcell.KeyEnd:
return 'b', true
case tcell.KeyPgDn:
return 'n', true
}
return 0, false
}
// editingKey translates the editing and control keys to their C0 codes
// (tcell.go ReadChar).
func editingKey(k tcell.Key) (byte, bool) {
//nolint:exhaustive // translation table: all other keys fall through
switch k {
case tcell.KeyEnter:
return '\n', true
case tcell.KeyEscape:
return game.Escape, true
case tcell.KeyBackspace, tcell.KeyBackspace2:
return '\b', true
case tcell.KeyDelete:
return '\x7f', true
case tcell.KeyTab:
return '\t', true
case tcell.KeyCtrlC:
return '\x03', true
}
return 0, false
}
// ShellEscape suspends the screen and runs the user's shell (main.c
// shell + md_shellescape).
func (t *Tcell) ShellEscape() {
err := t.screen.Suspend()
if err != nil {
return
}
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
}
_, _ = fmt.Fprintln(os.Stdout,
"[Entering shell; exit to return to the game]")
// The shell session has no deadline by design; Background context.
//nolint:gosec // G204: the user's own $SHELL
cmd := exec.CommandContext(context.Background(), shell)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run() // best effort: the shell is the user's business
resumeErr := t.screen.Resume()
if resumeErr != nil {
panic(resumeErr) // terminal resume failure is unrecoverable
}
}