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

230 lines
5.6 KiB
Go

package session
import (
"fmt"
"time"
)
// Input error sentinels. These replace the TIMEOUT and NO_CARRIER
// return codes that the original TAG-BBS passed through every function.
// In Go, we use the context for cancellation and return these as errors
// only when the caller needs to distinguish the reason.
var (
ErrTimeout = fmt.Errorf("input timeout")
ErrDisconnected = fmt.Errorf("disconnected")
)
// ReadChar reads a single byte from the client with a timeout.
// This is the direct replacement for ReadChar() in CONSOLE.C, which
// was the central input function that Wait()'d on serial, console,
// and timer signals simultaneously.
//
// In Go, the equivalent is a select on the input channel (fed by the
// readLoop goroutine), a timer, and the context's Done channel.
//
// A timeout of 0 means no timeout (wait forever, or until disconnect).
func (s *Session) ReadChar(timeout time.Duration) (byte, error) {
// Check context first — if the session is already cancelled,
// don't block on input.
select {
case <-s.ctx.Done():
return 0, ErrDisconnected
default:
}
if timeout == 0 {
// No timeout — block until input or disconnect
select {
case b, ok := <-s.inputCh:
if !ok {
return 0, ErrDisconnected
}
return b, nil
case <-s.ctx.Done():
return 0, ErrDisconnected
}
}
// With timeout — the original used SetTimer() and waited on
// TimerSig alongside the input signals. Same concept here.
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case b, ok := <-s.inputCh:
if !ok {
return 0, ErrDisconnected
}
return b, nil
case <-timer.C:
return 0, ErrTimeout
case <-s.ctx.Done():
return 0, ErrDisconnected
}
}
// ReadLine reads a full line of input (terminated by Enter) with echo
// and basic line editing. Returns the entered string, or an error on
// timeout/disconnect.
//
// This replaces LineInput() from the original, which handled echo,
// backspace, and had a pre-fill capability (the first argument was
// a default string displayed in the input field). We keep the same
// semantics.
//
// maxLen limits the input length (like the original's fixed char arrays).
// A value of 0 means no limit.
// timeout is the idle timeout — resets on each keypress.
func (s *Session) ReadLine(prefill string, maxLen int, timeout time.Duration) (string, error) {
buf := []byte(prefill)
// Display the prefill text if any
if len(prefill) > 0 {
if err := s.WriteString(prefill); err != nil {
return "", err
}
}
for {
ch, err := s.ReadChar(timeout)
if err != nil {
return string(buf), err
}
switch {
case ch == '\r' || ch == '\n':
// Enter — return the line
s.WriteString("\r\n")
return string(buf), nil
case ch == 127 || ch == 8:
// Backspace or DEL — erase last character
// Same as the original: PutStr("\b \b")
if len(buf) > 0 {
buf = buf[:len(buf)-1]
s.WriteString("\b \b")
}
case ch == 24: // Ctrl-X — erase entire line
// Same as the original editor's line-kill
for len(buf) > 0 {
buf = buf[:len(buf)-1]
s.WriteString("\b \b")
}
case ch == 23: // Ctrl-W — erase last word
// Delete trailing spaces first, then back to previous space.
// Same logic as the original EDIT.C's Ctrl-W handler.
for len(buf) > 0 && buf[len(buf)-1] == ' ' {
buf = buf[:len(buf)-1]
s.WriteString("\b \b")
}
for len(buf) > 0 && buf[len(buf)-1] != ' ' {
buf = buf[:len(buf)-1]
s.WriteString("\b \b")
}
case ch >= 32 && ch < 127:
// Printable character
if maxLen > 0 && len(buf) >= maxLen {
continue // At limit, ignore
}
buf = append(buf, ch)
s.Write([]byte{ch})
default:
// Control characters — ignore
continue
}
}
}
// ReadLineNoEcho reads a line of input without echoing characters.
// Used for password entry. Displays asterisks instead of the actual
// characters.
func (s *Session) ReadLineNoEcho(maxLen int, timeout time.Duration) (string, error) {
var buf []byte
for {
ch, err := s.ReadChar(timeout)
if err != nil {
return string(buf), err
}
switch {
case ch == '\r' || ch == '\n':
s.WriteString("\r\n")
return string(buf), nil
case ch == 127 || ch == 8:
if len(buf) > 0 {
buf = buf[:len(buf)-1]
s.WriteString("\b \b")
}
case ch >= 32 && ch < 127:
if maxLen > 0 && len(buf) >= maxLen {
continue
}
buf = append(buf, ch)
s.Write([]byte{'*'})
default:
continue
}
}
}
// ReadKey reads a single keypress and returns it immediately without
// waiting for Enter. Used for single-character menu selections.
// This is the classic BBS "press a key" input — the original's
// ReadChar(120L) in the menu dispatch loops.
func (s *Session) ReadKey(timeout time.Duration) (byte, error) {
for {
ch, err := s.ReadChar(timeout)
if err != nil {
return 0, err
}
// Skip null bytes (can come from function key sequences)
if ch == 0 {
continue
}
return ch, nil
}
}
// WaitForKey displays a prompt and waits for any keypress.
// The classic "Press any key to continue..." pattern.
func (s *Session) WaitForKey(prompt string, timeout time.Duration) error {
if prompt != "" {
if err := s.WriteString(prompt); err != nil {
return err
}
}
_, err := s.ReadKey(timeout)
return err
}
// Confirm asks a yes/no question and returns the result.
// Keeps prompting until Y or N is pressed.
func (s *Session) Confirm(prompt string, timeout time.Duration) (bool, error) {
s.WriteString(prompt)
for {
ch, err := s.ReadKey(timeout)
if err != nil {
return false, err
}
switch ch {
case 'y', 'Y':
s.WriteString("Yes\r\n")
return true, nil
case 'n', 'N', '\r', '\n':
s.WriteString("No\r\n")
return false, nil
}
}
}