From 6b0371b22a6c9ee5812b813de054414580c9cdc5 Mon Sep 17 00:00:00 2001 From: Steve Kemp Date: Sat, 18 May 2024 21:41:45 +0300 Subject: [PATCH] Tty (#80) * Use a stateful console-reading package This pull-request changes the IO interface we're using to maintaining state - although this doesn't (yet) remove the use of `stty` which is tracked in #65, it does close #78 by avoiding the need to re-exec the program after every character. Keeping track of the current "echo" vs "noecho" state means that we need to make this a global object, which we can reuse so we do that here. --- io/io.go => consolein/consolein.go | 110 +++++++++++++++++------------ cpm/cpm.go | 12 ++++ cpm/cpm_bdos.go | 22 ++---- cpm/cpm_bios.go | 8 +-- main.go | 12 +--- 5 files changed, 84 insertions(+), 80 deletions(-) rename io/io.go => consolein/consolein.go (57%) diff --git a/io/io.go b/consolein/consolein.go similarity index 57% rename from io/io.go rename to consolein/consolein.go index e9985df..5fddb74 100644 --- a/io/io.go +++ b/consolein/consolein.go @@ -1,22 +1,17 @@ -// Package io is designed to collect the code that reads from STDIN. +// Package consolein handles the reading of console input +// for our emulator. // -// There are three functions we need to care about: +// The package supports the minimum required functionality +// we need - which boils down to reading a single character +// of input, with and without echo, and reading a line of text. // -// * Block for a character, and return it. -// -// * Block for a character, and return it, but disable echo first. -// -// * Read a single line of input. -// -// There are functions for polling console status in CP/M, however it -// seems to work just fine if we fake their results - which means this -// package is simpler than it would otherwise need to be. -package io +// Note that no output functions are handled by this package, +// it is exclusively used for input. +package consolein import ( "bufio" "fmt" - "log/slog" "os" "os/exec" "strings" @@ -24,46 +19,44 @@ import ( "golang.org/x/term" ) -// IO is used to hold our package state -type IO struct { - Logger *slog.Logger -} +// Status is used to record our current state +type Status int -// New is our package constructor. -func New(log *slog.Logger) *IO { - return &IO{Logger: log} -} +var ( + // Unknown means we don't know the status of echo/noecho + Unknown Status = 0 -// disableEcho is the single place where we disable echoing. -func (io *IO) disableEcho() { - err := exec.Command("stty", "-F", "/dev/tty", "-echo").Run() - if err != nil { - io.Logger.Debug("disableEcho", - slog.String("error", err.Error())) - } -} + // Echo means that input will echo characters. + Echo Status = 1 -// enableEcho is the single place where we enable echoing. -func (io *IO) enableEcho() { - err := exec.Command("stty", "-F", "/dev/tty", "echo").Run() - if err != nil { - io.Logger.Debug("enableEcho", - slog.String("error", err.Error())) - } + // NoEcho means that input will not echo characters. + NoEcho Status = 2 +) + +// ConsoleIn holds our state +type ConsoleIn struct { + // State holds our current state + State Status } -// Restore enables echoing. -func (io *IO) Restore() { - io.enableEcho() +// New is our constructor +func New() *ConsoleIn { + t := &ConsoleIn{ + State: Unknown, + } + return t } -// BlockForCharacter returns the next character from the console, blocking until +// BlockForCharacterNoEcho returns the next character from the console, blocking until // one is available. // // NOTE: This function should not echo keystrokes which are entered. -func (io *IO) BlockForCharacter() (byte, error) { +func (io *ConsoleIn) BlockForCharacterNoEcho() (byte, error) { - io.disableEcho() + // Do we need to change state? If so then do it. + if io.State != NoEcho { + io.disableEcho() + } // switch stdin into 'raw' mode oldState, err := term.MakeRaw(int(os.Stdin.Fd())) @@ -92,9 +85,12 @@ func (io *IO) BlockForCharacter() (byte, error) { // blocking until one is available. // // NOTE: Characters should be echo'd as they are input. -func (io *IO) BlockForCharacterWithEcho() (byte, error) { +func (io *ConsoleIn) BlockForCharacterWithEcho() (byte, error) { - io.enableEcho() + // Do we need to change state? If so then do it. + if io.State != Echo { + io.enableEcho() + } // switch stdin into 'raw' mode oldState, err := term.MakeRaw(int(os.Stdin.Fd())) @@ -115,7 +111,6 @@ func (io *IO) BlockForCharacterWithEcho() (byte, error) { return 0x00, fmt.Errorf("error restoring terminal state %s", err) } - fmt.Printf("%c", b[0]) return b[0], nil } @@ -124,9 +119,12 @@ func (io *IO) BlockForCharacterWithEcho() (byte, error) { // buffer-overruns will occur!) // // Note: We should enable echo in this function. -func (io *IO) ReadLine(max uint8) (string, error) { +func (io *ConsoleIn) ReadLine(max uint8) (string, error) { - io.enableEcho() + // Do we need to change state? If so then do it. + if io.State != Echo { + io.enableEcho() + } // Create a reader reader := bufio.NewReader(os.Stdin) @@ -148,3 +146,21 @@ func (io *IO) ReadLine(max uint8) (string, error) { // Return the text return text, err } + +// Reset restores echo. +func (io *ConsoleIn) Reset() { + + if io.State == NoEcho { + io.enableEcho() + } +} + +// disableEcho is the single place where we disable echoing. +func (io *ConsoleIn) disableEcho() { + _ = exec.Command("stty", "-F", "/dev/tty", "-echo").Run() +} + +// enableEcho is the single place where we enable echoing. +func (io *ConsoleIn) enableEcho() { + _ = exec.Command("stty", "-F", "/dev/tty", "echo").Run() +} diff --git a/cpm/cpm.go b/cpm/cpm.go index 06c11bd..da043a2 100644 --- a/cpm/cpm.go +++ b/cpm/cpm.go @@ -16,6 +16,7 @@ import ( "github.com/koron-go/z80" "github.com/skx/cpmulator/ccp" + "github.com/skx/cpmulator/consolein" "github.com/skx/cpmulator/fcb" "github.com/skx/cpmulator/memory" ) @@ -98,6 +99,11 @@ type CPM struct { // files is the cache we use for File handles. files map[uint16]FileCache + // input is our interface for reading from the console. + // + // This needs to take account of echo/no-echo status. + input *consolein.ConsoleIn + // dma contains the address of the DMA area in RAM. // // The DMA area is used for all file I/O, and is 128 bytes in length. @@ -394,6 +400,7 @@ func New(logger *slog.Logger, prn string) *CPM { // Create the emulator object and return it tmp := &CPM{ Logger: logger, + input: consolein.New(), BDOSSyscalls: sys, BIOSSyscalls: b, dma: 0x0080, @@ -404,6 +411,11 @@ func New(logger *slog.Logger, prn string) *CPM { return tmp } +// Cleanup cleans up the state of the terminal, if necessary. +func (cpm *CPM) Cleanup() { + cpm.input.Reset() +} + // LoadBinary loads the given CP/M binary at the default address of 0x0100, // where it can then be launched by Execute. func (cpm *CPM) LoadBinary(filename string) error { diff --git a/cpm/cpm_bdos.go b/cpm/cpm_bdos.go index 021c6fa..8be24eb 100644 --- a/cpm/cpm_bdos.go +++ b/cpm/cpm_bdos.go @@ -15,7 +15,6 @@ import ( "strings" "github.com/skx/cpmulator/fcb" - cpmio "github.com/skx/cpmulator/io" ) // blkSize is the size of block-based I/O operations @@ -32,11 +31,8 @@ func SysCallExit(cpm *CPM) error { // SysCallReadChar reads a single character from the console. func SysCallReadChar(cpm *CPM) error { - // Use our I/O package - obj := cpmio.New(cpm.Logger) - // Block for input - c, err := obj.BlockForCharacterWithEcho() + c, err := cpm.input.BlockForCharacterWithEcho() if err != nil { return fmt.Errorf("error in call to BlockForCharacter: %s", err) } @@ -64,13 +60,10 @@ func SysCallWriteChar(cpm *CPM) error { // Note: Echo is not enabled in this function. func SysCallAuxRead(cpm *CPM) error { - // Use our I/O package - obj := cpmio.New(cpm.Logger) - // Block for input - c, err := obj.BlockForCharacter() + c, err := cpm.input.BlockForCharacterNoEcho() if err != nil { - return fmt.Errorf("error in call to BlockForCharacter: %s", err) + return fmt.Errorf("error in call to BlockForCharacterNoEcho: %s", err) } // Return values: @@ -105,13 +98,10 @@ func SysCallAuxWrite(cpm *CPM) error { // SysCallRawIO handles both simple character output, and input. func SysCallRawIO(cpm *CPM) error { - // Use our I/O package - obj := cpmio.New(cpm.Logger) - switch cpm.CPU.States.DE.Lo { case 0xFF, 0xFD: - out, err := obj.BlockForCharacter() + out, err := cpm.input.BlockForCharacterNoEcho() if err != nil { return err } @@ -180,8 +170,8 @@ func SysCallReadString(cpm *CPM) error { // First byte is the max len max := cpm.CPU.Memory.Get(addr) - obj := cpmio.New(cpm.Logger) - text, err := obj.ReadLine(max) + // read the input + text, err := cpm.input.ReadLine(max) if err != nil { return err diff --git a/cpm/cpm_bios.go b/cpm/cpm_bios.go index 29f5c1b..59e60d1 100644 --- a/cpm/cpm_bios.go +++ b/cpm/cpm_bios.go @@ -9,8 +9,6 @@ package cpm import ( "fmt" "log/slog" - - cpmio "github.com/skx/cpmulator/io" ) // BiosSysCallBoot handles a warm/cold boot. @@ -34,11 +32,7 @@ func BiosSysCallConsoleStatus(cpm *CPM) error { // and return the character pressed in the A-register. func BiosSysCallConsoleInput(cpm *CPM) error { - // Wait until the keyboard is ready to provide a character, and return it in A. - // Use our I/O package - obj := cpmio.New(cpm.Logger) - - out, err := obj.BlockForCharacter() + out, err := cpm.input.BlockForCharacterWithEcho() if err != nil { return err } diff --git a/main.go b/main.go index 59b8e50..64b0860 100644 --- a/main.go +++ b/main.go @@ -12,23 +12,13 @@ import ( cpmccp "github.com/skx/cpmulator/ccp" "github.com/skx/cpmulator/cpm" - cpmio "github.com/skx/cpmulator/io" ) // log holds our logging handle var log *slog.Logger -// restoreEcho is designed to ensure we leave our terminal in a good state, -// when we terminate, by enabling console-echoing if it had been disabled. -func restoreEcho() { - // Use our I/O package - obj := cpmio.New(log) - obj.Restore() -} - func main() { - defer restoreEcho() // // Parse the command-line flags for this driver-application // @@ -141,6 +131,8 @@ func main() { // Create a new emulator. obj := cpm.New(log, *prnPath) + defer obj.Cleanup() + // change directory? if *cd != "" { err := os.Chdir(*cd)