urit/internal/session/output.go
2026-05-02 21:11:50 -04:00

189 lines
5.6 KiB
Go

package session
import (
"fmt"
"os"
"strings"
"time"
)
// ANSI escape code constants for terminal formatting.
// The original TAG-BBS used raw escape codes inline (e.g., "\033c\14\2331m").
// These named constants are easier to work with and self-documenting.
const (
AnsiReset = "\033[0m"
AnsiBold = "\033[1m"
AnsiDim = "\033[2m"
AnsiUnderline = "\033[4m"
AnsiBlink = "\033[5m"
AnsiReverse = "\033[7m"
// Foreground colors
AnsiFgBlack = "\033[30m"
AnsiFgRed = "\033[31m"
AnsiFgGreen = "\033[32m"
AnsiFgYellow = "\033[33m"
AnsiFgBlue = "\033[34m"
AnsiFgMagenta = "\033[35m"
AnsiFgCyan = "\033[36m"
AnsiFgWhite = "\033[37m"
// Bright foreground colors
AnsiFgBrightBlack = "\033[90m"
AnsiFgBrightRed = "\033[91m"
AnsiFgBrightGreen = "\033[92m"
AnsiFgBrightYellow = "\033[93m"
AnsiFgBrightBlue = "\033[94m"
AnsiFgBrightMagenta = "\033[95m"
AnsiFgBrightCyan = "\033[96m"
AnsiFgBrightWhite = "\033[97m"
// Background colors
AnsiBgBlack = "\033[40m"
AnsiBgRed = "\033[41m"
AnsiBgGreen = "\033[42m"
AnsiBgYellow = "\033[43m"
AnsiBgBlue = "\033[44m"
AnsiBgMagenta = "\033[45m"
AnsiBgCyan = "\033[46m"
AnsiBgWhite = "\033[47m"
// Cursor and screen control
AnsiClearScreen = "\033[2J"
AnsiCursorHome = "\033[H"
AnsiClearLine = "\033[2K"
AnsiSaveCursor = "\033[s"
AnsiLoadCursor = "\033[u"
)
// Write sends raw bytes to the client. This is the lowest-level output
// function — everything else builds on it.
//
// In the original, this was split across SerPutStr/SerPutChar (serial)
// and ConPutStr/ConPutChar (console), with IO_Flags controlling which
// outputs were active. Here we just write to the connection; the single
// output destination (the network connection) replaces the flag-based
// multiplexing.
func (s *Session) Write(data []byte) (int, error) {
select {
case <-s.ctx.Done():
return 0, ErrDisconnected
default:
}
return s.conn.Write(data)
}
// WriteString sends a string to the client.
// This is the direct replacement for PutStr() in the original.
func (s *Session) WriteString(str string) error {
_, err := s.Write([]byte(str))
return err
}
// Printf formats and sends a string to the client.
// Convenience wrapper for the common sprintf+PutStr pattern that
// appeared throughout the original code.
func (s *Session) Printf(format string, args ...interface{}) error {
return s.WriteString(fmt.Sprintf(format, args...))
}
// NewLine sends a CR+LF. Telnet protocol requires \r\n for line endings,
// not just \n.
func (s *Session) NewLine() error {
return s.WriteString("\r\n")
}
// ClearScreen clears the terminal and moves cursor to home position.
// The original used "\014" (form feed) for this on the Amiga console;
// standard ANSI escape sequences are more portable.
func (s *Session) ClearScreen() error {
return s.WriteString(AnsiClearScreen + AnsiCursorHome)
}
// MoveCursor positions the cursor at the given column and row (1-based).
// Replaces StatCursorTo() from the original.
func (s *Session) MoveCursor(col, row int) error {
return s.Printf("\033[%d;%dH", row, col)
}
// Color sets the text color using ANSI codes. Accepts one or more
// ANSI constants which are concatenated. Reset with AnsiReset.
func (s *Session) Color(codes ...string) error {
return s.WriteString(strings.Join(codes, ""))
}
// HorizontalRule draws a line across the terminal width.
func (s *Session) HorizontalRule(ch rune) error {
width, _ := s.TerminalSize()
return s.WriteString(strings.Repeat(string(ch), width) + "\r\n")
}
// SendFile reads a file from disk and sends its contents to the client.
// This replaces MenuSend() from the original, which loaded text files
// (Logon.Text, MainMenu.Help, etc.) and sent them to the user.
//
// For ANSI art files (.ans), the raw bytes are sent directly — ANSI
// escape codes embedded in the file are interpreted by the client's
// terminal emulator.
//
// Returns nil if the file doesn't exist (missing screen files are not
// an error — the sysop simply hasn't installed one).
func (s *Session) SendFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil // Missing screen files are silently skipped
}
return fmt.Errorf("reading screen file %s: %w", path, err)
}
// Ensure proper line endings for telnet — convert bare \n to \r\n
// but don't double-convert existing \r\n sequences.
content := strings.ReplaceAll(string(data), "\r\n", "\n")
content = strings.ReplaceAll(content, "\n", "\r\n")
_, err = s.Write([]byte(content))
return err
}
// Paginate sends text through a "more" pager. After each screenful
// (based on terminal height), it pauses and waits for a keypress.
// Press Enter or space to continue, Q to stop.
//
// This improves on the original's behavior where long output scrolled
// off screen with only Ctrl-S (pause) and Ctrl-Q (resume) for flow
// control — a serial-era convention that doesn't apply to TCP.
func (s *Session) Paginate(text string, timeout time.Duration) error {
_, height := s.TerminalSize()
pageLines := height - 2 // Leave room for the prompt
lines := strings.Split(text, "\n")
lineCount := 0
for _, line := range lines {
// Ensure telnet line ending
cleaned := strings.TrimRight(line, "\r")
if err := s.WriteString(cleaned + "\r\n"); err != nil {
return err
}
lineCount++
if lineCount >= pageLines {
s.WriteString("\r\n" + AnsiBold + "--- More ---" + AnsiReset + " ")
ch, err := s.ReadKey(timeout)
if err != nil {
return err
}
// Clear the "more" prompt
s.WriteString("\r" + AnsiClearLine)
if ch == 'q' || ch == 'Q' {
return nil
}
lineCount = 0
}
}
return nil
}