// 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() } // ReadChar blocks for the next key, translated to the byte codes the C // game reads: arrows become hjkl, control keys their C0 codes. func (t *Tcell) ReadChar() byte { for { ev := t.screen.PollEvent() switch ev := ev.(type) { case *tcell.EventResize: if t.last != nil { t.Render(t.last) } case *tcell.EventKey: if b, ok := translateKey(ev); ok { return b } } } } // 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) { 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) { switch k { case tcell.KeyEnter: return '\n', true case tcell.KeyEscape: return game.Escape, true case tcell.KeyBackspace, tcell.KeyBackspace2: return 8, true case tcell.KeyDelete: return 0x7f, true case tcell.KeyTab: return '\t', true case tcell.KeyCtrlC: return 3, 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. cmd := exec.CommandContext(context.Background(), //nolint:gosec // G204: the user's own $SHELL 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 } }